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
+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"
>