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

+22
View File
@@ -179,6 +179,28 @@ export const CONTROLS: ControlDef[] = [
],
initial: () => [['FormattingEnabled', bool(true)], ['ItemHeight', int(12)], ['Size', size(120, 88)]],
},
{
type: `${FORMS}.PictureBox`,
label: '图片框',
group: '常用控件',
namePrefix: 'pictureBox',
defaultEvent: 'Click',
props: [
...COMMON_PROPS.filter((p) => p.name !== 'Text' && p.name !== 'ForeColor' && p.name !== 'Font'),
{
name: 'SizeMode', label: '显示方式', description: '图片在图片框里怎样显示', type: 'enum', enumType: `${FORMS}.PictureBoxSizeMode`,
options: ['Normal', 'StretchImage', 'AutoSize', 'CenterImage', 'Zoom'],
optionLabels: { Normal: '原样放在左上角', StretchImage: '拉伸填满', AutoSize: '图片框随图片大小', CenterImage: '居中', Zoom: '按比例缩放' },
default: enumValue(`${FORMS}.PictureBoxSizeMode`, 'Normal'), category: '外观',
},
{
name: 'BorderStyle', label: '边框', description: '边框', type: 'enum', enumType: `${FORMS}.BorderStyle`,
options: ['None', 'FixedSingle', 'Fixed3D'], optionLabels: { None: '无', FixedSingle: '单线', Fixed3D: '立体' }, default: enumValue(`${FORMS}.BorderStyle`, 'None'), category: '外观',
},
],
events: COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'),
initial: () => [['Size', size(100, 50)], ['TabStop', bool(false)]],
},
{
type: `${FORMS}.CheckBox`,
label: '复选框',
+95
View File
@@ -1,8 +1,10 @@
<script setup lang="ts">
// 运行区:学生程序的窗体(在一块"桌面"上)、提示框、虚拟实训台,以及编译错误和控制台输出。
// 实验页和老师的评阅页共用。
import { provide, ref } from 'vue'
import TrainingBoard from '../scene/TrainingBoard.vue'
import FormWindow from '../winforms/FormWindow.vue'
import { RUN_IMAGES } from '../winforms/images'
import MessageBoxDialog from '../winforms/MessageBoxDialog.vue'
import type { RunSession } from './session'
@@ -10,6 +12,10 @@ const props = defineProps<{ session: RunSession }>()
const emit = defineEmits<{ diagnostic: [file: string, line: number, column: number] }>()
const state = props.session.state
provide(RUN_IMAGES, props.session.images)
/** 照片列表里点开看大图 */
const viewing = ref<{ path: string; url: string } | null>(null)
</script>
<template>
@@ -32,6 +38,26 @@ const state = props.session.state
<div v-if="state.forms.length === 0" class="desktop-hint">点击"运行"后,学生程序的窗体会显示在这里</div>
</div>
<TrainingBoard :board="state.board" @set="session.setBoard" @reset="session.resetBoard" />
<div v-if="state.savedImages.length" class="photos">
<div class="photos-title">程序保存的图片({{ state.savedImages.length }})<span>只在这次运行中保留</span></div>
<div class="photos-list">
<figure
v-for="(photo, i) in state.savedImages"
:key="i"
:title="`${photo.path}(点击查看大图)`"
@click="viewing = { path: photo.path, url: session.images.get(photo.image) ?? '' }"
>
<img :src="session.images.get(photo.image)" alt="" />
<figcaption>{{ photo.path }}</figcaption>
</figure>
</div>
</div>
<div v-if="viewing" class="photo-viewer" @click="viewing = null">
<figure>
<img :src="viewing.url" alt="" />
<figcaption>{{ viewing.path }}(点击任意处关闭)</figcaption>
</figure>
</div>
<div class="output">
<div
v-for="(d, i) in state.diagnostics"
@@ -78,6 +104,75 @@ const state = props.session.state
color: rgb(255 255 255 / 0.7);
}
.photos {
font-size: 12px;
}
.photos-title {
margin-bottom: 4px;
font-weight: 600;
}
.photos-title span {
margin-left: 8px;
font-weight: normal;
color: #6b7280;
}
.photos-list {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
}
.photos-list figure {
flex: none;
width: 120px;
margin: 0;
cursor: zoom-in;
}
.photos-list img {
display: block;
width: 120px;
height: 90px;
object-fit: cover;
border-radius: 3px;
border: 1px solid #d1d5db;
}
.photos-list figcaption {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #4b5563;
}
.photo-viewer {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 0.6);
cursor: zoom-out;
}
.photo-viewer figure {
margin: 0;
max-width: 90vw;
text-align: center;
color: #fff;
}
.photo-viewer img {
max-width: 90vw;
max-height: 80vh;
border-radius: 4px;
}
.output {
max-height: 180px;
overflow: auto;
+22 -1
View File
@@ -15,6 +15,12 @@ export interface OpenForm {
modal: boolean
}
/** 学生程序保存的图片(如摄像头照片),path 相对程序所在目录 */
export interface SavedImage {
path: string
image: string
}
export interface PendingMessageBox {
requestId: number
text: string
@@ -34,10 +40,14 @@ export function createRunSession() {
messageBoxes: [] as PendingMessageBox[],
/** 虚拟实训台,由服务器推送;连接建立前为 null */
board: null as BoardState | null,
/** 本次运行保存的图片,显示在运行区的照片列表里;下次运行时清空 */
savedImages: [] as SavedImage[],
metrics: { compileMs: 0, startMs: 0, lastRoundTripMs: 0 },
})
/** id → 控件节点,patch 按 id 修改节点属性 */
const nodes = shallowReactive(new Map<string, ControlNode>())
/** 图片编号 → 网址(blob:)。运行器每张图只发一次,控件属性里只带编号 */
const images = shallowReactive(new Map<string, string>())
let socket: WebSocket | undefined
/** 正在建立或已建立的连接;同时多处调用 connect() 时共用同一个 */
let connecting: Promise<void> | undefined
@@ -76,7 +86,10 @@ export function createRunSession() {
state.errors = []
state.forms = []
state.messageBoxes = []
state.savedImages = []
nodes.clear()
for (const url of images.values()) URL.revokeObjectURL(url)
images.clear()
send({ type: 'run', files })
}
@@ -173,6 +186,14 @@ export function createRunSession() {
case 'messageBox':
state.messageBoxes.push(message)
break
case 'image': {
const bytes = Uint8Array.from(atob(message.data), (c) => c.charCodeAt(0))
images.set(message.key, URL.createObjectURL(new Blob([bytes], { type: message.mime })))
break
}
case 'imageSaved':
state.savedImages.push({ path: message.path, image: message.image })
break
case 'board':
state.board = message.state
break
@@ -197,7 +218,7 @@ export function createRunSession() {
socket?.close()
}
return { state, connect, run, stop, act, replyMessageBox, setBoard, resetBoard, close }
return { state, images, connect, run, stop, act, replyMessageBox, setBoard, resetBoard, close }
}
export type RunSession = ReturnType<typeof createRunSession>
+11 -1
View File
@@ -77,6 +77,10 @@ const relays = computed(() => props.board?.relays ?? [])
const streetLampOn = computed(() => props.board?.zigbee[1] === true)
const alarmOn = computed(() => relays.value[0] === true)
const ledText = computed(() => props.board?.ledText ?? '')
const camera = computed(() => props.board?.camera ?? { on: false, x: 3, y: 1 })
const cameraTitle = computed(() =>
camera.value.on ? `网络摄像头:正在传送画面(云台左右 ${camera.value.x + 1}/7,上下 ${camera.value.y + 1}/3)` : '网络摄像头(项目 4 使用):未开启',
)
</script>
<template>
@@ -121,7 +125,7 @@ const ledText = computed(() => props.board?.ledText ?? '')
</div>
<div class="device warning-light" :class="{ on: alarmOn }" title="警示灯(ADAM-4150 DO0)">警示灯</div>
<div class="beam" :class="{ broken: digital.infrared }" @click="toggle('infrared')" />
<div class="device camera" title="网络摄像头(项目 4 使用)">
<div class="device camera" :class="{ on: camera.on }" :title="cameraTitle">
<svg viewBox="0 0 24 24" width="22" height="22"><path fill="currentColor" d="M4 7h11a2 2 0 0 1 2 2v1.5l3.5-2.5v8L17 13.5V15a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z" /></svg>
</div>
<button class="device beam-end" :class="{ active: digital.infrared }" title="点击遮挡/恢复光束" @click="toggle('infrared')">
@@ -403,6 +407,12 @@ button {
color: var(--muted);
}
.camera.on {
color: #4cd964;
border-color: #4cd96488;
box-shadow: 0 0 10px 2px #4cd96444;
}
/* LED 屏 */
.led-screen {
position: relative;
+31 -1
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
// 按控件类型渲染一个 WinForms 控件及其子控件。
// 用户操作只通过 act() 上报,由运行器按真实 WinForms 的顺序触发事件。
import { computed, ref } from 'vue'
import { computed, inject, ref, type CSSProperties } from 'vue'
import type { ControlNode, UserAction } from '@iot/shared'
import { boxCss, displayText } from './format'
import { RUN_IMAGES, type ImageValue } from './images'
import LedControl from './LedControl.vue'
const props = defineProps<{
@@ -49,6 +50,24 @@ function selectItem(index: number) {
act({ action: 'select', index })
}
// 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 imageStyle = computed((): CSSProperties => {
const natural = { width: `${image.value?.width ?? 0}px`, height: `${image.value?.height ?? 0}px` }
switch (p.value.SizeMode) {
case 'StretchImage':
return { width: '100%', height: '100%', objectFit: 'fill' }
case 'Zoom':
return { width: '100%', height: '100%', objectFit: 'contain' }
case 'CenterImage':
return { ...natural, left: '50%', top: '50%', transform: 'translate(-50%, -50%)' }
default:
return natural
}
})
// ListBox
const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined) ?? 12}px`)
</script>
@@ -132,6 +151,17 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
</div>
</div>
<!-- PictureBox -->
<div
v-else-if="node.type === 'PictureBox'"
class="wf-control wf-picturebox"
:class="`wf-picturebox-border-${p.BorderStyle ?? 'None'}`"
:style="style"
@click="act({ action: 'click' })"
>
<img v-if="imageUrl" :src="imageUrl" :style="imageStyle" alt="" draggable="false" />
</div>
<!-- CheckBox / RadioButton:自绘勾选框,状态完全由运行器决定(点击只上报,不在浏览器里自行切换) -->
<div
v-else-if="node.type === 'CheckBox' || node.type === 'RadioButton'"
+12
View File
@@ -0,0 +1,12 @@
// 运行时图片:运行器发来的图片数据由 RunSession 保存成 blob 网址,控件属性里只带图片编号。
// 运行区 provide,ControlView inject;设计器预览没有运行时图片,取到空表。
import type { InjectionKey } from 'vue'
export const RUN_IMAGES: InjectionKey<ReadonlyMap<string, string>> = Symbol('runImages')
/** 控件 Image 属性的值(见 runtime/Sim.WinForms/Host/PropValue.cs) */
export interface ImageValue {
key: string
width: number
height: number
}
+20
View File
@@ -151,6 +151,26 @@
color: #fff;
}
.wf-picturebox {
overflow: hidden;
}
.wf-picturebox img {
position: absolute;
left: 0;
top: 0;
display: block;
user-select: none;
}
.wf-picturebox-border-FixedSingle {
border: 1px solid #646464;
}
.wf-picturebox-border-Fixed3D {
border: 2px inset #a0a0a0;
}
.wf-listbox {
overflow-y: auto;
overflow-x: hidden;