refactor: add mileage attribution drilldown
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
关闭明细
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user