{{ d.file }}({{ d.line }},{{ d.column }}) {{ d.code }}:{{ d.message }}
@@ -182,7 +190,7 @@ body {
.desktop {
position: relative;
flex: 1;
- min-height: 380px;
+ min-height: 320px;
background: #2d6aa3;
border-radius: 4px;
/* 窗体比桌面区域大时可以滚动查看 */
diff --git a/web/src/run/session.ts b/web/src/run/session.ts
index f594bb7..ea32c93 100644
--- a/web/src/run/session.ts
+++ b/web/src/run/session.ts
@@ -1,9 +1,9 @@
import { reactive, shallowReactive } from 'vue'
import type {
+ BoardState,
ClientMessage,
ControlNode,
Diagnostic,
- RunnerInput,
ServerMessage,
SourceFile,
UserAction,
@@ -31,23 +31,33 @@ export function createRunSession() {
errors: [] as string[],
forms: [] as OpenForm[],
messageBoxes: [] as PendingMessageBox[],
- devices: { ledText: '' },
+ /** 虚拟实训台,由服务器推送;连接建立前为 null */
+ board: null as BoardState | null,
metrics: { compileMs: 0, startMs: 0, lastRoundTripMs: 0 },
})
/** id → 控件节点,patch 按 id 修改节点属性 */
const nodes = shallowReactive(new Map
())
let socket: WebSocket | undefined
+ /** 正在建立或已建立的连接;同时多处调用 connect() 时共用同一个 */
+ let connecting: Promise | undefined
let actionSentAt = 0
function connect() {
- const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
- socket = new WebSocket(`${protocol}://${location.host}/ws`)
- socket.onmessage = (event) => handle(JSON.parse(event.data) as ServerMessage)
- socket.onclose = () => {
- state.status = 'stopped'
- socket = undefined
- }
- return new Promise((resolve) => socket!.addEventListener('open', () => resolve(), { once: true }))
+ connecting ??= new Promise((resolve) => {
+ const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
+ const ws = new WebSocket(`${protocol}://${location.host}/ws`)
+ socket = ws
+ ws.onmessage = (event) => handle(JSON.parse(event.data) as ServerMessage)
+ ws.onopen = () => resolve()
+ ws.onclose = () => {
+ state.status = 'stopped'
+ state.board = null
+ socket = undefined
+ connecting = undefined
+ resolve()
+ }
+ })
+ return connecting
}
function send(message: ClientMessage) {
@@ -55,24 +65,32 @@ export function createRunSession() {
}
async function run(files: SourceFile[]) {
- if (!socket) await connect()
+ await connect()
state.diagnostics = []
state.console = ''
state.errors = []
state.forms = []
state.messageBoxes = []
- state.devices.ledText = ''
nodes.clear()
send({ type: 'run', files })
}
+ /** 调节实训台环境(滑块、传感器开关) */
+ function setBoard(input: Omit, 'type'>) {
+ send({ type: 'boardInput', ...input })
+ }
+
+ function resetBoard() {
+ send({ type: 'boardReset' })
+ }
+
function stop() {
send({ type: 'stop' })
}
function act(id: string, action: UserAction) {
actionSentAt = performance.now()
- send({ type: 'action', id, ...action } as RunnerInput)
+ send({ type: 'action', id, ...action } as ClientMessage)
}
function replyMessageBox(requestId: number, result: string) {
@@ -145,8 +163,8 @@ export function createRunSession() {
case 'messageBox':
state.messageBoxes.push(message)
break
- case 'device':
- if (message.device === 'led' && message.action === 'text') state.devices.ledText = String(message.data)
+ case 'board':
+ state.board = message.state
break
case 'console':
state.console += message.text
@@ -164,7 +182,7 @@ export function createRunSession() {
}
}
- return { state, run, stop, act, replyMessageBox }
+ return { state, connect, run, stop, act, replyMessageBox, setBoard, resetBoard }
}
export type RunSession = ReturnType
diff --git a/web/src/scene/TrainingBoard.vue b/web/src/scene/TrainingBoard.vue
index 7498c2e..68cfbb1 100644
--- a/web/src/scene/TrainingBoard.vue
+++ b/web/src/scene/TrainingBoard.vue
@@ -1,52 +1,536 @@
-
-
LED 显示屏(COM5)
-
-
{{ ledText }}
+
+
+
+
+
+ 有人闯入
+
+
警示灯
+
+
+
+
+
+
+
+ {{ ledText }}
+
+
+
+
+
+
+
照明灯 1
+
ZigBee 节点 1
+
+
+
+
+
+
温湿度传感器
+
+
+
+
+
光照传感器
+
+
+
+
+
+
+
+
+
+
+
+
ADAM-4017+ 模拟量(地址 2)
+
+
+ Vin{{ i }} {{ ANALOG_INFO[key].label }} {{ board?.analog[key] ?? '-' }}
+
+
+
+
+
+
+
+
+
+
ADAM-4150 数字量(地址 1)
+
+
+ DI{{ i }} {{ input.label }}
+
+
+
+
+ DO{{ i }} {{ label }}
+
+
+
+
+
+
串口服务器
+
+ COM2 → 4017COM3 → 4150COM4 → ZigBee 协调器COM5 → LED 屏
+
+
+
diff --git a/web/src/winforms/ControlView.vue b/web/src/winforms/ControlView.vue
index 8a08dd3..a5fba25 100644
--- a/web/src/winforms/ControlView.vue
+++ b/web/src/winforms/ControlView.vue
@@ -108,6 +108,26 @@ function selectItem(index: number) {
+
+
+
+ {{ displayText(p.Text) }}
+
+
+
+
+
![]()
+
+
+
![]()
+
+
diff --git a/web/src/winforms/winforms.css b/web/src/winforms/winforms.css
index f6c0f49..0798c67 100644
--- a/web/src/winforms/winforms.css
+++ b/web/src/winforms/winforms.css
@@ -151,6 +151,55 @@
color: #fff;
}
+.wf-check {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ white-space: pre;
+ cursor: default;
+}
+
+.wf-checkbox-box,
+.wf-radio-dot {
+ flex: none;
+ width: 13px;
+ height: 13px;
+ box-sizing: border-box;
+ border: 1px solid #333;
+ background: #fff;
+}
+
+.wf-radio-dot {
+ border-radius: 50%;
+}
+
+.wf-checkbox-box[data-checked="true"]::after {
+ content: "✓";
+ display: block;
+ font: bold 11px/11px sans-serif;
+ text-align: center;
+ color: #333;
+}
+
+.wf-radio-dot[data-checked="true"] {
+ background: radial-gradient(circle, #333 0 3px, #fff 3.5px);
+}
+
+.wf-check:hover:not(.wf-disabled) .wf-checkbox-box,
+.wf-check:hover:not(.wf-disabled) .wf-radio-dot {
+ border-color: #0078d7;
+}
+
+.wf-device-image {
+ overflow: hidden;
+}
+
+/* 与教材控件一致:图片以原始尺寸从左上角显示 */
+.wf-device-image img {
+ display: block;
+ user-select: none;
+}
+
.wf-groupbox-frame {
position: absolute;
inset: 6px 0 0 0;