文件
phonerail/rack/frontend/src/pages/Overview.vue
T
zhou yang和Claude Opus 5.5 830b4cdff8 rack:主板 / 底板功率与功率曲线;底板曲线加电压电流、开机重按事件
- 每路主板功率(V×I):总览槽位、单板卡片、单路曲线新增「功率」一张
- 底板:单板页新增功率仪表(底板输入 / 五路主板合计 / 损耗),底板曲线加
  功率图(输入 vs 主板合计);board_history 每点多回一列 rails_mw
- 底板曲线改为纵向 N 张(温度 / 风扇 / 输入电压 / 输入电流 / 功率),断档断线
- LOG_EV_BOOT_CHECK(19)事件命名,只有「没开起来」标红

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 08:39:47 +08:00

190 行
10 KiB
Vue
原始文件 Blame 文件历史

此文件含有不可见的 Unicode 字符
此文件含有人类无法区分的不可见的 Unicode 字符,但可以由计算机进行不同的处理。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
<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>