utils/legacy.ts 是迁移期的临时层:新后端一律 camelCase,而组件读的还是
旧 Django 的 snake_case,于是在 api 层做一次递归键名重写。它自己的注释就
写了「迁移完成后这一层应当整体拆掉」。现在拆了。
代价不只是那 96 处包装:每个响应都要递归遍历整个对象重写一遍键名,而且
utils/types.ts 和 packages/contract 是两份真相 —— 手抄的那份还抄歪了好几处。
做法是按域推进,每域都用 vue-tsc 相对基线做差,确认零新增错误后再往下走。
前端的类型现在一律以契约为准,只在必要处窄化(比如 languages/template 的键
窄化成 LANGUAGE),删掉的重复定义包括 WebsiteConfig、LoginSummary、
AchievementSummary、ProblemSet、Contest、User、Profile、AdminTag、
StuckProblem 等等,其中 ClassComparison 有两个组件各手抄了一份。
## 顺带修掉的真 bug
- 管理端公告列表的「可见」开关每次都 400:列表响应被契约 omit 掉了 content,
而更新接口要求 content 必填,toggleVisible 把列表行原样回传。而且是乐观
翻转、不 await 不 catch,管理员看到开关动了、实际没存也没有提示。
改成先 GET 整条再 PUT,加失败提示。
- 删有提交的题时只显示笼统的「删除失败」:前端还在 match 旧 Django 的英文
文案,而后端返回的是 problem-has-submissions + 中文。连同另外 8 处同类
匹配一起改成判错误码 —— 文案是后端随时能改的,match 文案改一个字就静默失效。
- SubmissionStatus.time_limit_exceeded 写成 `1 | 2`,TS 按位或算成 3,和
memory_limit_exceeded 撞了同一个值。后端 judge/status.ts 里这是分开的
两个码,按后端拆成 cpu_/real_ 两项。当前没有代码读这两个成员,但
CLAUDE.md 明确要求判题状态码三处同步。
- 流程图历史翻到没有提交的那一页会直接抛:契约里 submission 是 nullable,
被 any 掩盖成看起来非空。补了 null 分支。
## 契约里被逼出来的三处不诚实
- grade 写成 z.string(),但 averageGrade() 在没有可用数据时返回空串,
前端三张图表拿它查 Record<Grade,...> 会查出 undefined。按实际收紧成
z.enum([...,""]),四个查表点都补了「无评级」分支。
- difficulty 写成 z.string()。核对过生产库 dump:956 道题只有
Low/Mid/High 三个值(761/149/46)。收紧成枚举。
- topReaction 写成 z.string(),既对不上前端渲染的 {type,count},也对不上
旧后端 get_top_reactions 下发的形状。改成正确形状并注明当前恒传 null。
## 明确保留 snake_case 的 54 处
判题沙箱原始输出(cpu_time/exit_code/output_md5/compile_output)、
statistic_info 内容(err_info/time_cost/ast_results)、submission_info
JSONB(is_ac/ac_time/error_number,回滚时旧后端还要读)、SQL 判题引擎的
total_rows/order_sensitive/changed_tables、WebSocket 的 submission_id、
以及数据库选项键 enable_maxkb。每一处都在类型定义旁写了为什么不能改。
language 没有跟着收紧契约 —— 它是配置项、随时可能加语言,收紧会让新语言
在后端 parse 时直接抛。改在 api 边界一处窄化。
## 另外
- utils/http.ts 整个模块已是死代码(四处引用全是 import type),删除。
- profile 的 blog/github/school/major/language 五个字段全链路空转,没有
任何组件读,从契约到类型一并摘除(数据库列不动)。
- admin/account.ts 往 user_profile 塞的 totalScore 是 OI 模式遗留,表里
没这一列。Drizzle 按表定义拼列名会把它静默丢弃,所以没出过错,是死代码。
验证:vue-tsc 143 → 54 条且无新增,apps/api tsc、check:routes、web build
全通过;各域响应形状逐条打接口核对过。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
377 lines
8.7 KiB
TypeScript
377 lines
8.7 KiB
TypeScript
import type { AchievementRarity, SUBMISSION_RESULT, ReactionKey } from "./types"
|
||
|
||
// 与后端 judge/status.ts 的 JudgeStatus 逐条对齐(submitting 除外,见下)。
|
||
// 注意:原来 time_limit_exceeded 写成 `1 | 2`,TS 按位或算成 3,和
|
||
// memory_limit_exceeded 撞了同一个值 —— 后端这两个是分开的两个码。
|
||
export enum SubmissionStatus {
|
||
compile_error = -2,
|
||
wrong_answer = -1,
|
||
accepted = 0,
|
||
cpu_time_limit_exceeded = 1,
|
||
real_time_limit_exceeded = 2,
|
||
memory_limit_exceeded = 3,
|
||
runtime_error = 4,
|
||
system_error = 5,
|
||
pending = 6,
|
||
judging = 7,
|
||
partial_accepted = 8,
|
||
// 前端自造的伪状态:点了提交、还没拿到判题结果时本地先填 9。
|
||
// 后端永远不会下发它,所以契约的 judgeStatusSchema 里没有。
|
||
submitting = 9,
|
||
ast_check_failed = 10,
|
||
}
|
||
|
||
export enum ContestStatus {
|
||
initial = "2", // 这里不需要传入到后端,只是为了一开始加载数据的时候,做一个初始位
|
||
not_started = "1",
|
||
underway = "0",
|
||
finished = "-1",
|
||
}
|
||
|
||
export enum ContestType {
|
||
public = "Public",
|
||
private = "Password Protected",
|
||
}
|
||
|
||
export const JUDGE_STATUS: {
|
||
[key in SUBMISSION_RESULT]: {
|
||
name: string
|
||
title: string
|
||
type: "error" | "success" | "warning" | "info"
|
||
}
|
||
} = {
|
||
"-2": {
|
||
name: "编译失败",
|
||
title: "编译失败",
|
||
type: "warning",
|
||
},
|
||
"-1": {
|
||
name: "答案错误",
|
||
title: "答案错误",
|
||
type: "error",
|
||
},
|
||
"0": {
|
||
name: "答案正确",
|
||
title: "答案正确",
|
||
type: "success",
|
||
},
|
||
"1": {
|
||
name: "运行超时",
|
||
title: "运行超时",
|
||
type: "error",
|
||
},
|
||
"2": {
|
||
name: "运行超时",
|
||
title: "运行超时",
|
||
type: "error",
|
||
},
|
||
"3": {
|
||
name: "内存超限",
|
||
title: "内存超限",
|
||
type: "error",
|
||
},
|
||
"4": {
|
||
name: "运行时错误",
|
||
title: "运行时错误",
|
||
type: "warning",
|
||
},
|
||
"5": {
|
||
name: "系统错误",
|
||
title: "系统错误",
|
||
type: "error",
|
||
},
|
||
"6": {
|
||
name: "等待评分",
|
||
title: "等待评分",
|
||
type: "warning",
|
||
},
|
||
"7": {
|
||
name: "正在评分",
|
||
title: "正在评分",
|
||
type: "warning",
|
||
},
|
||
"8": {
|
||
name: "部分正确",
|
||
title: "部分正确",
|
||
type: "warning",
|
||
},
|
||
"9": {
|
||
name: "正在提交",
|
||
title: "正在提交",
|
||
type: "info",
|
||
},
|
||
"10": {
|
||
name: "语法未通过",
|
||
title: "答案正确,但语法未通过",
|
||
type: "success",
|
||
},
|
||
}
|
||
|
||
export const CONTEST_STATUS: {
|
||
[key in ContestStatus]: {
|
||
name: string
|
||
type: "error" | "success" | "warning"
|
||
}
|
||
} = {
|
||
// 这里不需要传入到后端,只是为了一开始加载数据的时候,做一个初始位
|
||
"2": {
|
||
name: "未开始",
|
||
type: "warning",
|
||
},
|
||
"1": {
|
||
name: "未开始",
|
||
type: "warning",
|
||
},
|
||
"0": {
|
||
name: "进行中",
|
||
type: "success",
|
||
},
|
||
"-1": {
|
||
name: "已结束",
|
||
type: "error",
|
||
},
|
||
}
|
||
|
||
export const CONTEST_TYPE = {
|
||
PUBLIC: "Public",
|
||
PRIVATE: "Password Protected",
|
||
}
|
||
|
||
export const USER_TYPE = {
|
||
REGULAR_USER: "Regular User",
|
||
STUDENT_ADMIN: "Student Admin",
|
||
TEACHER_ADMIN: "Teacher Admin",
|
||
SUPER_ADMIN: "Super Admin",
|
||
}
|
||
|
||
export const PROBLEM_PERMISSION = {
|
||
NONE: "None",
|
||
OWN: "Own",
|
||
ALL: "All",
|
||
}
|
||
|
||
export const STORAGE_KEY = {
|
||
AUTHED: "authed",
|
||
LANGUAGE: "problemLanguage",
|
||
LEARN_CURRENT_STEP: "learnStep",
|
||
ADMIN_PROBLEM: "adminProblem",
|
||
ADMIN_PROBLEM_TAGS: "adminProblemTags",
|
||
DEMO_MODE: "demoMode",
|
||
}
|
||
|
||
export const DIFFICULTY = {
|
||
Low: "简单",
|
||
Mid: "中等",
|
||
High: "困难",
|
||
} as const
|
||
|
||
const cSource =
|
||
"#include<stdio.h>\r\n\r\nint main()\r\n{\r\n \r\n return 0;\r\n}"
|
||
const cppSource =
|
||
"#include<iostream>\r\n\r\nusing namespace std;\r\n\r\nint main()\r\n{\r\n \r\n return 0;\r\n}"
|
||
const pythonSource = ""
|
||
const javaSource =
|
||
'public class Main {\r\n public static void main(String[] args) {\r\n System.out.println("黄岩一职");\r\n }\r\n}'
|
||
|
||
export const SOURCES = {
|
||
C: cSource,
|
||
"C++": cppSource,
|
||
Java: javaSource,
|
||
Python3: pythonSource,
|
||
Python2: "",
|
||
JavaScript: "",
|
||
Golang: "",
|
||
Flowchart: "",
|
||
SQL: "",
|
||
} as const
|
||
|
||
export const LANGUAGE_ID = {
|
||
C: 50,
|
||
"C++": 54,
|
||
Java: 62,
|
||
Python3: 71,
|
||
Python2: 0,
|
||
JavaScript: 0,
|
||
Golang: 0,
|
||
Flowchart: 0,
|
||
SQL: 0,
|
||
} as const
|
||
|
||
export const LANGUAGE_FORMAT_VALUE = {
|
||
C: "c",
|
||
"C++": "cpp",
|
||
Java: "java",
|
||
Python2: "python",
|
||
Python3: "python",
|
||
JavaScript: "javascript",
|
||
Golang: "go",
|
||
Flowchart: "flowchart",
|
||
SQL: "sql",
|
||
} as const
|
||
|
||
export const LANGUAGE_SHOW_VALUE = {
|
||
Flowchart: "流程图",
|
||
C: "C语言",
|
||
"C++": "C++",
|
||
Java: "Java",
|
||
Python2: "Python",
|
||
Python3: "Python",
|
||
JavaScript: "JS",
|
||
Golang: "Go",
|
||
SQL: "SQL",
|
||
} as const
|
||
|
||
export const ICON_SET = {
|
||
Flowchart: "vscode-icons:file-type-drawio",
|
||
Python2: "devicon:python",
|
||
Python3: "devicon:python",
|
||
C: "devicon:c",
|
||
"C++": "devicon:cplusplus",
|
||
Java: "devicon:java",
|
||
JavaScript: "devicon:javascript",
|
||
Golang: "devicon:go",
|
||
SQL: "devicon:sqlite",
|
||
} as const
|
||
|
||
const cTemplate = `//TEMPLATE BEGIN
|
||
#include <stdio.h>
|
||
|
||
int main() {
|
||
printf("黄岩一职");
|
||
return 0;
|
||
}
|
||
//TEMPLATE END`
|
||
|
||
const cppTemplate = `//TEMPLATE BEGIN
|
||
#include <iostream>
|
||
|
||
int main() {
|
||
return 0;
|
||
}
|
||
//TEMPLATE END`
|
||
|
||
const blankTemplate = `//PREPEND BEGIN
|
||
//PREPEND END
|
||
|
||
//TEMPLATE BEGIN
|
||
//TEMPLATE END
|
||
|
||
//APPEND BEGIN
|
||
//APPEND END`
|
||
|
||
export const CODE_TEMPLATES = {
|
||
C: cTemplate,
|
||
"C++": cppTemplate,
|
||
Python2: blankTemplate,
|
||
Python3: blankTemplate,
|
||
Java: blankTemplate,
|
||
JavaScript: blankTemplate,
|
||
Golang: blankTemplate,
|
||
Flowchart: blankTemplate,
|
||
SQL: blankTemplate,
|
||
} as const
|
||
|
||
export enum ScreenMode {
|
||
both = "双栏",
|
||
code = "自测",
|
||
problem = "题目",
|
||
}
|
||
|
||
export enum ChartType {
|
||
Rank,
|
||
Activity,
|
||
}
|
||
|
||
// 成就稀有度
|
||
export const RARITY_LABEL: Record<AchievementRarity, string> = {
|
||
bronze: "青铜",
|
||
silver: "白银",
|
||
gold: "黄金",
|
||
platinum: "白金",
|
||
}
|
||
|
||
// 边框、色块用的原色:饱和度够高,明暗底上都醒目。
|
||
// 青铜往红偏(色相 23),黄金往纯黄推(色相 47),中间隔开两档亮度,
|
||
// 不然两个都落在橙棕区,扫一眼分不出来
|
||
export const RARITY_COLOR: Record<AchievementRarity, string> = {
|
||
bronze: "#c2703d",
|
||
silver: "#9fa6b2",
|
||
gold: "#f2c012",
|
||
platinum: "#7dd3fc",
|
||
}
|
||
|
||
// 文字要另配一套。上面那组是照深色底调的,搬到白底上白金只有 1.7:1、
|
||
// 黄金 2.2:1,12px 的稀有度标签根本看不清。取色见 useRarityColor
|
||
export const RARITY_TEXT_COLOR: Record<
|
||
"dark" | "light",
|
||
Record<AchievementRarity, string>
|
||
> = {
|
||
dark: {
|
||
bronze: "#e08d63",
|
||
silver: "#b6bcc7",
|
||
gold: "#f2c012",
|
||
platinum: "#7dd3fc",
|
||
},
|
||
light: {
|
||
bronze: "#a13d1e",
|
||
silver: "#6b7280",
|
||
gold: "#8f6f00",
|
||
platinum: "#0369a1",
|
||
},
|
||
}
|
||
|
||
// 时间范围配置
|
||
export const DURATION_OPTIONS = [
|
||
{ label: "本节课内", value: "hours:1" },
|
||
{ label: "两节课内", value: "hours:2" },
|
||
{ label: "一天内", value: "days:1" },
|
||
{ label: "一周内", value: "weeks:1" },
|
||
{ label: "一个月内", value: "months:1" },
|
||
{ label: "两个月内", value: "months:2" },
|
||
{ label: "半年内", value: "months:6" },
|
||
{ label: "一年内", value: "years:1" },
|
||
] as const
|
||
|
||
// 班级号的位数范围。学生用户名形如 ks<班级号><姓名>,班级号还要跟
|
||
// 网站配置里的班级列表对得上。
|
||
// 后端 OnlineJudge/utils/shortcuts.py 的 CLASS_NAME_MIN/MAX_DIGITS
|
||
// 是同一条规则的另一份,改这里必须同时改那边。
|
||
export const CLASS_NAME_MIN_DIGITS = 3
|
||
export const CLASS_NAME_MAX_DIGITS = 4
|
||
|
||
/** 合法班级号:3~4 位纯数字 */
|
||
export const CLASS_NAME_RE = new RegExp(
|
||
`^\\d{${CLASS_NAME_MIN_DIGITS},${CLASS_NAME_MAX_DIGITS}}$`,
|
||
)
|
||
|
||
/** 用户名开头的 ks<班级号>,用于从用户名里认出班级 */
|
||
export const USERNAME_CLASS_RE = new RegExp(
|
||
`^ks\\d{${CLASS_NAME_MIN_DIGITS},${CLASS_NAME_MAX_DIGITS}}`,
|
||
)
|
||
|
||
/** 班级号作为数字时的上下界,给 n-input-number 用 */
|
||
export const CLASS_NAME_MIN_VALUE = 10 ** (CLASS_NAME_MIN_DIGITS - 1)
|
||
export const CLASS_NAME_MAX_VALUE = 10 ** CLASS_NAME_MAX_DIGITS - 1
|
||
|
||
export const REACTIONS: {
|
||
key: ReactionKey
|
||
label: string
|
||
icon: string
|
||
}[] = [
|
||
{
|
||
key: "too_easy",
|
||
label: "太简单",
|
||
icon: "fluent-emoji:smiling-face-with-sunglasses",
|
||
},
|
||
{ key: "too_hard", label: "太难了", icon: "fluent-emoji:exploding-head" },
|
||
{
|
||
key: "confusing",
|
||
label: "没看懂",
|
||
icon: "fluent-emoji:face-with-spiral-eyes",
|
||
},
|
||
{ key: "buggy", label: "题目有错", icon: "fluent-emoji:bug" },
|
||
{ key: "learned", label: "学到了", icon: "fluent-emoji:light-bulb" },
|
||
{ key: "interesting", label: "有意思", icon: "fluent-emoji:star-struck" },
|
||
{ key: "want_explain", label: "想听讲解", icon: "fluent-emoji:books" },
|
||
]
|