Compare commits

...

17 Commits

Author SHA1 Message Date
ef7aa44577 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-19 04:29:04 -06:00
a72317173b feat: show warning when no answer code exists in generator 2026-05-19 04:27:10 -06:00
0ac203806c fix: round up initial rows to nearest multiple of 5 2026-05-19 04:25:31 -06:00
e7e270b928 fix: ensure at least 5 rows in generator even when samples exist 2026-05-19 04:25:00 -06:00
874a6fbe90 feat: pre-populate generator with problem samples on open 2026-05-19 04:22:23 -06:00
06738f6e29 refactor: move reminder tooltip into testcase section 2026-05-19 04:20:48 -06:00
8047a7af8e refactor: move test case info alert into testcase section 2026-05-19 04:20:16 -06:00
2912c7495c fix: use display-directive=show on modal to preserve generator state 2026-05-19 04:18:56 -06:00
60851e3255 feat: preserve testcase generator state across modal open/close 2026-05-19 04:17:49 -06:00
f57c2c4137 fix: show all languages in selector regardless of answer code 2026-05-19 04:16:17 -06:00
09475db932 fix: explicitly import TestcaseGenerator component 2026-05-19 04:08:37 -06:00
cf2f5eec7d fix: disable add/remove during run and fix score distribution to sum to 100 2026-05-19 04:04:18 -06:00
5c037bb438 fix: remount TestcaseGenerator on modal open to reset state 2026-05-19 04:02:00 -06:00
3b7b518109 feat: integrate testcase generator modal into problem edit page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 04:00:13 -06:00
a48baddcc3 fix: guard reset during run and use stable key for file list 2026-05-19 03:58:24 -06:00
631292c33b feat: add TestcaseGenerator component for inline test case creation 2026-05-19 03:55:54 -06:00
6485861c57 chore: install client-zip for frontend zip generation 2026-05-19 03:53:01 -06:00
7 changed files with 338 additions and 63 deletions

30
package-lock.json generated
View File

@@ -24,6 +24,7 @@
"axios": "^1.16.0",
"canvas-confetti": "^1.9.4",
"chart.js": "^4.5.1",
"client-zip": "^2.5.0",
"codemirror": "^6.0.2",
"copy-text-to-clipboard": "^3.2.2",
"date-fns": "^4.1.0",
@@ -597,6 +598,29 @@
"vue": "^3.0.11"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -2508,6 +2532,12 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/client-zip": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/client-zip/-/client-zip-2.5.0.tgz",
"integrity": "sha512-ydG4nDZesbFurnNq0VVCp/yyomIBh+X/1fZPI/P24zbnG4dtC4tQAfI5uQsomigsUMeiRO2wiTPizLWQh+IAyQ==",
"license": "MIT"
},
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz",

View File

@@ -26,6 +26,7 @@
"axios": "^1.16.0",
"canvas-confetti": "^1.9.4",
"chart.js": "^4.5.1",
"client-zip": "^2.5.0",
"codemirror": "^6.0.2",
"copy-text-to-clipboard": "^3.2.2",
"date-fns": "^4.1.0",

View File

