diff --git a/apps/web/package.json b/apps/web/package.json index b2369a0..a4eefbe 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,9 +22,6 @@ "@codemirror/view": "^6.43.14", "@lezer/highlight": "^1.2.5", "@oj2/contract": "workspace:*", - "@vue-flow/background": "^1.3.2", - "@vue-flow/controls": "^1.1.3", - "@vue-flow/core": "^1.48.2", "@vueuse/core": "^15.0.0", "@vueuse/router": "^15.0.0", "@wangeditor-next/editor": "^6.5.0", diff --git a/apps/web/src/oj/flowchart/index.vue b/apps/web/src/oj/flowchart/index.vue deleted file mode 100644 index cd8329d..0000000 --- a/apps/web/src/oj/flowchart/index.vue +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/apps/web/src/oj/problem/components/EditorToolbar.vue b/apps/web/src/oj/problem/components/EditorToolbar.vue index 74eaae1..1616e3e 100644 --- a/apps/web/src/oj/problem/components/EditorToolbar.vue +++ b/apps/web/src/oj/problem/components/EditorToolbar.vue @@ -17,6 +17,7 @@ import { openStatistics, useEditorMenu } from "../composables/editorMenu" import { useTeacherCollab } from "../composables/teacherCollab" const SubmitFlowchart = defineAsyncComponent(() => import("./SubmitFlowchart.vue")) +const FlowchartControls = defineAsyncComponent(() => import("./FlowchartControls.vue")) const collabStore = useCollabStore() @@ -211,6 +212,8 @@ const changeLanguage = (v: LANGUAGE) => {
+ + +import { useThemeVars } from "naive-ui" +import { storeToRefs } from "pinia" +import { useFlowchartStore } from "oj/store/flowchart" +import { parseTime } from "utils/functions" + +/** + * 画流程图时工具栏右边那一组:已保存几点、撤销、重做(设计稿 FlowEdB)。 + * 原来这几个和节点库挤在画布右上角的面板里,盖住画好的框;保存状态只是一个 20px 的圆点。 + * 「清空画布」收进了「⋯」。 + */ +const { editor } = storeToRefs(useFlowchartStore()) +const theme = useThemeVars() + +const saved = computed(() => { + const at = editor.value?.savedAt + return at ? `已保存 ${parseTime(at, "HH:mm")}` : "" +}) + + + + + diff --git a/apps/web/src/oj/problem/components/FlowchartResult.vue b/apps/web/src/oj/problem/components/FlowchartResult.vue index ba5fb23..a96df0d 100644 --- a/apps/web/src/oj/problem/components/FlowchartResult.vue +++ b/apps/web/src/oj/problem/components/FlowchartResult.vue @@ -7,6 +7,7 @@ import { useProblemStore } from "oj/store/problem" import { useUserStore } from "shared/store/user" import { sortFlowchartCriteria } from "utils/constants" import { decompressFromBase64, parseTime } from "utils/functions" +import { parseDoc, signature } from "shared/components/FlowchartEditor/model" /** * 「结果」页签里流程图作业的那一块:AI 在评 / 评完了几分 / 历次分数 / 评语和改进建议。 @@ -104,63 +105,15 @@ function percentType(ratio: number) { } // ==================== 载回画布 ==================== -/** 这一版的画布数据(压缩前的 JSON 字符串);老提交的压缩数据可能是坏的 */ -const versionJson = computed(() => { +/** + * 这一版的图(结构树)。2026-10 换成插入式编辑器之前交的是 vue-flow 的格式,读不出来就是 + * null:那些只能看上面的图,载不回画布。老提交的压缩数据也可能是坏的 + */ +const versionDoc = computed(() => { const data = detail.value?.flowchartData?.data if (typeof data !== "string" || !data) return null try { - return decompressFromBase64(data) - } catch { - return null - } -}) - -/** - * 一张图「长什么样」:节点的 id / 类型 / data(字写在里面)/ 位置,连线的两端和标签。不直接比 JSON 字符串 —— - * 载回画布时编辑器会补上默认字段(sourceHandle: null 之类),老提交还带着 vue-flow 的运行时 - * 字段,字符串永远对不上,按钮就一直在。 - */ -type Shape = { nodes?: unknown[]; edges?: unknown[] } -function signature(data: Shape) { - const nodes = (data.nodes ?? []).map((raw) => { - const node = raw as { - id?: string - type?: string - data?: unknown - position?: { x?: number; y?: number } - } - return [ - node.id, - node.type, - // 节点上的字(customLabel)和原始类型都在 data 里,整个带上 - JSON.stringify(node.data ?? {}), - Math.round(node.position?.x ?? 0), - Math.round(node.position?.y ?? 0), - ].join("|") - }) - const edges = (data.edges ?? []).map((raw) => { - const edge = raw as { - source?: string - target?: string - sourceHandle?: string | null - targetHandle?: string | null - label?: unknown - } - return [ - edge.source, - edge.target, - edge.sourceHandle ?? "", - edge.targetHandle ?? "", - edge.label ?? "", - ].join("|") - }) - return JSON.stringify([nodes.sort(), edges.sort()]) -} - -const versionSignature = computed(() => { - if (!versionJson.value) return null - try { - return signature(JSON.parse(versionJson.value) as Shape) + return parseDoc(JSON.parse(decompressFromBase64(data))) } catch { return null } @@ -168,22 +121,18 @@ const versionSignature = computed(() => { /** * 只有画布和这一版不一样时才给「载回画布」:刚交完、还没动过的时候,这个按钮点了什么 - * 都不会变,只会让人以为它坏了。读 getFlowchartData() 会顺带跟踪画布的节点和边。 + * 都不会变,只会让人以为它坏了。比的是结构和字,不比 id。 + * 读 getDoc() 会顺带跟踪画布的数据 */ const canvasDiffers = computed(() => { - if (!editor.value || !versionSignature.value) return false - return signature(editor.value.getFlowchartData()) !== versionSignature.value + if (!editor.value || !versionDoc.value) return false + return signature(editor.value.getDoc()) !== signature(versionDoc.value) }) function loadToCanvas() { - if (!editor.value || !versionJson.value) return - try { - const json = JSON.parse(versionJson.value) - editor.value.setFlowchartData({ nodes: json.nodes || [], edges: json.edges || [] }) - message.success("已载回画布,按 Ctrl+Z 可以撤回") - } catch { - message.error("这一版的图数据坏了,载不回画布") - } + if (!editor.value || !versionDoc.value) return + editor.value.setDoc(versionDoc.value) + message.success("已载回画布,按 Ctrl+Z 可以撤回") } /** A / S 之后:照着图写代码。切回学生习惯的那门语言 */ diff --git a/apps/web/src/oj/problem/components/SubmitFlowchart.vue b/apps/web/src/oj/problem/components/SubmitFlowchart.vue index 8e13c14..4fe9f66 100644 --- a/apps/web/src/oj/problem/components/SubmitFlowchart.vue +++ b/apps/web/src/oj/problem/components/SubmitFlowchart.vue @@ -2,7 +2,7 @@ import { storeToRefs } from "pinia" import { compressToBase64 } from "utils/functions" import { useBreakpoints } from "shared/composables/breakpoints" -import { useMermaidConverter } from "../composables/useMermaidConverter" +import { countBlank, toMermaid } from "shared/components/FlowchartEditor/model" import { useProblemStore } from "oj/store/problem" import { useFlowchartStore, type FlowchartEditorInstance } from "oj/store/flowchart" @@ -21,20 +21,22 @@ const { problem } = storeToRefs(useProblemStore()) const flowchartStore = useFlowchartStore() const { loading } = storeToRefs(flowchartStore) const { isDesktop } = useBreakpoints() -const { convertToMermaid } = useMermaidConverter() function submit() { const editor = flowchartEditorRef?.value if (!editor) return - const flowchartData = editor.getFlowchartData() - if (!flowchartData?.nodes?.length || !flowchartData?.edges?.length) { - message.warning("画布上还没有图:从左边拖几个框进来,再用线连起来") + const doc = editor.getDoc() + if (!doc.steps.length) { + message.warning("画布上还没有图:点「开始」下面的 +,加第一步") return } - flowchartStore.submit( - convertToMermaid(flowchartData), - compressToBase64(JSON.stringify(flowchartData)), - ) + // 空框交上去 AI 只能瞎猜,学生也不知道扣分是因为忘了写字 + const blank = countBlank(doc.steps) + if (blank) { + message.warning(`有 ${blank} 个框还没写字,点一下框就能写`) + return + } + flowchartStore.submit(toMermaid(doc), compressToBase64(JSON.stringify(doc))) } // 进题、换题都读一次历次分数(store 里同一道题只读一次) diff --git a/apps/web/src/oj/problem/composables/editorMenu.ts b/apps/web/src/oj/problem/composables/editorMenu.ts index 0d2b4c1..dc2c73d 100644 --- a/apps/web/src/oj/problem/composables/editorMenu.ts +++ b/apps/web/src/oj/problem/composables/editorMenu.ts @@ -9,6 +9,7 @@ import { compressToBase64, copyToClipboard } from "utils/functions" import storage from "utils/storage" import { useDraftKey } from "./draftKey" import { useTeacherCollab } from "./teacherCollab" +import { useFlowchartStore } from "oj/store/flowchart" import { useProblemPageContext } from "./problemPageContext" /** @@ -48,7 +49,9 @@ export function useEditorMenu(statisticsInline: Ref) { if (ctx.value.classStats && !statisticsInline.value && userStore.isTeacherOrAbove) { items.push({ label: "课堂统计", key: "statistics" }) } - if (codeStore.code.language !== "Flowchart") { + if (codeStore.code.language === "Flowchart") { + items.push({ label: "清空画布", key: "clearFlowchart" }) + } else { if (codeStore.code.language !== "SQL") items.push({ label: "去自测猫", key: "testcat" }) items.push({ label: "复制代码", key: "copy" }) // 协作中的教师不给「重置代码」:那会儿编辑器里是**学生的**代码,而 v-model @@ -80,6 +83,23 @@ export function useEditorMenu(statisticsInline: Ref) { }) } + // 清空会连这道题存着的草稿一起删掉(空图不留草稿),点错了只剩 Ctrl+Z + const flowchartStore = useFlowchartStore() + function clearFlowchart() { + const editor = flowchartStore.editor + if (!editor) return + dialog.warning({ + title: "清空画布", + content: "画布上的图会全部清掉。确定吗?", + positiveText: "清空", + negativeText: "再想想", + onPositiveClick: () => { + editor.clear() + message.success("已清空,按 Ctrl+Z 可以撤回") + }, + }) + } + function goTestCat() { const data = { lang: LANGUAGE_FORMAT_VALUE[codeStore.code.language], @@ -124,6 +144,9 @@ export function useEditorMenu(statisticsInline: Ref) { case "reset": reset() return true + case "clearFlowchart": + clearFlowchart() + return true case "edit": goEdit() return true diff --git a/apps/web/src/oj/problem/composables/useMermaidConverter.ts b/apps/web/src/oj/problem/composables/useMermaidConverter.ts deleted file mode 100644 index e689340..0000000 --- a/apps/web/src/oj/problem/composables/useMermaidConverter.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * 节点/连线标签是学生自己敲的任意文字,而下面是直接把它塞进 "..." 里。 - * 出现一个双引号就会把 mermaid 语法撑破:图渲染不出来,坏掉的代码还会原样送去 - * 给 AI 打分,学生完全不知道自己被扣分是因为一个引号。 - * - * mermaid 用 `#NN;` 形式的实体转义。`#` 必须**先**转,否则标签里本来就有的 - * `#quot;` 之类会被当成实体解释。换行转成
,不然会截断整条语句。 - */ -function escapeLabel(raw: unknown) { - return String(raw ?? "") - .replace(/#/g, "#35;") - .replace(/"/g, "#quot;") - .replace(/\r?\n/g, "
") -} - -/** - * 将流程图 JSON 数据转换为 Mermaid 格式 - */ -export function useMermaidConverter() { - const convertToMermaid = (flowchartData: any) => { - const nodes = flowchartData?.nodes - // edges 原来没做空值保护(nodes 有),拿到 undefined 会直接抛 - const edges = flowchartData?.edges ?? [] - - if (!nodes || nodes.length === 0) { - return "graph TD\n A[空流程图]" - } - - let mermaid = "graph TD\n" - - // Build safe ID mapping to prevent Mermaid syntax errors from special characters - const idMap = new Map() - nodes.forEach((node: any, index: number) => { - idMap.set(node.id, `node_${index}`) - }) - const safeId = (id: string) => idMap.get(id) || id.replace(/[^a-zA-Z0-9_]/g, "_") - - // 处理节点 - 根据原始类型和自定义标签 - nodes.forEach((node: any) => { - const nodeId = safeId(node.id) - const label = escapeLabel(node.data?.customLabel || node.data?.label || "节点") - const originalType = node.data?.originalType || node.type - - // 根据节点原始类型确定Mermaid语法 - switch (originalType) { - case "start": - mermaid += ` ${nodeId}(("${label}"))\n` - break - case "end": - mermaid += ` ${nodeId}(("${label}"))\n` - break - case "input": - // 输入框使用平行四边形 - mermaid += ` ${nodeId}[/"${label}"/]\n` - break - case "output": - // 输出框使用平行四边形 - mermaid += ` ${nodeId}[/"${label}"/]\n` - break - case "default": - mermaid += ` ${nodeId}["${label}"]\n` - break - case "decision": - mermaid += ` ${nodeId}{"${label}"}\n` - break - case "loop": - // 循环使用菱形 - mermaid += ` ${nodeId}{"${label}"}\n` - break - default: - mermaid += ` ${nodeId}["${label}"]\n` - } - }) - - // 处理边 - edges.forEach((edge: any) => { - const source = safeId(edge.source) - const target = safeId(edge.target) - const rawLabel = String(edge.label ?? "") - - if (rawLabel.trim() !== "") { - mermaid += ` ${source} -->|"${escapeLabel(rawLabel)}"| ${target}\n` - } else { - mermaid += ` ${source} --> ${target}\n` - } - }) - - // 添加样式定义来区分不同类型的节点 - mermaid += "\n" - mermaid += " classDef startNode fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#0f172a\n" - mermaid += " classDef endNode fill:#fee2e2,stroke:#dc2626,stroke-width:2px,color:#0f172a\n" - mermaid += " classDef input fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#0f172a\n" - mermaid += " classDef output fill:#ede9fe,stroke:#7c3aed,stroke-width:2px,color:#0f172a\n" - mermaid += " classDef process fill:#f0f9ff,stroke:#0284c7,stroke-width:2px,color:#0f172a\n" - mermaid += " classDef decision fill:#fef3c7,stroke:#d97706,stroke-width:2px,color:#0f172a\n" - mermaid += " classDef loop fill:#fae8ff,stroke:#c026d3,stroke-width:2px,color:#0f172a\n" - mermaid += "\n" - - // 为节点应用样式 - nodes.forEach((node: any) => { - const nodeId = safeId(node.id) - const originalType = node.data?.originalType || node.type - - switch (originalType) { - case "start": - mermaid += ` class ${nodeId} startNode\n` - break - case "end": - mermaid += ` class ${nodeId} endNode\n` - break - case "input": - mermaid += ` class ${nodeId} input\n` - break - case "output": - mermaid += ` class ${nodeId} output\n` - break - case "decision": - mermaid += ` class ${nodeId} decision\n` - break - case "loop": - mermaid += ` class ${nodeId} loop\n` - break - default: - mermaid += ` class ${nodeId} process\n` - } - }) - - return mermaid - } - - return { - convertToMermaid, - } -} diff --git a/apps/web/src/oj/store/flowchart.ts b/apps/web/src/oj/store/flowchart.ts index 7155e28..7fbce91 100644 --- a/apps/web/src/oj/store/flowchart.ts +++ b/apps/web/src/oj/store/flowchart.ts @@ -2,7 +2,7 @@ import { createDiscreteApi } from "naive-ui" import { defineStore } from "pinia" import { errorCode } from "utils/api" import { isFlowchartPass, type FlowchartScores, type FlowchartSubmission } from "@oj2/contract" -import type { Edge, Node } from "@vue-flow/core" +import type { FlowDoc } from "shared/components/FlowchartEditor/model" import { getFlowchartScores, getFlowchartSubmission, submitFlowchart } from "oj/api" import { useProblemStore } from "oj/store/problem" import { useSubmissionStore } from "oj/store/submission" @@ -22,10 +22,16 @@ export interface FlowchartRating { type Outcome = { ok: true; score: number; grade: string } | { ok: false; error?: string } -/** 编辑器对外的两个方法(FlowchartEditor 的 defineExpose) */ +/** 编辑器对外的东西(FlowchartEditor 的 defineExpose;ref 经组件实例读出来已经解包) */ export interface FlowchartEditorInstance { - getFlowchartData: () => { nodes: unknown[]; edges: unknown[] } - setFlowchartData: (data: { nodes: Node[]; edges: Edge[] }) => void + getDoc: () => FlowDoc + setDoc: (doc: FlowDoc) => void + undo: () => void + redo: () => void + clear: () => void + canUndo: boolean + canRedo: boolean + savedAt: Date | null } // 「A / S 算做完」的规则在契约里,前后端一份;这里转出去,组件照旧从 flowchart store 引 diff --git a/apps/web/src/routes.ts b/apps/web/src/routes.ts index 8055ebc..c743cbb 100644 --- a/apps/web/src/routes.ts +++ b/apps/web/src/routes.ts @@ -138,11 +138,6 @@ export const ojs: RouteRecordRaw = { component: () => import("oj/ai/analysis.vue"), meta: { requiresAuth: true }, }, - { - path: "flowchart", - name: "flowchart", - component: () => import("oj/flowchart/index.vue"), - }, { path: "problemset", component: () => import("oj/problemset/list.vue"), diff --git a/apps/web/src/shared/components/FlowchartEditor/CustomNode.vue b/apps/web/src/shared/components/FlowchartEditor/CustomNode.vue deleted file mode 100644 index 0a7c039..0000000 --- a/apps/web/src/shared/components/FlowchartEditor/CustomNode.vue +++ /dev/null @@ -1,270 +0,0 @@ - - - - - diff --git a/apps/web/src/shared/components/FlowchartEditor/NodeActions.vue b/apps/web/src/shared/components/FlowchartEditor/NodeActions.vue deleted file mode 100644 index c72535e..0000000 --- a/apps/web/src/shared/components/FlowchartEditor/NodeActions.vue +++ /dev/null @@ -1,52 +0,0 @@ - - - - - diff --git a/apps/web/src/shared/components/FlowchartEditor/NodeHandles.vue b/apps/web/src/shared/components/FlowchartEditor/NodeHandles.vue deleted file mode 100644 index 07e9977..0000000 --- a/apps/web/src/shared/components/FlowchartEditor/NodeHandles.vue +++ /dev/null @@ -1,238 +0,0 @@ - - - - - diff --git a/apps/web/src/shared/components/FlowchartEditor/Toolbar.vue b/apps/web/src/shared/components/FlowchartEditor/Toolbar.vue deleted file mode 100644 index 34efb9e..0000000 --- a/apps/web/src/shared/components/FlowchartEditor/Toolbar.vue +++ /dev/null @@ -1,400 +0,0 @@ - - - - diff --git a/apps/web/src/shared/components/FlowchartEditor/index.vue b/apps/web/src/shared/components/FlowchartEditor/index.vue index 8a7ace2..9d178e0 100644 --- a/apps/web/src/shared/components/FlowchartEditor/index.vue +++ b/apps/web/src/shared/components/FlowchartEditor/index.vue @@ -1,349 +1,1025 @@ diff --git a/apps/web/src/shared/components/FlowchartEditor/layout.ts b/apps/web/src/shared/components/FlowchartEditor/layout.ts new file mode 100644 index 0000000..874cf49 --- /dev/null +++ b/apps/web/src/shared/components/FlowchartEditor/layout.ts @@ -0,0 +1,294 @@ +import type { Branch, FlowDoc, FlowStep, Slot, StepKind } from "./model" + +/** + * 结构树 → 坐标。纯函数,每次改动整张重排(13 个框的图不到一毫秒)。 + * + * 排法照教科书上的流程图:主线竖着往下;判断的「是」往左、「否」往右,从菱形的两个角 + * 横着出去再往下,两路走完合回中线;循环的条件成立往下走循环体,走完从左边绕回菱形, + * 不成立从右边出去绕到下面。每一段竖线上留一个 +(插一步的地方)。 + * + * 画布稿(画布「画流程图重设计」页 FlowEdB)是用同一套排法的 Python 版画的,两边的 + * 常量对得上。 + */ + +export type NodeKind = StepKind | "start" | "end" + +export interface LaidNode { + id: string + kind: NodeKind + text: string + /** 左上角 */ + x: number + y: number + w: number + h: number +} + +export interface LaidSlot { + key: string + slot: Slot + x: number + y: number +} + +export interface LaidLabel { + x: number + y: number + text: string + anchor: "start" | "end" +} + +export interface Layout { + nodes: LaidNode[] + /** 带箭头的线、不带箭头的线(合流那几段) */ + arrows: string[] + lines: string[] + dots: { x: number; y: number }[] + labels: LaidLabel[] + slots: LaidSlot[] + width: number + height: number +} + +const H = 32 +const DH = 40 +/** 两步之间的竖线长度,中间放 + */ +const G = 30 +/** 判断左右两支之间至少空这么多 */ +const HG = 32 +const PAD = 28 + +export const PLACEHOLDER: Record = { + input: "输入什么", + output: "输出什么", + process: "做什么", + if: "条件", + while: "条件", +} + +type Measure = (text: string) => number + +type Item = FlowStep | { kind: "start" | "end"; id: string; text: string } + +interface Extent { + L: number + R: number + h: number +} + +export function layoutDoc(doc: FlowDoc, measure: Measure): Layout { + const shown = (step: Item) => + step.text.trim() || (step.kind in PLACEHOLDER ? PLACEHOLDER[step.kind as StepKind] : "") + + const nodeW = (step: Item) => { + const t = measure(shown(step)) + if (step.kind === "if" || step.kind === "while") return Math.max(t + 64, 104) + if (step.kind === "input" || step.kind === "output") return Math.max(t + 44, 100) + if (step.kind === "start" || step.kind === "end") return Math.max(t + 40, 80) + return Math.max(t + 32, 92) + } + + const extentCache = new Map() + + const measureBlock = (items: Item[]): Extent => { + let L = 0 + let R = 0 + let h = 0 + items.forEach((item, i) => { + const e = measureItem(item) + L = Math.max(L, e.L) + R = Math.max(R, e.R) + h += e.h + (i ? G : 0) + }) + return { L, R, h } + } + + const ifGeom = (step: Extract) => { + const dw = nodeW(step) + const yes = measureBlock(step.yes) + const no = measureBlock(step.no) + const lx = step.yes.length ? Math.min(-dw / 2 - 16, -HG / 2 - yes.R) : -dw / 2 - 16 + const rx = step.no.length ? Math.max(dw / 2 + 16, HG / 2 + no.L) : dw / 2 + 16 + const bh = Math.max(yes.h, no.h, 24) + return { dw, yes, no, lx, rx, bh } + } + + const measureItem = (item: Item): Extent => { + const cached = extentCache.get(item) + if (cached) return cached + let e: Extent + if (item.kind === "if") { + const { dw, yes, no, lx, rx, bh } = ifGeom(item) + e = { + L: Math.max(dw / 2, -lx + yes.L), + R: Math.max(dw / 2, rx + no.R), + h: DH + 24 + bh + 22, + } + } else if (item.kind === "while") { + const dw = nodeW(item) + const body = measureBlock(item.body) + e = { + L: Math.max(dw / 2, body.L) + 28, + R: Math.max(dw / 2, body.R) + 28, + h: DH + G + Math.max(body.h, 8) + 20 + 20, + } + } else { + const w = nodeW(item) + e = { L: w / 2, R: w / 2, h: H } + } + extentCache.set(item, e) + return e + } + + const out: Layout = { + nodes: [], + arrows: [], + lines: [], + dots: [], + labels: [], + slots: [], + width: 0, + height: 0, + } + const pathOf = (pts: [number, number][]) => + "M" + pts.map(([x, y]) => `${x.toFixed(1)} ${y.toFixed(1)}`).join(" L") + const arrow = (pts: [number, number][]) => out.arrows.push(pathOf(pts)) + const line = (pts: [number, number][]) => out.lines.push(pathOf(pts)) + const slot = (parent: string | null, branch: Branch, index: number, x: number, y: number) => + out.slots.push({ + key: `${parent ?? "main"}:${branch}:${index}`, + slot: { parent, branch, index }, + x, + y, + }) + + const place = (item: Item, cx: number, y: number) => { + const w = nodeW(item) + const h = item.kind === "if" || item.kind === "while" ? DH : H + out.nodes.push({ id: item.id, kind: item.kind, text: item.text, x: cx - w / 2, y, w, h }) + } + + /** + * 竖着排一串。main:两头是开始 / 结束,第 j 个缝对应 steps 的第 j-1 个位置; + * 分支:第 i 个缝就是第 i 个位置(头尾的 + 由判断 / 循环自己放) + */ + const drawBlock = ( + items: Item[], + cx: number, + y: number, + parent: string | null, + branch: Branch, + ): number => { + const offset = branch === "main" ? -1 : 0 + items.forEach((item, i) => { + if (i) { + arrow([ + [cx, y - G], + [cx, y - 1], + ]) + slot(parent, branch, i + offset, cx, y - G + 11) + } + drawItem(item, cx, y) + y += measureItem(item).h + (i < items.length - 1 ? G : 0) + }) + return y + } + + const drawItem = (item: Item, cx: number, y: number) => { + if (item.kind === "if") { + const { dw, lx, rx, bh } = ifGeom(item) + place(item, cx, y) + const my = y + DH / 2 + const top = y + DH + 24 + const merge = top + bh + 22 + const sides = [ + { label: "是", bx: cx + lx, steps: item.yes, branch: "yes" as const, corner: cx - dw / 2 }, + { label: "否", bx: cx + rx, steps: item.no, branch: "no" as const, corner: cx + dw / 2 }, + ] + for (const side of sides) { + if (side.steps.length) { + arrow([ + [side.corner, my], + [side.bx, my], + [side.bx, top - 1], + ]) + slot(item.id, side.branch, 0, side.bx, my + 22) + const end = drawBlock(side.steps, side.bx, top, item.id, side.branch) + slot(item.id, side.branch, side.steps.length, side.bx, end + 11) + line([ + [side.bx, end], + [side.bx, merge], + [cx, merge], + ]) + } else { + line([ + [side.corner, my], + [side.bx, my], + [side.bx, merge], + [cx, merge], + ]) + slot(item.id, side.branch, 0, side.bx, (my + merge) / 2) + } + out.labels.push({ + x: side.label === "是" ? side.corner - 6 : side.corner + 6, + y: my - 7, + text: side.label, + anchor: side.label === "是" ? "end" : "start", + }) + } + out.dots.push({ x: cx, y: merge }) + return + } + if (item.kind === "while") { + const dw = nodeW(item) + const body = measureBlock(item.body) + place(item, cx, y) + const my = y + DH / 2 + const top = y + DH + G + ;(item.body.length ? arrow : line)([ + [cx, y + DH], + [cx, item.body.length ? top - 1 : top + 8], + ]) + out.labels.push({ x: cx + 16, y: y + DH + 15, text: "是", anchor: "start" }) + slot(item.id, "body", 0, cx, y + DH + 12) + let end = top + 8 + if (item.body.length) { + end = drawBlock(item.body, cx, top, item.id, "body") + slot(item.id, "body", item.body.length, cx, end + 10) + } + const L = Math.max(dw / 2, body.L) + 28 + const R = Math.max(dw / 2, body.R) + 28 + const back = end + 20 + arrow([ + [cx, end], + [cx, back], + [cx - L, back], + [cx - L, my], + [cx - dw / 2 - 1, my], + ]) + const exit = back + 20 + line([ + [cx + dw / 2, my], + [cx + R, my], + [cx + R, exit], + [cx, exit], + ]) + out.labels.push({ x: cx + dw / 2 + 6, y: my - 7, text: "否", anchor: "start" }) + return + } + place(item, cx, y) + } + + const items: Item[] = [ + { kind: "start", id: "__start", text: "开始" }, + ...doc.steps, + { kind: "end", id: "__end", text: "结束" }, + ] + const ext = measureBlock(items) + // 竖线和 + 的位置跟着 doc.steps 走:空图时开始和结束之间也要有一个 + + const cx = PAD + ext.L + drawBlock(items, cx, PAD, null, "main") + out.width = ext.L + ext.R + PAD * 2 + out.height = ext.h + PAD * 2 + return out +} diff --git a/apps/web/src/shared/components/FlowchartEditor/model.ts b/apps/web/src/shared/components/FlowchartEditor/model.ts new file mode 100644 index 0000000..8ade6ac --- /dev/null +++ b/apps/web/src/shared/components/FlowchartEditor/model.ts @@ -0,0 +1,248 @@ +/** + * 流程图的数据:一棵结构树,不是「一堆框 + 一堆线」。 + * + * 原来的编辑器(vue-flow)存的是自由摆放的节点和学生手拉的连线,能画出断线、孤立的框、 + * 一个「是」连出去三根线 —— 学生大半时间花在拖框、对连接点、理线上。现在只有三种结构: + * 顺序、判断(是 / 否两路,走完合回来)、循环(当型,条件成立就走一遍循环体再回来判断), + * 「开始」「结束」固定在两头不进数据。画不出来的只有乱跳的线,中职教的三种结构都画得出。 + * + * 存进数据库(flowchart_submission.flowchart_data,压缩过)和本地草稿的都是 FlowDoc。 + * 2026-10 之前的提交存的是 vue-flow 的 nodes / edges,parseDoc 认不出来就是 null —— + * 那些只能看 Mermaid 渲染出来的图,载不回画布。 + */ + +export type BoxKind = "input" | "output" | "process" +export type StepKind = BoxKind | "if" | "while" + +interface StepBase { + id: string + text: string +} +export type BoxStep = StepBase & { kind: BoxKind } +export type IfStep = StepBase & { kind: "if"; yes: FlowStep[]; no: FlowStep[] } +export type WhileStep = StepBase & { kind: "while"; body: FlowStep[] } +export type FlowStep = BoxStep | IfStep | WhileStep + +export interface FlowDoc { + version: 2 + steps: FlowStep[] +} + +/** 一个步骤列表在树里的位置:主线,或者某个判断 / 循环的哪一支 */ +export type Branch = "main" | "yes" | "no" | "body" +/** 可以插一步的地方:某个列表的第 index 个位置之前(index = 长度就是末尾) */ +export interface Slot { + parent: string | null + branch: Branch + index: number +} + +export const STEP_LABEL: Record = { + input: "输入", + output: "输出", + process: "处理", + if: "判断", + while: "循环", +} + +export function emptyDoc(): FlowDoc { + return { version: 2, steps: [] } +} + +let seq = 0 +function newId() { + seq = (seq + 1) % 1_000_000 + return `s${Date.now().toString(36)}${seq.toString(36)}` +} + +export function newStep(kind: StepKind): FlowStep { + const base = { id: newId(), text: "" } + if (kind === "if") return { ...base, kind, yes: [], no: [] } + if (kind === "while") return { ...base, kind, body: [] } + return { ...base, kind } +} + +export function cloneDoc(doc: FlowDoc): FlowDoc { + return JSON.parse(JSON.stringify(doc)) as FlowDoc +} + +/** 按 id 找一步,顺带带出它所在的列表 */ +export function findStep( + steps: FlowStep[], + id: string, +): { step: FlowStep; list: FlowStep[]; index: number } | null { + for (let index = 0; index < steps.length; index++) { + const step = steps[index]! + if (step.id === id) return { step, list: steps, index } + for (const child of children(step)) { + const found = findStep(child, id) + if (found) return found + } + } + return null +} + +function children(step: FlowStep): FlowStep[][] { + if (step.kind === "if") return [step.yes, step.no] + if (step.kind === "while") return [step.body] + return [] +} + +function listAt(doc: FlowDoc, parent: string | null, branch: Branch): FlowStep[] | null { + if (parent === null) return branch === "main" ? doc.steps : null + const found = findStep(doc.steps, parent) + if (!found) return null + const step = found.step + if (step.kind === "if" && (branch === "yes" || branch === "no")) return step[branch] + if (step.kind === "while" && branch === "body") return step.body + return null +} + +/** 在 slot 处插一步,原地改 doc,返回新那一步 */ +export function insertStep(doc: FlowDoc, slot: Slot, kind: StepKind): FlowStep | null { + const list = listAt(doc, slot.parent, slot.branch) + if (!list) return null + const step = newStep(kind) + list.splice(Math.max(0, Math.min(slot.index, list.length)), 0, step) + return step +} + +/** 删一步(判断 / 循环连同里面的一起删),返回一共删掉几个框 */ +export function removeStep(doc: FlowDoc, id: string): number { + const found = findStep(doc.steps, id) + if (!found) return 0 + found.list.splice(found.index, 1) + return countSteps([found.step]) +} + +export function countSteps(steps: FlowStep[]): number { + return steps.reduce( + (sum, step) => sum + 1 + children(step).reduce((n, c) => n + countSteps(c), 0), + 0, + ) +} + +/** 还没写字的框有几个 */ +export function countBlank(steps: FlowStep[]): number { + return steps.reduce( + (sum, step) => + sum + (step.text.trim() ? 0 : 1) + children(step).reduce((n, c) => n + countBlank(c), 0), + 0, + ) +} + +/** + * 一张图「长什么样」,不带 id:载回旧版、和交过的那一版比,只看结构和字。 + */ +export function signature(doc: FlowDoc): string { + const strip = (steps: FlowStep[]): unknown[] => + steps.map((step) => [step.kind, step.text.trim(), ...children(step).map(strip)]) + return JSON.stringify(strip(doc.steps)) +} + +// ==================== 读回来的数据 ==================== + +const KINDS = new Set(["input", "output", "process", "if", "while"]) + +function parseSteps(raw: unknown): FlowStep[] | null { + if (!Array.isArray(raw)) return null + const out: FlowStep[] = [] + for (const item of raw) { + if (!item || typeof item !== "object") return null + const step = item as Record + const kind = step.kind + if (typeof kind !== "string" || !KINDS.has(kind)) return null + const id = typeof step.id === "string" && step.id ? step.id : newId() + const text = typeof step.text === "string" ? step.text : "" + if (kind === "if") { + const yes = parseSteps(step.yes ?? []) + const no = parseSteps(step.no ?? []) + if (!yes || !no) return null + out.push({ id, text, kind, yes, no }) + } else if (kind === "while") { + const body = parseSteps(step.body ?? []) + if (!body) return null + out.push({ id, text, kind, body }) + } else { + out.push({ id, text, kind: kind as BoxKind }) + } + } + return out +} + +/** 本地草稿、交过的某一版:是新格式就读出来,旧的 vue-flow 格式或者坏数据给 null */ +export function parseDoc(raw: unknown): FlowDoc | null { + if (!raw || typeof raw !== "object") return null + const data = raw as Record + if (data.version !== 2) return null + const steps = parseSteps(data.steps) + return steps ? { version: 2, steps } : null +} + +// ==================== Mermaid ==================== + +/** + * 交给 AI 的、结果页签和提交列表里渲染缩略图的,都是这份 Mermaid。 + * + * 形状照国标流程图:起止是圆角跑道、输入输出是平行四边形、处理是矩形、判断是菱形。 + * 原来起止画成圆、输入输出和画布上的样子对不上,AI 还会说「输出框应该用矩形」。 + * class 行留着给 useMermaid 上色;classDef 不写了 —— 颜色是渲染那边的事,写进来 + * AI 会把「用了颜色样式」也当成优点打分。 + * + * 学生敲的字直接塞进 "...",一个双引号就会把语法撑破:坏掉的代码还会原样送去打分, + * 学生不知道扣分是因为一个引号。`#` 必须先转,否则本来就有的 `#quot;` 会被当成实体。 + */ +function escapeLabel(raw: string) { + return raw.replace(/#/g, "#35;").replace(/"/g, "#quot;").replace(/\r?\n/g, "
") +} + +export function toMermaid(doc: FlowDoc): string { + const lines: string[] = ["flowchart TD"] + const classes: string[] = [] + let n = 0 + const node = (shape: (label: string) => string, text: string, cls: string) => { + const id = `n${n++}` + lines.push(` ${id}${shape(escapeLabel(text.trim() || " "))}`) + classes.push(` class ${id} ${cls}`) + return id + } + /** 还没接上下一步的出口:从哪个框出来、线上写什么 */ + type Exit = { from: string; label?: string } + const link = (exits: Exit[], to: string) => { + for (const exit of exits) { + lines.push( + exit.label ? ` ${exit.from} -->|"${exit.label}"| ${to}` : ` ${exit.from} --> ${to}`, + ) + } + } + const walk = (steps: FlowStep[], entries: Exit[]): Exit[] => { + let exits = entries + for (const step of steps) { + if (step.kind === "if") { + const id = node((l) => `{"${l}"}`, step.text, "decision") + link(exits, id) + exits = [ + ...walk(step.yes, [{ from: id, label: "是" }]), + ...walk(step.no, [{ from: id, label: "否" }]), + ] + } else if (step.kind === "while") { + const id = node((l) => `{"${l}"}`, step.text, "decision") + link(exits, id) + link(walk(step.body, [{ from: id, label: "是" }]), id) + exits = [{ from: id, label: "否" }] + } else { + const shape = + step.kind === "process" ? (l: string) => `["${l}"]` : (l: string) => `[/"${l}"/]` + const id = node(shape, step.text, step.kind) + link(exits, id) + exits = [{ from: id }] + } + } + return exits + } + const start = node((l) => `(["${l}"])`, "开始", "startNode") + const exits = walk(doc.steps, [{ from: start }]) + const end = node((l) => `(["${l}"])`, "结束", "endNode") + link(exits, end) + return [...lines, "", ...classes].join("\n") +} diff --git a/apps/web/src/shared/components/FlowchartEditor/serialize.ts b/apps/web/src/shared/components/FlowchartEditor/serialize.ts deleted file mode 100644 index e9bb63b..0000000 --- a/apps/web/src/shared/components/FlowchartEditor/serialize.ts +++ /dev/null @@ -1,45 +0,0 @@ -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[] -} diff --git a/apps/web/src/shared/components/FlowchartEditor/useCache.ts b/apps/web/src/shared/components/FlowchartEditor/useCache.ts deleted file mode 100644 index 0b6c538..0000000 --- a/apps/web/src/shared/components/FlowchartEditor/useCache.ts +++ /dev/null @@ -1,109 +0,0 @@ -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 - */ -export function useCache( - nodes: Ref, - edges: Ref, - storageKey: MaybeRefOrGetter = "flowchart-editor-data", - onReloaded?: () => void, -) { - const isSaving = ref(false) - const lastSaved = ref(null) - const hasUnsavedChanges = ref(false) - - // 使用 useStorage 管理数据存储,支持响应式 key(题目 ID 异步加载时自动切换) - const storedData = useStorage<{ - nodes: Node[] - edges: Edge[] - timestamp: string - }>(storageKey, { - nodes: [], - edges: [], - timestamp: "", - }) - - // 防抖保存:isSaving 在 watch 中置 true,保存完成后置 false,使 UI 能感知保存中状态 - const debouncedSave = useDebounceFn(() => { - 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 - isSaving.value = false - }, 500) - - // 立即保存 - const saveToCache = () => { - isSaving.value = true - 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 - isSaving.value = false - } - - // 从缓存加载数据 - const loadFromCache = () => { - if (storedData.value.nodes?.length || storedData.value.edges?.length) { - nodes.value = storedData.value.nodes - edges.value = storedData.value.edges - lastSaved.value = storedData.value.timestamp ? new Date(storedData.value.timestamp) : null - hasUnsavedChanges.value = false - return true - } - return false - } - - // 清除缓存数据 - const clearCache = () => { - storedData.value = { nodes: [], edges: [], timestamp: "" } - lastSaved.value = null - 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], - () => { - hasUnsavedChanges.value = true - isSaving.value = true - debouncedSave() - }, - { deep: true }, - ) - - return { - isSaving, - lastSaved, - hasUnsavedChanges, - saveToCache, - loadFromCache, - clearCache, - } -} diff --git a/apps/web/src/shared/components/FlowchartEditor/useDnD.ts b/apps/web/src/shared/components/FlowchartEditor/useDnD.ts deleted file mode 100644 index fa2894c..0000000 --- a/apps/web/src/shared/components/FlowchartEditor/useDnD.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { ref } from "vue" -import { useVueFlow } from "@vue-flow/core" -import { getNodeTypeConfig, createNodeStyle, getNodeDimensions } from "./useNodeStyles" -import { getRandomId } from "utils/functions" - -// 模块级共享:当前拖拽的节点类型(Toolbar 写入,canvas 读取) -export const currentDragNodeType = ref(null) - -/** - * 简化的拖拽处理 - */ -export function useDnD() { - const { addNodes, screenToFlowCoordinate } = useVueFlow() - const isDragOver = ref(false) - const screenDragPos = ref<{ x: number; y: number } | null>(null) - - // 拖拽悬停处理 - const onDragOver = (event: DragEvent) => { - event.preventDefault() - isDragOver.value = true - screenDragPos.value = { x: event.clientX, y: event.clientY } - } - - // 拖拽离开处理 - const onDragLeave = () => { - isDragOver.value = false - screenDragPos.value = null - } - - // 拖拽放置处理 - const onDrop = (event: DragEvent) => { - event.preventDefault() - isDragOver.value = false - screenDragPos.value = null - currentDragNodeType.value = null - - const type = event.dataTransfer?.getData("application/vueflow") - if (!type) return - - // 获取鼠标在画布中的坐标 - const position = screenToFlowCoordinate({ - x: event.clientX, - y: event.clientY, - }) - - // 根据节点类型获取实际尺寸 - const dimensions = getNodeDimensions(type) - - // 调整位置,使节点中心点对齐到鼠标位置 - const adjustedPosition = { - x: position.x - dimensions.width / 2, - y: position.y - dimensions.height / 2, - } - - const nodeId = `node-${getRandomId()}` - const config = getNodeTypeConfig(type) - const newNode = { - id: nodeId, - type: "custom", - position: adjustedPosition, - data: { - label: config.label, - color: config.color, - originalType: type, - }, - style: createNodeStyle(type), - } - - addNodes([newNode]) - return newNode - } - - return { - isDragOver, - screenDragPos, - onDragOver, - onDragLeave, - onDrop, - } -} diff --git a/apps/web/src/shared/components/FlowchartEditor/useFlowOperations.ts b/apps/web/src/shared/components/FlowchartEditor/useFlowOperations.ts deleted file mode 100644 index d6c5992..0000000 --- a/apps/web/src/shared/components/FlowchartEditor/useFlowOperations.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { nextTick } from "vue" -import type { Ref } from "vue" -import type { Node, Edge, Connection } from "@vue-flow/core" -import { useVueFlow } from "@vue-flow/core" -import { getRandomId } from "utils/functions" - -export function useFlowOperations( - nodes: Ref, - edges: Ref, - addEdges: (edges: Edge[]) => void, - removeNodes: (nodeIds: string[]) => void, - removeEdges: (edgeIds: string[]) => void, - saveState: (nodes: Node[], edges: Edge[]) => void, -) { - const { findNode, getSelectedNodes, getSelectedEdges } = useVueFlow() - const getAutoLabel = ( - sourceNode: Node | undefined, - targetNode: Node | undefined, - sourceHandle: string | null | undefined, - targetHandle: string | null | undefined, - ) => { - const sourceType = sourceNode?.data?.originalType || sourceNode?.type - const targetType = targetNode?.data?.originalType || targetNode?.type - - // 如果是判断节点 - if (sourceType === "decision") { - // 根据handle ID推断标签 - if (sourceHandle === "yes") { - return "是" - } else if (sourceHandle === "no") { - return "否" - } - } - - // 如果是循环节点 - if (sourceType === "loop") { - // 根据handle ID推断标签 - if (sourceHandle === "continue") { - return "继续" - } else if (sourceHandle === "exit") { - return "退出" - } - } - - // 如果是循环体回到循环节点 - if (targetType === "loop") { - if (targetHandle === "return") { - return "返回" - } - } - // 默认情况 - return "" - } - - 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) - - // 自动推断标签 - const autoLabel = getAutoLabel(sourceNode, targetNode, params.sourceHandle, params.targetHandle) - - const newEdge: Edge = { - id: `edge-${getRandomId()}`, - source: params.source, - target: params.target, - sourceHandle: params.sourceHandle, - targetHandle: params.targetHandle, - type: "default", - label: autoLabel, - } - - addEdges([newEdge]) - // vue-flow 的 store → v-model 回写走的是 watch(pre flush,异步), - // 紧接着读 nodes/edges 拿到的还是改动前的数组,存进历史就会错开一步。 - // 画布上 handleDrop 早就这么等了,这几处一直漏了。 - await nextTick() - saveState(nodes.value, edges.value) - } - - const handleEdgeClick = async ({ edge }: { edge: Edge }) => { - removeEdges([edge.id]) - await nextTick() - saveState(nodes.value, edges.value) - } - - // 节点删除。removeNodes 的 removeConnectedEdges 默认就是 true, - // 相连的边不用自己再删一遍 - const handleNodeDelete = async (nodeId: string) => { - removeNodes([nodeId]) - await nextTick() - saveState(nodes.value, edges.value) - } - - // 节点更新,空标签时清除自定义标签(恢复默认类型名称) - const handleNodeUpdate = (nodeId: string, newLabel: string) => { - const node = findNode(nodeId) - if (node) { - if (newLabel) { - node.data = { ...node.data, customLabel: newLabel } - } else { - const { customLabel: _, ...rest } = node.data - node.data = rest - } - saveState(nodes.value, edges.value) - } - } - - // 清空画布 - const clearCanvas = () => { - nodes.value = [] - edges.value = [] - saveState(nodes.value, edges.value) - } - - // 删除选中的节点和边 - 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)) - } - if (selectedEdges.length > 0) { - removeEdges(selectedEdges.map((edge) => edge.id)) - } - await nextTick() - saveState(nodes.value, edges.value) - } - - return { - handleConnect, - handleEdgeClick, - handleNodeDelete, - handleNodeUpdate, - clearCanvas, - deleteSelected, - } -} diff --git a/apps/web/src/shared/components/FlowchartEditor/useHistory.ts b/apps/web/src/shared/components/FlowchartEditor/useHistory.ts deleted file mode 100644 index 81b32b5..0000000 --- a/apps/web/src/shared/components/FlowchartEditor/useHistory.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { shallowRef, computed } from "vue" -import type { Node, Edge } from "@vue-flow/core" -import { toPortableEdges, toPortableNodes } from "./serialize" - -/** - * 简化的历史记录管理 - */ -export function useHistory() { - const history = shallowRef<{ nodes: Node[]; edges: Edge[] }[]>([]) - const historyIndex = ref(-1) - - // 是否可以撤销 - const canUndo = computed(() => historyIndex.value > 0) - - // 是否可以重做 - const canRedo = computed(() => historyIndex.value < history.value.length - 1) - - const deepCopyState = (nodes: Node[], edges: Edge[]): { nodes: Node[]; edges: Edge[] } => - // 先裁掉 vue-flow 的运行时内部字段再深拷贝:20 份快照 × 每个节点几百字节的 - // handleBounds/dimensions,纯属白拷 - JSON.parse( - JSON.stringify({ - nodes: toPortableNodes(nodes), - edges: toPortableEdges(edges), - }), - ) as { - nodes: Node[] - edges: Edge[] - } - - // 保存状态到历史记录 - const saveState = (nodes: Node[], edges: Edge[]) => { - const currentState = deepCopyState(nodes, edges) - - // 如果当前不在历史记录的末尾,删除后面的记录 - if (historyIndex.value < history.value.length - 1) { - history.value = history.value.slice(0, historyIndex.value + 1) - } - - history.value = [...history.value, currentState] - historyIndex.value = history.value.length - 1 - - // 限制历史记录数量 - if (history.value.length > 20) { - history.value = history.value.slice(1) - historyIndex.value-- - } - } - - // 用当前画布重建历史。挂载时和换题后都要调一次: - // 不播下这个初始快照的话 historyIndex 会从 -1 开始,canUndo 要求 index > 0, - // 第一步操作永远撤销不了;换题后不重建则一次撤销会把上一题的图还原到这一题里。 - const resetHistory = (nodes: Node[], edges: Edge[]) => { - history.value = [deepCopyState(nodes, edges)] - historyIndex.value = 0 - } - - // 撤销 - const undo = () => { - if (canUndo.value) { - historyIndex.value-- - const state = history.value[historyIndex.value] - return deepCopyState(state.nodes, state.edges) - } - return null - } - - // 重做 - const redo = () => { - if (canRedo.value) { - historyIndex.value++ - const state = history.value[historyIndex.value] - return deepCopyState(state.nodes, state.edges) - } - return null - } - - return { - canUndo, - canRedo, - resetHistory, - saveState, - undo, - redo, - } -} diff --git a/apps/web/src/shared/components/FlowchartEditor/useNodeStyles.ts b/apps/web/src/shared/components/FlowchartEditor/useNodeStyles.ts deleted file mode 100644 index 45e2114..0000000 --- a/apps/web/src/shared/components/FlowchartEditor/useNodeStyles.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * 节点样式管理 - */ - -/** - * 获取节点类型配置 - */ -export function getNodeTypeConfig(type: string) { - const configs: Record< - string, - { label: string; color: string; icon: string; description: string } - > = { - start: { - label: "开始", - color: "#10b981", - icon: "▶", - description: "流程开始", - }, - input: { - label: "输入", - color: "#06b6d4", - icon: "📥", - description: "数据输入", - }, - default: { - label: "赋值", - color: "#3b82f6", - icon: "⚙", - description: "赋值语句", - }, - decision: { - label: "判断", - color: "#f59e0b", - icon: "❓", - description: "条件语句", - }, - loop: { - label: "循环", - color: "#8b5cf6", - icon: "🔄", - description: "循环语句", - }, - output: { - label: "输出", - color: "#84cc16", - icon: "📤", - description: "数据输出", - }, - end: { - label: "结束", - color: "#ef4444", - icon: "⏹", - description: "流程结束", - }, - } - return ( - configs[type] || { - label: "节点", - color: "#6b7280", - icon: "⚪", - description: "未知节点", - } - ) -} - -/** - * 获取节点样式 - */ -export function getNodeStyle(type: string, color: string) { - const baseStyle = { - background: color, - color: "white", - border: `2px solid ${color}`, - borderRadius: "10px", - fontSize: "16px", - fontWeight: "500", - width: "auto", // 自动宽度 - height: "auto", // 自动高度 - minWidth: "100px", - minHeight: "40px", - maxWidth: "400px", - maxHeight: "160px", - display: "flex", - alignItems: "center", - justifyContent: "center", - boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)", - } - - // 根据节点类型调整样式 - switch (type) { - case "start": - case "end": - return { - ...baseStyle, - } - case "decision": - return { - ...baseStyle, - borderRadius: "8px", - minWidth: "140px", - minHeight: "50px", - } - case "loop": - return { - ...baseStyle, - borderRadius: "8px", - minWidth: "140px", - minHeight: "50px", - } - default: - return baseStyle - } -} - -/** - * 创建节点样式 - */ -export function createNodeStyle(type: string) { - const config = getNodeTypeConfig(type) - return getNodeStyle(type, config.color) -} - -/** - * 获取节点尺寸 - */ -export function getNodeDimensions(type: string) { - switch (type) { - case "start": - case "end": - return { width: 100, height: 40 } - case "decision": - case "loop": - return { width: 140, height: 50 } - default: - return { width: 120, height: 40 } - } -} diff --git a/apps/web/src/shared/composables/useMermaid.ts b/apps/web/src/shared/composables/useMermaid.ts index ee83c43..d105b38 100644 --- a/apps/web/src/shared/composables/useMermaid.ts +++ b/apps/web/src/shared/composables/useMermaid.ts @@ -58,8 +58,8 @@ const mermaidDisplayStyle = ` .oj-mermaid-flowchart g.node.startEnd ellipse, .oj-mermaid-flowchart g.node.startEnd circle, .oj-mermaid-flowchart g.node.startEnd path { - fill: #dcfce7 !important; - stroke: #16a34a !important; + fill: #eef8f2 !important; + stroke: #18a058 !important; } .oj-mermaid-flowchart g.node.endNode rect, @@ -67,8 +67,8 @@ const mermaidDisplayStyle = ` .oj-mermaid-flowchart g.node.endNode ellipse, .oj-mermaid-flowchart g.node.endNode circle, .oj-mermaid-flowchart g.node.endNode path { - fill: #fee2e2 !important; - stroke: #dc2626 !important; + fill: #eef8f2 !important; + stroke: #18a058 !important; } .oj-mermaid-flowchart g.node.input rect, @@ -76,8 +76,8 @@ const mermaidDisplayStyle = ` .oj-mermaid-flowchart g.node.input ellipse, .oj-mermaid-flowchart g.node.input circle, .oj-mermaid-flowchart g.node.input path { - fill: #dbeafe !important; - stroke: #2563eb !important; + fill: #eef4fd !important; + stroke: #2f6bc4 !important; } .oj-mermaid-flowchart g.node.output rect, @@ -85,8 +85,8 @@ const mermaidDisplayStyle = ` .oj-mermaid-flowchart g.node.output ellipse, .oj-mermaid-flowchart g.node.output circle, .oj-mermaid-flowchart g.node.output path { - fill: #ede9fe !important; - stroke: #7c3aed !important; + fill: #eef4fd !important; + stroke: #2f6bc4 !important; } .oj-mermaid-flowchart g.node.process rect, @@ -94,8 +94,8 @@ const mermaidDisplayStyle = ` .oj-mermaid-flowchart g.node.process ellipse, .oj-mermaid-flowchart g.node.process circle, .oj-mermaid-flowchart g.node.process path { - fill: #f0f9ff !important; - stroke: #0284c7 !important; + fill: #ffffff !important; + stroke: #7d828a !important; } .oj-mermaid-flowchart g.node.decision rect, @@ -103,8 +103,8 @@ const mermaidDisplayStyle = ` .oj-mermaid-flowchart g.node.decision ellipse, .oj-mermaid-flowchart g.node.decision circle, .oj-mermaid-flowchart g.node.decision path { - fill: #fef3c7 !important; - stroke: #d97706 !important; + fill: #fdf5e6 !important; + stroke: #d08a1c !important; } .oj-mermaid-flowchart g.node.loop rect, @@ -112,8 +112,8 @@ const mermaidDisplayStyle = ` .oj-mermaid-flowchart g.node.loop ellipse, .oj-mermaid-flowchart g.node.loop circle, .oj-mermaid-flowchart g.node.loop path { - fill: #fae8ff !important; - stroke: #c026d3 !important; + fill: #fdf5e6 !important; + stroke: #d08a1c !important; } .oj-mermaid-flowchart g.node.oj-node-palette-0 rect, diff --git a/bun.lock b/bun.lock index 30b3045..e2328c4 100644 --- a/bun.lock +++ b/bun.lock @@ -51,9 +51,6 @@ "@codemirror/view": "^6.43.14", "@lezer/highlight": "^1.2.5", "@oj2/contract": "workspace:*", - "@vue-flow/background": "^1.3.2", - "@vue-flow/controls": "^1.1.3", - "@vue-flow/core": "^1.48.2", "@vueuse/core": "^15.0.0", "@vueuse/router": "^15.0.0", "@wangeditor-next/editor": "^6.5.0", @@ -819,12 +816,6 @@ "@volar/typescript": ["@volar/typescript@2.4.28", "https://registry.npmjs.com/@volar/typescript/-/typescript-2.4.28.tgz", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="], - "@vue-flow/background": ["@vue-flow/background@1.3.2", "https://registry.npmjs.com/@vue-flow/background/-/background-1.3.2.tgz", { "peerDependencies": { "@vue-flow/core": "^1.23.0", "vue": "^3.3.0" } }, "sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w=="], - - "@vue-flow/controls": ["@vue-flow/controls@1.1.3", "https://registry.npmjs.com/@vue-flow/controls/-/controls-1.1.3.tgz", { "peerDependencies": { "@vue-flow/core": "^1.23.0", "vue": "^3.3.0" } }, "sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw=="], - - "@vue-flow/core": ["@vue-flow/core@1.48.2", "https://registry.npmjs.com/@vue-flow/core/-/core-1.48.2.tgz", { "dependencies": { "@vueuse/core": "^10.5.0", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" }, "peerDependencies": { "vue": "^3.3.0" } }, "sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA=="], - "@vue-macros/common": ["@vue-macros/common@3.1.4", "https://registry.npmjs.com/@vue-macros/common/-/common-3.1.4.tgz", { "dependencies": { "@vue/compiler-sfc": "^3.5.22", "ast-kit": "^2.1.2", "local-pkg": "^1.1.2", "magic-string-ast": "^1.0.2", "unplugin-utils": "^0.3.0" }, "peerDependencies": { "vue": "^2.7.0 || ^3.2.25" }, "optionalPeers": ["vue"] }, "sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw=="], "@vue/compiler-core": ["@vue/compiler-core@3.5.43", "https://registry.npmjs.com/@vue/compiler-core/-/compiler-core-3.5.43.tgz", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.43", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg=="], @@ -1049,7 +1040,7 @@ "d3-delaunay": ["d3-delaunay@6.0.4", "https://registry.npmjs.com/d3-delaunay/-/d3-delaunay-6.0.4.tgz", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="], - "d3-dispatch": ["d3-dispatch@3.0.1", "https://registry.npmjs.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + "d3-dispatch": ["d3-dispatch@1.0.6", "https://registry.npmjs.com/d3-dispatch/-/d3-dispatch-1.0.6.tgz", {}, "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA=="], "d3-drag": ["d3-drag@3.0.0", "https://registry.npmjs.com/d3-drag/-/d3-drag-3.0.0.tgz", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], @@ -1565,8 +1556,6 @@ "vue-chartjs": ["vue-chartjs@5.3.4", "https://registry.npmjs.com/vue-chartjs/-/vue-chartjs-5.3.4.tgz", { "peerDependencies": { "chart.js": "^4.1.1", "vue": "^3.0.0-0 || ^2.7.0" } }, "sha512-x3Fqob8RQvrTdssfi9ecsCzEkFOd8JPmNwSkSQzdfKj/uBsRJs/Y88cZcZIEcPsTVfMGwMo4MOoihoDG2DoE/g=="], - "vue-demi": ["vue-demi@0.14.10", "https://registry.npmjs.com/vue-demi/-/vue-demi-0.14.10.tgz", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="], - "vue-router": ["vue-router@5.4.0", "https://registry.npmjs.com/vue-router/-/vue-router-5.4.0.tgz", { "dependencies": { "@vue-macros/common": "^3.1.3", "@vue/devtools-api": "^8.1.5", "ast-walker-scope": "^0.10.0", "chokidar": "^5.0.0", "confbox": "^0.3.1", "local-pkg": "^1.2.1", "magic-string": "^1.4.3", "mlly": "^1.8.2", "muggle-string": "^0.4.1", "nostics": "^1.3.0", "pathe": "^2.0.3", "picomatch": "^4.0.7", "scule": "^1.3.0", "tinyglobby": "^0.2.17", "unplugin": "^3.4.0", "unplugin-utils": "^0.3.2" }, "peerDependencies": { "@pinia/colada": ">=0.21.2", "@vue/compiler-sfc": "^3.5.34 || ^4.0.0", "pinia": "^3.0.4 || ^4.0.2", "vite": "^7.3.0 || ^8.0.0", "vue": "^3.5.34 || ^4.0.0" }, "optionalPeers": ["@pinia/colada", "@vue/compiler-sfc", "pinia", "vite"] }, "sha512-pFvcYcxinDHjZUlf7AzFBrT8r8Xqvv4uMHoJnjc+EqPawXQvHTkF8S4tfSmQLpriVHUGfkaKlI7Et0KRjTMTdw=="], "vue-tsc": ["vue-tsc@3.3.13", "https://registry.npmjs.com/vue-tsc/-/vue-tsc-3.3.13.tgz", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.13" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-jE4/bXdOMSHVxEGgQCqNllJy+9N3Gx6tfSAsg0yhFK/MAN6eXqISihupnDEKSCt12VVnjN2gI3AqLYcqoh5x/A=="], @@ -1625,8 +1614,6 @@ "@vitest/snapshot/magic-string": ["magic-string@1.4.2", "https://registry.npmjs.com/magic-string/-/magic-string-1.4.2.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g=="], - "@vue-flow/core/@vueuse/core": ["@vueuse/core@10.11.1", "https://registry.npmjs.com/@vueuse/core/-/core-10.11.1.tgz", { "dependencies": { "@types/web-bluetooth": "^0.0.20", "@vueuse/metadata": "10.11.1", "@vueuse/shared": "10.11.1", "vue-demi": ">=0.14.8" } }, "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww=="], - "@vue-macros/common/@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.41", "https://registry.npmjs.com/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/compiler-core": "3.5.41", "@vue/compiler-dom": "3.5.41", "@vue/compiler-ssr": "3.5.41", "@vue/shared": "3.5.41", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.19", "source-map-js": "^1.2.1" } }, "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ=="], "@vue/compiler-core/entities": ["entities@7.0.1", "https://registry.npmjs.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], @@ -1661,14 +1648,24 @@ "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "https://registry.npmjs.com/cose-base/-/cose-base-2.2.0.tgz", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], - "d3-cloud/d3-dispatch": ["d3-dispatch@1.0.6", "https://registry.npmjs.com/d3-dispatch/-/d3-dispatch-1.0.6.tgz", {}, "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA=="], + "d3/d3-dispatch": ["d3-dispatch@3.0.1", "https://registry.npmjs.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-brush/d3-dispatch": ["d3-dispatch@3.0.1", "https://registry.npmjs.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag/d3-dispatch": ["d3-dispatch@3.0.1", "https://registry.npmjs.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], "d3-dsv/commander": ["commander@7.2.0", "https://registry.npmjs.com/commander/-/commander-7.2.0.tgz", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + "d3-force/d3-dispatch": ["d3-dispatch@3.0.1", "https://registry.npmjs.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + "d3-sankey/d3-array": ["d3-array@2.12.1", "https://registry.npmjs.com/d3-array/-/d3-array-2.12.1.tgz", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], "d3-sankey/d3-shape": ["d3-shape@1.3.7", "https://registry.npmjs.com/d3-shape/-/d3-shape-1.3.7.tgz", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "d3-transition/d3-dispatch": ["d3-dispatch@3.0.1", "https://registry.npmjs.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-zoom/d3-dispatch": ["d3-dispatch@3.0.1", "https://registry.npmjs.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + "katex/commander": ["commander@8.3.0", "https://registry.npmjs.com/commander/-/commander-8.3.0.tgz", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "magic-string-ast/magic-string": ["magic-string@0.30.21", "https://registry.npmjs.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -1755,12 +1752,6 @@ "@vitest/snapshot/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "https://registry.npmjs.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], - "@vue-flow/core/@vueuse/core/@types/web-bluetooth": ["@types/web-bluetooth@0.0.20", "https://registry.npmjs.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", {}, "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow=="], - - "@vue-flow/core/@vueuse/core/@vueuse/metadata": ["@vueuse/metadata@10.11.1", "https://registry.npmjs.com/@vueuse/metadata/-/metadata-10.11.1.tgz", {}, "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw=="], - - "@vue-flow/core/@vueuse/core/@vueuse/shared": ["@vueuse/shared@10.11.1", "https://registry.npmjs.com/@vueuse/shared/-/shared-10.11.1.tgz", { "dependencies": { "vue-demi": ">=0.14.8" } }, "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA=="], - "@vue-macros/common/@vue/compiler-sfc/@vue/compiler-core": ["@vue/compiler-core@3.5.41", "https://registry.npmjs.com/@vue/compiler-core/-/compiler-core-3.5.41.tgz", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.41", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg=="], "@vue-macros/common/@vue/compiler-sfc/@vue/compiler-dom": ["@vue/compiler-dom@3.5.41", "https://registry.npmjs.com/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", { "dependencies": { "@vue/compiler-core": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw=="],