窗体显示和 WinForms 对齐:文本框、下拉框、列表框、日期选择默认白底黑字,不沿用父控件的颜色(项目 8 白字分组框里的文本框填了字看不见);下拉框的背景色能显示、展开的列表跟着下拉框的颜色和字体;分组框、面板禁用时里面的控件也禁用(变灰、不响应操作,Enabled 读出 false 并触发 EnabledChanged);禁用的控件设了颜色也画灰字;窗体、文本框等设透明背景色和 WinForms 一样报错,设计器里也不提供"透明";多行文本框按 ScrollBars 显示滚动条;标签的边框和 TextAlign、窗体的 ControlBox、复选框的不确定状态能显示;设计器里 Enabled = false 的控件和 VS 一样照常显示
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-27 16:41:46 +08:00
1 parent a2f03ee57f
commit 50946467f6
15 files changed
+247 -26

No files matched your search

+9 -2
View File
@@ -1,7 +1,7 @@
<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 ControlDef, FORM_DEF, NO_TRANSPARENT_BACK_COLOR, type PropDef, controlDef } from './catalog'
import { type ChartModel, readChart } from './chart'
import ChartDialog from './ChartDialog.vue'
import type { DesignerDocument } from './document'
@@ -47,6 +47,13 @@ function current(prop: PropDef): Value | undefined {
return text === undefined ? prop.default : parseValue(text)
}
/** 颜色下拉框的选项:文本框等的背景色没有"透明"(已经设成透明的旧窗体照样显示出来) */
function colorChoices(prop: PropDef) {
const names = Object.keys(NAMED_COLORS)
if (prop.name !== 'BackColor' || !NO_TRANSPARENT_BACK_COLOR.has(typeName.value) || color(prop)?.name === 'Transparent') return names
return names.filter((name) => name !== 'Transparent')
}
/** 设成默认值时删除这条属性语句(VS 不写默认值) */
function set(prop: PropDef, value: Value) {
const isDefault = prop.default && JSON.stringify(prop.default) === JSON.stringify(value)
@@ -289,7 +296,7 @@ function renameHandler(event: string, value: string) {
<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" :title="name">{{ COLOR_LABELS[name] ?? name }}</option>
<option v-for="name in colorChoices(prop)" :key="name" :value="name" :title="name">{{ COLOR_LABELS[name] ?? name }}</option>
<option v-if="color(prop) && !color(prop)!.name" value="custom" disabled>{{ color(prop)!.css }}</option>
</select>
<input
+3
View File
@@ -22,6 +22,9 @@ export interface PropDef {
category: '外观' | '行为' | '布局' | '数据'
}
/** BackColor 不能选透明的控件(WinForms 里设透明会报"控件不支持透明的背景色",VS 的属性窗口也不让选) */
export const NO_TRANSPARENT_BACK_COLOR = new Set(['Form', 'TextBox', 'ComboBox', 'ListBox', 'DateTimePicker', 'DataGridView'])
export interface EventDef {
/** WinForms 的事件名,事件处理方法的名字也用它(如 button1_Click) */
name: string
+2 -1
View File
@@ -75,8 +75,9 @@ export function previewTree(doc: DesignerDocument): ControlNode {
const build = (name: string): ControlNode => {
const type = types.get(name)!
const props: Record<string, unknown> = { ...DEVICE_DEFAULTS[type], ...propsOf(doc, name) }
// 设计时即使 Visible = false 也显示出来(和 VS 一样),方便选中和编辑
// 设计时即使 Visible = false 也显示出来、Enabled = false 也不画成灰色(和 VS 一样,运行后才生效),方便选中和编辑
props.Visible = true
props.Enabled = true
if (type === 'System.Windows.Forms.ComboBox' || type === 'System.Windows.Forms.ListBox')
props.Items = getItems(doc, name).map((item) => {
const v = parseValue(item)
+19 -7
View File
@@ -3,7 +3,7 @@
// 用户操作只通过 act() 上报,由运行器按真实 WinForms 的顺序触发事件。
import { computed, inject, onBeforeUnmount, provide, ref, watch, type CSSProperties } from 'vue'
import type { ControlNode, UserAction } from '@iot/shared'
import { AMBIENT_BACK_COLOR, AMBIENT_BACK_TYPES, boxCss, DEFAULT_BACK_COLOR, displayText } from './format'
import { AMBIENT_BACK_COLOR, AMBIENT_BACK_TYPES, boxCss, contentAlignCss, DEFAULT_BACK_COLOR, displayText, PARENT_ENABLED } from './format'
import { backgroundCss, imageSources, RUN_IMAGES, srcset, type ImageValue } from './images'
import ChartControl from './ChartControl.vue'
import DataGridViewControl from './DataGridViewControl.vue'
@@ -19,7 +19,10 @@ const props = defineProps<{
const p = computed(() => props.node.props)
// WinForms 中先加入 Controls 的控件在最上层,所以倒序渲染(后渲染的 DOM 元素在上面)
const childrenTopLast = computed(() => [...props.node.children].reverse())
const enabled = computed(() => p.value.Enabled !== false)
const parentEnabled = inject(PARENT_ENABLED, ref(true))
/** 实际是否可用:自己和所有父控件都可用(和 WinForms 一样,运行器也按这个规则忽略禁用控件上的操作) */
const enabled = computed(() => p.value.Enabled !== false && parentEnabled.value)
provide(PARENT_ENABLED, enabled)
const runImages = inject(RUN_IMAGES, new Map<string, string>())
const ambient = inject(AMBIENT_BACK_COLOR, ref(DEFAULT_BACK_COLOR))
/** 这个控件实际的背景色,子控件没设置背景色时沿用 */
@@ -65,12 +68,16 @@ function openDropDown() {
const el = combo.value
if (!el) return
const rect = el.getBoundingClientRect()
// 列表挂在页面最外层,字体、颜色从下拉框本身取(和 WinForms 一样跟着下拉框的 Font、ForeColor、BackColor)
const css = getComputedStyle(el)
listStyle.value = {
left: `${rect.left}px`,
top: `${rect.bottom}px`,
width: `${el.offsetWidth}px`,
transform: `scale(${rect.width / el.offsetWidth || 1})`,
font: getComputedStyle(el).font,
font: css.font,
color: css.color,
backgroundColor: css.backgroundColor,
}
// 先触发 Click(教材在 Click 里填充串口列表),列表随后按最新的 Items 显示
act({ action: 'dropDown' })
@@ -131,8 +138,8 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
<div
v-else-if="node.type === 'Label'"
class="wf-control wf-label"
:class="{ 'wf-autosize': p.AutoSize === true, 'wf-disabled': !enabled }"
:style="style"
:class="[`wf-border-${p.BorderStyle ?? 'None'}`, { 'wf-autosize': p.AutoSize === true, 'wf-disabled': !enabled }]"
:style="[style, p.AutoSize === true ? {} : contentAlignCss(p.TextAlign)]"
@click="act({ action: 'click' })"
>
{{ displayText(p.Text) }}
@@ -142,6 +149,7 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
<div v-else-if="node.type === 'TextBox'" class="wf-control wf-textbox" :style="style">
<textarea
v-if="p.Multiline"
:class="`wf-scrollbars-${p.ScrollBars ?? 'None'}`"
:value="String(p.Text ?? '')"
:readonly="p.ReadOnly === true"
:disabled="!enabled"
@@ -232,7 +240,11 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
:style="style"
@click="act({ action: 'click' })"
>
<span :class="node.type === 'CheckBox' ? 'wf-checkbox-box' : 'wf-radio-dot'" :data-checked="p.Checked === true" />
<span
:class="node.type === 'CheckBox' ? 'wf-checkbox-box' : 'wf-radio-dot'"
:data-checked="p.Checked === true && p.CheckState !== 'Indeterminate'"
:data-indeterminate="p.CheckState === 'Indeterminate'"
/>
<span class="wf-check-text">{{ displayText(p.Text) }}</span>
</div>
@@ -245,7 +257,7 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
</div>
<!-- GroupBox -->
<div v-else-if="node.type === 'GroupBox'" class="wf-control wf-groupbox" :style="style">
<div v-else-if="node.type === 'GroupBox'" class="wf-control wf-groupbox" :class="{ 'wf-disabled': !enabled }" :style="style">
<!-- 标题处的边框断开、透出底下的背景(教材项目 8 的分组框放在背景图上,白色标题) -->
<fieldset class="wf-groupbox-frame">
<legend v-if="p.Text" class="wf-groupbox-caption">{{ displayText(p.Text) }}</legend>
+6 -3
View File
@@ -69,9 +69,12 @@ function close() {
>
<div v-if="!borderless" class="wf-titlebar" @pointerdown="startDrag" @pointermove="drag" @pointerup="dragStart = undefined">
<span class="wf-title">{{ displayText(p.Text) }}</span>
<span v-if="p.MinimizeBox !== false" class="wf-titlebar-button">─</span>
<span v-if="p.MaximizeBox !== false" class="wf-titlebar-button">☐</span>
<span class="wf-titlebar-button wf-titlebar-close" @pointerdown.stop @click="close">✕</span>
<!-- ControlBox = false 时标题栏右边的三个按钮都不显示(和 WinForms 一样) -->
<template v-if="p.ControlBox !== false">
<span v-if="p.MinimizeBox !== false" class="wf-titlebar-button">─</span>
<span v-if="p.MaximizeBox !== false" class="wf-titlebar-button">☐</span>
<span class="wf-titlebar-button wf-titlebar-close" @pointerdown.stop @click="close">✕</span>
</template>
</div>
<div
class="wf-client"
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, test } from 'bun:test'
import { contentAlignCss } from './format'
describe('Label 的 TextAlign', () => {
test('默认左上不加样式,其他位置用 flex 对齐', () => {
expect(contentAlignCss('TopLeft')).toEqual({})
expect(contentAlignCss(undefined)).toEqual({})
expect(contentAlignCss('MiddleCenter')).toEqual({ display: 'flex', alignItems: 'center', justifyContent: 'center', textAlign: 'center' })
expect(contentAlignCss('BottomRight')).toEqual({ display: 'flex', alignItems: 'flex-end', justifyContent: 'flex-end', textAlign: 'right' })
})
})
+12
View File
@@ -53,6 +53,18 @@ export function fontCss(font: FontValue | null | undefined): CSSProperties {
export const AMBIENT_BACK_COLOR: InjectionKey<Readonly<Ref<string>>> = Symbol('ambientBackColor')
export const DEFAULT_BACK_COLOR = '#f0f0f0'
/** 父控件实际是否可用:分组框、面板禁用时里面的控件也是禁用的(变灰、不响应操作),和 WinForms 一样 */
export const PARENT_ENABLED: InjectionKey<Readonly<Ref<boolean>>> = Symbol('parentEnabled')
/** Label 的 TextAlign(ContentAlignment,如 MiddleCenter)对应的 CSS:AutoSize 为 false 时文字在控件里的位置 */
export function contentAlignCss(align: unknown): CSSProperties {
const match = /^(Top|Middle|Bottom)(Left|Center|Right)$/.exec(String(align ?? ''))
if (!match || align === 'TopLeft') return {}
const flex = { Top: 'flex-start', Middle: 'center', Bottom: 'flex-end', Left: 'flex-start', Center: 'center', Right: 'flex-end' } as const
const [, vertical, horizontal] = match as unknown as [string, keyof typeof flex, keyof typeof flex]
return { display: 'flex', alignItems: flex[vertical], justifyContent: flex[horizontal], textAlign: horizontal.toLowerCase() as 'left' | 'center' | 'right' }
}
/** 背景色沿用父控件的控件(按钮、文本框等有自己的默认底色) */
export const AMBIENT_BACK_TYPES = new Set(['Label', 'Panel', 'GroupBox', 'CheckBox', 'RadioButton', 'PictureBox', 'TrackBar'])
+55 -6
View File
@@ -74,9 +74,22 @@
border-color: #005499;
}
/*
* 文本框、下拉框、列表框、日期选择的文字默认是黑色(SystemColors.WindowText),不沿用父控件的 ForeColor:
* 教材里分组框设成白字时,里面白底的文本框还是黑字。控件自己设了 ForeColor 时写在 style 上,优先于这里。
* 数据表格不在这里:它的单元格文字和 WinForms 一样跟着 ForeColor 走(没设置时沿用父控件)。
*/
.wf-textbox,
.wf-combobox,
.wf-listbox,
.wf-dtp {
color: #000;
}
/* 禁用的控件不管设了什么 ForeColor 都画成灰字(WinForms 画禁用文字用系统灰色),所以要压过 style 上的颜色 */
.wf-button:disabled,
.wf-disabled {
color: #a0a0a0;
color: #a0a0a0 !important;
}
.wf-label {
@@ -84,6 +97,16 @@
overflow: hidden;
}
/* Label 的 BorderStyle:单线是黑色细边框,立体是凹下去的边框 */
.wf-label.wf-border-FixedSingle {
border: 1px solid #000;
}
.wf-label.wf-border-Fixed3D {
border: 1px solid;
border-color: #a0a0a0 #fff #fff #a0a0a0;
}
.wf-textbox input,
.wf-textbox textarea,
.wf-combobox input {
@@ -105,8 +128,9 @@
border-color: #0078d7;
}
/* 文本框的背景色画在外层(控件的 BackColor 写在外层的 style 上),输入框本身透明 */
.wf-textbox {
/* 文本框、下拉框的背景色画在外层(控件的 BackColor 写在外层的 style 上),输入框本身透明 */
.wf-textbox,
.wf-combobox {
background: #fff;
}
@@ -114,8 +138,28 @@
background: #f0f0f0;
}
/* 禁用时和 WinForms 一样灰字(设了 ForeColor 也是灰字);没设背景色时是灰底 */
.wf-textbox:has(> :disabled),
.wf-combobox:has(> :disabled) {
background: #f0f0f0;
color: #6d6d6d !important;
}
/* 多行文本框的滚动条和 WinForms 一样由 ScrollBars 决定:默认没有,Vertical / Both 一直显示竖直滚动条(自动换行时没有水平滚动条) */
.wf-textbox textarea.wf-scrollbars-None,
.wf-textbox textarea.wf-scrollbars-Horizontal {
overflow: hidden;
}
.wf-textbox textarea.wf-scrollbars-Vertical,
.wf-textbox textarea.wf-scrollbars-Both {
overflow-x: hidden;
overflow-y: scroll;
}
.wf-textbox input,
.wf-textbox textarea {
.wf-textbox textarea,
.wf-combobox input {
background: transparent;
}
@@ -133,10 +177,9 @@
align-items: center;
justify-content: center;
font-size: 10px;
background: #fff;
}
/* 挂在 body 下,位置、宽度、缩放和字体由 ControlView.vue 按下拉框算好 */
/* 挂在 body 下,位置、宽度、缩放、字体和颜色由 ControlView.vue 按下拉框算好 */
.wf-combobox-list {
position: fixed;
z-index: 2000;
@@ -464,6 +507,12 @@
color: #333;
}
/* 不确定状态(CheckState.Indeterminate):框里一个实心小方块 */
.wf-checkbox-box[data-indeterminate='true'] {
background: #333;
box-shadow: inset 0 0 0 2px #fff;
}
.wf-radio-dot[data-checked='true'] {
background: radial-gradient(circle, #333 0 3px, #fff 3.5px);
}