refactor(前端): 拆掉 camelCase→snake_case 转换层,契约成为唯一真相

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>
This commit is contained in:
2026-08-25 13:44:24 -06:00
parent b5edd6faf5
commit c2a81209d7
125 changed files with 1651 additions and 2253 deletions

View File

@@ -83,6 +83,7 @@ const data = computed(() => {
// 根据等级返回对应的颜色
function getGradeColor(grade: Grade): string {
const colors: { [key in Grade]: string } = {
"": "#C9CDD4", // 无评级:后端在没有可用数据时下发空串
S: "#FF6384",
A: "#FFCE56",
B: "#36A2EB",

View File

@@ -73,14 +73,14 @@ const data = computed<ChartData<"bar" | "line">>(() => {
{
type: "bar",
label: "完成题目数",
data: aiStore.durationData.map((duration) => duration.problem_count),
data: aiStore.durationData.map((duration) => duration.problemCount),
yAxisID: "y",
order: 2,
},
{
type: "bar",
label: "总提交次数",
data: aiStore.durationData.map((duration) => duration.submission_count),
data: aiStore.durationData.map((duration) => duration.submissionCount),
yAxisID: "y",
order: 2,
},

View File

@@ -59,8 +59,8 @@ const show = computed(() => {
// 计算提交效率数据
const efficiencyData = computed(() => {
return aiStore.durationData.map((duration) => {
const problemCount = duration.problem_count || 0
const submissionCount = duration.submission_count || 0
const problemCount = duration.problemCount || 0
const submissionCount = duration.submissionCount || 0
// 计算效率:提交次数/完成题目数
// 值越接近1说明一次AC率越高

View File

@@ -28,8 +28,10 @@
</div>
</template>
<script setup lang="ts">
import type { Grade } from "utils/types"
const props = defineProps<{
grade: "S" | "A" | "B" | "C"
// 空串是「无评级」,四张图都不渲染 —— 后端没有可用数据时会下发它
grade: Grade
}>()
</script>
<style scoped>

View File

@@ -8,9 +8,9 @@
<span>你一共解决 </span>
<b class="charming"> {{ aiStore.detailsData.solved.length }} </b>
<span> 道题</span>
<span v-if="aiStore.detailsData.contest_count > 0">
<span v-if="aiStore.detailsData.contestCount > 0">
并且参加
<b class="charming"> {{ aiStore.detailsData.contest_count }} </b>
<b class="charming"> {{ aiStore.detailsData.contestCount }} </b>
次比赛
</span>
<span>综合评价给到</span>
@@ -49,6 +49,7 @@ const durationLabel = computed(() => {
const greeting = computed(() => {
return {
"": "还没有足够的数据来评级",
S: "要不试试高难度题目?",
A: "你很棒,继续保持!",
B: "请再接再厉!",

View File

@@ -44,6 +44,7 @@ const aiStore = useAIStore()
const gradeOrder = ["C", "B", "A", "S"] as const
const gradeColors: Record<Grade, string> = {
"": "#C9CDD4", // 无评级:后端在没有可用数据时下发空串
C: "#95F204",
B: "#36A2EB",
A: "#FFCE56",
@@ -74,7 +75,7 @@ const progressData = computed(() => {
let totalProblems = 0 // 累计题目总数
return aiStore.durationData.map((duration) => {
const problemCount = duration.problem_count || 0
const problemCount = duration.problemCount || 0
cumulativeCount += problemCount
// 计算本期等级的权重值

View File

@@ -38,8 +38,8 @@ const rankDistribution = computed(() => {
}))
aiStore.detailsData.solved.forEach((item) => {
const rank = item.period_rank
const acCount = item.period_ac_count
const rank = item.periodRank
const acCount = item.periodAcCount
if (rank && acCount && acCount > 0) {
const percentile = (rank / acCount) * 100
@@ -52,7 +52,7 @@ const rankDistribution = computed(() => {
if (rangeIndex !== -1) {
distribution[rangeIndex].count++
distribution[rangeIndex].problems.push(
`${item.problem.display_id}: ${item.problem.title}`,
`${item.problem.displayId}: ${item.problem.title}`,
)
}
}

View File

@@ -58,40 +58,40 @@ const columns: DataTableColumn<SolvedProblem>[] = [
{
text: true,
onClick: () => {
if (row.problem.contest_id) {
if (row.problem.contestId) {
router.push(
"/contest/" +
row.problem.contest_id +
row.problem.contestId +
"/problem/" +
row.problem.display_id,
row.problem.displayId,
)
} else {
router.push("/problem/" + row.problem.display_id)
router.push("/problem/" + row.problem.displayId)
}
},
},
() => {
if (row.problem.contest_id) {
if (row.problem.contestId) {
return h(TagTitle, { problem: row.problem })
} else {
return row.problem.display_id + " " + row.problem.title
return row.problem.displayId + " " + row.problem.title
}
},
),
},
{
title: () => (aiStore.detailsData.class_name ? "班级排名" : "全服排名"),
title: () => (aiStore.detailsData.className ? "班级排名" : "全服排名"),
key: "rank",
width: 100,
align: "center",
render: (row) => row.rank + " / " + row.ac_count,
render: (row) => row.rank + " / " + row.acCount,
},
{
title: "同期排名",
key: "period_rank",
width: 100,
align: "center",
render: (row) => row.period_rank + " / " + row.period_ac_count,
render: (row) => row.periodRank + " / " + row.periodAcCount,
},
{
title: () =>
@@ -128,10 +128,10 @@ const flowchartsColumns: DataTableColumn<FlowchartSummary>[] = [
{
text: true,
onClick: () => {
router.push("/problem/" + row.problem__id)
router.push("/problem/" + row.problemId)
},
},
() => `${row.problem__id} ${row.problem_title}`,
() => `${row.problemId} ${row.problemTitle}`,
),
},
{ title: "提交次数", key: "submission_count", width: 100, align: "center" },
@@ -140,14 +140,14 @@ const flowchartsColumns: DataTableColumn<FlowchartSummary>[] = [
key: "best",
width: 100,
align: "center",
render: (row) => `${row.best_score} (${row.best_grade})`,
render: (row) => `${row.bestScore} (${row.bestGrade})`,
},
{
title: "最新提交时间",
key: "latest_submission_time",
width: 200,
align: "center",
render: (row) => parseTime(row.latest_submission_time),
render: (row) => parseTime(row.latestSubmissionTime),
},
{ title: "平均分", key: "avg_score", width: 100, align: "center" },
]

View File

@@ -2,19 +2,15 @@
<n-flex vertical align="start">
<n-flex align="center">
<n-tag type="info" size="small" :bordered="false">比赛</n-tag>
<span>{{ problem.contest_title }}</span>
<span>{{ problem.contestTitle }}</span>
</n-flex>
<span>{{ problem.display_id }} {{ problem.title }}</span>
<span>{{ problem.displayId }} {{ problem.title }}</span>
</n-flex>
</template>
<script setup lang="ts">
import type { SolvedProblem } from "utils/types"
interface Props {
problem: {
title: string
display_id: string
contest_title: string
contest_id: number
}
problem: SolvedProblem["problem"]
}
const props = defineProps<Props>()

View File

@@ -50,7 +50,7 @@ const activityMatrix = computed(() => {
// 统计数据
aiStore.detailsData.solved.forEach((item) => {
const date = new Date(item.ac_time)
const date = new Date(item.acTime)
const weekday = date.getDay() // 0-60是周日
const hour = date.getHours() // 0-23