From e7b95d211d2f03441998182a41c0cccdbf81a96a Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 29 Sep 2026 00:07:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E9=A2=98=E7=9B=AE):=20=E4=BF=AE=E6=8E=89?= =?UTF-8?q?=E9=A2=98=E7=9B=AE=E9=A1=B5=E9=87=8D=E6=9E=84=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E6=9F=A5=E5=87=BA=E7=9A=84=E9=97=AE=E9=A2=98=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E9=80=94=E4=B8=AD=E6=8D=A2=E9=A2=98=E4=BC=9A?= =?UTF-8?q?=E6=8A=8A=E7=BB=93=E6=9E=9C=E7=AE=97=E5=88=B0=E6=96=B0=E9=A2=98?= =?UTF-8?q?=E4=B8=8A=E3=80=81=E8=BF=90=E8=A1=8C=E4=BE=8B=E5=AD=90=E6=8A=8A?= =?UTF-8?q?=E5=AF=B9=E7=9A=84=E8=8F=B1=E5=BD=A2=E5=88=A4=E9=94=99=E3=80=81?= =?UTF-8?q?=E7=82=B9=E8=AF=84=E5=AE=8C=E3=80=8C=E4=B8=8B=E4=B8=80=E9=A2=98?= =?UTF-8?q?=E3=80=8D=E4=B8=8D=E8=B7=B3=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 题目页重构之后按「功能对照 / 状态 / 结果页 / 布局与后端」四路审查了一遍,查出的问题一起修: 提交与换题 - 提交、画流程图途中换题: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 --- apps/api/src/routes/contest.ts | 14 +-- apps/api/src/routes/flowchart.ts | 32 +++-- apps/api/src/services/contest.ts | 6 +- .../src/oj/problem/components/CollabBar.vue | 3 +- .../src/oj/problem/components/ContextBar.vue | 5 +- .../oj/problem/components/EditorToolbar.vue | 2 +- .../oj/problem/components/FlowchartResult.vue | 114 +++++++++++++++++- .../src/oj/problem/components/MyFlowchart.vue | 6 +- .../oj/problem/components/ProblemContent.vue | 12 +- .../oj/problem/components/ProblemEditor.vue | 29 ++++- .../problem/components/ProblemSubmission.vue | 39 ++++++ .../src/oj/problem/components/ResultPane.vue | 62 ++++++++-- .../oj/problem/components/ResultTabLabel.vue | 30 ++++- .../problem/components/SubmissionEffects.vue | 6 +- .../problem/components/SubmissionResult.vue | 5 +- .../oj/problem/components/TrialErrorNote.vue | 2 +- .../problem/components/WrongAnswerExplain.vue | 7 +- .../src/oj/problem/composables/editorMenu.ts | 22 +++- .../problem/composables/problemPageContext.ts | 25 ++++ .../oj/problem/composables/teacherCollab.ts | 13 +- .../composables/useSubmissionMonitor.ts | 15 ++- .../src/oj/problem/composables/useTrialRun.ts | 23 +++- apps/web/src/oj/problem/detail.vue | 28 ++++- apps/web/src/oj/problem/utils/plainText.ts | 12 +- apps/web/src/oj/store/flowchart.ts | 68 +++++++++-- apps/web/src/oj/store/problem.ts | 8 +- apps/web/src/oj/store/submission.ts | 13 +- apps/web/src/oj/submission/list.vue | 10 ++ apps/web/src/shared/components/Beian.vue | 5 +- .../components/FlowchartEditor/index.vue | 6 +- .../src/shared/components/SyncCodeEditor.vue | 9 +- apps/web/src/utils/functions.ts | 10 +- apps/web/src/utils/judge.ts | 15 ++- ...2026-09-28-problem-page-redesign-design.md | 31 +++-- 34 files changed, 579 insertions(+), 108 deletions(-) diff --git a/apps/api/src/routes/contest.ts b/apps/api/src/routes/contest.ts index 59ec6bc..05df784 100644 --- a/apps/api/src/routes/contest.ts +++ b/apps/api/src/routes/contest.ts @@ -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) diff --git a/apps/api/src/routes/flowchart.ts b/apps/api/src/routes/flowchart.ts index 2af4452..3b4f9ea 100644 --- a/apps/api/src/routes/flowchart.ts +++ b/apps/api/src/routes/flowchart.ts @@ -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 ?? "", diff --git a/apps/api/src/services/contest.ts b/apps/api/src/services/contest.ts index a7a3379..e6d61d4 100644 --- a/apps/api/src/services/contest.ts +++ b/apps/api/src/services/contest.ts @@ -86,7 +86,7 @@ export async function canAccessContest( return { ok: false as const, code: "wrong-password", - message: "Wrong password or password expired", + message: "比赛密码不对,或者已经过期了,请重新输入", } } } @@ -94,7 +94,7 @@ export async function canAccessContest( 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) diff --git a/apps/web/src/oj/problem/components/CollabBar.vue b/apps/web/src/oj/problem/components/CollabBar.vue index 9df01fb..221f2c9 100644 --- a/apps/web/src/oj/problem/components/CollabBar.vue +++ b/apps/web/src/oj/problem/components/CollabBar.vue @@ -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") } diff --git a/apps/web/src/oj/problem/components/ContextBar.vue b/apps/web/src/oj/problem/components/ContextBar.vue index 0fc1193..fdf989f 100644 --- a/apps/web/src/oj/problem/components/ContextBar.vue +++ b/apps/web/src/oj/problem/components/ContextBar.vue @@ -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(() => { 还剩 {{ contestBar.remaining }} - 已结束 + {{ contestBar.notStarted ? "还没开始" : "已结束" }} 排名 diff --git a/apps/web/src/oj/problem/components/EditorToolbar.vue b/apps/web/src/oj/problem/components/EditorToolbar.vue index d7294df..56f8ddd 100644 --- a/apps/web/src/oj/problem/components/EditorToolbar.vue +++ b/apps/web/src/oj/problem/components/EditorToolbar.vue @@ -226,7 +226,7 @@ const changeLanguage = (v: LANGUAGE) => { diff --git a/apps/web/src/oj/problem/components/FlowchartResult.vue b/apps/web/src/oj/problem/components/FlowchartResult.vue index aa27033..2ef5174 100644 --- a/apps/web/src/oj/problem/components/FlowchartResult.vue +++ b/apps/web/src/oj/problem/components/FlowchartResult.vue @@ -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() {