From f24a18e45953d40a15eb1fcfc9fd25c7247183c5 Mon Sep 17 00:00:00 2001 From: kkfluous Date: Fri, 7 Aug 2026 14:00:43 +0800 Subject: [PATCH] refactor: persist mileage drill context in URL --- src/modules/mileage/MonitoringView.tsx | 64 +++++++++++-- src/modules/mileage/drill-context.test.ts | 53 +++++++++++ src/modules/mileage/drill-context.ts | 111 ++++++++++++++++++++++ 3 files changed, 221 insertions(+), 7 deletions(-) create mode 100644 src/modules/mileage/drill-context.test.ts create mode 100644 src/modules/mileage/drill-context.ts diff --git a/src/modules/mileage/MonitoringView.tsx b/src/modules/mileage/MonitoringView.tsx index 2caa0fe..0a07c9d 100644 --- a/src/modules/mileage/MonitoringView.tsx +++ b/src/modules/mileage/MonitoringView.tsx @@ -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(() => ( + 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([]); + const [filterPlates, setFilterPlates] = useState(() => ( + 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(null); - const [rangeStart, setRangeStart] = useState(defaultMileageDate); - const [rangeEnd, setRangeEnd] = useState(defaultMileageDate); - const [sourcePriority, setSourcePriority] = useState(['instrument']); + const [rangeStart, setRangeStart] = useState(() => drillContext.startDate || defaultMileageDate()); + const [rangeEnd, setRangeEnd] = useState(() => drillContext.endDate || defaultMileageDate()); + const [sourcePriority, setSourcePriority] = useState(drillContext.sourcePriority); const [vehicles, setVehicles] = useState([]); const [stats, setStats] = useState({ 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)} >
@@ -1588,7 +1638,7 @@ export default function MonitoringView() { setDetailVehicle(null)} + onClose={closeVehicleDetail} sourcePriority={sourcePriority} /> diff --git a/src/modules/mileage/drill-context.test.ts b/src/modules/mileage/drill-context.test.ts new file mode 100644 index 0000000..fd85693 --- /dev/null +++ b/src/modules/mileage/drill-context.test.ts @@ -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'); +}); diff --git a/src/modules/mileage/drill-context.ts b/src/modules/mileage/drill-context.ts new file mode 100644 index 0000000..ed879e7 --- /dev/null +++ b/src/modules/mileage/drill-context.ts @@ -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([ + '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}`; +}