import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Activity, CalendarDays, ChevronDown, ChevronLeft, Download, Fuel, RefreshCw, Search, Shield, TrendingDown, TrendingUp, Truck, Wallet, X, Zap, } from "lucide-react"; import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; import { fetchH2BiDaily, fetchH2BiDrill, fetchH2BiMeta, fetchH2BiOverview, } from "./api"; import type { H2BiDailyResponse, H2BiDrillMetric, H2BiDrillRecord, H2BiDrillResponse, H2BiKpis, H2BiMetaResponse, H2BiOverviewResponse, H2BiQuery, H2BiScope, H2BiStationRow, H2BiVerifyScope, H2BiVehicleScope, H2BiView, } from "./types"; import "../styles/energy-bi-board.css"; const now = new Date(); const isoDate = (date: Date) => date.toISOString().slice(0, 10); const defaultEndDate = isoDate(now); const defaultStart = new Date(now); defaultStart.setDate(defaultStart.getDate() - 14); const defaultStartDate = isoDate(defaultStart); function number(value: number | null | undefined, digits = 0) { return Number(value ?? 0).toLocaleString("zh-CN", { minimumFractionDigits: digits, maximumFractionDigits: digits, }); } function kg(value: number | null | undefined) { return `${number(value, 2)} Kg`; } function yuan(value: number | null | undefined) { return `¥${number(value, 2)}`; } function toT(value: number | null | undefined) { return number((value ?? 0) / 1000, 2); } function toWan(value: number | null | undefined) { return number((value ?? 0) / 10000, 2); } function rangeText(range?: { startDate: string | null; endDate: string | null; }) { if (!range) return "加载中…"; return ( [range.startDate, range.endDate].filter(Boolean).join(" 至 ") || "暂无时间范围" ); } function apiError(error: unknown) { return error instanceof Error ? error.message : "数据加载失败,请刷新后重试"; } function emptyKpis(): H2BiKpis { return { totalKg: 0, totalCost: 0, customerBearingKg: 0, companyBearingKg: 0, otherBearingKg: 0, customerRevenue: 0, customerCost: 0, companyCost: 0, otherCost: 0, totalRevenue: 0, customerGrossProfit: 0, monthKg: 0, monthCost: 0, todayKg: 0, todayCost: 0, monthShareOfRange: 0, todayShareOfMonth: 0, recordCount: 0, stationCount: 0, }; } interface DataState { data: T | null; error: string | null; loading: boolean; } function useRemoteData(key: string, load: () => Promise) { const [state, setState] = useState>({ data: null, error: null, loading: true, }); useEffect(() => { let active = true; setState((previous) => ({ ...previous, error: null, loading: true })); void load() .then((data) => active && setState({ data, error: null, loading: false })) .catch( (error: unknown) => active && setState((previous) => ({ ...previous, error: apiError(error), loading: false, })), ); return () => { active = false; }; }, [key]); // caller supplies the query-derived key deliberately return state; } function KpiCard({ label, value, prefix, unit, left, right, icon, tone, onClick, }: { label: string; value: string; prefix?: string; unit: string; left: string; right: string; icon: ReactNode; tone: "blue" | "green" | "amber" | "purple" | "cyan"; onClick: () => void; }) { return ( ); } function PrototypeYearSelect({ value, years, onChange, }: { value: number; years: number[]; onChange: (year: number) => void; }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { const close = (event: MouseEvent) => { if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false); }; if (open) document.addEventListener("mousedown", close); return () => document.removeEventListener("mousedown", close); }, [open]); return (
{open ? (
切换数据年份
{years.map((year) => ( ))}
) : null}
); } function EmptyTable({ children }: { children: ReactNode }) { return (
{children}
); } function TableCard({ title, children, hint, }: { title: string; children: ReactNode; hint?: string; }) { return (

{title}

{hint ? {hint} : null}
{children}
); } function OverviewRankings({ overview, onDrill, }: { overview: H2BiOverviewResponse; onDrill: ( metric: H2BiDrillMetric, stationId?: string | number | null, ) => void; }) { const [granularity, setGranularity] = useState<"province" | "city">("city"); const total = overview.kpis.totalKg || 1; const source = granularity === "city" ? overview.stations.reduce>( (acc, station) => ({ ...acc, [station.city || "未归属区域"]: (acc[station.city || "未归属区域"] || 0) + station.kg, }), {}, ) : overview.stations.reduce>( (acc, station) => ({ ...acc, [station.province || "未归属"]: (acc[station.province || "未归属"] || 0) + station.kg, }), {}, ); const colors = [ "#0284c7", "#38bdf8", "#10b981", "#f59e0b", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16", "#94a3b8", ]; const regions = Object.entries(source) .sort((a, b) => b[1] - a[1]) .slice(0, 8) .map(([region, kg], index) => ({ region, kg, color: colors[index] })); const rest = total - regions.reduce((sum, region) => sum + region.kg, 0); if (rest > 0) regions.push({ region: `其他${granularity === "city" ? "城市" : "省份"}`, kg: rest, color: "#94a3b8", }); let offset = 0; return (
加氢站加氢量 Top5
内部客户 外部客户
{overview.topStations.map((station, index) => { const ratio = station.kg ? (station.lingniuKg / station.kg) * 100 : 0; return ( ); })}
各区域加氢占比
{regions.map((region) => { const dash = (region.kg / total) * 238.76; const current = offset; offset += dash; return ( ); })}
年合计
{toT(total)}T
{regions.map((region) => ( ))}
); } function MonthlyRevenueComparison({ overview, onDrill, }: { overview: H2BiOverviewResponse; onDrill: (metric: H2BiDrillMetric) => void; }) { const max = Math.max( ...overview.monthly.flatMap((row) => [ row.customerRevenue, row.customerCost, ]), 1, ); const year = overview.range.startDate?.slice(0, 4) || "当前"; return (
{year} 年月度收支对比
对客金额 客户承担成本金额 统计范围:{rangeText(overview.range)} · 单位 元
{overview.monthly.map((row) => (
{row.month}
))}
); } function OverviewTables({ overview, onDrill, }: { overview: H2BiOverviewResponse; onDrill: ( metric: H2BiDrillMetric, stationId?: string | number | null, ) => void; }) { const [province, setProvince] = useState("全国"); const provinces = [ "全国", ...Array.from( new Set( overview.stations .map((station) => station.province) .filter((value): value is string => Boolean(value)), ), ), ]; const stations = province === "全国" ? overview.stations : overview.stations.filter((station) => station.province === province); const totalKg = overview.kpis.totalKg || 1; const totalRevenue = overview.kpis.customerRevenue || 1; return ( <>
加氢站加氢汇总
{provinces.map((item) => ( ))}
统计范围:{rangeText(overview.range)} · 共 {stations.length} 站
{stations.map((row, index) => ( onDrill("station", row.id)} style={{ cursor: "pointer" }} > ))}
# 加氢站(点击钻取) 所属省份 加氢量 占比 对客金额 收入占比
{index + 1} {row.name}{" "} 钻取 › {row.province || "未归属"} {toT(row.kg)} T
{number((row.kg / totalKg) * 100, 1)}%
¥{toWan(row.customerRevenue)} 万元
{number((row.customerRevenue / totalRevenue) * 100, 1)}%
客户账单汇总
统计范围:{rangeText(overview.range)} · Top{" "} {overview.customers.length}
{overview.customers.map((row, index) => ( onDrill("customer")} style={{ cursor: "pointer" }} > ))}
# 客户(点击钻取) 加氢量 客户承担成本 对客金额
{index + 1} {row.name}{" "} 钻取 › {toT(row.kg)} T ¥{toWan(row.customerCost)} 万元 ¥{toWan(row.customerRevenue)} 万元
); } function OverviewPage({ overview, onDrill, }: { overview: H2BiOverviewResponse; onDrill: ( metric: H2BiDrillMetric, stationId?: string | number | null, ) => void; }) { const kpis = overview.kpis || emptyKpis(); const firstStation = overview.topStations[0] as H2BiStationRow | undefined; const top5Share = overview.topStations.reduce( (sum, station) => sum + station.share, 0, ); const profitRate = kpis.customerRevenue ? (kpis.customerGrossProfit / kpis.customerRevenue) * 100 : 0; return ( <>
sum + row.lingniuKg, 0))} T`} right={`外部 ${toT(overview.monthly.reduce((sum, row) => sum + row.externalKg, 0))} T`} icon={} tone="blue" onClick={() => onDrill("totalKg")} /> } tone="blue" onClick={() => onDrill("totalCost")} /> } tone="green" onClick={() => onDrill("customerGrossProfit")} /> } tone="amber" onClick={() => onDrill("monthKg")} /> } tone="purple" onClick={() => onDrill("todayKg")} />
月度加氢异常波动
占年 {number(kpis.monthShareOfRange, 2)}%
当月 {toT(kpis.monthKg)} T ·{" "} {overview.range.endDate?.slice(0, 7) || "当前月"} 当前统计口径
加氢利润率
= 0 ? "is-pos" : "is-neg"}`} > {number(profitRate, 2)}%
加氢利润 ¥{toWan(kpis.customerGrossProfit)} 万 · 对客金额 ¥ {toWan(kpis.customerRevenue)} 万
{overview.range.startDate?.slice(0, 4) || "当前"} 年月度加氢量
内部客户 外部客户 统计范围:{rangeText(overview.range)} · 单位 Kg
{overview.monthly.length === 0 ? ( 当前筛选暂无月度趋势数据 ) : ( number(Number(v))} /> [ kg(Number(value)), name === "lingniuKg" ? "内部客户" : "外部客户", ]} /> v === "lingniuKg" ? "内部客户" : "外部客户" } /> )}
); } function DailyPage({ daily, onDrill, }: { daily: H2BiDailyResponse; onDrill: (metric: H2BiDrillMetric) => void; }) { const kpis = daily.kpis; return (
每日加氢量 (点击柱体下锚定位到对应日期明细)
加氢量
时间单位:日 · 单位 Kg
{daily.trend.length === 0 ? ( 当前日期范围暂无按日数据 ) : ( String(value).slice(5)} axisLine={false} tickLine={false} /> number(Number(v))} /> [kg(Number(value)), "加氢量"]} /> )}
每日加氢数据明细{" "} (可多层下钻:按日 → 加氢站 → 客户 → 车辆及数据源)
{daily.days.length === 0 ? ( 当前日期范围暂无按日明细 ) : ( {daily.days.map((row) => ( onDrill("day")} > ))}
日期 加氢量 成本 流水笔数 站点
{row.date}{" "} 钻取 › {kg(row.kg)} {yuan(row.cost)} {number(row.recordCount)} {number(row.stationCount)}
)}
); } function DrillModal({ query, title, onClose, }: { query: H2BiQuery & { metric: H2BiDrillMetric }; title: string; onClose: () => void; }) { const [search, setSearch] = useState(""); const state = useRemoteData(`drill:${JSON.stringify(query)}`, () => fetchH2BiDrill({ ...query, groupBy: "record", page: 1, pageSize: 100 }), ); const data: H2BiDrillResponse | null = state.data; const records = useMemo( () => (data?.records || []).filter((record) => JSON.stringify(record) .toLocaleLowerCase() .includes(search.trim().toLocaleLowerCase()), ), [data, search], ); const columns = useMemo( () => [ "time", "orderNo", "stationName", "customerName", "plateNo", "vehicleScope", "source", "verifyStatus", "kg", "unitPrice", "cost", "revenue", ].filter((column) => records.some((record) => record[column] !== undefined), ), [records], ); const fieldLabel: Record = { time: "加氢时间", orderNo: "订单号", stationName: "加氢站", customerName: "客户", plateNo: "车牌号", vehicleScope: "车辆归属", source: "数据源", verifyStatus: "核对状态", kg: "加氢量 (Kg)", unitPrice: "单价 (元/Kg)", cost: "成本 (元)", revenue: "收入 (元)", }; return (
{title}明细
真实氢费数据下钻 · 跟随当前筛选条件
{state.loading ? ( 正在加载真实明细数据… ) : state.error ? ( {state.error} ) : ( <>
筛选范围 {query.startDate && query.endDate ? `${query.startDate} 至 ${query.endDate}` : `${query.year} 年`}
记录数 {number( typeof data?.summary.recordCount === "number" ? data.summary.recordCount : 0, )}
加氢量 {number( typeof data?.summary.kg === "number" ? data.summary.kg : 0, 2, )}{" "} Kg
下钻结果使用当前真实数据源,不回填或补齐任何业务数值。
{records.length === 0 ? ( 暂无匹配的下钻记录 ) : ( {columns.map((column) => ( ))} {records.map((record, index) => ( {columns.map((column) => ( ))} ))}
{fieldLabel[column] || column}
{formatRecord(record[column])}
)}
)}
); } function formatRecord(value: H2BiDrillRecord[string]) { if (value === null || value === undefined || value === "") return "—"; if (value === "lingniu") return "羚牛车辆"; if (value === "external") return "外部车辆"; if (value === "verified") return "已核对"; if (value === "unverified") return "未核对"; if (typeof value === "number") return number(value, 2); return String(value); } function boardQuery(query: H2BiQuery) { return JSON.stringify(query); } export default function HydrogenBiV2App() { const [scope, setScope] = useState("global"); const [view, setView] = useState("overview"); const [year, setYear] = useState(now.getFullYear()); const [stationId, setStationId] = useState(null); const [vehicleScope, setVehicleScope] = useState("all"); const [verifyScope, setVerifyScope] = useState("all"); const [startDate, setStartDate] = useState(defaultStartDate); const [endDate, setEndDate] = useState(defaultEndDate); const [reload, setReload] = useState(0); const [drill, setDrill] = useState<{ metric: H2BiDrillMetric; title: string; stationId?: string | number | null; } | null>(null); const metaState = useRemoteData("meta", fetchH2BiMeta); const meta: H2BiMetaResponse | null = metaState.data; useEffect(() => { if (meta?.years.length && !meta.years.some((item) => item.value === year)) setYear(meta.years[0].value); }, [meta, year]); const query = useMemo( () => ({ year, startDate: view === "daily" ? startDate : undefined, endDate: view === "daily" ? endDate : undefined, stationId: scope === "station" ? stationId : null, vehicleScope, verifyScope, }), [ year, startDate, endDate, stationId, scope, vehicleScope, verifyScope, view, ], ); const overviewState = useRemoteData( `overview:${boardQuery(query)}:${reload}`, () => fetchH2BiOverview(query), ); const dailyState = useRemoteData(`daily:${boardQuery(query)}:${reload}`, () => fetchH2BiDaily(query), ); const activeState = view === "overview" ? overviewState : dailyState; const activeRange = view === "overview" ? overviewState.data?.range : dailyState.data?.range; const stationName = meta?.stations.find( (station) => station.id === stationId, )?.name; const openDrill = ( metric: H2BiDrillMetric, targetStationId?: string | number | null, ) => { const titles: Record = { totalKg: "加氢量", totalCost: "成本", totalRevenue: "氢费收入", customerGrossProfit: "客户毛利", monthKg: "本月加氢", todayKg: "本日加氢", station: "加氢站", customer: "客户账单", day: "按日加氢", }; setDrill({ metric, title: titles[metric], stationId: targetStationId }); }; return (
羚牛氢能 BI / 氢能

氢能经营看板

{scope === "global" ? ( 📅 统计时间范围:{rangeText(activeRange)} ) : null}
{scope === "global" ? (
) : null}
item.value)} onChange={setYear} /> {scope === "station" ? ( ) : null}
{view === "daily" ? ( <> setStartDate(event.target.value)} aria-label="开始日期" /> setEndDate(event.target.value)} aria-label="结束日期" /> ) : null}
{activeState.data?.watermark.ledgerAt || "加载中…"} {stationName ? ( 当前单站:{stationName} ) : null}
{metaState.error ? (
筛选元数据加载失败:{metaState.error}
) : null} {activeState.loading && !activeState.data ? (
正在加载真实氢能数据…
) : null} {activeState.error ? (
{activeState.error}
) : null} {view === "overview" && overviewState.data ? ( ) : null} {view === "daily" && dailyState.data ? ( ) : null}
{drill ? ( setDrill(null)} /> ) : null}
); }