项目 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

+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;