停靠布局、背景图片、无边框窗体;运行区按比例缩小、可放大

- Dock:和 WinForms 一样按 Controls 倒序依次停靠,扣掉 Panel 边框、GroupBox 标题占的地方;
  改大小、显示隐藏、加入移出控件时重新布局;SuspendLayout / ResumeLayout / PerformLayout
- 设计器里每次修改后按同样的规则排好停靠的控件(designer/layout.ts);属性窗口加"停靠"
- BackgroundImage + BackgroundImageLayout(平铺、居中、拉伸、缩放):窗体、面板、图片框,
  设计器里从项目资源选;无边框窗体没有标题栏,大小不含边框
- 背景色沿用父控件:没设背景色的标签、面板、分组框等画父控件的背景色(不是背景图片),
  和 WinForms 一样;分组框标题处边框断开、透出背景;文本框的 BackColor 生效
- 图片框可以放子控件,把鼠标按下、松开发给程序
- 运行区:窗体比运行区大时整体按比例缩小并显示比例;"放大"占满整个网页(窗体在左、实训台在右),Esc 还原
- Paint 事件:VS 双击面板生成的空 panel1_Paint 能编译(平台由浏览器绘制,不触发)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-23 15:14:34 +08:00
1 parent 42e258ddcd
commit d60868d912
17 files changed
+600 -49

No files matched your search

