Files
OJ2/apps/web/src/admin/problem/components/TestcaseGenerator.vue
yuetsh 3f55d231c3
Some checks failed
Deploy / deploy (push) Has been cancelled
refactor(契约): samples / answers / testCaseScore 补回旧后端的校验
旧后端这三个字段都是逐字段校验的(CreateSampleSerializer /
CreateAnswerSerializer / CreateTestCaseScoreSerializer),新后端一路写成
`z.record(z.string(), z.unknown())`,**比旧的松**。松出来的不只是少拦几个
错误请求,还有一处静默的落库形状漂移。

## test_case_score 会越存越胖,而且 score 变成字符串

上传接口的响应有六个键,前端 `{...entry, score}` 原样往回传。旧后端的 DRF
serializer 只认 input_name / output_name / score,多的直接丢,所以生产库
956 行 test_case_score **全部只有三个键**、7990 条 score 全部是 int
(前端算出来是 `(100/n).toFixed(0)` 这种字符串,IntegerField 收下时转了)。

新后端不做这件事,往后在 OJ2 上新建或编辑的题会存六个键、score 是字符串 ——
和旧后端写出来的不是一个形状。这列判题机不读,不影响判题,但它是回滚要
原样交回去的持久化数据,不该在这上面分叉。

现在三个精确 schema 顶上:problemSampleSchema / problemAnswerSchema /
problemTestCaseScoreSchema。zod 的 object 默认剥未知键,和 DRF 同一个行为;
score 用 `z.coerce.number().int().min(0)`,对齐 IntegerField 的收字符串转整数。

前端跟着改:`Testcase` 从 `TestCaseEntry & { score: string }`(六键+字符串分数)
换成契约的 ProblemTestCaseScore(三键+整数),三处构造点只挑落库要的键。
detail.vue 那处写成 `Number((100 / n).toFixed(0))` —— 取值和原来逐字相同,
只是不再包成字符串,分数算法一个字没动。AdminProblem 的 Omit 列表也短了两项
(samples / testCaseScore 现在契约里就是准的),只剩 answers 要把 language
收窄成 LANGUAGE。

## 顺带:一段被 @ts-ignore 压着的死代码

admin/problem/detail.vue 上传测试点那里有

    // @ts-ignore
    if (res.error) { ... }

—— 拿 { error, data } 信封当返回值判。上一个 commit 拆信封时正是因为
@ts-ignore 压着,vue-tsc 没报出来。res 现在是 UploadTestCaseResponse,
没有 error 这个键,这个分支永远进不去。失败本来就走 catch。

(全仓另外两处 @ts-ignore 查过了,是 skulpt 和 wangeditor 没类型定义,正常。)

## 生产数据依据

956 道题逐条扫过备份:samples 947 条 {input,output} + 9 条空数组(SQL 题没
样例);answers 268 条 {code,language} + 633 null + 55 空数组;
test_case_score 956 条全是三键,score 全 int。收紧不会打到任何存量行。

## 验证

tsc(apps/api) 0 error、check:routes 168 条无遮蔽、vue-tsc 0 error、build 通过。
起服务实打了写路径:

- 后台题目详情 200,三个 schema 都 parse 得过存量数据。
- 故意造脏 PUT:score 传字符串 "20" + 塞进 stripped_output_md5 / input_size /
  output_size,落库是干净的 `{input_name, output_name, score: 20(int)}`;
  samples 和 answers 里塞的多余键同样被剥掉。
- 三个错误载荷都按预期 400:samples 缺 output、answers 缺 code、score 传负数。
- 浏览器里打开后台题目编辑页,点提交 → PUT 200 → 跳回列表,库里形状正确。

(顺带发现 tags 为空的题在编辑页点提交会被 `tags.min(1)` 挡下 400 —— 旧后端
`allow_empty=False` 也是这个行为,不在本次范围。)

冒烟改动已还原:problem 2 的 answers 复位成 [],测试用的「冒烟」标签删掉。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:04:41 -06:00

