Files
ln-bi/src/modules/energy/ElectricDaily.tsx
T

431 lines
21 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { BatteryCharging, CalendarDays, ChevronRight, MapPin, Plug, TrendingUp, Truck, Wallet } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import TrendBadge from './TrendBadge';
import { fetchElectricMonthly, fetchElectricOrders } from './api';
import type { CustomerType, DateQuickPick, ElectricChargeOrderResponse, ElectricMonthGroup } from './types';
import RotatingFooterHint from '../../components/RotatingFooterHint';
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
import {
buildElectricDrillUrl,
parseElectricDrillContext,
type ElectricDrillContext,
} from './electric-drill-context';
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'thisWeek', label: '本周' },
{ id: 'thisMonth', label: '本月' },
{ id: 'last15', label: '近 15 天' },
];
type RangeMode = DateQuickPick | 'custom';
function fmtYmd(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function addDays(d: Date, days: number): Date {
const next = new Date(d);
next.setDate(next.getDate() + days);
return next;
}
function getQuickRange(pick: DateQuickPick): { start: string; end: string } {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (pick === 'thisWeek') {
const day = today.getDay() || 7;
return { start: fmtYmd(addDays(today, -(day - 1))), end: fmtYmd(today) };
}
if (pick === 'thisMonth') {
return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end: fmtYmd(today) };
}
return { start: fmtYmd(addDays(today, -14)), end: fmtYmd(today) };
}
function normalizeRange(start: string, end: string): { start: string; end: string } {
return start <= end ? { start, end } : { start: end, end: start };
}
export default function ElectricDaily() {
const [drillContext, setDrillContext] = useState<ElectricDrillContext>(() => (
parseElectricDrillContext(window.location.search)
));
const [customer, setCustomer] = useState<CustomerType>(drillContext.vehicleScope);
const [pick, setPick] = useState<RangeMode>(() => (
drillContext.startDate && drillContext.endDate || drillContext.selectedDate ? 'custom' : 'last15'
));
const [dateRange, setDateRange] = useState(() => {
if (drillContext.startDate && drillContext.endDate) {
return { start: drillContext.startDate, end: drillContext.endDate };
}
if (drillContext.selectedDate) {
return { start: drillContext.selectedDate, end: drillContext.selectedDate };
}
return getQuickRange('last15');
});
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
const [orders, setOrders] = useState<ElectricChargeOrderResponse | null>(null);
const [ordersError, setOrdersError] = useState<string | null>(null);
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
const selectedDate = drillContext.selectedDate;
const selectedDateRef = useRef(selectedDate);
selectedDateRef.current = selectedDate;
const commitDrillContext = useCallback((next: ElectricDrillContext) => {
const url = buildElectricDrillUrl(window.location, next);
window.history.replaceState(null, '', url);
setDrillContext(next);
}, []);
useEffect(() => {
let cancelled = false;
setError(null);
const query = pick === 'custom'
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
: { range: pick };
fetchElectricMonthly(customer, query)
.then(m => {
if (cancelled) return;
setMonths(m);
if (m.length > 0) {
setOpenMonths(new Set([selectedDateRef.current?.slice(0, 7) || m[0].month]));
}
})
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, [customer, pick, effectiveRange.start, effectiveRange.end]);
useEffect(() => {
if (!selectedDate) {
setOrders(null);
setOrdersError(null);
return undefined;
}
let cancelled = false;
setOrders(null);
setOrdersError(null);
fetchElectricOrders(selectedDate, customer)
.then(result => { if (!cancelled) setOrders(result); })
.catch(e => { if (!cancelled) setOrdersError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, [selectedDate, customer]);
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 totalFee = useMemo(() => (months ?? []).reduce((s, m) => s + (m.fee || 0), 0), [months]);
const activeDays = useMemo(() => (months ?? []).reduce((sum, m) => sum + m.rows.filter(r => r.kwh > 0).length, 0), [months]);
const abnormalDays = useMemo(() => (months ?? []).reduce((sum, m) => sum + m.rows.filter(r => Math.abs(r.chainPct) >= 0.3).length, 0), [months]);
const avgKwh = activeDays > 0 ? totalKwh / activeDays : 0;
const avgPrice = totalKwh > 0 ? totalFee / totalKwh : 0;
const scopeLabel = pick === 'custom'
? '自定义区间'
: QUICK_PICK_OPTIONS.find(item => item.id === pick)?.label ?? '当前时段';
const rangeText = `${effectiveRange.start}${effectiveRange.end}`;
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
const applyQuickPick = (nextPick: DateQuickPick) => {
const nextRange = getQuickRange(nextPick);
setPick(nextPick);
setDateRange(nextRange);
commitDrillContext({
vehicleScope: customer,
startDate: nextRange.start,
endDate: nextRange.end,
});
};
const updateDateRange = (field: 'start' | 'end', value: string) => {
if (!value) return;
const nextRange = { ...dateRange, [field]: value };
const normalized = normalizeRange(nextRange.start, nextRange.end);
setPick('custom');
setDateRange(nextRange);
commitDrillContext({
vehicleScope: customer,
startDate: normalized.start,
endDate: normalized.end,
});
};
const updateCustomer = (next: CustomerType) => {
setCustomer(next);
commitDrillContext({
...drillContext,
vehicleScope: next,
startDate: effectiveRange.start,
endDate: effectiveRange.end,
});
};
const toggleDate = (date: string) => {
commitDrillContext({
...drillContext,
vehicleScope: customer,
startDate: effectiveRange.start,
endDate: effectiveRange.end,
selectedDate: selectedDate === date ? undefined : date,
});
};
useEffect(() => {
const handlePopState = () => {
const next = parseElectricDrillContext(window.location.search);
setDrillContext(next);
setCustomer(next.vehicleScope);
if (next.startDate && next.endDate) {
setPick('custom');
setDateRange({ start: next.startDate, end: next.endDate });
} else if (next.selectedDate) {
setPick('custom');
setDateRange({ start: next.selectedDate, end: next.selectedDate });
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
return (
<div className="flex flex-col gap-3">
<SurfaceCard className="p-2 md:p-3">
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{QUICK_PICK_OPTIONS.map(opt => (
<button
key={opt.id}
onClick={() => applyQuickPick(opt.id)}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === opt.id
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
<button
onClick={() => setPick('custom')}
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
pick === 'custom'
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
: 'border-slate-100 bg-white text-slate-500 hover:bg-slate-50'
}`}
>
自定义
</button>
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400">开始日期</span>
<input
type="date"
value={dateRange.start}
onChange={e => updateDateRange('start', e.target.value)}
onInput={e => updateDateRange('start', e.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
<span className="block text-[10px] font-black text-slate-400">结束日期</span>
<input
type="date"
value={dateRange.end}
onChange={e => updateDateRange('end', e.target.value)}
onInput={e => updateDateRange('end', e.currentTarget.value)}
className="mt-1 h-6 w-full bg-transparent text-[12px] font-black text-slate-800 outline-none"
/>
</label>
</div>
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
{(['lingniu', 'external'] as const).map(c => (
<button
key={c}
onClick={() => updateCustomer(c)}
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
customer === c ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
<Truck size={14} />
{c === 'external' ? '外部车辆' : '羚牛车辆'}
</button>
))}
</div>
</SurfaceCard>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={BatteryCharging} label={`${scopeLabel}充电量`} value={totalKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="度" helper={rangeText} />
<MetricTile
icon={Wallet}
label="充电费用"
value={${totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
helper={`综合费用强度 ${avgPrice.toFixed(2)} 元/度`}
tone="emerald"
/>
<MetricTile icon={CalendarDays} label="有效天数" value={`${activeDays}`} unit="天" helper={`日均 ${avgKwh.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 度`} tone="amber" />
<MetricTile icon={TrendingUp} label="波动提醒" value={abnormalDays} unit="天" helper="环比超过 30% 标记" tone={abnormalDays > 0 ? 'rose' : 'slate'} />
</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="p-3"><ErrorState message={error} /></div>
) : months === null ? (
<div className="p-3"><LoadingState label="正在加载充电明细" /></div>
) : months.length === 0 ? (
<div className="p-3"><EmptyState title="暂无充电数据" description="请切换时间范围或车辆归属" /></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="border-t border-slate-100">
<button
type="button"
onClick={() => toggleDate(d.date)}
className={`grid w-full grid-cols-[minmax(0,1fr)_120px_88px] gap-3 px-3 py-2 pl-9 text-left md:grid-cols-[minmax(0,1fr)_160px_120px] ${abnormalBg}`}
aria-expanded={selectedDate === d.date}
>
<span className="flex items-center gap-1 text-[12px] font-bold text-slate-600">
<ChevronRight size={12} className={`text-slate-400 transition-transform ${selectedDate === d.date ? 'rotate-90' : ''}`} />
{d.date.slice(5)}
</span>
<span className="text-right text-[12px] font-bold tabular-nums text-slate-700">
{d.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<span className="text-right"><TrendBadge value={d.chainPct} /></span>
</button>
{selectedDate === d.date && (
<div className="border-t border-blue-100 bg-blue-50/40 px-3 py-3 md:pl-9">
{ordersError ? (
<ErrorState message={ordersError} />
) : !orders || orders.date !== d.date ? (
<LoadingState label="正在加载充电订单" />
) : orders.items.length === 0 ? (
<EmptyState title="当日无充电订单" description="当前车辆归属下没有订单明细" />
) : (
<div className="overflow-hidden rounded-lg border border-blue-100 bg-white">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-blue-100 bg-blue-50 px-3 py-2 text-[10px] font-bold text-blue-700">
<span>{orders.recordCount} · {orders.totalKwh.toLocaleString('zh-CN')} </span>
<span>合计 ¥{orders.totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</span>
</div>
{orders.truncated && (
<div className="border-b border-amber-100 bg-amber-50 px-3 py-2 text-[10px] font-bold text-amber-700">
明细超过 500 笔,当前仅展示前 500 笔;顶部合计仍为全量口径。
</div>
)}
<div className="grid grid-cols-[minmax(0,1fr)_72px_82px] gap-2 border-b border-slate-100 bg-slate-50 px-3 py-1.5 text-[10px] font-bold text-slate-400 md:grid-cols-[minmax(0,1fr)_100px_120px]">
<span>时间 / 站点 / 车辆</span>
<span className="text-right">电量</span>
<span className="text-right">费用</span>
</div>
{orders.items.map(order => (
<div key={order.id} className="grid grid-cols-[minmax(0,1fr)_72px_82px] gap-2 border-b border-slate-100 px-3 py-2 last:border-b-0 md:grid-cols-[minmax(0,1fr)_100px_120px]">
<div className="min-w-0">
<div className="flex items-center gap-1 text-[11px] font-black text-slate-700">
<span>{order.startTime.slice(11, 16)}</span>
<span className="truncate text-blue-600">{order.plate}</span>
<span className="shrink-0 rounded bg-slate-100 px-1 py-0.5 text-[9px] text-slate-500">{order.orderStatus}</span>
</div>
<div className="mt-1 flex min-w-0 items-center gap-1 text-[10px] font-bold text-slate-400">
<MapPin size={10} className="shrink-0" />
<span className="truncate">{order.stationName}</span>
<span className="hidden shrink-0 md:inline">· {order.orderNo}</span>
</div>
</div>
<div className="text-right text-[11px] font-black tabular-nums text-slate-700">
{order.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
<div className="mt-1 text-[9px] font-bold text-slate-400"></div>
</div>
<div className="text-right text-[11px] font-black tabular-nums text-emerald-600">
¥{order.totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
<div className="mt-1 text-[9px] font-bold text-slate-400">
{order.electricityFee.toLocaleString('zh-CN')} · {order.serviceFee.toLocaleString('zh-CN')}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
)}
<RotatingFooterHint />
</div>
);
}