From 4b6242ba70b7cfb19e4bab1fffaf07ebdb55a2c6 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 30 Aug 2026 09:01:56 -0600 Subject: [PATCH] =?UTF-8?q?fix(=E6=AF=94=E8=B5=9B):=20=E6=AF=94=E8=B5=9B?= =?UTF-8?q?=E8=BF=9B=E8=A1=8C=E4=B8=AD=EF=BC=8C=E5=AD=A6=E7=94=9F=E6=89=93?= =?UTF-8?q?=E5=BC=80=E6=AF=94=E8=B5=9B=E9=A2=98=E5=BF=85=20500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit contest.ts 两处把「藏起来的难度」表示成空串,而 problemDifficultySchema 是严格的三值枚举,parse 直接抛 ZodError。contestDetailsAllowed 在 「比赛进行中 + 看的人不是管理员」时就是假 —— 也就是正常学生在正常比赛里, 比赛题列表和详情页一开就挂。dev 库 contest 表一直是空的,这条路径没被跑到过。 改成下发 null,契约里给这两个字段单独起了 maskedProblemDifficultySchema。 语义上对齐旧 Django:ProblemSafeSerializer 把 difficulty 放在 exclude 里, 字段整个不下发,而不是给一个假值。 端上顺着 null 补了四处:ProblemInfo 的「难度」项整条不渲染(和旧栈表现一致), 题目列表、题单列表同理,transforms 的 ProblemFiltered.difficulty 也放开为可空。 这几处是 vue-tsc 逐个揪出来的,正是严格枚举该起的作用。 实测(学生 / 超管 × 比赛题 / 普通题 四种组合):学生看比赛题详情与列表 从 500 变 200 且 difficulty 为 null,超管仍拿到真值 Low,普通题不受影响; 页面上「难度」整项消失,统计面板其余内容正常。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016DHxhKNxXfG89JnVzHbvgj --- apps/api/src/routes/contest.ts | 4 ++-- apps/web/src/oj/problem/components/ProblemInfo.vue | 3 ++- apps/web/src/oj/problem/list.vue | 4 +++- .../components/ProblemSetProblemsList.vue | 1 + apps/web/src/oj/transforms.ts | 2 +- apps/web/src/utils/types.ts | 3 ++- packages/contract/src/problem.ts | 14 ++++++++++++-- 7 files changed, 23 insertions(+), 8 deletions(-) diff --git a/apps/api/src/routes/contest.ts b/apps/api/src/routes/contest.ts index f430874..aa05e36 100644 --- a/apps/api/src/routes/contest.ts +++ b/apps/api/src/routes/contest.ts @@ -144,7 +144,7 @@ contestRoutes.get("/contests/:id/problems", optionalAuth, requireContestAccess(" title: problem.title, submissionNumber: allowed ? problem.submissionNumber : 0, acceptedNumber: allowed ? problem.acceptedNumber : 0, - difficulty: allowed ? problem.difficulty : "", + difficulty: allowed ? problem.difficulty : null, createdBy: sampleUser(user, realName), tags: tags.get(problem.id) ?? [], contestId: contest.id, @@ -179,7 +179,7 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireCont lastUpdateTime: row.problem.lastUpdateTime, timeLimit: row.problem.timeLimit, memoryLimit: row.problem.memoryLimit, - difficulty: allowed ? row.problem.difficulty : "", + difficulty: allowed ? row.problem.difficulty : null, source: row.problem.source, prompt: row.problem.prompt, submissionNumber: allowed ? row.problem.submissionNumber : 0, diff --git a/apps/web/src/oj/problem/components/ProblemInfo.vue b/apps/web/src/oj/problem/components/ProblemInfo.vue index 02047c3..06d470f 100644 --- a/apps/web/src/oj/problem/components/ProblemInfo.vue +++ b/apps/web/src/oj/problem/components/ProblemInfo.vue @@ -121,7 +121,8 @@ onMounted(() => { {{ parseTime(problem.createTime) }} - + + {{ DIFFICULTY[problem.difficulty] }} diff --git a/apps/web/src/oj/problem/list.vue b/apps/web/src/oj/problem/list.vue index a690ed2..6194d5c 100644 --- a/apps/web/src/oj/problem/list.vue +++ b/apps/web/src/oj/problem/list.vue @@ -173,7 +173,9 @@ const baseColumns: DataTableColumn[] = [ key: "difficulty", width: 100, render: (row) => - h(NTag, { type: getTagColor(row.difficulty) }, () => row.difficulty), + row.difficulty + ? h(NTag, { type: getTagColor(row.difficulty) }, () => row.difficulty) + : null, }, { title: renderTableTitle("标签", "streamline-ultimate-color:attachment"), diff --git a/apps/web/src/oj/problemset/components/ProblemSetProblemsList.vue b/apps/web/src/oj/problemset/components/ProblemSetProblemsList.vue index e419691..65590c6 100644 --- a/apps/web/src/oj/problemset/components/ProblemSetProblemsList.vue +++ b/apps/web/src/oj/problemset/components/ProblemSetProblemsList.vue @@ -54,6 +54,7 @@ function handleProblemClick(problemId: string) { diff --git a/apps/web/src/oj/transforms.ts b/apps/web/src/oj/transforms.ts index 38504e9..edd56d5 100644 --- a/apps/web/src/oj/transforms.ts +++ b/apps/web/src/oj/transforms.ts @@ -8,7 +8,7 @@ export function filterResult(result: ProblemListItem): ProblemFiltered { id: result.id, _id: result._id, title: result.title, - difficulty: DIFFICULTY[result.difficulty], + difficulty: result.difficulty ? DIFFICULTY[result.difficulty] : null, tags: result.tags, submission: result.submissionNumber, rate: getACRate(result.acceptedNumber, result.submissionNumber), diff --git a/apps/web/src/utils/types.ts b/apps/web/src/utils/types.ts index 07810f2..6f4f9e7 100644 --- a/apps/web/src/utils/types.ts +++ b/apps/web/src/utils/types.ts @@ -176,7 +176,8 @@ export interface ProblemFiltered { _id: string id: number title: string - difficulty: "简单" | "中等" | "困难" + // 比赛进行中难度不下发,见 contract 的 maskedProblemDifficultySchema + difficulty: "简单" | "中等" | "困难" | null tags: string[] submission: number rate: string diff --git a/packages/contract/src/problem.ts b/packages/contract/src/problem.ts index 57bbb6d..9a926ca 100644 --- a/packages/contract/src/problem.ts +++ b/packages/contract/src/problem.ts @@ -9,6 +9,16 @@ import { paginatedSchema, sampleUserSchema } from "./common" */ export const problemDifficultySchema = z.enum(["Low", "Mid", "High"]) +/** + * 题目详情/列表里的难度。**可为 null** —— 比赛进行中、看的人又不是管理员时, + * 难度和提交数一样属于「先别告诉参赛者」的信息(旧 Django 的 + * `ProblemSafeSerializer` 直接把 difficulty 放进 exclude,字段整个不下发)。 + * + * 端上必须当「没有」处理,不要拿它去查 DIFFICULTY 映射表 —— 这正是 + * problemDifficultySchema 写成严格枚举要防的那件事。 + */ +export const maskedProblemDifficultySchema = problemDifficultySchema.nullable() + /** * SQL 题配置与展示数据。两者都是 `problem.sql_config` / `problem.sql_display` * 的 **JSONB 原文**,所以键名保持 snake_case —— 生产库 9 道 SQL 题存的就是这个形状 @@ -292,7 +302,7 @@ export const problemDetailSchema = z.object({ lastUpdateTime: z.string().nullable(), timeLimit: z.number().int(), memoryLimit: z.number().int(), - difficulty: problemDifficultySchema, + difficulty: maskedProblemDifficultySchema, source: z.string().nullable(), prompt: z.string().nullable(), submissionNumber: z.number().int(), @@ -327,7 +337,7 @@ export const problemListItemSchema = z.object({ title: z.string(), submissionNumber: z.number().int(), acceptedNumber: z.number().int(), - difficulty: problemDifficultySchema, + difficulty: maskedProblemDifficultySchema, createdBy: sampleUserSchema, tags: z.array(z.string()), contestId: z.number().int().nullable(),