fix(题目): 修掉题目页重构审查查出的问题——提交途中换题会把结果算到新题上、运行例子把对的菱形判错、点评完「下一题」不跳等

题目页重构之后按「功能对照 / 状态 / 结果页 / 布局与后端」四路审查了一遍,查出的问题一起修:

提交与换题
- 提交、画流程图途中换题:A 题格式化好的代码会盖掉 B 的草稿,A 判对了把 B 标成已解决、
  弹 B 的点评、题单里跳回题单。现在每个请求回来先核对题目,轮询和推送回来的也核对提交编号
- 画着流程图换到不能画的题,语言退到题目的第一门、还被记成偏好;现在回到学生习惯的那门
- 强制点评评完点「继续 / 下一题」,学生之前在看「题目」页签的话会停在原题
- 离开题目页之后判题、流程图的 WebSocket 一直连着,现在闲 15 分钟断开

运行例子
- 输出开头的空格被削掉,打印菱形这类题对的代码也显示「没对上」
- C 的编译警告拼进了「你的输出」(后台生成测试点也会写进 .out)
- Python 的语法错误(中文标点)被说成「下标有没有越界、除数是不是 0」
- 比赛里仍然把空格画成「·」、把不同的地方标红

入口与协作
- 比赛里不给「课堂统计」、不绑协作:房间和统计都按题号找,比赛题号和公开题撞号
- 「看他交过什么」按整名匹配,不再混进学号相近的同学
- 协作中点上下文条、相似题、下一题开新标签,协作不断
- 题单入口给管理员的「⋯」里留「本题提交」

结果与题面
- 「去自己输入拿 51 这些数试试」取到了题面 HTML 里的颜色值;「题目原话」露出 "
- 流程图 A 级的学生标题下还写着「还没交过」
- 「我的提交」上方补回「答案错误 × 3」这样的计数
- 流程图历次评分能点开看那一版的图;读分数失败不再说「还没交过流程图」;手机上能看流程图点评
- 「结果」页签有新结果没看时加粗上色,看过就恢复
- 空画布拖进第一个节点画布会跳一下;画布底下空着一条
- 比赛没开始写成「已结束」、倒计时超过一个月算错;分隔条拖到头工具栏被挤坏;题单里的题目页有备案页脚
- 流程图历次分数只读需要的几列(一个学生一道题 23 次提交:96 kB → 2 kB)
- 比赛密码、比赛没开始等几条学生看得到的英文报错换成中文

设计文档照实际做法更新了第 5.5、5.6、6、8、9、10、11 节。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-29 00:07:16 +08:00
1 parent ee66cef367
commit e7b95d211d
34 files changed
+576 -105

No files matched your search

