refactor: add mileage attribution drilldown

This commit is contained in:
kkfluous
2026-08-07 14:15:31 +08:00
parent f24a18e459
commit 91031bd034
6 changed files with 475 additions and 39 deletions
@@ -0,0 +1,109 @@
import { AlertTriangle, Building2, ChevronRight, Users } from 'lucide-react';
import type { TargetVehicle } from './types';
import {
buildMileageAttribution,
type AttributionDimension,
} from './attribution';
interface Props {
vehicles: TargetVehicle[];
dimension: AttributionDimension;
selectedValue?: string;
loading?: boolean;
onDimensionChange: (dimension: AttributionDimension) => void;
onSelect: (value: string) => void;
}
function fmtKm(value: number): string {
return value >= 10000 ? `${(value / 10000).toFixed(2)}` : value.toLocaleString(undefined, { maximumFractionDigits: 1 });
}
export default function MileageAttributionView({
vehicles,
dimension,
selectedValue,
loading = false,
onDimensionChange,
onSelect,
}: Props) {
const groups = buildMileageAttribution(vehicles, dimension);
const totalShortfall = groups.reduce((sum, group) => sum + group.shortfallMileage, 0);
const totalBehind = groups.reduce((sum, group) => sum + group.behindCount, 0);
return (
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex flex-col gap-3 border-b border-slate-100 px-4 py-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="flex items-center gap-2">
<span className="h-4 w-1 rounded-full bg-blue-600" />
<h2 className="text-sm font-black text-slate-800"></h2>
</div>
<p className="mt-1 text-[10px] font-bold text-slate-400">
{totalBehind} {fmtKm(totalShortfall)} km
</p>
</div>
<div className="flex rounded-lg bg-slate-100 p-1" aria-label="归因维度">
{([
['department', '部门', Building2],
['customer', '客户', Users],
] as const).map(([value, label, Icon]) => (
<button
key={value}
type="button"
onClick={() => onDimensionChange(value)}
className={`flex min-w-[72px] items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-[10px] font-black transition-colors ${
dimension === value ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-800'
}`}
>
<Icon size={12} />
{label}
</button>
))}
</div>
</div>
{loading ? (
<div className="px-4 py-8 text-center text-xs font-bold text-slate-400">...</div>
) : groups.length === 0 ? (
<div className="px-4 py-8 text-center text-xs font-bold text-slate-400"></div>
) : (
<div className="divide-y divide-slate-100">
{groups.slice(0, 8).map(group => (
<button
key={group.key}
type="button"
onClick={() => onSelect(group.key)}
className={`grid w-full grid-cols-[minmax(0,1fr)_70px_82px_24px] items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-slate-50 ${
selectedValue === group.key ? 'bg-blue-50/60' : ''
}`}
>
<span className="min-w-0">
<span className="block truncate text-xs font-black text-slate-800">{group.label}</span>
<span className="mt-0.5 flex items-center gap-2 text-[9px] font-bold text-slate-400">
<span>{group.vehicleCount} </span>
<span>{group.onlineCount} 线</span>
{group.zeroMileageCount > 0 && (
<span className="flex items-center gap-0.5 text-amber-600">
<AlertTriangle size={9} /> {group.zeroMileageCount}
</span>
)}
</span>
</span>
<span className="text-right">
<span className="block text-[11px] font-black text-slate-800">{fmtKm(group.todayMileage)}</span>
<span className="block text-[9px] font-bold text-slate-400"> {group.contributionRate.toFixed(1)}%</span>
</span>
<span className="text-right">
<span className={`block text-[11px] font-black ${group.shortfallMileage > 0 ? 'text-rose-600' : 'text-emerald-600'}`}>
{fmtKm(group.shortfallMileage)}
</span>
<span className="block text-[9px] font-bold text-slate-400"> · {group.behindCount} </span>
</span>
<ChevronRight size={14} className="text-slate-300" />
</button>
))}
</div>
)}
</section>
);
}
+179 -37
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
@@ -13,6 +13,16 @@ import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint }
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
import Blur from '../../components/Blur';
import { weightedCompletionRate } from '../../shared/analytics/metrics';
import MileageAttributionView from './MileageAttributionView';
import {
filterAttributionVehicles,
type AttributionDimension,
} from './attribution';
import {
buildMileageDrillUrl,
parseMileageDrillContext,
type MileageDrillContext,
} from './drill-context';
function getDefaultDate(): string {
const now = new Date();
@@ -61,23 +71,34 @@ function shortTargetName(name: string): string {
export default function StatisticsView() {
const currentDateLabel = getCurrentDateLabel();
const [drillContext, setDrillContext] = useState<MileageDrillContext>(() => (
parseMileageDrillContext(window.location.search)
));
const [targets, setTargets] = useState<TargetSummary[]>([]);
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(null);
const [selectedTargetId, setSelectedTargetId] = useState<number | null>(drillContext.targetId || null);
const [attributionDimension, setAttributionDimension] = useState<AttributionDimension>(() => (
drillContext.level === 'breakdown' && drillContext.dimension === 'customer' ? 'customer' : 'department'
));
const [chartType, setChartType] = useState<'bar' | 'line' | 'area'>('bar');
const [isTableFullscreen, setIsTableFullscreen] = useState(false);
const [expandedTargetId, setExpandedTargetId] = useState<number | null>(null);
const [assessmentYearMap, setAssessmentYearMap] = useState<Record<number, number>>({});
const [viewAllTargetId, setViewAllTargetId] = useState<number | null>(null);
const [viewAllTargetId, setViewAllTargetId] = useState<number | null>(() => (
drillContext.level === 'breakdown' ? drillContext.targetId || null : null
));
const [viewAllTargetName, setViewAllTargetName] = useState<string>('');
const [viewAllSearch, setViewAllSearch] = useState('');
const [viewAllSort, setViewAllSort] = useState<'asc' | 'desc'>('desc');
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
const [viewAllLoading, setViewAllLoading] = useState(false);
const [viewAllHistoricalVehicles, setViewAllHistoricalVehicles] = useState<TargetVehicle[] | null>(null);
const targetVehicleRequestsRef = useRef(new Set<number>());
const selectedTarget = targets.find(t => t.id === selectedTargetId);
const selectedTargetVehicles = selectedTargetId === null ? [] : targetVehiclesMap[selectedTargetId] || [];
const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null;
const selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0;
const selectedRemaining = selectedAssessment?.remaining ?? 0;
@@ -94,6 +115,81 @@ export default function StatisticsView() {
const previousTrend = trendData[trendData.length - 2];
const trendDelta = latestTrend && previousTrend ? latestTrend.mileage - previousTrend.mileage : 0;
const pressureLevel = selectedCompletion >= 90 ? '健康' : selectedCompletion >= 70 ? '关注' : '高压';
const selectedBreakdownValue = drillContext.level === 'breakdown'
? drillContext.dimensionValue
: undefined;
const commitDrillContext = useCallback((next: MileageDrillContext, mode: 'push' | 'replace') => {
const url = buildMileageDrillUrl(window.location, next);
window.history[mode === 'push' ? 'pushState' : 'replaceState'](null, '', url);
setDrillContext(next);
}, []);
const loadCurrentTargetVehicles = useCallback((targetId: number) => {
if (targetVehicleRequestsRef.current.has(targetId)) return;
targetVehicleRequestsRef.current.add(targetId);
fetchTargetVehicles(targetId, getDefaultDate()).then(vehicles => {
setTargetVehiclesMap(prev => ({ ...prev, [targetId]: vehicles }));
}).catch(() => {}).finally(() => {
targetVehicleRequestsRef.current.delete(targetId);
});
}, []);
const selectTarget = useCallback((targetId: number) => {
setSelectedTargetId(targetId);
setExpandedTargetId(targetId);
setViewAllTargetId(null);
setViewAllSearch('');
commitDrillContext({
level: 'overview',
targetId,
sourcePriority: ['instrument'],
}, 'replace');
}, [commitDrillContext]);
const openAttributionGroup = useCallback((value: string) => {
if (selectedTargetId === null) return;
setViewAllTargetId(selectedTargetId);
setViewAllTargetName(selectedTarget?.targetName || '考核车辆');
setViewAllSearch('');
setViewAllDate(getDefaultDate());
setViewAllHistoricalVehicles(null);
commitDrillContext({
level: 'breakdown',
targetId: selectedTargetId,
dimension: attributionDimension,
dimensionValue: value,
sourcePriority: ['instrument'],
}, 'push');
}, [attributionDimension, commitDrillContext, selectedTarget?.targetName, selectedTargetId]);
const closeVehiclePanel = useCallback(() => {
setViewAllTargetId(null);
setViewAllSearch('');
if (drillContext.level === 'breakdown') {
commitDrillContext({
level: 'overview',
targetId: selectedTargetId || undefined,
sourcePriority: ['instrument'],
}, 'replace');
}
}, [commitDrillContext, drillContext.level, selectedTargetId]);
const openVehicleDiagnostic = useCallback((vehicle: TargetVehicle) => {
const url = buildMileageDrillUrl(
{ pathname: window.location.pathname, search: window.location.search, hash: '#mileage' },
{
level: 'vehicle',
targetId: viewAllTargetId || selectedTargetId || undefined,
plate: vehicle.plateNumber,
startDate: viewAllDate,
endDate: viewAllDate,
sourcePriority: ['instrument'],
},
);
window.history.pushState(null, '', url);
window.dispatchEvent(new HashChangeEvent('hashchange'));
}, [selectedTargetId, viewAllDate, viewAllTargetId]);
// Load targets on mount
useEffect(() => {
@@ -103,13 +199,11 @@ export default function StatisticsView() {
? [focused, ...data.filter(item => item.id !== focused.id)]
: data;
setTargets(ordered);
if (ordered.length > 0 && !selectedTargetId) {
setSelectedTargetId(focused.id);
setExpandedTargetId(focused.id);
const initialTarget = ordered.find(item => item.id === selectedTargetId) || focused;
if (initialTarget) {
setSelectedTargetId(initialTarget.id);
setExpandedTargetId(initialTarget.id);
setAssessmentYearMap(Object.fromEntries(ordered.map(item => [item.id, item.yearlyAssessments[0]?.yearNumber || 1])));
fetchTargetVehicles(focused.id).then(vehicles => {
setTargetVehiclesMap(prev => ({ ...prev, [focused.id]: vehicles }));
}).catch(() => {});
}
}).catch(() => {});
}, []);
@@ -119,9 +213,7 @@ export default function StatisticsView() {
if (selectedTargetId === null) return;
setExpandedTargetId(selectedTargetId);
if (!targetVehiclesMap[selectedTargetId]) {
fetchTargetVehicles(selectedTargetId).then(vehicles => {
setTargetVehiclesMap(prev => ({ ...prev, [selectedTargetId]: vehicles }));
}).catch(() => {});
loadCurrentTargetVehicles(selectedTargetId);
}
fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([]));
}, [selectedTargetId]);
@@ -129,12 +221,52 @@ export default function StatisticsView() {
// Re-fetch target vehicles when viewAllDate changes
useEffect(() => {
if (viewAllTargetId === null) return;
if (viewAllDate === getDefaultDate()) {
setViewAllHistoricalVehicles(null);
setViewAllLoading(false);
return;
}
setViewAllLoading(true);
fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => {
setTargetVehiclesMap(prev => ({ ...prev, [viewAllTargetId]: data }));
setViewAllHistoricalVehicles(data);
}).catch(() => {}).finally(() => setViewAllLoading(false));
}, [viewAllTargetId, viewAllDate]);
useEffect(() => {
const handlePopState = () => {
const next = parseMileageDrillContext(window.location.search);
setDrillContext(next);
if (next.targetId) setSelectedTargetId(next.targetId);
if (next.level === 'breakdown' && next.targetId && next.dimensionValue) {
setAttributionDimension(next.dimension === 'customer' ? 'customer' : 'department');
setViewAllTargetId(next.targetId);
} else {
setViewAllTargetId(null);
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
const viewAllVehicles = useMemo(() => {
const vehicles = viewAllHistoricalVehicles
|| (viewAllTargetId === null ? [] : targetVehiclesMap[viewAllTargetId] || []);
if (
drillContext.level === 'breakdown'
&& drillContext.dimensionValue
&& (drillContext.dimension === 'department' || drillContext.dimension === 'customer')
) {
return filterAttributionVehicles(vehicles, drillContext.dimension, drillContext.dimensionValue);
}
return vehicles;
}, [drillContext, targetVehiclesMap, viewAllHistoricalVehicles, viewAllTargetId]);
const searchedViewAllVehicles = useMemo(() => (
viewAllVehicles.filter(vehicle => (
vehicle.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase())
))
), [viewAllSearch, viewAllVehicles]);
return (
<div className="space-y-2 pb-2 landscape:pb-4 landscape:h-full landscape:overflow-hidden landscape:flex landscape:flex-col flex-none landscape:flex-1 [overflow-anchor:none]" style={{ overflowX: 'clip' }}>
{/* Project Selector */}
@@ -142,10 +274,7 @@ export default function StatisticsView() {
{targets.map(target => (
<button
key={target.id}
onClick={() => {
setSelectedTargetId(target.id);
setExpandedTargetId(target.id);
}}
onClick={() => selectTarget(target.id)}
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
selectedTargetId === target.id
? 'bg-blue-600 text-white shadow-md shadow-blue-200'
@@ -196,6 +325,20 @@ export default function StatisticsView() {
</motion.div>
)}
{selectedTarget && (
<MileageAttributionView
vehicles={selectedTargetVehicles}
dimension={attributionDimension}
selectedValue={selectedBreakdownValue}
loading={!targetVehiclesMap[selectedTarget.id]}
onDimensionChange={(dimension) => {
setAttributionDimension(dimension);
if (drillContext.level === 'breakdown') closeVehiclePanel();
}}
onSelect={openAttributionGroup}
/>
)}
<div className="flex flex-col landscape:flex-row gap-4 flex-1 landscape:overflow-hidden">
{/* Left Side: Trend Chart / Dashboard Sidebar */}
<div className="flex-none landscape:flex-1 landscape:w-2/3 space-y-4 flex flex-col overflow-y-auto no-scrollbar min-w-0">
@@ -334,11 +477,6 @@ export default function StatisticsView() {
className="bg-white px-3 py-2 rounded-xl border border-slate-100 shadow-sm flex flex-col active:bg-slate-50 transition-all cursor-pointer"
onClick={() => {
setExpandedTargetId(target.id);
if (!targetVehiclesMap[target.id]) {
fetchTargetVehicles(target.id).then(data => {
setTargetVehiclesMap(prev => ({ ...prev, [target.id]: data }));
}).catch(() => {});
}
}}
>
<div className="flex items-center justify-between">
@@ -650,7 +788,7 @@ export default function StatisticsView() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setViewAllTargetId(null)}
onClick={closeVehiclePanel}
className="fixed inset-0 z-[110] bg-slate-950/60 backdrop-blur-sm"
/>
<motion.div
@@ -662,14 +800,16 @@ export default function StatisticsView() {
>
<div className="p-6 border-b border-slate-100 flex items-center justify-between">
<div>
<h3 className="text-lg font-black text-slate-900">{viewAllTargetName}</h3>
<h3 className="text-lg font-black text-slate-900">
{viewAllTargetName || selectedTarget?.targetName || '考核车辆'}
</h3>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1"></p>
{selectedBreakdownValue && (
<p className="mt-1 text-[10px] font-bold text-blue-600">{selectedBreakdownValue}</p>
)}
</div>
<button
onClick={() => {
setViewAllTargetId(null);
setViewAllSearch('');
}}
onClick={closeVehiclePanel}
className="p-2 hover:bg-slate-100 rounded-full transition-colors text-slate-400 hover:text-slate-900"
>
<X size={20} />
@@ -701,9 +841,8 @@ export default function StatisticsView() {
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
{viewAllLoading ? '加载中...' : (() => {
const filtered = (viewAllTargetId !== null ? (targetVehiclesMap[viewAllTargetId] || []) : []).filter(tv => tv.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase()));
const totalKm = filtered.reduce((sum, tv) => sum + (tv.todayMileage || 0), 0);
return `${filtered.length} 辆 · 合计 ${fmtKm(totalKm)} km`;
const totalKm = searchedViewAllVehicles.reduce((sum, vehicle) => sum + (vehicle.todayMileage || 0), 0);
return `${searchedViewAllVehicles.length} 辆 · 合计 ${fmtKm(totalKm)} km`;
})()}
</span>
<button
@@ -717,14 +856,17 @@ export default function StatisticsView() {
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-2 no-scrollbar">
{(viewAllTargetId !== null ? (targetVehiclesMap[viewAllTargetId] || []) : []).filter(tv =>
tv.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase())
).sort((a, b) => {
{[...searchedViewAllVehicles].sort((a, b) => {
const valA = a.todayMileage || 0;
const valB = b.todayMileage || 0;
return viewAllSort === 'desc' ? valB - valA : valA - valB;
}).map(tv => (
<div key={tv.plateNumber} className="bg-white px-3 py-2 rounded-xl border border-slate-50 shadow-sm flex items-center justify-between hover:border-blue-200 transition-all">
<button
type="button"
key={tv.plateNumber}
onClick={() => openVehicleDiagnostic(tv)}
className="flex w-full items-center justify-between rounded-xl border border-slate-100 bg-white px-3 py-2 text-left shadow-sm transition-all hover:border-blue-200 hover:shadow-md"
>
<div className="flex items-center gap-3 overflow-hidden flex-1">
<div className="relative flex-shrink-0">
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
@@ -749,13 +891,13 @@ export default function StatisticsView() {
<div className="text-sm font-black text-blue-600">{tv.todayMileage.toLocaleString()} <span className="text-[8px] text-slate-400">KM</span></div>
<div className="text-[9px] font-bold text-slate-300 mt-0.5">: {fmtKm(tv.totalMileage || 0)} km</div>
</div>
</div>
</button>
))}
</div>
<div className="p-4 bg-slate-50 border-t border-slate-100">
<button
onClick={() => setViewAllTargetId(null)}
onClick={closeVehiclePanel}
className="w-full py-3 bg-slate-900 text-white rounded-xl text-sm font-bold shadow-lg shadow-slate-200 active:scale-[0.98] transition-all"
>
+71
View File
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildMileageAttribution,
filterAttributionVehicles,
} from './attribution.js';
import type { TargetVehicle } from './types.js';
function vehicle(overrides: Partial<TargetVehicle>): TargetVehicle {
return {
plateNumber: '粤A00000',
todayMileage: 0,
totalMileage: 0,
completionRate: 0,
isQualified: false,
currentYearIsQualified: false,
dailyRequiredMileage: 100,
rentStatus: null,
department: null,
customer: null,
isOnline: false,
...overrides,
};
}
test('groups vehicles and calculates additive attribution metrics', () => {
const result = buildMileageAttribution([
vehicle({ plateNumber: '粤A1', department: '业务一部', todayMileage: 120, totalMileage: 1000, completionRate: 0.8, isOnline: true }),
vehicle({ plateNumber: '粤A2', department: '业务一部', todayMileage: 40, totalMileage: 500, completionRate: 0.4 }),
vehicle({ plateNumber: '粤A3', department: '业务二部', todayMileage: 40, totalMileage: 400, completionRate: 0.2 }),
], 'department');
const first = result.find(group => group.key === '业务一部');
assert.deepEqual(first, {
key: '业务一部',
label: '业务一部',
vehicleCount: 2,
onlineCount: 1,
zeroMileageCount: 0,
behindCount: 1,
todayMileage: 160,
totalMileage: 1500,
requiredMileage: 200,
shortfallMileage: 60,
averageCompletionRate: 60.00000000000001,
contributionRate: 80,
});
});
test('keeps unassigned vehicles visible and sorts largest shortfall first', () => {
const result = buildMileageAttribution([
vehicle({ plateNumber: '粤A1', department: null, todayMileage: 0 }),
vehicle({ plateNumber: '粤A2', department: '业务一部', todayMileage: 80 }),
], 'department');
assert.equal(result[0].label, '未分配部门');
assert.equal(result[0].shortfallMileage, 100);
assert.equal(result[0].zeroMileageCount, 1);
});
test('filters a group using the same empty-value semantics as aggregation', () => {
const vehicles = [
vehicle({ plateNumber: '粤A1', customer: null }),
vehicle({ plateNumber: '粤A2', customer: '客户甲' }),
];
assert.deepEqual(
filterAttributionVehicles(vehicles, 'customer', '未绑定客户').map(item => item.plateNumber),
['粤A1'],
);
});
+93
View File
@@ -0,0 +1,93 @@
import type { TargetVehicle } from './types';
export type AttributionDimension = 'department' | 'customer';
export interface MileageAttributionGroup {
key: string;
label: string;
vehicleCount: number;
onlineCount: number;
zeroMileageCount: number;
behindCount: number;
todayMileage: number;
totalMileage: number;
requiredMileage: number;
shortfallMileage: number;
averageCompletionRate: number;
contributionRate: number;
}
const EMPTY_LABELS: Record<AttributionDimension, string> = {
department: '未分配部门',
customer: '未绑定客户',
};
export function attributionValue(
vehicle: TargetVehicle,
dimension: AttributionDimension,
): string {
return vehicle[dimension]?.trim() || EMPTY_LABELS[dimension];
}
export function buildMileageAttribution(
vehicles: TargetVehicle[],
dimension: AttributionDimension,
): MileageAttributionGroup[] {
const totalTodayMileage = vehicles.reduce(
(sum, vehicle) => sum + Math.max(0, Number(vehicle.todayMileage) || 0),
0,
);
const grouped = new Map<string, TargetVehicle[]>();
for (const vehicle of vehicles) {
const key = attributionValue(vehicle, dimension);
const current = grouped.get(key) || [];
current.push(vehicle);
grouped.set(key, current);
}
return Array.from(grouped, ([key, members]) => {
const todayMileage = members.reduce((sum, vehicle) => sum + Math.max(0, Number(vehicle.todayMileage) || 0), 0);
const totalMileage = members.reduce((sum, vehicle) => sum + Math.max(0, Number(vehicle.totalMileage) || 0), 0);
const requiredMileage = members.reduce((sum, vehicle) => sum + Math.max(0, Number(vehicle.dailyRequiredMileage) || 0), 0);
const shortfallMileage = members.reduce((sum, vehicle) => {
const required = Math.max(0, Number(vehicle.dailyRequiredMileage) || 0);
const actual = Math.max(0, Number(vehicle.todayMileage) || 0);
return sum + Math.max(0, required - actual);
}, 0);
const completionTotal = members.reduce(
(sum, vehicle) => sum + Math.max(0, Number(vehicle.completionRate) || 0),
0,
);
return {
key,
label: key,
vehicleCount: members.length,
onlineCount: members.filter(vehicle => vehicle.isOnline).length,
zeroMileageCount: members.filter(vehicle => (Number(vehicle.todayMileage) || 0) <= 0).length,
behindCount: members.filter(vehicle => (
(Number(vehicle.todayMileage) || 0) < (Number(vehicle.dailyRequiredMileage) || 0)
)).length,
todayMileage,
totalMileage,
requiredMileage,
shortfallMileage,
averageCompletionRate: members.length > 0 ? completionTotal / members.length * 100 : 0,
contributionRate: totalTodayMileage > 0 ? todayMileage / totalTodayMileage * 100 : 0,
};
}).sort((a, b) => (
b.shortfallMileage - a.shortfallMileage
|| b.behindCount - a.behindCount
|| b.todayMileage - a.todayMileage
|| a.label.localeCompare(b.label, 'zh-CN')
));
}
export function filterAttributionVehicles(
vehicles: TargetVehicle[],
dimension: AttributionDimension,
value: string,
): TargetVehicle[] {
return vehicles.filter(vehicle => attributionValue(vehicle, dimension) === value);
}
+17 -2
View File
@@ -4,11 +4,12 @@ import { buildMileageDrillUrl, parseMileageDrillContext } from './drill-context.
test('parses a vehicle drill context', () => {
const context = parseMileageDrillContext(
'?mileageLevel=vehicle&mileagePlate=%E7%B2%A4A08190F&mileageStart=2026-08-01&mileageEnd=2026-08-07&mileageSource=instrument,gps',
'?mileageLevel=vehicle&mileageTarget=5&mileagePlate=%E7%B2%A4A08190F&mileageStart=2026-08-01&mileageEnd=2026-08-07&mileageSource=instrument,gps',
);
assert.deepEqual(context, {
level: 'vehicle',
targetId: 5,
plate: '粤A08190F',
startDate: '2026-08-01',
endDate: '2026-08-07',
@@ -21,11 +22,25 @@ test('requires complete context for breakdown and vehicle levels', () => {
assert.equal(parseMileageDrillContext('?mileageLevel=breakdown&mileageDimension=region').level, 'overview');
});
test('parses a target-scoped breakdown context', () => {
assert.deepEqual(
parseMileageDrillContext('?mileageLevel=breakdown&mileageTarget=5&mileageDimension=department&mileageValue=%E4%B8%9A%E5%8A%A1%E4%B8%80%E9%83%A8'),
{
level: 'breakdown',
targetId: 5,
dimension: 'department',
dimensionValue: '业务一部',
sourcePriority: ['instrument'],
},
);
});
test('builds a namespaced URL and preserves unrelated parameters', () => {
const url = buildMileageDrillUrl(
{ pathname: '/asset', search: '?company=5&legacy=1', hash: '#mileage' },
{
level: 'vehicle',
targetId: 5,
plate: '粤A08190F',
startDate: '2026-08-01',
endDate: '2026-08-07',
@@ -35,7 +50,7 @@ test('builds a namespaced URL and preserves unrelated parameters', () => {
assert.equal(
url,
'/asset?company=5&legacy=1&mileageLevel=vehicle&mileagePlate=%E7%B2%A4A08190F&mileageStart=2026-08-01&mileageEnd=2026-08-07&mileageSource=instrument#mileage',
'/asset?company=5&legacy=1&mileageLevel=vehicle&mileageTarget=5&mileagePlate=%E7%B2%A4A08190F&mileageStart=2026-08-01&mileageEnd=2026-08-07&mileageSource=instrument#mileage',
);
});
+6
View File
@@ -10,6 +10,7 @@ export type MileageBreakdownDimension =
export interface MileageDrillContext {
level: MileageDrillLevel;
targetId?: number;
dimension?: MileageBreakdownDimension;
dimensionValue?: string;
plate?: string;
@@ -51,6 +52,8 @@ function parseSourcePriority(value: string | null): MileageSourceGroup[] {
export function parseMileageDrillContext(search: string): MileageDrillContext {
const params = new URLSearchParams(search);
const rawTargetId = Number(params.get('mileageTarget'));
const targetId = Number.isInteger(rawTargetId) && rawTargetId > 0 ? rawTargetId : undefined;
const plate = params.get('mileagePlate')?.trim() || undefined;
const rawDimension = params.get('mileageDimension');
const dimension = rawDimension && BREAKDOWN_DIMENSIONS.has(rawDimension as MileageBreakdownDimension)
@@ -67,6 +70,7 @@ export function parseMileageDrillContext(search: string): MileageDrillContext {
level,
sourcePriority: parseSourcePriority(params.get('mileageSource')),
};
if (targetId) context.targetId = targetId;
if (level === 'breakdown') {
context.dimension = dimension;
context.dimensionValue = dimensionValue;
@@ -86,6 +90,7 @@ export function buildMileageDrillUrl(
const params = new URLSearchParams(location.search);
for (const key of [
'mileageLevel',
'mileageTarget',
'mileageDimension',
'mileageValue',
'mileagePlate',
@@ -97,6 +102,7 @@ export function buildMileageDrillUrl(
}
if (context.level !== 'overview') params.set('mileageLevel', context.level);
if (context.targetId) params.set('mileageTarget', String(context.targetId));
if (context.level === 'breakdown' && context.dimension && context.dimensionValue) {
params.set('mileageDimension', context.dimension);
params.set('mileageValue', context.dimensionValue);