fix(流程图编辑器): Ctrl+Y 没接、清空画布不问一声、存档里塞满 vue-flow 内部字段

## 存档里的一大半是运行时内部状态

画布上的 node 是 vue-flow 的 GraphNode,除了我们自己塞的字段,还挂着
`dimensions` / `computedPosition` / `handleBounds` / `selected` / `dragging` /
`resizing` / `initialized` / `isParent` / `events`(见 vue-flow 的 parseNode)。
这些会跟着一起写进 localStorage、进 20 份历史快照被反复深拷贝、还压缩后提交进
数据库**长期存着**。而重新挂载时它们全都会被重新算一遍 —— 存下来没有任何意义,
还把存档格式和 vue-flow 的内部实现绑死了,将来升级或迁移数据都得跟着动。

`handleBounds` 尤其占地方:一个循环节点有 4 个 handle,每个 6 个数字。

抽一个 `serialize.ts`,落盘/入历史/提交前统一裁成 id / type / position / data /
style 五个字段。`style` 保留 —— 它是建节点时按类型算好的,丢了恢复出来的图会变样。

实测:一个两节点带连线的图,存档 600 字节、节点上只剩那五个字段,九个内部字段
一个不剩;提交上去压缩后 396 字节。

## Ctrl+Y 是假的

工具栏按钮的 title 写着「重做 (Ctrl+Y)」,但只实现了 Ctrl+Shift+Z,按 Y 没反应。
顺手把 key 比较改成小写不敏感(按住 Shift 时 event.key 是大写的 "Z")。

## 清空画布点一下就没了

那个按钮不但清空画布,`clearCache()` 还会把这道题存着的草稿一起删掉,刷新也找
不回来。学生误点的代价太大,加一道确认;画布本来就是空的时候不弹。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 06:27:19 -06:00
parent 9e41855610
commit f5ad5318a0
4 changed files with 81 additions and 12 deletions

View File

@@ -19,6 +19,7 @@ import { getNodeTypeConfig } from "./useNodeStyles"
import { useHistory } from "./useHistory"
import { useFlowOperations } from "./useFlowOperations"
import { useCache } from "./useCache"
import { toPortableEdges, toPortableNodes } from "./serialize"
import CustomNode from "./CustomNode.vue"
import { useProblemStore } from "oj/store/problem"
@@ -120,10 +121,21 @@ const handleRedo = () => {
}
}
// 清空画布
// 清空画布。工具栏那个按钮点一下就全没了,且 clearCache() 会连本题存着的草稿
// 一起删掉,刷新也找不回来 —— 学生误点的代价太大,加一道确认
const dialog = useDialog()
const handleClear = () => {
clearCanvas()
clearCache()
if (nodes.value.length === 0 && edges.value.length === 0) return
dialog.warning({
title: "清空画布",
content: "画布上的内容会被清掉,这道题存着的草稿也会一起删除。确定吗?",
positiveText: "清空",
negativeText: "再想想",
onPositiveClick: () => {
clearCanvas()
clearCache()
},
})
}
// 键盘事件
@@ -139,10 +151,12 @@ const handleKeyDown = (event: KeyboardEvent) => {
}
if (event.ctrlKey || event.metaKey) {
if (event.key === "z" && !event.shiftKey) {
const key = event.key.toLowerCase()
if (key === "z" && !event.shiftKey) {
event.preventDefault()
handleUndo()
} else if (event.key === "z" && event.shiftKey) {
} else if ((key === "z" && event.shiftKey) || key === "y") {
// 工具栏上的提示写的就是 Ctrl+Y但原来只实现了 Ctrl+Shift+Z按 Y 没反应
event.preventDefault()
handleRedo()
}
@@ -188,9 +202,10 @@ const setFlowchartData = (data: { nodes: Node[]; edges: Edge[] }) => {
defineExpose({
nodes,
edges,
// 提交出去的这份会压缩后长期存进数据库,同样裁掉运行时内部字段
getFlowchartData: () => ({
nodes: nodes.value,
edges: edges.value,
nodes: toPortableNodes(nodes.value),
edges: toPortableEdges(edges.value),
}),
setFlowchartData,
})

View File

@@ -0,0 +1,45 @@
import type { Edge, Node } from "@vue-flow/core"
/**
* 把画布上的节点/连线裁成「可持久化」的形状。
*
* 画布里的 node 是 vue-flow 的 GraphNode除了我们自己塞进去的字段它还挂着
* `dimensions` / `computedPosition` / `handleBounds` / `selected` / `dragging` /
* `resizing` / `initialized` / `isParent` / `events` 一堆运行时内部状态(见
* vue-flow 的 parseNode。这些东西会跟着一起
*
* - 写进 localStorage每次改动都写一次
* - 进 20 份历史快照,每份都要 JSON 深拷贝一遍
* - 压缩后提交进数据库,长期存着
*
* 实测一个两节点的图就能撑到 600+ 字节,其中大半是 handleBounds。而重新挂载时
* 这些字段全都会被重新计算,存下来没有任何意义 —— 还会把存档格式和 vue-flow
* 的内部实现绑死,将来升级或迁移数据都要跟着动。
*
* `style` 保留:它是建节点时按类型算好的(见 useNodeStyles丢了会让恢复出来
* 的图变样。
*/
export function toPortableNodes(nodes: Node[]) {
return nodes.map((node) => ({
id: node.id,
type: node.type,
position: {
x: node.position?.x ?? 0,
y: node.position?.y ?? 0,
},
data: node.data,
style: node.style,
})) as Node[]
}
export function toPortableEdges(edges: Edge[]) {
return edges.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle ?? null,
targetHandle: edge.targetHandle ?? null,
type: edge.type,
label: edge.label,
})) as Edge[]
}

