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>
369 lines
11 KiB
Vue
369 lines
11 KiB
Vue
<script setup lang="ts">
|
||
import type { LANGUAGE, SQLDisplay, Testcase } from "utils/types"
|
||
import { createZipBlob } from "utils/functions"
|
||
import SQLDataTable from "oj/problem/components/SQLDataTable.vue"
|
||
import {
|
||
generateSQLTestcase,
|
||
getSQLTestcaseScripts,
|
||
previewSQLTestcase,
|
||
uploadTestcases,
|
||
} from "../../api"
|
||
|
||
interface ScriptEntry {
|
||
id: number
|
||
sql: string
|
||
display: SQLDisplay | null
|
||
error: string
|
||
// 标准答案或题型改过之后,旧预览结果作废,需重新预览才能上传
|
||
stale: boolean
|
||
}
|
||
|
||
interface Props {
|
||
answers: { language: LANGUAGE; code: string }[]
|
||
mode: "query" | "modify"
|
||
problemId?: number
|
||
}
|
||
|
||
const props = defineProps<Props>()
|
||
const emit = defineEmits<{
|
||
uploaded: [testCaseId: string, testCaseScore: Testcase[]]
|
||
}>()
|
||
|
||
const message = useMessage()
|
||
|
||
let nextId = 0
|
||
function blankEntry(): ScriptEntry {
|
||
return { id: nextId++, sql: "", display: null, error: "", stale: false }
|
||
}
|
||
|
||
const scripts = ref<ScriptEntry[]>([blankEntry(), blankEntry(), blankEntry()])
|
||
|
||
const refSQL = computed(
|
||
() =>
|
||
props.answers.find((a) => a.language === "SQL" && a.code.trim())?.code ??
|
||
"",
|
||
)
|
||
|
||
/** 已有脚本读取失败时的提示。空白编辑器和「本来就没测试点」长得一样,必须区分开 */
|
||
const loadError = ref("")
|
||
const isPreviewing = ref(false)
|
||
const isUploading = ref(false)
|
||
const isGenerating = ref(false)
|
||
|
||
const hasAnyScript = computed(() => scripts.value.some((s) => s.sql.trim()))
|
||
const hasBlankScript = computed(() => scripts.value.some((s) => !s.sql.trim()))
|
||
|
||
const filledCount = computed(
|
||
() => scripts.value.filter((s) => s.sql.trim()).length,
|
||
)
|
||
|
||
const canUpload = computed(() => {
|
||
const filled = scripts.value.filter((s) => s.sql.trim())
|
||
return (
|
||
!isPreviewing.value &&
|
||
// 至少 2 个数据不同的测试点,防止学生对照题目页的期望结果硬编码
|
||
filled.length >= 2 &&
|
||
filled.every((s) => s.display && !s.error && !s.stale)
|
||
)
|
||
})
|
||
|
||
watch([refSQL, () => props.mode], () => {
|
||
for (const s of scripts.value) {
|
||
if (s.display || s.error) s.stale = true
|
||
}
|
||
})
|
||
|
||
// 编辑已有 SQL 题时回显已上传的脚本;新题或旧格式测试点则保持空白
|
||
onMounted(async () => {
|
||
if (!props.problemId) return
|
||
try {
|
||
const res = await getSQLTestcaseScripts(props.problemId)
|
||
if (res.data.length) {
|
||
scripts.value = res.data.map((f) => ({ ...blankEntry(), sql: f.content }))
|
||
}
|
||
} catch (err: any) {
|
||
// 新题、以及旧格式(非 SQL)测试点,后端回 404/409,保持空白就是对的,不该报错。
|
||
// 但**其它**失败必须说出来:读不出来时编辑器长得和「这题本来就没测试点」一模一样,
|
||
// 教师看不出区别,会以为要自己重新填。保存本身是安全的(后端读不到测试点信息会拒绝
|
||
// 整个保存),所以这里只提示、不阻塞。
|
||
const code = err?.error
|
||
if (code && code !== "problem-not-found" && code !== "not-sql-test-case") {
|
||
// 后端那句话(如"测试点信息读取失败")只说了现象,教师需要的是「下面是空的、别存」
|
||
const detail = err?.data ? `(${err.data})` : ""
|
||
loadError.value =
|
||
`已有测试点脚本没能读出来${detail}。下方是空白模板,不是本题真实的测试点 —— ` +
|
||
`直接保存不会丢数据(后端会拒绝),但也不会生效。请先排查测试点文件,或重新上传。`
|
||
}
|
||
}
|
||
})
|
||
|
||
function add() {
|
||
scripts.value.push(blankEntry())
|
||
}
|
||
|
||
function remove(index: number) {
|
||
scripts.value.splice(index, 1)
|
||
}
|
||
|
||
function reset() {
|
||
scripts.value = [blankEntry(), blankEntry(), blankEntry()]
|
||
}
|
||
|
||
function expectedQuery(d: SQLDisplay) {
|
||
return "columns" in d.expected ? d.expected : null
|
||
}
|
||
|
||
function changedTables(d: SQLDisplay) {
|
||
return "changed_tables" in d.expected ? d.expected.changed_tables : []
|
||
}
|
||
|
||
async function generate() {
|
||
const blanks = scripts.value.filter((s) => !s.sql.trim())
|
||
if (!blanks.length) return
|
||
isGenerating.value = true
|
||
await Promise.all(
|
||
blanks.map(async (s) => {
|
||
try {
|
||
const res = await generateSQLTestcase({
|
||
refSql: refSQL.value,
|
||
mode: props.mode,
|
||
})
|
||
s.sql = res.data.sql
|
||
} catch (err) {
|
||
const data = (err as { data?: unknown })?.data
|
||
message.error(typeof data === "string" ? data : "AI 生成失败")
|
||
}
|
||
}),
|
||
)
|
||
isGenerating.value = false
|
||
await preview()
|
||
}
|
||
|
||
async function preview() {
|
||
// 丢弃空脚本
|
||
scripts.value = scripts.value.filter((s) => s.sql.trim())
|
||
if (!scripts.value.length) {
|
||
scripts.value = [blankEntry()]
|
||
return
|
||
}
|
||
isPreviewing.value = true
|
||
await Promise.all(
|
||
scripts.value.map(async (s) => {
|
||
s.display = null
|
||
s.error = ""
|
||
s.stale = false
|
||
try {
|
||
const res = await previewSQLTestcase({
|
||
initSql: s.sql,
|
||
refSql: refSQL.value,
|
||
mode: props.mode,
|
||
})
|
||
s.display = res.data
|
||
} catch (err) {
|
||
const data = (err as { data?: unknown })?.data
|
||
s.error = typeof data === "string" ? data : "预览失败"
|
||
}
|
||
}),
|
||
)
|
||
isPreviewing.value = false
|
||
}
|
||
|
||
async function upload() {
|
||
isUploading.value = true
|
||
try {
|
||
const data = scripts.value
|
||
.filter((s) => s.sql.trim())
|
||
.map((s, i) => ({
|
||
name: `${i + 1}.sql`,
|
||
content: s.sql,
|
||
}))
|
||
const blob = createZipBlob(data)
|
||
const file = new File([blob], "testcase.zip", { type: "application/zip" })
|
||
|
||
const res = await uploadTestcases(file, { sql: true })
|
||
const testcases: Testcase[] = res.data.info
|
||
const baseScore = Math.floor(100 / testcases.length)
|
||
const remainder = 100 - baseScore * testcases.length
|
||
testcases.forEach((tc, i) => {
|
||
tc.score = String(
|
||
i === testcases.length - 1 ? baseScore + remainder : baseScore,
|
||
)
|
||
})
|
||
|
||
emit("uploaded", res.data.id, testcases)
|
||
message.success("上传成功")
|
||
} catch {
|
||
message.error("上传失败")
|
||
} finally {
|
||
isUploading.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<n-flex vertical>
|
||
<n-alert
|
||
v-if="!refSQL"
|
||
type="warning"
|
||
:show-icon="false"
|
||
style="margin-bottom: 8px"
|
||
>
|
||
还没有填写 SQL 标准答案,请先在上方"本题参考答案"中填写,再来编写测试点
|
||
</n-alert>
|
||
<n-alert
|
||
v-if="loadError"
|
||
type="error"
|
||
:show-icon="false"
|
||
style="margin-bottom: 8px"
|
||
>
|
||
{{ loadError }}
|
||
</n-alert>
|
||
<n-flex align="center" wrap>
|
||
<n-button :disabled="isPreviewing || isGenerating" @click="reset">
|
||
清空
|
||
</n-button>
|
||
<n-button :disabled="isPreviewing || isGenerating" @click="add">
|
||
+1
|
||
</n-button>
|
||
<n-tooltip :disabled="!!refSQL && hasBlankScript">
|
||
<template #trigger>
|
||
<span>
|
||
<n-button
|
||
:loading="isGenerating"
|
||
:disabled="!refSQL || !hasBlankScript || isPreviewing"
|
||
@click="generate"
|
||
>
|
||
AI 生成
|
||
</n-button>
|
||
</span>
|
||
</template>
|
||
{{ !refSQL ? "请先填写 SQL 标准答案" : "所有脚本都写好了,无需生成" }}
|
||
</n-tooltip>
|
||
<n-tooltip :disabled="!!refSQL && hasAnyScript">
|
||
<template #trigger>
|
||
<span>
|
||
<n-button
|
||
type="success"
|
||
:loading="isPreviewing"
|
||
:disabled="!refSQL || !hasAnyScript || isGenerating"
|
||
@click="preview"
|
||
>
|
||
预览验证
|
||
</n-button>
|
||
</span>
|
||
</template>
|
||
{{ !refSQL ? "请先填写 SQL 标准答案" : "请先填写数据脚本" }}
|
||
</n-tooltip>
|
||
<n-tooltip :disabled="canUpload || isPreviewing">
|
||
<template #trigger>
|
||
<span>
|
||
<n-button
|
||
type="primary"
|
||
:loading="isUploading"
|
||
:disabled="!canUpload || isGenerating"
|
||
@click="upload"
|
||
>
|
||
上传
|
||
</n-button>
|
||
</span>
|
||
</template>
|
||
{{
|
||
filledCount < 2
|
||
? "SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果"
|
||
: "所有脚本预览验证通过后才能上传"
|
||
}}
|
||
</n-tooltip>
|
||
</n-flex>
|
||
|
||
<div v-for="(s, index) in scripts" :key="s.id" class="scriptBox">
|
||
<n-flex justify="space-between" align="center">
|
||
<strong>{{ index + 1 }}.sql</strong>
|
||
<n-button
|
||
size="small"
|
||
:disabled="scripts.length === 1 || isPreviewing || isGenerating"
|
||
@click="remove(index)"
|
||
>
|
||
删除
|
||
</n-button>
|
||
</n-flex>
|
||
<n-input
|
||
type="textarea"
|
||
v-model:value="s.sql"
|
||
:rows="8"
|
||
placeholder="-- 本测试点的建表 + 插入数据脚本
|
||
CREATE TABLE ...;
|
||
INSERT INTO ...;"
|
||
:status="
|
||
s.error ? 'error' : s.display && !s.stale ? 'success' : undefined
|
||
"
|
||
/>
|
||
<n-alert v-if="s.error" type="error" :show-icon="false">
|
||
{{ s.error }}
|
||
</n-alert>
|
||
<template v-if="s.display">
|
||
<n-alert v-if="s.stale" type="warning" :show-icon="false">
|
||
标准答案或题型已修改,以下预览已过期,请重新预览
|
||
</n-alert>
|
||
<div :class="{ stalePreview: s.stale }">
|
||
<p class="previewTitle">数据表</p>
|
||
<div v-for="t in s.display.tables" :key="t.name">
|
||
<p class="sqlTableName">{{ t.name }}</p>
|
||
<SQLDataTable
|
||
:columns="t.columns"
|
||
:rows="t.rows"
|
||
:total-rows="t.total_rows"
|
||
:truncated="t.truncated"
|
||
/>
|
||
</div>
|
||
<p class="previewTitle">期望结果</p>
|
||
<SQLDataTable
|
||
v-if="expectedQuery(s.display)"
|
||
:columns="expectedQuery(s.display)!.columns"
|
||
:rows="expectedQuery(s.display)!.rows"
|
||
:total-rows="expectedQuery(s.display)!.total_rows"
|
||
:truncated="expectedQuery(s.display)!.truncated"
|
||
/>
|
||
<div v-for="t in changedTables(s.display)" :key="t.name">
|
||
<p class="sqlTableName">
|
||
{{ t.dropped ? `${t.name} 表已被删除` : `执行后的 ${t.name} 表` }}
|
||
</p>
|
||
<SQLDataTable
|
||
v-if="!t.dropped"
|
||
:columns="t.columns"
|
||
:rows="t.rows"
|
||
:total-rows="t.total_rows"
|
||
:truncated="t.truncated"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</n-flex>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.scriptBox {
|
||
border: 1px solid var(--n-border-color, rgba(128, 128, 128, 0.2));
|
||
border-radius: 6px;
|
||
padding: 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.previewTitle {
|
||
font-weight: bold;
|
||
margin: 4px 0;
|
||
}
|
||
|
||
.sqlTableName {
|
||
font-weight: 500;
|
||
margin: 4px 0;
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.stalePreview {
|
||
opacity: 0.45;
|
||
}
|
||
</style>
|