Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e946c99fb | ||
|
|
b9ece7d38f | ||
|
|
04c90e3f89 | ||
|
|
6b7d87d677 |
No files matched your search
@@ -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<string, unknown> | null, mermaidCode: string | null) {
|
||||
const doc = parseFlowDoc(raw)
|
||||
return doc && doc.steps.length
|
||||
? { flowchartData: doc, mermaidCode: flowToMermaid(doc) }
|
||||
: { flowchartData: {}, mermaidCode }
|
||||
}
|
||||
|
||||
function problemValues(data: ReturnType<typeof createProblemRequestSchema.parse>, isSql: boolean) {
|
||||
return {
|
||||
displayId: data._id,
|
||||
@@ -296,7 +310,7 @@ function problemValues(data: ReturnType<typeof createProblemRequestSchema.parse>
|
||||
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,
|
||||
|
||||
@@ -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<AppEnv>()
|
||||
@@ -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", "生成失败,请稍后再试")
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
createFlowchartRequestSchema,
|
||||
isFlowchartPass,
|
||||
type CreateFlowchartResponse,
|
||||
type FlowchartCurrent,
|
||||
type FlowchartDetail,
|
||||
type FlowchartScores,
|
||||
type FlowchartList,
|
||||
type FlowchartListItem,
|
||||
@@ -612,6 +610,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
personCount: roster.length,
|
||||
completedCount: 0,
|
||||
wordFrequencies: [],
|
||||
commonMistakes: [],
|
||||
// 一条提交都没有时,花名册上的人**全都**是「没做」—— 原来这里写死空数组,
|
||||
// 于是一节课刚开始、最该点名的时候,教师面板反而一个名字都不给
|
||||
dataUnaccepted: roster.map((row) => ({
|
||||
@@ -647,8 +646,16 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
const pushText = (value: string) => {
|
||||
if (texts.length < WORDCLOUD_TEXT_LIMIT) texts.push(value)
|
||||
}
|
||||
const mistakes = new Map<string, number>()
|
||||
for (const row of textRows) {
|
||||
for (const [key, value] of Object.entries(asRecord(row.criteria))) {
|
||||
const report = asRecord(row.criteria)
|
||||
if (report.version === 2 && Array.isArray(report.cases)) {
|
||||
const first = report.cases.map((item) => asRecord(item)).find((item) => item.ok === false)
|
||||
if (typeof first?.why === "string" && first.why) {
|
||||
mistakes.set(first.why, (mistakes.get(first.why) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(report)) {
|
||||
const detail = asRecord(value)
|
||||
// 和上面那条聚合同一道闸:分数不是数字的项当没配过,满分和评语也都不收
|
||||
if (typeof detail.score !== "number") continue
|
||||
@@ -678,6 +685,10 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||
personCount: roster.length,
|
||||
completedCount: totals.completedCount,
|
||||
wordFrequencies: await buildWordFrequencies(texts),
|
||||
commonMistakes: [...mistakes]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 8)
|
||||
.map(([text, n]) => ({ text, count: n })),
|
||||
dataUnaccepted: roster
|
||||
.filter((row) => !submitted.has(row.id))
|
||||
.map((row) => ({
|
||||
@@ -777,10 +788,10 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
|
||||
|
||||
/**
|
||||
* 自己在这道题上评完的每一次,按时间从早到晚,已经滤掉加入题单之前的(题单闸门)。
|
||||
* current / history / scores 三个接口都从这一份出。
|
||||
* 结果页签的历次分数(scores)从这一份出。
|
||||
*
|
||||
* 只取分数和闸门要的几列:整行带着画布 JSON 和 mermaid 源码(一条约 5KB),画了几十次
|
||||
* 的学生每切一次题就要搬几百 KB,而结果页签只要分数。要整行的 history 自己按 id 再取。
|
||||
* 的学生每切一次题就要搬几百 KB,而结果页签只要分数。
|
||||
* userId / problemId / createTime 三列是 hiddenByProblemSet 要的,别删。
|
||||
*/
|
||||
async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) {
|
||||
@@ -809,46 +820,9 @@ async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) {
|
||||
return { visible, hidden: rows.length - visible.length }
|
||||
}
|
||||
|
||||
/*
|
||||
* current / history:前端从 2026-09-28 起改用下面的 scores + GET /flowcharts/:id,不再调这两个。
|
||||
* 先留着给上线那一刻还开着旧页面的浏览器(机房里一节课都不刷新是常态),下一次清理时删掉,
|
||||
* 连同契约里的 flowchartCurrentSchema / flowchartDetailSchema。
|
||||
*/
|
||||
flowchartRoutes.get("/problems/:id/flowchart/current", requireAuth, async (c) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const { visible } = await myEvaluatedFlowcharts(c.get("user")!, problemId)
|
||||
const latest = visible.at(-1)
|
||||
return success(c, {
|
||||
count: visible.length,
|
||||
score: latest?.aiScore ?? 0,
|
||||
grade: latest?.aiGrade ?? "",
|
||||
} satisfies FlowchartCurrent)
|
||||
})
|
||||
|
||||
flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const page = queryInteger(c.req.query("page"), 0, { min: 0 })
|
||||
const user = c.get("user")!
|
||||
const { visible: rows } = await myEvaluatedFlowcharts(user, problemId)
|
||||
const selected = page === 0 ? rows.at(-1) : rows[page - 1]
|
||||
if (page > rows.length) return failure(c, 400, "page-out-of-range", "Page out of range")
|
||||
// 列表只带了分数,选中的那一条再按 id 取整行。都是本人的提交,用户名就是自己的
|
||||
const [full] = selected
|
||||
? await db
|
||||
.select()
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(eq(schema.flowchartSubmission.id, selected.id))
|
||||
.limit(1)
|
||||
: []
|
||||
return success(c, {
|
||||
submission: full ? flowchartData(full, user.username) : null,
|
||||
count: rows.length,
|
||||
} satisfies FlowchartDetail)
|
||||
})
|
||||
|
||||
/**
|
||||
* 历次分数:结果页签上那一排「第 N 次 xx 分」。原来 history 一次只给一条,
|
||||
* 要看分数走势得一页一页翻。`hidden` 是被题单闸门藏起来的次数,界面上给一句说明。
|
||||
* 历次分数:结果页签上那一排「C → B → A」。原来的 history 一次只给一条,
|
||||
* 要看走势得一页一页翻(它和 current 已经删了)。`hidden` 是被题单闸门藏起来的次数,界面上给一句说明。
|
||||
*/
|
||||
flowchartRoutes.get("/problems/:id/flowchart/scores", requireAuth, async (c) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
|
||||
@@ -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<string, unknown> }>("admin/problems/flowchart", {
|
||||
python,
|
||||
})
|
||||
}
|
||||
@@ -276,7 +276,9 @@ export function generateTestInputs(data: GenerateTestInputsRequest) {
|
||||
|
||||
/** 出站补默认值。字段名两边已经一致,不再做键名转换 */
|
||||
function toProblemBody(problem: AdminProblem | BlankProblem) {
|
||||
const p = problem as Partial<AdminProblem>
|
||||
const p = problem as Partial<Omit<AdminProblem, "flowchartData">> & {
|
||||
flowchartData?: Record<string, unknown> | 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,
|
||||
|
||||
@@ -34,7 +34,6 @@ const props = defineProps<{
|
||||
astChecks: AstCheckState
|
||||
flowchartMode: FlowchartMode
|
||||
mermaidCode: string
|
||||
flowchartHint: string
|
||||
}>()
|
||||
|
||||
const tab = defineModel<PreviewTab>("tab", { required: true })
|
||||
@@ -248,18 +247,15 @@ onMounted(() => nextTick(render))
|
||||
<!-- 流程图:一直挂着,改了图的代码随时知道画不画得出来 -->
|
||||
<div v-show="tab === 'flowchart'" class="page">
|
||||
<p v-if="flowchartMode === 'none'" class="placeholder">这道题不用流程图</p>
|
||||
<p v-else-if="flowchartMode === 'draw'" class="muted small">
|
||||
学生自己画,这张图<b>学生看不到</b>,只拿来给学生画的图打分。
|
||||
<template v-if="flowchartHint.trim()">学生看到的提示:{{ flowchartHint }}</template>
|
||||
<p v-else-if="flowchartMode === 'draw'" class="placeholder">
|
||||
学生在题目页右边自己画,交上去按例子和测试数据评
|
||||
</p>
|
||||
<p v-else class="muted small">学生在题面里点开「老师给的参考流程图」看到的是:</p>
|
||||
<p v-if="flowchartMode !== 'none' && !mermaidCode.trim()" class="placeholder">
|
||||
还没写图的代码
|
||||
</p>
|
||||
<div v-if="renderError && flowchartMode !== 'none'" class="chartError">
|
||||
画不出来:{{ renderError }}
|
||||
</div>
|
||||
<div v-show="flowchartMode !== 'none'" ref="chart" class="chart"></div>
|
||||
<template v-else>
|
||||
<p class="muted small">学生在题面里点开「老师给的参考流程图」看到的是:</p>
|
||||
<p v-if="!mermaidCode.trim()" class="placeholder">还没画</p>
|
||||
<div v-if="renderError" class="chartError">画不出来:{{ renderError }}</div>
|
||||
</template>
|
||||
<div v-show="flowchartMode === 'show'" ref="chart" class="chart"></div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -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<FlowchartMode, string> = {
|
||||
}
|
||||
|
||||
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<Blocker[]>(() => {
|
||||
})
|
||||
})
|
||||
}
|
||||
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 () => {
|
||||
<label class="mode" :class="{ on: form.flowchartMode === 'draw' }">
|
||||
<n-radio value="draw" />
|
||||
<span>
|
||||
<b>让学生自己画,按这张图打分</b>
|
||||
<small>这张图学生看不到,只拿来给学生画的图打分;画到 A / S 级算做完</small>
|
||||
<b>让学生自己画</b>
|
||||
<small>按例子和测试数据评;画到 A / S 级算做完</small>
|
||||
</span>
|
||||
</label>
|
||||
</n-radio-group>
|
||||
<template v-if="form.flowchartMode !== 'none'">
|
||||
<!-- 学生画:按例子和测试数据评(拼成程序跑),不再需要参考图 -->
|
||||
<n-text v-if="form.flowchartMode === 'draw'" depth="3" class="note">
|
||||
学生画的图会拼成程序,拿这道题的例子和测试数据跑一遍,逐组看输出对不对,不用准备参考图。
|
||||
</n-text>
|
||||
<template v-if="form.flowchartMode === 'show'">
|
||||
<div class="inlineRow">
|
||||
<span class="label">图的代码(Mermaid)</span>
|
||||
<span class="label">{{ legacyMermaid ? "图的代码(Mermaid)" : "参考流程图" }}</span>
|
||||
<span class="grow"></span>
|
||||
<n-button
|
||||
v-if="legacyMermaid"
|
||||
size="tiny"
|
||||
quaternary
|
||||
@click="form.flowchartDoc = emptyDoc()"
|
||||
>
|
||||
改用画布画
|
||||
</n-button>
|
||||
<n-button
|
||||
size="tiny"
|
||||
:loading="aiDrawing"
|
||||
@@ -1215,25 +1264,20 @@ onMounted(async () => {
|
||||
AI 照 Python 答案画一张
|
||||
</n-button>
|
||||
</div>
|
||||
<n-input
|
||||
v-model:value="form.mermaidCode"
|
||||
type="textarea"
|
||||
class="mono"
|
||||
:autosize="{ minRows: 6, maxRows: 18 }"
|
||||
placeholder="graph TD A[开始] --> B[输入 n]"
|
||||
/>
|
||||
<n-text
|
||||
v-if="form.mermaidCode.trim()"
|
||||
:type="mermaidOk ? 'success' : 'error'"
|
||||
class="note"
|
||||
>
|
||||
{{ mermaidOk ? "✓ 画得出来(右边就是)" : "✗ 还画不出来,看右边的报错" }}
|
||||
</n-text>
|
||||
<n-input
|
||||
v-if="form.flowchartMode === 'draw'"
|
||||
v-model:value="form.flowchartHint"
|
||||
placeholder="给学生的提示(选填),比如:先判断门是不是开着"
|
||||
/>
|
||||
<template v-if="legacyMermaid">
|
||||
<n-input
|
||||
v-model:value="form.mermaidCode"
|
||||
type="textarea"
|
||||
class="mono"
|
||||
:autosize="{ minRows: 6, maxRows: 18 }"
|
||||
/>
|
||||
<n-text :type="mermaidOk ? 'success' : 'error'" class="note">
|
||||
{{ mermaidOk ? "✓ 画得出来(右边就是)" : "✗ 还画不出来,看右边的报错" }}
|
||||
</n-text>
|
||||
</template>
|
||||
<div v-else class="flowCanvas">
|
||||
<FlowchartEditor v-model:value="form.flowchartDoc" :draft="false" height="460px" />
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
@@ -206,13 +206,13 @@ const changeLanguage = (v: LANGUAGE) => {
|
||||
运行例子
|
||||
</n-button>
|
||||
|
||||
<SubmitFlowchart v-if="codeStore.code.language === 'Flowchart'" />
|
||||
<SubmitFlowchart v-if="codeStore.code.language === 'Flowchart' && isDesktop" />
|
||||
|
||||
<SubmitCode v-else-if="isDesktop" />
|
||||
|
||||
<div class="spacer" />
|
||||
|
||||
<FlowchartControls v-if="drawing && isDesktop" />
|
||||
<FlowchartControls v-if="drawing" />
|
||||
|
||||
<n-button
|
||||
v-if="showHelpButton"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { storeToRefs } from "pinia"
|
||||
import { useFlowchartStore } from "oj/store/flowchart"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { parseTime } from "utils/functions"
|
||||
|
||||
/**
|
||||
@@ -11,6 +12,8 @@ import { parseTime } from "utils/functions"
|
||||
*/
|
||||
const { editor, unchanged } = storeToRefs(useFlowchartStore())
|
||||
const theme = useThemeVars()
|
||||
// 手机上工具栏挤,「已保存」那几个字不放
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const saved = computed(() => {
|
||||
// 「交给 AI 点评」灰着的时候说一句为什么(设计稿 FlowResPass)
|
||||
@@ -22,7 +25,7 @@ const saved = computed(() => {
|
||||
|
||||
<template>
|
||||
<div v-if="editor" class="flowchart-controls">
|
||||
<span v-if="saved" class="saved">{{ saved }}</span>
|
||||
<span v-if="saved && isDesktop" class="saved">{{ saved }}</span>
|
||||
<n-button
|
||||
:disabled="!editor.canUndo"
|
||||
aria-label="撤销"
|
||||
|
||||
@@ -17,9 +17,7 @@ import { caseHeadline, caseReport } from "utils/flowchart"
|
||||
* 原来评分详情是提交按钮旁一个 1000px 的弹框:盖住画布,学生没法一边看改进建议一边改图;
|
||||
* 翻历次评分要一页一页点分页器。现在摆在左栏,画布整列都在右边(设计文档第 6 节)。
|
||||
*
|
||||
* viewOnly:手机上只能看、不能画(设计文档 5.6),不给「照着它写代码」「载回画布」。
|
||||
*/
|
||||
const { viewOnly = false } = defineProps<{ viewOnly?: boolean }>()
|
||||
|
||||
const MyFlowchart = defineAsyncComponent(() => import("./MyFlowchart.vue"))
|
||||
|
||||
@@ -154,7 +152,7 @@ watch(
|
||||
watch(
|
||||
() => [selectedCase.value, report.value, canvasSame.value, editor.value] as const,
|
||||
([index, next, same, instance]) => {
|
||||
if (!instance || viewOnly) return
|
||||
if (!instance) return
|
||||
const row = index === null ? undefined : next?.cases[index]
|
||||
if (!row || !same || !versionDoc.value) {
|
||||
instance.setHighlight(null)
|
||||
@@ -189,11 +187,7 @@ function goCode() {
|
||||
<n-empty
|
||||
v-else-if="!scores.length && phase !== 'evaluating'"
|
||||
class="empty"
|
||||
:description="
|
||||
viewOnly
|
||||
? '还没交过流程图。流程图要在电脑上画(手机上拖不动),交给 AI 点评之后,评分和建议在这里也看得到'
|
||||
: '还没交过流程图。画好之后点「交给 AI 点评」,评分和建议会出现在这里'
|
||||
"
|
||||
description="还没交过流程图。画好之后点「交给 AI 点评」,评分和建议会出现在这里"
|
||||
/>
|
||||
|
||||
<!-- 历次分数连成一条:一眼看出是不是越改越好;正在评的那次是虚线框 -->
|
||||
@@ -273,7 +267,7 @@ function goCode() {
|
||||
</template>
|
||||
|
||||
<template v-if="detail">
|
||||
<div v-if="detail.mermaidCode && (!report || canvasDiffers || viewOnly)" class="version">
|
||||
<div v-if="detail.mermaidCode && (!report || canvasDiffers)" class="version">
|
||||
<div class="version-head">
|
||||
<span class="version-title">{{ versionTitle }}</span>
|
||||
<n-button text type="primary" size="small" @click="versionZoom = true">
|
||||
@@ -327,7 +321,7 @@ function goCode() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!viewOnly" class="actions">
|
||||
<div class="actions">
|
||||
<n-button
|
||||
v-if="isFlowchartPass(current.grade) && canCode"
|
||||
type="primary"
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useProblemStore } from "oj/store/problem"
|
||||
import { useSubmissionStore } from "oj/store/submission"
|
||||
import SubmitCode from "./SubmitCode.vue"
|
||||
|
||||
const SubmitFlowchart = defineAsyncComponent(() => import("./SubmitFlowchart.vue"))
|
||||
|
||||
/**
|
||||
* 手机上屏幕底部固定的「运行例子 / 提交」(设计文档 5.6)。原来这两个按钮在「代码」页签
|
||||
* 顶上,和语言、更多挤成两行;看着题面想交一次,还得先切回代码页签。
|
||||
@@ -41,7 +43,8 @@ const canRunSamples = computed(
|
||||
运行例子
|
||||
</n-button>
|
||||
<div class="action">
|
||||
<SubmitCode size="large" block />
|
||||
<SubmitFlowchart v-if="codeStore.code.language === 'Flowchart'" size="large" block />
|
||||
<SubmitCode v-else size="large" block />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -191,13 +191,12 @@ watch(
|
||||
)
|
||||
|
||||
// 流程图编辑器交给 store:结果页签(在左栏,不在编辑器底下)要拿当前画布比一比、
|
||||
// 把旧版本载回去。子组件 SubmitFlowchart 还是从 inject 拿
|
||||
// 把旧版本载回去;「交给 AI 点评」在手机上挂在屏幕底部那条,也从 store 拿
|
||||
const flowchartStore = useFlowchartStore()
|
||||
watch(flowchartEditorRef, (editor) => flowchartStore.attachEditor(editor ?? null), {
|
||||
immediate: true,
|
||||
})
|
||||
onBeforeUnmount(() => flowchartStore.attachEditor(null))
|
||||
provide("flowchartEditorRef", flowchartEditorRef)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -5,8 +5,6 @@ import { useProblemStore } from "oj/store/problem"
|
||||
import { useSubmissionStore } from "oj/store/submission"
|
||||
import { useCodeStore } from "oj/store/code"
|
||||
import { useTeacherCollab } from "../composables/teacherCollab"
|
||||
import { useProblemPageContext } from "../composables/problemPageContext"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { parseTime } from "utils/functions"
|
||||
import ResultHeader from "./ResultHeader.vue"
|
||||
@@ -33,8 +31,6 @@ const { submission, syntaxErrorInfo, resultSegment, formattedBeforeSubmit } =
|
||||
storeToRefs(submissionStore)
|
||||
const { problem } = storeToRefs(useProblemStore())
|
||||
const codeStore = useCodeStore()
|
||||
const ctx = useProblemPageContext()
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
/** 协作中的老师自己还没交过:先摆学生最近一次交的 */
|
||||
const teacherCollab = useTeacherCollab()
|
||||
@@ -42,24 +38,7 @@ const teacherCollab = useTeacherCollab()
|
||||
const theme = useThemeVars()
|
||||
const { samplesRunAt } = submissionStore.trial
|
||||
|
||||
/**
|
||||
* 手机上流程图题只能看点评、不能画(设计文档 5.6)。画不了,语言也就落不到 Flowchart 上,
|
||||
* 下面的 drawing 永远是假 —— 原来学生在电脑上画过、AI 评过的,手机上一条都看不到。
|
||||
* 这时多给一段「流程图点评」,只读。
|
||||
*/
|
||||
const flowchartReadable = computed(
|
||||
() => !isDesktop.value && !!problem.value?.allowFlowchart && ctx.value.entry !== "contest",
|
||||
)
|
||||
/** 在看「流程图点评」那一段。只在这一页记着:点运行例子、提交时照常切回对应的分段 */
|
||||
const viewingFlowchart = ref(false)
|
||||
watch(
|
||||
() => [submissionStore.resultSeq, problem.value?.id, flowchartReadable.value],
|
||||
() => {
|
||||
viewingFlowchart.value = false
|
||||
},
|
||||
)
|
||||
|
||||
type Segment = "submit" | "samples" | "custom" | "flowchart"
|
||||
type Segment = "submit" | "samples" | "custom"
|
||||
const codeSegments = computed<{ value: Segment; label: string; time: string }[]>(() => [
|
||||
{
|
||||
value: "submit",
|
||||
@@ -84,25 +63,16 @@ const drawing = computed(() => codeStore.code.language === "Flowchart")
|
||||
// 试跑走判题机,SQL 题不走那里;流程图也没得跑。这两种只留「提交结果」
|
||||
const canTrial = computed(() => !problem.value || !isSqlProblem(problem.value))
|
||||
|
||||
const segments = computed(() => {
|
||||
const list = canTrial.value ? codeSegments.value : codeSegments.value.slice(0, 1)
|
||||
return flowchartReadable.value
|
||||
? [...list, { value: "flowchart" as const, label: "流程图点评", time: "" }]
|
||||
: list
|
||||
})
|
||||
/** 只有一段(SQL 题、又不是手机上的流程图题)就不摆分段了 */
|
||||
const segments = computed(() =>
|
||||
canTrial.value ? codeSegments.value : codeSegments.value.slice(0, 1),
|
||||
)
|
||||
/** 只有一段(SQL 题)就不摆分段了 */
|
||||
const showSegments = computed(() => segments.value.length > 1)
|
||||
|
||||
function isActive(segment: Segment) {
|
||||
if (viewingFlowchart.value) return segment === "flowchart"
|
||||
return resultSegment.value === segment
|
||||
}
|
||||
function pick(segment: Segment) {
|
||||
if (segment === "flowchart") {
|
||||
viewingFlowchart.value = true
|
||||
return
|
||||
}
|
||||
viewingFlowchart.value = false
|
||||
resultSegment.value = segment
|
||||
}
|
||||
</script>
|
||||
@@ -124,14 +94,13 @@ function pick(segment: Segment) {
|
||||
>
|
||||
{{ segment.label }}<template v-if="segment.time"> · {{ segment.time }}</template>
|
||||
</button>
|
||||
<span v-if="!viewingFlowchart && resultSegment === 'custom'" class="segment-note"
|
||||
<span v-if="resultSegment === 'custom'" class="segment-note"
|
||||
>自己编数据试试,这里不判对错</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<FlowchartResult v-if="viewingFlowchart && flowchartReadable" view-only />
|
||||
<!-- 提交结果用 v-show:切去看运行例子时不卸载,挂着的错误说明在编辑器里标着红 -->
|
||||
<div v-show="!viewingFlowchart && (resultSegment === 'submit' || !canTrial)">
|
||||
<div v-show="resultSegment === 'submit' || !canTrial">
|
||||
<template v-if="syntaxErrorInfo">
|
||||
<ResultHeader kind="warning" title="代码有语法错误,还没有交上去" />
|
||||
<CompileErrorExplain :err-info="syntaxErrorInfo" />
|
||||
@@ -149,7 +118,7 @@ function pick(segment: Segment) {
|
||||
description="还没有提交过。写完代码按「提交」,结果会出现在这里"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="canTrial && !viewingFlowchart">
|
||||
<template v-if="canTrial">
|
||||
<SampleRunResult v-if="resultSegment === 'samples'" />
|
||||
<CustomRun v-else-if="resultSegment === 'custom'" />
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { storeToRefs } from "pinia"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { countBlank } from "shared/components/FlowchartEditor/model"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { useFlowchartStore, type FlowchartEditorInstance } from "oj/store/flowchart"
|
||||
import { useFlowchartStore } from "oj/store/flowchart"
|
||||
|
||||
/**
|
||||
* 「交给 AI 点评」按钮。
|
||||
@@ -13,16 +13,20 @@ import { useFlowchartStore, type FlowchartEditorInstance } from "oj/store/flowch
|
||||
* 弹框盖住画布,学生没法一边看改进建议一边改图。
|
||||
*/
|
||||
|
||||
const flowchartEditorRef = inject<Ref<FlowchartEditorInstance | null>>("flowchartEditorRef")
|
||||
/** 手机上它在屏幕底部那条(MobileActionBar),不在编辑器里面,所以画布从 store 拿 */
|
||||
const { size, block = false } = defineProps<{
|
||||
size?: "small" | "medium" | "large"
|
||||
block?: boolean
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
const { problem } = storeToRefs(useProblemStore())
|
||||
const flowchartStore = useFlowchartStore()
|
||||
const { loading, unchanged } = storeToRefs(flowchartStore)
|
||||
const { loading, unchanged, editor: canvas } = storeToRefs(flowchartStore)
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
function submit() {
|
||||
const editor = flowchartEditorRef?.value
|
||||
const editor = canvas.value
|
||||
if (!editor) return
|
||||
const doc = editor.getDoc()
|
||||
if (!doc.steps.length) {
|
||||
@@ -44,7 +48,8 @@ watch(() => problem.value?.id, flowchartStore.ensureLoaded, { immediate: true })
|
||||
|
||||
<template>
|
||||
<n-button
|
||||
:size="isDesktop ? 'medium' : 'small'"
|
||||
:size="size ?? (isDesktop ? 'medium' : 'small')"
|
||||
:block="block"
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
:disabled="loading || unchanged"
|
||||
|
||||
@@ -200,13 +200,11 @@ const people = computed(() => [
|
||||
...props.data.dataUnaccepted.map((row) => ({
|
||||
username: row.username,
|
||||
grade: null as string | null,
|
||||
score: null as number | null,
|
||||
count: 0,
|
||||
})),
|
||||
...props.data.people.map((row) => ({
|
||||
username: row.username,
|
||||
grade: row.bestGrade,
|
||||
score: row.bestScore,
|
||||
count: row.count,
|
||||
})),
|
||||
])
|
||||
@@ -229,7 +227,6 @@ function gradeStyle(grade: string | null) {
|
||||
<div class="flow-stats">
|
||||
<div class="summary">
|
||||
<StatItem label="提交" :value="data.totalCount" unit="张" />
|
||||
<StatItem label="平均分" :value="data.avgScore" />
|
||||
<StatItem
|
||||
label="拿到 A / S"
|
||||
:value="`${goodCount} / ${personCount}`"
|
||||
@@ -267,31 +264,54 @@ function gradeStyle(grade: string | null) {
|
||||
<Doughnut :key="chartKey" :data="gradeData" :options="gradeOptions" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="criteria.length" class="card radar">
|
||||
<div class="card-title">四项评分(平均得分率)</div>
|
||||
<div class="chart">
|
||||
<Radar :key="chartKey" :data="radarData" :options="radarOptions" />
|
||||
<template v-if="criteria.length">
|
||||
<div class="card radar">
|
||||
<div class="card-title">四项评分(平均得分率)</div>
|
||||
<div class="chart">
|
||||
<Radar :key="chartKey" :data="radarData" :options="radarOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="criteria.length" class="card grow">
|
||||
<div class="card-title">各项平均分</div>
|
||||
<div class="chart"><Bar :key="chartKey" :data="barData" :options="barOptions" /></div>
|
||||
<div class="card grow">
|
||||
<div class="card-title">各项平均分</div>
|
||||
<div class="chart"><Bar :key="chartKey" :data="barData" :options="barOptions" /></div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 全是 2026-10 之后「走例子」评的图:没有四项分,这个位置给最常见的错 -->
|
||||
<div v-else class="card grow">
|
||||
<div class="card-title">最常见的错(最近交的图里)</div>
|
||||
<ol v-if="data.commonMistakes.length" class="mistakes">
|
||||
<li v-for="item in data.commonMistakes" :key="item.text">
|
||||
<span class="mistake-text">{{ item.text }}</span>
|
||||
<span class="muted">{{ item.count }} 张</span>
|
||||
</li>
|
||||
</ol>
|
||||
<div v-else class="muted">交上来的图都走对了</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-cards bottom">
|
||||
<div class="card grow">
|
||||
<!-- 新老评法混着:有走例子的错就列错,只有老数据时照旧是词云 -->
|
||||
<div v-if="criteria.length && data.commonMistakes.length" class="card grow">
|
||||
<div class="card-title">最常见的错(最近交的图里)</div>
|
||||
<ol class="mistakes">
|
||||
<li v-for="item in data.commonMistakes" :key="item.text">
|
||||
<span class="mistake-text">{{ item.text }}</span>
|
||||
<span class="muted">{{ item.count }} 张</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div v-else-if="criteria.length" class="card grow">
|
||||
<div class="card-title">常见问题高频词(最近的评语里)</div>
|
||||
<div class="cloud"><canvas ref="cloudCanvas"></canvas></div>
|
||||
<div v-if="!data.wordFrequencies.length" class="muted">评语太少,还拼不出词云</div>
|
||||
</div>
|
||||
<div class="card people">
|
||||
<div class="card people" :class="{ wide: !criteria.length }">
|
||||
<div class="card-title">每人最好的一次(没交的在前)</div>
|
||||
<div v-if="data.peopleTruncated" class="muted">人太多,只列了成绩靠后的 500 位</div>
|
||||
<div class="people-list">
|
||||
<div v-for="row in people" :key="row.username" class="person">
|
||||
<UserName :username="row.username" class="p-name" />
|
||||
<span class="grade-pill" :style="gradeStyle(row.grade)">
|
||||
{{ row.grade ? `${row.grade} 级 ${row.score ?? 0} 分` : "没交" }}
|
||||
{{ row.grade ? `${row.grade} 级` : "没交" }}
|
||||
</span>
|
||||
<span class="muted">{{ row.count }} 次</span>
|
||||
</div>
|
||||
@@ -489,4 +509,35 @@ function gradeStyle(grade: string | null) {
|
||||
max-height: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
.mistakes {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.mistakes li {
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.mistake-text {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* 没有词云、四项分的时候,每人一格铺满一行 */
|
||||
.card.people.wide {
|
||||
flex: 1 1 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.card.people.wide .people-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
align-content: start;
|
||||
column-gap: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,4 @@
|
||||
import { defineStore } from "pinia"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import type { LANGUAGE, ProblemDetail } from "utils/types"
|
||||
import { problemEntryOf } from "oj/problem/composables/problemPageContext"
|
||||
import { useCodeStore } from "oj/store/code"
|
||||
@@ -28,14 +27,13 @@ export const useProblemStore = defineStore("problem", () => {
|
||||
*/
|
||||
const failCount = computed(() => (problem.value?.myFailedCount ?? 0) + sessionFailCount.value)
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
/**
|
||||
* 这道题能不能画流程图。比赛里不给(入口差异表);题单里原来也不给,现在给了,画到 A/S
|
||||
* 算完成(设计文档第 3 节决定 4)。手机上不给:节点库是 HTML5 拖放,手机上拖不动。
|
||||
* 算完成(设计文档第 3 节决定 4)。手机上原来不给(节点库是 HTML5 拖放,拖不动),
|
||||
* 2026-10 换成插入式编辑器之后只用点,手机上也能画
|
||||
*/
|
||||
const canDraw = computed(
|
||||
() => !!problem.value?.allowFlowchart && problemEntryOf(route) !== "contest" && isDesktop.value,
|
||||
() => !!problem.value?.allowFlowchart && problemEntryOf(route) !== "contest",
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Props>()
|
||||
const { height = "calc(100vh - 134px)", draft = true } = defineProps<Props>()
|
||||
const model = defineModel<FlowDoc | null>("value", { default: null })
|
||||
|
||||
const message = useMessage()
|
||||
const theme = useThemeVars()
|
||||
@@ -98,6 +104,13 @@ const cacheKey = computed(() => {
|
||||
const savedAt = ref<Date | null>(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))
|
||||
|
||||
@@ -143,7 +143,10 @@ type ExcludeKeys =
|
||||
| "isPublic"
|
||||
| "contestId"
|
||||
|
||||
export type BlankProblem = Omit<AdminProblem, ExcludeKeys | "hint" | "mermaidCode"> & {
|
||||
export type BlankProblem = Omit<
|
||||
AdminProblem,
|
||||
ExcludeKeys | "hint" | "mermaidCode" | "flowchartData"
|
||||
> & {
|
||||
id?: number
|
||||
// 新建比赛题时由 detail.vue 在提交前写进来
|
||||
contestId?: number | null
|
||||
@@ -151,6 +154,8 @@ export type BlankProblem = Omit<AdminProblem, ExcludeKeys | "hint" | "mermaidCod
|
||||
// v-model 要的是 lvalue,模板里没法 ?? 兜底,所以在类型上就收掉 null
|
||||
hint: string
|
||||
mermaidCode: string
|
||||
/** 画布画的参考流程图(FlowDoc);不给就照存 mermaidCode(老题手写的 Mermaid) */
|
||||
flowchartData?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface ProblemRow {
|
||||
|
||||
@@ -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 的
|
||||
|
||||
@@ -66,16 +66,6 @@ export const createFlowchartResponseSchema = z.object({
|
||||
submissionId: z.string(),
|
||||
status: z.literal("pending"),
|
||||
})
|
||||
export const flowchartCurrentSchema = z.object({
|
||||
count: z.number().int(),
|
||||
score: z.number(),
|
||||
grade: z.string(),
|
||||
})
|
||||
export const flowchartDetailSchema = z.object({
|
||||
submission: flowchartSubmissionSchema.nullable(),
|
||||
count: z.number().int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* 流程图画到这两档算这道题做完:题单进度、课堂条、课堂看板、「你画的流程图」、结果页签的 ✓
|
||||
* 都按它(设计文档 2026-09-28-problem-page-redesign 第 3 节决定 4、6)。前后端只有这一份
|
||||
@@ -107,6 +97,12 @@ export const flowchartStatisticsSchema = z.object({
|
||||
personCount: z.number().int(),
|
||||
completedCount: z.number().int(),
|
||||
wordFrequencies: z.array(z.object({ word: z.string(), count: z.number().int() })),
|
||||
/**
|
||||
* 2026-10 起「走例子」评的图里最常见的错:每张图取第一组没走对的原因(「『一级果』多输出了
|
||||
* 一行『一级果』」),同一句话数一下,最多 8 条。新评法几乎没有评语,词云拼不出来,
|
||||
* 而同一个错在一个班里往往原样出现几十次 —— 老师要看的就是它
|
||||
*/
|
||||
commonMistakes: z.array(z.object({ text: z.string(), count: z.number().int() })),
|
||||
// 与提交统计共用「未完成学生」的形状,见 submission.ts 的 unacceptedStudentSchema
|
||||
dataUnaccepted: z.array(z.object({ username: z.string(), realName: z.string() })),
|
||||
/**
|
||||
@@ -177,8 +173,6 @@ export type FlowchartStatistics = z.infer<typeof flowchartStatisticsSchema>
|
||||
export type FlowchartSubmission = z.infer<typeof flowchartSubmissionSchema>
|
||||
export type FlowchartListItem = z.infer<typeof flowchartListItemSchema>
|
||||
export type FlowchartList = z.infer<typeof flowchartListSchema>
|
||||
export type FlowchartCurrent = z.infer<typeof flowchartCurrentSchema>
|
||||
export type FlowchartDetail = z.infer<typeof flowchartDetailSchema>
|
||||
export type FlowchartScores = z.infer<typeof flowchartScoresSchema>
|
||||
export type CreateFlowchartResponse = z.infer<typeof createFlowchartResponseSchema>
|
||||
export type CreateFlowchartRequest = z.infer<typeof createFlowchartRequestSchema>
|
||||
|
||||
Reference in new issue
Block a user