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
+579 -108

No files matched your search

@@ -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
}