diff --git a/src/modules/mileage/MonitoringView.tsx b/src/modules/mileage/MonitoringView.tsx index 258aeee..ce09130 100644 --- a/src/modules/mileage/MonitoringView.tsx +++ b/src/modules/mileage/MonitoringView.tsx @@ -10,6 +10,7 @@ import { fetchMonitoring } from './api'; import Blur from '../../components/Blur'; import PlateMultiSelect from './PlateMultiSelect'; import { exportMileageXlsx } from './xlsx-export'; +import VehicleDetailModal from './VehicleDetailModal'; const SearchableSelect = ({ options, @@ -115,6 +116,7 @@ export default function MonitoringView() { const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' }); const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' }); const [exporting, setExporting] = useState(false); + const [detailVehicle, setDetailVehicle] = useState(null); const [filterDate, setFilterDate] = useState(() => { const now = new Date(); if (now.getHours() < 5) now.setDate(now.getDate() - 1); @@ -899,10 +901,8 @@ export default function MonitoringView() { initial={{ opacity: 0 }} animate={{ opacity: 1 }} key={v.plate} - className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between active:bg-slate-50 transition-all" - onClick={() => { - navigator.clipboard.writeText(v.plate); - }} + className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between active:bg-slate-50 cursor-pointer transition-all" + onClick={() => setDetailVehicle(v)} >
@@ -966,6 +966,8 @@ export default function MonitoringView() {
+ setDetailVehicle(null)} /> + {/* 回到顶部按钮 */} {showBackToTop && ( diff --git a/src/modules/mileage/VehicleDetailModal.tsx b/src/modules/mileage/VehicleDetailModal.tsx new file mode 100644 index 0000000..207d21c --- /dev/null +++ b/src/modules/mileage/VehicleDetailModal.tsx @@ -0,0 +1,191 @@ +import { useEffect, useState } from 'react'; +import { motion, AnimatePresence } from 'motion/react'; +import { X, Truck, RotateCcw } 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; +} + +const DAYS = 15; + +function fmtMd(date: string): string { + // YYYY-MM-DD → MM-DD + return date.slice(5); +} + +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; +} + +export default function VehicleDetailModal({ vehicle, onClose }: Props) { + const [days, setDays] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!vehicle) return; + setLoading(true); + fetchVehicleRecent(vehicle.plate, DAYS) + .then(d => setDays(d.days)) + .catch(() => setDays([])) + .finally(() => setLoading(false)); + }, [vehicle]); + + // 锁滚动 + useEffect(() => { + if (!vehicle) return; + document.body.style.overflow = 'hidden'; + 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)); + + return ( + + {vehicle && ( + + e.stopPropagation()} + > +
+
+
+ +
+
+
+ {vehicle.plate} + + {vehicle.isOnline ? '在线' : '离线'} + +
+
+ {vehicle.rentStatus || ''} + {vehicle.department ? ` · ${vehicle.department.replace('业务', '')}` : ''} + {vehicle.customer ? ` · ` : ''} + {vehicle.customer && {vehicle.customer}} +
+
+
+ +
+ +
+
+
近{DAYS}日合计
+
+ {Math.round(totalKm).toLocaleString()} + km +
+
+
+
日均
+
+ {Math.round(avgKm).toLocaleString()} + km +
+
+
+
有数据天
+
+ {syncedDays}/{DAYS} +
+
+
+ +
+
+ 近 {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 ? ( +
+ + 加载中... +
+ ) : ( +
+ {historyDays.slice().reverse().map(d => ( +
+ {d.date} +
+
+
+
+ + {d.isDataSynced + ? <>{Math.round(d.dailyKm).toLocaleString()} km + : 未对接} + +
+
+ ))} +
+ )} +
+ + + )} + + ); +} diff --git a/src/modules/mileage/api.ts b/src/modules/mileage/api.ts index b44a796..73056b8 100644 --- a/src/modules/mileage/api.ts +++ b/src/modules/mileage/api.ts @@ -61,3 +61,17 @@ export async function fetchTrend(targetId?: number, days = 7): Promise(`${BASE}/trend?${params.toString()}`); } + +export interface VehicleRecentDay { + date: string; + dailyKm: number; + isDataSynced: boolean; +} + +export async function fetchVehicleRecent(plate: string, days = 15): Promise<{ plate: string; days: VehicleRecentDay[] }> { + const params = new URLSearchParams(); + params.set('days', String(days)); + return fetchJson<{ plate: string; days: VehicleRecentDay[] }>( + `${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}` + ); +} diff --git a/src/server/routes/mileage/index.ts b/src/server/routes/mileage/index.ts index 0770a80..6caccdc 100644 --- a/src/server/routes/mileage/index.ts +++ b/src/server/routes/mileage/index.ts @@ -3,6 +3,7 @@ import { refreshMonitoringCache } from './cache.js'; import monitoringRouter from './monitoring.js'; import targetsRouter from './targets.js'; import trendRouter from './trend.js'; +import vehicleRecentRouter from './vehicle-recent.js'; const app = new Hono(); @@ -10,6 +11,7 @@ app.route('/monitoring', monitoringRouter); app.route('/targets', targetsRouter); app.route('/target', targetsRouter); app.route('/trend', trendRouter); +app.route('/vehicle', vehicleRecentRouter); // 启动时立即刷新缓存,之后每分钟刷新 refreshMonitoringCache(); diff --git a/src/server/routes/mileage/vehicle-recent.ts b/src/server/routes/mileage/vehicle-recent.ts new file mode 100644 index 0000000..e4c4162 --- /dev/null +++ b/src/server/routes/mileage/vehicle-recent.ts @@ -0,0 +1,70 @@ +import { Hono } from 'hono'; +import mileagePool from '../../mileage-db.js'; + +const app = new Hono(); + +interface DayRow { + date: string; + daily_km: string | number | null; + source: string | null; +} + +function fmt(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}`; +} + +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({ days: [] }, 400); + + 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() + ORDER BY stat_date`, + [plate, days] + ) as [DayRow[], unknown]; + + // 同一 plate 同一天可能有多个数据源,取最大 daily_km + const map = new Map(); + for (const r of rows) { + const km = Number(r.daily_km) || 0; + const src = r.source || 'NONE'; + const existing = map.get(r.date); + if (!existing || km > existing.dailyKm) { + map.set(r.date, { dailyKm: km, source: src }); + } + } + + // 补全:从 N 天前到今天(含),每天一条 + const today = new Date(); + today.setHours(0, 0, 0, 0); + 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 hit = map.get(key); + result.push({ + date: key, + dailyKm: hit?.dailyKm ?? 0, + isDataSynced: !!hit && hit.source !== 'NONE', + }); + } + + return c.json({ plate, days: result }); + } catch (e: unknown) { + console.error('vehicle recent error:', e); + return c.json({ plate, days: [] }, 500); + } +}); + +export default app;