From 6e946c99fb11cc982cb487f91299837cb57f7cac Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 12 Oct 2026 21:39:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=87=BA=E9=A2=98):=20=E5=8F=82=E8=80=83?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E5=9B=BE=E6=94=B9=E7=94=A8=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E7=94=BB=EF=BC=8C=E3=80=8C=E8=AE=A9=E5=AD=A6=E7=94=9F=E8=87=AA?= =?UTF-8?q?=E5=B7=B1=E7=94=BB=E3=80=8D=E4=B8=8D=E5=86=8D=E8=A6=81=E5=8F=82?= =?UTF-8?q?=E8=80=83=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 学生画的图现在按例子和测试数据评(拼成程序跑),参考图用不上了:「让学生自己画」 只剩一句说明,保存前改成检查有没有例子;「给学生的提示」那一栏去掉(学生从来看不到, 16 道题也都没填) - 「给学生看一张参考流程图」改用学生画图的同一个编辑器,存结构树(problem.flowchart_data, 原来是死列),Mermaid 由后端照它生成,题面里的图和学生交的图同一套形状 - 「AI 照 Python 答案画一张」改成出结构树,直接摆在画布上接着改;for 循环画成 计数变量 + 当型循环 - 老题手写的 Mermaid 照旧给代码框,旁边「改用画布画」 Co-Authored-By: Claude Opus 5.5 --- apps/api/src/routes/admin/problem.ts | 16 ++- apps/api/src/routes/admin/tag.ts | 31 +++-- apps/web/src/admin/api.ts | 7 +- .../admin/problem/components/CodePreview.vue | 20 ++-- apps/web/src/admin/problem/detail.vue | 109 +++++++++++++----- .../components/FlowchartEditor/index.vue | 25 +++- apps/web/src/utils/types.ts | 7 +- packages/contract/src/admin.ts | 10 +- 8 files changed, 168 insertions(+), 57 deletions(-) diff --git a/apps/api/src/routes/admin/problem.ts b/apps/api/src/routes/admin/problem.ts index 59bc617..18f408b 100644 --- a/apps/api/src/routes/admin/problem.ts +++ b/apps/api/src/routes/admin/problem.ts @@ -1,4 +1,6 @@ import { + flowToMermaid, + parseFlowDoc, addContestProblemRequestSchema, reorderContestProblemsRequestSchema, astCheckRequestSchema, @@ -206,6 +208,7 @@ async function serialize(row: ProblemRow) { allowFlowchart: row.allowFlowchart, showFlowchart: row.showFlowchart, mermaidCode: row.mermaidCode, + flowchartData: asRecord(row.flowchartData), flowchartHint: row.flowchartHint, astRules: row.astRules, answers: Array.isArray(row.answers) ? row.answers : [], @@ -276,6 +279,17 @@ async function generateSqlDisplay( return { display: outcome.value } } +/** + * 参考流程图:画布画的(结构树)以它为准,Mermaid 照它生成 —— 题面里给学生看的、评分退回 + * 老办法时拿来比的都是这份,和学生交的图同一套形状。只有手写 Mermaid 的老题原样存 + */ +function referenceFlowchart(raw: Record | null, mermaidCode: string | null) { + const doc = parseFlowDoc(raw) + return doc && doc.steps.length + ? { flowchartData: doc, mermaidCode: flowToMermaid(doc) } + : { flowchartData: {}, mermaidCode } +} + function problemValues(data: ReturnType, isSql: boolean) { return { displayId: data._id, @@ -296,7 +310,7 @@ function problemValues(data: ReturnType source: data.source, allowFlowchart: data.allowFlowchart, showFlowchart: data.showFlowchart, - mermaidCode: data.mermaidCode, + ...referenceFlowchart(data.flowchartData, data.mermaidCode), flowchartHint: data.flowchartHint, astRules: pickAstRules(data.astRules, data.languages), answers: data.answers, diff --git a/apps/api/src/routes/admin/tag.ts b/apps/api/src/routes/admin/tag.ts index f86b4b0..068bee1 100644 --- a/apps/api/src/routes/admin/tag.ts +++ b/apps/api/src/routes/admin/tag.ts @@ -1,4 +1,5 @@ import { + parseFlowDoc, batchProblemTagRequestSchema, generateFlowchartRequestSchema, renameTagRequestSchema, @@ -16,7 +17,7 @@ import type { AuthUser } from "../../auth/session" import { db, schema } from "../../db" import { failure, parseBody, success } from "../../http" import { completeChat } from "../../services/ai" -import { queryInteger } from "../helpers" +import { asRecord, queryInteger } from "../helpers" import { canEdit, findTagsByName, normalizeTagNames } from "./problem" export const adminTagRoutes = new Hono() @@ -254,18 +255,32 @@ adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async ( // ---------------------------------------------------------------- Python → 流程图 +/** + * 照标准答案画参考流程图。原来让 AI 直接写 Mermaid,老师拿到的是一段代码,要改只能改代码; + * 现在出结构树,后台画布直接摆上去,和学生画图是同一个编辑器 + */ +const PYTHON_TO_FLOWCHART = `把下面这段 Python 标准答案画成流程图,给中职学生看。只返回 JSON:{"steps":[…]} +每一步是下面五种之一(开始、结束不用写): +{"kind":"input","text":"输入 n"} +{"kind":"output","text":"输出 a(保留两位小数)"} +{"kind":"process","text":"i = i + 1"} +{"kind":"if","text":"a > b","yes":[…],"no":[…]} +{"kind":"while","text":"i < 365","body":[…]} +- 框里写中文和简单的式子,像课本上那样;不要写 input、print、int、range 这些函数名 +- for 循环改成计数变量 + while:先一个处理框给变量赋初值,while 写条件,循环体最后一个处理框让变量加一 +- elif 写成 else 里再套一个 if +- break、continue、函数定义尽量改写成等价的顺序 / 判断 / 循环;实在改不了就用一个处理框写一句说明` + adminTagRoutes.post("/problems/flowchart", requireProblemPermission, async (c) => { const parsed = await parseBody(c, generateFlowchartRequestSchema, "python 代码不能为空") if (!parsed.success) return parsed.response try { - const flowchart = await completeChat( - `你是一个可以将Python代码转换为mermaid的助手。 -请将用户提供的Python代码转换为 Mermaid 纯文本。 -注意括号内的内容用引号包裹,如果本身就有引号,请注意双引号和单引号的问题。 -请只返回 mermaid 代码,连 \`\`\` 都不需要。`, - parsed.data.python, + const reply = asRecord( + JSON.parse(await completeChat(PYTHON_TO_FLOWCHART, parsed.data.python, { json: true })), ) - return success(c, { flowchart } satisfies GenerateFlowchartResponse) + const doc = parseFlowDoc({ version: 2, steps: reply.steps }) + if (!doc || !doc.steps.length) throw new Error("AI returned no flowchart steps") + return success(c, { doc: { ...doc } } satisfies GenerateFlowchartResponse) } catch (error) { console.error("Flowchart generation failed", error) return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试") diff --git a/apps/web/src/admin/api.ts b/apps/web/src/admin/api.ts index 862076a..714a841 100644 --- a/apps/web/src/admin/api.ts +++ b/apps/web/src/admin/api.ts @@ -117,7 +117,7 @@ export function toggleProblemVisible(problemID: number) { } export function generateFlowchartFromPythonCode(python: string) { - return api.post<{ flowchart: string }>("admin/problems/flowchart", { + return api.post<{ doc: Record }>("admin/problems/flowchart", { python, }) } @@ -276,7 +276,9 @@ export function generateTestInputs(data: GenerateTestInputsRequest) { /** 出站补默认值。字段名两边已经一致,不再做键名转换 */ function toProblemBody(problem: AdminProblem | BlankProblem) { - const p = problem as Partial + const p = problem as Partial> & { + flowchartData?: Record | null + } return { _id: p._id, title: p.title, @@ -300,6 +302,7 @@ function toProblemBody(problem: AdminProblem | BlankProblem) { allowFlowchart: p.allowFlowchart ?? false, showFlowchart: p.showFlowchart ?? false, mermaidCode: p.mermaidCode ?? null, + flowchartData: p.flowchartData ?? null, flowchartHint: p.flowchartHint ?? null, astRules: p.astRules ?? null, sqlConfig: p.sqlConfig ?? null, diff --git a/apps/web/src/admin/problem/components/CodePreview.vue b/apps/web/src/admin/problem/components/CodePreview.vue index 7c29283..564af0d 100644 --- a/apps/web/src/admin/problem/components/CodePreview.vue +++ b/apps/web/src/admin/problem/components/CodePreview.vue @@ -34,7 +34,6 @@ const props = defineProps<{ astChecks: AstCheckState flowchartMode: FlowchartMode mermaidCode: string - flowchartHint: string }>() const tab = defineModel("tab", { required: true }) @@ -248,18 +247,15 @@ onMounted(() => nextTick(render))

这道题不用流程图

-

- 学生自己画,这张图学生看不到,只拿来给学生画的图打分。 - +

+ 学生在题目页右边自己画,交上去按例子和测试数据评

-

学生在题面里点开「老师给的参考流程图」看到的是:

-

- 还没写图的代码 -

-
- 画不出来:{{ renderError }} -
-
+ +
diff --git a/apps/web/src/admin/problem/detail.vue b/apps/web/src/admin/problem/detail.vue index 7cf298f..0b46bbe 100644 --- a/apps/web/src/admin/problem/detail.vue +++ b/apps/web/src/admin/problem/detail.vue @@ -42,8 +42,17 @@ import { type TemplateParts, } from "./components/codeTemplate" import type { AstCheckState, FlowchartMode, PreviewTab } from "./components/editorTypes" +import { + emptyDoc, + parseDoc, + toMermaid, + type FlowDoc, +} from "shared/components/FlowchartEditor/model" const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue")) +const FlowchartEditor = defineAsyncComponent( + () => import("shared/components/FlowchartEditor/index.vue"), +) /** * 编程题(C / C++ / Python)的出题页,设计稿「编程题出题页」方案 B:左边写,右边是学生 @@ -93,6 +102,8 @@ interface CodeForm { astRules: AstRules | null flowchartMode: FlowchartMode mermaidCode: string + /** 画布画的参考流程图;null 是老题(只有手写的 Mermaid)或者还没画 */ + flowchartDoc: FlowDoc | null flowchartHint: string } @@ -118,6 +129,7 @@ const blankForm = (): CodeForm => ({ astRules: null, flowchartMode: "none", mermaidCode: "", + flowchartDoc: null, flowchartHint: "", }) @@ -437,13 +449,26 @@ const FLOWCHART_LABEL: Record = { } const mermaidOk = ref(false) + +/** + * 老题的参考图是手写的 Mermaid(没有结构树):照旧给代码框;新画的、或者点了「改用画布画」 + * 的用画布。保存时画布的图以它为准,后端照它重新生成 Mermaid + */ +const legacyMermaid = computed(() => !form.flowchartDoc && !!form.mermaidCode.trim()) +/** 右边预览画的那张:画布有图就照画布生成,不然是老的代码 */ +const previewMermaid = computed(() => { + if (!form.flowchartDoc) return form.mermaidCode + return form.flowchartDoc.steps.length ? toMermaid(form.flowchartDoc) : "" +}) const aiDrawing = ref(false) async function aiFlowchart() { aiDrawing.value = true try { const res = await generateFlowchartFromPythonCode(form.answers.Python) - form.mermaidCode = res.flowchart + const doc = parseDoc(res.doc) + if (!doc) throw new Error("AI 画出来的图读不了") + form.flowchartDoc = doc preview.value = "flowchart" } catch (err) { message.error(errorMessage(err, "生成失败,请稍后再试")) @@ -599,6 +624,7 @@ async function loadProblem(data: AdminProblem) { astRules: data.astRules ?? null, flowchartMode: data.allowFlowchart ? "draw" : data.showFlowchart ? "show" : "none", mermaidCode: data.mermaidCode ?? "", + flowchartDoc: parseDoc(data.flowchartData), flowchartHint: data.flowchartHint ?? "", } satisfies CodeForm) answerTab.value = RUNNABLE.find((l) => languages.includes(l) && answers[l].trim()) ?? "Python" @@ -703,10 +729,17 @@ const blockers = computed(() => { }) }) } - if (form.flowchartMode !== "none") { - if (!form.mermaidCode.trim()) list.push({ text: "写流程图的代码", section: "flowchart" }) - else if (!mermaidOk.value) - list.push({ text: "改好流程图的代码(画不出来)", section: "flowchart", severe: true }) + if (form.flowchartMode === "show") { + if (legacyMermaid.value) { + if (!mermaidOk.value) + list.push({ text: "改好流程图的代码(画不出来)", section: "flowchart", severe: true }) + } else if (!form.flowchartDoc?.steps.length) { + list.push({ text: "画参考流程图", section: "flowchart" }) + } + } + // 学生画的图拼成程序拿例子和测试数据跑(flowchart/program.ts),一组例子都没有就没得评 + if (form.flowchartMode === "draw" && !examples.value.length) { + list.push({ text: "勾至少一组测试数据当例子(流程图照例子评)", section: "cases" }) } return list }) @@ -775,6 +808,10 @@ async function save() { allowFlowchart: form.flowchartMode === "draw", showFlowchart: form.flowchartMode === "show", mermaidCode: form.flowchartMode === "none" ? form.mermaidCode : form.mermaidCode.trim(), + flowchartData: + form.flowchartMode === "show" && form.flowchartDoc?.steps.length + ? { ...form.flowchartDoc } + : null, flowchartHint: form.flowchartHint.trim(), astRules: form.astRules, sqlConfig: null, @@ -1196,15 +1233,27 @@ onMounted(async () => { - @@ -1326,8 +1370,7 @@ onMounted(async () => { :ast-rules="form.astRules" :ast-checks="astChecks" :flowchart-mode="form.flowchartMode" - :mermaid-code="form.mermaidCode" - :flowchart-hint="form.flowchartHint" + :mermaid-code="previewMermaid" @render-state="mermaidOk = $event" @collapse="previewCollapsed = true" /> @@ -1658,4 +1701,10 @@ onMounted(async () => { .blocker:hover { text-decoration: underline; } + +.flowCanvas { + border: 1px solid v-bind("theme.borderColor"); + border-radius: 6px; + overflow: hidden; +} diff --git a/apps/web/src/shared/components/FlowchartEditor/index.vue b/apps/web/src/shared/components/FlowchartEditor/index.vue index 2555716..6cabac9 100644 --- a/apps/web/src/shared/components/FlowchartEditor/index.vue +++ b/apps/web/src/shared/components/FlowchartEditor/index.vue @@ -30,8 +30,14 @@ import { layoutDoc, PLACEHOLDER, type LaidNode, type LaidSlot } from "./layout" interface Props { height?: string + /** + * 题目页(默认):每道题一份本地草稿,自动存。 + * false:图交给外面管(后台出题页的参考流程图,v-model:value),不存草稿 + */ + draft?: boolean } -const { height = "calc(100vh - 134px)" } = defineProps() +const { height = "calc(100vh - 134px)", draft = true } = defineProps() +const model = defineModel("value", { default: null }) const message = useMessage() const theme = useThemeVars() @@ -98,6 +104,13 @@ const cacheKey = computed(() => { const savedAt = ref(null) function loadDraft() { + if (!draft) { + doc.value = model.value ? cloneDoc(model.value) : emptyDoc() + deselect() + resetHistory() + fitSoon() + return + } let saved: unknown = null try { saved = storage.get(cacheKey.value) @@ -135,12 +148,20 @@ watch( doc, () => { if (loading) return - saveDraft(cacheKey.value) + if (draft) saveDraft(cacheKey.value) + else model.value = cloneDoc(doc.value) }, { deep: true }, ) +/** 外面换了一张图(后台点了「AI 照答案画一张」):摆上来,进历史,Ctrl+Z 能回去 */ +watch(model, (next) => { + if (draft || !next || signature(next) === signature(doc.value)) return + setDoc(next) +}) + watch(cacheKey, () => { + if (!draft) return loading = true loadDraft() nextTick(() => (loading = false)) diff --git a/apps/web/src/utils/types.ts b/apps/web/src/utils/types.ts index 3c7b709..5d898da 100644 --- a/apps/web/src/utils/types.ts +++ b/apps/web/src/utils/types.ts @@ -143,7 +143,10 @@ type ExcludeKeys = | "isPublic" | "contestId" -export type BlankProblem = Omit & { +export type BlankProblem = Omit< + AdminProblem, + ExcludeKeys | "hint" | "mermaidCode" | "flowchartData" +> & { id?: number // 新建比赛题时由 detail.vue 在提交前写进来 contestId?: number | null @@ -151,6 +154,8 @@ export type BlankProblem = Omit | null } export interface ProblemRow { diff --git a/packages/contract/src/admin.ts b/packages/contract/src/admin.ts index a3fb64a..d8cd2e5 100644 --- a/packages/contract/src/admin.ts +++ b/packages/contract/src/admin.ts @@ -675,8 +675,9 @@ export const generateFlowchartRequestSchema = z.object({ .min(1) .max(64 * 1024), }) +/** AI 照 Python 答案画的参考流程图:结构树(FlowDoc),后台画布直接摆上去接着改 */ export const generateFlowchartResponseSchema = z.object({ - flowchart: z.string(), + doc: z.record(z.string(), z.unknown()), }) // ---------------------------------------------------------------- 题目管理 @@ -771,6 +772,8 @@ export const adminProblemSchema = z.object({ allowFlowchart: z.boolean(), showFlowchart: z.boolean(), mermaidCode: z.string().nullable(), + /** 参考流程图的结构树(FlowDoc);2026-10 之前出的题只有 mermaidCode,这里是空对象 */ + flowchartData: z.record(z.string(), z.unknown()), flowchartHint: z.string().nullable(), astRules: astRulesSchema.nullable(), answers: z.array(problemAnswerSchema), @@ -823,6 +826,11 @@ export const createProblemRequestSchema = z.object({ allowFlowchart: z.boolean().default(false), showFlowchart: z.boolean().default(false), mermaidCode: z.string().nullable().default(null), + /** + * 用画布画的参考流程图(FlowDoc)。给了就以它为准,mermaidCode 由后端照它重新生成; + * 不给(老题只有手写的 Mermaid)就原样存 mermaidCode + */ + flowchartData: z.record(z.string(), z.unknown()).nullable().default(null), flowchartHint: z.string().nullable().default(null), astRules: astRulesSchema.nullable().default(null), // order_sensitive 缺省补 false —— 与旧后端 SQLConfigSerializer 的