Compare commits

...

10 Commits

Author SHA1 Message Date
e00ab7b876 perf(流程图统计): 给词云的分词量封顶
Some checks failed
Deploy / deploy (push) Has been cancelled
统计接口会把时间窗内所有已完成提交的 criteria / feedback / suggestions 全取回来,
逐条走一遍 jieba。而前端的「全部时段」是不带 start 的 —— 攒一学年就得把所有评语
重新 cut 一遍,而这是个同步阻塞的请求。

只给词云的分词条数封顶(3000),并按时间倒序取,留下的是最近的那批。

**数值不封顶**:总数、均分、等级分布、各项平均分、完成人数仍然按整个时间窗精确
计算 —— 那只是已取回行上的算术,不额外花钱。这些数一旦采样,老师看到的完成率和
均分就是错的,而且从界面上完全看不出来;词云是辅助性的,看的是高频问题,取最近
这些条足够。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 06:27:33 -06:00
74d3b97f05 feat(流程图列表): 补上状态列,重判按钮按状态置灰
教师端的流程图提交列表一直没有状态列。「排队中」「评分中」「评分失败」三种情况
在界面上长得一模一样 —— 都只是分数栏空着,老师分不出是还没评完还是评失败了。

三处一起改:

- 加状态列(排队中 / 评分中 / 已完成 / 评分失败)。
- 分数列只在已完成时渲染 Grade。原来无论什么状态都渲染,没评完会显示成 0 分,
  看着像「评了但得了 0 分」。
- 重判按钮按状态置灰。后端只接受已完成 / 已失败的重判(其余返回 409),
  原来是点了才知道不行。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 06:27:33 -06:00
f5ad5318a0 fix(流程图编辑器): Ctrl+Y 没接、清空画布不问一声、存档里塞满 vue-flow 内部字段
## 存档里的一大半是运行时内部状态

画布上的 node 是 vue-flow 的 GraphNode,除了我们自己塞的字段,还挂着
`dimensions` / `computedPosition` / `handleBounds` / `selected` / `dragging` /
`resizing` / `initialized` / `isParent` / `events`(见 vue-flow 的 parseNode)。
这些会跟着一起写进 localStorage、进 20 份历史快照被反复深拷贝、还压缩后提交进
数据库**长期存着**。而重新挂载时它们全都会被重新算一遍 —— 存下来没有任何意义,
还把存档格式和 vue-flow 的内部实现绑死了,将来升级或迁移数据都得跟着动。

`handleBounds` 尤其占地方:一个循环节点有 4 个 handle,每个 6 个数字。

抽一个 `serialize.ts`,落盘/入历史/提交前统一裁成 id / type / position / data /
style 五个字段。`style` 保留 —— 它是建节点时按类型算好的,丢了恢复出来的图会变样。

实测:一个两节点带连线的图,存档 600 字节、节点上只剩那五个字段,九个内部字段
一个不剩;提交上去压缩后 396 字节。

## Ctrl+Y 是假的

工具栏按钮的 title 写着「重做 (Ctrl+Y)」,但只实现了 Ctrl+Shift+Z,按 Y 没反应。
顺手把 key 比较改成小写不敏感(按住 Shift 时 event.key 是大写的 "Z")。

## 清空画布点一下就没了

那个按钮不但清空画布,`clearCache()` 还会把这道题存着的草稿一起删掉,刷新也找
不回来。学生误点的代价太大,加一道确认;画布本来就是空的时候不弹。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 06:27:19 -06:00
9e41855610 fix(流程图): 点进去空白的 tab、以及「渲染成功」这个只进不出的开关
## 两个开关同时打开会做出一个空 tab

后端在 `allowFlowchart` 为真时把 `mermaidCode` 置成 null(不能把标准答案下发给
正要自己画图的学生,见 routes/problem.ts)。而前端 `tabOptions` 只看
`showFlowchart` 就把 "flowchart" 加进选项,面板那边却要求
`showFlowchart && mermaidCode` —— 两个开关都打开时,选项存在、面板不存在,
URL 里带 `?tab=flowchart` 就会选中一个渲染不出任何东西的页签。

三个断点条件还各不相同(两处要求两者都有,第三处只看 showFlowchart,那处会拿
null 去渲染 ProblemFlowchart)。统一成一个 `canShowFlowchart`。

后台那边把「显示标准流程图」在允许提交流程图时置灰并说明原因,再补一个 watch
把存量数据里两个都开着的情况纠正掉 —— 它们本来就是互斥的。

## 「渲染成功」只进不出

保存前的校验靠 `mermaidRenderSuccess`,而 MermaidEditor 只在成功时 emit、
这个 ref 也就只会从 false 变 true,永不复位。**先写对、再改坏,照样能存进库。**

改成上报渲染结果本身(`render-state`),并在 modelValue 一变就立刻打回
「未验证」,等防抖后的渲染真跑完再报结论 —— 只挂防抖那一支的话,改完 300ms 内
点保存读到的还是上一次的结论,刚改坏的代码会被当成校验通过。宁可让出题人多等
一下,也不能放脏数据进库。

实测:改动后 50ms 读到 false(此时保存会被拦),渲染完成后回到 true;
贴一段坏语法则一直是 false。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 06:27:02 -06:00
d10172f017 fix(流程图): 节点名里带一个双引号,生成的 mermaid 就是坏的
节点和连线的标签是学生自己敲的任意文字,而转换器是直接把它塞进 `"..."` 里:

    ${nodeId}(("${label}"))

出现一个双引号就把语法撑破了。后果不只是图渲染不出来 —— 这段坏掉的代码会**原样
提交给 AI 打分**,学生完全不知道自己被扣分是因为一个引号。把 `说"你好"` 当节点名
是很自然的写法。

改用 mermaid 的实体转义。`#` 必须先转,否则标签里本来就有的 `#quot;` 之类会被当
成实体解释;换行转成 `<br/>`,不然会截断整条语句。

实测:`说"你好" #1` 现在生成 `说#quot;你好#quot; #35;1`,渲染出来仍然显示
`说"你好" #1`;未转义的那版渲染报语法错误、一个 svg 都出不来。

顺带补上 `edges` 的空值保护 —— `nodes` 有,`edges` 一直没有,拿到 undefined 直接抛。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 06:26:45 -06:00
0392445dd2 fix(流程图): 补上几处静默失败 —— 点了没反应、弹框永久转圈
`utils/api.ts` 的拦截器只对 login-required / account-disabled / permission-denied
弹提示,其余业务错误一律静默 reject。而流程图这一块的调用点基本都没 catch,
于是失败时用户什么都看不到:

