fix(流程图编辑器): 换题草稿串题、第一步撤销不了、历史存的是改动前的状态

## 换题时画布不跟着换

storage key 是按题目 ID 算的 computed。`useStorage` 确实会 watch key,但它只把
新 key 的内容读进 `storedData`,**不会回填 `nodes`/`edges`**,而 `loadFromCache()`
只在 onMounted 调一次。于是同名路由换参数(题目 → 题目,组件不重新挂载)时,
画布上还留着上一题的图;学生一动,防抖保存就把上一题的内容写进**这一题的 key**,
把原本存着的草稿覆盖掉。

补一个 key 的 watch,重新载入并在载不到时清空画布。这里依赖 `useStorage` 内部
对 key 的 watch 先于本 watch 执行 —— 两者都是 pre flush,且 useStorage 在上方
先创建,pre 队列按创建顺序跑,此刻 `storedData` 已经是新 key 的数据。

实测(router.push 直接切题):修复前 1003 的画布上挂着 1002 的节点,修复后
1003 是空的、1002 的草稿完好。

**需要说明**:今天的 UI 走不到这条路 —— 题目页没有「下一题」入口,题单和比赛
切题都要先回列表页(不同路由、组件会重新挂载)。所以这条目前是加固,一旦以后
加了题内切题入口就立刻变成必需品。

## 撤销少一步、存的还是旧状态

`historyIndex` 从 -1 开始,而 `canUndo` 要求 `index > 0`,第一步操作永远撤销
不了。补 `resetHistory`,挂载时和换题后各播一次初始快照(换题不重建的话,一次
撤销会把上一题的图还原到这一题里)。

`addEdges` / `removeNodes` / `removeEdges` 之后紧接着 `saveState(nodes.value,
edges.value)` —— 而 vue-flow 的 store → v-model 回写走的是 `watchPausable`
(pre flush,异步),此刻读到的还是**改动前**的数组,存进历史整体错开一步。
画布上的 `handleDrop` 早就 `await nextTick()` 了,这几处一直漏了;
`clearCanvas` 因为是直接赋值 model ref(同步)反而是对的 —— 所以这套行为一直
是「有时对有时错」,更难排查。

顺带:`handleNodeDelete` 里手动删相连边是多余的,`removeNodes` 的
`removeConnectedEdges` 默认就是 true;`deleteSelected` 在什么都没选中时不再
白记一条历史。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 06:14:59 -06:00
parent 5f0fe713dc
commit 5480abaaea
4 changed files with 55 additions and 17 deletions

View File

