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 {
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)
+1 -1
View File
@@ -88,7 +88,7 @@ function errInfo(row: HintRow) {
* 输出说明里的 `&lt;`、`&nbsp;` 恰恰可能就是格式要求本身,不解码模型会读歪。
* 图片直接丢掉(模型看不到图),只留一个占位,让它知道那里原本有东西。
*/
function plainText(html: string) {
export function plainText(html: string) {
return (
html
.replace(/<img\b[^>]*>/gi, "[图片]")
+38 -1
View File
@@ -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)
+22
View File
@@ -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<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)
}
// 编程题出题页:读回已有测试点的原文(太大的只给数量和大小)
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) {
const p = problem as Partial<AdminProblem>
@@ -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<Record<LANGUAGE, string>>
}
const props = defineProps<Props>()
@@ -28,6 +34,54 @@ const unsupportedLanguages = computed(() =>
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[] = [
{
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) => {
</script>
<template>
<n-collapse>
<n-collapse-item title="代码规则检查(选填)" name="ast-rules">
<n-alert
v-if="unsupportedLanguages.length"
type="info"
:bordered="false"
style="margin-bottom: 8px"
>
{{ unsupportedLanguages.join("、") }}
暂不支持代码规则检查,判题机只能检查
{{ AST_SUPPORTED_LANGUAGES.join(" / ") }}
</n-alert>
<n-tabs v-if="supportedLanguages.length" type="segment" v-model:value="activeTab">
<n-tab-pane v-for="lang in supportedLanguages" :key="lang" :name="lang" :tab="lang">
<n-flex vertical>
<div
v-for="(rule, index) in getRulesForLang(lang)"
:key="index"
style="margin-bottom: 8px"
>
<n-flex align="center" :wrap="false">
<n-select
:options="ENGINE_OPTIONS"
:value="rule.engine"
@update:value="(v: string) => updateRule(lang, index, 'engine', v)"
style="width: 150px"
<div class="astRules">
<div class="head">
<span class="label">语法要求</span>
<div v-if="supportedLanguages.length" class="langs" role="tablist" aria-label="语言">
<button
v-for="lang in supportedLanguages"
:key="lang"
type="button"
role="tab"
:aria-selected="activeTab === lang"
class="lang"
:class="{ on: activeTab === lang }"
@click="activeTab = lang"
>
{{ lang }} · {{ countOf(lang) ? `${countOf(lang)} 条` : "没有" }}
</button>
</div>
<div class="grow"></div>
<n-text depth="3" class="note">提交时逐条检查,不满足算错</n-text>
</div>
<n-text v-if="unsupportedLanguages.length" depth="3" class="note">
{{ unsupportedLanguages.join("、") }} 检查不了语法要求(判题机只认
{{ AST_SUPPORTED_LANGUAGES.join(" / ") }})
</n-text>
<n-text v-if="!supportedLanguages.length" depth="3" class="note">先选能交的语言</n-text>
<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"
class="num"
placeholder="最少"
:min="0"
:show-button="false"
@update:value="(v: number | null) => updateRule(lang, index, 'min', v)"
/>
<n-select
v-if="needsTargetDropdown(rule.engine)"
:options="nodeTargetOptions(lang)"
:value="rule.target"
@update:value="(v: string) => updateRule(lang, index, 'target', v)"
style="width: 150px"
<span>~</span>
<n-input-number
:value="rule.max ?? null"
size="small"
filterable
class="num"
placeholder="最多"
:min="0"
:show-button="false"
@update:value="(v: number | null) => updateRule(lang, index, 'max', v)"
/>
<n-input
v-if="needsTargetInput(rule.engine)"
:value="rule.target"
@update:value="(v: string) => updateRule(lang, index, 'target', v)"
placeholder="函数/方法名"
style="width: 150px"
size="small"
/>
<n-select
v-if="needsOperatorDropdown(rule.engine)"
:options="operatorTargetOptions(lang)"
:value="rule.target"
@update:value="(v: string) => updateRule(lang, index, 'target', v)"
style="width: 150px"
size="small"
/>
<template v-if="isCountEngine(rule.engine)">
<n-select
:options="COUNT_MODE_OPTIONS"
:value="getCountMode(rule)"
@update:value="(v: 'exact' | 'range') => updateCountMode(lang, index, v)"
style="width: 80px"
size="small"
/>
<n-input-number
v-if="getCountMode(rule) === 'exact'"
:value="rule.exact ?? null"
@update:value="(v: number | null) => updateExactCount(lang, index, v)"
placeholder="次数"
style="width: 100px"
size="small"
:min="1"
clearable
/>
<template v-else>
<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>
<span>次</span>
</template>
<div class="grow"></div>
<n-button quaternary size="tiny" @click="removeRule(lang, index)">删掉</n-button>
</div>
<div class="ruleRow sub">
<n-input
:value="rule.message"
size="tiny"
class="message"
:placeholder="
checkOf(lang, index)?.description
? `学生看到「${checkOf(lang, index)!.description}」,想换个说法写这里`
: '学生看到的话(不填就按规则自动写)'
"
@update:value="(v: string) => updateRule(lang, index, 'message', v)"
/>
<span v-if="checkOf(lang, index)?.passed === true" class="ok">✓ 标准答案过了</span>
<span v-else-if="checkOf(lang, index)?.passed === false" class="bad">
✗ 标准答案自己没过{{
checkOf(lang, index)!.actual !== undefined
? `(数到 ${checkOf(lang, index)!.actual} 次)`
: ""
}}
</span>
<n-text v-else-if="checkOf(lang, index)" depth="3">没有 {{ lang }} 的标准答案</n-text>
</div>
</div>
<div>
<n-button size="tiny" @click="addRule(lang)">+ 加一条</n-button>
</div>
</div>
</template>
</div>
</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 {
title: string
simple?: boolean
/** 一行放得下的那套:出题页左栏只有 600 宽,完整工具栏会折成三行 */
compact?: boolean
/** 最少的一套:出题页的输入 / 输出说明并排放,每个才 280 宽 */
mini?: boolean
minHeight?: number
}
const rawHtml = defineModel<string>("value")
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()
@@ -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> = {
scroll: false,
MENU_CONF: {
@@ -110,7 +138,15 @@ async function customUpload(file: File, insertFn: InsertFnType) {
<Toolbar
class="toolbar"
:editor="toolbarEditorRef"
:defaultConfig="simple ? toolbarConfigSimple : toolbarConfig"
:defaultConfig="
mini
? toolbarConfigMini
: simple
? toolbarConfigSimple
: compact
? toolbarConfigCompact
: toolbarConfig
"
mode="simple"
/>
<Editor
+1
View File
@@ -153,6 +153,7 @@ export const STORAGE_KEY = {
LEARN_CURRENT_STEP: "learnStep",
ADMIN_PROBLEM: "adminProblem",
ADMIN_SQL_PROBLEM: "adminSqlProblem",
ADMIN_CODE_PROBLEM: "adminCodeProblem",
ADMIN_PROBLEM_TAGS: "adminProblemTags",
ADMIN_SIDER_COLLAPSED: "adminSiderCollapsed",
DEMO_MODE: "demoMode",
+61 -1
View File
@@ -6,8 +6,9 @@ import { achievementRaritySchema } from "./achievement"
import { rankProfileSchema } from "./account"
import { paginatedSchema, sampleUserSchema } from "./common"
import { reactionKeySchema } from "./content"
import { problemLanguageSchema } from "./language"
import { problemLanguageSchema, runnableLanguageSchema } from "./language"
import {
astRuleSchema,
astRulesSchema,
problemDifficultySchema,
sqlConfigSchema,
@@ -836,6 +837,60 @@ export const generateSqlTestCaseRequestSchema = z.object({
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 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 SqlTestCaseScript = z.infer<typeof sqlTestCaseScriptSchema>
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 AdminAnnouncementList = z.infer<typeof adminAnnouncementListSchema>