用 bun fmt 格式化全部代码和文档
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s

- bunfig.toml 的 [fmt]:不写分号、单引号、一行最长 160,和原来的写法一致
- .prettierignore:docs/教材摘要/ 是书上的摘录,不格式化
- 模板里一行写两条语句的事件处理(@keydown.delete、@dblclick)改成调用方法:去掉分号后拆成两行,Vue 编译不过

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 14:02:33 +08:00
1 parent d4b8bf9241
commit de277d3e9d
102 files changed
+1971 -1059

No files matched your search

+8 -2
View File
@@ -22,10 +22,16 @@
if (!app || app.hasAttribute('data-v-app')) return
app.innerHTML =
'<div style="max-width:560px;margin:80px auto;padding:0 16px;font:16px/1.8 sans-serif;color:#333">' +
'<h2 style="margin:0 0 12px">' + title + '</h2><p>' + text + '</p>' +
'<h2 style="margin:0 0 12px">' +
title +
'</h2><p>' +
text +
'</p>' +
'<p>用 360 浏览器、QQ 浏览器时,请点地址栏右边的闪电图标,切换到<b>极速模式</b>;或者换用新版的 <b>Chrome</b>、<b>Edge</b>。' +
'还是打不开的话,请把下面这行文字告诉老师:</p>' +
'<p style="font-size:13px;color:#888;word-break:break-all">' + navigator.userAgent.replace(/</g, '&lt;') + '</p></div>'
'<p style="font-size:13px;color:#888;word-break:break-all">' +
navigator.userAgent.replace(/</g, '&lt;') +
'</p></div>'
}
window.addEventListener('error', function (event) {
if (event && event.error instanceof SyntaxError) {
+1 -1
View File
@@ -16,6 +16,6 @@ import { NConfigProvider, NDialogProvider, NMessageProvider, zhCN } from 'naive-
<style>
body {
margin: 0;
font-family: system-ui, "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
font-family: system-ui, 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif;
}
</style>
+6 -1
View File
@@ -82,7 +82,12 @@ function problem(): string | 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() })))
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()
+1 -1
View File
@@ -233,7 +233,7 @@ defineExpose({ reload: load })
.grid {
border-collapse: collapse;
font-size: 12px;
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
font-family: 'Microsoft YaHei', 'PingFang SC', 'Noto Sans CJK SC', sans-serif;
background: #fff;
}
+20 -7
View File
@@ -47,7 +47,11 @@ function choose(key: string) {
const REFRESH: ExplorerAction = { kind: 'refresh' }
function rootMenu(event: MouseEvent) {
openMenu(event, [['新建数据库…', { kind: 'newDatabase' }], ['-', REFRESH], ['刷新', REFRESH]])
openMenu(event, [
['新建数据库…', { kind: 'newDatabase' }],
['-', REFRESH],
['刷新', REFRESH],
])
}
function databaseMenu(event: MouseEvent, database: string) {
@@ -61,7 +65,11 @@ function databaseMenu(event: MouseEvent, database: string) {
}
function tablesMenu(event: MouseEvent, database: string) {
openMenu(event, [['新建表…', { kind: 'newTable', database }], ['-', REFRESH], ['刷新', REFRESH]])
openMenu(event, [
['新建表…', { kind: 'newTable', database }],
['-', REFRESH],
['刷新', REFRESH],
])
}
function tableMenu(event: MouseEvent, database: string, table: ExplorerTable) {
@@ -88,18 +96,21 @@ function columnText(column: ExplorerTable['columns'][number]) {
<ul class="tree">
<li>
<div class="node" @click="toggle('root')" @contextmenu="rootMenu">
<span class="arrow">{{ expanded.has('root') ? '▾' : '▸' }}</span>📁 数据库
<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 }}
<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>📁 表
<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>
@@ -110,12 +121,14 @@ function columnText(column: ExplorerTable['columns'][number]) {
@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 }}
<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>📁 列
<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">
+1 -1
View File
@@ -34,7 +34,7 @@ defineProps<{ result: ResultSet }>()
.grid {
border-collapse: collapse;
font-size: 12px;
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
font-family: 'Microsoft YaHei', 'PingFang SC', 'Noto Sans CJK SC', sans-serif;
background: #fff;
}
+24 -8
View File
@@ -29,7 +29,7 @@ describe('编辑表数据生成的 T-SQL', () => {
})
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(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')
})
@@ -38,9 +38,16 @@ describe('编辑表数据生成的 T-SQL', () => {
})
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([
[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')
})
})
@@ -53,16 +60,25 @@ describe('表设计器生成的 T-SQL', () => {
{ 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)')
).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')
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: '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(['命令已成功完成。'])
})
+25 -5
View File
@@ -27,8 +27,7 @@ export interface ResultSet {
}
export type QueryReply =
| { type: 'result'; database: string; results: ResultSet[]; affected: number; ms: number }
| { type: 'error'; number: number; message: string; line: number }
{ 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 })
@@ -89,9 +88,30 @@ export interface DesignColumn {
/** 设计器"数据类型"下拉里的常用类型(和 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',
'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) {
+5 -2
View File
@@ -33,7 +33,10 @@ function nextName(prefix: string, taken: string[]) {
function addSeries() {
const d = draft.value
d.series.push({
name: nextName('Series', d.series.map((s) => s.name)),
name: nextName(
'Series',
d.series.map((s) => s.name),
),
chartType: 'Column',
chartArea: d.areas[0] ?? '',
legend: d.legends[0] ?? '',
@@ -93,7 +96,7 @@ function save() {
</section>
<section v-if="series" class="fields">
<label title="Name:代码里用 chart1.Series[&quot;名称&quot;] 访问这个系列">名称</label>
<label title='Name:代码里用 chart1.Series["名称"] 访问这个系列'>名称</label>
<input v-model="series.name" />
<label title="ChartType:画成折线、柱形还是饼图">图表类型</label>
<select v-model="series.chartType">
+76 -19
View File
@@ -172,7 +172,12 @@ const surface = ref<HTMLElement>()
// ---------- 缩放:教材项目 8 的窗体有 1043×791,"适应"时按画布宽度缩小(不放大) ----------
type ZoomChoice = 'fit' | '1' | '0.75' | '0.5'
const ZOOM_KEY = 'iot-sim:designer-zoom'
const ZOOM_OPTIONS: [ZoomChoice, string][] = [['fit', '适应宽度'], ['1', '100%'], ['0.75', '75%'], ['0.5', '50%']]
const ZOOM_OPTIONS: [ZoomChoice, string][] = [
['fit', '适应宽度'],
['1', '100%'],
['0.75', '75%'],
['0.5', '50%'],
]
const zoomChoice = ref<ZoomChoice>(readZoom())
function readZoom(): ZoomChoice {
try {
@@ -325,9 +330,25 @@ function onKeyDown(event: KeyboardEvent) {
// ---------- 工具箱 ----------
const TOOLBOX_GROUPS = ['常用控件', '容器', '数据', '组件', '物联网设备'] as const
const ICONS: Record<string, string> = {
按钮: '▭', 标签: 'A', 文本框: '▤', 下拉框: '☰', 列表框: '≣', 图片框: '🖼', 日期时间选择器: '📅', 滑动条: '⊶',
复选框: '☑', 单选按钮: '◉', 分组框: '⬚', 面板: '▢', 数据表格: '▦', 图表: '📈',
定时器: '⏱', 媒体播放器: '🔊', 'LED 显示屏': '▬', 灯泡: '💡', 报警灯: '🚨',
按钮: '▭',
标签: 'A',
文本框: '▤',
下拉框: '☰',
列表框: '≣',
图片框: '🖼',
日期时间选择器: '📅',
滑动条: '⊶',
复选框: '☑',
单选按钮: '◉',
分组框: '⬚',
面板: '▢',
数据表格: '▦',
图表: '📈',
定时器: '⏱',
媒体播放器: '🔊',
'LED 显示屏': '▬',
灯泡: '💡',
报警灯: '🚨',
}
const DRAG_TYPE = 'application/x-winforms-control'
@@ -437,6 +458,11 @@ function onDoubleClick(event: MouseEvent) {
openEvent(hit, def.defaultEvent)
}
function deleteComponent(name: string) {
commit(removeControl(parsed.value.doc!, name))
selected.value = ''
}
function onComponentDoubleClick(name: string) {
const d = parsed.value.doc
if (d) openEvent(name, controlDef(typeOf(d, name) ?? '').defaultEvent)
@@ -475,7 +501,8 @@ const noop = () => {}
@dragstart="onToolDragStart($event, def)"
@dblclick="onToolDoubleClick(def)"
>
<span class="tool-icon">{{ ICONS[def.label] ?? '▫' }}</span>{{ def.label }}
<span class="tool-icon">{{ ICONS[def.label] ?? '▫' }}</span
>{{ def.label }}
</div>
</template>
</aside>
@@ -495,9 +522,7 @@ const noop = () => {}
{{ value === 'fit' ? `${label}(${Math.round(zoom * 100)}%)` : label }}
</option>
</select>
<button class="view-code" title="设计器根据窗体上的控件自动生成的代码(Designer.cs)" @click="emit('openDesignerFile')">
查看生成的代码
</button>
<button class="view-code" title="设计器根据窗体上的控件自动生成的代码(Designer.cs)" @click="emit('openDesignerFile')">查看生成的代码</button>
</div>
<div class="window" :class="{ selected: selected === '' }" :style="zoom !== 1 ? { zoom } : undefined">
<div class="titlebar" @pointerdown="selected = ''">
@@ -543,7 +568,7 @@ const noop = () => {}
:class="{ active: selected === c.name }"
@click="selected = c.name"
@dblclick="onComponentDoubleClick(c.name)"
@keydown.delete="commit(removeControl(parsed.doc!, c.name)); selected = ''"
@keydown.delete="deleteComponent(c.name)"
>
⏱ {{ c.name }}
</button>
@@ -694,7 +719,7 @@ const noop = () => {}
height: 30px;
padding: 0 8px;
background: #fff;
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
font-family: 'Microsoft YaHei', 'PingFang SC', 'Noto Sans CJK SC', sans-serif;
font-size: 12px;
user-select: none;
}
@@ -707,7 +732,7 @@ const noop = () => {}
.client {
position: relative;
overflow: hidden;
font-family: "SimSun", "Songti SC", "Noto Serif CJK SC", "Noto Serif SC", serif;
font-family: 'SimSun', 'Songti SC', 'Noto Serif CJK SC', 'Noto Serif SC', serif;
font-size: 12px;
}
@@ -734,14 +759,46 @@ const noop = () => {}
pointer-events: auto;
}
.handle.nw { left: -4px; top: -4px; cursor: nwse-resize; }
.handle.n { left: calc(50% - 4px); top: -4px; cursor: ns-resize; }
.handle.ne { right: -4px; top: -4px; cursor: nesw-resize; }
.handle.e { right: -4px; top: calc(50% - 4px); cursor: ew-resize; }
.handle.se { right: -4px; bottom: -4px; cursor: nwse-resize; }
.handle.s { left: calc(50% - 4px); bottom: -4px; cursor: ns-resize; }
.handle.sw { left: -4px; bottom: -4px; cursor: nesw-resize; }
.handle.w { left: -4px; top: calc(50% - 4px); cursor: ew-resize; }
.handle.nw {
left: -4px;
top: -4px;
cursor: nwse-resize;
}
.handle.n {
left: calc(50% - 4px);
top: -4px;
cursor: ns-resize;
}
.handle.ne {
right: -4px;
top: -4px;
cursor: nesw-resize;
}
.handle.e {
right: -4px;
top: calc(50% - 4px);
cursor: ew-resize;
}
.handle.se {
right: -4px;
bottom: -4px;
cursor: nwse-resize;
}
.handle.s {
left: calc(50% - 4px);
bottom: -4px;
cursor: ns-resize;
}
.handle.sw {
left: -4px;
bottom: -4px;
cursor: nesw-resize;
}
.handle.w {
left: -4px;
top: calc(50% - 4px);
cursor: ew-resize;
}
.form-resize {
position: absolute;
+131 -40
View File
@@ -56,7 +56,7 @@ function set(prop: PropDef, value: Value) {
/** 图片属性:项目资源里的名字,没有设置时为空 */
function imageOf(prop: PropDef) {
const text = getPropText(props.doc, props.selected, prop.name)
return text === undefined ? '' : resourceNameOf(text) ?? text
return text === undefined ? '' : (resourceNameOf(text) ?? text)
}
function setImage(prop: PropDef, choice: string, select: HTMLSelectElement) {
@@ -77,10 +77,14 @@ function reset(prop: PropDef) {
const nameDraft = ref('')
const nameError = ref<string | null>(null)
// 切换选中的控件或切换到另一个窗体时刷新
watch([() => props.selected, () => props.doc.formName], () => {
nameDraft.value = props.selected || props.doc.formName
nameError.value = null
}, { immediate: true })
watch(
[() => props.selected, () => props.doc.formName],
() => {
nameDraft.value = props.selected || props.doc.formName
nameError.value = null
},
{ immediate: true },
)
function commitName() {
const name = nameDraft.value.trim()
@@ -151,13 +155,24 @@ function setFont(prop: PropDef, change: Partial<{ family: string; size: number;
set(prop, { ...base, ...change })
}
const itemsText = computed(() => getItems(props.doc, props.selected).map((item) => {
const v = parseValue(item)
return v.kind === 'string' ? v.value : item
}).join('\n'))
const itemsText = computed(() =>
getItems(props.doc, props.selected)
.map((item) => {
const v = parseValue(item)
return v.kind === 'string' ? v.value : item
})
.join('\n'),
)
function setItemsFromText(text: string) {
emit('setItems', props.selected, text.split('\n').map((s) => s.trimEnd()).filter((s) => s !== ''))
emit(
'setItems',
props.selected,
text
.split('\n')
.map((s) => s.trimEnd())
.filter((s) => s !== ''),
)
}
const handlerOf = (event: string) => getEventHandler(props.doc, props.selected, event) ?? ''
@@ -193,35 +208,80 @@ function renameHandler(event: string, value: string) {
<div class="editor">
<code v-if="prop.type !== 'image' && prop.type !== 'chart' && raw(prop) !== undefined" class="raw" :title="raw(prop)">{{ raw(prop) }}</code>
<input v-else-if="prop.type === 'string'" :value="str(prop)" @change="set(prop, { kind: 'string', value: ($event.target as HTMLInputElement).value })" />
<input
v-else-if="prop.type === 'string'"
:value="str(prop)"
@change="set(prop, { kind: 'string', value: ($event.target as HTMLInputElement).value })"
/>
<textarea v-else-if="prop.type === 'multiline'" rows="2" :value="str(prop)"
@change="set(prop, { kind: 'string', value: ($event.target as HTMLTextAreaElement).value })" />
<textarea
v-else-if="prop.type === 'multiline'"
rows="2"
:value="str(prop)"
@change="set(prop, { kind: 'string', value: ($event.target as HTMLTextAreaElement).value })"
/>
<input v-else-if="prop.type === 'char'" maxlength="1" class="short" :value="str(prop)"
@change="($event.target as HTMLInputElement).value ? set(prop, { kind: 'char', value: ($event.target as HTMLInputElement).value }) : reset(prop)" />
<input
v-else-if="prop.type === 'char'"
maxlength="1"
class="short"
:value="str(prop)"
@change="($event.target as HTMLInputElement).value ? set(prop, { kind: 'char', value: ($event.target as HTMLInputElement).value }) : reset(prop)"
/>
<select v-else-if="prop.type === 'bool'" :value="String(boolOf(prop))"
@change="set(prop, { kind: 'bool', value: ($event.target as HTMLSelectElement).value === 'true' })">
<select
v-else-if="prop.type === 'bool'"
:value="String(boolOf(prop))"
@change="set(prop, { kind: 'bool', value: ($event.target as HTMLSelectElement).value === 'true' })"
>
<option value="true">是</option>
<option value="false">否</option>
</select>
<input v-else-if="prop.type === 'int'" type="number" :value="num(prop)"
@change="set(prop, { kind: 'int', value: Number(($event.target as HTMLInputElement).value) })" />
<input
v-else-if="prop.type === 'int'"
type="number"
:value="num(prop)"
@change="set(prop, { kind: 'int', value: Number(($event.target as HTMLInputElement).value) })"
/>
<span v-else-if="prop.type === 'point'" class="pair">
X <input type="number" :value="point(prop).x" @change="set(prop, { kind: 'point', x: Number(($event.target as HTMLInputElement).value), y: point(prop).y })" />
Y <input type="number" :value="point(prop).y" @change="set(prop, { kind: 'point', x: point(prop).x, y: Number(($event.target as HTMLInputElement).value) })" />
X
<input
type="number"
:value="point(prop).x"
@change="set(prop, { kind: 'point', x: Number(($event.target as HTMLInputElement).value), y: point(prop).y })"
/>
Y
<input
type="number"
:value="point(prop).y"
@change="set(prop, { kind: 'point', x: point(prop).x, y: Number(($event.target as HTMLInputElement).value) })"
/>
</span>
<span v-else-if="prop.type === 'size'" class="pair">
宽 <input type="number" min="1" :value="size(prop).width" @change="set(prop, { kind: 'size', width: Number(($event.target as HTMLInputElement).value), height: size(prop).height })" />
高 <input type="number" min="1" :value="size(prop).height" @change="set(prop, { kind: 'size', width: size(prop).width, height: Number(($event.target as HTMLInputElement).value) })" />
宽
<input
type="number"
min="1"
:value="size(prop).width"
@change="set(prop, { kind: 'size', width: Number(($event.target as HTMLInputElement).value), height: size(prop).height })"
/>
高
<input
type="number"
min="1"
:value="size(prop).height"
@change="set(prop, { kind: 'size', width: size(prop).width, height: Number(($event.target as HTMLInputElement).value) })"
/>
</span>
<select v-else-if="prop.type === 'enum'" :value="enumOf(prop)"
@change="set(prop, { kind: 'enum', type: prop.enumType!, member: ($event.target as HTMLSelectElement).value })">
<select
v-else-if="prop.type === 'enum'"
:value="enumOf(prop)"
@change="set(prop, { kind: 'enum', type: prop.enumType!, member: ($event.target as HTMLSelectElement).value })"
>
<option v-for="option in prop.options" :key="option" :value="option">{{ prop.optionLabels?.[option] ?? option }}</option>
</select>
@@ -232,39 +292,70 @@ function renameHandler(event: string, value: string) {
<option v-for="(_, name) in NAMED_COLORS" :key="name" :value="name">{{ name }}</option>
<option v-if="color(prop) && !color(prop)!.name" value="custom" disabled>{{ color(prop)!.css }}</option>
</select>
<input type="color" title="自定义颜色" :value="color(prop)?.css.startsWith('#') ? color(prop)!.css : '#000000'"
@change="setColor(prop, ($event.target as HTMLInputElement).value)" />
<input
type="color"
title="自定义颜色"
:value="color(prop)?.css.startsWith('#') ? color(prop)!.css : '#000000'"
@change="setColor(prop, ($event.target as HTMLInputElement).value)"
/>
</span>
<span v-else-if="prop.type === 'font'" class="font">
<select :value="font(prop)?.family ?? ''" @change="($event.target as HTMLSelectElement).value ? setFont(prop, { family: ($event.target as HTMLSelectElement).value }) : reset(prop)">
<select
:value="font(prop)?.family ?? ''"
@change="($event.target as HTMLSelectElement).value ? setFont(prop, { family: ($event.target as HTMLSelectElement).value }) : reset(prop)"
>
<option value="">(默认:宋体 9)</option>
<option v-for="family in FONT_FAMILIES" :key="family" :value="family">{{ family }}</option>
</select>
<template v-if="font(prop)">
<input type="number" min="5" max="72" step="0.5" class="short" :value="font(prop)!.size"
@change="setFont(prop, { size: Number(($event.target as HTMLInputElement).value) })" />
<label class="check"><input type="checkbox" :checked="font(prop)!.bold" @change="setFont(prop, { bold: ($event.target as HTMLInputElement).checked })" />粗</label>
<label class="check"><input type="checkbox" :checked="font(prop)!.italic" @change="setFont(prop, { italic: ($event.target as HTMLInputElement).checked })" />斜</label>
<input
type="number"
min="5"
max="72"
step="0.5"
class="short"
:value="font(prop)!.size"
@change="setFont(prop, { size: Number(($event.target as HTMLInputElement).value) })"
/>
<label class="check"
><input type="checkbox" :checked="font(prop)!.bold" @change="setFont(prop, { bold: ($event.target as HTMLInputElement).checked })" />粗</label
>
<label class="check"
><input
type="checkbox"
:checked="font(prop)!.italic"
@change="setFont(prop, { italic: ($event.target as HTMLInputElement).checked })"
/>斜</label
>
</template>
</span>
<span v-else-if="prop.type === 'chart'" class="chart-summary">
<span :title="`系列:${chartModel.series.map((s) => s.name).join('、') || '无'};图例:${chartModel.legends.join('、') || '无'}`">{{ chartModel.series.length }} 个系列</span>
<span :title="`系列:${chartModel.series.map((s) => s.name).join('、') || '无'};图例:${chartModel.legends.join('、') || '无'}`"
>{{ chartModel.series.length }} 个系列</span
>
<button @click="chartOpen = true">编辑…</button>
</span>
<textarea v-else-if="prop.type === 'items'" rows="4" :value="itemsText" placeholder="每行一项"
@change="setItemsFromText(($event.target as HTMLTextAreaElement).value)" />
<textarea
v-else-if="prop.type === 'items'"
rows="4"
:value="itemsText"
placeholder="每行一项"
@change="setItemsFromText(($event.target as HTMLTextAreaElement).value)"
/>
<select v-else-if="prop.type === 'image'" :value="imageOf(prop)"
@change="setImage(prop, ($event.target as HTMLSelectElement).value, $event.target as HTMLSelectElement)">
<select
v-else-if="prop.type === 'image'"
:value="imageOf(prop)"
@change="setImage(prop, ($event.target as HTMLSelectElement).value, $event.target as HTMLSelectElement)"
>
<option value="">(无)</option>
<option v-for="name in resources?.names ?? []" :key="name" :value="name">Resources.{{ name }}</option>
<option v-if="imageOf(prop) && !resources?.names.includes(imageOf(prop))" :value="imageOf(prop)">{{ imageOf(prop) }}(不在项目资源里)</option>
<option value="+">添加资源…</option>
</select>
</div>
</div>
</div>
@@ -358,7 +449,7 @@ function renameHandler(event: string, value: string) {
min-width: 0;
}
.editor input:not([type="checkbox"]):not([type="color"]),
.editor input:not([type='checkbox']):not([type='color']),
.editor select,
.editor textarea {
width: 100%;
@@ -394,7 +485,7 @@ function renameHandler(event: string, value: string) {
background-image: linear-gradient(45deg, #ddd 25%, transparent 25%, transparent 75%, #ddd 75%);
}
.color input[type="color"] {
.color input[type='color'] {
flex: none;
width: 24px;
height: 22px;
+8 -3
View File
@@ -36,14 +36,19 @@ function listen(file: string) {
function save() {
// 按素材库的顺序保存,生成的代码稳定
emit('save', gallery.value.map((g) => g.name).filter((name) => chosen.value.has(name)))
emit(
'save',
gallery.value.map((g) => g.name).filter((name) => chosen.value.has(name)),
)
emit('update:show', false)
}
</script>
<template>
<NModal :show="show" preset="card" title="项目资源" style="width: 560px; max-width: calc(100vw - 32px)" @update:show="emit('update:show', $event)">
<p class="note">勾选要加入项目的图片和声音(平台素材库,名字和教材一致)。代码里写 <code>Properties.Resources.名字</code>,图片框的"图片"属性也可以直接选。</p>
<p class="note">
勾选要加入项目的图片和声音(平台素材库,名字和教材一致)。代码里写 <code>Properties.Resources.名字</code>,图片框的"图片"属性也可以直接选。
</p>
<div class="items">
<!-- 点整行都能勾选;复选框和"试听"自己处理点击,不再传给这一行 -->
<div v-for="item in gallery" :key="item.name" class="item" :class="{ on: chosen.has(item.name) }" @click="toggle(item.name, !chosen.has(item.name))">
@@ -123,7 +128,7 @@ function save() {
}
.name {
font-family: Consolas, "Courier New", monospace;
font-family: Consolas, 'Courier New', monospace;
font-size: 13px;
}
+232 -60
View File
@@ -75,9 +75,15 @@ const COMMON_PROPS: PropDef[] = [
{ name: 'Visible', label: '可见', description: '是否可见', type: 'bool', default: bool(true), category: '行为' },
{ name: 'TabIndex', label: 'Tab 顺序', description: 'Tab 键顺序', type: 'int', category: '行为' },
{
name: 'Dock', label: '停靠', description: '贴在所在容器的哪一边(并随容器拉伸);填充是占满剩下的地方', type: 'enum', enumType: `${FORMS}.DockStyle`,
options: ['None', 'Top', 'Bottom', 'Left', 'Right', 'Fill'], optionLabels: { None: '不停靠', Top: '顶部', Bottom: '底部', Left: '左边', Right: '右边', Fill: '填充' },
default: enumValue(`${FORMS}.DockStyle`, 'None'), category: '布局',
name: 'Dock',
label: '停靠',
description: '贴在所在容器的哪一边(并随容器拉伸);填充是占满剩下的地方',
type: 'enum',
enumType: `${FORMS}.DockStyle`,
options: ['None', 'Top', 'Bottom', 'Left', 'Right', 'Fill'],
optionLabels: { None: '不停靠', Top: '顶部', Bottom: '底部', Left: '左边', Right: '右边', Fill: '填充' },
default: enumValue(`${FORMS}.DockStyle`, 'None'),
category: '布局',
},
]
@@ -85,9 +91,15 @@ const COMMON_PROPS: PropDef[] = [
const BACKGROUND_PROPS: PropDef[] = [
{ name: 'BackgroundImage', label: '背景图片', description: '背景图片:从项目资源里选(Properties.Resources)', type: 'image', category: '外观' },
{
name: 'BackgroundImageLayout', label: '背景排列', description: '背景图片怎样摆放', type: 'enum', enumType: `${FORMS}.ImageLayout`,
options: ['None', 'Tile', 'Center', 'Stretch', 'Zoom'], optionLabels: { None: '左上角', Tile: '平铺', Center: '居中', Stretch: '拉伸填满', Zoom: '按比例缩放' },
default: enumValue(`${FORMS}.ImageLayout`, 'Tile'), category: '外观',
name: 'BackgroundImageLayout',
label: '背景排列',
description: '背景图片怎样摆放',
type: 'enum',
enumType: `${FORMS}.ImageLayout`,
options: ['None', 'Tile', 'Center', 'Stretch', 'Zoom'],
optionLabels: { None: '左上角', Tile: '平铺', Center: '居中', Stretch: '拉伸填满', Zoom: '按比例缩放' },
default: enumValue(`${FORMS}.ImageLayout`, 'Tile'),
category: '外观',
},
]
@@ -112,7 +124,6 @@ export function textWidth(text: string) {
return width
}
export const CONTROLS: ControlDef[] = [
{
type: `${FORMS}.Button`,
@@ -122,7 +133,11 @@ export const CONTROLS: ControlDef[] = [
defaultEvent: 'Click',
props: [...COMMON_PROPS],
events: COMMON_EVENTS,
initial: (name) => [['Size', size(75, 23)], ['Text', str(name)], ['UseVisualStyleBackColor', bool(true)]],
initial: (name) => [
['Size', size(75, 23)],
['Text', str(name)],
['UseVisualStyleBackColor', bool(true)],
],
},
{
type: `${FORMS}.Label`,
@@ -130,12 +145,27 @@ export const CONTROLS: ControlDef[] = [
group: '常用控件',
namePrefix: 'label',
defaultEvent: 'Click',
props: [...COMMON_PROPS, AUTO_SIZE, {
name: 'BorderStyle', label: '边框', description: '边框', type: 'enum', enumType: `${FORMS}.BorderStyle`,
options: ['None', 'FixedSingle', 'Fixed3D'], optionLabels: { None: '无', FixedSingle: '单线', Fixed3D: '立体' }, default: enumValue(`${FORMS}.BorderStyle`, 'None'), category: '外观',
}],
props: [
...COMMON_PROPS,
AUTO_SIZE,
{
name: 'BorderStyle',
label: '边框',
description: '边框',
type: 'enum',
enumType: `${FORMS}.BorderStyle`,
options: ['None', 'FixedSingle', 'Fixed3D'],
optionLabels: { None: '无', FixedSingle: '单线', Fixed3D: '立体' },
default: enumValue(`${FORMS}.BorderStyle`, 'None'),
category: '外观',
},
],
events: COMMON_EVENTS,
initial: (name) => [['AutoSize', bool(true)], ['Size', size(textWidth(name) + 5, 12)], ['Text', str(name)]],
initial: (name) => [
['AutoSize', bool(true)],
['Size', size(textWidth(name) + 5, 12)],
['Text', str(name)],
],
autoSize: (text) => ({ width: textWidth(text) + 5, height: 12 }),
},
{
@@ -151,8 +181,15 @@ export const CONTROLS: ControlDef[] = [
{ name: 'PasswordChar', label: '密码字符', description: '输入时用这个字符代替显示的内容,如 *', type: 'char', category: '行为' },
{ name: 'UseSystemPasswordChar', label: '系统密码字符', description: '用系统密码字符显示', type: 'bool', default: bool(false), category: '行为' },
{
name: 'ScrollBars', label: '滚动条', description: '滚动条', type: 'enum', enumType: `${FORMS}.ScrollBars`,
options: ['None', 'Horizontal', 'Vertical', 'Both'], optionLabels: { None: '无', Horizontal: '水平', Vertical: '垂直', Both: '水平和垂直' }, default: enumValue(`${FORMS}.ScrollBars`, 'None'), category: '外观',
name: 'ScrollBars',
label: '滚动条',
description: '滚动条',
type: 'enum',
enumType: `${FORMS}.ScrollBars`,
options: ['None', 'Horizontal', 'Vertical', 'Both'],
optionLabels: { None: '无', Horizontal: '水平', Vertical: '垂直', Both: '水平和垂直' },
default: enumValue(`${FORMS}.ScrollBars`, 'None'),
category: '外观',
},
],
events: COMMON_EVENTS,
@@ -168,12 +205,22 @@ export const CONTROLS: ControlDef[] = [
...COMMON_PROPS,
{ name: 'Items', label: '列表项', description: '下拉列表里的选项,每行一项', type: 'items', category: '数据' },
{
name: 'DropDownStyle', label: '下拉样式', description: '下拉样式', type: 'enum', enumType: `${FORMS}.ComboBoxStyle`,
options: ['DropDown', 'DropDownList', 'Simple'], optionLabels: { DropDown: '可输入的下拉框', DropDownList: '只能选择的下拉列表', Simple: '始终展开' }, default: enumValue(`${FORMS}.ComboBoxStyle`, 'DropDown'), category: '外观',
name: 'DropDownStyle',
label: '下拉样式',
description: '下拉样式',
type: 'enum',
enumType: `${FORMS}.ComboBoxStyle`,
options: ['DropDown', 'DropDownList', 'Simple'],
optionLabels: { DropDown: '可输入的下拉框', DropDownList: '只能选择的下拉列表', Simple: '始终展开' },
default: enumValue(`${FORMS}.ComboBoxStyle`, 'DropDown'),
category: '外观',
},
],
events: [...COMMON_EVENTS, { name: 'SelectedIndexChanged', label: '选中项改变', ...EVENT }, { name: 'DropDown', label: '展开列表', ...EVENT }],
initial: () => [['FormattingEnabled', bool(true)], ['Size', size(121, 20)]],
initial: () => [
['FormattingEnabled', bool(true)],
['Size', size(121, 20)],
],
},
{
type: `${FORMS}.ListBox`,
@@ -185,15 +232,23 @@ export const CONTROLS: ControlDef[] = [
...COMMON_PROPS.filter((p) => p.name !== 'Text'),
{ name: 'Items', label: '列表项', description: '列表里的项,每行一项', type: 'items', category: '数据' },
{
name: 'BorderStyle', label: '边框', description: '边框', type: 'enum', enumType: `${FORMS}.BorderStyle`,
options: ['None', 'FixedSingle', 'Fixed3D'], optionLabels: { None: '无', FixedSingle: '单线', Fixed3D: '立体' }, default: enumValue(`${FORMS}.BorderStyle`, 'Fixed3D'), category: '外观',
name: 'BorderStyle',
label: '边框',
description: '边框',
type: 'enum',
enumType: `${FORMS}.BorderStyle`,
options: ['None', 'FixedSingle', 'Fixed3D'],
optionLabels: { None: '无', FixedSingle: '单线', Fixed3D: '立体' },
default: enumValue(`${FORMS}.BorderStyle`, 'Fixed3D'),
category: '外观',
},
],
events: [
...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'),
{ name: 'SelectedIndexChanged', label: '选中项改变', ...EVENT },
events: [...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'), { name: 'SelectedIndexChanged', label: '选中项改变', ...EVENT }],
initial: () => [
['FormattingEnabled', bool(true)],
['ItemHeight', int(12)],
['Size', size(120, 88)],
],
initial: () => [['FormattingEnabled', bool(true)], ['ItemHeight', int(12)], ['Size', size(120, 88)]],
},
{
type: `${FORMS}.PictureBox`,
@@ -206,18 +261,33 @@ export const CONTROLS: ControlDef[] = [
{ name: 'Image', label: '图片', description: '显示的图片:从项目资源里选(Properties.Resources)', type: 'image', category: '外观' },
...BACKGROUND_PROPS,
{
name: 'SizeMode', label: '显示方式', description: '图片在图片框里怎样显示', type: 'enum', enumType: `${FORMS}.PictureBoxSizeMode`,
name: 'SizeMode',
label: '显示方式',
description: '图片在图片框里怎样显示',
type: 'enum',
enumType: `${FORMS}.PictureBoxSizeMode`,
options: ['Normal', 'StretchImage', 'AutoSize', 'CenterImage', 'Zoom'],
optionLabels: { Normal: '原样放在左上角', StretchImage: '拉伸填满', AutoSize: '图片框随图片大小', CenterImage: '居中', Zoom: '按比例缩放' },
default: enumValue(`${FORMS}.PictureBoxSizeMode`, 'Normal'), category: '外观',
default: enumValue(`${FORMS}.PictureBoxSizeMode`, 'Normal'),
category: '外观',
},
{
name: 'BorderStyle', label: '边框', description: '边框', type: 'enum', enumType: `${FORMS}.BorderStyle`,
options: ['None', 'FixedSingle', 'Fixed3D'], optionLabels: { None: '无', FixedSingle: '单线', Fixed3D: '立体' }, default: enumValue(`${FORMS}.BorderStyle`, 'None'), category: '外观',
name: 'BorderStyle',
label: '边框',
description: '边框',
type: 'enum',
enumType: `${FORMS}.BorderStyle`,
options: ['None', 'FixedSingle', 'Fixed3D'],
optionLabels: { None: '无', FixedSingle: '单线', Fixed3D: '立体' },
default: enumValue(`${FORMS}.BorderStyle`, 'None'),
category: '外观',
},
],
events: COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'),
initial: () => [['Size', size(100, 50)], ['TabStop', bool(false)]],
initial: () => [
['Size', size(100, 50)],
['TabStop', bool(false)],
],
},
{
type: `${FORMS}.DataGridView`,
@@ -228,19 +298,43 @@ export const CONTROLS: ControlDef[] = [
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: '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`,
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: '行为',
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: '布局',
name: 'AutoSizeColumnsMode',
label: '列宽',
description: '列宽怎样自动调整',
type: 'enum',
enumType: `${FORMS}.DataGridViewAutoSizeColumnsMode`,
options: ['None', 'Fill'],
optionLabels: { None: '固定宽度', Fill: '填满表格宽度' },
default: enumValue(`${FORMS}.DataGridViewAutoSizeColumnsMode`, 'None'),
category: '布局',
},
],
events: [
@@ -251,7 +345,10 @@ export const CONTROLS: ControlDef[] = [
{ name: 'CellValueChanged', label: '单元格的值改变', delegate: `${FORMS}.DataGridViewCellEventHandler`, args: 'DataGridViewCellEventArgs' },
{ name: 'SelectionChanged', label: '选中改变', ...EVENT },
],
initial: () => [['ColumnHeadersHeightSizeMode', enumValue(`${FORMS}.DataGridViewColumnHeadersHeightSizeMode`, 'AutoSize')], ['Size', size(240, 150)]],
initial: () => [
['ColumnHeadersHeightSizeMode', enumValue(`${FORMS}.DataGridViewColumnHeadersHeightSizeMode`, 'AutoSize')],
['Size', size(240, 150)],
],
},
{
type: 'System.Windows.Forms.DataVisualization.Charting.Chart',
@@ -265,7 +362,10 @@ export const CONTROLS: ControlDef[] = [
{ name: 'Series', label: '系列和图例', description: '图表里画哪几条线(系列)、数据来自哪一列、图例怎样显示', type: 'chart', category: '数据' },
],
events: COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'),
initial: (name) => [['Size', size(300, 300)], ['Text', str(name)]],
initial: (name) => [
['Size', size(300, 300)],
['Text', str(name)],
],
},
{
type: `${FORMS}.DateTimePicker`,
@@ -276,9 +376,15 @@ export const CONTROLS: ControlDef[] = [
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: '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: '外观' },
@@ -301,14 +407,26 @@ export const CONTROLS: ControlDef[] = [
{ name: 'LargeChange', label: '大步长', description: '点击轨道时每次改变多少', type: 'int', default: int(5), category: '行为' },
{ name: 'SmallChange', label: '小步长', description: '按方向键时每次改变多少', type: 'int', default: int(1), category: '行为' },
{
name: 'TickStyle', label: '刻度位置', description: '刻度画在哪一边', type: 'enum', enumType: `${FORMS}.TickStyle`,
options: ['None', 'TopLeft', 'BottomRight', 'Both'], optionLabels: { None: '不画刻度', TopLeft: '上方(左边)', BottomRight: '下方(右边)', Both: '两边' },
default: enumValue(`${FORMS}.TickStyle`, 'BottomRight'), category: '外观',
name: 'TickStyle',
label: '刻度位置',
description: '刻度画在哪一边',
type: 'enum',
enumType: `${FORMS}.TickStyle`,
options: ['None', 'TopLeft', 'BottomRight', 'Both'],
optionLabels: { None: '不画刻度', TopLeft: '上方(左边)', BottomRight: '下方(右边)', Both: '两边' },
default: enumValue(`${FORMS}.TickStyle`, 'BottomRight'),
category: '外观',
},
{
name: 'Orientation', label: '方向', description: '横向或竖向', type: 'enum', enumType: `${FORMS}.Orientation`,
options: ['Horizontal', 'Vertical'], optionLabels: { Horizontal: '横向', Vertical: '竖向' },
default: enumValue(`${FORMS}.Orientation`, 'Horizontal'), category: '外观',
name: 'Orientation',
label: '方向',
description: '横向或竖向',
type: 'enum',
enumType: `${FORMS}.Orientation`,
options: ['Horizontal', 'Vertical'],
optionLabels: { Horizontal: '横向', Vertical: '竖向' },
default: enumValue(`${FORMS}.Orientation`, 'Horizontal'),
category: '外观',
},
],
events: [
@@ -326,7 +444,12 @@ export const CONTROLS: ControlDef[] = [
defaultEvent: 'CheckedChanged',
props: [...COMMON_PROPS, AUTO_SIZE, { name: 'Checked', label: '勾选', description: '是否勾选', type: 'bool', default: bool(false), category: '外观' }],
events: [...COMMON_EVENTS, { name: 'CheckedChanged', label: '勾选状态改变', ...EVENT }],
initial: (name) => [['AutoSize', bool(true)], ['Size', size(textWidth(name) + 24, 16)], ['Text', str(name)], ['UseVisualStyleBackColor', bool(true)]],
initial: (name) => [
['AutoSize', bool(true)],
['Size', size(textWidth(name) + 24, 16)],
['Text', str(name)],
['UseVisualStyleBackColor', bool(true)],
],
autoSize: (text) => ({ width: textWidth(text) + 24, height: 16 }),
},
{
@@ -337,7 +460,12 @@ export const CONTROLS: ControlDef[] = [
defaultEvent: 'CheckedChanged',
props: [...COMMON_PROPS, AUTO_SIZE, { name: 'Checked', label: '选中', description: '是否选中', type: 'bool', default: bool(false), category: '外观' }],
events: [...COMMON_EVENTS, { name: 'CheckedChanged', label: '选中状态改变', ...EVENT }],
initial: (name) => [['AutoSize', bool(true)], ['Size', size(textWidth(name) + 17, 16)], ['Text', str(name)], ['UseVisualStyleBackColor', bool(true)]],
initial: (name) => [
['AutoSize', bool(true)],
['Size', size(textWidth(name) + 17, 16)],
['Text', str(name)],
['UseVisualStyleBackColor', bool(true)],
],
autoSize: (text) => ({ width: textWidth(text) + 17, height: 16 }),
},
{
@@ -349,7 +477,11 @@ export const CONTROLS: ControlDef[] = [
defaultEvent: 'Enter',
props: [...COMMON_PROPS],
events: COMMON_EVENTS,
initial: (name) => [['Size', size(200, 100)], ['TabStop', bool(false)], ['Text', str(name)]],
initial: (name) => [
['Size', size(200, 100)],
['TabStop', bool(false)],
['Text', str(name)],
],
},
{
type: `${FORMS}.Panel`,
@@ -371,7 +503,14 @@ export const CONTROLS: ControlDef[] = [
defaultEvent: 'Tick',
props: [
{ name: 'Interval', label: '间隔', description: '每隔多少毫秒触发一次"计时到"事件', type: 'int', default: int(100), category: '行为' },
{ name: 'Enabled', label: '启用', description: '程序启动后是否立即开始计时;也可以在代码里调用 Start()', type: 'bool', default: bool(false), category: '行为' },
{
name: 'Enabled',
label: '启用',
description: '程序启动后是否立即开始计时;也可以在代码里调用 Start()',
type: 'bool',
default: bool(false),
category: '行为',
},
],
events: [{ name: 'Tick', label: '计时到', ...EVENT }],
initial: () => [],
@@ -384,7 +523,10 @@ export const CONTROLS: ControlDef[] = [
defaultEvent: 'Enter',
props: COMMON_PROPS.filter((p) => ['Location', 'Size', 'Enabled', 'Visible', 'TabIndex'].includes(p.name)),
events: [{ name: 'Enter', label: '获得焦点', ...EVENT }],
initial: () => [['Enabled', bool(true)], ['Size', size(245, 240)]],
initial: () => [
['Enabled', bool(true)],
['Size', size(245, 240)],
],
},
{
type: 'WinFormsDeviceControlLibrary.LED',
@@ -404,7 +546,10 @@ export const CONTROLS: ControlDef[] = [
defaultEvent: 'Load',
props: COMMON_PROPS.filter((p) => p.name !== 'Text'),
events: [{ name: 'Load', label: '加载', ...EVENT }, ...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged')],
initial: () => [['BackColor', { kind: 'color', css: '#ffffff', name: 'White', r: 255, g: 255, b: 255, a: 255 }], ['Size', size(105, 185)]],
initial: () => [
['BackColor', { kind: 'color', css: '#ffffff', name: 'White', r: 255, g: 255, b: 255, a: 255 }],
['Size', size(105, 185)],
],
},
{
type: 'WinFormsDeviceControlLibrary.Alarm',
@@ -433,16 +578,40 @@ export const FORM_DEF: ControlDef = {
{ name: 'Font', label: '字体', description: '字体', type: 'font', category: '外观' },
{ name: 'ClientSize', label: '窗口大小', description: '窗口内部的宽度和高度(不含标题栏和边框)', type: 'size', category: '布局' },
{
name: 'FormBorderStyle', label: '边框样式', description: '边框样式', type: 'enum', enumType: `${FORMS}.FormBorderStyle`,
name: 'FormBorderStyle',
label: '边框样式',
description: '边框样式',
type: 'enum',
enumType: `${FORMS}.FormBorderStyle`,
options: ['None', 'FixedSingle', 'Fixed3D', 'FixedDialog', 'Sizable', 'FixedToolWindow', 'SizableToolWindow'],
optionLabels: { None: '无边框', FixedSingle: '固定单线', Fixed3D: '固定立体', FixedDialog: '对话框', Sizable: '可调整大小', FixedToolWindow: '固定工具窗口', SizableToolWindow: '可调整工具窗口' },
default: enumValue(`${FORMS}.FormBorderStyle`, 'Sizable'), category: '外观',
optionLabels: {
None: '无边框',
FixedSingle: '固定单线',
Fixed3D: '固定立体',
FixedDialog: '对话框',
Sizable: '可调整大小',
FixedToolWindow: '固定工具窗口',
SizableToolWindow: '可调整工具窗口',
},
default: enumValue(`${FORMS}.FormBorderStyle`, 'Sizable'),
category: '外观',
},
{
name: 'StartPosition', label: '启动位置', description: '启动位置', type: 'enum', enumType: `${FORMS}.FormStartPosition`,
name: 'StartPosition',
label: '启动位置',
description: '启动位置',
type: 'enum',
enumType: `${FORMS}.FormStartPosition`,
options: ['Manual', 'CenterScreen', 'WindowsDefaultLocation', 'WindowsDefaultBounds', 'CenterParent'],
optionLabels: { Manual: '手动指定', CenterScreen: '屏幕中央', WindowsDefaultLocation: '系统默认位置', WindowsDefaultBounds: '系统默认位置和大小', CenterParent: '父窗体中央' },
default: enumValue(`${FORMS}.FormStartPosition`, 'WindowsDefaultLocation'), category: '布局',
optionLabels: {
Manual: '手动指定',
CenterScreen: '屏幕中央',
WindowsDefaultLocation: '系统默认位置',
WindowsDefaultBounds: '系统默认位置和大小',
CenterParent: '父窗体中央',
},
default: enumValue(`${FORMS}.FormStartPosition`, 'WindowsDefaultLocation'),
category: '布局',
},
...BACKGROUND_PROPS,
{ name: 'MaximizeBox', label: '最大化按钮', description: '显示最大化按钮', type: 'bool', default: bool(true), category: '外观' },
@@ -468,7 +637,10 @@ export function controlDef(type: string): ControlDef {
type,
label: type.split('.').at(-1)!,
group: '常用控件',
namePrefix: type.split('.').at(-1)!.replace(/^./, (c) => c.toLowerCase()),
namePrefix: type
.split('.')
.at(-1)!
.replace(/^./, (c) => c.toLowerCase()),
defaultEvent: 'Click',
props: COMMON_PROPS,
events: COMMON_EVENTS,
+22 -3
View File
@@ -42,8 +42,26 @@ describe('图表的系列和图例', () => {
areas: ['ChartArea1'],
legends: ['MaxLegend', 'AVGLegend'],
series: [
{ name: 'MaxSeries', chartType: 'Line', chartArea: 'ChartArea1', legend: 'MaxLegend', legendText: '最大值', isVisibleInLegend: true, xValueMember: '_Time', yValueMembers: 'MaxTemperature' },
{ name: 'AVGSeries', chartType: 'Line', chartArea: 'ChartArea1', legend: 'AVGLegend', legendText: '平均值', isVisibleInLegend: true, xValueMember: '_Time', yValueMembers: 'AVGTemperature' },
{
name: 'MaxSeries',
chartType: 'Line',
chartArea: 'ChartArea1',
legend: 'MaxLegend',
legendText: '最大值',
isVisibleInLegend: true,
xValueMember: '_Time',
yValueMembers: 'MaxTemperature',
},
{
name: 'AVGSeries',
chartType: 'Line',
chartArea: 'ChartArea1',
legend: 'AVGLegend',
legendText: '平均值',
isVisibleInLegend: true,
xValueMember: '_Time',
yValueMembers: 'AVGTemperature',
},
],
})
})
@@ -70,7 +88,8 @@ describe('图表的系列和图例', () => {
const { doc, name } = addControl(parseDesigner(EMPTY_FORM), def, '', { x: 10, y: 20 })
expect(name).toBe('chart1')
const out = generateDesigner(doc)
expect(out).toContain(` System.Windows.Forms.DataVisualization.Charting.ChartArea chartArea1 = new System.Windows.Forms.DataVisualization.Charting.ChartArea();
expect(out)
.toContain(` System.Windows.Forms.DataVisualization.Charting.ChartArea chartArea1 = new System.Windows.Forms.DataVisualization.Charting.ChartArea();
System.Windows.Forms.DataVisualization.Charting.Legend legend1 = new System.Windows.Forms.DataVisualization.Charting.Legend();
System.Windows.Forms.DataVisualization.Charting.Series series1 = new System.Windows.Forms.DataVisualization.Charting.Series();
this.chart1 = new System.Windows.Forms.DataVisualization.Charting.Chart();`)
+12 -1
View File
@@ -51,7 +51,18 @@ export const CHART_TYPES: Record<string, string> = {
export const DEFAULT_CHART: ChartModel = {
areas: ['ChartArea1'],
legends: ['Legend1'],
series: [{ name: 'Series1', chartType: 'Column', chartArea: 'ChartArea1', legend: 'Legend1', legendText: '', isVisibleInLegend: true, xValueMember: '', yValueMembers: '' }],
series: [
{
name: 'Series1',
chartType: 'Column',
chartArea: 'ChartArea1',
legend: 'Legend1',
legendText: '',
isVisibleInLegend: true,
xValueMember: '',
yValueMembers: '',
},
],
}
interface ChartObject {
+4 -8
View File
@@ -9,13 +9,7 @@ test('插入事件处理方法:放在类的最后,与上一个方法空一
const code = await Bun.file(FORM1).text()
const { code: next, line } = ensureHandler(code, 'Form1', 'Form1_Load', 'EventArgs')
const lines = next.split('\n')
expect(lines.slice(line - 4, line + 1)).toEqual([
'',
' private void Form1_Load(object sender, EventArgs e)',
' {',
'',
' }',
])
expect(lines.slice(line - 4, line + 1)).toEqual(['', ' private void Form1_Load(object sender, EventArgs e)', ' {', '', ' }'])
// 方法的 "}" 之后就是类的 "}"
expect(lines[line + 1]).toBe(' }')
})
@@ -30,7 +24,9 @@ test('方法已经存在:不重复插入,只返回它的位置', async () =>
test('类里还没有成员时不留多余空行', () => {
const code = 'namespace A\n{\n public partial class F : Form\n {\n }\n}\n'
const { code: next, line } = ensureHandler(code, 'F', 'F_Load', 'EventArgs')
expect(next).toBe('namespace A\n{\n public partial class F : Form\n {\n private void F_Load(object sender, EventArgs e)\n {\n\n }\n }\n}\n')
expect(next).toBe(
'namespace A\n{\n public partial class F : Form\n {\n private void F_Load(object sender, EventArgs e)\n {\n\n }\n }\n}\n',
)
expect(line).toBe(7)
})
+1 -7
View File
@@ -20,13 +20,7 @@ export function ensureHandler(code: string, className: string, handler: string,
// 类里已有成员时,与上一个成员之间空一行
const before = code.slice(0, lineStart)
const hasMembers = !/\{\s*$/.test(before)
const method = [
...(hasMembers ? [''] : []),
`${indent}private void ${handler}(object sender, ${args} e)`,
`${indent}{`,
'',
`${indent}}`,
].join(eol)
const method = [...(hasMembers ? [''] : []), `${indent}private void ${handler}(object sender, ${args} e)`, `${indent}{`, '', `${indent}}`].join(eol)
const next = `${before}${method}${eol}${code.slice(lineStart)}`
return { code: next, line: lineOf(next, before.length) + (hasMembers ? 3 : 2) }
}
+1 -4
View File
@@ -18,10 +18,7 @@ async function designerFiles(dir: string) {
}
describe('读入 Designer.cs 再原样写出,内容逐字节不变', async () => {
const files = [
...(await designerFiles(resolve(ROOT, 'samples'))),
...(existsSync(REFERENCE_DIR) ? await designerFiles(REFERENCE_DIR) : []),
]
const files = [...(await designerFiles(resolve(ROOT, 'samples'))), ...(existsSync(REFERENCE_DIR) ? await designerFiles(REFERENCE_DIR) : [])]
for (const file of files) {
test(file.replace(`${ROOT}/`, ''), async () => {
const text = await Bun.file(file).text()
+18 -15
View File
@@ -19,9 +19,7 @@ export function listControls(doc: DesignerDocument): ControlInfo[] {
for (const block of doc.blocks) {
for (const stmt of block.stmts) if (stmt.kind === 'add') parents.set(stmt.child, stmt.target)
}
return doc.prologue.flatMap((stmt) =>
stmt.kind === 'create' ? [{ name: stmt.name, type: stmt.type, parent: parents.get(stmt.name) ?? null }] : [],
)
return doc.prologue.flatMap((stmt) => (stmt.kind === 'create' ? [{ name: stmt.name, type: stmt.type, parent: parents.get(stmt.name) ?? null }] : []))
}
/** 某个容器里的子控件,按 Controls.Add 的顺序(第一个在最上层) */
@@ -133,7 +131,9 @@ export function setEvent(doc: DesignerDocument, target: string, event: string, d
}
const KEYWORDS = new Set(
'abstract as base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile while'.split(' '),
'abstract as base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile while'.split(
' ',
),
)
/** 检查控件名是否可用;可用返回 null,否则返回原因 */
@@ -206,11 +206,7 @@ export function removeControl(doc: DesignerDocument, name: string): DesignerDocu
// 图表的图表区、图例、系列是 InitializeComponent 开头的局部变量,一起删掉
for (const n of doomed) if (typeOf(next, n) === CHART_TYPE) next.prologue = removeChartDeclarations(next, n).prologue
const keep = (s: Stmt) =>
!(
(s.kind === 'create' && doomed.has(s.name)) ||
(s.kind === 'add' && doomed.has(s.child)) ||
(s.kind === 'layout' && doomed.has(s.target))
)
!((s.kind === 'create' && doomed.has(s.name)) || (s.kind === 'add' && doomed.has(s.child)) || (s.kind === 'layout' && doomed.has(s.target)))
next.prologue = next.prologue.filter(keep)
next.epilogue = next.epilogue.filter(keep)
next.blocks = next.blocks.filter((b) => !doomed.has(b.name))
@@ -229,7 +225,9 @@ export function renameControl(doc: DesignerDocument, from: string, to: string):
return s.name === from ? { ...s, name: to, text: undefined } : s
case 'prop':
if (s.target !== from) return s
return s.path === 'Name' ? { ...s, target: to, value: renderValue({ kind: 'string', value: to }), text: undefined } : { ...s, target: to, text: undefined }
return s.path === 'Name'
? { ...s, target: to, value: renderValue({ kind: 'string', value: to }), text: undefined }
: { ...s, target: to, text: undefined }
case 'event':
case 'items':
return s.target === from ? { ...s, target: to, text: undefined } : s
@@ -240,7 +238,9 @@ export function renameControl(doc: DesignerDocument, from: string, to: string):
case 'layout':
return s.target === from ? { ...s, target: to, text: undefined } : s
case 'raw':
return s.text.includes(`this.${from}`) ? { kind: 'raw', text: s.text.replace(new RegExp(`(?<![\\p{L}\\p{N}_])this\\.${from}(?![\\p{L}\\p{N}_])`, 'gu'), `this.${to}`) } : s
return s.text.includes(`this.${from}`)
? { kind: 'raw', text: s.text.replace(new RegExp(`(?<![\\p{L}\\p{N}_])this\\.${from}(?![\\p{L}\\p{N}_])`, 'gu'), `this.${to}`) }
: s
}
}
next.prologue = next.prologue.map(fix)
@@ -274,7 +274,12 @@ export function moveControl(doc: DesignerDocument, name: string, parent: string,
* 子控件里有 AutoSize 的,末尾再加 PerformLayout()。已有的语句不调整顺序,只增删。
*/
export function syncLayout(doc: DesignerDocument) {
const containers = ['', ...listControls(doc).filter((c) => isContainerType(c.type)).map((c) => c.name)]
const containers = [
'',
...listControls(doc)
.filter((c) => isContainerType(c.type))
.map((c) => c.name),
]
for (const container of containers) {
const children = childrenOf(doc, container)
const hasChildren = children.length > 0 || container === ''
@@ -331,9 +336,7 @@ function ensureLayout(doc: DesignerDocument, target: string, op: 'SuspendLayout'
} else {
// 容器:ResumeLayout(false) + PerformLayout() 成对,排在 EndInit 和窗体的 ResumeLayout 之前
const resume = list.findIndex((s) => s.kind === 'layout' && s.target === target && s.op === 'ResumeLayout')
const at = op === 'PerformLayout' && resume >= 0
? resume + 1
: list.findIndex((s) => s.kind === 'layout' && (s.op === 'EndInit' || s.target === ''))
const at = op === 'PerformLayout' && resume >= 0 ? resume + 1 : list.findIndex((s) => s.kind === 'layout' && (s.op === 'EndInit' || s.target === ''))
list.splice(at < 0 ? list.length : at, 0, stmt)
}
}
+3 -4
View File
@@ -34,9 +34,7 @@ function dockOf(doc: DesignerDocument, name: string) {
function paddingOf(doc: DesignerDocument, name: string) {
const m = /Padding\((\d+)(?:,\s*(\d+),\s*(\d+),\s*(\d+))?\)/.exec(getPropText(doc, name, 'Padding') ?? '')
if (!m) return { left: 0, top: 0, right: 0, bottom: 0 }
return m[2] === undefined
? { left: +m[1], top: +m[1], right: +m[1], bottom: +m[1] }
: { left: +m[1], top: +m[2], right: +m[3], bottom: +m[4] }
return m[2] === undefined ? { left: +m[1], top: +m[1], right: +m[1], bottom: +m[1] } : { left: +m[1], top: +m[2], right: +m[3], bottom: +m[4] }
}
/** 容器里用来停靠的区域:客户区去掉内边距;带边框的面板去掉边框,分组框去掉边框和标题 */
@@ -63,7 +61,8 @@ export function dockLayout(doc: DesignerDocument): DesignerDocument {
const point = pointOf(next, name)
if (point.x !== bounds.x || point.y !== bounds.y) next = setProp(next, name, 'Location', { kind: 'point', x: bounds.x, y: bounds.y })
const size = sizeOf(next, name)
if (size?.width !== bounds.width || size?.height !== bounds.height) next = setProp(next, name, 'Size', { kind: 'size', width: bounds.width, height: bounds.height })
if (size?.width !== bounds.width || size?.height !== bounds.height)
next = setProp(next, name, 'Size', { kind: 'size', width: bounds.width, height: bounds.height })
}
const layout = (container: string) => {
+22 -7
View File
@@ -61,7 +61,11 @@ function propsOf(doc: DesignerDocument, target: string): Record<string, unknown>
/** 教材控件在 Designer.cs 里不写、但运行时构造函数会设置的外观 */
const DEVICE_DEFAULTS: Record<string, Record<string, unknown>> = {
'WinFormsDeviceControlLibrary.LED': { BackColor: '#000000', ForeColor: '#ff0000', Font: { family: '宋体', size: 70, bold: false, italic: false, underline: false, strikeout: false } },
'WinFormsDeviceControlLibrary.LED': {
BackColor: '#000000',
ForeColor: '#ff0000',
Font: { family: '宋体', size: 70, bold: false, italic: false, underline: false, strikeout: false },
},
'WinFormsDeviceControlLibrary.Lamp': { BackColor: '#ffffff' },
'WinFormsDeviceControlLibrary.Alarm': { BackColor: '#ffffff' },
}
@@ -73,17 +77,26 @@ export function previewTree(doc: DesignerDocument): ControlNode {
const props: Record<string, unknown> = { ...DEVICE_DEFAULTS[type], ...propsOf(doc, name) }
// 设计时即使 Visible = false 也显示出来(和 VS 一样),方便选中和编辑
props.Visible = true
if (type === 'System.Windows.Forms.ComboBox' || type === 'System.Windows.Forms.ListBox') props.Items = getItems(doc, name).map((item) => {
const v = parseValue(item)
return v.kind === 'string' ? v.value : item
})
if (type === 'System.Windows.Forms.ComboBox' || type === 'System.Windows.Forms.ListBox')
props.Items = getItems(doc, name).map((item) => {
const v = parseValue(item)
return v.kind === 'string' ? v.value : item
})
// 设计器里不需要滚动:LED 只显示黑底
if (type === 'WinFormsDeviceControlLibrary.LED') props.LedText = ''
// 图表和 VS 一样用示例数据画出各个系列
if (type === CHART_TYPE) props.Chart = previewChart(readChart(doc, name))
// 和 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) }
return {
id: name,
type: clientType(type),
name,
props,
children: childrenOf(doc, name)
.filter((c) => types.has(c))
.map(build),
}
}
const formProps = propsOf(doc, '')
return {
@@ -91,7 +104,9 @@ export function previewTree(doc: DesignerDocument): ControlNode {
type: 'Form',
name: doc.formName,
props: { ClientSize: { width: 300, height: 300 }, ...formProps, Visible: true },
children: childrenOf(doc, '').filter((c) => types.has(c)).map(build),
children: childrenOf(doc, '')
.filter((c) => types.has(c))
.map(build),
}
}
+26 -5
View File
@@ -3,10 +3,24 @@ import { expect, test } from 'bun:test'
import { generateDesigner, parseDesigner } from './document'
import { addControl } from './edit'
import { controlDef } from './catalog'
import { assignmentTemplate, classPathError, formNameTaken, newClassFile, newFormFiles, nextFormName, projectNamespace, renameForm, startupForm } from './templates'
import {
assignmentTemplate,
classPathError,
formNameTaken,
newClassFile,
newFormFiles,
nextFormName,
projectNamespace,
renameForm,
startupForm,
} from './templates'
const files = [
{ path: 'Program.cs', content: 'namespace 登录\n{\n static class Program\n {\n static void Main()\n {\n Application.Run(new Login());\n }\n }\n}\n' },
{
path: 'Program.cs',
content:
'namespace 登录\n{\n static class Program\n {\n static void Main()\n {\n Application.Run(new Login());\n }\n }\n}\n',
},
{ path: 'Login.cs', content: 'namespace 登录\n{\n public partial class Login : Form\n {\n }\n}\n' },
]
@@ -68,10 +82,14 @@ test('作业起点"空白工程":Program.cs 启动空的 Form1', async () => {
})
test('新建类:可以带文件夹,文件夹也是命名空间的一段;名字不能重复', () => {
const files = [{ path: 'Program.cs', content: 'namespace 智慧校园\n{\n static class Program { }\n}\n' }, { path: 'AppSet.cs', content: 'class AppSet { }' }]
const files = [
{ path: 'Program.cs', content: 'namespace 智慧校园\n{\n static class Program { }\n}\n' },
{ path: 'AppSet.cs', content: 'class AppSet { }' },
]
expect(newClassFile('智慧校园', 'SQLHelpers/SQLHelper')).toEqual({
path: 'SQLHelpers/SQLHelper.cs',
content: 'using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Text;\nusing System.Threading.Tasks;\n\nnamespace 智慧校园.SQLHelpers\n{\n class SQLHelper\n {\n }\n}\n',
content:
'using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Text;\nusing System.Threading.Tasks;\n\nnamespace 智慧校园.SQLHelpers\n{\n class SQLHelper\n {\n }\n}\n',
})
expect(newClassFile('智慧校园', 'RecordClass.cs').path).toBe('RecordClass.cs')
expect(classPathError(files, 'SQLHelpers/SQLBLL')).toBeNull()
@@ -84,7 +102,10 @@ test('新建类:可以带文件夹,文件夹也是命名空间的一段;
test('窗体改名:类名、构造函数、Program.cs 和两个文件名一起改,方法名和字符串不动', async () => {
const blank = assignmentTemplate(await sample('p8-t1-主界面'), 'blank')
const code = blank.find((f) => f.path === 'Form1.cs')!
code.content = code.content.replace('InitializeComponent();', 'InitializeComponent();\n MessageBox.Show("Form1"); // Form1\n }\n\n private void Form1_Load(object sender, EventArgs e)\n {\n Form1 other = new Form1();')
code.content = code.content.replace(
'InitializeComponent();',
'InitializeComponent();\n MessageBox.Show("Form1"); // Form1\n }\n\n private void Form1_Load(object sender, EventArgs e)\n {\n Form1 other = new Form1();',
)
const files = renameForm(blank, 'Form1.Designer.cs', 'Main')
expect(files.map((f) => f.path).sort()).toEqual(['Main.Designer.cs', 'Main.cs', 'Program.cs'])
expect(files.find((f) => f.path === 'Program.cs')!.content).toContain('Application.Run(new Main())')
+4 -1
View File
@@ -21,7 +21,10 @@ export function nextFormName(files: SourceFile[]): string {
/** 窗体名是否已被使用(同名文件或同名类) */
export function formNameTaken(files: SourceFile[], name: string): boolean {
return files.some((f) => f.path === `${name}.cs` || f.path === `${name}.Designer.cs` || new RegExp(`(?<![\\p{L}\\p{N}_])class\\s+${name}(?![\\p{L}\\p{N}_])`, 'u').test(f.content))
return files.some(
(f) =>
f.path === `${name}.cs` || f.path === `${name}.Designer.cs` || new RegExp(`(?<![\\p{L}\\p{N}_])class\\s+${name}(?![\\p{L}\\p{N}_])`, 'u').test(f.content),
)
}
/**
+3 -6
View File
@@ -88,12 +88,9 @@ export function renderValue(value: Value): string {
? `System.Drawing.Color.FromArgb(${[value.r, value.g, value.b].map(byteArg).join(', ')})`
: `System.Drawing.Color.FromArgb(${[value.a, value.r, value.g, value.b].map(byteArg).join(', ')})`
case 'font': {
const styles = [
value.bold && 'Bold',
value.italic && 'Italic',
value.underline && 'Underline',
value.strikeout && 'Strikeout',
].filter(Boolean) as string[]
const styles = [value.bold && 'Bold', value.italic && 'Italic', value.underline && 'Underline', value.strikeout && 'Strikeout'].filter(
Boolean,
) as string[]
const style = styles.length ? styles.map((s) => `System.Drawing.FontStyle.${s}`).join(' | ') : 'System.Drawing.FontStyle.Regular'
// 与中文 Windows 上的 VS 一致:带 GB2312 字符集 134
return `new System.Drawing.Font("${escapeString(value.family)}", ${value.size}F, ${style}, System.Drawing.GraphicsUnit.Point, ((byte)(${value.charset ?? 134})))`
+10 -9
View File
@@ -73,7 +73,7 @@ function applyMarkers() {
startColumn: d.column,
endLineNumber: d.line,
// 编译器只给出起点;标到该行的下一个词结尾,至少一个字符
endColumn: (model.getWordAtPosition({ lineNumber: d.line, column: d.column })?.endColumn ?? d.column + 1),
endColumn: model.getWordAtPosition({ lineNumber: d.line, column: d.column })?.endColumn ?? d.column + 1,
}))
monaco.editor.setModelMarkers(model, 'csharp-compiler', markers)
}
@@ -134,11 +134,14 @@ onMounted(() => {
applyMarkers()
})
watch(() => props.files, () => {
syncModels()
showActive()
applyMarkers()
})
watch(
() => props.files,
() => {
syncModels()
showActive()
applyMarkers()
},
)
watch(() => props.activeFile, showActive)
watch(
() => props.diagnostics,
@@ -158,9 +161,7 @@ onBeforeUnmount(() => {
<template>
<div class="code-editor">
<div v-if="activeFile?.endsWith('.Designer.cs')" class="designer-note">
此文件由窗体设计器生成,一般不要手动修改;控件的属性和事件请在设计器里设置。
</div>
<div v-if="activeFile?.endsWith('.Designer.cs')" class="designer-note">此文件由窗体设计器生成,一般不要手动修改;控件的属性和事件请在设计器里设置。</div>
<div ref="container" class="monaco-host" />
</div>
</template>
+10 -12
View File
@@ -84,18 +84,16 @@ export function registerLanguageFeatures() {
if (!result) return { suggestions: [] }
const range = toRange(result.range)
return {
suggestions: result.items.map(
(item, index): Item => ({
index,
label: item.label,
insertText: item.insert,
filterText: item.filter,
sortText: item.sort,
kind: KINDS[item.kind] ?? Kind.Text,
detail: item.detail || undefined,
range,
}),
),
suggestions: result.items.map((item, index): Item => ({
index,
label: item.label,
insertText: item.insert,
filterText: item.filter,
sortText: item.sort,
kind: KINDS[item.kind] ?? Kind.Text,
detail: item.detail || undefined,
range,
})),
}
},
// 选中某一项时再取它的签名和说明
+7 -3
View File
@@ -20,7 +20,9 @@ const manualScore = computed(() => props.review?.scores.reduce<number>((a, s) =>
<div class="grade">
<div v-if="submission.status === 'pending'" class="pending">
<NSpin size="small" />
<span>评分中…<template v-if="submission.queueAhead">前面还有 {{ submission.queueAhead }} 份在排队</template></span>
<span
>评分中…<template v-if="submission.queueAhead">前面还有 {{ submission.queueAhead }} 份在排队</template></span
>
</div>
<div v-else-if="submission.status === 'error'" class="error">评分时服务器出错了,请重新提交;还是不行请告诉老师。</div>
<template v-else-if="submission.result">
@@ -47,7 +49,9 @@ const manualScore = computed(() => props.review?.scores.reduce<number>((a, s) =>
</div>
<ul class="items">
<li v-for="(m, i) in manual" :key="i">
<span class="title">{{ m.title }}<span class="points">{{ review.scores[i] ?? '未打分' }} / {{ m.points }} 分</span></span>
<span class="title"
>{{ m.title }}<span class="points">{{ review.scores[i] ?? '未打分' }} / {{ m.points }} 分</span></span
>
</li>
</ul>
<div v-if="review.comment" class="comment">老师评语:{{ review.comment }}</div>
@@ -138,6 +142,6 @@ const manualScore = computed(() => props.review?.scores.reduce<number>((a, s) =>
}
.manual span + span::before {
content: "、";
content: '、';
}
</style>
+2 -7
View File
@@ -85,8 +85,7 @@ const formatTime = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { ho
<template v-if="titles.length">
<h4 class="section">评价项</h4>
<p class="note">
下面这些功能项由平台自动检查。点"自己检查",平台会运行你现在的程序,逐项操作、核对结果,
告诉你哪几项还没做好。<b>不计分、不留记录</b>,可以随时检查。
下面这些功能项由平台自动检查。点"自己检查",平台会运行你现在的程序,逐项操作、核对结果, 告诉你哪几项还没做好。<b>不计分、不留记录</b>,可以随时检查。
</p>
<div class="actions">
<NButton type="primary" :loading="running" :disabled="running || titles.length === 0" @click="start">自己检查</NButton>
@@ -101,11 +100,7 @@ const formatTime = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { ho
<div v-if="current?.result && !current.result.compiled" class="error">编译没有通过,先改正编译错误</div>
<ul class="items">
<li
v-for="(title, i) in titles"
:key="i"
:class="current?.result && !running ? (current.result.results[i]?.passed ? 'passed' : 'failed') : ''"
>
<li v-for="(title, i) in titles" :key="i" :class="current?.result && !running ? (current.result.results[i]?.passed ? 'passed' : 'failed') : ''">
<span class="mark">{{ current?.result && !running ? (current.result.results[i]?.passed ? '✓' : '✗') : '·' }}</span>
<div>
<div class="title">
+5 -3
View File
@@ -18,13 +18,15 @@ onMounted(async () => {
loaded.value = true
})
const formatTime = (ms: number) =>
new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
const formatTime = (ms: number) => new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
const total = (a: Assignment) => a.scoring.auto.reduce((x, y) => x + y, 0)
function status(row: Row) {
const s = row.latest
if (!s) return row.deadline && Date.now() > row.deadline ? h(NTag, { type: 'error', size: 'small' }, () => '未提交,已截止') : h(NTag, { size: 'small' }, () => '未提交')
if (!s)
return row.deadline && Date.now() > row.deadline
? h(NTag, { type: 'error', size: 'small' }, () => '未提交,已截止')
: h(NTag, { size: 'small' }, () => '未提交')
if (s.status === 'pending') return h(NTag, { type: 'warning', size: 'small' }, () => '评分中')
if (s.status === 'error') return h(NTag, { type: 'error', size: 'small' }, () => '评分出错')
if (row.manualScore !== null) {
+6 -11
View File
@@ -24,9 +24,7 @@ import { currentUser } from '../auth'
<h2>学生</h2>
<h3>1. 登录</h3>
<p>
用老师发给你的账号(学号)和密码登录。请用新版的 Chrome 或 Edge 浏览器;用 360、QQ 浏览器时,点地址栏右边的闪电图标切换到<b>极速模式</b>。
</p>
<p>用老师发给你的账号(学号)和密码登录。请用新版的 Chrome 或 Edge 浏览器;用 360、QQ 浏览器时,点地址栏右边的闪电图标切换到<b>极速模式</b>。</p>
<h3>2. 自由练习和做作业</h3>
<ul>
@@ -66,10 +64,11 @@ import { currentUser } from '../auth'
<h3>6. 自己检查、提交</h3>
<ul>
<li><b>任务</b>面板下面是<b>评价项</b>:这个任务要自动检查哪些功能;点<b>自己检查</b>,平台按评分规则试运行你的程序,告诉你哪项没通过、为什么。不计分,可以检查很多次。</li>
<li>
做作业时点<b>提交</b>,平台自动评分,几十秒内出结果(全班同时提交时要排队)。截止前可以改了再交,以最后一次为准。
<b>任务</b
>面板下面是<b>评价项</b>:这个任务要自动检查哪些功能;点<b>自己检查</b>,平台按评分规则试运行你的程序,告诉你哪项没通过、为什么。不计分,可以检查很多次。
</li>
<li>做作业时点<b>提交</b>,平台自动评分,几十秒内出结果(全班同时提交时要排队)。截止前可以改了再交,以最后一次为准。</li>
<li>点工具栏上的分数,看每一项的得分;老师评阅后,这里能看到老师的打分和评语。</li>
</ul>
@@ -111,9 +110,7 @@ import { currentUser } from '../auth'
<h3>4. 上课前后</h3>
<ul>
<li>
第一次用之前,让学生提前打开一次网站:实验页第一次打开要下载约 1.5 MB,之后浏览器会缓存;全班同时第一次打开时会慢一些。
</li>
<li>第一次用之前,让学生提前打开一次网站:实验页第一次打开要下载约 1.5 MB,之后浏览器会缓存;全班同时第一次打开时会慢一些。</li>
<li>上课时间不要更新平台(推送代码):更新会重启平台,正在运行的学生程序会结束(页面会提示并自动重连)。</li>
<li>全班同时"自己检查"或提交时要排队,项目 8 这种大任务最后一个人大约等 1.5 分钟。</li>
<li>重要节点前后备份数据:在服务器上执行 <code>deploy/backup.sh</code>(说明见 deploy/README.md)。</li>
@@ -126,9 +123,7 @@ import { currentUser } from '../auth'
<dt>窗体上的按钮点了没反应</dt>
<dd>看运行区有没有显示"与服务器的连接断开了"。断开后程序已经结束,等它自动连上,再按 F5 重新运行。</dd>
<dt>代码改乱了,想重来</dt>
<dd>
工具栏的<b>恢复原始代码</b>(做作业时是<b>恢复到作业起点</b>)把代码换回起点;已经提交的不受影响。
</dd>
<dd>工具栏的<b>恢复原始代码</b>(做作业时是<b>恢复到作业起点</b>)把代码换回起点;已经提交的不受影响。</dd>
<dt>登录时提示密码输错太多次</dt>
<dd>同一个账号连续输错 10 次要等 10 分钟,或者请老师在名单里查看、重置密码。</dd>
<dt>网页打不开、一片空白</dt>
+1 -7
View File
@@ -34,13 +34,7 @@ async function submit() {
<h1>物联网编程仿真平台</h1>
<p>用老师发给你的账号和密码登录</p>
<NInput v-model:value="username" placeholder="账号" autofocus :input-props="{ autocomplete: 'username' }" />
<NInput
v-model:value="password"
type="password"
show-password-on="click"
placeholder="密码"
:input-props="{ autocomplete: 'current-password' }"
/>
<NInput v-model:value="password" type="password" show-password-on="click" placeholder="密码" :input-props="{ autocomplete: 'current-password' }" />
<div v-if="error" class="error">{{ error }}</div>
<NButton type="primary" attr-type="submit" block :loading="busy" :disabled="!username || !password">登录</NButton>
<RouterLink to="/help" target="_blank" class="help">第一次使用?看使用说明</RouterLink>
+13 -7
View File
@@ -237,8 +237,7 @@ watch(selectedId, (_, old) => {
if (old !== undefined && dirty.value) message.warning('上一个学生的评分还没保存')
})
const formatTime = (ms: number) =>
new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
const formatTime = (ms: number) => new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
function statusTag(row: Row): { type: 'default' | 'warning' | 'error' | 'success' | 'info'; text: string } {
if (!row.status) return { type: 'default', text: '未提交' }
@@ -262,7 +261,15 @@ function exportCsv() {
const a = assignment.value
if (!a) return
downloadCsv(`${a.title}-成绩.csv`, [
['学号', '姓名', `自动评分(${autoTotal.value})`, ...a.scoring.manual.map((m) => `${m.title}(${m.points})`), `总分(${autoTotal.value + manualTotal.value})`, '提交次数', '评语'],
[
'学号',
'姓名',
`自动评分(${autoTotal.value})`,
...a.scoring.manual.map((m) => `${m.title}(${m.points})`),
`总分(${autoTotal.value + manualTotal.value})`,
'提交次数',
'评语',
],
...rows.value.map((r) => [
`\t${r.username}`,
r.name,
@@ -375,7 +382,8 @@ async function signOut() {
<template v-else-if="detail.aiReview.status === 'done'">
<div v-for="(m, i) in assignment.scoring.manual" :key="i" class="ai-item">
<span class="score-title">{{ m.title }}</span>
<b>{{ detail.aiReview.scores[i]?.score }}</b><span class="muted"> / {{ m.points }}</span>
<b>{{ detail.aiReview.scores[i]?.score }}</b
><span class="muted"> / {{ m.points }}</span>
<div class="ai-reason">{{ detail.aiReview.scores[i]?.reason }}</div>
</div>
<div v-if="detail.aiReview.comment" class="ai-comment">评语:{{ detail.aiReview.comment }}</div>
@@ -401,9 +409,7 @@ async function signOut() {
</div>
<NInput v-model:value="comment" type="textarea" :autosize="{ minRows: 2, maxRows: 6 }" placeholder="评语(学生能看到)" />
<div class="save-row">
<span class="muted">
总分 {{ (detail.submission.autoScore ?? 0) + draftManual }} / {{ autoTotal + manualTotal }}
</span>
<span class="muted"> 总分 {{ (detail.submission.autoScore ?? 0) + draftManual }} / {{ autoTotal + manualTotal }} </span>
<span class="spacer" />
<NButton size="small" :loading="saving" @click="save()">保存</NButton>
<NButton size="small" type="primary" :loading="saving" @click="save(true)">保存并看下一个</NButton>
+14 -20
View File
@@ -53,9 +53,7 @@ async function loadClasses() {
}
async function loadStudents() {
students.value = selectedId.value
? ((await attempt(() => api<Student[]>('GET', `/api/teacher/classes/${selectedId.value}/students`))) ?? [])
: []
students.value = selectedId.value ? ((await attempt(() => api<Student[]>('GET', `/api/teacher/classes/${selectedId.value}/students`))) ?? []) : []
}
async function selectClass(id: number) {
@@ -173,18 +171,14 @@ async function confirmImport() {
else
dialog.warning({
title: '部分学生没有导入',
content: () =>
h('div', [h('p', summary), ...result.errors.map((e) => h('div', `${rows[e.row - 1]?.username ?? ''}:${e.message}`))]),
content: () => h('div', [h('p', summary), ...result.errors.map((e) => h('div', `${rows[e.row - 1]?.username ?? ''}:${e.message}`))]),
positiveText: '知道了',
})
}
function exportRoster() {
if (!selected.value) return
downloadCsv(`${selected.value.name}-名单.csv`, [
['学号', '姓名', '密码'],
...students.value.map((s) => [`\t${s.username}`, s.name, `\t${s.password}`]),
])
downloadCsv(`${selected.value.name}-名单.csv`, [['学号', '姓名', '密码'], ...students.value.map((s) => [`\t${s.username}`, s.name, `\t${s.password}`])])
}
function downloadTemplate() {
@@ -226,13 +220,7 @@ async function signOut() {
</form>
</NPopover>
</div>
<button
v-for="c in classes"
:key="c.id"
class="class-item"
:class="{ active: c.id === selectedId }"
@click="selectClass(c.id)"
>
<button v-for="c in classes" :key="c.id" class="class-item" :class="{ active: c.id === selectedId }" @click="selectClass(c.id)">
<span>{{ c.name }}</span>
<span class="count">{{ c.studentCount }} 人</span>
</button>
@@ -267,7 +255,8 @@ async function signOut() {
<input ref="fileInput" type="file" accept=".csv,text/csv" hidden @change="onFileChosen" />
</div>
<p class="tip">
名单是 Excel 另存的 CSV,列为"学号、姓名、密码"。密码可以留空,平台会生成 6 位数字密码;导入后点"导出名单"发给学生。学号已存在时更新姓名和班级,密码留空则不变。
名单是 Excel 另存的 CSV,列为"学号、姓名、密码"。密码可以留空,平台会生成 6
位数字密码;导入后点"导出名单"发给学生。学号已存在时更新姓名和班级,密码留空则不变。
</p>
<NDataTable :columns="columns" :data="students" :row-key="(s: Student) => s.id" size="small" :bordered="false" />
</NTabPane>
@@ -292,14 +281,19 @@ async function signOut() {
@update:show="(v: boolean) => !v && (preview = null)"
>
<p>
共 {{ preview?.length }} 人<template v-if="preview && previewValid.length < preview.length">,
<span class="problem">{{ preview.length - previewValid.length }} 行有问题,不会导入</span></template
共 {{ preview?.length }} 人<template v-if="preview && previewValid.length < preview.length"
>, <span class="problem">{{ preview.length - previewValid.length }} 行有问题,不会导入</span></template
>。
</p>
<div class="preview">
<table>
<thead>
<tr><th>学号</th><th>姓名</th><th>密码</th><th /></tr>
<tr>
<th>学号</th>
<th>姓名</th>
<th>密码</th>
<th />
</tr>
</thead>
<tbody>
<tr v-for="(r, i) in preview" :key="i" :class="{ bad: r.problem }">
+31 -25
View File
@@ -10,7 +10,17 @@ import FormDesigner from '../designer/FormDesigner.vue'
import { createDesignerHistory } from '../designer/history'
import { gallery, loadGallery, projectResources, resourcePrefix, resourcesFile, RESOURCES_FILE } from '../designer/resources'
import ResourcesDialog from '../designer/ResourcesDialog.vue'
import { classPathError, formNameTaken, isFormDesigner, newClassFile, newFormFiles, nextFormName, projectNamespace, renameForm, startupForm } from '../designer/templates'
import {
classPathError,
formNameTaken,
isFormDesigner,
newClassFile,
newFormFiles,
nextFormName,
projectNamespace,
renameForm,
startupForm,
} from '../designer/templates'
import CodeEditor from '../editor/CodeEditor.vue'
import { api } from '../api'
import { currentUser, logout } from '../auth'
@@ -36,11 +46,13 @@ const DESIGN = '设计:'
/** 每个 X.Designer.cs 对应一个设计器标签页,和它的代码文件 X.cs */
const forms = computed(() => {
const startup = startupForm(files.value)
return files.value
.filter((f) => isFormDesigner(f.path))
.map((f) => ({ tab: DESIGN + f.path, designer: f.path, code: f.path.replace(/\.Designer\.cs$/, '.cs') }))
// 启动窗体排在最前
.sort((a, b) => Number(b.code === `${startup}.cs`) - Number(a.code === `${startup}.cs`))
return (
files.value
.filter((f) => isFormDesigner(f.path))
.map((f) => ({ tab: DESIGN + f.path, designer: f.path, code: f.path.replace(/\.Designer\.cs$/, '.cs') }))
// 启动窗体排在最前
.sort((a, b) => Number(b.code === `${startup}.cs`) - Number(a.code === `${startup}.cs`))
)
})
/**
* 标签页按窗体分组:每个窗体依次是 [设计]、代码;其他文件(Program.cs 等)放最后。
@@ -79,7 +91,9 @@ const message = useMessage()
const workKey = ref<string>()
const STATUS_TEXT = { idle: '未运行', compiling: '编译中…', running: '运行中', stopped: '已停止' } as const
const statusType = computed(() => ({ idle: 'default', compiling: 'warning', running: 'success', stopped: 'default' })[state.status] as 'default' | 'warning' | 'success')
const statusType = computed(
() => ({ idle: 'default', compiling: 'warning', running: 'success', stopped: 'default' })[state.status] as 'default' | 'warning' | 'success',
)
const running = computed(() => state.status === 'compiling' || state.status === 'running')
/**
@@ -165,7 +179,6 @@ const taskPanel = computed(() => {
return { task: sampleTask.value, titles: sampleChecks.value, target: `sample:${id}`, url: `/api/samples/${encodeURIComponent(id)}/selfcheck` }
})
// ---------- 作业 ----------
const assignment = ref<Assignment | null>(null)
const submission = ref<Submission | null>(null)
@@ -265,8 +278,7 @@ function pollSubmission() {
}
onBeforeUnmount(() => clearTimeout(pollTimer))
const formatTime = (ms: number) =>
new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
const formatTime = (ms: number) => new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
// ---------- 自动保存到服务器 ----------
/** idle:还没改过;pending:有改动等待保存;saving:正在保存;saved:已保存;error:保存失败(下次改动时重试) */
@@ -510,13 +522,7 @@ function run() {
</template>
<template v-else>
<strong>物联网编程仿真平台</strong>
<NSelect
class="sample-select"
:value="sampleId"
:options="samples"
placeholder="选择示例工程"
@update:value="loadSample"
/>
<NSelect class="sample-select" :value="sampleId" :options="samples" placeholder="选择示例工程" @update:value="loadSample" />
</template>
<NButton type="primary" :disabled="running" title="F5" @click="run">▶ 运行</NButton>
<NButton :disabled="!running" title="Shift+F5" @click="session.stop">■ 停止</NButton>
@@ -546,8 +552,7 @@ function run() {
<span v-if="!viewing" class="save-state" :class="saveState">{{ SAVE_TEXT[saveState] }}</span>
<NTag :type="statusType">{{ STATUS_TEXT[state.status] }}</NTag>
<span v-if="!assignmentId && !viewing && currentUser?.role === 'teacher'" class="metrics">
编译 {{ state.metrics.compileMs }} ms · 启动 {{ state.metrics.startMs }} ms · 最近一次操作往返
{{ state.metrics.lastRoundTripMs }} ms
编译 {{ state.metrics.compileMs }} ms · 启动 {{ state.metrics.startMs }} ms · 最近一次操作往返 {{ state.metrics.lastRoundTripMs }} ms
</span>
<span class="spacer" />
<RouterLink v-if="!assignmentId && currentUser?.role === 'student'" to="/assignments" class="nav-link">我的作业</RouterLink>
@@ -616,7 +621,12 @@ function run() {
</template>
将删除 {{ deletableClass }},代码里用到它的地方需要自己修改。确定删除吗?
</NPopconfirm>
<NButton size="small" quaternary title="相当于 VS 的“项目属性 → 资源”:添加图片和声音,代码里用 Properties.Resources.名字" @click="resourcesOpen = true">
<NButton
size="small"
quaternary
title="相当于 VS 的“项目属性 → 资源”:添加图片和声音,代码里用 Properties.Resources.名字"
@click="resourcesOpen = true"
>
项目资源{{ resourceNames.length ? `(${resourceNames.length})` : '' }}
</NButton>
<NPopconfirm v-if="deletableForm" positive-text="删除" negative-text="取消" @positive-click="deleteForm">
@@ -655,11 +665,7 @@ function run() {
/>
</section>
<button
class="run-toggle"
:title="runCollapsed ? '展开运行区' : '收起运行区(左边的代码和设计器占满宽度,运行时自动展开)'"
@click="toggleRunArea()"
>
<button class="run-toggle" :title="runCollapsed ? '展开运行区' : '收起运行区(左边的代码和设计器占满宽度,运行时自动展开)'" @click="toggleRunArea()">
{{ runCollapsed ? '◂' : '▸' }}
</button>
<RunArea v-show="!runCollapsed" :session="session" @diagnostic="showDiagnostic" />
+3 -13
View File
@@ -59,10 +59,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
<template>
<section class="run-area" :class="{ maximized }">
<div ref="desktop" class="desktop">
<div
class="desktop-canvas"
:style="scale < 1 ? { transform: `scale(${scale})`, width: `${100 / scale}%`, height: `${100 / scale}%` } : undefined"
>
<div class="desktop-canvas" :style="scale < 1 ? { transform: `scale(${scale})`, width: `${100 / scale}%`, height: `${100 / scale}%` } : undefined">
<FormWindow
v-for="(form, index) in state.forms"
:key="form.root.id"
@@ -73,21 +70,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
:act="session.act"
/>
</div>
<MessageBoxDialog
v-for="box in state.messageBoxes"
:key="box.requestId"
:box="box"
@reply="session.replyMessageBox(box.requestId, $event)"
/>
<MessageBoxDialog v-for="box in state.messageBoxes" :key="box.requestId" :box="box" @reply="session.replyMessageBox(box.requestId, $event)" />
<div v-if="state.connection === 'lost'" class="desktop-hint lost">与服务器的连接断开了,正在重新连接…</div>
<div v-else-if="state.connection === 'restored' && state.forms.length === 0" class="desktop-hint lost">
连接已恢复。刚才的程序随连接断开已经结束,请重新运行
</div>
<div v-else-if="state.forms.length === 0" class="desktop-hint">点击"运行"后,学生程序的窗体会显示在这里</div>
<div class="desktop-tools">
<span v-if="scale < 1" class="desktop-scale" title="窗体比运行区大,按比例缩小显示;点&quot;放大&quot;可以看得更清楚">
缩放 {{ Math.round(scale * 100) }}%
</span>
<span v-if="scale < 1" class="desktop-scale" title='窗体比运行区大,按比例缩小显示;点"放大"可以看得更清楚'> 缩放 {{ Math.round(scale * 100) }}% </span>
<button type="button" :title="maximized ? '还原(Esc)' : '运行区占满整个网页'" @click="maximized = !maximized">
{{ maximized ? '还原' : '放大' }}
</button>
+1 -9
View File
@@ -1,14 +1,6 @@
import { reactive, shallowReactive } from 'vue'
import { validAccessToken } from '../tokens'
import type {
BoardState,
ClientMessage,
ControlNode,
Diagnostic,
ServerMessage,
SourceFile,
UserAction,
} from '@iot/shared'
import type { BoardState, ClientMessage, ControlNode, Diagnostic, ServerMessage, SourceFile, UserAction } from '@iot/shared'
export interface OpenForm {
root: ControlNode
+17 -18
View File
@@ -151,20 +151,18 @@ const cameraTitle = computed(() =>
<!-- 顶棚:红外对射、警示灯(DO0)、摄像头 -->
<div class="ceiling">
<button class="device beam-end" :class="{ active: digital.infrared }" title="点击遮挡/恢复光束" @click="toggle('infrared')">
红外对射
</button>
<button class="device beam-end" :class="{ active: digital.infrared }" title="点击遮挡/恢复光束" @click="toggle('infrared')">红外对射</button>
<div class="beam" :class="{ broken: digital.infrared }" @click="toggle('infrared')">
<span v-if="digital.infrared" class="beam-tag">有人闯入</span>
</div>
<div class="device warning-light" :class="{ on: alarmOn }" title="警示灯(ADAM-4150 DO0)">警示灯</div>
<div class="beam" :class="{ broken: digital.infrared }" @click="toggle('infrared')" />
<div class="device camera" :class="{ on: camera.on }" :title="cameraTitle">
<svg viewBox="0 0 24 24" width="22" height="22"><path fill="currentColor" d="M4 7h11a2 2 0 0 1 2 2v1.5l3.5-2.5v8L17 13.5V15a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z" /></svg>
<svg viewBox="0 0 24 24" width="22" height="22">
<path fill="currentColor" d="M4 7h11a2 2 0 0 1 2 2v1.5l3.5-2.5v8L17 13.5V15a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z" />
</svg>
</div>
<button class="device beam-end" :class="{ active: digital.infrared }" title="点击遮挡/恢复光束" @click="toggle('infrared')">
红外对射
</button>
<button class="device beam-end" :class="{ active: digital.infrared }" title="点击遮挡/恢复光束" @click="toggle('infrared')">红外对射</button>
</div>
<!-- LED 显示屏 -->
@@ -227,7 +225,14 @@ const cameraTitle = computed(() =>
<div class="card" :title="playing ? `广播正在播放 ${playing.name}` : '广播(教材项目 7 用 Windows Media Player 播放铃声)'">
<svg viewBox="0 0 24 24" width="30" height="30" class="icon speaker" :class="{ on: playing }">
<path fill="currentColor" d="M4 9h4l5-4v14l-5-4H4z" />
<path v-if="playing" d="M16 8.5a5 5 0 0 1 0 7M18.5 6a8.5 8.5 0 0 1 0 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
<path
v-if="playing"
d="M16 8.5a5 5 0 0 1 0 7M18.5 6a8.5 8.5 0 0 1 0 12"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
/>
</svg>
<div class="card-title">广播</div>
<div class="card-note">{{ playing ? `播放 ${playing.name}` : '静音' }}</div>
@@ -278,22 +283,16 @@ const cameraTitle = computed(() =>
<div class="module">
<div class="module-title">ADAM-4150 数字量(地址 1)</div>
<div class="io-row">
<span v-for="(input, i) in DIGITAL_INPUTS" :key="input.key" class="io" :class="{ lit: digital[input.key] }">
<i />DI{{ i }} {{ input.label }}
</span>
<span v-for="(input, i) in DIGITAL_INPUTS" :key="input.key" class="io" :class="{ lit: digital[input.key] }"> <i />DI{{ i }} {{ input.label }} </span>
</div>
<div class="io-row">
<span v-for="(label, i) in RELAYS" :key="label" class="io out" :class="{ lit: relays[i] }">
<i />DO{{ i }} {{ label }}
</span>
<span v-for="(label, i) in RELAYS" :key="label" class="io out" :class="{ lit: relays[i] }"> <i />DO{{ i }} {{ label }} </span>
</div>
</div>
<div class="module ports">
<div class="module-title">串口服务器</div>
<div class="port-list">
<span>COM2 → 4017</span><span>COM3 → 4150</span><span>COM4 → ZigBee 协调器</span><span>COM5 → LED 屏</span>
</div>
<div class="port-list"><span>COM2 → 4017</span><span>COM3 → 4150</span><span>COM4 → ZigBee 协调器</span><span>COM5 → LED 屏</span></div>
<button class="reset" :disabled="!board" @click="emit('reset')">实训台复位</button>
</div>
</div>
@@ -498,7 +497,7 @@ button {
white-space: pre;
color: #ff2a2a;
text-shadow: 0 0 6px rgb(255 40 40 / 0.8);
font-family: "SimHei", "Noto Sans CJK SC", sans-serif;
font-family: 'SimHei', 'Noto Sans CJK SC', sans-serif;
font-size: 30px;
line-height: 36px;
animation: led-scroll 12s linear infinite;
+1 -2
View File
@@ -53,8 +53,7 @@ function review(row: Row) {
void router.push(`/teacher/assignments/${row.id}`)
}
const formatTime = (ms: number) =>
new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
const formatTime = (ms: number) => new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
const columns: DataTableColumns<Row> = [
{ title: '作业', key: 'title' },
+1 -2
View File
@@ -46,8 +46,7 @@ function cell(row: Row, assignmentId: number) {
const c = row.scores[assignmentId]
const open = () => router.push({ path: `/teacher/assignments/${assignmentId}`, query: { student: row.userId } })
if (!c?.submitted) return h('span', { class: 'missing' }, '未交')
const text =
c.manual !== null ? String(c.total) : c.aiManual !== null ? `${(c.auto ?? 0) + c.aiManual}(AI 建议,待确认)` : `${c.total}(待评)`
const text = c.manual !== null ? String(c.total) : c.aiManual !== null ? `${(c.auto ?? 0) + c.aiManual}(AI 建议,待确认)` : `${c.total}(待评)`
return h(NButton, { text: true, type: 'primary', onClick: open }, () => text)
}
+60 -11
View File
@@ -2,14 +2,46 @@
// 图表(WinForms 的 Chart,教材项目 8 的 7 天温度折线图):运行器发来各个系列的点,用 Chart.js 画。
// 图表类型按 WinForms 的 SeriesChartType 对应;配色是 WinForms 默认的 BrightPastel;图例默认在右边;不要动画。
import {
ArcElement, BarController, BarElement, CategoryScale, Chart, DoughnutController, Filler, Legend, LinearScale, LineController,
LineElement, PieController, PointElement, RadarController, RadialLinearScale, Title, Tooltip, type ChartConfiguration,
ArcElement,
BarController,
BarElement,
CategoryScale,
Chart,
DoughnutController,
Filler,
Legend,
LinearScale,
LineController,
LineElement,
PieController,
PointElement,
RadarController,
RadialLinearScale,
Title,
Tooltip,
type ChartConfiguration,
} from 'chart.js'
import { computed, onBeforeUnmount, onMounted, ref, watch, type CSSProperties } from 'vue'
import type { ControlNode } from '@iot/shared'
Chart.register(ArcElement, BarController, BarElement, CategoryScale, DoughnutController, Filler, Legend, LinearScale, LineController,
LineElement, PieController, PointElement, RadarController, RadialLinearScale, Title, Tooltip)
Chart.register(
ArcElement,
BarController,
BarElement,
CategoryScale,
DoughnutController,
Filler,
Legend,
LinearScale,
LineController,
LineElement,
PieController,
PointElement,
RadarController,
RadialLinearScale,
Title,
Tooltip,
)
interface ChartSeries {
name: string
@@ -33,7 +65,23 @@ interface ChartData {
const props = defineProps<{ node: ControlNode; style: CSSProperties }>()
/** WinForms 图表默认的 BrightPastel 配色 */
const PALETTE = ['#418CF0', '#FCB441', '#E0400A', '#056492', '#BFBFBF', '#1A3B69', '#FFE382', '#129CDD', '#CA6B4B', '#005CDB', '#F3D288', '#506381', '#F1B9A8', '#E0830A', '#7893BE']
const PALETTE = [
'#418CF0',
'#FCB441',
'#E0400A',
'#056492',
'#BFBFBF',
'#1A3B69',
'#FFE382',
'#129CDD',
'#CA6B4B',
'#005CDB',
'#F3D288',
'#506381',
'#F1B9A8',
'#E0830A',
'#7893BE',
]
const data = computed(() => props.node.props.Chart as ChartData | undefined)
const canvas = ref<HTMLCanvasElement>()
@@ -84,12 +132,13 @@ function config(d: ChartData): ChartConfiguration {
legend: { display: d.series.some((s) => s.inLegend), position, labels: { filter: (item) => d.series[item.datasetIndex ?? 0]?.inLegend ?? true } },
title: { display: d.titles.length > 0, text: d.titles },
},
scales: pie || chartKind === 'radar'
? {}
: {
x: { stacked, grid: { color: '#d9d9d9' } },
y: { stacked, min: d.yMin ?? undefined, max: d.yMax ?? undefined, beginAtZero: true, grid: { color: '#d9d9d9' } },
},
scales:
pie || chartKind === 'radar'
? {}
: {
x: { stacked, grid: { color: '#d9d9d9' } },
y: { stacked, min: d.yMin ?? undefined, max: d.yMax ?? undefined, beginAtZero: true, grid: { color: '#d9d9d9' } },
},
},
} as ChartConfiguration
}
+2 -12
View File
@@ -95,13 +95,7 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
<template>
<!-- Button -->
<button
v-if="node.type === 'Button'"
class="wf-control wf-button"
:style="style"
:disabled="!enabled"
@click="act({ action: 'click' })"
>
<button v-if="node.type === 'Button'" class="wf-control wf-button" :style="style" :disabled="!enabled" @click="act({ action: 'click' })">
{{ displayText(p.Text) }}
</button>
@@ -230,11 +224,7 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
</div>
<!-- 教材控件库:LED 显示屏 -->
<LedControl
v-else-if="node.type === 'WinFormsDeviceControlLibrary.LED'"
:node="node"
:style="style"
/>
<LedControl v-else-if="node.type === 'WinFormsDeviceControlLibrary.LED'" :node="node" :style="style" />
<!-- 其他容器(Panel、UserControl 等):按普通容器渲染 -->
<div v-else class="wf-control wf-panel" :style="style" :title="node.type">
+7 -7
View File
@@ -53,6 +53,11 @@ function click(row: number, column: number) {
if (col?.check && !col.readOnly && p.value.ReadOnly !== true) props.act({ action: 'cellEdit', row, column, value: cell === true ? 'False' : 'True' })
}
function doubleClick(row: number, column: number) {
props.act({ action: 'cellDoubleClick', row, column })
startEdit(row, column)
}
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
@@ -137,12 +142,7 @@ function columnStyle(column: Column): CSSProperties {
</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` }"
>
<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>
@@ -152,7 +152,7 @@ function columnStyle(column: Column): CSSProperties {
: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)"
@dblclick="doubleClick(r, column.index)"
>
<input
v-if="editing && editing.row === r && editing.column === column.index"
+1 -2
View File
@@ -33,8 +33,7 @@ function pick(event: Event) {
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)
const next = inputType.value === 'date' ? `${picked}T${time}` : inputType.value === 'time' ? `${date}T${withSeconds(picked)}` : withSeconds(picked)
props.act({ action: 'pick', value: next })
}
+4 -1
View File
@@ -26,7 +26,10 @@ const props = defineProps<{
const p = computed(() => props.form.props)
const runImages = inject(RUN_IMAGES, new Map<string, string>())
provide(AMBIENT_BACK_COLOR, computed(() => (p.value.BackColor as string | null) ?? DEFAULT_BACK_COLOR))
provide(
AMBIENT_BACK_COLOR,
computed(() => (p.value.BackColor as string | null) ?? DEFAULT_BACK_COLOR),
)
const borderless = computed(() => p.value.FormBorderStyle === 'None')
// WinForms 中先加入 Controls 的控件在最上层,所以倒序渲染
const childrenTopLast = computed(() => [...props.form.children].reverse())
+32 -6
View File
@@ -8,13 +8,39 @@ const emit = defineEmits<{ reply: [result: string] }>()
const BUTTONS: Record<string, [string, string][]> = {
OK: [['OK', '确定']],
OKCancel: [['OK', '确定'], ['Cancel', '取消']],
YesNo: [['Yes', '是(Y)'], ['No', '否(N)']],
YesNoCancel: [['Yes', '是(Y)'], ['No', '否(N)'], ['Cancel', '取消']],
RetryCancel: [['Retry', '重试(R)'], ['Cancel', '取消']],
AbortRetryIgnore: [['Abort', '中止(A)'], ['Retry', '重试(R)'], ['Ignore', '忽略(I)']],
OKCancel: [
['OK', '确定'],
['Cancel', '取消'],
],
YesNo: [
['Yes', '是(Y)'],
['No', '否(N)'],
],
YesNoCancel: [
['Yes', '是(Y)'],
['No', '否(N)'],
['Cancel', '取消'],
],
RetryCancel: [
['Retry', '重试(R)'],
['Cancel', '取消'],
],
AbortRetryIgnore: [
['Abort', '中止(A)'],
['Retry', '重试(R)'],
['Ignore', '忽略(I)'],
],
}
const ICONS: Record<string, string> = {
Error: '⛔',
Hand: '⛔',
Stop: '⛔',
Warning: '⚠️',
Exclamation: '⚠️',
Information: 'ℹ️',
Asterisk: 'ℹ️',
Question: '❓',
}
const ICONS: Record<string, string> = { Error: '⛔', Hand: '⛔', Stop: '⛔', Warning: '⚠️', Exclamation: '⚠️', Information: 'ℹ️', Asterisk: 'ℹ️', Question: '❓' }
const buttons = computed(() => BUTTONS[props.box.buttons] ?? BUTTONS.OK)
const icon = computed(() => ICONS[props.box.icon])
+1 -1
View File
@@ -22,7 +22,7 @@ const vertical = computed(() => p.value.Orientation === 'Vertical')
const tickStyle = computed(() => String(p.value.TickStyle ?? 'BottomRight'))
const size = computed(() => p.value.Size as { width: number; height: number } | undefined)
/** 轨道方向上的长度 */
const length = computed(() => (vertical.value ? size.value?.height ?? 104 : size.value?.width ?? 104))
const length = computed(() => (vertical.value ? (size.value?.height ?? 104) : (size.value?.width ?? 104)))
/** 值 → 沿轨道的位置(像素);竖向时最小值在下面 */
function position(v: number) {
+1 -2
View File
@@ -28,8 +28,7 @@ export function imageSources(value: unknown, images: ReadonlyMap<string, string>
}
/** <img> 的 srcset:有两倍图时浏览器按屏幕分辨率选 */
export const srcset = (sources: { src: string; hidpi?: string } | undefined) =>
sources?.hidpi ? `${sources.src} 1x, ${sources.hidpi} 2x` : undefined
export const srcset = (sources: { src: string; hidpi?: string } | undefined) => (sources?.hidpi ? `${sources.src} 1x, ${sources.hidpi} 2x` : undefined)
/** 背景图片的 CSS:BackgroundImageLayout 为 Tile(默认,平铺)、None(左上角一张)、Center、Stretch、Zoom */
export function backgroundCss(value: unknown, layout: unknown, images: ReadonlyMap<string, string>): CSSProperties {
+7 -7
View File
@@ -5,7 +5,7 @@
background: #f0f0f0;
border: 1px solid #1883d7;
box-shadow: 0 6px 24px rgb(0 0 0 / 0.25);
font-family: "SimSun", "Songti SC", "Noto Serif CJK SC", "Noto Serif SC", serif;
font-family: 'SimSun', 'Songti SC', 'Noto Serif CJK SC', 'Noto Serif SC', serif;
font-size: 12px;
color: #000;
user-select: none;
@@ -26,7 +26,7 @@
.wf-title {
flex: 1;
padding-left: 8px;
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
font-family: 'Microsoft YaHei', 'PingFang SC', 'Noto Sans CJK SC', sans-serif;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
@@ -373,7 +373,7 @@
flex-direction: column;
background: #000;
color: #9aa4b2;
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif;
font-size: 11px;
overflow: hidden;
}
@@ -456,15 +456,15 @@
border-radius: 50%;
}
.wf-checkbox-box[data-checked="true"]::after {
content: "✓";
.wf-checkbox-box[data-checked='true']::after {
content: '✓';
display: block;
font: bold 11px/11px sans-serif;
text-align: center;
color: #333;
}
.wf-radio-dot[data-checked="true"] {
.wf-radio-dot[data-checked='true'] {
background: radial-gradient(circle, #333 0 3px, #fff 3.5px);
}
@@ -551,7 +551,7 @@
align-items: center;
padding: 24px 28px;
background: #fff;
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
font-family: 'Microsoft YaHei', 'PingFang SC', 'Noto Sans CJK SC', sans-serif;
}
.wf-messagebox-icon {
+4 -17
View File
@@ -3,27 +3,14 @@
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": [
"ES2023",
"DOM",
"DOM.Iterable"
],
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"jsx": "preserve",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"types": [
"vite/client"
]
"types": ["vite/client"]
},
"include": [
"src/**/*.ts",
"src/**/*.vue",
"vite.config.ts",
"../shared"
],
"exclude": [
"src/**/*.test.ts"
]
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "../shared"],
"exclude": ["src/**/*.test.ts"]
}