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