refactor(契约): 补齐 80 个类型导出,前端不再手抄形状

契约原来有 184 个 schema 但只导出了 104 个类型,缺的那 80 个前端只能照着
手抄一遍 —— 这是「表格列静默空白」那一类 bug 的根因(d3348f9、2edb8cf,
以及上一个 commit 修的 Top100 两列)。现在 186 个 schema 对 186 个类型,
一一对应,下次要用直接 import。

补导出是机械的(fooSchema → Foo),零命名冲突。真正有价值的是换的过程中
契约逼出来的 5 处分歧 —— 手抄那份在说谎,而 vue-tsc 拦不住,因为类型说它是对的:

- Tutorial.createdBy 手抄成了可选的 `User`(即 AdminUser,带 email、
  rawPassword),后端下发的是必有的 SampleUser。读 createdBy.email 会拿到
  undefined。列表页因此被迫写 `row.createdBy?.username` 和 `row.createdAt!`,
  换成契约类型后两处断言都不需要了。
- TutorialListItem 手抄成 `Omit<Tutorial, "content">`,但后端列表接口连 code
  一起省了 —— 类型声称 code 在。
- Testcase 手抄成 `{input_name, output_name, score}`:响应实际有 5 个字段,
  且**没有 score**。score 是上传完成后前端按测试点数量平分补上去的,手抄那份
  把本地字段说成了响应字段。现在写成 `TestCaseEntry & { score: string }`。
- Tag 手抄成 `{id, name}`,契约是 `{id, name, problemCount}` —— shared/api.ts
  只好用 `Tag & { problemCount: number }` 把丢掉的补回来。
- CreateMessage 是旧后端按名字投递的形状(sender/recipient/submission),
  契约要的是 recipientId/submissionId。全仓零引用,删掉。

同时删掉另外两个零引用的手写类型:LANGUAGE_SHOW_LABEL、UserAdminType;
本地重复的 SampleUser 换成契约的;oj/problem/list.vue 里本地第三份 Tag 改成
`ContractTag & { checked: boolean }`。

需要收窄的一律**从契约派生再收窄**,字段名跟着契约走,只有真正本地的那一两个
键是自己的:

    export type Exercise = Omit<AdminExercise, "data"> & { data: 七种题型的联合 }
    export type SubmitCodePayload =
      Omit<CreateSubmissionRequest, "language"> & { language: LANGUAGE }

保留不动的窄化:StatisticInfo / SubmissionInfo(判题 JSONB 原文,snake_case)、
SQLDisplay*、ProblemFiltered(视图模型)、Exercise*Data(契约里 data 就是
Record<string, unknown>,七种题型结构不同,后端本来也不校验)。
types.ts 的手写 interface 从 31 个降到 22 个。

顺带修的代码:admin/tutorial/detail.vue 新建教程的表单对象缺三个后端产出的
字段,加了 TutorialEdit(对齐 BlankProblem / BlankContest 的写法);三处测试点
上传原来是拿响应对象原地塞 score,改成 map 出新对象,分数算法一字未改。

验证:apps/api tsc(7.0.2) 0 error、check:routes 168 条无遮蔽、
apps/web vue-tsc 0 error、vite build 通过。改动绝大部分在类型层,运行时只有
测试点上传那三处(等价替换)—— **那条路径要传 zip 才能实跑,没有实打**,
只做了代码等价性核对。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 18:07:01 -06:00
parent 78a2fb4fce
commit d3b05b8629
18 changed files with 193 additions and 105 deletions

View File

