rack:界面改版(仪器前面板风格 + 暗色模式)+ 底板温度曲线

- 总览:每块板一块面板、五个刀片槽(状态灯 + DIN 读数 + 最近两分钟电流小曲线,平线 = 疑似卡死);
  只有异常 / 故障 / 执行中上色;顶上一句话概况,有问题直接列出是哪几路
- 单板页:新增「底板温度」(两路 NTC + 风扇占空比,上下两块共用时间轴,10 分钟 ~ 7 天);
  每路电流 / 电压去掉双 Y 轴,改上下两块,电压固定 3.5~4.1 V;名称 / 位置就地编辑
- 主题:theme.css 亮 / 暗两套变量(跟随系统,Element Plus 暗色变量同挂 html.dark),图表切换时重画;
  读数用系统自带 DIN 系字体(不加载网络字体);温度两色按本页亮暗底色跑过 dataviz validate_palette.js 全通过
- 后端:board_tele_min 分钟聚合(温度带 min/max,留 90 天)+ GET /api/boards/{id}/history(Db.board_history)
- 修一个偶发失败的测试(test_ota_events_are_named_by_arg:只推一次遥测,赶上 Hub 正在同步时会漏触发);
  后端 42 例本机 + 服务器全绿,已部署;线上已取到板 B 温度 24.4 / 26.0 °C

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
zhou yang
2026-09-27 15:03:24 +08:00
共同撰写人 Claude Opus 5.5
父节点 1cd4fd4082
当前提交 9858b6c98e
共修改 25 个文件,包含 828 行新增和 225 行删除
+145 -76
查看文件
@@ -1,53 +1,77 @@
<template>
<div class="ov">
<section class="boards">
<el-empty v-if="!boards.length" description="还没有板子上线。新板子经 USB 配好 wifi / mqtt 之后会自动出现在这里。" />
<router-link v-for="b in boards" :key="b.id" :to="`/board/${b.id}`" class="card board">
<div class="bh">
<div>
<div class="bname">{{ b.name || b.id }}</div>
<div class="muted">{{ b.location || '未填位置' }} · {{ b.fw }}</div>
</div>
<el-tag v-if="!b.online || b.stale" type="info" size="small">通信中断 · {{ ago(b.last_tele) }}</el-tag>
<el-tag v-else type="success" size="small">在线</el-tag>
</div>
<div class="slots">
<div v-for="i in 5" :key="i" class="slot" :style="{ borderColor: color(b, i) }">
<div class="sh">
<span class="sn">{{ i }}</span>
<span class="lbl">{{ phone(b, i).label }}</span>
<el-icon v-if="phone(b, i).auto_recover" title="已开自动恢复"><Refresh /></el-icon>
<div class="main">
<p class="summary" v-if="boards.length">
<template v-if="attention.length">
<span class="bad">{{ attention.length }} 路需要看:</span>
<router-link v-for="a in attention" :key="a.key" :to="`/board/${a.board}`" class="att">
{{ a.name }} #{{ a.rail }} {{ a.text }}
</router-link>
</template>
<template v-else>{{ tally }}</template>
</p>
<el-empty v-if="!boards.length" description="还没有板子上线。新板子经 USB 配好 WiFi 和 MQTT 之后会自动出现在这里。" />
<section class="boards">
<router-link v-for="b in boards" :key="b.id" :to="`/board/${b.id}`" class="card panel">
<header class="ph">
<div class="pn">
<span :class="['led', !b.online || b.stale ? 'lost' : 'ok']" />
<b>{{ b.name || b.id }}</b>
<span class="muted loc">{{ b.location || '没填位置' }}</span>
</div>
<span v-if="!b.online || b.stale" class="muted">通信中断,最后数据 {{ ago(b.last_tele) }}</span>
<span v-else class="muted fw">{{ b.status?.fw || b.fw }}</span>
</header>
<div class="bays">
<div v-for="i in 5" :key="i" :class="['bay', health(b, i).level]">
<div class="bt">
<span class="bn readout">{{ i }}</span>
<span :class="['led', health(b, i).level]" />
<el-icon v-if="phone(b, i).auto_recover" class="ar" title="已开自动恢复"><Refresh /></el-icon>
</div>
<div class="bl" :title="phone(b, i).label">{{ phone(b, i).label || ' ' }}</div>
<template v-if="rail(b, i)?.pr">
<div class="bv readout">{{ mA(rail(b, i)?.ua) }}<small>mA</small></div>
<Spark :board="b.id" :rail="i" :color="loud(b, i) ? LEVEL_COLOR[health(b, i).level] : 'var(--ink-2)'" />
</template>
<div class="bs" :style="{ color: loud(b, i) ? LEVEL_COLOR[health(b, i).level] : 'var(--muted)' }">{{ health(b, i).text }}</div>
</div>
<div class="val" :style="{ color: color(b, i) }">{{ health(b, i).text }}</div>
<div class="num" v-if="rail(b, i)?.pr">{{ mA(rail(b, i)?.ua) }}<small> mA</small></div>
</div>
</div>
<div class="bf muted" v-if="b.status">
输入 {{ V(b.status.in?.mv) }} V / {{ mA(b.status.in?.ua) }} mA ·
温度 {{ temps(b) }} ·
风扇 {{ b.status.fan?.duty / 10 }}%
<span v-if="b.status.fan?.stall?.some(x => x)" class="bad"> · 风扇停转</span>
<span v-if="b.status.derate" class="bad"> · 过温</span>
</div>
</router-link>
</section>
<footer class="pf" v-if="b.status">
<div><span class="k">输入</span><span class="readout">{{ V(b.status.in?.mv) }}</span> V
<span class="readout">{{ A(b.status.in?.ua) }}</span> A</div>
<div><span class="k">温度</span>
<span v-for="(t, k) in b.status.temp || []" :key="k" class="readout t">{{ t == null ? '—' : C(t) }}</span> °C</div>
<div><span class="k">风扇</span><span class="readout">{{ (b.status.fan?.duty ?? 0) / 10 }}</span> %
<span v-if="b.status.fan?.stall?.some(x => x)" class="bad">停转</span></div>
<div v-if="b.status.derate" class="bad">过温降载</div>
</footer>
</router-link>
</section>
</div>
<aside class="card feed">
<div class="fh">实时动态</div>
<div v-if="!state.feed.length" class="muted">暂无。事件、操作、自动恢复都会出现在这里。</div>
<div v-for="(f, k) in state.feed" :key="k" class="fi">
<span class="muted">{{ fmtTime(f.ts) }}</span>
<span class="who">{{ boardName(f.board) }}</span>
<template v-if="f.kind === 'event'">
<span :class="{ bad: SEVERE.has(f.ev[3]) }">{{ f.ev[2] ? `#${f.ev[2]} ` : '' }}{{ evName(f.ev[3]) }}</span>
</template>
<template v-else-if="f.kind === 'audit'">
<span>{{ f.row.user }}:#{{ f.row.rail ?? '' }} {{ f.row.request.cmd }} → {{ rspText(f.row.response) }}</span>
</template>
<template v-else>
<span class="warn">自动恢复 #{{ f.row.rail }}:{{ RECOVERY_ZH[f.row.state] }}({{ REASON_ZH[f.row.reason] || f.row.reason }})</span>
</template>
</div>
<h2 class="ttl">实时动态</h2>
<div v-if="!state.feed.length" class="muted">事件、操作和自动恢复会实时出现在这里。</div>
<ol class="fl">
<li v-for="(f, k) in state.feed" :key="k">
<time class="muted readout">{{ fmtTime(f.ts) }}</time>
<span class="who">{{ boardName(f.board) }}</span>
<template v-if="f.kind === 'event'">
<span :class="{ bad: SEVERE.has(f.ev[3]) }">{{ f.ev[2] ? `#${f.ev[2]} ` : '' }}{{ evName(f.ev[3]) }}</span>
</template>
<template v-else-if="f.kind === 'audit'">
<span>{{ f.row.user }} 对 #{{ f.row.rail ?? '' }} 执行 {{ f.row.request.cmd }}:{{ rspText(f.row.response) }}</span>
</template>
<template v-else>
<span class="warn">自动恢复 #{{ f.row.rail }}:{{ RECOVERY_ZH[f.row.state] }}({{ REASON_ZH[f.row.reason] || f.row.reason }})</span>
</template>
</li>
</ol>
</aside>
</div>
</template>
@@ -55,57 +79,102 @@
<script setup>
import { computed } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import Spark from '../components/Spark.vue'
import { state } from '../store'
import { rspText } from '../api'
import { mA, V, C, ago, fmtTime, railHealth, LEVEL_COLOR, RECOVERY_ZH, REASON_ZH } from '../util'
// 与 backend/prrack/protocol.py 的 EVENT_TYPES / EVENT_SEVERE 同一张表(数字来自固件 evlog.h)
const EV = ['', '板子上电', '事件丢失', '过压关路', '欠压关路', '反灌断 VBUS', '顶高断 VBUS', 'eFuse 故障',
'PG 变化', 'I²C 失联', '过温降载', '风扇停转', '重启序列', '命令被拒', 'mux 失联全断', '刀片不在位', 'PD 限功率']
const SEVERE = new Set([3, 4, 5, 6, 7, 9, 11, 14])
'PG 变化', 'I²C 失联', '过温降载', '风扇停转', '重启序列', '命令被拒', 'mux 失联全断', '刀片不在位', 'PD 限功率',
'上电等不到 PG', '固件升级']
const SEVERE = new Set([3, 4, 5, 6, 7, 9, 11, 14, 17])
const evName = t => EV[t] || `事件 ${t}`
const A = ua => (ua == null ? '—' : (ua / 1e6).toFixed(2))
const boards = computed(() => Object.values(state.boards).sort((a, b) =>
(a.location + a.name + a.id).localeCompare(b.location + b.name + b.id)))
const rail = (b, i) => b.status?.rails?.[i - 1]
const phone = (b, i) => b.phones?.find(p => p.rail === i) || {}
const health = (b, i) => railHealth(b, rail(b, i))
const color = (b, i) => LEVEL_COLOR[health(b, i).level]
const temps = b => (b.status.temp || []).map(t => (t == null ? '—' : C(t) + '°')).join(' / ')
const boardName = id => state.boards[id]?.name || id
// 只有要人看的状态(异常 / 故障 / 正在执行)才上色;「运行」「断电」「空槽」安静 —— 灯已经说了
const loud = (b, i) => ['bad', 'warn', 'busy'].includes(health(b, i).level)
// 需要人看的:异常 / 故障 / 读不到。断电、空槽、通信中断是「状态」,不是「告警」,只进计数。
const attention = computed(() => {
const out = []
for (const b of boards.value) {
for (let i = 1; i <= 5; i++) {
const h = health(b, i)
if (h.level === 'bad' || h.level === 'warn') {
out.push({ key: `${b.id}/${i}`, board: b.id, name: b.name || b.id, rail: i, text: h.text })
}
}
}
return out
})
const tally = computed(() => {
const n = { ok: 0, busy: 0, off: 0, empty: 0, lost: 0 }
for (const b of boards.value) for (let i = 1; i <= 5; i++) n[health(b, i).level] = (n[health(b, i).level] || 0) + 1
const parts = [`${n.ok} 台在跑`]
if (n.busy) parts.push(`${n.busy} 台正在执行动作`)
if (n.off) parts.push(`${n.off} 台断电`)
if (n.empty) parts.push(`${n.empty} 个空槽`)
if (n.lost) parts.push(`${n.lost} 路通信中断`)
return parts.join(',') + '。'
})
</script>
<style scoped>
.ov { display: grid; grid-template-columns: 1fr 320px; gap: 16px; align-items: start; }
.boards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(420px, 100%), 1fr)); gap: 16px; }
.board { display: block; color: inherit; text-decoration: none; transition: box-shadow .15s; }
.board:hover { box-shadow: 0 4px 16px rgba(15, 23, 42, .08); }
.bh { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
.bname { font-weight: 600; font-size: 16px; }
.slots { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; }
.slot { border: 2px solid; border-radius: 8px; padding: 8px 6px; min-height: 86px; }
.sh { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); }
.sn { font-weight: 700; color: #0f172a; }
.lbl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.val { font-size: 13px; font-weight: 600; margin-top: 6px; }
.num { font-size: 18px; font-weight: 600; margin-top: 2px; font-variant-numeric: tabular-nums; }
.num small { font-size: 11px; color: var(--muted); font-weight: 400; }
.bf { margin-top: 10px; }
.bad { color: #dc2626; } .warn { color: #d97706; }
.feed { position: sticky; top: 68px; max-height: calc(100vh - 90px); overflow: auto; }
.fh { font-weight: 600; margin-bottom: 8px; }
.fi { font-size: 13px; padding: 6px 0; border-bottom: 1px dashed var(--line); display: flex; gap: 6px; flex-wrap: wrap; }
.who { color: #334155; font-weight: 500; }
@media (max-width: 1000px) {
.ov { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 20px; align-items: start; }
.summary { margin: 0 0 14px; color: var(--ink-2); font-size: 15px; display: flex; flex-wrap: wrap; gap: 4px 10px; }
.att { color: var(--st-bad); text-decoration: underline; text-underline-offset: 3px; }
.boards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(460px, 100%), 1fr)); gap: 16px; }
.panel { display: flex; flex-direction: column; gap: 12px; color: inherit; text-decoration: none; padding: 14px; }
.panel:hover { border-color: var(--muted); }
.ph { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
.pn { display: flex; align-items: center; gap: 8px; min-width: 0; }
.pn b { font-size: 16px; }
.loc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fw { white-space: nowrap; }
/* 刀片槽:凹进去的一格,顶上一条状态色的「灯条」 */
.bays { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; }
.bay { background: var(--bay); border: 1px solid var(--bay-edge); border-radius: var(--r-bay);
padding: 8px 8px 6px; display: flex; flex-direction: column; gap: 2px; min-height: 118px;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, .06); }
.bay.empty { background: transparent; border-style: dashed; box-shadow: none; }
.bt { display: flex; align-items: center; gap: 6px; }
.bn { font-size: 15px; font-weight: 700; color: var(--ink-2); }
.ar { margin-left: auto; color: var(--muted); font-size: 13px; }
.bl { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bv { font-size: 24px; line-height: 1.1; color: var(--ink); margin-top: 2px; }
.bv small { font-size: 11px; color: var(--muted); margin-left: 2px; font-family: var(--ui); }
.bs { font-size: 12px; font-weight: 600; margin-top: auto; }
.bay.off .bv, .bay.lost .bv { color: var(--muted); }
.pf { display: flex; flex-wrap: wrap; gap: 4px 18px; font-size: 13px; color: var(--ink-2);
border-top: 1px solid var(--line-soft); padding-top: 10px; }
.pf .k { color: var(--muted); margin-right: 6px; }
.pf .readout { font-size: 15px; color: var(--ink); }
.pf .t + .t::before { content: '/'; margin: 0 4px; color: var(--muted); }
.feed { position: sticky; top: 74px; max-height: calc(100vh - 94px); overflow: auto; }
.fl { list-style: none; margin: 0; padding: 0; }
.fl li { font-size: 13px; padding: 7px 0; border-bottom: 1px solid var(--line-soft); display: flex; gap: 6px; flex-wrap: wrap; }
.fl time { font-size: 12px; }
.who { color: var(--ink); font-weight: 500; }
@media (max-width: 1100px) {
.ov { grid-template-columns: 1fr; }
.feed { position: static; max-height: 360px; }
}
@media (max-width: 520px) {
.boards { grid-template-columns: 1fr; }
.slots { gap: 4px; }
.slot { padding: 5px 3px; min-height: 72px; }
.val { font-size: 12px; }
.num { font-size: 14px; }
.num small { display: none; }
@media (max-width: 560px) {
.bays { gap: 4px; }
.bay { padding: 6px 5px 5px; min-height: 104px; }
.bv { font-size: 18px; }
.bv small { display: none; }
.bl { display: none; }
}
</style>