feat(后台): SQL 题有了自己的出题页——左边写,右边就是学生看到的样子,改答案、改数据马上重跑

- 「新建题目」先选编程题还是 SQL 题,选了就不能改;编辑时按题目本身进对应的页
- SQL 出题页(设计稿「SQL 出题页」方案 B):测试数据一组一行,改完自动重跑,
  跑不通的用中文说原因;保存时才打包上传测试点,不用再手动「预览」「上传」
- 新建时存草稿,误关页面不丢
- 编程题出题页去掉 SQL 那一堆分支(以前勾上 SQL 会静默清掉语法要求和流程图)
- 题目列表能按题型筛,SQL 题标题后面挂标签

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-09 18:59:15 +08:00
1 parent 25d3296410
commit 2cb39fd96b
13 files changed
+1414 -185

No files matched your search

+85 -169
View File
@@ -5,12 +5,11 @@ import { PROBLEM_TAG_MAX_LENGTH } from "@oj2/contract"
import { getProblemTagList } from "shared/api"
import TextEditor from "shared/components/TextEditor.vue"
import TestcaseGenerator from "./components/TestcaseGenerator.vue"
import SQLTestcaseEditor from "./components/SQLTestcaseEditor.vue"
import AstRulesEditor from "./components/AstRulesEditor.vue"
import { CODE_TEMPLATES, LANGUAGE_SHOW_VALUE, STORAGE_KEY } from "utils/constants"
import download from "utils/download"
import { unique } from "utils/functions"
import type { BlankProblem, LANGUAGE, Tag, Testcase } from "utils/types"
import type { AdminProblem, BlankProblem, LANGUAGE, Tag, Testcase } from "utils/types"
import {
createContestProblem,
createProblem,
@@ -24,9 +23,15 @@ const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEdit
const MermaidEditor = defineAsyncComponent(() => import("shared/components/MermaidEditor.vue"))
/**
* 编程题(C / C++ / Python)的出题页。SQL 题在 SqlDetail.vue,两种题由 editor.vue 分流:
* 新建看 `?type=sql`,编辑看题目本身
*/
interface Props {
problemID?: string
contestID?: string
/** 编辑时外层已经拉过一次题目(要靠它判断题型),直接给进来 */
initial?: AdminProblem | null
}
const message = useMessage()
@@ -166,12 +171,8 @@ const languageOptions = [
{ label: LANGUAGE_SHOW_VALUE["Python"], value: "Python" },
{ label: LANGUAGE_SHOW_VALUE["C"], value: "C" },
{ label: LANGUAGE_SHOW_VALUE["C++"], value: "C++" },
{ label: LANGUAGE_SHOW_VALUE["SQL"], value: "SQL" },
]
const isSQLProblem = computed(() => !!problem.value?.languages.includes("SQL"))
// SQL 题联动:SQL 必须是唯一语言(后端强校验),不需要预制代码,自动初始化 sql_config
// 两个流程图开关是互斥的:allowFlowchart 为真时后端不会把 mermaidCode 下发给
// 学生,showFlowchart 就成了一个点进去什么都没有的 tab。UI 上已经把开关置灰,
// 这里再把存量数据里两个都开着的情况纠正掉。
@@ -182,34 +183,6 @@ watch(
},
)
watch(
() => problem.value?.languages,
(langs) => {
if (!langs) return
if (langs.includes("SQL")) {
if (langs.length > 1) {
problem.value.languages = ["SQL"]
return
}
needTemplate.value = false
if (!problem.value.sqlConfig) {
problem.value.sqlConfig = { mode: "query", order_sensitive: false }
}
currentActiveAnswer.value = "SQL"
// 代码规则检查基于 Python/C 的 AST 解析,对 SQL 没有意义,清空避免脏数据
if (problem.value.astRules) {
problem.value.astRules = null
}
// 流程图依赖 Python 答案生成,对 SQL 没有意义
problem.value.allowFlowchart = false
problem.value.showFlowchart = false
} else if (problem.value.sqlConfig) {
problem.value.sqlConfig = null
}
},
{ immediate: true },
)
async function getProblemDetail() {
if (!props.problemID) {
// 草稿缓存和编辑页共用同一个 localStorage key:编辑页会把服务器数据连 id
@@ -217,12 +190,17 @@ async function getProblemDetail() {
// 下的是那道旧题的包,SQL 测试点编辑器也会去回显那道旧题的脚本。
// 新建页不存在 id,进来先摘掉。
delete problem.value.id
// 以前 SQL 题也在这一页出,草稿里可能还留着 SQL:现在 SQL 题有自己的页
if (problem.value.languages.includes("SQL")) {
problem.value.languages = ["Python", "C"]
problem.value.sqlConfig = null
}
syncTagInputsFromProblemTags()
toggleReady(true)
return
}
try {
const data = await getProblem(props.problemID)
const data = props.initial ?? (await getProblem(props.problemID))
problem.value.id = data.id
problem.value._id = data._id
problem.value.title = data.title
@@ -300,7 +278,7 @@ async function handleUploadTestcases({ file }: UploadCustomRequestOptions) {
try {
// 失败走 catch —— 原来这里还有一句 `if (res.error)`(拿 { error, data }
// 信封当返回值),被 @ts-ignore 压着,信封拆掉之后就是一段死代码了
const res = await uploadTestcases(file.file!, { sql: isSQLProblem.value })
const res = await uploadTestcases(file.file!)
// score 不在上传响应里,前端按测试点数量平分补上
const entries = res.info
const testcases: Testcase[] = entries.map((entry) => ({
@@ -342,21 +320,19 @@ async function validateProblem() {
// 题目
else if (
!problem.value.description ||
(!isSQLProblem.value && (!problem.value.inputDescription || !problem.value.outputDescription))
!problem.value.inputDescription ||
!problem.value.outputDescription
) {
message.error("题目或输入或输出没有填写")
hasErrors = true
}
// 样例
else if (!isSQLProblem.value && problem.value.samples.length == 0) {
else if (problem.value.samples.length == 0) {
message.error("样例没有填写")
hasErrors = true
}
// 样例是空的
else if (
!isSQLProblem.value &&
problem.value.samples.some((sample) => sample.output === "" || sample.input === "")
) {
else if (problem.value.samples.some((sample) => sample.output === "" || sample.input === "")) {
message.error("空样例没有删干净")
hasErrors = true
}
@@ -368,17 +344,6 @@ async function validateProblem() {
message.error("编程语言没有选择")
hasErrors = true
}
// SQL 题验证
else if (isSQLProblem.value && !problem.value.sqlConfig?.mode) {
message.error("SQL 题需要选择题型(查询题/增删改题)")
hasErrors = true
} else if (
isSQLProblem.value &&
!problem.value.answers.find((ans) => ans.language === "SQL" && ans.code.trim() !== "")
) {
message.error("SQL 题必须填写标准答案(判题时用它生成期望结果)")
hasErrors = true
}
// 流程图验证
else if (problem.value.showFlowchart || problem.value.allowFlowchart) {
if (!problem.value.mermaidCode || problem.value.mermaidCode.trim() === "") {
@@ -421,18 +386,12 @@ function filterAnswers() {
problem.value.answers = problem.value.answers.filter((ans) => ans.code.trim() !== "")
}
function filterSamplesForSQL() {
// SQL 题不展示样例;后端 CreateSampleSerializer 也不接受空字符串样例
if (isSQLProblem.value) problem.value.samples = []
}
async function submit() {
const hasValidationErrors = await validateProblem()
if (hasValidationErrors) return
filterHint()
getTemplate()
filterAnswers()
filterSamplesForSQL()
syncProblemTags()
const api = {
"admin problem create": createProblem,
@@ -584,43 +543,31 @@ watch(
title="题目的描述"
:min-height="300"
/>
<TextEditor
v-if="ready && !isSQLProblem"
v-model:value="problem.inputDescription"
title="输入的描述"
/>
<TextEditor
v-if="ready && !isSQLProblem"
v-model:value="problem.outputDescription"
title="输出的描述"
/>
<template v-if="!isSQLProblem">
<div class="box" v-for="(sample, index) in problem.samples" :key="index">
<n-flex justify="space-between" align="center">
<strong>测试样例 {{ index + 1 }}</strong>
<n-button tertiary type="warning" size="small" @click="removeSample(index)">
删除 {{ index + 1 }}
</n-button>
</n-flex>
<n-grid x-gap="20" cols="2">
<n-gi span="1">
<n-flex vertical>
<span>输入样例</span>
<n-input type="textarea" v-model:value="sample.input" />
</n-flex>
</n-gi>
<n-gi span="1">
<n-flex vertical>
<span>输出样例</span>
<n-input type="textarea" v-model:value="sample.output" />
</n-flex>
</n-gi>
</n-grid>
</div>
<n-button class="addSamples box" tertiary type="primary" @click="addSample">
添加用例
</n-button>
</template>
<TextEditor v-if="ready" v-model:value="problem.inputDescription" title="输入的描述" />
<TextEditor v-if="ready" v-model:value="problem.outputDescription" title="输出的描述" />
<div class="box" v-for="(sample, index) in problem.samples" :key="index">
<n-flex justify="space-between" align="center">
<strong>测试样例 {{ index + 1 }}</strong>
<n-button tertiary type="warning" size="small" @click="removeSample(index)">
删除 {{ index + 1 }}
</n-button>
</n-flex>
<n-grid x-gap="20" cols="2">
<n-gi span="1">
<n-flex vertical>
<span>输入样例</span>
<n-input type="textarea" v-model:value="sample.input" />
</n-flex>
</n-gi>
<n-gi span="1">
<n-flex vertical>
<span>输出样例</span>
<n-input type="textarea" v-model:value="sample.output" />
</n-flex>
</n-gi>
</n-grid>
</div>
<n-button class="addSamples box" tertiary type="primary" @click="addSample"> 添加用例 </n-button>
<TextEditor v-if="ready" v-model:value="problem.hint" title="提示(选填)" />
<n-form>
<n-form-item label="题目的来源(选填)">
@@ -651,7 +598,7 @@ watch(
</n-flex>
</n-checkbox-group>
</n-form-item>
<n-form-item v-if="!isSQLProblem">
<n-form-item>
<n-checkbox v-model:checked="needTemplate" label="预制代码(显示在编辑器中,帮助快速上手)" />
</n-form-item>
<n-form-item>
@@ -667,31 +614,10 @@ watch(
</n-form-item>
</n-form>
<n-form v-if="isSQLProblem && problem.sqlConfig" inline label-placement="left">
<n-form-item label="SQL 题型">
<n-radio-group v-model:value="problem.sqlConfig.mode">
<n-radio-button value="query">查询题(比对查询结果)</n-radio-button>
<n-radio-button value="modify"> 增删改题(比对执行后的表数据) </n-radio-button>
</n-radio-group>
</n-form-item>
<n-form-item label="严格比对行顺序">
<n-switch v-model:value="problem.sqlConfig.order_sensitive" />
<n-text depth="3" style="margin-left: 12px">
题目要求 ORDER BY 时开启;关闭则按无序集合比对
</n-text>
</n-form-item>
</n-form>
<n-grid :cols="2" x-gap="20">
<n-gi>
<n-form>
<n-form-item
:label="
isSQLProblem
? '标准答案(必填,判题依据:每个测试点会运行它生成期望结果)'
: '本题参考答案(选填,用于 AI 分析,不会泄露)'
"
>
<n-form-item label="本题参考答案(选填,用于 AI 分析,不会泄露)">
<n-tabs type="segment" default-value="Python" v-model:value="currentActiveAnswer">
<n-tab-pane
v-for="(answer, index) in problem.answers"
@@ -727,7 +653,7 @@ watch(
</n-gi>
</n-grid>
<n-grid v-if="!isSQLProblem" :cols="2">
<n-grid :cols="2">
<n-gi :span="1">
<AstRulesEditor v-model="problem.astRules!" :languages="problem.languages" />
</n-gi>
@@ -737,7 +663,7 @@ watch(
<h2 class="title">测试用例区域</h2>
<n-flex v-if="!isSQLProblem" align="center" style="margin-bottom: 12px">
<n-flex align="center" style="margin-bottom: 12px">
<div>
<n-button type="success" @click="showGeneratorModal = true"> (新)直接生成 </n-button>
</div>
@@ -754,14 +680,6 @@ watch(
</n-tooltip>
</n-flex>
<SQLTestcaseEditor
v-if="isSQLProblem"
:answers="problem.answers"
:mode="problem.sqlConfig?.mode ?? 'query'"
:problem-id="problem.id"
@uploaded="handleTestcasesGenerated"
/>
<n-alert class="box" v-if="problem.testCaseScore.length" :show-icon="false" type="info">
<template #header>
<n-flex align="center">
@@ -792,49 +710,47 @@ watch(
/>
</n-modal>
<template v-if="!isSQLProblem">
<n-divider />
<n-divider />
<h2 class="title">流程图区域</h2>
<h2 class="title">流程图区域</h2>
<!-- 流程图相关设置 -->
<n-form inline label-placement="left" :show-feedback="false">
<n-form-item label="根据上面的【Python答案】智能生成 Mermaid 代码">
<n-button
type="primary"
size="small"
:disabled="!problem.answers.filter((a) => a.language === 'Python')[0]?.code.length"
:loading="isAIGenerating"
@click="generateMermaid"
>
AI 生成
</n-button>
</n-form-item>
<n-form-item label="允许提交流程图">
<n-switch v-model:value="problem.allowFlowchart" />
</n-form-item>
<n-form-item label="显示标准流程图">
<n-flex align="center">
<n-switch v-model:value="problem.showFlowchart" :disabled="problem.allowFlowchart" />
<n-text v-if="problem.allowFlowchart" depth="3" style="font-size: 12px">
让学生自己画图时,标准流程图不会下发给学生,这个开关没有意义
</n-text>
</n-flex>
</n-form-item>
</n-form>
<!-- 流程图相关设置 -->
<n-form inline label-placement="left" :show-feedback="false">
<n-form-item label="根据上面的【Python答案】智能生成 Mermaid 代码">
<n-button
type="primary"
size="small"
:disabled="!problem.answers.filter((a) => a.language === 'Python')[0]?.code.length"
:loading="isAIGenerating"
@click="generateMermaid"
>
AI 生成
</n-button>
</n-form-item>
<n-form-item label="允许提交流程图">
<n-switch v-model:value="problem.allowFlowchart" />
</n-form-item>
<n-form-item label="显示标准流程图">
<n-flex align="center">
<n-switch v-model:value="problem.showFlowchart" :disabled="problem.allowFlowchart" />
<n-text v-if="problem.allowFlowchart" depth="3" style="font-size: 12px">
让学生自己画图时,标准流程图不会下发给学生,这个开关没有意义
</n-text>
</n-flex>
</n-form-item>
</n-form>
<n-form>
<n-form-item>
<MermaidEditor v-model="problem.mermaidCode" @render-state="onMermaidRenderState" />
</n-form-item>
<n-form-item label="流程图提示信息(选填)">
<n-input
v-model:value="problem.flowchartHint"
placeholder="请输入流程图相关的提示信息,帮助学生理解题目要求"
/>
</n-form-item>
</n-form>
</template>
<n-form>
<n-form-item>
<MermaidEditor v-model="problem.mermaidCode" @render-state="onMermaidRenderState" />
</n-form-item>
<n-form-item label="流程图提示信息(选填)">
<n-input
v-model:value="problem.flowchartHint"
placeholder="请输入流程图相关的提示信息,帮助学生理解题目要求"
/>
</n-form-item>
</n-form>
<n-flex style="margin: 16px 0 120px" align="center" justify="end">
<n-button type="primary" @click="submit">提交</n-button>
</n-flex>