From 97ac92a0da23b18344188d7771c4bdd3d6eb9ad0 Mon Sep 17 00:00:00 2001 From: kkfluous Date: Wed, 29 Apr 2026 17:14:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(mileage):=20=E8=BD=A6=E8=BE=86=E6=98=8E?= =?UTF-8?q?=E7=BB=86=E5=BC=B9=E7=AA=97=E6=96=B0=E5=A2=9E=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=E5=88=87=E6=8D=A2=E3=80=81=E9=AA=A8=E6=9E=B6?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E4=B8=8E=E4=B8=8B=E6=BB=91=E5=85=B3=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 vehicle/:plate/recent 支持 start/end 任意区间,最长 366 天 - 前端弹窗加 segmented control: 近 15 天 / 本月 / 本季度,切换重新加载 - 加载时柱状图与每日明细均显示骨架,区间合计/日均/有数据天 KPI 同步骨架 - 数据回来后柱条与每行进度条带渐入动画 - 顶部加 iOS 风格 drag handle(小白条),按住下滑超过 100px 或大速度触发关闭 - 保留点击背景与 X 按钮两种关闭方式 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/modules/mileage/VehicleDetailModal.tsx | 286 +++++++++++++++----- src/modules/mileage/api.ts | 18 +- src/server/routes/mileage/vehicle-recent.ts | 59 +++- 3 files changed, 277 insertions(+), 86 deletions(-) diff --git a/src/modules/mileage/VehicleDetailModal.tsx b/src/modules/mileage/VehicleDetailModal.tsx index 207d21c..74f78f7 100644 --- a/src/modules/mileage/VehicleDetailModal.tsx +++ b/src/modules/mileage/VehicleDetailModal.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState } from 'react'; -import { motion, AnimatePresence } from 'motion/react'; -import { X, Truck, RotateCcw } from 'lucide-react'; +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'; @@ -13,31 +13,73 @@ interface Props { onClose: () => void; } -const DAYS = 15; +type RangeKey = 'last15' | 'month' | 'quarter'; -function fmtMd(date: string): string { - // YYYY-MM-DD → MM-DD - return date.slice(5); +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 { - const d = new Date(); - const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; - return date === k; + 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([]); const [loading, setLoading] = useState(false); + const [range, setRange] = useState('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); - fetchVehicleRecent(vehicle.plate, DAYS) - .then(d => setDays(d.days)) - .catch(() => setDays([])) - .finally(() => setLoading(false)); - }, [vehicle]); + 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(() => { @@ -46,12 +88,24 @@ export default function VehicleDetailModal({ vehicle, onClose }: Props) { return () => { document.body.style.overflow = ''; }; }, [vehicle]); - // 排除"今日"列(数据未到位时易引起误读):仅展示历史 N 天 - const historyDays = days.filter(d => !isToday(d.date)); - const totalKm = historyDays.reduce((sum, d) => sum + d.dailyKm, 0); - const syncedDays = historyDays.filter(d => d.isDataSynced).length; - const avgKm = syncedDays > 0 ? totalKm / syncedDays : 0; - const maxKm = Math.max(1, ...historyDays.map(d => d.dailyKm)); + // 排除"今日"列(数据未到位时易引起误读) + 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 ( @@ -64,14 +118,32 @@ export default function VehicleDetailModal({ vehicle, onClose }: Props) { onClick={onClose} > { + 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 —— 长按下滑可关闭 */} +
dragControls.start(e)} + style={{ touchAction: 'none' }} + > +
+
+ + {/* Header */} +
@@ -96,80 +168,123 @@ export default function VehicleDetailModal({ vehicle, onClose }: Props) {
+ {/* 时间范围切换 */} +
+
+ {RANGE_TABS.map(tab => ( + + ))} +
+
+ + {/* KPI cards */}
-
近{DAYS}日合计
+
区间合计
- {Math.round(totalKm).toLocaleString()} - km + {loading ? + : <>{Math.round(stats.totalKm).toLocaleString()}km}
日均
- {Math.round(avgKm).toLocaleString()} - km + {loading ? + : <>{Math.round(stats.avg).toLocaleString()}km}
有数据天
- {syncedDays}/{DAYS} + {loading ? + : <>{stats.synced}/{stats.totalDays}}
+ {/* Bar chart */}
- 近 {DAYS} 日行驶里程 + 行驶里程 单位 km
- - - - - [`${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)' }} - /> - - {historyDays.map((d, i) => ( - - ))} - - - +
+ {loading ? ( + + ) : ( + + + formatLabel(d, range)} + tick={{ fontSize: 9, fill: '#94a3b8' }} + tickLine={false} + axisLine={false} + interval="preserveStartEnd" + minTickGap={6} + /> + + [`${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)' }} + /> + + {historyDays.map((d, i) => ( + + ))} + + + + )} +
+ {/* 每日明细 */}
每日明细
{loading ? ( -
- - 加载中... -
+ ) : ( -
- {historyDays.slice().reverse().map(d => ( -
+ + {historyDays.slice().reverse().map((d, i) => ( + {d.date}
-
@@ -178,9 +293,9 @@ export default function VehicleDetailModal({ vehicle, onClose }: Props) { : 未对接}
-
+
))} -
+ )}
@@ -189,3 +304,36 @@ export default function VehicleDetailModal({ vehicle, onClose }: Props) { ); } + +function SkeletonBars({ count }: { count: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+ ))} +
+ ); +} + +function SkeletonList({ count }: { count: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} diff --git a/src/modules/mileage/api.ts b/src/modules/mileage/api.ts index 73056b8..784cf30 100644 --- a/src/modules/mileage/api.ts +++ b/src/modules/mileage/api.ts @@ -68,10 +68,22 @@ export interface VehicleRecentDay { 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 { const params = new URLSearchParams(); - params.set('days', String(days)); - return fetchJson<{ plate: string; days: VehicleRecentDay[] }>( + if (range.start) params.set('start', range.start); + if (range.end) params.set('end', range.end); + if (range.days != null) params.set('days', String(range.days)); + return fetchJson( `${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}` ); } diff --git a/src/server/routes/mileage/vehicle-recent.ts b/src/server/routes/mileage/vehicle-recent.ts index e4c4162..2f5a223 100644 --- a/src/server/routes/mileage/vehicle-recent.ts +++ b/src/server/routes/mileage/vehicle-recent.ts @@ -16,21 +16,54 @@ function fmt(d: Date): string { 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) => { 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 { const [rows] = await mileagePool.execute( `SELECT DATE_FORMAT(stat_date, '%Y-%m-%d') AS date, daily_km, source FROM v_vehicle_daily_stats - WHERE plate = ? - AND stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) - AND stat_date <= CURDATE() + WHERE plate = ? AND stat_date >= ? AND stat_date <= ? ORDER BY stat_date`, - [plate, days] + [plate, fmt(start), fmt(end)] ) as [DayRow[], unknown]; // 同一 plate 同一天可能有多个数据源,取最大 daily_km @@ -44,23 +77,21 @@ app.get('/:plate/recent', async (c) => { } } - // 补全:从 N 天前到今天(含),每天一条 - const today = new Date(); - today.setHours(0, 0, 0, 0); + // 补全:从 start 到 end 每天一条 const result: { date: string; dailyKm: number; isDataSynced: boolean }[] = []; - for (let i = days; i >= 0; i--) { - const d = new Date(today); - d.setDate(today.getDate() - i); - const key = fmt(d); + const cursor = new Date(start); + while (cursor <= end) { + const key = fmt(cursor); const hit = map.get(key); result.push({ date: key, dailyKm: hit?.dailyKm ?? 0, 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) { console.error('vehicle recent error:', e); return c.json({ plate, days: [] }, 500);