Compare commits

...
3 Commits
Author SHA1 Message Date
xuyueandClaude Fable 5 ad28c0ba21 fix: sqlTableName 字体 fallback
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 06:26:00 -06:00
xuyueandClaude Fable 5 0b627b5157 feat: 管理端 SQL 题隐藏输入输出描述与样例表单
- 隐藏 SQL 题的输入/输出描述编辑器
- 隐藏 SQL 题的样例编辑区域
- 跳过 SQL 题的输入输出和样例验证
- SQL 题提交时清空样例数组

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 01:31:08 -06:00
xuyueandClaude Fable 5 aa212427ba feat: SQL 题展示数据表与期望结果
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 01:26:16 -06:00
4 changed files with 278 additions and 124 deletions
+44 -35
View File
@@ -344,19 +344,20 @@ async function validateProblem() {
// 题目 // 题目
else if ( else if (
!problem.value.description || !problem.value.description ||
!problem.value.input_description || (!isSQLProblem.value &&
!problem.value.output_description (!problem.value.input_description || !problem.value.output_description))
) { ) {
message.error("题目或输入或输出没有填写") message.error("题目或输入或输出没有填写")
hasErrors = true hasErrors = true
} }
// 样例 // 样例
else if (problem.value.samples.length == 0) { else if (!isSQLProblem.value && problem.value.samples.length == 0) {
message.error("样例没有填写") message.error("样例没有填写")
hasErrors = true hasErrors = true
} }
// 样例是空的 // 样例是空的
else if ( else if (
!isSQLProblem.value &&
problem.value.samples.some( problem.value.samples.some(
(sample) => sample.output === "" || sample.input === "", (sample) => sample.output === "" || sample.input === "",
) )
@@ -432,12 +433,18 @@ function filterAnswers() {
) )
} }
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,
@@ -592,45 +599,47 @@ watch(
:min-height="300" :min-height="300"
/> />
<TextEditor <TextEditor
v-if="ready" v-if="ready && !isSQLProblem"
v-model:value="problem.input_description" v-model:value="problem.input_description"
title="输入的描述" title="输入的描述"
/> />
<TextEditor <TextEditor
v-if="ready" v-if="ready && !isSQLProblem"
v-model:value="problem.output_description" v-model:value="problem.output_description"
title="输出的描述" title="输出的描述"
/> />
<div class="box" v-for="(sample, index) in problem.samples" :key="index"> <template v-if="!isSQLProblem">
<n-flex justify="space-between" align="center"> <div class="box" v-for="(sample, index) in problem.samples" :key="index">
<strong>测试样例 {{ index + 1 }}</strong> <n-flex justify="space-between" align="center">
<n-button <strong>测试样例 {{ index + 1 }}</strong>
tertiary <n-button
type="warning" tertiary
size="small" type="warning"
@click="removeSample(index)" size="small"
> @click="removeSample(index)"
删除 {{ index + 1 }} >
</n-button> 删除 {{ index + 1 }}
</n-flex> </n-button>
<n-grid x-gap="20" cols="2"> </n-flex>
<n-gi span="1"> <n-grid x-gap="20" cols="2">
<n-flex vertical> <n-gi span="1">
<span>输入样例</span> <n-flex vertical>
<n-input type="textarea" v-model:value="sample.input" /> <span>输入样例</span>
</n-flex> <n-input type="textarea" v-model:value="sample.input" />
</n-gi> </n-flex>
<n-gi span="1"> </n-gi>
<n-flex vertical> <n-gi span="1">
<span>输出样例</span> <n-flex vertical>
<n-input type="textarea" v-model:value="sample.output" /> <span>输出样例</span>
</n-flex> <n-input type="textarea" v-model:value="sample.output" />
</n-gi> </n-flex>
</n-grid> </n-gi>
</div> </n-grid>
<n-button class="addSamples box" tertiary type="primary" @click="addSample"> </div>
添加用例 <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="题目的来源(选填)">
+142 -65
View File
@@ -12,6 +12,7 @@ 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 { getSimilarProblems } from "oj/api" import { getSimilarProblems } from "oj/api"
import SQLDataTable from "./SQLDataTable.vue"
type Sample = Problem["samples"][number] & { type Sample = Problem["samples"][number] & {
id: number id: number
@@ -30,6 +31,18 @@ const { problem } = storeToRefs(problemStore)
const problemSetId = computed(() => route.params.problemSetId) const problemSetId = computed(() => route.params.problemSetId)
// SQL 题:隐藏输入/输出/例子,改为渲染数据表与期望结果
const isSQL = computed(() => !!problem.value?.sql_config)
const sqlDisplay = computed(() => problem.value?.sql_display ?? 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 router = useRouter() const router = useRouter()
// 相似题目推荐 // 相似题目推荐
@@ -269,29 +282,79 @@ function type(status: ProblemStatus) {
:theme="isDark ? 'dark' : 'light'" :theme="isDark ? 'dark' : 'light'"
/> />
<p class="title" :style="style"> <template v-if="!isSQL">
<n-flex align="center"> <p class="title" :style="style">
<Icon icon="streamline-ultimate-color:envelope-back-front"></Icon> <n-flex align="center">
输入 <Icon icon="streamline-ultimate-color:envelope-back-front"></Icon>
</n-flex> 输入
</p> </n-flex>
<MdPreview </p>
preview-theme="vuepress" <MdPreview
:model-value="problem.input_description" preview-theme="vuepress"
:theme="isDark ? 'dark' : 'light'" :model-value="problem.input_description"
/> :theme="isDark ? 'dark' : 'light'"
/>
<p class="title" :style="style"> <p class="title" :style="style">
<n-flex align="center"> <n-flex align="center">
<Icon icon="streamline-ultimate-color:mailbox-post"></Icon> <Icon icon="streamline-ultimate-color:mailbox-post"></Icon>
输出 输出
</n-flex> </n-flex>
</p> </p>
<MdPreview <MdPreview
preview-theme="vuepress" preview-theme="vuepress"
:model-value="problem.output_description" :model-value="problem.output_description"
:theme="isDark ? 'dark' : 'light'" :theme="isDark ? 'dark' : 'light'"
/> />
</template>
<template v-if="isSQL && sqlDisplay">
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="devicon:sqlite"></Icon>
数据表
</n-flex>
</p>
<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>
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-ultimate-color:check-button"></Icon>
期望结果
</n-flex>
</p>
<template v-if="sqlExpectedQuery">
<SQLDataTable
:columns="sqlExpectedQuery.columns.map((name) => ({ name }))"
:rows="sqlExpectedQuery.rows"
:total-rows="sqlExpectedQuery.total_rows"
:truncated="sqlExpectedQuery.truncated"
/>
<p v-if="!problem.sql_config?.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>
<div v-if="problem.hint"> <div v-if="problem.hint">
<p class="title" :style="style"> <p class="title" :style="style">
@@ -334,50 +397,52 @@ function type(status: ProblemStatus) {
</div> </div>
</div> </div>
<div v-for="(sample, index) of samples" :key="index"> <template v-if="!isSQL">
<n-flex align="center"> <div v-for="(sample, index) of samples" :key="index">
<p class="title" :style="style"> <n-flex align="center">
<n-flex align="center"> <p class="title" :style="style">
<Icon icon="streamline-emojis:microscope"></Icon> <n-flex align="center">
例子 {{ index + 1 }} <Icon icon="streamline-emojis:microscope"></Icon>
</n-flex> 例子 {{ index + 1 }}
</p> </n-flex>
<n-button </p>
size="small" <n-button
:type="type(sample.status)" size="small"
@click="test(sample, index)" :type="type(sample.status)"
@click="test(sample, index)"
>
{{ label(sample.status, sample.loading) }}
</n-button>
</n-flex>
<n-descriptions
bordered
:column="2"
label-style="width: 50%; min-width: 100px"
> >
{{ label(sample.status, sample.loading) }} <n-descriptions-item>
</n-button> <template #label>
</n-flex> <n-flex>
<n-descriptions <span>输入</span>
bordered <Copy :value="sample.input" />
:column="2" </n-flex>
label-style="width: 50%; min-width: 100px" </template>
> <div class="testcase">{{ sample.input }}</div>
<n-descriptions-item> </n-descriptions-item>
<template #label> <n-descriptions-item>
<n-flex> <template #label>
<span>输入</span> <n-flex>
<Copy :value="sample.input" /> <span>输出</span>
</n-flex> <Copy :value="sample.output" />
</template> </n-flex>
<div class="testcase">{{ sample.input }}</div> </template>
</n-descriptions-item> <div class="testcase">{{ sample.output }}</div>
<n-descriptions-item> </n-descriptions-item>
<template #label> <n-descriptions-item label="运行结果" v-if="sample.msg">
<n-flex> <div class="testcase">{{ sample.msg }}</div>
<span>输出</span> </n-descriptions-item>
<Copy :value="sample.output" /> </n-descriptions>
</n-flex> </div>
</template> </template>
<div class="testcase">{{ sample.output }}</div>
</n-descriptions-item>
<n-descriptions-item label="运行结果" v-if="sample.msg">
<div class="testcase">{{ sample.msg }}</div>
</n-descriptions-item>
</n-descriptions>
</div>
<div v-if="problem.source"> <div v-if="problem.source">
<p class="title" :style="style"> <p class="title" :style="style">
@@ -470,4 +535,16 @@ function type(status: ProblemStatus) {
font-size: 13px; font-size: 13px;
opacity: 0.65; opacity: 0.65;
} }
.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>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import type { SQLDisplayColumn } from "utils/types"
defineProps<{
columns: SQLDisplayColumn[]
rows: (string | number | null)[][]
totalRows?: number
truncated?: boolean
}>()
</script>
<template>
<n-table class="sqlTable" size="small" :single-line="false">
<thead>
<tr>
<th v-for="(col, i) in columns" :key="i">
{{ col.name }}
<span v-if="col.type" class="colType">{{ col.type }}</span>
</th>
</tr>
</thead>
<tbody>
<tr v-if="rows.length === 0">
<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 }">
{{ v === null ? "NULL" : v }}
</td>
</tr>
</tbody>
</n-table>
<p v-if="truncated" class="truncNote">
{{ totalRows }} 仅展示前 {{ rows.length }}
</p>
</template>
<style scoped>
.sqlTable {
margin-bottom: 8px;
}
.colType {
font-size: 12px;
opacity: 0.55;
margin-left: 4px;
font-weight: normal;
}
.nullCell {
opacity: 0.45;
font-style: italic;
}
.truncNote {
font-size: 13px;
opacity: 0.65;
margin: 0 0 8px;
}
</style>
+32 -24
View File
@@ -34,10 +34,7 @@ export interface Profile {
} }
export type UserAdminType = export type UserAdminType =
| "Regular User" "Regular User" | "Student Admin" | "Teacher Admin" | "Super Admin"
| "Student Admin"
| "Teacher Admin"
| "Super Admin"
export interface User { export interface User {
id: number id: number
@@ -72,23 +69,37 @@ export interface SQLConfig {
order_sensitive: boolean order_sensitive: boolean
} }
export interface SQLDisplayColumn {
name: string
type?: string
}
export interface SQLDisplayTable {
name: string
columns: SQLDisplayColumn[]
rows: (string | number | null)[][]
total_rows: number
truncated: boolean
dropped?: boolean
}
export interface SQLDisplay {
tables: SQLDisplayTable[]
expected:
| {
columns: string[]
rows: (string | number | null)[][]
total_rows: number
truncated: boolean
}
| { changed_tables: SQLDisplayTable[] }
}
export type LANGUAGE_SHOW_LABEL = export type LANGUAGE_SHOW_LABEL =
(typeof LANGUAGE_SHOW_VALUE)[keyof typeof LANGUAGE_SHOW_VALUE] (typeof LANGUAGE_SHOW_VALUE)[keyof typeof LANGUAGE_SHOW_VALUE]
export type SUBMISSION_RESULT = export type SUBMISSION_RESULT =
| -2 -2 | -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10
| -1
| 0
| 1
| 2
| 3
| 4
| 5
| 6
| 7
| 8
| 9
| 10
export type ProblemStatus = "passed" | "failed" | "not_test" export type ProblemStatus = "passed" | "failed" | "not_test"
@@ -173,6 +184,9 @@ export interface Problem {
// SQL 题配置(非 SQL 题为 null // SQL 题配置(非 SQL 题为 null
sql_config?: SQLConfig | null sql_config?: SQLConfig | null
// SQL 题展示数据(后端保存题目时自动生成)
sql_display?: SQLDisplay | null
} }
export type AdminProblem = Problem & AlterProblem export type AdminProblem = Problem & AlterProblem
@@ -664,13 +678,7 @@ export interface ExerciseGroupData {
} }
export type ExerciseType = export type ExerciseType =
| "mcq" "mcq" | "sort" | "fill" | "match" | "predict" | "debug" | "group"
| "sort"
| "fill"
| "match"
| "predict"
| "debug"
| "group"
export interface Exercise { export interface Exercise {
id: number id: number