From 5480abaaeae1f1c5cba33c520ba913427687724a Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Thu, 27 Aug 2026 06:14:59 -0600 Subject: [PATCH] =?UTF-8?q?fix(=E6=B5=81=E7=A8=8B=E5=9B=BE=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8):=20=E6=8D=A2=E9=A2=98=E8=8D=89=E7=A8=BF?= =?UTF-8?q?=E4=B8=B2=E9=A2=98=E3=80=81=E7=AC=AC=E4=B8=80=E6=AD=A5=E6=92=A4?= =?UTF-8?q?=E9=94=80=E4=B8=8D=E4=BA=86=E3=80=81=E5=8E=86=E5=8F=B2=E5=AD=98?= =?UTF-8?q?=E7=9A=84=E6=98=AF=E6=94=B9=E5=8A=A8=E5=89=8D=E7=9A=84=E7=8A=B6?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 换题时画布不跟着换 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 --- .../components/FlowchartEditor/index.vue | 12 ++++++-- .../components/FlowchartEditor/useCache.ts | 23 ++++++++++++++- .../FlowchartEditor/useFlowOperations.ts | 28 ++++++++++--------- .../components/FlowchartEditor/useHistory.ts | 9 ++++++ 4 files changed, 55 insertions(+), 17 deletions(-) diff --git a/apps/web/src/shared/components/FlowchartEditor/index.vue b/apps/web/src/shared/components/FlowchartEditor/index.vue index c84ec35..91125fc 100644 --- a/apps/web/src/shared/components/FlowchartEditor/index.vue +++ b/apps/web/src/shared/components/FlowchartEditor/index.vue @@ -38,7 +38,7 @@ const nodes = ref([]) as Ref const edges = ref([]) as Ref // 历史记录管理 -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(() => { diff --git a/apps/web/src/shared/components/FlowchartEditor/useCache.ts b/apps/web/src/shared/components/FlowchartEditor/useCache.ts index bc332fd..2f61556 100644 --- a/apps/web/src/shared/components/FlowchartEditor/useCache.ts +++ b/apps/web/src/shared/components/FlowchartEditor/useCache.ts @@ -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, edges: Ref, storageKey: MaybeRefOrGetter = "flowchart-editor-data", + onReloaded?: () => void, ) { const isSaving = ref(false) const lastSaved = ref(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], diff --git a/apps/web/src/shared/components/FlowchartEditor/useFlowOperations.ts b/apps/web/src/shared/components/FlowchartEditor/useFlowOperations.ts index e9d41d4..447dea6 100644 --- a/apps/web/src/shared/components/FlowchartEditor/useFlowOperations.ts +++ b/apps/web/src/shared/components/FlowchartEditor/useFlowOperations.ts @@ -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 回写走的是 watch(pre 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) } diff --git a/apps/web/src/shared/components/FlowchartEditor/useHistory.ts b/apps/web/src/shared/components/FlowchartEditor/useHistory.ts index 8ec9a50..94ced95 100644 --- a/apps/web/src/shared/components/FlowchartEditor/useHistory.ts +++ b/apps/web/src/shared/components/FlowchartEditor/useHistory.ts @@ -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,