Compare commits
3
Commits
527127d9c7
...
be1b9a97a0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be1b9a97a0 | ||
|
|
02c523b32f | ||
|
|
f6d4d9bcb2 |
@@ -0,0 +1,28 @@
|
|||||||
|
# SQL 题强制至少 2 个测试点 — 设计
|
||||||
|
|
||||||
|
日期:2026-07-05
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
题目页的 `sql_display` 用**测试点 1** 的数据生成期望结果展示(截断到 20 行)。如果 SQL 题只有 1 个测试点且结果 ≤20 行,页面展示的期望结果就是完整答案输出,学生可用 `SELECT ... UNION ALL ...`(query 模式)或硬编码 INSERT/UPDATE(modify 模式)对照抄写直接 AC。多测试点时数据不同,硬编码只能过测试点 1。
|
||||||
|
|
||||||
|
## 决定
|
||||||
|
|
||||||
|
SQL 题**强制至少 2 个数据不同的初始化脚本**,前后端双重拦截:
|
||||||
|
|
||||||
|
- **前端** `ojnext/src/admin/problem/components/SQLTestcaseEditor.vue`:
|
||||||
|
- `canUpload` 要求非空脚本数 ≥ 2,不满足时上传按钮禁用;
|
||||||
|
- 上传按钮 tooltip 在脚本不足时显示原因("SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果")。
|
||||||
|
- **后端** `OnlineJudge/problem/views/admin.py` 的 `TestCaseZipProcessor.process_zip`:
|
||||||
|
- `sql=True` 且测试点数 < 2 时 `raise APIError(...)`,兜底直接调 API 的情况。
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
- 只在**重新上传/保存测试点**时拦截,已有的单测试点老题目不受影响、不回溯校验。
|
||||||
|
- 非 SQL 题(.in/.out 沙箱判题)不受影响。
|
||||||
|
- "数据不同"不做内容级校验(两个脚本内容相同也能过),只保证数量下限,YAGNI。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
前端 `vue-tsc --noEmit`、Prettier 通过;后端 `ruff check` / `ruff format --check` 通过。
|
||||||
|
人工验证:出题页只填 1 个脚本 → 上传按钮禁用且 tooltip 说明原因;填 2 个并预览通过 → 可上传。
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# SQL 题目表名/字段名自动补全 — 设计
|
||||||
|
|
||||||
|
日期:2026-07-05
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
学生在 SQL 题目的代码编辑器里输入时,自动补全列表中除现有的 SQL 关键字/函数外,还出现**当前题目的表名和字段名**(带类型提示),减少抄写表名字段名的负担和拼写错误。
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
- SQL 题目详情页已下发 `problem.sql_display`(`SQLDisplay` 类型),其中 `tables: SQLDisplayTable[]` 包含每张表的 `name`、`columns[{name, type}]`。数据在前端齐全,**无需后端改动**。
|
||||||
|
- 编辑器补全入口是 `shared/extensions/autocompletion.ts` 的 `enhanceCompletion(language)`,`CodeEditor.vue` 和 `SyncCodeEditor.vue` 都用它,且都叠加了 `completeAnyWord`。
|
||||||
|
- SQL 静态关键字补全表在 `shared/extensions/sql.ts`。
|
||||||
|
- `shared` 直接 import `oj/store/problem` 已有先例(`FlowchartEditor/index.vue`)。
|
||||||
|
|
||||||
|
## 方案(已选:方案 A)
|
||||||
|
|
||||||
|
在 `enhanceCompletion` 中,当 `language === "SQL"` 时,从 `useProblemStore().problem?.sql_display?.tables` 动态生成补全项,追加到静态关键字列表后:
|
||||||
|
|
||||||
|
- **表名**:`type: "class"`,`detail: "数据表"`,`info` 列出该表全部字段(如 `字段:id INTEGER, name TEXT, score REAL`),`boost` 高于所有关键字(如 110)。
|
||||||
|
- **字段名**:`type: "property"`,`detail` 标注来源表和类型(如 `students 的字段 · TEXT`),`boost` 略低于表名、高于关键字(如 105)。
|
||||||
|
- **同名字段每表一条**,靠 detail 区分来源表。
|
||||||
|
- store 在补全回调内惰性读取(每次按键执行),题目切换后自动反映最新表结构。
|
||||||
|
- 非 SQL 语言、无题目上下文(如 admin/tutorial/learn 页面)或 `sql_display` 为空时,不追加任何项,行为与现状一致。
|
||||||
|
|
||||||
|
### 不做的事(YAGNI)
|
||||||
|
|
||||||
|
- 不改后端;不改题目描述展示(`SQLDataTable` 已展示表结构)。
|
||||||
|
- 管理端出题的 SQL 编辑器(`SQLTestcaseEditor`)不接入。
|
||||||
|
- 不做基于 SQL 语法位置的智能上下文补全(如 FROM 后只补表名)。
|
||||||
|
|
||||||
|
## 改动文件
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `src/shared/extensions/autocompletion.ts` | SQL 分支追加由 `sql_display.tables` 生成的动态补全项 |
|
||||||
|
|
||||||
|
(如生成逻辑较长,可拆一个小函数放同文件或 `sql.ts`,保持单一职责。)
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
- `problem`、`sql_display`、`tables` 任一为空 → 返回纯静态列表(可选链兜底)。
|
||||||
|
- Pinia store 在组件上下文外调用的风险:补全回调在编辑器运行期触发,此时 Pinia 已安装;与 FlowchartEditor 的既有用法一致。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
项目无测试套件(政策:不写新测试)。人工验证:
|
||||||
|
1. 打开一道 SQL 题,编辑器中输入表名/字段名前缀,确认补全项出现且 detail/info 正确。
|
||||||
|
2. 打开非 SQL 题,确认补全行为无变化。
|
||||||
|
3. 协作编辑(SyncCodeEditor)场景同样生效。
|
||||||
@@ -51,11 +51,16 @@ const isGenerating = ref(false)
|
|||||||
const hasAnyScript = computed(() => scripts.value.some((s) => s.sql.trim()))
|
const hasAnyScript = computed(() => scripts.value.some((s) => s.sql.trim()))
|
||||||
const hasBlankScript = 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 canUpload = computed(() => {
|
||||||
const filled = scripts.value.filter((s) => s.sql.trim())
|
const filled = scripts.value.filter((s) => s.sql.trim())
|
||||||
return (
|
return (
|
||||||
!isPreviewing.value &&
|
!isPreviewing.value &&
|
||||||
filled.length > 0 &&
|
// 至少 2 个数据不同的测试点,防止学生对照题目页的期望结果硬编码
|
||||||
|
filled.length >= 2 &&
|
||||||
filled.every((s) => s.display && !s.error && !s.stale)
|
filled.every((s) => s.display && !s.error && !s.stale)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -241,7 +246,11 @@ async function upload() {
|
|||||||
</n-button>
|
</n-button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
所有脚本预览验证通过后才能上传
|
{{
|
||||||
|
filledCount < 2
|
||||||
|
? "SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果"
|
||||||
|
: "所有脚本预览验证通过后才能上传"
|
||||||
|
}}
|
||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
CompletionSource,
|
CompletionSource,
|
||||||
} from "@codemirror/autocomplete"
|
} from "@codemirror/autocomplete"
|
||||||
import type { EditorView } from "@codemirror/view"
|
import type { EditorView } from "@codemirror/view"
|
||||||
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import type { LANGUAGE } from "utils/types"
|
import type { LANGUAGE } from "utils/types"
|
||||||
import { c } from "./c"
|
import { c } from "./c"
|
||||||
import { python } from "./python"
|
import { python } from "./python"
|
||||||
@@ -22,6 +23,30 @@ const chineseAnnotations: Record<string, ChineseCompletion[]> = {
|
|||||||
sql,
|
sql,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SQL 题:当前题目的表名和字段名补全,数据来自 sql_display
|
||||||
|
function sqlSchemaCompletions(): Completion[] {
|
||||||
|
const tables = useProblemStore().problem?.sql_display?.tables ?? []
|
||||||
|
return tables.flatMap((table) => [
|
||||||
|
{
|
||||||
|
label: table.name,
|
||||||
|
detail: "数据表",
|
||||||
|
type: "class",
|
||||||
|
info: `字段:${table.columns
|
||||||
|
.map((col) => (col.type ? `${col.name} ${col.type}` : col.name))
|
||||||
|
.join(", ")}`,
|
||||||
|
boost: 110,
|
||||||
|
},
|
||||||
|
...table.columns.map((col) => ({
|
||||||
|
label: col.name,
|
||||||
|
detail: col.type
|
||||||
|
? `${table.name} 的字段 · ${col.type}`
|
||||||
|
: `${table.name} 的字段`,
|
||||||
|
type: "property",
|
||||||
|
boost: 105,
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
export function enhanceCompletion(language: LANGUAGE): CompletionSource {
|
export function enhanceCompletion(language: LANGUAGE): CompletionSource {
|
||||||
return async function (
|
return async function (
|
||||||
context: CompletionContext,
|
context: CompletionContext,
|
||||||
@@ -69,6 +94,10 @@ export function enhanceCompletion(language: LANGUAGE): CompletionSource {
|
|||||||
return completion
|
return completion
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (trulyLanguage === "sql") {
|
||||||
|
completions.push(...sqlSchemaCompletions())
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
from: word ? word.from : context.pos,
|
from: word ? word.from : context.pos,
|
||||||
options: completions,
|
options: completions,
|
||||||
|
|||||||
Reference in New Issue
Block a user