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]>
这个提交包含在:
+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>
|
||||
|
||||
在新工单中引用
屏蔽一个用户