项目 4 任务 2 网络摄像机:PictureBox、Image / Bitmap、照片列表
- 摄像头:自绘东围墙场景(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:
1 parent
4ed9450c02
commit
6b088240da
76 files changed
+1290
-14
No files matched your search
@@ -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: '复选框',
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user