- **重新判题**:后端会拒掉还在评分中的提交(409 retry-not-allowed),也可能撞上
  限流。老师点下去完全没反应,连报错都没有。实测修复前 0 条提示,修复后弹出
  「这条还在评分中,等出了结果再重新评分」。
  提示按**错误码**分支而不是 match 文案(`utils/api.ts` 里写明的约定)——
  后端文案是英文的,直接弹给老师看不合适。
- **课堂统计**:请求失败后图表停在旧数据上,没有任何迹象表明这次没拉到。

同一批里 `SubmitFlowchart` 的三处(弹框翻页、打开评分详情、加载到编辑器)随
上一个提交一起改了,问题是一样的:前两处失败时 `rendering` 卡在 true,弹框
永久转圈。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 06:15:11 -06:00
5480abaaea fix(流程图编辑器): 换题草稿串题、第一步撤销不了、历史存的是改动前的状态
## 换题时画布不跟着换

storage key 是按题目 ID 算的 computed。`useStorage` 确实会 watch key,但它只把
新 key 的内容读进 `storedData`,**不会回填 `nodes`/`edges`**,而 `loadFromCache()`
只在 onMounted 调一次。于是同名路由换参数(题目 → 题目,组件不重新挂载)时,
画布上还留着上一题的图;学生一动,防抖保存就把上一题的内容写进**这一题的 key**,
把原本存着的草稿覆盖掉。

补一个 key 的 watch,重新载入并在载不到时清空画布。这里依赖 `useStorage` 内部
对 key 的 watch 先于本 watch 执行 —— 两者都是 pre flush,且 useStorage 在上方
先创建,pre 队列按创建顺序跑,此刻 `storedData` 已经是新 key 的数据。

实测(router.push 直接切题):修复前 1003 的画布上挂着 1002 的节点,修复后
1003 是空的、1002 的草稿完好。

**需要说明**:今天的 UI 走不到这条路 —— 题目页没有「下一题」入口,题单和比赛
切题都要先回列表页(不同路由、组件会重新挂载)。所以这条目前是加固,一旦以后
加了题内切题入口就立刻变成必需品。

## 撤销少一步、存的还是旧状态

`historyIndex` 从 -1 开始,而 `canUndo` 要求 `index > 0`,第一步操作永远撤销
不了。补 `resetHistory`,挂载时和换题后各播一次初始快照(换题不重建的话,一次
撤销会把上一题的图还原到这一题里)。

`addEdges` / `removeNodes` / `removeEdges` 之后紧接着 `saveState(nodes.value,
edges.value)` —— 而 vue-flow 的 store → v-model 回写走的是 `watchPausable`
(pre flush,异步),此刻读到的还是**改动前**的数组,存进历史整体错开一步。
画布上的 `handleDrop` 早就 `await nextTick()` 了,这几处一直漏了;
`clearCanvas` 因为是直接赋值 model ref(同步)反而是对的 —— 所以这套行为一直
是「有时对有时错」,更难排查。

顺带:`handleNodeDelete` 里手动删相连边是多余的,`removeNodes` 的
`removeConnectedEdges` 默认就是 true;`deleteSelected` 在什么都没选中时不再
白记一条历史。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 06:14:59 -06:00
5f0fe713dc fix(流程图渲染): 出错后再也画不出来、每失败一次往 body 漏一个 div
## 渲染失败后永久空白

四个渲染点都是同一个写法:

    <n-alert v-if="renderError" ... />
    <div v-else ref="mermaidContainer"></div>

一旦渲染出错,容器被 `v-else` 卸载,`mermaidContainer.value` 变成 null。而
`renderFlowchart` 一进来就先清 `renderError`、再因为 `!container` 直接 return ——
于是**报错提示消失了,图也再画不出来**,只剩一块空白,只能刷新页面。翻历史提交
最容易踩到。改成容器常驻、用 `v-show` 隐藏,和 MermaidEditor 本来的写法一致。
`FlowchartScoreDetail` 那处 Teleport 上不能挂 v-show,把条件挪到内层容器。

实测:修复前切回合法代码后 0 个 svg(永久空白),修复后正常渲染。

## 每次渲染失败都往 body 漏一个 div

`m.render(id, code)` 没传容器,mermaid 会在 `document.body` 上建一个临时
`div#d{id}`。而 `suppressErrorRendering` 默认关着,看 mermaid 源码,解析出错时
是先 `errorRenderer.draw()` 再 `throw`,**清理临时容器的那行在 throw 之后**,
永远执行不到;每次 render 用的又是新的随机 id,`removeExistingElements` 也清不掉
旧的。于是渲染失败一次就留一个。

出题页是边敲边预览,且没有防抖,每个字符触发一次完整渲染,中间态几乎全是语法
错误 —— 实测逐字符敲 26 个字符,body 里留下 16 个残留 div。打开
`suppressErrorRendering`(该分支是先清理再抛)+ 预览防抖 300ms,实测降到 0,
预览功能不受影响。

## 顺带

`loadMermaid` 缓存的是实例,两个组件同屏挂载时会双双落进 `if (!mermaidInstance)`,
import 和 initialize 各跑两次。改成缓存 Promise,并在失败时清掉缓存,避免一次
网络抖动把后续所有渲染都钉死在这个失败结果上。

`SubmitFlowchart` 的 `updatePage` / `openDetailModal` / `loadToEditor` 一并补了
错误兜底(同文件,见下一个提交的说明):前两个失败时 `rendering` 会卡在 true,
弹框永久转圈;`loadToEditor` 是裸 `JSON.parse`,老提交数据坏掉就点了没反应。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 06:14:40 -06:00
c27c9fdbf9 fix(流程图评分): 队列重试从没生效过、AI 调用不设超时、等级由模型自报
## 重试是摆设

`flowchartQueue` 配了 `attempts: 3` + 指数退避,但任务开头有一道守卫:

    if (!row || ![0, 1].includes(row.flowchart.status)) return

而 catch 里第一件事就是把 status 写成 3(FAILED)。于是第 2、3 次尝试进来一看
状态是 3,直接 return、算作成功 —— **实际只跑了一次**。AI 侧的偶发失败(限流、
超时、网络抖动)永远等不到重试,学生看到「评分失败」只能自己重新提交。

改成只有最后一次尝试才落 FAILED,中间几次把状态留在 PROCESSING(1) 让守卫放行。
「是不是最后一次」由 worker 算好传进来:`attemptsMade` 是「此前已失败几次」,
当前这次还没计入,所以判据是 `attemptsMade + 1 >= attempts`。

实测(用没配 AI_KEY 这条必然失败的路径):修复前 t+1s 就落 FAILED、只评一次;
修复后评满 3 次,状态到 t+7s 才落 FAILED。

## fetch 不设超时

`completeChat` 直接 `fetch`,而 fetch 默认不超时。AI 侧一挂就把 worker 的并发位
(只有 2 个)一直占着,学生那边的按钮也就一直转。加 60 秒超时。

