项目 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

+6
View File
@@ -31,6 +31,8 @@ const props = defineProps<{
codeText: string
canUndo: boolean
canRedo: boolean
/** 项目资源(图片属性的选项) */
resources?: { names: string[]; prefix: string }
}>()
const emit = defineEmits<{
@@ -42,6 +44,8 @@ const emit = defineEmits<{
openDesignerFile: []
undo: []
redo: []
/** 属性窗口里要添加项目资源 */
addResource: []
}>()
/** Ctrl+Z 撤销,Ctrl+Y / Ctrl+Shift+Z 重做;在属性窗口的输入框里打字时不拦截(输入框有自己的撤销) */
@@ -492,11 +496,13 @@ const noop = () => {}
class="properties"
:doc="doc"
:selected="selected"
:resources="resources"
@set-prop="onSetProp"
@set-items="onSetItems"
@rename="onRename"
@open-event="openEvent"
@clear-event="clearEvent"
@add-resource="emit('addResource')"
/>
</div>
</template>
+30 -1
View File
@@ -4,12 +4,15 @@ 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 { resourceNameOf } from './resources'
import { NAMED_COLORS, hexToRgb, parseValue, rgb, type Value } from './values'
const props = defineProps<{
doc: DesignerDocument
/** '' 表示窗体本身 */
selected: string
/** 项目资源(图片属性从这里选)和引用资源的前缀 global::命名空间.Properties.Resources. */
resources?: { names: string[]; prefix: string }
}>()
const emit = defineEmits<{
@@ -19,6 +22,8 @@ const emit = defineEmits<{
/** 双击事件或点"生成":绑定(必要时生成)事件处理方法并跳到代码 */
openEvent: [target: string, event: string, handler?: string]
clearEvent: [target: string, event: string]
/** 图片属性里选了"添加资源…":打开项目资源对话框 */
addResource: []
}>()
const tab = ref<'props' | 'events'>('props')
@@ -37,6 +42,22 @@ function set(prop: PropDef, value: Value) {
emit('setProp', props.selected, prop.name, isDefault ? null : value)
}
/** 图片属性:项目资源里的名字,没有设置时为空 */
function imageOf(prop: PropDef) {
const text = getPropText(props.doc, props.selected, prop.name)
return text === undefined ? '' : resourceNameOf(text) ?? text
}
function setImage(prop: PropDef, choice: string, select: HTMLSelectElement) {
if (choice === '+') {
select.value = imageOf(prop)
emit('addResource')
return
}
if (choice === '') reset(prop)
else emit('setProp', props.selected, prop.name, { kind: 'raw', text: `${props.resources?.prefix ?? ''}${choice}` })
}
function reset(prop: PropDef) {
emit('setProp', props.selected, prop.name, null)
}
@@ -152,7 +173,7 @@ function renameHandler(event: string, value: string) {
<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>
<code v-if="prop.type !== 'image' && 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 })" />
@@ -213,6 +234,14 @@ function renameHandler(event: string, value: string) {
<textarea v-else-if="prop.type === 'items'" rows="4" :value="itemsText" placeholder="每行一项"
@change="setItemsFromText(($event.target as HTMLTextAreaElement).value)" />
<select v-else-if="prop.type === 'image'" :value="imageOf(prop)"
@change="setImage(prop, ($event.target as HTMLSelectElement).value, $event.target as HTMLSelectElement)">
<option value="">(无)</option>
<option v-for="name in resources?.names ?? []" :key="name" :value="name">Resources.{{ name }}</option>
<option v-if="imageOf(prop) && !resources?.names.includes(imageOf(prop))" :value="imageOf(prop)">{{ imageOf(prop) }}(不在项目资源里)</option>
<option value="+">添加资源…</option>
</select>
</div>
</div>
</div>
+128
View File
@@ -0,0 +1,128 @@
<script setup lang="ts">
// 项目资源(相当于 VS 的"项目属性 → 资源 → 添加资源"):从平台素材库里勾选图片和声音,
// 代码里用 Properties.Resources.名字,设计器里图片框的"图片"属性也可以选它们。
import { NButton, NCheckbox, NModal } from 'naive-ui'
import { ref, watch } from 'vue'
import { gallery, loadGallery } from './resources'
const props = defineProps<{ show: boolean; selected: string[] }>()
const emit = defineEmits<{ 'update:show': [show: boolean]; save: [names: string[]] }>()
const chosen = ref(new Set<string>())
watch(
() => props.show,
(show) => {
if (!show) return
void loadGallery()
chosen.value = new Set(props.selected)
},
{ immediate: true },
)
function toggle(name: string, on: boolean) {
const next = new Set(chosen.value)
if (on) next.add(name)
else next.delete(name)
chosen.value = next
}
let audio: HTMLAudioElement | undefined
function listen(file: string) {
audio?.pause()
audio = new Audio(`/resources/${file}`)
void audio.play()
}
function save() {
// 按素材库的顺序保存,生成的代码稳定
emit('save', gallery.value.map((g) => g.name).filter((name) => chosen.value.has(name)))
emit('update:show', false)
}
</script>
<template>
<NModal :show="show" preset="card" title="项目资源" style="width: 560px; max-width: calc(100vw - 32px)" @update:show="emit('update:show', $event)">
<p class="note">勾选要加入项目的图片和声音(平台素材库,名字和教材一致)。代码里写 <code>Properties.Resources.名字</code>,图片框的"图片"属性也可以直接选。</p>
<div class="items">
<label v-for="item in gallery" :key="item.name" class="item" :class="{ on: chosen.has(item.name) }">
<NCheckbox :checked="chosen.has(item.name)" @update:checked="toggle(item.name, $event)" />
<img v-if="item.kind === 'image'" :src="`/resources/${item.file}`" alt="" class="thumb" />
<NButton v-else size="tiny" class="audio" @click.prevent="listen(item.file)">▶ 试听</NButton>
<span class="name">{{ item.name }}</span>
<span class="label">{{ item.label }}</span>
</label>
<div v-if="gallery.length === 0" class="note">正在加载素材库…</div>
</div>
<template #footer>
<div class="buttons">
<NButton type="primary" @click="save">确定</NButton>
<NButton @click="emit('update:show', false)">取消</NButton>
</div>
</template>
</NModal>
</template>
<style scoped>
.note {
margin: 0 0 10px;
font-size: 13px;
color: #4b5563;
}
.items {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 60vh;
overflow: auto;
}
.item {
display: grid;
grid-template-columns: auto 64px 1fr;
grid-template-rows: auto auto;
align-items: center;
column-gap: 10px;
padding: 6px 8px;
border: 1px solid #e5e7eb;
border-radius: 4px;
cursor: pointer;
}
.item.on {
border-color: #2563eb;
background: #eff6ff;
}
.item > :first-child {
grid-row: span 2;
}
.thumb,
.audio {
grid-row: span 2;
max-width: 64px;
max-height: 40px;
}
.audio {
justify-self: start;
}
.name {
font-family: Consolas, "Courier New", monospace;
font-size: 13px;
}
.label {
font-size: 12px;
color: #6b7280;
}
.buttons {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
+44 -1
View File
@@ -2,7 +2,7 @@
// 只列出仿真运行时已经实现的控件和属性;默认值与 WinForms 一致(等于默认值的属性不写进 Designer.cs)。
import type { Value } from './values'
export type PropType = 'string' | 'multiline' | 'bool' | 'int' | 'char' | 'point' | 'size' | 'color' | 'font' | 'enum' | 'items'
export type PropType = 'string' | 'multiline' | 'bool' | 'int' | 'char' | 'point' | 'size' | 'color' | 'font' | 'enum' | 'items' | 'image'
export interface PropDef {
/** WinForms 的属性名,写进 Designer.cs,代码里也用它 */
@@ -187,6 +187,7 @@ export const CONTROLS: ControlDef[] = [
defaultEvent: 'Click',
props: [
...COMMON_PROPS.filter((p) => p.name !== 'Text' && p.name !== 'ForeColor' && p.name !== 'Font'),
{ name: 'Image', label: '图片', description: '显示的图片:从项目资源里选(Properties.Resources)', type: 'image', category: '外观' },
{
name: 'SizeMode', label: '显示方式', description: '图片在图片框里怎样显示', type: 'enum', enumType: `${FORMS}.PictureBoxSizeMode`,
options: ['Normal', 'StretchImage', 'AutoSize', 'CenterImage', 'Zoom'],
@@ -254,6 +255,38 @@ export const CONTROLS: ControlDef[] = [
events: [...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'), { name: 'ValueChanged', label: '值改变', ...EVENT }],
initial: () => [['Size', size(200, 21)]],
},
{
type: `${FORMS}.TrackBar`,
label: '滑动条',
group: '常用控件',
namePrefix: 'trackBar',
defaultEvent: 'Scroll',
props: [
...COMMON_PROPS.filter((p) => p.name !== 'Text' && p.name !== 'ForeColor' && p.name !== 'Font'),
{ name: 'Minimum', label: '最小值', description: '滑块在最小端时的值', type: 'int', default: int(0), category: '行为' },
{ name: 'Maximum', label: '最大值', description: '滑块在最大端时的值', type: 'int', default: int(10), category: '行为' },
{ name: 'Value', label: '值', description: '滑块现在的值', type: 'int', default: int(0), category: '行为' },
{ name: 'TickFrequency', label: '刻度间隔', description: '每隔多少画一个刻度', type: 'int', default: int(1), category: '外观' },
{ name: 'LargeChange', label: '大步长', description: '点击轨道时每次改变多少', type: 'int', default: int(5), category: '行为' },
{ name: 'SmallChange', label: '小步长', description: '按方向键时每次改变多少', type: 'int', default: int(1), category: '行为' },
{
name: 'TickStyle', label: '刻度位置', description: '刻度画在哪一边', type: 'enum', enumType: `${FORMS}.TickStyle`,
options: ['None', 'TopLeft', 'BottomRight', 'Both'], optionLabels: { None: '不画刻度', TopLeft: '上方(左边)', BottomRight: '下方(右边)', Both: '两边' },
default: enumValue(`${FORMS}.TickStyle`, 'BottomRight'), category: '外观',
},
{
name: 'Orientation', label: '方向', description: '横向或竖向', type: 'enum', enumType: `${FORMS}.Orientation`,
options: ['Horizontal', 'Vertical'], optionLabels: { Horizontal: '横向', Vertical: '竖向' },
default: enumValue(`${FORMS}.Orientation`, 'Horizontal'), category: '外观',
},
],
events: [
...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'),
{ name: 'Scroll', label: '拖动滑块', ...EVENT },
{ name: 'ValueChanged', label: '值改变', ...EVENT },
],
initial: () => [['Size', size(104, 45)]],
},
{
type: `${FORMS}.CheckBox`,
label: '复选框',
@@ -312,6 +345,16 @@ export const CONTROLS: ControlDef[] = [
events: [{ name: 'Tick', label: '计时到', ...EVENT }],
initial: () => [],
},
{
type: 'AxWMPLib.AxWindowsMediaPlayer',
label: '媒体播放器',
group: '物联网设备',
namePrefix: 'axWindowsMediaPlayer',
defaultEvent: 'Enter',
props: COMMON_PROPS.filter((p) => ['Location', 'Size', 'Enabled', 'Visible', 'TabIndex'].includes(p.name)),
events: [{ name: 'Enter', label: '获得焦点', ...EVENT }],
initial: () => [['Enabled', bool(true)], ['Size', size(245, 240)]],
},
{
type: 'WinFormsDeviceControlLibrary.LED',
label: 'LED 显示屏',
+8
View File
@@ -4,6 +4,7 @@ import type { ControlNode } from '@iot/shared'
import { controlDef } from './catalog'
import type { DesignerDocument } from './document'
import { childrenOf, findBlock, getItems, listControls } from './edit'
import { resourceNameOf, resourceUrl } from './resources'
import { parseValue, type Value } from './values'
/** 窗体在预览树里的 id;控件的 id 就是控件名 */
@@ -11,6 +12,7 @@ export const FORM_ID = '__form'
/** 运行时控件树里的类型名:System.Windows.Forms.Button → Button,教材控件库保留完整类名 */
function clientType(type: string) {
if (type === 'AxWMPLib.AxWindowsMediaPlayer') return 'AxWindowsMediaPlayer'
return type.startsWith('System.Windows.Forms.') ? type.slice('System.Windows.Forms.'.length) : type
}
@@ -43,6 +45,12 @@ function propsOf(doc: DesignerDocument, target: string): Record<string, unknown>
const props: Record<string, unknown> = {}
for (const stmt of findBlock(doc, target)?.stmts ?? []) {
if (stmt.kind !== 'prop' || stmt.path.includes('.')) continue
// 项目资源里的图片:预览时直接显示素材文件
const resource = resourceNameOf(stmt.value)
if (resource) {
props[stmt.path] = { key: '', url: resourceUrl(resource), width: 0, height: 0 }
continue
}
const value = runtimeValue(parseValue(stmt.value))
if (value !== undefined) props[stmt.path] = value
}
+81
View File
@@ -0,0 +1,81 @@
// 项目资源(Properties.Resources):学生从平台素材库里选图片和声音,平台生成 Properties/Resources.Designer.cs。
// 生成的类和 VS 的强类型资源类用法一样(Properties.Resources.switch_on 是 Bitmap,声音是 byte[]),
// 取值时调用运行器的素材库。这个文件由平台生成,不在标签页里显示。
import { ref } from 'vue'
import type { SourceFile } from '@iot/shared'
export interface GalleryItem {
/** 资源名,也是 Properties.Resources 的属性名,和教材一致(如 switch_on) */
name: string
file: string
/** 中文说明 */
label: string
kind: 'image' | 'audio'
}
export const RESOURCES_FILE = 'Properties/Resources.Designer.cs'
/** 素材清单(服务器上 /resources/index.json),第一次用到时加载 */
export const gallery = ref<GalleryItem[]>([])
let loading: Promise<void> | undefined
export function loadGallery() {
loading ??= fetch('/resources/index.json')
.then((r) => (r.ok ? r.json() : []))
.then((items: GalleryItem[]) => {
gallery.value = items
})
.catch(() => {
loading = undefined
})
return loading
}
/** 素材文件的网址(设计器预览、资源对话框里显示) */
export function resourceUrl(name: string) {
const item = gallery.value.find((g) => g.name === name)
return `/resources/${item?.file ?? `${name}.png`}`
}
/** 工程里已添加的资源名(从生成的 Resources.Designer.cs 里读出) */
export function projectResources(files: SourceFile[]): string[] {
const text = files.find((f) => f.path === RESOURCES_FILE)?.content ?? ''
return [...text.matchAll(/internal static \S+ ([\p{L}\p{N}_]+) \{/gu)].map((m) => m[1])
}
/** 设计器里引用资源的写法:global::命名空间.Properties.Resources.名字 */
export const resourcePrefix = (namespace: string) => `global::${namespace}.Properties.Resources.`
/** 从属性值里认出资源名:global::X.Properties.Resources.switch_off → switch_off */
export function resourceNameOf(text: string) {
return /\.Properties\.Resources\.([\p{L}\p{N}_]+)$/u.exec(text.trim())?.[1]
}
/** 生成 Properties/Resources.Designer.cs;没有资源时返回 null(删掉这个文件) */
export function resourcesFile(namespace: string, names: string[]): SourceFile | null {
const items = names.map((name) => gallery.value.find((g) => g.name === name)).filter((g): g is GalleryItem => g !== undefined)
if (items.length === 0) return null
const members = items.map((item) =>
item.kind === 'image'
? ` /// <summary>${item.label}</summary>\n internal static System.Drawing.Bitmap ${item.name} {\n get { return global::Sim.Host.ProjectResources.Image("${item.file}"); }\n }`
: ` /// <summary>${item.label}</summary>\n internal static byte[] ${item.name} {\n get { return global::Sim.Host.ProjectResources.Bytes("${item.file}"); }\n }`,
)
const content = `//------------------------------------------------------------------------------
// <auto-generated>
// 项目资源(平台素材库)。在"项目资源"里添加或移除,不要手动修改这个文件。
// </auto-generated>
//------------------------------------------------------------------------------
namespace ${namespace}.Properties {
/// <summary>
/// 项目资源:Properties.Resources.资源名
/// </summary>
internal class Resources {
${members.join('\n\n')}
}
}
`
return { path: RESOURCES_FILE, content }
}
+7 -1
View File
@@ -31,7 +31,7 @@ test('新窗体的 Designer.cs 能被设计器打开,添加控件后格式正
/** 读取 samples/ 下的示例工程 */
async function sample(id: string) {
const dir = `${import.meta.dir}/../../../samples/${id}`
const paths = await Array.fromAsync(new Bun.Glob('*.cs').scan({ cwd: dir }))
const paths = await Array.fromAsync(new Bun.Glob('**/*.cs').scan({ cwd: dir }))
return Promise.all(paths.sort().map(async (path) => ({ path, content: await Bun.file(`${dir}/${path}`).text() })))
}
@@ -53,6 +53,12 @@ test('作业起点"界面已做好":多个窗体都保留,其他类(Public
expect(files.find((f) => f.path === 'AddUser.cs')!.content).toContain('public partial class AddUser : Form')
})
test('作业起点"界面已做好":图片引用了项目资源时带上资源类,资源类不当作窗体', async () => {
const files = assignmentTemplate(await sample('p7-t1-智慧教室'), 'ui')
expect(files.map((f) => f.path).sort()).toEqual(['Program.cs', 'Properties/Resources.Designer.cs', '教室情景模式.Designer.cs', '教室情景模式.cs'])
expect(files.find((f) => f.path === '教室情景模式.Designer.cs')!.content).toContain('global::教室情景模式.Properties.Resources.switch_off')
})
test('作业起点"空白工程":Program.cs 启动空的 Form1', async () => {
const files = assignmentTemplate(await sample('p2-t2-模拟量采集'), 'blank')
expect(files.map((f) => f.path).sort()).toEqual(['Form1.Designer.cs', 'Form1.cs', 'Program.cs'])
+7 -1
View File
@@ -123,7 +123,10 @@ export function assignmentTemplate(files: SourceFile[], mode: StartMode): Source
return [...(programText ? [{ path: 'Program.cs', content: programText }] : []), ...newFormFiles(namespace, 'Form1')]
}
const result: SourceFile[] = program ? [{ ...program }] : []
for (const designer of files.filter((f) => f.path.endsWith('.Designer.cs'))) {
// 界面里的图片引用了项目资源,资源类一起带上(空白工程由学生自己按书上的步骤添加)
const resources = files.find((f) => f.path.startsWith('Properties/') && f.path.endsWith('.Designer.cs'))
if (resources) result.push({ ...resources })
for (const designer of files.filter((f) => isFormDesigner(f.path))) {
const doc = parseDesigner(designer.content)
for (const block of doc.blocks) block.stmts = block.stmts.filter((s) => s.kind !== 'event')
const [code] = newFormFiles(namespace, doc.className)
@@ -131,3 +134,6 @@ export function assignmentTemplate(files: SourceFile[], mode: StartMode): Source
}
return result
}
/** 窗体的 Designer.cs(Properties/ 下平台生成的 Resources.Designer.cs 不是窗体) */
export const isFormDesigner = (path: string) => path.endsWith('.Designer.cs') && !path.startsWith('Properties/')