feat(monitor): preserve investigation context

This commit is contained in:
lingniu
2026-07-16 17:08:54 +08:00
parent 38b056f5f1
commit 1cfc9c2bdd
17 changed files with 426 additions and 37 deletions

View File

@@ -0,0 +1,57 @@
import { expect, test } from 'vitest';
import {
buildMonitorPath,
monitorReturnFromParams,
parseMonitorRouteContext,
preserveMonitorReturn,
validatedMonitorReturn,
withMonitorReturn
} from './monitorContext';
test('round trips the selected vehicle, filters, panel state, pagination and map viewport', () => {
const path = buildMonitorPath({
mode: 'list',
keyword: '粤A12345',
protocol: 'JT808',
status: 'online',
selectedVin: 'LTEST000000000001',
detailOpen: false,
viewport: { zoom: 13.25, bounds: '113.100000,23.000000,113.400000,23.300000' },
listOffset: 100,
listLimit: 100
});
const parsed = parseMonitorRouteContext(new URL(path, 'https://vehicle-platform.invalid').searchParams);
expect(parsed).toMatchObject({
mode: 'list',
keyword: '粤A12345',
protocol: 'JT808',
status: 'online',
selectedVin: 'LTEST000000000001',
detailOpen: false,
viewport: { zoom: 13.25, bounds: '113.100000,23.000000,113.400000,23.300000' },
listOffset: 100,
listLimit: 100,
hasViewport: true
});
});
test('carries only an internal monitor return and preserves it through child query changes', () => {
const monitor = '/monitor?selectedVin=LTEST000000000001&detail=open&zoom=15&bounds=113.1%2C23%2C113.4%2C23.3';
const child = withMonitorReturn('/tracks?vin=LTEST000000000001', monitor);
const childParams = new URL(child, 'https://vehicle-platform.invalid').searchParams;
const monitorReturn = monitorReturnFromParams(childParams);
const changed = preserveMonitorReturn(new URLSearchParams({ keyword: 'LTEST000000000001', protocol: 'JT808' }), monitorReturn);
expect(monitorReturn).toContain('/monitor?');
expect(changed.get('monitorReturn')).toBe(monitorReturn);
expect(validatedMonitorReturn('https://attacker.invalid/monitor?selectedVin=SECRET')).toBe('');
expect(validatedMonitorReturn('/vehicles/LTEST')).toBe('');
});
test('rejects malformed viewport input instead of restoring unsafe map state', () => {
const context = parseMonitorRouteContext('?zoom=999&bounds=120,20,110,10&listOffset=-1&listLimit=500');
expect(context.viewport).toEqual({ zoom: 5, bounds: '' });
expect(context.listOffset).toBe(0);
expect(context.listLimit).toBe(50);
});

View File

@@ -0,0 +1,116 @@
import type { MonitorViewport } from '../hooks/useMonitorData';
export const MONITOR_RETURN_PARAM = 'monitorReturn';
export type MonitorRouteContext = {
mode: 'map' | 'list';
keyword: string;
protocol: string;
status: string;
selectedVin: string;
detailOpen: boolean;
viewport: MonitorViewport;
listOffset: number;
listLimit: number;
hasViewport: boolean;
};
const DEFAULT_CONTEXT: MonitorRouteContext = {
mode: 'map',
keyword: '',
protocol: '',
status: '',
selectedVin: '',
detailOpen: false,
viewport: { zoom: 5, bounds: '' },
listOffset: 0,
listLimit: 50,
hasViewport: false
};
function integer(value: string | null, fallback: number, minimum: number, maximum: number) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= minimum && parsed <= maximum ? parsed : fallback;
}
function decimal(value: string | null, fallback: number, minimum: number, maximum: number) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= minimum && parsed <= maximum ? parsed : fallback;
}
function validBounds(value: string | null) {
if (!value) return '';
const coordinates = value.split(',').map(Number);
if (coordinates.length !== 4 || coordinates.some((coordinate) => !Number.isFinite(coordinate))) return '';
const [west, south, east, north] = coordinates;
if (west < -180 || east > 180 || south < -90 || north > 90 || west >= east || south >= north) return '';
return coordinates.map((coordinate) => coordinate.toFixed(6)).join(',');
}
export function parseMonitorRouteContext(search: string | URLSearchParams): MonitorRouteContext {
const params = typeof search === 'string' ? new URLSearchParams(search) : search;
const selectedVin = params.get('selectedVin')?.trim().toUpperCase() ?? '';
const bounds = validBounds(params.get('bounds'));
const hasViewport = params.has('zoom') || Boolean(bounds);
return {
mode: params.get('mode') === 'list' ? 'list' : 'map',
keyword: params.get('keyword')?.trim() ?? '',
protocol: params.get('protocol')?.trim() ?? '',
status: params.get('status')?.trim() ?? '',
selectedVin,
detailOpen: Boolean(selectedVin) && params.get('detail') !== 'collapsed',
viewport: {
zoom: decimal(params.get('zoom'), DEFAULT_CONTEXT.viewport.zoom, 3, 20),
bounds
},
listOffset: integer(params.get('listOffset'), 0, 0, 1_000_000),
listLimit: [20, 50, 100].includes(Number(params.get('listLimit'))) ? Number(params.get('listLimit')) : 50,
hasViewport
};
}
export function buildMonitorPath(context: Omit<MonitorRouteContext, 'hasViewport'> | MonitorRouteContext) {
const params = new URLSearchParams();
if (context.mode !== 'map') params.set('mode', context.mode);
if (context.keyword.trim()) params.set('keyword', context.keyword.trim());
if (context.protocol) params.set('protocol', context.protocol);
if (context.status) params.set('status', context.status);
if (context.selectedVin) {
params.set('selectedVin', context.selectedVin);
params.set('detail', context.detailOpen ? 'open' : 'collapsed');
}
params.set('zoom', String(Math.round(context.viewport.zoom * 100) / 100));
if (validBounds(context.viewport.bounds)) params.set('bounds', validBounds(context.viewport.bounds));
if (context.listOffset) params.set('listOffset', String(context.listOffset));
if (context.listLimit !== 50) params.set('listLimit', String(context.listLimit));
const query = params.toString();
return query ? `/monitor?${query}` : '/monitor';
}
export function validatedMonitorReturn(value?: string | null) {
if (!value) return '';
try {
const parsed = new URL(value, 'https://vehicle-platform.invalid');
if (parsed.origin !== 'https://vehicle-platform.invalid' || parsed.pathname !== '/monitor') return '';
return buildMonitorPath(parseMonitorRouteContext(parsed.searchParams));
} catch {
return '';
}
}
export function withMonitorReturn(path: string, monitorPath: string) {
const target = new URL(path, 'https://vehicle-platform.invalid');
const validated = validatedMonitorReturn(monitorPath);
if (validated) target.searchParams.set(MONITOR_RETURN_PARAM, validated);
return `${target.pathname}${target.search}${target.hash}`;
}
export function monitorReturnFromParams(params: URLSearchParams) {
return validatedMonitorReturn(params.get(MONITOR_RETURN_PARAM));
}
export function preserveMonitorReturn(params: URLSearchParams, monitorReturn: string) {
if (monitorReturn) params.set(MONITOR_RETURN_PARAM, monitorReturn);
else params.delete(MONITOR_RETURN_PARAM);
return params;
}