设计器:撤销 / 重做;工具箱、属性、事件窗口改为中文
- 撤销 / 重做按窗体保存在页面层,切换标签页不丢;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:
1 parent
ea5a25af06
commit
02d2f0eabc
6 files changed
+302
-67
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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: () => [],
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user