feat(后台): 编程题出题页重做——左边写,右边是学生看到的样子;例子和测试数据的输出都由标准答案跑出来,改老题能直接看、改测试数据

- 例子就是测试数据里勾了「当例子」的几组,不再手抄输出;老题的例子不在测试数据里的补到最后
- 改了答案,输出变了的那组标黄、写清原来存的是什么;答案在某组上报错就挡住保存
- 没存标准答案的老题输出手写;太大的测试数据(>100 组或单文件 >32KB)只能导入 / 下载 zip
- 预制代码只写学生看得到的那段,前后藏起来的代码收进折叠;语法要求先拿标准答案自测
- 流程图两个互斥开关改成三选一;时间 / 内存限制能改了;草稿只给新建页
- 后端:读回测试点原文、语法要求自测、AI 想测试输入三个接口

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-09 20:07:34 +08:00
1 parent 033a245abb
commit ba5b9ada0e
15 files changed
+3043 -1068

No files matched your search

+99 -1
View File
@@ -1,6 +1,10 @@
import { import {
addContestProblemRequestSchema, addContestProblemRequestSchema,
astCheckRequestSchema,
generateTestInputsRequestSchema,
isSqlProblem, isSqlProblem,
TEST_CASE_EDIT_MAX_CASES,
TEST_CASE_EDIT_MAX_FILE_BYTES,
createProblemRequestSchema, createProblemRequestSchema,
generateSqlTestCaseRequestSchema, generateSqlTestCaseRequestSchema,
makeProblemPublicRequestSchema, makeProblemPublicRequestSchema,
@@ -9,11 +13,14 @@ import {
type AdminProblem, type AdminProblem,
type AdminProblemList, type AdminProblemList,
type AdminProblemListItem, type AdminProblemListItem,
type AstCheckResponse,
type AstRules, type AstRules,
type GenerateTestInputsResponse,
type GenerateSqlTestCaseResponse, type GenerateSqlTestCaseResponse,
type SqlConfig, type SqlConfig,
type SqlDisplay, type SqlDisplay,
type SqlTestCaseScript, type SqlTestCaseScript,
type TestCaseFiles,
type UploadTestCaseResponse, type UploadTestCaseResponse,
} from "@oj2/contract" } from "@oj2/contract"
import { and, count, desc, eq, ilike, inArray, isNull, ne, not, or, sql } from "drizzle-orm" 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 type { AuthUser } from "../../auth/session"
import { db, schema, type DbOrTx } from "../../db" import { db, schema, type DbOrTx } from "../../db"
import { failure, parseBody, success } from "../../http" 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 { buildSqlDisplay } from "../../judge/sql"
import { completeChat } from "../../services/ai" import { completeChat } from "../../services/ai"
import { plainText } from "../../services/hint-diagnosis"
import { contestStatus } from "../../services/contest" import { contestStatus } from "../../services/contest"
import { import {
packTestCaseZip, packTestCaseZip,
processTestCaseZip, processTestCaseZip,
readInfo, readInfo,
readSqlScripts, readSqlScripts,
readTestCaseFiles,
TestCaseError, TestCaseError,
} from "../../services/test-case" } from "../../services/test-case"
import { config } from "../../config" 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 题测试点预览:跑一遍初始化脚本 + 标准答案,返回题目页要展示的数据表与期望结果 */ /** SQL 题测试点预览:跑一遍初始化脚本 + 标准答案,返回题目页要展示的数据表与期望结果 */
adminProblemRoutes.post("/sql-test-cases/preview", requireProblemPermission, async (c) => { adminProblemRoutes.post("/sql-test-cases/preview", requireProblemPermission, async (c) => {
const parsed = await parseBody(c, sqlPreviewRequestSchema) const parsed = await parseBody(c, sqlPreviewRequestSchema)
+1 -1
View File
@@ -88,7 +88,7 @@ function errInfo(row: HintRow) {
* 输出说明里的 `&lt;`、`&nbsp;` 恰恰可能就是格式要求本身,不解码模型会读歪。 * 输出说明里的 `&lt;`、`&nbsp;` 恰恰可能就是格式要求本身,不解码模型会读歪。
* 图片直接丢掉(模型看不到图),只留一个占位,让它知道那里原本有东西。 * 图片直接丢掉(模型看不到图),只留一个占位,让它知道那里原本有东西。
*/ */
function plainText(html: string) { export function plainText(html: string) {
return ( return (
html html
.replace(/<img\b[^>]*>/gi, "[图片]") .replace(/<img\b[^>]*>/gi, "[图片]")
+38 -1
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto" 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 { resolve } from "node:path"
import { unzipSync, zipSync } from "fflate" import { unzipSync, zipSync } from "fflate"
@@ -250,6 +250,43 @@ export async function readSqlScripts(testCaseId: string) {
return scripts 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() { function randomId() {
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789" const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
const bytes = new Uint8Array(32) const bytes = new Uint8Array(32)
+22
View File
@@ -45,6 +45,13 @@ import type {
TutorialListItem, TutorialListItem,
TagCategory, TagCategory,
} from "utils/types" } from "utils/types"
import type {
AstCheckRequest,
AstCheckResponse,
GenerateTestInputsRequest,
GenerateTestInputsResponse,
TestCaseFiles,
} from "@oj2/contract"
export function getBaseInfo() { export function getBaseInfo() {
return api.get<DashboardInfo>("admin/dashboard") return api.get<DashboardInfo>("admin/dashboard")
@@ -252,6 +259,21 @@ export function generateSQLTestcase(data: { refSql: string; mode: "query" | "mod
return api.post<GenerateSqlTestCaseResponse>("admin/sql-test-cases/generate", data) return api.post<GenerateSqlTestCaseResponse>("admin/sql-test-cases/generate", data)
} }
// 编程题出题页:读回已有测试点的原文(太大的只给数量和大小)
export function getTestCaseFiles(problemId: number) {
return api.get<TestCaseFiles>(`admin/problems/${problemId}/test-case-files`)
}
// 拿标准答案自测语法要求
export function checkAstRules(data: AstCheckRequest) {
return api.post<AstCheckResponse>("admin/ast-check", data)
}
// AI 按题面和标准答案想几组测试输入(输出由标准答案跑)
export function generateTestInputs(data: GenerateTestInputsRequest) {
return api.post<GenerateTestInputsResponse>("admin/test-inputs/generate", data)
}
/** 出站补默认值。字段名两边已经一致,不再做键名转换 */ /** 出站补默认值。字段名两边已经一致,不再做键名转换 */
function toProblemBody(problem: AdminProblem | BlankProblem) { function toProblemBody(problem: AdminProblem | BlankProblem) {
const p = problem as Partial<AdminProblem> const p = problem as Partial<AdminProblem>
@@ -4,11 +4,17 @@ import {
AST_OPERATOR_TARGETS_BY_LANGUAGE, AST_OPERATOR_TARGETS_BY_LANGUAGE,
AST_SUPPORTED_LANGUAGES, AST_SUPPORTED_LANGUAGES,
} from "@oj2/contract" } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { errorMessage } from "utils/api"
import type { AstRule, AstRules, LANGUAGE } from "utils/types" import type { AstRule, AstRules, LANGUAGE } from "utils/types"
import { checkAstRules } from "../../api"
import type { AstCheckState } from "./editorTypes"
interface Props { interface Props {
modelValue: AstRules | null modelValue: AstRules | null
languages: LANGUAGE[] languages: LANGUAGE[]
/** 各语言的标准答案(学生那段代码,不套模板):拿来自测规则 */
answers: Partial<Record<LANGUAGE, string>>
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -28,6 +34,54 @@ const unsupportedLanguages = computed(() =>
const activeTab = ref(supportedLanguages.value[0] || "Python") const activeTab = ref(supportedLanguages.value[0] || "Python")
const theme = useThemeVars()
// ---------------------------------------------------------------- 自测
/**
* 规则配错(节点类型对不上)时完全静默:「必须使用 X」永远失败、「不能使用 X」永远通过。
* 拿标准答案先跑一遍,标准答案自己都过不了,多半是规则配错了。顺带拿到学生看到的那句话
*/
const checks = defineModel<AstCheckState>("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[] = [ const ENGINE_OPTIONS: SelectOption[] = [
{ {
label: "节点检查", label: "节点检查",
@@ -107,7 +161,7 @@ function isCountEngine(engine: string) {
} }
const COUNT_MODE_OPTIONS: SelectOption[] = [ const COUNT_MODE_OPTIONS: SelectOption[] = [
{ label: "精确", value: "exact" }, { label: "正好", value: "exact" },
{ label: "范围", value: "range" }, { label: "范围", value: "range" },
] ]
@@ -238,120 +292,258 @@ watch(supportedLanguages, (langs) => {
</script> </script>
<template> <template>
<n-collapse> <div class="astRules">
<n-collapse-item title="代码规则检查(选填)" name="ast-rules"> <div class="head">
<n-alert <span class="label">语法要求</span>
v-if="unsupportedLanguages.length" <div v-if="supportedLanguages.length" class="langs" role="tablist" aria-label="语言">
type="info" <button
:bordered="false" v-for="lang in supportedLanguages"
style="margin-bottom: 8px" :key="lang"
> type="button"
{{ unsupportedLanguages.join("、") }} role="tab"
暂不支持代码规则检查,判题机只能检查 :aria-selected="activeTab === lang"
{{ AST_SUPPORTED_LANGUAGES.join(" / ") }} class="lang"
</n-alert> :class="{ on: activeTab === lang }"
<n-tabs v-if="supportedLanguages.length" type="segment" v-model:value="activeTab"> @click="activeTab = lang"
<n-tab-pane v-for="lang in supportedLanguages" :key="lang" :name="lang" :tab="lang"> >
<n-flex vertical> {{ lang }} · {{ countOf(lang) ? `${countOf(lang)} 条` : "没有" }}
<div </button>
v-for="(rule, index) in getRulesForLang(lang)" </div>
:key="index" <div class="grow"></div>
style="margin-bottom: 8px" <n-text depth="3" class="note">提交时逐条检查,不满足算错</n-text>
> </div>
<n-flex align="center" :wrap="false"> <n-text v-if="unsupportedLanguages.length" depth="3" class="note">
<n-select {{ unsupportedLanguages.join("、") }} 检查不了语法要求(判题机只认
:options="ENGINE_OPTIONS" {{ AST_SUPPORTED_LANGUAGES.join(" / ") }})
:value="rule.engine" </n-text>
@update:value="(v: string) => updateRule(lang, index, 'engine', v)" <n-text v-if="!supportedLanguages.length" depth="3" class="note">先选能交的语言</n-text>
style="width: 150px" <template v-for="lang in supportedLanguages" :key="lang">
<div v-if="activeTab === lang" class="rules">
<div v-if="checks[lang] && 'error' in checks[lang]" class="ruleError">
{{ (checks[lang] as { error: string }).error }}
</div>
<div v-for="(rule, index) in getRulesForLang(lang)" :key="index" class="rule">
<div class="ruleRow">
<n-select
:options="ENGINE_OPTIONS"
:value="rule.engine"
size="small"
class="engine"
@update:value="(v: string) => updateRule(lang, index, 'engine', v)"
/>
<n-select
v-if="needsTargetDropdown(rule.engine)"
:options="nodeTargetOptions(lang)"
:value="rule.target"
size="small"
class="target"
filterable
@update:value="(v: string) => updateRule(lang, index, 'target', v)"
/>
<n-input
v-if="needsTargetInput(rule.engine)"
:value="rule.target"
size="small"
class="target"
placeholder="函数 / 方法名"
@update:value="(v: string) => updateRule(lang, index, 'target', v)"
/>
<n-select
v-if="needsOperatorDropdown(rule.engine)"
:options="operatorTargetOptions(lang)"
:value="rule.target"
size="small"
class="target"
@update:value="(v: string) => updateRule(lang, index, 'target', v)"
/>
<template v-if="isCountEngine(rule.engine)">
<n-select
:options="COUNT_MODE_OPTIONS"
:value="getCountMode(rule)"
size="small"
class="mode"
@update:value="(v: 'exact' | 'range') => updateCountMode(lang, index, v)"
/>
<n-input-number
v-if="getCountMode(rule) === 'exact'"
:value="rule.exact ?? null"
size="small"
class="num"
placeholder="次数"
:min="1"
:show-button="false"
@update:value="(v: number | null) => updateExactCount(lang, index, v)"
/>
<template v-else>
<n-input-number
:value="rule.min ?? null"
size="small" size="small"
class="num"
placeholder="最少"
:min="0"
:show-button="false"
@update:value="(v: number | null) => updateRule(lang, index, 'min', v)"
/> />
<n-select <span>~</span>
v-if="needsTargetDropdown(rule.engine)" <n-input-number
:options="nodeTargetOptions(lang)" :value="rule.max ?? null"
:value="rule.target"
@update:value="(v: string) => updateRule(lang, index, 'target', v)"
style="width: 150px"
size="small" size="small"
filterable class="num"
placeholder="最多"
:min="0"
:show-button="false"
@update:value="(v: number | null) => updateRule(lang, index, 'max', v)"
/> />
<n-input </template>
v-if="needsTargetInput(rule.engine)" <span>次</span>
:value="rule.target" </template>
@update:value="(v: string) => updateRule(lang, index, 'target', v)" <div class="grow"></div>
placeholder="函数/方法名" <n-button quaternary size="tiny" @click="removeRule(lang, index)">删掉</n-button>
style="width: 150px" </div>
size="small" <div class="ruleRow sub">
/> <n-input
<n-select :value="rule.message"
v-if="needsOperatorDropdown(rule.engine)" size="tiny"
:options="operatorTargetOptions(lang)" class="message"
:value="rule.target" :placeholder="
@update:value="(v: string) => updateRule(lang, index, 'target', v)" checkOf(lang, index)?.description
style="width: 150px" ? `学生看到「${checkOf(lang, index)!.description}」,想换个说法写这里`
size="small" : '学生看到的话(不填就按规则自动写)'
/> "
<template v-if="isCountEngine(rule.engine)"> @update:value="(v: string) => updateRule(lang, index, 'message', v)"
<n-select />
:options="COUNT_MODE_OPTIONS" <span v-if="checkOf(lang, index)?.passed === true" class="ok">✓ 标准答案过了</span>
:value="getCountMode(rule)" <span v-else-if="checkOf(lang, index)?.passed === false" class="bad">
@update:value="(v: 'exact' | 'range') => updateCountMode(lang, index, v)" ✗ 标准答案自己没过{{
style="width: 80px" checkOf(lang, index)!.actual !== undefined
size="small" ? `(数到 ${checkOf(lang, index)!.actual} 次)`
/> : ""
<n-input-number }}
v-if="getCountMode(rule) === 'exact'" </span>
:value="rule.exact ?? null" <n-text v-else-if="checkOf(lang, index)" depth="3">没有 {{ lang }} 的标准答案</n-text>
@update:value="(v: number | null) => updateExactCount(lang, index, v)" </div>
placeholder="次数" </div>
style="width: 100px" <div>
size="small" <n-button size="tiny" @click="addRule(lang)">+ 加一条</n-button>
:min="1" </div>
clearable </div>
/> </template>
<template v-else> </div>
<n-input-number
:value="rule.min ?? null"
@update:value="(v: number | null) => updateRule(lang, index, 'min', v)"
placeholder="最少"
style="width: 100px"
size="small"
:min="0"
clearable
/>
<n-input-number
:value="rule.max ?? null"
@update:value="(v: number | null) => updateRule(lang, index, 'max', v)"
placeholder="最多"
style="width: 100px"
size="small"
:min="0"
clearable
/>
</template>
</template>
<n-input
:value="rule.message"
@update:value="(v: string) => updateRule(lang, index, 'message', v)"
placeholder="错误提示(选填)"
style="flex: 1"
size="small"
/>
<n-button size="small" tertiary type="error" @click="removeRule(lang, index)">
删除
</n-button>
</n-flex>
</div>
<n-button size="small" tertiary type="primary" @click="addRule(lang)">
添加规则
</n-button>
</n-flex>
</n-tab-pane>
</n-tabs>
<n-empty
v-else
:description="languages.length ? '当前语言不支持代码规则检查' : '请先选择编程语言'"
/>
</n-collapse-item>
</n-collapse>
</template> </template>
<style scoped>
.astRules {
display: flex;
flex-direction: column;
gap: 8px;
}
.head,
.ruleRow {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.label {
font-size: 13px;
color: v-bind("theme.textColor2");
width: 72px;
flex: none;
}
.langs {
display: flex;
border: 1px solid v-bind("theme.borderColor");
border-radius: 4px;
overflow: hidden;
}
.lang {
height: 24px;
padding: 0 8px;
border: 0;
background: transparent;
font: inherit;
font-size: 12px;
color: v-bind("theme.textColor3");
cursor: pointer;
}
.lang + .lang {
border-left: 1px solid v-bind("theme.borderColor");
}
.lang.on {
background-color: rgba(24, 160, 88, 0.12);
color: v-bind("theme.primaryColorPressed");
font-weight: 600;
}
.grow {
flex: 1 1 auto;
}
.note {
font-size: 12px;
}
.rules {
display: flex;
flex-direction: column;
gap: 6px;
}
.rule {
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px 8px;
border: 1px solid v-bind("theme.dividerColor");
border-radius: 4px;
background-color: rgba(128, 128, 128, 0.04);
font-size: 13px;
}
.ruleRow.sub {
font-size: 12px;
flex-wrap: nowrap;
}
.engine {
width: 130px;
}
.target {
width: 140px;
}
.mode {
width: 72px;
}
.num {
width: 64px;
}
.message {
flex: 1 1 auto;
min-width: 0;
}
.ok {
color: v-bind("theme.successColor");
white-space: nowrap;
}
.bad {
color: v-bind("theme.errorColor");
white-space: nowrap;
}
.ruleError {
font-size: 12px;
color: v-bind("theme.errorColor");
}
</style>
@@ -0,0 +1,257 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { caseStatus, type AnswerSource, type CaseRow } from "./caseRows"
/**
* 测试数据一组一行:输入 | 输出 | 当例子(设计稿「编程题出题页」B)。
* 有标准答案时输出是跑出来的、只读;没有时(老题)输出手写。
*/
const rows = defineModel<CaseRow[]>("rows", { required: true })
const props = defineProps<{
source: AnswerSource | null
/** 只有例子这一列表的时候(测试数据太大、单独写例子)不显示「当例子」 */
examplesOnly?: boolean
}>()
const theme = useThemeVars()
/** 输出最多先露这么多行,长了点开看全 */
const CLIP_LINES = 6
const expanded = ref(new Set<number>())
function lines(text: string) {
return text.split("\n")
}
function clipped(row: CaseRow) {
const all = lines(row.output)
if (expanded.value.has(row.key) || all.length <= CLIP_LINES + 1)
return { text: row.output, rest: 0 }
return { text: all.slice(0, CLIP_LINES).join("\n"), rest: all.length - CLIP_LINES }
}
function toggle(key: number) {
const next = new Set(expanded.value)
if (next.has(key)) next.delete(key)
else next.add(key)
expanded.value = next
}
function remove(index: number) {
rows.value.splice(index, 1)
}
const status = (row: CaseRow) => caseStatus(row, props.source)
</script>
<template>
<div class="table" :class="{ examplesOnly }">
<div class="row head">
<span></span>
<span>输入</span>
<span>{{ source ? "输出(答案跑出来的)" : "输出(手写)" }}</span>
<span v-if="!examplesOnly">当例子</span>
<span></span>
<span></span>
</div>
<div
v-for="(row, index) in rows"
:key="row.key"
class="row"
:class="{ bad: status(row) === 'error', changed: status(row) === 'changed' }"
>
<b class="no">{{ index + 1 }}</b>
<n-input
v-model:value="row.input"
type="textarea"
class="mono"
size="small"
:autosize="{ minRows: 1, maxRows: 8 }"
placeholder="(没有输入)"
:aria-label="`第 ${index + 1} 组输入`"
/>
<n-input
v-if="!source"
v-model:value="row.output"
type="textarea"
class="mono"
size="small"
:autosize="{ minRows: 1, maxRows: 8 }"
:aria-label="`第 ${index + 1} 组输出`"
/>
<div v-else class="outCell">
<div v-if="status(row) === 'error'" class="cell errorCell">{{ row.error }}</div>
<template v-else>
<pre class="cell" :class="{ stale: status(row) === 'running' }">{{
clipped(row).text || (status(row) === "running" ? "在跑……" : "(没有输出)")
}}</pre>
<button v-if="clipped(row).rest" type="button" class="more" @click="toggle(row.key)">
⋯ 还有 {{ clipped(row).rest }} 行
</button>
<button
v-else-if="expanded.has(row.key)"
type="button"
class="more"
@click="toggle(row.key)"
>
收起
</button>
<div v-if="status(row) === 'changed'" class="was">
变了 · 原来存的是
<del>{{
lines(row.saved ?? "")
.slice(0, 3)
.join(" ⏎ ") || "(空)"
}}</del>
</div>
</template>
</div>
<n-checkbox
v-if="!examplesOnly"
v-model:checked="row.example"
class="example"
:aria-label="`第 ${index + 1} 组当例子`"
/>
<span class="mark" :class="status(row)">
<template v-if="status(row) === 'ok'">✓</template>
<template v-else-if="status(row) === 'error'">✗</template>
<template v-else-if="status(row) === 'changed'">●</template>
</span>
<n-button
quaternary
size="tiny"
class="remove"
:aria-label="`删掉第 ${index + 1} 组`"
@click="remove(index)"
>
删掉
</n-button>
</div>
</div>
</template>
<style scoped>
.table {
display: flex;
flex-direction: column;
}
.row {
display: grid;
grid-template-columns: 22px minmax(0, 1fr) minmax(0, 1fr) 44px 16px 40px;
gap: 8px;
align-items: start;
padding: 7px 4px;
border-top: 1px solid v-bind("theme.dividerColor");
font-size: 12px;
border-radius: 4px;
}
.examplesOnly .row {
grid-template-columns: 22px minmax(0, 1fr) minmax(0, 1fr) 16px 40px;
}
.row.head {
border-top: 0;
padding-top: 2px;
padding-bottom: 2px;
color: v-bind("theme.textColor3");
}
.row.changed {
background-color: rgba(240, 160, 32, 0.08);
}
.no {
line-height: 26px;
text-align: right;
}
.mono :deep(textarea),
.cell {
font-family: ui-monospace, "Cascadia Mono", Consolas, "Microsoft YaHei", monospace;
font-size: 12px;
line-height: 1.5;
}
.outCell {
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.cell {
margin: 0;
width: 100%;
box-sizing: border-box;
min-height: 26px;
padding: 3px 7px;
border-radius: 3px;
border: 1px solid v-bind("theme.dividerColor");
background-color: rgba(128, 128, 128, 0.05);
white-space: pre;
overflow-x: auto;
}
.cell.stale {
opacity: 0.5;
}
.errorCell {
white-space: pre-wrap;
word-break: break-all;
color: v-bind("theme.errorColor");
border-color: v-bind("theme.errorColorSuppl");
background-color: rgba(208, 48, 80, 0.06);
}
.more {
border: 0;
padding: 0;
background: none;
font: inherit;
color: v-bind("theme.textColor3");
cursor: pointer;
}
.was {
color: v-bind("theme.warningColorPressed");
}
.was del {
color: v-bind("theme.textColor3");
}
.example {
margin-top: 5px;
}
.mark {
line-height: 26px;
font-weight: 700;
}
.mark.ok {
color: v-bind("theme.successColor");
}
.mark.error {
color: v-bind("theme.errorColor");
}
.mark.changed {
color: v-bind("theme.warningColor");
}
.remove {
margin-top: 1px;
opacity: 0;
}
.row:hover .remove,
.remove:focus-visible {
opacity: 1;
}
</style>
@@ -0,0 +1,456 @@
<script setup lang="ts">
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { useThemeVars } from "naive-ui"
import { useMermaid } from "shared/composables/useMermaid"
import { DIFFICULTY, SOURCES } from "utils/constants"
import { getTagColor } from "utils/functions"
import type { AstRules, BlankProblem, LANGUAGE } from "utils/types"
import type { AstCheckState, FlowchartMode, PreviewTab } from "./editorTypes"
import type { TemplateParts } from "./codeTemplate"
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
/**
* 编程题出题页的右栏:学生打开这道题看到的样子(设计稿「编程题出题页」B)。
* 题面的排法照 oj/problem/components/ProblemContent.vue,但不复用它 —— 那个组件读的是
* 题目页的 store 和入口上下文,出题页这里只有一份还没存的表单。
*/
const props = defineProps<{
displayId: string
title: string
difficulty: BlankProblem["difficulty"]
description: string
inputDescription: string
outputDescription: string
hint: string
examples: { input: string; output: string }[]
/** 例子来自哪几组,比如「测试数据第 1–4 组」 */
examplesNote: string
languages: LANGUAGE[]
templates: Partial<Record<LANGUAGE, TemplateParts>>
astRules: AstRules | null
astChecks: AstCheckState
flowchartMode: FlowchartMode
mermaidCode: string
flowchartHint: string
}>()
const tab = defineModel<PreviewTab>("tab", { required: true })
const emit = defineEmits<{ renderState: [ok: boolean] }>()
const theme = useThemeVars()
const isDark = useDark()
const isBlankHtml = (html: string) => !html.trim() || html === "<p><br></p>"
// ---------------------------------------------------------------- 语法要求
const KIND_TAG_TYPE = { require: "success", forbid: "error", count: "info" } as const
function requirementsOf(lang: string) {
const rules = props.astRules?.[lang] ?? []
const state = props.astChecks[lang]
return rules.map((rule, i) => ({
text:
rule.message ||
(state && !("error" in state) ? state.rules[i]?.description : "") ||
"(规则还没写完)",
kind: rule.engine.startsWith("must_not")
? ("forbid" as const)
: rule.engine.startsWith("count")
? ("count" as const)
: ("require" as const),
}))
}
const requirementLanguages = computed(() =>
props.languages.filter((lang) => (props.astRules?.[lang]?.length ?? 0) > 0),
)
// ---------------------------------------------------------------- 编辑器
const editorLanguage = ref<LANGUAGE>(props.languages[0] ?? "Python")
watch(
() => props.languages,
(langs) => {
if (langs.length && !langs.includes(editorLanguage.value)) editorLanguage.value = langs[0]!
},
)
const editorParts = computed(() => props.templates[editorLanguage.value])
const editorCode = computed(() => {
const own = editorParts.value?.template
return own?.trim() ? own : SOURCES[editorLanguage.value]
})
// ---------------------------------------------------------------- 流程图
const container = useTemplateRef<HTMLElement>("chart")
const { renderFlowchart, renderError, renderSuccess } = useMermaid()
async function render() {
await renderFlowchart(container.value, props.mermaidCode)
emit("renderState", renderSuccess.value)
}
// 改了先报「没验证」,防抖之后的渲染跑完再报结果(和 MermaidEditor 同一个理由)
watch(
() => props.mermaidCode,
() => emit("renderState", false),
)
watchDebounced(() => props.mermaidCode, render, { debounce: 300, maxWait: 1000 })
onMounted(() => nextTick(render))
</script>
<template>
<aside class="preview" aria-label="学生看到的样子">
<div class="previewHead">
<b>学生看到的样子</b>
<div class="grow"></div>
<n-radio-group v-model:value="tab" size="small">
<n-radio-button value="statement">题面</n-radio-button>
<n-radio-button value="editor">编辑器</n-radio-button>
<n-radio-button value="flowchart" :class="{ dim: flowchartMode === 'none' }">
流程图
</n-radio-button>
</n-radio-group>
</div>
<!-- 题面 -->
<div v-show="tab === 'statement'" class="page">
<div class="titleRow">
<span v-if="displayId" class="pid">{{ displayId }}</span>
<h2>{{ title || "(题目还没起名)" }}</h2>
</div>
<div class="meta">
<n-tag size="small" :bordered="false" :type="getTagColor(difficulty)">
{{ DIFFICULTY[difficulty] }}
</n-tag>
</div>
<div v-if="requirementLanguages.length" class="requirements">
<span class="requirementsHead">语法要求</span>
<template v-for="lang in requirementLanguages" :key="lang">
<span v-if="requirementLanguages.length > 1 || languages.length > 1" class="langLabel">
{{ lang }}
</span>
<n-tag
v-for="(rule, i) in requirementsOf(lang)"
:key="`${lang}-${i}`"
:type="KIND_TAG_TYPE[rule.kind]"
size="small"
:bordered="false"
>
{{ rule.text }}
</n-tag>
</template>
<span class="muted">提交时会逐条检查</span>
</div>
<h3 class="title">描述</h3>
<MdPreview
v-if="!isBlankHtml(description)"
preview-theme="vuepress"
:model-value="description"
:theme="isDark ? 'dark' : 'light'"
/>
<p v-else class="placeholder">(还没写描述)</p>
<div class="io">
<section>
<h3 class="title">输入</h3>
<MdPreview
preview-theme="vuepress"
:model-value="inputDescription"
:theme="isDark ? 'dark' : 'light'"
/>
</section>
<section>
<h3 class="title">输出</h3>
<MdPreview
preview-theme="vuepress"
:model-value="outputDescription"
:theme="isDark ? 'dark' : 'light'"
/>
</section>
</div>
<button v-if="flowchartMode === 'show'" type="button" class="fold" @click="tab = 'flowchart'">
<span>›</span><b>老师给的参考流程图</b>
<span class="muted">学生点开才看到 · 在「流程图」里看</span>
</button>
<div class="exampleHead">
<h3 class="title">例子</h3>
<div class="grow"></div>
<span class="muted">{{ examplesNote }}</span>
</div>
<div v-if="examples.length" class="sampleTable" role="table" aria-label="例子">
<span role="columnheader"></span>
<span role="columnheader" class="sampleLabel">输入</span>
<span role="columnheader" class="sampleLabel">输出</span>
<template v-for="(sample, index) of examples" :key="index">
<span class="sampleNo" role="rowheader">例子 {{ index + 1 }}</span>
<pre class="sampleCell" role="cell">{{ sample.input }}</pre>
<pre class="sampleCell" role="cell">{{ sample.output }}</pre>
</template>
</div>
<p v-else class="placeholder">在左边的测试数据里勾几组「当例子」</p>
<template v-if="!isBlankHtml(hint)">
<h3 class="title">提示</h3>
<MdPreview
preview-theme="vuepress"
:model-value="hint"
:theme="isDark ? 'dark' : 'light'"
/>
</template>
</div>
<!-- 编辑器 -->
<div v-show="tab === 'editor'" class="page">
<div class="editorHead">
<n-radio-group v-model:value="editorLanguage" size="small">
<n-radio-button v-for="lang in languages" :key="lang" :value="lang">
{{ lang }}
</n-radio-button>
</n-radio-group>
</div>
<div v-if="(astRules?.[editorLanguage]?.length ?? 0) > 0" class="requirements">
<span class="requirementsHead">语法要求</span>
<n-tag
v-for="(rule, i) in requirementsOf(editorLanguage)"
:key="i"
:type="KIND_TAG_TYPE[rule.kind]"
size="small"
:bordered="false"
>
{{ rule.text }}
</n-tag>
</div>
<p class="muted small">
{{
editorParts?.template.trim()
? "学生打开编辑器时里面已经有这些:"
: SOURCES[editorLanguage]
? `没写 ${editorLanguage} 的预制代码,学生打开是全站默认的:`
: `没写 ${editorLanguage} 的预制代码,学生打开是空白的`
}}
</p>
<CodeEditor
v-if="editorCode"
:value="editorCode"
:language="editorLanguage"
:font-size="14"
height="320px"
readonly
/>
<p v-if="editorParts?.prepend.trim() || editorParts?.append.trim()" class="muted small">
提交时,学生的代码{{
editorParts?.prepend.trim() && editorParts?.append.trim()
? "前后"
: editorParts?.prepend.trim()
? "前面"
: "后面"
}}还会拼上你藏起来的代码。
</p>
</div>
<!-- 流程图:一直挂着,改了图的代码随时知道画不画得出来 -->
<div v-show="tab === 'flowchart'" class="page">
<p v-if="flowchartMode === 'none'" class="placeholder">这道题不用流程图</p>
<p v-else-if="flowchartMode === 'draw'" class="muted small">
学生自己画,这张图<b>学生看不到</b>,只拿来给学生画的图打分。
<template v-if="flowchartHint.trim()">学生看到的提示:{{ flowchartHint }}</template>
</p>
<p v-else class="muted small">学生在题面里点开「老师给的参考流程图」看到的是:</p>
<p v-if="flowchartMode !== 'none' && !mermaidCode.trim()" class="placeholder">
还没写图的代码
</p>
<div v-if="renderError && flowchartMode !== 'none'" class="chartError">
画不出来:{{ renderError }}
</div>
<div v-show="flowchartMode !== 'none'" ref="chart" class="chart"></div>
</div>
</aside>
</template>
<style scoped>
.preview {
position: sticky;
top: 12px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.previewHead {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
font-size: 14px;
}
.grow {
flex: 1 1 auto;
}
.dim {
opacity: 0.6;
}
.page {
max-height: calc(100vh - 150px);
overflow: auto;
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
padding: 18px 22px;
background: v-bind("theme.cardColor");
display: flex;
flex-direction: column;
gap: 10px;
}
.titleRow {
display: flex;
align-items: baseline;
gap: 8px;
}
.titleRow h2 {
margin: 0;
font-size: 20px;
line-height: 1.3;
}
.pid {
color: v-bind("theme.textColor3");
}
/* 和题目页的小节标题一个样子(oj/problem/components/ProblemContent.vue) */
.title {
font-size: 16px;
font-weight: 700;
line-height: 1.4;
margin: 6px 0 0;
padding-left: 9px;
border-left: 3px solid v-bind("theme.primaryColor");
}
.page :deep(.md-editor) {
background: transparent;
}
.page :deep(.md-editor-preview-wrapper) {
padding: 0;
}
.io {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.requirements {
padding: 8px 12px;
border-radius: 6px;
border: 1px solid rgba(24, 160, 88, 0.3);
background-color: rgba(24, 160, 88, 0.06);
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 8px;
font-size: 13px;
}
.requirementsHead,
.langLabel {
font-weight: 600;
}
.muted {
color: v-bind("theme.textColor3");
font-size: 12px;
}
.small {
margin: 0;
font-size: 13px;
}
.placeholder {
margin: 0;
color: v-bind("theme.textColor3");
}
.fold {
display: flex;
align-items: center;
gap: 8px;
height: 38px;
padding: 0 12px;
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
background: transparent;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
}
.exampleHead {
display: flex;
align-items: center;
}
.sampleTable {
display: grid;
grid-template-columns: 56px minmax(0, 1fr) minmax(0, 1fr);
gap: 6px 8px;
}
.sampleLabel,
.sampleNo {
font-size: 12px;
color: v-bind("theme.textColor3");
}
.sampleNo {
display: flex;
align-items: center;
}
.sampleCell {
margin: 0;
min-width: 0;
padding: 4px 10px;
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.08);
font-family: ui-monospace, "Cascadia Mono", Consolas, "Microsoft YaHei", monospace;
font-size: 13px;
line-height: 1.55;
white-space: pre;
overflow-x: auto;
}
.editorHead {
display: flex;
}
.chart {
display: flex;
justify-content: center;
}
.chartError {
padding: 8px 10px;
border-radius: 4px;
font-size: 13px;
color: v-bind("theme.errorColor");
background-color: rgba(208, 48, 80, 0.08);
word-break: break-all;
}
</style>
@@ -1,277 +0,0 @@
<script setup lang="ts">
import { TRIAL_ADMIN_MAX_CASES } from "@oj2/contract"
import type { LANGUAGE, Testcase } from "utils/types"
import { errorMessage } from "utils/api"
import { SubmissionStatus } from "utils/constants"
import { createZipBlob } from "utils/functions"
import { isRunnableLanguage, trialRun } 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>("Python")
// 始终显示所有语言,不管有没有答案代码
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)
/**
* 一个测试点必须有输出,但不一定有输入 —— 打印类题目(输出星号矩形、只靠
* print 的格式化输出题…)的 `N.in` 本来就该是空文件。
*
* 原来「这行算不算数」判的是 `f.in.trim()`,把「这题没有输入」和「这行我还没填」
* 撞成了同一种状态:输入留空 → 点「先运行」整行被删 → 一行不剩 → 「上传」
* 永远是灰的。老师只能往输入框里塞个「无」才存得下去,那两个字节就真的进了 stdin。
*/
function hasOutput(file: FileEntry) {
return !!file.out.trim() && !file.error
}
/** 老师碰过这一行没有。面板一开就铺 5 个空行,`+1` / `+5` 还能再加,空行是占位不是测试点 */
function isFilled(file: FileEntry) {
return !!(file.in.trim() || file.out.trim())
}
/** 真正会被打进 zip 的行:有输出就算数,输入空不空无所谓 */
const uploadable = computed(() => files.value.filter(hasOutput))
const canUpload = computed(
() =>
!isRunning.value &&
uploadable.value.length > 0 &&
// 填了输入却没跑出输出:要么还没运行,要么运行炸了,这种状态不许传
files.value.every((f) => !f.in.trim() || hasOutput(f)),
)
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>()
const kept = files.value.filter((f) => {
if (!isFilled(f)) return false
if (seen.has(f.in)) return false
seen.add(f.in)
return true
})
files.value = kept.length > 0 ? kept : files.value.slice(0, 1)
// 清空旧输出
files.value = files.value.map((f) => ({ ...f, out: "", error: false }))
// 走本站判题机(和判题同一套编译命令),不带题号:题目可能还没存,按后端给管理员的默认限制跑。
// 一次最多 TRIAL_ADMIN_MAX_CASES 组,多了分几批
const language = selectedLanguage.value
if (!isRunnableLanguage(language)) {
message.error(`${language} 不能用来生成测试点`)
return
}
isRunning.value = true
try {
for (let start = 0; start < files.value.length; start += TRIAL_ADMIN_MAX_CASES) {
const batch = files.value.slice(start, start + TRIAL_ADMIN_MAX_CASES)
const response = await trialRun({
language,
code: answer.code,
cases: batch.map((f) => ({ input: f.in })),
})
batch.forEach((_, offset) => {
const i = start + offset
if (response.status === "compile-error") {
files.value[i] = { ...files.value[i], out: response.message, error: true }
} else if (response.status === "too-much-output") {
files.value[i] = { ...files.value[i], out: "输出太多,判题机回包超过上限", error: true }
} else {
const run = response.cases[offset]!
const ok = run.result === SubmissionStatus.accepted
files.value[i] = {
...files.value[i],
out: ok ? run.output.trimEnd() : run.output,
error: !ok,
}
}
})
}
} catch (err) {
message.error(errorMessage(err, "运行失败"))
files.value = files.value.map((f) => (f.out ? f : { ...f, error: true }))
} finally {
isRunning.value = false
}
}
async function upload() {
isUploading.value = true
try {
const data = uploadable.value.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">
<template #trigger>
<span>
<n-button type="success" :loading="isRunning" :disabled="!hasAnswerCode" @click="run">
先运行
</n-button>
</span>
</template>
请先在题目中填写答案代码
</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>
@@ -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) },
]
})
}
@@ -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` : ""
}
@@ -0,0 +1,13 @@
import type { AstCheckResponse } from "@oj2/contract"
/**
* 出题页里每种语言的语法要求自测结果:每条规则学生看到的那句话 + 标准答案过不过。
* 规则本身配得不对(后端 astRulesError)时是一句错误。右边预览和保存前的检查都看它
*/
export type AstCheckState = Record<string, { rules: AstCheckResponse["rules"] } | { error: string }>
/** 右栏「学生看到的样子」开着哪一页 */
export type PreviewTab = "statement" | "editor" | "flowchart"
/** 流程图三选一:不用 / 给学生看参考图(showFlowchart)/ 让学生自己画、按这张图打分(allowFlowchart) */
export type FlowchartMode = "none" | "show" | "draw"
File diff suppressed because it is too large. Load diff
+38 -2
View File
@@ -7,13 +7,17 @@ import { uploadImage } from "../../admin/api"
interface Props { interface Props {
title: string title: string
simple?: boolean simple?: boolean
/** 一行放得下的那套:出题页左栏只有 600 宽,完整工具栏会折成三行 */
compact?: boolean
/** 最少的一套:出题页的输入 / 输出说明并排放,每个才 280 宽 */
mini?: boolean
minHeight?: number minHeight?: number
} }
const rawHtml = defineModel<string>("value") const rawHtml = defineModel<string>("value")
type InsertFnType = (url: string, alt: string, href: string) => void type InsertFnType = (url: string, alt: string, href: string) => void
const { title, minHeight = 0, simple = false } = defineProps<Props>() const { title, minHeight = 0, simple = false, compact = false, mini = false } = defineProps<Props>()
const message = useMessage() const message = useMessage()
@@ -66,6 +70,30 @@ const toolbarConfigSimple: Partial<IToolbarConfig> = {
], ],
} }
const toolbarConfigCompact: Partial<IToolbarConfig> = {
toolbarKeys: [
"bold",
"color",
"bgColor",
"|",
"bulletedList",
"numberedList",
"|",
"uploadImage",
"insertTable",
"insertLink",
"emotion",
"|",
"clearStyle",
"undo",
"redo",
],
}
const toolbarConfigMini: Partial<IToolbarConfig> = {
toolbarKeys: ["bold", "color", "uploadImage", "insertLink", "undo", "redo"],
}
const editorConfig: Partial<IEditorConfig> = { const editorConfig: Partial<IEditorConfig> = {
scroll: false, scroll: false,
MENU_CONF: { MENU_CONF: {
@@ -110,7 +138,15 @@ async function customUpload(file: File, insertFn: InsertFnType) {
<Toolbar <Toolbar
class="toolbar" class="toolbar"
:editor="toolbarEditorRef" :editor="toolbarEditorRef"
:defaultConfig="simple ? toolbarConfigSimple : toolbarConfig" :defaultConfig="
mini
? toolbarConfigMini
: simple
? toolbarConfigSimple
: compact
? toolbarConfigCompact
: toolbarConfig
"
mode="simple" mode="simple"
/> />
<Editor <Editor
+1
View File
@@ -153,6 +153,7 @@ export const STORAGE_KEY = {
LEARN_CURRENT_STEP: "learnStep", LEARN_CURRENT_STEP: "learnStep",
ADMIN_PROBLEM: "adminProblem", ADMIN_PROBLEM: "adminProblem",
ADMIN_SQL_PROBLEM: "adminSqlProblem", ADMIN_SQL_PROBLEM: "adminSqlProblem",
ADMIN_CODE_PROBLEM: "adminCodeProblem",
ADMIN_PROBLEM_TAGS: "adminProblemTags", ADMIN_PROBLEM_TAGS: "adminProblemTags",
ADMIN_SIDER_COLLAPSED: "adminSiderCollapsed", ADMIN_SIDER_COLLAPSED: "adminSiderCollapsed",
DEMO_MODE: "demoMode", DEMO_MODE: "demoMode",
+61 -1
View File
@@ -6,8 +6,9 @@ import { achievementRaritySchema } from "./achievement"
import { rankProfileSchema } from "./account" import { rankProfileSchema } from "./account"
import { paginatedSchema, sampleUserSchema } from "./common" import { paginatedSchema, sampleUserSchema } from "./common"
import { reactionKeySchema } from "./content" import { reactionKeySchema } from "./content"
import { problemLanguageSchema } from "./language" import { problemLanguageSchema, runnableLanguageSchema } from "./language"
import { import {
astRuleSchema,
astRulesSchema, astRulesSchema,
problemDifficultySchema, problemDifficultySchema,
sqlConfigSchema, sqlConfigSchema,
@@ -836,6 +837,60 @@ export const generateSqlTestCaseRequestSchema = z.object({
export const generateSqlTestCaseResponseSchema = z.object({ sql: z.string() }) export const generateSqlTestCaseResponseSchema = z.object({ sql: z.string() })
/**
* 编程题出题页回显已有的测试点(N.in / N.out 原文)。太大的不展开 —— 页面上要一组一组
* 摆出来、改了还要整包重跑一遍标准答案,几百组或者几 MB 的数据摆不下也跑不动,
* 这种只给数量和大小,老师走下载 / 导入 zip。
*/
export const TEST_CASE_EDIT_MAX_CASES = 100
export const TEST_CASE_EDIT_MAX_FILE_BYTES = 32 * 1024
export const testCaseFilesSchema = z.discriminatedUnion("editable", [
z.object({
editable: z.literal(true),
cases: z.array(z.object({ input: z.string(), output: z.string() })),
}),
z.object({
editable: z.literal(false),
count: z.number().int(),
totalBytes: z.number().int(),
}),
])
/**
* 出题页的语法要求:每条规则学生看到的那句话,以及标准答案自己过不过(规则配错时
* 标准答案也过不了)。`code` 给空串就只要那句话,`passed` 为 null
*/
export const astCheckRequestSchema = z.object({
language: runnableLanguageSchema,
code: z.string().max(1024 * 1024),
rules: z.array(astRuleSchema).min(1).max(50),
})
export const astCheckResponseSchema = z.object({
rules: z.array(
z.object({
description: z.string(),
passed: z.boolean().nullable(),
actual: z.number().int().optional(),
}),
),
})
/** AI 按题面和标准答案想几组测试输入(输出由标准答案在判题机上跑,不让 AI 编) */
export const generateTestInputsRequestSchema = z.object({
description: z.string().max(64 * 1024),
inputDescription: z.string().max(16 * 1024),
language: runnableLanguageSchema,
answer: z
.string()
.min(1)
.max(64 * 1024),
existing: z.array(z.string().max(4096)).max(100),
})
export const generateTestInputsResponseSchema = z.object({ inputs: z.array(z.string()) })
export type AdminAchievement = z.infer<typeof adminAchievementSchema> export type AdminAchievement = z.infer<typeof adminAchievementSchema>
export type AchievementMetric = z.infer<typeof achievementMetricSchema> export type AchievementMetric = z.infer<typeof achievementMetricSchema>
@@ -873,6 +928,11 @@ export type RenameTagResponse = z.infer<typeof renameTagResponseSchema>
export type BatchProblemTagResponse = z.infer<typeof batchProblemTagResponseSchema> export type BatchProblemTagResponse = z.infer<typeof batchProblemTagResponseSchema>
export type SqlTestCaseScript = z.infer<typeof sqlTestCaseScriptSchema> export type SqlTestCaseScript = z.infer<typeof sqlTestCaseScriptSchema>
export type GenerateSqlTestCaseResponse = z.infer<typeof generateSqlTestCaseResponseSchema> export type GenerateSqlTestCaseResponse = z.infer<typeof generateSqlTestCaseResponseSchema>
export type TestCaseFiles = z.infer<typeof testCaseFilesSchema>
export type AstCheckRequest = z.infer<typeof astCheckRequestSchema>
export type AstCheckResponse = z.infer<typeof astCheckResponseSchema>
export type GenerateTestInputsRequest = z.infer<typeof generateTestInputsRequestSchema>
export type GenerateTestInputsResponse = z.infer<typeof generateTestInputsResponseSchema>
export type AdminProblemSetProgress = z.infer<typeof adminProblemSetProgressSchema> export type AdminProblemSetProgress = z.infer<typeof adminProblemSetProgressSchema>
export type AdminAnnouncementList = z.infer<typeof adminAnnouncementListSchema> export type AdminAnnouncementList = z.infer<typeof adminAnnouncementListSchema>