911 lines
24 KiB
Vue
911 lines
24 KiB
Vue
<script setup lang="ts">
|
||
// 虚拟实训台:按教材实训台布局图(顶棚 → LED 屏 → 传感器 → 采集模块)排列。
|
||
// 传感器卡片上的滑块和开关调节环境(经服务器推给学生程序),灯和继电器的状态由学生程序控制。
|
||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||
import { ANALOG_INFO, clockWall, type AnalogKey, type BoardState, type ClientMessage, type DigitalKey } from '@iot/shared'
|
||
|
||
type BoardInput = Omit<Extract<ClientMessage, { type: 'boardInput' }>, 'type'>
|
||
|
||
/** playing:学生程序正在播放的声音(媒体播放器),广播喇叭亮起 */
|
||
const props = defineProps<{ board: BoardState | null; playing?: { name: string } | 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 COLLAPSED_KEY = 'iot-sim:board-collapsed'
|
||
const collapsed = ref(readCollapsed())
|
||
|
||
function readCollapsed() {
|
||
try {
|
||
return localStorage.getItem(COLLAPSED_KEY) === '1'
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function setCollapsed(value: boolean) {
|
||
collapsed.value = value
|
||
try {
|
||
localStorage.setItem(COLLAPSED_KEY, value ? '1' : '0')
|
||
} catch {
|
||
// 浏览器禁用本地存储时只是记不住,不影响使用
|
||
}
|
||
}
|
||
|
||
// 拖动滑块时先显示本地值,避免服务器回传前滑块跳动;发送做节流
|
||
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 = {}
|
||
}
|
||
|
||
// 松开后继续显示本地值,等服务器回传了这个值再用服务器的:键盘方向键连按时每按一下都会触发 change,
|
||
// 立刻换回服务器的旧值,下一下就从旧值开始加,看起来像丢了几步。服务器没回传同样的值(比如被截到范围内)时 1 秒后放弃
|
||
const releasing = new Map<AnalogKey, ReturnType<typeof setTimeout>>()
|
||
|
||
function release(key: AnalogKey) {
|
||
flush()
|
||
clearTimeout(releasing.get(key))
|
||
releasing.set(
|
||
key,
|
||
setTimeout(() => drop(key), 1000),
|
||
)
|
||
}
|
||
|
||
function drop(key: AnalogKey) {
|
||
clearTimeout(releasing.get(key))
|
||
releasing.delete(key)
|
||
const { [key]: _, ...rest } = dragging.value
|
||
dragging.value = rest
|
||
}
|
||
|
||
watch(
|
||
() => props.board?.analog,
|
||
(values) => {
|
||
for (const key of [...releasing.keys()]) if (values?.[key] === dragging.value[key]) drop(key)
|
||
},
|
||
)
|
||
onBeforeUnmount(() => releasing.forEach((timer) => clearTimeout(timer)))
|
||
|
||
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)
|
||
/** ADAM-4150 DO1 接的照明灯(教材项目 7 的智慧教室、寝室、会议室用它) */
|
||
const roomLampOn = computed(() => relays.value[1] === true)
|
||
const ledText = computed(() => props.board?.ledText ?? '')
|
||
const camera = computed(() => props.board?.camera ?? { on: false, x: 3, y: 1 })
|
||
// ---------- 虚拟时钟:显示学生程序看到的时间,可以拨时间、调倍速 ----------
|
||
const SPEEDS = [1, 10, 60]
|
||
const tick = ref(Date.now())
|
||
const ticker = setInterval(() => (tick.value = Date.now()), 250)
|
||
onBeforeUnmount(() => clearInterval(ticker))
|
||
|
||
const clock = computed(() => props.board?.clock ?? null)
|
||
/** 现在的墙上时间 yyyy-MM-ddTHH:mm:ss:拨过时间时按虚拟时钟算,否则是本机时间 */
|
||
const wall = computed(() => {
|
||
if (clock.value) return clockWall(clock.value, tick.value)
|
||
const d = new Date(tick.value)
|
||
const pad = (n: number) => String(n).padStart(2, '0')
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||
})
|
||
const clockDate = ref('')
|
||
const clockTime = ref('')
|
||
|
||
function openClockEditor() {
|
||
clockDate.value = wall.value.slice(0, 10)
|
||
clockTime.value = wall.value.slice(11, 19)
|
||
}
|
||
|
||
function setClock() {
|
||
if (!clockDate.value || !clockTime.value) return
|
||
const time = clockTime.value.length === 5 ? `${clockTime.value}:00` : clockTime.value
|
||
emit('set', { clock: { time: `${clockDate.value}T${time}` } })
|
||
}
|
||
|
||
function setSpeed(speed: number) {
|
||
emit('set', { clock: { speed } })
|
||
}
|
||
|
||
const cameraTitle = computed(() =>
|
||
camera.value.on ? `网络摄像头:正在传送画面(云台左右 ${camera.value.x + 1}/7,上下 ${camera.value.y + 1}/3)` : '网络摄像头(项目 4 使用):未开启',
|
||
)
|
||
</script>
|
||
|
||
<template>
|
||
<!-- 收起时:一行状态,传感器开关仍然可以点 -->
|
||
<div v-if="collapsed" class="board collapsed" :class="{ disconnected: !board }">
|
||
<span class="board-name">虚拟实训台</span>
|
||
<span class="io out warn" :class="{ lit: alarmOn }"><i />警示灯</span>
|
||
<span class="io out" :class="{ lit: streetLampOn }"><i />照明灯 1</span>
|
||
<span class="io out" :class="{ lit: roomLampOn }"><i />照明灯 2</span>
|
||
<span class="summary-readings">
|
||
<span v-for="key in ['temperature', 'humidity', 'light'] as AnalogKey[]" :key="key">
|
||
{{ ANALOG_INFO[key].label }} <b>{{ analog(key) }}{{ ANALOG_INFO[key].unit }}</b>
|
||
</span>
|
||
</span>
|
||
<button
|
||
v-for="input in DIGITAL_INPUTS"
|
||
:key="input.key"
|
||
class="io chip"
|
||
:class="{ lit: digital[input.key] }"
|
||
:disabled="!board"
|
||
:title="`点击切换${input.label}`"
|
||
@click="toggle(input.key)"
|
||
>
|
||
<i />{{ input.label }}
|
||
</button>
|
||
<span v-if="ledText" class="summary-led" :title="ledText">LED:{{ ledText }}</span>
|
||
<span v-if="clock" class="summary-clock" title="虚拟时钟:学生程序里的 DateTime.Now">🕒 {{ wall.slice(11) }}({{ clock.speed }}×)</span>
|
||
<button class="link expand" @click="setCollapsed(false)">展开 ▾</button>
|
||
</div>
|
||
|
||
<div v-else class="board" :class="{ disconnected: !board }">
|
||
<div class="board-header">
|
||
<span class="board-name">虚拟实训台</span>
|
||
<button class="link" title="收起后只显示一行状态,给窗体留出更多地方" @click="setCollapsed(true)">收起 ▴</button>
|
||
</div>
|
||
|
||
<!-- 顶棚:红外对射、警示灯(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" :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')">红外对射</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>
|
||
|
||
<div class="card" :title="playing ? `广播正在播放 ${playing.name}` : '广播(教材项目 7 用 Windows Media Player 播放铃声)'">
|
||
<svg viewBox="0 0 24 24" width="30" height="30" class="icon speaker" :class="{ on: playing }">
|
||
<path fill="currentColor" d="M4 9h4l5-4v14l-5-4H4z" />
|
||
<path
|
||
v-if="playing"
|
||
d="M16 8.5a5 5 0 0 1 0 7M18.5 6a8.5 8.5 0 0 1 0 12"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="1.8"
|
||
stroke-linecap="round"
|
||
/>
|
||
</svg>
|
||
<div class="card-title">广播</div>
|
||
<div class="card-note">{{ playing ? `播放 ${playing.name}` : '静音' }}</div>
|
||
</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 class="card" title="照明灯 2:由 ADAM-4150 的 DO1 控制(教材项目 7 的教室、寝室、会议室照明)">
|
||
<div class="bulb" :class="{ on: roomLampOn }" />
|
||
<div class="card-title">照明灯 2</div>
|
||
<div class="card-note">ADAM-4150 DO1</div>
|
||
</div>
|
||
</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>
|
||
|
||
<!-- 虚拟时钟:学生程序里的 DateTime.Now 跟着它走 -->
|
||
<div class="clock-strip" :class="{ virtual: clock }">
|
||
<span class="module-title">时钟</span>
|
||
<b class="clock-time">{{ wall.slice(11) }}</b>
|
||
<span class="clock-date">{{ wall.slice(0, 10) }}</span>
|
||
<span class="clock-tag">{{ clock ? `虚拟时间 · ${clock.speed}×` : '真实时间' }}</span>
|
||
<span class="spacer" />
|
||
<input v-model="clockDate" type="date" :disabled="!board" @focus="!clockDate && openClockEditor()" />
|
||
<input v-model="clockTime" type="time" step="1" :disabled="!board" @focus="!clockTime && openClockEditor()" />
|
||
<button class="chip-button" :disabled="!board || !clockDate || !clockTime" title="把学生程序看到的时间拨到这个时刻" @click="setClock">拨到</button>
|
||
<span class="speeds" title="倍速:60× 时真实的 1 秒是 1 分钟">
|
||
<button
|
||
v-for="speed in SPEEDS"
|
||
:key="speed"
|
||
class="chip-button"
|
||
:class="{ active: (clock?.speed ?? 1) === speed && (clock !== null || speed === 1) }"
|
||
:disabled="!board"
|
||
@click="setSpeed(speed)"
|
||
>
|
||
{{ speed }}×
|
||
</button>
|
||
</span>
|
||
<button v-if="clock" class="chip-button" :disabled="!board" @click="emit('set', { clock: null })">恢复真实时间</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.board {
|
||
--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;
|
||
color: var(--text);
|
||
font-size: 12px;
|
||
user-select: none;
|
||
/* 布局按实训台自身宽度调整(打开设计器时右栏会变窄) */
|
||
container-type: inline-size;
|
||
}
|
||
|
||
.board-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin: -2px 0 -4px;
|
||
}
|
||
|
||
.board-name {
|
||
color: var(--muted);
|
||
}
|
||
|
||
.board.collapsed {
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 4px 12px;
|
||
padding: 6px 10px;
|
||
}
|
||
|
||
.summary-readings {
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
|
||
.summary-readings b {
|
||
color: #9fd3ff;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.chip {
|
||
padding: 1px 6px;
|
||
background: var(--card);
|
||
border: 1px solid #4a566a;
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.chip:hover:not(:disabled) {
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.summary-led {
|
||
min-width: 0;
|
||
max-width: 16em;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
color: #ff6b6b;
|
||
}
|
||
|
||
.expand {
|
||
margin-left: auto;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
.camera.on {
|
||
color: #4cd964;
|
||
border-color: #4cd96488;
|
||
box-shadow: 0 0 10px 2px #4cd96444;
|
||
}
|
||
|
||
/* LED 屏 */
|
||
.led-screen {
|
||
position: relative;
|
||
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 #1a1f27;
|
||
}
|
||
|
||
.led-text {
|
||
position: absolute;
|
||
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: 30px;
|
||
line-height: 36px;
|
||
animation: led-scroll 12s linear infinite;
|
||
}
|
||
|
||
/* 传感器:中等宽度时 4 个开关卡片一行、两个滑块卡片一行;够宽时按实训台顺序排成一行;更窄时每行 2 格 */
|
||
.sensors {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 8px;
|
||
}
|
||
|
||
.sensors .wide {
|
||
order: 1;
|
||
}
|
||
|
||
@container (min-width: 1100px) {
|
||
.sensors {
|
||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||
}
|
||
|
||
.sensors .wide {
|
||
order: 0;
|
||
}
|
||
}
|
||
|
||
@container (max-width: 560px) {
|
||
.sensors {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
.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: 4.5em minmax(60px, 1fr) 4.5em;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.slider span {
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.slider input {
|
||
width: 100%;
|
||
accent-color: var(--accent);
|
||
}
|
||
|
||
.slider b {
|
||
text-align: right;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
/* 模块 */
|
||
.clock-strip {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 6px 8px;
|
||
padding: 6px 8px;
|
||
background: #3a4556;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.clock-strip.virtual {
|
||
box-shadow: inset 0 0 0 1px #f5a623;
|
||
}
|
||
|
||
.clock-time {
|
||
font-size: 16px;
|
||
font-variant-numeric: tabular-nums;
|
||
color: #fff;
|
||
}
|
||
|
||
.clock-date {
|
||
color: var(--muted);
|
||
}
|
||
|
||
.clock-tag {
|
||
color: var(--muted);
|
||
}
|
||
|
||
.virtual .clock-tag {
|
||
color: #f5a623;
|
||
}
|
||
|
||
.clock-strip .spacer {
|
||
flex: 1;
|
||
}
|
||
|
||
.clock-strip input {
|
||
background: #2b3340;
|
||
color: var(--text);
|
||
border: 1px solid #556175;
|
||
border-radius: 3px;
|
||
padding: 1px 4px;
|
||
font: inherit;
|
||
color-scheme: dark;
|
||
}
|
||
|
||
.chip-button {
|
||
background: #2b3340;
|
||
color: var(--text);
|
||
border: 1px solid #556175;
|
||
border-radius: 3px;
|
||
padding: 1px 8px;
|
||
font: inherit;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.chip-button.active {
|
||
border-color: var(--accent);
|
||
color: #fff;
|
||
background: #2f5f99;
|
||
}
|
||
|
||
.chip-button:disabled {
|
||
opacity: 0.5;
|
||
cursor: default;
|
||
}
|
||
|
||
.speeds {
|
||
display: inline-flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.speaker {
|
||
color: var(--muted);
|
||
}
|
||
|
||
.speaker.on {
|
||
color: #4cd964;
|
||
animation: blink 0.8s steps(2) infinite;
|
||
}
|
||
|
||
.summary-clock {
|
||
color: #f5a623;
|
||
}
|
||
|
||
.modules {
|
||
display: grid;
|
||
grid-template-columns: 1.2fr 1.5fr 1fr;
|
||
gap: 8px;
|
||
}
|
||
|
||
/* 窄时串口服务器单独占一行,四个串口横排 */
|
||
@container (max-width: 760px) {
|
||
.modules {
|
||
grid-template-columns: 1fr 1fr;
|
||
}
|
||
|
||
.ports {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.ports .port-list {
|
||
flex-direction: row;
|
||
}
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.io.warn.lit i {
|
||
background: #ff3b30;
|
||
box-shadow: 0 0 6px #ff3b30;
|
||
}
|
||
|
||
.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%;
|
||
transform: translateX(0);
|
||
}
|
||
to {
|
||
left: 0;
|
||
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>
|