Files
IOT_SIM/web/src/database/EditTab.vue
T
xuyueandClaude Opus 5.5 670abe5921
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
项目 6 数据存储:仿冒 SqlClient(T-SQL 转写成 SQLite)、数据库页面、DataGridView、DateTimePicker
- 每个学生一套数据库,长期保存(data/databases/<用户编号>/库名.db);学生程序运行时挂进沙箱可读写
- Sim.Data:SqlConnection / SqlCommand / SqlDataReader / SqlDataAdapter / SqlCommandBuilder / SqlParameter;
  连接字符串只按库名打开学生自己的库;ScriptDom 解析 T-SQL 后按白名单转写成 SQLite 语句,
  STRICT 表 + 元数据表还原 SQL Server 的列类型,报错编号和中文信息与 SQL Server 一致;
  INSERT 不写列名时跳过自增列,日期统一格式(书上 DateTime.ToString() 拼接的日期能正确比较)
- 常驻 SQL 服务(Sim.Runner sql-server):数据库页面的查询、对象资源管理器,评分前按实验 database.sql 建库
- 数据库页面(代替 SSMS):对象资源管理器、查询窗口、编辑前 200 行、表设计器、恢复实验初始数据
- DataGridView(绑定 DataView/DataTable/DataSet,单元格修改写回 DataTable)、DateTimePicker;设计器工具箱加入
- 评分:每个检查项一份新建的实验库;新增点 / 修改表格单元格、选日期、表格里有 / 没有某行的检查
- 示例:项目 6 任务 2、3;时间格式改成和中文 Windows 一致(9:05:03);异常堆栈不暴露服务器路径
- 测试:数据库端到端、接口(每人只看到自己的库、恢复实验数据)、评分出错情况、T-SQL 生成

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:18:56 +08:00

