Files
IOT_SIM/web/src/designer/PropertyGrid.vue
T
xuyueandClaude Opus 5.5 8feda289de 多窗体:新建 / 删除窗体,项目 1 登录示例;标识符支持中文
- 新建窗体:生成与 VS 新建窗体相同的 X.cs 和 X.Designer.cs,沿用工程命名空间;
  删除窗体(启动窗体不能删)
- 标签页按窗体分组,启动窗体排在最前并默认打开
- 运行时点哪个窗口哪个到最前;消息框始终在所有窗口之上
- 示例:项目 1 任务 1 登录与增加用户,两个窗体的 Designer.cs 由设计器引擎生成;
  修正书上未声明的 change 变量、关闭后重复 Show 已释放窗体的问题
- 设计器的正则支持中文控件名、类名、命名空间
- 修正:切换窗体时属性窗口的名称没有刷新
- 测试:多窗体端到端测试;新窗体模板测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:23:37 +08:00

390 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// 属性窗口和事件窗口(仿 VS):显示选中控件的常用属性;改动立即写回 Designer.cs。
import { computed, ref, watch } from 'vue'
import { type ControlDef, FORM_DEF, type PropDef, controlDef } from './catalog'
import type { DesignerDocument } from './document'
import { checkName, getEventHandler, getItems, getPropText, typeOf } from './edit'
import { NAMED_COLORS, hexToRgb, parseValue, rgb, type Value } from './values'
const props = defineProps<{
doc: DesignerDocument
/** '' 表示窗体本身 */
selected: string
}>()
const emit = defineEmits<{
setProp: [target: string, path: string, value: Value | null]
setItems: [target: string, items: string[]]
rename: [from: string, to: string]
/** 双击事件或点"生成":绑定(必要时生成)事件处理方法并跳到代码 */
openEvent: [target: string, event: string, handler?: string]
clearEvent: [target: string, event: string]
}>()
const tab = ref<'props' | 'events'>('props')
const def = computed<ControlDef>(() => (props.selected === '' ? FORM_DEF : controlDef(typeOf(props.doc, props.selected) ?? '')))
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)
return text === undefined ? prop.default : parseValue(text)
}
/** 设成默认值时删除这条属性语句(VS 不写默认值) */
function set(prop: PropDef, value: Value) {
const isDefault = prop.default && JSON.stringify(prop.default) === JSON.stringify(value)
emit('setProp', props.selected, prop.name, isDefault ? null : value)
}
function reset(prop: PropDef) {
emit('setProp', props.selected, prop.name, null)
}
// ---------- 名称 ----------
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 })
function commitName() {
const name = nameDraft.value.trim()
if (!props.selected || name === props.selected) return
nameError.value = checkName(props.doc, name, props.selected)
if (!nameError.value) emit('rename', props.selected, name)
}
// ---------- 各类型的读写 ----------
function str(prop: PropDef) {
const v = current(prop)
return v?.kind === 'string' || v?.kind === 'char' ? v.value : ''
}
function num(prop: PropDef) {
const v = current(prop)
return v?.kind === 'int' || v?.kind === 'float' ? v.value : 0
}
function boolOf(prop: PropDef) {
const v = current(prop)
return v?.kind === 'bool' ? v.value : false
}
function point(prop: PropDef) {
const v = current(prop)
return v?.kind === 'point' ? v : { x: 0, y: 0 }
}
function size(prop: PropDef) {
const v = current(prop)
return v?.kind === 'size' ? v : { width: 0, height: 0 }
}
function enumOf(prop: PropDef) {
const v = current(prop)
return v?.kind === 'enum' ? v.member : ''
}
function color(prop: PropDef) {
const v = current(prop)
return v?.kind === 'color' ? v : undefined
}
function font(prop: PropDef) {
const v = current(prop)
return v?.kind === 'font' ? v : undefined
}
function raw(prop: PropDef) {
const v = current(prop)
return v?.kind === 'raw' ? v.text : undefined
}
function setColor(prop: PropDef, choice: string) {
if (choice === '') return reset(prop)
if (choice.startsWith('#')) {
const [r, g, b] = hexToRgb(choice)
return set(prop, rgb(r, g, b))
}
const css = NAMED_COLORS[choice]
const [r, g, b] = css === 'transparent' ? [255, 255, 255] : hexToRgb(css)
set(prop, { kind: 'color', css, name: choice, r, g, b, a: css === 'transparent' ? 0 : 255 })
}
const FONT_FAMILIES = ['宋体', '黑体', '楷体', '微软雅黑', 'Arial', 'Times New Roman']
function setFont(prop: PropDef, change: Partial<{ family: string; size: number; bold: boolean; italic: boolean }>) {
const base = font(prop) ?? { kind: 'font' as const, family: '宋体', size: 9, bold: false, italic: false, underline: false, strikeout: false }
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'))
function setItemsFromText(text: string) {
emit('setItems', props.selected, text.split('\n').map((s) => s.trimEnd()).filter((s) => s !== ''))
}
const handlerOf = (event: string) => getEventHandler(props.doc, props.selected, event) ?? ''
function renameHandler(event: string, value: string) {
const name = value.trim()
if (!name) emit('clearEvent', props.selected, event)
else if (name !== handlerOf(event)) emit('openEvent', props.selected, event, name)
}
</script>
<template>
<div class="grid">
<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>
</div>
<div v-if="tab === 'props'" class="rows">
<div class="row">
<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>
<div v-else-if="selected !== ''" class="note">改名后,代码里用到旧名字的地方要自己修改</div>
</div>
</div>
<div v-for="prop in def.props" :key="prop.name" class="row">
<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>
<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 })" />
<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' })">
<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) })" />
<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) })" />
</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) })" />
</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 })">
<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">
<i class="swatch" :style="{ background: color(prop)?.css ?? 'transparent' }" />
<select :value="color(prop)?.name ?? (color(prop) ? 'custom' : '')" @change="setColor(prop, ($event.target as HTMLSelectElement).value)">
<option value="">(默认)</option>
<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)" />
</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)">
<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>
</template>
</span>
<textarea v-else-if="prop.type === 'items'" rows="4" :value="itemsText" placeholder="每行一项"
@change="setItemsFromText(($event.target as HTMLTextAreaElement).value)" />
</div>
</div>
</div>
<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.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>
</div>
</div>
</div>
</template>
<style scoped>
.grid {
display: flex;
flex-direction: column;
min-height: 0;
font-size: 12px;
background: #fff;
border-left: 1px solid #e5e7eb;
}
.grid-title {
padding: 6px 8px;
font-weight: 600;
border-bottom: 1px solid #e5e7eb;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tabs {
display: flex;
border-bottom: 1px solid #e5e7eb;
}
.tabs button {
flex: 1;
padding: 4px;
border: none;
background: #f7f7f8;
cursor: pointer;
}
.tabs button.active {
background: #fff;
font-weight: 600;
box-shadow: inset 0 -2px #2563eb;
}
.rows {
flex: 1;
overflow: auto;
}
.row {
display: grid;
grid-template-columns: 92px 1fr;
gap: 6px;
padding: 4px 8px;
border-bottom: 1px solid #f1f1f3;
}
.row > label {
padding-top: 3px;
color: #374151;
overflow: hidden;
text-overflow: ellipsis;
}
.event-name {
cursor: pointer;
}
.event-name:hover {
color: #2563eb;
}
.editor {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.editor input:not([type="checkbox"]):not([type="color"]),
.editor select,
.editor textarea {
width: 100%;
box-sizing: border-box;
font: inherit;
padding: 2px 4px;
border: 1px solid #d1d5db;
border-radius: 3px;
}
.pair,
.color,
.font {
display: flex;
align-items: center;
gap: 4px;
}
.pair input {
min-width: 0;
}
.short {
width: 56px !important;
flex: none;
}
.swatch {
flex: none;
width: 14px;
height: 14px;
border: 1px solid #9ca3af;
background-image: linear-gradient(45deg, #ddd 25%, transparent 25%, transparent 75%, #ddd 75%);
}
.color input[type="color"] {
flex: none;
width: 24px;
height: 22px;
padding: 0;
border: none;
}
.check {
display: inline-flex;
align-items: center;
white-space: nowrap;
}
.raw {
font-size: 11px;
color: #6b7280;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hint,
.note {
color: #9ca3af;
font-size: 11px;
}
.events-note {
padding: 6px 8px;
}
.error {
color: #dc2626;
font-size: 11px;
}
.go {
flex: none;
padding: 1px 6px;
border: 1px solid #d1d5db;
border-radius: 3px;
background: #f9fafb;
cursor: pointer;
}
</style>