feat(后台): 编程题出题页重做——左边写,右边是学生看到的样子;例子和测试数据的输出都由标准答案跑出来,改老题能直接看、改测试数据
- 例子就是测试数据里勾了「当例子」的几组,不再手抄输出;老题的例子不在测试数据里的补到最后 - 改了答案,输出变了的那组标黄、写清原来存的是什么;答案在某组上报错就挡住保存 - 没存标准答案的老题输出手写;太大的测试数据(>100 组或单文件 >32KB)只能导入 / 下载 zip - 预制代码只写学生看得到的那段,前后藏起来的代码收进折叠;语法要求先拿标准答案自测 - 流程图两个互斥开关改成三选一;时间 / 内存限制能改了;草稿只给新建页 - 后端:读回测试点原文、语法要求自测、AI 想测试输入三个接口 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
033a245abb
commit
ba5b9ada0e
15 files changed
+3043
-1068
No files matched your search
@@ -0,0 +1,192 @@
|
||||
import { TEST_CASE_EDIT_MAX_CASES, TEST_CASE_EDIT_MAX_FILE_BYTES } from "@oj2/contract"
|
||||
import type { RunnableLanguage } from "@oj2/contract"
|
||||
import { strFromU8, unzipSync } from "fflate"
|
||||
import type { Ref } from "vue"
|
||||
import { errorMessage } from "utils/api"
|
||||
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
|
||||
import { trialRun } from "utils/judge"
|
||||
|
||||
/**
|
||||
* 编程题出题页的测试数据(设计稿「编程题出题页」B):一组一行,输出由标准答案在判题机上
|
||||
* 跑出来,勾上「当例子」的几组按顺序写进题面的例子。
|
||||
*
|
||||
* 没有标准答案时(老题,954 道里 497 道)输出是手写的 —— 当初上传的文件原样读回来,
|
||||
* 老师直接改;写上答案以后就改成跟着答案跑。
|
||||
*/
|
||||
export interface CaseRow {
|
||||
key: number
|
||||
input: string
|
||||
/** 有标准答案时是答案跑出来的,没有时是手写的 */
|
||||
output: string
|
||||
example: boolean
|
||||
/** 从文件读回来时存着的输出。答案跑出来的和它不一样,就标「变了」 */
|
||||
saved: string | null
|
||||
/** 最近一次跑完对应的是哪一版(见 signatureOf) */
|
||||
ranFor: string
|
||||
/** 标准答案在这组上没跑通的原因 */
|
||||
error: string
|
||||
}
|
||||
|
||||
/** 拿来跑测试数据的那份标准答案。已经套好了题目模板的前后两段 */
|
||||
export interface AnswerSource {
|
||||
language: RunnableLanguage
|
||||
code: string
|
||||
}
|
||||
|
||||
export type CaseStatus = "manual" | "empty" | "running" | "error" | "changed" | "ok"
|
||||
|
||||
let nextKey = 0
|
||||
|
||||
export function newRow(input = "", output = "", example = false, saved: string | null = null) {
|
||||
return {
|
||||
key: nextKey++,
|
||||
input,
|
||||
output,
|
||||
example,
|
||||
saved,
|
||||
ranFor: "",
|
||||
error: "",
|
||||
} satisfies CaseRow
|
||||
}
|
||||
|
||||
/** 判题机比对时去掉的是整段输出末尾的空白(见 services/test-case.ts 的 rstrip),这里同一个口径 */
|
||||
export function sameOutput(a: string, b: string) {
|
||||
return a.replace(/\r\n/g, "\n").trimEnd() === b.replace(/\r\n/g, "\n").trimEnd()
|
||||
}
|
||||
|
||||
const signatureOf = (source: AnswerSource, row: CaseRow) =>
|
||||
JSON.stringify([source.language, source.code, row.input])
|
||||
|
||||
export function caseStatus(row: CaseRow, source: AnswerSource | null): CaseStatus {
|
||||
if (!source) return "manual"
|
||||
if (row.ranFor !== signatureOf(source, row)) return "running"
|
||||
if (row.error) return "error"
|
||||
if (row.saved !== null && !sameOutput(row.saved, row.output)) return "changed"
|
||||
return "ok"
|
||||
}
|
||||
|
||||
/** 答案报错时给老师看的一句话:状态 + 输出的最后一行(Python 的异常原文就在那) */
|
||||
function describeFailure(result: SubmissionStatus, output: string, line?: number | null) {
|
||||
const name = JUDGE_STATUS[result]?.name ?? "没跑通"
|
||||
const last = output.trimEnd().split("\n").at(-1)?.trim()
|
||||
const where = line ? `(第 ${line} 行)` : ""
|
||||
return last ? `${name}:${last}${where}` : `${name}${where}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试数据的重跑:一次只跑一批(后端每人同时最多两批,见 judge/trial.ts),
|
||||
* 跑的过程中又改了就记一笔,跑完再补一轮。
|
||||
*/
|
||||
export function useCaseRunner(rows: Ref<CaseRow[]>, source: Ref<AnswerSource | null>) {
|
||||
const running = ref(false)
|
||||
let again = false
|
||||
|
||||
async function runAll() {
|
||||
if (running.value) {
|
||||
again = true
|
||||
return
|
||||
}
|
||||
const answer = source.value
|
||||
if (!answer) return
|
||||
const todo = rows.value.filter((row) => row.ranFor !== signatureOf(answer, row))
|
||||
if (!todo.length) return
|
||||
running.value = true
|
||||
try {
|
||||
for (let start = 0; start < todo.length; start += 50) {
|
||||
const batch = todo.slice(start, start + 50)
|
||||
const signatures = batch.map((row) => signatureOf(answer, row))
|
||||
try {
|
||||
const response = await trialRun({
|
||||
language: answer.language,
|
||||
code: answer.code,
|
||||
cases: batch.map((row) => ({ input: row.input })),
|
||||
})
|
||||
batch.forEach((row, i) => {
|
||||
row.ranFor = signatures[i]!
|
||||
if (response.status === "compile-error") {
|
||||
row.output = ""
|
||||
row.error = `标准答案编译不过:${response.message.trim().split("\n").at(-1) ?? ""}`
|
||||
} else if (response.status === "too-much-output") {
|
||||
row.output = ""
|
||||
row.error = "输出太多(多半是死循环在不停打印)"
|
||||
} else {
|
||||
const run = response.cases[i]!
|
||||
if (run.result === SubmissionStatus.accepted) {
|
||||
row.output = run.output.trimEnd()
|
||||
row.error = ""
|
||||
} else {
|
||||
row.output = ""
|
||||
row.error = describeFailure(run.result, run.output, run.runtimeError?.line)
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
// 判题机忙、连不上:这一批记成没跑通,别无限重试;老师点「重跑」再来
|
||||
const reason = errorMessage(err, "判题机暂时连不上")
|
||||
batch.forEach((row, i) => {
|
||||
row.ranFor = signatures[i]!
|
||||
row.error = reason
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
running.value = false
|
||||
if (again) {
|
||||
again = false
|
||||
void runAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 「重跑」:把没跑通的几组作废重来 */
|
||||
function rerunFailed() {
|
||||
rows.value.forEach((row) => {
|
||||
if (row.error) row.ranFor = ""
|
||||
})
|
||||
void runAll()
|
||||
}
|
||||
|
||||
return { running, runAll, rerunFailed }
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入 zip:在浏览器里拆成一组组。和后端 collectPairs 同一个规矩 —— 从 1.in / 1.out
|
||||
* 开始连续编号,断了就停。太大(见契约 TEST_CASE_EDIT_*)就不拆,交给调用方直接上传。
|
||||
*/
|
||||
export function readCaseZip(
|
||||
archive: Uint8Array,
|
||||
): { ok: true; cases: { input: string; output: string }[] } | { ok: false; reason: string } {
|
||||
let files: Record<string, Uint8Array>
|
||||
try {
|
||||
files = unzipSync(archive, { filter: (file) => /^\d+\.(in|out)$/.test(file.name) })
|
||||
} catch {
|
||||
return { ok: false, reason: "压缩包损坏,或者不是 zip" }
|
||||
}
|
||||
const cases: { input: string; output: string }[] = []
|
||||
for (let i = 1; files[`${i}.in`] && files[`${i}.out`]; i += 1) {
|
||||
const input = files[`${i}.in`]!
|
||||
const output = files[`${i}.out`]!
|
||||
if (
|
||||
input.length > TEST_CASE_EDIT_MAX_FILE_BYTES ||
|
||||
output.length > TEST_CASE_EDIT_MAX_FILE_BYTES
|
||||
)
|
||||
return { ok: false, reason: "too-large" }
|
||||
cases.push({ input: strFromU8(input), output: strFromU8(output) })
|
||||
}
|
||||
if (!cases.length)
|
||||
return { ok: false, reason: "压缩包里没有从 1.in / 1.out 开始连续编号的测试点" }
|
||||
if (cases.length > TEST_CASE_EDIT_MAX_CASES) return { ok: false, reason: "too-large" }
|
||||
return { ok: true, cases }
|
||||
}
|
||||
|
||||
/** 打包上传前的文件内容:CRLF 统一成 LF,非空输入补结尾换行(C 的 scanf / gets 读最后一行更稳) */
|
||||
export function caseFiles(rows: { input: string; output: string }[]) {
|
||||
const lf = (text: string) => text.replace(/\r\n/g, "\n")
|
||||
return rows.flatMap((row, i) => {
|
||||
const input = lf(row.input)
|
||||
return [
|
||||
{ name: `${i + 1}.in`, content: input && !input.endsWith("\n") ? `${input}\n` : input },
|
||||
{ name: `${i + 1}.out`, content: lf(row.output) },
|
||||
]
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user