fix(mileage): skip missing daily mileage in interval totals
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { hasDailyMileage, recentMileageStats } from './mileage-display';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { motion, AnimatePresence, useDragControls } from 'motion/react';
|
||||
import { X, Truck } from 'lucide-react';
|
||||
@@ -97,13 +98,10 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
||||
|
||||
// 排除"今日"列(数据未到位时易引起误读)
|
||||
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 stats = useMemo(() => recentMileageStats(historyDays), [historyDays]);
|
||||
const chartDays = useMemo(() => historyDays.map(day => ({
|
||||
...day, dailyKm: hasDailyMileage(day) ? day.dailyKm : null,
|
||||
})), [historyDays]);
|
||||
|
||||
// 骨架天数:根据区间预估
|
||||
const skeletonCount = useMemo(() => {
|
||||
@@ -206,14 +204,14 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
||||
<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></>}
|
||||
: stats.synced === 0 ? '—' : <>{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></>}
|
||||
: stats.synced === 0 ? '—' : <>{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">
|
||||
@@ -237,7 +235,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
||||
<SkeletonBars count={Math.min(skeletonCount, 30)} />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={historyDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
||||
<BarChart data={chartDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(d) => formatLabel(d, range)}
|
||||
@@ -249,7 +247,8 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
||||
/>
|
||||
<YAxis hide />
|
||||
<Tooltip
|
||||
formatter={(v) => [`${Math.round(Number(v) || 0).toLocaleString()} km`, '行驶里程']}
|
||||
filterNull={false}
|
||||
formatter={(v) => [v == null ? '—' : `${Math.round(Number(v)).toLocaleString()} km`, '行驶里程']}
|
||||
labelFormatter={(d) => `日期 ${d}`}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 11, padding: '4px 8px' }}
|
||||
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
|
||||
@@ -306,15 +305,15 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
||||
<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}%` }}
|
||||
animate={{ width: `${hasDailyMileage(d) ? (d.dailyKm / stats.max) * 100 : 0}%` }}
|
||||
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
|
||||
<span className={`text-[11px] font-mono font-bold w-20 text-right ${hasDailyMileage(d) ? 'text-slate-700' : 'text-slate-400'}`}>
|
||||
{hasDailyMileage(d)
|
||||
? <>{Math.round(d.dailyKm).toLocaleString()} <span className="text-[9px] text-slate-400">km</span></>
|
||||
: <span className="text-[9px]">未对接</span>}
|
||||
: <span>—</span>}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { monitoringMileageValue, recentMileageStats } from './mileage-display';
|
||||
import type { VehicleRecentDay } from './api';
|
||||
import type { MonitoringVehicle } from './types';
|
||||
|
||||
const day = (dailyKm: number, isDataSynced = true): VehicleRecentDay => ({
|
||||
date: '2026-10-12', dailyKm, isDataSynced, sourceProtocol: null, sourceCategory: null,
|
||||
});
|
||||
|
||||
test('详情合计、平均值和有数据天排除缺失日,保留真实零里程', () => {
|
||||
assert.deepEqual(recentMileageStats([day(100), day(900, false), day(200), day(0)]), {
|
||||
totalKm: 300, synced: 3, avg: 100, max: 200, totalDays: 4,
|
||||
});
|
||||
assert.equal(recentMileageStats([day(0, false)]).synced, 0);
|
||||
});
|
||||
|
||||
test('列表全区间缺失时显示横杠,即使存在累计里程', () => {
|
||||
const vehicle = { dailyKm: 0, isDataSynced: false, totalKm: 64413.6 } as MonitoringVehicle;
|
||||
assert.equal(monitoringMileageValue(vehicle), '—');
|
||||
assert.equal(monitoringMileageValue({ ...vehicle, isDataSynced: true }), 0);
|
||||
assert.equal(monitoringMileageValue({ ...vehicle, isDataSynced: true, dailyKm: 5417 }), 5417);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { MonitoringVehicle } from './types';
|
||||
import type { VehicleRecentDay } from './api';
|
||||
|
||||
export function monitoringMileageValue(vehicle: MonitoringVehicle): number | string {
|
||||
if (vehicle.mileageAnomaly) return '数据异常';
|
||||
if (!vehicle.isDataSynced) return '—';
|
||||
return Math.max(0, vehicle.dailyKm || 0);
|
||||
}
|
||||
|
||||
export function hasDailyMileage(day: VehicleRecentDay): boolean {
|
||||
return day.isDataSynced && Number.isFinite(day.dailyKm) && day.dailyKm >= 0;
|
||||
}
|
||||
|
||||
export function recentMileageStats(days: VehicleRecentDay[]) {
|
||||
const validDays = days.filter(hasDailyMileage);
|
||||
const totalKm = validDays.reduce((sum, day) => sum + day.dailyKm, 0);
|
||||
return {
|
||||
totalKm,
|
||||
synced: validDays.length,
|
||||
avg: validDays.length ? totalKm / validDays.length : 0,
|
||||
max: Math.max(1, ...validDays.map(day => day.dailyKm)),
|
||||
totalDays: days.length,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { monitoringMileageValue } from '../../mileage-display';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { ArrowDown, ArrowUp, Minimize2, RotateCcw } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
@@ -283,6 +284,8 @@ export default function FullscreenMonitoring({
|
||||
<tbody className="divide-y divide-slate-800/30">
|
||||
{fullscreenVehicles.map((v) => {
|
||||
const highMileageAlert = isHighMileageAlert(v);
|
||||
const mileageValue = monitoringMileageValue(v);
|
||||
const hasMileage = typeof mileageValue === 'number';
|
||||
const statisticTime = vehicleStatisticTime(v, relativeNow);
|
||||
const sourceDisplay = vehicleSourceDisplay(v);
|
||||
return (
|
||||
@@ -310,7 +313,7 @@ export default function FullscreenMonitoring({
|
||||
<td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<span className={`text-xs font-mono font-bold ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-400' : 'text-blue-400') : 'text-amber-400'}`}>
|
||||
{(v.isDataSynced || v.totalKm != null) ? <>{v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400/70' : 'text-slate-500'}`}>{v.mileageAnomaly ? '' : 'km'}</span></> : <span className="text-[8px] text-amber-500/50">未对接</span>}
|
||||
<>{mileageValue.toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400/70' : 'text-slate-500'}`}>{hasMileage ? 'km' : ''}</span></>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { monitoringMileageValue } from '../../mileage-display';
|
||||
import type { RefObject } from 'react';
|
||||
import { Truck } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
@@ -54,6 +55,8 @@ export default function VehicleList({
|
||||
<div className="grid grid-cols-1 gap-1.5">
|
||||
{vehicles.map((v) => {
|
||||
const highMileageAlert = isHighMileageAlert(v);
|
||||
const mileageValue = monitoringMileageValue(v);
|
||||
const hasMileage = typeof mileageValue === 'number';
|
||||
const statisticTime = vehicleStatisticTime(v, relativeNow);
|
||||
const sourceDisplay = vehicleSourceDisplay(v);
|
||||
return (
|
||||
@@ -102,8 +105,8 @@ export default function VehicleList({
|
||||
</div>
|
||||
<div className="hidden text-right md:block">
|
||||
<div className={`text-sm font-black tabular-nums ${highMileageAlert ? 'text-red-600' : 'text-blue-600'}`}>
|
||||
{v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()}
|
||||
<span className="ml-1 text-[9px] font-bold text-slate-400">{v.mileageAnomaly ? '' : 'km'}</span>
|
||||
{mileageValue.toLocaleString()}
|
||||
<span className="ml-1 text-[9px] font-bold text-slate-400">{hasMileage ? 'km' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden text-right md:block">
|
||||
@@ -119,7 +122,7 @@ export default function VehicleList({
|
||||
)}
|
||||
<span className="text-[7px] font-black text-blue-600/50 bg-blue-50 w-3 h-3 rounded flex items-center justify-center leading-none" title={isRangeMode ? '区间内里程' : '当日里程'}>区</span>
|
||||
<div className={`text-sm font-black leading-none ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-600' : 'text-blue-600') : 'text-amber-600'}`}>
|
||||
{(v.isDataSynced || v.totalKm != null) ? <>{v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400' : 'text-slate-400'}`}>{v.mileageAnomaly ? '' : 'km'}</span></> : <span className="text-[7px] text-amber-500/70">未对接</span>}
|
||||
<>{mileageValue.toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400' : 'text-slate-400'}`}>{hasMileage ? 'km' : ''}</span></>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface MonitoringVehicle {
|
||||
vin: string;
|
||||
dailyKm: number;
|
||||
mileageAnomaly?: string | null;
|
||||
dailyMileage?: Record<string, number>;
|
||||
dailyMileage?: Record<string, number | null>;
|
||||
totalKm: number | null;
|
||||
source: string;
|
||||
sourceProtocol: MileageSourceProtocol | null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { monitoringMileageValue } from './mileage-display';
|
||||
import * as XLSX from 'xlsx';
|
||||
import type { MonitoringVehicle } from './types';
|
||||
|
||||
@@ -27,8 +28,7 @@ function statusLabel(v: MonitoringVehicle): string {
|
||||
|
||||
function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | number {
|
||||
if (kind === 'today') {
|
||||
if (!v.isDataSynced && v.totalKm == null) return '未对接';
|
||||
return Math.max(0, v.dailyKm || 0);
|
||||
return monitoringMileageValue(v);
|
||||
}
|
||||
return v.totalKm != null ? v.totalKm : '未对接';
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
v.rentStatus || '',
|
||||
v.region || '',
|
||||
];
|
||||
const dailyCols = dayKeys.map(day => v.dailyMileage?.[day] ?? 0);
|
||||
const dailyCols = dayKeys.map(day => v.dailyMileage?.[day] ?? '—');
|
||||
return [
|
||||
...baseRow,
|
||||
...dailyCols,
|
||||
@@ -135,8 +135,8 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
||||
v.project || '',
|
||||
v.rentStatus || '',
|
||||
v.region || '',
|
||||
...dayKeys.map(day => v.dailyMileage?.[day] || 0),
|
||||
Math.max(0, v.dailyKm || 0),
|
||||
...dayKeys.map(day => v.dailyMileage?.[day] ?? '—'),
|
||||
mileageCell(v, 'today'),
|
||||
v.totalKm != null ? v.totalKm : '',
|
||||
]),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user