Some checks failed
Deploy / deploy (push) Has been cancelled
旧后端这三个字段都是逐字段校验的(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>
265 lines
6.7 KiB
Vue
265 lines
6.7 KiB
Vue
<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>
|