项目 7 情景控制:虚拟时钟、项目资源、Windows Media Player、TrackBar

- 虚拟时钟:实训台时钟面板可拨时间、1×/10×/60× 倍速、恢复真实时间;编译时把
  DateTime.Now / Today / UtcNow 改写成 SimClock;评分规则加 clock 步骤
- 项目资源(Properties.Resources):平台素材库(自绘开关图片、合成门铃声),"项目资源"对话框勾选,
  平台生成 Properties/Resources.Designer.cs;设计器图片框的"图片"属性可选资源;程序目录下的 Resources
  指向素材库;未添加资源时给中文提示
- Windows Media Player(AxWMPLib):设置 URL 自动播放、Ctlcontrols.play/stop,浏览器里播放声音,
  实训台加广播喇叭;编译时给窗体嵌空资源,OcxState 取到 null 不报错;图片和声音统一按内容缓存(blob)
- TrackBar:拖动、点轨道、方向键,先 ValueChanged 再 Scroll;设计器加"滑动条""媒体播放器"
- 评分规则加 value、media 检查;示例项目 7 任务 1–3(修正书上开关灯判断写反、漏填串口列表)
- 修正:Properties/ 下的资源类不当作窗体;带命名空间的 System.AppDomain.CurrentDomain.BaseDirectory 放行
- 测试:虚拟时钟、TrackBar、项目 7 示例评分、作业起点带资源类

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-23 14:11:05 +08:00
1 parent a9af26c2d6
commit eedf1aa2fb
66 files changed
+3538 -43

No files matched your search

