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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user