View File

@@ -1,6 +1,7 @@
import { ref, toValue, watch, type Ref, type MaybeRefOrGetter } from "vue"
import { useStorage, useDebounceFn } from "@vueuse/core"
import type { Node, Edge } from "@vue-flow/core"
import { toPortableEdges, toPortableNodes } from "./serialize"
/**
* 缓存管理 - 使用 @vueuse 的 useStorage
@@ -28,8 +29,8 @@ export function useCache(
// 防抖保存isSaving 在 watch 中置 true保存完成后置 false使 UI 能感知保存中状态
const debouncedSave = useDebounceFn(() => {
storedData.value.nodes = nodes.value
storedData.value.edges = edges.value
storedData.value.nodes = toPortableNodes(nodes.value)
storedData.value.edges = toPortableEdges(edges.value)
storedData.value.timestamp = new Date().toISOString()
lastSaved.value = new Date()
hasUnsavedChanges.value = false
@@ -39,8 +40,8 @@ export function useCache(
// 立即保存
const saveToCache = () => {
isSaving.value = true
storedData.value.nodes = nodes.value
storedData.value.edges = edges.value
storedData.value.nodes = toPortableNodes(nodes.value)
storedData.value.edges = toPortableEdges(edges.value)
storedData.value.timestamp = new Date().toISOString()
lastSaved.value = new Date()
hasUnsavedChanges.value = false

View File

@@ -1,5 +1,6 @@
import { shallowRef, computed } from "vue"
import type { Node, Edge } from "@vue-flow/core"
import { toPortableEdges, toPortableNodes } from "./serialize"
/**
* 简化的历史记录管理
@@ -18,7 +19,14 @@ export function useHistory() {
nodes: Node[],
edges: Edge[],
): { nodes: Node[]; edges: Edge[] } =>
JSON.parse(JSON.stringify({ nodes, edges })) as {
// 先裁掉 vue-flow 的运行时内部字段再深拷贝20 份快照 × 每个节点几百字节的
// handleBounds/dimensions纯属白拷
JSON.parse(
JSON.stringify({
nodes: toPortableNodes(nodes),
edges: toPortableEdges(edges),
}),
) as {
nodes: Node[]
edges: Edge[]
}