129 lines
6.7 KiB
TypeScript
129 lines
6.7 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { BatteryCharging, Gauge, Wallet, CalendarClock } from 'lucide-react';
|
||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
|
||
import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
|
||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||
import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||
|
||
function fmtYuan(yuan: number) {
|
||
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
|
||
}
|
||
function fmtKwh(kwh: number) {
|
||
return `${kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} 度`;
|
||
}
|
||
|
||
export default function ElectricOverview() {
|
||
const [data, setData] = useState<ElectricOverviewResponse | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
fetchElectricOverview()
|
||
.then(d => { if (!cancelled) setData(d); })
|
||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||
return () => { cancelled = true; };
|
||
}, []);
|
||
|
||
if (error) {
|
||
return <ErrorState message={error} />;
|
||
}
|
||
if (!data) {
|
||
return <LoadingState label="正在加载电能总览" />;
|
||
}
|
||
const k = data.kpi;
|
||
const trendData = data.trend;
|
||
// 当电能数据滞后(本月无数据走 fallback)时,柱图标题显示实际月份
|
||
const trendMonthLabel = trendData[0]?.date.slice(0, 7);
|
||
const currentMonth = new Date().toISOString().slice(0, 7);
|
||
const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth
|
||
? `${trendMonthLabel} 每日充电`
|
||
: '本月每日充电';
|
||
const activeDays = trendData.filter(item => item.kwh > 0).length;
|
||
const avgDailyKwh = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.kwh, 0) / activeDays : 0;
|
||
const avgDailyFee = activeDays > 0 ? trendData.reduce((sum, item) => sum + item.fee, 0) / activeDays : 0;
|
||
const peakDay = trendData.reduce<typeof trendData[number] | null>((best, item) => (!best || item.kwh > best.kwh ? item : best), null);
|
||
const avgPrice = k.totalKwh > 0 ? k.totalFee / k.totalKwh : 0;
|
||
const monthPrice = k.monthKwh > 0 ? k.monthFee / k.monthKwh : 0;
|
||
|
||
return (
|
||
<div className="flex flex-col gap-3">
|
||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400">
|
||
龙王路停车场充电站,期初 2025-01-01,手工导入每日更新
|
||
</div>
|
||
{/* 横向 mini KPI 头 */}
|
||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||
<MetricTile icon={Wallet} label="累计充电费" value={fmtYuan(k.totalFee)} helper={fmtKwh(k.totalKwh)} />
|
||
<MetricTile icon={CalendarClock} label="本月充电费" value={fmtYuan(k.monthFee)} helper={fmtKwh(k.monthKwh)} tone="emerald" />
|
||
<MetricTile icon={Gauge} label="累计均价" value={avgPrice.toFixed(2)} unit="元/度" helper={`本月 ${monthPrice.toFixed(2)} 元/度`} tone="amber" />
|
||
<MetricTile icon={BatteryCharging} label="今日充电" value={k.todayKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={`${fmtYuan(k.todayFee)} · ${k.todayChainPct >= 0 ? '+' : ''}${(k.todayChainPct * 100).toFixed(1)}%`} tone={Math.abs(k.todayChainPct) >= 0.3 ? 'rose' : 'slate'} />
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||
<SurfaceCard className="p-3">
|
||
<div className="text-[11px] font-black text-slate-400">有效充电日</div>
|
||
<div className="mt-1 text-xl font-black text-slate-900">{activeDays}<span className="ml-1 text-[11px] text-slate-400">天</span></div>
|
||
<div className="mt-1 text-[11px] font-bold text-slate-500">日均 {avgDailyKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度</div>
|
||
</SurfaceCard>
|
||
<SurfaceCard className="p-3">
|
||
<div className="text-[11px] font-black text-slate-400">峰值日</div>
|
||
<div className="mt-1 text-xl font-black text-blue-600">{peakDay ? peakDay.date.slice(5) : '—'}</div>
|
||
<div className="mt-1 text-[11px] font-bold text-slate-500">{peakDay ? `${peakDay.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度 · ${fmtYuan(peakDay.fee)}` : '暂无数据'}</div>
|
||
</SurfaceCard>
|
||
<SurfaceCard className="p-3">
|
||
<div className="text-[11px] font-black text-slate-400">月度占比</div>
|
||
<div className="mt-1 text-xl font-black text-emerald-600">{k.totalFee > 0 ? (k.monthFee / k.totalFee * 100).toFixed(1) : '0.0'}%</div>
|
||
<div className="mt-1 text-[11px] font-bold text-slate-500">本月费用 / 累计费用</div>
|
||
</SurfaceCard>
|
||
</div>
|
||
|
||
{/* 本月每日充电柱图 */}
|
||
<SurfaceCard className="p-4">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-sm font-bold text-slate-700">{chartTitle}</span>
|
||
<span className="text-[11px] text-slate-400 font-bold">时间单位:日 · 单位 元 · 均线为日均费用</span>
|
||
</div>
|
||
<ResponsiveContainer width="100%" height={160}>
|
||
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||
<XAxis
|
||
dataKey="date"
|
||
tickFormatter={(v: string) => v.slice(5)}
|
||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||
tickLine={false}
|
||
axisLine={false}
|
||
interval="preserveStartEnd"
|
||
minTickGap={8}
|
||
/>
|
||
<YAxis hide />
|
||
<Tooltip
|
||
formatter={(v) => [`¥${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`, '充电费用']}
|
||
labelFormatter={(d) => `日期 ${d}`}
|
||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
|
||
/>
|
||
{avgDailyFee > 0 && (
|
||
<ReferenceLine
|
||
y={avgDailyFee}
|
||
stroke="#f59e0b"
|
||
strokeDasharray="4 4"
|
||
label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }}
|
||
/>
|
||
)}
|
||
<Bar dataKey="fee" radius={[4, 4, 0, 0]}>
|
||
{trendData.map((_, i) => (
|
||
<Cell key={i} fill="url(#electricBarGrad)" />
|
||
))}
|
||
</Bar>
|
||
<defs>
|
||
<linearGradient id="electricBarGrad" x1="0" x2="0" y1="0" y2="1">
|
||
<stop offset="0%" stopColor="#3b82f6" />
|
||
<stop offset="100%" stopColor="#22d3ee" />
|
||
</linearGradient>
|
||
</defs>
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
</SurfaceCard>
|
||
<RotatingFooterHint />
|
||
</div>
|
||
);
|
||
}
|