feat(energy): hydrogen daily table with station drilldown

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-04-28 11:33:52 +08:00
parent c8a1e8506e
commit a40fd2be34

View File

@@ -1,7 +1,173 @@
import { useMemo, useState } from 'react';
import { ChevronRight } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import TrendBadge from './TrendBadge';
import { HYDROGEN_DAILY } from './mock';
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
const TODAY = new Date('2026-04-28');
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'today', label: '当天' },
{ id: 'thisWeek', label: '本周' },
{ id: 'thisMonth', label: '本月' },
{ id: 'thisQuarter', label: '本季度' },
{ id: 'last7', label: '最近7天' },
{ id: 'last30', label: '最近30天' },
];
function isInPick(date: string, pick: DateQuickPick): boolean {
const d = new Date(date);
switch (pick) {
case 'today': {
return d.toISOString().slice(0, 10) === TODAY.toISOString().slice(0, 10);
}
case 'thisWeek': {
const day = TODAY.getDay() || 7;
const start = new Date(TODAY); start.setDate(TODAY.getDate() - day + 1);
return d >= start && d <= TODAY;
}
case 'thisMonth':
return d.getFullYear() === TODAY.getFullYear() && d.getMonth() === TODAY.getMonth();
case 'thisQuarter': {
const q = Math.floor(TODAY.getMonth() / 3);
const dq = Math.floor(d.getMonth() / 3);
return d.getFullYear() === TODAY.getFullYear() && dq === q;
}
case 'last7': {
const c = new Date(TODAY); c.setDate(TODAY.getDate() - 6);
return d >= c && d <= TODAY;
}
case 'last30': {
const c = new Date(TODAY); c.setDate(TODAY.getDate() - 29);
return d >= c && d <= TODAY;
}
}
}
export default function HydrogenDaily() {
const [pick, setPick] = useState<DateQuickPick>('last30');
const [customer, setCustomer] = useState<CustomerType>('external');
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const rows = useMemo<HydrogenDailyRow[]>(() => {
return HYDROGEN_DAILY
.filter(r => r.customerType === customer)
.filter(r => isInPick(r.date, pick))
.sort((a, b) => b.date.localeCompare(a.date));
}, [pick, customer]);
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="bg-white rounded-2xl border border-slate-100 shadow-sm p-6 text-center text-slate-400 text-sm">
Task 5
<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">
{(['external', 'lingniu'] 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>
{/* 表格 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
{/* 表头 */}
<div className="grid grid-cols-[1fr_auto_auto] md:grid-cols-[1fr_140px_120px_120px] gap-2 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
<span> / </span>
<span className="hidden md:block text-right"></span>
<span className="text-right">(Kg)</span>
<span className="text-right"></span>
</div>
{/* 合计行 */}
<div className="grid grid-cols-[1fr_auto_auto] md:grid-cols-[1fr_140px_120px_120px] gap-2 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>
{/* 主行 + 子行 */}
{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);
return (
<div key={r.date} className="border-t border-slate-100">
<button
onClick={() => toggle(r.date)}
className="w-full grid grid-cols-[1fr_auto_auto] md:grid-cols-[1fr_140px_120px_120px] gap-2 px-3 py-2.5 text-left hover:bg-slate-50 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-400"></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-[1fr_auto_auto] md:grid-cols-[1fr_140px_120px_120px] gap-2 px-3 py-2 pl-9 md:pl-9 border-t border-slate-100 first:border-t-0"
>
<span className="text-[12px] text-slate-600 truncate">
{s.name}
<span className="md:hidden text-slate-400"> · {s.pricePerKg} /Kg</span>
</span>
<span className="hidden md:block text-right text-[12px] text-slate-500 font-bold">{s.pricePerKg} /Kg</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>
</div>
);
}