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:
@@ -19,6 +19,7 @@ import { getNodeTypeConfig } from "./useNodeStyles"
|
|||||||
import { useHistory } from "./useHistory"
|
import { useHistory } from "./useHistory"
|
||||||
import { useFlowOperations } from "./useFlowOperations"
|
import { useFlowOperations } from "./useFlowOperations"
|
||||||
import { useCache } from "./useCache"
|
import { useCache } from "./useCache"
|
||||||
|
import { toPortableEdges, toPortableNodes } from "./serialize"
|
||||||
import CustomNode from "./CustomNode.vue"
|
import CustomNode from "./CustomNode.vue"
|
||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
|
|
||||||
@@ -120,10 +121,21 @@ const handleRedo = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清空画布
|
// 清空画布。工具栏那个按钮点一下就全没了,且 clearCache() 会连本题存着的草稿
|
||||||
|
// 一起删掉,刷新也找不回来 —— 学生误点的代价太大,加一道确认
|
||||||
|
const dialog = useDialog()
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
clearCanvas()
|
if (nodes.value.length === 0 && edges.value.length === 0) return
|
||||||
clearCache()
|
dialog.warning({
|
||||||
|
title: "清空画布",
|
||||||
|
content: "画布上的内容会被清掉,这道题存着的草稿也会一起删除。确定吗?",
|
||||||
|
positiveText: "清空",
|
||||||
|
negativeText: "再想想",
|
||||||
|
onPositiveClick: () => {
|
||||||
|
clearCanvas()
|
||||||
|
clearCache()
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 键盘事件
|
// 键盘事件
|
||||||
@@ -139,10 +151,12 @@ const handleKeyDown = (event: KeyboardEvent) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.ctrlKey || event.metaKey) {
|
if (event.ctrlKey || event.metaKey) {
|
||||||
if (event.key === "z" && !event.shiftKey) {
|
const key = event.key.toLowerCase()
|
||||||
|
if (key === "z" && !event.shiftKey) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handleUndo()
|
handleUndo()
|
||||||
} else if (event.key === "z" && event.shiftKey) {
|
} else if ((key === "z" && event.shiftKey) || key === "y") {
|
||||||
|
// 工具栏上的提示写的就是 Ctrl+Y,但原来只实现了 Ctrl+Shift+Z,按 Y 没反应
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
handleRedo()
|
handleRedo()
|
||||||
}
|
}
|
||||||
@@ -188,9 +202,10 @@ const setFlowchartData = (data: { nodes: Node[]; edges: Edge[] }) => {
|
|||||||
defineExpose({
|
defineExpose({
|
||||||
nodes,
|
nodes,
|
||||||
edges,
|
edges,
|
||||||
|
// 提交出去的这份会压缩后长期存进数据库,同样裁掉运行时内部字段
|
||||||
getFlowchartData: () => ({
|
getFlowchartData: () => ({
|
||||||
nodes: nodes.value,
|
nodes: toPortableNodes(nodes.value),
|
||||||
edges: edges.value,
|
edges: toPortableEdges(edges.value),
|
||||||
}),
|
}),
|
||||||
setFlowchartData,
|
setFlowchartData,
|
||||||
})
|
})
|
||||||
|
|||||||
45
apps/web/src/shared/components/FlowchartEditor/serialize.ts
Normal file
45
apps/web/src/shared/components/FlowchartEditor/serialize.ts
Normal 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[]
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ref, toValue, watch, type Ref, type MaybeRefOrGetter } from "vue"
|
import { ref, toValue, watch, type Ref, type MaybeRefOrGetter } from "vue"
|
||||||
import { useStorage, useDebounceFn } from "@vueuse/core"
|
import { useStorage, useDebounceFn } from "@vueuse/core"
|
||||||
import type { Node, Edge } from "@vue-flow/core"
|
import type { Node, Edge } from "@vue-flow/core"
|
||||||
|
import { toPortableEdges, toPortableNodes } from "./serialize"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 缓存管理 - 使用 @vueuse 的 useStorage
|
* 缓存管理 - 使用 @vueuse 的 useStorage
|
||||||
@@ -28,8 +29,8 @@ export function useCache(
|
|||||||
|
|
||||||
// 防抖保存:isSaving 在 watch 中置 true,保存完成后置 false,使 UI 能感知保存中状态
|
// 防抖保存:isSaving 在 watch 中置 true,保存完成后置 false,使 UI 能感知保存中状态
|
||||||
const debouncedSave = useDebounceFn(() => {
|
const debouncedSave = useDebounceFn(() => {
|
||||||
storedData.value.nodes = nodes.value
|
storedData.value.nodes = toPortableNodes(nodes.value)
|
||||||
storedData.value.edges = edges.value
|
storedData.value.edges = toPortableEdges(edges.value)
|
||||||
storedData.value.timestamp = new Date().toISOString()
|
storedData.value.timestamp = new Date().toISOString()
|
||||||
lastSaved.value = new Date()
|
lastSaved.value = new Date()
|
||||||
hasUnsavedChanges.value = false
|
hasUnsavedChanges.value = false
|
||||||
@@ -39,8 +40,8 @@ export function useCache(
|
|||||||
// 立即保存
|
// 立即保存
|
||||||
const saveToCache = () => {
|
const saveToCache = () => {
|
||||||
isSaving.value = true
|
isSaving.value = true
|
||||||
storedData.value.nodes = nodes.value
|
storedData.value.nodes = toPortableNodes(nodes.value)
|
||||||
storedData.value.edges = edges.value
|
storedData.value.edges = toPortableEdges(edges.value)
|
||||||
storedData.value.timestamp = new Date().toISOString()
|
storedData.value.timestamp = new Date().toISOString()
|
||||||
lastSaved.value = new Date()
|
lastSaved.value = new Date()
|
||||||
hasUnsavedChanges.value = false
|
hasUnsavedChanges.value = false
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { shallowRef, computed } from "vue"
|
import { shallowRef, computed } from "vue"
|
||||||
import type { Node, Edge } from "@vue-flow/core"
|
import type { Node, Edge } from "@vue-flow/core"
|
||||||
|
import { toPortableEdges, toPortableNodes } from "./serialize"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 简化的历史记录管理
|
* 简化的历史记录管理
|
||||||
@@ -18,7 +19,14 @@ export function useHistory() {
|
|||||||
nodes: Node[],
|
nodes: Node[],
|
||||||
edges: Edge[],
|
edges: Edge[],
|
||||||
): { 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[]
|
nodes: Node[]
|
||||||
edges: Edge[]
|
edges: Edge[]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user