refactor: modularize application domains

This commit is contained in:
kkfluous
2026-08-13 12:03:34 +08:00
parent d610e4b841
commit 06fe75b4c7
142 changed files with 14645 additions and 8885 deletions
+29 -112
View File
@@ -1,46 +1,19 @@
import { useEffect, useMemo, useState } from 'react';
import { BatteryCharging, CalendarDays, ChevronRight, Plug, TrendingUp, Truck, Wallet } from 'lucide-react';
import { BatteryCharging, CalendarDays, ChevronRight, Plug, TrendingUp, Wallet } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import TrendBadge from './TrendBadge';
import { fetchElectricMonthly } from './api';
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'thisWeek', label: '本周' },
{ id: 'thisMonth', label: '本月' },
{ id: 'last15', label: '近 15 天' },
];
type RangeMode = DateQuickPick | 'custom';
function fmtYmd(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function addDays(d: Date, days: number): Date {
const next = new Date(d);
next.setDate(next.getDate() + days);
return next;
}
function getQuickRange(pick: DateQuickPick): { start: string; end: string } {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (pick === 'thisWeek') {
const day = today.getDay() || 7;
return { start: fmtYmd(addDays(today, -(day - 1))), end: fmtYmd(today) };
}
if (pick === 'thisMonth') {
return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end: fmtYmd(today) };
}
return { start: fmtYmd(addDays(today, -14)), end: fmtYmd(today) };
}
function normalizeRange(start: string, end: string): { start: string; end: string } {
return start <= end ? { start, end } : { start: end, end: start };
}
import { EmptyState, ErrorState, LoadingState, MetricTile } from '../../components/ui/surface';
import DailyRangeControls from './daily-range/DailyRangeControls';
import {
getQuickRange,
getRangeModeLabel,
normalizeRange,
type RangeMode,
} from './daily-range/model';
import { summarizeElectricMonths } from './electric-daily/model';
export default function ElectricDaily() {
const [customer, setCustomer] = useState<CustomerType>('lingniu');
@@ -75,17 +48,17 @@ export default function ElectricDaily() {
return next;
});
const totalKwh = useMemo(() => (months ?? []).reduce((s, m) => s + (m.kwh || 0), 0), [months]);
const totalFee = useMemo(() => (months ?? []).reduce((s, m) => s + (m.fee || 0), 0), [months]);
const activeDays = useMemo(() => (months ?? []).reduce((sum, m) => sum + m.rows.filter(r => r.kwh > 0).length, 0), [months]);
const abnormalDays = useMemo(() => (months ?? []).reduce((sum, m) => sum + m.rows.filter(r => Math.abs(r.chainPct) >= 0.3).length, 0), [months]);
const avgKwh = activeDays > 0 ? totalKwh / activeDays : 0;
const avgPrice = totalKwh > 0 ? totalFee / totalKwh : 0;
const scopeLabel = pick === 'custom'
? '自定义区间'
: QUICK_PICK_OPTIONS.find(item => item.id === pick)?.label ?? '当前时段';
const {
totalKwh,
totalFee,
activeDays,
abnormalDays,
avgKwh,
avgPrice,
hasFeeDetail,
} = useMemo(() => summarizeElectricMonths(months), [months]);
const scopeLabel = getRangeModeLabel(pick);
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
const hasFeeDetail = totalFee > 0;
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
const applyQuickPick = (nextPick: DateQuickPick) => {
@@ -101,71 +74,15 @@ export default function ElectricDaily() {
return (
<div className="flex flex-col gap-3">
<SurfaceCard className="p-2 md:p-3">
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{QUICK_PICK_OPTIONS.map(opt => (
<button
key={opt.id}
onClick={() => applyQuickPick(opt.id)}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === opt.id
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
<button
onClick={() => setPick('custom')}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === 'custom'
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`}
>
自定义
</button>
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400">开始日期</span>
<input
type="date"
value={dateRange.start}
onChange={e => updateDateRange('start', e.target.value)}
onInput={e => updateDateRange('start', e.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400">结束日期</span>
<input
type="date"
value={dateRange.end}
onChange={e => updateDateRange('end', e.target.value)}
onInput={e => updateDateRange('end', e.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
</div>
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
{(['lingniu', 'external'] as const).map(c => (
<button
key={c}
onClick={() => setCustomer(c)}
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
customer === c ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
<Truck size={14} />
{c === 'external' ? '外部车辆' : '羚牛车辆'}
</button>
))}
</div>
</SurfaceCard>
<DailyRangeControls
pick={pick}
dateRange={dateRange}
customer={customer}
onQuickPick={applyQuickPick}
onCustomPick={() => setPick('custom')}
onDateRangeChange={updateDateRange}
onCustomerChange={setCustomer}
/>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={BatteryCharging} label={`${scopeLabel}充电量`} value={totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={rangeText} />
+28 -118
View File
@@ -5,44 +5,17 @@ import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, Refere
import TrendBadge from './TrendBadge';
import { fetchHydrogenDaily } from './api';
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
import {
getQuickRange,
getRangeModeLabel,
normalizeRange,
summarizeHydrogenRows,
type RangeMode,
} from './hydrogen-daily/model';
import DailyRangeControls from './daily-range/DailyRangeControls';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'thisWeek', label: '本周' },
{ id: 'thisMonth', label: '本月' },
{ id: 'last15', label: '近 15 天' },
];
type RangeMode = DateQuickPick | 'custom';
function fmtYmd(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function addDays(d: Date, days: number): Date {
const next = new Date(d);
next.setDate(next.getDate() + days);
return next;
}
function getQuickRange(pick: DateQuickPick): { start: string; end: string } {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (pick === 'thisWeek') {
const day = today.getDay() || 7;
return { start: fmtYmd(addDays(today, -(day - 1))), end: fmtYmd(today) };
}
if (pick === 'thisMonth') {
return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end: fmtYmd(today) };
}
return { start: fmtYmd(addDays(today, -14)), end: fmtYmd(today) };
}
function normalizeRange(start: string, end: string): { start: string; end: string } {
return start <= end ? { start, end } : { start: end, end: start };
}
export default function HydrogenDaily() {
const [pick, setPick] = useState<RangeMode>('last15');
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
@@ -65,25 +38,18 @@ export default function HydrogenDaily() {
return () => { cancelled = true; };
}, [pick, customer, effectiveRange.start, effectiveRange.end]);
// 柱图:按日期升序,用于"从左到右时间流"
const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
const totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0);
const activeDays = (rows ?? []).filter(r => r.totalKg > 0).length;
const stationCount = useMemo(() => {
const names = new Set<string>();
(rows ?? []).forEach(r => r.stations.forEach(s => names.add(s.name)));
return names.size;
}, [rows]);
const avgKg = activeDays > 0 ? totalKg / activeDays : 0;
const scopeLabel = pick === 'custom'
? '自定义区间'
: QUICK_PICK_OPTIONS.find(item => item.id === pick)?.label ?? '当前时段';
const {
trendData,
totalKg,
activeDays,
stationCount,
avgKg,
peakDay,
lowDay,
zeroDays,
} = useMemo(() => summarizeHydrogenRows(rows), [rows]);
const scopeLabel = getRangeModeLabel(pick);
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
const peakDay = trendData.reduce<HydrogenDailyRow | null>((best, item) => (!best || item.totalKg > best.totalKg ? item : best), null);
const lowDay = trendData
.filter(item => item.totalKg > 0)
.reduce<HydrogenDailyRow | null>((low, item) => (!low || item.totalKg < low.totalKg ? item : low), null);
const zeroDays = (rows ?? []).filter(r => r.totalKg === 0).length;
const toggle = (date: string) => setExpanded(prev => {
const next = new Set(prev);
@@ -104,71 +70,15 @@ export default function HydrogenDaily() {
return (
<div className="flex flex-col gap-3">
<SurfaceCard className="p-2 md:p-3">
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{QUICK_PICK_OPTIONS.map(opt => (
<button
key={opt.id}
onClick={() => applyQuickPick(opt.id)}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === opt.id
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
<button
onClick={() => setPick('custom')}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === 'custom'
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`}
>
自定义
</button>
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400">开始日期</span>
<input
type="date"
value={dateRange.start}
onChange={e => updateDateRange('start', e.target.value)}
onInput={e => updateDateRange('start', e.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400">结束日期</span>
<input
type="date"
value={dateRange.end}
onChange={e => updateDateRange('end', e.target.value)}
onInput={e => updateDateRange('end', e.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
</div>
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
{(['lingniu', 'external'] as const).map(c => (
<button
key={c}
onClick={() => setCustomer(c)}
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
customer === c ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
<Truck size={14} />
{c === 'external' ? '外部车辆' : '羚牛车辆'}
</button>
))}
</div>
</SurfaceCard>
<DailyRangeControls
pick={pick}
dateRange={dateRange}
customer={customer}
onQuickPick={applyQuickPick}
onCustomPick={() => setPick('custom')}
onDateRangeChange={updateDateRange}
onCustomerChange={setCustomer}
/>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
+51 -659
View File
@@ -1,89 +1,16 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
} from 'recharts';
import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw, Gauge, AlertTriangle, Building2 } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
import { DistributionCharts } from './hydrogen-overview/components/DistributionCharts';
import { HydrogenOverviewSkeleton } from './hydrogen-overview/components/HydrogenOverviewSkeleton';
import { InsightCards } from './hydrogen-overview/components/InsightCards';
import { KpiSection } from './hydrogen-overview/components/KpiSection';
import { MonthlyCharts } from './hydrogen-overview/components/MonthlyCharts';
import { OverviewHeader } from './hydrogen-overview/components/OverviewHeader';
import { RefreshOverlay } from './hydrogen-overview/components/RefreshOverlay';
import { CustomerSummaryTable, StationSummaryTable } from './hydrogen-overview/components/SummaryTables';
import { deriveOverviewMetrics, formatYuan as fmtYuan } from './hydrogen-overview/model';
const REGION_COLORS = [
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
'#94a3b8',
];
interface YAxisTickProps {
x?: number;
y?: number;
index?: number;
payload?: { value: string };
}
function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
return (
<g transform={`translate(${x},${y})`}>
<circle cx={-172} cy={0} r={9} fill="#3b82f6" />
<text x={-172} y={3} textAnchor="middle" fontSize={10} fontWeight={700} fill="#fff">
{index + 1}
</text>
<text x={-154} y={4} textAnchor="start" fontSize={11} fill="#475569">
{payload?.value}
</text>
</g>
);
}
// ---------- 数字格式化 ----------
function fmtKg(kg: number): { value: string; unit: string } {
if (kg >= 1000) return { value: (kg / 1000).toFixed(2), unit: 'T' };
return { value: kg.toFixed(2), unit: 'Kg' };
}
function fmtYuan(yuan: number): { value: string; unit: string } {
const abs = Math.abs(yuan);
if (abs >= 100_000_000) return { value: (yuan / 100_000_000).toFixed(2), unit: '亿元' };
if (abs >= 10_000) {
const w = yuan / 10_000;
return { value: w.toLocaleString('zh-CN', { maximumFractionDigits: 2 }), unit: '万元' };
}
return { value: yuan.toLocaleString('zh-CN', { maximumFractionDigits: 0 }), unit: '元' };
}
// ---------- KPI 卡 ----------
interface KpiCardProps {
icon: React.ReactNode;
label: string;
hero: { value: string; unit: string };
rows: { label: string; value: string; valueClass?: string }[];
accentClass: string;
iconBg: string;
}
function KpiCard({ icon, label, hero, rows, accentClass, iconBg }: KpiCardProps) {
return (
<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>
<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>
<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>
</div>
);
}
// ============================================================
export default function HydrogenOverview() {
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -124,591 +51,56 @@ export default function HydrogenOverview() {
if (!data) {
return <HydrogenOverviewSkeleton />;
}
const k = data.kpi;
const top5 = data.top5;
const regions = data.regions;
const monthly = data.monthly;
const customers = data.customers;
const stations = data.stations;
const availableYears = data.availableYears;
const activeYear = data.year;
const yearKgFmt = fmtKg(k.yearKg);
const yearFeeFmt = fmtYuan(k.yearFee);
const yearProfitFmt = fmtYuan(k.yearProfit);
const ourYearKgFmt = fmtKg(k.ourYearKg);
const customerYearKgFmt = fmtKg(k.customerYearKg);
const monthKgFmt = fmtKg(k.monthKg);
const monthFeeFmt = fmtYuan(k.monthFee);
const todayKgFmt = fmtKg(k.todayKg);
const todayFeeFmt = fmtYuan(k.todayFee);
const customerYearFee = Math.max(0, k.yearFee - k.ourYearFee);
const customerYearFeeFmt = fmtYuan(customerYearFee);
const yearRevenueFmt = fmtYuan(k.yearRevenue);
const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600';
const monthAvgKg = monthly.length > 0 ? monthly.reduce((sum, m) => sum + m.kg, 0) / monthly.length : 0;
const bestMonth = monthly.reduce<typeof monthly[number] | null>((best, item) => (!best || item.kg > best.kg ? item : best), null);
const latestMonth = monthly[monthly.length - 1];
const prevMonth = monthly[monthly.length - 2];
const monthMomentum = latestMonth && prevMonth && prevMonth.kg > 0 ? (latestMonth.kg - prevMonth.kg) / prevMonth.kg * 100 : null;
const top5Share = top5.reduce((sum, item) => sum + item.kg, 0) / Math.max(1, k.yearKg) * 100;
const profitYield = k.yearRevenue > 0 ? k.yearProfit / k.yearRevenue * 100 : 0;
const stationAvgKg = stations.length > 0 ? k.yearKg / stations.length : 0;
// 月度收支组合数据(推算"年内每月"图)
const monthlyDual = monthly.map(m => ({
...m,
monthLabel: m.month.slice(5).replace(/^0/, '') + '月',
}));
const { kpi, top5, regions, monthly, customers, stations, availableYears, year: activeYear } = data;
const {
monthAvgKg,
bestMonth,
latestMonth,
monthMomentum,
top5Share,
profitYield,
stationAvgKg,
monthlyDual,
} = deriveOverviewMetrics(data);
const yearProfitFmt = fmtYuan(kpi.yearProfit);
const yearRevenueFmt = fmtYuan(kpi.yearRevenue);
return (
<div className="flex flex-col gap-3 relative">
{/* 顶部说明条 + 年份切换 + 刷新按钮 */}
<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={() => setYear(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>
);
})}
</div>
<button
onClick={() => void load(year, true)}
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>
<OverviewHeader
activeYear={activeYear}
availableYears={availableYears}
lastRefreshAt={lastRefreshAt}
refreshing={refreshing}
onSelectYear={setYear}
onRefresh={() => void load(year, true)}
/>
{/* KPI 5 卡 */}
<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>
<KpiSection kpi={kpi} />
<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>
<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>
<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>
<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">
共 {stations.length} 站 · 单站年均 {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)}%
</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="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
时享获利 {yearProfitFmt.value}{yearProfitFmt.unit} · 客户收入 {yearRevenueFmt.value}{yearRevenueFmt.unit}
{profitYield < 0 ? ' · 需关注亏损站点与客户价格' : ' · 当前保持正向收益'}
</div>
</div>
</div>
<InsightCards
monthAvgKg={monthAvgKg}
bestMonth={bestMonth}
latestMonth={latestMonth}
monthMomentum={monthMomentum}
top5Share={top5Share}
profitYield={profitYield}
stationAvgKg={stationAvgKg}
stationCount={stations.length}
yearProfitValue={yearProfitFmt.value}
yearProfitUnit={yearProfitFmt.unit}
yearRevenueValue={yearRevenueFmt.value}
yearRevenueUnit={yearRevenueFmt.unit}
/>
{/* 月度趋势:年内每月加氢量 */}
{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>
<ResponsiveContainer width="100%" height={140}>
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
<XAxis
dataKey="monthLabel"
tick={{ fontSize: 10, fill: '#94a3b8' }}
tickLine={false}
axisLine={false}
interval={0}
/>
<YAxis hide />
<Tooltip
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })} Kg`, '加氢量']}
labelFormatter={(d) => `${d}`}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
/>
<Bar dataKey="kg" radius={[4, 4, 0, 0]}>
{monthlyDual.map((_, i) => (
<Cell key={i} fill="url(#monthlyBarGrad)" />
))}
</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>
)}
{/* 月度收支对比 */}
{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>
<ResponsiveContainer width="100%" height={180}>
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
<XAxis
dataKey="monthLabel"
tick={{ fontSize: 10, fill: '#94a3b8' }}
tickLine={false}
axisLine={false}
interval={0}
/>
<YAxis hide />
<Legend
verticalAlign="top"
height={20}
iconSize={8}
wrapperStyle={{ fontSize: 11, paddingBottom: 4 }}
/>
<Tooltip
formatter={(v, name) => {
const f = fmtYuan(Number(v ?? 0));
return [`¥${f.value} ${f.unit}`, name];
}}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(148, 163, 184, 0.06)' }}
/>
<Bar dataKey="fee" name="成本支出" fill="#f59e0b" radius={[3, 3, 0, 0]} />
<Bar dataKey="revenue" name="客户收入" fill="#10b981" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
{/* Top5 + 区域占比 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* Top5 加氢站 */}
<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>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 12 }}>
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="name"
width={188}
tick={<RankYAxisTick />}
tickLine={false}
axisLine={false}
/>
<Tooltip
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN')} Kg`}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
/>
<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>
<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 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="relative w-1/2 h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={regions}
dataKey="kg"
nameKey="region"
innerRadius={48}
outerRadius={80}
paddingAngle={1}
>
{regions.map((_, i) => (
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(v) => `${(Number(v ?? 0) / 1000).toFixed(2)}T`} contentStyle={{ borderRadius: 12, fontSize: 12 }} />
</PieChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<div className="text-[10px] text-slate-400 font-bold">年合计</div>
<div className="text-base font-bold text-slate-700 leading-tight">{(k.yearKg / 1000).toFixed(2)}T</div>
</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">
<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>
))}
</div>
</div>
</div>
</div>
{/* 加氢站加氢汇总(全量) */}
{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>
<div className="overflow-x-auto -mx-1 px-1">
<table className="w-full text-[11px]">
<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>
</thead>
<tbody>
{stations.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>
</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)}%` }} />
</div>
<span className="text-slate-500 tabular-nums">{(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>
<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)}%` }} />
</div>
<span className="text-slate-500 tabular-nums">{(s.revenueShare * 100).toFixed(1)}%</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* 客户账单汇总 Top */}
{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>
<div className="overflow-x-auto -mx-1 px-1">
<table className="w-full text-[11px]">
<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>
</thead>
<tbody>
{customers.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">
{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="px-1.5 py-0.5 rounded bg-amber-50 text-amber-600 text-[10px] font-bold">客户</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>
<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>
<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>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
<MonthlyCharts activeYear={activeYear} monthly={monthly} monthlyDual={monthlyDual} />
<DistributionCharts top5={top5} regions={regions} yearKg={kpi.yearKg} />
<StationSummaryTable stations={stations} />
<CustomerSummaryTable customers={customers} />
<RotatingFooterHint />
{/* 刷新中:透明遮罩 + 顶部进度条(不替换内容,避免闪烁) */}
<AnimatePresence>
{refreshing && data && (
<motion.div
key="refresh-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed top-0 left-0 right-0 h-0.5 z-50 pointer-events-none overflow-hidden"
>
<motion.div
className="h-full bg-gradient-to-r from-blue-400 via-cyan-400 to-blue-400"
initial={{ x: '-100%' }}
animate={{ x: '100%' }}
transition={{ duration: 1.2, repeat: Infinity, ease: 'linear' }}
style={{ width: '40%' }}
/>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function formatRelative(ts: number): string {
const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
if (s < 5) return '刚刚';
if (s < 60) return `${s} 秒前`;
const m = Math.floor(s / 60);
if (m < 60) return `${m} 分钟前`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} 小时前`;
return new Date(ts).toLocaleString('zh-CN', { hour12: false });
}
function formatRefreshTime(ts: number): string {
const exactTime = new Date(ts).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
return `${formatRelative(ts)} · ${exactTime.replace(/\//g, '-')}`;
}
function HydrogenOverviewSkeleton() {
return (
<div className="flex flex-col gap-3 animate-pulse">
<div className="bg-white rounded-xl border border-slate-100 px-3 py-2">
<div className="h-3 w-44 bg-slate-100 rounded" />
</div>
{/* 5 卡占位 */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 space-y-2">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-xl bg-slate-100" />
<div className="h-3 w-16 bg-slate-100 rounded" />
</div>
<div className="h-7 w-24 bg-slate-200 rounded" />
<div className="space-y-1.5 pt-1 border-t border-slate-50">
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
</div>
</div>
))}
</div>
{/* 月度柱图占位 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
<div className="flex items-center justify-between mb-3">
<div className="h-4 w-32 bg-slate-100 rounded" />
<div className="h-3 w-12 bg-slate-100 rounded" />
</div>
<div className="flex items-end gap-2 h-[120px]">
{[60, 75, 50, 80, 35, 90, 45].map((h, i) => (
<div key={i} className="flex-1 bg-slate-100 rounded-t" style={{ height: `${h}%` }} />
))}
</div>
</div>
<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-4">
<div className="flex items-center justify-between mb-3">
<div className="h-4 w-32 bg-slate-100 rounded" />
<div className="h-3 w-12 bg-slate-100 rounded" />
</div>
<div className="space-y-3">
{[100, 78, 56, 40, 28].map((w, i) => (
<div key={i} className="flex items-center gap-3">
<div className="w-5 h-5 rounded-full bg-slate-200" />
<div className="h-3 w-32 bg-slate-100 rounded" />
<div className="flex-1 h-4 rounded-md bg-gradient-to-r from-slate-200 to-slate-100" style={{ maxWidth: `${w}%` }} />
<div className="h-3 w-12 bg-slate-100 rounded" />
</div>
))}
</div>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-3">
<div className="h-4 w-28 bg-slate-100 rounded" />
<div className="flex items-center gap-3">
<div className="w-1/2 h-[200px] flex items-center justify-center">
<div className="w-32 h-32 rounded-full border-[18px] border-slate-100" />
</div>
<div className="flex-1 space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-slate-200" />
<div className="h-3 w-16 bg-slate-100 rounded" />
<div className="h-3 w-10 bg-slate-100 rounded ml-auto" />
</div>
))}
</div>
</div>
</div>
</div>
<div className="text-center text-[11px] text-slate-400 font-bold flex items-center justify-center gap-1.5">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
正在加载氢能总览…
</div>
<RefreshOverlay refreshing={refreshing} hasData={Boolean(data)} />
</div>
);
}
@@ -0,0 +1,92 @@
import { Truck } from 'lucide-react';
import { SurfaceCard } from '../../../components/ui/surface';
import type { CustomerType, DateQuickPick } from '../types';
import { QUICK_PICK_OPTIONS, type RangeMode } from './model';
interface DailyRangeControlsProps {
pick: RangeMode;
dateRange: { start: string; end: string };
customer: CustomerType;
onQuickPick: (pick: DateQuickPick) => void;
onCustomPick: () => void;
onDateRangeChange: (field: 'start' | 'end', value: string) => void;
onCustomerChange: (customer: CustomerType) => void;
}
export default function DailyRangeControls({
pick,
dateRange,
customer,
onQuickPick,
onCustomPick,
onDateRangeChange,
onCustomerChange,
}: DailyRangeControlsProps) {
return (
<SurfaceCard className="p-2 md:p-3">
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{QUICK_PICK_OPTIONS.map(opt => (
<button
key={opt.id}
onClick={() => onQuickPick(opt.id)}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === opt.id
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
<button
onClick={onCustomPick}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === 'custom'
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`}
>
自定义
</button>
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400">开始日期</span>
<input
type="date"
value={dateRange.start}
onChange={event => onDateRangeChange('start', event.target.value)}
onInput={event => onDateRangeChange('start', event.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400">结束日期</span>
<input
type="date"
value={dateRange.end}
onChange={event => onDateRangeChange('end', event.target.value)}
onInput={event => onDateRangeChange('end', event.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
</div>
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
{(['lingniu', 'external'] as const).map(option => (
<button
key={option}
onClick={() => onCustomerChange(option)}
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
customer === option ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
<Truck size={14} />
{option === 'external' ? '外部车辆' : '羚牛车辆'}
</button>
))}
</div>
</SurfaceCard>
);
}
+48
View File
@@ -0,0 +1,48 @@
import type { DateQuickPick } from '../types';
export const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'thisWeek', label: '本周' },
{ id: 'thisMonth', label: '本月' },
{ id: 'last15', label: '近 15 天' },
];
export type RangeMode = DateQuickPick | 'custom';
export function formatYmd(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function addDays(date: Date, days: number): Date {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
/** Energy quick ranges follow the browser's local calendar day, not UTC. */
export function getQuickRange(
pick: DateQuickPick,
now: Date = new Date(),
): { start: string; end: string } {
const today = new Date(now);
today.setHours(0, 0, 0, 0);
if (pick === 'thisWeek') {
const day = today.getDay() || 7;
return { start: formatYmd(addDays(today, -(day - 1))), end: formatYmd(today) };
}
if (pick === 'thisMonth') {
return {
start: formatYmd(new Date(today.getFullYear(), today.getMonth(), 1)),
end: formatYmd(today),
};
}
return { start: formatYmd(addDays(today, -14)), end: formatYmd(today) };
}
export function normalizeRange(start: string, end: string): { start: string; end: string } {
return start <= end ? { start, end } : { start: end, end: start };
}
export function getRangeModeLabel(mode: RangeMode): string {
if (mode === 'custom') return '自定义区间';
return QUICK_PICK_OPTIONS.find(item => item.id === mode)?.label ?? '当前时段';
}
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ElectricMonthGroup } from '../types';
import { summarizeElectricMonths } from './model';
test('electric daily summary preserves totals, active-day and 30 percent boundaries', () => {
const months: ElectricMonthGroup[] = [
{
month: '2026-08',
kwh: 150,
fee: 75,
rows: [
{ date: '2026-08-01', kwh: 100, fee: 50, chainPct: 0.3 },
{ date: '2026-08-02', kwh: 50, fee: 25, chainPct: -0.29 },
{ date: '2026-08-03', kwh: 0, fee: 0, chainPct: -1 },
],
},
];
assert.deepEqual(summarizeElectricMonths(months), {
totalKwh: 150,
totalFee: 75,
activeDays: 2,
abnormalDays: 2,
avgKwh: 75,
avgPrice: 0.5,
hasFeeDetail: true,
});
});
test('electric daily summary preserves empty and missing-fee fallbacks', () => {
assert.deepEqual(summarizeElectricMonths(null), {
totalKwh: 0,
totalFee: 0,
activeDays: 0,
abnormalDays: 0,
avgKwh: 0,
avgPrice: 0,
hasFeeDetail: false,
});
});
@@ -0,0 +1,25 @@
import type { ElectricMonthGroup } from '../types';
export function summarizeElectricMonths(months: ElectricMonthGroup[] | null) {
const source = months ?? [];
const totalKwh = source.reduce((sum, month) => sum + (month.kwh || 0), 0);
const totalFee = source.reduce((sum, month) => sum + (month.fee || 0), 0);
const activeDays = source.reduce(
(sum, month) => sum + month.rows.filter(row => row.kwh > 0).length,
0,
);
const abnormalDays = source.reduce(
(sum, month) => sum + month.rows.filter(row => Math.abs(row.chainPct) >= 0.3).length,
0,
);
return {
totalKwh,
totalFee,
activeDays,
abnormalDays,
avgKwh: activeDays > 0 ? totalKwh / activeDays : 0,
avgPrice: totalKwh > 0 ? totalFee / totalKwh : 0,
hasFeeDetail: totalFee > 0,
};
}
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { HydrogenDailyRow } from '../types.js';
import {
getQuickRange,
getRangeModeLabel,
normalizeRange,
summarizeHydrogenRows,
} from './model.js';
test('快捷日期继续使用本地自然日并覆盖本周、本月和近15天', () => {
const now = new Date(2026, 7, 12, 23, 30);
assert.deepEqual(getQuickRange('thisWeek', now), {
start: '2026-08-10',
end: '2026-08-12',
});
assert.deepEqual(getQuickRange('thisMonth', now), {
start: '2026-08-01',
end: '2026-08-12',
});
assert.deepEqual(getQuickRange('last15', now), {
start: '2026-07-29',
end: '2026-08-12',
});
});
test('自定义日期倒置时仅交换查询边界', () => {
assert.deepEqual(normalizeRange('2026-08-12', '2026-08-01'), {
start: '2026-08-01',
end: '2026-08-12',
});
assert.equal(getRangeModeLabel('custom'), '自定义区间');
assert.equal(getRangeModeLabel('last15'), '近 15 天');
});
test('每日加氢统计保持排序、有效天、站点去重和峰谷口径', () => {
const rows: HydrogenDailyRow[] = [
{
date: '2026-08-12',
totalKg: 0,
chainPct: -1,
customerType: 'lingniu',
stations: [{ name: 'A站', kg: 0, pricePerKg: 0, chainPct: -1 }],
},
{
date: '2026-08-10',
totalKg: 100,
chainPct: 0,
customerType: 'lingniu',
stations: [{ name: 'A站', kg: 100, pricePerKg: 20, chainPct: 0 }],
},
{
date: '2026-08-11',
totalKg: 300,
chainPct: 2,
customerType: 'lingniu',
stations: [{ name: 'B站', kg: 300, pricePerKg: 25, chainPct: 2 }],
},
];
const summary = summarizeHydrogenRows(rows);
assert.deepEqual(rows.map(row => row.date), ['2026-08-12', '2026-08-10', '2026-08-11']);
assert.deepEqual(summary.trendData.map(row => row.date), ['2026-08-10', '2026-08-11', '2026-08-12']);
assert.equal(summary.totalKg, 400);
assert.equal(summary.activeDays, 2);
assert.equal(summary.avgKg, 200);
assert.equal(summary.stationCount, 2);
assert.equal(summary.peakDay?.date, '2026-08-11');
assert.equal(summary.lowDay?.date, '2026-08-10');
assert.equal(summary.zeroDays, 1);
});
test('空数据保持零值且没有峰谷日', () => {
assert.deepEqual(summarizeHydrogenRows(null), {
trendData: [],
totalKg: 0,
activeDays: 0,
stationCount: 0,
avgKg: 0,
peakDay: null,
lowDay: null,
zeroDays: 0,
});
});
@@ -0,0 +1,41 @@
import type { HydrogenDailyRow } from '../types';
export {
formatYmd,
getQuickRange,
getRangeModeLabel,
normalizeRange,
QUICK_PICK_OPTIONS,
type RangeMode,
} from '../daily-range/model';
export function summarizeHydrogenRows(rows: HydrogenDailyRow[] | null) {
const source = rows ?? [];
// 图表固定按日期升序;复制数组避免改变接口返回及表格原始顺序。
const trendData = [...source].sort((left, right) => left.date.localeCompare(right.date));
const totalKg = source.reduce((total, row) => total + row.totalKg, 0);
const activeDays = source.filter(row => row.totalKg > 0).length;
const stationNames = new Set<string>();
source.forEach(row => row.stations.forEach(station => stationNames.add(station.name)));
const peakDay = trendData.reduce<HydrogenDailyRow | null>(
(best, item) => (!best || item.totalKg > best.totalKg ? item : best),
null,
);
const lowDay = trendData
.filter(item => item.totalKg > 0)
.reduce<HydrogenDailyRow | null>(
(low, item) => (!low || item.totalKg < low.totalKg ? item : low),
null,
);
return {
trendData,
totalKg,
activeDays,
stationCount: stationNames.size,
avgKg: activeDays > 0 ? totalKg / activeDays : 0,
peakDay,
lowDay,
zeroDays: source.filter(row => row.totalKg === 0).length,
};
}
@@ -0,0 +1,133 @@
import {
Bar,
BarChart,
Cell,
LabelList,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { HydrogenRegionShare, HydrogenStationTop } from '../../types';
const REGION_COLORS = [
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
'#94a3b8',
];
interface YAxisTickProps {
x?: number;
y?: number;
index?: number;
payload?: { value: string };
}
function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
return (
<g transform={`translate(${x},${y})`}>
<circle cx={-172} cy={0} r={9} fill="#3b82f6" />
<text x={-172} y={3} textAnchor="middle" fontSize={10} fontWeight={700} fill="#fff">
{index + 1}
</text>
<text x={-154} y={4} textAnchor="start" fontSize={11} fill="#475569">
{payload?.value}
</text>
</g>
);
}
interface DistributionChartsProps {
top5: HydrogenStationTop[];
regions: HydrogenRegionShare[];
yearKg: number;
}
export function DistributionCharts({ top5, regions, yearKg }: DistributionChartsProps) {
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>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 12 }}>
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="name"
width={188}
tick={<RankYAxisTick />}
tickLine={false}
axisLine={false}
/>
<Tooltip
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN')} Kg`}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
/>
<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>
<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 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="relative w-1/2 h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={regions}
dataKey="kg"
nameKey="region"
innerRadius={48}
outerRadius={80}
paddingAngle={1}
>
{regions.map((_, i) => (
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(v) => `${(Number(v ?? 0) / 1000).toFixed(2)}T`} contentStyle={{ borderRadius: 12, fontSize: 12 }} />
</PieChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<div className="text-[10px] text-slate-400 font-bold">年合计</div>
<div className="text-base font-bold text-slate-700 leading-tight">{(yearKg / 1000).toFixed(2)}T</div>
</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">
<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>
))}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,80 @@
export function HydrogenOverviewSkeleton() {
return (
<div className="flex flex-col gap-3 animate-pulse">
<div className="bg-white rounded-xl border border-slate-100 px-3 py-2">
<div className="h-3 w-44 bg-slate-100 rounded" />
</div>
{/* 5 卡占位 */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 space-y-2">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-xl bg-slate-100" />
<div className="h-3 w-16 bg-slate-100 rounded" />
</div>
<div className="h-7 w-24 bg-slate-200 rounded" />
<div className="space-y-1.5 pt-1 border-t border-slate-50">
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
</div>
</div>
))}
</div>
{/* 月度柱图占位 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
<div className="flex items-center justify-between mb-3">
<div className="h-4 w-32 bg-slate-100 rounded" />
<div className="h-3 w-12 bg-slate-100 rounded" />
</div>
<div className="flex items-end gap-2 h-[120px]">
{[60, 75, 50, 80, 35, 90, 45].map((h, i) => (
<div key={i} className="flex-1 bg-slate-100 rounded-t" style={{ height: `${h}%` }} />
))}
</div>
</div>
<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-4">
<div className="flex items-center justify-between mb-3">
<div className="h-4 w-32 bg-slate-100 rounded" />
<div className="h-3 w-12 bg-slate-100 rounded" />
</div>
<div className="space-y-3">
{[100, 78, 56, 40, 28].map((w, i) => (
<div key={i} className="flex items-center gap-3">
<div className="w-5 h-5 rounded-full bg-slate-200" />
<div className="h-3 w-32 bg-slate-100 rounded" />
<div className="flex-1 h-4 rounded-md bg-gradient-to-r from-slate-200 to-slate-100" style={{ maxWidth: `${w}%` }} />
<div className="h-3 w-12 bg-slate-100 rounded" />
</div>
))}
</div>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-3">
<div className="h-4 w-28 bg-slate-100 rounded" />
<div className="flex items-center gap-3">
<div className="w-1/2 h-[200px] flex items-center justify-center">
<div className="w-32 h-32 rounded-full border-[18px] border-slate-100" />
</div>
<div className="flex-1 space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-slate-200" />
<div className="h-3 w-16 bg-slate-100 rounded" />
<div className="h-3 w-10 bg-slate-100 rounded ml-auto" />
</div>
))}
</div>
</div>
</div>
</div>
<div className="text-center text-[11px] text-slate-400 font-bold flex items-center justify-center gap-1.5">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
正在加载氢能总览…
</div>
</div>
);
}
@@ -0,0 +1,88 @@
import { AlertTriangle, Building2, Gauge } from 'lucide-react';
import type { HydrogenMonthlyPoint } from '../../types';
import { formatKg as fmtKg } from '../model';
interface InsightCardsProps {
monthAvgKg: number;
bestMonth: HydrogenMonthlyPoint | null;
latestMonth: HydrogenMonthlyPoint | undefined;
monthMomentum: number | null;
top5Share: number;
profitYield: number;
stationAvgKg: number;
stationCount: number;
yearProfitValue: string;
yearProfitUnit: string;
yearRevenueValue: string;
yearRevenueUnit: string;
}
export function InsightCards({
monthAvgKg,
bestMonth,
latestMonth,
monthMomentum,
top5Share,
profitYield,
stationAvgKg,
stationCount,
yearProfitValue,
yearProfitUnit,
yearRevenueValue,
yearRevenueUnit,
}: InsightCardsProps) {
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>
<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>
<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>
<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)}%
</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="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
时享获利 {yearProfitValue}{yearProfitUnit} · 客户收入 {yearRevenueValue}{yearRevenueUnit}
{profitYield < 0 ? ' · 需关注亏损站点与客户价格' : ' · 当前保持正向收益'}
</div>
</div>
</div>
);
}
@@ -0,0 +1,114 @@
import type { ReactNode } from 'react';
import { CalendarDays, Fuel, Sparkles, TrendingUp, Wallet } from 'lucide-react';
import type { HydrogenKpi } from '../../types';
import { formatKg as fmtKg, formatYuan as fmtYuan } from '../model';
interface KpiCardProps {
icon: ReactNode;
label: string;
hero: { value: string; unit: string };
rows: { label: string; value: string; valueClass?: string }[];
accentClass: string;
iconBg: string;
}
function KpiCard({ icon, label, hero, rows, accentClass, iconBg }: KpiCardProps) {
return (
<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>
<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>
<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>
</div>
);
}
export function KpiSection({ kpi: k }: { kpi: HydrogenKpi }) {
const yearKgFmt = fmtKg(k.yearKg);
const yearFeeFmt = fmtYuan(k.yearFee);
const yearProfitFmt = fmtYuan(k.yearProfit);
const ourYearKgFmt = fmtKg(k.ourYearKg);
const customerYearKgFmt = fmtKg(k.customerYearKg);
const monthKgFmt = fmtKg(k.monthKg);
const monthFeeFmt = fmtYuan(k.monthFee);
const todayKgFmt = fmtKg(k.todayKg);
const todayFeeFmt = fmtYuan(k.todayFee);
const customerYearFee = Math.max(0, k.yearFee - k.ourYearFee);
const customerYearFeeFmt = fmtYuan(customerYearFee);
const yearRevenueFmt = fmtYuan(k.yearRevenue);
const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600';
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>
);
}
@@ -0,0 +1,101 @@
import {
Bar,
BarChart,
Cell,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { HydrogenMonthlyPoint } from '../../types';
import { formatYuan as fmtYuan } from '../model';
type MonthlyChartPoint = HydrogenMonthlyPoint & { monthLabel: string };
interface MonthlyChartsProps {
activeYear: number;
monthly: HydrogenMonthlyPoint[];
monthlyDual: MonthlyChartPoint[];
}
export function MonthlyCharts({ activeYear, monthly, monthlyDual }: MonthlyChartsProps) {
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>
<ResponsiveContainer width="100%" height={140}>
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
<XAxis
dataKey="monthLabel"
tick={{ fontSize: 10, fill: '#94a3b8' }}
tickLine={false}
axisLine={false}
interval={0}
/>
<YAxis hide />
<Tooltip
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })} Kg`, '加氢量']}
labelFormatter={(d) => `${d}`}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
/>
<Bar dataKey="kg" radius={[4, 4, 0, 0]}>
{monthlyDual.map((_, i) => (
<Cell key={i} fill="url(#monthlyBarGrad)" />
))}
</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>
)}
{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>
<ResponsiveContainer width="100%" height={180}>
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
<XAxis
dataKey="monthLabel"
tick={{ fontSize: 10, fill: '#94a3b8' }}
tickLine={false}
axisLine={false}
interval={0}
/>
<YAxis hide />
<Legend
verticalAlign="top"
height={20}
iconSize={8}
wrapperStyle={{ fontSize: 11, paddingBottom: 4 }}
/>
<Tooltip
formatter={(v, name) => {
const f = fmtYuan(Number(v ?? 0));
return [`¥${f.value} ${f.unit}`, name];
}}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(148, 163, 184, 0.06)' }}
/>
<Bar dataKey="fee" name="成本支出" fill="#f59e0b" radius={[3, 3, 0, 0]} />
<Bar dataKey="revenue" name="客户收入" fill="#10b981" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</>
);
}
@@ -0,0 +1,53 @@
import { RefreshCw } from 'lucide-react';
import { formatRefreshTime } from '../model';
interface OverviewHeaderProps {
activeYear: number;
availableYears: number[];
lastRefreshAt: number;
refreshing: boolean;
onSelectYear: (year: number) => void;
onRefresh: () => void;
}
export function OverviewHeader({
activeYear,
availableYears,
lastRefreshAt,
refreshing,
onSelectYear,
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>
);
})}
</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>
);
}
@@ -0,0 +1,31 @@
import { AnimatePresence, motion } from 'motion/react';
interface RefreshOverlayProps {
refreshing: boolean;
hasData: boolean;
}
export function RefreshOverlay({ refreshing, hasData }: RefreshOverlayProps) {
return (
<AnimatePresence>
{refreshing && hasData && (
<motion.div
key="refresh-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed top-0 left-0 right-0 h-0.5 z-50 pointer-events-none overflow-hidden"
>
<motion.div
className="h-full bg-gradient-to-r from-blue-400 via-cyan-400 to-blue-400"
initial={{ x: '-100%' }}
animate={{ x: '100%' }}
transition={{ duration: 1.2, repeat: Infinity, ease: 'linear' }}
style={{ width: '40%' }}
/>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -0,0 +1,123 @@
import type { HydrogenCustomerRow, HydrogenStationFull } from '../../types';
import { formatKg as fmtKg, formatYuan as fmtYuan } from '../model';
export function StationSummaryTable({ stations }: { stations: HydrogenStationFull[] }) {
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>
<div className="overflow-x-auto -mx-1 px-1">
<table className="w-full text-[11px]">
<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>
</thead>
<tbody>
{stations.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>
</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)}%` }} />
</div>
<span className="text-slate-500 tabular-nums">{(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>
<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)}%` }} />
</div>
<span className="text-slate-500 tabular-nums">{(s.revenueShare * 100).toFixed(1)}%</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</>
);
}
export function CustomerSummaryTable({ customers }: { customers: HydrogenCustomerRow[] }) {
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>
<div className="overflow-x-auto -mx-1 px-1">
<table className="w-full text-[11px]">
<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>
</thead>
<tbody>
{customers.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">
{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="px-1.5 py-0.5 rounded bg-amber-50 text-amber-600 text-[10px] font-bold">客户</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>
<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>
<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>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { HydrogenOverviewResponse } from '../api.js';
import {
deriveOverviewMetrics,
formatKg,
formatRelative,
formatYuan,
} from './model.js';
test('重量和金额单位保持现有阈值及负数格式', () => {
assert.deepEqual(formatKg(999.5), { value: '999.50', unit: 'Kg' });
assert.deepEqual(formatKg(1000), { value: '1.00', unit: 'T' });
assert.deepEqual(formatYuan(9999), { value: '9,999', unit: '元' });
assert.deepEqual(formatYuan(12345), { value: '1.23', unit: '万元' });
assert.deepEqual(formatYuan(-100_000_000), { value: '-1.00', unit: '亿元' });
});
test('相对更新时间保持秒、分钟、小时和未来时间边界', () => {
const now = new Date(2026, 7, 13, 12, 0, 0).getTime();
assert.equal(formatRelative(now + 60_000, now), '刚刚');
assert.equal(formatRelative(now - 30_000, now), '30 秒前');
assert.equal(formatRelative(now - 5 * 60_000, now), '5 分钟前');
assert.equal(formatRelative(now - 3 * 60 * 60_000, now), '3 小时前');
});
test('总览派生指标保持月份顺序、动能、集中度和收益率口径', () => {
const data = {
kpi: {
yearKg: 1000,
yearFee: 0,
yearProfit: -50,
ourYearKg: 0,
customerYearKg: 0,
ourYearFee: 0,
yearRevenue: 500,
monthKg: 0,
monthFee: 0,
monthRevenue: 0,
monthProfit: 0,
todayKg: 0,
todayFee: 0,
todayRevenue: 0,
todayProfit: 0,
lingniuBornKg: 0,
lingniuBornFee: 0,
},
top5: [{ rank: 1, name: 'A', kg: 600, fee: 100, share: 0.6 }],
regions: [],
monthly: [
{ month: '2026-01', kg: 100, fee: 20, revenue: 30, profit: 10 },
{ month: '2026-02', kg: 150, fee: 30, revenue: 40, profit: 10 },
],
customers: [],
stations: [
{ name: 'A', kg: 600, share: 0.6, revenue: 300, revenueShare: 0.6 },
{ name: 'B', kg: 400, share: 0.4, revenue: 200, revenueShare: 0.4 },
],
availableYears: [2026],
year: 2026,
} satisfies HydrogenOverviewResponse;
const metrics = deriveOverviewMetrics(data);
assert.equal(metrics.monthAvgKg, 125);
assert.equal(metrics.bestMonth?.month, '2026-02');
assert.equal(metrics.latestMonth?.month, '2026-02');
assert.equal(metrics.monthMomentum, 50);
assert.equal(metrics.top5Share, 60);
assert.equal(metrics.profitYield, -10);
assert.equal(metrics.stationAvgKg, 500);
assert.deepEqual(metrics.monthlyDual.map(item => item.monthLabel), ['1月', '2月']);
});
@@ -0,0 +1,75 @@
import type { HydrogenOverviewResponse } from '../api';
export function formatKg(kg: number): { value: string; unit: string } {
if (kg >= 1000) return { value: (kg / 1000).toFixed(2), unit: 'T' };
return { value: kg.toFixed(2), unit: 'Kg' };
}
export function formatYuan(yuan: number): { value: string; unit: string } {
const absolute = Math.abs(yuan);
if (absolute >= 100_000_000) {
return { value: (yuan / 100_000_000).toFixed(2), unit: '亿元' };
}
if (absolute >= 10_000) {
return {
value: (yuan / 10_000).toLocaleString('zh-CN', { maximumFractionDigits: 2 }),
unit: '万元',
};
}
return {
value: yuan.toLocaleString('zh-CN', { maximumFractionDigits: 0 }),
unit: '元',
};
}
export function formatRelative(timestamp: number, now = Date.now()): string {
const seconds = Math.max(0, Math.floor((now - timestamp) / 1000));
if (seconds < 5) return '刚刚';
if (seconds < 60) return `${seconds} 秒前`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} 小时前`;
return new Date(timestamp).toLocaleString('zh-CN', { hour12: false });
}
export function formatRefreshTime(timestamp: number, now = Date.now()): string {
const exactTime = new Date(timestamp).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
return `${formatRelative(timestamp, now)} · ${exactTime.replace(/\//g, '-')}`;
}
export function deriveOverviewMetrics(data: HydrogenOverviewResponse) {
const { kpi, monthly, stations, top5 } = data;
const monthAvgKg = monthly.length > 0
? monthly.reduce((sum, month) => sum + month.kg, 0) / monthly.length
: 0;
const bestMonth = monthly.reduce<typeof monthly[number] | null>(
(best, item) => (!best || item.kg > best.kg ? item : best),
null,
);
const latestMonth = monthly[monthly.length - 1];
const previousMonth = monthly[monthly.length - 2];
return {
monthAvgKg,
bestMonth,
latestMonth,
monthMomentum: latestMonth && previousMonth && previousMonth.kg > 0
? ((latestMonth.kg - previousMonth.kg) / previousMonth.kg) * 100
: null,
top5Share: (top5.reduce((sum, item) => sum + item.kg, 0) / Math.max(1, kpi.yearKg)) * 100,
profitYield: kpi.yearRevenue > 0 ? (kpi.yearProfit / kpi.yearRevenue) * 100 : 0,
stationAvgKg: stations.length > 0 ? kpi.yearKg / stations.length : 0,
monthlyDual: monthly.map(month => ({
...month,
monthLabel: `${month.month.slice(5).replace(/^0/, '')}月`,
})),
};
}