From c1b44b6e23299b485eab031e079338757277bd35 Mon Sep 17 00:00:00 2001 From: 12451 <12451@example.com> Date: Mon, 21 Sep 2026 00:25:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E8=83=BD=E8=80=97=E9=A1=B5?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E3=80=8C=E6=95=B4=E6=9F=9C=E6=97=A5=E7=94=A8?= =?UTF-8?q?=E7=94=B5=E9=87=8F=EF=BC=88=E7=94=B5=E8=A1=A8=E8=AF=BB=E6=95=B0?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=EF=BC=89=E3=80=8D=E5=8C=BA=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 `GET /api/cabinets/{id}/meter-usage` 上一提交已上线,本提交把它接到既有的 能耗管理页(`/energy`)—— 用户指出已有能源统计页面,无需新建页面。 ## 区块内容 - 柜子下拉(复用 `/cabinets/options`,支持搜索)+ 表格(日期 / 电表末读数 / 当日用电 / 数据质量) - 分页 15 条/页,未选柜子时提示"请先选择柜子"(而不是显示空表格) - 表格下方一行区间合计(kWh),并注明"无基准与电表更换日不计入" ## ⛔ 与页面既有区块的区别(必须让用户看见,不能混) 区块顶部加了一条 Alert 常驻说明: > 本区块是电表读数口径(整柜实际用电,硬件计量、含非充电损耗); > 上方卡片与排行是订单电量口径(平台按 V×I×Δt 积分的估算值)。 > 两者数值必然不一致,不可互相替代。 ## 三个语义坑在前端的落地(与后端 flags 一一对应) | flags | 前端渲染 | |---|---| | `no_baseline` | 当日用电显示 **`-`**(**不是 0**) | | `covers_multiple_days` | 数据质量列显示"覆盖多日"(warning 色) | | `meter_reset_or_replaced` | 当日用电显示 **`-`**,数据质量列显示"电表更换/清零"(danger 色) | 数据质量列正常时显示"正常"。`flags` 与文案的映射收在 `METER_FLAG_META` 常量表里, **新增 flag 必须同步该表**(否则会渲染成空白标签)。 ## 验证 - `npm run typecheck`:**exit 0** - **自证能失败**:植入未定义变量 `meterFlagNoSuchVar` → `TS2304` / **exit 2**;还原 → exit 0 > 附:第一次自证我选错了判据(把接口的 `usage_wh?` 改成必填),typecheck 不报错 —— > 因为接口数据源是 `any`,类型由我自己声明的接口决定,改必填确实不冲突。 > 换成编译期必错的未定义变量后才验证成功。**是判据选错,不是工具假绿。** - `npm run build`:exit 0(产物 572 KB / 6 个文件) - 后端全量验收仍绿:`cargo check` 0 告警、`cargo test` **204 passed**、 `device_status_parse` 16/16、`rssi_signal` 9/9、结构漂移无 - **端到端**(临时超管账号 + 自签 JWT,用完即删):造 5 条读数打真实 HTTPS, 断言前端渲染所需的每个字段都在、`flags` 取值与 `METER_FLAG_META` 的 key 一一对应、 三个语义坑全部生效、汇总 500 正确 ⇒ **exit 0** - 已发布:测试 + 生产(web 产物时间戳与本地构建一致 `00:19:40`, 生产 0 ERROR、域名 401/200;测试环境 nginx reload 后三服务 active) --- software/web/src/pages/energy/index.tsx | 200 ++++++++++++++++++++++++ 1 file changed, 200 insertions(+) diff --git a/software/web/src/pages/energy/index.tsx b/software/web/src/pages/energy/index.tsx index b3c7176..cf10584 100644 --- a/software/web/src/pages/energy/index.tsx +++ b/software/web/src/pages/energy/index.tsx @@ -15,6 +15,7 @@ import { Space, Select, Grid, + Alert, Spin, Message } from '@arco-design/web-react'; import { IconDownload } from '@arco-design/web-react/icon'; @@ -77,6 +78,61 @@ interface TrendData { list: TrendPoint[]; } +/** 柜子下拉选项 */ +interface CabinetOption { + id: number; + name?: string; + abstract_id?: string; +} + +/** + * 电表口径日用电量行 + * + * ⛔ 与页面其它区块**不是一套数据**:其它区块是 `energy-stats`(订单电量口径, + * 平台按 V×I×Δt 积分的估算值);这里是 `meter-usage`(**电表读数**口径, + * 硬件计量、含非充电损耗)。两者数值必然不一致,谁也不替代谁。 + */ +interface MeterUsageDay { + day: string; + end_reading: number; + /** 当日用电量(Wh)。`undefined` = 无对比基准(该柜第一条读数) */ + usage_wh?: number; + /** 数据质量标记,前端据此决定怎么展示 */ + flags: MeterUsageFlag[]; +} + +/** 数据质量标记(与后端 `UsageFlag` 枚举一一对应) */ +type MeterUsageFlag = + | 'no_baseline' + | 'covers_multiple_days' + | 'meter_reset_or_replaced'; + +/** 电表口径汇总 */ +interface MeterUsageSummary { + total_wh: number; + counted_days: number; + days_with_data: number; +} + +/** 电表口径响应 */ +interface MeterUsageData { + cabinet_id: number; + start: string; + end: string; + days: MeterUsageDay[]; + summary: MeterUsageSummary; +} + +/** flags → 人类可读说明 + 展示色调 */ +const METER_FLAG_META: Record = { + // 该柜第一条读数,无对比基准 ⇒ 不是 0 度,是"不知道" + no_baseline: { text: '无基准', color: 'var(--color-text-3)' }, + // 柜子离线了几天,本行差值覆盖多天 + covers_multiple_days: { text: '覆盖多日', color: 'var(--color-warning-6)' }, + // 读数变小 = 换表 / 清零,差值不是用电量 + meter_reset_or_replaced: { text: '电表更换/清零', color: 'var(--color-danger-6)' }, +}; + export default function Energy() { // 总览 const [summary, setSummary] = useState(null); @@ -87,6 +143,11 @@ export default function Energy() { // 趋势 const [trendData, setTrendData] = useState([]); const [granularity, setGranularity] = useState('day'); + // 电表口径(整柜日用电量) + const [cabinetOptions, setCabinetOptions] = useState([]); + const [meterCabinetId, setMeterCabinetId] = useState(); + const [meterData, setMeterData] = useState(null); + const [meterLoading, setMeterLoading] = useState(false); // 通用 const [loading, setLoading] = useState(true); const [downloadVisible, setDownloadVisible] = useState(false); @@ -146,6 +207,27 @@ export default function Energy() { fetchTrend(); }, [fetchTrend]); + /** 加载柜子下拉(电表口径区块用) */ + useEffect(() => { + api.get('/cabinets/options') + .then((res) => setCabinetOptions(res.data || [])) + .catch(() => Message.error('加载柜子列表失败')); + }, []); + + /** 选中柜子变化时加载电表口径日用电量 */ + useEffect(() => { + if (!meterCabinetId) { + setMeterData(null); + return; + } + setMeterLoading(true); + api + .get(`/cabinets/${meterCabinetId}/meter-usage`) + .then((res) => setMeterData(res.data)) + .catch(() => Message.error('加载电表口径用电量失败')) + .finally(() => setMeterLoading(false)); + }, [meterCabinetId]); + /** 导出(后端要求 energy:export) */ const handleExport = async () => { try { @@ -235,6 +317,53 @@ export default function Energy() { padding: '8px 12px', }; + /** 电表口径日用电量列定义 */ + const meterUsageColumns = [ + { + title: '日期', + dataIndex: 'day', + width: 140, + defaultSortOrder: 'descend' as const, + sortable: true, + sorter: (a: MeterUsageDay, b: MeterUsageDay) => a.day.localeCompare(b.day), + }, + { + title: '电表末读数(kWh)', + dataIndex: 'end_reading', + width: 160, + render: (v: number) => fmt(v / 1000), + }, + { + title: '当日用电(kWh)', + dataIndex: 'usage_wh', + width: 150, + render: (v: number | undefined, record: MeterUsageDay) => { + // ⛔ 无基准 ⇒ 不是 0 度,必须显示"-"(显示 0 等于告诉用户这天没用一度电) + if (v == null) return '-'; + // 换表/清零 ⇒ 差值是负数,绝不能呈现成"负用电量" + if (record.flags.includes('meter_reset_or_replaced')) return '-'; + return fmt(v / 1000); + }, + }, + { + title: '数据质量', + dataIndex: 'flags', + width: 220, + render: (flags: MeterUsageFlag[]) => + flags.length === 0 ? ( + 正常 + ) : ( + + {flags.map((f) => ( + + {METER_FLAG_META[f].text} + + ))} + + ), + }, + ]; + return ( {/* 顶部数据卡片 */} @@ -446,6 +575,77 @@ export default function Energy() { + {/* 整柜日用电量(电表读数口径)*/} + + + 默认最近 30 天 + +