feat: persist mileage monitoring context

This commit is contained in:
kkfluous
2026-08-07 18:39:13 +08:00
parent d958f3b28f
commit c0d504cb7b
4 changed files with 348 additions and 24 deletions
+111 -24
View File
@@ -24,6 +24,11 @@ import {
type MileageDrillContext,
} from './drill-context';
import { formatMileageSnapshotTime } from './data-freshness';
import {
buildMileageMonitoringUrl,
parseMileageMonitoringContext,
type MileageMonitoringContext,
} from './monitoring-context';
const HIGH_MILEAGE_ALERT_TARGETS = new Set([
'交投40辆4.5T普货',
@@ -454,10 +459,11 @@ 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');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [initialMonitoringContext] = useState(() => parseMileageMonitoringContext(window.location.search));
const [searchTerm, setSearchTerm] = useState(initialMonitoringContext.search || '');
const [filterDept, setFilterDept] = useState(initialMonitoringContext.department || 'All');
const [sortBy, setSortBy] = useState<'today' | 'total' | 'statisticTime'>(initialMonitoringContext.sortBy);
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(initialMonitoringContext.sortOrder);
const [isFilterOpen, setIsFilterOpen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [fullscreenVehicles, setFullscreenVehicles] = useState<MonitoringVehicle[]>([]);
@@ -469,24 +475,31 @@ export default function MonitoringView() {
const [fullscreenUpdatedAt, setFullscreenUpdatedAt] = useState<string | null>(null);
// New filters from image
const [filterPlates, setFilterPlates] = useState<string[]>(() => (
drillContext.level === 'vehicle' && drillContext.plate ? [drillContext.plate] : []
const [filterPlates, setFilterPlates] = useState<string[]>(initialMonitoringContext.plates);
const [drillPlateFallback, setDrillPlateFallback] = useState<string | undefined>(() => (
drillContext.level === 'vehicle' && drillContext.plate ? drillContext.plate : undefined
));
const [filterCustomer, setFilterCustomer] = useState('All');
const [filterProject, setFilterProject] = useState('All');
const [filterEntity, setFilterEntity] = useState('All');
const [filterRentStatus, setFilterRentStatus] = useState('All');
const [filterPlatePrefix, setFilterPlatePrefix] = useState('All');
const [filterTargetNames, setFilterTargetNames] = useState<string[]>([]);
const [filterRegion, setFilterRegion] = useState('All');
const [filterBrands, setFilterBrands] = useState<string[]>([]);
const [filterMileageRange, setFilterMileageRange] = useState({ min: '', max: '' });
const [appliedMileageRange, setAppliedMileageRange] = useState({ min: '', max: '' });
const [filterCustomer, setFilterCustomer] = useState(initialMonitoringContext.customer || 'All');
const [filterProject, setFilterProject] = useState(initialMonitoringContext.project || 'All');
const [filterEntity, setFilterEntity] = useState(initialMonitoringContext.entity || 'All');
const [filterRentStatus, setFilterRentStatus] = useState(initialMonitoringContext.rentStatus || 'All');
const [filterPlatePrefix, setFilterPlatePrefix] = useState(initialMonitoringContext.platePrefix || 'All');
const [filterTargetNames, setFilterTargetNames] = useState<string[]>(initialMonitoringContext.targetNames);
const [filterRegion, setFilterRegion] = useState(initialMonitoringContext.region || 'All');
const [filterBrands, setFilterBrands] = useState<string[]>(initialMonitoringContext.brands);
const [filterMileageRange, setFilterMileageRange] = useState({
min: initialMonitoringContext.mileageMin || '',
max: initialMonitoringContext.mileageMax || '',
});
const [appliedMileageRange, setAppliedMileageRange] = useState({
min: initialMonitoringContext.mileageMin || '',
max: initialMonitoringContext.mileageMax || '',
});
const [exporting, setExporting] = useState(false);
const [exportStatus, setExportStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const [rangeStart, setRangeStart] = useState(() => drillContext.startDate || defaultMileageDate());
const [rangeEnd, setRangeEnd] = useState(() => drillContext.endDate || defaultMileageDate());
const [sourcePriority, setSourcePriority] = useState<MileageSourceGroup[]>(drillContext.sourcePriority);
const [rangeStart, setRangeStart] = useState(() => initialMonitoringContext.startDate || drillContext.startDate || defaultMileageDate());
const [rangeEnd, setRangeEnd] = useState(() => initialMonitoringContext.endDate || drillContext.endDate || defaultMileageDate());
const [sourcePriority, setSourcePriority] = useState<MileageSourceGroup[]>(initialMonitoringContext.sourcePriority);
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
@@ -515,6 +528,7 @@ export default function MonitoringView() {
}, []);
const openVehicleDetail = useCallback((vehicle: MonitoringVehicle) => {
setDrillPlateFallback(undefined);
commitDrillContext({
level: 'vehicle',
plate: vehicle.plate,
@@ -525,6 +539,7 @@ export default function MonitoringView() {
}, [commitDrillContext, effectiveRange.end, effectiveRange.start, sourcePriority]);
const closeVehicleDetail = useCallback(() => {
setDrillPlateFallback(undefined);
commitDrillContext({
level: 'overview',
startDate: effectiveRange.start,
@@ -541,11 +556,31 @@ export default function MonitoringView() {
useEffect(() => {
const handlePopState = () => {
const next = parseMileageDrillContext(window.location.search);
const nextMonitoring = parseMileageMonitoringContext(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]);
setRangeStart(nextMonitoring.startDate || defaultMileageDate());
setRangeEnd(nextMonitoring.endDate || defaultMileageDate());
setSourcePriority(nextMonitoring.sourcePriority);
setFilterTargetNames(nextMonitoring.targetNames);
setFilterRegion(nextMonitoring.region || 'All');
setFilterPlates(nextMonitoring.plates);
setDrillPlateFallback(next.level === 'vehicle' && next.plate ? next.plate : undefined);
setFilterDept(nextMonitoring.department || 'All');
setFilterCustomer(nextMonitoring.customer || 'All');
setFilterProject(nextMonitoring.project || 'All');
setFilterEntity(nextMonitoring.entity || 'All');
setFilterRentStatus(nextMonitoring.rentStatus || 'All');
setFilterBrands(nextMonitoring.brands);
setFilterPlatePrefix(nextMonitoring.platePrefix || 'All');
setSearchTerm(nextMonitoring.search || '');
const nextMileageRange = {
min: nextMonitoring.mileageMin || '',
max: nextMonitoring.mileageMax || '',
};
setFilterMileageRange(nextMileageRange);
setAppliedMileageRange(nextMileageRange);
setSortBy(nextMonitoring.sortBy);
setSortOrder(nextMonitoring.sortOrder);
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
@@ -565,6 +600,11 @@ export default function MonitoringView() {
return inAlertTarget && Math.max(0, v.dailyKm || 0) >= HIGH_MILEAGE_ALERT_KM;
}, [filterTargetNames]);
const requestPlates = useMemo(() => Array.from(new Set([
...filterPlates,
...(drillPlateFallback ? [drillPlateFallback] : []),
])), [drillPlateFallback, filterPlates]);
const monitoringFilters = useMemo<MonitoringRequestParams>(() => ({
search: searchTerm || undefined,
dept: filterDept !== 'All' ? filterDept : undefined,
@@ -575,13 +615,50 @@ export default function MonitoringView() {
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
targetNames: filterTargetNames.length > 0 ? filterTargetNames : undefined,
region: filterRegion !== 'All' ? filterRegion : undefined,
plate: filterPlates.length > 0 ? filterPlates.join(',') : undefined,
plate: requestPlates.length > 0 ? requestPlates.join(',') : undefined,
mileageMin: appliedMileageRange.min || undefined,
mileageMax: appliedMileageRange.max || undefined,
startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
brands: filterBrands.length > 0 ? filterBrands : undefined,
sourcePriority,
}), [
appliedMileageRange.max,
appliedMileageRange.min,
filterBrands,
filterCustomer,
filterDept,
filterEntity,
filterPlatePrefix,
filterProject,
filterRegion,
filterRentStatus,
filterTargetNames,
rangeEnd,
rangeStart,
requestPlates,
searchTerm,
sourcePriority,
]);
const monitoringUrlContext = useMemo<MileageMonitoringContext>(() => ({
startDate: rangeStart || undefined,
endDate: rangeEnd || undefined,
sourcePriority,
targetNames: filterTargetNames,
region: filterRegion !== 'All' ? filterRegion : undefined,
plates: filterPlates,
department: filterDept !== 'All' ? filterDept : undefined,
customer: filterCustomer !== 'All' ? filterCustomer : undefined,
project: filterProject !== 'All' ? filterProject : undefined,
entity: filterEntity !== 'All' ? filterEntity : undefined,
rentStatus: filterRentStatus !== 'All' ? filterRentStatus : undefined,
brands: filterBrands,
platePrefix: filterPlatePrefix !== 'All' ? filterPlatePrefix : undefined,
search: searchTerm || undefined,
mileageMin: appliedMileageRange.min || undefined,
mileageMax: appliedMileageRange.max || undefined,
sortBy,
sortOrder,
}), [
appliedMileageRange.max,
appliedMileageRange.min,
@@ -598,8 +675,17 @@ export default function MonitoringView() {
rangeEnd,
rangeStart,
searchTerm,
sortBy,
sortOrder,
sourcePriority,
]);
useEffect(() => {
const nextUrl = buildMileageMonitoringUrl(window.location, monitoringUrlContext);
const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (nextUrl !== currentUrl) window.history.replaceState(null, '', nextUrl);
}, [monitoringUrlContext]);
const monitoringRequestKey = useMemo(
() => JSON.stringify({ ...monitoringFilters, sortBy, sortOrder }),
[monitoringFilters, sortBy, sortOrder],
@@ -705,6 +791,7 @@ export default function MonitoringView() {
// 区域级联:plate 选项收窄后,剔除已选但已不属于该区域的车牌
useEffect(() => {
if (filterPlates.length === 0) return;
if (filterOptions.plates.length === 0) return;
const valid = new Set(filterOptions.plates);
const next = filterPlates.filter(p => valid.has(p));
if (next.length !== filterPlates.length) setFilterPlates(next);
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildMileageMonitoringUrl,
parseMileageMonitoringContext,
} from './monitoring-context.js';
test('parses a complete mileage monitoring context', () => {
const context = parseMileageMonitoringContext(
'?mileageStart=2026-08-01&mileageEnd=2026-08-07&mileageSource=gps,instrument'
+ '&mileageBatch=%E4%BA%A4%E6%8A%95190%E8%BE%86&mileageBatch=%E7%BE%9A%E7%89%9B136%E8%BE%86'
+ '&mileageRegion=%E5%B9%BF%E5%B7%9E&mileageFilterPlate=%E7%B2%A4A12345&mileageDepartment=%E4%B8%9A%E5%8A%A1%E4%B8%80%E9%83%A8'
+ '&mileageCustomer=%E5%AE%A2%E6%88%B7A&mileageProject=%E9%A1%B9%E7%9B%AEA&mileageEntity=%E4%B8%BB%E4%BD%93A'
+ '&mileageRentStatus=%E7%A7%9F%E8%B5%81&mileageBrand=%E7%8E%B0%E4%BB%A3&mileagePlatePrefix=%E7%B2%A4A'
+ '&mileageSearch=123&mileageMin=10&mileageMax=500&mileageSort=total&mileageOrder=asc',
);
assert.deepEqual(context, {
startDate: '2026-08-01',
endDate: '2026-08-07',
sourcePriority: ['gps', 'instrument'],
targetNames: ['交投190辆', '羚牛136辆'],
region: '广州',
plates: ['粤A12345'],
department: '业务一部',
customer: '客户A',
project: '项目A',
entity: '主体A',
rentStatus: '租赁',
brands: ['现代'],
platePrefix: '粤A',
search: '123',
mileageMin: '10',
mileageMax: '500',
sortBy: 'total',
sortOrder: 'asc',
});
});
test('normalizes invalid monitoring URL values', () => {
const context = parseMileageMonitoringContext(
'?mileageStart=invalid&mileageSource=unknown&mileageBatch=&mileageRegion=All&mileageSort=unknown&mileageOrder=unknown',
);
assert.deepEqual(context, {
startDate: undefined,
endDate: undefined,
sourcePriority: ['instrument'],
targetNames: [],
region: undefined,
plates: [],
department: undefined,
customer: undefined,
project: undefined,
entity: undefined,
rentStatus: undefined,
brands: [],
platePrefix: undefined,
search: undefined,
mileageMin: undefined,
mileageMax: undefined,
sortBy: 'today',
sortOrder: 'desc',
});
});
test('builds monitoring state without removing drill or unrelated context', () => {
const url = buildMileageMonitoringUrl(
{
pathname: '/asset',
search: '?company=5&mileageLevel=vehicle&mileagePlate=%E7%B2%A4A08190F&mileageBatch=old',
hash: '#mileage',
},
{
startDate: '2026-08-01',
endDate: '2026-08-07',
sourcePriority: ['instrument'],
targetNames: ['交投190辆', '羚牛136辆'],
region: '广州',
plates: ['粤A12345'],
department: undefined,
customer: undefined,
project: undefined,
entity: undefined,
rentStatus: undefined,
brands: [],
platePrefix: undefined,
search: undefined,
mileageMin: undefined,
mileageMax: undefined,
sortBy: 'statisticTime',
sortOrder: 'asc',
},
);
assert.equal(
url,
'/asset?company=5&mileageLevel=vehicle&mileagePlate=%E7%B2%A4A08190F&mileageStart=2026-08-01&mileageEnd=2026-08-07&mileageSource=instrument&mileageBatch=%E4%BA%A4%E6%8A%95190%E8%BE%86&mileageBatch=%E7%BE%9A%E7%89%9B136%E8%BE%86&mileageRegion=%E5%B9%BF%E5%B7%9E&mileageFilterPlate=%E7%B2%A4A12345&mileageSort=statisticTime&mileageOrder=asc#mileage',
);
});
+136
View File
@@ -0,0 +1,136 @@
import type { MileageSourceGroup } from './types';
export type MonitoringSortBy = 'today' | 'total' | 'statisticTime';
export type MonitoringSortOrder = 'asc' | 'desc';
export interface MileageMonitoringContext {
startDate?: string;
endDate?: string;
sourcePriority: MileageSourceGroup[];
targetNames: string[];
region?: string;
plates: string[];
department?: string;
customer?: string;
project?: string;
entity?: string;
rentStatus?: string;
brands: string[];
platePrefix?: string;
search?: string;
mileageMin?: string;
mileageMax?: string;
sortBy: MonitoringSortBy;
sortOrder: MonitoringSortOrder;
}
interface LocationParts {
pathname: string;
search: string;
hash: string;
}
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const CONTEXT_KEYS = [
'mileageStart',
'mileageEnd',
'mileageSource',
'mileageBatch',
'mileageRegion',
'mileageFilterPlate',
'mileageDepartment',
'mileageCustomer',
'mileageProject',
'mileageEntity',
'mileageRentStatus',
'mileageBrand',
'mileagePlatePrefix',
'mileageSearch',
'mileageMin',
'mileageMax',
'mileageSort',
'mileageOrder',
] as const;
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 boundedValue(value: string | null, maxLength = 200): string | undefined {
const normalized = value?.trim();
if (!normalized || normalized === 'All') return undefined;
return normalized.slice(0, maxLength);
}
function uniqueValues(values: string[], limit = 200): string[] {
return Array.from(new Set(values.map(value => boundedValue(value)).filter((value): value is string => !!value))).slice(0, limit);
}
function parseSourcePriority(value: string | null): MileageSourceGroup[] {
const sources = value
?.split(',')
.filter((source): source is MileageSourceGroup => source === 'instrument' || source === 'gps');
return sources && sources.length > 0 ? Array.from(new Set(sources)).slice(0, 2) : ['instrument'];
}
function appendValues(params: URLSearchParams, key: string, values: string[]): void {
uniqueValues(values).forEach(value => params.append(key, value));
}
export function parseMileageMonitoringContext(search: string): MileageMonitoringContext {
const params = new URLSearchParams(search);
const rawSortBy = params.get('mileageSort');
const rawSortOrder = params.get('mileageOrder');
return {
startDate: validDate(params.get('mileageStart')),
endDate: validDate(params.get('mileageEnd')),
sourcePriority: parseSourcePriority(params.get('mileageSource')),
targetNames: uniqueValues(params.getAll('mileageBatch')),
region: boundedValue(params.get('mileageRegion')),
plates: uniqueValues(params.getAll('mileageFilterPlate')),
department: boundedValue(params.get('mileageDepartment')),
customer: boundedValue(params.get('mileageCustomer')),
project: boundedValue(params.get('mileageProject')),
entity: boundedValue(params.get('mileageEntity')),
rentStatus: boundedValue(params.get('mileageRentStatus')),
brands: uniqueValues(params.getAll('mileageBrand')),
platePrefix: boundedValue(params.get('mileagePlatePrefix')),
search: boundedValue(params.get('mileageSearch')),
mileageMin: boundedValue(params.get('mileageMin'), 32),
mileageMax: boundedValue(params.get('mileageMax'), 32),
sortBy: rawSortBy === 'total' || rawSortBy === 'statisticTime' ? rawSortBy : 'today',
sortOrder: rawSortOrder === 'asc' ? 'asc' : 'desc',
};
}
export function buildMileageMonitoringUrl(
location: LocationParts,
context: MileageMonitoringContext,
): string {
const params = new URLSearchParams(location.search);
CONTEXT_KEYS.forEach(key => params.delete(key));
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(','));
appendValues(params, 'mileageBatch', context.targetNames);
if (context.region) params.set('mileageRegion', context.region);
appendValues(params, 'mileageFilterPlate', context.plates);
if (context.department) params.set('mileageDepartment', context.department);
if (context.customer) params.set('mileageCustomer', context.customer);
if (context.project) params.set('mileageProject', context.project);
if (context.entity) params.set('mileageEntity', context.entity);
if (context.rentStatus) params.set('mileageRentStatus', context.rentStatus);
appendValues(params, 'mileageBrand', context.brands);
if (context.platePrefix) params.set('mileagePlatePrefix', context.platePrefix);
if (context.search) params.set('mileageSearch', context.search);
if (context.mileageMin) params.set('mileageMin', context.mileageMin);
if (context.mileageMax) params.set('mileageMax', context.mileageMax);
if (context.sortBy !== 'today') params.set('mileageSort', context.sortBy);
if (context.sortOrder !== 'desc') params.set('mileageOrder', context.sortOrder);
const query = params.toString();
return `${location.pathname}${query ? `?${query}` : ''}${location.hash}`;
}