refactor: persist mileage drill context in URL

This commit is contained in:
kkfluous
2026-08-07 14:00:43 +08:00
parent ce50265771
commit f24a18e459
3 changed files with 221 additions and 7 deletions
+57 -7
View File
@@ -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}
/>
+53
View File
@@ -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');
});
+111
View File
@@ -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}`;
}