- 每路主板功率(V×I):总览槽位、单板卡片、单路曲线新增「功率」一张 - 底板:单板页新增功率仪表(底板输入 / 五路主板合计 / 损耗),底板曲线加 功率图(输入 vs 主板合计);board_history 每点多回一列 rails_mw - 底板曲线改为纵向 N 张(温度 / 风扇 / 输入电压 / 输入电流 / 功率),断档断线 - LOG_EV_BOOT_CHECK(19)事件命名,只有「没开起来」标红 Co-Authored-By: Claude Opus 5.5 <[email protected]>
190 行
10 KiB
Vue
190 行
10 KiB
Vue
<template>
|
||
<div class="ov">
|
||
<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>
|
||
<div class="bw readout">{{ W(rail(b, i)?.mv, rail(b, i)?.ua) }}<small>W</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>
|
||
|
||
<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 class="readout">{{ W(b.status.in?.mv, b.status.in?.ua) }}</span> W
|
||
<span class="k sub">主板</span><span class="readout">{{ railsW(b) }}</span> W</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">
|
||
<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: isSevere(f.ev) }">{{ 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>
|
||
|
||
<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, W, C, ago, fmtTime, railsMw, 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 限功率',
|
||
'上电等不到 PG', '固件升级', '开机重按']
|
||
const SEVERE = new Set([3, 4, 5, 6, 7, 9, 11, 14, 17])
|
||
// 开机重按(19)只有 arg bit7「最终没开起来」才算严重
|
||
const isSevere = ev => SEVERE.has(ev[3]) || (ev[3] === 19 && (ev[4] & 0x80) !== 0)
|
||
const evName = t => EV[t] || `事件 ${t}`
|
||
|
||
const A = ua => (ua == null ? '—' : (ua / 1e6).toFixed(2))
|
||
const railsW = b => { const s = railsMw(b.status?.rails); return s == null ? '—' : (s / 1000).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 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: 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); }
|
||
.bw { font-size: 13px; color: var(--ink-2); line-height: 1.2; }
|
||
.bw small { font-size: 10px; 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, .bay.off .bw, .bay.lost .bw { 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 .k.sub { margin-left: 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: 560px) {
|
||
.bays { gap: 4px; }
|
||
.bay { padding: 6px 5px 5px; min-height: 104px; }
|
||
.bv { font-size: 18px; }
|
||
.bv small { display: none; }
|
||
.bl { display: none; }
|
||
}
|
||
</style>
|