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")}` : ""
+})
+
+
+
+
+
{{ saved }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
-
-
-
-
-
-
-
-
- {{ displayLabel }}
-
-
-
-
-
-
- {{ displayLabel }}
-
-
-
-
-
-
-
-
-
-
-
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 @@
-
-
-
-
- {{ getNodeTypeConfig(currentDragNodeType).icon }}
- {{ getNodeTypeConfig(currentDragNodeType).label }}
-
-
-
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
+
+
+
+ {{ t.text }}
+
+
+
+
+
+
+ {{ nodeLabel(node) }}
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
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=="],