feat(mileage): 车辆明细弹窗新增时间范围切换、骨架加载与下滑关闭
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 后端 vehicle/:plate/recent 支持 start/end 任意区间,最长 366 天 - 前端弹窗加 segmented control: 近 15 天 / 本月 / 本季度,切换重新加载 - 加载时柱状图与每日明细均显示骨架,区间合计/日均/有数据天 KPI 同步骨架 - 数据回来后柱条与每行进度条带渐入动画 - 顶部加 iOS 风格 drag handle(小白条),按住下滑超过 100px 或大速度触发关闭 - 保留点击背景与 X 按钮两种关闭方式 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence, useDragControls } from 'motion/react';
|
||||||
import { X, Truck, RotateCcw } from 'lucide-react';
|
import { X, Truck } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
|
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
@@ -13,31 +13,73 @@ interface Props {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DAYS = 15;
|
type RangeKey = 'last15' | 'month' | 'quarter';
|
||||||
|
|
||||||
function fmtMd(date: string): string {
|
const RANGE_TABS: { key: RangeKey; label: string }[] = [
|
||||||
// YYYY-MM-DD → MM-DD
|
{ key: 'last15', label: '近 15 天' },
|
||||||
return date.slice(5);
|
{ 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 {
|
function isToday(date: string): boolean {
|
||||||
const d = new Date();
|
return date === fmtYmd(new Date());
|
||||||
const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
}
|
||||||
return date === k;
|
|
||||||
|
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) {
|
export default function VehicleDetailModal({ vehicle, onClose }: Props) {
|
||||||
const [days, setDays] = useState<VehicleRecentDay[]>([]);
|
const [days, setDays] = useState<VehicleRecentDay[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!vehicle) return;
|
if (!vehicle) return;
|
||||||
|
const { start, end } = rangeFor(range);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetchVehicleRecent(vehicle.plate, DAYS)
|
setDays([]);
|
||||||
.then(d => setDays(d.days))
|
let cancelled = false;
|
||||||
.catch(() => setDays([]))
|
fetchVehicleRecent(vehicle.plate, { start, end })
|
||||||
.finally(() => setLoading(false));
|
.then(d => { if (!cancelled) setDays(d.days); })
|
||||||
}, [vehicle]);
|
.catch(() => { if (!cancelled) setDays([]); })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [vehicle?.plate, range]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// 锁滚动
|
// 锁滚动
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -46,12 +88,24 @@ export default function VehicleDetailModal({ vehicle, onClose }: Props) {
|
|||||||
return () => { document.body.style.overflow = ''; };
|
return () => { document.body.style.overflow = ''; };
|
||||||
}, [vehicle]);
|
}, [vehicle]);
|
||||||
|
|
||||||
// 排除"今日"列(数据未到位时易引起误读):仅展示历史 N 天
|
// 排除"今日"列(数据未到位时易引起误读)
|
||||||
const historyDays = days.filter(d => !isToday(d.date));
|
const historyDays = useMemo(() => days.filter(d => !isToday(d.date)), [days]);
|
||||||
const totalKm = historyDays.reduce((sum, d) => sum + d.dailyKm, 0);
|
const stats = useMemo(() => {
|
||||||
const syncedDays = historyDays.filter(d => d.isDataSynced).length;
|
const totalKm = historyDays.reduce((s, d) => s + d.dailyKm, 0);
|
||||||
const avgKm = syncedDays > 0 ? totalKm / syncedDays : 0;
|
const synced = historyDays.filter(d => d.isDataSynced).length;
|
||||||
const maxKm = Math.max(1, ...historyDays.map(d => d.dailyKm));
|
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 (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -64,14 +118,32 @@ export default function VehicleDetailModal({ vehicle, onClose }: Props) {
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ y: 30, opacity: 0 }}
|
initial={{ y: 80, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
exit={{ y: 30, opacity: 0 }}
|
exit={{ y: 60, opacity: 0 }}
|
||||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
transition={{ type: 'spring', damping: 28, stiffness: 320 }}
|
||||||
className="bg-white w-full md:max-w-md md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[90vh] overflow-hidden flex flex-col"
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between px-4 pt-4 pb-2 border-b border-slate-100">
|
{/* 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="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">
|
<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" />
|
<Truck size={16} className="text-blue-600" />
|
||||||
@@ -96,80 +168,123 @@ export default function VehicleDetailModal({ vehicle, onClose }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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="px-4 py-3 grid grid-cols-3 gap-2">
|
||||||
<div className="bg-slate-50 rounded-xl p-2.5">
|
<div className="bg-slate-50 rounded-xl p-2.5">
|
||||||
<div className="text-[9px] font-bold text-slate-400 uppercase">近{DAYS}日合计</div>
|
<div className="text-[9px] font-bold text-slate-400 uppercase">区间合计</div>
|
||||||
<div className="text-base font-black text-slate-900 leading-tight">
|
<div className="text-base font-black text-slate-900 leading-tight">
|
||||||
{Math.round(totalKm).toLocaleString()}
|
{loading ? <span className="inline-block h-4 w-14 bg-slate-200 rounded animate-pulse align-middle" />
|
||||||
<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span>
|
: <>{Math.round(stats.totalKm).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-slate-50 rounded-xl p-2.5">
|
<div className="bg-slate-50 rounded-xl p-2.5">
|
||||||
<div className="text-[9px] font-bold text-slate-400 uppercase">日均</div>
|
<div className="text-[9px] font-bold text-slate-400 uppercase">日均</div>
|
||||||
<div className="text-base font-black text-slate-900 leading-tight">
|
<div className="text-base font-black text-slate-900 leading-tight">
|
||||||
{Math.round(avgKm).toLocaleString()}
|
{loading ? <span className="inline-block h-4 w-10 bg-slate-200 rounded animate-pulse align-middle" />
|
||||||
<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span>
|
: <>{Math.round(stats.avg).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-slate-50 rounded-xl p-2.5">
|
<div className="bg-slate-50 rounded-xl p-2.5">
|
||||||
<div className="text-[9px] font-bold text-slate-400 uppercase">有数据天</div>
|
<div className="text-[9px] font-bold text-slate-400 uppercase">有数据天</div>
|
||||||
<div className="text-base font-black text-slate-900 leading-tight">
|
<div className="text-base font-black text-slate-900 leading-tight">
|
||||||
{syncedDays}<span className="text-[9px] font-bold text-slate-400 ml-0.5">/{DAYS}</span>
|
{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bar chart */}
|
||||||
<div className="px-4 pb-2">
|
<div className="px-4 pb-2">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="text-[10px] font-bold text-slate-500">近 {DAYS} 日行驶里程</span>
|
<span className="text-[10px] font-bold text-slate-500">行驶里程</span>
|
||||||
<span className="text-[9px] font-bold text-slate-300">单位 km</span>
|
<span className="text-[9px] font-bold text-slate-300">单位 km</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-xl border border-slate-50">
|
<div className="bg-white rounded-xl border border-slate-50">
|
||||||
<ResponsiveContainer width="100%" height={140}>
|
<div className="h-[140px]">
|
||||||
<BarChart data={historyDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
{loading ? (
|
||||||
<XAxis
|
<SkeletonBars count={Math.min(skeletonCount, 30)} />
|
||||||
dataKey="date"
|
) : (
|
||||||
tickFormatter={fmtMd}
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
tick={{ fontSize: 9, fill: '#94a3b8' }}
|
<BarChart data={historyDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
||||||
tickLine={false}
|
<XAxis
|
||||||
axisLine={false}
|
dataKey="date"
|
||||||
interval="preserveStartEnd"
|
tickFormatter={(d) => formatLabel(d, range)}
|
||||||
minTickGap={6}
|
tick={{ fontSize: 9, fill: '#94a3b8' }}
|
||||||
/>
|
tickLine={false}
|
||||||
<YAxis hide />
|
axisLine={false}
|
||||||
<Tooltip
|
interval="preserveStartEnd"
|
||||||
formatter={(v) => [`${Math.round(Number(v) || 0).toLocaleString()} km`, '行驶里程']}
|
minTickGap={6}
|
||||||
labelFormatter={(d) => `日期 ${d}`}
|
/>
|
||||||
contentStyle={{ borderRadius: 12, fontSize: 11, padding: '4px 8px' }}
|
<YAxis hide />
|
||||||
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
|
<Tooltip
|
||||||
/>
|
formatter={(v) => [`${Math.round(Number(v) || 0).toLocaleString()} km`, '行驶里程']}
|
||||||
<Bar dataKey="dailyKm" radius={[3, 3, 0, 0]}>
|
labelFormatter={(d) => `日期 ${d}`}
|
||||||
{historyDays.map((d, i) => (
|
contentStyle={{ borderRadius: 12, fontSize: 11, padding: '4px 8px' }}
|
||||||
<Cell key={i} fill={d.isDataSynced ? '#3b82f6' : '#e2e8f0'} />
|
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
|
||||||
))}
|
/>
|
||||||
</Bar>
|
<Bar dataKey="dailyKm" radius={[3, 3, 0, 0]} animationDuration={500}>
|
||||||
</BarChart>
|
{historyDays.map((d, i) => (
|
||||||
</ResponsiveContainer>
|
<Cell key={i} fill={d.isDataSynced ? '#3b82f6' : '#e2e8f0'} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 每日明细 */}
|
||||||
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
||||||
<div className="text-[10px] font-bold text-slate-500 mb-1.5">每日明细</div>
|
<div className="text-[10px] font-bold text-slate-500 mb-1.5">每日明细</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="py-6 text-center">
|
<SkeletonList count={Math.min(skeletonCount, 15)} />
|
||||||
<RotateCcw size={14} className="inline animate-spin text-slate-400 mr-1" />
|
|
||||||
<span className="text-[11px] font-bold text-slate-400">加载中...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<motion.div
|
||||||
{historyDays.slice().reverse().map(d => (
|
key={range}
|
||||||
<div key={d.date} className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-slate-50">
|
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>
|
<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 items-center gap-2 flex-1 ml-3">
|
||||||
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||||
<div
|
<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'}`}
|
className={`h-full rounded-full ${d.isDataSynced ? 'bg-blue-500' : 'bg-slate-200'}`}
|
||||||
style={{ width: `${(d.dailyKm / maxKm) * 100}%` }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-[11px] font-mono font-bold w-20 text-right ${d.isDataSynced ? 'text-slate-700' : 'text-amber-500/60'}`}>
|
<span className={`text-[11px] font-mono font-bold w-20 text-right ${d.isDataSynced ? 'text-slate-700' : 'text-amber-500/60'}`}>
|
||||||
@@ -178,9 +293,9 @@ export default function VehicleDetailModal({ vehicle, onClose }: Props) {
|
|||||||
: <span className="text-[9px]">未对接</span>}
|
: <span className="text-[9px]">未对接</span>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -189,3 +304,36 @@ export default function VehicleDetailModal({ vehicle, onClose }: Props) {
|
|||||||
</AnimatePresence>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,10 +68,22 @@ export interface VehicleRecentDay {
|
|||||||
isDataSynced: boolean;
|
isDataSynced: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchVehicleRecent(plate: string, days = 15): Promise<{ plate: string; days: VehicleRecentDay[] }> {
|
export interface VehicleRecentResponse {
|
||||||
|
plate: string;
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
|
days: VehicleRecentDay[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVehicleRecent(
|
||||||
|
plate: string,
|
||||||
|
range: { days?: number; start?: string; end?: string } = { days: 15 },
|
||||||
|
): Promise<VehicleRecentResponse> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('days', String(days));
|
if (range.start) params.set('start', range.start);
|
||||||
return fetchJson<{ plate: string; days: VehicleRecentDay[] }>(
|
if (range.end) params.set('end', range.end);
|
||||||
|
if (range.days != null) params.set('days', String(range.days));
|
||||||
|
return fetchJson<VehicleRecentResponse>(
|
||||||
`${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}`
|
`${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,21 +16,54 @@ function fmt(d: Date): string {
|
|||||||
return `${y}-${m}-${dd}`;
|
return `${y}-${m}-${dd}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseYmd(s: string): Date | null {
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
||||||
|
if (!m) return null;
|
||||||
|
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return Number.isFinite(d.getTime()) ? d : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_DAYS = 366;
|
||||||
|
|
||||||
app.get('/:plate/recent', async (c) => {
|
app.get('/:plate/recent', async (c) => {
|
||||||
const plate = c.req.param('plate');
|
const plate = c.req.param('plate');
|
||||||
const days = Math.min(Math.max(Number(c.req.query('days')) || 15, 1), 60);
|
if (!plate) return c.json({ plate: '', days: [] }, 400);
|
||||||
|
|
||||||
if (!plate) return c.json({ days: [] }, 400);
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// 区间参数:优先 start/end;否则回退 days(兼容旧调用)
|
||||||
|
const startQ = c.req.query('start');
|
||||||
|
const endQ = c.req.query('end');
|
||||||
|
let start: Date;
|
||||||
|
let end: Date;
|
||||||
|
if (startQ) {
|
||||||
|
const ps = parseYmd(startQ);
|
||||||
|
if (!ps) return c.json({ plate, days: [] }, 400);
|
||||||
|
start = ps;
|
||||||
|
end = endQ ? (parseYmd(endQ) ?? today) : today;
|
||||||
|
} else {
|
||||||
|
const days = Math.min(Math.max(Number(c.req.query('days')) || 15, 1), MAX_DAYS);
|
||||||
|
end = today;
|
||||||
|
start = new Date(today);
|
||||||
|
start.setDate(today.getDate() - (days - 1));
|
||||||
|
}
|
||||||
|
if (start > end) [start, end] = [end, start];
|
||||||
|
// 限制区间长度
|
||||||
|
const span = Math.round((end.getTime() - start.getTime()) / 86400000) + 1;
|
||||||
|
if (span > MAX_DAYS) {
|
||||||
|
start = new Date(end);
|
||||||
|
start.setDate(end.getDate() - (MAX_DAYS - 1));
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [rows] = await mileagePool.execute(
|
const [rows] = await mileagePool.execute(
|
||||||
`SELECT DATE_FORMAT(stat_date, '%Y-%m-%d') AS date, daily_km, source
|
`SELECT DATE_FORMAT(stat_date, '%Y-%m-%d') AS date, daily_km, source
|
||||||
FROM v_vehicle_daily_stats
|
FROM v_vehicle_daily_stats
|
||||||
WHERE plate = ?
|
WHERE plate = ? AND stat_date >= ? AND stat_date <= ?
|
||||||
AND stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
|
||||||
AND stat_date <= CURDATE()
|
|
||||||
ORDER BY stat_date`,
|
ORDER BY stat_date`,
|
||||||
[plate, days]
|
[plate, fmt(start), fmt(end)]
|
||||||
) as [DayRow[], unknown];
|
) as [DayRow[], unknown];
|
||||||
|
|
||||||
// 同一 plate 同一天可能有多个数据源,取最大 daily_km
|
// 同一 plate 同一天可能有多个数据源,取最大 daily_km
|
||||||
@@ -44,23 +77,21 @@ app.get('/:plate/recent', async (c) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 补全:从 N 天前到今天(含),每天一条
|
// 补全:从 start 到 end 每天一条
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
const result: { date: string; dailyKm: number; isDataSynced: boolean }[] = [];
|
const result: { date: string; dailyKm: number; isDataSynced: boolean }[] = [];
|
||||||
for (let i = days; i >= 0; i--) {
|
const cursor = new Date(start);
|
||||||
const d = new Date(today);
|
while (cursor <= end) {
|
||||||
d.setDate(today.getDate() - i);
|
const key = fmt(cursor);
|
||||||
const key = fmt(d);
|
|
||||||
const hit = map.get(key);
|
const hit = map.get(key);
|
||||||
result.push({
|
result.push({
|
||||||
date: key,
|
date: key,
|
||||||
dailyKm: hit?.dailyKm ?? 0,
|
dailyKm: hit?.dailyKm ?? 0,
|
||||||
isDataSynced: !!hit && hit.source !== 'NONE',
|
isDataSynced: !!hit && hit.source !== 'NONE',
|
||||||
});
|
});
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ plate, days: result });
|
return c.json({ plate, start: fmt(start), end: fmt(end), days: result });
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error('vehicle recent error:', e);
|
console.error('vehicle recent error:', e);
|
||||||
return c.json({ plate, days: [] }, 500);
|
return c.json({ plate, days: [] }, 500);
|
||||||
|
|||||||
Reference in New Issue
Block a user