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

+10 -2
View File
@@ -1,5 +1,6 @@
import {
addContestProblemRequestSchema,
isSqlProblem,
createProblemRequestSchema,
generateSqlTestCaseRequestSchema,
makeProblemPublicRequestSchema,
@@ -15,7 +16,7 @@ import {
type SqlTestCaseScript,
type UploadTestCaseResponse,
} from "@oj2/contract"
import { and, count, desc, eq, ilike, inArray, isNull, ne, or, sql } from "drizzle-orm"
import { and, count, desc, eq, ilike, inArray, isNull, ne, not, or, sql } from "drizzle-orm"
import { Hono } from "hono"
import { requireProblemPermission, type AppEnv } from "../../auth/middleware"
@@ -216,7 +217,7 @@ function commonChecks(data: {
}): { error: string } | { sql: boolean } {
const astError = astRulesError(pickAstRules(data.astRules, data.languages))
if (astError) return { error: astError }
if (data.languages.includes("SQL")) {
if (isSqlProblem(data)) {
if (data.languages.length !== 1)
return { error: "SQL problem cannot be mixed with other languages" }
if (!data.sqlConfig) return { error: "SQL problem requires sql_config" }
@@ -397,6 +398,11 @@ adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => {
const author = c.req.query("author")?.trim()
const keyword = c.req.query("keyword")?.trim()
const tagId = c.req.query("tagId")?.trim()
// 题型:sql = SQL 题,code = 编程题(C / C++ / Python)
const kind = c.req.query("kind")
const sqlOnly = sql`${schema.problem.languages} @> '["SQL"]'::jsonb`
if (kind === "sql") filters.push(sqlOnly)
else if (kind === "code") filters.push(not(sqlOnly))
if (author) filters.push(eq(schema.user.username, author))
if (keyword) {
filters.push(
@@ -456,6 +462,7 @@ adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => {
createTime: problem.createTime,
difficulty: problem.difficulty,
tags: tags.get(problem.id) ?? [],
isSql: isSqlProblem(problem),
hasAstRules: problem.astRules !== null,
allowFlowchart: problem.allowFlowchart,
showFlowchart: problem.showFlowchart,
@@ -637,6 +644,7 @@ adminProblemRoutes.get("/contests/:contestId/problems", requireProblemPermission
createTime: problem.createTime,
difficulty: problem.difficulty,
tags: tags.get(problem.id) ?? [],
isSql: isSqlProblem(problem),
hasAstRules: problem.astRules !== null,
allowFlowchart: problem.allowFlowchart,
showFlowchart: problem.showFlowchart,
+3 -1
View File
@@ -63,10 +63,12 @@ export async function getProblemList(
author?: string,
contestID?: string,
tagId?: number,
/** 题型:sql = SQL 题,code = 编程题;不传是全部(比赛题列表不认) */
kind?: "sql" | "code" | "",
) {
const endpoint = contestID ? `admin/contests/${contestID}/problems` : "admin/problems"
const res = await api.get<AdminProblemList>(endpoint, {
params: { offset, limit, keyword, author, tagId },
params: { offset, limit, keyword, author, tagId, kind: kind || undefined },
})
return {
results: res.results.map(toAdminProblemRow),
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
/**
* 「新建题目」先选题型:编程题和 SQL 题是两张不同的出题页,测试数据的格式也不一样,
* 建好之后不能互相改(设计稿「SQL 出题页 · 三版共用」)。
* 每项要两行(名字 + 一句话),n-dropdown 的选项是固定行高,放不下,所以自己画
*/
defineProps<{ label: string; primary?: boolean }>()
const emit = defineEmits<{ select: [kind: "code" | "sql"] }>()
const show = ref(false)
const OPTIONS = [
{
kind: "code",
icon: "ph:code-bold",
title: "编程题",
note: "C / C++ / Python,有输入输出说明和样例,也能挂流程图、语法要求",
},
{
kind: "sql",
icon: "ph:database-bold",
title: "SQL 题",
note: "学生写 SQL 查数据库;给几组建表脚本和一条标准答案就行",
},
] as const
function pick(kind: "code" | "sql") {
show.value = false
emit("select", kind)
}
</script>
<template>
<n-popover
v-model:show="show"
trigger="click"
placement="bottom-end"
:show-arrow="false"
style="padding: 6px; width: 340px"
>
<template #trigger>
<n-button :type="primary ? 'primary' : 'default'" icon-placement="right">
{{ label }}
<template #icon><Icon icon="ph:caret-down-bold" /></template>
</n-button>
</template>
<div class="menu">
<button
v-for="option in OPTIONS"
:key="option.kind"
type="button"
class="option"
@click="pick(option.kind)"
>
<Icon :icon="option.icon" :width="20" class="icon" :class="option.kind" />
<span class="text">
<b>{{ option.title }}</b>
<span class="note">{{ option.note }}</span>
</span>
</button>
<div class="footnote">选好就定了:两种题的测试数据不一样,建好之后不能互相改</div>
</div>
</n-popover>
</template>
<style scoped>
.menu {
display: flex;
flex-direction: column;
gap: 2px;
}
.option {
display: flex;
gap: 12px;
padding: 10px 12px;
border: 0;
border-radius: 4px;
background: transparent;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
}
.option:hover,
.option:focus-visible {
background-color: rgba(128, 128, 128, 0.1);
}
.icon {
flex: none;
margin-top: 1px;
}
/*
* 弹出层挂在 body 下,拿不到这个组件根上 v-bind 出来的主题变量,颜色只能写死
* (绿和题目页、青和 SQL 标签同一个值)
*/
.icon.code {
color: #18a058;
}
.icon.sql {
color: #0b6470;
}
html.dark .icon.sql {
color: #8fd8e0;
}
.text {
display: flex;
flex-direction: column;
gap: 2px;
}
.note {
font-size: 12px;
opacity: 0.65;
}
.footnote {
margin-top: 4px;
padding: 8px 12px 4px;
border-top: 1px solid rgba(128, 128, 128, 0.2);
font-size: 12px;
opacity: 0.65;
}
</style>
@@ -0,0 +1,62 @@
import type { SqlDisplay } from "utils/types"
import { changedRowCount, tableChanges } from "oj/problem/utils/sqlChanges"
/** 出题页的一组测试数据:一份建表 + 插数据的脚本,和它跑出来的样子 */
export interface ScriptGroup {
key: number
sql: string
/** 跑出来的结果对应的是哪一版(标准答案 + 判题方式 + 脚本),和现在的对不上就是过期了 */
ranFor: string
display: SqlDisplay | null
/** 跑不起来的原因,常见的已经翻成中文(explainSqlError) */
error: string
/** 后端回的原话,翻译过的时候用小字附在后面 */
errorRaw: string
/** 正在跑的那一版 */
running: string
}
export type GroupStatus = "empty" | "no-answer" | "running" | "ok" | "error"
/**
* SQLite 的报错是英文,出题的老师未必看得懂。常见的几种翻成一句中文,
* 认不出来的原样给(后端已经加了「初始化脚本执行失败」这类中文前缀)
*/
export function explainSqlError(message: string) {
const rules: [RegExp, (m: RegExpMatchArray) => string][] = [
[/no such table: (\S+)/, (m) => `没有 ${m[1]} 这张表`],
[/no such column: (\S+)/, (m) => `没有 ${m[1]} 这一列`],
[/table (\S+) already exists/, (m) => `${m[1]} 这张表建了两次`],
[/near "([^"]*)": syntax error/, (m) => `语法有错,在「${m[1]}」附近`],
[/incomplete input/, () => "语句没写完(少了右括号或分号?)"],
[/UNIQUE constraint failed: (\S+)/, (m) => `${m[1]} 有重复的值`],
[/(\d+) values for (\d+) columns/, (m) => `一行给了 ${m[1]} 个值,但表有 ${m[2]} 列`],
]
if (message.includes("标准答案未修改任何表数据")) {
return "标准答案在这组数据上一行都没改:增删改题的每组数据里都要有会被改到的行"
}
for (const [pattern, say] of rules) {
const match = message.match(pattern)
if (match) {
const where = message.startsWith("标准答案") ? "标准答案在这组数据上" : "这组的脚本"
return `${where}跑不起来:${say(match)}`
}
}
return message
}
/** 列表里那一行的小结:几张表各几行 → 期望几行 / 改了几行 */
export function groupSummary(display: SqlDisplay) {
const rows = display.tables.map((t) => t.total_rows).join(" + ")
const expected = display.expected
if ("columns" in expected) return `${rows} 行 → 期望 ${expected.total_rows} 行`
const changed = expected.changed_tables.reduce((sum, table) => {
if (table.dropped) return sum + 1
const changes = tableChanges(
display.tables.find((t) => t.name === table.name),
table,
)
return sum + (changes ? changedRowCount(changes) : 0)
}, 0)
return changed ? `${rows} 行 → 改了 ${changed} 行` : `${rows} 行 → 一行都没改`
}
+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>
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { isSqlProblem } from "@oj2/contract"
import type { AdminProblem } from "utils/types"
import { getProblem } from "../api"
import ProblemDetail from "./detail.vue"
import SqlDetail from "./SqlDetail.vue"
/**
* 出题 / 改题的入口:编程题和 SQL 题是两张不同的页。
* 新建看地址上的 `?type=sql`(后台题目列表「新建题目」下拉里选的),
* 编辑看题目本身 —— 题型建好之后不能改,所以没有「切换题型」这回事。
*/
const props = defineProps<{
problemID?: string
contestID?: string
}>()
const route = useRoute()
const router = useRouter()
const message = useMessage()
const loaded = shallowRef<AdminProblem | null>(null)
const kind = ref<"code" | "sql" | null>(null)
async function resolve() {
kind.value = null
loaded.value = null
if (!props.problemID) {
kind.value = route.query.type === "sql" ? "sql" : "code"
return
}
try {
loaded.value = await getProblem(props.problemID)
kind.value = isSqlProblem(loaded.value) ? "sql" : "code"
} catch {
message.error("获取题目失败")
router.push(
props.contestID
? { name: "admin contest problem list", params: { contestID: props.contestID } }
: { name: "admin problem list" },
)
}
}
watch(() => [props.problemID, route.query.type], resolve, { immediate: true })
</script>
<template>
<SqlDetail
v-if="kind === 'sql'"
:key="`sql-${problemID ?? 'new'}`"
:problem-i-d="problemID"
:contest-i-d="contestID"
:initial="loaded"
/>
<ProblemDetail
v-else-if="kind === 'code'"
:key="`code-${problemID ?? 'new'}`"
:problem-i-d="problemID"
:contest-i-d="contestID"
:initial="loaded"
/>
</template>
+38 -9
View File
@@ -13,7 +13,9 @@ import { useRouteQuery } from "@vueuse/router"
import AuthorSelect from "shared/components/AuthorSelect.vue"
import type { DataTableRowKey } from "naive-ui"
import BatchTagModal from "./components/BatchTagModal.vue"
import CreateProblemMenu from "./components/CreateProblemMenu.vue"
import PageHeader from "admin/components/PageHeader.vue"
import ProblemTypeTag from "oj/problem/components/ProblemTypeTag.vue"
interface Props {
contestID?: string
@@ -72,18 +74,31 @@ const nextDisplayID = computed(() => {
interface ProblemQuery {
keyword: string
author: string
kind: "" | "code" | "sql"
}
// 使用分页 composable
const { query, clearQuery } = usePagination<ProblemQuery>({
keyword: useRouteQuery("keyword", "").value,
author: useRouteQuery("author", "").value,
kind: useRouteQuery<ProblemQuery["kind"]>("kind", "").value,
})
const baseColumns: DataTableColumn<AdminProblemRow>[] = [
{ title: "ID", key: "id", width: 100 },
{ title: "显示编号", key: "_id", width: 100 },
{ title: "标题", key: "title", minWidth: 200 },
{
title: "标题",
key: "title",
minWidth: 200,
render: (row) =>
row.isSql
? h(NFlex, { size: 6, align: "center", wrap: false }, () => [
h("span", row.title),
h(ProblemTypeTag, { kind: "sql" }),
])
: row.title,
},
{
title: "难度",
key: "difficulty",
@@ -194,6 +209,8 @@ async function listProblems() {
query.keyword,
query.author,
props.contestID,
undefined,
query.kind,
)
total.value = res.total
problems.value = res.results
@@ -209,10 +226,17 @@ async function toggleVisible(problemID: number) {
})
}
function createContestProblem() {
const kindOptions = [
{ label: "全部题型", value: "" },
{ label: "编程题", value: "code" },
{ label: "SQL 题", value: "sql" },
]
function createProblem(kind: string) {
router.push({
name: "admin contest problem create",
params: { contestID: props.contestID },
name: isContestProblemList.value ? "admin contest problem create" : "admin problem create",
params: isContestProblemList.value ? { contestID: props.contestID } : {},
query: kind === "sql" ? { type: "sql" } : {},
})
}
@@ -230,7 +254,7 @@ watchDebounced(() => query.keyword, listProblems, {
})
// 监听其他查询条件变化
watch(() => [query.page, query.limit, query.author], listProblems)
watch(() => [query.page, query.limit, query.author, query.kind], listProblems)
</script>
<template>
@@ -246,12 +270,10 @@ watch(() => [query.page, query.limit, query.author], listProblems)
添加标签({{ selectedProblemIds.length }})
</n-button>
</template>
<n-button type="primary" @click="$router.push({ name: 'admin problem create' })">
新建题目
</n-button>
<CreateProblemMenu label="新建题目" primary @select="createProblem" />
</template>
<template v-else>
<n-button @click="createContestProblem">新建比赛题目</n-button>
<CreateProblemMenu label="新建比赛题目" @select="createProblem" />
<n-button type="primary" @click="selectProblems">从题库中选择</n-button>
</template>
</template>
@@ -263,6 +285,13 @@ watch(() => [query.page, query.limit, query.author], listProblems)
style="width: 220px"
@clear="clearQuery"
/>
<n-select
v-if="!props.contestID"
v-model:value="query.kind"
:options="kindOptions"
aria-label="题型"
style="width: 120px"
/>
<n-flex v-if="!props.contestID" align="center" :size="8">
<n-text depth="3">出题人</n-text>
<AuthorSelect v-model:value="query.author" all />
+1
View File
@@ -11,6 +11,7 @@ export function toAdminProblemRow(result: AdminProblemListItem): AdminProblemRow
visible: result.visible,
difficulty: result.difficulty,
tags: result.tags,
isSql: result.isSql,
hasAstRules: result.hasAstRules,
allowFlowchart: result.allowFlowchart,
showFlowchart: result.showFlowchart,
+4 -4
View File
@@ -194,13 +194,13 @@ export const admins: RouteRecordRaw = {
{
path: "problem/create",
name: "admin problem create",
component: () => import("admin/problem/detail.vue"),
component: () => import("admin/problem/editor.vue"),
meta: { requiresProblemPermission: true },
},
{
path: "problem/edit/:problemID",
name: "admin problem edit",
component: () => import("admin/problem/detail.vue"),
component: () => import("admin/problem/editor.vue"),
props: true,
meta: { requiresProblemPermission: true },
},
@@ -233,14 +233,14 @@ export const admins: RouteRecordRaw = {
{
path: "contest/:contestID/problem/create",
name: "admin contest problem create",
component: () => import("admin/problem/detail.vue"),
component: () => import("admin/problem/editor.vue"),
props: true,
meta: { requiresTeacherAdmin: true },
},
{
path: "contest/:contestID/problem/edit/:problemID",
name: "admin contest problem edit",
component: () => import("admin/problem/detail.vue"),
component: () => import("admin/problem/editor.vue"),
props: true,
meta: { requiresTeacherAdmin: true },
},
+1
View File
@@ -152,6 +152,7 @@ export const STORAGE_KEY = {
LANGUAGE: "problemLanguage",
LEARN_CURRENT_STEP: "learnStep",
ADMIN_PROBLEM: "adminProblem",
ADMIN_SQL_PROBLEM: "adminSqlProblem",
ADMIN_PROBLEM_TAGS: "adminProblemTags",
ADMIN_SIDER_COLLAPSED: "adminSiderCollapsed",
DEMO_MODE: "demoMode",
+1
View File
@@ -181,6 +181,7 @@ export interface AdminProblemRow {
createTime: string
difficulty: ProblemDifficulty
tags: string[]
isSql: boolean
hasAstRules: boolean
allowFlowchart: boolean
showFlowchart: boolean
+2
View File
@@ -633,6 +633,8 @@ export const adminProblemListItemSchema = z.object({
createTime: z.string(),
difficulty: problemDifficultySchema,
tags: z.array(z.string()),
/** SQL 题:列表里标题后面挂标签;编辑时进 SQL 出题页 */
isSql: z.boolean(),
hasAstRules: z.boolean(),
allowFlowchart: z.boolean(),
showFlowchart: z.boolean(),