流式调用**不加**:那边超时会把正在推的长回答直接掐断,而客户端断开本来就能收尾。

## 等级不该由模型说了算

提示词里写死了 S/A/B/C 四档分数区间,但模型偶尔会给出「88 分配 S 级」这种自相
矛盾的结果,甚至直接吐「优秀」。脏值会一路串到等级分布图、等级筛选,以及
「A/S 才把流程图展示给学生」的判断里。改成一律由分数推出等级,模型自报的 grade
不再采信;score 本来就已经 clamp 到 0-100。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 06:14:21 -06:00
8f08ed03a0 fix(流程图): 学生能翻出全班评分、AI 调用没有限流
## 列表漏了一道门

代码提交列表在 `routes/submission.ts` 里有 `submission_list_show_all` 兜底:
关掉时非管理员一律返回空。流程图列表**从来没有这道门**,而它的过滤是

    if (myself === "1" || (!username && 是普通用户)) 只看自己
    else if (username) 按用户名模糊匹配

—— 只要带上 `username`,第二支就把第一支的限制绕过去了。学生在提交记录页把
语言切成「流程图」、用户名框随便填一个字,就能翻出全班同学的 AI 评分,不需要
动接口。补上和代码提交同一套口径。

## 提交与重判没有限流

每一次流程图提交都会触发一次外部 AI 调用,是和判题沙箱同级的有限资源,而这两个
入口都没限流。`canView` 还允许**本人**重试自己的提交,等于学生可以对着自己的
提交反复点,无上限地刷 AI 调用。

限流桶不能直接用 `throttling:user:<id>` —— 那是代码提交在用的桶(capacity 20,
回填约 1.8 个/分钟),共用的话学生在机房连着交几次代码,流程图这边就会莫名其妙
交不上去。单独开 `throttling:user:flowchart:<id>`。

重判对教师放行:成批点几十行是他们的正常用法。

## 提交编号的权限判断在前端自己算了一遍

契约里 `flowchartListItem.showLink` 是后端逐行下发的(与 `GET /flowcharts/:id`
的放行条件同源),前端却没用,自己按「超管或本人」重算了一次 —— 教师因此看得到
「重新判题」却打不开评分详情。

更要命的是无权限那一支渲染的 `n-text` **照样挂着 @click**,权限判断只改了外观。
学生点别人的编号,后端以 404 挡下,`loadSubmission` 只 console.error,于是弹出
一个 600px 高的空白面板,什么提示都没有。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 06:14:05 -06:00
21 changed files with 456 additions and 117 deletions

View File

