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

+10
View File
@@ -1,5 +1,6 @@
// HTTP 接口。除登录外都要先登录;/api/teacher/* 只有老师能用。
import type { Database } from 'bun:sqlite'
import { dirname } from 'node:path'
import { Hono, type Context } from 'hono'
import { bearerToken, login, logout, refresh, tokenUser, tooManyFailures, type Tokens, type User } from './auth'
import { createClass, deleteClass, deleteStudent, importStudents, listClasses, listStudents, renameClass, resetPassword, type ImportRow } from './classes'
@@ -71,6 +72,15 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
return c.json({})
})
// 项目资源素材库(Properties.Resources 的图片、声音):设计器预览用 <img> 直接显示,不需要登录
app.get('/resources/:file', async (c) => {
const name = c.req.param('file')
if (!/^[\w.-]+$/.test(name)) return c.notFound()
const file = Bun.file(`${dirname(config.runnerDll)}/Resources/gallery/${name}`)
if (!(await file.exists())) return c.notFound()
return new Response(file, { headers: { 'Cache-Control': 'public, max-age=3600' } })
})
// 其余接口都要先登录(请求头 Authorization: Bearer <令牌>)
app.use('/api/*', async (c, next) => {
const user = await tokenUser(db, bearerToken(c.req.header('authorization')))
+34
View File
@@ -1,4 +1,5 @@
import {
clockWall,
ANALOG_CHANNELS,
ANALOG_INFO,
type BoardState,
@@ -7,6 +8,7 @@ import {
type RunnerInput,
type RunnerMessage,
} from '@iot/shared'
import { config } from './config'
export function defaultBoardState(): BoardState {
return {
@@ -16,6 +18,7 @@ export function defaultBoardState(): BoardState {
zigbee: [false, false],
ledText: '',
camera: { on: false, x: 3, y: 1 },
clock: null,
}
}
@@ -29,6 +32,7 @@ export class Board {
type: 'board',
analog: ANALOG_CHANNELS.map((key) => this.state.analog[key]),
digital: DIGITAL_CHANNELS.map((key) => this.state.digital[key]),
clock: this.state.clock,
}
}
@@ -49,9 +53,31 @@ export class Board {
this.state.digital[name] = value
changed = true
}
if (message.clock === null) {
changed ||= this.state.clock !== null
this.state.clock = null
} else if (message.clock && typeof message.clock === 'object') {
changed = this.setClock(message.clock.time, message.clock.speed) || changed
}
return changed
}
/**
* 拨虚拟时钟:time 是墙上时间(yyyy-MM-ddTHH:mm:ss,不给就从现在的时刻接着走),speed 是倍速(不给就保持原来的)。
* 恢复真实时间是 clock = null。
*/
setClock(time: string | undefined, speed: number | undefined) {
if (time !== undefined && (typeof time !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(time) || Number.isNaN(Date.parse(`${time}Z`)))) return false
if (speed !== undefined && (typeof speed !== 'number' || !Number.isFinite(speed) || speed < 1 || speed > 3600)) return false
const now = Date.now()
this.state.clock = {
wall: time ?? (this.state.clock ? clockWall(this.state.clock, now) : wallNow()),
realAt: now,
speed: speed ?? this.state.clock?.speed ?? 1,
}
return true
}
/** 学生程序控制了输出设备;返回 true 表示实训台状态有变化 */
applyDevice(message: Extract<RunnerMessage, { type: 'device' }>) {
switch (`${message.device}/${message.action}`) {
@@ -93,3 +119,11 @@ export class Board {
this.state = defaultBoardState()
}
}
/** 服务器所在时区(config.timeZone)现在的墙上时间 yyyy-MM-ddTHH:mm:ss */
export function wallNow(now = new Date()) {
return new Intl.DateTimeFormat('sv-SE', {
timeZone: config.timeZone,
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
}).format(now).replace(' ', 'T')
}
+20 -3
View File
@@ -17,13 +17,15 @@ export type Step =
| { kind: 'edit'; control: string; row: number; column: number | string; value: string }
/** 在日期时间选择器里选日期(和时间):2024-01-15 或 2024-01-15 08:30:00 */
| { kind: 'pick'; control: string; value: string }
/** 拨虚拟时钟到今天的某个时刻(HH:mm:ss),可以同时设倍速 */
| { kind: 'clock'; time: string; speed?: number }
| { kind: 'board'; analog: Partial<Record<AnalogKey, number>>; digital: Partial<Record<DigitalKey, boolean>> }
| { kind: 'wait'; ms: number }
/** desc:给学生看的期望说明,如"显示当前时间",不写就按期望自动生成 */
| { kind: 'expect'; expect: Expectation; within: number; desc?: string }
export type Expectation =
/** 控件属性等于某个值:text → Text,ledText → LedText(LED 控件),enabled → Enabled,checked → Checked,on → IsOn(灯泡、报警灯) */
/** 控件属性等于某个值:text → Text,ledText → LedText(LED 控件),enabled → Enabled,checked → Checked,on → IsOn(灯泡、报警灯),value → Value(滑动条) */
| { kind: 'prop'; control: string; prop: string; value: unknown }
/** 控件文字包含某段文字;列表框是任意一个列表项包含 */
| { kind: 'contains'; control: string; text: string }
@@ -38,6 +40,8 @@ export type Expectation =
| { kind: 'image'; control: string }
/** 摄像头在传画面(on)/ 已关闭(off),或云台比开始时朝某个方向转过 */
| { kind: 'camera'; state: 'on' | 'off' | 'up' | 'down' | 'left' | 'right' }
/** 程序用媒体播放器播放了这个文件(如 ring.mp3) */
| { kind: 'media'; name: string }
/** 程序用 Image.Save 保存了图片,路径包含这段文字(如 Camera/) */
| { kind: 'savedImage'; path: string }
/** 数据表格里有(present)/ 没有一行同时满足这些"列标题=值" */
@@ -47,8 +51,9 @@ export type Expectation =
/** 期望默认最多等多久(毫秒);依赖定时器的检查在规则里写 within */
const DEFAULT_WITHIN = 2000
const PROP_KEYS: Record<string, string> = { text: 'Text', ledText: 'LedText', enabled: 'Enabled', checked: 'Checked', on: 'IsOn' }
const PROP_KEYS: Record<string, string> = { text: 'Text', ledText: 'LedText', enabled: 'Enabled', checked: 'Checked', on: 'IsOn', value: 'Value' }
const STRING_PROPS = new Set(['Text', 'LedText'])
const NUMBER_PROPS = new Set(['Value'])
/** "6s"、"500ms" 或毫秒数 */
function duration(value: unknown, where: string): number {
@@ -78,6 +83,7 @@ function parseExpectation(raw: Record<string, unknown>, where: string): Expectat
if (!['on', 'off', 'up', 'down', 'left', 'right'].includes(state)) throw new Error(`${where}:camera 要写 on、off、up、down、left 或 right`)
return { kind: 'camera', state: state as Extract<Expectation, { kind: 'camera' }>['state'] }
}
if ('media' in raw) return { kind: 'media', name: str(raw.media, where, '播放的文件名') }
if ('savedImage' in raw) return { kind: 'savedImage', path: str(raw.savedImage, where, '保存的图片路径应包含的文字') }
if ('relay' in raw) return { kind: 'relay', index: Number(raw.relay), on: bool(raw.on, where, 'on') }
if ('zigbee' in raw) return { kind: 'zigbee', index: Number(raw.zigbee), on: bool(raw.on, where, 'on') }
@@ -102,7 +108,10 @@ function parseExpectation(raw: Record<string, unknown>, where: string): Expectat
}
if ('image' in raw) return { kind: 'image', control }
for (const [key, prop] of Object.entries(PROP_KEYS)) {
if (key in raw) return { kind: 'prop', control, prop, value: STRING_PROPS.has(prop) ? String(raw[key]) : bool(raw[key], where, key) }
if (key in raw) {
if (NUMBER_PROPS.has(prop) && typeof raw[key] !== 'number') throw new Error(`${where}:${key} 要写数字`)
return { kind: 'prop', control, prop, value: STRING_PROPS.has(prop) ? String(raw[key]) : NUMBER_PROPS.has(prop) ? raw[key] : bool(raw[key], where, key) }
}
}
throw new Error(`${where}:不认识的期望 ${JSON.stringify(raw)}`)
}
@@ -140,6 +149,14 @@ function parseStep(raw: unknown, where: string): Step {
return { kind, control: str(obj.control, where, '控件名 control'), value: String(obj.value ?? '') }
case 'select':
return { kind, control: str(obj.control, where, '控件名 control'), item: str(obj.item, where, '要选的项 item') }
case 'clock': {
// clock: 06:44:50 或 clock: { time: 06:44:50, speed: 60 }
const time = typeof value === 'string' ? value : str(obj.time, where, '时刻 time')
if (!/^\d{2}:\d{2}:\d{2}$/.test(time)) throw new Error(`${where}:时刻要写成 06:44:50(加引号)`)
const speed = typeof value === 'object' && obj.speed !== undefined ? Number(obj.speed) : undefined
if (speed !== undefined && !(speed >= 1 && speed <= 3600)) throw new Error(`${where}:倍速 speed 要在 1 到 3600 之间`)
return { kind, time, ...(speed === undefined ? {} : { speed }) }
}
case 'pick': {
const value = str(obj.value, where, '日期 value')
if (!/^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/.test(value)) throw new Error(`${where}:日期要写成 2024-01-15 或 2024-01-15 08:30:00`)
+12 -1
View File
@@ -2,7 +2,7 @@
// 提示框自动点第一个按钮("确定"/"是"),免得程序一直等在 MessageBox.Show 上。
import type { Subprocess } from 'bun'
import type { ControlNode, RunnerInput, RunnerMessage, UserAction } from '@iot/shared'
import { Board } from '../board'
import { Board, wallNow } from '../board'
import { readLines } from '../runner-process'
import { runCommand } from '../sandbox'
import type { Step } from './checks'
@@ -18,6 +18,8 @@ export class ProgramDriver {
readonly messageBoxes: { text: string; caption: string }[] = []
/** 程序用 Image.Save 保存的图片路径 */
readonly savedImages: string[] = []
/** 媒体播放器播放过的文件名 */
readonly played: string[] = []
readonly exceptions: string[] = []
console = ''
exited = false
@@ -111,6 +113,9 @@ export class ProgramDriver {
case 'imageSaved':
this.savedImages.push(message.path)
break
case 'media':
if (message.action === 'play' && message.name) this.played.push(message.name)
break
case 'console':
this.console += message.text
break
@@ -139,6 +144,12 @@ export class ProgramDriver {
this.send({ type: 'action', id: node.id, ...action })
}
/** 拨虚拟时钟到今天的某个时刻(服务器所在时区) */
setClock(time: string, speed?: number) {
this.board.setClock(`${wallNow().slice(0, 10)}T${time}`, speed)
this.send(this.board.runnerInputs())
}
setBoard(step: BoardStep) {
this.board.applyInput({ type: 'boardInput', analog: step.analog, digital: step.digital })
this.send(this.board.runnerInputs())
+9
View File
@@ -141,6 +141,10 @@ async function runStep(driver: ProgramDriver, step: Step) {
driver.setBoard(step)
await driver.settle()
break
case 'clock':
driver.setClock(step.time, step.speed)
await driver.settle()
break
case 'wait':
await Bun.sleep(step.ms)
break
@@ -227,6 +231,8 @@ function satisfied(driver: ProgramDriver, e: Exclude<Expectation, { kind: 'uncha
}
case 'savedImage':
return driver.savedImages.some((path) => path.includes(e.path))
case 'media':
return driver.played.includes(e.name)
case 'row': {
const node = driver.find(e.control)
return node !== undefined && rowMatches(node, e.cells) === e.present
@@ -244,6 +250,7 @@ function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind:
if (e.prop === 'Enabled') return `期望${prefix} ${e.control} ${desc ?? (e.value ? '可用' : '变成灰色不可用')},实际${actual === false ? '不可用' : '可用'}`
if (e.prop === 'Checked') return `期望${prefix} ${e.control} ${desc ?? (e.value ? '被选中' : '没有被选中')},实际${actual ? '被选中' : '没有被选中'}`
if (e.prop === 'IsOn') return `期望${prefix}窗体上的 ${e.control} ${desc ?? (e.value ? '亮' : '灭')},实际是${actual ? '亮' : '灭'}的`
if (e.prop === 'Value') return `期望${prefix} ${e.control} ${desc ?? `的值是 ${String(e.value)}`},实际是 ${String(actual)}`
return `期望${prefix} ${e.control} 的 ${e.prop} 是 ${String(e.value)},实际是 ${String(actual)}`
}
case 'contains': {
@@ -274,6 +281,8 @@ function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind:
const saved = driver.savedImages.map((p) => `"${p}"`).join('、')
return `期望${prefix}${desc ?? `保存图片到"${e.path}"`},${saved ? `实际保存的是 ${saved}` : '实际没有保存图片'}`
}
case 'media':
return `期望${prefix}${desc ?? `播放 ${e.name}`},${driver.played.length ? `实际播放的是 ${driver.played.join('、')}` : '实际没有播放声音'}`
case 'row': {
const node = need(driver, e.control)
const wanted = Object.entries(e.cells).map(([k, v]) => `${k}=${v}`).join(',')