feat(energy): rebuild hydrogen BI board and drill-through
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
kkfluous
2026-08-20 13:59:03 +08:00
parent 37a9f303ec
commit 62efef0ab9
72 changed files with 36773 additions and 965 deletions
@@ -10,7 +10,16 @@ import {
XAxis,
YAxis,
} from 'recharts';
import type { HydrogenRegionShare, HydrogenStationTop } from '../../types';
import { useMemo, useState } from 'react';
import type { HydrogenRegionShare, HydrogenStationFull, HydrogenStationTop } from '../../types';
import {
buildRegionDrillPayload,
buildStationDrillPayload,
type OverviewDrillPayload,
type OverviewDrillRequest,
type OverviewScope,
} from '../model';
import { OverviewDrillDialog } from './OverviewDrillDialog';
const REGION_COLORS = [
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
@@ -42,70 +51,123 @@ function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
interface DistributionChartsProps {
top5: HydrogenStationTop[];
regions: HydrogenRegionShare[];
stations: HydrogenStationFull[];
yearKg: number;
onSelectStation?: (stationId: number) => void;
scope?: OverviewScope;
scopeLabel?: string | null;
onDrillRequest?: (request: OverviewDrillRequest) => void;
}
export function DistributionCharts({ top5, regions, yearKg }: DistributionChartsProps) {
export function DistributionCharts({ top5, regions, stations, yearKg, onSelectStation, scope = 'global', scopeLabel, onDrillRequest }: DistributionChartsProps) {
const [regionGranularity, setRegionGranularity] = useState<'province' | 'city'>('city');
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
const provinceRegions = useMemo(() => {
const totals = new Map<string, number>();
for (const station of stations) {
const province = station.province?.trim() || '未归属';
totals.set(province, (totals.get(province) ?? 0) + station.kg);
}
return [...totals.entries()]
.map(([region, kg]) => ({ region, kg, share: kg / Math.max(1, yearKg) }))
.sort((a, b) => b.kg - a.kg);
}, [stations, yearKg]);
const visibleRegions = regionGranularity === 'province' ? provinceRegions : regions;
const openStation = (stationId: number) => {
const station = stations.find(item => item.id === stationId);
if (!station) return;
setSelectedStationId(station.id);
if (onDrillRequest) onDrillRequest({ kind: 'station', key: String(station.id), label: station.name, entityId: station.id });
else setDrill(buildStationDrillPayload(station));
};
const openRegion = (region: HydrogenRegionShare) => {
setSelectedStationId(null);
if (onDrillRequest) onDrillRequest({ kind: 'region', key: `${regionGranularity}:${region.region}`, label: region.region });
else setDrill(buildRegionDrillPayload(region, stations, regionGranularity));
};
const scopeText = scope === 'station' && scopeLabel ? ` · ${scopeLabel}` : '';
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-bold text-slate-700"> Top5</span>
<span className="text-[11px] text-slate-400 font-bold"> Kg</span>
<>
<div className="ehb-two-charts-row">
<div className="ehb-chart-box">
<div className="ehb-chart-box-head">
<div className="ehb-chart-box-title"> Top5{scopeText}</div>
<div className="ehb-chart-box-meta">Kg</div>
</div>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 12 }}>
<div className="ehb-chart-box-body h-[260px]">
<ResponsiveContainer width="100%" height={260} minWidth={0} initialDimension={{ width: 1, height: 260 }}>
<BarChart
data={top5}
layout="vertical"
margin={{ top: 0, right: 24, left: 16, bottom: 0 }}
barSize={10}
className="cursor-pointer"
onClick={(state) => {
const stationId = (state as { activePayload?: { payload?: HydrogenStationTop }[] } | undefined)?.activePayload?.[0]?.payload?.id;
if (stationId) openStation(stationId);
}}
>
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="name"
width={188}
tick={<RankYAxisTick />}
tickLine={false}
type="category"
axisLine={false}
tickLine={false}
width={180}
tick={<RankYAxisTick />}
/>
<Tooltip
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN')} Kg`}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(59, 130, 246, 0.04)' }}
contentStyle={{ borderRadius: '12px', fontSize: '12px', padding: '8px 12px', border: 'none', boxShadow: '0 4px 20px rgba(0,0,0,0.08)' }}
formatter={(val: unknown) => [`${Number(val ?? 0).toLocaleString('zh-CN')} Kg`, '加氢量']}
/>
<Bar dataKey="kg" radius={[6, 6, 6, 6]}>
{top5.map((_, i) => (
<Cell key={i} fill="url(#topBarGrad)" />
))}
<LabelList
dataKey="kg"
position="right"
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
fill="#475569"
fontSize={11}
fontWeight={700}
/>
<Bar dataKey="kg" fill="#3b82f6" radius={[0, 4, 4, 0]}>
<LabelList dataKey="kg" position="right" formatter={(v: unknown) => {
const value = Number(v ?? 0);
return value >= 1000 ? `${(value / 1000).toFixed(1)}k` : String(value);
}} fontSize={11} fontWeight={700} fill="#475569" />
</Bar>
<defs>
<linearGradient id="topBarGrad" x1="0" x2="1" y1="0" y2="0">
<stop offset="0%" stopColor="#3b82f6" />
<stop offset="100%" stopColor="#22d3ee" />
</linearGradient>
</defs>
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
<span className="text-sm font-bold text-slate-700"></span>
<div className="flex items-center gap-2">
<div className="ehb-chart-box">
<div className="ehb-chart-box-head">
<div className="ehb-chart-box-title">{scopeText}</div>
<div className="ehb-mini-tabs">
<button
type="button"
className={`ehb-mini-tab ${regionGranularity === 'province' ? 'is-active' : ''}`}
onClick={() => setRegionGranularity('province')}
>
</button>
<button
type="button"
className={`ehb-mini-tab ${regionGranularity === 'city' ? 'is-active' : ''}`}
onClick={() => setRegionGranularity('city')}
>
</button>
</div>
</div>
<div className="ehb-chart-box-body h-[260px] flex items-center justify-center">
<div className="relative w-1/2 h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 1, height: 200 }}>
<PieChart>
<Pie
data={regions}
data={visibleRegions}
dataKey="kg"
nameKey="region"
innerRadius={48}
outerRadius={80}
paddingAngle={1}
className="cursor-pointer outline-none"
onClick={(entry) => openRegion(entry as unknown as HydrogenRegionShare)}
>
{regions.map((_, i) => (
{visibleRegions.map((_, i) => (
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
))}
</Pie>
@@ -118,16 +180,22 @@ export function DistributionCharts({ top5, regions, yearKg }: DistributionCharts
</div>
</div>
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
{regions.map((r, i) => (
<div key={r.region} className="flex items-center gap-1.5">
{visibleRegions.map((r, i) => (
<button key={r.region} type="button" onClick={() => openRegion(r)} className="flex min-w-0 items-center gap-1.5 rounded px-1 py-0.5 text-left hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500">
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: REGION_COLORS[i % REGION_COLORS.length] }} />
<span className="text-slate-600 truncate">{r.region}</span>
<span className="text-slate-400 ml-auto font-bold flex-shrink-0">{(r.share * 100).toFixed(1)}%</span>
</div>
</button>
))}
</div>
</div>
</div>
</div>
<OverviewDrillDialog
payload={drill}
onClose={() => { setDrill(null); setSelectedStationId(null); }}
onPrimaryAction={selectedStationId && onSelectStation ? () => { const stationId = selectedStationId; setDrill(null); setSelectedStationId(null); onSelectStation(stationId); } : undefined}
/>
</>
);
}
@@ -1,6 +1,13 @@
import { AlertTriangle, Building2, Gauge } from 'lucide-react';
import type { HydrogenMonthlyPoint } from '../../types';
import { formatKg as fmtKg } from '../model';
import { Activity, ChevronDown, Shield, TrendingDown } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import type { HydrogenMonthlyPoint, HydrogenStationFull } from '../../types';
import {
buildStationDrillPayload,
formatKg as fmtKg,
type OverviewDrillPayload,
type OverviewDrillRequest,
} from '../model';
import { OverviewDrillDialog } from './OverviewDrillDialog';
interface InsightCardsProps {
monthAvgKg: number;
@@ -8,13 +15,16 @@ interface InsightCardsProps {
latestMonth: HydrogenMonthlyPoint | undefined;
monthMomentum: number | null;
top5Share: number;
profitYield: number;
customerGrossMarginPct: number;
stationAvgKg: number;
stationCount: number;
yearProfitValue: string;
yearProfitUnit: string;
yearRevenueValue: string;
yearRevenueUnit: string;
stations: HydrogenStationFull[];
onSelectStation: (stationId: number) => void;
onDrillRequest?: (request: OverviewDrillRequest) => void;
}
export function InsightCards({
@@ -23,66 +33,144 @@ export function InsightCards({
latestMonth,
monthMomentum,
top5Share,
profitYield,
customerGrossMarginPct,
stationAvgKg,
stationCount,
yearProfitValue,
yearProfitUnit,
yearRevenueValue,
yearRevenueUnit,
stations,
onSelectStation,
onDrillRequest,
}: InsightCardsProps) {
const [rankingOpen, setRankingOpen] = useState(false);
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
const rankingRef = useRef<HTMLDivElement>(null);
const highestKg = stations[0]?.kg || 1;
useEffect(() => {
if (!rankingOpen) return;
const closeOnOutsideClick = (event: MouseEvent) => {
if (rankingRef.current && !rankingRef.current.contains(event.target as Node)) setRankingOpen(false);
};
document.addEventListener('mousedown', closeOnOutsideClick);
return () => document.removeEventListener('mousedown', closeOnOutsideClick);
}, [rankingOpen]);
const openStation = (station: HydrogenStationFull) => {
setRankingOpen(false);
setSelectedStationId(station.id);
if (onDrillRequest) onDrillRequest({ kind: 'station', key: String(station.id), label: station.name, entityId: station.id });
else setDrill(buildStationDrillPayload(station));
};
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 md:gap-3">
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-lg font-black text-slate-900">
{monthMomentum === null ? '暂无对比' : `${monthMomentum >= 0 ? '+' : ''}${monthMomentum.toFixed(1)}%`}
</div>
</div>
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100">
<Gauge size={18} />
</span>
<>
<div className="ehb-insight" aria-label="经营洞察">
<div className="ehb-insight__card">
<div className="ehb-insight__icon is-down">
<TrendingDown size={18} aria-hidden />
</div>
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
{latestMonth ? `${latestMonth.month} 加氢 ${fmtKg(latestMonth.kg).value}${fmtKg(latestMonth.kg).unit}` : '暂无月度数据'}
{bestMonth ? ` · 峰值 ${bestMonth.month}` : ''}
{monthAvgKg > 0 ? ` · 月均 ${fmtKg(monthAvgKg).value}${fmtKg(monthAvgKg).unit}` : ''}
<div>
<div className="ehb-insight__title"></div>
<div className="ehb-insight__value is-neg">
{monthMomentum === null ? '暂无对比' : `${monthMomentum >= 0 ? '+' : ''}${monthMomentum.toFixed(1)}%`}
</div>
<div className="ehb-insight__desc">
{latestMonth ? `${latestMonth.month} 加氢 ${fmtKg(latestMonth.kg).value}${fmtKg(latestMonth.kg).unit}` : '暂无月度数据'}
{bestMonth ? ` · 峰值 ${bestMonth.month}` : ''}
{monthAvgKg > 0 ? ` · 月均 ${fmtKg(monthAvgKg).value}${fmtKg(monthAvgKg).unit}` : ''}
</div>
</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-[11px] font-black text-slate-400"></div>
<div className="mt-1 text-lg font-black text-slate-900">Top5 {top5Share.toFixed(1)}%</div>
<div
className={`ehb-insight__card ehb-insight__card--rank ${rankingOpen ? 'is-open' : ''}`}
ref={rankingRef}
onClick={() => setRankingOpen((v) => !v)}
style={{ cursor: 'pointer' }}
title="点击查看加氢站加氢量排名"
>
<div className="ehb-insight__icon">
<Shield size={18} aria-hidden />
</div>
<div className="ehb-insight__rank-body">
<div className="ehb-insight__title"></div>
<div className="ehb-insight__value">Top5 {top5Share.toFixed(1)}%</div>
<div className="ehb-insight__desc">
{stationCount} · {fmtKg(stationAvgKg).value}{fmtKg(stationAvgKg).unit} ·
</div>
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-cyan-50 text-cyan-600 ring-1 ring-cyan-100">
<Building2 size={18} />
</span>
</div>
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
{stationCount} · {fmtKg(stationAvgKg).value}{fmtKg(stationAvgKg).unit}
{top5Share >= 70 ? ' · 头部站点依赖偏高' : ' · 分布相对健康'}
</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-[11px] font-black text-slate-400"></div>
<div className={`mt-1 text-lg font-black ${profitYield >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{profitYield.toFixed(1)}%
<ChevronDown
size={14}
className={`ehb-insight__rank-chevron ${rankingOpen ? 'is-open' : ''}`}
aria-hidden
/>
{rankingOpen && (
<div
className="ehb-station-rank-dropdown"
onClick={(e) => e.stopPropagation()}
role="listbox"
aria-label="加氢站加氢量排名"
>
<div className="ehb-station-rank-dropdown__head">
<span></span>
<span className="ehb-station-rank-dropdown__meta">
· {stationCount}
</span>
</div>
<div className="ehb-station-rank-dropdown__list">
{stations.map((station, index) => (
<button
key={`${station.id}-${station.name}-${index}`}
type="button"
className="ehb-station-rank-item"
onClick={() => openStation(station)}
title="点击钻取该站明细"
>
<span className={`ehb-station-rank-item__rank ${index < 3 ? 'is-top' : ''}`}>
{index + 1}
</span>
<span className="ehb-station-rank-item__main">
<span className="ehb-station-rank-item__name">{station.name}</span>
<span className="ehb-station-rank-item__bar">
<span style={{ width: `${Math.max(2, station.kg / highestKg * 100)}%` }} />
</span>
</span>
<span className="ehb-station-rank-item__val">
{fmtKg(station.kg).value} {fmtKg(station.kg).unit}
</span>
<span className="ehb-station-rank-item__share">{(station.share * 100).toFixed(1)}%</span>
</button>
))}
{stations.length === 0 && (
<div className="ehb-station-rank-empty"></div>
)}
</div>
</div>
<span className={`flex h-9 w-9 items-center justify-center rounded-xl ring-1 ${profitYield >= 0 ? 'bg-emerald-50 text-emerald-600 ring-emerald-100' : 'bg-rose-50 text-rose-600 ring-rose-100'}`}>
<AlertTriangle size={18} />
</span>
)}
</div>
<div className="ehb-insight__card">
<div className={`ehb-insight__icon ${customerGrossMarginPct >= 0 ? 'is-ok' : 'is-neg'}`}>
<Activity size={18} aria-hidden />
</div>
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
{yearProfitValue}{yearProfitUnit} · {yearRevenueValue}{yearRevenueUnit}
{profitYield < 0 ? ' · 需关注亏损站点与客户价格' : ' · 当前保持正向收益'}
<div>
<div className="ehb-insight__title"></div>
<div className={`ehb-insight__value ${customerGrossMarginPct >= 0 ? 'is-pos' : 'is-neg'}`}>
{customerGrossMarginPct.toFixed(1)}%
</div>
<div className="ehb-insight__desc">
{yearProfitValue}{yearProfitUnit} · {yearRevenueValue}{yearRevenueUnit}
{customerGrossMarginPct < 0 ? ' · 需关注客户价格与站点成本' : ' · 当前客户单保持正毛利'}
</div>
</div>
</div>
</div>
<OverviewDrillDialog
payload={drill}
onClose={() => { setDrill(null); setSelectedStationId(null); }}
onPrimaryAction={selectedStationId ? () => { const stationId = selectedStationId; setDrill(null); setSelectedStationId(null); onSelectStation(stationId); } : undefined}
/>
</>
);
}
@@ -1,43 +1,76 @@
import type { ReactNode } from 'react';
import { CalendarDays, Fuel, Sparkles, TrendingUp, Wallet } from 'lucide-react';
import { Activity, Fuel, Search, Truck, Wallet, Zap } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import type { HydrogenKpi } from '../../types';
import { formatKg as fmtKg, formatYuan as fmtYuan } from '../model';
import {
buildMetricDrillPayload,
formatKg as fmtKg,
formatYuan as fmtYuan,
type OverviewDrillPayload,
type OverviewDrillRequest,
type OverviewMetricKey,
type OverviewScope,
} from '../model';
import { OverviewDrillDialog } from './OverviewDrillDialog';
interface KpiCardProps {
icon: ReactNode;
metricKey: OverviewMetricKey;
label: string;
hero: { value: string; unit: string };
rows: { label: string; value: string; valueClass?: string }[];
accentClass: string;
iconBg: string;
rows: { label: string; value: string }[];
tone: 'blue' | 'green' | 'amber' | 'purple';
valueClass?: string;
onOpen: (key: OverviewMetricKey) => void;
}
function KpiCard({ icon, label, hero, rows, accentClass, iconBg }: KpiCardProps) {
const TONE_CLASS = {
blue: 'bg-blue-50 text-blue-600',
green: 'bg-emerald-50 text-emerald-600',
amber: 'bg-amber-50 text-amber-600',
purple: 'bg-violet-50 text-violet-600',
} as const;
function KpiCard({ icon, metricKey, label, hero, rows, tone, onOpen }: KpiCardProps) {
const isYuan = hero.value.startsWith('¥');
const numValue = isYuan ? hero.value.replace('¥', '') : hero.value;
return (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className={`w-7 h-7 rounded-xl flex items-center justify-center ${iconBg}`}>
{icon}
</div>
<span className="text-[11px] font-bold text-slate-500">{label}</span>
<div
className={`ehb-kpi-dual is-${tone}`}
onClick={() => onOpen(metricKey)}
title="点击查看真实汇总及可用下钻明细"
>
<div className="ehb-kpi-dual__head">
<span className="ehb-kpi-dual__label">
{label}
<span className="ehb-kpi-drill-hint">
<Search size={10} />
</span>
</span>
<span className={`ehb-kpi-dual__badge is-${tone}`}>{icon}</span>
</div>
<div className="flex items-baseline gap-1">
<span className={`text-xl md:text-2xl font-black tabular-nums leading-none ${accentClass}`}>{hero.value}</span>
<span className="text-[11px] text-slate-400 font-bold">{hero.unit}</span>
<div className="ehb-kpi-dual__val">
{isYuan && <span className="ehb-kpi-dual__symbol">¥</span>}
<span className="ehb-kpi-dual__num">{numValue}</span>
{hero.unit && <span className="ehb-kpi-dual__unit">{hero.unit}</span>}
</div>
<div className="space-y-0.5 pt-1 border-t border-slate-50">
{rows.map((r, i) => (
<div key={i} className="flex items-center justify-between text-[11px] font-bold">
<span className="text-slate-400">{r.label}</span>
<span className={`tabular-nums ${r.valueClass ?? 'text-slate-700'}`}>{r.value}</span>
</div>
<div className="ehb-kpi-dual__deck">
{rows.map((row) => (
<span key={row.label}>{row.label} {row.value}</span>
))}
</div>
</div>
);
}
export function KpiSection({ kpi: k }: { kpi: HydrogenKpi }) {
interface KpiSectionProps {
kpi: HydrogenKpi;
scope?: OverviewScope;
scopeLabel?: string | null;
onDrillRequest?: (request: OverviewDrillRequest) => void;
}
export function KpiSection({ kpi: k, scope = 'global', scopeLabel, onDrillRequest }: KpiSectionProps) {
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
const yearKgFmt = fmtKg(k.yearKg);
const yearFeeFmt = fmtYuan(k.yearFee);
const yearProfitFmt = fmtYuan(k.yearProfit);
@@ -50,65 +83,22 @@ export function KpiSection({ kpi: k }: { kpi: HydrogenKpi }) {
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 openDrill = (key: OverviewMetricKey) => {
if (onDrillRequest) onDrillRequest({ kind: 'metric', key, label: key });
else setDrill(buildMetricDrillPayload(k, key));
};
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
<KpiCard
icon={<Fuel size={14} className="text-cyan-600" strokeWidth={2.4} />}
iconBg="bg-cyan-50"
accentClass="text-slate-800"
label="累计加氢"
hero={yearKgFmt}
rows={[
{ label: '我司', value: `${ourYearKgFmt.value} ${ourYearKgFmt.unit}` },
{ label: '客户', value: `${customerYearKgFmt.value} ${customerYearKgFmt.unit}` },
]}
/>
<KpiCard
icon={<Wallet size={14} className="text-blue-600" strokeWidth={2.4} />}
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}` },
]}
/>
<KpiCard
icon={<TrendingUp size={14} className="text-emerald-600" strokeWidth={2.4} />}
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}` },
]}
/>
<KpiCard
icon={<CalendarDays size={14} className="text-amber-600" strokeWidth={2.4} />}
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'}%` },
]}
/>
<KpiCard
icon={<Sparkles size={14} className="text-violet-600" strokeWidth={2.4} />}
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'}%` },
]}
/>
</div>
<>
<div className="ehb-host-kpi" aria-label={`${scope === 'station' ? scopeLabel || '单站' : '全局'}经营指标`}>
<KpiCard metricKey="yearKg" icon={<Fuel size={14} />} tone="blue" label="累计加氢量" hero={yearKgFmt} rows={[{ label: '我司', value: `${ourYearKgFmt.value}${ourYearKgFmt.unit}` }, { label: '客户', value: `${customerYearKgFmt.value}${customerYearKgFmt.unit}` }]} onOpen={openDrill} />
<KpiCard metricKey="yearFee" icon={<Wallet size={14} />} tone="blue" 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}` }]} onOpen={openDrill} />
<KpiCard metricKey="yearProfit" icon={<Activity size={14} />} tone="green" label="客户单毛利" hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }} rows={[{ label: '收入', value: `¥${yearRevenueFmt.value}${yearRevenueFmt.unit}` }, { label: '成本', value: `¥${customerYearFeeFmt.value}${customerYearFeeFmt.unit}` }]} onOpen={openDrill} />
<KpiCard metricKey="monthKg" icon={<Truck size={14} />} tone="amber" label="本月加氢" hero={monthKgFmt} rows={[{ label: '加氢费', value: `¥${monthFeeFmt.value}${monthFeeFmt.unit}` }, { label: '占年比', value: `${k.yearKg > 0 ? (k.monthKg / k.yearKg * 100).toFixed(1) : '0.0'}%` }]} onOpen={openDrill} />
<KpiCard metricKey="todayKg" icon={<Zap size={14} />} tone="purple" label="本日加氢" hero={todayKgFmt} rows={[{ label: '加氢费', value: `¥${todayFeeFmt.value}${todayFeeFmt.unit}` }, { label: '占月比', value: `${k.monthKg > 0 ? (k.todayKg / k.monthKg * 100).toFixed(1) : '0.0'}%` }]} onOpen={openDrill} />
</div>
<OverviewDrillDialog payload={drill} onClose={() => setDrill(null)} />
</>
);
}
@@ -1,15 +1,23 @@
import {
Bar,
BarChart,
Cell,
LabelList,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { useState } from 'react';
import type { HydrogenMonthlyPoint } from '../../types';
import { formatYuan as fmtYuan } from '../model';
import {
buildMonthDrillPayload,
formatYuan as fmtYuan,
type OverviewDrillPayload,
type OverviewDrillRequest,
type OverviewScope,
} from '../model';
import { OverviewDrillDialog } from './OverviewDrillDialog';
type MonthlyChartPoint = HydrogenMonthlyPoint & { monthLabel: string };
@@ -17,19 +25,36 @@ interface MonthlyChartsProps {
activeYear: number;
monthly: HydrogenMonthlyPoint[];
monthlyDual: MonthlyChartPoint[];
scope?: OverviewScope;
scopeLabel?: string | null;
onDrillRequest?: (request: OverviewDrillRequest) => void;
}
export function MonthlyCharts({ activeYear, monthly, monthlyDual }: MonthlyChartsProps) {
export function MonthlyCharts({ activeYear, monthly, monthlyDual, scope = 'global', scopeLabel, onDrillRequest }: MonthlyChartsProps) {
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
const openMonthPoint = (point: MonthlyChartPoint | undefined) => {
if (!point) return;
if (onDrillRequest) onDrillRequest({ kind: 'month', key: point.month, label: `${point.month} 月度经营明细` });
else setDrill(buildMonthDrillPayload(point));
};
const openMonthFromChart = (state: unknown) => {
openMonthPoint((state as { activePayload?: { payload?: MonthlyChartPoint }[] } | undefined)?.activePayload?.[0]?.payload);
};
const openMonthFromBar = (entry: unknown) => {
openMonthPoint((entry as { payload?: MonthlyChartPoint } | undefined)?.payload);
};
const scopeText = scope === 'station' && scopeLabel ? ` · ${scopeLabel}` : '';
return (
<>
{monthly.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700">{activeYear} </span>
<span className="text-[11px] text-slate-400 font-bold"> Kg</span>
<div className="ehb-chart-box">
<div className="ehb-chart-box-head">
<div className="ehb-chart-box-title">{activeYear} {scopeText}</div>
<div className="ehb-chart-box-meta">Kg</div>
</div>
<ResponsiveContainer width="100%" height={140}>
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
<div className="ehb-chart-box-body">
<ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 1, height: 200 }}>
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }} onClick={openMonthFromChart} className="cursor-pointer">
<XAxis
dataKey="monthLabel"
tick={{ fontSize: 10, fill: '#94a3b8' }}
@@ -44,30 +69,29 @@ export function MonthlyCharts({ activeYear, monthly, monthlyDual }: MonthlyChart
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
/>
<Bar dataKey="kg" radius={[4, 4, 0, 0]}>
{monthlyDual.map((_, i) => (
<Cell key={i} fill="url(#monthlyBarGrad)" />
))}
<Legend verticalAlign="top" height={24} iconSize={8} wrapperStyle={{ fontSize: 11, paddingBottom: 4 }} />
<Bar dataKey="lingniuKg" name="羚牛车辆" stackId="kg" fill="#4ba3df" radius={[0, 0, 0, 0]} onClick={openMonthFromBar} />
<Bar dataKey="externalKg" name="外部车辆" stackId="kg" fill="#f4bb45" radius={[4, 4, 0, 0]} onClick={openMonthFromBar}>
<LabelList dataKey="kg" position="top" formatter={value => {
const total = Number(value ?? 0);
return total >= 1000 ? `${(total / 1000).toFixed(1)}k` : total.toFixed(0);
}} fill="#475569" fontSize={10} fontWeight={700} />
</Bar>
<defs>
<linearGradient id="monthlyBarGrad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#22d3ee" />
<stop offset="100%" stopColor="#3b82f6" />
</linearGradient>
</defs>
</BarChart>
</ResponsiveContainer>
</div>
</div>
)}
{monthly.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700">{activeYear} </span>
<span className="text-[11px] text-slate-400 font-bold"> </span>
<div className="ehb-chart-box">
<div className="ehb-chart-box-head">
<div className="ehb-chart-box-title">{activeYear} {scopeText}</div>
<div className="ehb-chart-box-meta"></div>
</div>
<ResponsiveContainer width="100%" height={180}>
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
<div className="ehb-chart-box-body">
<ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 1, height: 200 }}>
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }} onClick={openMonthFromChart} className="cursor-pointer">
<XAxis
dataKey="monthLabel"
tick={{ fontSize: 10, fill: '#94a3b8' }}
@@ -90,12 +114,14 @@ export function MonthlyCharts({ activeYear, monthly, monthlyDual }: MonthlyChart
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(148, 163, 184, 0.06)' }}
/>
<Bar dataKey="fee" name="成本支出" fill="#f59e0b" radius={[3, 3, 0, 0]} />
<Bar dataKey="revenue" name="客户收入" fill="#10b981" radius={[3, 3, 0, 0]} />
<Bar dataKey="fee" name="成本支出" fill="#f59e0b" radius={[3, 3, 0, 0]} onClick={openMonthFromBar} />
<Bar dataKey="revenue" name="客户收入" fill="#10b981" radius={[3, 3, 0, 0]} onClick={openMonthFromBar} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
)}
<OverviewDrillDialog payload={drill} onClose={() => setDrill(null)} />
</>
);
}
@@ -0,0 +1,165 @@
import { ChevronLeft, Database, ExternalLink, Search, X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import type { OverviewDrillPayload } from '../model';
import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from '../../components/SortableColumnHeader';
interface OverviewDrillDialogProps {
payload: OverviewDrillPayload | null;
onClose: () => void;
onPrimaryAction?: () => void;
onGroupByChange?: (groupBy: 'station' | 'customer' | 'vehicle') => void;
onRowSelect?: (row: Record<string, string | number>) => void;
}
const TONE_CLASS = {
default: 'text-slate-900',
blue: 'text-sky-600',
green: 'text-emerald-600',
amber: 'text-amber-600',
red: 'text-rose-600',
} as const;
export function OverviewDrillDialog({ payload, onClose, onPrimaryAction, onGroupByChange, onRowSelect }: OverviewDrillDialogProps) {
const [sortKey, setSortKey] = useState('');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
useEffect(() => {
if (!payload) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', onKeyDown);
};
}, [onClose, payload]);
useEffect(() => {
if (!payload?.columns.length) return;
setSortKey(payload.columns[0].key);
setSortDirection('desc');
}, [payload]);
const sortedRows = useMemo(
() => payload ? sortBy(payload.rows, sortKey, sortDirection, (row, key) => row[key]) : [],
[payload, sortDirection, sortKey],
);
const changeSort = (nextKey: string) => {
const next = toggleSort(sortKey, sortDirection, nextKey);
setSortKey(next.key);
setSortDirection(next.direction);
};
if (!payload || typeof document === 'undefined') return null;
return createPortal(
<div
className="fixed inset-0 z-[1000] flex items-center justify-center bg-slate-950/70 p-3 backdrop-blur-[8px] md:p-5"
onMouseDown={event => {
if (event.target === event.currentTarget) onClose();
}}
role="presentation"
>
<section
className="flex max-h-[90vh] w-full max-w-[1100px] flex-col overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.35)]"
role="dialog"
aria-modal="true"
aria-labelledby="overview-drill-title"
>
<header className="flex items-center justify-between gap-3 bg-slate-900 px-3 py-3 text-white md:px-5 md:py-4">
<div className="flex min-w-0 items-center gap-2.5">
<button type="button" onClick={onClose} className="inline-flex h-8 shrink-0 items-center gap-1 rounded-lg border border-white/20 bg-white/10 px-2.5 text-[12px] font-bold text-slate-100 transition hover:border-sky-400 hover:bg-sky-400/15 hover:text-sky-300">
<ChevronLeft size={16} />
<span className="hidden sm:inline"></span>
</button>
<div className="min-w-0">
<h2 id="overview-drill-title" className="flex items-center gap-2 truncate text-[14px] font-bold text-slate-50 md:text-[16px]">
<Search size={16} className="shrink-0 text-sky-300" />
<span className="truncate">{payload.title}</span>
</h2>
<p className="mt-0.5 truncate text-[11px] font-medium text-slate-400 md:text-[12px]">{payload.subtitle}</p>
</div>
</div>
<button type="button" onClick={onClose} className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-white/10 text-slate-300 transition hover:bg-rose-500/80 hover:text-white" aria-label="关闭下钻弹层">
<X size={17} />
</button>
</header>
<div className="flex-1 overflow-y-auto bg-slate-50 p-3 md:p-5">
<div className="grid grid-cols-2 gap-2 rounded-lg border border-slate-200 bg-white p-3 md:grid-cols-4 md:gap-4 md:px-[18px] md:py-[14px]">
{payload.metrics.map(metric => (
<div key={metric.label} className="min-w-0">
<div className="text-[11px] font-medium text-slate-500">{metric.label}</div>
<div className={`mt-1 truncate text-[16px] font-extrabold tabular-nums ${TONE_CLASS[metric.tone ?? 'default']}`} title={metric.value}>{metric.value}</div>
</div>
))}
</div>
{payload.groupByOptions?.length && onGroupByChange ? (
<div className="mt-4 flex items-center justify-between gap-3 rounded-lg border border-slate-200 bg-white px-3 py-2.5">
<div className="text-[11px] font-medium text-slate-500"></div>
<div className="flex rounded-md bg-slate-100 p-0.5">
{payload.groupByOptions.map(groupBy => (
<button
key={groupBy}
type="button"
onClick={() => onGroupByChange(groupBy)}
className={`rounded px-3 py-1.5 text-[11px] font-bold transition ${payload.groupBy === groupBy ? 'bg-white text-sky-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
{groupBy === 'customer' ? '客户' : groupBy === 'station' ? '加氢站' : '车辆'}
</button>
))}
</div>
</div>
) : null}
{payload.columns.length > 0 && payload.rows.length > 0 ? (
<div className="mt-4 max-h-[440px] overflow-auto rounded-lg border border-slate-200 bg-white">
<table className="w-full min-w-[620px] border-collapse text-left">
<thead className="sticky top-0 z-10 bg-slate-100">
<tr>
{payload.columns.map(column => (
<th key={column.key} className={`border-b border-slate-200 px-3 py-2.5 text-[11px] font-bold text-slate-600 ${column.align === 'right' ? 'text-right' : 'text-left'}`}><SortableColumnHeader label={column.label} sortKey={column.key} activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align={column.align === 'right' ? 'right' : 'left'} /></th>
))}
</tr>
</thead>
<tbody>
{sortedRows.map((row, rowIndex) => (
<tr
key={rowIndex}
className={`border-b border-slate-100 last:border-0 hover:bg-slate-50 ${onRowSelect && payload.rowActionLabel ? 'cursor-pointer' : ''}`}
onClick={() => onRowSelect?.(row)}
title={onRowSelect && payload.rowActionLabel ? payload.rowActionLabel : undefined}
>
{payload.columns.map(column => (
<td key={column.key} className={`whitespace-nowrap px-3 py-2.5 text-[12px] text-slate-700 ${column.align === 'right' ? 'text-right font-semibold tabular-nums' : 'text-left'}`}>{row[column.key]}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="mt-4 flex min-h-[180px] flex-col items-center justify-center rounded-lg border border-dashed border-slate-300 bg-white px-5 text-center">
<span className="grid h-10 w-10 place-items-center rounded-lg bg-sky-50 text-sky-600"><Database size={19} /></span>
<div className="mt-3 text-[13px] font-bold text-slate-700"></div>
<p className="mt-1 max-w-[620px] text-[11px] font-medium leading-5 text-slate-500">{payload.emptyMessage}</p>
</div>
)}
{payload.primaryActionLabel && onPrimaryAction ? (
<div className="mt-4 flex justify-end">
<button type="button" onClick={onPrimaryAction} className="inline-flex h-9 items-center gap-1.5 rounded-lg bg-sky-600 px-3.5 text-[12px] font-bold text-white shadow-sm transition hover:bg-sky-700">
{payload.primaryActionLabel}<ExternalLink size={14} />
</button>
</div>
) : null}
</div>
</section>
</div>,
document.body,
);
}
@@ -0,0 +1,378 @@
import { ChevronDown, ChevronRight, Database, Download, Search, Truck, X } from 'lucide-react';
import { Fragment, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import * as XLSX from 'xlsx';
import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from '../../components/SortableColumnHeader';
import type {
HydrogenOverviewDetailGroup,
HydrogenOverviewDetailGroupBy,
HydrogenOverviewDetailRecord,
HydrogenOverviewDetailResponse,
} from '../../types';
import type { HydrogenVehicleScope } from '../../api';
type Selection = {
stationId?: number | null;
customerId?: number | null;
customerName?: string | null;
plateNo?: string | null;
};
type TreeSortKey = 'name' | 'ownership' | 'source' | 'verify' | 'recordCount' | 'kg' | 'cost' | 'revenue';
interface OverviewDrillTreeDialogProps {
title: string;
initialVehicleScope: HydrogenVehicleScope;
initialSelection?: Selection;
load: (
groupBy: HydrogenOverviewDetailGroupBy | null,
selection: Selection,
vehicleScope: HydrogenVehicleScope,
includeAll?: boolean,
) => Promise<HydrogenOverviewDetailResponse>;
onClose: () => void;
}
const number = (value: number, digits = 2) => value.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
const customerKey = (stationId: number | string, customer: HydrogenOverviewDetailGroup) => `${stationId}:${customer.id}:${customer.name}`;
const vehicleKey = (stationId: number | string, customer: HydrogenOverviewDetailGroup, vehicle: HydrogenOverviewDetailGroup) => `${customerKey(stationId, customer)}:${vehicle.name}`;
/** 原型定义的四层账本树:加氢站 -> 客户 -> 车辆 -> 单笔订单与核对明细。 */
export function OverviewDrillTreeDialog({ title, initialVehicleScope, initialSelection = {}, load, onClose }: OverviewDrillTreeDialogProps) {
const [root, setRoot] = useState<HydrogenOverviewDetailResponse | null>(null);
const [customers, setCustomers] = useState<Record<string, HydrogenOverviewDetailGroup[]>>({});
const [vehicles, setVehicles] = useState<Record<string, HydrogenOverviewDetailGroup[]>>({});
const [orders, setOrders] = useState<Record<string, HydrogenOverviewDetailRecord[]>>({});
const [openStations, setOpenStations] = useState<Record<string, boolean>>({});
const [openCustomers, setOpenCustomers] = useState<Record<string, boolean>>({});
const [openVehicles, setOpenVehicles] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState<string | null>('root');
const [stationFilter, setStationFilter] = useState('all');
const [customerFilter, setCustomerFilter] = useState('all');
const [plateFilter, setPlateFilter] = useState('all');
const [vehicleScope, setVehicleScope] = useState<HydrogenVehicleScope>(initialVehicleScope);
const [exporting, setExporting] = useState(false);
const [sortKey, setSortKey] = useState<TreeSortKey>('kg');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const loadRoot = useCallback(async (scope: HydrogenVehicleScope) => {
setLoading('root');
setCustomers({});
setVehicles({});
setOrders({});
setOpenStations({});
setOpenCustomers({});
setOpenVehicles({});
try {
setRoot(await load('station', initialSelection, scope));
} catch {
setRoot(null);
} finally {
setLoading(null);
}
}, [initialSelection, load]);
useEffect(() => { void loadRoot(vehicleScope); }, [loadRoot, vehicleScope]);
const toggleStation = async (station: HydrogenOverviewDetailGroup) => {
const key = String(station.id);
if (openStations[key]) {
setOpenStations(value => ({ ...value, [key]: false }));
return;
}
setOpenStations(value => ({ ...value, [key]: true }));
if (customers[key]) return;
setLoading(`station:${key}`);
try {
const data = await load('customer', { ...initialSelection, stationId: Number(station.id) || null }, vehicleScope);
setCustomers(value => ({ ...value, [key]: data.groups }));
} finally {
setLoading(null);
}
};
const toggleCustomer = async (stationId: number | string, customer: HydrogenOverviewDetailGroup) => {
const key = customerKey(stationId, customer);
if (openCustomers[key]) {
setOpenCustomers(value => ({ ...value, [key]: false }));
return;
}
setOpenCustomers(value => ({ ...value, [key]: true }));
if (vehicles[key]) return;
setLoading(`customer:${key}`);
try {
const data = await load('vehicle', {
...initialSelection,
stationId: Number(stationId) || null,
customerId: Number(customer.id) || 0,
customerName: customer.name,
}, vehicleScope);
setVehicles(value => ({ ...value, [key]: data.groups }));
} finally {
setLoading(null);
}
};
const toggleVehicle = async (stationId: number | string, customer: HydrogenOverviewDetailGroup, vehicle: HydrogenOverviewDetailGroup) => {
const key = vehicleKey(stationId, customer, vehicle);
if (openVehicles[key]) {
setOpenVehicles(value => ({ ...value, [key]: false }));
return;
}
setOpenVehicles(value => ({ ...value, [key]: true }));
if (orders[key]) return;
setLoading(`vehicle:${key}`);
try {
const data = await load(null, {
...initialSelection,
stationId: Number(stationId) || null,
customerId: Number(customer.id) || 0,
customerName: customer.name,
plateNo: vehicle.name,
}, vehicleScope);
setOrders(value => ({ ...value, [key]: data.records }));
} finally {
setLoading(null);
}
};
const stationOptions = root?.groups ?? [];
const selectedStation = stationFilter === 'all' ? null : stationOptions.find(station => String(station.id) === stationFilter) ?? null;
const customerOptions = selectedStation ? customers[String(selectedStation.id)] ?? [] : [];
const selectedCustomer = customerFilter === 'all' ? null : customerOptions.find(customer => customerKey(selectedStation?.id ?? '', customer) === customerFilter) ?? null;
const plateOptions = selectedStation && selectedCustomer ? vehicles[customerKey(selectedStation.id, selectedCustomer)] ?? [] : [];
const visibleStations = useMemo(() => {
const filtered = stationFilter === 'all' ? stationOptions : stationOptions.filter(station => String(station.id) === stationFilter);
return sortTreeGroups(filtered, sortKey, sortDirection);
}, [sortDirection, sortKey, stationFilter, stationOptions]);
const selectStation = (value: string) => {
setStationFilter(value);
setCustomerFilter('all');
setPlateFilter('all');
const station = stationOptions.find(item => String(item.id) === value);
if (station && !openStations[String(station.id)]) void toggleStation(station);
};
const selectCustomer = (value: string) => {
setCustomerFilter(value);
setPlateFilter('all');
const customer = customerOptions.find(item => customerKey(selectedStation?.id ?? '', item) === value);
if (selectedStation && customer && !openCustomers[value]) void toggleCustomer(selectedStation.id, customer);
};
const changeVehicleScope = (scope: HydrogenVehicleScope) => {
if (scope === vehicleScope) return;
setVehicleScope(scope);
setStationFilter('all');
setCustomerFilter('all');
setPlateFilter('all');
};
const changeSort = (nextKey: TreeSortKey) => {
const next = toggleSort(sortKey, sortDirection, nextKey);
setSortKey(next.key);
setSortDirection(next.direction);
};
const exportAll = async () => {
setExporting(true);
try {
const detail = await load(null, {
...initialSelection,
stationId: selectedStation ? Number(selectedStation.id) || null : null,
customerId: selectedCustomer ? Number(selectedCustomer.id) || 0 : null,
customerName: selectedCustomer?.name ?? null,
plateNo: plateFilter === 'all' ? null : plateFilter,
}, vehicleScope, true);
const rows = detail.records.map(record => ({
加氢时间: record.refuelTime,
加氢站: record.stationName,
客户: record.customerName,
车牌: record.plateNo,
车辆归属: record.vehicleScope === 'lingniu' ? '羚牛车辆' : '外部车辆',
数据来源: record.source,
核对状态: verifyText(record.verifyStatus),
订单编号: record.orderNo,
加氢量Kg: record.kg,
成本元: record.cost,
客户收入元: record.revenue,
}));
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(rows), '穿透账单');
XLSX.writeFile(workbook, `${title.replaceAll(/[\\/:*?"<>|]/g, '_')}_穿透账单.xlsx`);
if (detail.truncated) window.alert('当前筛选范围超过 20,000 笔,导出已截取前 20,000 笔。请收窄筛选范围后再次导出。');
} finally {
setExporting(false);
}
};
if (typeof document === 'undefined') return null;
const summary = root?.summary;
return createPortal(
<div className="ehb-modal-overlay" onMouseDown={event => { if (event.target === event.currentTarget) onClose(); }}>
<section className="ehb-modal-card" role="dialog" aria-modal="true" aria-label={`${title}穿透明细`}>
<header className="ehb-modal-head">
<div className="ehb-modal-head__title-group">
<div>
<h2 className="ehb-modal-head__title">{title}</h2>
<p className="ehb-modal-head__sub"> </p>
</div>
</div>
<button type="button" onClick={onClose} className="ehb-modal-close-btn" aria-label="关闭下钻弹层"><X size={17} /></button>
</header>
<div className="ehb-modal-body">
<div className="ehb-modal-meta-bar">
<Metric label="统计范围" value={title} />
<Metric label="加氢总量" value={`${number(summary?.kg ?? 0, 2)} Kg`} tone="text-sky-600" />
<Metric label="涉及加氢站" value={`${summary?.stationCount ?? 0}`} />
<Metric label="账本流水" value={`${summary?.recordCount ?? 0}`} />
</div>
<div className="ehb-modal-filter-row">
<div className="ehb-modal-filter-group">
<SearchSelect allLabel="全部加氢站" value={stationFilter} onChange={selectStation} options={stationOptions.map(item => ({ value: String(item.id), label: item.name }))} />
<SearchSelect allLabel="全部客户" value={customerFilter} onChange={selectCustomer} disabled={!selectedStation} options={customerOptions.map(item => ({ value: customerKey(selectedStation?.id ?? '', item), label: item.name }))} />
<SearchSelect allLabel="全部车辆" value={plateFilter} onChange={setPlateFilter} disabled={!selectedCustomer} options={plateOptions.map(item => ({ value: item.name, label: item.name }))} />
<div className="flex h-8 overflow-hidden rounded-md border border-slate-300 bg-white text-[11px]">
{([{ key: 'all', label: '全部车辆' }, { key: 'lingniu', label: '仅羚牛车辆' }, { key: 'external', label: '仅外部车辆' }] as const).map(item => (
<button key={item.key} type="button" onClick={() => changeVehicleScope(item.key)} className={`inline-flex items-center gap-1 px-2.5 font-medium transition-colors ${vehicleScope === item.key ? 'bg-sky-50 text-sky-700 shadow-sm' : 'text-slate-500 hover:bg-slate-50'}`}>
{item.key !== 'all' ? <Truck size={13} /> : null}{item.label}
</button>
))}
</div>
<p className="ehb-modal-hint-text"></p>
</div>
<button type="button" onClick={() => void exportAll()} disabled={exporting} className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-slate-300 bg-white px-2.5 text-[11px] font-semibold text-slate-600 transition-colors hover:border-sky-300 hover:bg-sky-50 hover:text-sky-700 disabled:opacity-60">
<Download size={14} />{exporting ? '正在导出…' : '导出 Excel 穿透账单'}
</button>
</div>
<p className="mb-2 text-center text-[11px] text-slate-400 md:hidden"></p>
<div className="ehb-modal-table-wrap is-v-scroll">
<table className="ehb-modal-table min-w-[1080px]">
<thead className="sticky top-0 z-10">
<tr>
<th className="min-w-[240px] border-b border-slate-200 px-3 py-2.5"><SortableColumnHeader label="加氢站 / 客户 / 车辆与凭证链路" sortKey="name" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
<th className="border-b border-slate-200 px-3 py-2.5"><SortableColumnHeader label="类型 / 归属" sortKey="ownership" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
<th className="border-b border-slate-200 px-3 py-2.5"><SortableColumnHeader label="数据来源及凭证号" sortKey="source" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
<th className="border-b border-slate-200 px-3 py-2.5"><SortableColumnHeader label="核对状态" sortKey="verify" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
<th className="border-b border-slate-200 px-3 py-2.5 text-right"><SortableColumnHeader label="加氢笔数" sortKey="recordCount" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<th className="border-b border-slate-200 px-3 py-2.5 text-right"><SortableColumnHeader label="加氢总量 (Kg)" sortKey="kg" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<th className="border-b border-slate-200 px-3 py-2.5 text-right"><SortableColumnHeader label="成本 (元)" sortKey="cost" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<th className="border-b border-slate-200 px-3 py-2.5 text-right"><SortableColumnHeader label="客户收入 (元)" sortKey="revenue" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
</tr>
</thead>
<tbody>
{visibleStations.map(station => {
const stationKey = String(station.id);
return <StationBranch key={`${stationKey}:${station.name}`} station={station} expanded={Boolean(openStations[stationKey])} loadingKey={loading} customers={customers[stationKey] ?? []} customerFilter={customerFilter} plateFilter={plateFilter} openCustomers={openCustomers} vehicles={vehicles} orders={orders} openVehicles={openVehicles} sortKey={sortKey} sortDirection={sortDirection} onStation={() => void toggleStation(station)} onCustomer={customer => void toggleCustomer(station.id, customer)} onVehicle={(customer, vehicle) => void toggleVehicle(station.id, customer, vehicle)} />;
})}
</tbody>
</table>
</div>
{!root && loading === null ? <div className="mt-4 flex items-center gap-2 rounded-lg border border-dashed border-slate-300 bg-white p-5 text-sm text-slate-500"><Database size={18} /></div> : null}
</div>
</section>
</div>, document.body,
);
}
function Metric({ label, value, tone = 'text-slate-900' }: { label: string; value: string; tone?: string }) {
return <div className="ehb-modal-meta-item"><div className="ehb-modal-meta-label">{label}</div><div className={`ehb-modal-meta-val ${tone}`} title={value}>{value}</div></div>;
}
function SearchSelect({ allLabel, value, onChange, options, disabled = false }: { allLabel: string; value: string; onChange: (value: string) => void; options: { value: string; label: string }[]; disabled?: boolean }) {
const [open, setOpen] = useState(false);
const [keyword, setKeyword] = useState('');
const selected = options.find(option => option.value === value);
const filtered = options.filter(option => option.label.toLowerCase().includes(keyword.trim().toLowerCase()));
const pick = (next: string) => {
onChange(next);
setOpen(false);
setKeyword('');
};
return <div className={`ehb-bi-search-select w-[170px] ${disabled ? 'is-disabled' : ''}`}>
<button type="button" aria-label={allLabel} aria-expanded={open} disabled={disabled} onClick={() => setOpen(value => !value)} className={`ehb-bi-search-select__trigger ${open ? 'is-open' : ''} ${selected ? 'has-value' : ''}`}>
<span className="ehb-bi-search-select__label">{selected?.label ?? allLabel}</span><ChevronDown size={14} className="ehb-bi-search-select__chevron" />
</button>
{open ? <div className="ehb-bi-search-select__dropdown">
<label className="ehb-bi-search-select__search"><Search size={13} /><input autoFocus value={keyword} onChange={event => setKeyword(event.target.value)} placeholder={`搜索${allLabel.replace('全部', '')}`} /></label>
<div className="ehb-bi-search-select__list">
<button type="button" onClick={() => pick('all')} className={`ehb-bi-search-select__item ${value === 'all' ? 'is-selected' : ''}`}>{allLabel}</button>
{filtered.map(option => <button key={option.value} type="button" onClick={() => pick(option.value)} className={`ehb-bi-search-select__item ${value === option.value ? 'is-selected' : ''}`}>{option.label}</button>)}
{filtered.length === 0 ? <p className="ehb-bi-search-select__empty"></p> : null}
</div>
</div> : null}
</div>;
}
function ExpandMark({ open }: { open: boolean }) { return open ? <ChevronDown size={15} className="shrink-0 text-sky-600" /> : <ChevronRight size={15} className="shrink-0 text-sky-600" />; }
function StationBranch({ station, expanded, loadingKey, customers, customerFilter, plateFilter, openCustomers, vehicles, orders, openVehicles, sortKey, sortDirection, onStation, onCustomer, onVehicle }: {
station: HydrogenOverviewDetailGroup; expanded: boolean; loadingKey: string | null; customers: HydrogenOverviewDetailGroup[]; customerFilter: string; plateFilter: string; openCustomers: Record<string, boolean>; vehicles: Record<string, HydrogenOverviewDetailGroup[]>; orders: Record<string, HydrogenOverviewDetailRecord[]>; openVehicles: Record<string, boolean>; sortKey: TreeSortKey; sortDirection: SortDirection; onStation: () => void; onCustomer: (customer: HydrogenOverviewDetailGroup) => void; onVehicle: (customer: HydrogenOverviewDetailGroup, vehicle: HydrogenOverviewDetailGroup) => void;
}) {
const stationKey = String(station.id);
const visibleCustomers = sortTreeGroups(customerFilter === 'all' ? customers : customers.filter(customer => customerKey(station.id, customer) === customerFilter), sortKey, sortDirection);
return <>
<TreeRow className={expanded ? 'bg-sky-50 font-bold' : 'bg-slate-50 font-bold'} onClick={onStation} label={<><ExpandMark open={expanded} />{station.name}<span className="ml-2 text-[11px] font-medium text-slate-400">({station.customerCount} )</span></>} ownership="-" source="全量自动归集" verify="-" group={station} />
{expanded && visibleCustomers.map(customer => {
const key = customerKey(stationKey, customer);
const childVehicles = vehicles[key] ?? [];
const visibleVehicles = sortTreeGroups(plateFilter === 'all' ? childVehicles : childVehicles.filter(vehicle => vehicle.name === plateFilter), sortKey, sortDirection);
return <Fragment key={key}>
<TreeRow className={openCustomers[key] ? 'bg-slate-100' : 'bg-white'} onClick={() => onCustomer(customer)} indent={1} label={<><ExpandMark open={Boolean(openCustomers[key])} /> {customer.name}</>} ownership="客户" source={`${childVehicles.length || '待'} 辆车挂载`} verify="-" group={customer} />
{openCustomers[key] && visibleVehicles.map(vehicle => {
const key = vehicleKey(stationKey, customer, vehicle);
return <Fragment key={key}>
<TreeRow className={openVehicles[key] ? 'bg-slate-100 text-[11px]' : 'bg-white text-[11px]'} onClick={() => onVehicle(customer, vehicle)} indent={2} label={<><ExpandMark open={Boolean(openVehicles[key])} /><strong>{vehicle.name}</strong><span className="ml-1 text-[10px] font-normal text-slate-400">({vehicle.recordCount} )</span></>} ownership={vehicle.vehicleScope === 'lingniu' ? '羚牛车辆' : '外部车辆'} source={vehicle.source ?? '未知来源'} verify={verifyText(vehicle.verifyStatus)} group={vehicle} />
{openVehicles[key] && <OrderRows orders={orders[key] ?? []} sortKey={sortKey} sortDirection={sortDirection} />}
</Fragment>;
})}
{openCustomers[key] && loadingKey === `customer:${key}` ? <LoadingRow text="正在读取车辆汇总…" /> : null}
</Fragment>;
})}
{expanded && loadingKey === `station:${stationKey}` ? <LoadingRow text="正在读取客户汇总…" /> : null}
</>;
}
function TreeRow({ label, ownership, source, verify, group, indent = 0, className, onClick }: { label: ReactNode; ownership: string; source: string; verify: string; group: HydrogenOverviewDetailGroup; indent?: number; className: string; onClick: () => void }) {
return <tr className={`${className} cursor-pointer border-b border-slate-100 transition-colors hover:bg-slate-50`} onClick={onClick}><td className="px-3 py-2.5" style={{ paddingLeft: `${12 + indent * 16}px` }}><span className="inline-flex items-center gap-1.5">{label}</span></td><td className="px-3 py-2.5 text-slate-500">{ownership}</td><td className="px-3 py-2.5 text-[11px] text-slate-500">{source}</td><td className="px-3 py-2.5">{verify === '-' ? <span className="text-slate-400">-</span> : <VerifyTag value={verify} />}</td><td className="px-3 py-2.5 text-right font-mono text-slate-600">{group.recordCount} </td><td className="px-3 py-2.5 text-right font-mono font-semibold text-sky-600">{number(group.kg, 3)}</td><td className="px-3 py-2.5 text-right font-mono">{number(group.cost)}</td><td className="px-3 py-2.5 text-right font-mono">{number(group.revenue)}</td></tr>;
}
function OrderRows({ orders, sortKey, sortDirection }: { orders: HydrogenOverviewDetailRecord[]; sortKey: TreeSortKey; sortDirection: SortDirection }) {
return <>{sortTreeOrders(orders, sortKey, sortDirection).map(order => <tr key={order.id} className="border-b border-slate-100 bg-slate-50 text-[11px]"><td className="px-3 py-2" style={{ paddingLeft: '76px' }}><span className="mr-1.5 text-slate-300"></span><span className="mr-1 text-[10px] text-slate-500"></span><span className="font-mono font-semibold text-sky-600">{order.orderNo || order.id}</span><span className="ml-1 text-[10px] text-slate-500">({order.refuelTime})</span></td><td className="px-3 py-2 text-[10px] text-slate-500"> ¥{order.costPrice.toFixed(2)}/Kg</td><td className="px-3 py-2">{order.source}</td><td className="px-3 py-2"><VerifyTag value={verifyText(order.verifyStatus)} /></td><td className="px-3 py-2 text-right font-mono text-slate-400">1 </td><td className="px-3 py-2 text-right font-mono text-sky-600">{number(order.kg, 3)}</td><td className="px-3 py-2 text-right font-mono">{number(order.cost)}</td><td className="px-3 py-2 text-right font-mono">{number(order.revenue)}</td></tr>)}</>;
}
function sortTreeGroups(rows: HydrogenOverviewDetailGroup[], sortKey: TreeSortKey, sortDirection: SortDirection) {
return sortBy(rows, sortKey, sortDirection, (row, key) => {
if (key === 'ownership') return row.vehicleScope ?? '';
if (key === 'verify') return row.verifyStatus ?? '';
return row[key === 'name' ? 'name' : key] ?? '';
});
}
function sortTreeOrders(rows: HydrogenOverviewDetailRecord[], sortKey: TreeSortKey, sortDirection: SortDirection) {
return sortBy(rows, sortKey, sortDirection, (row, key) => {
if (key === 'name') return row.orderNo || row.id;
if (key === 'ownership') return row.vehicleScope;
if (key === 'verify') return row.verifyStatus;
if (key === 'recordCount') return 1;
return row[key] ?? '';
});
}
function LoadingRow({ text }: { text: string }) { return <tr><td className="px-8 py-3 text-xs text-slate-400" colSpan={8}>{text}</td></tr>; }
function verifyText(value?: string) {
const normalized = (value ?? '').toUpperCase();
if (normalized === 'VERIFIED' || normalized === 'PASS') return '已核对';
if (normalized === 'PARTIAL') return '部分核对';
if (normalized === 'FAILED' || normalized === 'REJECT') return '异常';
return '未核对';
}
function VerifyTag({ value }: { value: string }) {
const color = value === '已核对' ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : value === '部分核对' ? 'border-amber-100 bg-amber-50 text-amber-700' : value === '异常' ? 'border-rose-100 bg-rose-50 text-rose-700' : 'border-slate-200 bg-slate-50 text-slate-500';
return <span className={`inline-flex rounded border px-1.5 py-0.5 text-[10px] font-medium ${color}`}>{value}</span>;
}
@@ -1,53 +1,174 @@
import { RefreshCw } from 'lucide-react';
import { ChevronDown, RefreshCw, Truck } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import type { HydrogenVehicleScope } from '../../api';
import { formatRefreshTime } from '../model';
interface BiYearSelectProps {
value: number;
years: number[];
onChange: (year: number) => void;
}
function BiYearSelect({ value, years, onChange }: BiYearSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
return (
<div className="ehb-year-select-wrapper" ref={ref}>
<button
type="button"
className={`ehb-year-select-btn ${isOpen ? 'is-active' : ''}`}
onClick={() => setIsOpen(!isOpen)}
aria-label="年份选择"
>
<span className="ehb-year-text">{value} </span>
<ChevronDown
size={13}
style={{
transition: 'transform 0.2s ease',
transform: isOpen ? 'rotate(180deg)' : 'none',
color: '#64748b',
}}
/>
</button>
{isOpen && (
<div className="ehb-year-dropdown">
<div className="ehb-year-dropdown__header"></div>
<div className="ehb-year-dropdown__list">
{years.map((y) => (
<button
key={y}
type="button"
className={`ehb-year-dropdown__item ${y === value ? 'is-selected' : ''}`}
onClick={() => {
onChange(y);
setIsOpen(false);
}}
>
<span>{y} </span>
{y === value && <span className="ehb-year-check"></span>}
</button>
))}
</div>
</div>
)}
</div>
);
}
interface OverviewHeaderProps {
activeYear: number;
availableYears: number[];
lastRefreshAt: number;
refreshing: boolean;
vehicleScope: HydrogenVehicleScope;
verifyScope?: 'all' | 'verified';
onVerifyScopeChange?: (scope: 'all' | 'verified') => void;
selectedStationId?: number | null;
selectedStationName?: string | null;
stations?: { id: number; name: string }[];
latestLedgerTime?: string | null;
lastRefreshAt?: number;
refreshing?: boolean;
onSelectYear: (year: number) => void;
onVehicleScopeChange: (scope: HydrogenVehicleScope) => void;
onStationChange?: (stationId: number | null) => void;
onRefresh: () => void;
}
export function OverviewHeader({
activeYear,
availableYears,
lastRefreshAt,
refreshing,
vehicleScope,
verifyScope = 'all',
onVerifyScopeChange,
latestLedgerTime,
lastRefreshAt = 0,
refreshing = false,
onSelectYear,
onVehicleScopeChange,
onRefresh,
}: OverviewHeaderProps) {
return (
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2">
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRefreshTime(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
{availableYears.map(y => {
const active = y === activeYear;
return (
<button
key={y}
onClick={() => onSelectYear(y)}
className={`px-2 py-0.5 text-[11px] font-bold rounded-md transition-all ${
active ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'
}`}
>
{y}
</button>
);
})}
<section className="ehb-daily-filter-card" style={{ marginBottom: 12 }} aria-label="总览筛选工具栏">
<div className="ehb-daily-filter-row">
<div className="ehb-daily-filter-group">
<BiYearSelect value={activeYear} years={availableYears} onChange={onSelectYear} />
<div className="ehb-pill-tabs">
<button
type="button"
className={`ehb-pill-btn ${verifyScope === 'all' ? 'is-active' : ''}`}
onClick={() => onVerifyScopeChange?.('all')}
>
</button>
<button
type="button"
className={`ehb-pill-btn ${verifyScope === 'verified' ? 'is-active' : ''}`}
onClick={() => onVerifyScopeChange?.('verified')}
title="真实账本已返回全量有效订单"
>
</button>
</div>
</div>
<div className="ehb-daily-filter-group">
<div className="ehb-fleet-segmented">
<button
type="button"
className={`ehb-fleet-btn ${vehicleScope === 'all' ? 'is-active' : ''}`}
onClick={() => onVehicleScopeChange('all')}
>
</button>
<button
type="button"
className={`ehb-fleet-btn ${vehicleScope === 'lingniu' ? 'is-active' : ''}`}
onClick={() => onVehicleScopeChange('lingniu')}
>
<Truck size={14} />
</button>
<button
type="button"
className={`ehb-fleet-btn ${vehicleScope === 'external' ? 'is-active' : ''}`}
onClick={() => onVehicleScopeChange('external')}
>
<Truck size={14} />
</button>
</div>
<span className="ehb-chrome__clock" style={{ fontSize: 12, color: '#64748b' }}>
{latestLedgerTime ? `账本 ${latestLedgerTime.slice(5, 16)}` : lastRefreshAt ? formatRefreshTime(lastRefreshAt) : '已同步'}
</span>
<button
type="button"
className="ehb-btn ehb-btn--ghost"
onClick={onRefresh}
disabled={refreshing}
title="数据刷新"
>
<RefreshCw size={14} className={refreshing ? 'animate-spin' : ''} aria-hidden />
</button>
</div>
<button
onClick={onRefresh}
disabled={refreshing}
className="flex items-center gap-1 px-2 py-0.5 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
title="手动刷新(绕过缓存)"
>
<RefreshCw size={11} className={refreshing ? 'animate-spin' : ''} strokeWidth={2.6} />
<span className="text-[11px] font-bold"></span>
</button>
</div>
</div>
</section>
);
}
@@ -1,55 +1,116 @@
import type { HydrogenCustomerRow, HydrogenStationFull } from '../../types';
import { formatKg as fmtKg, formatYuan as fmtYuan } from '../model';
import { useMemo, useState } from 'react';
import { ChevronRight, Search } from 'lucide-react';
import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from '../../components/SortableColumnHeader';
import {
buildCustomerDrillPayload,
buildStationDrillPayload,
formatKg as fmtKg,
formatYuan as fmtYuan,
type OverviewDrillPayload,
type OverviewDrillRequest,
type OverviewScope,
} from '../model';
import { OverviewDrillDialog } from './OverviewDrillDialog';
export function StationSummaryTable({ stations }: { stations: HydrogenStationFull[] }) {
export function StationSummaryTable({
stations,
onSelectStation,
scope = 'global',
scopeLabel,
onDrillRequest,
}: {
stations: HydrogenStationFull[];
onSelectStation?: (stationId: number) => void;
scope?: OverviewScope;
scopeLabel?: string | null;
onDrillRequest?: (request: OverviewDrillRequest) => void;
}) {
const [province, setProvince] = useState('all');
const [sortKey, setSortKey] = useState<'name' | 'province' | 'kg' | 'share' | 'revenue' | 'revenueShare'>('kg');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
const provinces = useMemo(() => [...new Set(stations.map(station => station.province?.trim()).filter(Boolean) as string[])], [stations]);
const filteredStations = province === 'all'
? stations
: stations.filter(station => station.province === province);
const sortedStations = useMemo(() => sortBy(filteredStations, sortKey, sortDirection, (station, key) => station[key]), [filteredStations, sortDirection, sortKey]);
const changeSort = (nextKey: typeof sortKey) => {
const next = toggleSort(sortKey, sortDirection, nextKey);
setSortKey(next.key);
setSortDirection(next.direction);
};
const openStation = (station: HydrogenStationFull) => {
setSelectedStationId(station.id);
if (onDrillRequest) onDrillRequest({ kind: 'station', key: String(station.id), label: station.name, entityId: station.id });
else setDrill(buildStationDrillPayload(station));
};
return (
<>
{stations.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700"></span>
<span className="text-[11px] text-slate-400 font-bold"> {stations.length} </span>
<div className="ehb-sum-table-card">
<div className="ehb-sum-table-card__head" style={{ flexWrap: 'wrap', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div className="ehb-sum-table-card__title"><Search size={14} className="text-sky-600" />{scope === 'station' && scopeLabel ? ` · ${scopeLabel}` : ''}</div>
<div className="ehb-mini-tabs">
<button type="button" onClick={() => setProvince('all')} className={`ehb-mini-tab ${province === 'all' ? 'is-active' : ''}`}></button>
{provinces.map(item => <button key={item} type="button" onClick={() => setProvince(item)} className={`ehb-mini-tab ${province === item ? 'is-active' : ''}`}>{item}</button>)}
</div>
</div>
<div className="ehb-sum-table-card__meta">
{stations.length}
</div>
</div>
<div className="overflow-x-auto -mx-1 px-1">
<table className="w-full text-[11px]">
<div className="ehb-sum-table-wrap">
<table className="ehb-sum-table">
<thead>
<tr className="text-slate-400 font-bold border-b border-slate-100">
<th className="text-left py-1.5 pl-1 w-8">#</th>
<th className="text-left py-1.5"></th>
<th className="text-right py-1.5 w-20"></th>
<th className="text-right py-1.5 pl-2 hidden sm:table-cell"></th>
<th className="text-right py-1.5 pl-2 w-24"></th>
<th className="text-right py-1.5 pr-1 hidden md:table-cell"></th>
<tr>
<th className="col-idx">#</th>
<th style={{ textAlign: 'left' }}><SortableColumnHeader label="加氢站" sortKey="name" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
<th style={{ textAlign: 'left' }}><SortableColumnHeader label="所属省份" sortKey="province" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="加氢量" sortKey="kg" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="占比" sortKey="share" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="氢费收入" sortKey="revenue" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="收入占比" sortKey="revenueShare" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
</tr>
</thead>
<tbody>
{stations.map((s, i) => {
{sortedStations.map((s, i) => {
const kgFmt = fmtKg(s.kg);
const revFmt = fmtYuan(s.revenue);
return (
<tr key={s.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
<td className="py-1.5 text-slate-700 truncate max-w-[180px]">{s.name}</td>
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
<tr key={s.name + i} onClick={() => openStation(s)} style={{ cursor: 'pointer' }} title="查看加氢站汇总明细">
<td className="col-idx">{i + 1}</td>
<td style={{ fontWeight: 600, color: '#0284c7' }}>
{s.name} <span style={{ fontSize: 11, fontWeight: 400, opacity: 0.8 }}> </span>
</td>
<td className="py-1.5 pl-2 text-right hidden sm:table-cell">
<div className="inline-flex items-center gap-1.5">
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-cyan-400 to-blue-500" style={{ width: `${Math.min(100, s.share * 100)}%` }} />
<td>
<span style={{ fontSize: 11, color: '#0284c7', background: '#eff6ff', padding: '1px 6px', borderRadius: 4, fontWeight: 500 }}>
{s.province ?? '未归属'}
</span>
</td>
<td style={{ textAlign: 'right' }} className="col-bold-kg">
{kgFmt.value} <span style={{ fontSize: 11, fontWeight: 400, color: '#64748b' }}>{kgFmt.unit}</span>
</td>
<td>
<div className="ehb-ratio-flex">
<div className="ehb-mini-bar-track">
<div className="ehb-mini-bar-fill is-blue" style={{ width: `${Math.min(100, s.share * 100 * 2.5)}%` }} />
</div>
<span className="text-slate-500 tabular-nums">{(s.share * 100).toFixed(1)}%</span>
<span className="ehb-ratio-text">{(s.share * 100).toFixed(1)}%</span>
</div>
</td>
<td className="py-1.5 pl-2 text-right tabular-nums font-bold text-emerald-600">
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
<td style={{ textAlign: 'right' }} className="col-green-fee">
¥{revFmt.value} <span style={{ fontSize: 11, fontWeight: 400 }}>{revFmt.unit}</span>
</td>
<td className="py-1.5 pr-1 text-right hidden md:table-cell">
<div className="inline-flex items-center gap-1.5">
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-emerald-400 to-emerald-600" style={{ width: `${Math.min(100, s.revenueShare * 100)}%` }} />
<td>
<div className="ehb-ratio-flex">
<div className="ehb-mini-bar-track">
<div className="ehb-mini-bar-fill is-green" style={{ width: `${Math.min(100, s.revenueShare * 100 * 5)}%` }} />
</div>
<span className="text-slate-500 tabular-nums">{(s.revenueShare * 100).toFixed(1)}%</span>
<span className="ehb-ratio-text">{(s.revenueShare * 100).toFixed(1)}%</span>
</div>
</td>
</tr>
@@ -60,55 +121,98 @@ export function StationSummaryTable({ stations }: { stations: HydrogenStationFul
</div>
</div>
)}
<OverviewDrillDialog
payload={drill}
onClose={() => { setDrill(null); setSelectedStationId(null); }}
onPrimaryAction={selectedStationId && onSelectStation ? () => { const stationId = selectedStationId; setDrill(null); setSelectedStationId(null); onSelectStation(stationId); } : undefined}
/>
</>
);
}
export function CustomerSummaryTable({ customers }: { customers: HydrogenCustomerRow[] }) {
export function CustomerSummaryTable({
customers,
scope = 'global',
scopeLabel,
onDrillRequest,
}: {
customers: HydrogenCustomerRow[];
scope?: OverviewScope;
scopeLabel?: string | null;
onDrillRequest?: (request: OverviewDrillRequest) => void;
}) {
const [sortKey, setSortKey] = useState<'name' | 'payer' | 'kg' | 'cost' | 'revenue'>('kg');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
const sortedCustomers = useMemo(() => sortBy(customers, sortKey, sortDirection, (customer, key) => customer[key]), [customers, sortDirection, sortKey]);
const changeSort = (nextKey: typeof sortKey) => {
const next = toggleSort(sortKey, sortDirection, nextKey);
setSortKey(next.key);
setSortDirection(next.direction);
};
const openCustomer = (customer: HydrogenCustomerRow, index: number) => {
if (onDrillRequest) onDrillRequest({ kind: 'customer', key: `${customer.name}-${index}`, label: customer.name });
else setDrill(buildCustomerDrillPayload(customer));
};
return (
<>
{customers.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700"></span>
<span className="text-[11px] text-slate-400 font-bold">Top {customers.length}</span>
<div className="ehb-sum-table-card">
<div className="ehb-sum-table-card__head">
<div className="ehb-sum-table-card__title">
<Search size={14} className="text-sky-600" style={{ display: 'inline-block', verticalAlign: 'middle', marginRight: 6 }} />
{scope === 'station' && scopeLabel ? ` · ${scopeLabel}` : ''}
<span className="ehb-title-sub ehb-hide-h5">
/
</span>
<span className="ehb-title-sub ehb-show-h5">
</span>
</div>
<div className="ehb-sum-table-card__meta">
Top {customers.length}
</div>
</div>
<div className="overflow-x-auto -mx-1 px-1">
<table className="w-full text-[11px]">
<div className="ehb-sum-table-wrap">
<table className="ehb-sum-table">
<thead>
<tr className="text-slate-400 font-bold border-b border-slate-100">
<th className="text-left py-1.5 pl-1 w-8">#</th>
<th className="text-left py-1.5"></th>
<th className="text-center py-1.5 w-14 hidden sm:table-cell"></th>
<th className="text-right py-1.5 w-20"></th>
<th className="text-right py-1.5 pl-2 w-24"></th>
<th className="text-right py-1.5 pr-1 w-24 hidden md:table-cell"></th>
<tr>
<th className="col-idx">#</th>
<th style={{ textAlign: 'left' }}><SortableColumnHeader label="客户" sortKey="name" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
<th style={{ textAlign: 'center' }}><SortableColumnHeader label="承担方" sortKey="payer" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="center" /></th>
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="加氢量" sortKey="kg" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="成本支出" sortKey="cost" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="应收" sortKey="revenue" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
</tr>
</thead>
<tbody>
{customers.map((c2, i) => {
{sortedCustomers.map((c2, i) => {
const kgFmt = fmtKg(c2.kg);
const costFmt = fmtYuan(c2.cost);
const revFmt = fmtYuan(c2.revenue);
return (
<tr key={c2.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
<td className="py-1.5 text-slate-700 truncate max-w-[200px]">{c2.name}</td>
<td className="py-1.5 text-center hidden sm:table-cell">
<tr key={c2.name + i} onClick={() => openCustomer(c2, i)} style={{ cursor: 'pointer' }} title="点击查看客户账单明细">
<td className="col-idx">{i + 1}</td>
<td style={{ fontWeight: 600, color: '#0284c7' }}>
{c2.name} <span style={{ fontSize: 11, fontWeight: 400, opacity: 0.8 }}> </span>
</td>
<td style={{ textAlign: 'center' }}>
{c2.payer === 'lingniu' ? (
<span className="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600 text-[10px] font-bold"></span>
<span className="ehb-payer-tag is-own"></span>
) : c2.payer === 'mixed' ? (
<span className="ehb-payer-tag is-mix"></span>
) : (
<span className="px-1.5 py-0.5 rounded bg-amber-50 text-amber-600 text-[10px] font-bold"></span>
<span className="ehb-payer-tag is-ext"></span>
)}
</td>
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
<td style={{ textAlign: 'right' }} className="col-bold-kg">
{kgFmt.value} <span style={{ fontSize: 11, fontWeight: 400, color: '#64748b' }}>{kgFmt.unit}</span>
</td>
<td className="py-1.5 pl-2 text-right tabular-nums text-amber-600 font-bold">
¥{costFmt.value}<span className="text-slate-400 font-normal ml-0.5">{costFmt.unit}</span>
<td style={{ textAlign: 'right' }} className="col-amber-fee">
¥{costFmt.value} <span style={{ fontSize: 11, fontWeight: 400 }}>{costFmt.unit}</span>
</td>
<td className="py-1.5 pr-1 text-right tabular-nums text-emerald-600 font-bold hidden md:table-cell">
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
<td style={{ textAlign: 'right' }} className="col-green-fee">
¥{revFmt.value} <span style={{ fontSize: 11, fontWeight: 400 }}>{revFmt.unit}</span>
</td>
</tr>
);
@@ -118,6 +222,7 @@ export function CustomerSummaryTable({ customers }: { customers: HydrogenCustome
</div>
</div>
)}
<OverviewDrillDialog payload={drill} onClose={() => setDrill(null)} />
</>
);
}