289 lines
8.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// 编辑前 200 行(仿 SSMS):改单元格立即写回数据库(UPDATE),最下面的 * 行输入新数据、离开这一行或按回车时插入(INSERT),
// 右键行号删除(DELETE)。输入 NULL 表示空值。自增列和二进制列不能改。出错时显示数据库的报错并恢复原值。
import { NButton, NDropdown, useDialog, useMessage } from 'naive-ui'
import { computed, nextTick, onMounted, ref } from 'vue'
import { deleteRow, execute, insertRow, parseInput, quoteName, updateCell, type ExplorerTable } from './sql'
const props = defineProps<{ database: string; table: ExplorerTable }>()
const emit = defineEmits<{ changed: [] }>()
const message = useMessage()
const dialog = useDialog()
type Cell = string | number | null
const rows = ref<Cell[][]>([])
const loading = ref(false)
const current = ref<{ row: number; column: number } | null>(null)
const editing = ref<{ row: number; column: number; value: string } | null>(null)
const editor = ref<HTMLInputElement[]>([])
/** 新行里已经填的值(列序号 → 值) */
const newRow = ref(new Map<number, string | null>())
const menu = ref<{ x: number; y: number; row: number } | null>(null)
const columns = computed(() => props.table.columns)
const newRowIndex = computed(() => rows.value.length)
const readOnly = (column: number) => columns.value[column].identity || /^(binary|varbinary|image)/i.test(columns.value[column].type)
async function load() {
loading.value = true
try {
const sql = `SELECT TOP (200) ${columns.value.map((c) => quoteName(c.name)).join(', ')} FROM ${quoteName(props.table.name)}`
const reply = await execute(props.database, sql)
rows.value = (reply.type === 'result' ? reply.results[0]?.rows : []) as Cell[][]
} catch (error) {
message.error((error as Error).message)
} finally {
loading.value = false
}
}
/** 执行一条修改语句,然后重新读取(出错时提示,表格恢复成数据库里的样子) */
async function apply(sql: string) {
try {
await execute(props.database, sql)
emit('changed')
} catch (error) {
dialog.error({ title: 'Microsoft SQL Server Management Studio', content: `未更新任何行。\n${(error as Error).message}`, positiveText: '确定' })
}
await load()
}
async function select(row: number, column: number) {
await commitEdit()
if (current.value?.row === newRowIndex.value && row !== newRowIndex.value) await commitNewRow()
current.value = { row, column }
}
function startEdit(row: number, column: number, initial?: string) {
if (readOnly(column)) return
const value = row === newRowIndex.value ? newRow.value.get(column) : rows.value[row]?.[column]
editing.value = { row, column, value: initial ?? (value === null || value === undefined ? (row === newRowIndex.value ? '' : 'NULL') : String(value)) }
void nextTick(() => {
editor.value[0]?.focus()
if (initial === undefined) editor.value[0]?.select()
})
}
async function commitEdit() {
const edit = editing.value
if (!edit) return
editing.value = null
if (edit.row === newRowIndex.value) {
if (edit.value !== '') newRow.value.set(edit.column, parseInput(edit.value))
return
}
const original = rows.value[edit.row]
const value = parseInput(edit.value)
if (String(original[edit.column] ?? 'NULL') === edit.value) return
await apply(updateCell(props.table, edit.column, value, original))
}
async function commitNewRow() {
if (newRow.value.size === 0) return
const values = newRow.value
newRow.value = new Map()
await apply(insertRow(props.table, values))
}
function keydown(event: KeyboardEvent) {
if (editing.value || !current.value) return
const { row, column } = current.value
const move = (r: number, c: number) => {
if (r >= 0 && r <= newRowIndex.value && c >= 0 && c < columns.value.length) void select(r, c)
}
if (event.key === 'ArrowDown') move(row + 1, column)
else if (event.key === 'ArrowUp') move(row - 1, column)
else if (event.key === 'ArrowLeft') move(row, column - 1)
else if (event.key === 'ArrowRight' || event.key === 'Tab') move(row, column + 1)
else if (event.key === 'F2') startEdit(row, column)
else if (event.key === 'Enter') {
if (row === newRowIndex.value) void commitNewRow()
else startEdit(row, column)
} else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) startEdit(row, column, event.key)
else return
event.preventDefault()
}
async function editorKeydown(event: KeyboardEvent) {
event.stopPropagation()
if (event.key === 'Escape') editing.value = null
else if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault()
const row = editing.value?.row
await commitEdit()
if (row === newRowIndex.value && event.key === 'Enter') await commitNewRow()
}
}
function rowMenu(event: MouseEvent, row: number) {
event.preventDefault()
if (row >= rows.value.length) return
menu.value = null
void nextTick(() => (menu.value = { x: event.clientX, y: event.clientY, row }))
}
function confirmDelete() {
const row = menu.value!.row
menu.value = null
dialog.warning({
title: 'Microsoft SQL Server Management Studio',
content: '将永久删除 1 行。\n确实要删除这些行吗?',
positiveText: '是',
negativeText: '否',
onPositiveClick: () => apply(deleteRow(props.table, rows.value[row])),
})
}
const display = (value: Cell | undefined) => (value === null || value === undefined ? 'NULL' : String(value))
onMounted(load)
defineExpose({ reload: load })
</script>
<template>
<div class="edit">
<div class="bar">
<span>{{ database }} - dbo.{{ table.name }}</span>
<NButton size="small" :loading="loading" @click="load">刷新</NButton>
<span class="hint">双击或直接打字修改;输入 NULL 表示空值;在 * 行输入新数据,按回车保存;右键行号删除</span>
</div>
<div class="grid-wrap" tabindex="0" @keydown="keydown">
<table class="grid">
<thead>
<tr>
<th class="head" />
<th v-for="(column, c) in columns" :key="c" :title="column.type">{{ column.name }}</th>
</tr>
</thead>
<tbody>
<tr v-for="r in rows.length + 1" :key="r - 1">
<th class="head" @click="select(r - 1, 0)" @contextmenu="rowMenu($event, r - 1)">
{{ r - 1 === newRowIndex ? '*' : current?.row === r - 1 ? '▶' : '' }}
</th>
<td
v-for="(column, c) in columns"
:key="c"
:class="{
current: current?.row === r - 1 && current?.column === c,
null: r - 1 < rows.length ? rows[r - 1][c] === null : !newRow.has(c) || newRow.get(c) === null,
readonly: readOnly(c),
}"
@click="select(r - 1, c)"
@dblclick="startEdit(r - 1, c)"
>
<input
v-if="editing && editing.row === r - 1 && editing.column === c"
ref="editor"
v-model="editing.value"
class="editor"
@keydown="editorKeydown"
@blur="commitEdit"
/>
<template v-else-if="r - 1 < rows.length">{{ display(rows[r - 1][c]) }}</template>
<template v-else>{{ newRow.has(c) ? display(newRow.get(c)) : 'NULL' }}</template>
</td>
</tr>
</tbody>
</table>
</div>
<NDropdown
v-if="menu"
show
trigger="manual"
placement="bottom-start"
:x="menu.x"
:y="menu.y"
:options="[{ label: '删除', key: 'delete' }]"
size="small"
@select="confirmDelete"
@clickoutside="menu = null"
/>
</div>
</template>
<style scoped>
.edit {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-bottom: 1px solid #e5e7eb;
font-size: 13px;
}
.hint {
color: #6b7280;
font-size: 12px;
}
.grid-wrap {
flex: 1;
overflow: auto;
outline: none;
padding: 8px;
}
.grid {
border-collapse: collapse;
font-size: 12px;
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
background: #fff;
}
.grid th,
.grid td {
border: 1px solid #d4d4d4;
padding: 2px 6px;
min-width: 80px;
max-width: 280px;
height: 22px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
font-weight: normal;
cursor: default;
}
.grid thead th,
.grid .head {
background: #f3f3f3;
}
.grid .head {
min-width: 24px;
width: 24px;
text-align: center;
}
.grid td.null {
color: #6b7280;
font-style: italic;
}
.grid td.readonly {
background: #f9fafb;
}
.grid td.current {
outline: 2px solid #0078d7;
outline-offset: -2px;
}
.editor {
width: 100%;
border: none;
outline: none;
font: inherit;
padding: 0;
background: #fff;
}
</style>