项目 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
|
||||
}
|
||||
@@ -277,7 +277,7 @@ function onKeyDown(event: KeyboardEvent) {
|
||||
}
|
||||
|
||||
// ---------- 工具箱 ----------
|
||||
const TOOLBOX_GROUPS = ['常用控件', '容器', '组件', '物联网设备'] as const
|
||||
const TOOLBOX_GROUPS = ['常用控件', '容器', '数据', '组件', '物联网设备'] as const
|
||||
const ICONS: Record<string, string> = {
|
||||
按钮: '▭', 标签: 'A', 文本框: '▤', 下拉框: '☰', 复选框: '☑', 单选按钮: '◉', 分组框: '⬚', 面板: '▢',
|
||||
定时器: '⏱', 'LED 显示屏': '▬', 灯泡: '💡', 报警灯: '🚨',
|
||||
|
||||
@@ -37,7 +37,7 @@ export interface ControlDef {
|
||||
type: string
|
||||
/** 工具箱和属性窗口里显示的中文名(英文类名在鼠标悬停时显示) */
|
||||
label: string
|
||||
group: '常用控件' | '容器' | '组件' | '物联网设备'
|
||||
group: '常用控件' | '容器' | '数据' | '组件' | '物联网设备'
|
||||
/** 新控件的名字前缀:button → button1 */
|
||||
namePrefix: string
|
||||
/** 非可视组件(如 Timer):不在窗体上,显示在窗体下方的组件栏 */
|
||||
@@ -201,6 +201,59 @@ export const CONTROLS: ControlDef[] = [
|
||||
events: COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'),
|
||||
initial: () => [['Size', size(100, 50)], ['TabStop', bool(false)]],
|
||||
},
|
||||
{
|
||||
type: `${FORMS}.DataGridView`,
|
||||
label: '数据表格',
|
||||
group: '数据',
|
||||
namePrefix: 'dataGridView',
|
||||
defaultEvent: 'CellContentClick',
|
||||
props: [
|
||||
...COMMON_PROPS.filter((p) => p.name !== 'Text' && p.name !== 'ForeColor'),
|
||||
{ name: 'ReadOnly', label: '只读', description: '只读:不能在表格里修改数据', type: 'bool', default: bool(false), category: '行为' },
|
||||
{ name: 'AllowUserToAddRows', label: '允许添加行', description: '最下面显示一行空行,可以在里面输入新数据', type: 'bool', default: bool(true), category: '行为' },
|
||||
{ name: 'AllowUserToDeleteRows', label: '允许删除行', description: '允许删除行', type: 'bool', default: bool(true), category: '行为' },
|
||||
{ name: 'RowHeadersVisible', label: '显示行标题', description: '显示最左边的行标题列', type: 'bool', default: bool(true), category: '外观' },
|
||||
{
|
||||
name: 'SelectionMode', label: '选择方式', description: '点单元格时选中什么', type: 'enum', enumType: `${FORMS}.DataGridViewSelectionMode`,
|
||||
options: ['CellSelect', 'FullRowSelect', 'FullColumnSelect', 'RowHeaderSelect', 'ColumnHeaderSelect'],
|
||||
optionLabels: { CellSelect: '单元格', FullRowSelect: '整行', FullColumnSelect: '整列', RowHeaderSelect: '单元格(点行标题选整行)', ColumnHeaderSelect: '单元格(点列标题选整列)' },
|
||||
default: enumValue(`${FORMS}.DataGridViewSelectionMode`, 'RowHeaderSelect'), category: '行为',
|
||||
},
|
||||
{
|
||||
name: 'AutoSizeColumnsMode', label: '列宽', description: '列宽怎样自动调整', type: 'enum', enumType: `${FORMS}.DataGridViewAutoSizeColumnsMode`,
|
||||
options: ['None', 'Fill'], optionLabels: { None: '固定宽度', Fill: '填满表格宽度' },
|
||||
default: enumValue(`${FORMS}.DataGridViewAutoSizeColumnsMode`, 'None'), category: '布局',
|
||||
},
|
||||
],
|
||||
events: [
|
||||
...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'),
|
||||
{ name: 'CellClick', label: '单击单元格', delegate: `${FORMS}.DataGridViewCellEventHandler`, args: 'DataGridViewCellEventArgs' },
|
||||
{ name: 'CellContentClick', label: '单击单元格内容', delegate: `${FORMS}.DataGridViewCellEventHandler`, args: 'DataGridViewCellEventArgs' },
|
||||
{ name: 'CellDoubleClick', label: '双击单元格', delegate: `${FORMS}.DataGridViewCellEventHandler`, args: 'DataGridViewCellEventArgs' },
|
||||
{ name: 'CellValueChanged', label: '单元格的值改变', delegate: `${FORMS}.DataGridViewCellEventHandler`, args: 'DataGridViewCellEventArgs' },
|
||||
{ name: 'SelectionChanged', label: '选中改变', ...EVENT },
|
||||
],
|
||||
initial: () => [['ColumnHeadersHeightSizeMode', enumValue(`${FORMS}.DataGridViewColumnHeadersHeightSizeMode`, 'AutoSize')], ['Size', size(240, 150)]],
|
||||
},
|
||||
{
|
||||
type: `${FORMS}.DateTimePicker`,
|
||||
label: '日期时间选择器',
|
||||
group: '常用控件',
|
||||
namePrefix: 'dateTimePicker',
|
||||
defaultEvent: 'ValueChanged',
|
||||
props: [
|
||||
...COMMON_PROPS.filter((p) => p.name !== 'Text'),
|
||||
{
|
||||
name: 'Format', label: '格式', description: '日期和时间的显示格式', type: 'enum', enumType: `${FORMS}.DateTimePickerFormat`,
|
||||
options: ['Long', 'Short', 'Time', 'Custom'], optionLabels: { Long: '长日期(2024年1月16日)', Short: '短日期(2024/1/16)', Time: '时间(9:05:03)', Custom: '自定义' },
|
||||
default: enumValue(`${FORMS}.DateTimePickerFormat`, 'Long'), category: '外观',
|
||||
},
|
||||
{ name: 'CustomFormat', label: '自定义格式', description: '格式为"自定义"时使用,如 yyyy-MM-dd HH:mm', type: 'string', category: '外观' },
|
||||
{ name: 'ShowUpDown', label: '上下箭头', description: '用上下箭头调整,代替下拉日历', type: 'bool', default: bool(false), category: '外观' },
|
||||
],
|
||||
events: [...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'), { name: 'ValueChanged', label: '值改变', ...EVENT }],
|
||||
initial: () => [['Size', size(200, 21)]],
|
||||
},
|
||||
{
|
||||
type: `${FORMS}.CheckBox`,
|
||||
label: '复选框',
|
||||
|
||||
@@ -69,6 +69,8 @@ export function previewTree(doc: DesignerDocument): ControlNode {
|
||||
})
|
||||
// 设计器里不需要滚动:LED 只显示黑底
|
||||
if (type === 'WinFormsDeviceControlLibrary.LED') props.LedText = ''
|
||||
// 和 VS 一样显示今天的日期
|
||||
if (type === 'System.Windows.Forms.DateTimePicker') props.Text = previewDate(String(props.Format ?? 'Long'))
|
||||
return { id: name, type: clientType(type), name, props, children: childrenOf(doc, name).filter((c) => types.has(c)).map(build) }
|
||||
}
|
||||
const formProps = propsOf(doc, '')
|
||||
@@ -81,6 +83,13 @@ export function previewTree(doc: DesignerDocument): ControlNode {
|
||||
}
|
||||
}
|
||||
|
||||
function previewDate(format: string) {
|
||||
const now = new Date()
|
||||
const [y, m, d] = [now.getFullYear(), now.getMonth() + 1, now.getDate()]
|
||||
const time = `${now.getHours()}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`
|
||||
return format === 'Short' ? `${y}/${m}/${d}` : format === 'Time' ? time : `${y}年${m}月${d}日`
|
||||
}
|
||||
|
||||
/** 非可视组件(Timer 等),显示在窗体下方的组件栏 */
|
||||
export function componentsOf(doc: DesignerDocument) {
|
||||
return listControls(doc).filter((c) => c.parent === null && controlDef(c.type).component === true)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// 按需加载 Monaco(0.57 的模块布局):编辑器 API + 全部编辑功能(查找、折叠、括号匹配等)+ C# 语法高亮,
|
||||
// 按需加载 Monaco(0.57 的模块布局):编辑器 API + 全部编辑功能(查找、折叠、括号匹配等)+ C# 和 SQL 语法高亮,
|
||||
// 不加载其他几十种语言和 JS/TS/CSS 语言服务,体积小很多。
|
||||
import * as monaco from 'monaco-editor/editor/editor.api'
|
||||
import 'monaco-editor/features/register.all'
|
||||
import 'monaco-editor/languages/definitions/csharp/register'
|
||||
import 'monaco-editor/languages/definitions/sql/register'
|
||||
import EditorWorker from 'monaco-editor/editor/editor.worker?worker'
|
||||
|
||||
self.MonacoEnvironment = {
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
<script setup lang="ts">
|
||||
// 数据库页面(代替教材里的 SSMS):左边对象资源管理器,右边查询窗口、编辑前 200 行、表设计器的标签页。
|
||||
// 每个学生一套自己的数据库,长期保存;可以按实验恢复初始数据。
|
||||
import { NButton, NInput, NModal, NSelect, NTabPane, NTabs, useDialog, useMessage } from 'naive-ui'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { currentUser } from '../auth'
|
||||
import DesignTab from '../database/DesignTab.vue'
|
||||
import EditTab from '../database/EditTab.vue'
|
||||
import ObjectExplorer, { type ExplorerAction } from '../database/ObjectExplorer.vue'
|
||||
import QueryTab from '../database/QueryTab.vue'
|
||||
import { execute, loadExplorer, quoteName, type ExplorerDatabase, type ExplorerTable } from '../database/sql'
|
||||
|
||||
type Tab =
|
||||
| { key: string; kind: 'query'; title: string; database: string; sql?: string; run?: boolean }
|
||||
| { key: string; kind: 'edit'; title: string; database: string; table: ExplorerTable }
|
||||
| { key: string; kind: 'design'; title: string; database: string; table?: ExplorerTable }
|
||||
|
||||
/** 新开的标签页(编号由 open 分配):对联合类型的每一种分别去掉 key */
|
||||
type WithoutKey<T> = T extends unknown ? Omit<T, 'key'> : never
|
||||
type NewTab = WithoutKey<Tab>
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const databases = ref<ExplorerDatabase[]>([])
|
||||
const tabs = ref<Tab[]>([])
|
||||
const active = ref<string>()
|
||||
let nextKey = 1
|
||||
let queryCount = 0
|
||||
|
||||
const databaseNames = computed(() => databases.value.map((d) => d.name))
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
databases.value = (await loadExplorer()).databases
|
||||
} catch (error) {
|
||||
message.error((error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
function open(tab: NewTab) {
|
||||
const key = String(nextKey++)
|
||||
tabs.value.push({ ...tab, key } as Tab)
|
||||
active.value = key
|
||||
}
|
||||
|
||||
function newQuery(database = databaseNames.value[0] ?? 'master', sql = '', run = false, title?: string) {
|
||||
open({ kind: 'query', title: title ?? `SQLQuery${++queryCount}.sql`, database, sql, run })
|
||||
}
|
||||
|
||||
function close(key: string) {
|
||||
const index = tabs.value.findIndex((t) => t.key === key)
|
||||
tabs.value.splice(index, 1)
|
||||
if (active.value === key) active.value = tabs.value[Math.max(0, index - 1)]?.key
|
||||
}
|
||||
|
||||
// ---------- 对象资源管理器的操作 ----------
|
||||
|
||||
const creatingDatabase = ref<string | null>(null)
|
||||
|
||||
async function onAction(action: ExplorerAction) {
|
||||
switch (action.kind) {
|
||||
case 'refresh':
|
||||
await refresh()
|
||||
break
|
||||
case 'newDatabase':
|
||||
creatingDatabase.value = ''
|
||||
break
|
||||
case 'newQuery':
|
||||
newQuery(action.database)
|
||||
break
|
||||
case 'newTable':
|
||||
open({ kind: 'design', title: `${action.database} - 新表`, database: action.database })
|
||||
break
|
||||
case 'design':
|
||||
open({ kind: 'design', title: `${action.database} - dbo.${action.table.name}`, database: action.database, table: action.table })
|
||||
break
|
||||
case 'editRows':
|
||||
open({ kind: 'edit', title: `${action.database} - dbo.${action.table.name}`, database: action.database, table: action.table })
|
||||
break
|
||||
case 'selectRows': {
|
||||
// 和 SSMS 生成的语句格式一样
|
||||
const columns = action.table.columns.map((c) => quoteName(c.name)).join('\n ,')
|
||||
newQuery(action.database, `SELECT TOP 1000 ${columns}\n FROM [dbo].${quoteName(action.table.name)}`, true)
|
||||
break
|
||||
}
|
||||
case 'dropDatabase':
|
||||
confirmDrop(`删除数据库"${action.database}"?库里的所有表和数据都会被删除,不能恢复。`, 'master', `DROP DATABASE ${quoteName(action.database)}`, () => {
|
||||
tabs.value = tabs.value.filter((t) => t.database !== action.database)
|
||||
})
|
||||
break
|
||||
case 'dropTable':
|
||||
confirmDrop(`删除表"dbo.${action.table.name}"?表里的数据会一起删除,不能恢复。`, action.database, `DROP TABLE ${quoteName(action.table.name)}`, () => {
|
||||
tabs.value = tabs.value.filter((t) => !(t.database === action.database && 'table' in t && t.table?.name === action.table.name))
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDrop(content: string, database: string, sql: string, after: () => void) {
|
||||
dialog.warning({
|
||||
title: '删除对象',
|
||||
content,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await execute(database, sql)
|
||||
after()
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
message.error((error as Error).message)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function createDatabase() {
|
||||
const name = creatingDatabase.value?.trim()
|
||||
if (!name) return
|
||||
try {
|
||||
await execute('master', `CREATE DATABASE ${quoteName(name)}`)
|
||||
creatingDatabase.value = null
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
message.error((error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
/** 表设计器保存后:刷新树,标签页换成已保存的表 */
|
||||
async function designSaved(tab: Tab, table: string) {
|
||||
await refresh()
|
||||
const saved = databases.value.find((d) => d.name === tab.database)?.tables.find((t) => t.name === table)
|
||||
if (saved && tab.kind === 'design') {
|
||||
tab.table = saved
|
||||
tab.title = `${tab.database} - dbo.${saved.name}`
|
||||
tab.key = `${tab.key}*`
|
||||
active.value = tab.key
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 恢复实验初始数据 ----------
|
||||
|
||||
const experiments = ref<{ id: string; title: string }[]>([])
|
||||
const restoring = ref<string | null>(null)
|
||||
const restoreBusy = ref(false)
|
||||
|
||||
async function openRestore() {
|
||||
experiments.value = await api('GET', '/api/db/experiments')
|
||||
restoring.value = experiments.value[0]?.id ?? null
|
||||
}
|
||||
|
||||
async function restore() {
|
||||
if (!restoring.value) return
|
||||
restoreBusy.value = true
|
||||
try {
|
||||
const { databases: restored } = await api<{ databases: string[] }>('POST', '/api/db/restore', { sampleId: restoring.value })
|
||||
message.success(`已恢复:${restored.join('、') || '(这个实验没有数据库)'}`)
|
||||
restoring.value = null
|
||||
tabs.value = tabs.value.filter((t) => !restored.includes(t.database))
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
message.error((error as Error).message)
|
||||
} finally {
|
||||
restoreBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<header class="toolbar">
|
||||
<RouterLink to="/" class="nav-link">← 返回实验</RouterLink>
|
||||
<strong>数据库</strong>
|
||||
<NButton size="small" type="primary" @click="newQuery()">新建查询</NButton>
|
||||
<NButton size="small" @click="creatingDatabase = ''">新建数据库</NButton>
|
||||
<NButton size="small" @click="openRestore">恢复实验初始数据</NButton>
|
||||
<span class="spacer" />
|
||||
<span class="user">{{ currentUser?.name }}的数据库(只有自己能看到)</span>
|
||||
</header>
|
||||
<div class="workspace">
|
||||
<ObjectExplorer class="explorer" :databases="databases" @action="onAction" />
|
||||
<main class="documents">
|
||||
<NTabs v-if="tabs.length" v-model:value="active" type="card" size="small" closable class="tabs" @close="close">
|
||||
<NTabPane v-for="tab in tabs" :key="tab.key" :name="tab.key" :tab="tab.title" display-directive="show">
|
||||
<QueryTab
|
||||
v-if="tab.kind === 'query'"
|
||||
v-model:database="tab.database"
|
||||
:databases="databaseNames"
|
||||
:initial-sql="tab.sql"
|
||||
:run-on-open="tab.run"
|
||||
@changed="refresh"
|
||||
/>
|
||||
<EditTab v-else-if="tab.kind === 'edit'" :database="tab.database" :table="tab.table" />
|
||||
<DesignTab v-else :key="tab.key" :database="tab.database" :table="tab.table" @saved="designSaved(tab, $event)" />
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
<div v-else class="empty">
|
||||
<p>右键左边的"数据库"新建数据库;右键表可以编辑数据、设计表结构。</p>
|
||||
<p>也可以点"新建查询",用 SQL 语句建库、建表、增删改查。</p>
|
||||
<p>做教材项目 6 的任务 2、3 前,可以点"恢复实验初始数据"准备好"环境数据表"库。</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<NModal :show="creatingDatabase !== null" preset="card" title="新建数据库" style="width: 380px" @update:show="creatingDatabase = null">
|
||||
<div style="margin-bottom: 8px">数据库名称:</div>
|
||||
<NInput v-if="creatingDatabase !== null" v-model:value="creatingDatabase" autofocus placeholder="例如:环境数据表" @keydown.enter="createDatabase" />
|
||||
<template #footer>
|
||||
<div class="dialog-buttons">
|
||||
<NButton type="primary" :disabled="!creatingDatabase?.trim()" @click="createDatabase">确定</NButton>
|
||||
<NButton @click="creatingDatabase = null">取消</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
|
||||
<NModal :show="restoring !== null" preset="card" title="恢复实验初始数据" style="width: 460px" @update:show="restoring = null">
|
||||
<NSelect v-model:value="restoring" :options="experiments.map((e) => ({ label: e.title, value: e.id }))" />
|
||||
<p class="warning">会用实验的初始数据替换实验用到的数据库(如"环境数据表"),你在这些库里做的改动会丢失;其他数据库不受影响。</p>
|
||||
<template #footer>
|
||||
<div class="dialog-buttons">
|
||||
<NButton type="warning" :loading="restoreBusy" :disabled="!restoring" @click="restore">恢复</NButton>
|
||||
<NButton @click="restoring = null">取消</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user {
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #2563eb;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.explorer {
|
||||
width: 280px;
|
||||
flex: none;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.documents {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tabs :deep(.n-tabs-pane-wrapper) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tabs :deep(.n-tab-pane) {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: auto;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.dialog-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: #b45309;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.workspace {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.explorer {
|
||||
width: auto;
|
||||
max-height: 40vh;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -409,6 +409,7 @@ function run() {
|
||||
</span>
|
||||
<span v-else class="spacer" />
|
||||
<RouterLink v-if="!assignmentId && currentUser?.role === 'student'" to="/assignments" class="nav-link">我的作业</RouterLink>
|
||||
<RouterLink v-if="!viewing" to="/database" target="_blank" class="nav-link" title="在新标签页打开数据库(代替 SSMS)">数据库</RouterLink>
|
||||
<span class="user">
|
||||
{{ currentUser?.name }}<template v-if="currentUser?.className">({{ currentUser.className }})</template>
|
||||
</span>
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
// 页面路由:登录页、实验页(自由练习、做作业、老师运行学生的提交共用)、学生的作业列表、老师的班级管理页和评阅页。
|
||||
// 页面路由:登录页、实验页(自由练习、做作业、老师运行学生的提交共用)、学生的作业列表、数据库页面、老师的班级管理页和评阅页。
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { apiHooks } from './api'
|
||||
import { currentUser } from './auth'
|
||||
@@ -9,6 +9,7 @@ export const router = createRouter({
|
||||
{ path: '/login', component: () => import('./pages/LoginPage.vue'), meta: { public: true } },
|
||||
{ path: '/', component: () => import('./pages/WorkbenchPage.vue') },
|
||||
{ path: '/assignments', component: () => import('./pages/AssignmentsPage.vue') },
|
||||
{ path: '/database', component: () => import('./pages/DatabasePage.vue') },
|
||||
{ path: '/assignments/:id', component: () => import('./pages/WorkbenchPage.vue'), props: (route) => ({ assignmentId: String(route.params.id) }) },
|
||||
{ path: '/teacher', component: () => import('./pages/TeacherPage.vue'), meta: { teacher: true } },
|
||||
{
|
||||
|
||||
@@ -5,6 +5,8 @@ import { computed, inject, ref, type CSSProperties } from 'vue'
|
||||
import type { ControlNode, UserAction } from '@iot/shared'
|
||||
import { boxCss, displayText } from './format'
|
||||
import { RUN_IMAGES, type ImageValue } from './images'
|
||||
import DataGridViewControl from './DataGridViewControl.vue'
|
||||
import DateTimePickerControl from './DateTimePickerControl.vue'
|
||||
import LedControl from './LedControl.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -162,6 +164,10 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
|
||||
<img v-if="imageUrl" :src="imageUrl" :style="imageStyle" alt="" draggable="false" />
|
||||
</div>
|
||||
|
||||
<DataGridViewControl v-else-if="node.type === 'DataGridView'" :node="node" :style="style" :enabled="enabled" :act="act" />
|
||||
|
||||
<DateTimePickerControl v-else-if="node.type === 'DateTimePicker'" :node="node" :style="style" :enabled="enabled" :act="act" />
|
||||
|
||||
<!-- CheckBox / RadioButton:自绘勾选框,状态完全由运行器决定(点击只上报,不在浏览器里自行切换) -->
|
||||
<div
|
||||
v-else-if="node.type === 'CheckBox' || node.type === 'RadioButton'"
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
// 数据表格:显示运行器发来的列和行;单元格的值、当前单元格、选中的行都以运行器为准。
|
||||
// 编辑:双击、按 F2 或直接打字开始,回车或离开时提交(运行器按列的类型转换后写回),Esc 取消。
|
||||
import { computed, nextTick, ref, type CSSProperties } from 'vue'
|
||||
import type { ControlNode, UserAction } from '@iot/shared'
|
||||
|
||||
interface Column {
|
||||
name: string
|
||||
header: string
|
||||
width: number
|
||||
visible: boolean
|
||||
readOnly: boolean
|
||||
check: boolean
|
||||
fill: boolean
|
||||
}
|
||||
|
||||
interface Rows {
|
||||
items: { cells: (string | boolean)[]; isNew: boolean }[]
|
||||
total: number
|
||||
height: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
node: ControlNode
|
||||
style: CSSProperties
|
||||
enabled: boolean
|
||||
act: (action: UserAction) => void
|
||||
}>()
|
||||
|
||||
const p = computed(() => props.node.props)
|
||||
const columns = computed(() => ((p.value.Columns as Column[] | undefined) ?? []).map((c, index) => ({ ...c, index })).filter((c) => c.visible))
|
||||
const rows = computed(() => (p.value.Rows as Rows | undefined) ?? { items: [], total: 0, height: 23 })
|
||||
const current = computed(() => (p.value.CurrentCell as [number, number] | null | undefined) ?? null)
|
||||
const selectedRows = computed(() => new Set((p.value.SelectedRows as number[] | undefined) ?? []))
|
||||
const rowHeaders = computed(() => p.value.RowHeadersVisible !== false)
|
||||
const columnHeaders = computed(() => p.value.ColumnHeadersVisible !== false)
|
||||
const hasFill = computed(() => columns.value.some((c) => c.fill))
|
||||
|
||||
const editing = ref<{ row: number; column: number; value: string } | null>(null)
|
||||
const editor = ref<HTMLInputElement[]>([])
|
||||
|
||||
function isCurrent(row: number, column: number) {
|
||||
return current.value?.[0] === row && current.value?.[1] === column
|
||||
}
|
||||
|
||||
function click(row: number, column: number) {
|
||||
if (!props.enabled) return
|
||||
commit()
|
||||
props.act({ action: 'cellClick', row, column })
|
||||
const col = columns.value.find((c) => c.index === column)
|
||||
const cell = rows.value.items[row]?.cells[column]
|
||||
// 复选框列:点一下就切换
|
||||
if (col?.check && !col.readOnly && p.value.ReadOnly !== true) props.act({ action: 'cellEdit', row, column, value: cell === true ? 'False' : 'True' })
|
||||
}
|
||||
|
||||
function startEdit(row: number, column: number, initial?: string) {
|
||||
const col = columns.value.find((c) => c.index === column)
|
||||
if (!props.enabled || !col || col.readOnly || col.check || p.value.ReadOnly === true) return
|
||||
const cell = rows.value.items[row]?.cells[column]
|
||||
editing.value = { row, column, value: initial ?? String(cell ?? '') }
|
||||
void nextTick(() => {
|
||||
const input = editor.value[0]
|
||||
input?.focus()
|
||||
if (initial === undefined) input?.select()
|
||||
})
|
||||
}
|
||||
|
||||
function commit() {
|
||||
const edit = editing.value
|
||||
if (!edit) return
|
||||
editing.value = null
|
||||
const original = String(rows.value.items[edit.row]?.cells[edit.column] ?? '')
|
||||
if (edit.value !== original || rows.value.items[edit.row]?.isNew) {
|
||||
if (edit.value !== '' || !rows.value.items[edit.row]?.isNew) props.act({ action: 'cellEdit', row: edit.row, column: edit.column, value: edit.value })
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
editing.value = null
|
||||
}
|
||||
|
||||
/** 表格获得焦点时:方向键移动当前单元格,F2 / 回车编辑,直接打字开始编辑 */
|
||||
function keydown(event: KeyboardEvent) {
|
||||
if (editing.value || !current.value) return
|
||||
const [row, column] = current.value
|
||||
const visible = columns.value.map((c) => c.index)
|
||||
const at = visible.indexOf(column)
|
||||
const move = (r: number, c: number) => {
|
||||
if (r >= 0 && r < rows.value.items.length && c >= 0) props.act({ action: 'cellClick', row: r, column: c })
|
||||
}
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
move(row + 1, column)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
move(row - 1, column)
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
if (at > 0) move(row, visible[at - 1])
|
||||
break
|
||||
case 'ArrowRight':
|
||||
if (at < visible.length - 1) move(row, visible[at + 1])
|
||||
break
|
||||
case 'F2':
|
||||
case 'Enter':
|
||||
startEdit(row, column)
|
||||
break
|
||||
default:
|
||||
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
|
||||
startEdit(row, column, event.key)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
function columnStyle(column: Column): CSSProperties {
|
||||
return column.fill ? {} : { width: `${column.width}px`, minWidth: `${column.width}px`, maxWidth: `${column.width}px` }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="wf-control wf-dgv"
|
||||
:class="{ 'wf-disabled': !enabled }"
|
||||
:style="{ ...style, backgroundColor: (p.BackgroundColor as string | null) ?? '#ababab' }"
|
||||
tabindex="0"
|
||||
@keydown="keydown"
|
||||
>
|
||||
<table v-if="columns.length" class="wf-dgv-table" :class="{ 'wf-dgv-fill': hasFill }">
|
||||
<thead v-if="columnHeaders">
|
||||
<tr>
|
||||
<th v-if="rowHeaders" class="wf-dgv-corner" />
|
||||
<th v-for="column in columns" :key="column.index" :style="columnStyle(column)">{{ column.header }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(row, r) in rows.items"
|
||||
:key="r"
|
||||
:class="{ 'wf-dgv-selected': selectedRows.has(r) }"
|
||||
:style="{ height: `${rows.height}px` }"
|
||||
>
|
||||
<th v-if="rowHeaders" class="wf-dgv-rowheader" @click="click(r, -1)">
|
||||
{{ current?.[0] === r ? '▶' : row.isNew ? '*' : '' }}
|
||||
</th>
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="column.index"
|
||||
:class="{ 'wf-dgv-current': isCurrent(r, column.index) }"
|
||||
:style="columnStyle(column)"
|
||||
@click="click(r, column.index)"
|
||||
@dblclick="act({ action: 'cellDoubleClick', row: r, column: column.index }); startEdit(r, column.index)"
|
||||
>
|
||||
<input
|
||||
v-if="editing && editing.row === r && editing.column === column.index"
|
||||
ref="editor"
|
||||
v-model="editing.value"
|
||||
class="wf-dgv-editor"
|
||||
@keydown.enter.stop.prevent="commit"
|
||||
@keydown.esc.stop.prevent="cancel"
|
||||
@keydown.stop
|
||||
@blur="commit"
|
||||
/>
|
||||
<span v-else-if="column.check" class="wf-checkbox-box wf-dgv-check" :data-checked="row.cells[column.index] === true" />
|
||||
<template v-else>{{ row.cells[column.index] }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="rows.total > rows.items.length" class="wf-dgv-more">(共 {{ rows.total }} 行,只显示前 {{ rows.items.length }} 行)</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
// 日期时间选择器:文字由运行器按 Format 生成(如 2024年1月16日),点下拉按钮弹出浏览器的日期 / 时间选择框,
|
||||
// 选好后把完整的日期时间发给运行器(只选日期时保留原来的时间,和 WinForms 一样)。
|
||||
import { computed, ref, type CSSProperties } from 'vue'
|
||||
import type { ControlNode, UserAction } from '@iot/shared'
|
||||
|
||||
const props = defineProps<{
|
||||
node: ControlNode
|
||||
style: CSSProperties
|
||||
enabled: boolean
|
||||
act: (action: UserAction) => void
|
||||
}>()
|
||||
|
||||
const p = computed(() => props.node.props)
|
||||
const value = computed(() => String(p.value.Value ?? '2000-01-01T00:00:00'))
|
||||
const hasDate = computed(() => p.value.HasDate !== false)
|
||||
const hasTime = computed(() => p.value.HasTime === true)
|
||||
const inputType = computed(() => (hasDate.value && hasTime.value ? 'datetime-local' : hasTime.value ? 'time' : 'date'))
|
||||
const nativeValue = computed(() => {
|
||||
const [date, time] = value.value.split('T')
|
||||
return inputType.value === 'date' ? date : inputType.value === 'time' ? time : value.value
|
||||
})
|
||||
const picker = ref<HTMLInputElement>()
|
||||
|
||||
function open() {
|
||||
if (!props.enabled) return
|
||||
props.act({ action: 'dropDown' })
|
||||
picker.value?.showPicker?.()
|
||||
}
|
||||
|
||||
function pick(event: Event) {
|
||||
const picked = (event.target as HTMLInputElement).value
|
||||
if (!picked) return
|
||||
const [date, time] = value.value.split('T')
|
||||
const withSeconds = (t: string) => (t.length === 5 ? `${t}:00` : t)
|
||||
const next =
|
||||
inputType.value === 'date' ? `${picked}T${time}` : inputType.value === 'time' ? `${date}T${withSeconds(picked)}` : withSeconds(picked)
|
||||
props.act({ action: 'pick', value: next })
|
||||
}
|
||||
|
||||
/** ShowUpDown 的上下箭头:日期格式按天、时间格式按分钟调整 */
|
||||
function step(delta: number) {
|
||||
if (!props.enabled) return
|
||||
const d = new Date(value.value)
|
||||
if (hasTime.value && !hasDate.value) d.setMinutes(d.getMinutes() + delta)
|
||||
else d.setDate(d.getDate() + delta)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
props.act({
|
||||
action: 'pick',
|
||||
value: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wf-control wf-dtp" :class="{ 'wf-disabled': !enabled }" :style="style">
|
||||
<span class="wf-dtp-text">{{ p.Text }}</span>
|
||||
<span v-if="p.ShowUpDown" class="wf-dtp-spin">
|
||||
<span @mousedown.prevent="step(1)">▴</span>
|
||||
<span @mousedown.prevent="step(-1)">▾</span>
|
||||
</span>
|
||||
<span v-else class="wf-dtp-button" title="选择日期" @mousedown.prevent="open">▾</span>
|
||||
<input ref="picker" class="wf-dtp-native" :type="inputType" step="1" :value="nativeValue" tabindex="-1" @change="pick" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -171,6 +171,136 @@
|
||||
border: 2px inset #a0a0a0;
|
||||
}
|
||||
|
||||
/* DataGridView:灰色背景上的白色表格,蓝色选中行,当前单元格虚线框 */
|
||||
.wf-dgv {
|
||||
overflow: auto;
|
||||
border: 1px solid #646464;
|
||||
outline: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wf-dgv-table {
|
||||
border-collapse: collapse;
|
||||
background: #fff;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.wf-dgv-table.wf-dgv-fill {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wf-dgv-table th,
|
||||
.wf-dgv-table td {
|
||||
border: 1px solid #a0a0a0;
|
||||
padding: 0 3px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: normal;
|
||||
text-align: left;
|
||||
height: inherit;
|
||||
}
|
||||
|
||||
.wf-dgv-table thead th {
|
||||
height: 23px;
|
||||
background: #fff;
|
||||
border-color: #dadada #a0a0a0 #a0a0a0 #dadada;
|
||||
}
|
||||
|
||||
.wf-dgv-corner,
|
||||
.wf-dgv-rowheader {
|
||||
width: 41px;
|
||||
min-width: 41px;
|
||||
max-width: 41px;
|
||||
background: #fff;
|
||||
font-size: 9px;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.wf-dgv-selected td,
|
||||
.wf-dgv-selected .wf-dgv-rowheader {
|
||||
background: #0078d7;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wf-dgv-current {
|
||||
outline: 1px dotted #000;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.wf-dgv-editor {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: #000;
|
||||
background: #fff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.wf-dgv-check {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.wf-dgv-more {
|
||||
padding: 2px 4px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wf-dtp {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid #7a7a7a;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wf-dtp-text {
|
||||
flex: 1;
|
||||
padding: 0 3px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wf-dtp-button {
|
||||
width: 17px;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
border-left: 1px solid #dadada;
|
||||
}
|
||||
|
||||
.wf-dtp-spin {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 15px;
|
||||
align-self: stretch;
|
||||
font-size: 8px;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wf-dtp-spin span {
|
||||
flex: 1;
|
||||
border-left: 1px solid #dadada;
|
||||
}
|
||||
|
||||
.wf-dtp-native {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wf-listbox {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
Reference in new issue
Block a user