+6 -8
View File
@@ -114,7 +114,7 @@ contestRoutes.get("/contests/:id", optionalAuth, async (c) => {
c.get("user"),
queryInteger(c.req.param("id"), 0, { min: 1 }),
)
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
if (!contest) return failure(c, 404, "contest-not-found", "比赛不存在")
const byId = await creators([contest.createdById])
return success(
c,
@@ -133,9 +133,8 @@ contestRoutes.post("/contests/:id/access", requireAuth, async (c) => {
c.get("user"),
queryInteger(c.req.param("id"), 0, { min: 1 }),
)
if (!contest || !contest.password)
return failure(c, 404, "contest-not-found", "Contest does not exist")
const parsed = await parseBody(c, contestPasswordRequestSchema, "Password is required")
if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "比赛不存在")
const parsed = await parseBody(c, contestPasswordRequestSchema, "请输入比赛密码")
if (!parsed.success) return parsed.response
// 比赛密码往往就是几位数字,不限的话一个脚本几分钟就能扫完。
// 按「人 × 比赛」计失败次数:猜错的是自己,锁的也只是自己进这一场。
@@ -151,7 +150,7 @@ contestRoutes.post("/contests/:id/access", requireAuth, async (c) => {
}
if (!checkContestPassword(parsed.data.password, contest.password)) {
await countAttempt(attemptKey, CONTEST_PASSWORD_RULE)
return failure(c, 403, "wrong-password", "Wrong password or password expired")
return failure(c, 403, "wrong-password", "比赛密码不对,或者已经过期了,请重新输入")
}
await setContestPassword(c, contest.id, parsed.data.password)
return success(c, true)
@@ -162,8 +161,7 @@ contestRoutes.get("/contests/:id/access", requireAuth, async (c) => {
c.get("user"),
queryInteger(c.req.param("id"), 0, { min: 1 }),
)
if (!contest || !contest.password)
return failure(c, 404, "contest-not-found", "Contest does not exist")
if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "比赛不存在")
const access = await canAccessContest(c, contest, "details")
return success(c, { access: access.ok } satisfies ContestAccess)
})
@@ -276,7 +274,7 @@ contestRoutes.get(
),
)
.limit(1)
if (!row) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!row) return failure(c, 404, "problem-not-found", "题目不存在")
const tags = await contestProblemTags([row.problem.id])
const allowed = contestDetailsAllowed(c.get("user"), contest)
const statuses = await contestProblemStatuses(c.get("user")?.id)
+24 -8
View File
@@ -618,16 +618,23 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
/**
* 自己在这道题上评完的每一次,按时间从早到晚,已经滤掉加入题单之前的(题单闸门)。
* current / history / scores 三个接口都从这一份出。
*
* 只取分数和闸门要的几列:整行带着画布 JSON 和 mermaid 源码(一条约 5KB),画了几十次
* 的学生每切一次题就要搬几百 KB,而结果页签只要分数。要整行的 history 自己按 id 再取。
* userId / problemId / createTime 三列是 hiddenByProblemSet 要的,别删。
*/
async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) {
const [rows, joinTimes] = await Promise.all([
db
.select({
flowchart: schema.flowchartSubmission,
username: schema.user.username,
id: schema.flowchartSubmission.id,
userId: schema.flowchartSubmission.userId,
problemId: schema.flowchartSubmission.problemId,
createTime: schema.flowchartSubmission.createTime,
aiScore: schema.flowchartSubmission.aiScore,
aiGrade: schema.flowchartSubmission.aiGrade,
})
.from(schema.flowchartSubmission)
.innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
.where(
and(
eq(schema.flowchartSubmission.userId, user.id),
@@ -638,7 +645,7 @@ async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) {
.orderBy(asc(schema.flowchartSubmission.createTime)),
joinTimesFor(user, [{ userId: user.id, problemId }]),
])
const visible = rows.filter((row) => !hiddenByProblemSet(user, row.flowchart, joinTimes))
const visible = rows.filter((row) => !hiddenByProblemSet(user, row, joinTimes))
return { visible, hidden: rows.length - visible.length }
}
@@ -650,7 +657,7 @@ async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) {
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)?.flowchart
const latest = visible.at(-1)
return success(c, {
count: visible.length,
score: latest?.aiScore ?? 0,
@@ -661,11 +668,20 @@ flowchartRoutes.get("/problems/:id/flowchart/current", requireAuth, async (c) =>
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 { visible: rows } = await myEvaluatedFlowcharts(c.get("user")!, problemId)
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: selected ? flowchartData(selected.flowchart, selected.username) : null,
submission: full ? flowchartData(full, user.username) : null,
count: rows.length,
} satisfies FlowchartDetail)
})
@@ -678,7 +694,7 @@ flowchartRoutes.get("/problems/:id/flowchart/scores", requireAuth, async (c) =>
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
const { visible, hidden } = await myEvaluatedFlowcharts(c.get("user")!, problemId)
return success(c, {
scores: visible.map(({ flowchart }) => ({
scores: visible.map((flowchart) => ({
id: flowchart.id,
score: flowchart.aiScore ?? 0,
grade: flowchart.aiGrade ?? "",
+3 -3
View File
@@ -86,7 +86,7 @@ export async function canAccessContest<E extends AppEnv>(
return {
ok: false as const,
code: "wrong-password",
message: "Wrong password or password expired",
message: "比赛密码不对,或者已经过期了,请重新输入",
}
}
}
@@ -94,7 +94,7 @@ export async function canAccessContest<E extends AppEnv>(
return {
ok: false as const,
code: "contest-not-started",
message: "Contest has not started yet.",
message: "比赛还没有开始",
}
}
return { ok: true as const }
@@ -119,7 +119,7 @@ export function requireContestAccess(
const id = Number(c.req.param(paramName))
const contest =
Number.isInteger(id) && id > 0 ? await findAccessibleContest(c.get("user"), id) : null
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
if (!contest) return failure(c, 404, "contest-not-found", "比赛不存在")
const access = await canAccessContest(c, contest, checkType)
if (!access.ok) {
return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message)
@@ -23,7 +23,8 @@ function openHisSubmissions() {
if (!room || !problem.value) return
const target = router.resolve({
name: "submissions",
query: { problem: problem.value._id, username: room.peerName },
// exactUsername:整名匹配,不然学号相近的同学(ks24a1 / ks24a10…)会混进来
query: { problem: problem.value._id, username: room.peerName, exactUsername: "1" },
})
window.open(target.href, "_blank")
}
@@ -9,6 +9,7 @@ import { useProblemStore } from "oj/store/problem"
import { useUserStore } from "shared/store/user"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useThemeVars } from "naive-ui"
import { ContestStatus } from "utils/constants"
import type { ProblemSet, ProblemSetProblem } from "utils/types"
import { useProblemPageContext } from "../composables/problemPageContext"
import ProblemChips, { type ChipItem } from "./ProblemChips.vue"
@@ -213,6 +214,8 @@ const contestBar = computed(() => {
title: contest.title,
chips,
remaining: contestStore.remaining,
// 不在进行中的两种:管理员赛前看题是「还没开始」,不能一律写「已结束」
notStarted: contestStore.contestStatus === ContestStatus.not_started,
home: { name: "contest problems", params: { contestID: ctx.value.contestId } },
rank: { name: "contest rank", params: { contestID: ctx.value.contestId } },
}
@@ -352,7 +355,7 @@ onSolvedHere(() => {
<span class="meta">还剩</span>
<span class="countdown">{{ contestBar.remaining }}</span>
</template>
<span v-else class="meta">已结束</span>
<span v-else class="meta">{{ contestBar.notStarted ? "还没开始" : "已结束" }}</span>
<router-link :to="contestBar.rank" class="contest-rank">排名</router-link>
</div>
</template>
@@ -226,7 +226,7 @@ const changeLanguage = (v: LANGUAGE) => {
</n-button>
<n-button
v-if="statisticsInline && userStore.isTeacherOrAbove"
v-if="ctx.classStats && statisticsInline && userStore.isTeacherOrAbove"
:size="buttonSize"
@click="statisticsOpen = true"
>
@@ -4,6 +4,7 @@ import { storeToRefs } from "pinia"
import { useCodeStore } from "oj/store/code"
import { isFlowchartPass, useFlowchartStore } from "oj/store/flowchart"
import { useProblemStore } from "oj/store/problem"
import { useUserStore } from "shared/store/user"
import { sortFlowchartCriteria } from "utils/constants"
import { decompressFromBase64, parseTime } from "utils/functions"
@@ -12,13 +13,29 @@ import { decompressFromBase64, parseTime } from "utils/functions"
*
* 原来评分详情是提交按钮旁一个 1000px 的弹框:盖住画布,学生没法一边看改进建议一边改图;
* 翻历次评分要一页一页点分页器。现在摆在左栏,画布整列都在右边(设计文档第 6 节)。
*
* viewOnly:手机上只能看、不能画(设计文档 5.6),不给「照着它写代码」「载回画布」。
*/
const { viewOnly = false } = defineProps<{ viewOnly?: boolean }>()
const MyFlowchart = defineAsyncComponent(() => import("./MyFlowchart.vue"))
const flowchartStore = useFlowchartStore()
const { phase, scores, hiddenCount, selectedId, details, detailLoading, editor } =
const { phase, scores, hiddenCount, selectedId, details, detailLoading, scoresFailed, editor } =
storeToRefs(flowchartStore)
const problemStore = useProblemStore()
const codeStore = useCodeStore()
const userStore = useUserStore()
// 历次分数原来只由题面(ProblemContent)去读。手机上题面是另一个页签、不切过去就不挂载,
// 直接打开 ?tab=result 的话「流程图点评」一条都没有。这里自己也读一次(同一道题只读一次)
watch(
() => [problemStore.problem?.id, userStore.isAuthed] as const,
([id, authed]) => {
if (id && authed) flowchartStore.ensureLoaded()
},
{ immediate: true },
)
const message = useMessage()
const theme = useThemeVars()
// 暗色主题的成功色是浅薄荷绿,上面的白字看不清
@@ -33,6 +50,23 @@ const current = computed(() => {
const detail = computed(() => (current.value ? details.value[current.value.id] : undefined))
const viewingLatest = computed(() => selectedId.value === null)
// ==================== 这一版的图 ====================
/**
* 点历史分数条上的哪一次,就摆哪一次交上去的图(只读的 Mermaid 渲染,不动画布)。
* 原来的评分弹框左边就是这张图;挪进结果页签时丢了,学生翻到「第 2 次 C 42」
* 只看得到评语,想不起来那一版画成什么样。图从评语同一个详情接口来,随评语一起现拉。
*/
const versionIndex = computed(() =>
current.value ? scores.value.findIndex((row) => row.id === current.value!.id) : -1,
)
const versionTitle = computed(() => {
if (!current.value) return ""
return `第 ${versionIndex.value + 1} 次画的图 · ${parseTime(current.value.createTime, "MM-DD HH:mm")}`
})
/** 收起来之后翻别的次也保持收着:左栏在机房屏上只有六百来像素高 */
const versionOpen = ref(true)
const versionZoom = ref(false)
// 评完的那次、点到的那次,评语都要现拉(历次分数里只有分数)
watch(
() => current.value?.id,
@@ -162,10 +196,18 @@ function goCode() {
<template>
<!-- 设计稿「画流程图:画布占满右栏,AI 点评在左边」「流程图评分中」 -->
<div class="flowchart-result">
<div v-if="scoresFailed && !scores.length && phase !== 'evaluating'" class="failed load-failed">
<span>以前交过的流程图评分没读出来,可能是网络断了一下</span>
<n-button size="small" secondary @click="flowchartStore.ensureLoaded()">再读一次</n-button>
</div>
<n-empty
v-if="!scores.length && phase !== 'evaluating'"
v-else-if="!scores.length && phase !== 'evaluating'"
class="empty"
description="还没交过流程图。画好之后点「交给 AI 点评」,评分和建议会出现在这里"
:description="
viewOnly
? '还没交过流程图。流程图要在电脑上画(手机上拖不动),交给 AI 点评之后,评分和建议在这里也看得到'
: '还没交过流程图。画好之后点「交给 AI 点评」,评分和建议会出现在这里'
"
/>
<!-- 历次分数连成一条:一眼看出是不是越改越好;正在评的那次是虚线框 -->
@@ -229,6 +271,35 @@ function goCode() {
</n-flex>
<template v-if="detail">
<div v-if="detail.mermaidCode" class="version">
<div class="version-head">
<span class="version-title">{{ versionTitle }}</span>
<n-button text type="primary" size="small" @click="versionZoom = true">
放大看
</n-button>
<n-button text size="small" @click="versionOpen = !versionOpen">
{{ versionOpen ? "收起" : "展开" }}
</n-button>
</div>
<div
v-if="versionOpen"
class="version-chart"
role="button"
:aria-label="`放大看${versionTitle}`"
@click="versionZoom = true"
>
<MyFlowchart compact :code="detail.mermaidCode" />
</div>
<n-modal
v-model:show="versionZoom"
preset="card"
:title="versionTitle"
:style="{ maxWidth: '900px' }"
>
<MyFlowchart :code="detail.mermaidCode" />
</n-modal>
</div>
<div v-if="criteria.length" class="criteria">
<div v-for="[name, item] in criteria" :key="name" class="criterion">
<div class="criterion-head">
@@ -254,7 +325,7 @@ function goCode() {
</div>
</div>
<div class="actions">
<div v-if="!viewOnly" class="actions">
<n-button
v-if="isFlowchartPass(current.grade) && canCode"
type="primary"
@@ -369,6 +440,41 @@ function goCode() {
border: 1px solid rgba(240, 160, 32, 0.35);
}
.load-failed {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
}
/* 这一版的图:和题面里「你画的流程图」同一个样子,点一下放大 */
.version {
margin-bottom: 14px;
}
.version-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
}
.version-title {
font-size: 14px;
font-weight: 600;
}
.version-chart {
cursor: zoom-in;
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.06);
padding: 8px;
}
.version-chart :deep(.flowchart-container.compact) {
height: 180px;
}
.score-line {
display: flex;
align-items: baseline;
@@ -5,15 +5,17 @@ import { useMermaid } from "shared/composables/useMermaid"
/**
* 学生自己画到 A/S 的那张流程图。题面里是缩略的一小块(compact),点「放大看」弹出整张。
* 原来是左栏单独一个「我的流程图」页签,评到 A/S 时还会把学生强行切过去。
*
* 结果页签里翻历次评分时也用它摆「这一版的图」,那时由 code 传进来,不读 store。
*/
const props = defineProps<{ compact?: boolean }>()
const props = defineProps<{ compact?: boolean; code?: string }>()
const store = useMyFlowchartStore()
const { renderError, renderFlowchart } = useMermaid()
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
watch(
() => store.mermaidCode,
() => props.code ?? store.mermaidCode,
async (code) => {
if (!code) return
await nextTick()
@@ -150,10 +150,14 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
<n-tag v-if="myStatus === 'solved'" size="small" type="success" :bordered="false">
已解决
</n-tag>
<n-tag v-else-if="myStatus === 'tried'" size="small" type="warning" :bordered="false">
<!-- 流程图画到 A / S 就算做完了(决定 4):别在「流程图 A 级」旁边再写「还没交过」
「做过,还没对」—— 那两个说的是代码 -->
<template v-else-if="!flowchartGrade">
<n-tag v-if="myStatus === 'tried'" size="small" type="warning" :bordered="false">
做过,还没对
</n-tag>
<n-tag v-else-if="myStatus === 'none'" size="small" :bordered="false">还没交过</n-tag>
</template>
<n-tag v-if="flowchartGrade" size="small" type="success" :bordered="false">
流程图 {{ flowchartGrade }} 级
</n-tag>
@@ -12,7 +12,8 @@ import { beginEditTrace, editTraceExtensions } from "oj/problem/utils/editTrace"
import { errorMarkExtensions } from "oj/problem/utils/errorMark"
import EditorToolbar from "./EditorToolbar.vue"
import { useDraftKey } from "../composables/draftKey"
import { useTeacherCollab } from "../composables/teacherCollab"
import { useCollabHere, useTeacherCollab } from "../composables/teacherCollab"
import { useProblemPageContext } from "../composables/problemPageContext"
import CollabBar from "./CollabBar.vue"
import { useFlowchartStore } from "oj/store/flowchart"
@@ -27,6 +28,7 @@ const codeStore = useCodeStore()
const problemStore = useProblemStore()
const collabStore = useCollabStore()
const { problem } = storeToRefs(problemStore)
const ctx = useProblemPageContext()
/**
* 课堂求助的协作就开在这道题上。
@@ -35,9 +37,7 @@ const { problem } = storeToRefs(problemStore)
* 直接跳到题目页、就在页面这一个编辑器里协作 —— 顺带治好了「按一下 Esc 弹框就关、
* 协作跟着结束」:页面上没有弹框可关,结束协作只有工具栏那个按钮和离开这一页两条路。
*/
const collabHere = computed(
() => collabStore.room !== null && collabStore.room.problemId === problem.value?._id,
)
const collabHere = useCollabHere()
/** 协作中的教师:编辑器里是学生的代码,不是他自己的 */
const teacherCollab = useTeacherCollab()
@@ -151,6 +151,20 @@ watch(
},
)
/**
* 画着流程图时忽然不让画了:工具栏的「写代码 / 画流程图」和语言下拉都跟着 canDraw 藏起来,
* 编辑器要是还停在画布上,学生就回不去写代码了。拖窄到手机宽度时页面会换一套布局、编辑器
* 重新挂载,loadCode 自己会把语言改掉;但同一道题重新拉题面(登录后补拉)时老师刚好关掉了
* 流程图,编辑器不重挂,只能靠这里。和工具栏的「写代码」走同一条路,回到他习惯的那门语言
*/
watch(
() => problemStore.canDraw,
(canDraw) => {
if (canDraw || codeStore.code.language !== "Flowchart") return
problemStore.switchLanguage(problemStore.supportedLanguage(codeStore.preferredLanguage()))
},
)
// 流程图编辑器交给 store:结果页签(在左栏,不在编辑器底下)要拿当前画布比一比、
// 把旧版本载回去。子组件 SubmitFlowchart 还是从 inject 拿
const flowchartStore = useFlowchartStore()
@@ -172,12 +186,17 @@ provide("flowchartEditorRef", flowchartEditorRef)
学生自己切到流程图就是不写代码了,编辑器卸载、协作正常结束(SyncCodeEditor
的 detach),这是原来就有的语义。
-->
<FlowchartEditor v-if="codeStore.code.language === 'Flowchart'" ref="flowchartEditorRef" />
<FlowchartEditor
v-if="codeStore.code.language === 'Flowchart'"
ref="flowchartEditorRef"
:height="editorHeight"
/>
<SyncCodeEditor
v-else
v-model:value="codeStore.code.value"
:language="codeStore.code.language"
:problem-id="problem!._id"
:collab="ctx.collab"
:height="editorHeight"
:extra-extensions="editorExtensions"
@update:model-value="changeCode"
@@ -109,6 +109,25 @@ async function listSubmissions() {
listed.value = true
}
/**
* 「我的提交统计」:这一页里各种结果各几次(「答案错误 × 3」「答案正确 × 1」),和原来一样
* 只数当前这一页。被题单闸门锁住的那几次不数 —— 它们在列表里只露一行说明,这里也不该露出结果
*/
const statusDistribution = computed(() => {
const counts = new Map<number, number>()
for (const row of submissions.value) {
if (row.showLink) counts.set(row.result, (counts.get(row.result) ?? 0) + 1)
}
return [...counts.entries()]
.sort((a, b) => a[0] - b[0])
.map(([result, count]) => ({
result,
name: JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.name ?? "未知",
type: JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.type ?? "info",
count,
}))
})
/** 列表是新的在前;「第几次」从最早那次数起 */
function attemptNo(index: number) {
return total.value - (query.page - 1) * query.limit - index
@@ -220,6 +239,19 @@ watch(query, () => {
</n-button>
</div>
<div v-if="statusDistribution.length" class="distribution">
<n-tag
v-for="item in statusDistribution"
:key="item.result"
:type="item.type"
size="small"
round
:bordered="false"
>
{{ item.name }} × {{ item.count }}
</n-tag>
</div>
<n-empty
v-if="listed && !submissions.length"
class="empty"
@@ -333,6 +365,13 @@ watch(query, () => {
</template>
<style scoped>
.distribution {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.rank {
display: flex;
flex-wrap: wrap;
@@ -4,6 +4,8 @@ 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"
@@ -30,6 +32,8 @@ const { submission, syntaxErrorInfo, resultSegment, formattedBeforeSubmit } =
storeToRefs(submissionStore)
const { problem } = storeToRefs(useProblemStore())
const codeStore = useCodeStore()
const ctx = useProblemPageContext()
const { isDesktop } = useBreakpoints()
/** 协作中的老师自己还没交过:先摆学生最近一次交的 */
const teacherCollab = useTeacherCollab()
@@ -37,8 +41,25 @@ const teacherCollab = useTeacherCollab()
const theme = useThemeVars()
const { samplesRunAt } = submissionStore.trial
type Segment = "submit" | "samples" | "custom"
const segments = computed<{ value: Segment; label: string; time: string }[]>(() => [
/**
* 手机上流程图题只能看点评、不能画(设计文档 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"
const codeSegments = computed<{ value: Segment; label: string; time: string }[]>(() => [
{
value: "submit",
// 语法没过就没交上去:那一段不叫「提交结果」
@@ -64,32 +85,55 @@ const canTrial = computed(() => {
const languages = problem.value?.languages ?? []
return !languages.includes("SQL")
})
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 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>
<template>
<FlowchartResult v-if="drawing" />
<div v-show="!drawing" class="result-pane">
<!-- 设计稿:三个胶囊,选中的深色实心,带上那次结果的时间 -->
<div v-if="canTrial" class="segments" role="tablist" aria-label="结果">
<div v-if="showSegments" class="segments" role="tablist" aria-label="结果">
<button
v-for="segment in segments"
:key="segment.value"
type="button"
role="tab"
class="segment"
:class="{ active: resultSegment === segment.value }"
:aria-selected="resultSegment === segment.value"
@click="resultSegment = segment.value"
:class="{ active: isActive(segment.value) }"
:aria-selected="isActive(segment.value)"
@click="pick(segment.value)"
>
{{ segment.label }}<template v-if="segment.time"> · {{ segment.time }}</template>
</button>
<span v-if="resultSegment === 'custom'" class="segment-note"
<span v-if="!viewingFlowchart && resultSegment === 'custom'" class="segment-note"
>自己编数据试试,这里不判对错</span
>
</div>
<FlowchartResult v-if="viewingFlowchart && flowchartReadable" view-only />
<!-- 提交结果用 v-show:切去看运行例子时不卸载,挂着的错误说明在编辑器里标着红 -->
<div v-show="resultSegment === 'submit' || !canTrial">
<div v-show="!viewingFlowchart && (resultSegment === 'submit' || !canTrial)">
<template v-if="syntaxErrorInfo">
<ResultHeader kind="warning" title="代码有语法错误,还没有交上去" />
<PythonErrorExplain :err-info="syntaxErrorInfo" />
@@ -107,7 +151,7 @@ const canTrial = computed(() => {
description="还没有提交过。写完代码按「提交」,结果会出现在这里"
/>
</div>
<template v-if="canTrial">
<template v-if="canTrial && !viewingFlowchart">
<SampleRunResult v-if="resultSegment === 'samples'" />
<CustomRun v-else-if="resultSegment === 'custom'" />
</template>
@@ -10,7 +10,12 @@ import { SubmissionStatus } from "utils/constants"
/**
* 「结果」页签的标题:带上这一次的状态。用图标而不是一个彩色小圆点 ——
* 机房投影、色弱的学生只靠颜色分不出对错。
*
* 新结果出来、学生还没点开看过:「结果」两个字跟着状态上色、加粗。看过一次就算已读,
* 图标留着、不再强调(设计文档 5.3)。流程图评完时学生在看题目不会被强制切过去,
* 靠的就是这一下提醒
*/
const props = defineProps<{ active: boolean }>()
const theme = useThemeVars()
const { submission, syntaxErrorInfo, judging, pending, submitting } =
@@ -44,10 +49,29 @@ const status = computed(() => {
return { icon: "ph:warning-bold", color: theme.value.warningColor }
return { icon: "ph:x-bold", color: theme.value.errorColor }
})
/**
* 这一次结果的身份:哪条提交判成了什么、第几次流程图评分、哪一次语法没过。
* 换了就是新结果,要重新提醒
*/
const resultKey = computed(() => {
if (!status.value) return ""
if (codeStore.code.language === "Flowchart")
return `flowchart:${flowchart.evaluatedSeq.value}:${flowchart.phase.value}`
if (syntaxErrorInfo.value) return `syntax:${syntaxErrorInfo.value}`
return `submission:${submission.value?.id}:${submission.value?.result}`
})
const readKey = ref("")
watchEffect(() => {
if (props.active) readKey.value = resultKey.value
})
const unread = computed(
() => !!status.value && !status.value.spin && !props.active && resultKey.value !== readKey.value,
)
</script>
<template>
<span class="label">
<span class="label" :class="{ unread }" :style="unread ? { color: status!.color } : undefined">
结果
<Icon
v-if="status"
@@ -66,6 +90,10 @@ const status = computed(() => {
gap: 4px;
}
.unread {
font-weight: 700;
}
.spin {
animation: spin 1s linear infinite;
}
@@ -76,8 +76,10 @@ function settleReview() {
function closeCommentPanel() {
commentPanel.value = false
// 「下一题」在结果页签里(LessonNext),评价完回到那里 —— 学生点评之前可能切去看了题目
submissionStore.revealResult()
// 「下一题」在结果页签里(LessonNext),评价完回到那里 —— 学生点评之前可能切去看了题目。
// 接着要换题的就别切:切页签会 router.replace 改 `?tab=`,排在下面那次 push 前头,
// 把 push 当成被新导航取消,人就停在原题
if (!navigationAfterReview) submissionStore.revealResult()
settleReview()
}
@@ -14,6 +14,7 @@ import type { Submission } from "utils/types"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { useProblemStore } from "oj/store/problem"
import { useSubmissionStore } from "oj/store/submission"
import { markdownToText } from "oj/problem/utils/plainText"
import PythonErrorExplain from "./PythonErrorExplain.vue"
import RuntimeErrorExplain from "./RuntimeErrorExplain.vue"
import WrongAnswerExplain from "./WrongAnswerExplain.vue"
@@ -219,7 +220,9 @@ const customRunHint = computed(() => {
(sample) => `${sample.input} ${sample.output}`.match(/-?\d+(?:\.\d+)?/g) ?? [],
),
)
const numbers = [...new Set(problem.description.match(/-?\d+(?:\.\d+)?/g) ?? [])]
// 先摊成纯文字:题面是 HTML,446 道题带着 style="color: rgb(51, 51, 51)",直接取数会建议「拿 51 试试」
const description = markdownToText(problem.description, Infinity)
const numbers = [...new Set(description.match(/-?\d+(?:\.\d+)?/g) ?? [])]
.filter((value) => !inSamples.has(value))
.slice(0, 2)
return numbers.length
@@ -15,7 +15,7 @@ const message = computed(() => {
switch (props.result) {
case SubmissionStatus.compile_error:
return props.language === "Python"
? "代码没能跑起来,写法有错误。按「提交代码」会先检查一遍,告诉你错在哪一行。"
? "代码有语法错误,程序一行都没跑。最常见的是用了中文标点(,:()“”)。按「提交」会先检查一遍,告诉你错在哪一行,中文标点能一键换成英文。"
: "代码没能编译通过,写法有错误。看看是不是漏了分号、括号没配对、变量没声明。"
case SubmissionStatus.runtime_error:
return "程序运行到一半出错,停下来了。检查一下输入是怎么读的、下标有没有越界、除数是不是 0。"
@@ -52,8 +52,11 @@ const requirement = computed(() => {
const TEXT_LIMIT = 2000
const onSample = computed(() => props.check.result === SubmissionStatus.wrong_answer)
// plain(比赛)连差异类型都不算:把空格画成「·」、把不一样的那段标红,和提示句一样是格式线索
const kind = computed(() =>
onSample.value ? classifyDiff(props.check.expected ?? "", props.check.output ?? "") : null,
onSample.value && !props.plain
? classifyDiff(props.check.expected ?? "", props.check.output ?? "")
: null,
)
const hint = computed(() => {
if (props.check.passed) return ""
@@ -95,7 +98,7 @@ function splitDiff(expected: string, output: string) {
}
const diffParts = computed(() => {
if (!onSample.value || diffLine.value < 0) return null
if (!onSample.value || props.plain || diffLine.value < 0) return null
const expected = lines(props.check.expected ?? "")[diffLine.value] ?? ""
const output = lines(props.check.output ?? "")[diffLine.value] ?? ""
return splitDiff(expected, output)
@@ -9,6 +9,7 @@ import { compressToBase64, copyToClipboard } from "utils/functions"
import storage from "utils/storage"
import { useDraftKey } from "./draftKey"
import { useTeacherCollab } from "./teacherCollab"
import { useProblemPageContext } from "./problemPageContext"
/**
* 「课堂统计」弹窗开着没有。弹窗挂在题目页上(StatisticsModal),菜单可能在工具栏里、
@@ -34,11 +35,15 @@ export function useEditorMenu(statisticsInline: Ref<boolean>) {
const { isDesktop } = useBreakpoints()
const teacherCollab = useTeacherCollab()
const draftKey = useDraftKey()
const ctx = useProblemPageContext()
const options = computed<DropdownOption[]>(() => {
const items: DropdownOption[] = []
// 「本题提交」挪到了「我的提交」抽屉的底部
if (!statisticsInline.value && userStore.isTeacherOrAbove) {
// 「本题提交」挪到了「我的提交」抽屉的底部;题单入口没有抽屉,给管理员角色留在这里
if (ctx.value.allSubmissionsInMenu && userStore.isAdminRole) {
items.push({ label: "本题提交", key: "allSubmissions" })
}
if (ctx.value.classStats && !statisticsInline.value && userStore.isTeacherOrAbove) {
items.push({ label: "课堂统计", key: "statistics" })
}
if (codeStore.code.language !== "Flowchart") {
@@ -92,6 +97,16 @@ export function useEditorMenu(statisticsInline: Ref<boolean>) {
window.open(router.resolve(url).href, "_blank")
}
function goAllSubmissions() {
const href = router.resolve({
name: "submissions",
query: { problem: problem.value!._id },
}).href
// 协作中走新标签,跳走这一页协作就断了(设计文档第 9 节)
if (teacherCollab.value) window.open(href, "_blank")
else router.push(href)
}
/** 菜单项被点了;不是这张菜单里的 key 返回 false,调用方自己处理(页签行的抽屉) */
function select(key: string) {
switch (key) {
@@ -110,6 +125,9 @@ export function useEditorMenu(statisticsInline: Ref<boolean>) {
case "edit":
goEdit()
return true
case "allSubmissions":
goAllSubmissions()
return true
}
return false
}
@@ -32,6 +32,22 @@ interface EntryRules {
backToProblemSet: boolean
/** 举手求助 */
help: boolean
/**
* 老师协作绑在这一页的编辑器上。房间只记了题号(`room.problemId` 是 displayId),
* 比赛题的编号常是 1、2、3 —— 老师帮着学生做公开题 1 的时候打开比赛题 1,会绑上
* 学生的文档、挂出协作条、禁掉语言选择
*/
collab: boolean
/**
* 教师的「课堂统计」。统计接口按 displayId 在公开题库里找题(`submission-statistics.ts`),
* 比赛题号拿过去查到的是另一道公开题,或者 404
*/
classStats: boolean
/**
* 「看这道题所有人的提交」放进「⋯」(只给管理员角色)。它平时在「我的提交」抽屉底部,
* 题单入口没有抽屉,老师就没地方点了
*/
allSubmissionsInMenu: boolean
}
const RULES: Record<ProblemEntry, EntryRules> = {
@@ -43,6 +59,9 @@ const RULES: Record<ProblemEntry, EntryRules> = {
reviewAfterAccepted: true,
backToProblemSet: false,
help: true,
collab: true,
classStats: true,
allSubmissionsInMenu: false,
},
// 题单里什么「以前的」都不给看:加入题单之前的提交、统计、点评都能拿来抄答案
problemset: {
@@ -55,6 +74,9 @@ const RULES: Record<ProblemEntry, EntryRules> = {
reviewAfterAccepted: false,
backToProblemSet: true,
help: true,
collab: true,
classStats: true,
allSubmissionsInMenu: true,
},
contest: {
drawers: ["info", "submission"],
@@ -66,6 +88,9 @@ const RULES: Record<ProblemEntry, EntryRules> = {
reviewAfterAccepted: false,
backToProblemSet: false,
help: false,
collab: false,
classStats: false,
allSubmissionsInMenu: false,
},
}
@@ -1,6 +1,7 @@
import { useProblemStore } from "oj/store/problem"
import { useCollabStore } from "shared/store/collab"
import { useUserStore } from "shared/store/user"
import { useProblemPageContext } from "./problemPageContext"
/**
* 老师正在这道题上协作:编辑器里是**学生的**代码,「我的提交」看的是学生的,
@@ -8,11 +9,21 @@ import { useUserStore } from "shared/store/user"
*/
export function useTeacherCollab() {
const userStore = useUserStore()
const collabHere = useCollabHere()
return computed(() => userStore.isTeacherOrAbove && collabHere.value)
}
/**
* 协作房间开在这道题上(老师、学生两边都算)。比赛入口一律不算:房间只记了题号,
* 比赛题号和公开题撞号是常态,见 problemPageContext 的 `collab`
*/
export function useCollabHere() {
const collabStore = useCollabStore()
const problemStore = useProblemStore()
const ctx = useProblemPageContext()
return computed(
() =>
userStore.isTeacherOrAbove &&
ctx.value.collab &&
collabStore.room !== null &&
collabStore.room.problemId === problemStore.problem?._id,
)
@@ -18,10 +18,14 @@ export function useSubmissionMonitor() {
// ==================== 轮询机制 ====================
const { pause: pausePolling, resume: resumePolling } = useIntervalFn(
async () => {
if (!submissionId.value) return
const id = submissionId.value
if (!id) return
try {
const res = await getSubmission(submissionId.value)
const res = await getSubmission(id)
// 请求在路上时换了题(reset)或者又交了一次:回来的是上一条,扔掉。
// 不扔的话上一道题判对了,会把新题标成已解决、弹点评
if (id !== submissionId.value) return
submission.value = res
const result = res.result
@@ -76,7 +80,9 @@ export function useSubmissionMonitor() {
// 结果已经到手,别让重连再去重放这条早就判完的订阅
unsubscribe()
getSubmission(submissionId.value).then((res) => {
const id = submissionId.value
getSubmission(id).then((res) => {
if (id !== submissionId.value) return // 同上面轮询:换过题就扔掉
submission.value = res
// 15分钟无新提交则断开WebSocket(节省资源)
scheduleDisconnect(15 * 60 * 1000)
@@ -137,6 +143,9 @@ export function useSubmissionMonitor() {
const reset = () => {
pausePolling()
unsubscribe()
// 连接由 store 持有,离开题目页组件卸了它也还在。和判完之后一样,空闲 15 分钟断开;
// 不在这里立刻断,是因为刚交的那条判完之后还可能推成就过来
scheduleDisconnect(15 * 60 * 1000)
submissionId.value = ""
submission.value = undefined
}
@@ -16,16 +16,27 @@ const JUDGE0_COMPILE_ERROR = 6
const isJudge0RuntimeError = (status: number) => status >= 7 && status <= 12
/**
* 把 Judge0 的状态换成判题机那套码,好直接交给 WrongAnswerExplain 和 JUDGE_STATUS。
* 跑完、只是输出对不上的,按答案错误算。
* Python 的语法错误。Judge0 的 Python 没有编译这一步,SyntaxError 回来的是运行时错误
* (status 11),得看回溯的最后一行才认得出来
*/
export function trialStatus(status: number | null, passed: boolean) {
const PYTHON_SYNTAX_ERROR = /^(SyntaxError|IndentationError|TabError): /m
/**
* 把 Judge0 的状态换成判题机那套码,好直接交给 WrongAnswerExplain 和 JUDGE_STATUS。
* 跑完、只是输出对不上的,按答案错误算。Python 的语法错误按编译失败算:
* 学生最常犯的中文冒号、中文括号,原来在这里被说成「程序运行到一半出错……下标有没有越界」
*/
export function trialStatus(status: number | null, passed: boolean, output: string) {
if (status === JUDGE0_ACCEPTED) {
return passed ? SubmissionStatus.accepted : SubmissionStatus.wrong_answer
}
if (status === JUDGE0_TIME_LIMIT) return SubmissionStatus.real_time_limit_exceeded
if (status === JUDGE0_COMPILE_ERROR) return SubmissionStatus.compile_error
if (status !== null && isJudge0RuntimeError(status)) return SubmissionStatus.runtime_error
if (status !== null && isJudge0RuntimeError(status)) {
return PYTHON_SYNTAX_ERROR.test(output)
? SubmissionStatus.compile_error
: SubmissionStatus.runtime_error
}
return SubmissionStatus.system_error
}
@@ -88,7 +99,7 @@ export function useTrialRun() {
input: sample.input,
expected: sample.output,
output: run.output,
result: trialStatus(run.status, passed),
result: trialStatus(run.status, passed, run.output),
}
})
samplesRunAt.value = new Date().toISOString()
@@ -103,7 +114,7 @@ export function useTrialRun() {
if (mine !== customGeneration) return
customOutput.value = run.output
// 自己输入的没有标准答案,跑完就算「对」,只看有没有出错
customResult.value = trialStatus(run.status, true)
customResult.value = trialStatus(run.status, true, run.output)
customRunning.value = false
}
+23 -5
View File
@@ -98,6 +98,13 @@ const drawer = ref<DrawerKey | null>(null)
const leftPane = useTemplateRef<HTMLElement>("leftPane")
const theme = useThemeVars()
/**
* 分隔条两头按像素卡,不按比例:左栏窄于 360 时题单条的「下一题」箭头被裁掉、三个抽屉按钮
* 折成两行;右栏窄于 460 时工具栏收进「⋯」之后也放不下、语言下拉被挤成两行(原来 0.8 在 1280 屏上只给右栏 256)
*/
const { width: windowWidth } = useWindowSize()
const splitMax = computed(() => `${Math.max(360, windowWidth.value - 460)}px`)
// 协作中的老师看的是学生的提交
const teacherCollab = useTeacherCollab()
const drawerLabel = (key: DrawerKey) =>
@@ -151,8 +158,14 @@ const dialog = useDialog()
*/
let approvedTarget: string | null = null
function confirmLeavingHelp(to: RouteLocationNormalized, from: RouteLocationNormalized) {
if (collabStore.isTeacher || collabStore.helpStatus === "idle") return true
if (to.name === from.name && to.params.problemID === from.params.problemID) return true
// 协作中的老师:「页面即协作现场」,跳走这一页协作就断了、求助退回排队。所以上下文条、
// 相似题、下一题这些站内链接一律开新标签,这一页留着(设计文档第 9 节)
if (teacherCollab.value) {
window.open(router.resolve(to).href, "_blank")
return false
}
if (collabStore.isTeacher || collabStore.helpStatus === "idle") return true
if (approvedTarget === to.fullPath) {
approvedTarget = null
return true
@@ -187,9 +200,12 @@ watch(
)
// 结果页签的标题带状态图标(ResultTabLabel),n-tab-pane 的 tab 接受渲染函数
const resultTab = () => h(ResultTabLabel)
const resultTab = () => h(ResultTabLabel, { active: currentTab.value === "result" })
// 连着换了几次题,先发的请求后回来的话,地址栏是 C、页面却是 B:只认最后一次
let initSeq = 0
async function init() {
const mine = ++initSeq
// 「我的流程图」是上一道题的。这道题也画到了 A/S 的话,SubmitFlowchart 查完会再亮出来
myFlowchartStore.hide()
// 并行预取右侧编辑器 chunk(CodeMirror ~370K+),
@@ -197,8 +213,10 @@ async function init() {
loadProblemEditor()
try {
const res = await getProblem(problemID, contestID)
if (mine !== initSeq) return
problem.value = res
} catch (err) {
if (mine !== initSeq) return
problem.value = null
if (errorCode(err) === "contest-not-started") {
errMsg.value = "比赛还没有开始"
@@ -247,8 +265,8 @@ onBeforeUnmount(() => {
class="problem-split"
direction="horizontal"
:default-size="0.43"
:min="0.2"
:max="0.8"
min="360px"
:max="splitMax"
:resize-trigger-size="1"
>
<template #1>
@@ -274,7 +292,7 @@ onBeforeUnmount(() => {
:aria-selected="currentTab === 'result'"
@click="currentTab = 'result'"
>
<ResultTabLabel />
<ResultTabLabel :active="currentTab === 'result'" />
</button>
</div>
<div v-if="ctx.drawers.length" class="drawer-buttons">
+9 -3
View File
@@ -13,7 +13,13 @@ export function markdownToText(markdown: string, max = 120) {
.replace(/\$\$?([^$]*)\$\$?/g, "$1")
.replace(/^\s{0,3}(#{1,6}|>|[-*+]|\d+\.)\s+/gm, "")
.replace(/(\*\*|__|\*|_|~~)(.+?)\1/g, "$2")
.replace(/\s+/g, " ")
.trim()
return text.length > max ? `${text.slice(0, max)}…` : text
// 题面是富文本编辑器存的 HTML:标签去掉之后还剩 &quot; &nbsp; 这些实体,得换回字
const decoded = decodeEntities(text).replace(/\s+/g, " ").trim()
return decoded.length > max ? `${decoded.slice(0, max)}…` : decoded
}
/** 走浏览器自己的 HTML 解析,标签在前面已经去掉了,这里只剩实体 */
function decodeEntities(text: string) {
if (!text.includes("&")) return text
return new DOMParser().parseFromString(text, "text/html").documentElement.textContent ?? text
}
+59 -9
View File
@@ -71,6 +71,11 @@ export const useFlowchartStore = defineStore("flowchart", () => {
/** 看过的几次的完整评语,按 id 缓存(评完就不会再变) */
const details = ref<Record<string, FlowchartSubmission>>({})
const detailLoading = ref(false)
/**
* 进题时历次分数没读出来。原来读失败就当没交过,结果页签写着「还没交过流程图」——
* 学生明明交过五次,会以为记录丢了
*/
const scoresFailed = ref(false)
/** 画布本身。结果页签要拿当前画布和某一版比一比、把那一版载回去 */
const editor = shallowRef<FlowchartEditorInstance | null>(null)
@@ -81,6 +86,11 @@ export const useFlowchartStore = defineStore("flowchart", () => {
const lastSubmittedMermaidCode = ref("")
/** 最近一次分数是哪道题的;换题后置空,下次挂载时重新拉 */
let loadedFor: number | null = null
/**
* 换一次题(reset)加一。每个 await 回来都要对一下:请求在路上时学生换了题,
* 回来的是上一道题的东西,不能落到这一道上
*/
let generation = 0
// ==================== 评分结果监听 ====================
/**
@@ -92,6 +102,8 @@ export const useFlowchartStore = defineStore("flowchart", () => {
* 有兜底轮询,流程图这边没有,丢一次消息按钮就一直转圈转到用户自己刷新。
*/
let monitoringId = ""
/** 正在跟的那一次是哪道题的,结算时再对一遍 */
let monitoringProblemId: number | null = null
const { pause: pausePolling, resume: resumePolling } = useIntervalFn(
async () => {
@@ -141,20 +153,26 @@ export const useFlowchartStore = defineStore("flowchart", () => {
/** 不再跟这一次评分:结算完、超时、或者换了题 */
function stopMonitoring() {
monitoringId = ""
monitoringProblemId = null
unsubscribe()
pausePolling()
stopPollingFallback()
stopPollingDeadline()
loading.value = false
// 连接挂在 store 上,离开题目页也不会跟着组件断。不立刻断:接着还可能再交一次,
// A/S 之后的成就也走这条连接推过来。和判题那边一样,闲 15 分钟再断
scheduleDisconnect(15 * 60 * 1000)
}
function settle(submissionId: string, outcome: Outcome) {
// 一个学生可能同时开着几道题的页面,每条连接都订在同一个用户 topic 上,
// 别的页面的评分结果照样会推到这里来 —— 必须认 id,不然会张冠李戴
if (!submissionId || submissionId !== monitoringId) return
// 换题时 reset 已经把 monitoringId 清了,这里再对一遍题号:别的题评完的分数、烟花、
// 「这节课做完」、A/S 那张图,都不能落到眼前这道题上
const problemId = monitoringProblemId
stopMonitoring()
// 评完了就不急着断:接着还可能再交一次。和判题那边一样,闲 15 分钟再断
scheduleDisconnect(15 * 60 * 1000)
if (!problemId || problemStore.problem?.id !== problemId) return
if (!outcome.ok) {
phase.value = "failed"
@@ -170,8 +188,22 @@ export const useFlowchartStore = defineStore("flowchart", () => {
if (isFlowchartPass(outcome.grade) && lastSubmittedMermaidCode.value) {
myFlowchartStore.show(lastSubmittedMermaidCode.value)
}
const problemId = problemStore.problem?.id
if (problemId) loadScores(problemId)
// 先把这一次记进历次分数,再去拉全量:拉失败的话,结果页签照样看得到这次的分数,
// 不会还停在上一次(第一次交时则是「还没交过流程图」)
if (!scores.value.some((row) => row.id === submissionId)) {
scores.value = [
...scores.value,
{
id: submissionId,
score: outcome.score,
grade: outcome.grade,
createTime: new Date().toISOString(),
},
]
}
loadScores(problemId).catch((error) => {
console.error("[Flowchart] 刷新历次评分失败:", error)
})
}
const handleWebSocketMessage = (data: FlowchartEvaluationUpdate) => {
@@ -208,6 +240,7 @@ export const useFlowchartStore = defineStore("flowchart", () => {
return
}
const gen = generation
lastSubmittedMermaidCode.value = mermaidCode
loading.value = true
phase.value = "evaluating"
@@ -226,16 +259,21 @@ export const useFlowchartStore = defineStore("flowchart", () => {
},
})
// 请求在路上时换了题:这一次照常评完落库,回到那道题时在历次分数里看得到;
// 但不能再跟它 —— 评完会把分数、烟花、「做完了」都落到新题上
if (gen !== generation) return
if (response.submissionId) {
// 订阅提交更新,同时开启轮询兜底。connect() 幂等;没连上时 subscribe 会先记着
cancelScheduledDisconnect()
connect()
monitoringId = response.submissionId
monitoringProblemId = problem.id
subscribe(response.submissionId)
startPollingFallback()
startPollingDeadline()
}
} catch (error) {
if (gen !== generation) return
loading.value = false
phase.value = "idle"
// 按错误码分支(见 utils/api.ts 的约定)。限流是最容易撞上的一种:
@@ -272,9 +310,11 @@ export const useFlowchartStore = defineStore("flowchart", () => {
const problemId = problemStore.problem?.id
if (!problemId || loadedFor === problemId) return
loadedFor = problemId
const gen = generation
try {
await loadScores(problemId)
if (problemStore.problem?.id !== problemId) return
scoresFailed.value = false
const passed = scores.value.filter((row) => isFlowchartPass(row.grade)).at(-1)
if (passed) {
const detail = await fetchDetail(passed.id)
@@ -283,17 +323,22 @@ export const useFlowchartStore = defineStore("flowchart", () => {
if (detail?.mermaidCode) myFlowchartStore.show(detail.mermaidCode)
}
} catch (error) {
// 拉不到就当没交过;下次挂载再试
loadedFor = null
console.error("[Flowchart] 读取历次评分失败:", error)
// 换过题了:这是上一道题的失败,别把新题的状态改掉
if (gen !== generation) return
// 下次挂载、或者结果页签里点「再读一次」时重试
loadedFor = null
if (!scores.value.length) scoresFailed.value = true
}
}
async function fetchDetail(id: string) {
const cached = details.value[id]
if (cached) return cached
const gen = generation
const detail = await getFlowchartSubmission(id)
details.value[id] = detail
// 换过题就不进缓存:reset 刚清过,别把上一道题的塞回去
if (gen === generation) details.value[id] = detail
return detail
}
@@ -302,13 +347,14 @@ export const useFlowchartStore = defineStore("flowchart", () => {
selectedId.value = id
const target = id ?? scores.value.at(-1)?.id
if (!target || details.value[target]) return
const gen = generation
detailLoading.value = true
try {
await fetchDetail(target)
} catch {
message.error("这一次的评语读不出来,过一会儿再试")
if (gen === generation) message.error("这一次的评语读不出来,过一会儿再试")
} finally {
detailLoading.value = false
if (gen === generation) detailLoading.value = false
}
}
@@ -318,6 +364,7 @@ export const useFlowchartStore = defineStore("flowchart", () => {
* 连卸载都不会 —— 不收的话上一道还在评的那次评完,会把它的图挂到新题上。
*/
function reset() {
generation++
stopMonitoring()
latestRating.value = { score: 0, grade: "" }
phase.value = "idle"
@@ -325,6 +372,8 @@ export const useFlowchartStore = defineStore("flowchart", () => {
hiddenCount.value = 0
selectedId.value = null
details.value = {}
detailLoading.value = false
scoresFailed.value = false
lastSubmittedMermaidCode.value = ""
loadedFor = null
}
@@ -348,6 +397,7 @@ export const useFlowchartStore = defineStore("flowchart", () => {
selectedId,
details,
detailLoading,
scoresFailed,
editor,
attachEditor,
submit,
+7 -1
View File
@@ -2,6 +2,7 @@ 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"
export const useProblemStore = defineStore("problem", () => {
const problem = ref<ProblemDetail | null>(null)
@@ -59,12 +60,17 @@ export const useProblemStore = defineStore("problem", () => {
}
/**
* 这道题收不收当前语言,不收就退到它支持的第一种(SQL 题只有 "SQL",硬编码的
* 这道题收不收当前语言,不收就退到学生习惯的那门、再退到它支持的第一种(SQL 题只有 "SQL",硬编码的
* Python 会被后端拒绝)。编辑器每次载入代码之前都要过一遍:草稿的键里带着语言,
* 先载入再改语言,编辑器里摆的就是另一种语言的模板。
*/
function supportedLanguage(current: LANGUAGE): LANGUAGE {
if (languages.value.includes(current)) return current
// 当前这门这道题不给,先回到学生习惯用的那门。current 是 Flowchart 时最要紧:
// 在能画图的题上画着图换到不能画的题,直接取第一项会把偏好 C 的学生扔到 Python,
// 而且 code store 的 watch 会把 Python 记成新的偏好
const preferred = useCodeStore().preferredLanguage()
if (preferred !== "Flowchart" && languages.value.includes(preferred)) return preferred
// 兜底落在编程语言上:languages 的第一项可能是 Flowchart,不能默认把人扔到画布上
return codeLanguages.value[0] ?? languages.value[0] ?? "Python"
}
+11 -2
View File
@@ -89,6 +89,10 @@ export const useSubmissionStore = defineStore("submission", () => {
async function submit(entry: { contestId: string; problemSetId: string }) {
const problem = problemStore.problem
if (!problem) return
// 格式化、提交都要等请求回来,这中间学生可能已经点了「下一题」或课堂条上的别的题。
// 换了题就什么都别再写:不然 A 格式化好的代码会盖掉 B 的编辑器(连带 B 的草稿),
// 或者开始跟 A 的那条提交,A 判对了却把 B 标成已解决、弹 B 的点评
const stale = () => problemStore.problem?.id !== problem.id
syntaxErrorInfo.value = ""
formattedBeforeSubmit.value = false
@@ -102,11 +106,13 @@ export const useSubmissionStore = defineStore("submission", () => {
code: codeStore.code.value,
language: formatLang,
})
if (stale()) return
if (res.code !== codeStore.code.value) {
codeStore.setCode(res.code)
formattedBeforeSubmit.value = true
}
} catch (e) {
if (stale()) return
if (errorCode(e) === "syntax-error") {
// 仅 Python 会出现:message 是 CPython 的报错原文,交给 PythonErrorExplain 翻译
syntaxErrorInfo.value = errorMessage(e)
@@ -124,9 +130,10 @@ export const useSubmissionStore = defineStore("submission", () => {
problemId: problem.id,
language: codeStore.code.language,
code: codeStore.code.value,
// 编辑过程信号,见 utils/editTrace.ts。协作的判断和 ProblemEditor 的 collabHere 同一个口径
// 编辑过程信号,见 utils/editTrace.ts。协作的判断和 ProblemEditor 的 collabHere
// (teacherCollab.ts 的 useCollabHere)同一个口径:比赛入口不算协作
trace: snapshotEditTrace(
collabStore.room !== null && collabStore.room.problemId === problem._id,
!entry.contestId && collabStore.room !== null && collabStore.room.problemId === problem._id,
),
}
if (entry.contestId) {
@@ -142,6 +149,8 @@ export const useSubmissionStore = defineStore("submission", () => {
try {
const res = await submitCode(data)
console.log(`[Submit] 代码已提交: ID=${res.submissionId}`)
// 这条照常判完落库、题库里查得到,只是这一页已经换了题,不再跟
if (stale()) return
// 交上了才清零;被限流 / 网络失败的话这一段接着记,下次提交一起报
restartEditTrace(codeStore.code.value.length)
+10
View File
@@ -106,6 +106,13 @@ const { query, clearQuery } = usePagination<SubmissionQuery>({
language: useRouteQuery("language", "").value,
today: "0",
})
/**
* 从题目页协作条「看他交过什么」点进来时带着 `exactUsername=1`:用户名按整名匹配。
* 默认是「包含」,而学号互相包含是常态(帮 ks24a1 时会混进 ks24a10–ks24a19)。
* 只对带进来的那个用户名生效,老师在输入框里改了名字就回到原来的包含匹配
*/
const exactUsernameFor =
useRouteQuery<string>("exactUsername", "").value === "1" ? query.username : ""
const submissionID = ref("")
const problemDisplayID = ref("")
const [statisticPanel, toggleStatisticPanel] = useToggle(false)
@@ -168,6 +175,9 @@ async function listSubmissions() {
contestId: (route.params.contestID as string) ?? "",
language: query.language,
today: query.today,
...(exactUsernameFor && query.username === exactUsernameFor
? { exactUsername: "1" as const }
: {}),
})
submissions.value = res.results
total.value = res.total
+4 -1
View File
@@ -24,7 +24,10 @@ import { zonedYear } from "utils/functions"
const route = useRoute()
const { isMobile } = useBreakpoints()
const hiddenICP = computed(() => ["problem", "contest problem"].includes(route.name as string))
// 题目页两栏顶满整屏,三种入口都不放页脚(原来漏了题单入口,页脚压在编辑器底下)
const hiddenICP = computed(() =>
["problem", "contest problem", "problemset problem"].includes(route.name as string),
)
// 版权年份也走东八区:站内不留任何一处按浏览器时区取时间部件的代码,
// 免得下一个人照着抄
@@ -29,11 +29,13 @@ const { addEdges, removeNodes, removeEdges, fitView, onNodesInitialized } = useV
/**
* 进页面读回草稿、载回旧版之后缩放到正好装下整张图。原来画布停在默认的位置和缩放上,
* 图大一点就有一半在外面,学生以为草稿丢了。只在这两种时候缩:拖进一个新节点时也会
* 触发 nodes-initialized,那时候跟着缩放会让画布跳一下
* 触发 nodes-initialized,那时候跟着缩放会让画布跳一下。
* 画布是空的就不挂:空画布不会触发 nodes-initialized,挂着的这一下会一直留到学生拖进
* 第一个节点,正好把画布拉近、对准那一个节点 —— 就是上面要躲开的那一跳
*/
let fitPending = false
function fitSoon() {
fitPending = true
fitPending = nodes.value.length > 0
}
onNodesInitialized(() => {
if (!fitPending) return
@@ -34,6 +34,11 @@ interface Props {
* 切到别的题,接通了也不该把这道题的代码交出去。
*/
problemId?: string
/**
* 这个入口接不接协作。比赛入口传 false:房间只记题号,比赛题号 1、2、3 和公开题撞号,
* 光比对题号挡不住(见 problemPageContext 的 `collab`)
*/
collab?: boolean
}
const {
@@ -44,6 +49,7 @@ const {
placeholder = "",
extraExtensions = [],
problemId = "",
collab = true,
} = defineProps<Props>()
const code = defineModel<string>("value")
@@ -74,7 +80,8 @@ const editorView = shallowRef<EditorView | null>(null)
/** 房间开着,而且开的就是这道题 */
const roomIsHere = computed(
() => collabStore.room !== null && (!problemId || collabStore.room.problemId === problemId),
() =>
collab && collabStore.room !== null && (!problemId || collabStore.room.problemId === problemId),
)
/**
+4 -6
View File
@@ -224,13 +224,11 @@ export function durationToDays(start: Date | string, end: Date | string): string
}
export function secondsToDuration(seconds: number): string {
const duration = intervalToDuration({
start: 0,
end: seconds * 1000,
})
const hours = (duration.days ?? 0) * 24 + (duration.hours ?? 0)
// 直接算,不走 date-fns 的 intervalToDuration:那个会拆出年、月,原来只把天和小时
// 折回小时,超过一个月就把月丢了(剩 40 天 1 小时显示成 217:00:00)
const total = Math.max(0, Math.floor(seconds))
const pad = (n: number) => String(n).padStart(2, "0")
return [hours, pad(duration.minutes ?? 0), pad(duration.seconds ?? 0)].join(":")
return [Math.floor(total / 3600), pad(Math.floor(total / 60) % 60), pad(total % 60)].join(":")
}
export function submissionMemoryFormat(memory: number | string | undefined) {
+11 -4
View File
@@ -34,10 +34,17 @@ export async function createTestSubmission(code: Code, input: string) {
params: { base64_encoded: true, wait: true },
})
const data = response.data
const status: number | null = data.status?.id ?? null
const stdout = base64DecodeUtf8(data.stdout)
// 跑通了(3 = Accepted)就只要程序自己的输出,而且只去掉末尾的空白:
// - C 有警告(隐式声明 sqrt、用了 gets)时 Judge0 跑通了也带回 compile_output,拼进来
// 就成了「你的输出」的一部分,例子永远对不上;后台生成测试点时还会写进 .out
// - 开头的空格是输出的一部分(打印菱形、三角形),原来整体 trim() 会把它削掉,
// 对的代码被判成「只是空格不一样」
if (status === 3) return { status, output: stdout.trimEnd() }
// 没跑通:报错原文给人看,两段拼起来
return {
status: data.status && data.status.id,
output: [base64DecodeUtf8(data.compile_output), base64DecodeUtf8(data.stdout)]
.join("\n")
.trim(),
status,
output: [base64DecodeUtf8(data.compile_output), stdout].join("\n").trim(),
}
}
@@ -1,7 +1,7 @@
# OJ2 设计文档:题目详情页重设计
日期:2026-09-28
状态:**第 14 节的 6 步和第 7 节都已实施**(2026-09-28),界面已照画布「机房(方案 B)」的画板逐块对齐过。和本文有出入的地方:协作中上下文条的跳转没有改成开新标签(老师不在课堂名单里,题库入口下这一条本来就不出现)。效果图在 claude.ai 的私有画布「题目详情页重设计」里(「机房(方案 B)」
状态:**第 14 节的 6 步和第 7 节都已实施**(2026-09-28),界面已照画布「机房(方案 B)」的画板逐块对齐过。2026-09-28 实施后又做过一轮全面审查并修掉查出的问题,和本文原稿有出入的地方都已改进正文。效果图在 claude.ai 的私有画布「题目详情页重设计」里(「机房(方案 B)」
那一页是定稿,「初稿 1280×800」那一页已作废,只留着对照)。**画布管样子,本文管规则**:
版式、颜色、文案照画板,效果图表达不了的规则写在这里。
@@ -169,13 +169,17 @@ B 看得到。
「放回编辑器」后提示「已放回,按 Ctrl+Z 撤回」—— 要确认编辑器的撤销能撤回这次替换。
- **统计**:出题人、创建时间、标签、数字卡、结果饼图、历年通过率,内容原样。
- **点评**:和强制弹窗同一个轮盘(第 7 节),每人只能评一次。
- 题单入口三个按钮都不出现。比赛入口没有「点评」。
- **我的提交**列表上方另有一行「答案错误 × 3」「答案正确 × 1」(这一页各种结果各几次,和原来一样;
被题单闸门锁住的那几次不数)。
- 题单入口三个按钮都不出现;管理员角色的「看这道题所有人的提交」挪进「⋯」(叫「本题提交」)。
比赛入口没有「点评」。
### 5.6 手机
三个页签:题目 / 代码 / 结果,底部固定「运行例子 / 提交」两个按钮(高 44+)。
措辞和桌面一致(「这节课 做完 2/5」「正确输出」)。语言选择放在「代码」页签顶部;
统计 / 点评 / 我的提交和 ⋯ 菜单里的各项放进页签行右侧的「⋯」。流程图题在手机上只能看点评、不能画(节点库是 HTML5 拖放,手机上拖不动)。
统计 / 点评 / 我的提交和 ⋯ 菜单里的各项放进页签行右侧的「⋯」。流程图题在手机上只能看点评、不能画(节点库是 HTML5 拖放,手机上拖不动):「结果」页签多一段
「流程图点评」,只读,没有「照着它写代码」「载回画布」。
## 6. 结果页签里每种情况显示什么
@@ -190,7 +194,7 @@ B 看得到。
| 运行例子(试跑) | ✕ 试跑:N 个例子没对上(不算提交)+ 各例子 ✓/✕ 芯片 | 选中那个例子的差异说明;底部「例子对了也可能在别的测试点上错,提交才算数」 |
| 自己输入 | —— | 输入框 +「用这组数据运行」+「填入例子 1 / 2」+ 输出;「这里不判对错」 |
| 流程图评分中 | AI 正在看你的图(一般 5 秒左右) | 评的时候画布还能改,改了不影响这一次 |
| 流程图评完 | 86 分 A 级 + 一句评语 + 历史分数条 | 四项分数与评语、改进建议;A/S 时「照着它写代码 →」;「把这一版载回画布」**只在画布改过之后**出现 |
| 流程图评完 | 86 分 A 级 + 一句评语 + 历史分数条 | 四项分数与评语、改进建议;点历史分数条上的某一次,看那一版画的图(只读缩略图,可放大,不动画布);A/S 时「照着它写代码 →」;「把这一版载回画布」**只在画布改过之后**出现 |
| 管理员 | —— | 测试点表格原样(只有管理员有数据) |
**运行例子的判定**:两边都去掉末尾空白再比(现在只 trim 了运行输出,末尾带空格的样例永远「不通过」,
@@ -237,6 +241,8 @@ B 看得到。
| 统计 / 点评 / 我的提交 | ✓ | 全部隐藏 | 统计、我的提交 ✓;点评 ✗ |
| 「已解决 / 尝试过」标签 | ✓ | 隐藏 | ✓ |
| 相似题 | ✓ | ✗(点进去会离开题单) | ✗ |
| 老师协作 | ✓ | ✓ | ✗(房间只记题号,比赛题号 1、2、3 和公开题撞号) |
| 课堂统计(教师) | ✓ | ✓ | ✗(统计接口按题号在公开题库里找题) |
| SQL 题 | 隐藏「运行例子」「自己输入」(Judge0 跑不了 SQL);数据表可横向滚动 | 同左 | 同左 |
一道题既在这节课里、又是从题单进来的:按入口路由,显示题单条。
@@ -248,6 +254,9 @@ B 看得到。
(现在的「复制回到题目」走 `setCode`,协作中会经 Yjs 直接覆盖学生的代码)。
- **协作中所有跳转开新标签页**:抽屉里的链接、班级排名旁的「查看」、上下文条都算(现在只有工具栏
「本题提交」这么做)。「看他交过什么」要带上学生用户名,现在只带题号。
实现:抽屉里的链接各自 `window.open`;站内的 router-link(上下文条、相似题、下一题)统一由
`detail.vue` 的路由守卫接住,协作中离开这道题就开新标签、这一页不动。「看他交过什么」带
`exactUsername=1`,提交列表按整名匹配(默认的「包含」会混进学号相近的同学)。
- 老师接单后落在 `/problem/:id`,「结果」页签是空的 —— 显示学生最近一次提交的结果(这一项是新增,
需要接口支持;做不到就显示「学生还没提交过」之类的空状态,别留白)。
- **学生在求助排队中或已接入时换题,会断开协作**(`SyncCodeEditor` 的 detach):换题入口要先确认。
@@ -257,8 +266,9 @@ B 看得到。
1. **提交状态上移到 store**:当前提交、判题跟踪(WebSocket + 轮询)、AI 提示、失败计数、
流程图评分(含 WebSocket 订阅与 3 分钟超时)都不再挂在按钮组件上。结果页签只负责显示。
这是修掉「编辑器一卸载结果就丢」的前提。
2. **错误标记也进 store**:`PythonErrorExplain` / `RuntimeErrorExplain` 现在挂载时标红、卸载时清掉,
切到「题目」页签红线就没了。改成由结果决定,和显示组件解耦。
2. **错误标记不能跟着页签消失**:`PythonErrorExplain` / `RuntimeErrorExplain` 挂载时标红、卸载时清掉,
原来切到「题目」页签红线就没了。实际做法没挪进 store:「结果」页签第一次打开之后就一直挂着、
只是藏起来(`detail.vue` 的 `resultMounted`),标红跟着结果走,换题时由换题重置清掉。
3. **三种入口用同一个编辑器**:合并 `ProblemEditor` 与 `BasicEditor`,协作、错误标记、中文标点替换
在三种入口都有;比赛里是否允许协作由第 8 节的表决定。
4. **换题 = 完整重置**:例子、语言、流程图状态、结果页签、定时器全部重来。`1b149a4` 已经修了
@@ -267,9 +277,12 @@ B 看得到。
## 11. 后端前置改动
1. **流程图 A/S 算完成**:提交流程图的请求带上 `problemSetId`;评分落库后,A/S 走和
`recordSolvedProblem`(`services/problemset.ts`)同样的题单记账;课堂条的 `myStatus`
(`classroom` 路由)把流程图 A/S 算作 accepted。
1. **流程图 A/S 算完成**:评分落库后,A/S 走和 `recordSolvedProblem`(`services/problemset.ts`)
同样的题单记账 —— 和判代码那一路一样,记进**所有已加入、且含这道题**的题单,所以提交请求不用带
`problemSetId`。`classroom` 路由里「流程图 A/S 算做完、交过流程图算做过」是同一个口径,
用在课堂条的 `myStatus`、看板的格子,也用在推断「这节课做哪几道」、「班上 M 人做完」、
看板上「最近几节课来了几次」和猜正在上课的班(用户 2026-09-28 拍板一起改)。
班级对比、个人资料里的通过数仍然只数代码。
2. **流程图历史分数**:新增一个接口,一次返回这道题自己所有流程图提交的 id、分数、等级、时间
(现在 `/flowchart/history` 一次只给一条)。
3. **流程图历史的防抄闸门**:加入题单之前画的图,照 `canViewSubmission`(`routes/submission.ts`)