契约原来有 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>
117 lines
3.5 KiB
TypeScript
117 lines
3.5 KiB
TypeScript
import { z } from "zod"
|
||
|
||
/**
|
||
* 评级。`grade()` 返回 S/A/B/C,`averageGrade()` 在没有可用数据时返回空串 ——
|
||
* 空串是真会下发的值,别把它从这里去掉,前端要按「无评级」处理。
|
||
*/
|
||
export const gradeSchema = z.enum(["S", "A", "B", "C", ""])
|
||
|
||
export const durationDataSchema = z.object({
|
||
unit: z.string(),
|
||
index: z.number().int(),
|
||
start: z.string(),
|
||
end: z.string(),
|
||
grade: gradeSchema,
|
||
problemCount: z.number().int(),
|
||
submissionCount: z.number().int(),
|
||
})
|
||
|
||
export const solvedProblemSchema = z.object({
|
||
problem: z.object({
|
||
title: z.string(),
|
||
displayId: z.string(),
|
||
contestTitle: z.string(),
|
||
contestId: z.number().int().nullable(),
|
||
}),
|
||
acTime: z.string(),
|
||
rank: z.number().int().nullable(),
|
||
acCount: z.number().int(),
|
||
grade: gradeSchema,
|
||
periodRank: z.number().int().nullable(),
|
||
periodAcCount: z.number().int(),
|
||
difficulty: z.string(),
|
||
})
|
||
|
||
export const flowchartSummarySchema = z.object({
|
||
problemId: z.string(),
|
||
problemTitle: z.string(),
|
||
submissionCount: z.number().int(),
|
||
bestScore: z.number(),
|
||
bestGrade: z.string(),
|
||
latestSubmissionTime: z.string(),
|
||
avgScore: z.number(),
|
||
})
|
||
|
||
export const aiDetailSchema = z.object({
|
||
user: z.string(),
|
||
className: z.string().nullable(),
|
||
start: z.string(),
|
||
end: z.string(),
|
||
solved: z.array(solvedProblemSchema),
|
||
flowcharts: z.array(flowchartSummarySchema),
|
||
grade: gradeSchema,
|
||
tags: z.record(z.string(), z.number().int()),
|
||
difficulty: z.record(z.string(), z.number().int()),
|
||
contestCount: z.number().int(),
|
||
})
|
||
|
||
export const aiAnalysisRequestSchema = z.object({
|
||
details: z.unknown(),
|
||
duration: z.unknown(),
|
||
})
|
||
|
||
export const aiHintRequestSchema = z.object({ submissionId: z.string().min(1) })
|
||
|
||
export const classAnalysisRequestSchema = z.object({
|
||
comparison: z.record(z.string(), z.unknown()),
|
||
})
|
||
|
||
export const classPkAnalysisRequestSchema = z.object({
|
||
comparisons: z.array(z.record(z.string(), z.unknown())).min(2),
|
||
timeRangeLabel: z.string().default("全部时间"),
|
||
})
|
||
|
||
export const heatmapItemSchema = z.object({
|
||
timestamp: z.number(),
|
||
value: z.number().int(),
|
||
})
|
||
|
||
export const aiAnalysisRecordSchema = z.object({
|
||
id: z.number().int(),
|
||
provider: z.string(),
|
||
model: z.string(),
|
||
data: z.record(z.string(), z.unknown()),
|
||
analysis: z.string(),
|
||
createTime: z.string(),
|
||
isPinned: z.boolean(),
|
||
username: z.string().optional(),
|
||
})
|
||
|
||
export const loginSummarySchema = z.object({
|
||
summary: z.object({
|
||
start: z.string(),
|
||
end: z.string(),
|
||
newProblemCount: z.number().int(),
|
||
submissionCount: z.number().int(),
|
||
acceptedCount: z.number().int(),
|
||
solvedCount: z.number().int(),
|
||
flowchartSubmissionCount: z.number().int(),
|
||
}),
|
||
analysis: z.string(),
|
||
analysisError: z.string().optional(),
|
||
})
|
||
|
||
export type Grade = z.infer<typeof gradeSchema>
|
||
export type DurationData = z.infer<typeof durationDataSchema>
|
||
export type SolvedProblem = z.infer<typeof solvedProblemSchema>
|
||
export type FlowchartSummary = z.infer<typeof flowchartSummarySchema>
|
||
export type AiDetail = z.infer<typeof aiDetailSchema>
|
||
export type HeatmapItem = z.infer<typeof heatmapItemSchema>
|
||
export type AiAnalysisRecord = z.infer<typeof aiAnalysisRecordSchema>
|
||
export type LoginSummary = z.infer<typeof loginSummarySchema>
|
||
|
||
export type AiAnalysisRequest = z.infer<typeof aiAnalysisRequestSchema>
|
||
export type AiHintRequest = z.infer<typeof aiHintRequestSchema>
|
||
export type ClassAnalysisRequest = z.infer<typeof classAnalysisRequestSchema>
|
||
export type ClassPkAnalysisRequest = z.infer<typeof classPkAnalysisRequestSchema>
|