From ba5b9ada0e58a49d7a55565b0423a7d76e4c257c Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Fri, 9 Oct 2026 20:07:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=90=8E=E5=8F=B0):=20=E7=BC=96=E7=A8=8B?= =?UTF-8?q?=E9=A2=98=E5=87=BA=E9=A2=98=E9=A1=B5=E9=87=8D=E5=81=9A=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=B7=A6=E8=BE=B9=E5=86=99=EF=BC=8C=E5=8F=B3=E8=BE=B9?= =?UTF-8?q?=E6=98=AF=E5=AD=A6=E7=94=9F=E7=9C=8B=E5=88=B0=E7=9A=84=E6=A0=B7?= =?UTF-8?q?=E5=AD=90=EF=BC=9B=E4=BE=8B=E5=AD=90=E5=92=8C=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9A=84=E8=BE=93=E5=87=BA=E9=83=BD=E7=94=B1?= =?UTF-8?q?=E6=A0=87=E5=87=86=E7=AD=94=E6=A1=88=E8=B7=91=E5=87=BA=E6=9D=A5?= =?UTF-8?q?=EF=BC=8C=E6=94=B9=E8=80=81=E9=A2=98=E8=83=BD=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E7=9C=8B=E3=80=81=E6=94=B9=E6=B5=8B=E8=AF=95=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 例子就是测试数据里勾了「当例子」的几组,不再手抄输出;老题的例子不在测试数据里的补到最后 - 改了答案,输出变了的那组标黄、写清原来存的是什么;答案在某组上报错就挡住保存 - 没存标准答案的老题输出手写;太大的测试数据(>100 组或单文件 >32KB)只能导入 / 下载 zip - 预制代码只写学生看得到的那段,前后藏起来的代码收进折叠;语法要求先拿标准答案自测 - 流程图两个互斥开关改成三选一;时间 / 内存限制能改了;草稿只给新建页 - 后端:读回测试点原文、语法要求自测、AI 想测试输入三个接口 Co-Authored-By: Claude Opus 5.5 --- apps/api/src/routes/admin/problem.ts | 100 +- apps/api/src/services/hint-diagnosis.ts | 2 +- apps/api/src/services/test-case.ts | 39 +- apps/web/src/admin/api.ts | 22 + .../problem/components/AstRulesEditor.vue | 418 +++- .../admin/problem/components/CaseTable.vue | 257 ++ .../admin/problem/components/CodePreview.vue | 456 ++++ .../problem/components/TestcaseGenerator.vue | 277 --- .../src/admin/problem/components/caseRows.ts | 192 ++ .../admin/problem/components/codeTemplate.ts | 55 + .../admin/problem/components/editorTypes.ts | 13 + apps/web/src/admin/problem/detail.vue | 2177 ++++++++++++----- apps/web/src/shared/components/TextEditor.vue | 40 +- apps/web/src/utils/constants.ts | 1 + packages/contract/src/admin.ts | 62 +- 15 files changed, 3043 insertions(+), 1068 deletions(-) create mode 100644 apps/web/src/admin/problem/components/CaseTable.vue create mode 100644 apps/web/src/admin/problem/components/CodePreview.vue delete mode 100644 apps/web/src/admin/problem/components/TestcaseGenerator.vue create mode 100644 apps/web/src/admin/problem/components/caseRows.ts create mode 100644 apps/web/src/admin/problem/components/codeTemplate.ts create mode 100644 apps/web/src/admin/problem/components/editorTypes.ts diff --git a/apps/api/src/routes/admin/problem.ts b/apps/api/src/routes/admin/problem.ts index dddeb09..452421e 100644 --- a/apps/api/src/routes/admin/problem.ts +++ b/apps/api/src/routes/admin/problem.ts @@ -1,6 +1,10 @@ import { addContestProblemRequestSchema, + astCheckRequestSchema, + generateTestInputsRequestSchema, isSqlProblem, + TEST_CASE_EDIT_MAX_CASES, + TEST_CASE_EDIT_MAX_FILE_BYTES, createProblemRequestSchema, generateSqlTestCaseRequestSchema, makeProblemPublicRequestSchema, @@ -9,11 +13,14 @@ import { type AdminProblem, type AdminProblemList, type AdminProblemListItem, + type AstCheckResponse, type AstRules, + type GenerateTestInputsResponse, type GenerateSqlTestCaseResponse, type SqlConfig, type SqlDisplay, type SqlTestCaseScript, + type TestCaseFiles, type UploadTestCaseResponse, } from "@oj2/contract" import { and, count, desc, eq, ilike, inArray, isNull, ne, not, or, sql } from "drizzle-orm" @@ -23,15 +30,17 @@ import { requireProblemPermission, type AppEnv } from "../../auth/middleware" import type { AuthUser } from "../../auth/session" import { db, schema, type DbOrTx } from "../../db" import { failure, parseBody, success } from "../../http" -import { astRulesError, pickAstRules } from "../../judge/ast" +import { astRulesError, checkAst, describeAstRule, pickAstRules } from "../../judge/ast" import { buildSqlDisplay } from "../../judge/sql" import { completeChat } from "../../services/ai" +import { plainText } from "../../services/hint-diagnosis" import { contestStatus } from "../../services/contest" import { packTestCaseZip, processTestCaseZip, readInfo, readSqlScripts, + readTestCaseFiles, TestCaseError, } from "../../services/test-case" import { config } from "../../config" @@ -863,6 +872,95 @@ adminProblemRoutes.get("/problems/:id/sql-scripts", requireProblemPermission, as } }) +/** + * 回显编程题已有的测试点原文,出题页就地改。太大的(见契约 TEST_CASE_EDIT_*)只给数量和大小。 + * SQL 题的测试点是脚本,走上面的 sql-scripts。 + */ +adminProblemRoutes.get("/problems/:id/test-case-files", requireProblemPermission, async (c) => { + const [problem] = await db + .select() + .from(schema.problem) + .where(eq(schema.problem.id, queryInteger(c.req.param("id"), 0, { min: 1 }))) + .limit(1) + if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist") + if (!(await canEdit(c.get("user")!, problem))) { + return failure(c, 404, "problem-not-found", "Problem does not exist") + } + const info = await readInfo(problem.testCaseId) + if (info?.sql) return failure(c, 409, "sql-test-case", "该题的测试点是 SQL 脚本") + try { + const files = await readTestCaseFiles(problem.testCaseId, { + maxCases: TEST_CASE_EDIT_MAX_CASES, + maxFileBytes: TEST_CASE_EDIT_MAX_FILE_BYTES, + }) + return success(c, files satisfies TestCaseFiles) + } catch (error) { + if (error instanceof TestCaseError) + return failure(c, 404, "test-case-not-found", "测试点文件不存在") + throw error + } +}) + +/** 语法要求:每条规则学生看到的那句话 + 标准答案过不过,和判题时同一个 checkAst */ +adminProblemRoutes.post("/ast-check", requireProblemPermission, async (c) => { + const parsed = await parseBody(c, astCheckRequestSchema) + if (!parsed.success) return parsed.response + const { language, code, rules } = parsed.data + const error = astRulesError({ [language]: rules }) + if (error) return failure(c, 400, "invalid-ast-rules", error) + const checked = [] + for (const rule of rules) { + const description = describeAstRule(rule, language) + if (!code.trim()) { + checked.push({ description, passed: null }) + continue + } + const [result] = (await checkAst(code, language, [rule])).results + checked.push({ description, passed: result?.passed ?? null, actual: result?.actual }) + } + return success(c, { rules: checked } satisfies AstCheckResponse) +}) + +/** + * AI 按题面和标准答案想几组测试输入。只要输入:输出一律由标准答案在判题机上跑, + * AI 编的输出不可信。 + */ +adminProblemRoutes.post("/test-inputs/generate", requireProblemPermission, async (c) => { + const parsed = await parseBody(c, generateTestInputsRequestSchema) + if (!parsed.success) return parsed.response + const body = parsed.data + const existing = body.existing.length + ? body.existing.map((input, i) => `第 ${i + 1} 组:\n${input}`).join("\n\n") + : "(还没有)" + try { + const raw = await completeChat( + `你是编程题出题助手,帮老师补测试数据。老师会给你题目描述、输入说明、标准答案和已有的测试输入。 +请再想 5 组新的测试输入,要和已有的不重复,重点覆盖边界情况(最小值、最大值、0、负数、 +重复元素、只有一个元素、各个分支都要走到……),但必须严格符合输入说明的格式,标准答案要能正常处理。 +数据规模保持小,每组不超过 20 行。 +只回 json:{"inputs": ["第一组输入", "第二组输入", ...]},每组输入是一个字符串,多行用 \\n 分隔。`, + [ + `题目描述:${plainText(body.description).slice(0, 3000)}`, + `输入说明:${plainText(body.inputDescription).slice(0, 1000) || "无"}`, + `标准答案(${body.language}):\n${body.answer}`, + `已有的测试输入:\n${existing.slice(0, 4000)}`, + ].join("\n\n"), + { json: true }, + ) + const value: unknown = JSON.parse(raw) + const list = + value && typeof value === "object" && Array.isArray((value as { inputs?: unknown }).inputs) + ? ((value as { inputs: unknown[] }).inputs.filter((x) => typeof x === "string") as string[]) + : [] + const known = new Set(body.existing.map((input) => input.trim())) + const inputs = list.filter((input) => !known.has(input.trim())).slice(0, 10) + return success(c, { inputs } satisfies GenerateTestInputsResponse) + } catch (error) { + console.error("Test input generation failed", error) + return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试") + } +}) + /** SQL 题测试点预览:跑一遍初始化脚本 + 标准答案,返回题目页要展示的数据表与期望结果 */ adminProblemRoutes.post("/sql-test-cases/preview", requireProblemPermission, async (c) => { const parsed = await parseBody(c, sqlPreviewRequestSchema) diff --git a/apps/api/src/services/hint-diagnosis.ts b/apps/api/src/services/hint-diagnosis.ts index 4875eb9..952aa9b 100644 --- a/apps/api/src/services/hint-diagnosis.ts +++ b/apps/api/src/services/hint-diagnosis.ts @@ -88,7 +88,7 @@ function errInfo(row: HintRow) { * 输出说明里的 `<`、` ` 恰恰可能就是格式要求本身,不解码模型会读歪。 * 图片直接丢掉(模型看不到图),只留一个占位,让它知道那里原本有东西。 */ -function plainText(html: string) { +export function plainText(html: string) { return ( html .replace(/]*>/gi, "[图片]") diff --git a/apps/api/src/services/test-case.ts b/apps/api/src/services/test-case.ts index faab6ee..780f45d 100644 --- a/apps/api/src/services/test-case.ts +++ b/apps/api/src/services/test-case.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto" -import { mkdir, chmod, readdir, readFile, writeFile } from "node:fs/promises" +import { mkdir, chmod, readdir, readFile, stat, writeFile } from "node:fs/promises" import { resolve } from "node:path" import { unzipSync, zipSync } from "fflate" @@ -250,6 +250,43 @@ export async function readSqlScripts(testCaseId: string) { return scripts } +/** + * 读回编程题测试点的原文(出题页回显、就地改)。超过 `limits` 的只报数量和大小,不读内容。 + * 目录不存在时抛 TestCaseError。 + */ +export async function readTestCaseFiles( + testCaseId: string, + limits: { maxCases: number; maxFileBytes: number }, +): Promise< + | { editable: true; cases: { input: string; output: string }[] } + | { editable: false; count: number; totalBytes: number } +> { + const directory = resolve(config.testCaseDirectory, testCaseId) + let entries: string[] + try { + entries = await readdir(directory) + } catch { + throw new TestCaseError("Test case does not exist") + } + const pairs = collectPairs(new Set(entries)) + let totalBytes = 0 + let tooLarge = pairs.length > limits.maxCases + for (const name of pairs.flat()) { + const size = (await stat(resolve(directory, name))).size + totalBytes += size + if (size > limits.maxFileBytes) tooLarge = true + } + if (tooLarge) return { editable: false, count: pairs.length, totalBytes } + const cases: { input: string; output: string }[] = [] + for (const [input, output] of pairs) { + cases.push({ + input: await readFile(resolve(directory, input), "utf8"), + output: await readFile(resolve(directory, output), "utf8"), + }) + } + return { editable: true, cases } +} + function randomId() { const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789" const bytes = new Uint8Array(32) diff --git a/apps/web/src/admin/api.ts b/apps/web/src/admin/api.ts index 6c325b3..fdb7b18 100644 --- a/apps/web/src/admin/api.ts +++ b/apps/web/src/admin/api.ts @@ -45,6 +45,13 @@ import type { TutorialListItem, TagCategory, } from "utils/types" +import type { + AstCheckRequest, + AstCheckResponse, + GenerateTestInputsRequest, + GenerateTestInputsResponse, + TestCaseFiles, +} from "@oj2/contract" export function getBaseInfo() { return api.get("admin/dashboard") @@ -252,6 +259,21 @@ export function generateSQLTestcase(data: { refSql: string; mode: "query" | "mod return api.post("admin/sql-test-cases/generate", data) } +// 编程题出题页:读回已有测试点的原文(太大的只给数量和大小) +export function getTestCaseFiles(problemId: number) { + return api.get(`admin/problems/${problemId}/test-case-files`) +} + +// 拿标准答案自测语法要求 +export function checkAstRules(data: AstCheckRequest) { + return api.post("admin/ast-check", data) +} + +// AI 按题面和标准答案想几组测试输入(输出由标准答案跑) +export function generateTestInputs(data: GenerateTestInputsRequest) { + return api.post("admin/test-inputs/generate", data) +} + /** 出站补默认值。字段名两边已经一致,不再做键名转换 */ function toProblemBody(problem: AdminProblem | BlankProblem) { const p = problem as Partial diff --git a/apps/web/src/admin/problem/components/AstRulesEditor.vue b/apps/web/src/admin/problem/components/AstRulesEditor.vue index 6cbe821..54fa8ea 100644 --- a/apps/web/src/admin/problem/components/AstRulesEditor.vue +++ b/apps/web/src/admin/problem/components/AstRulesEditor.vue @@ -4,11 +4,17 @@ import { AST_OPERATOR_TARGETS_BY_LANGUAGE, AST_SUPPORTED_LANGUAGES, } from "@oj2/contract" +import { useThemeVars } from "naive-ui" +import { errorMessage } from "utils/api" import type { AstRule, AstRules, LANGUAGE } from "utils/types" +import { checkAstRules } from "../../api" +import type { AstCheckState } from "./editorTypes" interface Props { modelValue: AstRules | null languages: LANGUAGE[] + /** 各语言的标准答案(学生那段代码,不套模板):拿来自测规则 */ + answers: Partial> } const props = defineProps() @@ -28,6 +34,54 @@ const unsupportedLanguages = computed(() => const activeTab = ref(supportedLanguages.value[0] || "Python") +const theme = useThemeVars() + +// ---------------------------------------------------------------- 自测 + +/** + * 规则配错(节点类型对不上)时完全静默:「必须使用 X」永远失败、「不能使用 X」永远通过。 + * 拿标准答案先跑一遍,标准答案自己都过不了,多半是规则配错了。顺带拿到学生看到的那句话 + */ +const checks = defineModel("checks", { default: () => ({}) }) + +let generation = 0 + +async function runChecks() { + const mine = ++generation + const next: AstCheckState = {} + for (const lang of supportedLanguages.value) { + const rules = getRulesForLang(lang) + if (!rules.length) continue + try { + next[lang] = await checkAstRules({ + language: lang as "C" | "C++" | "Python", + code: props.answers[lang] ?? "", + rules, + }) + } catch (err) { + next[lang] = { error: errorMessage(err, "这组规则检查不了") } + } + } + // 跑的过程中又改了:这一轮作废,新的那轮另有一次 + if (mine === generation) checks.value = next +} + +watchDebounced(() => [props.modelValue, props.answers, supportedLanguages.value], runChecks, { + debounce: 500, + deep: true, + immediate: true, +}) + +function checkOf(lang: string, index: number) { + const state = checks.value[lang] + if (!state || "error" in state) return null + return state.rules[index] ?? null +} + +function countOf(lang: string) { + return getRulesForLang(lang).length +} + const ENGINE_OPTIONS: SelectOption[] = [ { label: "节点检查", @@ -107,7 +161,7 @@ function isCountEngine(engine: string) { } const COUNT_MODE_OPTIONS: SelectOption[] = [ - { label: "精确", value: "exact" }, + { label: "正好", value: "exact" }, { label: "范围", value: "range" }, ] @@ -238,120 +292,258 @@ watch(supportedLanguages, (langs) => { + + diff --git a/apps/web/src/admin/problem/components/CaseTable.vue b/apps/web/src/admin/problem/components/CaseTable.vue new file mode 100644 index 0000000..cdd6302 --- /dev/null +++ b/apps/web/src/admin/problem/components/CaseTable.vue @@ -0,0 +1,257 @@ + + + + + diff --git a/apps/web/src/admin/problem/components/CodePreview.vue b/apps/web/src/admin/problem/components/CodePreview.vue new file mode 100644 index 0000000..fe6d743 --- /dev/null +++ b/apps/web/src/admin/problem/components/CodePreview.vue @@ -0,0 +1,456 @@ + + + + + diff --git a/apps/web/src/admin/problem/components/TestcaseGenerator.vue b/apps/web/src/admin/problem/components/TestcaseGenerator.vue deleted file mode 100644 index 693a01b..0000000 --- a/apps/web/src/admin/problem/components/TestcaseGenerator.vue +++ /dev/null @@ -1,277 +0,0 @@ - - - diff --git a/apps/web/src/admin/problem/components/caseRows.ts b/apps/web/src/admin/problem/components/caseRows.ts new file mode 100644 index 0000000..073844d --- /dev/null +++ b/apps/web/src/admin/problem/components/caseRows.ts @@ -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, source: Ref) { + 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 + 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) }, + ] + }) +} diff --git a/apps/web/src/admin/problem/components/codeTemplate.ts b/apps/web/src/admin/problem/components/codeTemplate.ts new file mode 100644 index 0000000..4affc55 --- /dev/null +++ b/apps/web/src/admin/problem/components/codeTemplate.ts @@ -0,0 +1,55 @@ +/** + * 题目的代码模板在库里是带标记的一整段: + * + * //PREPEND BEGIN … //PREPEND END 藏在学生代码前面一起运行(学生看不到) + * //TEMPLATE BEGIN … //TEMPLATE END 学生打开编辑器时里面已经有的代码 + * //APPEND BEGIN … //APPEND END 藏在后面 + * + * 出题页只让老师写三段内容,标记在这里拼。解析和后端 judge/template.ts 同一个正则 —— + * 段落内容非空时必须以换行结尾(`([\s\S]+?)//X END` 的口径),拼的时候补上。 + */ +export interface TemplateParts { + prepend: string + template: string + append: string +} + +const section = (raw: string, name: string) => + raw.match(new RegExp(`//${name} BEGIN\\n([\\s\\S]+?)//${name} END`))?.[1] ?? "" + +/** 段落末尾那个换行是标记格式要的,不是内容,显示时去掉 */ +const strip = (text: string) => text.replace(/\n$/, "") + +export function parseTemplate(raw: string | undefined): TemplateParts { + if (!raw) return { prepend: "", template: "", append: "" } + return { + prepend: strip(section(raw, "PREPEND")), + template: strip(section(raw, "TEMPLATE")), + append: strip(section(raw, "APPEND")), + } +} + +export function isBlankTemplate(parts: TemplateParts) { + return !parts.prepend.trim() && !parts.template.trim() && !parts.append.trim() +} + +/** 三段都空就不存(返回 null),学生打开时用全站默认的 */ +export function buildTemplate(parts: TemplateParts): string | null { + if (isBlankTemplate(parts)) return null + return [ + `//PREPEND BEGIN\n${body(parts.prepend)}//PREPEND END`, + `//TEMPLATE BEGIN\n${body(parts.template)}//TEMPLATE END`, + `//APPEND BEGIN\n${body(parts.append)}//APPEND END`, + ].join("\n\n") +} + +/** 跑标准答案时套上前后两段,和判题时(routes/trial-run.ts、judgeSubmission)一个拼法 */ +export function wrapWithTemplate(code: string, parts: TemplateParts) { + if (!parts.prepend.trim() && !parts.append.trim()) return code + return `${body(parts.prepend)}\n${code}\n${body(parts.append)}` +} + +/** 一段内容:非空时统一以一个换行结尾 */ +function body(text: string) { + return text.trim() ? `${text.replace(/\n*$/, "")}\n` : "" +} diff --git a/apps/web/src/admin/problem/components/editorTypes.ts b/apps/web/src/admin/problem/components/editorTypes.ts new file mode 100644 index 0000000..4f20291 --- /dev/null +++ b/apps/web/src/admin/problem/components/editorTypes.ts @@ -0,0 +1,13 @@ +import type { AstCheckResponse } from "@oj2/contract" + +/** + * 出题页里每种语言的语法要求自测结果:每条规则学生看到的那句话 + 标准答案过不过。 + * 规则本身配得不对(后端 astRulesError)时是一句错误。右边预览和保存前的检查都看它 + */ +export type AstCheckState = Record + +/** 右栏「学生看到的样子」开着哪一页 */ +export type PreviewTab = "statement" | "editor" | "flowchart" + +/** 流程图三选一:不用 / 给学生看参考图(showFlowchart)/ 让学生自己画、按这张图打分(allowFlowchart) */ +export type FlowchartMode = "none" | "show" | "draw" diff --git a/apps/web/src/admin/problem/detail.vue b/apps/web/src/admin/problem/detail.vue index fa6dcf3..a116c9e 100644 --- a/apps/web/src/admin/problem/detail.vue +++ b/apps/web/src/admin/problem/detail.vue @@ -1,775 +1,1608 @@ diff --git a/apps/web/src/shared/components/TextEditor.vue b/apps/web/src/shared/components/TextEditor.vue index b0d8153..538484f 100644 --- a/apps/web/src/shared/components/TextEditor.vue +++ b/apps/web/src/shared/components/TextEditor.vue @@ -7,13 +7,17 @@ import { uploadImage } from "../../admin/api" interface Props { title: string simple?: boolean + /** 一行放得下的那套:出题页左栏只有 600 宽,完整工具栏会折成三行 */ + compact?: boolean + /** 最少的一套:出题页的输入 / 输出说明并排放,每个才 280 宽 */ + mini?: boolean minHeight?: number } const rawHtml = defineModel("value") type InsertFnType = (url: string, alt: string, href: string) => void -const { title, minHeight = 0, simple = false } = defineProps() +const { title, minHeight = 0, simple = false, compact = false, mini = false } = defineProps() const message = useMessage() @@ -66,6 +70,30 @@ const toolbarConfigSimple: Partial = { ], } +const toolbarConfigCompact: Partial = { + toolbarKeys: [ + "bold", + "color", + "bgColor", + "|", + "bulletedList", + "numberedList", + "|", + "uploadImage", + "insertTable", + "insertLink", + "emotion", + "|", + "clearStyle", + "undo", + "redo", + ], +} + +const toolbarConfigMini: Partial = { + toolbarKeys: ["bold", "color", "uploadImage", "insertLink", "undo", "redo"], +} + const editorConfig: Partial = { scroll: false, MENU_CONF: { @@ -110,7 +138,15 @@ async function customUpload(file: File, insertFn: InsertFnType) { export type AchievementMetric = z.infer @@ -873,6 +928,11 @@ export type RenameTagResponse = z.infer export type BatchProblemTagResponse = z.infer export type SqlTestCaseScript = z.infer export type GenerateSqlTestCaseResponse = z.infer +export type TestCaseFiles = z.infer +export type AstCheckRequest = z.infer +export type AstCheckResponse = z.infer +export type GenerateTestInputsRequest = z.infer +export type GenerateTestInputsResponse = z.infer export type AdminProblemSetProgress = z.infer export type AdminAnnouncementList = z.infer