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) {