Compare commits

..
3 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 033a245abb feat(SQL 题): 增删改题除了第 1 组,其余测试数据可以一行都不改——用来抓学生多改(WHERE 写漏、写宽)
Deploy / deploy (push) Canceled after 0s
原来每组数据都必须有被标准答案改到的行,所以「这组里没有研发部,看学生会不会把别的部门也改了」
这种测试数据建不了。现在只有第 1 组(题面上给学生看的那组)还要求改到行;其余组一行都没改时
出题页写「一行都没改」、预览写「执行后所有表都没有变化」。删掉第 1 组、别的组顶上来时自动按
新位置重查。判题本来就不管这一条,不受影响。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:17:42 +08:00
xuyueandClaude Opus 5.5 2cb39fd96b feat(后台): SQL 题有了自己的出题页——左边写,右边就是学生看到的样子,改答案、改数据马上重跑
- 「新建题目」先选编程题还是 SQL 题,选了就不能改;编辑时按题目本身进对应的页
- SQL 出题页(设计稿「SQL 出题页」方案 B):测试数据一组一行,改完自动重跑,
  跑不通的用中文说原因;保存时才打包上传测试点,不用再手动「预览」「上传」
- 新建时存草稿,误关页面不丢
- 编程题出题页去掉 SQL 那一堆分支(以前勾上 SQL 会静默清掉语法要求和流程图)
- 题目列表能按题型筛,SQL 题标题后面挂标签

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:59:15 +08:00
xuyueandClaude Opus 5.5 25d3296410 feat(SQL 题): 增删改题的期望结果标出执行后变了的格子,删掉的行写清是哪几行
学生原来只能拿两张表逐格对,看不出要改的是哪几行。现在改过的格子、新插的行标黄,
删掉的行在表下面写「删掉了 2 行(id 是 2、3)」。按第一列配对,对不上就不标。
数据表和期望结果抽成一个组件,后台出题页的预览也用它。
题型判断统一走契约的 isSqlProblem()。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:59:15 +08:00
28 changed files with 1651 additions and 551 deletions

No files matched your search

