feat(出题): 测试数据最多 20 组,几百组的题提交详情截图截不下

上传 zip 超过 20 组直接报错(编程题、SQL 题都算);出题页加满 20 组后「加一组」
「AI 想几组输入」变灰,导入超过 20 组的 zip 在浏览器里就拦下,改老题超了不让保存。
上限在契约 TEST_CASE_MAX_CASES。线上超过 20 组的题已经手工缩到 20 组。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-10 14:07:30 +08:00
1 parent ab757553d4
commit eaab224ba8
5 files changed
+88 -14

No files matched your search

+9 -1
View File
@@ -4,6 +4,8 @@ import { resolve } from "node:path"
import { unzipSync, zipSync } from "fflate"
import { TEST_CASE_MAX_CASES } from "@oj2/contract"
import { config } from "../config"
/**
@@ -18,7 +20,7 @@ import { config } from "../config"
const MAX_ENTRY_BYTES = 32 * 1024 * 1024
/** 解压后总大小上限,防 zip bomb */
const MAX_TOTAL_BYTES = 128 * 1024 * 1024
/** 测试点数量上限 */
/** 往后找连续编号最多找到几号。真正的上限是契约的 TEST_CASE_MAX_CASES,多出来的要报错而不是静默截掉 */
const MAX_CASES = 500
export class TestCaseError extends Error {}
@@ -132,6 +134,12 @@ export async function processTestCaseZip(
const selected = options.sql ? collectSqlScripts(names) : collectPairs(names).flat()
if (selected.length === 0) throw new TestCaseError("压缩包里没有找到从 1 开始连续编号的测试点")
const count = options.sql ? selected.length : selected.length / 2
if (count > TEST_CASE_MAX_CASES) {
throw new TestCaseError(
`测试数据最多 ${TEST_CASE_MAX_CASES} 组,这个压缩包里有 ${count} 组,挑有代表性的留下`,
)
}
if (options.sql && selected.length < 2) {
// 题目页会展示测试点 1 的期望结果,只有一个测试点时学生可以对照着硬编码 AC
throw new TestCaseError("SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果")
+24 -5
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { EditorView } from "@codemirror/view"
import { PROBLEM_TAG_MAX_LENGTH } from "@oj2/contract"
import { PROBLEM_TAG_MAX_LENGTH, TEST_CASE_MAX_CASES } from "@oj2/contract"
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { StorageSerializers } from "@vueuse/core"
@@ -275,7 +275,11 @@ watchDebounced(
{ debounce: 600, deep: true },
)
/** 测试数据加满了(见契约 TEST_CASE_MAX_CASES) */
const groupsFull = computed(() => groups.value.length >= TEST_CASE_MAX_CASES)
function addGroup() {
if (groupsFull.value) return
groups.value.push(newGroup())
selected.value = groups.value.length - 1
}
@@ -288,6 +292,7 @@ function removeGroup(index: number) {
const generating = ref(false)
async function generateGroup() {
if (groupsFull.value && groups.value.every((g) => g.sql.trim())) return
generating.value = true
try {
const res = await generateSQLTestcase({ refSql: form.answer, mode: form.sqlConfig.mode })
@@ -318,6 +323,8 @@ const blocker = computed(() => {
if (!form.tags.length) return "至少要有一个标签"
if (!form.answer.trim()) return "标准答案还没写"
if (groups.value.length < 2) return "测试数据至少要 2 组"
if (groups.value.length > TEST_CASE_MAX_CASES)
return `测试数据最多 ${TEST_CASE_MAX_CASES} 组,删掉 ${groups.value.length - TEST_CASE_MAX_CASES} 组`
for (const [i, g] of groups.value.entries()) {
if (!g.sql.trim()) return `第 ${i + 1} 组还是空的,写上或删掉`
}
@@ -557,18 +564,30 @@ onMounted(async () => {
<section class="card">
<div class="cardHead">
<h3>测试数据</h3>
<n-text depth="3" class="note">至少 2 组,数据要不一样</n-text>
<n-text depth="3" class="note">{{ `2~${TEST_CASE_MAX_CASES} 组,数据要不一样` }}</n-text>
<div class="grow"></div>
<n-button
size="tiny"
:loading="generating"
:disabled="!form.answer.trim()"
:title="form.answer.trim() ? '按标准答案用到的表,让 AI 编一组数据' : '先写标准答案'"
:disabled="!form.answer.trim() || (groupsFull && groups.every((g) => g.sql.trim()))"
:title="
groupsFull && groups.every((g) => g.sql.trim())
? `最多 ${TEST_CASE_MAX_CASES} 组,已经满了`
: form.answer.trim()
? '按标准答案用到的表,让 AI 编一组数据'
: '先写标准答案'
"
@click="generateGroup"
>
AI 再写一组
</n-button>
<n-button size="tiny" @click="addGroup">+ 加一组</n-button>
<n-button
size="tiny"
:disabled="groupsFull"
:title="groupsFull ? `最多 ${TEST_CASE_MAX_CASES} 组,已经满了` : undefined"
@click="addGroup"
>+ 加一组</n-button
>
</div>
<n-alert v-if="loadError" type="error" :show-icon="false">{{ loadError }}</n-alert>
<div class="groups" role="listbox" aria-label="测试数据">
@@ -1,4 +1,8 @@
import { TEST_CASE_EDIT_MAX_CASES, TEST_CASE_EDIT_MAX_FILE_BYTES } from "@oj2/contract"
import {
TEST_CASE_EDIT_MAX_CASES,
TEST_CASE_EDIT_MAX_FILE_BYTES,
TEST_CASE_MAX_CASES,
} from "@oj2/contract"
import type { RunnableLanguage } from "@oj2/contract"
import { strFromU8, unzipSync } from "fflate"
import type { Ref } from "vue"
@@ -186,6 +190,11 @@ export function readCaseZip(
}
if (!cases.length)
return { ok: false, reason: "压缩包里没有从 1.in / 1.out 开始连续编号的测试点" }
if (cases.length > TEST_CASE_MAX_CASES)
return {
ok: false,
reason: `测试数据最多 ${TEST_CASE_MAX_CASES} 组,这个压缩包里有 ${cases.length} 组,挑有代表性的留下`,
}
if (cases.length > TEST_CASE_EDIT_MAX_CASES) return { ok: false, reason: "too-large" }
return { ok: true, cases }
}
+38 -7
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { PROBLEM_TAG_MAX_LENGTH, type RunnableLanguage } from "@oj2/contract"
import { PROBLEM_TAG_MAX_LENGTH, TEST_CASE_MAX_CASES, type RunnableLanguage } from "@oj2/contract"
import { StorageSerializers } from "@vueuse/core"
import { useThemeVars } from "naive-ui"
import PageHeader from "admin/components/PageHeader.vue"
@@ -287,7 +287,11 @@ function formatBytes(bytes: number) {
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
}
/** 测试数据加满了(见契约 TEST_CASE_MAX_CASES) */
const casesFull = computed(() => rows.value.length >= TEST_CASE_MAX_CASES)
function addRow() {
if (casesFull.value) return
// 空着的第一组(新建页)直接用掉;例子一组都还没勾时,新加的这组先勾上
const noExample = !rows.value.some((r) => r.example)
rows.value.push(newRow("", "", noExample))
@@ -311,14 +315,23 @@ async function aiInputs() {
message.info("AI 没想出新的输入")
return
}
// 空着的行先填掉
// 空着的行先填掉,再往后加,加到上限为止
const blanks = rows.value.filter((r) => !r.input.trim() && !r.output.trim())
for (const input of res.inputs) {
const inputs = res.inputs.slice(0, blanks.length + TEST_CASE_MAX_CASES - rows.value.length)
if (!inputs.length) {
message.info(`测试数据最多 ${TEST_CASE_MAX_CASES} 组,已经满了`)
return
}
for (const input of inputs) {
const blank = blanks.shift()
if (blank) blank.input = input
else rows.value.push(newRow(input))
}
message.success(`加了 ${res.inputs.length} 组,输出马上跑出来`)
message.success(
inputs.length < res.inputs.length
? `加了 ${inputs.length} 组,到 ${TEST_CASE_MAX_CASES} 组的上限了`
: `加了 ${inputs.length} 组,输出马上跑出来`,
)
} catch (err) {
message.error(errorMessage(err, "AI 没想出来,稍后再试"))
} finally {
@@ -651,6 +664,12 @@ const blockers = computed<Blocker[]>(() => {
const caseRows = big.value ? exampleRows.value : rows.value
if (!big.value && !rows.value.length) list.push({ text: "加一组测试数据", section: "cases" })
if (!big.value && rows.value.length > TEST_CASE_MAX_CASES)
list.push({
text: `删掉 ${rows.value.length - TEST_CASE_MAX_CASES} 组测试数据(最多 ${TEST_CASE_MAX_CASES} 组)`,
section: "cases",
severe: true,
})
if (caseRows.some((r) => caseStatus(r, source.value) === "running"))
list.push({ text: "等测试数据跑完", section: "cases" })
caseRows.forEach((r, i) => {
@@ -997,13 +1016,25 @@ onMounted(async () => {
<n-button
size="tiny"
:loading="generatingInputs"
:disabled="!source"
:title="source ? '按题面和标准答案,让 AI 想几组边界情况' : '先写标准答案'"
:disabled="!source || casesFull"
:title="
casesFull
? `最多 ${TEST_CASE_MAX_CASES} 组,已经满了`
: source
? '按题面和标准答案,让 AI 想几组边界情况'
: '先写标准答案'
"
@click="aiInputs"
>
AI 想几组输入
</n-button>
<n-button size="tiny" @click="addRow">+ 加一组</n-button>
<n-button
size="tiny"
:disabled="casesFull"
:title="casesFull ? `最多 ${TEST_CASE_MAX_CASES} 组,已经满了` : undefined"
@click="addRow"
>+ 加一组</n-button
>
</template>
<span class="grow"></span>
<n-button