@@ -38,7 +38,7 @@ const nodes = ref([]) as Ref<Node[]>
const edges = ref([]) as Ref<Edge[]>
// 历史记录管理
const { canUndo, canRedo, saveState, undo, redo } = useHistory()
const { canUndo, canRedo, resetHistory, saveState, undo, redo } = useHistory()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
@@ -55,7 +55,11 @@ const {
saveToCache,
loadFromCache,
clearCache,
} = useCache(nodes, edges, cacheKey)
} = useCache(nodes, edges, cacheKey, () => {
// 换题后画布已经被换成新题的草稿,历史必须跟着重建,
// 否则一次撤销就会把上一题的图还原到这一题里
resetHistory(nodes.value, edges.value)
})
// 拖拽处理
const { onDragOver, onDragLeave, onDrop, isDragOver, screenDragPos } = useDnD()
@@ -148,8 +152,10 @@ const handleKeyDown = (event: KeyboardEvent) => {
onMounted(() => {
document.addEventListener("keydown", handleKeyDown)
// 从缓存恢复数据
// 从缓存恢复数据,并把当前画布作为历史起点,
// 否则第一步操作没有可回退的目标,撤销按钮一直是灰的
loadFromCache()
resetHistory(nodes.value, edges.value)
})
onUnmounted(() => {

View File

@@ -1,4 +1,4 @@
import { ref, watch, type Ref, type MaybeRefOrGetter } from "vue"
import { ref, toValue, watch, type Ref, type MaybeRefOrGetter } from "vue"
import { useStorage, useDebounceFn } from "@vueuse/core"
import type { Node, Edge } from "@vue-flow/core"
@@ -9,6 +9,7 @@ export function useCache(
nodes: Ref<Node[]>,
edges: Ref<Edge[]>,
storageKey: MaybeRefOrGetter<string> = "flowchart-editor-data",
onReloaded?: () => void,
) {
const isSaving = ref(false)
const lastSaved = ref<Date | null>(null)
@@ -67,6 +68,26 @@ export function useCache(
hasUnsavedChanges.value = false
}
// 题目 ID 异步加载完成、或直接切到下一题时 storageKey 会变。
// useStorage 只把新 key 的内容读进 storedData不会回填 nodes/edges
// 不处理的话画布会继续显示上一题的图,学生一动就把上一题的内容写进这一题的
// key把这道题原本存着的草稿覆盖掉。
// 这里依赖 useStorage 内部对 key 的 watch 先于本 watch 执行(两者都是 pre
// flush且 useStorage 在上方先创建pre 队列按创建顺序跑),
// 因此此刻 storedData 已经是新 key 的数据。
watch(
() => toValue(storageKey),
() => {
if (!loadFromCache()) {
nodes.value = []
edges.value = []
lastSaved.value = null
hasUnsavedChanges.value = false
}
onReloaded?.()
},
)
// 监听节点和边的变化isSaving 在此置 true 以覆盖防抖等待窗口
watch(
[nodes, edges],

View File

@@ -1,3 +1,4 @@
import { nextTick } from "vue"
import type { Ref } from "vue"
import type { Node, Edge, Connection } from "@vue-flow/core"
import { useVueFlow } from "@vue-flow/core"
@@ -51,7 +52,7 @@ export function useFlowOperations(
return ""
}
const handleConnect = (params: Connection) => {
const handleConnect = async (params: Connection) => {
const sourceNode = nodes.value.find((node) => node.id === params.source)
const targetNode = nodes.value.find((node) => node.id === params.target)
@@ -74,25 +75,24 @@ export function useFlowOperations(
}
addEdges([newEdge])
// vue-flow 的 store → v-model 回写走的是 watchpre flush异步
// 紧接着读 nodes/edges 拿到的还是改动前的数组,存进历史就会错开一步。
// 画布上 handleDrop 早就这么等了,这几处一直漏了。
await nextTick()
saveState(nodes.value, edges.value)
}
const handleEdgeClick = ({ edge }: { edge: Edge }) => {
const handleEdgeClick = async ({ edge }: { edge: Edge }) => {
removeEdges([edge.id])
await nextTick()
saveState(nodes.value, edges.value)
}
// 节点删除
const handleNodeDelete = (nodeId: string) => {
// 删除相关边
const relatedEdges = edges.value.filter(
(edge) => edge.source === nodeId || edge.target === nodeId,
)
if (relatedEdges.length > 0) {
removeEdges(relatedEdges.map((edge) => edge.id))
}
// 节点删除。removeNodes 的 removeConnectedEdges 默认就是 true
// 相连的边不用自己再删一遍
const handleNodeDelete = async (nodeId: string) => {
removeNodes([nodeId])
await nextTick()
saveState(nodes.value, edges.value)
}
@@ -118,9 +118,10 @@ export function useFlowOperations(
}
// 删除选中的节点和边
const deleteSelected = () => {
const deleteSelected = async () => {
const selectedNodes = getSelectedNodes.value
const selectedEdges = getSelectedEdges.value
if (selectedNodes.length === 0 && selectedEdges.length === 0) return
if (selectedNodes.length > 0) {
removeNodes(selectedNodes.map((node) => node.id))
@@ -128,6 +129,7 @@ export function useFlowOperations(
if (selectedEdges.length > 0) {
removeEdges(selectedEdges.map((edge) => edge.id))
}
await nextTick()
saveState(nodes.value, edges.value)
}

View File

@@ -42,6 +42,14 @@ export function useHistory() {
}
}
// 用当前画布重建历史。挂载时和换题后都要调一次:
// 不播下这个初始快照的话 historyIndex 会从 -1 开始canUndo 要求 index > 0
// 第一步操作永远撤销不了;换题后不重建则一次撤销会把上一题的图还原到这一题里。
const resetHistory = (nodes: Node[], edges: Edge[]) => {
history.value = [deepCopyState(nodes, edges)]
historyIndex.value = 0
}
// 撤销
const undo = () => {
if (canUndo.value) {
@@ -65,6 +73,7 @@ export function useHistory() {
return {
canUndo,
canRedo,
resetHistory,
saveState,
undo,
redo,