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:
1 parent
ab757553d4
commit
eaab224ba8
5 files changed
+88
-14
No files matched your search
@@ -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 个数据不同的测试点,防止硬编码期望结果")
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -822,6 +822,13 @@ export const generateSqlTestCaseRequestSchema = z.object({
|
||||
|
||||
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 原文)。太大的不展开 —— 页面上要一组一组
|
||||
* 摆出来、改了还要整包重跑一遍标准答案,几百组或者几 MB 的数据摆不下也跑不动,
|
||||
|
||||
Reference in new issue
Block a user