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(() => ( parseElectricDrillContext(window.location.search) )); const [customer, setCustomer] = useState(drillContext.vehicleScope); const [pick, setPick] = useState(() => ( 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(null); const [openMonths, setOpenMonths] = useState>(new Set()); const [error, setError] = useState(null); const [orders, setOrders] = useState(null); const [ordersError, setOrdersError] = useState(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 (
{QUICK_PICK_OPTIONS.map(opt => ( ))}
{(['lingniu', 'external'] as const).map(c => ( ))}
0 ? 'rose' : 'slate'} />
{/* 外部车辆 数据未就绪 */} {showExternalEmpty && (
外部车辆 · 数据未就绪
新系统的外部车辆充电数据还在准备中
上线后此处将展示完整明细
)} {/* 月份分组表 */} {!showExternalEmpty && (
月份 / 日期 充电量 (度) 环比
{error ? (
) : months === null ? (
) : months.length === 0 ? (
) : months.map(m => { const open = openMonths.has(m.month); return (
{open && ( {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 (
{selectedDate === d.date && (
{ordersError ? ( ) : !orders || orders.date !== d.date ? ( ) : orders.items.length === 0 ? ( ) : (
{orders.recordCount} 笔 · {orders.totalKwh.toLocaleString('zh-CN')} 度 合计 ¥{orders.totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
{orders.truncated && (
明细超过 500 笔,当前仅展示前 500 笔;顶部合计仍为全量口径。
)}
时间 / 站点 / 车辆 电量 费用
{orders.items.map(order => (
{order.startTime.slice(11, 16)} {order.plate} {order.orderStatus}
{order.stationName} · {order.orderNo}
{order.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
¥{order.totalFee.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
电 {order.electricityFee.toLocaleString('zh-CN')} · 服 {order.serviceFee.toLocaleString('zh-CN')}
))}
)}
)}
); })}
)}
); })}
)}
); }