feat(流程图): 画流程图不用再拖框连线——点箭头上的 + 加一步,判断自动分出是 / 否,排版全自动

原来要从节点库拖框、对准连接点拉线、自己摆位置,还能画出断线和孤立的框;
画布上全是彩色圆角块,交上去的却是平行四边形和菱形。现在照画布稿 FlowEdB:
- 图存成一棵结构树(顺序 / 判断 / 循环),开始、结束固定在两头,排版是纯函数
- 每根箭头上一个 +,点了选输入 / 输出 / 处理 / 判断 / 循环;点框直接改字,右上角「删除」
- 形状照国标流程图,画布、缩略图、交给 AI 的 Mermaid 是同一套;缩略图配色跟着画布改
- 已保存时间、撤销、重做挪到工具栏,「清空画布」收进「⋯」;空框交不上去
- 去掉 vue-flow 三个包(编辑器打包后 16 KB),删掉没人进的 /flowchart 页

2026-10 之前交的图是 vue-flow 格式,只能看缩略图、载不回画布;旧草稿键名不同,不去碰。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-12 20:56:45 +08:00
1 parent ce26925e79
commit 09f8a56913
25 files changed
+1670 -2102

No files matched your search

-7
View File
@@ -1,7 +0,0 @@
<script lang="ts" setup>
import FlowchartEditor from "shared/components/FlowchartEditor/index.vue"
</script>
<template>
<FlowchartEditor />
</template>
<style scoped></style>
@@ -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) => {
<div class="spacer" />
<FlowchartControls v-if="drawing && isDesktop" />
<n-button
v-if="showHelpButton"
:size="buttonSize"
@@ -0,0 +1,81 @@
<script setup lang="ts">
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")}` : ""
})
</script>
<template>
<div v-if="editor" class="flowchart-controls">
<span v-if="saved" class="saved">{{ saved }}</span>
<n-button
:disabled="!editor.canUndo"
aria-label="撤销"
title="撤销(Ctrl+Z)"
@click="editor.undo()"
>
<template #icon>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M9 14L4 9l5-5" />
<path d="M4 9h10a6 6 0 010 12h-3" />
</svg>
</template>
</n-button>
<n-button
:disabled="!editor.canRedo"
aria-label="重做"
title="重做(Ctrl+Y)"
@click="editor.redo()"
>
<template #icon>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M15 14l5-5-5-5" />
<path d="M20 9H10a6 6 0 000 12h3" />
</svg>
</template>
</n-button>
</div>
</template>
<style scoped>
.flowchart-controls {
display: flex;
align-items: center;
gap: 8px;
}
.saved {
font-size: 13px;
color: v-bind("theme.textColor3");
white-space: nowrap;
}
</style>
@@ -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 之后:照着图写代码。切回学生习惯的那门语言 */
@@ -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 里同一道题只读一次)
@@ -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<boolean>) {
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<boolean>) {
})
}
// 清空会连这道题存着的草稿一起删掉(空图不留草稿),点错了只剩 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<boolean>) {
case "reset":
reset()
return true
case "clearFlowchart":
clearFlowchart()
return true
case "edit":
goEdit()
return true
@@ -1,134 +0,0 @@
/**
* 节点/连线标签是学生自己敲的任意文字,而下面是直接把它塞进 "..." 里。
* 出现一个双引号就会把 mermaid 语法撑破:图渲染不出来,坏掉的代码还会原样送去
* 给 AI 打分,学生完全不知道自己被扣分是因为一个引号。
*
* mermaid 用 `#NN;` 形式的实体转义。`#` 必须**先**转,否则标签里本来就有的
* `#quot;` 之类会被当成实体解释。换行转成 <br/>,不然会截断整条语句。
*/
function escapeLabel(raw: unknown) {
return String(raw ?? "")
.replace(/#/g, "#35;")
.replace(/"/g, "#quot;")
.replace(/\r?\n/g, "<br/>")
}
/**
* 将流程图 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<string, string>()
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,
}
}
+10 -4
View File
@@ -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 引