refactor: persist mileage drill context in URL
This commit is contained in:
@@ -18,6 +18,11 @@ import Blur from '../../components/Blur';
|
||||
import PlateMultiSelect from './PlateMultiSelect';
|
||||
import { exportMileageXlsx } from './xlsx-export';
|
||||
import VehicleDetailModal from './VehicleDetailModal';
|
||||
import {
|
||||
buildMileageDrillUrl,
|
||||
parseMileageDrillContext,
|
||||
type MileageDrillContext,
|
||||
} from './drill-context';
|
||||
|
||||
const HIGH_MILEAGE_ALERT_TARGETS = new Set([
|
||||
'交投40辆4.5T普货',
|
||||
@@ -444,6 +449,9 @@ const BatchMultiSelect = ({
|
||||
};
|
||||
|
||||
export default function MonitoringView() {
|
||||
const [drillContext, setDrillContext] = useState<MileageDrillContext>(() => (
|
||||
parseMileageDrillContext(window.location.search)
|
||||
));
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterDept, setFilterDept] = useState('All');
|
||||
const [sortBy, setSortBy] = useState<'today' | 'total' | 'statisticTime'>('today');
|
||||
@@ -456,7 +464,9 @@ export default function MonitoringView() {
|
||||
const [fullscreenLoading, setFullscreenLoading] = useState(false);
|
||||
|
||||
// New filters from image
|
||||
const [filterPlates, setFilterPlates] = useState<string[]>([]);
|
||||
const [filterPlates, setFilterPlates] = useState<string[]>(() => (
|
||||
drillContext.level === 'vehicle' && drillContext.plate ? [drillContext.plate] : []
|
||||
));
|
||||
const [filterCustomer, setFilterCustomer] = useState('All');
|
||||
const [filterProject, setFilterProject] = useState('All');
|
||||
const [filterEntity, setFilterEntity] = useState('All');
|
||||
@@ -468,10 +478,9 @@ export default function MonitoringView() {
|
||||
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
|
||||
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null);
|
||||
const [rangeStart, setRangeStart] = useState(defaultMileageDate);
|
||||
const [rangeEnd, setRangeEnd] = useState(defaultMileageDate);
|
||||
const [sourcePriority, setSourcePriority] = useState<MileageSourceGroup[]>(['instrument']);
|
||||
const [rangeStart, setRangeStart] = useState(() => drillContext.startDate || defaultMileageDate());
|
||||
const [rangeEnd, setRangeEnd] = useState(() => drillContext.endDate || defaultMileageDate());
|
||||
const [sourcePriority, setSourcePriority] = useState<MileageSourceGroup[]>(drillContext.sourcePriority);
|
||||
|
||||
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
|
||||
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
||||
@@ -488,12 +497,53 @@ export default function MonitoringView() {
|
||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||
const [relativeNow, setRelativeNow] = useState(() => Date.now());
|
||||
const PAGE_SIZE = 50;
|
||||
const detailVehicle = drillContext.level === 'vehicle' && drillContext.plate
|
||||
? vehicles.find(vehicle => vehicle.plate === drillContext.plate) || null
|
||||
: null;
|
||||
|
||||
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 openVehicleDetail = useCallback((vehicle: MonitoringVehicle) => {
|
||||
commitDrillContext({
|
||||
level: 'vehicle',
|
||||
plate: vehicle.plate,
|
||||
startDate: effectiveRange.start,
|
||||
endDate: effectiveRange.end,
|
||||
sourcePriority,
|
||||
}, 'push');
|
||||
}, [commitDrillContext, effectiveRange.end, effectiveRange.start, sourcePriority]);
|
||||
|
||||
const closeVehicleDetail = useCallback(() => {
|
||||
commitDrillContext({
|
||||
level: 'overview',
|
||||
startDate: effectiveRange.start,
|
||||
endDate: effectiveRange.end,
|
||||
sourcePriority,
|
||||
}, 'replace');
|
||||
}, [commitDrillContext, effectiveRange.end, effectiveRange.start, sourcePriority]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setRelativeNow(Date.now()), 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
const next = parseMileageDrillContext(window.location.search);
|
||||
setDrillContext(next);
|
||||
if (next.startDate) setRangeStart(next.startDate);
|
||||
if (next.endDate) setRangeEnd(next.endDate);
|
||||
setSourcePriority(next.sourcePriority);
|
||||
if (next.level === 'vehicle' && next.plate) setFilterPlates([next.plate]);
|
||||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []);
|
||||
|
||||
const departments = filterOptions.departments;
|
||||
const plateNumbers = filterOptions.plates;
|
||||
const rangeLabel = normalizeRangeLabel(effectiveRange.start, effectiveRange.end);
|
||||
@@ -1494,7 +1544,7 @@ export default function MonitoringView() {
|
||||
animate={{ opacity: 1 }}
|
||||
key={v.plate}
|
||||
className="grid cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-xl border border-slate-200 bg-white px-3 py-2.5 shadow-sm transition-all hover:border-blue-200 hover:shadow-md active:bg-slate-50 md:grid-cols-[minmax(190px,1.1fr)_minmax(160px,1fr)_130px_130px] md:px-4"
|
||||
onClick={() => setDetailVehicle(v)}
|
||||
onClick={() => openVehicleDetail(v)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3 overflow-hidden">
|
||||
<div className="relative flex-shrink-0">
|
||||
@@ -1588,7 +1638,7 @@ export default function MonitoringView() {
|
||||
|
||||
<VehicleDetailModal
|
||||
vehicle={detailVehicle}
|
||||
onClose={() => setDetailVehicle(null)}
|
||||
onClose={closeVehicleDetail}
|
||||
sourcePriority={sourcePriority}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { buildMileageDrillUrl, parseMileageDrillContext } from './drill-context.js';
|
||||
|
||||
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',
|
||||
);
|
||||
|
||||
assert.deepEqual(context, {
|
||||
level: 'vehicle',
|
||||
plate: '粤A08190F',
|
||||
startDate: '2026-08-01',
|
||||
endDate: '2026-08-07',
|
||||
sourcePriority: ['instrument', 'gps'],
|
||||
});
|
||||
});
|
||||
|
||||
test('requires complete context for breakdown and vehicle levels', () => {
|
||||
assert.equal(parseMileageDrillContext('?mileageLevel=vehicle').level, 'overview');
|
||||
assert.equal(parseMileageDrillContext('?mileageLevel=breakdown&mileageDimension=region').level, 'overview');
|
||||
});
|
||||
|
||||
test('builds a namespaced URL and preserves unrelated parameters', () => {
|
||||
const url = buildMileageDrillUrl(
|
||||
{ pathname: '/asset', search: '?company=5&legacy=1', hash: '#mileage' },
|
||||
{
|
||||
level: 'vehicle',
|
||||
plate: '粤A08190F',
|
||||
startDate: '2026-08-01',
|
||||
endDate: '2026-08-07',
|
||||
sourcePriority: ['instrument'],
|
||||
},
|
||||
);
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
|
||||
test('clears entity drill parameters when returning to overview', () => {
|
||||
const url = buildMileageDrillUrl(
|
||||
{
|
||||
pathname: '/asset',
|
||||
search: '?mileageLevel=vehicle&mileagePlate=%E7%B2%A4A08190F&company=5',
|
||||
hash: '#mileage',
|
||||
},
|
||||
{ level: 'overview', sourcePriority: ['instrument'] },
|
||||
);
|
||||
|
||||
assert.equal(url, '/asset?company=5&mileageSource=instrument#mileage');
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { MileageSourceGroup } from './types';
|
||||
|
||||
export type MileageDrillLevel = 'overview' | 'breakdown' | 'vehicle';
|
||||
export type MileageBreakdownDimension =
|
||||
| 'department'
|
||||
| 'region'
|
||||
| 'customer'
|
||||
| 'vehicle-model'
|
||||
| 'assessment-target';
|
||||
|
||||
export interface MileageDrillContext {
|
||||
level: MileageDrillLevel;
|
||||
dimension?: MileageBreakdownDimension;
|
||||
dimensionValue?: string;
|
||||
plate?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
sourcePriority: MileageSourceGroup[];
|
||||
}
|
||||
|
||||
interface LocationParts {
|
||||
pathname: string;
|
||||
search: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const BREAKDOWN_DIMENSIONS = new Set<MileageBreakdownDimension>([
|
||||
'department',
|
||||
'region',
|
||||
'customer',
|
||||
'vehicle-model',
|
||||
'assessment-target',
|
||||
]);
|
||||
|
||||
function validDate(value: string | null): string | undefined {
|
||||
if (!value || !DATE_PATTERN.test(value)) return undefined;
|
||||
const timestamp = Date.parse(`${value}T00:00:00+08:00`);
|
||||
return Number.isFinite(timestamp) ? value : undefined;
|
||||
}
|
||||
|
||||
function parseSourcePriority(value: string | null): MileageSourceGroup[] {
|
||||
if (!value) return ['instrument'];
|
||||
const sources = value
|
||||
.split(',')
|
||||
.filter((source): source is MileageSourceGroup => source === 'instrument' || source === 'gps');
|
||||
return Array.from(new Set(sources)).slice(0, 2).length > 0
|
||||
? Array.from(new Set(sources)).slice(0, 2)
|
||||
: ['instrument'];
|
||||
}
|
||||
|
||||
export function parseMileageDrillContext(search: string): MileageDrillContext {
|
||||
const params = new URLSearchParams(search);
|
||||
const plate = params.get('mileagePlate')?.trim() || undefined;
|
||||
const rawDimension = params.get('mileageDimension');
|
||||
const dimension = rawDimension && BREAKDOWN_DIMENSIONS.has(rawDimension as MileageBreakdownDimension)
|
||||
? rawDimension as MileageBreakdownDimension
|
||||
: undefined;
|
||||
const dimensionValue = params.get('mileageValue')?.trim() || undefined;
|
||||
const requestedLevel = params.get('mileageLevel');
|
||||
|
||||
let level: MileageDrillLevel = 'overview';
|
||||
if (requestedLevel === 'vehicle' && plate) level = 'vehicle';
|
||||
if (requestedLevel === 'breakdown' && dimension && dimensionValue) level = 'breakdown';
|
||||
|
||||
const context: MileageDrillContext = {
|
||||
level,
|
||||
sourcePriority: parseSourcePriority(params.get('mileageSource')),
|
||||
};
|
||||
if (level === 'breakdown') {
|
||||
context.dimension = dimension;
|
||||
context.dimensionValue = dimensionValue;
|
||||
}
|
||||
if (level === 'vehicle') context.plate = plate;
|
||||
const startDate = validDate(params.get('mileageStart'));
|
||||
const endDate = validDate(params.get('mileageEnd'));
|
||||
if (startDate) context.startDate = startDate;
|
||||
if (endDate) context.endDate = endDate;
|
||||
return context;
|
||||
}
|
||||
|
||||
export function buildMileageDrillUrl(
|
||||
location: LocationParts,
|
||||
context: MileageDrillContext,
|
||||
): string {
|
||||
const params = new URLSearchParams(location.search);
|
||||
for (const key of [
|
||||
'mileageLevel',
|
||||
'mileageDimension',
|
||||
'mileageValue',
|
||||
'mileagePlate',
|
||||
'mileageStart',
|
||||
'mileageEnd',
|
||||
'mileageSource',
|
||||
]) {
|
||||
params.delete(key);
|
||||
}
|
||||
|
||||
if (context.level !== 'overview') params.set('mileageLevel', context.level);
|
||||
if (context.level === 'breakdown' && context.dimension && context.dimensionValue) {
|
||||
params.set('mileageDimension', context.dimension);
|
||||
params.set('mileageValue', context.dimensionValue);
|
||||
}
|
||||
if (context.level === 'vehicle' && context.plate) params.set('mileagePlate', context.plate);
|
||||
if (context.startDate) params.set('mileageStart', context.startDate);
|
||||
if (context.endDate) params.set('mileageEnd', context.endDate);
|
||||
if (context.sourcePriority.length > 0) params.set('mileageSource', context.sourcePriority.join(','));
|
||||
|
||||
const query = params.toString();
|
||||
return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`;
|
||||
}
|
||||
Reference in New Issue
Block a user