项目 6 数据存储:仿冒 SqlClient(T-SQL 转写成 SQLite)、数据库页面、DataGridView、DateTimePicker
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s

- 每个学生一套数据库,长期保存(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:
xuyueandClaude Opus 5.5 committed 2026-10-23 09:18:56 +08:00
1 parent 6b088240da
commit 670abe5921
72 files changed
+7253 -51

No files matched your search

+290
View File
@@ -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>
+288
View File
@@ -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>
+209
View File
@@ -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>
+159
View File
@@ -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>
+76
View File
@@ -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>
+68
View File
@@ -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(['命令已成功完成。'])
})
+123
View File
@@ -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
}
+1 -1
View File
@@ -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 显示屏': '▬', 灯泡: '💡', 报警灯: '🚨',
+54 -1
View File
@@ -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: '复选框',
+9
View File
@@ -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)
+2 -1
View File
@@ -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 = {
+333
View File
@@ -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>
+1
View File
@@ -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
View File
@@ -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 } },
{
+6
View File
@@ -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'"
+175
View File
@@ -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>
+130
View File
@@ -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;