feat(后台): 编程题出题页重做——左边写,右边是学生看到的样子;例子和测试数据的输出都由标准答案跑出来,改老题能直接看、改测试数据
- 例子就是测试数据里勾了「当例子」的几组,不再手抄输出;老题的例子不在测试数据里的补到最后 - 改了答案,输出变了的那组标黄、写清原来存的是什么;答案在某组上报错就挡住保存 - 没存标准答案的老题输出手写;太大的测试数据(>100 组或单文件 >32KB)只能导入 / 下载 zip - 预制代码只写学生看得到的那段,前后藏起来的代码收进折叠;语法要求先拿标准答案自测 - 流程图两个互斥开关改成三选一;时间 / 内存限制能改了;草稿只给新建页 - 后端:读回测试点原文、语法要求自测、AI 想测试输入三个接口 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
033a245abb
commit
ba5b9ada0e
15 files changed
+3043
-1068
No files matched your search
@@ -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)
|
||||
|
||||
@@ -88,7 +88,7 @@ function errInfo(row: HintRow) {
|
||||
* 输出说明里的 `<`、` ` 恰恰可能就是格式要求本身,不解码模型会读歪。
|
||||
* 图片直接丢掉(模型看不到图),只留一个占位,让它知道那里原本有东西。
|
||||
*/
|
||||
function plainText(html: string) {
|
||||
export function plainText(html: string) {
|
||||
return (
|
||||
html
|
||||
.replace(/<img\b[^>]*>/gi, "[图片]")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
+1505
-672
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user