diff --git a/apps/web/src/admin/problem/components/SQLTestcaseEditor.vue b/apps/web/src/admin/problem/components/SQLTestcaseEditor.vue index 6040f39..5d9cd78 100644 --- a/apps/web/src/admin/problem/components/SQLTestcaseEditor.vue +++ b/apps/web/src/admin/problem/components/SQLTestcaseEditor.vue @@ -185,11 +185,12 @@ async function upload() { const entries = res.info const baseScore = Math.floor(100 / entries.length) const remainder = 100 - baseScore * entries.length + // 只留落库的三个键:上传响应里的 stripped_output_md5 / input_size / + // output_size 保存时会被剥掉,见契约 problemTestCaseScoreSchema const testcases: Testcase[] = entries.map((entry, i) => ({ - ...entry, - score: String( - i === entries.length - 1 ? baseScore + remainder : baseScore, - ), + input_name: entry.input_name, + output_name: entry.output_name, + score: i === entries.length - 1 ? baseScore + remainder : baseScore, })) emit("uploaded", res.id, testcases) diff --git a/apps/web/src/admin/problem/components/TestcaseGenerator.vue b/apps/web/src/admin/problem/components/TestcaseGenerator.vue index 40680b3..2755033 100644 --- a/apps/web/src/admin/problem/components/TestcaseGenerator.vue +++ b/apps/web/src/admin/problem/components/TestcaseGenerator.vue @@ -174,10 +174,9 @@ async function upload() { const baseScore = Math.floor(100 / entries.length) const remainder = 100 - baseScore * entries.length const testcases: Testcase[] = entries.map((entry, i) => ({ - ...entry, - score: String( - i === entries.length - 1 ? baseScore + remainder : baseScore, - ), + input_name: entry.input_name, + output_name: entry.output_name, + score: i === entries.length - 1 ? baseScore + remainder : baseScore, })) emit("uploaded", res.id, testcases) diff --git a/apps/web/src/admin/problem/detail.vue b/apps/web/src/admin/problem/detail.vue index ae424b7..d07645a 100644 --- a/apps/web/src/admin/problem/detail.vue +++ b/apps/web/src/admin/problem/detail.vue @@ -288,17 +288,16 @@ function resetTemplate(language: LANGUAGE) { async function handleUploadTestcases({ file }: UploadCustomRequestOptions) { try { + // 失败走 catch —— 原来这里还有一句 `if (res.error)`(拿 { error, data } + // 信封当返回值),被 @ts-ignore 压着,信封拆掉之后就是一段死代码了 const res = await uploadTestcases(file.file!, { sql: isSQLProblem.value }) - // @ts-ignore - if (res.error) { - message.error("上传测试用例失败") - return - } // score 不在上传响应里,前端按测试点数量平分补上 const entries = res.info const testcases: Testcase[] = entries.map((entry) => ({ - ...entry, - score: (100 / entries.length).toFixed(0), + input_name: entry.input_name, + output_name: entry.output_name, + // 取值与原来的 `.toFixed(0)` 逐字相同,只是不再包成字符串 + score: Number((100 / entries.length).toFixed(0)), })) problem.value.testCaseScore = testcases problem.value.testCaseId = res.id diff --git a/apps/web/src/utils/types.ts b/apps/web/src/utils/types.ts index 6685452..6dc46f8 100644 --- a/apps/web/src/utils/types.ts +++ b/apps/web/src/utils/types.ts @@ -101,10 +101,12 @@ export type { export type { UploadTestCaseResponse } from "@oj2/contract" /** - * 题目表单里的测试点:上传返回的条目 + 前端本地算出的分值。 - * `score` **不在响应里** —— 是上传完成后按测试点数量平分补上去的。 + * 题目表单里的测试点。落库的只有 {input_name, output_name, score} 三个键 —— + * 上传响应里多出来的 stripped_output_md5 / input_size / output_size 在保存时 + * 被剥掉(旧后端的 serializer 也是这么干的,见契约 problemTestCaseScoreSchema)。 + * score 不在上传响应里,是上传完成后按测试点数量平分补上去的。 */ -export type Testcase = TestCaseEntry & { score: string } +export type { ProblemTestCaseScore as Testcase } from "@oj2/contract" /** * 题目详情。以契约的 ProblemDetail 为准,只在这里补两处前端自己的窄化: @@ -140,18 +142,11 @@ export type { /** 后台题目详情:比 oj 侧多 answers / testCase* / astRules */ export type AdminProblem = Omit< ContractAdminProblem, - | "languages" - | "template" - | "testCaseScore" - | "samples" - | "answers" - | "astRules" + "languages" | "template" | "answers" | "astRules" > & { languages: LANGUAGE[] template: { [key in LANGUAGE]?: string } - // 测试点条目的键名由判题沙箱定,保持 snake_case - testCaseScore: Testcase[] - samples: { input: string; output: string }[] + // 契约里 answers[].language 是 string,这里收窄成 LANGUAGE answers: { language: LANGUAGE; code: string }[] astRules?: AstRules | null } @@ -470,7 +465,6 @@ import type { AdminExercise, AdminTutorial, CreateSubmissionRequest, - TestCaseEntry, } from "@oj2/contract" /** diff --git a/packages/contract/src/admin.ts b/packages/contract/src/admin.ts index 3a9a105..bb1a0d7 100644 --- a/packages/contract/src/admin.ts +++ b/packages/contract/src/admin.ts @@ -43,6 +43,9 @@ export const createAnnouncementRequestSchema = z.object({ export const updateAnnouncementRequestSchema = createAnnouncementRequestSchema +export type ProblemSample = z.infer +export type ProblemAnswer = z.infer +export type ProblemTestCaseScore = z.infer export type AdminAnnouncement = z.infer export type AdminAnnouncementListItem = z.infer< typeof adminAnnouncementListItemSchema @@ -530,6 +533,39 @@ export const adminProblemListItemSchema = z.object({ export const adminProblemListSchema = paginatedSchema(adminProblemListItemSchema) +/** + * 题面样例 / 标准答案 / 测试点分值。这三个旧后端都是逐字段校验的 + * (CreateSampleSerializer / CreateAnswerSerializer / CreateTestCaseScoreSerializer), + * 新后端一路写成 `z.record(z.unknown())` **反而比旧的松**。 + * + * 生产库 956 道题逐条比对过:samples 947 条是 {input,output}(另外 9 条是空数组, + * SQL 题没样例)、answers 268 条是 {language,code}(633 条 null、55 条空数组)。 + */ +export const problemSampleSchema = z.object({ + input: z.string(), + output: z.string(), +}) + +export const problemAnswerSchema = z.object({ + language: z.string(), + code: z.string(), +}) + +/** + * 测试点分值。**只有这三个键** —— 上传接口的响应比这多三个 + * (stripped_output_md5 / input_size / output_size),旧后端的 DRF serializer + * 收下时把多的丢掉,生产库 956 行全是三键。zod 的 object 默认也剥未知键, + * 行为一致。 + * + * score 前端算出来是字符串(`(100 / n).toFixed(0)`),旧后端 IntegerField 转成 + * 整数落库,7990 条全是 int —— 这里用 coerce 保持同一个落库形状。 + */ +export const problemTestCaseScoreSchema = z.object({ + input_name: z.string().max(32), + output_name: z.string().max(32), + score: z.coerce.number().int().min(0), +}) + /** 后台题目详情:包含 oj 侧永不下发的 answers / testCase* / astRules */ export const adminProblemSchema = z.object({ id: z.number().int(), @@ -538,9 +574,9 @@ export const adminProblemSchema = z.object({ description: z.string(), inputDescription: z.string(), outputDescription: z.string(), - samples: z.array(z.record(z.string(), z.unknown())), + samples: z.array(problemSampleSchema), testCaseId: z.string(), - testCaseScore: z.array(z.record(z.string(), z.unknown())), + testCaseScore: z.array(problemTestCaseScoreSchema), hint: z.string().nullable(), languages: z.array(z.string()), template: z.record(z.string(), z.string()), @@ -564,7 +600,7 @@ export const adminProblemSchema = z.object({ mermaidCode: z.string().nullable(), flowchartHint: z.string().nullable(), astRules: z.unknown(), - answers: z.array(z.record(z.string(), z.unknown())), + answers: z.array(problemAnswerSchema), prompt: z.string().nullable(), sqlConfig: sqlConfigSchema.nullable(), sqlDisplay: sqlDisplaySchema.nullable(), @@ -576,9 +612,9 @@ export const createProblemRequestSchema = z.object({ description: z.string(), inputDescription: z.string(), outputDescription: z.string(), - samples: z.array(z.record(z.string(), z.unknown())), + samples: z.array(problemSampleSchema), testCaseId: z.string().regex(/^[a-zA-Z0-9]+$/).max(32), - testCaseScore: z.array(z.record(z.string(), z.unknown())), + testCaseScore: z.array(problemTestCaseScoreSchema), timeLimit: z.number().int().min(1).max(1000 * 60), memoryLimit: z.number().int().min(1).max(1024), languages: z.array(z.string()).min(1), @@ -589,7 +625,7 @@ export const createProblemRequestSchema = z.object({ hint: z.string().nullable().default(null), source: z.string().max(256).nullable().default(null), prompt: z.string().nullable().default(null), - answers: z.array(z.record(z.string(), z.unknown())).default([]), + answers: z.array(problemAnswerSchema).default([]), shareSubmission: z.boolean(), allowFlowchart: z.boolean().default(false), showFlowchart: z.boolean().default(false),