From 02d2f0eabc9858c7886cd4532098418546a7a1f6 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Thu, 22 Oct 2026 11:18:43 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E5=99=A8=EF=BC=9A=E6=92=A4?= =?UTF-8?q?=E9=94=80=20/=20=E9=87=8D=E5=81=9A=EF=BC=9B=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E7=AE=B1=E3=80=81=E5=B1=9E=E6=80=A7=E3=80=81=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E6=94=B9=E4=B8=BA=E4=B8=AD=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 撤销 / 重做按窗体保存在页面层,切换标签页不丢;Ctrl+Z、Ctrl+Y / Ctrl+Shift+Z - 撤销"生成事件处理方法"时事件绑定和插入的方法一起撤掉; 学生已在方法里写了代码则只撤销设计,不动代码 - Designer.cs 在代码页被手改后,该窗体历史失效并清空,避免覆盖手改内容 - 没有实际改动的操作(如双击已绑定的事件)不记入历史 - 工具箱、属性名、事件名、枚举选项显示中文;悬停显示英文名和用途说明 - 开发模式下把页面状态挂到 window.__iot,供浏览器自动化测试读取 Co-Authored-By: Claude Opus 5.5 --- web/src/App.vue | 18 +++++ web/src/designer/FormDesigner.vue | 66 ++++++++++++++++- web/src/designer/PropertyGrid.vue | 19 +++-- web/src/designer/catalog.ts | 117 ++++++++++++++++-------------- web/src/designer/history.test.ts | 65 +++++++++++++++++ web/src/designer/history.ts | 84 +++++++++++++++++++++ 6 files changed, 302 insertions(+), 67 deletions(-) create mode 100644 web/src/designer/history.test.ts create mode 100644 web/src/designer/history.ts diff --git a/web/src/App.vue b/web/src/App.vue index 32d7fd8..cfe9f0e 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -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>() @@ -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" /> () 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 = { + 按钮: '▭', 标签: 'A', 文本框: '▤', 下拉框: '☰', 复选框: '☑', 单选按钮: '◉', 分组框: '⬚', 面板: '▢', + 定时器: '⏱', 'LED 显示屏': '▬', 灯泡: '💡', 报警灯: '🚨', +} const DRAG_TYPE = 'application/x-winforms-control' function onToolDragStart(event: DragEvent, def: ControlDef) { @@ -384,7 +407,7 @@ const noop = () => {}

Designer.cs 的内容不是窗体设计器能识别的格式,可能是手动修改造成的。可以在代码里改回来,或者用"恢复原始代码"。

-
+
+
+ + + 拖动工具箱里的控件到窗体上;双击控件生成事件处理方法;Delete 删除 +
{{ displayText(formTitle) }} @@ -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; diff --git a/web/src/designer/PropertyGrid.vue b/web/src/designer/PropertyGrid.vue index f7b1b24..ae72e25 100644 --- a/web/src/designer/PropertyGrid.vue +++ b/web/src/designer/PropertyGrid.vue @@ -23,7 +23,8 @@ const emit = defineEmits<{ const tab = ref<'props' | 'events'>('props') const def = computed(() => (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) {