From 2cb39fd96b967f416eb5a4a9cee456a12b8fcc06 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Fri, 9 Oct 2026 18:59:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=90=8E=E5=8F=B0):=20SQL=20=E9=A2=98?= =?UTF-8?q?=E6=9C=89=E4=BA=86=E8=87=AA=E5=B7=B1=E7=9A=84=E5=87=BA=E9=A2=98?= =?UTF-8?q?=E9=A1=B5=E2=80=94=E2=80=94=E5=B7=A6=E8=BE=B9=E5=86=99=EF=BC=8C?= =?UTF-8?q?=E5=8F=B3=E8=BE=B9=E5=B0=B1=E6=98=AF=E5=AD=A6=E7=94=9F=E7=9C=8B?= =?UTF-8?q?=E5=88=B0=E7=9A=84=E6=A0=B7=E5=AD=90=EF=BC=8C=E6=94=B9=E7=AD=94?= =?UTF-8?q?=E6=A1=88=E3=80=81=E6=94=B9=E6=95=B0=E6=8D=AE=E9=A9=AC=E4=B8=8A?= =?UTF-8?q?=E9=87=8D=E8=B7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「新建题目」先选编程题还是 SQL 题,选了就不能改;编辑时按题目本身进对应的页 - SQL 出题页(设计稿「SQL 出题页」方案 B):测试数据一组一行,改完自动重跑, 跑不通的用中文说原因;保存时才打包上传测试点,不用再手动「预览」「上传」 - 新建时存草稿,误关页面不丢 - 编程题出题页去掉 SQL 那一堆分支(以前勾上 SQL 会静默清掉语法要求和流程图) - 题目列表能按题型筛,SQL 题标题后面挂标签 Co-Authored-By: Claude Opus 5.5 --- apps/api/src/routes/admin/problem.ts | 12 +- apps/web/src/admin/api.ts | 4 +- apps/web/src/admin/problem/SqlDetail.vue | 1012 +++++++++++++++++ .../problem/components/CreateProblemMenu.vue | 132 +++ .../src/admin/problem/components/sqlScript.ts | 62 + apps/web/src/admin/problem/detail.vue | 254 ++--- apps/web/src/admin/problem/editor.vue | 63 + apps/web/src/admin/problem/list.vue | 47 +- apps/web/src/admin/transforms.ts | 1 + apps/web/src/routes.ts | 8 +- apps/web/src/utils/constants.ts | 1 + apps/web/src/utils/types.ts | 1 + packages/contract/src/admin.ts | 2 + 13 files changed, 1414 insertions(+), 185 deletions(-) create mode 100644 apps/web/src/admin/problem/SqlDetail.vue create mode 100644 apps/web/src/admin/problem/components/CreateProblemMenu.vue create mode 100644 apps/web/src/admin/problem/components/sqlScript.ts create mode 100644 apps/web/src/admin/problem/editor.vue diff --git a/apps/api/src/routes/admin/problem.ts b/apps/api/src/routes/admin/problem.ts index e614577..3eba647 100644 --- a/apps/api/src/routes/admin/problem.ts +++ b/apps/api/src/routes/admin/problem.ts @@ -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, diff --git a/apps/web/src/admin/api.ts b/apps/web/src/admin/api.ts index 31672de..186cccc 100644 --- a/apps/web/src/admin/api.ts +++ b/apps/web/src/admin/api.ts @@ -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(endpoint, { - params: { offset, limit, keyword, author, tagId }, + params: { offset, limit, keyword, author, tagId, kind: kind || undefined }, }) return { results: res.results.map(toAdminProblemRow), diff --git a/apps/web/src/admin/problem/SqlDetail.vue b/apps/web/src/admin/problem/SqlDetail.vue new file mode 100644 index 0000000..afad4e8 --- /dev/null +++ b/apps/web/src/admin/problem/SqlDetail.vue @@ -0,0 +1,1012 @@ + + + + + diff --git a/apps/web/src/admin/problem/components/CreateProblemMenu.vue b/apps/web/src/admin/problem/components/CreateProblemMenu.vue new file mode 100644 index 0000000..72cb300 --- /dev/null +++ b/apps/web/src/admin/problem/components/CreateProblemMenu.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/apps/web/src/admin/problem/components/sqlScript.ts b/apps/web/src/admin/problem/components/sqlScript.ts new file mode 100644 index 0000000..1f77069 --- /dev/null +++ b/apps/web/src/admin/problem/components/sqlScript.ts @@ -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} 行 → 一行都没改` +} diff --git a/apps/web/src/admin/problem/detail.vue b/apps/web/src/admin/problem/detail.vue index 14d7ce8..fa6dcf3 100644 --- a/apps/web/src/admin/problem/detail.vue +++ b/apps/web/src/admin/problem/detail.vue @@ -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" /> - - - + + +
+ + 测试样例 {{ index + 1 }} + + 删除 {{ index + 1 }} + + + + + + 输入样例 + + + + + + 输出样例 + + + + +
+ 添加用例 @@ -651,7 +598,7 @@ watch( - + @@ -667,31 +614,10 @@ watch( - - - - 查询题(比对查询结果) - 增删改题(比对执行后的表数据) - - - - - - 题目要求 ORDER BY 时开启;关闭则按无序集合比对 - - - - - + - + @@ -737,7 +663,7 @@ watch(

测试用例区域

- +
(新)直接生成
@@ -754,14 +680,6 @@ watch(
- - @@ -263,6 +285,13 @@ watch(() => [query.page, query.limit, query.author], listProblems) style="width: 220px" @clear="clearQuery" /> + 出题人 diff --git a/apps/web/src/admin/transforms.ts b/apps/web/src/admin/transforms.ts index 99123a1..b50e5f1 100644 --- a/apps/web/src/admin/transforms.ts +++ b/apps/web/src/admin/transforms.ts @@ -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, diff --git a/apps/web/src/routes.ts b/apps/web/src/routes.ts index a5fb8bc..d87987c 100644 --- a/apps/web/src/routes.ts +++ b/apps/web/src/routes.ts @@ -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 }, }, diff --git a/apps/web/src/utils/constants.ts b/apps/web/src/utils/constants.ts index 5ae8314..385f43c 100644 --- a/apps/web/src/utils/constants.ts +++ b/apps/web/src/utils/constants.ts @@ -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", diff --git a/apps/web/src/utils/types.ts b/apps/web/src/utils/types.ts index 444ec1a..09e0667 100644 --- a/apps/web/src/utils/types.ts +++ b/apps/web/src/utils/types.ts @@ -181,6 +181,7 @@ export interface AdminProblemRow { createTime: string difficulty: ProblemDifficulty tags: string[] + isSql: boolean hasAstRules: boolean allowFlowchart: boolean showFlowchart: boolean diff --git a/packages/contract/src/admin.ts b/packages/contract/src/admin.ts index 94c2324..4a6bc89 100644 --- a/packages/contract/src/admin.ts +++ b/packages/contract/src/admin.ts @@ -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(),