项目 4 任务 2 网络摄像机:PictureBox、Image / Bitmap、照片列表
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s

- 摄像头:自绘东围墙场景(Camera/scene.svg),按云台位置切成 640×480 帧(build-frames.ts);
  Pan 每次转一格,红外对射被遮挡时画面出现翻墙的人;类名、委托、事件参数与教材 dll 一致,
  ImageRead 每 250 ms 在后台线程触发
- Image / Bitmap 保存真实图片数据:Save、FromFile、FromStream,文件夹不存在时报 GDI+ 一般性错误;
  图片每张只发给浏览器一次(按内容哈希缓存)
- 程序所在目录(AppDomain.CurrentDomain.BaseDirectory,编译检查单独放行)和当前目录指向可写的 bin/Debug,
  沙箱里在沙箱的临时目录
- PictureBox(SizeMode、BorderStyle);设计器工具箱加"图片框";运行区显示程序保存的图片,点开看大图;
  实训台摄像头图标显示开启状态
- 评分规则加图片框显示图片、摄像头开关与转向、保存图片三种检查;示例 p4-t2-网络摄像机
- 测试:摄像头端到端、图片读写与程序目录、AppDomain 编译检查、评分出错情况

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-22 20:58:09 +08:00
1 parent 4ed9450c02
commit 6b088240da
76 files changed
+1290 -14

No files matched your search

+15
View File
@@ -15,6 +15,7 @@ export function defaultBoardState(): BoardState {
relays: [false, false, false, false, false],
zigbee: [false, false],
ledText: '',
camera: { on: false, x: 3, y: 1 },
}
}
@@ -69,11 +70,25 @@ export class Board {
this.state.zigbee[relay] = on === 1
return true
}
case 'camera/on':
this.state.camera.on = message.data === true
return true
case 'camera/pan': {
const [x, y] = message.data as [number, number]
this.state.camera.x = x
this.state.camera.y = y
return true
}
default:
return false
}
}
/** 学生程序结束:摄像头不再传画面,云台回到正中(下次运行的程序从正中开始) */
programExited() {
this.state.camera = { on: false, x: 3, y: 1 }
}
reset() {
this.state = defaultBoardState()
}
+13
View File
@@ -28,6 +28,12 @@ export type Expectation =
| { kind: 'relay'; index: number; on: boolean }
| { kind: 'zigbee'; index: number; on: boolean }
| { kind: 'messageBox'; text: string }
/** 图片框里显示了图片 */
| { kind: 'image'; control: string }
/** 摄像头在传画面(on)/ 已关闭(off),或云台比开始时朝某个方向转过 */
| { kind: 'camera'; state: 'on' | 'off' | 'up' | 'down' | 'left' | 'right' }
/** 程序用 Image.Save 保存了图片,路径包含这段文字(如 Camera/) */
| { kind: 'savedImage'; path: string }
| { kind: 'console'; text: string }
/** 期望默认最多等多久(毫秒);依赖定时器的检查在规则里写 within */
@@ -59,6 +65,12 @@ function parseExpectation(raw: Record<string, unknown>, where: string): Expectat
if ('led' in raw) return { kind: 'led', text: str(raw.led, where, ' LED 屏应显示的文字') }
if ('messageBox' in raw) return { kind: 'messageBox', text: str(raw.messageBox, where, '提示框应包含的文字') }
if ('console' in raw) return { kind: 'console', text: str(raw.console, where, '控制台应输出的文字') }
if ('camera' in raw) {
const state = String(raw.camera)
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 ('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') }
@@ -69,6 +81,7 @@ function parseExpectation(raw: Record<string, unknown>, where: string): Expectat
return { kind: 'matches', control, pattern: new RegExp(source), source }
}
if ('unchanged' in raw) return { kind: 'unchanged', control, ms: duration(raw.unchanged, where) }
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) }
}
+5
View File
@@ -16,6 +16,8 @@ export class ProgramDriver {
private readonly nodes = new Map<string, ControlNode>()
private readonly parents = new Map<string, ControlNode>()
readonly messageBoxes: { text: string; caption: string }[] = []
/** 程序用 Image.Save 保存的图片路径 */
readonly savedImages: string[] = []
readonly exceptions: string[] = []
console = ''
exited = false
@@ -106,6 +108,9 @@ export class ProgramDriver {
case 'device':
this.board.applyDevice(message)
break
case 'imageSaved':
this.savedImages.push(message.path)
break
case 'console':
this.console += message.text
break
+19
View File
@@ -11,6 +11,9 @@ const START_TIMEOUT = 10_000
const RELAY_NAMES = ['报警灯', '照明灯', '路灯', '插座 1', '插座 2']
const ZIGBEE_NAMES = ['ZigBee 节点 0', '照明灯 1']
/** 摄像头云台开始时的位置(见 IPCameraHelper.cs) */
const CAMERA_START = { x: 3, y: 1 }
const CAMERA_WORDS = { on: '开启(传送画面)', off: '关闭', up: '向上转', down: '向下转', left: '向左转', right: '向右转' }
// ---------- 并发限制 ----------
let running = 0
@@ -160,6 +163,14 @@ function satisfied(driver: ProgramDriver, e: Exclude<Expectation, { kind: 'uncha
return driver.messageBoxes.some((box) => box.text.includes(e.text))
case 'console':
return driver.console.includes(e.text)
case 'image':
return Boolean(props(e.control)?.Image)
case 'camera': {
const { on, x, y } = driver.board.state.camera
return { on, off: !on, up: y < CAMERA_START.y, down: y > CAMERA_START.y, left: x < CAMERA_START.x, right: x > CAMERA_START.x }[e.state]
}
case 'savedImage':
return driver.savedImages.some((path) => path.includes(e.path))
}
}
@@ -195,5 +206,13 @@ function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind:
}
case 'console':
return `期望${prefix}控制台${desc ?? `输出"${e.text}"`},实际输出${quote(driver.console.trim())}`
case 'image':
return `期望${prefix} ${e.control} ${desc ?? '显示图片'},实际是空的`
case 'camera':
return `期望${prefix}网络摄像机${desc ?? CAMERA_WORDS[e.state]},实际${driver.board.state.camera.on ? '开着' : '没有开启'}${e.state === 'on' || e.state === 'off' ? '' : '、云台没有朝这个方向转'}`
case 'savedImage': {
const saved = driver.savedImages.map((p) => `"${p}"`).join('、')
return `期望${prefix}${desc ?? `保存图片到"${e.path}"`},${saved ? `实际保存的是 ${saved}` : '实际没有保存图片'}`
}
}
}
+4
View File
@@ -108,6 +108,8 @@ export class RunSession {
clearTimeout(timeout)
if (this.proc !== proc) return
this.proc = undefined
this.board.programExited()
this.sendBoard()
const cpuMessage = cpuLimitMessage(code, proc.signalCode)
if (cpuMessage) this.send({ type: 'console', text: cpuMessage })
this.send({ type: 'exited', code })
@@ -159,6 +161,8 @@ export class RunSession {
if (!proc) return
proc.kill()
await proc.exited
this.board.programExited()
this.sendBoard()
}
private async cleanup() {