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