项目 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:
1 parent
a9af26c2d6
commit
eedf1aa2fb
66 files changed
+3538
-43
No files matched your search
@@ -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')))
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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`)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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(',')
|
||||
|
||||
Reference in new issue
Block a user