@@ -15,22 +15,38 @@ function evaluationPrompt(problem: typeof schema.problem.$inferSelect) {
题目:${problem.title}\n${problem.description.slice(0, 2000)}`
}
/**
* 等级一律由分数推出来,不采信模型自报的 grade。
* 提示词里写死了这四档,但模型偶尔会给出 88 分配 S 级这种自相矛盾的结果,
* 甚至直接吐「优秀」脏值会一路串到等级分布图和「A/S 才展示流程图」的判断里。
*/
function gradeForScore(score: number) {
if (score >= 90) return "S"
if (score >= 80) return "A"
if (score >= 70) return "B"
return "C"
}
function parseEvaluation(value: string) {
const block = value.match(/```(?:json)?\s*([\s\S]*?)```/)?.[1]
const json = block ?? value.match(/\{[\s\S]*\}/)?.[0]
if (!json) throw new Error("AI response did not contain JSON")
const data = JSON.parse(json) as Record<string, unknown>
if (typeof data.score !== "number" || typeof data.grade !== "string") throw new Error("AI response is missing score or grade")
if (typeof data.score !== "number" || Number.isNaN(data.score)) throw new Error("AI response is missing score")
const score = Math.max(0, Math.min(100, data.score))
return {
score: Math.max(0, Math.min(100, data.score)),
grade: data.grade,
score,
grade: gradeForScore(score),
feedback: typeof data.feedback === "string" ? data.feedback : "",
suggestions: typeof data.suggestions === "string" ? data.suggestions : "",
criteria: data.criteria_details && typeof data.criteria_details === "object" ? data.criteria_details : {},
}
}
export async function evaluateFlowchart(job: FlowchartJobData) {
export async function evaluateFlowchart(
job: FlowchartJobData,
{ isFinalAttempt = true }: { isFinalAttempt?: boolean } = {},
) {
const [row] = await db.select({ flowchart: schema.flowchartSubmission, problem: schema.problem }).from(schema.flowchartSubmission)
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
.where(eq(schema.flowchartSubmission.id, job.submissionId)).limit(1)
@@ -69,6 +85,11 @@ export async function evaluateFlowchart(job: FlowchartJobData) {
// AI provider 的地址、内部报错就这么进了浏览器。真实原因留在服务端日志里,
// 学生只需要知道「失败了再试一次」error 字段留空,前端有兜底文案。
console.error(`Failed to evaluate flowchart ${row.flowchart.id}`, error)
// 只有最后一次尝试才落 FAILED。中间几次必须把状态留在 PROCESSING(1)
// 上面那道 `![0, 1].includes(status)` 的守卫会把状态为 3 的任务直接放行返回,
// 一旦提前写成 3队列配的 attempts: 3 就成了摆设 —— 后两次尝试进来什么都不做
// 就算成功AI 侧的偶发失败(限流、超时、网络抖动)永远等不到重试。
if (!isFinalAttempt) throw error
await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
await publishFlowchartUpdate(row.flowchart.userId, flowchartUpdateSchema.parse({
type: "flowchart_evaluation_failed",

View File

@@ -18,6 +18,8 @@ import { config } from "../config"
import { db, schema } from "../db"
import { failure, success } from "../http"
import { flowchartQueue } from "../queue"
import { getBooleanOption } from "../services/options"
import { consumeToken } from "../services/throttling"
import { buildWordFrequencies } from "../services/word-frequency"
import {
isAdminRole,
@@ -30,6 +32,11 @@ import {
export const flowchartRoutes = new Hono<AppEnv>()
// AI 评分单独一个限流桶,与代码提交的 `throttling:user:<id>` 分开计数
function flowchartThrottleKey(userId: number) {
return `flowchart:${userId}`
}
function canView(user: import("../auth/session").AuthUser, row: { userId: number }, problem: { createdById: number }) {
return row.userId === user.id || isAdminRole(user) || problem.createdById === user.id
}
@@ -67,6 +74,13 @@ flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
.where(eq(schema.problem.id, parsed.data.problemId)).limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!problem.allow) return failure(c, 400, "flowchart-not-allowed", "This problem does not allow flowchart submission")
// 限流:每次提交都会触发一次外部 AI 调用,是和判题沙箱同级的有限资源。
// 身份前缀单独开一个桶,**不能**直接用 user id —— 那是代码提交在用的桶,
// 共用的话学生在机房连着交几次代码,流程图这边就会莫名其妙交不上去。
const throttle = await consumeToken("user", flowchartThrottleKey(c.get("user")!.id))
if (!throttle.allowed) {
return failure(c, 429, "too-many-submissions", `Please wait ${Math.floor(throttle.wait)} seconds`)
}
const id = randomBytes(16).toString("hex")
await db.insert(schema.flowchartSubmission).values({
id,
@@ -103,6 +117,12 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
const displayId = c.req.query("problemId")?.trim()
const username = c.req.query("username")?.trim()
const grade = c.req.query("grade")
// 与代码提交列表同一套口径submission.ts 的 GET /submissions关掉
// submission_list_show_all 时非管理员看不到列表。流程图这边一直漏了这道门,
// 学生把语言切成「流程图」、用户名随便填一个字就能翻出全班的 AI 评分。
if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) {
return success(c, flowchartListSchema.parse({ results: [], total: 0 }))
}
if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`)
if (c.req.query("myself") === "1" || (!username && user.adminType === "Regular User")) filters.push(eq(schema.flowchartSubmission.userId, user.id))
else if (username) filters.push(ilike(schema.user.username, `%${username}%`))
@@ -138,6 +158,19 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
const FLOWCHART_COMPLETED = 2
/**
* 词云的分词条数上限。
*
* 数值统计(总数、均分、等级分布、各项平均分、完成人数)仍然按整个时间窗**精确**
* 计算 —— 那只是已取回行上的算术,不额外花钱。真正会随数据量线性变重的是分词:
* 每条 feedback / suggestions / comment 都要走一遍 jieba而前端的「全部时段」
* 是不带 start 的,攒一学年就得把所有评语重新 cut 一遍。
*
* 词云是辅助性的,看的是高频问题,取最近这些条足够;数值不能采样 —— 采了之后
* 老师看到的完成率和均分就是错的,而且从界面上看不出来。
*/
const WORDCLOUD_TEXT_LIMIT = 3000
flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
const end = c.req.query("end")?.trim()
if (!end) return failure(c, 400, "invalid-request", "end is required")
@@ -191,6 +224,8 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
.from(schema.flowchartSubmission)
.innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
.where(and(...filters))
// 按时间倒序,好让词云取到的那部分是最近的
.orderBy(desc(schema.flowchartSubmission.createTime))
const empty = {
totalCount: 0,
@@ -207,6 +242,9 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
const gradeDistribution: Record<string, number> = {}
const criteriaTotals = new Map<string, { sum: number; count: number; max: number }>()
const texts: string[] = []
const pushText = (value: string) => {
if (texts.length < WORDCLOUD_TEXT_LIMIT) texts.push(value)
}
const submitted = new Set<string>()
let scoreSum = 0
let scoreCount = 0
@@ -235,10 +273,10 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
max: typeof detail.max === "number" ? detail.max : 100,
})
}
if (typeof detail.comment === "string" && detail.comment) texts.push(detail.comment)
if (typeof detail.comment === "string" && detail.comment) pushText(detail.comment)
}
if (row.feedback) texts.push(row.feedback)
if (row.suggestions) texts.push(row.suggestions)
if (row.feedback) pushText(row.feedback)
if (row.suggestions) pushText(row.suggestions)
}
const criteriaAverages: Record<string, { avg: number; max: number }> = {}
@@ -274,11 +312,20 @@ flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => {
})
flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
const user = c.get("user")!
const [row] = await db.select({ flowchart: schema.flowchartSubmission, problem: schema.problem }).from(schema.flowchartSubmission)
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
.where(eq(schema.flowchartSubmission.id, c.req.param("id"))).limit(1)
if (!row || !canView(c.get("user")!, row.flowchart, row.problem)) return failure(c, 404, "flowchart-not-found", "Submission does not exist")
if (!row || !canView(user, row.flowchart, row.problem)) return failure(c, 404, "flowchart-not-found", "Submission does not exist")
if (![2, 3].includes(row.flowchart.status)) return failure(c, 409, "retry-not-allowed", "Submission is not in a state that allows retry")
// canView 允许本人重试自己的提交,不限流的话学生可以反复点着刷 AI 调用。
// 教师放行:重新判题是他们的日常操作,成批点几十行是正常用法
if (!isAdminRole(user)) {
const throttle = await consumeToken("user", flowchartThrottleKey(user.id))
if (!throttle.allowed) {
return failure(c, 429, "too-many-submissions", `Please wait ${Math.floor(throttle.wait)} seconds`)
}
}
await db.update(schema.flowchartSubmission).set({
status: 0, aiScore: null, aiGrade: null, aiFeedback: null, aiSuggestions: null,
aiCriteriaDetails: {}, processingTime: null, evaluationTime: null,

View File

@@ -15,10 +15,18 @@ function requestBody(messages: ChatMessage[], stream: boolean) {
}
}
/**
* 非流式调用的超时。fetch 默认不超时AI 侧一挂就会把 worker 的并发位一直占着,
* 学生那边的按钮也就一直转。流式调用不设:那边超时会把正在推的长回答直接掐断,
* 客户端断开本来就能收尾。
*/
const COMPLETE_TIMEOUT_MS = 60_000
export async function completeChat(system: string, user: string) {
if (!config.aiKey) throw new Error("缺少 AI_KEY")
const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), {
method: "POST",
signal: AbortSignal.timeout(COMPLETE_TIMEOUT_MS),
headers: { "content-type": "application/json", authorization: `Bearer ${config.aiKey}` },
body: JSON.stringify(requestBody([
{ role: "system", content: system },

View File

@@ -18,7 +18,11 @@ const worker = new Worker<JudgeJobData>(
const flowchartWorker = new Worker<FlowchartJobData>(
flowchartQueueName,
async (job) => evaluateFlowchart(job.data),
// attemptsMade 是「此前已经失败过几次」,当前这次还没计进去,
// 所以最后一次尝试的判据是 attemptsMade + 1 >= attempts
async (job) => evaluateFlowchart(job.data, {
isFinalAttempt: job.attemptsMade + 1 >= (job.opts.attempts ?? 1),
}),
{ connection: createBlockingRedis(), concurrency: 2 },
)

View File

@@ -173,6 +173,16 @@ const languageOptions = [
const isSQLProblem = computed(() => !!problem.value?.languages.includes("SQL"))
// SQL 题联动SQL 必须是唯一语言(后端强校验),不需要预制代码,自动初始化 sql_config
// 两个流程图开关是互斥的allowFlowchart 为真时后端不会把 mermaidCode 下发给
// 学生showFlowchart 就成了一个点进去什么都没有的 tab。UI 上已经把开关置灰,
// 这里再把存量数据里两个都开着的情况纠正掉。
watch(
() => problem.value?.allowFlowchart,
(allow) => {
if (allow) problem.value.showFlowchart = false
},
)
watch(
() => problem.value?.languages,
(langs) => {
@@ -310,9 +320,10 @@ function downloadTestcases() {
download(`problems/${problem.value.id}/test-cases`)
}
// Mermaid 渲染事件处理
function onMermaidRenderSuccess() {
mermaidRenderSuccess.value = true
// Mermaid 渲染事件处理。这里必须原样接受 false ——
// 原来只在成功时置 true、永不复位先写对再改坏就能把语法错误的代码存进库
function onMermaidRenderState(ok: boolean) {
mermaidRenderSuccess.value = ok
}
// 题目是否有漏写的
@@ -864,7 +875,15 @@ watch(
<n-switch v-model:value="problem.allowFlowchart" />
</n-form-item>
<n-form-item label="显示标准流程图">
<n-switch v-model:value="problem.showFlowchart" />
<n-flex align="center">
<n-switch
v-model:value="problem.showFlowchart"
:disabled="problem.allowFlowchart"
/>
<n-text v-if="problem.allowFlowchart" depth="3" style="font-size: 12px">
让学生自己画图时标准流程图不会下发给学生这个开关没有意义
</n-text>
</n-flex>
</n-form-item>
</n-form>
@@ -872,7 +891,7 @@ watch(
<n-form-item>
<MermaidEditor
v-model="problem.mermaidCode"
@render-success="onMermaidRenderSuccess"
@render-state="onMermaidRenderState"
/>
</n-form-item>
<n-form-item label="流程图提示信息(选填)">

View File

@@ -22,7 +22,11 @@ watch(
<n-alert v-if="renderError" type="error" title="渲染失败" size="small">
{{ renderError }}
</n-alert>
<div v-else ref="mermaidContainer" class="flowchart-container"></div>
<div
v-show="!renderError"
ref="mermaidContainer"
class="flowchart-container"
></div>
</div>
</template>

View File

@@ -27,7 +27,9 @@ watch(() => problem.value?.mermaidCode, renderProblemFlowchart)
{{ renderError }}
</template>
</n-alert>
<div v-else ref="mermaidContainer" class="container"></div>
<!-- 容器必须常驻 v-else 卸载掉之后 mermaidContainer 变成 null
下一次渲染会因为拿不到容器直接 return图就再也画不出来了 -->
<div v-show="!renderError" ref="mermaidContainer" class="container"></div>
</div>
</template>

View File

@@ -314,26 +314,40 @@ async function getSubmission(submissionPage = 0) {
}
}
// 请求失败时 rendering 必须复位:拦截器对普通业务错误是静默 reject 的,
// 少了这个 finally弹框就会永远停在转圈状态也没有任何提示
async function updatePage(val: number) {
page.value = val
rendering.value = true
await getSubmission(val)
// 等待 DOM 更新
await nextTick()
await renderFlowchart(mermaidContainer.value, myMermaidCode.value)
rendering.value = false
try {
await getSubmission(val)
// 等待 DOM 更新
await nextTick()
await renderFlowchart(mermaidContainer.value, myMermaidCode.value)
} catch (error) {
message.error("加载这次提交失败,请稍后重试")
console.error("加载流程图提交失败:", error)
} finally {
rendering.value = false
}
}
// ==================== 模态框相关函数 ====================
async function openDetailModal() {
showDetailModal.value = true
rendering.value = true
await getSubmission()
page.value = submissionCount.value
// 等待 DOM 更新,确保弹框已经渲染
await nextTick()
await renderFlowchart(mermaidContainer.value, myMermaidCode.value)
rendering.value = false
try {
await getSubmission()
page.value = submissionCount.value
// 等待 DOM 更新,确保弹框已经渲染
await nextTick()
await renderFlowchart(mermaidContainer.value, myMermaidCode.value)
} catch (error) {
message.error("加载评分详情失败,请稍后重试")
console.error("加载流程图评分详情失败:", error)
} finally {
rendering.value = false
}
}
function closeModal() {
@@ -342,14 +356,18 @@ function closeModal() {
function loadToEditor() {
if (myFlowchartZippedStr.value) {
const str = atou(myFlowchartZippedStr.value)
const json = JSON.parse(str)
const processedData = {
nodes: json.nodes || [],
edges: json.edges || [],
}
if (flowchartEditorRef?.value) {
flowchartEditorRef.value.setFlowchartData(processedData)
// 老提交的压缩数据可能是坏的(格式换过、存了一半),
// 不兜住的话 atou/JSON.parse 直接抛,按钮点了毫无反应
try {
const json = JSON.parse(atou(myFlowchartZippedStr.value))
flowchartEditorRef?.value?.setFlowchartData({
nodes: json.nodes || [],
edges: json.edges || [],
})
} catch (error) {
message.error("这份流程图数据已损坏,无法加载到编辑器")
console.error("解析流程图数据失败:", error)
return
}
}
closeModal()
@@ -432,7 +450,11 @@ onUnmounted(() => {
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
{{ renderError }}
</n-alert>
<div class="flowchart" v-else ref="mermaidContainer"></div>
<div
class="flowchart"
v-show="!renderError"
ref="mermaidContainer"
></div>
</n-spin>
</div>
<!-- 加载到编辑器按钮 -->

View File

@@ -1,9 +1,26 @@
/**
* 将流程图JSON数据转换为Mermaid格式
* 节点/连线标签是学生自己敲的任意文字,而下面是直接把它塞进 "..." 里。
* 出现一个双引号就会把 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, edges } = flowchartData
const nodes = flowchartData?.nodes
// edges 原来没做空值保护nodes 有),拿到 undefined 会直接抛
const edges = flowchartData?.edges ?? []
if (!nodes || nodes.length === 0) {
return "graph TD\n A[空流程图]"
@@ -22,7 +39,9 @@ export function useMermaidConverter() {
// 处理节点 - 根据原始类型和自定义标签
nodes.forEach((node: any) => {
const nodeId = safeId(node.id)
const label = node.data?.customLabel || node.data?.label || "节点"
const label = escapeLabel(
node.data?.customLabel || node.data?.label || "节点",
)
const originalType = node.data?.originalType || node.type
// 根据节点原始类型确定Mermaid语法
@@ -60,10 +79,10 @@ export function useMermaidConverter() {
edges.forEach((edge: any) => {
const source = safeId(edge.source)
const target = safeId(edge.target)
const label = edge.label ?? ""
const rawLabel = String(edge.label ?? "")
if (label && label.trim() !== "") {
mermaid += ` ${source} -->|"${label}"| ${target}\n`
if (rawLabel.trim() !== "") {
mermaid += ` ${source} -->|"${escapeLabel(rawLabel)}"| ${target}\n`
} else {
mermaid += ` ${source} --> ${target}\n`
}

View File

@@ -53,9 +53,17 @@ const { shouldShowProblem } = storeToRefs(screenModeStore)
const { isMobile, isDesktop } = useBreakpoints()
// tab 选项和面板必须用同一个条件。后端在 allowFlowchart 为真时会把 mermaidCode
// 置成 null不能把标准答案下发给正要自己画图的学生只看 showFlowchart 的话,
// 两个开关同时打开就会做出一个「选项存在、面板不存在」的 tab —— URL 里带
// ?tab=flowchart 会选中一个渲染不出任何东西的页签。
const canShowFlowchart = computed(
() => !!problem.value?.showFlowchart && !!problem.value?.mermaidCode,
)
const tabOptions = computed(() => {
const options: string[] = ["content"]
if (problem.value?.showFlowchart) {
if (canShowFlowchart.value) {
options.push("flowchart")
}
@@ -159,7 +167,7 @@ watch(
<ProblemContent />
</n-tab-pane>
<n-tab-pane
v-if="problem.showFlowchart && problem.mermaidCode"
v-if="canShowFlowchart"
name="flowchart"
tab="流程图表"
>
@@ -211,7 +219,7 @@ watch(
<ProblemContent />
</n-tab-pane>
<n-tab-pane
v-if="problem.showFlowchart && problem.mermaidCode"
v-if="canShowFlowchart"
name="flowchart"
tab="流程图表"
>
@@ -251,7 +259,7 @@ watch(
<n-tab-pane name="content" tab="描述">
<ProblemContent />
</n-tab-pane>
<n-tab-pane v-if="problem.showFlowchart" name="flowchart" tab="流程">
<n-tab-pane v-if="canShowFlowchart" name="flowchart" tab="流程">
<ProblemFlowchart />
</n-tab-pane>
<n-tab-pane name="editor" tab="代码">

View File

@@ -1,16 +1,15 @@
<template>
<n-button v-if="showLink" type="info" text @click="handleClick">
<n-button v-if="flowchart.showLink" type="info" text @click="handleClick">
{{ flowchart.id.slice(0, 12) }}
</n-button>
<n-text v-else class="flowchart-id" @click="handleClick">
<!-- 没权限时不能挂 @click后端 GET /flowcharts/:id 会以 404 挡下
前端只会得到一个静默失败的空白面板 -->
<n-text v-else class="flowchart-id" depth="3">
{{ flowchart.id.slice(0, 12) }}
</n-text>
</template>
<script setup lang="ts">
import type { FlowchartSubmissionListItem } from "utils/types"
import { useUserStore } from "shared/store/user"
const userStore = useUserStore()
interface Props {
flowchart: FlowchartSubmissionListItem
@@ -21,12 +20,9 @@ const emit = defineEmits<{
showDetail: [id: string]
}>()
const showLink = computed(() => {
if (!userStore.isAuthed) return false
if (userStore.isSuperAdmin) return true
return props.flowchart.username === userStore.user?.username
})
// showLink 由后端逐行下发(见 routes/flowchart.ts 的 canView
// GET /flowcharts/:id 的放行条件同源。原来前端自己按「超管或本人」算了一遍,
// 既漏了教师,也和后端对不上。
function handleClick() {
emit("showDetail", props.flowchart.id)
}

View File

@@ -20,8 +20,9 @@
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
{{ renderError }}
</n-alert>
<Teleport v-else to="body" :disabled="!showLargeImage">
<Teleport to="body" :disabled="!showLargeImage">
<div
v-show="!renderError"
:class="['flowchart', { 'flowchart-fullscreen': showLargeImage }]"
ref="mermaidContainer"
></div>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NButton } from "naive-ui"
import { NButton, NTag, NText } from "naive-ui"
import { useRouteQuery } from "@vueuse/router"
import {
adminRejudge,
@@ -22,6 +22,19 @@ import { usePagination } from "shared/composables/pagination"
import { useUserStore } from "shared/store/user"
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
import { renderTableTitle } from "utils/renders"
import { FlowchartSubmissionStatus } from "utils/types"
// 流程图提交的四种状态,列表里原来一列都没有 ——
// 老师分不出「还在评」和「评失败了」,两种都只是分数栏空着
const FLOWCHART_STATUS_TAG: Record<
number,
{ text: string; type: "default" | "info" | "success" | "error" }
> = {
[FlowchartSubmissionStatus.PENDING]: { text: "排队中", type: "default" },
[FlowchartSubmissionStatus.PROCESSING]: { text: "评分中", type: "info" },
[FlowchartSubmissionStatus.COMPLETED]: { text: "已完成", type: "success" },
[FlowchartSubmissionStatus.FAILED]: { text: "评分失败", type: "error" },
}
import ButtonWithSearch from "./components/ButtonWithSearch.vue"
import SubmissionLink from "./components/SubmissionLink.vue"
import Grade from "./components/Grade.vue"
@@ -195,7 +208,21 @@ async function rejudge(submissionID: string) {
}
async function retryFlowchart(submissionId: string) {
await retryFlowchartSubmission(submissionId)
// 后端会拒掉「还在评分中」的提交409 retry-not-allowed也可能撞上限流。
// 不兜住的话拦截器静默 reject老师点下去完全没反应。
// 按错误码分支而不是 match 文案(见 utils/api.ts 的约定)——后端文案是英文的,
// 直接弹给老师看不合适
const retryTips: Record<string, string> = {
"retry-not-allowed": "这条还在评分中,等出了结果再重新评分",
"too-many-submissions": "操作太频繁了,缓一下再试",
"flowchart-not-found": "提交不存在,或者没有权限",
}
try {
await retryFlowchartSubmission(submissionId)
} catch (err: any) {
message.error(retryTips[err?.error] ?? "重新评分失败")
return
}
message.success("重新评分已提交")
listSubmissions()
}
@@ -384,17 +411,33 @@ const flowchartColumns = computed(() => {
() => `${row.problem} ${row.problemTitle}`,
),
},
{
title: renderTableTitle("状态", "fluent-emoji:hourglass-not-done"),
key: "status",
render: (row) => {
const tag = FLOWCHART_STATUS_TAG[row.status]
return h(
NTag,
{ size: "small", round: true, type: tag?.type ?? "default" },
() => tag?.text ?? "未知",
)
},
},
{
title: renderTableTitle(
"评分",
"streamline-ultimate-color:analytics-bars-3d",
),
key: "ai_score",
// 只有评完的才有分数。没评完也渲染 Grade 的话会显示成 0 分,
// 看着像「评了但得了 0 分」
render: (row) =>
h(Grade, {
score: row.aiScore ?? 0,
grade: (row.aiGrade ?? "") as GradeValue,
}),
row.status === FlowchartSubmissionStatus.COMPLETED
? h(Grade, {
score: row.aiScore ?? 0,
grade: (row.aiGrade ?? "") as GradeValue,
})
: h(NText, { depth: 3 }, () => "—"),
},
{
title: renderTableTitle(
@@ -421,6 +464,8 @@ const flowchartColumns = computed(() => {
res.push({
title: renderTableTitle("选项", "streamline-emojis:wrench"),
key: "retry",
// 后端只接受已完成 / 已失败的重判(其余返回 409这里同步置灰
// 免得老师点了才发现不行
render: (row) =>
h(
NButton,
@@ -428,6 +473,9 @@ const flowchartColumns = computed(() => {
quaternary: true,
size: "small",
type: "primary",
disabled:
row.status !== FlowchartSubmissionStatus.COMPLETED &&
row.status !== FlowchartSubmissionStatus.FAILED,
onClick: () => retryFlowchart(row.id),
},
() => "重新判题",

View File

@@ -19,6 +19,7 @@ import { getNodeTypeConfig } from "./useNodeStyles"
import { useHistory } from "./useHistory"
import { useFlowOperations } from "./useFlowOperations"
import { useCache } from "./useCache"
import { toPortableEdges, toPortableNodes } from "./serialize"
import CustomNode from "./CustomNode.vue"
import { useProblemStore } from "oj/store/problem"
@@ -38,7 +39,7 @@ const nodes = ref([]) as Ref<Node[]>
const edges = ref([]) as Ref<Edge[]>
// 历史记录管理
const { canUndo, canRedo, saveState, undo, redo } = useHistory()
const { canUndo, canRedo, resetHistory, saveState, undo, redo } = useHistory()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
@@ -55,7 +56,11 @@ const {
saveToCache,
loadFromCache,
clearCache,
} = useCache(nodes, edges, cacheKey)
} = useCache(nodes, edges, cacheKey, () => {
// 换题后画布已经被换成新题的草稿,历史必须跟着重建,
// 否则一次撤销就会把上一题的图还原到这一题里
resetHistory(nodes.value, edges.value)
})
// 拖拽处理
const { onDragOver, onDragLeave, onDrop, isDragOver, screenDragPos } = useDnD()
@@ -116,10 +121,21 @@ const handleRedo = () => {
}
}
// 清空画布
// 清空画布。工具栏那个按钮点一下就全没了,且 clearCache() 会连本题存着的草稿
// 一起删掉,刷新也找不回来 —— 学生误点的代价太大,加一道确认
const dialog = useDialog()
const handleClear = () => {
clearCanvas()
clearCache()
if (nodes.value.length === 0 && edges.value.length === 0) return
dialog.warning({
title: "清空画布",
content: "画布上的内容会被清掉,这道题存着的草稿也会一起删除。确定吗?",
positiveText: "清空",
negativeText: "再想想",
onPositiveClick: () => {
clearCanvas()
clearCache()
},
})
}
// 键盘事件
@@ -135,10 +151,12 @@ const handleKeyDown = (event: KeyboardEvent) => {
}
if (event.ctrlKey || event.metaKey) {
if (event.key === "z" && !event.shiftKey) {
const key = event.key.toLowerCase()
if (key === "z" && !event.shiftKey) {
event.preventDefault()
handleUndo()
} else if (event.key === "z" && event.shiftKey) {
} else if ((key === "z" && event.shiftKey) || key === "y") {
// 工具栏上的提示写的就是 Ctrl+Y但原来只实现了 Ctrl+Shift+Z按 Y 没反应
event.preventDefault()
handleRedo()
}
@@ -148,8 +166,10 @@ const handleKeyDown = (event: KeyboardEvent) => {
onMounted(() => {
document.addEventListener("keydown", handleKeyDown)
// 从缓存恢复数据
// 从缓存恢复数据,并把当前画布作为历史起点,
// 否则第一步操作没有可回退的目标,撤销按钮一直是灰的
loadFromCache()
resetHistory(nodes.value, edges.value)
})
onUnmounted(() => {
@@ -182,9 +202,10 @@ const setFlowchartData = (data: { nodes: Node[]; edges: Edge[] }) => {
defineExpose({
nodes,
edges,
// 提交出去的这份会压缩后长期存进数据库,同样裁掉运行时内部字段
getFlowchartData: () => ({
nodes: nodes.value,
edges: edges.value,
nodes: toPortableNodes(nodes.value),
edges: toPortableEdges(edges.value),
}),
setFlowchartData,
})

View File

@@ -0,0 +1,45 @@
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[]
}

View File

@@ -1,6 +1,7 @@
import { ref, watch, type Ref, type MaybeRefOrGetter } from "vue"
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
@@ -9,6 +10,7 @@ export function useCache(
nodes: Ref<Node[]>,
edges: Ref<Edge[]>,
storageKey: MaybeRefOrGetter<string> = "flowchart-editor-data",
onReloaded?: () => void,
) {
const isSaving = ref(false)
const lastSaved = ref<Date | null>(null)
@@ -27,8 +29,8 @@ export function useCache(
// 防抖保存isSaving 在 watch 中置 true保存完成后置 false使 UI 能感知保存中状态
const debouncedSave = useDebounceFn(() => {
storedData.value.nodes = nodes.value
storedData.value.edges = edges.value
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
@@ -38,8 +40,8 @@ export function useCache(
// 立即保存
const saveToCache = () => {
isSaving.value = true
storedData.value.nodes = nodes.value
storedData.value.edges = edges.value
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
@@ -67,6 +69,26 @@ export function useCache(
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],

View File

@@ -1,3 +1,4 @@
import { nextTick } from "vue"
import type { Ref } from "vue"
import type { Node, Edge, Connection } from "@vue-flow/core"
import { useVueFlow } from "@vue-flow/core"
@@ -51,7 +52,7 @@ export function useFlowOperations(
return ""
}
const handleConnect = (params: Connection) => {
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)
@@ -74,25 +75,24 @@ export function useFlowOperations(
}
addEdges([newEdge])
// vue-flow 的 store → v-model 回写走的是 watchpre flush异步
// 紧接着读 nodes/edges 拿到的还是改动前的数组,存进历史就会错开一步。
// 画布上 handleDrop 早就这么等了,这几处一直漏了。
await nextTick()
saveState(nodes.value, edges.value)
}
const handleEdgeClick = ({ edge }: { edge: Edge }) => {
const handleEdgeClick = async ({ edge }: { edge: Edge }) => {
removeEdges([edge.id])
await nextTick()
saveState(nodes.value, edges.value)
}
// 节点删除
const handleNodeDelete = (nodeId: string) => {
// 删除相关边
const relatedEdges = edges.value.filter(
(edge) => edge.source === nodeId || edge.target === nodeId,
)
if (relatedEdges.length > 0) {
removeEdges(relatedEdges.map((edge) => edge.id))
}
// 节点删除。removeNodes 的 removeConnectedEdges 默认就是 true
// 相连的边不用自己再删一遍
const handleNodeDelete = async (nodeId: string) => {
removeNodes([nodeId])
await nextTick()
saveState(nodes.value, edges.value)
}
@@ -118,9 +118,10 @@ export function useFlowOperations(
}
// 删除选中的节点和边
const deleteSelected = () => {
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))
@@ -128,6 +129,7 @@ export function useFlowOperations(
if (selectedEdges.length > 0) {
removeEdges(selectedEdges.map((edge) => edge.id))
}
await nextTick()
saveState(nodes.value, edges.value)
}

View File

@@ -1,5 +1,6 @@
import { shallowRef, computed } from "vue"
import type { Node, Edge } from "@vue-flow/core"
import { toPortableEdges, toPortableNodes } from "./serialize"
/**
* 简化的历史记录管理
@@ -18,7 +19,14 @@ export function useHistory() {
nodes: Node[],
edges: Edge[],
): { nodes: Node[]; edges: Edge[] } =>
JSON.parse(JSON.stringify({ nodes, edges })) as {
// 先裁掉 vue-flow 的运行时内部字段再深拷贝20 份快照 × 每个节点几百字节的
// handleBounds/dimensions纯属白拷
JSON.parse(
JSON.stringify({
nodes: toPortableNodes(nodes),
edges: toPortableEdges(edges),
}),
) as {
nodes: Node[]
edges: Edge[]
}
@@ -42,6 +50,14 @@ export function useHistory() {
}
}
// 用当前画布重建历史。挂载时和换题后都要调一次:
// 不播下这个初始快照的话 historyIndex 会从 -1 开始canUndo 要求 index > 0
// 第一步操作永远撤销不了;换题后不重建则一次撤销会把上一题的图还原到这一题里。
const resetHistory = (nodes: Node[], edges: Edge[]) => {
history.value = [deepCopyState(nodes, edges)]
historyIndex.value = 0
}
// 撤销
const undo = () => {
if (canUndo.value) {
@@ -65,6 +81,7 @@ export function useHistory() {
return {
canUndo,
canRedo,
resetHistory,
saveState,
undo,
redo,

View File

@@ -203,6 +203,8 @@ interface Props {
const props = defineProps<Props>()
const message = useMessage()
const durationOptions: SelectOption[] = [
{ label: "10分钟内", value: "minutes:10" },
{ label: "20分钟内", value: "minutes:20" },
@@ -528,12 +530,18 @@ async function handleStatistics() {
query.duration === "all"
? { end }
: { start: formatISO(sub(current, subOptions.value)), end }
const res = await getFlowchartStatistics(
duration,
query.problem,
query.username,
)
Object.assign(data, res)
try {
const res = await getFlowchartStatistics(
duration,
query.problem,
query.username,
)
Object.assign(data, res)
} catch (error) {
message.error("获取流程图统计失败")
console.error("获取流程图统计失败:", error)
return
}
await nextTick()
renderWordCloud()
}

View File

@@ -7,20 +7,29 @@ const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
const { renderFlowchart, renderError, renderSuccess } = useMermaid()
// 上报渲染结果而不是只上报成功:调用方拿它做保存前校验,只进不出的话,
// 「先写对再改坏」照样能存进库
const emit = defineEmits<{
renderSuccess: []
renderState: [ok: boolean]
}>()
const renderMermaid = async () => {
await renderFlowchart(mermaidContainer.value, modelValue.value)
if (renderSuccess.value) emit("renderSuccess")
emit("renderState", renderSuccess.value)
}
onMounted(() => {
nextTick(renderMermaid)
})
watch(modelValue, renderMermaid)
// 一改动就立刻把状态打回「未验证」,等防抖后的渲染真跑完再报结果。
// 只挂防抖那一支的话,改完 300ms 内点保存,读到的还是上一次渲染的结论 ——
// 刚改坏的代码会被当成校验通过。宁可让用户多等一下,也不能放脏数据进库。
watch(modelValue, () => emit("renderState", false))
// 出题页是边敲边预览,不防抖的话每个字符都会触发一次完整的 mermaid 渲染,
// 而中间态几乎全是语法错误
watchDebounced(modelValue, renderMermaid, { debounce: 300, maxWait: 1000 })
const clearCode = () => {
modelValue.value = ""

View File

@@ -256,25 +256,41 @@ function getChromeVersion(): number {
return match ? parseInt(match[1]) : Infinity
}
let mermaidInstance: any = null
let mermaidPromise: Promise<any> | null = null
let mermaidIsLegacy = false
async function loadMermaid() {
if (!mermaidInstance) {
if (getChromeVersion() < 94) {
mermaidInstance = (await import("mermaid-legacy")).default
mermaidIsLegacy = true
} else {
mermaidInstance = (await import("mermaid")).default
}
mermaidInstance.initialize({
startOnLoad: false,
securityLevel: "strict",
theme: "base",
themeVariables: mermaidThemeVariables,
function loadMermaid(): Promise<any> {
// 缓存 Promise 而不是实例:同一屏里两个组件一起挂载时,缓存实例会让两边都
// 落进 if (!mermaidInstance)import 两次、initialize 两次
if (!mermaidPromise) {
mermaidPromise = (async () => {
let instance: any
if (getChromeVersion() < 94) {
instance = (await import("mermaid-legacy")).default
mermaidIsLegacy = true
} else {
instance = (await import("mermaid")).default
}
instance.initialize({
startOnLoad: false,
securityLevel: "strict",
theme: "base",
// 解析失败时 mermaid 默认会先把一张「错误图」画进挂在 document.body 上的
// 临时容器,再抛异常,而清理临时容器的那行在 throw 之后,永远执行不到;
// 每次 render 用的又是新的随机 id旧的也清不掉。于是每失败一次就往
// body 里留一个 div#dmermaid-xxx —— 出题页每敲一个字符渲染一次,一段
// 代码写下来能堆几十个。打开这个开关后 mermaid 会先清理再抛。
suppressErrorRendering: true,
themeVariables: mermaidThemeVariables,
})
return instance
})().catch((error) => {
// 失败的 Promise 不能留在缓存里,否则后面每次渲染都直接复用这个失败结果
mermaidPromise = null
throw error
})
}
return mermaidInstance
return mermaidPromise
}
export function useMermaid() {