Compare commits
3
Commits
e01dabc6da
...
033a245abb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
033a245abb | ||
|
|
2cb39fd96b | ||
|
|
25d3296410 |
No files matched your search
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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} 行 → 一行都没改`
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { isSqlProblem } from "@oj2/contract"
|
||||||
|
import type { AdminProblem } from "utils/types"
|
||||||
|
import { getProblem } from "../api"
|
||||||
|
import ProblemDetail from "./detail.vue"
|
||||||
|
import SqlDetail from "./SqlDetail.vue"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 出题 / 改题的入口:编程题和 SQL 题是两张不同的页。
|
||||||
|
* 新建看地址上的 `?type=sql`(后台题目列表「新建题目」下拉里选的),
|
||||||
|
* 编辑看题目本身 —— 题型建好之后不能改,所以没有「切换题型」这回事。
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
problemID?: string
|
||||||
|
contestID?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const loaded = shallowRef<AdminProblem | null>(null)
|
||||||
|
const kind = ref<"code" | "sql" | null>(null)
|
||||||
|
|
||||||
|
async function resolve() {
|
||||||
|
kind.value = null
|
||||||
|
loaded.value = null
|
||||||
|
if (!props.problemID) {
|
||||||
|
kind.value = route.query.type === "sql" ? "sql" : "code"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
loaded.value = await getProblem(props.problemID)
|
||||||
|
kind.value = isSqlProblem(loaded.value) ? "sql" : "code"
|
||||||
|
} catch {
|
||||||
|
message.error("获取题目失败")
|
||||||
|
router.push(
|
||||||
|
props.contestID
|
||||||
|
? { name: "admin contest problem list", params: { contestID: props.contestID } }
|
||||||
|
: { name: "admin problem list" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [props.problemID, route.query.type], resolve, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SqlDetail
|
||||||
|
v-if="kind === 'sql'"
|
||||||
|
:key="`sql-${problemID ?? 'new'}`"
|
||||||
|
:problem-i-d="problemID"
|
||||||
|
:contest-i-d="contestID"
|
||||||
|
:initial="loaded"
|
||||||
|
/>
|
||||||
|
<ProblemDetail
|
||||||
|
v-else-if="kind === 'code'"
|
||||||
|
:key="`code-${problemID ?? 'new'}`"
|
||||||
|
:problem-i-d="problemID"
|
||||||
|
:contest-i-d="contestID"
|
||||||
|
:initial="loaded"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -13,7 +13,9 @@ import { useRouteQuery } from "@vueuse/router"
|
|||||||
import AuthorSelect from "shared/components/AuthorSelect.vue"
|
import 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 />
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user