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

@@ -185,8 +185,8 @@ const goSubmissions = () => {
}
const goEdit = () => {
const url = problem.value!.contest
? `/admin/contest/${problem.value!.contest}/problem/edit/${problem.value!.id}`
const url = problem.value!.contestId
? `/admin/contest/${problem.value!.contestId}/problem/edit/${problem.value!.id}`
: `/admin/problem/edit/${problem.value!.id}`
window.open(router.resolve(url).href, "_blank")
}

View File

@@ -32,8 +32,8 @@ const { problem } = storeToRefs(problemStore)
const problemSetId = computed(() => route.params.problemSetId)
// SQL 题:隐藏输入/输出/例子,改为渲染数据表与期望结果
const isSQL = computed(() => !!problem.value?.sql_config)
const sqlDisplay = computed(() => problem.value?.sql_display ?? null)
const isSQL = computed(() => !!problem.value?.sqlConfig)
const sqlDisplay = computed(() => problem.value?.sqlDisplay ?? null)
const sqlExpectedQuery = computed(() => {
const exp = sqlDisplay.value?.expected
return exp && "columns" in exp ? exp : null
@@ -71,7 +71,7 @@ watch(
// AC 或失败次数 >= 3 时加载推荐
watch(
() => [problem.value?._id, problem.value?.my_status, problemStore.failCount],
() => [problem.value?._id, problem.value?.myStatus, problemStore.failCount],
([, status, failCount]) => {
if (status === 0 || (failCount as number) >= 3) {
loadSimilarProblems()
@@ -82,9 +82,9 @@ watch(
const hasTriedButNotPassed = computed(() => {
return (
problem.value?.my_status !== undefined &&
problem.value?.my_status !== null &&
problem.value?.my_status !== 0
problem.value?.myStatus !== undefined &&
problem.value?.myStatus !== null &&
problem.value?.myStatus !== 0
)
})
@@ -177,8 +177,8 @@ function ruleTagType(engine: string): "error" | "success" | "info" {
}
const astRulesForDisplay = computed(() => {
if (!problem.value?.ast_rules) return []
return Object.entries(problem.value.ast_rules).filter(
if (!problem.value?.astRules) return []
return Object.entries(problem.value.astRules).filter(
([, rules]) => rules.length > 0,
)
})
@@ -249,7 +249,7 @@ function type(status: ProblemStatus) {
<!-- 已通过 -->
<n-alert
class="status-alert"
v-if="problem.my_status === 0"
v-if="problem.myStatus === 0"
type="success"
title="🎉 本 题 已 经 被 你 解 决 啦"
>
@@ -291,7 +291,7 @@ function type(status: ProblemStatus) {
</p>
<MdPreview
preview-theme="vuepress"
:model-value="problem.input_description"
:model-value="problem.inputDescription"
:theme="isDark ? 'dark' : 'light'"
/>
@@ -303,7 +303,7 @@ function type(status: ProblemStatus) {
</p>
<MdPreview
preview-theme="vuepress"
:model-value="problem.output_description"
:model-value="problem.outputDescription"
:theme="isDark ? 'dark' : 'light'"
/>
</template>
@@ -338,7 +338,7 @@ function type(status: ProblemStatus) {
:total-rows="sqlExpectedQuery.total_rows"
:truncated="sqlExpectedQuery.truncated"
/>
<p v-if="!problem.sql_config?.order_sensitive" class="sqlNote">
<p v-if="!problem.sqlConfig?.order_sensitive" class="sqlNote">
结果顺序不限
</p>
</template>

View File

@@ -11,13 +11,13 @@ const { renderError, renderFlowchart } = useMermaid()
const renderProblemFlowchart = async () => {
await renderFlowchart(
mermaidContainer.value,
problem.value?.mermaid_code ?? "",
problem.value?.mermaidCode ?? "",
)
}
onMounted(renderProblemFlowchart)
watch(() => problem.value?.mermaid_code, renderProblemFlowchart)
watch(() => problem.value?.mermaidCode, renderProblemFlowchart)
</script>
<template>

View File

@@ -30,7 +30,7 @@ const beatRate = ref("0")
const yearlyACData = ref<YearlyACData[]>([])
const data = computed(() => {
const status = problem.value!.statistic_info
const status = problem.value!.statisticInfo
const labels = []
for (let i in status) {
if (status[i] !== 0) {
@@ -50,14 +50,14 @@ const numbers = computed(() => {
return [
{
icon: "streamline-ultimate-color:checklist",
title: problem.value?.submission_number ?? 0,
title: problem.value?.submissionNumber ?? 0,
content: "总提交",
int: true,
suffix: "",
},
{
icon: "streamline-emojis:woman-raising-hand-2",
title: problem.value?.accepted_number ?? 0,
title: problem.value?.acceptedNumber ?? 0,
content: "通过数",
int: true,
suffix: "",
@@ -65,8 +65,8 @@ const numbers = computed(() => {
{
icon: "fluent-emoji:chart-increasing",
title: getACRateNumber(
problem.value?.accepted_number ?? 0,
problem.value?.submission_number ?? 0,
problem.value?.acceptedNumber ?? 0,
problem.value?.submissionNumber ?? 0,
),
content: "通过率",
int: false,
@@ -115,10 +115,10 @@ onMounted(() => {
{{ problem._id }}
</n-descriptions-item>
<n-descriptions-item label="出题人">
{{ problem.created_by.username }}
{{ problem.createdBy.username }}
</n-descriptions-item>
<n-descriptions-item label="创建时间">
{{ parseTime(problem.create_time) }}
{{ parseTime(problem.createTime) }}
</n-descriptions-item>
<n-descriptions-item label="难度">
<n-tag :type="getTagColor(problem.difficulty)">
@@ -150,7 +150,7 @@ onMounted(() => {
</n-card>
</n-gi>
</n-grid>
<div class="pie" v-if="problem && problem.submission_number > 0">
<div class="pie" v-if="problem && problem.submissionNumber > 0">
<Pie :data="data" :options="options" />
</div>
<ProblemYearlyChart :data="yearlyACData" />

View File

@@ -10,17 +10,17 @@ defineProps<{
<n-flex align="center">
<span>{{ problem.title }}</span>
<Icon
v-if="problem.allow_flowchart"
v-if="problem.allowFlowchart"
width="18"
icon="vscode-icons:file-type-drawio"
/>
<Icon
v-else-if="problem.show_flowchart"
v-else-if="problem.showFlowchart"
width="18"
icon="vscode-icons:file-type-graphql"
/>
<Icon
v-if="problem.has_ast_rules"
v-if="problem.hasAstRules"
width="18"
icon="vscode-icons:file-type-light-todo"
/>

View File

@@ -80,7 +80,7 @@ const wheelItems = REACTIONS.map((item, index) => ({
style: getWheelItemStyle(index),
}))
const solved = computed(() => problem.value?.my_status === 0)
const solved = computed(() => problem.value?.myStatus === 0)
const locked = computed(() => mine.value !== null)
const canInteract = computed(
() =>

View File

@@ -8,7 +8,7 @@ import { useUserStore } from "shared/store/user"
import { JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from "utils/constants"
import { parseTime } from "utils/functions"
import { renderTableTitle } from "utils/renders"
import type { Submission } from "utils/types"
import type { SubmissionListItem } from "utils/types"
import SubmissionDetail from "oj/submission/detail.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
@@ -29,19 +29,19 @@ function showCodePanel(id: string, problem: string) {
toggleCodePanel(true)
}
const columns: DataTableColumn<Submission>[] = [
const columns: DataTableColumn<SubmissionListItem>[] = [
{
title: renderTableTitle("提交时间", "fluent-emoji:seven-oclock"),
key: "create_time",
width: 200,
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"),
},
{
title: renderTableTitle("编号", "fluent-emoji-flat:input-numbers"),
key: "id",
minWidth: 160,
render: (row) => {
if (!row.show_link)
if (!row.showLink)
return h(NFlex, { align: "center" }, () => [
h("span", row.id.slice(0, 12)),
h(
@@ -90,7 +90,7 @@ const class_ac_count = ref(0)
const all_ac_count = ref(0)
const loading = ref(false)
const submissions = ref<Submission[]>([])
const submissions = ref<SubmissionListItem[]>([])
const total = ref(0)
const query = reactive({
limit: 10,
@@ -126,8 +126,8 @@ async function listSubmissions() {
...query,
myself: "1",
offset,
problem_id: (route.params.problemID as string) ?? "",
contest_id: (route.params.contestID as string) ?? "",
problemId: (route.params.problemID as string) ?? "",
contestId: (route.params.contestID as string) ?? "",
})
submissions.value = res.data.results
total.value = res.data.total
@@ -138,10 +138,10 @@ async function getRankOfThisProblem() {
const res = await getRankOfProblem((route.params.problemID as string) ?? "")
loading.value = false
class_name.value = res.data.class_name
class_name.value = res.data.className
rank.value = res.data.rank
class_ac_count.value = res.data.class_ac_count
all_ac_count.value = res.data.all_ac_count
class_ac_count.value = res.data.classAcCount
all_ac_count.value = res.data.allAcCount
}
onMounted(() => {

View File

@@ -35,7 +35,7 @@ const chartData = computed(() => ({
datasets: [
{
label: "AC 率",
data: props.data.map((d) => d.ac_rate),
data: props.data.map((d) => d.acRate),
fill: true,
tension: 0.3,
backgroundColor: "rgba(99, 179, 237, 0.2)",
@@ -58,7 +58,7 @@ const chartOptions = computed(() => ({
callbacks: {
label: (context: any) => {
const d = props.data[context.dataIndex]
return [`AC 率: ${d.ac_rate}%`, `通过: ${d.accepted} / ${d.total}`]
return [`AC 率: ${d.acRate}%`, `通过: ${d.accepted} / ${d.total}`]
},
},
},

View File

@@ -47,9 +47,9 @@ const msg = computed(() => {
if (
result !== SubmissionStatus.ast_check_failed &&
props.submission.statistic_info?.err_info
props.submission.statisticInfo?.err_info
) {
msg += props.submission.statistic_info.err_info
msg += props.submission.statisticInfo.err_info
}
return msg
@@ -161,15 +161,13 @@ const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
<n-flex
vertical
v-if="
msg ||
infoTable.length ||
submission.statistic_info?.ast_results?.length
msg || infoTable.length || submission.statisticInfo?.ast_results?.length
"
>
<n-card v-if="submission.statistic_info?.ast_results?.length" embedded>
<n-card v-if="submission.statisticInfo?.ast_results?.length" embedded>
<n-flex vertical :size="8">
<n-flex
v-for="(rule, i) in submission.statistic_info.ast_results"
v-for="(rule, i) in submission.statisticInfo.ast_results"
:key="i"
align="center"
:size="6"

View File

@@ -130,22 +130,22 @@ async function submit() {
// 1. 构建提交数据
const data: SubmitCodePayload = {
problem_id: problem.value!.id,
problemId: problem.value!.id,
language: codeStore.code.language,
code: codeStore.code.value,
}
if (contestID) {
data.contest_id = parseInt(contestID)
data.contestId = parseInt(contestID)
}
// 2. 提交代码到后端
isSubmittingRequest.value = true
try {
const res = await submitCode(data)
console.log(`[Submit] 代码已提交: ID=${res.data.submission_id}`)
console.log(`[Submit] 代码已提交: ID=${res.data.submissionId}`)
// 3. 启动冷却 + 监控
startCooldown()
startMonitoring(res.data.submission_id)
startMonitoring(res.data.submissionId)
showResult.value = true
} finally {
isSubmittingRequest.value = false
@@ -183,7 +183,7 @@ watch(
return
// 1. 刷新题目状态
problem.value!.my_status = 0
problem.value!.myStatus = 0
// 2. 创建ProblemSetSubmission记录更新题单进度
if (problemSetId) {

View File

@@ -135,16 +135,16 @@ async function submitFlowchartData() {
try {
const response = await submitFlowchart({
problem_id: problem.value!.id,
mermaid_code: mermaidCode,
flowchart_data: {
problemId: problem.value!.id,
mermaidCode,
flowchartData: {
compressed: true,
data: compressed,
},
})
// 获取提交ID并订阅更新
const submissionId = response.data.submission_id
const submissionId = response.data.submissionId
if (submissionId) {
subscribeToSubmission(submissionId)
@@ -183,18 +183,34 @@ async function getSubmission(submissionPage = 0) {
)
submissionCount.value = data.count
const submission = data.submission
myFlowchartZippedStr.value = submission.flowchart_data.data
myMermaidCode.value = submission.mermaid_code || ""
// 翻到没有提交的页时后端返回 null契约里 submission 是 nullable——
// 原来的 any 让这里看起来非空,真翻到那一页会直接抛
if (!submission) {
myFlowchartZippedStr.value = ""
myMermaidCode.value = ""
modalRating.value = { score: 0, grade: "" }
evaluation.value = {
score: 0,
grade: "",
feedback: "",
suggestions: "",
criteria_details: {},
}
return
}
myFlowchartZippedStr.value = String(submission.flowchartData.data ?? "")
myMermaidCode.value = submission.mermaidCode || ""
modalRating.value = {
score: submission.ai_score,
grade: submission.ai_grade,
score: submission.aiScore ?? 0,
grade: (submission.aiGrade ?? "") as Rating["grade"],
}
evaluation.value = {
score: submission.ai_score,
grade: submission.ai_grade,
feedback: submission.ai_feedback,
suggestions: submission.ai_suggestions,
criteria_details: submission.ai_criteria_details,
score: submission.aiScore ?? 0,
grade: (submission.aiGrade ?? "") as Rating["grade"],
feedback: submission.aiFeedback ?? "",
suggestions: submission.aiSuggestions ?? "",
criteria_details:
submission.aiCriteriaDetails as Evaluation["criteria_details"],
}
}

View File

@@ -55,7 +55,7 @@ const { isMobile, isDesktop } = useBreakpoints()
const tabOptions = computed(() => {
const options: string[] = ["content"]
if (problem.value?.show_flowchart) {
if (problem.value?.showFlowchart) {
options.push("flowchart")
}
@@ -113,7 +113,7 @@ async function init() {
problem.value = res.data
} catch (err: any) {
problem.value = null
if (err.data === "Contest has not started yet.") {
if (err.error === "contest-not-started") {
errMsg.value = "比赛还没有开始"
}
}
@@ -159,7 +159,7 @@ watch(
<ProblemContent />
</n-tab-pane>
<n-tab-pane
v-if="problem.show_flowchart && problem.mermaid_code"
v-if="problem.showFlowchart && problem.mermaidCode"
name="flowchart"
tab="流程图表"
>
@@ -211,7 +211,7 @@ watch(
<ProblemContent />
</n-tab-pane>
<n-tab-pane
v-if="problem.show_flowchart && problem.mermaid_code"
v-if="problem.showFlowchart && problem.mermaidCode"
name="flowchart"
tab="流程图表"
>
@@ -251,7 +251,7 @@ watch(
<n-tab-pane name="content" tab="描述">
<ProblemContent />
</n-tab-pane>
<n-tab-pane v-if="problem.show_flowchart" name="flowchart" tab="流程">
<n-tab-pane v-if="problem.showFlowchart" name="flowchart" tab="流程">
<ProblemFlowchart />
</n-tab-pane>
<n-tab-pane name="editor" tab="代码">