@@ -1,4 +1,3 @@
import { LANGUAGE_SHOW_VALUE } from "./constants"
import type {
AdminProblem as ContractAdminProblem,
SubmissionDetail,
@@ -9,6 +8,7 @@ import type {
SessionUser,
UserProfile,
EmbeddedSubmission as ContractEmbeddedSubmission,
Message as ContractMessage,
Grade,
ProblemDetail,
ProblemDifficulty,
@@ -32,9 +32,6 @@ export interface AcmProblemsStatus {
}
}
export type UserAdminType =
"Regular User" | "Student Admin" | "Teacher Admin" | "Super Admin"
/**
* 后台用户管理里的用户。`rawPassword` 是明文密码,只有超管专属接口下发 ——
* 老师要能查学生密码,见契约 adminUserSchema 的注释。
@@ -86,24 +83,17 @@ export interface SQLDisplay {
| { changed_tables: SQLDisplayTable[] }
}
export type LANGUAGE_SHOW_LABEL =
(typeof LANGUAGE_SHOW_VALUE)[keyof typeof LANGUAGE_SHOW_VALUE]
export type SUBMISSION_RESULT =
-2 | -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10
export type ProblemStatus = "passed" | "failed" | "not_test"
interface SampleUser {
id: number
username: string
realName: string | null
}
export interface Tag {
id: number
name: string
}
/**
* 题目标签。用契约的 —— 它比手抄那份多一个 `problemCount`
* shared/api.ts 原来还得用 `Tag & { problemCount: number }` 把它补回来。
*/
export type { Tag } from "@oj2/contract"
export type {
AdminTag,
@@ -113,16 +103,18 @@ export type {
GenerateSqlTestCaseResponse,
} from "@oj2/contract"
export interface TestcaseUploadedReturns {
id: string
info: Testcase[]
}
/**
* 上传测试点的返回。取契约 —— 手抄那份少了三个字段
* stripped_output_md5 / input_size / output_size。这套键名保持 snake_case
* 是因为它会原样落进 problem.test_case_score 和判题沙箱读的 info 文件。
*/
export type { UploadTestCaseResponse as TestcaseUploadedReturns } from "@oj2/contract"
export interface Testcase {
input_name: string
output_name: string
score: string
}
/**
* 题目表单里的测试点:上传返回的条目 + 前端本地算出的分值。
* `score` **不在响应里** —— 是上传完成后按测试点数量平分补上去的。
*/
export type Testcase = TestCaseEntry & { score: string }
/**
* 题目详情。以契约的 ProblemDetail 为准,只在这里补两处前端自己的窄化:
@@ -275,11 +267,9 @@ export interface Code {
value: string
}
export interface SubmitCodePayload {
problemId: number
/** 提交代码的请求体。取契约的形状,只把 language 收窄成前端的 LANGUAGE 联合 */
export type SubmitCodePayload = Omit<CreateSubmissionRequest, "language"> & {
language: LANGUAGE
code: string
contestId?: number
}
// ==================== 流程图相关类型 ====================
@@ -465,52 +455,56 @@ export interface Announcement extends AnnouncementEdit {
/** 列表不下发正文:公告是 8MB 上限的富文本,列表页只显示标题 */
export type AnnouncementListItem = Omit<Announcement, "content">
export interface Message {
id: number
sender: SampleUser
createTime: string
message: string
/**
* 站内信。取契约的形状,只把 `submission` 换成前端窄化过的那个
* statisticInfo / language 在契约里是 unknown见 EmbeddedSubmission
*/
export type Message = Omit<ContractMessage, "submission"> & {
submission: EmbeddedSubmission
}
export interface CreateMessage {
sender: string
recipient: string
submission: string
message: string
}
/**
* 题目表情。三个类型都直接取自契约 —— 语义 key 必须与后端 reaction/models.py
* 的 ReactionType 一致(见根 CLAUDE.md手抄一份迟早对不上。
*
* 注意 `ReactionCounts` 是 Partial 的:后端只下发有票的类型,没人投的键不出现。
*/
export type {
ReactionKey,
ReactionCounts,
ReactionState,
} from "@oj2/contract"
import type { ReactionKey, SampleUser } from "@oj2/contract"
export type ReactionKey =
| "too_easy"
| "too_hard"
| "confusing"
| "buggy"
| "learned"
| "interesting"
| "want_explain"
/**
* 教程。直接取契约 —— 手抄的那份把 `createdBy` 写成了可选的 `User`(后端下发的是
* SampleUser`createdAt` / `updatedAt` 也写成了可选,列表页因此被迫写
* `row.createdBy?.username` 和 `row.createdAt!`。
*
* 列表项也用契约的:它比 `Omit<Tutorial, "content">` **还少一个 code** ——
* 后端列表接口连 code 一起省了,手抄那份声称它在。
*/
export type {
AdminTutorial as Tutorial,
AdminTutorialListItem as TutorialListItem,
} from "@oj2/contract"
import type {
AdminExercise,
AdminTutorial,
CreateSubmissionRequest,
TestCaseEntry,
} from "@oj2/contract"
export type ReactionCounts = Record<ReactionKey, number>
export interface ReactionState {
mine: ReactionKey | null
counts: ReactionCounts | null
}
export interface Tutorial {
id: number
title: string
content: string
code: string
isPublic: boolean
order: number
type: "python" | "c"
createdBy?: User
updatedAt?: string
createdAt?: string
}
/** 后台教程列表不下发正文:教程正文是整篇 markdown列表只排序和切换可见性 */
export type TutorialListItem = Omit<Tutorial, "content">
/**
* 教程编辑表单。只留可编辑字段 —— createdBy / createdAt / updatedAt 由后端产出,
* 新建时压根不存在(对齐 BlankProblem / BlankContest 的写法)。
*
* `code` 收窄成 string读回来时统一 `?? ""`,代码编辑器的 v-model 不接受 null
*/
export type TutorialEdit = Omit<
AdminTutorial,
"createdBy" | "createdAt" | "updatedAt" | "code"
> & { code: string }
export interface ExerciseMcqData {
question: string
@@ -555,12 +549,13 @@ export interface ExerciseGroupData {
answer: number[]
}
export type ExerciseType =
"mcq" | "sort" | "fill" | "match" | "predict" | "debug" | "group"
export type { ExerciseType } from "@oj2/contract"
export interface Exercise {
id: number
type: ExerciseType
/**
* 练习题。契约里 `data` 是 Record<string, unknown>(各题型结构不同,后端不校验),
* 前端按题型收窄成判别联合 —— 组件靠它区分七种题型的字段。
*/
export type Exercise = Omit<AdminExercise, "data"> & {
data:
| ExerciseMcqData
| ExerciseSortData
@@ -569,7 +564,6 @@ export interface Exercise {
| ExercisePredictData
| ExerciseDebugData
| ExerciseGroupData
order: number
}
export type {