172 lines
7.6 KiB
TypeScript
172 lines
7.6 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
||
import { ChevronRight, Plug } 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';
|
||
|
||
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
|
||
{ id: 'thisWeek', label: '本周' },
|
||
{ id: 'thisMonth', label: '本月' },
|
||
{ id: 'last15', label: '近 15 天' },
|
||
];
|
||
|
||
export default function ElectricDaily() {
|
||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
||
const [pick, setPick] = useState<DateQuickPick>('last15');
|
||
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
|
||
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
setError(null);
|
||
fetchElectricMonthly(customer, pick)
|
||
.then(m => {
|
||
if (cancelled) return;
|
||
setMonths(m);
|
||
// 默认展开最新一个月
|
||
if (m.length > 0) setOpenMonths(prev => prev.size > 0 ? prev : new Set([m[0].month]));
|
||
})
|
||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||
return () => { cancelled = true; };
|
||
}, [customer, pick]);
|
||
|
||
const toggleMonth = (m: string) => setOpenMonths(prev => {
|
||
const next = new Set(prev);
|
||
next.has(m) ? next.delete(m) : next.add(m);
|
||
return next;
|
||
});
|
||
|
||
const totalKwh = useMemo(() => (months ?? []).reduce((s, m) => s + (m.kwh || 0), 0), [months]);
|
||
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
|
||
|
||
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>
|
||
|
||
{/* 客户类型 */}
|
||
<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>
|
||
|
||
{/* 外部车辆 数据未就绪 */}
|
||
{showExternalEmpty && (
|
||
<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>
|
||
)}
|
||
|
||
{/* 月份分组表 */}
|
||
{!showExternalEmpty && (
|
||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||
<div className="grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
|
||
<span>月份 / 日期</span>
|
||
<span className="text-right">充电量 (度)</span>
|
||
<span className="text-right">环比</span>
|
||
</div>
|
||
{error ? (
|
||
<div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">加载失败:{error}</div>
|
||
) : months === null ? (
|
||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">加载中…</div>
|
||
) : months.length === 0 ? (
|
||
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold">暂无数据</div>
|
||
) : months.map(m => {
|
||
const open = openMonths.has(m.month);
|
||
return (
|
||
<div key={m.month} className="border-t border-slate-100 first:border-t-0">
|
||
<button
|
||
onClick={() => toggleMonth(m.month)}
|
||
className={`w-full grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2.5 text-left transition-colors ${
|
||
open ? 'bg-blue-50/30' : 'hover:bg-slate-50'
|
||
}`}
|
||
>
|
||
<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`} />
|
||
{m.month}
|
||
</span>
|
||
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
||
{m.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||
</span>
|
||
<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"
|
||
>
|
||
{m.rows.map(d => {
|
||
const isAbnormal = Math.abs(d.chainPct) >= 0.3;
|
||
const abnormalBg = isAbnormal
|
||
? d.chainPct > 0 ? 'bg-emerald-50/40' : 'bg-red-50/40'
|
||
: 'bg-slate-50/50';
|
||
return (
|
||
<div
|
||
key={d.date}
|
||
className={`grid grid-cols-[minmax(0,1fr)_120px_88px] md:grid-cols-[minmax(0,1fr)_160px_120px] gap-3 px-3 py-2 pl-9 border-t border-slate-100 ${abnormalBg}`}
|
||
>
|
||
<span className="text-[12px] text-slate-600">{d.date.slice(5)}</span>
|
||
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
|
||
{d.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
|
||
</span>
|
||
<span className="text-right"><TrendBadge value={d.chainPct} /></span>
|
||
</div>
|
||
);
|
||
})}
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
<RotatingFooterHint />
|
||
</div>
|
||
);
|
||
}
|