feat(web): 能耗页新增「整柜日用电量(电表读数口径)」区块

后端 `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)
This commit is contained in:
12451 2026-09-21 00:25:11 +08:00
parent 8ae55e9ca3
commit c1b44b6e23

View File

@ -15,6 +15,7 @@ import {
Space, Space,
Select, Select,
Grid, Grid,
Alert,
Spin, Message Spin, Message
} from '@arco-design/web-react'; } from '@arco-design/web-react';
import { IconDownload } from '@arco-design/web-react/icon'; import { IconDownload } from '@arco-design/web-react/icon';
@ -77,6 +78,61 @@ interface TrendData {
list: TrendPoint[]; 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<MeterUsageFlag, { text: string; color: string }> = {
// 该柜第一条读数,无对比基准 ⇒ 不是 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() { export default function Energy() {
// 总览 // 总览
const [summary, setSummary] = useState<Summary | null>(null); const [summary, setSummary] = useState<Summary | null>(null);
@ -87,6 +143,11 @@ export default function Energy() {
// 趋势 // 趋势
const [trendData, setTrendData] = useState<TrendPoint[]>([]); const [trendData, setTrendData] = useState<TrendPoint[]>([]);
const [granularity, setGranularity] = useState<string>('day'); const [granularity, setGranularity] = useState<string>('day');
// 电表口径(整柜日用电量)
const [cabinetOptions, setCabinetOptions] = useState<CabinetOption[]>([]);
const [meterCabinetId, setMeterCabinetId] = useState<number | undefined>();
const [meterData, setMeterData] = useState<MeterUsageData | null>(null);
const [meterLoading, setMeterLoading] = useState(false);
// 通用 // 通用
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [downloadVisible, setDownloadVisible] = useState(false); const [downloadVisible, setDownloadVisible] = useState(false);
@ -146,6 +207,27 @@ export default function Energy() {
fetchTrend(); fetchTrend();
}, [fetchTrend]); }, [fetchTrend]);
/** 加载柜子下拉(电表口径区块用) */
useEffect(() => {
api.get<CabinetOption[]>('/cabinets/options')
.then((res) => setCabinetOptions(res.data || []))
.catch(() => Message.error('加载柜子列表失败'));
}, []);
/** 选中柜子变化时加载电表口径日用电量 */
useEffect(() => {
if (!meterCabinetId) {
setMeterData(null);
return;
}
setMeterLoading(true);
api
.get<MeterUsageData>(`/cabinets/${meterCabinetId}/meter-usage`)
.then((res) => setMeterData(res.data))
.catch(() => Message.error('加载电表口径用电量失败'))
.finally(() => setMeterLoading(false));
}, [meterCabinetId]);
/** 导出(后端要求 energy:export) */ /** 导出(后端要求 energy:export) */
const handleExport = async () => { const handleExport = async () => {
try { try {
@ -235,6 +317,53 @@ export default function Energy() {
padding: '8px 12px', 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 ? (
<span style={{ color: 'var(--color-text-3)' }}>正常</span>
) : (
<Space size={4} wrap>
{flags.map((f) => (
<span key={f} style={{ color: METER_FLAG_META[f].color, fontSize: 12 }}>
{METER_FLAG_META[f].text}
</span>
))}
</Space>
),
},
];
return ( return (
<Spin loading={loading} style={{ width: '100%' }}> <Spin loading={loading} style={{ width: '100%' }}>
{/* 顶部数据卡片 */} {/* 顶部数据卡片 */}
@ -446,6 +575,77 @@ export default function Energy() {
</Space> </Space>
</Card> </Card>
{/* 整柜日用电量(电表读数口径)*/}
<Card
title="整柜日用电量(电表读数口径)"
extra={
<Space>
<span style={{ fontSize: 12, color: 'var(--color-text-3)' }}>
默认最近 30 天
</span>
<Select
value={meterCabinetId}
onChange={(val) => setMeterCabinetId(val as number | undefined)}
placeholder="选择柜子"
style={{ width: 200 }}
allowClear
showSearch
filterOption={(input, option) => {
// Arco 的 filterOption 签名把 option 定为 {},这里自行收窄
const label = (option as { props?: { label?: string } } | undefined)
?.props?.label;
return (
typeof label === 'string' &&
label.toLowerCase().includes(input.toLowerCase())
);
}}
options={cabinetOptions.map((c) => ({
label: `${c.abstract_id || c.id}${c.name ? ` ${c.name}` : ''}`,
value: c.id,
}))}
/>
</Space>
}
>
<Alert
type="info"
style={{ marginBottom: 12 }}
content={
'本区块是电表读数口径(整柜实际用电,硬件计量、含非充电损耗);' +
'上方卡片与排行是订单电量口径(平台按 V×I×Δt 积分的估算值)。' +
'两者数值必然不一致,不可互相替代。'
}
/>
<Table
rowKey="day"
columns={meterUsageColumns}
data={meterData?.days || []}
loading={meterLoading}
pagination={{
pageSize: 15,
showTotal: (total: number) => `共 ${total} 天`,
}}
size="default"
noDataElement={
meterCabinetId ? '该柜暂无电表读数记录' : '请先选择柜子'
}
/>
{meterData && meterData.summary.days_with_data > 0 && (
<div
style={{
marginTop: 12,
fontSize: 13,
color: 'var(--color-text-2)',
}}
>
{`区间合计 ${fmt(meterData.summary.total_wh / 1000)} kWh` +
`(计入 ${meterData.summary.counted_days} 天` +
`,有读数 ${meterData.summary.days_with_data} 天` +
`;无基准与电表更换日不计入)`}
</div>
)}
</Card>
{/* 下载中心弹窗 */} {/* 下载中心弹窗 */}
<DownloadCenter <DownloadCenter
visible={downloadVisible} visible={downloadVisible}