rack:PD 供电信息、每日耗电量与平均功率

PD 供电(板卡页新增一格):
- 优先用遥测里的实时值 status.pd(固件 ≥0.1.17);老固件退回最近一条 PD_LIMIT 事件(快照的 pd)。
  显示 20 V / 档位电流 / 整板功率上限 / 输入功率占上限的比例。
- PD_LIMIT 事件写成「PD 20 V / 3.00 A,上限 60 W」,不再把那两列当电压电流显示;arg 2 = 运行中换档。

耗电量(板卡页新增卡片):
- 新表 energy_day(永久保留):从分钟聚合滚出每日能量与有数据秒数,rail 0 = 底板 12 V 输入,1~5 = 各路主板。
  每分钟重算今天 / 昨天,更早的只补缺不覆盖(分钟数据 90 天后会被清)。按服务器本地零点切日。
- GET /api/boards/{id}/energy;前端 EnergyCard:今日 / 昨日 / 近 7 天 / 近 30 天 / 累计(各带平均功率)、
  每日柱状图、今日各路主板。量的是 12 V 输入,不含 PD 20→12 V 降压损耗。

后端测试 57 项全过;已部署。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
zhou yang
2026-10-06 06:31:04 +08:00
共同撰写人 Claude Opus 5.5
父节点 489cd6a6f3
当前提交 580468e48e
共修改 11 个文件,包含 385 行新增和 8 行删除
+1
查看文件
@@ -26,6 +26,7 @@ export const api = {
req('GET', `/api/boards/${board}/rails/${rail}/history?start=${start}${end ? `&end=${end}` : ''}`),
boardHistory: (board, start, end) =>
req('GET', `/api/boards/${board}/history?start=${start}${end ? `&end=${end}` : ''}`),
energy: (board, days = 90) => req('GET', `/api/boards/${board}/energy?days=${days}`),
events: (q = {}) => req('GET', '/api/events?' + new URLSearchParams(q)),
audit: (q = {}) => req('GET', '/api/audit?' + new URLSearchParams(q)),
recovery: () => req('GET', '/api/recovery'),
+2 -2
查看文件
@@ -6,12 +6,12 @@
// · 文字一律用墨色 / 次墨色,不用系列色;网格线用最浅的细线;
// · 线宽 2px,不画数据点;min~max 画成同色 15% 的带子。
import * as echarts from 'echarts/core'
import { LineChart } from 'echarts/charts'
import { LineChart, BarChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, AxisPointerComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { cssVar, dark } from './theme'
echarts.use([LineChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, AxisPointerComponent, CanvasRenderer])
echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, AxisPointerComponent, CanvasRenderer])
export { echarts }
// 字体是打包进来异步下的,而 zrender 按「字体 → 文字」缓存宽度、从不清空:
+169
查看文件
@@ -0,0 +1,169 @@
<template>
<section class="card chart-card">
<div class="hh">
<h2 class="ttl">耗电量</h2>
<el-radio-group v-model="span" size="small">
<el-radio-button v-for="o in SPANS" :key="o.v" :value="o.v">{{ o.t }}</el-radio-button>
</el-radio-group>
</div>
<div class="tiles">
<div v-for="t in tiles" :key="t.k" class="g" :title="t.tip">
<div class="gh"><span class="gk">{{ t.k }}</span><span class="gs">{{ t.sub }}</span></div>
<div class="gvs"><span class="gv readout">{{ t.e.v }}<small>{{ t.e.u }}</small></span>
<span class="gv sm readout">{{ t.w }}<small>W 平均</small></span></div>
</div>
</div>
<div ref="el" class="chart" style="height: 220px" role="img" aria-label="每日耗电量柱状图" />
<div class="rails" v-if="today">
<span class="muted">今日各路主板</span>
<span v-for="r in railsToday" :key="r.i" class="rl">
<b>#{{ r.i }}</b><template v-if="r.label"> {{ r.label }}</template>
<span class="readout">{{ r.e.v }}</span><small>{{ r.e.u }}</small>
<span class="readout w">{{ r.w }}</span><small>W</small>
</span>
</div>
<p class="muted note">{{ note }}</p>
</section>
</template>
<script setup>
// 每日耗电量:后端把分钟聚合滚成 energy_day(db.py energy_rollup),这里只做单位换算和求和。
// 量的是 12 V 母线上的底板输入(U13),不含 20 → 12 V 那级降压的损耗,墙上实际再多几个点。
// 平均功率 = 能量 ÷ 有数据的时长:板子离线的时段不算进分母,所以它是「开着的时候平均多少瓦」。
// 一个量一种颜色:柱子用调色板第 1 槽;今天还没过完,用同色半透明区分,不另起一种颜色。
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { api } from '../api'
import { state } from '../store'
import { dark } from '../theme'
import { echarts, palette, band, chartFonts } from '../chart'
const props = defineProps({ board: String })
const SPANS = [{ v: 14, t: '14 天' }, { v: 30, t: '30 天' }, { v: 90, t: '90 天' }, { v: 3660, t: '全部' }]
const span = ref(30)
const el = ref(null)
const data = ref(null) // { today, days: [{ day, in_mws, secs, rails }], total }
const note = ref('')
let chart = null
// mW·s → 显示:不到 1 kWh 用 Wh
const fmtE = mws => {
if (mws == null) return { v: '—', u: 'Wh' }
const wh = mws / 3.6e6
return wh >= 1000 ? { v: (wh / 1000).toFixed(2), u: 'kWh' } : { v: wh.toFixed(wh >= 100 ? 0 : 1), u: 'Wh' }
}
const fmtW = (mws, secs) => (mws == null || !secs ? '—' : (mws / secs / 1000).toFixed(2))
const hours = secs => (secs / 3600).toFixed(1)
const byDay = computed(() => Object.fromEntries((data.value?.days || []).map(d => [d.day, d])))
const today = computed(() => byDay.value[data.value?.today] || null)
// 最近 n 个日历日(含今天)的合计
function lastDays(n) {
if (!data.value) return { mws: null, secs: 0 }
const end = new Date(data.value.today + 'T00:00:00')
let mws = null, secs = 0
for (let i = 0; i < n; i++) {
const d = new Date(end); d.setDate(end.getDate() - i)
const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const row = byDay.value[k]
if (row?.in_mws != null) { mws = (mws ?? 0) + row.in_mws; secs += row.secs }
}
return { mws, secs }
}
const tiles = computed(() => {
const mk = (k, x, sub, tip) => ({ k, sub, tip, e: fmtE(x.mws), w: fmtW(x.mws, x.secs) })
const t = lastDays(1), y2 = lastDays(2), w = lastDays(7), m = lastDays(30)
const y = { mws: y2.mws == null || !(y2.secs - t.secs) ? null : y2.mws - (t.mws ?? 0), secs: y2.secs - t.secs }
const tot = data.value?.total
return [
mk('今日', t, t.secs ? `已记 ${hours(t.secs)} h` : '还没有数据', '从今天 0 点到现在,最多滞后两分钟'),
mk('昨日', y, y.secs ? `${hours(y.secs)} h` : ''),
mk('近 7 天', w, w.secs ? `${hours(w.secs)} h` : ''),
mk('近 30 天', m, m.secs ? `${hours(m.secs)} h` : ''),
mk('累计', tot?.days ? tot : { mws: null, secs: 0 }, tot?.since ? `自 ${tot.since.slice(5)} 起 ${tot.days} 天` : ''),
]
})
const railsToday = computed(() => {
const phones = state.boards[props.board]?.phones || []
return (today.value?.rails || []).map((r, i) => ({
i: i + 1, label: phones.find(p => p.rail === i + 1)?.label || '',
e: fmtE(r?.mws), w: fmtW(r?.mws, r?.secs),
}))
})
function render() {
if (!chart || !data.value) return
const p = palette()
const days = data.value.days
const railsSum = d => d.rails.reduce((s, r) => (r ? (s ?? 0) + r.mws : s), null)
chart.setOption({
animation: false,
textStyle: { fontFamily: getComputedStyle(document.body).fontFamily },
grid: { left: 52, right: 16, top: 28, bottom: 28 },
xAxis: { type: 'category', data: days.map(d => d.day.slice(5)), axisLine: { lineStyle: { color: p.axis } },
axisTick: { show: false }, axisLabel: { color: p.muted, fontSize: 11, hideOverlap: true } },
yAxis: { type: 'value', name: '每日耗电 Wh', min: 0, splitNumber: 3,
nameTextStyle: { color: p.ink2, fontSize: 12, align: 'left', padding: [0, 0, 0, -34] },
axisLabel: { color: p.muted, fontSize: 11 }, splitLine: { lineStyle: { color: p.grid } } },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow', shadowStyle: { color: band(p.s1, 0.06) } },
backgroundColor: p.panel, borderColor: p.axis, textStyle: { color: p.ink, fontSize: 12 },
formatter: ps => {
const d = days[ps[0].dataIndex]
const e = fmtE(d.in_mws), r = railsSum(d), re = fmtE(r)
const loss = d.in_mws == null || r == null ? null : fmtE(d.in_mws - r)
return [`<b>${d.day}</b>${d.day === data.value.today ? '(今天,未满)' : ''}`,
`底板输入 ${e.v} ${e.u}`, `平均功率 ${fmtW(d.in_mws, d.secs)} W`,
`五路主板 ${re.v} ${re.u}`, ...(loss ? [`板上损耗 ${loss.v} ${loss.u}`] : []),
`有数据  ${hours(d.secs)} h`].join('<br>')
} },
series: [{ type: 'bar', barMaxWidth: 22, itemStyle: { borderRadius: [2, 2, 0, 0] },
data: days.map(d => ({ value: d.in_mws == null ? null : +(d.in_mws / 3.6e6).toFixed(1),
itemStyle: { color: d.day === data.value.today ? band(p.s1, 0.5) : p.s1 } })) }],
}, true)
}
async function load() {
try {
data.value = await api.energy(props.board, span.value)
note.value = data.value.days.length === 0 ? '还没有耗电记录(板子上线一两分钟后出现)'
: '量的是底板 12 V 输入,不含 PD 20 → 12 V 降压的损耗;平均功率只按有数据的时段算。浅色柱是今天(未满一天)'
render()
} catch (e) {
note.value = '耗电量没取到:' + e.message
}
}
const onResize = () => chart && chart.resize()
let gone = false
let timer = null
onMounted(async () => {
window.addEventListener('resize', onResize)
timer = setInterval(load, 60000)
await chartFonts('每日耗电')
if (gone) return
chart = echarts.init(el.value)
load()
})
onBeforeUnmount(() => { gone = true; clearInterval(timer); window.removeEventListener('resize', onResize); chart && chart.dispose() })
watch(() => [props.board, span.value], load)
watch(dark, render)
</script>
<style scoped>
.tiles { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; margin: 12px 0 4px; }
.g { background: var(--bay); border: 1px solid var(--bay-edge); border-radius: var(--r-bay); padding: 10px 14px 12px;
display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.gh { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 20px; }
.gk { color: var(--muted); font-size: 13px; white-space: nowrap; }
.gs { color: var(--muted); font-size: 11px; text-align: right; min-width: 0; }
.gvs { display: flex; gap: 6px 18px; align-items: baseline; flex-wrap: wrap; }
.gv { font-size: 24px; color: var(--ink); line-height: 1.1; white-space: nowrap; }
.gv.sm { font-size: 17px; color: var(--ink-2); }
.gv small { font-size: 11px; color: var(--muted); margin-left: 3px; font-family: var(--ui); }
.rails { display: flex; flex-wrap: wrap; gap: 4px 20px; align-items: baseline; font-size: 13px; color: var(--ink-2); margin-top: 4px; }
.rl small { color: var(--muted); font-size: 11px; margin: 0 2px; }
.rl b { color: var(--ink); font-weight: 600; margin-right: 4px; }
.rl .readout { margin-left: 4px; }
.rl .w { margin-left: 8px; }
@media (max-width: 1200px) { .tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 700px) { .tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } .gv { font-size: 20px; } }
</style>
+42 -3
查看文件
@@ -31,6 +31,12 @@
<div class="g"><div class="gh"><span class="gk">输入</span></div>
<div class="gvs"><span class="gv readout">{{ V(b.status.in?.mv) }}<small>V</small></span>
<span class="gv readout">{{ mA(b.status.in?.ua) }}<small>mA</small></span></div></div>
<div class="g" :title="PD_TIP"><div class="gh"><span class="gk">PD 供电</span><span class="gs">{{ pdNote }}</span></div>
<div class="gvs" v-if="pd"><span class="gv readout">{{ pd.mv / 1000 }}<small>V</small></span>
<span class="gv readout" :class="{ bad: pd.fallback }">{{ pd.ma == null ? '—' : (pd.ma / 1000).toFixed(2) }}<small>A</small></span>
<span class="gv sm readout">{{ pd.limit_mw / 1000 }}<small>W 上限</small></span>
<span v-if="pdUsed != null" class="gv sm readout" :class="{ bad: pdUsed >= 90 }">{{ pdUsed }}<small>% 已用</small></span></div>
<div class="gvs" v-else><span class="gv readout">—</span></div></div>
<div class="g"><div class="gh"><span class="gk">功率</span><span class="gs">底板输入 · 主板合计 · 损耗</span></div>
<div class="gvs"><span class="gv readout">{{ W(b.status.in?.mv, b.status.in?.ua) }}<small>W</small></span>
<span class="gv sm readout">{{ fmtW(pw.rails) }}<small>W 主板</small></span>
@@ -58,6 +64,8 @@
<BoardChart :board="b.id" />
</div>
<EnergyCard :board="b.id" />
<div class="two">
<section class="card">
<h2 class="ttl">最近事件</h2>
@@ -86,6 +94,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import HistoryChart from '../components/HistoryChart.vue'
import BoardChart from '../components/BoardChart.vue'
import EnergyCard from '../components/EnergyCard.vue'
import OtaState from '../components/OtaState.vue'
import Topology from '../components/Topology.vue'
import { api, rspText } from '../api'
@@ -101,6 +110,36 @@ const pw = computed(() => {
const rails = railsMw(s?.rails)
return { rails, loss: input == null || rails == null ? null : input - rails }
})
// CH224A(U36)协商到的 PD 档位。固件 ≥0.1.17 每秒读一次、放在遥测的 status.pd({st, ma},这一拍没读到是 null);
// 老固件只在启动时读一次、记成「PD 限功率」事件,后端取最近一条放进快照的 b.pd —— 没有实时值时退回它。
// 「已用」拿 12 V 母线上的输入功率比上限,没算 20 → 12 V 那级降压的损耗,实际从充电器取的还要多几个点。
const PD_TIP = 'CH224A(U36)向充电器请求的是固定 20 V 档;电流是充电器在这一档此刻最多肯给的(它广播的 Source Capabilities),' +
'固件按它定整板功率上限(读不到按 60 W)。只有 3 A:充电器本身 ≤65 W、线不是带 E-Marker 的 5 A 线,' +
'或者多口充电器别的口正在用、把这一口降了档 —— 三种情况受电端看到的都一样'
// 0x09 协议状态的各位(CH224A 手册):哪种快充协议握上了
const PD_PROTO = [[0x10, 'EPR'], [0x08, 'PD'], [0x04, 'QC3'], [0x02, 'QC2'], [0x01, 'BC1.2']]
const pd = computed(() => {
const ev = b.value?.pd
const live = b.value?.status?.pd
if (live) {
const ma = live.ma || null
return { mv: 20000, ma, limit_mw: ma ? 20 * ma : ev?.limit_mw ?? 60000, fallback: !ma, live: true,
proto: PD_PROTO.filter(([bit]) => live.st & bit).map(([, n]) => n).join('+') || '未握手' }
}
return ev ? { ...ev, live: false, missed: live === null } : null
})
const pdNote = computed(() => {
const p = pd.value
if (!p) return '还没收到启动事件'
if (p.live) return `实时 · ${p.proto}`
if (p.missed) return 'CH224A 这一拍没读到'
return p.fallback ? 'CH224A 读不到 · 按保守值' : `启动时读 · ${ago(p.ts)}`
})
const pdUsed = computed(() => {
const p = pd.value
const input = mW(b.value?.status?.in?.mv, b.value?.status?.in?.ua)
return !p?.limit_mw || input == null ? null : Math.round(input / p.limit_mw * 100)
})
// 固件 0.1.3 起才有 host 字段;老固件没有 ⇒ 显示「—」
const HOST = { true: '已连电脑', false: '未连电脑' }
const fmtW = v => (v == null ? '—' : (v / 1000).toFixed(2))
@@ -188,8 +227,8 @@ watch(() => props.id, refresh)
.meta div { display: flex; gap: 10px; align-items: center; min-width: 0; }
.meta dt { color: var(--muted); width: 28px; flex-shrink: 0; }
.meta dd { margin: 0; color: var(--ink-2); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
/* 五个仪表格:凹进面板的小槽,标签在上、读数在下 */
.gauges { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; }
/* 六个仪表格:凹进面板的小槽,标签在上、读数在下 */
.gauges { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px; }
.g { background: var(--bay); border: 1px solid var(--bay-edge); border-radius: var(--r-bay); padding: 10px 14px 12px;
display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.gh { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 24px; }
@@ -210,7 +249,7 @@ watch(() => props.id, refresh)
.quiet { color: var(--muted); }
.charts { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: start; }
.two { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; }
@media (max-width: 1500px) { .gauges { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 1700px) { .gauges { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 1200px) { .charts { grid-template-columns: 1fr; }
.gauges { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 800px) { .two { grid-template-columns: 1fr; } }