From df76e7f101dd5da46a85dd9f45dc25e09eb5f88f Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 13 Oct 2026 01:00:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=8F=90=E4=BA=A4=E8=AF=A6=E6=83=85):=20?= =?UTF-8?q?=E5=8D=95=E7=8B=AC=E6=89=93=E5=BC=80=E7=9A=84=E4=B8=80=E6=9D=A1?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E6=94=B9=E6=88=90=E5=B7=A6=E8=BE=B9=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E3=80=81=E5=8F=B3=E8=BE=B9=E4=B8=BA=E4=BB=80=E4=B9=88?= =?UTF-8?q?=E9=94=99=20+=20=E8=BF=99=E9=81=93=E9=A2=98=E4=BA=A4=E7=9A=84?= =?UTF-8?q?=E6=AF=8F=E4=B8=80=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 原来不显示是哪道题、时间只到日、答案错误不说为什么,测试点表 5 行一模一样 - 右边摆题目页那块例子对比(输入 / 正确输出 / 他的输出,差的字标红),编译失败、运行时错误同提交列表右栏的说法,代码里标出那一行 - 「这道题交了 N 次」常驻右栏,← → 一次次翻,看他是怎么改对的;题单藏着的那几次点不进 - 老师:「他的全部提交」和「⋯」(复制两项、重新判题);测试点方块悬停看用时和内存;判题中自己刷新 - 别人的代码、链接不对原来一直转圈,现在说清楚是哪种;自己在题单布置期内的照样看得到是哪道题、什么结果 - 后端详情多给题目名、班级、题单,`?attempts=1` 带上同题的每一次;看不到别人的回 403 而不是 404;提交者本人也拿得到 contestId(比赛里的题号是比赛自己的) Co-Authored-By: Claude Opus 5.5 --- apps/api/src/routes/submission.ts | 109 ++- apps/web/src/oj/api.ts | 7 +- .../problem/components/WrongAnswerExplain.vue | 12 +- .../oj/submission/components/ExplainNote.vue | 82 ++ .../submission/components/SubmissionPane.vue | 144 +--- .../src/oj/submission/composables/explain.ts | 96 +++ apps/web/src/oj/submission/detail.vue | 794 +++++++++++++++--- packages/contract/src/submission.ts | 25 + 8 files changed, 1000 insertions(+), 269 deletions(-) create mode 100644 apps/web/src/oj/submission/components/ExplainNote.vue create mode 100644 apps/web/src/oj/submission/composables/explain.ts diff --git a/apps/api/src/routes/submission.ts b/apps/api/src/routes/submission.ts index 33bafd5..0afd86a 100644 --- a/apps/api/src/routes/submission.ts +++ b/apps/api/src/routes/submission.ts @@ -384,48 +384,99 @@ function listCaseSummary(row: { return { passed: row.casesPassed ?? 0, total: row.casesTotal } } -async function submissionDetail(id: string, user: AuthUser) { +/** + * 提交详情。三种结局:没有这条(404)、别人的代码看不到(403)、能看 —— 能看里又分自己的 + * 代码在题单布置期内藏着(showLink: false,代码不给、别的照给,页面摆出是谁哪道题什么结果)。 + */ +async function submissionDetail(id: string, user: AuthUser, withAttempts: boolean) { const [row] = await db .select({ submission: schema.submission, problem: schema.problem, - contest: schema.contest, + // 当前用户名 / 班级,和提交列表同口径;已删号的学生 user 表里没有行 + currentUsername: schema.user.username, + className: schema.user.className, + problemSetTitle: schema.problemset.title, }) .from(schema.submission) .innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)) - .leftJoin(schema.contest, eq(schema.submission.contestId, schema.contest.id)) + .leftJoin(schema.user, eq(schema.user.id, schema.submission.userId)) + .leftJoin(schema.problemset, eq(schema.problemset.id, schema.submission.problemsetId)) .where(eq(schema.submission.id, id)) .limit(1) - if (!row) return null + if (!row) return { status: "not-found" } as const + const own = row.submission.userId === user.id // 详情也要过闸门。旧后端只挡了列表里的链接,`SubmissionAPI.get`(views/oj.py:103) // 光走 check_user_permission——知道 submission id 直接访问照样拿得到代码,遮挡是虚的。 const cutoffs = - isTeacherOrAbove(user) || row.submission.userId !== user.id + isTeacherOrAbove(user) || !own ? undefined : await problemSetLockCutoffs(user.id, [row.submission.problemId]) - if (!canViewSubmission(user, row.submission, row.problem, cutoffs)) return null + const visible = canViewSubmission(user, row.submission, row.problem, cutoffs) + // 走到这里还看不了、又是自己的,只剩题单布置期这一种(见 canViewSubmission) + if (!visible && !own) return { status: "forbidden" } as const // info(含每个测试点的 test_case 编号与 output_md5)只给管理员,对齐旧后端: // submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与 // SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。 - const full = isAdminRole(user) + const full = isAdminRole(user) && visible + const attempts = withAttempts + ? await db + .select({ + id: schema.submission.id, + createTime: schema.submission.createTime, + userId: schema.submission.userId, + problemId: schema.submission.problemId, + result: schema.submission.result, + }) + .from(schema.submission) + .where( + and( + eq(schema.submission.userId, row.submission.userId), + eq(schema.submission.problemId, row.submission.problemId), + row.submission.contestId === null + ? isNull(schema.submission.contestId) + : eq(schema.submission.contestId, row.submission.contestId), + ), + ) + .orderBy(schema.submission.createTime, schema.submission.id) + .limit(200) + : null return { - id: row.submission.id, - createTime: row.submission.createTime, - userId: row.submission.userId, - username: row.submission.username, - code: row.submission.code, - result: row.submission.result, - info: full ? row.submission.info : {}, - language: row.submission.language, - statisticInfo: asRecord(row.submission.statisticInfo), - // contest 也在旧后端的排除名单里,同样只给管理员 - contestId: full ? row.submission.contestId : null, - problemId: row.submission.problemId, - // problem 表本来就 join 了,不额外查库 - problemDisplayId: row.problem.displayId, - showLink: true, - caseSummary: caseSummary(row.submission), - } satisfies SubmissionDetail + status: "ok", + data: { + id: row.submission.id, + createTime: row.submission.createTime, + userId: row.submission.userId, + username: row.currentUsername ?? row.submission.username, + code: visible ? row.submission.code : "", + result: row.submission.result, + info: full ? row.submission.info : {}, + language: row.submission.language, + statisticInfo: visible ? asRecord(row.submission.statisticInfo) : {}, + // contest 也在旧后端的排除名单里,同样只给管理员。提交者本人例外:他知道自己在哪场比赛 + // 里交的,不给的话独立页面上「复制回到题目」「题目链接」只能拼出题库的 + // /problem/<题号> —— 比赛里的题号是比赛自己的,指到的是另一道题 + contestId: full || own ? row.submission.contestId : null, + problemId: row.submission.problemId, + // problem 表本来就 join 了,不额外查库 + problemDisplayId: row.problem.displayId, + problemTitle: row.problem.title, + className: row.className, + problemSet: + row.submission.problemsetId !== null && row.problemSetTitle !== null + ? { id: row.submission.problemsetId, title: row.problemSetTitle } + : null, + showLink: visible, + caseSummary: visible ? caseSummary(row.submission) : null, + attempts: + attempts?.map((item) => ({ + id: item.id, + createTime: item.createTime, + result: item.result, + showLink: canViewSubmission(user, item, row.problem, cutoffs), + })) ?? null, + } satisfies SubmissionDetail, + } as const } /** @@ -688,9 +739,13 @@ submissionRoutes.get( submissionRoutes.get("/submissions/:id", requireAuth, async (c) => { const user = c.get("user")! - const data = await submissionDetail(c.req.param("id"), user) - if (!data) { + const detail = await submissionDetail(c.req.param("id"), user, c.req.query("attempts") === "1") + if (detail.status === "not-found") { return failure(c, 404, "submission-not-found", "Submission does not exist") } - return success(c, data) + // 原来看不到也回 404,独立页面分不清「链接不对」和「别人的」,只能一直转圈 + if (detail.status === "forbidden") { + return failure(c, 403, "submission-forbidden", "No permission to view this submission") + } + return success(c, detail.data) }) diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index 12fc53b..f26f560 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -131,10 +131,13 @@ export function getProblemStats(problemID: number) { return api.get(`problems/${problemID}/stats`) } -export async function getSubmission(id: string): Promise { +/** `attempts`:带上这个人这道题交过的每一次(独立的提交详情页用,见契约 attempts) */ +export async function getSubmission(id: string, attempts = false): Promise { const [{ submissionDetailSchema }, response] = await Promise.all([ loadDetailSchemas(), - api.get(`submissions/${encodeURIComponent(id)}`), + api.get(`submissions/${encodeURIComponent(id)}`, { + params: attempts ? { attempts: "1" } : undefined, + }), ]) return contract("GET /submissions/:id", submissionDetailSchema, response) } diff --git a/apps/web/src/oj/problem/components/WrongAnswerExplain.vue b/apps/web/src/oj/problem/components/WrongAnswerExplain.vue index 95230c2..79a9ef1 100644 --- a/apps/web/src/oj/problem/components/WrongAnswerExplain.vue +++ b/apps/web/src/oj/problem/components/WrongAnswerExplain.vue @@ -31,6 +31,11 @@ const props = defineProps<{ * 格式错、漏了一行这类错误,对着题目原话一看就明白;运行例子那边不需要 */ full?: boolean + /** + * 看的是别人的提交(老师在独立的提交详情页看学生的):「你的输出」「你的程序」都换成「他」。 + * 提示句全是对学生说的第二人称,整句换掉人称就读得通 + */ + peer?: boolean }>() const theme = useThemeVars() @@ -63,9 +68,10 @@ const hint = computed(() => { if (props.check.passed) return "" if (!kind.value) { const name = JUDGE_STATUS[props.check.result as keyof typeof JUDGE_STATUS]?.name ?? "出错了" - return `你的程序在这个例子上就「${name}」了,先让它在例子上跑通。` + return `${props.peer ? "他" : "你"}的程序在这个例子上就「${name}」了,先让它在例子上跑通。` } - return explainDiff(kind.value, props.check, props.code, props.language) + const text = explainDiff(kind.value, props.check, props.code, props.language) + return props.peer ? text.replace(/你/g, "他") : text }) const diffLine = computed(() => firstDifferentLine(props.check.expected ?? "", props.check.output ?? ""), @@ -160,7 +166,7 @@ function lines(text: string) {
-
你的输出
+
{{ peer ? "他" : "你" }}的输出