229 lines
11 KiB
TypeScript
229 lines
11 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
||
import { ChevronRight, Plug } from 'lucide-react';
|
||
import { motion, AnimatePresence } from 'motion/react';
|
||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
|
||
import TrendBadge from './TrendBadge';
|
||
import { fetchHydrogenDaily } from './api';
|
||
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
|
||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||
|
||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||
{ id: 'thisWeek', label: '本周' },
|
||
{ id: 'thisMonth', label: '本月' },
|
||
{ id: 'last15', label: '近 15 天' },
|
||
];
|
||
|
||
export default function HydrogenDaily() {
|
||
const [pick, setPick] = useState<DateQuickPick>('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);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
setError(null);
|
||
fetchHydrogenDaily(pick, customer)
|
||
.then(r => { if (!cancelled) setRows(r); })
|
||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||
return () => { cancelled = true; };
|
||
}, [pick, customer]);
|
||
|
||
// 柱图:按日期升序,用于"从左到右时间流"
|
||
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 toggle = (date: string) => setExpanded(prev => {
|
||
const next = new Set(prev);
|
||
next.has(date) ? next.delete(date) : next.add(date);
|
||
return next;
|
||
});
|
||
|
||
return (
|
||
<div className="flex flex-col gap-3">
|
||
{/* 日期速选 */}
|
||
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
|
||
{QUICK_PICK_OPTIONS.map(opt => (
|
||
<button
|
||
key={opt.id}
|
||
onClick={() => setPick(opt.id)}
|
||
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
|
||
pick === opt.id
|
||
? 'bg-blue-50 text-blue-600 border-blue-200'
|
||
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
|
||
}`}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* 客户类型 segmented */}
|
||
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
|
||
{(['lingniu', 'external'] as const).map(c => (
|
||
<button
|
||
key={c}
|
||
onClick={() => setCustomer(c)}
|
||
className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
|
||
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
|
||
}`}
|
||
>
|
||
{c === 'external' ? '外部车辆' : '羚牛车辆'}
|
||
</button>
|
||
))}
|
||
</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 && (
|
||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm 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">单位 Kg</span>
|
||
</div>
|
||
<ResponsiveContainer width="100%" height={160}>
|
||
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||
<XAxis
|
||
dataKey="date"
|
||
tickFormatter={(v: string) => v.slice(5)}
|
||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||
tickLine={false}
|
||
axisLine={false}
|
||
interval="preserveStartEnd"
|
||
minTickGap={8}
|
||
/>
|
||
<YAxis hide />
|
||
<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)' }}
|
||
/>
|
||
<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>
|
||
)}
|
||
|
||
{/* 表格(外部车辆 + 全 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="px-3 py-10 text-center text-red-500 text-[12px] font-bold">加载失败:{error}</div>
|
||
) : rows === null ? (
|
||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">加载中…</div>
|
||
) : rows.length === 0 ? (
|
||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">暂无数据</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>
|
||
);
|
||
}
|