feat(SQL 题): 增删改题的期望结果标出执行后变了的格子,删掉的行写清是哪几行

学生原来只能拿两张表逐格对,看不出要改的是哪几行。现在改过的格子、新插的行标黄,
删掉的行在表下面写「删掉了 2 行(id 是 2、3)」。按第一列配对,对不上就不标。
数据表和期望结果抽成一个组件,后台出题页的预览也用它。
题型判断统一走契约的 isSqlProblem()。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-09 18:59:15 +08:00
1 parent e01dabc6da
commit 25d3296410
12 files changed
+247 -421

No files matched your search

@@ -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>
<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>
<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>
</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
}