Files
ln-bi/src/modules/energy/HydrogenDaily.tsx
T
shishengliang 6ea1b3d5dd
ci/woodpecker/push/woodpecker Pipeline failed
revert: restore v1.1.14 (37a9f303)
2026-09-16 09:29:30 +08:00

279 lines
13 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { ChevronRight, Fuel, Plug, TrendingUp, Truck } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
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';
export default function HydrogenDaily() {
const [pick, setPick] = useState<RangeMode>('last15');
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
const [customer, setCustomer] = useState<CustomerType>('lingniu');
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
useEffect(() => {
let cancelled = false;
setError(null);
const query = pick === 'custom'
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
: { range: pick };
fetchHydrogenDaily(query, customer)
.then(r => { if (!cancelled) setRows(r); })
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, [pick, customer, effectiveRange.start, effectiveRange.end]);
const {
trendData,
totalKg,
activeDays,
stationCount,
avgKg,
peakDay,
lowDay,
zeroDays,
} = useMemo(() => summarizeHydrogenRows(rows), [rows]);
const scopeLabel = getRangeModeLabel(pick);
const rangeText = `${effectiveRange.start}${effectiveRange.end}`;
const toggle = (date: string) => setExpanded(prev => {
const next = new Set(prev);
next.has(date) ? next.delete(date) : next.add(date);
return next;
});
const applyQuickPick = (nextPick: DateQuickPick) => {
setPick(nextPick);
setDateRange(getQuickRange(nextPick));
};
const updateDateRange = (field: 'start' | 'end', value: string) => {
if (!value) return;
setPick('custom');
setDateRange(prev => ({ ...prev, [field]: value }));
};
return (
<div className="flex flex-col gap-3">
<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} />
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" />
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" />
</div>
{/* 外部车辆:新系统数据还没准备好 */}
{customer === 'external' && rows !== null && totalKg === 0 && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
>
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3 relative">
<Plug size={22} className="text-blue-500" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
</div>
<div className="text-sm font-bold text-slate-700 mb-1">外部车辆 · 数据未就绪</div>
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
新系统的外部车辆加氢数据还在准备中
<br />
上线后此处将展示完整明细
</div>
</motion.div>
)}
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
<SurfaceCard>
<div className="flex items-center justify-between px-4 pt-4 mb-2">
<span className="text-sm font-bold text-slate-700">每日加氢量</span>
<span className="text-[11px] text-slate-400 font-bold">时间单位:日 · 单位 Kg</span>
</div>
<div className="mx-4 mb-2 grid grid-cols-3 gap-2 rounded-xl bg-slate-50 p-2">
<div>
<div className="text-[10px] font-black text-slate-400">峰值日</div>
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
{peakDay ? `${peakDay.date.slice(5)} · ${peakDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
</div>
</div>
<div>
<div className="text-[10px] font-black text-slate-400">低谷日</div>
<div className="mt-0.5 truncate text-[11px] font-black text-slate-800">
{lowDay ? `${lowDay.date.slice(5)} · ${lowDay.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}` : '—'}
</div>
</div>
<div>
<div className="text-[10px] font-black text-slate-400">零数据日</div>
<div className={`mt-0.5 text-[11px] font-black ${zeroDays > 0 ? 'text-amber-600' : 'text-emerald-600'}`}>
{zeroDays}
</div>
</div>
</div>
<div className="h-[180px] min-w-0 px-2 pb-2">
<ResponsiveContainer width="100%" height={180} minWidth={0}>
<BarChart data={trendData} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
<XAxis
dataKey="date"
tickFormatter={(v: string) => v.slice(5)}
tick={{ fontSize: 10, fill: '#94a3b8' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
minTickGap={8}
/>
<YAxis
width={42}
axisLine={false}
tickLine={false}
tick={{ fontSize: 9, fill: '#94a3b8' }}
tickFormatter={(v: number) => v >= 1000 ? `${Math.round(v / 1000)}k` : `${Math.round(v)}`}
/>
<Tooltip
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })} Kg`, '加氢量']}
labelFormatter={(d) => `日期 ${d}`}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
/>
{avgKg > 0 && (
<ReferenceLine
y={avgKg}
stroke="#f59e0b"
strokeDasharray="4 4"
label={{ value: '均值', position: 'right', fill: '#d97706', fontSize: 10, fontWeight: 700 }}
/>
)}
<Bar dataKey="totalKg" radius={[4, 4, 0, 0]}>
{trendData.map((_, i) => (
<Cell key={i} fill="url(#hydrogenBarGrad)" />
))}
</Bar>
<defs>
<linearGradient id="hydrogenBarGrad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#22d3ee" />
<stop offset="100%" stopColor="#3b82f6" />
</linearGradient>
</defs>
</BarChart>
</ResponsiveContainer>
</div>
</SurfaceCard>
)}
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
{!(customer === 'external' && rows !== null && totalKg === 0) && (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
{/* 表头 */}
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
<span>日期 / 加氢站</span>
<span className="hidden md:block text-right">单价 (/Kg)</span>
<span className="text-right">加氢量 (Kg)</span>
<span className="text-right">环比</span>
</div>
{/* 合计行 */}
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-blue-50/50 text-[12px] text-blue-600 font-bold">
<span>合计</span>
<span className="hidden md:block" />
<span className="text-right">{totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</span>
<span />
</div>
{/* 主行 + 子行 */}
{error ? (
<div className="p-3"><ErrorState message={error} /></div>
) : rows === null ? (
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
) : rows.length === 0 ? (
<div className="p-3"><EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" /></div>
) : rows.map(r => {
const open = expanded.has(r.date);
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
const abnormalBg = isAbnormal
? r.chainPct > 0 ? 'bg-emerald-50/40' : 'bg-red-50/40'
: '';
return (
<div key={r.date} className={`border-t border-slate-100 ${abnormalBg}`}>
<button
onClick={() => toggle(r.date)}
className="w-full grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2.5 text-left hover:bg-slate-50/60 transition-colors"
>
<span className="flex items-center gap-1 text-[12px] text-slate-700 font-bold">
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
{r.date}
</span>
<span className="hidden md:block text-right text-[12px] text-slate-300"></span>
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
{r.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<span className="text-right"><TrendBadge value={r.chainPct} /></span>
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden bg-slate-50/50"
>
{r.stations.map(s => (
<div
key={s.name}
className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 pl-6 md:pl-9 border-t border-slate-100 first:border-t-0 items-start"
>
<div className="min-w-0">
<div className="text-[12px] text-slate-700 font-medium whitespace-nowrap leading-snug">
{s.name}
</div>
{s.pricePerKg > 0 && (
<div className="md:hidden mt-1">
<span className="inline-flex items-center text-[10px] text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded font-bold whitespace-nowrap">
单价 {s.pricePerKg} /Kg
</span>
</div>
)}
</div>
<span className="hidden md:block text-right text-[12px] text-slate-500 font-bold tabular-nums">{s.pricePerKg > 0 ? s.pricePerKg : '—'}</span>
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
{s.kg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<span className="text-right"><TrendBadge value={s.chainPct} /></span>
</div>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
)}
<RotatingFooterHint />
</div>
);
}