feat(mileage): 点击车辆卡片展示近 15 日行驶里程明细
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 后端新增 GET /api/mileage/vehicle/:plate/recent,返回近 N 天 + 今日的每日里程 - 缺失日补全为 dailyKm=0 + isDataSynced=false - 前端新增 VehicleDetailModal:头部信息、合计/日均/有数据天 KPI、近 N 日柱状图、每日明细列表 - 移动端从底部弹起;缺失日柱条置灰,明细行标注「未对接」 - 卡片点击改为打开弹窗(不再复制车牌) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import { fetchMonitoring } from './api';
|
|||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
import PlateMultiSelect from './PlateMultiSelect';
|
import PlateMultiSelect from './PlateMultiSelect';
|
||||||
import { exportMileageXlsx } from './xlsx-export';
|
import { exportMileageXlsx } from './xlsx-export';
|
||||||
|
import VehicleDetailModal from './VehicleDetailModal';
|
||||||
|
|
||||||
const SearchableSelect = ({
|
const SearchableSelect = ({
|
||||||
options,
|
options,
|
||||||
@@ -115,6 +116,7 @@ export default function MonitoringView() {
|
|||||||
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
|
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
|
||||||
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
|
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null);
|
||||||
const [filterDate, setFilterDate] = useState(() => {
|
const [filterDate, setFilterDate] = useState(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (now.getHours() < 5) now.setDate(now.getDate() - 1);
|
if (now.getHours() < 5) now.setDate(now.getDate() - 1);
|
||||||
@@ -899,10 +901,8 @@ export default function MonitoringView() {
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
key={v.plate}
|
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"
|
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={() => {
|
onClick={() => setDetailVehicle(v)}
|
||||||
navigator.clipboard.writeText(v.plate);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 overflow-hidden flex-1">
|
<div className="flex items-center gap-3 overflow-hidden flex-1">
|
||||||
<div className="relative flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
@@ -966,6 +966,8 @@ export default function MonitoringView() {
|
|||||||
<div ref={sentinelRef} className="h-1" />
|
<div ref={sentinelRef} className="h-1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<VehicleDetailModal vehicle={detailVehicle} onClose={() => setDetailVehicle(null)} />
|
||||||
|
|
||||||
{/* 回到顶部按钮 */}
|
{/* 回到顶部按钮 */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showBackToTop && (
|
{showBackToTop && (
|
||||||
|
|||||||
191
src/modules/mileage/VehicleDetailModal.tsx
Normal file
191
src/modules/mileage/VehicleDetailModal.tsx
Normal file
@@ -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<VehicleRecentDay[]>([]);
|
||||||
|
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 (
|
||||||
|
<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: 30, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: 30, opacity: 0 }}
|
||||||
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
|
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"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-4 pt-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 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">近{DAYS}日合计</div>
|
||||||
|
<div className="text-base font-black text-slate-900 leading-tight">
|
||||||
|
{Math.round(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">
|
||||||
|
{Math.round(avgKm).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">
|
||||||
|
{syncedDays}<span className="text-[9px] font-bold text-slate-400 ml-0.5">/{DAYS}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 pb-2">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-[10px] font-bold text-slate-500">近 {DAYS} 日行驶里程</span>
|
||||||
|
<span className="text-[9px] font-bold text-slate-300">单位 km</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-slate-50">
|
||||||
|
<ResponsiveContainer width="100%" height={140}>
|
||||||
|
<BarChart data={historyDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={fmtMd}
|
||||||
|
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]}>
|
||||||
|
{historyDays.map((d, i) => (
|
||||||
|
<Cell key={i} fill={d.isDataSynced ? '#3b82f6' : '#e2e8f0'} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</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 ? (
|
||||||
|
<div className="py-6 text-center">
|
||||||
|
<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">
|
||||||
|
{historyDays.slice().reverse().map(d => (
|
||||||
|
<div key={d.date} 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">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${d.isDataSynced ? 'bg-blue-500' : 'bg-slate-200'}`}
|
||||||
|
style={{ width: `${(d.dailyKm / maxKm) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -61,3 +61,17 @@ export async function fetchTrend(targetId?: number, days = 7): Promise<TrendPoin
|
|||||||
params.set('days', String(days));
|
params.set('days', String(days));
|
||||||
return fetchJson<TrendPoint[]>(`${BASE}/trend?${params.toString()}`);
|
return fetchJson<TrendPoint[]>(`${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()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { refreshMonitoringCache } from './cache.js';
|
|||||||
import monitoringRouter from './monitoring.js';
|
import monitoringRouter from './monitoring.js';
|
||||||
import targetsRouter from './targets.js';
|
import targetsRouter from './targets.js';
|
||||||
import trendRouter from './trend.js';
|
import trendRouter from './trend.js';
|
||||||
|
import vehicleRecentRouter from './vehicle-recent.js';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ app.route('/monitoring', monitoringRouter);
|
|||||||
app.route('/targets', targetsRouter);
|
app.route('/targets', targetsRouter);
|
||||||
app.route('/target', targetsRouter);
|
app.route('/target', targetsRouter);
|
||||||
app.route('/trend', trendRouter);
|
app.route('/trend', trendRouter);
|
||||||
|
app.route('/vehicle', vehicleRecentRouter);
|
||||||
|
|
||||||
// 启动时立即刷新缓存,之后每分钟刷新
|
// 启动时立即刷新缓存,之后每分钟刷新
|
||||||
refreshMonitoringCache();
|
refreshMonitoringCache();
|
||||||
|
|||||||
70
src/server/routes/mileage/vehicle-recent.ts
Normal file
70
src/server/routes/mileage/vehicle-recent.ts
Normal file
@@ -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<string, { dailyKm: number; source: string }>();
|
||||||
|
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;
|
||||||
Reference in New Issue
Block a user