feat(SQL 题): 增删改题的期望结果标出执行后变了的格子,删掉的行写清是哪几行
学生原来只能拿两张表逐格对,看不出要改的是哪几行。现在改过的格子、新插的行标黄, 删掉的行在表下面写「删掉了 2 行(id 是 2、3)」。按第一列配对,对不上就不标。 数据表和期望结果抽成一个组件,后台出题页的预览也用它。 题型判断统一走契约的 isSqlProblem()。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e01dabc6da
commit
25d3296410
12 files changed
+247
-421
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
isSqlProblem,
|
||||
classComparisonRequestSchema,
|
||||
STUDENT_ROLES,
|
||||
classLessonRequestSchema,
|
||||
@@ -929,7 +930,7 @@ classroomRoutes.put("/classroom/lesson", requireTeacher, async (c) => {
|
||||
// SQL 题除外:它只收 SQL,C 的作业里夹一道也不会让人选错语言,计数那边也放行了 SQL
|
||||
if (language) {
|
||||
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)
|
||||
if (unsupported.length) {
|
||||
return failure(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
isSqlProblem,
|
||||
contestPasswordRequestSchema,
|
||||
STUDENT_ROLES,
|
||||
type Contest,
|
||||
@@ -244,7 +245,7 @@ contestRoutes.get(
|
||||
allowFlowchart: problem.allowFlowchart,
|
||||
showFlowchart: problem.showFlowchart,
|
||||
hasAstRules: problem.astRules !== null,
|
||||
isSql: problem.languages.includes("SQL"),
|
||||
isSql: isSqlProblem(problem),
|
||||
myStatus: myStatusOf(statuses, problem.id),
|
||||
// 按人数是公开题库列表的列;比赛题表看的是榜单,不下发
|
||||
solvedUsers: 0,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
isSqlProblem,
|
||||
normalizeLanguage,
|
||||
problemListSortSchema,
|
||||
problemTypeFilterSchema,
|
||||
@@ -121,7 +122,7 @@ function listItem(
|
||||
allowFlowchart: row.problem.allowFlowchart,
|
||||
showFlowchart: row.problem.showFlowchart,
|
||||
hasAstRules: row.problem.astRules !== null,
|
||||
isSql: row.problem.languages.includes("SQL"),
|
||||
isSql: isSqlProblem(row.problem),
|
||||
// 流程图评到 A / S 也打勾:problemStates 已经把它算进 solved
|
||||
myStatus: states?.solved.has(id)
|
||||
? JudgeStatus.ACCEPTED
|
||||
|
||||
@@ -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>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { isSqlProblem } from "@oj2/contract"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { storeToRefs } from "pinia"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
@@ -8,7 +9,7 @@ import { useSubmissionStore } from "oj/store/submission"
|
||||
import { useDark } from "@vueuse/core"
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
import SQLDataTable from "./SQLDataTable.vue"
|
||||
import SqlProblemData from "./SqlProblemData.vue"
|
||||
import SimilarProblems from "./SimilarProblems.vue"
|
||||
import { isFlowchartPass, useFlowchartStore } from "oj/store/flowchart"
|
||||
import { useMyFlowchartStore } from "shared/store/myFlowchart"
|
||||
@@ -25,16 +26,7 @@ const { problem } = storeToRefs(problemStore)
|
||||
const ctx = useProblemPageContext()
|
||||
|
||||
// SQL 题:隐藏输入/输出/例子,改为渲染数据表与期望结果
|
||||
const isSQL = computed(() => !!problem.value?.sqlConfig)
|
||||
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 : []
|
||||
})
|
||||
const isSQL = computed(() => !!problem.value && isSqlProblem(problem.value))
|
||||
|
||||
// 相似题推荐:做对了、或者错了 3 次以上才给(比赛、题单里不给,见 problemPageContext)
|
||||
const showSimilar = computed(
|
||||
@@ -317,41 +309,15 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-if="isSQL && sqlDisplay">
|
||||
<h3 class="title sql-title">数据表</h3>
|
||||
<div v-for="t in sqlDisplay.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>
|
||||
|
||||
<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>
|
||||
<SqlProblemData
|
||||
v-if="isSQL && problem.sqlDisplay"
|
||||
:display="problem.sqlDisplay"
|
||||
:order-sensitive="!!problem.sqlConfig?.order_sensitive"
|
||||
>
|
||||
<template #title="{ text }">
|
||||
<h3 class="title sql-title">{{ text }}</h3>
|
||||
</template>
|
||||
</SqlProblemData>
|
||||
|
||||
<section v-if="problem.hint" class="block">
|
||||
<h3 class="title">提示</h3>
|
||||
@@ -603,16 +569,4 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
|
||||
.sql-title {
|
||||
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>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { isSqlProblem } from "@oj2/contract"
|
||||
import { storeToRefs } from "pinia"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
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")
|
||||
|
||||
// 试跑走判题机,SQL 题不走那里;流程图也没得跑。这两种只留「提交结果」
|
||||
const canTrial = computed(() => {
|
||||
const languages = problem.value?.languages ?? []
|
||||
return !languages.includes("SQL")
|
||||
})
|
||||
const canTrial = computed(() => !problem.value || !isSqlProblem(problem.value))
|
||||
|
||||
const segments = computed(() => {
|
||||
const list = canTrial.value ? codeSegments.value : codeSegments.value.slice(0, 1)
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { SqlDisplayColumn } from "utils/types"
|
||||
import type { TableChanges } from "../utils/sqlChanges"
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
columns: SqlDisplayColumn[]
|
||||
rows: (string | number | null)[][]
|
||||
totalRows?: number
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -29,7 +35,11 @@ defineProps<{
|
||||
<td :colspan="columns.length" class="nullCell">(空表)</td>
|
||||
</tr>
|
||||
<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 }}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -63,6 +73,18 @@ defineProps<{
|
||||
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 {
|
||||
opacity: 0.45;
|
||||
font-style: italic;
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<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 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="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">
|
||||
import { Icon } from "@iconify/vue"
|
||||
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 { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
|
||||
import {
|
||||
@@ -215,7 +215,7 @@ const submissionStore = useSubmissionStore()
|
||||
const customRunHint = computed(() => {
|
||||
if (!sampleCheck.value?.passed || props.peer) return ""
|
||||
const problem = problemStore.problem
|
||||
if (!problem || problem.sqlConfig) return ""
|
||||
if (!problem || isSqlProblem(problem)) return ""
|
||||
// 例子里出现过的数不算:例子已经对了,拿它们再试一遍没用
|
||||
const inSamples = new Set(
|
||||
problem.samples.flatMap(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { isSqlProblem } from "@oj2/contract"
|
||||
import { storeToRefs } from "pinia"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
@@ -41,7 +42,7 @@ const { problem } = storeToRefs(useProblemStore())
|
||||
* 例子都对了、错在隐藏测试点 —— 摆「描述」,边界条件多半写在那里。SQL 题两节都不摆
|
||||
*/
|
||||
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 text = markdownToText(source ?? "", props.check.passed ? 160 : 120)
|
||||
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
|
||||
}
|
||||
@@ -49,6 +49,14 @@ export const runnableLanguageSchema = judgeLanguageSchema.extract(["C", "C++", "
|
||||
*/
|
||||
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 LessonLanguage = z.infer<typeof lessonLanguageSchema>
|
||||
export type RunnableLanguage = z.infer<typeof runnableLanguageSchema>
|
||||
|
||||
Reference in new issue
Block a user