停靠布局、背景图片、无边框窗体;运行区按比例缩小、可放大
- 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:
1 parent
42e258ddcd
commit
d60868d912
17 files changed
+600
-49
No files matched your search
@@ -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>
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user