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 { motion, AnimatePresence } from 'motion/react';
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
@@ -13,6 +13,16 @@ import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint }
|
|||||||
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
import { weightedCompletionRate } from '../../shared/analytics/metrics';
|
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 {
|
function getDefaultDate(): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -61,23 +71,34 @@ function shortTargetName(name: string): string {
|
|||||||
|
|
||||||
export default function StatisticsView() {
|
export default function StatisticsView() {
|
||||||
const currentDateLabel = getCurrentDateLabel();
|
const currentDateLabel = getCurrentDateLabel();
|
||||||
|
const [drillContext, setDrillContext] = useState<MileageDrillContext>(() => (
|
||||||
|
parseMileageDrillContext(window.location.search)
|
||||||
|
));
|
||||||
const [targets, setTargets] = useState<TargetSummary[]>([]);
|
const [targets, setTargets] = useState<TargetSummary[]>([]);
|
||||||
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
||||||
const [targetVehiclesMap, setTargetVehiclesMap] = useState<Record<number, TargetVehicle[]>>({});
|
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 [chartType, setChartType] = useState<'bar' | 'line' | 'area'>('bar');
|
||||||
const [isTableFullscreen, setIsTableFullscreen] = useState(false);
|
const [isTableFullscreen, setIsTableFullscreen] = useState(false);
|
||||||
const [expandedTargetId, setExpandedTargetId] = useState<number | null>(null);
|
const [expandedTargetId, setExpandedTargetId] = useState<number | null>(null);
|
||||||
const [assessmentYearMap, setAssessmentYearMap] = useState<Record<number, number>>({});
|
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 [viewAllTargetName, setViewAllTargetName] = useState<string>('');
|
||||||
const [viewAllSearch, setViewAllSearch] = useState('');
|
const [viewAllSearch, setViewAllSearch] = useState('');
|
||||||
const [viewAllSort, setViewAllSort] = useState<'asc' | 'desc'>('desc');
|
const [viewAllSort, setViewAllSort] = useState<'asc' | 'desc'>('desc');
|
||||||
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
|
const [viewAllDate, setViewAllDate] = useState(getDefaultDate);
|
||||||
const [viewAllLoading, setViewAllLoading] = useState(false);
|
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 selectedTarget = targets.find(t => t.id === selectedTargetId);
|
||||||
|
const selectedTargetVehicles = selectedTargetId === null ? [] : targetVehiclesMap[selectedTargetId] || [];
|
||||||
const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null;
|
const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null;
|
||||||
const selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0;
|
const selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0;
|
||||||
const selectedRemaining = selectedAssessment?.remaining ?? 0;
|
const selectedRemaining = selectedAssessment?.remaining ?? 0;
|
||||||
@@ -94,6 +115,81 @@ export default function StatisticsView() {
|
|||||||
const previousTrend = trendData[trendData.length - 2];
|
const previousTrend = trendData[trendData.length - 2];
|
||||||
const trendDelta = latestTrend && previousTrend ? latestTrend.mileage - previousTrend.mileage : 0;
|
const trendDelta = latestTrend && previousTrend ? latestTrend.mileage - previousTrend.mileage : 0;
|
||||||
const pressureLevel = selectedCompletion >= 90 ? '健康' : selectedCompletion >= 70 ? '关注' : '高压';
|
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
|
// Load targets on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -103,13 +199,11 @@ export default function StatisticsView() {
|
|||||||
? [focused, ...data.filter(item => item.id !== focused.id)]
|
? [focused, ...data.filter(item => item.id !== focused.id)]
|
||||||
: data;
|
: data;
|
||||||
setTargets(ordered);
|
setTargets(ordered);
|
||||||
if (ordered.length > 0 && !selectedTargetId) {
|
const initialTarget = ordered.find(item => item.id === selectedTargetId) || focused;
|
||||||
setSelectedTargetId(focused.id);
|
if (initialTarget) {
|
||||||
setExpandedTargetId(focused.id);
|
setSelectedTargetId(initialTarget.id);
|
||||||
|
setExpandedTargetId(initialTarget.id);
|
||||||
setAssessmentYearMap(Object.fromEntries(ordered.map(item => [item.id, item.yearlyAssessments[0]?.yearNumber || 1])));
|
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(() => {});
|
}).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -119,9 +213,7 @@ export default function StatisticsView() {
|
|||||||
if (selectedTargetId === null) return;
|
if (selectedTargetId === null) return;
|
||||||
setExpandedTargetId(selectedTargetId);
|
setExpandedTargetId(selectedTargetId);
|
||||||
if (!targetVehiclesMap[selectedTargetId]) {
|
if (!targetVehiclesMap[selectedTargetId]) {
|
||||||
fetchTargetVehicles(selectedTargetId).then(vehicles => {
|
loadCurrentTargetVehicles(selectedTargetId);
|
||||||
setTargetVehiclesMap(prev => ({ ...prev, [selectedTargetId]: vehicles }));
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
}
|
||||||
fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([]));
|
fetchTrend(selectedTargetId).then(setTrendData).catch(() => setTrendData([]));
|
||||||
}, [selectedTargetId]);
|
}, [selectedTargetId]);
|
||||||
@@ -129,12 +221,52 @@ export default function StatisticsView() {
|
|||||||
// Re-fetch target vehicles when viewAllDate changes
|
// Re-fetch target vehicles when viewAllDate changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewAllTargetId === null) return;
|
if (viewAllTargetId === null) return;
|
||||||
|
if (viewAllDate === getDefaultDate()) {
|
||||||
|
setViewAllHistoricalVehicles(null);
|
||||||
|
setViewAllLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setViewAllLoading(true);
|
setViewAllLoading(true);
|
||||||
fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => {
|
fetchTargetVehicles(viewAllTargetId, viewAllDate).then(data => {
|
||||||
setTargetVehiclesMap(prev => ({ ...prev, [viewAllTargetId]: data }));
|
setViewAllHistoricalVehicles(data);
|
||||||
}).catch(() => {}).finally(() => setViewAllLoading(false));
|
}).catch(() => {}).finally(() => setViewAllLoading(false));
|
||||||
}, [viewAllTargetId, viewAllDate]);
|
}, [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 (
|
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' }}>
|
<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 */}
|
{/* Project Selector */}
|
||||||
@@ -142,10 +274,7 @@ export default function StatisticsView() {
|
|||||||
{targets.map(target => (
|
{targets.map(target => (
|
||||||
<button
|
<button
|
||||||
key={target.id}
|
key={target.id}
|
||||||
onClick={() => {
|
onClick={() => selectTarget(target.id)}
|
||||||
setSelectedTargetId(target.id);
|
|
||||||
setExpandedTargetId(target.id);
|
|
||||||
}}
|
|
||||||
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
|
className={`px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
|
||||||
selectedTargetId === target.id
|
selectedTargetId === target.id
|
||||||
? 'bg-blue-600 text-white shadow-md shadow-blue-200'
|
? 'bg-blue-600 text-white shadow-md shadow-blue-200'
|
||||||
@@ -196,6 +325,20 @@ export default function StatisticsView() {
|
|||||||
</motion.div>
|
</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">
|
<div className="flex flex-col landscape:flex-row gap-4 flex-1 landscape:overflow-hidden">
|
||||||
{/* Left Side: Trend Chart / Dashboard Sidebar */}
|
{/* 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">
|
<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"
|
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={() => {
|
onClick={() => {
|
||||||
setExpandedTargetId(target.id);
|
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">
|
<div className="flex items-center justify-between">
|
||||||
@@ -650,7 +788,7 @@ export default function StatisticsView() {
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
onClick={() => setViewAllTargetId(null)}
|
onClick={closeVehiclePanel}
|
||||||
className="fixed inset-0 z-[110] bg-slate-950/60 backdrop-blur-sm"
|
className="fixed inset-0 z-[110] bg-slate-950/60 backdrop-blur-sm"
|
||||||
/>
|
/>
|
||||||
<motion.div
|
<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 className="p-6 border-b border-slate-100 flex items-center justify-between">
|
||||||
<div>
|
<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>
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={closeVehiclePanel}
|
||||||
setViewAllTargetId(null);
|
|
||||||
setViewAllSearch('');
|
|
||||||
}}
|
|
||||||
className="p-2 hover:bg-slate-100 rounded-full transition-colors text-slate-400 hover:text-slate-900"
|
className="p-2 hover:bg-slate-100 rounded-full transition-colors text-slate-400 hover:text-slate-900"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
@@ -701,9 +841,8 @@ export default function StatisticsView() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
||||||
{viewAllLoading ? '加载中...' : (() => {
|
{viewAllLoading ? '加载中...' : (() => {
|
||||||
const filtered = (viewAllTargetId !== null ? (targetVehiclesMap[viewAllTargetId] || []) : []).filter(tv => tv.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase()));
|
const totalKm = searchedViewAllVehicles.reduce((sum, vehicle) => sum + (vehicle.todayMileage || 0), 0);
|
||||||
const totalKm = filtered.reduce((sum, tv) => sum + (tv.todayMileage || 0), 0);
|
return `${searchedViewAllVehicles.length} 辆 · 合计 ${fmtKm(totalKm)} km`;
|
||||||
return `${filtered.length} 辆 · 合计 ${fmtKm(totalKm)} km`;
|
|
||||||
})()}
|
})()}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -717,14 +856,17 @@ export default function StatisticsView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-2 no-scrollbar">
|
<div className="flex-1 overflow-y-auto p-4 space-y-2 no-scrollbar">
|
||||||
{(viewAllTargetId !== null ? (targetVehiclesMap[viewAllTargetId] || []) : []).filter(tv =>
|
{[...searchedViewAllVehicles].sort((a, b) => {
|
||||||
tv.plateNumber.toLowerCase().includes(viewAllSearch.toLowerCase())
|
|
||||||
).sort((a, b) => {
|
|
||||||
const valA = a.todayMileage || 0;
|
const valA = a.todayMileage || 0;
|
||||||
const valB = b.todayMileage || 0;
|
const valB = b.todayMileage || 0;
|
||||||
return viewAllSort === 'desc' ? valB - valA : valA - valB;
|
return viewAllSort === 'desc' ? valB - valA : valA - valB;
|
||||||
}).map(tv => (
|
}).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="flex items-center gap-3 overflow-hidden flex-1">
|
||||||
<div className="relative flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
|
<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-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 className="text-[9px] font-bold text-slate-300 mt-0.5">累计: {fmtKm(tv.totalMileage || 0)} km</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 bg-slate-50 border-t border-slate-100">
|
<div className="p-4 bg-slate-50 border-t border-slate-100">
|
||||||
<button
|
<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"
|
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', () => {
|
test('parses a vehicle drill context', () => {
|
||||||
const context = parseMileageDrillContext(
|
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, {
|
assert.deepEqual(context, {
|
||||||
level: 'vehicle',
|
level: 'vehicle',
|
||||||
|
targetId: 5,
|
||||||
plate: '粤A08190F',
|
plate: '粤A08190F',
|
||||||
startDate: '2026-08-01',
|
startDate: '2026-08-01',
|
||||||
endDate: '2026-08-07',
|
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');
|
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', () => {
|
test('builds a namespaced URL and preserves unrelated parameters', () => {
|
||||||
const url = buildMileageDrillUrl(
|
const url = buildMileageDrillUrl(
|
||||||
{ pathname: '/asset', search: '?company=5&legacy=1', hash: '#mileage' },
|
{ pathname: '/asset', search: '?company=5&legacy=1', hash: '#mileage' },
|
||||||
{
|
{
|
||||||
level: 'vehicle',
|
level: 'vehicle',
|
||||||
|
targetId: 5,
|
||||||
plate: '粤A08190F',
|
plate: '粤A08190F',
|
||||||
startDate: '2026-08-01',
|
startDate: '2026-08-01',
|
||||||
endDate: '2026-08-07',
|
endDate: '2026-08-07',
|
||||||
@@ -35,7 +50,7 @@ test('builds a namespaced URL and preserves unrelated parameters', () => {
|
|||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
url,
|
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 {
|
export interface MileageDrillContext {
|
||||||
level: MileageDrillLevel;
|
level: MileageDrillLevel;
|
||||||
|
targetId?: number;
|
||||||
dimension?: MileageBreakdownDimension;
|
dimension?: MileageBreakdownDimension;
|
||||||
dimensionValue?: string;
|
dimensionValue?: string;
|
||||||
plate?: string;
|
plate?: string;
|
||||||
@@ -51,6 +52,8 @@ function parseSourcePriority(value: string | null): MileageSourceGroup[] {
|
|||||||
|
|
||||||
export function parseMileageDrillContext(search: string): MileageDrillContext {
|
export function parseMileageDrillContext(search: string): MileageDrillContext {
|
||||||
const params = new URLSearchParams(search);
|
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 plate = params.get('mileagePlate')?.trim() || undefined;
|
||||||
const rawDimension = params.get('mileageDimension');
|
const rawDimension = params.get('mileageDimension');
|
||||||
const dimension = rawDimension && BREAKDOWN_DIMENSIONS.has(rawDimension as MileageBreakdownDimension)
|
const dimension = rawDimension && BREAKDOWN_DIMENSIONS.has(rawDimension as MileageBreakdownDimension)
|
||||||
@@ -67,6 +70,7 @@ export function parseMileageDrillContext(search: string): MileageDrillContext {
|
|||||||
level,
|
level,
|
||||||
sourcePriority: parseSourcePriority(params.get('mileageSource')),
|
sourcePriority: parseSourcePriority(params.get('mileageSource')),
|
||||||
};
|
};
|
||||||
|
if (targetId) context.targetId = targetId;
|
||||||
if (level === 'breakdown') {
|
if (level === 'breakdown') {
|
||||||
context.dimension = dimension;
|
context.dimension = dimension;
|
||||||
context.dimensionValue = dimensionValue;
|
context.dimensionValue = dimensionValue;
|
||||||
@@ -86,6 +90,7 @@ export function buildMileageDrillUrl(
|
|||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
for (const key of [
|
for (const key of [
|
||||||
'mileageLevel',
|
'mileageLevel',
|
||||||
|
'mileageTarget',
|
||||||
'mileageDimension',
|
'mileageDimension',
|
||||||
'mileageValue',
|
'mileageValue',
|
||||||
'mileagePlate',
|
'mileagePlate',
|
||||||
@@ -97,6 +102,7 @@ export function buildMileageDrillUrl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (context.level !== 'overview') params.set('mileageLevel', context.level);
|
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) {
|
if (context.level === 'breakdown' && context.dimension && context.dimensionValue) {
|
||||||
params.set('mileageDimension', context.dimension);
|
params.set('mileageDimension', context.dimension);
|
||||||
params.set('mileageValue', context.dimensionValue);
|
params.set('mileageValue', context.dimensionValue);
|
||||||
|
|||||||
Reference in New Issue
Block a user