设计器:撤销 / 重做;工具箱、属性、事件窗口改为中文

- 撤销 / 重做按窗体保存在页面层,切换标签页不丢;Ctrl+Z、Ctrl+Y / Ctrl+Shift+Z
- 撤销"生成事件处理方法"时事件绑定和插入的方法一起撤掉;
  学生已在方法里写了代码则只撤销设计,不动代码
- Designer.cs 在代码页被手改后,该窗体历史失效并清空,避免覆盖手改内容
- 没有实际改动的操作(如双击已绑定的事件)不记入历史
- 工具箱、属性名、事件名、枚举选项显示中文;悬停显示英文名和用途说明
- 开发模式下把页面状态挂到 window.__iot,供浏览器自动化测试读取

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-22 11:18:43 +08:00
1 parent ea5a25af06
commit 02d2f0eabc
6 files changed
+302 -67

No files matched your search

+18
View File
@@ -4,6 +4,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { NButton, NConfigProvider, NPopconfirm, NSelect, NTab, NTabs, NTag, zhCN } from 'naive-ui'
import type { SourceFile } from '@iot/shared'
import FormDesigner from './designer/FormDesigner.vue'
import { createDesignerHistory } from './designer/history'
import CodeEditor from './editor/CodeEditor.vue'
import { clearDraft, loadDraft, saveDraft } from './editor/drafts'
import { createRunSession } from './run/session'
@@ -35,6 +36,10 @@ const activeForm = computed(() => forms.value.find((f) => f.tab === activeTab.va
/** 代码编辑器显示的文件:设计器打开时保持上一次的代码文件 */
const activeFile = computed(() => (activeForm.value ? undefined : activeTab.value))
const fileText = (path: string) => files.value.find((f) => f.path === path)?.content ?? ''
const designerHistory = createDesignerHistory({ read: fileText, write: (path, content) => updateFile(path, content) })
// 开发模式下暴露页面状态,供浏览器自动化测试读取
if (import.meta.env.DEV) Object.assign(window, { __iot: { files, fileText, designerHistory, activeTab } })
/** 当前代码是否和示例原始代码不同(来自浏览器里保存的草稿) */
const modified = ref(false)
const editorRef = ref<InstanceType<typeof CodeEditor>>()
@@ -74,6 +79,7 @@ async function loadSample(id: string, discardDraft = false) {
sampleId.value = id
history.replaceState(null, '', `?sample=${encodeURIComponent(id)}`)
if (discardDraft) clearDraft(id)
designerHistory.clear()
const draft = discardDraft ? null : loadDraft(id)
modified.value = draft !== null
files.value = draft ?? (await (await fetch(`/api/samples/${encodeURIComponent(id)}`)).json())
@@ -94,6 +100,14 @@ function updateFile(path: string, content: string) {
function onDesignerUpdate(designerText: string, codeText?: string) {
const form = activeForm.value
if (!form) return
// 没有实际改动(如双击已绑定的事件,只是跳到代码)不记入历史
const codeChanged = codeText !== undefined && codeText !== fileText(form.code)
if (designerText === fileText(form.designer) && !codeChanged) return
designerHistory.record(form.designer, {
designerBefore: fileText(form.designer),
designerAfter: designerText,
...(codeText !== undefined ? { codeBefore: fileText(form.code), codeAfter: codeText } : {}),
})
updateFile(form.designer, designerText)
if (codeText !== undefined) updateFile(form.code, codeText)
}
@@ -160,7 +174,11 @@ function run() {
class="code"
:designer-text="fileText(activeForm.designer)"
:code-text="fileText(activeForm.code)"
:can-undo="designerHistory.canUndo(activeForm.designer)"
:can-redo="designerHistory.canRedo(activeForm.designer)"
@update="onDesignerUpdate"
@undo="designerHistory.undo(activeForm.designer, activeForm.code)"
@redo="designerHistory.redo(activeForm.designer, activeForm.code)"
@open-code="openCode"
/>
<CodeEditor
+63 -3
View File
@@ -29,6 +29,8 @@ import { parseValue, type Value } from './values'
const props = defineProps<{
designerText: string
codeText: string
canUndo: boolean
canRedo: boolean
}>()
const emit = defineEmits<{
@@ -36,8 +38,25 @@ const emit = defineEmits<{
update: [designerText: string, codeText?: string]
/** 跳到窗体代码文件的某一行 */
openCode: [line: number]
undo: []
redo: []
}>()
/** Ctrl+Z 撤销,Ctrl+Y / Ctrl+Shift+Z 重做;在属性窗口的输入框里打字时不拦截(输入框有自己的撤销) */
function onShortcut(event: KeyboardEvent) {
const target = event.target as HTMLElement
if (target.closest('input, textarea, select')) return
if (!(event.ctrlKey || event.metaKey)) return
const key = event.key.toLowerCase()
if (key === 'z' && !event.shiftKey) {
event.preventDefault()
emit('undo')
} else if (key === 'y' || (key === 'z' && event.shiftKey)) {
event.preventDefault()
emit('redo')
}
}
// ---------- 文档 ----------
const parsed = computed<{ doc?: DesignerDocument; error?: string }>(() => {
try {
@@ -257,6 +276,10 @@ function onKeyDown(event: KeyboardEvent) {
// ---------- 工具箱 ----------
const TOOLBOX_GROUPS = ['常用控件', '容器', '组件', '物联网设备'] as const
const ICONS: Record<string, string> = {
按钮: '▭', 标签: 'A', 文本框: '▤', 下拉框: '☰', 复选框: '☑', 单选按钮: '◉', 分组框: '⬚', 面板: '▢',
定时器: '⏱', 'LED 显示屏': '▬', 灯泡: '💡', 报警灯: '🚨',
}
const DRAG_TYPE = 'application/x-winforms-control'
function onToolDragStart(event: DragEvent, def: ControlDef) {
@@ -384,7 +407,7 @@ const noop = () => {}
<p>Designer.cs 的内容不是窗体设计器能识别的格式,可能是手动修改造成的。可以在代码里改回来,或者用"恢复原始代码"。</p>
</div>
<div v-else-if="doc && tree" class="designer">
<div v-else-if="doc && tree" class="designer" @keydown="onShortcut">
<aside class="toolbox">
<div class="panel-title">工具箱</div>
<template v-for="group in TOOLBOX_GROUPS" :key="group">
@@ -394,16 +417,21 @@ const noop = () => {}
:key="def.type"
class="tool"
draggable="true"
:title="`拖到窗体上,或双击添加`"
:title="`${def.type.split('.').at(-1)}:拖到窗体上,或双击添加`"
@dragstart="onToolDragStart($event, def)"
@dblclick="onToolDoubleClick(def)"
>
{{ def.label }}
<span class="tool-icon">{{ ICONS[def.label] ?? '▫' }}</span>{{ def.label }}
</div>
</template>
</aside>
<section class="canvas">
<div class="actions">
<button :disabled="!canUndo" title="撤销(Ctrl+Z)" @click="emit('undo')">↶ 撤销</button>
<button :disabled="!canRedo" title="重做(Ctrl+Y)" @click="emit('redo')">↷ 重做</button>
<span class="tips">拖动工具箱里的控件到窗体上;双击控件生成事件处理方法;Delete 删除</span>
</div>
<div class="window" :class="{ selected: selected === '' }">
<div class="titlebar" @pointerdown="selected = ''">
<span class="title">{{ displayText(formTitle) }}</span>
@@ -510,6 +538,12 @@ const noop = () => {}
user-select: none;
}
.tool-icon {
display: inline-block;
width: 18px;
color: #6b7280;
}
.tool:hover {
background: #e8f0fe;
}
@@ -524,6 +558,32 @@ const noop = () => {}
gap: 12px;
}
.actions {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.actions button {
padding: 2px 10px;
border: 1px solid #c5ccd5;
border-radius: 3px;
background: #fff;
font: inherit;
cursor: pointer;
}
.actions button:disabled {
color: #b0b6bf;
cursor: default;
}
.tips {
margin-left: 8px;
color: #7a838e;
}
.window {
align-self: flex-start;
border: 1px solid #9aa3ad;
+9 -10
View File
@@ -23,7 +23,8 @@ const emit = defineEmits<{
const tab = ref<'props' | 'events'>('props')
const def = computed<ControlDef>(() => (props.selected === '' ? FORM_DEF : controlDef(typeOf(props.doc, props.selected) ?? '')))
const title = computed(() => `${props.selected || props.doc.formName} ${def.value.type.split('.').at(-1)}`)
const typeName = computed(() => def.value.type.split('.').at(-1)!)
const title = computed(() => `${props.selected || props.doc.formName}(${def.value.label})`)
function current(prop: PropDef): Value | undefined {
const text = getPropText(props.doc, props.selected, prop.name)
@@ -131,7 +132,7 @@ function renameHandler(event: string, value: string) {
<template>
<div class="grid">
<div class="grid-title" :title="title">{{ title }}</div>
<div class="grid-title" :title="`类型:${typeName}`">{{ title }}</div>
<div class="tabs">
<button :class="{ active: tab === 'props' }" @click="tab = 'props'">属性</button>
<button :class="{ active: tab === 'events' }" title="事件" @click="tab = 'events'">⚡ 事件</button>
@@ -139,7 +140,7 @@ function renameHandler(event: string, value: string) {
<div v-if="tab === 'props'" class="rows">
<div class="row">
<label title="控件的名称,代码里用 this.名称 访问它">(Name)</label>
<label title="Name:控件的名称,代码里用这个名字访问它">名称</label>
<div class="editor">
<input v-model="nameDraft" :disabled="selected === ''" @change="commitName" @keydown.enter="commitName" />
<div v-if="nameError" class="error">{{ nameError }}</div>
@@ -148,7 +149,7 @@ function renameHandler(event: string, value: string) {
</div>
<div v-for="prop in def.props" :key="prop.name" class="row">
<label :title="prop.label">{{ prop.name }}</label>
<label :title="`${prop.name}:${prop.description}`">{{ prop.label }}</label>
<div class="editor">
<code v-if="raw(prop) !== undefined" class="raw" :title="raw(prop)">{{ raw(prop) }}</code>
@@ -162,8 +163,8 @@ function renameHandler(event: string, value: string) {
<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">True</option>
<option value="false">False</option>
<option value="true">是</option>
<option value="false">否</option>
</select>
<input v-else-if="prop.type === 'int'" type="number" :value="num(prop)"
@@ -181,7 +182,7 @@ function renameHandler(event: string, value: string) {
<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">{{ option }}</option>
<option v-for="option in prop.options" :key="option" :value="option">{{ prop.optionLabels?.[option] ?? option }}</option>
</select>
<span v-else-if="prop.type === 'color'" class="color">
@@ -211,7 +212,6 @@ function renameHandler(event: string, value: string) {
<textarea v-else-if="prop.type === 'items'" rows="4" :value="itemsText" placeholder="每行一项"
@change="setItemsFromText(($event.target as HTMLTextAreaElement).value)" />
<div class="hint">{{ prop.label }}</div>
</div>
</div>
</div>
@@ -219,13 +219,12 @@ function renameHandler(event: string, value: string) {
<div v-else class="rows">
<div class="note events-note">双击事件名生成事件处理方法,并跳到代码</div>
<div v-for="event in def.events" :key="event.name" class="row">
<label class="event-name" :title="`${event.label}(双击生成处理方法)`" @dblclick="emit('openEvent', selected, event.name)">{{ event.name }}</label>
<label class="event-name" :title="`${event.name}:双击生成事件处理方法`" @dblclick="emit('openEvent', selected, event.name)">{{ event.label }}</label>
<div class="editor">
<span class="pair">
<input :value="handlerOf(event.name)" placeholder="(未绑定)" @change="renameHandler(event.name, ($event.target as HTMLInputElement).value)" />
<button class="go" title="生成或跳到事件处理方法" @click="emit('openEvent', selected, event.name)">→</button>
</span>
<div class="hint">{{ event.label }}</div>
</div>
</div>
</div>
+63 -54
View File
@@ -5,20 +5,27 @@ import type { Value } from './values'
export type PropType = 'string' | 'multiline' | 'bool' | 'int' | 'char' | 'point' | 'size' | 'color' | 'font' | 'enum' | 'items'
export interface PropDef {
/** WinForms 的属性名,写进 Designer.cs,代码里也用它 */
name: string
/** 属性窗口里显示的中文说明 */
/** 属性窗口里显示的中文名 */
label: string
/** 鼠标悬停时显示的用途说明 */
description: string
type: PropType
/** 枚举属性的完整类型名和可选值 */
enumType?: string
options?: string[]
/** 枚举选项的中文名(写进 Designer.cs 的仍是英文值) */
optionLabels?: Record<string, string>
/** 等于这个值时不写进 Designer.cs(WinForms 的默认值) */
default?: Value
category: '外观' | '行为' | '布局' | '数据'
}
export interface EventDef {
/** WinForms 的事件名,事件处理方法的名字也用它(如 button1_Click) */
name: string
/** 事件窗口里显示的中文名 */
label: string
delegate: string
/** 事件处理方法第二个参数的类型 */
@@ -28,7 +35,7 @@ export interface EventDef {
export interface ControlDef {
/** 完整类型名,如 System.Windows.Forms.Button */
type: string
/** 工具箱和属性窗口里显示的名字 */
/** 工具箱和属性窗口里显示的中文名(英文类名在鼠标悬停时显示) */
label: string
group: '常用控件' | '容器' | '组件' | '物联网设备'
/** 新控件的名字前缀:button → button1 */
@@ -56,15 +63,15 @@ const EVENT = { delegate: 'System.EventHandler', args: 'EventArgs' }
const MOUSE = { delegate: 'System.Windows.Forms.MouseEventHandler', args: 'MouseEventArgs' }
const COMMON_PROPS: PropDef[] = [
{ name: 'Text', label: '显示的文字', type: 'string', default: str(''), category: '外观' },
{ name: 'BackColor', label: '背景色', type: 'color', category: '外观' },
{ name: 'ForeColor', label: '前景色(文字颜色)', type: 'color', category: '外观' },
{ name: 'Font', label: '字体', type: 'font', category: '外观' },
{ name: 'Location', label: '位置(相对于所在容器)', type: 'point', category: '布局' },
{ name: 'Size', label: '大小', type: 'size', category: '布局' },
{ name: 'Enabled', label: '是否可用', type: 'bool', default: bool(true), category: '行为' },
{ name: 'Visible', label: '是否可见', type: 'bool', default: bool(true), category: '行为' },
{ name: 'TabIndex', label: 'Tab 键顺序', type: 'int', category: '行为' },
{ name: 'Text', label: '文字', description: '控件上显示的文字', type: 'string', default: str(''), category: '外观' },
{ name: 'BackColor', label: '背景色', description: '背景色', type: 'color', category: '外观' },
{ name: 'ForeColor', label: '文字颜色', description: '前景色(文字颜色)', type: 'color', category: '外观' },
{ name: 'Font', label: '字体', description: '字体', type: 'font', category: '外观' },
{ name: 'Location', label: '位置', description: '左上角的坐标(相对于所在的窗体或分组框)', type: 'point', category: '布局' },
{ name: 'Size', label: '大小', description: '宽度和高度(像素)', type: 'size', category: '布局' },
{ name: 'Enabled', label: '可用', description: '是否可用', type: 'bool', default: bool(true), category: '行为' },
{ name: 'Visible', label: '可见', description: '是否可见', type: 'bool', default: bool(true), category: '行为' },
{ name: 'TabIndex', label: 'Tab 顺序', description: 'Tab 键顺序', type: 'int', category: '行为' },
]
const COMMON_EVENTS: EventDef[] = [
@@ -74,12 +81,12 @@ const COMMON_EVENTS: EventDef[] = [
{ name: 'MouseUp', label: '鼠标松开', ...MOUSE },
{ name: 'MouseEnter', label: '鼠标进入', ...EVENT },
{ name: 'MouseLeave', label: '鼠标离开', ...EVENT },
{ name: 'TextChanged', label: 'Text 改变', ...EVENT },
{ name: 'TextChanged', label: '文字改变', ...EVENT },
{ name: 'Enter', label: '获得焦点', ...EVENT },
{ name: 'Leave', label: '失去焦点', ...EVENT },
]
const AUTO_SIZE: PropDef = { name: 'AutoSize', label: '根据文字自动调整大小', type: 'bool', default: bool(false), category: '布局' }
const AUTO_SIZE: PropDef = { name: 'AutoSize', label: '自动大小', description: '根据文字自动调整大小', type: 'bool', default: bool(false), category: '布局' }
/** 宋体 9pt 下文字的大致宽度:半角约 6 像素,全角约 12 像素(与 VS 设计器算出的 Size 接近) */
export function textWidth(text: string) {
@@ -93,7 +100,7 @@ const FORMS = 'System.Windows.Forms'
export const CONTROLS: ControlDef[] = [
{
type: `${FORMS}.Button`,
label: 'Button 按钮',
label: '按钮',
group: '常用控件',
namePrefix: 'button',
defaultEvent: 'Click',
@@ -103,13 +110,13 @@ export const CONTROLS: ControlDef[] = [
},
{
type: `${FORMS}.Label`,
label: 'Label 标签',
label: '标签',
group: '常用控件',
namePrefix: 'label',
defaultEvent: 'Click',
props: [...COMMON_PROPS, AUTO_SIZE, {
name: 'BorderStyle', label: '边框', type: 'enum', enumType: `${FORMS}.BorderStyle`,
options: ['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,
initial: (name) => [['AutoSize', bool(true)], ['Size', size(textWidth(name) + 5, 12)], ['Text', str(name)]],
@@ -117,19 +124,19 @@ export const CONTROLS: ControlDef[] = [
},
{
type: `${FORMS}.TextBox`,
label: 'TextBox 文本框',
label: '文本框',
group: '常用控件',
namePrefix: 'textBox',
defaultEvent: 'TextChanged',
props: [
...COMMON_PROPS.map((p) => (p.name === 'Text' ? { ...p, type: 'multiline' as const } : p)),
{ name: 'Multiline', label: '多行', type: 'bool', default: bool(false), category: '行为' },
{ name: 'ReadOnly', label: '只读', type: 'bool', default: bool(false), category: '行为' },
{ name: 'PasswordChar', label: '密码字符(如 *)', type: 'char', category: '行为' },
{ name: 'UseSystemPasswordChar', label: '用系统密码字符显示', type: 'bool', default: bool(false), category: '行为' },
{ name: 'Multiline', label: '多行', description: '多行', type: 'bool', default: bool(false), category: '行为' },
{ name: 'ReadOnly', label: '只读', description: '只读', type: 'bool', default: bool(false), category: '行为' },
{ name: 'PasswordChar', label: '密码字符', description: '输入时用这个字符代替显示的内容,如 *', type: 'char', category: '行为' },
{ name: 'UseSystemPasswordChar', label: '系统密码字符', description: '用系统密码字符显示', type: 'bool', default: bool(false), category: '行为' },
{
name: 'ScrollBars', label: '滚动条', type: 'enum', enumType: `${FORMS}.ScrollBars`,
options: ['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,
@@ -137,16 +144,16 @@ export const CONTROLS: ControlDef[] = [
},
{
type: `${FORMS}.ComboBox`,
label: 'ComboBox 下拉框',
label: '下拉框',
group: '常用控件',
namePrefix: 'comboBox',
defaultEvent: 'SelectedIndexChanged',
props: [
...COMMON_PROPS,
{ name: 'Items', label: '列表项(每行一项)', type: 'items', category: '数据' },
{ name: 'Items', label: '列表项', description: '下拉列表里的选项,每行一项', type: 'items', category: '数据' },
{
name: 'DropDownStyle', label: '下拉样式', type: 'enum', enumType: `${FORMS}.ComboBoxStyle`,
options: ['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 }],
@@ -154,29 +161,29 @@ export const CONTROLS: ControlDef[] = [
},
{
type: `${FORMS}.CheckBox`,
label: 'CheckBox 复选框',
label: '复选框',
group: '常用控件',
namePrefix: 'checkBox',
defaultEvent: 'CheckedChanged',
props: [...COMMON_PROPS, AUTO_SIZE, { name: 'Checked', label: '是否勾选', type: 'bool', default: bool(false), category: '外观' }],
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)]],
autoSize: (text) => ({ width: textWidth(text) + 24, height: 16 }),
},
{
type: `${FORMS}.RadioButton`,
label: 'RadioButton 单选按钮',
label: '单选按钮',
group: '常用控件',
namePrefix: 'radioButton',
defaultEvent: 'CheckedChanged',
props: [...COMMON_PROPS, AUTO_SIZE, { name: 'Checked', label: '是否选中', type: 'bool', default: bool(false), category: '外观' }],
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)]],
autoSize: (text) => ({ width: textWidth(text) + 17, height: 16 }),
},
{
type: `${FORMS}.GroupBox`,
label: 'GroupBox 分组框',
label: '分组框',
group: '容器',
namePrefix: 'groupBox',
container: true,
@@ -187,7 +194,7 @@ export const CONTROLS: ControlDef[] = [
},
{
type: `${FORMS}.Panel`,
label: 'Panel 面板',
label: '面板',
group: '容器',
namePrefix: 'panel',
container: true,
@@ -198,16 +205,16 @@ export const CONTROLS: ControlDef[] = [
},
{
type: `${FORMS}.Timer`,
label: 'Timer 定时器',
label: '定时器',
group: '组件',
namePrefix: 'timer',
component: true,
defaultEvent: 'Tick',
props: [
{ name: 'Interval', label: '间隔(毫秒)', type: 'int', default: int(100), category: '行为' },
{ name: 'Enabled', label: '启动后立即计时', type: 'bool', default: bool(false), category: '行为' },
{ name: 'Interval', label: '间隔', description: '每隔多少毫秒触发一次"计时到"事件', type: 'int', default: int(100), category: '行为' },
{ name: 'Enabled', label: '启用', description: '程序启动后是否立即开始计时;也可以在代码里调用 Start()', type: 'bool', default: bool(false), category: '行为' },
],
events: [{ name: 'Tick', label: '时间到', ...EVENT }],
events: [{ name: 'Tick', label: '计时到', ...EVENT }],
initial: () => [],
},
{
@@ -222,7 +229,7 @@ export const CONTROLS: ControlDef[] = [
},
{
type: 'WinFormsDeviceControlLibrary.Lamp',
label: 'Lamp 灯泡',
label: '灯泡',
group: '物联网设备',
namePrefix: 'lamp',
defaultEvent: 'Load',
@@ -232,7 +239,7 @@ export const CONTROLS: ControlDef[] = [
},
{
type: 'WinFormsDeviceControlLibrary.Alarm',
label: 'Alarm 报警灯',
label: '报警灯',
group: '物联网设备',
namePrefix: 'alarm',
defaultEvent: 'Load',
@@ -245,36 +252,38 @@ export const CONTROLS: ControlDef[] = [
/** 窗体本身 */
export const FORM_DEF: ControlDef = {
type: `${FORMS}.Form`,
label: 'Form 窗体',
label: '窗体',
group: '容器',
namePrefix: 'Form',
container: true,
defaultEvent: 'Load',
props: [
{ name: 'Text', label: '标题栏文字', type: 'string', default: str(''), category: '外观' },
{ name: 'BackColor', label: '背景色', type: 'color', category: '外观' },
{ name: 'ForeColor', label: '前景色(文字颜色)', type: 'color', category: '外观' },
{ name: 'Font', label: '字体', type: 'font', category: '外观' },
{ name: 'ClientSize', label: '客户区大小(不含标题栏和边框)', type: 'size', category: '布局' },
{ name: 'Text', label: '标题', description: '标题栏文字', type: 'string', default: str(''), category: '外观' },
{ name: 'BackColor', label: '背景色', description: '背景色', type: 'color', category: '外观' },
{ name: 'ForeColor', label: '文字颜色', description: '前景色(文字颜色)', type: 'color', category: '外观' },
{ name: 'Font', label: '字体', description: '字体', type: 'font', category: '外观' },
{ name: 'ClientSize', label: '窗口大小', description: '窗口内部的宽度和高度(不含标题栏和边框)', type: 'size', category: '布局' },
{
name: 'FormBorderStyle', label: '边框样式', 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: '外观',
},
{
name: 'StartPosition', label: '启动位置', 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: '布局',
},
{ name: 'MaximizeBox', label: '显示最大化按钮', type: 'bool', default: bool(true), category: '外观' },
{ name: 'MinimizeBox', label: '显示最小化按钮', type: 'bool', default: bool(true), category: '外观' },
{ name: 'MaximizeBox', label: '最大化按钮', description: '显示最大化按钮', type: 'bool', default: bool(true), category: '外观' },
{ name: 'MinimizeBox', label: '最小化按钮', description: '显示最小化按钮', type: 'bool', default: bool(true), category: '外观' },
],
events: [
{ name: 'Load', label: '加载', ...EVENT },
{ name: 'Shown', label: '首次显示', ...EVENT },
{ name: 'FormClosing', label: '正在关闭', delegate: `${FORMS}.FormClosingEventHandler`, args: 'FormClosingEventArgs' },
{ name: 'FormClosed', label: '已关闭', delegate: `${FORMS}.FormClosedEventHandler`, args: 'FormClosedEventArgs' },
{ name: 'Activated', label: '被激活', ...EVENT },
{ name: 'Shown', label: '显示后', ...EVENT },
{ name: 'FormClosing', label: '关闭前', delegate: `${FORMS}.FormClosingEventHandler`, args: 'FormClosingEventArgs' },
{ name: 'FormClosed', label: '关闭后', delegate: `${FORMS}.FormClosedEventHandler`, args: 'FormClosedEventArgs' },
{ name: 'Activated', label: '激活', ...EVENT },
...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'),
],
initial: () => [],
+65
View File
@@ -0,0 +1,65 @@
/// <reference types="bun" />
import { expect, test } from 'bun:test'
import { createDesignerHistory } from './history'
function setup() {
const files = new Map([
['F.Designer.cs', 'd0'],
['F.cs', 'c0'],
])
const history = createDesignerHistory({ read: (p) => files.get(p) ?? '', write: (p, c) => files.set(p, c) })
/** 模拟设计器的一次改动 */
const change = (designer: string, code?: string) => {
history.record('F.Designer.cs', {
designerBefore: files.get('F.Designer.cs')!,
designerAfter: designer,
...(code !== undefined ? { codeBefore: files.get('F.cs')!, codeAfter: code } : {}),
})
files.set('F.Designer.cs', designer)
if (code !== undefined) files.set('F.cs', code)
}
return { files, history, change }
}
test('撤销、重做依次恢复 Designer.cs;新的改动清空重做', () => {
const { files, history, change } = setup()
change('d1')
change('d2')
expect(history.undo('F.Designer.cs', 'F.cs')).toBe(true)
expect(files.get('F.Designer.cs')).toBe('d1')
expect(history.undo('F.Designer.cs', 'F.cs')).toBe(true)
expect(files.get('F.Designer.cs')).toBe('d0')
expect(history.canUndo('F.Designer.cs')).toBe(false)
expect(history.redo('F.Designer.cs', 'F.cs')).toBe(true)
expect(files.get('F.Designer.cs')).toBe('d1')
change('d3')
expect(history.canRedo('F.Designer.cs')).toBe(false)
})
test('撤销"生成事件处理方法":事件绑定和插入的方法一起撤掉', () => {
const { files, history, change } = setup()
change('d1+event', 'c0+handler')
history.undo('F.Designer.cs', 'F.cs')
expect(files.get('F.Designer.cs')).toBe('d0')
expect(files.get('F.cs')).toBe('c0')
history.redo('F.Designer.cs', 'F.cs')
expect(files.get('F.cs')).toBe('c0+handler')
})
test('学生已经在生成的方法里写了代码:撤销只回退设计,不动代码', () => {
const { files, history, change } = setup()
change('d1+event', 'c0+handler')
files.set('F.cs', 'c0+handler+学生写的代码')
history.undo('F.Designer.cs', 'F.cs')
expect(files.get('F.Designer.cs')).toBe('d0')
expect(files.get('F.cs')).toBe('c0+handler+学生写的代码')
})
test('Designer.cs 在代码页里被手动改过:历史失效并清空,不覆盖手改的内容', () => {
const { files, history, change } = setup()
change('d1')
files.set('F.Designer.cs', 'd1 手动改过')
expect(history.undo('F.Designer.cs', 'F.cs')).toBe(false)
expect(files.get('F.Designer.cs')).toBe('d1 手动改过')
expect(history.canUndo('F.Designer.cs')).toBe(false)
})
+84
View File
@@ -0,0 +1,84 @@
// 窗体设计器的撤销 / 重做。每个窗体(按 Designer.cs 路径)一份历史,保存在页面层,切换标签页不会丢。
// 每一步记录 Designer.cs 改动前后的内容;如果这一步还往窗体代码里插入了事件处理方法,代码文件的前后内容也一起记下。
import { reactive } from 'vue'
export interface Step {
designerBefore: string
designerAfter: string
/** 只有这一步改了窗体代码(插入事件处理方法)时才有 */
codeBefore?: string
codeAfter?: string
}
interface Stacks {
undo: Step[]
redo: Step[]
}
const LIMIT = 100
export interface FileAccess {
read(path: string): string
write(path: string, content: string): void
}
export function createDesignerHistory(files: FileAccess) {
const stacks = reactive(new Map<string, Stacks>())
function of(designer: string): Stacks {
let s = stacks.get(designer)
if (!s) {
s = { undo: [], redo: [] }
stacks.set(designer, s)
}
return s
}
function record(designer: string, step: Step) {
const s = of(designer)
s.undo.push(step)
if (s.undo.length > LIMIT) s.undo.shift()
s.redo = []
}
/**
* 撤销或重做一步。Designer.cs 在代码页里被手动改过(和历史记录对不上)时,这个窗体的历史失效、清空,返回 false。
* 代码文件只在内容仍是这一步的结果时才回退——学生已经在生成的方法里写了代码,就不去动它。
*/
function apply(designer: string, code: string, direction: 'undo' | 'redo'): boolean {
const s = of(designer)
const from = direction === 'undo' ? s.undo : s.redo
const to = direction === 'undo' ? s.redo : s.undo
const step = from.at(-1)
if (!step) return false
const [expected, target] = direction === 'undo' ? [step.designerAfter, step.designerBefore] : [step.designerBefore, step.designerAfter]
if (files.read(designer) !== expected) {
clear(designer)
return false
}
from.pop()
files.write(designer, target)
if (step.codeBefore !== undefined && step.codeAfter !== undefined) {
const [codeExpected, codeTarget] = direction === 'undo' ? [step.codeAfter, step.codeBefore] : [step.codeBefore, step.codeAfter]
if (files.read(code) === codeExpected) files.write(code, codeTarget)
}
to.push(step)
return true
}
function clear(designer?: string) {
if (designer) stacks.delete(designer)
else stacks.clear()
}
return {
record,
undo: (designer: string, code: string) => apply(designer, code, 'undo'),
redo: (designer: string, code: string) => apply(designer, code, 'redo'),
canUndo: (designer: string) => (stacks.get(designer)?.undo.length ?? 0) > 0,
canRedo: (designer: string) => (stacks.get(designer)?.redo.length ?? 0) > 0,
clear,
}
}
export type DesignerHistory = ReturnType<typeof createDesignerHistory>