@@ -0,0 +1,219 @@
<script setup lang="ts">
import { downloadZip } from "client-zip"
import type { LANGUAGE, Testcase } from "utils/types"
import { createTestSubmission } 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>("Python3")
// 始终显示所有语言,不管有没有答案代码
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)
const hasAnyInput = computed(() => files.value.some((f) => f.in.trim()))
const canUpload = computed(
() =>
!isRunning.value &&
hasAnyInput.value &&
files.value.filter((f) => f.in.trim()).every((f) => f.out && !f.error),
)
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>()
files.value = files.value.filter((f) => {
if (!f.in.trim()) return false
if (seen.has(f.in)) return false
seen.add(f.in)
return true
})
// 清空旧输出
files.value = files.value.map((f) => ({ ...f, out: "", error: false }))
isRunning.value = true
await Promise.all(
files.value.map(async (_, i) => {
try {
const result = await createTestSubmission(
{ language: selectedLanguage.value, value: answer.code },
files.value[i].in,
)
files.value[i] = { ...files.value[i], out: result.output, error: result.status !== 3 }
} catch {
files.value[i] = { ...files.value[i], out: "", error: true }
}
}),
)
isRunning.value = false
}
async function upload() {
isUploading.value = true
try {
const now = new Date()
const data = files.value
.filter((f) => f.in.trim() && f.out && !f.error)
.flatMap((f, i) => [
{ name: `${i + 1}.in`, input: f.in, lastModified: now },
{ name: `${i + 1}.out`, input: f.out, lastModified: now },
])
const blob = await downloadZip(data).blob()
const file = new File([blob], "testcase.zip", { type: "application/zip" })
const res = await uploadTestcases(file)
const testcases: Testcase[] = res.data.info
const baseScore = Math.floor(100 / testcases.length)
const remainder = 100 - baseScore * testcases.length
testcases.forEach((tc, i) => {
tc.score = String(i === testcases.length - 1 ? baseScore + remainder : baseScore)
})
emit("uploaded", res.data.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 && hasAnyInput">
<template #trigger>
<span>
<n-button
type="success"
:loading="isRunning"
:disabled="!hasAnswerCode || !hasAnyInput"
@click="run"
>
先运行
</n-button>
</span>
</template>
{{ !hasAnswerCode ? "请先在题目中填写答案代码" : "请先填写输入" }}
</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>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { getProblemTagList } from "shared/api"
import TextEditor from "shared/components/TextEditor.vue"
import TestcaseGenerator from "./components/TestcaseGenerator.vue"
import {
CODE_TEMPLATES,
LANGUAGE_SHOW_VALUE,
@@ -8,7 +9,7 @@ import {
} from "utils/constants"
import download from "utils/download"
import { unique } from "utils/functions"
import { BlankProblem, LANGUAGE, Tag, Testcase } from "utils/types"
import type { BlankProblem, LANGUAGE, Tag, Testcase } from "utils/types"
import {
createContestProblem,
createProblem,
@@ -139,7 +140,6 @@ const languageOptions = [
{ label: LANGUAGE_SHOW_VALUE["C++"], value: "C++" },
]
async function getProblemDetail() {
if (!props.problemID) {
toggleReady(true)
@@ -416,6 +416,17 @@ async function generateMermaid() {
problem.value.mermaid_code = res.data.flowchart
}
const showGeneratorModal = ref(false)
function handleTestcasesGenerated(
testCaseId: string,
testCaseScore: Testcase[],
) {
problem.value.test_case_id = testCaseId
problem.value.test_case_score = testCaseScore
showGeneratorModal.value = false
}
onMounted(() => {
getTagList()
getProblemDetail()
@@ -634,6 +645,74 @@ watch(
<n-divider />
<h2 class="title">测试用例区域</h2>
<n-flex align="center" style="margin-bottom: 12px">
<div>
<n-button type="success" @click="showGeneratorModal = true">
直接生成
</n-button>
</div>
<div>
<n-upload
:show-file-list="false"
accept=".zip"
:custom-request="handleUploadTestcases"
>
<n-button type="info">手动上传</n-button>
</n-upload>
</div>
<n-tooltip placement="right">
<template #trigger>
<n-button text>温馨提醒</n-button>
</template>
测试用例最好要有10个要考虑边界情况且不要跟测试样例一模一样
</n-tooltip>
</n-flex>
<n-alert
class="box"
v-if="problem.test_case_score.length"
:show-icon="false"
type="info"
>
<template #header>
<n-flex align="center">
<div>
测试组编号 {{ problem.test_case_id.slice(0, 12) }} 共有
{{ problem.test_case_score.length }}
条测试用例
</div>
<n-button
v-if="problem.id"
tertiary
type="info"
size="small"
@click="downloadTestcases"
>
下载
</n-button>
</n-flex>
</template>
</n-alert>
<n-modal
v-model:show="showGeneratorModal"
preset="card"
title="测试用例生成器"
style="width: 80vw; max-width: 900px"
:mask-closable="false"
display-directive="show"
>
<TestcaseGenerator
:answers="problem.answers"
:samples="problem.samples"
@uploaded="handleTestcasesGenerated"
/>
</n-modal>
<n-divider />
<h2 class="title">流程图区域</h2>
<!-- 流程图相关设置 -->
@@ -674,48 +753,7 @@ watch(
/>
</n-form-item>
</n-form>
<n-divider />
<n-alert
class="box"
v-if="problem.test_case_score.length"
:show-icon="false"
type="info"
>
<template #header>
<n-flex align="center">
<div>
测试组编号 {{ problem.test_case_id.slice(0, 12) }} 共有
{{ problem.test_case_score.length }}
条测试用例
</div>
<n-button
v-if="problem.id"
tertiary
type="info"
size="small"
@click="downloadTestcases"
>
下载
</n-button>
</n-flex>
</template>
</n-alert>
<n-flex style="margin-bottom: 120px" align="center" justify="end">
<n-tooltip placement="left">
<template #trigger>
<n-button text>温馨提醒</n-button>
</template>
测试用例最好要有10个要考虑边界情况且不要跟测试样例一模一样
</n-tooltip>
<div>
<n-upload
:show-file-list="false"
accept=".zip"
:custom-request="handleUploadTestcases"
>
<n-button type="info">上传测试用例</n-button>
</n-upload>
</div>
<n-button type="primary" @click="submit">提交</n-button>
</n-flex>
</template>

View File

@@ -61,7 +61,7 @@ watch(
() => [
problem.value?._id,
problem.value?.my_status,
problemStore.totalFailCount,
problemStore.failCount,
],
([, status, failCount]) => {
if (status === 0 || (failCount as number) >= 3) {

View File

@@ -51,7 +51,7 @@ const msg = computed(() => {
const showAIHint = computed(() => {
if (!props.submission) return false
return (
problemStore.totalFailCount >= 3 &&
problemStore.failCount >= 3 &&
props.submission.result !== SubmissionStatus.accepted &&
props.submission.result !== SubmissionStatus.pending &&
props.submission.result !== SubmissionStatus.judging &&

View File

@@ -1,19 +1,12 @@
import { defineStore } from "pinia"
import { LANGUAGE, Problem } from "utils/types"
import type { LANGUAGE, Problem } from "utils/types"
/**
* 题目状态管理 Store
* 管理当前题目的信息
*/
export const useProblemStore = defineStore("problem", () => {
// ==================== 状态 ====================
const problem = ref<Problem | null>(null)
const route = useRoute()
// 本次会话内累计的失败次数(与服务端 my_failed_count 叠加)
const localFailCount = ref(0)
const failCount = ref(0)
// ==================== 计算属性 ====================
const languages = computed<LANGUAGE[]>(() => {
if (route.name === "problem" && problem.value?.allow_flowchart) {
return ["Flowchart", ...problem.value?.languages]
@@ -21,27 +14,21 @@ export const useProblemStore = defineStore("problem", () => {
return problem.value?.languages ?? []
})
const totalFailCount = computed(
() => (problem.value?.my_failed_count ?? 0) + localFailCount.value,
)
function incrementFailCount() {
localFailCount.value++
failCount.value++
}
// 切题时重置
watch(
() => problem.value?.id,
() => {
localFailCount.value = 0
failCount.value = 0
},
)
return {
problem,
localFailCount,
failCount,
languages,
totalFailCount,
incrementFailCount,
}
})