+13 -2
View File
@@ -8,6 +8,7 @@ import { RUN_IMAGES, type ImageValue } from './images'
import DataGridViewControl from './DataGridViewControl.vue'
import DateTimePickerControl from './DateTimePickerControl.vue'
import LedControl from './LedControl.vue'
import TrackBarControl from './TrackBarControl.vue'
const props = defineProps<{
node: ControlNode
@@ -55,9 +56,11 @@ 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 ? runImages.get(image.value.key) : undefined))
const imageUrl = computed(() => (image.value ? image.value.url ?? runImages.get(image.value.key) : undefined))
// 设计器预览里不知道素材的尺寸,原样大小显示时让浏览器按图片本身的大小
const naturalSize = computed(() => (image.value?.width ? { width: `${image.value.width}px`, height: `${image.value.height}px` } : {}))
const imageStyle = computed((): CSSProperties => {
const natural = { width: `${image.value?.width ?? 0}px`, height: `${image.value?.height ?? 0}px` }
const natural = naturalSize.value
switch (p.value.SizeMode) {
case 'StretchImage':
return { width: '100%', height: '100%', objectFit: 'fill' }
@@ -168,6 +171,14 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
<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(教材里通常设为不可见,只用来播放声音) -->
<div v-else-if="node.type === 'AxWindowsMediaPlayer'" class="wf-control wf-wmp" :style="style">
<div class="wf-wmp-screen">Windows Media Player</div>
<div class="wf-wmp-bar"><span>▶</span><span>■</span><span class="wf-wmp-progress" /></div>
</div>
<!-- CheckBox / RadioButton:自绘勾选框,状态完全由运行器决定(点击只上报,不在浏览器里自行切换) -->
<div
v-else-if="node.type === 'CheckBox' || node.type === 'RadioButton'"
+121
View File
@@ -0,0 +1,121 @@
<script setup lang="ts">
// 滑动条(仿 Windows 10 的 TrackBar):拖动滑块、点轨道(按 LargeChange 靠近)、方向键(按 SmallChange)改变值,
// 新值发给运行器,由运行器决定最终的 Value(并触发 ValueChanged、Scroll)。竖向时最小值在下面,和 WinForms 一样。
import { computed, ref, type CSSProperties } from 'vue'
import type { ControlNode, UserAction } from '@iot/shared'
const props = defineProps<{
node: ControlNode
style: CSSProperties
enabled: boolean
act: (action: UserAction) => void
}>()
/** 轨道两端留出的距离(滑块半宽 + 边距),和 WinForms 差不多 */
const PAD = 13
const p = computed(() => props.node.props)
const min = computed(() => Number(p.value.Minimum ?? 0))
const max = computed(() => Number(p.value.Maximum ?? 10))
const value = computed(() => Number(p.value.Value ?? 0))
const vertical = computed(() => p.value.Orientation === 'Vertical')
const tickStyle = computed(() => String(p.value.TickStyle ?? 'BottomRight'))
const size = computed(() => p.value.Size as { width: number; height: number } | undefined)
/** 轨道方向上的长度 */
const length = computed(() => (vertical.value ? size.value?.height ?? 104 : size.value?.width ?? 104))
/** 值 → 沿轨道的位置(像素);竖向时最小值在下面 */
function position(v: number) {
const ratio = max.value === min.value ? 0 : (v - min.value) / (max.value - min.value)
const along = PAD + ratio * (length.value - 2 * PAD)
return vertical.value ? length.value - along : along
}
const ticks = computed(() => {
const frequency = Math.max(1, Number(p.value.TickFrequency ?? 1))
const list: number[] = []
for (let v = min.value; v <= max.value && list.length < 200; v += frequency) list.push(v)
if (list.at(-1) !== max.value) list.push(max.value)
return list
})
const root = ref<HTMLDivElement>()
let dragging = false
function valueAt(event: PointerEvent) {
const rect = root.value!.getBoundingClientRect()
const along = vertical.value ? rect.bottom - event.clientY : event.clientX - rect.left
const ratio = Math.min(1, Math.max(0, (along - PAD) / (length.value - 2 * PAD)))
return Math.round(min.value + ratio * (max.value - min.value))
}
function send(next: number) {
const clamped = Math.min(max.value, Math.max(min.value, next))
if (clamped !== value.value) props.act({ action: 'scroll', value: String(clamped) })
}
function down(event: PointerEvent) {
if (!props.enabled) return
root.value?.focus()
const target = event.target as HTMLElement
if (target.classList.contains('wf-trackbar-thumb')) {
dragging = true
root.value!.setPointerCapture(event.pointerId)
return
}
// 点轨道:向点击的位置移动一个 LargeChange
const large = Number(p.value.LargeChange ?? 5)
send(value.value + (valueAt(event) > value.value ? large : -large))
}
function move(event: PointerEvent) {
if (dragging) send(valueAt(event))
}
function up() {
dragging = false
}
function key(event: KeyboardEvent) {
if (!props.enabled) return
const small = Number(p.value.SmallChange ?? 1)
const large = Number(p.value.LargeChange ?? 5)
const delta: Record<string, number> = { ArrowRight: small, ArrowUp: small, ArrowLeft: -small, ArrowDown: -small, PageUp: large, PageDown: -large }
if (event.key in delta) send(value.value + delta[event.key])
else if (event.key === 'Home') send(min.value)
else if (event.key === 'End') send(max.value)
else return
event.preventDefault()
}
const thumbStyle = computed((): CSSProperties => (vertical.value ? { top: `${position(value.value) - 5}px` } : { left: `${position(value.value) - 5}px` }))
/** 刻度:滑块下方(BottomRight)或上方(TopLeft),两端的刻度长一点 */
const tickStyleOf = (v: number, side: 'a' | 'b'): CSSProperties => {
const extent = v === min.value || v === max.value ? '5px' : '3px'
const offset = side === 'a' ? '0px' : '28px'
return vertical.value ? { top: `${position(v)}px`, left: offset, width: extent } : { left: `${position(v)}px`, top: offset, height: extent }
}
</script>
<template>
<div
ref="root"
class="wf-control wf-trackbar"
:class="{ 'wf-trackbar-vertical': vertical, 'wf-disabled': !enabled }"
:style="style"
tabindex="0"
@pointerdown="down"
@pointermove="move"
@pointerup="up"
@keydown="key"
>
<div class="wf-trackbar-track" :style="vertical ? { top: `${PAD}px`, bottom: `${PAD}px` } : { left: `${PAD}px`, right: `${PAD}px` }" />
<template v-if="tickStyle === 'TopLeft' || tickStyle === 'Both'">
<span v-for="v in ticks" :key="`a${v}`" class="wf-trackbar-tick" :style="tickStyleOf(v, 'a')" />
</template>
<template v-if="tickStyle === 'BottomRight' || tickStyle === 'Both'">
<span v-for="v in ticks" :key="`b${v}`" class="wf-trackbar-tick" :style="tickStyleOf(v, 'b')" />
</template>
<span class="wf-trackbar-thumb" :style="thumbStyle" />
</div>
</template>
+2
View File
@@ -9,4 +9,6 @@ export interface ImageValue {
key: string
width: number
height: number
/** 设计器预览里的项目资源:直接给出素材网址(运行时用 key) */
url?: string
}
+90
View File
@@ -301,6 +301,96 @@
padding: 0;
}
/* TrackBar:灰色轨道、刻度、蓝色滑块(Windows 10 样式) */
.wf-trackbar {
outline: none;
touch-action: none;
cursor: default;
}
.wf-trackbar-track {
position: absolute;
top: 12px;
height: 4px;
background: #e7e7e7;
border: 1px solid #d6d6d6;
}
.wf-trackbar-vertical .wf-trackbar-track {
top: auto;
left: 12px;
width: 4px;
height: auto;
}
.wf-trackbar-tick {
position: absolute;
width: 1px;
background: #c4c4c4;
}
.wf-trackbar-vertical .wf-trackbar-tick {
width: auto;
height: 1px;
}
.wf-trackbar-thumb {
position: absolute;
top: 4px;
width: 11px;
height: 20px;
background: #007ad9;
border-radius: 1px;
}
.wf-trackbar-vertical .wf-trackbar-thumb {
top: auto;
left: 4px;
width: 20px;
height: 11px;
}
.wf-trackbar:focus-visible .wf-trackbar-thumb {
outline: 1px dotted #000;
outline-offset: 1px;
}
.wf-disabled .wf-trackbar-thumb {
background: #cccccc;
}
.wf-wmp {
display: flex;
flex-direction: column;
background: #000;
color: #9aa4b2;
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
font-size: 11px;
overflow: hidden;
}
.wf-wmp-screen {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.wf-wmp-bar {
display: flex;
align-items: center;
gap: 8px;
height: 22px;
padding: 0 6px;
background: linear-gradient(#3a3f47, #1c1f24);
}
.wf-wmp-progress {
flex: 1;
height: 3px;
background: #555;
}
.wf-listbox {
overflow-y: auto;
overflow-x: hidden;