项目 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>
This commit is contained in:
1 parent
6b088240da
commit
670abe5921
72 files changed
+7253
-51
No files matched your search
@@ -0,0 +1,290 @@
|
||||
<script setup lang="ts">
|
||||
// 表设计器(仿 SSMS):每行一列(列名、数据类型、允许 Null 值),右键行号设置主键,下方"列属性"里设置标识(自增)。
|
||||
// 新表保存时输入表名(CREATE TABLE);已有的表只能添加新列(ALTER TABLE ADD),已有的列只读。
|
||||
// "查看 SQL 脚本"显示保存时要执行的 T-SQL,方便对照书上的 SQL 语句。
|
||||
import { NButton, NCheckbox, NDropdown, NInput, NModal, useMessage } from 'naive-ui'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { addColumns, COMMON_TYPES, createTable, execute, type DesignColumn, type ExplorerTable } from './sql'
|
||||
|
||||
const props = defineProps<{ database: string; table?: ExplorerTable }>()
|
||||
const emit = defineEmits<{ saved: [table: string] }>()
|
||||
const message = useMessage()
|
||||
|
||||
interface Row extends DesignColumn {
|
||||
existing: boolean
|
||||
}
|
||||
|
||||
const blank = (): Row => ({ name: '', type: 'nchar(10)', nullable: true, identity: false, primaryKey: false, existing: false })
|
||||
const rows = ref<Row[]>([...(props.table?.columns.map((c) => ({ ...c, existing: true })) ?? []), blank()])
|
||||
const selected = ref(0)
|
||||
const menu = ref<{ x: number; y: number; row: number } | null>(null)
|
||||
const naming = ref<string | null>(null)
|
||||
const script = ref<string | null>(null)
|
||||
const saving = ref(false)
|
||||
|
||||
/** 填了列名的新列(最后一行空行用来添加) */
|
||||
const newColumns = computed(() => rows.value.filter((r) => !r.existing && r.name.trim() !== ''))
|
||||
const current = computed(() => rows.value[selected.value])
|
||||
|
||||
function edited(index: number) {
|
||||
// 在最后一行填了列名:下面再补一行空行
|
||||
if (index === rows.value.length - 1 && rows.value[index].name.trim() !== '') rows.value.push(blank())
|
||||
}
|
||||
|
||||
function rowMenu(event: MouseEvent, row: number) {
|
||||
event.preventDefault()
|
||||
selected.value = row
|
||||
menu.value = null
|
||||
void nextTick(() => (menu.value = { x: event.clientX, y: event.clientY, row }))
|
||||
}
|
||||
|
||||
const menuOptions = computed(() => {
|
||||
const row = menu.value ? rows.value[menu.value.row] : null
|
||||
if (!row || row.existing || !row.name.trim()) return [{ label: '(已有的列不能修改)', key: 'none', disabled: true }]
|
||||
return [
|
||||
{ label: row.primaryKey ? '删除主键' : '设置主键', key: 'key' },
|
||||
{ label: '删除列', key: 'delete' },
|
||||
]
|
||||
})
|
||||
|
||||
function chooseMenu(key: string) {
|
||||
const index = menu.value!.row
|
||||
menu.value = null
|
||||
const row = rows.value[index]
|
||||
if (key === 'key') {
|
||||
row.primaryKey = !row.primaryKey
|
||||
if (row.primaryKey) row.nullable = false
|
||||
} else if (key === 'delete') {
|
||||
rows.value.splice(index, 1)
|
||||
selected.value = Math.min(selected.value, rows.value.length - 1)
|
||||
}
|
||||
}
|
||||
|
||||
function setIdentity(value: boolean) {
|
||||
const row = current.value
|
||||
if (value && !/^(int|bigint|smallint|tinyint)$/i.test(row.type.trim())) {
|
||||
message.warning('只有 int、bigint、smallint、tinyint 类型的列可以设为标识列(自增)')
|
||||
return
|
||||
}
|
||||
row.identity = value
|
||||
if (value) row.nullable = false
|
||||
}
|
||||
|
||||
/** 保存前检查:列名不能重复,新表至少要有一列 */
|
||||
function problem(): string | null {
|
||||
const names = rows.value.filter((r) => r.name.trim()).map((r) => r.name.trim().toLowerCase())
|
||||
if (names.length === 0) return '表里至少要有一列'
|
||||
const duplicate = names.find((n, i) => names.indexOf(n) !== i)
|
||||
if (duplicate) return `列名 "${duplicate}" 重复了`
|
||||
if (rows.value.filter((r) => r.identity).length > 1) return '一张表只能有一个标识列'
|
||||
if (props.table && newColumns.value.length === 0) return '没有要保存的新列(已有的列不能修改)'
|
||||
return null
|
||||
}
|
||||
|
||||
const sqlFor = (name: string) =>
|
||||
props.table ? addColumns(props.table.name, newColumns.value) : createTable(name, rows.value.filter((r) => r.name.trim()).map((r) => ({ ...r, name: r.name.trim(), type: r.type.trim() })))
|
||||
|
||||
function save() {
|
||||
const error = problem()
|
||||
if (error) {
|
||||
message.warning(error)
|
||||
return
|
||||
}
|
||||
if (props.table) void run(props.table.name)
|
||||
else naming.value = 'Table_1'
|
||||
}
|
||||
|
||||
async function run(name: string) {
|
||||
saving.value = true
|
||||
try {
|
||||
await execute(props.database, sqlFor(name))
|
||||
message.success(props.table ? '已添加新列' : `已创建表 ${name}`)
|
||||
naming.value = null
|
||||
emit('saved', name)
|
||||
} catch (error) {
|
||||
message.error((error as Error).message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showScript() {
|
||||
const error = problem()
|
||||
script.value = error ? `-- ${error}` : sqlFor(props.table?.name ?? 'Table_1')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="design">
|
||||
<div class="bar">
|
||||
<span>{{ database }} - {{ table ? `dbo.${table.name}` : '新表' }}</span>
|
||||
<NButton size="small" type="primary" :loading="saving" @click="save">保存</NButton>
|
||||
<NButton size="small" @click="showScript">查看 SQL 脚本</NButton>
|
||||
<span class="hint">右键行号设置主键;选中一列后在下方设置标识(自增){{ table ? ';已有的列不能修改,可以添加新列' : '' }}</span>
|
||||
</div>
|
||||
<div class="grid-wrap">
|
||||
<table class="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="head" />
|
||||
<th>列名</th>
|
||||
<th>数据类型</th>
|
||||
<th>允许 Null 值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, i) in rows" :key="i" :class="{ selected: selected === i, existing: row.existing }" @click="selected = i">
|
||||
<th class="head" @contextmenu="rowMenu($event, i)">{{ row.primaryKey ? '🔑' : selected === i ? '▶' : '' }}</th>
|
||||
<td><input v-model="row.name" :readonly="row.existing" @input="edited(i)" /></td>
|
||||
<td>
|
||||
<input v-model="row.type" :readonly="row.existing" list="sim-sql-types" />
|
||||
</td>
|
||||
<td class="center">
|
||||
<input v-model="row.nullable" type="checkbox" :disabled="row.existing || row.primaryKey || row.identity" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<datalist id="sim-sql-types">
|
||||
<option v-for="type in COMMON_TYPES" :key="type" :value="type" />
|
||||
</datalist>
|
||||
</div>
|
||||
<div class="properties">
|
||||
<div class="properties-title">列属性{{ current?.name ? `:${current.name}` : '' }}</div>
|
||||
<div v-if="current && current.name.trim()" class="property">
|
||||
<NCheckbox :checked="current.identity" :disabled="current.existing" @update:checked="setIdentity">
|
||||
标识规范(是标识):插入数据时自动编号,从 1 开始,每次加 1
|
||||
</NCheckbox>
|
||||
</div>
|
||||
<div v-if="current && current.name.trim()" class="property">主键:{{ current.primaryKey ? '是' : '否' }}(右键行号设置)</div>
|
||||
</div>
|
||||
|
||||
<NModal :show="naming !== null" preset="card" title="选择名称" style="width: 360px" @update:show="naming = null">
|
||||
<div style="margin-bottom: 8px">输入表名称:</div>
|
||||
<NInput v-if="naming !== null" v-model:value="naming" autofocus @keydown.enter="naming?.trim() && run(naming.trim())" />
|
||||
<template #footer>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 8px">
|
||||
<NButton type="primary" :disabled="!naming?.trim()" :loading="saving" @click="run(naming!.trim())">确定</NButton>
|
||||
<NButton @click="naming = null">取消</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
|
||||
<NModal :show="script !== null" preset="card" title="SQL 脚本" style="width: 560px; max-width: calc(100vw - 32px)" @update:show="script = null">
|
||||
<pre class="script">{{ script }}</pre>
|
||||
</NModal>
|
||||
|
||||
<NDropdown
|
||||
v-if="menu"
|
||||
show
|
||||
trigger="manual"
|
||||
placement="bottom-start"
|
||||
:x="menu.x"
|
||||
:y="menu.y"
|
||||
:options="menuOptions"
|
||||
size="small"
|
||||
@select="chooseMenu"
|
||||
@clickoutside="menu = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.design {
|
||||
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;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.grid th,
|
||||
.grid td {
|
||||
border: 1px solid #d4d4d4;
|
||||
padding: 0;
|
||||
font-weight: normal;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.grid thead th {
|
||||
background: #f3f3f3;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.grid .head {
|
||||
width: 26px;
|
||||
text-align: center;
|
||||
background: #f3f3f3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.grid input:not([type]),
|
||||
.grid input[list] {
|
||||
width: 180px;
|
||||
border: none;
|
||||
padding: 3px 6px;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.grid tr.selected td {
|
||||
background: #e5f1fb;
|
||||
}
|
||||
|
||||
.grid tr.existing input {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center !important;
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.properties {
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding: 8px;
|
||||
font-size: 13px;
|
||||
min-height: 90px;
|
||||
}
|
||||
|
||||
.properties-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.property {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.script {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,288 @@
|
||||
<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>
|
||||
@@ -0,0 +1,209 @@
|
||||
<script setup lang="ts">
|
||||
// 对象资源管理器(仿 SSMS):数据库 → 库 → 表 → 列。右键菜单里的操作交给页面处理。
|
||||
import { NDropdown, type DropdownOption } from 'naive-ui'
|
||||
import { nextTick, reactive, ref } from 'vue'
|
||||
import type { ExplorerDatabase, ExplorerTable } from './sql'
|
||||
|
||||
export type ExplorerAction =
|
||||
| { kind: 'newDatabase' }
|
||||
| { kind: 'refresh' }
|
||||
| { kind: 'newQuery'; database: string }
|
||||
| { kind: 'newTable'; database: string }
|
||||
| { kind: 'dropDatabase'; database: string }
|
||||
| { kind: 'editRows'; database: string; table: ExplorerTable }
|
||||
| { kind: 'selectRows'; database: string; table: ExplorerTable }
|
||||
| { kind: 'design'; database: string; table: ExplorerTable }
|
||||
| { kind: 'dropTable'; database: string; table: ExplorerTable }
|
||||
|
||||
defineProps<{ databases: ExplorerDatabase[] }>()
|
||||
const emit = defineEmits<{ action: [action: ExplorerAction] }>()
|
||||
|
||||
/** 展开的节点:root、db:库名、tables:库名、table:库名/表名、columns:库名/表名 */
|
||||
const expanded = reactive(new Set<string>(['root']))
|
||||
const toggle = (key: string) => (expanded.has(key) ? expanded.delete(key) : expanded.add(key))
|
||||
|
||||
// ---------- 右键菜单 ----------
|
||||
const menu = ref<{ x: number; y: number; options: DropdownOption[]; actions: Map<string, ExplorerAction> } | null>(null)
|
||||
|
||||
function openMenu(event: MouseEvent, items: [string, ExplorerAction][]) {
|
||||
event.preventDefault()
|
||||
menu.value = null
|
||||
void nextTick(() => {
|
||||
menu.value = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
options: items.map(([label], i) => (label === '-' ? { type: 'divider', key: `d${i}` } : { label, key: String(i) })),
|
||||
actions: new Map(items.map(([, action], i) => [String(i), action])),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function choose(key: string) {
|
||||
const action = menu.value?.actions.get(key)
|
||||
menu.value = null
|
||||
if (action) emit('action', action)
|
||||
}
|
||||
|
||||
const REFRESH: ExplorerAction = { kind: 'refresh' }
|
||||
|
||||
function rootMenu(event: MouseEvent) {
|
||||
openMenu(event, [['新建数据库…', { kind: 'newDatabase' }], ['-', REFRESH], ['刷新', REFRESH]])
|
||||
}
|
||||
|
||||
function databaseMenu(event: MouseEvent, database: string) {
|
||||
openMenu(event, [
|
||||
['新建查询', { kind: 'newQuery', database }],
|
||||
['新建表…', { kind: 'newTable', database }],
|
||||
['-', REFRESH],
|
||||
['删除', { kind: 'dropDatabase', database }],
|
||||
['刷新', REFRESH],
|
||||
])
|
||||
}
|
||||
|
||||
function tablesMenu(event: MouseEvent, database: string) {
|
||||
openMenu(event, [['新建表…', { kind: 'newTable', database }], ['-', REFRESH], ['刷新', REFRESH]])
|
||||
}
|
||||
|
||||
function tableMenu(event: MouseEvent, database: string, table: ExplorerTable) {
|
||||
openMenu(event, [
|
||||
['新建表…', { kind: 'newTable', database }],
|
||||
['设计', { kind: 'design', database, table }],
|
||||
['选择前 1000 行', { kind: 'selectRows', database, table }],
|
||||
['编辑前 200 行', { kind: 'editRows', database, table }],
|
||||
['-', REFRESH],
|
||||
['删除', { kind: 'dropTable', database, table }],
|
||||
['刷新', REFRESH],
|
||||
])
|
||||
}
|
||||
|
||||
function columnText(column: ExplorerTable['columns'][number]) {
|
||||
const flags = [column.primaryKey ? 'PK' : '', column.type, column.nullable ? 'null' : 'not null'].filter(Boolean)
|
||||
return `${column.name} (${flags.join(', ')})`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="explorer">
|
||||
<div class="title">对象资源管理器</div>
|
||||
<ul class="tree">
|
||||
<li>
|
||||
<div class="node" @click="toggle('root')" @contextmenu="rootMenu">
|
||||
<span class="arrow">{{ expanded.has('root') ? '▾' : '▸' }}</span>📁 数据库
|
||||
</div>
|
||||
<ul v-if="expanded.has('root')">
|
||||
<li v-if="databases.length === 0" class="empty">(还没有数据库:右键"数据库"新建,或在上方恢复实验初始数据)</li>
|
||||
<li v-for="db in databases" :key="db.name">
|
||||
<div class="node" @click="toggle(`db:${db.name}`)" @contextmenu="databaseMenu($event, db.name)">
|
||||
<span class="arrow">{{ expanded.has(`db:${db.name}`) ? '▾' : '▸' }}</span>🛢 {{ db.name }}
|
||||
</div>
|
||||
<ul v-if="expanded.has(`db:${db.name}`)">
|
||||
<li>
|
||||
<div class="node" @click="toggle(`tables:${db.name}`)" @contextmenu="tablesMenu($event, db.name)">
|
||||
<span class="arrow">{{ expanded.has(`tables:${db.name}`) ? '▾' : '▸' }}</span>📁 表
|
||||
</div>
|
||||
<ul v-if="expanded.has(`tables:${db.name}`)">
|
||||
<li v-if="db.tables.length === 0" class="empty">(没有表)</li>
|
||||
<li v-for="table in db.tables" :key="table.name">
|
||||
<div
|
||||
class="node"
|
||||
title="右键:编辑前 200 行、设计、删除"
|
||||
@click="toggle(`table:${db.name}/${table.name}`)"
|
||||
@contextmenu="tableMenu($event, db.name, table)"
|
||||
>
|
||||
<span class="arrow">{{ expanded.has(`table:${db.name}/${table.name}`) ? '▾' : '▸' }}</span>▦ dbo.{{ table.name }}
|
||||
</div>
|
||||
<ul v-if="expanded.has(`table:${db.name}/${table.name}`)">
|
||||
<li>
|
||||
<div class="node" @click="toggle(`columns:${db.name}/${table.name}`)">
|
||||
<span class="arrow">{{ expanded.has(`columns:${db.name}/${table.name}`) ? '▾' : '▸' }}</span>📁 列
|
||||
</div>
|
||||
<ul v-if="expanded.has(`columns:${db.name}/${table.name}`)">
|
||||
<li v-for="column in table.columns" :key="column.name" class="node leaf">
|
||||
{{ column.primaryKey ? '🔑' : '▫' }} {{ columnText(column) }}
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<NDropdown
|
||||
v-if="menu"
|
||||
show
|
||||
trigger="manual"
|
||||
placement="bottom-start"
|
||||
:x="menu.x"
|
||||
:y="menu.y"
|
||||
:options="menu.options"
|
||||
size="small"
|
||||
@select="choose"
|
||||
@clickoutside="menu = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.explorer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.title {
|
||||
padding: 6px 8px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.tree,
|
||||
.tree ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tree {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.tree ul {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.node {
|
||||
padding: 2px 6px;
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.node:hover {
|
||||
background: #e5f1fb;
|
||||
}
|
||||
|
||||
.leaf {
|
||||
padding-left: 20px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 2px 6px 2px 22px;
|
||||
color: #9ca3af;
|
||||
white-space: normal;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
// 查询窗口(仿 SSMS):选数据库、写 T-SQL、F5 执行(选中一部分时只执行选中的),下面是结果和消息。
|
||||
// USE 语句会切换当前数据库,和 SSMS 一样执行后下拉框跟着变。
|
||||
import { NButton, NSelect, NTab, NTabs } from 'naive-ui'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, shallowRef } from 'vue'
|
||||
import { monaco } from '../editor/monaco'
|
||||
import ResultGrid from './ResultGrid.vue'
|
||||
import { messages, runQuery, type QueryReply } from './sql'
|
||||
|
||||
const props = defineProps<{ databases: string[]; initialSql?: string; runOnOpen?: boolean }>()
|
||||
const database = defineModel<string>('database', { required: true })
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
|
||||
const container = ref<HTMLDivElement>()
|
||||
const editor = shallowRef<monaco.editor.IStandaloneCodeEditor>()
|
||||
const reply = ref<QueryReply | null>(null)
|
||||
const running = ref(false)
|
||||
const view = ref<'results' | 'messages'>('results')
|
||||
|
||||
const options = computed(() => ['master', ...props.databases].map((name) => ({ label: name, value: name })))
|
||||
const messageLines = computed(() => (reply.value ? messages(reply.value) : []))
|
||||
|
||||
async function execute() {
|
||||
if (!editor.value || running.value) return
|
||||
const model = editor.value.getModel()!
|
||||
const selection = editor.value.getSelection()
|
||||
const sql = selection && !selection.isEmpty() ? model.getValueInRange(selection) : model.getValue()
|
||||
if (!sql.trim()) return
|
||||
running.value = true
|
||||
try {
|
||||
reply.value = await runQuery(database.value, sql)
|
||||
if (reply.value.type === 'result') {
|
||||
database.value = reply.value.database
|
||||
view.value = reply.value.results.length > 0 ? 'results' : 'messages'
|
||||
// 可能建了库、建了表:让对象资源管理器刷新
|
||||
if (/\b(create|drop|alter)\b/i.test(sql)) emit('changed')
|
||||
} else {
|
||||
view.value = 'messages'
|
||||
}
|
||||
} catch (error) {
|
||||
reply.value = { type: 'error', number: 0, message: (error as Error).message, line: 1 }
|
||||
view.value = 'messages'
|
||||
} finally {
|
||||
running.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 这个查询窗口是当前标签页时,在页面任何地方按 F5 都执行(并阻止浏览器刷新),和 SSMS 一样 */
|
||||
function f5(event: KeyboardEvent) {
|
||||
if (event.key !== 'F5' || event.defaultPrevented || !container.value?.offsetParent) return
|
||||
event.preventDefault()
|
||||
void execute()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', f5)
|
||||
editor.value = monaco.editor.create(container.value!, {
|
||||
value: props.initialSql ?? '',
|
||||
language: 'sql',
|
||||
automaticLayout: true,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
scrollBeyondLastLine: false,
|
||||
tabSize: 4,
|
||||
})
|
||||
editor.value.addCommand(monaco.KeyCode.F5, () => void execute())
|
||||
editor.value.focus()
|
||||
if (props.runOnOpen) void execute()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', f5)
|
||||
editor.value?.getModel()?.dispose()
|
||||
editor.value?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="query">
|
||||
<div class="bar">
|
||||
<NSelect v-model:value="database" :options="options" size="small" style="width: 180px" />
|
||||
<NButton size="small" type="primary" :loading="running" title="F5(选中一部分时只执行选中的语句)" @click="execute">▶ 执行</NButton>
|
||||
<span v-if="reply?.type === 'result'" class="time">用时 {{ reply.ms }} ms</span>
|
||||
</div>
|
||||
<div ref="container" class="editor" />
|
||||
<div v-if="reply" class="output">
|
||||
<NTabs v-model:value="view" size="small" type="line">
|
||||
<NTab name="results" :disabled="reply.type !== 'result' || reply.results.length === 0">结果</NTab>
|
||||
<NTab name="messages">消息</NTab>
|
||||
</NTabs>
|
||||
<div v-if="view === 'results' && reply.type === 'result'" class="results">
|
||||
<ResultGrid v-for="(result, i) in reply.results" :key="i" :result="result" />
|
||||
</div>
|
||||
<div v-else class="messages">
|
||||
<pre v-for="(line, i) in messageLines" :key="i" :class="{ error: line.error }">{{ line.text }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.query {
|
||||
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;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.editor {
|
||||
flex: 1 1 45%;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.output {
|
||||
flex: 1 1 55%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding: 0 8px 8px;
|
||||
}
|
||||
|
||||
.results {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.messages pre {
|
||||
margin: 4px 0;
|
||||
font-size: 13px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.messages pre.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
// 查询结果网格(只读),和 SSMS 一样:左边是行号,NULL 显示成浅黄底的 "NULL"。
|
||||
import type { ResultSet } from './sql'
|
||||
|
||||
defineProps<{ result: ResultSet }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid-wrap">
|
||||
<table class="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="num" />
|
||||
<th v-for="(column, i) in result.columns" :key="i" :title="column.type ?? ''">{{ column.name || '(无列名)' }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, r) in result.rows" :key="r">
|
||||
<th class="num">{{ r + 1 }}</th>
|
||||
<td v-for="(cell, c) in row" :key="c" :class="{ null: cell === null }">{{ cell === null ? 'NULL' : cell }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="result.total > result.rows.length" class="more">共 {{ result.total }} 行,只显示前 {{ result.rows.length }} 行</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grid-wrap {
|
||||
overflow: auto;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
white-space: nowrap;
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.grid thead th {
|
||||
background: #f3f3f3;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.grid .num {
|
||||
background: #f3f3f3;
|
||||
color: #666;
|
||||
text-align: right;
|
||||
min-width: 28px;
|
||||
}
|
||||
|
||||
.grid td.null {
|
||||
background: #ffffe1;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.more {
|
||||
padding: 4px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { addColumns, createTable, deleteRow, insertRow, literal, messages, parseInput, rowCondition, updateCell, type ExplorerTable } from './sql'
|
||||
|
||||
const campus: ExplorerTable = {
|
||||
name: 'CampusEnvironment',
|
||||
columns: [
|
||||
{ name: 'ID', type: 'int', nullable: false, identity: true, primaryKey: true },
|
||||
{ name: 'light', type: 'varchar(50)', nullable: true, identity: false, primaryKey: false },
|
||||
{ name: 'addtime', type: 'datetime', nullable: true, identity: false, primaryKey: false },
|
||||
],
|
||||
}
|
||||
|
||||
const noKey: ExplorerTable = {
|
||||
name: 'EnvironmentWork',
|
||||
columns: [
|
||||
{ name: 'light', type: 'varchar(MAX)', nullable: true, identity: false, primaryKey: false },
|
||||
{ name: 'temperature', type: 'varchar(MAX)', nullable: true, identity: false, primaryKey: false },
|
||||
{ name: 'photo', type: 'varbinary(MAX)', nullable: true, identity: false, primaryKey: false },
|
||||
],
|
||||
}
|
||||
|
||||
describe('编辑表数据生成的 T-SQL', () => {
|
||||
test('字面量:字符串用 N 前缀并转义单引号,NULL 原样', () => {
|
||||
expect(literal("it's")).toBe("N'it''s'")
|
||||
expect(literal(null)).toBe('NULL')
|
||||
expect(literal(25)).toBe('25')
|
||||
expect(parseInput('NULL')).toBeNull()
|
||||
expect(parseInput('null')).toBe('null')
|
||||
})
|
||||
|
||||
test('有主键时按主键定位', () => {
|
||||
expect(updateCell(campus, 1, '600', [3, '500', '2024-01-16 09:00:00.000'])).toBe('UPDATE TOP (1) [CampusEnvironment] SET [light] = N\'600\' WHERE [ID] = 3')
|
||||
expect(deleteRow(campus, [3, '500', null])).toBe('DELETE TOP (1) FROM [CampusEnvironment] WHERE [ID] = 3')
|
||||
})
|
||||
|
||||
test('没有主键时按所有列的原值定位(二进制列除外),空值用 IS NULL', () => {
|
||||
expect(rowCondition(noKey, ['520', null, '0xFF'])).toBe("[light] = N'520' AND [temperature] IS NULL")
|
||||
})
|
||||
|
||||
test('新增行跳过自增列;什么都没填时用 DEFAULT VALUES', () => {
|
||||
expect(insertRow(campus, new Map([[2, '2024-01-20 08:00:00'], [0, '9'], [1, null]]))).toBe(
|
||||
"INSERT INTO [CampusEnvironment] ([light], [addtime]) VALUES (NULL, N'2024-01-20 08:00:00')",
|
||||
)
|
||||
expect(insertRow(campus, new Map())).toBe('INSERT INTO [CampusEnvironment] DEFAULT VALUES')
|
||||
})
|
||||
})
|
||||
|
||||
describe('表设计器生成的 T-SQL', () => {
|
||||
test('建表:主键和标识列不允许 Null', () => {
|
||||
expect(
|
||||
createTable('EnvironmentWorkNew', [
|
||||
{ name: 'id', type: 'int', nullable: true, identity: true, primaryKey: true },
|
||||
{ name: 'EnvironmentDay', type: 'date', nullable: true, identity: false, primaryKey: false },
|
||||
{ name: 'EnvironmentWork', type: 'varchar(200)', nullable: true, identity: false, primaryKey: false },
|
||||
]),
|
||||
).toBe('CREATE TABLE [EnvironmentWorkNew]\n(\n [id] int IDENTITY(1,1) NOT NULL,\n [EnvironmentDay] date NULL,\n [EnvironmentWork] varchar(200) NULL,\n PRIMARY KEY ([id])\n)')
|
||||
})
|
||||
|
||||
test('给已有的表加列', () => {
|
||||
expect(addColumns('T', [{ name: 'memo', type: 'nvarchar(50)', nullable: true, identity: false, primaryKey: false }])).toBe('ALTER TABLE [T] ADD [memo] nvarchar(50) NULL')
|
||||
})
|
||||
})
|
||||
|
||||
test('消息栏和 SSMS 一致', () => {
|
||||
expect(messages({ type: 'error', number: 208, message: "对象名 'X' 无效。", line: 2 })).toEqual([{ text: "消息 208,级别 16,状态 1,第 2 行\n对象名 'X' 无效。", error: true }])
|
||||
expect(messages({ type: 'result', database: 'db', results: [{ columns: [], rows: [], total: 3 }], affected: 1, ms: 1 }).map((m) => m.text)).toEqual(['(3 行受影响)', '(1 行受影响)'])
|
||||
expect(messages({ type: 'result', database: 'db', results: [], affected: -1, ms: 1 }).map((m) => m.text)).toEqual(['命令已成功完成。'])
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
// 数据库页面(代替 SSMS)的数据和 T-SQL 生成:编辑表数据、表设计器的操作都生成 T-SQL 交给服务器执行,
|
||||
// 和学生在查询窗口里写的语句走同一套转写和报错。
|
||||
import { api } from '../api'
|
||||
|
||||
export interface ExplorerColumn {
|
||||
name: string
|
||||
type: string
|
||||
nullable: boolean
|
||||
identity: boolean
|
||||
primaryKey: boolean
|
||||
}
|
||||
|
||||
export interface ExplorerTable {
|
||||
name: string
|
||||
columns: ExplorerColumn[]
|
||||
}
|
||||
|
||||
export interface ExplorerDatabase {
|
||||
name: string
|
||||
tables: ExplorerTable[]
|
||||
}
|
||||
|
||||
export interface ResultSet {
|
||||
columns: { name: string; type: string | null }[]
|
||||
rows: (string | number | null)[][]
|
||||
total: number
|
||||
}
|
||||
|
||||
export type QueryReply =
|
||||
| { type: 'result'; database: string; results: ResultSet[]; affected: number; ms: number }
|
||||
| { type: 'error'; number: number; message: string; line: number }
|
||||
|
||||
export const loadExplorer = () => api<{ databases: ExplorerDatabase[] }>('GET', '/api/db')
|
||||
export const runQuery = (database: string, sql: string) => api<QueryReply>('POST', '/api/db/query', { database, sql })
|
||||
|
||||
/** 执行并在出错时抛出(编辑表数据、设计器用);消息和 SQL Server 一样 */
|
||||
export async function execute(database: string, sql: string) {
|
||||
const reply = await runQuery(database, sql)
|
||||
if (reply.type === 'error') throw new Error(reply.message)
|
||||
return reply
|
||||
}
|
||||
|
||||
export const quoteName = (name: string) => `[${name.replace(/]/g, ']]')}]`
|
||||
|
||||
/** 值写成 T-SQL 字面量;null 是 NULL(和 SSMS 一样,在单元格里输入 NULL 表示空值) */
|
||||
export const literal = (value: string | number | null) =>
|
||||
value === null ? 'NULL' : typeof value === 'number' ? String(value) : `N'${value.replace(/'/g, "''")}'`
|
||||
|
||||
/** 编辑表格里输入的文字转成要保存的值 */
|
||||
export const parseInput = (text: string): string | null => (text === 'NULL' ? null : text)
|
||||
|
||||
const isBinary = (column: ExplorerColumn) => /^(binary|varbinary|image)/i.test(column.type)
|
||||
|
||||
/**
|
||||
* 定位一行的条件:有主键时按主键;没有主键时按所有列的原值(配合 TOP (1),有完全相同的行时只改一行,和 SSMS 一样)。
|
||||
* 二进制列无法按显示的值比较,不放进条件。
|
||||
*/
|
||||
export function rowCondition(table: ExplorerTable, original: (string | number | null)[]) {
|
||||
const keys = table.columns.map((column, i) => ({ column, value: original[i] })).filter(({ column }) => column.primaryKey)
|
||||
const parts = (keys.length > 0 ? keys : table.columns.map((column, i) => ({ column, value: original[i] })).filter(({ column }) => !isBinary(column))).map(
|
||||
({ column, value }) => (value === null ? `${quoteName(column.name)} IS NULL` : `${quoteName(column.name)} = ${literal(value)}`),
|
||||
)
|
||||
return parts.join(' AND ')
|
||||
}
|
||||
|
||||
export function updateCell(table: ExplorerTable, columnIndex: number, value: string | null, original: (string | number | null)[]) {
|
||||
return `UPDATE TOP (1) ${quoteName(table.name)} SET ${quoteName(table.columns[columnIndex].name)} = ${literal(value)} WHERE ${rowCondition(table, original)}`
|
||||
}
|
||||
|
||||
export function deleteRow(table: ExplorerTable, original: (string | number | null)[]) {
|
||||
return `DELETE TOP (1) FROM ${quoteName(table.name)} WHERE ${rowCondition(table, original)}`
|
||||
}
|
||||
|
||||
/** 新增一行:只写用户填过的列(自增列和没填的列由数据库决定) */
|
||||
export function insertRow(table: ExplorerTable, values: Map<number, string | null>) {
|
||||
const entries = [...values].filter(([i]) => !table.columns[i].identity).sort(([a], [b]) => a - b)
|
||||
if (entries.length === 0) return `INSERT INTO ${quoteName(table.name)} DEFAULT VALUES`
|
||||
return `INSERT INTO ${quoteName(table.name)} (${entries.map(([i]) => quoteName(table.columns[i].name)).join(', ')}) VALUES (${entries.map(([, v]) => literal(v)).join(', ')})`
|
||||
}
|
||||
|
||||
/** 表设计器里的一列 */
|
||||
export interface DesignColumn {
|
||||
name: string
|
||||
type: string
|
||||
nullable: boolean
|
||||
identity: boolean
|
||||
primaryKey: boolean
|
||||
}
|
||||
|
||||
/** 设计器"数据类型"下拉里的常用类型(和 SSMS 一样也可以直接输入,如 varchar(20)) */
|
||||
export const COMMON_TYPES = [
|
||||
'int', 'bigint', 'smallint', 'tinyint', 'bit', 'float', 'real', 'decimal(18, 0)', 'money',
|
||||
'char(10)', 'varchar(50)', 'varchar(MAX)', 'nchar(10)', 'nvarchar(50)', 'nvarchar(MAX)', 'text', 'ntext',
|
||||
'date', 'datetime', 'smalldatetime', 'time(7)', 'uniqueidentifier', 'varbinary(MAX)', 'image',
|
||||
]
|
||||
|
||||
export function columnDefinition(column: DesignColumn) {
|
||||
const identity = column.identity ? ' IDENTITY(1,1)' : ''
|
||||
const nullable = column.nullable && !column.primaryKey && !column.identity ? ' NULL' : ' NOT NULL'
|
||||
return `${quoteName(column.name)} ${column.type.replace(/^time\(7\)$/i, 'time')}${identity}${nullable}`
|
||||
}
|
||||
|
||||
export function createTable(name: string, columns: DesignColumn[]) {
|
||||
const keys = columns.filter((c) => c.primaryKey)
|
||||
const lines = columns.map((c) => ` ${columnDefinition(c)}`)
|
||||
if (keys.length > 0) lines.push(` PRIMARY KEY (${keys.map((c) => quoteName(c.name)).join(', ')})`)
|
||||
return `CREATE TABLE ${quoteName(name)}\n(\n${lines.join(',\n')}\n)`
|
||||
}
|
||||
|
||||
export function addColumns(table: string, columns: DesignColumn[]) {
|
||||
return columns.map((c) => `ALTER TABLE ${quoteName(table)} ADD ${columnDefinition(c)}`).join('\n')
|
||||
}
|
||||
|
||||
/** 消息栏的文字,和 SSMS 一致:每个结果集、增删改各一行"(n 行受影响)";出错时"消息 208,级别 16,状态 1,第 1 行" */
|
||||
export function messages(reply: QueryReply): { text: string; error: boolean }[] {
|
||||
if (reply.type === 'error') {
|
||||
return [{ text: `消息 ${reply.number},级别 16,状态 1,第 ${reply.line} 行\n${reply.message}`, error: true }]
|
||||
}
|
||||
const lines = reply.results.map((r) => ({ text: `(${r.total} 行受影响)`, error: false }))
|
||||
if (reply.affected >= 0) lines.push({ text: `(${reply.affected} 行受影响)`, error: false })
|
||||
if (lines.length === 0) lines.push({ text: '命令已成功完成。', error: false })
|
||||
return lines
|
||||
}
|
||||
Reference in new issue
Block a user