import { useCallback, useEffect, useRef, useState } from 'react'; import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend, } from 'recharts'; import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api'; import RotatingFooterHint from '../../components/RotatingFooterHint'; const REGION_COLORS = [ '#3b82f6', '#22d3ee', '#a855f7', '#f59e0b', '#10b981', '#ef4444', '#6366f1', '#14b8a6', '#94a3b8', ]; interface YAxisTickProps { x?: number; y?: number; index?: number; payload?: { value: string }; } function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) { return ( {index + 1} {payload?.value} ); } // ---------- 数字格式化 ---------- function fmtKg(kg: number): { value: string; unit: string } { if (kg >= 1000) return { value: (kg / 1000).toFixed(2), unit: 'T' }; return { value: kg.toFixed(2), unit: 'Kg' }; } function fmtYuan(yuan: number): { value: string; unit: string } { const abs = Math.abs(yuan); if (abs >= 100_000_000) return { value: (yuan / 100_000_000).toFixed(2), unit: '亿元' }; if (abs >= 10_000) { const w = yuan / 10_000; return { value: w.toLocaleString('zh-CN', { maximumFractionDigits: 2 }), unit: '万元' }; } return { value: yuan.toLocaleString('zh-CN', { maximumFractionDigits: 0 }), unit: '元' }; } // ---------- KPI 卡 ---------- interface KpiCardProps { icon: React.ReactNode; label: string; hero: { value: string; unit: string }; rows: { label: string; value: string; valueClass?: string }[]; accentClass: string; iconBg: string; } function KpiCard({ icon, label, hero, rows, accentClass, iconBg }: KpiCardProps) { return (
{icon}
{label}
{hero.value} {hero.unit}
{rows.map((r, i) => (
{r.label} {r.value}
))}
); } // ============================================================ export default function HydrogenOverview() { const [data, setData] = useState(null); const [error, setError] = useState(null); const [year, setYear] = useState(null); const [refreshing, setRefreshing] = useState(false); const [lastRefreshAt, setLastRefreshAt] = useState(0); const refreshSeq = useRef(0); const load = useCallback(async (selectedYear: number | null, force: boolean) => { const seq = ++refreshSeq.current; setRefreshing(true); try { const d = await fetchHydrogenOverview(selectedYear ?? undefined, force); if (seq !== refreshSeq.current) return; // outdated setData(d); setError(null); setLastRefreshAt(Date.now()); } catch (e) { if (seq !== refreshSeq.current) return; setError(e instanceof Error ? e.message : String(e)); } finally { if (seq === refreshSeq.current) setRefreshing(false); } }, []); // 初始加载 + 年份切换:用 force=false 命中热缓存 useEffect(() => { void load(year, false); }, [year, load]); // 客户端兜底自动刷新:每 60s 静默拉一次(命中后端热缓存,几乎零成本) useEffect(() => { const t = setInterval(() => { void load(year, false); }, 60_000); return () => clearInterval(t); }, [year, load]); if (error && !data) { return
加载失败:{error}
; } if (!data) { return ; } const k = data.kpi; const top5 = data.top5; const regions = data.regions; const monthly = data.monthly; const customers = data.customers; const stations = data.stations; const availableYears = data.availableYears; const activeYear = data.year; const yearKgFmt = fmtKg(k.yearKg); const yearFeeFmt = fmtYuan(k.yearFee); const yearProfitFmt = fmtYuan(k.yearProfit); const ourYearKgFmt = fmtKg(k.ourYearKg); const customerYearKgFmt = fmtKg(k.customerYearKg); const monthKgFmt = fmtKg(k.monthKg); const monthFeeFmt = fmtYuan(k.monthFee); const todayKgFmt = fmtKg(k.todayKg); const todayFeeFmt = fmtYuan(k.todayFee); const customerYearFee = Math.max(0, k.yearFee - k.ourYearFee); const customerYearFeeFmt = fmtYuan(customerYearFee); const yearRevenueFmt = fmtYuan(k.yearRevenue); const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600'; // 月度收支组合数据(推算"年内每月"图) const monthlyDual = monthly.map(m => ({ ...m, monthLabel: m.month.slice(5).replace(/^0/, '') + '月', })); return (
{/* 顶部说明条 + 年份切换 + 刷新按钮 */}
{lastRefreshAt ? `更新于 ${formatRelative(lastRefreshAt)}` : '数据自 2025-01-01 起'}
{availableYears.map(y => { const active = y === activeYear; return ( ); })}
{/* KPI 5 卡 */}
} iconBg="bg-cyan-50" accentClass="text-slate-800" label="累计加氢量" hero={yearKgFmt} rows={[ { label: '我司', value: `${ourYearKgFmt.value} ${ourYearKgFmt.unit}` }, { label: '客户', value: `${customerYearKgFmt.value} ${customerYearKgFmt.unit}` }, ]} /> } iconBg="bg-blue-50" accentClass="text-slate-800" label="累计加氢费" hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }} rows={[ { label: '我司承担', value: `¥${fmtYuan(k.ourYearFee).value} ${fmtYuan(k.ourYearFee).unit}` }, { label: '客户承担', value: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` }, ]} /> } iconBg="bg-emerald-50" accentClass={profitColor} label="时享加氢获利" hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }} rows={[ { label: '收入', value: `¥${yearRevenueFmt.value} ${yearRevenueFmt.unit}` }, { label: '成本', value: `¥${yearFeeFmt.value} ${yearFeeFmt.unit}` }, ]} /> } iconBg="bg-amber-50" accentClass="text-amber-600" label="本月加氢" hero={monthKgFmt} rows={[ { label: '加氢费', value: `¥${monthFeeFmt.value} ${monthFeeFmt.unit}` }, { label: '占年比', value: `${k.yearKg > 0 ? (k.monthKg / k.yearKg * 100).toFixed(1) : '0.0'}%` }, ]} /> } iconBg="bg-violet-50" accentClass="text-violet-600" label="本日加氢" hero={todayKgFmt} rows={[ { label: '加氢费', value: `¥${todayFeeFmt.value} ${todayFeeFmt.unit}` }, { label: '占月比', value: `${k.monthKg > 0 ? (k.todayKg / k.monthKg * 100).toFixed(1) : '0.0'}%` }, ]} />
{/* 月度趋势:年内每月加氢量 */} {monthly.length > 0 && (
{activeYear} 年月度加氢量 单位 Kg
[`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })} Kg`, '加氢量']} labelFormatter={(d) => `${d}`} contentStyle={{ borderRadius: 12, fontSize: 12 }} cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }} /> {monthlyDual.map((_, i) => ( ))}
)} {/* 月度收支对比 */} {monthly.length > 0 && (
{activeYear} 年月度收支对比 单位 元
{ const f = fmtYuan(Number(v ?? 0)); return [`¥${f.value} ${f.unit}`, name]; }} contentStyle={{ borderRadius: 12, fontSize: 12 }} cursor={{ fill: 'rgba(148, 163, 184, 0.06)' }} />
)} {/* Top5 + 区域占比 */}
{/* Top5 加氢站 */}
加氢站加注量 Top5 单位 Kg
} tickLine={false} axisLine={false} /> `${Number(v ?? 0).toLocaleString('zh-CN')} Kg`} contentStyle={{ borderRadius: 12, fontSize: 12 }} /> {top5.map((_, i) => ( ))} `${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`} fill="#475569" fontSize={11} fontWeight={700} />
{/* 区域占比 */}
各区域加氢占比
{regions.map((_, i) => ( ))} `${(Number(v ?? 0) / 1000).toFixed(2)}T`} contentStyle={{ borderRadius: 12, fontSize: 12 }} />
年合计
{(k.yearKg / 1000).toFixed(2)}T
{regions.map((r, i) => (
{r.region} {(r.share * 100).toFixed(1)}%
))}
{/* 加氢站加氢汇总(全量) */} {stations.length > 0 && (
加氢站加氢汇总 共 {stations.length} 站
{stations.map((s, i) => { const kgFmt = fmtKg(s.kg); const revFmt = fmtYuan(s.revenue); return ( ); })}
# 加氢站 加氢量 占比 氢费收入 收入占比
{i + 1} {s.name} {kgFmt.value}{kgFmt.unit}
{(s.share * 100).toFixed(1)}%
¥{revFmt.value}{revFmt.unit}
{(s.revenueShare * 100).toFixed(1)}%
)} {/* 客户账单汇总 Top */} {customers.length > 0 && (
客户账单汇总 Top {customers.length}
{customers.map((c2, i) => { const kgFmt = fmtKg(c2.kg); const costFmt = fmtYuan(c2.cost); const revFmt = fmtYuan(c2.revenue); return ( ); })}
# 客户 承担方 加氢量 成本支出 应收
{i + 1} {c2.name} {c2.payer === 'lingniu' ? ( 羚牛 ) : ( 客户 )} {kgFmt.value}{kgFmt.unit} ¥{costFmt.value}{costFmt.unit} ¥{revFmt.value}{revFmt.unit}
)} {/* 刷新中:透明遮罩 + 顶部进度条(不替换内容,避免闪烁) */} {refreshing && data && ( )}
); } function formatRelative(ts: number): string { const s = Math.max(0, Math.floor((Date.now() - ts) / 1000)); if (s < 5) return '刚刚'; if (s < 60) return `${s} 秒前`; const m = Math.floor(s / 60); if (m < 60) return `${m} 分钟前`; const h = Math.floor(m / 60); if (h < 24) return `${h} 小时前`; return new Date(ts).toLocaleString('zh-CN', { hour12: false }); } function HydrogenOverviewSkeleton() { return (
{/* 5 卡占位 */}
{Array.from({ length: 5 }).map((_, i) => (
))}
{/* 月度柱图占位 */}
{[60, 75, 50, 80, 35, 90, 45].map((h, i) => (
))}
{[100, 78, 56, 40, 28].map((w, i) => (
))}
{Array.from({ length: 5 }).map((_, i) => (
))}
正在加载氢能总览…
); }