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,
|
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}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user