feat(后台): SQL 题有了自己的出题页——左边写,右边就是学生看到的样子,改答案、改数据马上重跑
- 「新建题目」先选编程题还是 SQL 题,选了就不能改;编辑时按题目本身进对应的页 - SQL 出题页(设计稿「SQL 出题页」方案 B):测试数据一组一行,改完自动重跑, 跑不通的用中文说原因;保存时才打包上传测试点,不用再手动「预览」「上传」 - 新建时存草稿,误关页面不丢 - 编程题出题页去掉 SQL 那一堆分支(以前勾上 SQL 会静默清掉语法要求和流程图) - 题目列表能按题型筛,SQL 题标题后面挂标签 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
25d3296410
commit
2cb39fd96b
13 files changed
+1414
-185
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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} 行 → 一行都没改`
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -181,6 +181,7 @@ export interface AdminProblemRow {
|
||||
createTime: string
|
||||
difficulty: ProblemDifficulty
|
||||
tags: string[]
|
||||
isSql: boolean
|
||||
hasAstRules: boolean
|
||||
allowFlowchart: boolean
|
||||
showFlowchart: boolean
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user