+28 -7
View File
@@ -1,10 +1,10 @@
<script setup lang="ts">
// 按控件类型渲染一个 WinForms 控件及其子控件。
// 用户操作只通过 act() 上报,由运行器按真实 WinForms 的顺序触发事件。
import { computed, inject, ref, type CSSProperties } from 'vue'
import { computed, inject, provide, ref, type CSSProperties } from 'vue'
import type { ControlNode, UserAction } from '@iot/shared'
import { boxCss, displayText } from './format'
import { RUN_IMAGES, type ImageValue } from './images'
import { AMBIENT_BACK_COLOR, AMBIENT_BACK_TYPES, boxCss, DEFAULT_BACK_COLOR, displayText } from './format'
import { backgroundCss, RUN_IMAGES, type ImageValue } from './images'
import DataGridViewControl from './DataGridViewControl.vue'
import DateTimePickerControl from './DateTimePickerControl.vue'
import LedControl from './LedControl.vue'
@@ -19,12 +19,28 @@ const p = computed(() => props.node.props)
// WinForms 中先加入 Controls 的控件在最上层,所以倒序渲染(后渲染的 DOM 元素在上面)
const childrenTopLast = computed(() => [...props.node.children].reverse())
const enabled = computed(() => p.value.Enabled !== false)
const style = computed(() => boxCss(p.value, props.node.type === 'Label' && p.value.AutoSize === true))
const runImages = inject(RUN_IMAGES, new Map<string, string>())
const ambient = inject(AMBIENT_BACK_COLOR, ref(DEFAULT_BACK_COLOR))
/** 这个控件实际的背景色,子控件没设置背景色时沿用 */
const backColor = computed(() => (p.value.BackColor as string | null) ?? ambient.value)
provide(AMBIENT_BACK_COLOR, backColor)
const style = computed(() => ({
...boxCss(p.value, props.node.type === 'Label' && p.value.AutoSize === true),
...(AMBIENT_BACK_TYPES.has(props.node.type) ? { backgroundColor: backColor.value } : {}),
...backgroundCss(p.value.BackgroundImage, p.value.BackgroundImageLayout, runImages),
}))
function act(action: UserAction) {
if (enabled.value) props.act(props.node.id, action)
}
/** 鼠标按下、松开(教材项目 8 的方向键图片:按下转动摄像机、换成按下的图片) */
function mouse(kind: 'mouseDown' | 'mouseUp', event: PointerEvent) {
if (event.target !== event.currentTarget && !(event.target as HTMLElement).matches('img')) return
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
act({ action: kind, button: event.button === 2 ? 'right' : 'left', x: Math.round(event.clientX - rect.left), y: Math.round(event.clientY - rect.top) })
}
// TextBox
const passwordChar = computed(() => {
if (p.value.UseSystemPasswordChar) return '●'
@@ -54,7 +70,6 @@ function selectItem(index: number) {
}
// PictureBox:按 SizeMode 摆放图片
const runImages = inject(RUN_IMAGES, new Map<string, string>())
const image = computed(() => p.value.Image as ImageValue | null | undefined)
const imageUrl = computed(() => (image.value ? image.value.url ?? runImages.get(image.value.key) : undefined))
// 设计器预览里不知道素材的尺寸,原样大小显示时让浏览器按图片本身的大小
@@ -163,14 +178,18 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
:class="`wf-picturebox-border-${p.BorderStyle ?? 'None'}`"
:style="style"
@click="act({ action: 'click' })"
@pointerdown="mouse('mouseDown', $event)"
@pointerup="mouse('mouseUp', $event)"
>
<img v-if="imageUrl" :src="imageUrl" :style="imageStyle" alt="" draggable="false" />
<ControlView v-for="child in childrenTopLast" :key="child.id" :node="child" :act="props.act" />
</div>
<DataGridViewControl v-else-if="node.type === 'DataGridView'" :node="node" :style="style" :enabled="enabled" :act="act" />
<DateTimePickerControl v-else-if="node.type === 'DateTimePicker'" :node="node" :style="style" :enabled="enabled" :act="act" />
<TrackBarControl v-else-if="node.type === 'TrackBar'" :node="node" :style="style" :enabled="enabled" :act="act" />
<!-- Windows Media Player(教材里通常设为不可见,只用来播放声音) -->
@@ -201,8 +220,10 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
<!-- GroupBox -->
<div v-else-if="node.type === 'GroupBox'" class="wf-control wf-groupbox" :style="style">
<div class="wf-groupbox-frame" />
<span v-if="p.Text" class="wf-groupbox-caption">{{ displayText(p.Text) }}</span>
<!-- 标题处的边框断开、透出底下的背景(教材项目 8 的分组框放在背景图上,白色标题) -->
<fieldset class="wf-groupbox-frame">
<legend v-if="p.Text" class="wf-groupbox-caption">{{ displayText(p.Text) }}</legend>
</fieldset>
<ControlView v-for="child in childrenTopLast" :key="child.id" :node="child" :act="props.act" />
</div>
+14 -4
View File
@@ -1,29 +1,38 @@
<script lang="ts">
let topZ = 1
const nextZ = () => ++topZ
/** 第一个窗体离桌面左上角的距离,之后每个窗体错开的距离 */
export const WINDOW_MARGIN = 24
export const WINDOW_STEP = 32
</script>
<script setup lang="ts">
// 一个 WinForms 窗体:标题栏 + 客户区。窗体可以拖动标题栏移动。
import { computed, ref } from 'vue'
import { computed, inject, provide, ref } from 'vue'
import type { ControlNode, UserAction } from '@iot/shared'
import ControlView from './ControlView.vue'
import { displayText, fontCss, type FontValue, type SizeValue } from './format'
import { AMBIENT_BACK_COLOR, DEFAULT_BACK_COLOR, displayText, fontCss, type FontValue, type SizeValue } from './format'
import { backgroundCss, RUN_IMAGES } from './images'
const props = defineProps<{
form: ControlNode
modal: boolean
offset: number
/** 桌面的缩放比例(窗体太大时整体缩小),拖动时把鼠标移动的距离换算回去 */
scale: number
act: (id: string, action: UserAction) => void
}>()
const p = computed(() => props.form.props)
const runImages = inject(RUN_IMAGES, new Map<string, string>())
provide(AMBIENT_BACK_COLOR, computed(() => (p.value.BackColor as string | null) ?? DEFAULT_BACK_COLOR))
const borderless = computed(() => p.value.FormBorderStyle === 'None')
// WinForms 中先加入 Controls 的控件在最上层,所以倒序渲染
const childrenTopLast = computed(() => [...props.form.children].reverse())
const clientSize = computed(() => (p.value.ClientSize as SizeValue | undefined) ?? { width: 300, height: 300 })
const position = ref({ x: 24 + props.offset * 32, y: 24 + props.offset * 32 })
const position = ref({ x: WINDOW_MARGIN + props.offset * WINDOW_STEP, y: WINDOW_MARGIN + props.offset * WINDOW_STEP })
// 点哪个窗口,哪个窗口到最前面(所有窗体共用一个递增的层级)
const zIndex = ref(nextZ())
@@ -39,7 +48,7 @@ function startDrag(event: PointerEvent) {
function drag(event: PointerEvent) {
if (!dragStart) return
position.value = { x: dragStart.px + event.clientX - dragStart.x, y: dragStart.py + event.clientY - dragStart.y }
position.value = { x: dragStart.px + (event.clientX - dragStart.x) / props.scale, y: dragStart.py + (event.clientY - dragStart.y) / props.scale }
}
function close() {
@@ -69,6 +78,7 @@ function close() {
color: (p.ForeColor as string | null) ?? undefined,
backgroundColor: (p.BackColor as string | null) ?? undefined,
...fontCss(p.Font as FontValue | null),
...backgroundCss(p.BackgroundImage, p.BackgroundImageLayout, runImages),
}"
>
<ControlView v-for="child in childrenTopLast" :key="child.id" :node="child" :act="act" />
+12 -1
View File
@@ -1,7 +1,7 @@
// 把运行器发来的 WinForms 属性值转换成 CSS。
// 运行器的序列化格式:Point → {x,y},Size → {width,height},
// Color → CSS 颜色字符串(未设置时为 null,表示继承父控件),Font → FontValue。
import type { CSSProperties } from 'vue'
import type { CSSProperties, InjectionKey, Ref } from 'vue'
export interface PointValue {
x: number
@@ -45,6 +45,17 @@ export function fontCss(font: FontValue | null | undefined): CSSProperties {
}
}
/**
* 父控件的背景色(BackColor 是"环境属性":控件没设置时沿用父控件的)。
* 父控件有背景图片时,没设置背景色的子控件在 WinForms 里画的是父控件的背景色而不是那张图,
* 所以这些控件要自己涂上这个颜色;"transparent" 表示父控件是透明的(Color.Transparent),子控件也透明。
*/
export const AMBIENT_BACK_COLOR: InjectionKey<Readonly<Ref<string>>> = Symbol('ambientBackColor')
export const DEFAULT_BACK_COLOR = '#f0f0f0'
/** 背景色沿用父控件的控件(按钮、文本框等有自己的默认底色) */
export const AMBIENT_BACK_TYPES = new Set(['Label', 'Panel', 'GroupBox', 'CheckBox', 'RadioButton', 'PictureBox', 'TrackBar'])
/** 控件外框:绝对定位,坐标相对父控件的客户区 */
export function boxCss(props: Record<string, unknown>, autoSize = false): CSSProperties {
const location = (props.Location as PointValue | undefined) ?? { x: 0, y: 0 }
+21 -1
View File
@@ -1,6 +1,6 @@
// 运行时图片:运行器发来的图片数据由 RunSession 保存成 blob 网址,控件属性里只带图片编号。
// 运行区 provide,ControlView inject;设计器预览没有运行时图片,取到空表。
import type { InjectionKey } from 'vue'
import type { CSSProperties, InjectionKey } from 'vue'
export const RUN_IMAGES: InjectionKey<ReadonlyMap<string, string>> = Symbol('runImages')
@@ -12,3 +12,23 @@ export interface ImageValue {
/** 设计器预览里的项目资源:直接给出素材网址(运行时用 key) */
url?: string
}
/** 背景图片的 CSS:BackgroundImageLayout 为 Tile(默认,平铺)、None(左上角一张)、Center、Stretch、Zoom */
export function backgroundCss(value: unknown, layout: unknown, images: ReadonlyMap<string, string>): CSSProperties {
const image = value as ImageValue | null | undefined
const url = image ? image.url ?? images.get(image.key) : undefined
if (!url) return {}
const base = { backgroundImage: `url("${url}")` }
switch (layout) {
case 'None':
return { ...base, backgroundRepeat: 'no-repeat', backgroundPosition: '0 0' }
case 'Center':
return { ...base, backgroundRepeat: 'no-repeat', backgroundPosition: 'center' }
case 'Stretch':
return { ...base, backgroundRepeat: 'no-repeat', backgroundSize: '100% 100%' }
case 'Zoom':
return { ...base, backgroundRepeat: 'no-repeat', backgroundPosition: 'center', backgroundSize: 'contain' }
default:
return { ...base, backgroundRepeat: 'repeat', backgroundPosition: '0 0' }
}
}
+24 -7
View File
@@ -105,11 +105,20 @@
border-color: #0078d7;
}
.wf-textbox input[readonly],
.wf-textbox textarea[readonly] {
/* 文本框的背景色画在外层(控件的 BackColor 写在外层的 style 上),输入框本身透明 */
.wf-textbox {
background: #fff;
}
.wf-textbox:has(> [readonly]) {
background: #f0f0f0;
}
.wf-textbox input,
.wf-textbox textarea {
background: transparent;
}
.wf-combobox input {
padding-right: 18px;
}
@@ -391,6 +400,10 @@
background: #555;
}
.wf-chart {
background: #fff;
}
.wf-listbox {
overflow-y: auto;
overflow-x: hidden;
@@ -472,17 +485,21 @@
.wf-groupbox-frame {
position: absolute;
inset: 6px 0 0 0;
inset: 0;
margin: 0;
padding: 0 6px;
border: 1px solid #dcdcdc;
border-radius: 2px;
min-width: 0;
}
/* 没有标题时边框的上沿和有标题时一样在 6px 处 */
.wf-groupbox-frame:not(:has(legend)) {
top: 6px;
}
.wf-groupbox-caption {
position: absolute;
left: 6px;
top: 0;
padding: 0 2px;
background: #f0f0f0;
line-height: 12px;
white-space: pre;
}