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]>
这个提交包含在:
@@ -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'),
|
||||
|
||||
@@ -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 按「字体 → 文字」缓存宽度、从不清空:
|
||||
|
||||
@@ -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>
|
||||
@@ -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; } }
|
||||
|
||||
在新工单中引用
屏蔽一个用户