From 91031bd034d64d9bcb39ebb835cbada4a1dc634c Mon Sep 17 00:00:00 2001 From: kkfluous Date: Fri, 7 Aug 2026 14:15:31 +0800 Subject: [PATCH] refactor: add mileage attribution drilldown --- .../mileage/MileageAttributionView.tsx | 109 +++++++++ src/modules/mileage/StatisticsView.tsx | 216 +++++++++++++++--- src/modules/mileage/attribution.test.ts | 71 ++++++ src/modules/mileage/attribution.ts | 93 ++++++++ src/modules/mileage/drill-context.test.ts | 19 +- src/modules/mileage/drill-context.ts | 6 + 6 files changed, 475 insertions(+), 39 deletions(-) create mode 100644 src/modules/mileage/MileageAttributionView.tsx create mode 100644 src/modules/mileage/attribution.test.ts create mode 100644 src/modules/mileage/attribution.ts diff --git a/src/modules/mileage/MileageAttributionView.tsx b/src/modules/mileage/MileageAttributionView.tsx new file mode 100644 index 0000000..a349020 --- /dev/null +++ b/src/modules/mileage/MileageAttributionView.tsx @@ -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 ( +
+
+
+
+ +

任务缺口归因

+
+

+ {totalBehind} 台低于当日任务,缺口 {fmtKm(totalShortfall)} km +

+
+
+ {([ + ['department', '部门', Building2], + ['customer', '客户', Users], + ] as const).map(([value, label, Icon]) => ( + + ))} +
+
+ + {loading ? ( +
正在计算归因...
+ ) : groups.length === 0 ? ( +
暂无可归因车辆
+ ) : ( +
+ {groups.slice(0, 8).map(group => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/modules/mileage/StatisticsView.tsx b/src/modules/mileage/StatisticsView.tsx index fd8fa9c..0890645 100644 --- a/src/modules/mileage/StatisticsView.tsx +++ b/src/modules/mileage/StatisticsView.tsx @@ -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(() => ( + parseMileageDrillContext(window.location.search) + )); const [targets, setTargets] = useState([]); const [trendData, setTrendData] = useState([]); const [targetVehiclesMap, setTargetVehiclesMap] = useState>({}); - const [selectedTargetId, setSelectedTargetId] = useState(null); + const [selectedTargetId, setSelectedTargetId] = useState(drillContext.targetId || null); + const [attributionDimension, setAttributionDimension] = useState(() => ( + 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(null); const [assessmentYearMap, setAssessmentYearMap] = useState>({}); - const [viewAllTargetId, setViewAllTargetId] = useState(null); + const [viewAllTargetId, setViewAllTargetId] = useState(() => ( + drillContext.level === 'breakdown' ? drillContext.targetId || null : null + )); const [viewAllTargetName, setViewAllTargetName] = useState(''); 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(null); + const targetVehicleRequestsRef = useRef(new Set()); 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 (
{/* Project Selector */} @@ -142,10 +274,7 @@ export default function StatisticsView() { {targets.map(target => ( ))}