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:
parent
8ae55e9ca3
commit
c1b44b6e23
@ -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<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() {
|
||||
// 总览
|
||||
const [summary, setSummary] = useState<Summary | null>(null);
|
||||
@ -87,6 +143,11 @@ export default function Energy() {
|
||||
// 趋势
|
||||
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
||||
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 [downloadVisible, setDownloadVisible] = useState(false);
|
||||
@ -146,6 +207,27 @@ export default function Energy() {
|
||||
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) */
|
||||
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 ? (
|
||||
<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 (
|
||||
<Spin loading={loading} style={{ width: '100%' }}>
|
||||
{/* 顶部数据卡片 */}
|
||||
@ -446,6 +575,77 @@ export default function Energy() {
|
||||
</Space>
|
||||
</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
|
||||
visible={downloadVisible}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user