+8 -1
View File
@@ -34,6 +34,7 @@ export type SqlJob =
initSql: string initSql: string
refSql: string refSql: string
mode: "query" | "modify" mode: "query" | "modify"
requireChange: boolean
} }
/** /**
@@ -76,7 +77,13 @@ export async function runSqlChild() {
try { try {
if (job.kind === "display") { if (job.kind === "display") {
markPhase("display") markPhase("display")
const display = await buildDisplay(job.initSql, job.refSql, job.mode) const display = await buildDisplay(
job.initSql,
job.refSql,
job.mode,
undefined,
job.requireChange,
)
finish({ ok: true, display }) finish({ ok: true, display })
} }
// 阶段由 runCase 内部回调标记:prepare(受信脚本)→ student(学生 SQL) // 阶段由 runCase 内部回调标记:prepare(受信脚本)→ student(学生 SQL)
+9 -1
View File
@@ -584,12 +584,19 @@ function queryResultColumns(names: string[], tables: DisplayTable[]) {
return names.map((name) => ({ name, type: types.get(name) ?? "" })) return names.map((name) => ({ name, type: types.get(name) ?? "" }))
} }
/** 生成题目页展示数据:初始数据表 + 期望结果。失败一律抛 SqlCaseError(出题配置问题) */ /**
* 生成题目页展示数据:初始数据表 + 期望结果。失败一律抛 SqlCaseError(出题配置问题)。
*
* `requireChange`:增删改题的标准答案必须改到行。题面上展示的那组(第 1 组)必须 ——
* 学生要看着「执行后的表」知道该改什么;其余组可以一行都不改(看学生会不会多改),
* 这时 changed_tables 是空数组。判题(runCase)本来就不管这一条
*/
export async function buildDisplay( export async function buildDisplay(
initSql: string, initSql: string,
refSql: string, refSql: string,
mode: "query" | "modify", mode: "query" | "modify",
memoryLimitMb = 64, memoryLimitMb = 64,
requireChange = true,
) { ) {
const SQL = await sqlEngine() const SQL = await sqlEngine()
const db = newDatabase(SQL, memoryLimitMb) const db = newDatabase(SQL, memoryLimitMb)
@@ -648,6 +655,7 @@ export async function buildDisplay(
if (JSON.stringify(before[name]) !== JSON.stringify(after[name])) changed.add(name) if (JSON.stringify(before[name]) !== JSON.stringify(after[name])) changed.add(name)
} }
if (changed.size === 0) { if (changed.size === 0) {
if (!requireChange) return { tables, expected: { changed_tables: [] } }
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, "标准答案未修改任何表数据,请检查题目配置") throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, "标准答案未修改任何表数据,请检查题目配置")
} }
const changedTables = dumpDisplayTables(db, changed) const changedTables = dumpDisplayTables(db, changed)
+8 -2
View File
@@ -184,11 +184,17 @@ export function runSqlCase(job: Extract<SqlJob, { kind: "judge" }>) {
}) })
} }
export function buildSqlDisplay(initSql: string, refSql: string, mode: "query" | "modify") { /** `requireChange` 见 engine.ts 的 buildDisplay:只有题面上展示的那组必须改到行 */
export function buildSqlDisplay(
initSql: string,
refSql: string,
mode: "query" | "modify",
requireChange = true,
) {
// 子进程产出的形状由 engine.ts 的 dumpDisplayTables / runDisplay 决定,就是契约里的 // 子进程产出的形状由 engine.ts 的 dumpDisplayTables / runDisplay 决定,就是契约里的
// SqlDisplay —— 同一个仓库里的两端,不在这儿再 parse 一遍 // SqlDisplay —— 同一个仓库里的两端,不在这儿再 parse 一遍
return runJob<SqlDisplay>( return runJob<SqlDisplay>(
{ kind: "display", initSql, refSql, mode }, { kind: "display", initSql, refSql, mode, requireChange },
{ trustedMs: DISPLAY_BUDGET_MS, studentMs: null }, { trustedMs: DISPLAY_BUDGET_MS, studentMs: null },
) )
} }
+16 -3
View File
@@ -1,5 +1,6 @@
import { import {
addContestProblemRequestSchema, addContestProblemRequestSchema,
isSqlProblem,
createProblemRequestSchema, createProblemRequestSchema,
generateSqlTestCaseRequestSchema, generateSqlTestCaseRequestSchema,
makeProblemPublicRequestSchema, makeProblemPublicRequestSchema,
@@ -15,7 +16,7 @@ import {
type SqlTestCaseScript, type SqlTestCaseScript,
type UploadTestCaseResponse, type UploadTestCaseResponse,
} from "@oj2/contract" } 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 { Hono } from "hono"
import { requireProblemPermission, type AppEnv } from "../../auth/middleware" import { requireProblemPermission, type AppEnv } from "../../auth/middleware"
@@ -216,7 +217,7 @@ function commonChecks(data: {
}): { error: string } | { sql: boolean } { }): { error: string } | { sql: boolean } {
const astError = astRulesError(pickAstRules(data.astRules, data.languages)) const astError = astRulesError(pickAstRules(data.astRules, data.languages))
if (astError) return { error: astError } if (astError) return { error: astError }
if (data.languages.includes("SQL")) { if (isSqlProblem(data)) {
if (data.languages.length !== 1) if (data.languages.length !== 1)
return { error: "SQL problem cannot be mixed with other languages" } return { error: "SQL problem cannot be mixed with other languages" }
if (!data.sqlConfig) return { error: "SQL problem requires sql_config" } 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 author = c.req.query("author")?.trim()
const keyword = c.req.query("keyword")?.trim() const keyword = c.req.query("keyword")?.trim()
const tagId = c.req.query("tagId")?.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 (author) filters.push(eq(schema.user.username, author))
if (keyword) { if (keyword) {
filters.push( filters.push(
@@ -456,6 +462,7 @@ adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => {
createTime: problem.createTime, createTime: problem.createTime,
difficulty: problem.difficulty, difficulty: problem.difficulty,
tags: tags.get(problem.id) ?? [], tags: tags.get(problem.id) ?? [],
isSql: isSqlProblem(problem),
hasAstRules: problem.astRules !== null, hasAstRules: problem.astRules !== null,
allowFlowchart: problem.allowFlowchart, allowFlowchart: problem.allowFlowchart,
showFlowchart: problem.showFlowchart, showFlowchart: problem.showFlowchart,
@@ -637,6 +644,7 @@ adminProblemRoutes.get("/contests/:contestId/problems", requireProblemPermission
createTime: problem.createTime, createTime: problem.createTime,
difficulty: problem.difficulty, difficulty: problem.difficulty,
tags: tags.get(problem.id) ?? [], tags: tags.get(problem.id) ?? [],
isSql: isSqlProblem(problem),
hasAstRules: problem.astRules !== null, hasAstRules: problem.astRules !== null,
allowFlowchart: problem.allowFlowchart, allowFlowchart: problem.allowFlowchart,
showFlowchart: problem.showFlowchart, showFlowchart: problem.showFlowchart,
@@ -859,7 +867,12 @@ adminProblemRoutes.get("/problems/:id/sql-scripts", requireProblemPermission, as
adminProblemRoutes.post("/sql-test-cases/preview", requireProblemPermission, async (c) => { adminProblemRoutes.post("/sql-test-cases/preview", requireProblemPermission, async (c) => {
const parsed = await parseBody(c, sqlPreviewRequestSchema) const parsed = await parseBody(c, sqlPreviewRequestSchema)
if (!parsed.success) return parsed.response if (!parsed.success) return parsed.response
const outcome = await buildSqlDisplay(parsed.data.initSql, parsed.data.refSql, parsed.data.mode) const outcome = await buildSqlDisplay(
parsed.data.initSql,
parsed.data.refSql,
parsed.data.mode,
parsed.data.shown,
)
if (!outcome.ok) return failure(c, 400, "sql-preview-failed", outcome.message) if (!outcome.ok) return failure(c, 400, "sql-preview-failed", outcome.message)
return success(c, outcome.value) return success(c, outcome.value)
}) })
+2 -1
View File
@@ -1,4 +1,5 @@
import { import {
isSqlProblem,
classComparisonRequestSchema, classComparisonRequestSchema,
STUDENT_ROLES, STUDENT_ROLES,
classLessonRequestSchema, classLessonRequestSchema,
@@ -929,7 +930,7 @@ classroomRoutes.put("/classroom/lesson", requireTeacher, async (c) => {
// SQL 题除外:它只收 SQL,C 的作业里夹一道也不会让人选错语言,计数那边也放行了 SQL // SQL 题除外:它只收 SQL,C 的作业里夹一道也不会让人选错语言,计数那边也放行了 SQL
if (language) { if (language) {
const unsupported = found const unsupported = found
.filter((row) => !row.languages.includes(language) && !row.languages.includes("SQL")) .filter((row) => !row.languages.includes(language) && !isSqlProblem(row))
.map((row) => row.displayId) .map((row) => row.displayId)
if (unsupported.length) { if (unsupported.length) {
return failure( return failure(
+2 -1
View File
@@ -1,4 +1,5 @@
import { import {
isSqlProblem,
contestPasswordRequestSchema, contestPasswordRequestSchema,
STUDENT_ROLES, STUDENT_ROLES,
type Contest, type Contest,
@@ -244,7 +245,7 @@ contestRoutes.get(
allowFlowchart: problem.allowFlowchart, allowFlowchart: problem.allowFlowchart,
showFlowchart: problem.showFlowchart, showFlowchart: problem.showFlowchart,
hasAstRules: problem.astRules !== null, hasAstRules: problem.astRules !== null,
isSql: problem.languages.includes("SQL"), isSql: isSqlProblem(problem),
myStatus: myStatusOf(statuses, problem.id), myStatus: myStatusOf(statuses, problem.id),
// 按人数是公开题库列表的列;比赛题表看的是榜单,不下发 // 按人数是公开题库列表的列;比赛题表看的是榜单,不下发
solvedUsers: 0, solvedUsers: 0,
+2 -1
View File
@@ -1,4 +1,5 @@
import { import {
isSqlProblem,
normalizeLanguage, normalizeLanguage,
problemListSortSchema, problemListSortSchema,
problemTypeFilterSchema, problemTypeFilterSchema,
@@ -121,7 +122,7 @@ function listItem(
allowFlowchart: row.problem.allowFlowchart, allowFlowchart: row.problem.allowFlowchart,
showFlowchart: row.problem.showFlowchart, showFlowchart: row.problem.showFlowchart,
hasAstRules: row.problem.astRules !== null, hasAstRules: row.problem.astRules !== null,
isSql: row.problem.languages.includes("SQL"), isSql: isSqlProblem(row.problem),
// 流程图评到 A / S 也打勾:problemStates 已经把它算进 solved // 流程图评到 A / S 也打勾:problemStates 已经把它算进 solved
myStatus: states?.solved.has(id) myStatus: states?.solved.has(id)
? JudgeStatus.ACCEPTED ? JudgeStatus.ACCEPTED
+5 -1
View File
@@ -63,10 +63,12 @@ export async function getProblemList(
author?: string, author?: string,
contestID?: string, contestID?: string,
tagId?: number, tagId?: number,
/** 题型:sql = SQL 题,code = 编程题;不传是全部(比赛题列表不认) */
kind?: "sql" | "code" | "",
) { ) {
const endpoint = contestID ? `admin/contests/${contestID}/problems` : "admin/problems" const endpoint = contestID ? `admin/contests/${contestID}/problems` : "admin/problems"
const res = await api.get<AdminProblemList>(endpoint, { const res = await api.get<AdminProblemList>(endpoint, {
params: { offset, limit, keyword, author, tagId }, params: { offset, limit, keyword, author, tagId, kind: kind || undefined },
}) })
return { return {
results: res.results.map(toAdminProblemRow), results: res.results.map(toAdminProblemRow),
@@ -234,6 +236,8 @@ export function previewSQLTestcase(data: {
initSql: string initSql: string
refSql: string refSql: string
mode: "query" | "modify" mode: "query" | "modify"
/** 是不是题面上展示的那组(第 1 组):增删改题只有它必须改到行 */
shown: boolean
}) { }) {
return api.post<SqlDisplay>("admin/sql-test-cases/preview", data) return api.post<SqlDisplay>("admin/sql-test-cases/preview", data)
} }
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>
@@ -1,352 +0,0 @@
<script setup lang="ts">
import { errorCode, isApiError } from "utils/api"
import type { LANGUAGE, SqlDisplay, Testcase } from "utils/types"
import { createZipBlob } from "utils/functions"
import SQLDataTable from "oj/problem/components/SQLDataTable.vue"
import {
generateSQLTestcase,
getSQLTestcaseScripts,
previewSQLTestcase,
uploadTestcases,
} from "../../api"
interface ScriptEntry {
id: number
sql: string
display: SqlDisplay | null
error: string
// 标准答案或题型改过之后,旧预览结果作废,需重新预览才能上传
stale: boolean
}
interface Props {
answers: { language: LANGUAGE; code: string }[]
mode: "query" | "modify"
problemId?: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
uploaded: [testCaseId: string, testCaseScore: Testcase[]]
}>()
const message = useMessage()
let nextId = 0
function blankEntry(): ScriptEntry {
return { id: nextId++, sql: "", display: null, error: "", stale: false }
}
const scripts = ref<ScriptEntry[]>([blankEntry(), blankEntry(), blankEntry()])
const refSQL = computed(
() => props.answers.find((a) => a.language === "SQL" && a.code.trim())?.code ?? "",
)
/** 已有脚本读取失败时的提示。空白编辑器和「本来就没测试点」长得一样,必须区分开 */
const loadError = ref("")
const isPreviewing = ref(false)
const isUploading = ref(false)
const isGenerating = ref(false)
const hasAnyScript = computed(() => scripts.value.some((s) => s.sql.trim()))
const hasBlankScript = computed(() => scripts.value.some((s) => !s.sql.trim()))
const filledCount = computed(() => scripts.value.filter((s) => s.sql.trim()).length)
const canUpload = computed(() => {
const filled = scripts.value.filter((s) => s.sql.trim())
return (
!isPreviewing.value &&
// 至少 2 个数据不同的测试点,防止学生对照题目页的期望结果硬编码
filled.length >= 2 &&
filled.every((s) => s.display && !s.error && !s.stale)
)
})
watch([refSQL, () => props.mode], () => {
for (const s of scripts.value) {
if (s.display || s.error) s.stale = true
}
})
// 编辑已有 SQL 题时回显已上传的脚本;新题或旧格式测试点则保持空白
onMounted(async () => {
if (!props.problemId) return
try {
const res = await getSQLTestcaseScripts(props.problemId)
if (res.length) {
scripts.value = res.map((f) => ({ ...blankEntry(), sql: f.content }))
}
} catch (err) {
// 新题、以及旧格式(非 SQL)测试点,后端回 404/409,保持空白就是对的,不该报错。
// 但**其它**失败必须说出来:读不出来时编辑器长得和「这题本来就没测试点」一模一样,
// 教师看不出区别,会以为要自己重新填。保存本身是安全的(后端读不到测试点信息会拒绝
// 整个保存),所以这里只提示、不阻塞。
const code = errorCode(err)
if (code && code !== "problem-not-found" && code !== "not-sql-test-case") {
// 后端那句话(如"测试点信息读取失败")只说了现象,教师需要的是「下面是空的、别存」
const detail = isApiError(err) && err.data ? `(${err.data})` : ""
loadError.value =
`已有测试点脚本没能读出来${detail}。下方是空白模板,不是本题真实的测试点 —— ` +
`直接保存不会丢数据(后端会拒绝),但也不会生效。请先排查测试点文件,或重新上传。`
}
}
})
function add() {
scripts.value.push(blankEntry())
}
function remove(index: number) {
scripts.value.splice(index, 1)
}
function reset() {
scripts.value = [blankEntry(), blankEntry(), blankEntry()]
}
function expectedQuery(d: SqlDisplay) {
return "columns" in d.expected ? d.expected : null
}
function changedTables(d: SqlDisplay) {
return "changed_tables" in d.expected ? d.expected.changed_tables : []
}
async function generate() {
const blanks = scripts.value.filter((s) => !s.sql.trim())
if (!blanks.length) return
isGenerating.value = true
await Promise.all(
blanks.map(async (s) => {
try {
const res = await generateSQLTestcase({
refSql: refSQL.value,
mode: props.mode,
})
s.sql = res.sql
} catch (err) {
const data = (err as { data?: unknown })?.data
message.error(typeof data === "string" ? data : "AI 生成失败")
}
}),
)
isGenerating.value = false
await preview()
}
async function preview() {
// 丢弃空脚本
scripts.value = scripts.value.filter((s) => s.sql.trim())
if (!scripts.value.length) {
scripts.value = [blankEntry()]
return
}
isPreviewing.value = true
await Promise.all(
scripts.value.map(async (s) => {
s.display = null
s.error = ""
s.stale = false
try {
const res = await previewSQLTestcase({
initSql: s.sql,
refSql: refSQL.value,
mode: props.mode,
})
s.display = res
} catch (err) {
const data = (err as { data?: unknown })?.data
s.error = typeof data === "string" ? data : "预览失败"
}
}),
)
isPreviewing.value = false
}
async function upload() {
isUploading.value = true
try {
const data = scripts.value
.filter((s) => s.sql.trim())
.map((s, i) => ({
name: `${i + 1}.sql`,
content: s.sql,
}))
const blob = createZipBlob(data)
const file = new File([blob], "testcase.zip", { type: "application/zip" })
const res = await uploadTestcases(file, { sql: true })
// score 不在上传响应里,是这里按测试点数量平分补上的(余数给最后一个)
const entries = res.info
const baseScore = Math.floor(100 / entries.length)
const remainder = 100 - baseScore * entries.length
// 只留落库的三个键:上传响应里的 stripped_output_md5 / input_size /
// output_size 保存时会被剥掉,见契约 problemTestCaseScoreSchema
const testcases: Testcase[] = entries.map((entry, i) => ({
input_name: entry.input_name,
output_name: entry.output_name,
score: i === entries.length - 1 ? baseScore + remainder : baseScore,
}))
emit("uploaded", res.id, testcases)
message.success("上传成功")
} catch {
message.error("上传失败")
} finally {
isUploading.value = false
}
}
</script>
<template>
<n-flex vertical>
<n-alert v-if="!refSQL" type="warning" :show-icon="false" style="margin-bottom: 8px">
还没有填写 SQL 标准答案,请先在上方"本题参考答案"中填写,再来编写测试点
</n-alert>
<n-alert v-if="loadError" type="error" :show-icon="false" style="margin-bottom: 8px">
{{ loadError }}
</n-alert>
<n-flex align="center" wrap>
<n-button :disabled="isPreviewing || isGenerating" @click="reset"> 清空 </n-button>
<n-button :disabled="isPreviewing || isGenerating" @click="add"> +1 </n-button>
<n-tooltip :disabled="!!refSQL && hasBlankScript">
<template #trigger>
<span>
<n-button
:loading="isGenerating"
:disabled="!refSQL || !hasBlankScript || isPreviewing"
@click="generate"
>
AI 生成
</n-button>
</span>
</template>
{{ !refSQL ? "请先填写 SQL 标准答案" : "所有脚本都写好了,无需生成" }}
</n-tooltip>
<n-tooltip :disabled="!!refSQL && hasAnyScript">
<template #trigger>
<span>
<n-button
type="success"
:loading="isPreviewing"
:disabled="!refSQL || !hasAnyScript || isGenerating"
@click="preview"
>
预览验证
</n-button>
</span>
</template>
{{ !refSQL ? "请先填写 SQL 标准答案" : "请先填写数据脚本" }}
</n-tooltip>
<n-tooltip :disabled="canUpload || isPreviewing">
<template #trigger>
<span>
<n-button
type="primary"
:loading="isUploading"
:disabled="!canUpload || isGenerating"
@click="upload"
>
上传
</n-button>
</span>
</template>
{{
filledCount < 2
? "SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果"
: "所有脚本预览验证通过后才能上传"
}}
</n-tooltip>
</n-flex>
<div v-for="(s, index) in scripts" :key="s.id" class="scriptBox">
<n-flex justify="space-between" align="center">
<strong>{{ index + 1 }}.sql</strong>
<n-button
size="small"
:disabled="scripts.length === 1 || isPreviewing || isGenerating"
@click="remove(index)"
>
删除
</n-button>
</n-flex>
<n-input
type="textarea"
v-model:value="s.sql"
:rows="8"
placeholder="-- 本测试点的建表 + 插入数据脚本
CREATE TABLE ...;
INSERT INTO ...;"
:status="s.error ? 'error' : s.display && !s.stale ? 'success' : undefined"
/>
<n-alert v-if="s.error" type="error" :show-icon="false">
{{ s.error }}
</n-alert>
<template v-if="s.display">
<n-alert v-if="s.stale" type="warning" :show-icon="false">
标准答案或题型已修改,以下预览已过期,请重新预览
</n-alert>
<div :class="{ stalePreview: s.stale }">
<p class="previewTitle">数据表</p>
<div v-for="t in s.display.tables" :key="t.name">
<p class="sqlTableName">{{ t.name }}</p>
<SQLDataTable
:columns="t.columns"
:rows="t.rows"
:total-rows="t.total_rows"
:truncated="t.truncated"
/>
</div>
<p class="previewTitle">期望结果</p>
<SQLDataTable
v-if="expectedQuery(s.display)"
:columns="expectedQuery(s.display)!.columns"
:rows="expectedQuery(s.display)!.rows"
:total-rows="expectedQuery(s.display)!.total_rows"
:truncated="expectedQuery(s.display)!.truncated"
/>
<div v-for="t in changedTables(s.display)" :key="t.name">
<p class="sqlTableName">
{{ t.dropped ? `${t.name} 表已被删除` : `执行后的 ${t.name} 表` }}
</p>
<SQLDataTable
v-if="!t.dropped"
:columns="t.columns"
:rows="t.rows"
:total-rows="t.total_rows"
:truncated="t.truncated"
/>
</div>
</div>
</template>
</div>
</n-flex>
</template>
<style scoped>
.scriptBox {
border: 1px solid var(--n-border-color, rgba(128, 128, 128, 0.2));
border-radius: 6px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.previewTitle {
font-weight: bold;
margin: 4px 0;
}
.sqlTableName {
font-weight: 500;
margin: 4px 0;
opacity: 0.85;
}
.stalePreview {
opacity: 0.45;
}
</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 "标准答案在这组数据上一行都没改:第 1 组是题面上给学生看的,里面要有会被改到的行"
}
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} 行 → 一行都没改`
}
+25 -109
View File
@@ -5,12 +5,11 @@ import { PROBLEM_TAG_MAX_LENGTH } from "@oj2/contract"
import { getProblemTagList } from "shared/api" import { getProblemTagList } from "shared/api"
import TextEditor from "shared/components/TextEditor.vue" import TextEditor from "shared/components/TextEditor.vue"
import TestcaseGenerator from "./components/TestcaseGenerator.vue" import TestcaseGenerator from "./components/TestcaseGenerator.vue"
import SQLTestcaseEditor from "./components/SQLTestcaseEditor.vue"
import AstRulesEditor from "./components/AstRulesEditor.vue" import AstRulesEditor from "./components/AstRulesEditor.vue"
import { CODE_TEMPLATES, LANGUAGE_SHOW_VALUE, STORAGE_KEY } from "utils/constants" import { CODE_TEMPLATES, LANGUAGE_SHOW_VALUE, STORAGE_KEY } 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 { AdminProblem, BlankProblem, LANGUAGE, Tag, Testcase } from "utils/types"
import { import {
createContestProblem, createContestProblem,
createProblem, createProblem,
@@ -24,9 +23,15 @@ const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEdit
const MermaidEditor = defineAsyncComponent(() => import("shared/components/MermaidEditor.vue")) const MermaidEditor = defineAsyncComponent(() => import("shared/components/MermaidEditor.vue"))
/**
* 编程题(C / C++ / Python)的出题页。SQL 题在 SqlDetail.vue,两种题由 editor.vue 分流:
* 新建看 `?type=sql`,编辑看题目本身
*/
interface Props { interface Props {
problemID?: string problemID?: string
contestID?: string contestID?: string
/** 编辑时外层已经拉过一次题目(要靠它判断题型),直接给进来 */
initial?: AdminProblem | null
} }
const message = useMessage() const message = useMessage()
@@ -166,12 +171,8 @@ const languageOptions = [
{ label: LANGUAGE_SHOW_VALUE["Python"], value: "Python" }, { 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["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 下发给 // 两个流程图开关是互斥的:allowFlowchart 为真时后端不会把 mermaidCode 下发给
// 学生,showFlowchart 就成了一个点进去什么都没有的 tab。UI 上已经把开关置灰, // 学生,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() { async function getProblemDetail() {
if (!props.problemID) { if (!props.problemID) {
// 草稿缓存和编辑页共用同一个 localStorage key:编辑页会把服务器数据连 id // 草稿缓存和编辑页共用同一个 localStorage key:编辑页会把服务器数据连 id
@@ -217,12 +190,17 @@ async function getProblemDetail() {
// 下的是那道旧题的包,SQL 测试点编辑器也会去回显那道旧题的脚本。 // 下的是那道旧题的包,SQL 测试点编辑器也会去回显那道旧题的脚本。
// 新建页不存在 id,进来先摘掉。 // 新建页不存在 id,进来先摘掉。
delete problem.value.id delete problem.value.id
// 以前 SQL 题也在这一页出,草稿里可能还留着 SQL:现在 SQL 题有自己的页
if (problem.value.languages.includes("SQL")) {
problem.value.languages = ["Python", "C"]
problem.value.sqlConfig = null
}
syncTagInputsFromProblemTags() syncTagInputsFromProblemTags()
toggleReady(true) toggleReady(true)
return return
} }
try { 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._id = data._id problem.value._id = data._id
problem.value.title = data.title problem.value.title = data.title
@@ -300,7 +278,7 @@ async function handleUploadTestcases({ file }: UploadCustomRequestOptions) {
try { try {
// 失败走 catch —— 原来这里还有一句 `if (res.error)`(拿 { error, data } // 失败走 catch —— 原来这里还有一句 `if (res.error)`(拿 { error, data }
// 信封当返回值),被 @ts-ignore 压着,信封拆掉之后就是一段死代码了 // 信封当返回值),被 @ts-ignore 压着,信封拆掉之后就是一段死代码了
const res = await uploadTestcases(file.file!, { sql: isSQLProblem.value }) const res = await uploadTestcases(file.file!)
// score 不在上传响应里,前端按测试点数量平分补上 // score 不在上传响应里,前端按测试点数量平分补上
const entries = res.info const entries = res.info
const testcases: Testcase[] = entries.map((entry) => ({ const testcases: Testcase[] = entries.map((entry) => ({
@@ -342,21 +320,19 @@ async function validateProblem() {
// 题目 // 题目
else if ( else if (
!problem.value.description || !problem.value.description ||
(!isSQLProblem.value && (!problem.value.inputDescription || !problem.value.outputDescription)) !problem.value.inputDescription ||
!problem.value.outputDescription
) { ) {
message.error("题目或输入或输出没有填写") message.error("题目或输入或输出没有填写")
hasErrors = true hasErrors = true
} }
// 样例 // 样例
else if (!isSQLProblem.value && problem.value.samples.length == 0) { else if (problem.value.samples.length == 0) {
message.error("样例没有填写") message.error("样例没有填写")
hasErrors = true hasErrors = true
} }
// 样例是空的 // 样例是空的
else if ( else if (problem.value.samples.some((sample) => sample.output === "" || sample.input === "")) {
!isSQLProblem.value &&
problem.value.samples.some((sample) => sample.output === "" || sample.input === "")
) {
message.error("空样例没有删干净") message.error("空样例没有删干净")
hasErrors = true hasErrors = true
} }
@@ -368,17 +344,6 @@ async function validateProblem() {
message.error("编程语言没有选择") message.error("编程语言没有选择")
hasErrors = true 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) { else if (problem.value.showFlowchart || problem.value.allowFlowchart) {
if (!problem.value.mermaidCode || problem.value.mermaidCode.trim() === "") { 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() !== "") problem.value.answers = problem.value.answers.filter((ans) => ans.code.trim() !== "")
} }
function filterSamplesForSQL() {
// SQL 题不展示样例;后端 CreateSampleSerializer 也不接受空字符串样例
if (isSQLProblem.value) problem.value.samples = []
}
async function submit() { async function submit() {
const hasValidationErrors = await validateProblem() const hasValidationErrors = await validateProblem()
if (hasValidationErrors) return if (hasValidationErrors) return
filterHint() filterHint()
getTemplate() getTemplate()
filterAnswers() filterAnswers()
filterSamplesForSQL()
syncProblemTags() syncProblemTags()
const api = { const api = {
"admin problem create": createProblem, "admin problem create": createProblem,
@@ -584,17 +543,8 @@ watch(
title="题目的描述" title="题目的描述"
:min-height="300" :min-height="300"
/> />
<TextEditor <TextEditor v-if="ready" v-model:value="problem.inputDescription" title="输入的描述" />
v-if="ready && !isSQLProblem" <TextEditor v-if="ready" v-model:value="problem.outputDescription" title="输出的描述" />
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"> <div class="box" v-for="(sample, index) in problem.samples" :key="index">
<n-flex justify="space-between" align="center"> <n-flex justify="space-between" align="center">
<strong>测试样例 {{ index + 1 }}</strong> <strong>测试样例 {{ index + 1 }}</strong>
@@ -617,10 +567,7 @@ watch(
</n-gi> </n-gi>
</n-grid> </n-grid>
</div> </div>
<n-button class="addSamples box" tertiary type="primary" @click="addSample"> <n-button class="addSamples box" tertiary type="primary" @click="addSample"> 添加用例 </n-button>
添加用例
</n-button>
</template>
<TextEditor v-if="ready" v-model:value="problem.hint" title="提示(选填)" /> <TextEditor v-if="ready" v-model:value="problem.hint" title="提示(选填)" />
<n-form> <n-form>
<n-form-item label="题目的来源(选填)"> <n-form-item label="题目的来源(选填)">
@@ -651,7 +598,7 @@ watch(
</n-flex> </n-flex>
</n-checkbox-group> </n-checkbox-group>
</n-form-item> </n-form-item>
<n-form-item v-if="!isSQLProblem"> <n-form-item>
<n-checkbox v-model:checked="needTemplate" label="预制代码(显示在编辑器中,帮助快速上手)" /> <n-checkbox v-model:checked="needTemplate" label="预制代码(显示在编辑器中,帮助快速上手)" />
</n-form-item> </n-form-item>
<n-form-item> <n-form-item>
@@ -667,31 +614,10 @@ watch(
</n-form-item> </n-form-item>
</n-form> </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-grid :cols="2" x-gap="20">
<n-gi> <n-gi>
<n-form> <n-form>
<n-form-item <n-form-item label="本题参考答案(选填,用于 AI 分析,不会泄露)">
:label="
isSQLProblem
? '标准答案(必填,判题依据:每个测试点会运行它生成期望结果)'
: '本题参考答案(选填,用于 AI 分析,不会泄露)'
"
>
<n-tabs type="segment" default-value="Python" v-model:value="currentActiveAnswer"> <n-tabs type="segment" default-value="Python" v-model:value="currentActiveAnswer">
<n-tab-pane <n-tab-pane
v-for="(answer, index) in problem.answers" v-for="(answer, index) in problem.answers"
@@ -727,7 +653,7 @@ watch(
</n-gi> </n-gi>
</n-grid> </n-grid>
<n-grid v-if="!isSQLProblem" :cols="2"> <n-grid :cols="2">
<n-gi :span="1"> <n-gi :span="1">
<AstRulesEditor v-model="problem.astRules!" :languages="problem.languages" /> <AstRulesEditor v-model="problem.astRules!" :languages="problem.languages" />
</n-gi> </n-gi>
@@ -737,7 +663,7 @@ watch(
<h2 class="title">测试用例区域</h2> <h2 class="title">测试用例区域</h2>
<n-flex v-if="!isSQLProblem" align="center" style="margin-bottom: 12px"> <n-flex align="center" style="margin-bottom: 12px">
<div> <div>
<n-button type="success" @click="showGeneratorModal = true"> (新)直接生成 </n-button> <n-button type="success" @click="showGeneratorModal = true"> (新)直接生成 </n-button>
</div> </div>
@@ -754,14 +680,6 @@ watch(
</n-tooltip> </n-tooltip>
</n-flex> </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"> <n-alert class="box" v-if="problem.testCaseScore.length" :show-icon="false" type="info">
<template #header> <template #header>
<n-flex align="center"> <n-flex align="center">
@@ -792,7 +710,6 @@ watch(
/> />
</n-modal> </n-modal>
<template v-if="!isSQLProblem">
<n-divider /> <n-divider />
<h2 class="title">流程图区域</h2> <h2 class="title">流程图区域</h2>
@@ -834,7 +751,6 @@ watch(
/> />
</n-form-item> </n-form-item>
</n-form> </n-form>
</template>
<n-flex style="margin: 16px 0 120px" align="center" justify="end"> <n-flex style="margin: 16px 0 120px" align="center" justify="end">
<n-button type="primary" @click="submit">提交</n-button> <n-button type="primary" @click="submit">提交</n-button>
</n-flex> </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 AuthorSelect from "shared/components/AuthorSelect.vue"
import type { DataTableRowKey } from "naive-ui" import type { DataTableRowKey } from "naive-ui"
import BatchTagModal from "./components/BatchTagModal.vue" import BatchTagModal from "./components/BatchTagModal.vue"
import CreateProblemMenu from "./components/CreateProblemMenu.vue"
import PageHeader from "admin/components/PageHeader.vue" import PageHeader from "admin/components/PageHeader.vue"
import ProblemTypeTag from "oj/problem/components/ProblemTypeTag.vue"
interface Props { interface Props {
contestID?: string contestID?: string
@@ -72,18 +74,31 @@ const nextDisplayID = computed(() => {
interface ProblemQuery { interface ProblemQuery {
keyword: string keyword: string
author: string author: string
kind: "" | "code" | "sql"
} }
// 使用分页 composable // 使用分页 composable
const { query, clearQuery } = usePagination<ProblemQuery>({ const { query, clearQuery } = usePagination<ProblemQuery>({
keyword: useRouteQuery("keyword", "").value, keyword: useRouteQuery("keyword", "").value,
author: useRouteQuery("author", "").value, author: useRouteQuery("author", "").value,
kind: useRouteQuery<ProblemQuery["kind"]>("kind", "").value,
}) })
const baseColumns: DataTableColumn<AdminProblemRow>[] = [ const baseColumns: DataTableColumn<AdminProblemRow>[] = [
{ title: "ID", key: "id", width: 100 }, { title: "ID", key: "id", width: 100 },
{ title: "显示编号", 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: "难度", title: "难度",
key: "difficulty", key: "difficulty",
@@ -194,6 +209,8 @@ async function listProblems() {
query.keyword, query.keyword,
query.author, query.author,
props.contestID, props.contestID,
undefined,
query.kind,
) )
total.value = res.total total.value = res.total
problems.value = res.results 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({ router.push({
name: "admin contest problem create", name: isContestProblemList.value ? "admin contest problem create" : "admin problem create",
params: { contestID: props.contestID }, 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> </script>
<template> <template>
@@ -246,12 +270,10 @@ watch(() => [query.page, query.limit, query.author], listProblems)
添加标签({{ selectedProblemIds.length }}) 添加标签({{ selectedProblemIds.length }})
</n-button> </n-button>
</template> </template>
<n-button type="primary" @click="$router.push({ name: 'admin problem create' })"> <CreateProblemMenu label="新建题目" primary @select="createProblem" />
新建题目
</n-button>
</template> </template>
<template v-else> <template v-else>
<n-button @click="createContestProblem">新建比赛题目</n-button> <CreateProblemMenu label="新建比赛题目" @select="createProblem" />
<n-button type="primary" @click="selectProblems">从题库中选择</n-button> <n-button type="primary" @click="selectProblems">从题库中选择</n-button>
</template> </template>
</template> </template>
@@ -263,6 +285,13 @@ watch(() => [query.page, query.limit, query.author], listProblems)
style="width: 220px" style="width: 220px"
@clear="clearQuery" @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-flex v-if="!props.contestID" align="center" :size="8">
<n-text depth="3">出题人</n-text> <n-text depth="3">出题人</n-text>
<AuthorSelect v-model:value="query.author" all /> <AuthorSelect v-model:value="query.author" all />
+1
View File
@@ -11,6 +11,7 @@ export function toAdminProblemRow(result: AdminProblemListItem): AdminProblemRow
visible: result.visible, visible: result.visible,
difficulty: result.difficulty, difficulty: result.difficulty,
tags: result.tags, tags: result.tags,
isSql: result.isSql,
hasAstRules: result.hasAstRules, hasAstRules: result.hasAstRules,
allowFlowchart: result.allowFlowchart, allowFlowchart: result.allowFlowchart,
showFlowchart: result.showFlowchart, showFlowchart: result.showFlowchart,
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { isSqlProblem } from "@oj2/contract"
import { useThemeVars } from "naive-ui" import { useThemeVars } from "naive-ui"
import { storeToRefs } from "pinia" import { storeToRefs } from "pinia"
import { useProblemStore } from "oj/store/problem" import { useProblemStore } from "oj/store/problem"
@@ -8,7 +9,7 @@ import { useSubmissionStore } from "oj/store/submission"
import { useDark } from "@vueuse/core" import { useDark } from "@vueuse/core"
import { MdPreview } from "md-editor-v3" import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css" import "md-editor-v3/lib/preview.css"
import SQLDataTable from "./SQLDataTable.vue" import SqlProblemData from "./SqlProblemData.vue"
import SimilarProblems from "./SimilarProblems.vue" import SimilarProblems from "./SimilarProblems.vue"
import { isFlowchartPass, useFlowchartStore } from "oj/store/flowchart" import { isFlowchartPass, useFlowchartStore } from "oj/store/flowchart"
import { useMyFlowchartStore } from "shared/store/myFlowchart" import { useMyFlowchartStore } from "shared/store/myFlowchart"
@@ -25,16 +26,7 @@ const { problem } = storeToRefs(problemStore)
const ctx = useProblemPageContext() const ctx = useProblemPageContext()
// SQL 题:隐藏输入/输出/例子,改为渲染数据表与期望结果 // SQL 题:隐藏输入/输出/例子,改为渲染数据表与期望结果
const isSQL = computed(() => !!problem.value?.sqlConfig) const isSQL = computed(() => !!problem.value && isSqlProblem(problem.value))
const sqlDisplay = computed(() => problem.value?.sqlDisplay ?? null)
const sqlExpectedQuery = computed(() => {
const exp = sqlDisplay.value?.expected
return exp && "columns" in exp ? exp : null
})
const sqlChangedTables = computed(() => {
const exp = sqlDisplay.value?.expected
return exp && "changed_tables" in exp ? exp.changed_tables : []
})
// 相似题推荐:做对了、或者错了 3 次以上才给(比赛、题单里不给,见 problemPageContext) // 相似题推荐:做对了、或者错了 3 次以上才给(比赛、题单里不给,见 problemPageContext)
const showSimilar = computed( const showSimilar = computed(
@@ -317,41 +309,15 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
</section> </section>
</template> </template>
<template v-if="isSQL && sqlDisplay"> <SqlProblemData
<h3 class="title sql-title">数据表</h3> v-if="isSQL && problem.sqlDisplay"
<div v-for="t in sqlDisplay.tables" :key="t.name"> :display="problem.sqlDisplay"
<p class="sqlTableName">{{ t.name }}</p> :order-sensitive="!!problem.sqlConfig?.order_sensitive"
<SQLDataTable >
:columns="t.columns" <template #title="{ text }">
:rows="t.rows" <h3 class="title sql-title">{{ text }}</h3>
:total-rows="t.total_rows"
:truncated="t.truncated"
/>
</div>
<h3 class="title sql-title">期望结果</h3>
<template v-if="sqlExpectedQuery">
<SQLDataTable
:columns="sqlExpectedQuery.columns"
:rows="sqlExpectedQuery.rows"
:total-rows="sqlExpectedQuery.total_rows"
:truncated="sqlExpectedQuery.truncated"
/>
<p v-if="!problem.sqlConfig?.order_sensitive" class="sqlNote">结果顺序不限</p>
</template>
<div v-for="t in sqlChangedTables" :key="t.name">
<p class="sqlTableName">
{{ t.dropped ? `${t.name} 表已被删除` : `执行后的 ${t.name} 表` }}
</p>
<SQLDataTable
v-if="!t.dropped"
:columns="t.columns"
:rows="t.rows"
:total-rows="t.total_rows"
:truncated="t.truncated"
/>
</div>
</template> </template>
</SqlProblemData>
<section v-if="problem.hint" class="block"> <section v-if="problem.hint" class="block">
<h3 class="title">提示</h3> <h3 class="title">提示</h3>
@@ -603,16 +569,4 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
.sql-title { .sql-title {
margin-top: 14px; margin-top: 14px;
} }
.sqlTableName {
font-weight: 600;
margin: 8px 0 4px;
font-family: Monaco, Consolas, monospace;
}
.sqlNote {
font-size: 13px;
opacity: 0.65;
margin: 0 0 8px;
}
</style> </style>
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { isSqlProblem } from "@oj2/contract"
import { storeToRefs } from "pinia" import { storeToRefs } from "pinia"
import { useProblemStore } from "oj/store/problem" import { useProblemStore } from "oj/store/problem"
import { useSubmissionStore } from "oj/store/submission" import { useSubmissionStore } from "oj/store/submission"
@@ -81,10 +82,7 @@ const codeSegments = computed<{ value: Segment; label: string; time: string }[]>
const drawing = computed(() => codeStore.code.language === "Flowchart") const drawing = computed(() => codeStore.code.language === "Flowchart")
// 试跑走判题机,SQL 题不走那里;流程图也没得跑。这两种只留「提交结果」 // 试跑走判题机,SQL 题不走那里;流程图也没得跑。这两种只留「提交结果」
const canTrial = computed(() => { const canTrial = computed(() => !problem.value || !isSqlProblem(problem.value))
const languages = problem.value?.languages ?? []
return !languages.includes("SQL")
})
const segments = computed(() => { const segments = computed(() => {
const list = canTrial.value ? codeSegments.value : codeSegments.value.slice(0, 1) const list = canTrial.value ? codeSegments.value : codeSegments.value.slice(0, 1)
@@ -1,12 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SqlDisplayColumn } from "utils/types" import type { SqlDisplayColumn } from "utils/types"
import type { TableChanges } from "../utils/sqlChanges"
defineProps<{ const props = defineProps<{
columns: SqlDisplayColumn[] columns: SqlDisplayColumn[]
rows: (string | number | null)[][] rows: (string | number | null)[][]
totalRows?: number totalRows?: number
truncated?: boolean truncated?: boolean
/** 增删改题执行后的表:变了的格子、新插的行标出来 */
changes?: TableChanges | null
}>() }>()
const changed = (i: number, j: number) =>
!!props.changes && (props.changes.newRows.has(i) || props.changes.cells.has(`${i}:${j}`))
</script> </script>
<template> <template>
@@ -29,7 +35,11 @@ defineProps<{
<td :colspan="columns.length" class="nullCell">(空表)</td> <td :colspan="columns.length" class="nullCell">(空表)</td>
</tr> </tr>
<tr v-for="(row, i) in rows" :key="i"> <tr v-for="(row, i) in rows" :key="i">
<td v-for="(v, j) in row" :key="j" :class="{ nullCell: v === null }"> <td
v-for="(v, j) in row"
:key="j"
:class="{ nullCell: v === null, changed: changed(i, j) }"
>
{{ v === null ? "NULL" : v }} {{ v === null ? "NULL" : v }}
</td> </td>
</tr> </tr>
@@ -63,6 +73,18 @@ defineProps<{
font-weight: normal; font-weight: normal;
} }
/* 和图例同一个黄:浅底深字,暗色下压暗底、提亮字 */
.sqlTable :deep(td.changed) {
background-color: #fff4d6;
color: #7a4b00;
font-weight: 600;
}
html.dark .sqlTable :deep(td.changed) {
background-color: rgba(240, 180, 40, 0.18);
color: #f5d38a;
}
.nullCell { .nullCell {
opacity: 0.45; opacity: 0.45;
font-style: italic; font-style: italic;
@@ -0,0 +1,127 @@
<script setup lang="ts">
import type { SqlDisplay } from "utils/types"
import { tableChanges } from "../utils/sqlChanges"
import SQLDataTable from "./SQLDataTable.vue"
/**
* SQL 题题面里的「数据表」和「期望结果」。题目页和后台出题页的预览共用这一份 ——
* 出题页右边就是「学生看到的样子」,两边各写一份迟早对不上。
*
* 小节标题交给调用方的 `#title` 插槽画:两边的标题样式各在自己的 scoped CSS 里。
*/
const props = defineProps<{
display: SqlDisplay
orderSensitive: boolean
}>()
defineSlots<{ title(props: { text: string }): any }>()
const expectedQuery = computed(() =>
"columns" in props.display.expected ? props.display.expected : null,
)
/** 增删改题:执行后的每张表配上它比原来变了什么 */
const changedTables = computed(() => {
const expected = props.display.expected
if (!("changed_tables" in expected)) return []
return expected.changed_tables.map((table) => ({
table,
changes: tableChanges(
props.display.tables.find((t) => t.name === table.name),
table,
),
}))
})
/** 增删改题里不是题面那组的测试数据可以一行都不改(看学生会不会多改),出题页预览会碰到 */
const noChange = computed(
() => "changed_tables" in props.display.expected && !props.display.expected.changed_tables.length,
)
const anyChanged = computed(() =>
changedTables.value.some(
({ changes }) => changes && (changes.cells.size > 0 || changes.newRows.size > 0),
),
)
</script>
<template>
<slot name="title" text="数据表" />
<div v-for="t in display.tables" :key="t.name">
<p class="sqlTableName">{{ t.name }}</p>
<SQLDataTable
:columns="t.columns"
:rows="t.rows"
:total-rows="t.total_rows"
:truncated="t.truncated"
/>
</div>
<slot name="title" text="期望结果" />
<template v-if="expectedQuery">
<SQLDataTable
:columns="expectedQuery.columns"
:rows="expectedQuery.rows"
:total-rows="expectedQuery.total_rows"
:truncated="expectedQuery.truncated"
/>
<p v-if="!orderSensitive" class="sqlNote">结果顺序不限</p>
</template>
<div v-for="{ table, changes } in changedTables" :key="table.name">
<p class="sqlTableName">
{{ table.dropped ? `${table.name} 表已被删除` : `执行后的 ${table.name} 表` }}
</p>
<template v-if="!table.dropped">
<SQLDataTable
:columns="table.columns"
:rows="table.rows"
:total-rows="table.total_rows"
:truncated="table.truncated"
:changes="changes"
/>
<p v-if="changes?.removed.length" class="sqlNote">
删掉了 {{ changes.removed.length }} 行({{ table.columns[0]?.name }} 是
{{ changes.removed.join("、") }})
</p>
<p v-else-if="changes && !changes.cells.size && !changes.newRows.size" class="sqlNote">
执行后这张表没有变化
</p>
</template>
</div>
<p v-if="noChange" class="sqlNote">执行后所有表都没有变化</p>
<p v-if="anyChanged" class="sqlNote legend">
<span class="swatch" aria-hidden="true"></span>执行后变了的格子
</p>
</template>
<style scoped>
.sqlTableName {
font-weight: 600;
margin: 8px 0 4px;
font-family: Monaco, Consolas, monospace;
}
.sqlNote {
font-size: 13px;
opacity: 0.65;
margin: 0 0 8px;
}
.legend {
display: flex;
align-items: center;
gap: 6px;
}
.swatch {
width: 12px;
height: 12px;
border: 1px solid #f0d48a;
background-color: #fff4d6;
}
html.dark .swatch {
border-color: rgba(240, 180, 40, 0.45);
background-color: rgba(240, 180, 40, 0.18);
}
</style>
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui" import { useThemeVars } from "naive-ui"
import { HINT_MIN_FAILURES, hintLevelLabel } from "@oj2/contract" import { HINT_MIN_FAILURES, hintLevelLabel, isSqlProblem } from "@oj2/contract"
import type { JudgeCaseResult } from "@oj2/contract" import type { JudgeCaseResult } from "@oj2/contract"
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants" import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
import { import {
@@ -215,7 +215,7 @@ const submissionStore = useSubmissionStore()
const customRunHint = computed(() => { const customRunHint = computed(() => {
if (!sampleCheck.value?.passed || props.peer) return "" if (!sampleCheck.value?.passed || props.peer) return ""
const problem = problemStore.problem const problem = problemStore.problem
if (!problem || problem.sqlConfig) return "" if (!problem || isSqlProblem(problem)) return ""
// 例子里出现过的数不算:例子已经对了,拿它们再试一遍没用 // 例子里出现过的数不算:例子已经对了,拿它们再试一遍没用
const inSamples = new Set( const inSamples = new Set(
problem.samples.flatMap( problem.samples.flatMap(
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { isSqlProblem } from "@oj2/contract"
import { storeToRefs } from "pinia" import { storeToRefs } from "pinia"
import { useThemeVars } from "naive-ui" import { useThemeVars } from "naive-ui"
import { useProblemStore } from "oj/store/problem" import { useProblemStore } from "oj/store/problem"
@@ -41,7 +42,7 @@ const { problem } = storeToRefs(useProblemStore())
* 例子都对了、错在隐藏测试点 —— 摆「描述」,边界条件多半写在那里。SQL 题两节都不摆 * 例子都对了、错在隐藏测试点 —— 摆「描述」,边界条件多半写在那里。SQL 题两节都不摆
*/ */
const requirement = computed(() => { const requirement = computed(() => {
if (!props.full || !problem.value || problem.value.sqlConfig) return null if (!props.full || !problem.value || isSqlProblem(problem.value)) return null
const source = props.check.passed ? problem.value.description : problem.value.outputDescription const source = props.check.passed ? problem.value.description : problem.value.outputDescription
const text = markdownToText(source ?? "", props.check.passed ? 160 : 120) const text = markdownToText(source ?? "", props.check.passed ? 160 : 120)
if (!text) return null if (!text) return null
@@ -0,0 +1,71 @@
import type { SqlDisplayTable } from "utils/types"
/** 增删改题:执行后的表比原来变了哪些。行号、列号都是执行后那张表里的 */
export interface TableChanges {
/** 变了的格子,`行:列` */
cells: Set<string>
/** 整行是新插进来的 */
newRows: Set<number>
/** 删掉的行,记第一列的值(一般是 id) */
removed: (string | number)[]
}
const sameColumns = (a: SqlDisplayTable, b: SqlDisplayTable) =>
a.columns.length === b.columns.length && a.columns.every((c, i) => c.name === b.columns[i]!.name)
/** 第一列能当行的身份用:没有空值、不重复 */
const keyed = (rows: SqlDisplayTable["rows"]) =>
rows.every((row) => row[0] !== null && row[0] !== undefined) &&
new Set(rows.map((row) => row[0])).size === rows.length
/**
* 对比执行前后的同一张表。对不上就返回 null(不标,宁可不标也不标错):
* - 列变了(ALTER TABLE)
* - 第一列不能当身份、行数又不一样(删、插都有,没法知道哪行对哪行)
*
* 第一列能当身份时按它配对 —— 题目里的表几乎都是 id 打头,删掉中间一行,后面的行
* 不会因为错位全被标成「变了」。删掉的行只在两边都没截断时才数,截断时看不全
*/
export function tableChanges(
before: SqlDisplayTable | undefined,
after: SqlDisplayTable,
): TableChanges | null {
if (after.dropped) return null
const cells = new Set<string>()
const newRows = new Set<number>()
// 原来没有这张表:标准答案新建的,整张都是新的
if (!before) {
after.rows.forEach((_, i) => newRows.add(i))
return { cells, newRows, removed: [] }
}
if (!sameColumns(before, after)) return null
const diffRow = (i: number, row: SqlDisplayTable["rows"][number], old: typeof row) =>
row.forEach((value, j) => {
if (value !== old[j]) cells.add(`${i}:${j}`)
})
if (keyed(before.rows) && keyed(after.rows)) {
const byKey = new Map(before.rows.map((row) => [row[0], row]))
after.rows.forEach((row, i) => {
const old = byKey.get(row[0])
if (old) diffRow(i, row, old)
else newRows.add(i)
})
const kept = new Set(after.rows.map((row) => row[0]))
const removed =
before.truncated || after.truncated
? []
: before.rows.filter((row) => !kept.has(row[0])).map((row) => row[0] as string | number)
return { cells, newRows, removed }
}
if (before.rows.length !== after.rows.length) return null
after.rows.forEach((row, i) => diffRow(i, row, before.rows[i]!))
return { cells, newRows, removed: [] }
}
/** 一共动了几行(改过的、新插的、删掉的),出题页的测试数据列表里用 */
export function changedRowCount(changes: TableChanges) {
const touched = new Set([...changes.cells].map((key) => key.split(":")[0]))
return touched.size + changes.newRows.size + changes.removed.length
}
+4 -4
View File
@@ -194,13 +194,13 @@ export const admins: RouteRecordRaw = {
{ {
path: "problem/create", path: "problem/create",
name: "admin problem create", name: "admin problem create",
component: () => import("admin/problem/detail.vue"), component: () => import("admin/problem/editor.vue"),
meta: { requiresProblemPermission: true }, meta: { requiresProblemPermission: true },
}, },
{ {
path: "problem/edit/:problemID", path: "problem/edit/:problemID",
name: "admin problem edit", name: "admin problem edit",
component: () => import("admin/problem/detail.vue"), component: () => import("admin/problem/editor.vue"),
props: true, props: true,
meta: { requiresProblemPermission: true }, meta: { requiresProblemPermission: true },
}, },
@@ -233,14 +233,14 @@ export const admins: RouteRecordRaw = {
{ {
path: "contest/:contestID/problem/create", path: "contest/:contestID/problem/create",
name: "admin contest problem create", name: "admin contest problem create",
component: () => import("admin/problem/detail.vue"), component: () => import("admin/problem/editor.vue"),
props: true, props: true,
meta: { requiresTeacherAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{ {
path: "contest/:contestID/problem/edit/:problemID", path: "contest/:contestID/problem/edit/:problemID",
name: "admin contest problem edit", name: "admin contest problem edit",
component: () => import("admin/problem/detail.vue"), component: () => import("admin/problem/editor.vue"),
props: true, props: true,
meta: { requiresTeacherAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
+1
View File
@@ -152,6 +152,7 @@ export const STORAGE_KEY = {
LANGUAGE: "problemLanguage", LANGUAGE: "problemLanguage",
LEARN_CURRENT_STEP: "learnStep", LEARN_CURRENT_STEP: "learnStep",
ADMIN_PROBLEM: "adminProblem", ADMIN_PROBLEM: "adminProblem",
ADMIN_SQL_PROBLEM: "adminSqlProblem",
ADMIN_PROBLEM_TAGS: "adminProblemTags", ADMIN_PROBLEM_TAGS: "adminProblemTags",
ADMIN_SIDER_COLLAPSED: "adminSiderCollapsed", ADMIN_SIDER_COLLAPSED: "adminSiderCollapsed",
DEMO_MODE: "demoMode", DEMO_MODE: "demoMode",
+1
View File
@@ -181,6 +181,7 @@ export interface AdminProblemRow {
createTime: string createTime: string
difficulty: ProblemDifficulty difficulty: ProblemDifficulty
tags: string[] tags: string[]
isSql: boolean
hasAstRules: boolean hasAstRules: boolean
allowFlowchart: boolean allowFlowchart: boolean
showFlowchart: boolean showFlowchart: boolean
+7
View File
@@ -633,6 +633,8 @@ export const adminProblemListItemSchema = z.object({
createTime: z.string(), createTime: z.string(),
difficulty: problemDifficultySchema, difficulty: problemDifficultySchema,
tags: z.array(z.string()), tags: z.array(z.string()),
/** SQL 题:列表里标题后面挂标签;编辑时进 SQL 出题页 */
isSql: z.boolean(),
hasAstRules: z.boolean(), hasAstRules: z.boolean(),
allowFlowchart: z.boolean(), allowFlowchart: z.boolean(),
showFlowchart: z.boolean(), showFlowchart: z.boolean(),
@@ -817,6 +819,11 @@ export const sqlPreviewRequestSchema = z.object({
.min(1) .min(1)
.max(1024 * 1024), .max(1024 * 1024),
mode: z.enum(["query", "modify"]), mode: z.enum(["query", "modify"]),
/**
* 这组是不是题面上给学生看的那组(第 1 组)。增删改题只有这一组必须改到行 ——
* 其余组可以一行都不改,用来看学生会不会多改(比如 WHERE 写漏了)
*/
shown: z.boolean().default(true),
}) })
export const generateSqlTestCaseRequestSchema = z.object({ export const generateSqlTestCaseRequestSchema = z.object({
+8
View File
@@ -49,6 +49,14 @@ export const runnableLanguageSchema = judgeLanguageSchema.extract(["C", "C++", "
*/ */
export const lessonLanguageSchema = problemLanguageSchema.extract(["Python", "C", "C++", "SQL"]) export const lessonLanguageSchema = problemLanguageSchema.extract(["Python", "C", "C++", "SQL"])
/**
* SQL 题:可选语言里有 SQL(后端保存时强校验 SQL 只能单独出现)。题型只认这一条,
* 前后端一律走它,别再各自看 sqlConfig 或 languages
*/
export function isSqlProblem(problem: { languages: readonly string[] }) {
return problem.languages.includes("SQL")
}
export type JudgeLanguage = z.infer<typeof judgeLanguageSchema> export type JudgeLanguage = z.infer<typeof judgeLanguageSchema>
export type LessonLanguage = z.infer<typeof lessonLanguageSchema> export type LessonLanguage = z.infer<typeof lessonLanguageSchema>
export type RunnableLanguage = z.infer<typeof runnableLanguageSchema> export type RunnableLanguage = z.infer<typeof runnableLanguageSchema>