新增 SQL 题型前端支持

- LANGUAGE 类型与六张语言映射表补 SQL;编辑器 SQLite 方言高亮
- 出题表单:SQL 语言选项、题型/行序配置卡片、数据脚本压缩包上传、标准答案必填校验
- 修复做题页语言回退硬编码 Python3 的问题(SQL 题会提交被拒)
- SQL 题隐藏自测猫/自测屏/复制到自测猫(外部运行器不支持 SQL)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 17:23:23 -06:00
parent 5867bbceed
commit 38500671af
11 changed files with 138 additions and 12 deletions

3
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"@codemirror/autocomplete": "^6.20.3", "@codemirror/autocomplete": "^6.20.3",
"@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-python": "^6.2.1", "@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-sql": "^6.10.0",
"@vue-flow/background": "^1.3.2", "@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3", "@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.2", "@vue-flow/core": "^1.48.2",
@@ -424,7 +425,7 @@
}, },
"node_modules/@codemirror/lang-sql": { "node_modules/@codemirror/lang-sql": {
"version": "6.10.0", "version": "6.10.0",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz", "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz",
"integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==", "integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@@ -13,6 +13,7 @@
"@codemirror/autocomplete": "^6.20.3", "@codemirror/autocomplete": "^6.20.3",
"@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-python": "^6.2.1", "@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-sql": "^6.10.0",
"@vue-flow/background": "^1.3.2", "@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3", "@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.2", "@vue-flow/core": "^1.48.2",

View File

@@ -133,10 +133,13 @@ export async function uploadImage(file: File): Promise<string> {
return res.success ? res.file_path : "" return res.success ? res.file_path : ""
} }
// 上传测试用例 // 上传测试用例SQL 题的压缩包是 1.sql..N.sql每个文件一个测试点的建表+数据脚本)
export function uploadTestcases(file: File) { export function uploadTestcases(file: File, options: { sql?: boolean } = {}) {
const form = new window.FormData() const form = new window.FormData()
form.append("file", file) form.append("file", file)
if (options.sql) {
form.append("sql", "1")
}
return http.post<TestcaseUploadedReturns>("admin/test_case", form, { return http.post<TestcaseUploadedReturns>("admin/test_case", form, {
headers: { "content-type": "multipart/form-data" }, headers: { "content-type": "multipart/form-data" },
}) })

View File

@@ -10,7 +10,13 @@ import {
} from "utils/constants" } from "utils/constants"
import download from "utils/download" import download from "utils/download"
import { unique } from "utils/functions" import { unique } from "utils/functions"
import type { BlankProblem, LANGUAGE, Tag, Testcase } from "utils/types" import type {
BlankProblem,
LANGUAGE,
SQLConfig,
Tag,
Testcase,
} from "utils/types"
import { import {
createContestProblem, createContestProblem,
createProblem, createProblem,
@@ -89,6 +95,7 @@ const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
flowchart_hint: "", flowchart_hint: "",
show_flowchart: false, show_flowchart: false,
ast_rules: null as { [key: string]: any[] } | null, ast_rules: null as { [key: string]: any[] } | null,
sql_config: null as SQLConfig | null,
}) })
// 从服务器来的tag列表 // 从服务器来的tag列表
@@ -171,8 +178,33 @@ const languageOptions = [
{ label: LANGUAGE_SHOW_VALUE["Python3"], value: "Python3" }, { label: LANGUAGE_SHOW_VALUE["Python3"], value: "Python3" },
{ label: LANGUAGE_SHOW_VALUE["C"], value: "C" }, { label: LANGUAGE_SHOW_VALUE["C"], value: "C" },
{ 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
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.sql_config) {
problem.value.sql_config = { mode: "query", order_sensitive: false }
}
currentActiveAnswer.value = "SQL"
} else if (problem.value.sql_config) {
problem.value.sql_config = null
}
},
{ immediate: true },
)
async function getProblemDetail() { async function getProblemDetail() {
if (!props.problemID) { if (!props.problemID) {
syncTagInputsFromProblemTags() syncTagInputsFromProblemTags()
@@ -211,6 +243,7 @@ async function getProblemDetail() {
problem.value.flowchart_hint = data.flowchart_hint ?? "" problem.value.flowchart_hint = data.flowchart_hint ?? ""
problem.value.flowchart_data = data.flowchart_data problem.value.flowchart_data = data.flowchart_data
problem.value.ast_rules = data.ast_rules ?? null problem.value.ast_rules = data.ast_rules ?? null
problem.value.sql_config = data.sql_config ?? null
if (data.answers && data.answers.length) { if (data.answers && data.answers.length) {
problem.value.answers = data.answers problem.value.answers = data.answers
} else { } else {
@@ -262,7 +295,7 @@ function resetTemplate(language: LANGUAGE) {
async function handleUploadTestcases({ file }: UploadCustomRequestOptions) { async function handleUploadTestcases({ file }: UploadCustomRequestOptions) {
try { try {
const res = await uploadTestcases(file.file!) const res = await uploadTestcases(file.file!, { sql: isSQLProblem.value })
// @ts-ignore // @ts-ignore
if (res.error) { if (res.error) {
message.error("上传测试用例失败") message.error("上传测试用例失败")
@@ -332,6 +365,19 @@ async function validateProblem() {
message.error("编程语言没有选择") message.error("编程语言没有选择")
hasErrors = true hasErrors = true
} }
// SQL 题验证
else if (isSQLProblem.value && !problem.value.sql_config?.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.show_flowchart || problem.value.allow_flowchart) { else if (problem.value.show_flowchart || problem.value.allow_flowchart) {
if ( if (
@@ -611,7 +657,7 @@ watch(
</n-flex> </n-flex>
</n-checkbox-group> </n-checkbox-group>
</n-form-item> </n-form-item>
<n-form-item> <n-form-item v-if="!isSQLProblem">
<n-checkbox <n-checkbox
v-model:checked="needTemplate" v-model:checked="needTemplate"
label="预制代码(显示在编辑器中,帮助快速上手)" label="预制代码(显示在编辑器中,帮助快速上手)"
@@ -630,10 +676,37 @@ watch(
</n-form-item> </n-form-item>
</n-form> </n-form>
<n-form
v-if="isSQLProblem && problem.sql_config"
inline
label-placement="left"
>
<n-form-item label="SQL 题型">
<n-radio-group v-model:value="problem.sql_config.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.sql_config.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-grid :cols="2" x-gap="20">
<n-gi> <n-gi>
<n-form> <n-form>
<n-form-item label="本题参考答案(选填,用于 AI 分析,不会泄露)"> <n-form-item
:label="
isSQLProblem
? '标准答案(必填,判题依据:每个测试点会运行它生成期望结果)'
: '本题参考答案(选填,用于 AI 分析,不会泄露)'
"
>
<n-tabs <n-tabs
type="segment" type="segment"
default-value="Python3" default-value="Python3"
@@ -695,7 +768,7 @@ watch(
<h2 class="title">测试用例区域</h2> <h2 class="title">测试用例区域</h2>
<n-flex align="center" style="margin-bottom: 12px"> <n-flex align="center" style="margin-bottom: 12px">
<div> <div v-if="!isSQLProblem">
<n-button type="success" @click="showGeneratorModal = true"> <n-button type="success" @click="showGeneratorModal = true">
直接生成 直接生成
</n-button> </n-button>
@@ -706,14 +779,23 @@ watch(
accept=".zip" accept=".zip"
:custom-request="handleUploadTestcases" :custom-request="handleUploadTestcases"
> >
<n-button type="info">手动上传</n-button> <n-button type="info">
{{ isSQLProblem ? "上传数据脚本压缩包" : "(老)手动上传" }}
</n-button>
</n-upload> </n-upload>
</div> </div>
<n-tooltip placement="right"> <n-tooltip placement="right">
<template #trigger> <template #trigger>
<n-button text>温馨提醒</n-button> <n-button text>温馨提醒</n-button>
</template> </template>
测试用例最好要有10个要考虑边界情况且不要跟测试样例一模一样 <template v-if="isSQLProblem">
压缩包内放 1.sql2.sql每个文件是一个测试点的建表+插入数据脚本
判题时对每个测试点分别运行标准答案和学生 SQL 比对结果 建议至少 2-3
个数据不同的测试点防止学生硬编码答案
</template>
<template v-else>
测试用例最好要有10个要考虑边界情况且不要跟测试样例一模一样
</template>
</n-tooltip> </n-tooltip>
</n-flex> </n-flex>

View File

@@ -140,7 +140,8 @@ defineExpose({
onMounted(() => { onMounted(() => {
if (!languages.value.includes(codeStore.code.language)) { if (!languages.value.includes(codeStore.code.language)) {
codeStore.code.language = "Python3" // 回退到题目支持的第一种语言(如 SQL 题只有 "SQL",硬编码 Python3 会被后端拒绝)
codeStore.code.language = languages.value[0] ?? "Python3"
} }
}) })
</script> </script>
@@ -175,6 +176,7 @@ onMounted(() => {
<template v-if="codeStore.code.language !== 'Flowchart'"> <template v-if="codeStore.code.language !== 'Flowchart'">
<IconButton <IconButton
v-if="codeStore.code.language !== 'SQL'"
icon="streamline-ultimate-color:business-lucky-cat" icon="streamline-ultimate-color:business-lucky-cat"
tip="自测猫" tip="自测猫"
@click="goTestCat" @click="goTestCat"

View File

@@ -130,6 +130,16 @@ onBeforeUnmount(() => {
watch(isMobile, (value) => { watch(isMobile, (value) => {
if (value) screenModeStore.resetScreenMode() if (value) screenModeStore.resetScreenMode()
}) })
// SQL 题不支持"自测"模式(外部代码运行器无法执行 SQL切到该屏时自动跳到下一模式
watch(
() => screenModeStore.isCodeOnlyMode,
(codeOnly) => {
if (codeOnly && problem.value?.languages.includes("SQL")) {
screenModeStore.switchScreenMode()
}
},
)
</script> </script>
<template> <template>

View File

@@ -127,7 +127,13 @@ onMounted(init)
</n-flex> </n-flex>
</n-alert> </n-alert>
<n-flex :vertical="isDesktop" justify="center"> <n-flex :vertical="isDesktop" justify="center">
<n-button secondary @click="copyToCat">复制到自测猫</n-button> <n-button
v-if="submission.language !== 'SQL'"
secondary
@click="copyToCat"
>
复制到自测猫
</n-button>
<n-button secondary @click="copyToProblem">复制回到题目</n-button> <n-button secondary @click="copyToProblem">复制回到题目</n-button>
</n-flex> </n-flex>
</n-flex> </n-flex>

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { cpp } from "@codemirror/lang-cpp" import { cpp } from "@codemirror/lang-cpp"
import { python } from "@codemirror/lang-python" import { python } from "@codemirror/lang-python"
import { sql, SQLite } from "@codemirror/lang-sql"
import { bracketMatching } from "@codemirror/language" import { bracketMatching } from "@codemirror/language"
import { EditorView } from "@codemirror/view" import { EditorView } from "@codemirror/view"
import { Codemirror } from "vue-codemirror" import { Codemirror } from "vue-codemirror"
@@ -57,6 +58,8 @@ const emit = defineEmits<{
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
const langExtension = computed((): Extension => { const langExtension = computed((): Extension => {
if (language === "SQL")
return sql({ dialect: SQLite, upperCaseKeywords: true })
return ["Python2", "Python3"].includes(language) ? python() : cpp() return ["Python2", "Python3"].includes(language) ? python() : cpp()
}) })

View File

@@ -27,6 +27,9 @@ export function enhanceCompletion(language: LANGUAGE): CompletionSource {
const word = context.matchBefore(/\w+/) const word = context.matchBefore(/\w+/)
if (!word && !context.explicit) return null if (!word && !context.explicit) return null
// SQL 没有中文注释提示,关键字补全由 @codemirror/lang-sql 提供
if (language === "SQL") return null
const trulyLanguage = language.startsWith("Python") ? "python" : "c" const trulyLanguage = language.startsWith("Python") ? "python" : "c"
const completions: Completion[] = ( const completions: Completion[] = (
chineseAnnotations[trulyLanguage] || [] chineseAnnotations[trulyLanguage] || []

View File

@@ -175,6 +175,7 @@ export const SOURCES = {
JavaScript: "", JavaScript: "",
Golang: "", Golang: "",
Flowchart: "", Flowchart: "",
SQL: "",
} as const } as const
export const LANGUAGE_ID = { export const LANGUAGE_ID = {
@@ -186,6 +187,7 @@ export const LANGUAGE_ID = {
JavaScript: 0, JavaScript: 0,
Golang: 0, Golang: 0,
Flowchart: 0, Flowchart: 0,
SQL: 0,
} as const } as const
export const LANGUAGE_FORMAT_VALUE = { export const LANGUAGE_FORMAT_VALUE = {
@@ -197,6 +199,7 @@ export const LANGUAGE_FORMAT_VALUE = {
JavaScript: "javascript", JavaScript: "javascript",
Golang: "go", Golang: "go",
Flowchart: "flowchart", Flowchart: "flowchart",
SQL: "sql",
} as const } as const
export const LANGUAGE_SHOW_VALUE = { export const LANGUAGE_SHOW_VALUE = {
@@ -208,6 +211,7 @@ export const LANGUAGE_SHOW_VALUE = {
Python3: "Python", Python3: "Python",
JavaScript: "JS", JavaScript: "JS",
Golang: "Go", Golang: "Go",
SQL: "SQL",
} as const } as const
export const ICON_SET = { export const ICON_SET = {
@@ -219,6 +223,7 @@ export const ICON_SET = {
Java: "devicon:java", Java: "devicon:java",
JavaScript: "devicon:javascript", JavaScript: "devicon:javascript",
Golang: "devicon:go", Golang: "devicon:go",
SQL: "devicon:sqlite",
} as const } as const
const cTemplate = `//TEMPLATE BEGIN const cTemplate = `//TEMPLATE BEGIN
@@ -256,6 +261,7 @@ export const CODE_TEMPLATES = {
JavaScript: blankTemplate, JavaScript: blankTemplate,
Golang: blankTemplate, Golang: blankTemplate,
Flowchart: blankTemplate, Flowchart: blankTemplate,
SQL: blankTemplate,
} as const } as const
export enum ScreenMode { export enum ScreenMode {

View File

@@ -65,6 +65,12 @@ export type LANGUAGE =
| "JavaScript" | "JavaScript"
| "Golang" | "Golang"
| "Flowchart" | "Flowchart"
| "SQL"
export interface SQLConfig {
mode: "query" | "modify"
order_sensitive: boolean
}
export type LANGUAGE_SHOW_LABEL = export type LANGUAGE_SHOW_LABEL =
(typeof LANGUAGE_SHOW_VALUE)[keyof typeof LANGUAGE_SHOW_VALUE] (typeof LANGUAGE_SHOW_VALUE)[keyof typeof LANGUAGE_SHOW_VALUE]
@@ -164,6 +170,9 @@ export interface Problem {
}[] }[]
} | null } | null
has_ast_rules?: boolean has_ast_rules?: boolean
// SQL 题配置(非 SQL 题为 null
sql_config?: SQLConfig | null
} }
export type AdminProblem = Problem & AlterProblem export type AdminProblem = Problem & AlterProblem