265 lines
6.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { LANGUAGE, Testcase } from "utils/types"
import { createZipBlob } from "utils/functions"
import { createTestSubmission } from "utils/judge"
import { uploadTestcases } from "../../api"
interface FileEntry {
id: number
in: string
out: string
error: boolean
}
interface Props {
answers: { language: LANGUAGE; code: string }[]
samples?: { input: string; output: string }[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
uploaded: [testCaseId: string, testCaseScore: Testcase[]]
}>()
const message = useMessage()
let nextId = 0
function makeInitialFiles(): FileEntry[] {
const fromSamples = (props.samples ?? []).map((s) => ({
id: nextId++,
in: s.input,
out: s.output,
error: false,
}))
const total = Math.ceil(Math.max(fromSamples.length, 1) / 5) * 5
const extra = total - fromSamples.length
return [
...fromSamples,
...Array.from({ length: extra }, () => ({
id: nextId++,
in: "",
out: "",
error: false,
})),
]
}
const files = ref<FileEntry[]>(makeInitialFiles())
const selectedLanguage = ref<LANGUAGE>("Python3")
// 始终显示所有语言,不管有没有答案代码
const availableLanguages = computed(() =>
props.answers.map((a) => ({ label: a.language, value: a.language })),
)
const hasAnyAnswerCode = computed(() =>
props.answers.some((a) => a.code.trim()),
)
// 当前选中语言是否有答案代码(用于控制"先运行"按钮)
const hasAnswerCode = computed(() => {
const answer = props.answers.find(
(a) => a.language === selectedLanguage.value,
)
return !!answer?.code.trim()
})
// 当语言列表变化时,确保 selectedLanguage 始终指向一个有效值
watch(
availableLanguages,
(langs) => {
if (
langs.length &&
!langs.find((l) => l.value === selectedLanguage.value)
) {
selectedLanguage.value = langs[0].value
}
},
{ immediate: true },
)
const isRunning = ref(false)
const isUploading = ref(false)
const hasAnyInput = computed(() => files.value.some((f) => f.in.trim()))
const canUpload = computed(
() =>
!isRunning.value &&
hasAnyInput.value &&
files.value.filter((f) => f.in.trim()).every((f) => f.out && !f.error),
)
function reset() {
files.value = Array.from({ length: 5 }, () => ({
id: nextId++,
in: "",
out: "",
error: false,
}))
}
function add(n: number) {
files.value.push(
...Array.from({ length: n }, () => ({
id: nextId++,
in: "",
out: "",
error: false,
})),
)
}
function remove(index: number) {
files.value.splice(index, 1)
}
async function run() {
const answer = props.answers.find(
(a) => a.language === selectedLanguage.value,
)
if (!answer?.code.trim()) return
// 过滤空行,去重(按输入内容)
const seen = new Set<string>()
files.value = files.value.filter((f) => {
if (!f.in.trim()) return false
if (seen.has(f.in)) return false
seen.add(f.in)
return true
})
// 清空旧输出
files.value = files.value.map((f) => ({ ...f, out: "", error: false }))
isRunning.value = true
await Promise.all(
files.value.map(async (_, i) => {
try {
const result = await createTestSubmission(
{ language: selectedLanguage.value, value: answer.code },
files.value[i].in,
)
files.value[i] = {
...files.value[i],
out: result.output,
error: result.status !== 3,
}
} catch {
files.value[i] = { ...files.value[i], out: "", error: true }
}
}),
)
isRunning.value = false
}
async function upload() {
isUploading.value = true
try {
const data = files.value
.filter((f) => f.in.trim() && f.out && !f.error)
.flatMap((f, i) => [
{ name: `${i + 1}.in`, content: f.in },
{ name: `${i + 1}.out`, content: f.out },
])
const blob = createZipBlob(data)
const file = new File([blob], "testcase.zip", { type: "application/zip" })
const res = await uploadTestcases(file)
// score 不在上传响应里,是这里按测试点数量平分补上的(余数给最后一个)
const entries = res.info
const baseScore = Math.floor(100 / entries.length)
const remainder = 100 - baseScore * entries.length
const testcases: Testcase[] = entries.map((entry, i) => ({
input_name: entry.input_name,
output_name: entry.output_name,
score: i === entries.length - 1 ? baseScore + remainder : baseScore,
}))
emit("uploaded", res.id, testcases)
message.success("上传成功")
} catch {
message.error("上传失败")
} finally {
isUploading.value = false
}
}
</script>
<template>
<n-flex vertical>
<n-alert
v-if="!hasAnyAnswerCode"
type="warning"
:show-icon="false"
style="margin-bottom: 8px"
>
还没有填写答案代码请先在上方"本题参考答案"中填写至少一种语言的答案再来生成测试用例
</n-alert>
<n-flex align="center" wrap>
<n-select
style="width: 120px"
:options="availableLanguages"
v-model:value="selectedLanguage"
/>
<n-button :disabled="isRunning" @click="reset">清空</n-button>
<n-button :disabled="isRunning" @click="add(1)">+1</n-button>
<n-button :disabled="isRunning" @click="add(5)">+5</n-button>
<n-tooltip :disabled="hasAnswerCode && hasAnyInput">
<template #trigger>
<span>
<n-button
type="success"
:loading="isRunning"
:disabled="!hasAnswerCode || !hasAnyInput"
@click="run"
>
先运行
</n-button>
</span>
</template>
{{ !hasAnswerCode ? "请先在题目中填写答案代码" : "请先填写输入" }}
</n-tooltip>
<n-button
type="primary"
:loading="isUploading"
:disabled="!canUpload"
@click="upload"
>
上传
</n-button>
</n-flex>
<n-flex
v-for="(file, index) in files"
:key="file.id"
align="start"
style="gap: 8px"
>
<n-flex vertical style="flex: 1">
<span>{{ index + 1 }}.in</span>
<n-input type="textarea" v-model:value="file.in" :rows="3" />
</n-flex>
<n-flex vertical style="flex: 1">
<span>{{ index + 1 }}.out</span>
<n-input
type="textarea"
v-model:value="file.out"
:rows="3"
:status="file.out ? (file.error ? 'error' : 'success') : undefined"
/>
</n-flex>
<n-button
:disabled="files.length === 1 || isRunning"
style="margin-top: 22px"
@click="remove(index)"
>
删除
</n-button>
</n-flex>
</n-flex>
</template>