refactor: add electric order drilldown

This commit is contained in:
kkfluous
2026-08-07 14:55:55 +08:00
parent cf9782562b
commit cdf1fd27e7
8 changed files with 414 additions and 21 deletions
+177 -21
View File
@@ -1,11 +1,16 @@
import { useEffect, useMemo, useState } from 'react';
import { BatteryCharging, CalendarDays, ChevronRight, Plug, TrendingUp, Truck, Wallet } from 'lucide-react';
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 } from './api';
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
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: '本周' },
@@ -43,14 +48,38 @@ function normalizeRange(start: string, end: string): { start: string; end: strin
}
export default function ElectricDaily() {
const [customer, setCustomer] = useState<CustomerType>('lingniu');
const [pick, setPick] = useState<RangeMode>('last15');
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
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;
@@ -62,13 +91,29 @@ export default function ElectricDaily() {
.then(m => {
if (cancelled) return;
setMonths(m);
// 默认展开最新一个月
if (m.length > 0) setOpenMonths(new Set([m[0].month]));
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);
@@ -88,16 +133,66 @@ export default function ElectricDaily() {
const showExternalEmpty = customer === 'external' && months !== null && totalKwh === 0;
const applyQuickPick = (nextPick: DateQuickPick) => {
const nextRange = getQuickRange(nextPick);
setPick(nextPick);
setDateRange(getQuickRange(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(prev => ({ ...prev, [field]: value }));
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">
@@ -154,7 +249,7 @@ export default function ElectricDaily() {
{(['lingniu', 'external'] as const).map(c => (
<button
key={c}
onClick={() => setCustomer(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'
}`}
@@ -248,15 +343,76 @@ export default function ElectricDaily() {
? 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 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>
);
})}