Files
ln-bi/src/modules/mileage/VehicleDetailModal.tsx
kkfluous 66779a98e3
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix(mileage): 弹窗关闭改为向下滑出 + 淡出
- exit 从 y:60 改为 y:'100%',整张表自顶向下滑出屏幕
- y/opacity 拆分 transition:y 走 spring,opacity 走 0.18s 淡出
- initial 也改为 y:'100%' 让出现/消失对称

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:16:57 +08:00

343 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react';
import { motion, AnimatePresence, useDragControls } from 'motion/react';
import { X, Truck } from 'lucide-react';
import {
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
} from 'recharts';
import type { MonitoringVehicle } from './types';
import { fetchVehicleRecent, type VehicleRecentDay } from './api';
import Blur from '../../components/Blur';
interface Props {
vehicle: MonitoringVehicle | null;
onClose: () => void;
}
type RangeKey = 'last15' | 'month' | 'quarter';
const RANGE_TABS: { key: RangeKey; label: string }[] = [
{ key: 'last15', label: '近 15 天' },
{ key: 'month', label: '本月' },
{ key: 'quarter', label: '本季度' },
];
function fmtYmd(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${dd}`;
}
function rangeFor(key: RangeKey): { start: string; end: string; rangeLabel: string } {
const today = new Date();
today.setHours(0, 0, 0, 0);
const end = fmtYmd(today);
if (key === 'last15') {
const start = new Date(today);
start.setDate(today.getDate() - 14);
return { start: fmtYmd(start), end, rangeLabel: '近 15 天' };
}
if (key === 'month') {
const start = new Date(today.getFullYear(), today.getMonth(), 1);
return { start: fmtYmd(start), end, rangeLabel: '本月' };
}
const q = Math.floor(today.getMonth() / 3);
const start = new Date(today.getFullYear(), q * 3, 1);
return { start: fmtYmd(start), end, rangeLabel: '本季度' };
}
function isToday(date: string): boolean {
return date === fmtYmd(new Date());
}
function formatLabel(date: string, key: RangeKey): string {
// YYYY-MM-DD → MM-DD季度时仍展示 MM-DD
void key;
return date.slice(5);
}
export default function VehicleDetailModal({ vehicle, onClose }: Props) {
const [days, setDays] = useState<VehicleRecentDay[]>([]);
const [loading, setLoading] = useState(false);
const [range, setRange] = useState<RangeKey>('last15');
const dragControls = useDragControls();
// 切换车辆时重置区间为默认
useEffect(() => {
if (vehicle) setRange('last15');
}, [vehicle?.plate]); // eslint-disable-line react-hooks/exhaustive-deps
// 拉取数据(车辆或区间变化)
useEffect(() => {
if (!vehicle) return;
const { start, end } = rangeFor(range);
setLoading(true);
setDays([]);
let cancelled = false;
fetchVehicleRecent(vehicle.plate, { start, end })
.then(d => { if (!cancelled) setDays(d.days); })
.catch(() => { if (!cancelled) setDays([]); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [vehicle?.plate, range]); // eslint-disable-line react-hooks/exhaustive-deps
// 锁滚动
useEffect(() => {
if (!vehicle) return;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}, [vehicle]);
// 排除"今日"列(数据未到位时易引起误读)
const historyDays = useMemo(() => days.filter(d => !isToday(d.date)), [days]);
const stats = useMemo(() => {
const totalKm = historyDays.reduce((s, d) => s + d.dailyKm, 0);
const synced = historyDays.filter(d => d.isDataSynced).length;
const avg = synced > 0 ? totalKm / synced : 0;
const max = Math.max(1, ...historyDays.map(d => d.dailyKm));
return { totalKm, synced, avg, max, totalDays: historyDays.length };
}, [historyDays]);
// 骨架天数:根据区间预估
const skeletonCount = useMemo(() => {
if (range === 'last15') return 15;
const { start, end } = rangeFor(range);
const s = new Date(start);
const e = new Date(end);
return Math.max(1, Math.round((e.getTime() - s.getTime()) / 86400000));
}, [range]);
return (
<AnimatePresence>
{vehicle && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[80] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
onClick={onClose}
>
<motion.div
initial={{ y: '100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '100%', opacity: 0 }}
transition={{
y: { type: 'spring', damping: 32, stiffness: 320 },
opacity: { duration: 0.18 },
}}
drag="y"
dragControls={dragControls}
dragListener={false}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.6 }}
onDragEnd={(_, info) => {
if (info.offset.y > 100 || info.velocity.y > 600) onClose();
}}
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col touch-pan-y"
onClick={(e) => e.stopPropagation()}
>
{/* iOS 风格 drag handle —— 长按下滑可关闭 */}
<div
className="flex justify-center pt-2.5 pb-1.5 cursor-grab active:cursor-grabbing select-none"
onPointerDown={(e) => dragControls.start(e)}
style={{ touchAction: 'none' }}
>
<div className="w-10 h-1 rounded-full bg-slate-300" />
</div>
{/* Header */}
<div className="flex items-center justify-between px-4 pb-2 border-b border-slate-100">
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 rounded-xl bg-blue-50 flex items-center justify-center flex-shrink-0">
<Truck size={16} className="text-blue-600" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-black text-slate-900 font-mono truncate"><Blur>{vehicle.plate}</Blur></span>
<span className={`text-[8px] px-1 rounded font-bold ${vehicle.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'}`}>
{vehicle.isOnline ? '在线' : '离线'}
</span>
</div>
<div className="text-[10px] font-bold text-slate-400 truncate">
{vehicle.rentStatus || ''}
{vehicle.department ? ` · ${vehicle.department.replace('业务', '')}` : ''}
{vehicle.customer ? ` · ` : ''}
{vehicle.customer && <Blur>{vehicle.customer}</Blur>}
</div>
</div>
</div>
<button onClick={onClose} className="p-2 -mr-1 text-slate-400 hover:text-slate-700 flex-shrink-0">
<X size={18} />
</button>
</div>
{/* 时间范围切换 */}
<div className="px-4 pt-3">
<div className="relative inline-flex bg-slate-100 p-0.5 rounded-lg">
{RANGE_TABS.map(tab => (
<button
key={tab.key}
onClick={() => setRange(tab.key)}
className={`relative px-3 py-1 text-[10px] font-bold rounded-md transition-colors ${range === tab.key ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700'}`}
>
{range === tab.key && (
<motion.div
layoutId="rangeTabBg"
className="absolute inset-0 bg-white shadow-sm rounded-md"
transition={{ type: 'spring', damping: 30, stiffness: 350 }}
/>
)}
<span className="relative">{tab.label}</span>
</button>
))}
</div>
</div>
{/* KPI cards */}
<div className="px-4 py-3 grid grid-cols-3 gap-2">
<div className="bg-slate-50 rounded-xl p-2.5">
<div className="text-[9px] font-bold text-slate-400 uppercase"></div>
<div className="text-base font-black text-slate-900 leading-tight">
{loading ? <span className="inline-block h-4 w-14 bg-slate-200 rounded animate-pulse align-middle" />
: <>{Math.round(stats.totalKm).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
</div>
</div>
<div className="bg-slate-50 rounded-xl p-2.5">
<div className="text-[9px] font-bold text-slate-400 uppercase"></div>
<div className="text-base font-black text-slate-900 leading-tight">
{loading ? <span className="inline-block h-4 w-10 bg-slate-200 rounded animate-pulse align-middle" />
: <>{Math.round(stats.avg).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
</div>
</div>
<div className="bg-slate-50 rounded-xl p-2.5">
<div className="text-[9px] font-bold text-slate-400 uppercase"></div>
<div className="text-base font-black text-slate-900 leading-tight">
{loading ? <span className="inline-block h-4 w-12 bg-slate-200 rounded animate-pulse align-middle" />
: <>{stats.synced}<span className="text-[9px] font-bold text-slate-400 ml-0.5">/{stats.totalDays}</span></>}
</div>
</div>
</div>
{/* Bar chart */}
<div className="px-4 pb-2">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] font-bold text-slate-500"></span>
<span className="text-[9px] font-bold text-slate-300"> km</span>
</div>
<div className="bg-white rounded-xl border border-slate-50">
<div className="h-[140px]">
{loading ? (
<SkeletonBars count={Math.min(skeletonCount, 30)} />
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={historyDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<XAxis
dataKey="date"
tickFormatter={(d) => formatLabel(d, range)}
tick={{ fontSize: 9, fill: '#94a3b8' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
minTickGap={6}
/>
<YAxis hide />
<Tooltip
formatter={(v) => [`${Math.round(Number(v) || 0).toLocaleString()} km`, '行驶里程']}
labelFormatter={(d) => `日期 ${d}`}
contentStyle={{ borderRadius: 12, fontSize: 11, padding: '4px 8px' }}
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
/>
<Bar dataKey="dailyKm" radius={[3, 3, 0, 0]} animationDuration={500}>
{historyDays.map((d, i) => (
<Cell key={i} fill={d.isDataSynced ? '#3b82f6' : '#e2e8f0'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
{/* 每日明细 */}
<div className="flex-1 overflow-y-auto px-4 pb-4">
<div className="text-[10px] font-bold text-slate-500 mb-1.5"></div>
{loading ? (
<SkeletonList count={Math.min(skeletonCount, 15)} />
) : (
<motion.div
key={range}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
className="space-y-1"
>
{historyDays.slice().reverse().map((d, i) => (
<motion.div
key={d.date}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: Math.min(i * 0.012, 0.4), duration: 0.18 }}
className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-slate-50"
>
<span className="text-[11px] font-mono font-bold text-slate-600">{d.date}</span>
<div className="flex items-center gap-2 flex-1 ml-3">
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${(d.dailyKm / stats.max) * 100}%` }}
transition={{ delay: Math.min(i * 0.012, 0.4) + 0.1, duration: 0.4 }}
className={`h-full rounded-full ${d.isDataSynced ? 'bg-blue-500' : 'bg-slate-200'}`}
/>
</div>
<span className={`text-[11px] font-mono font-bold w-20 text-right ${d.isDataSynced ? 'text-slate-700' : 'text-amber-500/60'}`}>
{d.isDataSynced
? <>{Math.round(d.dailyKm).toLocaleString()} <span className="text-[9px] text-slate-400">km</span></>
: <span className="text-[9px]"></span>}
</span>
</div>
</motion.div>
))}
</motion.div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
function SkeletonBars({ count }: { count: number }) {
return (
<div className="flex items-end gap-1 h-full px-2 pb-2 pt-2">
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className="flex-1 bg-slate-100 rounded-t animate-pulse"
style={{
height: `${30 + Math.sin(i * 0.7) * 25 + Math.cos(i * 0.4) * 15 + 30}%`,
animationDelay: `${i * 40}ms`,
}}
/>
))}
</div>
);
}
function SkeletonList({ count }: { count: number }) {
return (
<div className="space-y-1">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-1.5 px-2 animate-pulse" style={{ animationDelay: `${i * 30}ms` }}>
<div className="h-3 w-20 bg-slate-100 rounded" />
<div className="flex items-center gap-2 flex-1 ml-3">
<div className="flex-1 h-1.5 bg-slate-100 rounded-full" />
<div className="h-3 w-12 bg-slate-100 rounded" />
</div>
</div>
))}
</div>
);
}