第 1 阶段:虚拟实训台,项目 2、3 的示例跑通
- 实训台状态由服务器统一保存:滑块和开关设定传感器读数,推给学生程序; 学生程序控制的灯、继电器、LED 屏回写到实训台 - 网页按教材实训台布局图绘制实训台,传感器卡片上直接调节环境 - 新控件:CheckBox、RadioButton(事件顺序与 WinForms 一致),Lamp、Alarm - 设备类库:串口未打开时控制无效,与实物一致 - 修正:列表属性序列化时数字被转成字符串 - 示例:项目 2 任务 2–3、项目 3 任务 1–2;网址参数 ?sample= 打开指定示例 - 测试:实训台单元测试,项目 2、3 的端到端测试 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
98be8331bc
commit
26104ae81e
38 files changed
+2346
-77
No files matched your search
+13
-5
@@ -21,15 +21,23 @@ const statusType = computed(() => ({ idle: 'default', compiling: 'warning', runn
|
||||
const running = computed(() => state.status === 'compiling' || state.status === 'running')
|
||||
|
||||
onMounted(async () => {
|
||||
// 一打开页面就连接服务器,实训台在运行程序之前就能显示和调节
|
||||
void session.connect()
|
||||
const list: { id: string; title: string }[] = await (await fetch('/api/samples')).json()
|
||||
samples.value = list.map((s) => ({ label: s.title, value: s.id }))
|
||||
if (list[0]) await loadSample(list[0].id)
|
||||
// 网址里带 ?sample=示例编号 时直接打开该示例(便于老师把链接发给学生)
|
||||
const requested = new URLSearchParams(location.search).get('sample')
|
||||
const initial = list.find((s) => s.id === requested) ?? list[0]
|
||||
if (initial) await loadSample(initial.id)
|
||||
})
|
||||
|
||||
async function loadSample(id: string) {
|
||||
sampleId.value = id
|
||||
history.replaceState(null, '', `?sample=${encodeURIComponent(id)}`)
|
||||
files.value = await (await fetch(`/api/samples/${encodeURIComponent(id)}`)).json()
|
||||
activeFile.value = files.value.find((f) => f.path.endsWith('Form1.cs'))?.path ?? files.value[0]?.path
|
||||
// 默认打开窗体的代码文件(不一定叫 Form1.cs,如教材的 frm_analog.cs)
|
||||
activeFile.value =
|
||||
files.value.find((f) => !f.path.endsWith('.Designer.cs') && f.path !== 'Program.cs')?.path ?? files.value[0]?.path
|
||||
}
|
||||
|
||||
function updateFile(path: string, content: string) {
|
||||
@@ -46,7 +54,7 @@ function run() {
|
||||
<NConfigProvider :locale="zhCN">
|
||||
<div class="page">
|
||||
<header class="toolbar">
|
||||
<strong>物联网编程仿真平台 · 第 0 阶段</strong>
|
||||
<strong>物联网编程仿真平台</strong>
|
||||
<NSelect
|
||||
class="sample-select"
|
||||
:value="sampleId"
|
||||
@@ -95,7 +103,7 @@ function run() {
|
||||
/>
|
||||
<div v-if="state.forms.length === 0" class="desktop-hint">点击"运行"后,学生程序的窗体会显示在这里</div>
|
||||
</div>
|
||||
<TrainingBoard :led-text="state.devices.ledText" />
|
||||
<TrainingBoard :board="state.board" @set="session.setBoard" @reset="session.resetBoard" />
|
||||
<div class="output">
|
||||
<div v-for="(d, i) in state.diagnostics" :key="`d${i}`" :class="['diag', d.severity]">
|
||||
{{ 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;
|
||||
/* 窗体比桌面区域大时可以滚动查看 */
|
||||
|
||||
+34
-16
@@ -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<string, ControlNode>())
|
||||
let socket: WebSocket | undefined
|
||||
/** 正在建立或已建立的连接;同时多处调用 connect() 时共用同一个 */
|
||||
let connecting: Promise<void> | 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<void>((resolve) => socket!.addEventListener('open', () => resolve(), { once: true }))
|
||||
connecting ??= new Promise<void>((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<Extract<ClientMessage, { type: 'boardInput' }>, '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<typeof createRunSession>
|
||||
+526
-19
@@ -1,52 +1,536 @@
|
||||
<script setup lang="ts">
|
||||
// 虚拟实训台。第 0 阶段只有 LED 显示屏,其余设备按布局图逐步加入。
|
||||
defineProps<{ ledText: string }>()
|
||||
// 虚拟实训台:按教材实训台布局图(顶棚 → LED 屏 → 传感器 → 采集模块)排列。
|
||||
// 传感器卡片上的滑块和开关调节环境(经服务器推给学生程序),灯和继电器的状态由学生程序控制。
|
||||
import { computed, ref } from 'vue'
|
||||
import { ANALOG_INFO, type AnalogKey, type BoardState, type ClientMessage, type DigitalKey } from '@iot/shared'
|
||||
|
||||
type BoardInput = Omit<Extract<ClientMessage, { type: 'boardInput' }>, 'type'>
|
||||
|
||||
const props = defineProps<{ board: BoardState | null }>()
|
||||
const emit = defineEmits<{ set: [input: BoardInput]; reset: [] }>()
|
||||
|
||||
const MORE_SENSORS: AnalogKey[] = ['airPressure', 'airQuality', 'co2', 'wind']
|
||||
const RELAYS = ['报警灯', '照明灯', '路灯', '插座 1', '插座 2']
|
||||
const DIGITAL_INPUTS: { key: DigitalKey; label: string }[] = [
|
||||
{ key: 'bodyInfrared', label: '人体红外' },
|
||||
{ key: 'fire', label: '火焰' },
|
||||
{ key: 'smoke', label: '烟雾' },
|
||||
{ key: 'infrared', label: '红外对射' },
|
||||
]
|
||||
|
||||
const showMore = ref(false)
|
||||
|
||||
// 拖动滑块时先显示本地值,避免服务器回传前滑块跳动;发送做节流
|
||||
const dragging = ref<Partial<Record<AnalogKey, number>>>({})
|
||||
let pending: Partial<Record<AnalogKey, number>> = {}
|
||||
let flushTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function analog(key: AnalogKey) {
|
||||
return dragging.value[key] ?? props.board?.analog[key] ?? 0
|
||||
}
|
||||
|
||||
function slide(key: AnalogKey, value: number) {
|
||||
dragging.value = { ...dragging.value, [key]: value }
|
||||
pending[key] = value
|
||||
flushTimer ??= setTimeout(flush, 80)
|
||||
}
|
||||
|
||||
function flush() {
|
||||
flushTimer = undefined
|
||||
if (Object.keys(pending).length) emit('set', { analog: pending })
|
||||
pending = {}
|
||||
}
|
||||
|
||||
function release(key: AnalogKey) {
|
||||
flush()
|
||||
const { [key]: _, ...rest } = dragging.value
|
||||
dragging.value = rest
|
||||
}
|
||||
|
||||
function toggle(key: DigitalKey) {
|
||||
if (props.board) emit('set', { digital: { [key]: !props.board.digital[key] } })
|
||||
}
|
||||
|
||||
const digital = computed(() => props.board?.digital ?? { bodyInfrared: false, fire: false, smoke: false, infrared: false })
|
||||
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 ?? '')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="board">
|
||||
<div class="board-label">LED 显示屏(COM5)</div>
|
||||
<div class="led-screen">
|
||||
<span v-if="ledText" :key="ledText" class="led-screen-text">{{ ledText }}</span>
|
||||
<div class="board" :class="{ disconnected: !board }">
|
||||
<!-- 顶棚:红外对射、警示灯(DO0)、摄像头 -->
|
||||
<div class="ceiling">
|
||||
<button class="device beam-end" :class="{ active: digital.infrared }" title="点击遮挡/恢复光束" @click="toggle('infrared')">
|
||||
红外对射
|
||||
</button>
|
||||
<div class="beam" :class="{ broken: digital.infrared }" @click="toggle('infrared')">
|
||||
<span v-if="digital.infrared" class="beam-tag">有人闯入</span>
|
||||
</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 使用)">
|
||||
<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')">
|
||||
红外对射
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- LED 显示屏 -->
|
||||
<div class="led-screen" title="LED 显示屏">
|
||||
<span v-if="ledText" :key="ledText" class="led-text">{{ ledText }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 传感器与执行器 -->
|
||||
<div class="sensors">
|
||||
<div class="card" title="照明灯 1:由 ZigBee 继电器节点 1 控制(教材项目 3 的路灯)">
|
||||
<div class="bulb" :class="{ on: streetLampOn }" />
|
||||
<div class="card-title">照明灯 1</div>
|
||||
<div class="card-note">ZigBee 节点 1</div>
|
||||
</div>
|
||||
|
||||
<button class="card toggle" :class="{ active: digital.bodyInfrared }" title="点击切换:有人 / 无人" @click="toggle('bodyInfrared')">
|
||||
<svg viewBox="0 0 24 24" width="30" height="30" class="icon">
|
||||
<circle cx="12" cy="5" r="2.6" fill="currentColor" />
|
||||
<path fill="currentColor" d="M9 9h6l1.5 6h-2l-.8 7h-3.4l-.8-7h-2z" />
|
||||
</svg>
|
||||
<div class="card-title">人体红外</div>
|
||||
<div class="card-note">{{ digital.bodyInfrared ? '有人' : '无人' }}</div>
|
||||
</button>
|
||||
|
||||
<div class="card wide">
|
||||
<div class="card-title">温湿度传感器</div>
|
||||
<label v-for="key in ['temperature', 'humidity'] as AnalogKey[]" :key="key" class="slider">
|
||||
<span>{{ ANALOG_INFO[key].label }}</span>
|
||||
<input
|
||||
type="range"
|
||||
:min="ANALOG_INFO[key].min"
|
||||
:max="ANALOG_INFO[key].max"
|
||||
:value="analog(key)"
|
||||
:disabled="!board"
|
||||
@input="slide(key, Number(($event.target as HTMLInputElement).value))"
|
||||
@change="release(key)"
|
||||
/>
|
||||
<b>{{ analog(key) }}{{ ANALOG_INFO[key].unit }}</b>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card wide">
|
||||
<div class="card-title">光照传感器</div>
|
||||
<label class="slider">
|
||||
<span>光照</span>
|
||||
<input
|
||||
type="range"
|
||||
:min="ANALOG_INFO.light.min"
|
||||
:max="ANALOG_INFO.light.max"
|
||||
step="10"
|
||||
:value="analog('light')"
|
||||
:disabled="!board"
|
||||
@input="slide('light', Number(($event.target as HTMLInputElement).value))"
|
||||
@change="release('light')"
|
||||
/>
|
||||
<b>{{ analog('light') }}lx</b>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="card toggle" :class="{ active: digital.smoke }" title="点击切换:有烟雾 / 正常" @click="toggle('smoke')">
|
||||
<div class="smoke" :class="{ on: digital.smoke }"><i /><i /><i /></div>
|
||||
<div class="card-title">烟雾传感器</div>
|
||||
<div class="card-note">{{ digital.smoke ? '有烟雾' : '正常' }}</div>
|
||||
</button>
|
||||
|
||||
<button class="card toggle" :class="{ active: digital.fire }" title="点击切换:有火焰 / 正常" @click="toggle('fire')">
|
||||
<svg viewBox="0 0 24 24" width="30" height="30" class="icon flame" :class="{ on: digital.fire }">
|
||||
<path fill="currentColor" d="M12 2c1 4 5 5.5 5 11a5 5 0 0 1-10 0c0-3 1.5-4.5 2.5-6 .5 2 1.5 3 2.5 3-1-3 0-6 0-8z" />
|
||||
</svg>
|
||||
<div class="card-title">火焰传感器</div>
|
||||
<div class="card-note">{{ digital.fire ? '有火焰' : '正常' }}</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 采集模块与串口 -->
|
||||
<div class="modules">
|
||||
<div class="module">
|
||||
<div class="module-title">ADAM-4017+ 模拟量(地址 2)</div>
|
||||
<div class="readings">
|
||||
<span v-for="(key, i) in ['temperature', 'humidity', 'light'] as AnalogKey[]" :key="key">
|
||||
Vin{{ i }} {{ ANALOG_INFO[key].label }} <b>{{ board?.analog[key] ?? '-' }}</b>
|
||||
</span>
|
||||
<button class="link" @click="showMore = !showMore">{{ showMore ? '收起' : 'Vin3–6 更多传感器' }}</button>
|
||||
</div>
|
||||
<div v-if="showMore" class="more">
|
||||
<label v-for="key in MORE_SENSORS" :key="key" class="slider">
|
||||
<span>{{ ANALOG_INFO[key].label }}</span>
|
||||
<input
|
||||
type="range"
|
||||
:min="ANALOG_INFO[key].min"
|
||||
:max="ANALOG_INFO[key].max"
|
||||
:value="analog(key)"
|
||||
:disabled="!board"
|
||||
@input="slide(key, Number(($event.target as HTMLInputElement).value))"
|
||||
@change="release(key)"
|
||||
/>
|
||||
<b>{{ analog(key) }}{{ ANALOG_INFO[key].unit }}</b>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="module">
|
||||
<div class="module-title">ADAM-4150 数字量(地址 1)</div>
|
||||
<div class="io-row">
|
||||
<span v-for="(input, i) in DIGITAL_INPUTS" :key="input.key" class="io" :class="{ lit: digital[input.key] }">
|
||||
<i />DI{{ i }} {{ input.label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="io-row">
|
||||
<span v-for="(label, i) in RELAYS" :key="label" class="io out" :class="{ lit: relays[i] }">
|
||||
<i />DO{{ i }} {{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="module ports">
|
||||
<div class="module-title">串口服务器</div>
|
||||
<div class="port-list">
|
||||
<span>COM2 → 4017</span><span>COM3 → 4150</span><span>COM4 → ZigBee 协调器</span><span>COM5 → LED 屏</span>
|
||||
</div>
|
||||
<button class="reset" :disabled="!board" @click="emit('reset')">实训台复位</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.board {
|
||||
padding: 12px;
|
||||
background: #3b4252;
|
||||
--panel: #2b3340;
|
||||
--card: #364052;
|
||||
--text: #dfe5ee;
|
||||
--muted: #98a3b3;
|
||||
--accent: #4fa3ff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: var(--panel);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.board-label {
|
||||
margin-bottom: 6px;
|
||||
color: #d8dee9;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.board.disconnected {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* 顶棚 */
|
||||
.ceiling {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
background: #3a4556;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.device {
|
||||
flex: none;
|
||||
padding: 4px 8px;
|
||||
background: var(--card);
|
||||
border: 1px solid #4a566a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.beam-end {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.beam-end.active {
|
||||
border-color: #ff6b6b;
|
||||
}
|
||||
|
||||
.beam {
|
||||
flex: 1;
|
||||
height: 12px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(#0000 5px, #ff4d4d88 5px, #ff4d4d88 7px, #0000 7px);
|
||||
}
|
||||
|
||||
.beam.broken {
|
||||
background: repeating-linear-gradient(90deg, #ff4d4d 0 6px, #0000 6px 12px) center / 100% 2px no-repeat;
|
||||
}
|
||||
|
||||
.beam-tag {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: -4px;
|
||||
transform: translateX(-50%);
|
||||
padding: 0 4px;
|
||||
background: #ff4d4d;
|
||||
color: #fff;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.warning-light {
|
||||
background: #6b4a2a;
|
||||
border-color: #8a5a2a;
|
||||
}
|
||||
|
||||
.warning-light.on {
|
||||
background: #ff3b30;
|
||||
border-color: #ff8a80;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 14px 4px #ff3b30aa;
|
||||
animation: blink 0.8s steps(2) infinite;
|
||||
}
|
||||
|
||||
.camera {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* LED 屏 */
|
||||
.led-screen {
|
||||
position: relative;
|
||||
height: 56px;
|
||||
height: 48px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle, rgb(255 255 255 / 0.06) 1px, transparent 1.5px) 0 0 / 4px 4px,
|
||||
#0b0b0b;
|
||||
border: 3px solid #222;
|
||||
border: 3px solid #1a1f27;
|
||||
}
|
||||
|
||||
.led-screen-text {
|
||||
.led-text {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
top: 6px;
|
||||
white-space: pre;
|
||||
color: #ff2a2a;
|
||||
text-shadow: 0 0 6px rgb(255 40 40 / 0.8);
|
||||
font-family: "SimHei", "Noto Sans CJK SC", sans-serif;
|
||||
font-size: 34px;
|
||||
line-height: 40px;
|
||||
font-size: 30px;
|
||||
line-height: 36px;
|
||||
animation: led-scroll 12s linear infinite;
|
||||
}
|
||||
|
||||
/* 传感器 */
|
||||
.sensors {
|
||||
display: grid;
|
||||
/* 照明灯、人体红外、温湿度(2 格)、光照(2 格)、烟雾、火焰排成一行 */
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-height: 74px;
|
||||
padding: 6px;
|
||||
background: var(--card);
|
||||
border: 1px solid #4a566a;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card.wide {
|
||||
grid-column: span 2;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.card.toggle {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card.toggle:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.card.toggle.active {
|
||||
border-color: #ff9f43;
|
||||
background: #4a3d33;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-note {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.active .icon {
|
||||
color: #ffb86b;
|
||||
}
|
||||
|
||||
.flame.on {
|
||||
color: #ff5722;
|
||||
filter: drop-shadow(0 0 6px #ff5722);
|
||||
animation: flicker 0.3s alternate infinite;
|
||||
}
|
||||
|
||||
.bulb {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
background: #555f6e;
|
||||
border: 2px solid #6d7889;
|
||||
}
|
||||
|
||||
.bulb.on {
|
||||
background: #ffe066;
|
||||
border-color: #fff3b0;
|
||||
box-shadow: 0 0 18px 6px #ffd43b99;
|
||||
}
|
||||
|
||||
.smoke {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
height: 30px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.smoke i {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #6d7889;
|
||||
}
|
||||
|
||||
.smoke.on i {
|
||||
background: #c9ced6;
|
||||
animation: rise 1.4s ease-in infinite;
|
||||
}
|
||||
|
||||
.smoke.on i:nth-child(2) {
|
||||
animation-delay: 0.45s;
|
||||
}
|
||||
|
||||
.smoke.on i:nth-child(3) {
|
||||
animation-delay: 0.9s;
|
||||
}
|
||||
|
||||
.slider {
|
||||
display: grid;
|
||||
grid-template-columns: 3em 1fr 4.5em;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.slider input {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.slider b {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 模块 */
|
||||
.modules {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1.5fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.module {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
background: #3a4556;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.module-title {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.readings,
|
||||
.io-row,
|
||||
.port-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 10px;
|
||||
}
|
||||
|
||||
.readings b {
|
||||
color: #9fd3ff;
|
||||
}
|
||||
|
||||
.more {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.io {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.io i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #56606f;
|
||||
}
|
||||
|
||||
.io.lit {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.io.lit i {
|
||||
background: #ff9f43;
|
||||
box-shadow: 0 0 6px #ff9f43;
|
||||
}
|
||||
|
||||
.io.out.lit i {
|
||||
background: #51cf66;
|
||||
box-shadow: 0 0 6px #51cf66;
|
||||
}
|
||||
|
||||
.port-list {
|
||||
flex-direction: column;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.link {
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reset {
|
||||
align-self: flex-start;
|
||||
padding: 3px 10px;
|
||||
background: var(--card);
|
||||
border: 1px solid #4a566a;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reset:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
@keyframes led-scroll {
|
||||
from {
|
||||
left: 100%;
|
||||
@@ -57,4 +541,27 @@ defineProps<{ ledText: string }>()
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flicker {
|
||||
to {
|
||||
transform: scale(1.08, 0.94);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateY(-18px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -108,6 +108,26 @@ function selectItem(index: number) {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- CheckBox / RadioButton:自绘勾选框,状态完全由运行器决定(点击只上报,不在浏览器里自行切换) -->
|
||||
<div
|
||||
v-else-if="node.type === 'CheckBox' || node.type === 'RadioButton'"
|
||||
class="wf-control wf-check"
|
||||
:class="{ 'wf-disabled': !enabled, 'wf-autosize': p.AutoSize === true }"
|
||||
:style="style"
|
||||
@click="act({ action: 'click' })"
|
||||
>
|
||||
<span :class="node.type === 'CheckBox' ? 'wf-checkbox-box' : 'wf-radio-dot'" :data-checked="p.Checked === true" />
|
||||
<span class="wf-check-text">{{ displayText(p.Text) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 教材控件库:灯泡、报警灯(图片沿用教材控件库) -->
|
||||
<div v-else-if="node.type === 'WinFormsDeviceControlLibrary.Lamp'" class="wf-control wf-device-image" :style="style">
|
||||
<img :src="p.IsOn ? '/devices/LampOn.png' : '/devices/LampOff.png'" alt="" draggable="false" />
|
||||
</div>
|
||||
<div v-else-if="node.type === 'WinFormsDeviceControlLibrary.Alarm'" class="wf-control wf-device-image" :style="style">
|
||||
<img :src="p.IsOn ? '/devices/AlarmOn.jpg' : '/devices/AlarmOff.jpg'" alt="" draggable="false" />
|
||||
</div>
|
||||
|
||||
<!-- GroupBox -->
|
||||
<div v-else-if="node.type === 'GroupBox'" class="wf-control wf-groupbox" :style="style">
|
||||
<div class="wf-groupbox-frame" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user