From d133b187ad9320c5199aab2364822e6eb74e5ad0 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 15:20:23 +0800 Subject: [PATCH] feat(platform): show trajectory evidence quality --- .../apps/web/src/pages/History.tsx | 52 +++++++++++++++++++ .../apps/web/src/test/App.test.tsx | 46 ++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/vehicle-data-platform/apps/web/src/pages/History.tsx b/vehicle-data-platform/apps/web/src/pages/History.tsx index e32016cc..72a9ed0a 100644 --- a/vehicle-data-platform/apps/web/src/pages/History.tsx +++ b/vehicle-data-platform/apps/web/src/pages/History.tsx @@ -84,6 +84,23 @@ function formatNumber(value?: number, suffix = '') { return `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}${suffix}`; } +function formatPercent(value?: number) { + if (!isFiniteNumber(value)) return '-'; + return `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}%`; +} + +function timeValue(row?: HistoryLocationRow) { + const value = row?.deviceTime || row?.serverTime || row?.lastSeen; + const ms = Date.parse(String(value ?? '').replace(' ', 'T')); + return Number.isFinite(ms) ? ms : undefined; +} + +function formatDurationMinutes(value?: number, suffix = '') { + if (!isFiniteNumber(value)) return '-'; + if (value < 1) return `${Math.round(value * 60)} 秒${suffix}`; + return `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })} 分钟${suffix}`; +} + function mergeInitialFilters(initialVin: string, initialProtocol?: string, initialFilters: Record = {}): HistoryFilters { const hasExplicitFilters = Object.keys(initialFilters).length > 0; return { @@ -332,6 +349,11 @@ export function History({ const maxSpeed = locationItems.map((item) => item.speedKmh).filter(isFiniteNumber).reduce((max, item) => max == null ? item : Math.max(max, item), undefined); const firstLocation = locationItems[0]; const lastLocation = locationItems[locationItems.length - 1]; + const trajectoryCoverageRate = locationItems.length > 0 ? (validLocations.length / locationItems.length) * 100 : undefined; + const playbackStartMs = timeValue(firstLocation); + const playbackEndMs = timeValue(lastLocation); + const playbackSpanMinutes = playbackStartMs != null && playbackEndMs != null ? Math.abs(playbackEndMs - playbackStartMs) / 60000 : undefined; + const playbackIntervalMinutes = isFiniteNumber(playbackSpanMinutes) && locationItems.length > 1 ? playbackSpanMinutes / (locationItems.length - 1) : undefined; const currentVehicleKeyword = filters.keyword?.trim() ?? ''; const currentProtocol = filters.protocol?.trim() ?? ''; const amapConfigured = isAMapConfigured(); @@ -566,6 +588,36 @@ export function History({ 当前筛选范围暂无轨迹点,请调整车辆、来源或时间范围。 ) : null} + +
+ {[ + { + label: '定位覆盖率', + value: formatPercent(trajectoryCoverageRate), + color: (trajectoryCoverageRate ?? 0) >= 80 ? 'green' as const : 'orange' as const, + detail: `${validLocations.length.toLocaleString()} / ${locationItems.length.toLocaleString()} 个点有有效坐标。` + }, + { + label: '回放跨度', + value: formatDurationMinutes(playbackSpanMinutes), + color: isFiniteNumber(playbackSpanMinutes) ? 'blue' as const : 'grey' as const, + detail: '当前页首末轨迹点设备时间跨度。' + }, + { + label: '采样间隔', + value: formatDurationMinutes(playbackIntervalMinutes, '/点'), + color: isFiniteNumber(playbackIntervalMinutes) && playbackIntervalMinutes <= 10 ? 'green' as const : 'orange' as const, + detail: '当前页估算的平均轨迹采样间隔。' + } + ].map((item) => ( + + {item.label} +
{item.value}
+ {item.detail} +
+ ))} +
+
起点:{firstLocation?.deviceTime || firstLocation?.serverTime || '-'} 终点:{lastLocation?.deviceTime || lastLocation?.serverTime || '-'} diff --git a/vehicle-data-platform/apps/web/src/test/App.test.tsx b/vehicle-data-platform/apps/web/src/test/App.test.tsx index 3acd4038..adb9120f 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -5267,6 +5267,52 @@ test('shows trajectory playback workspace from history locations', async () => { expect(screen.getByText('终点')).toBeInTheDocument(); }); +test('shows trajectory evidence quality on history playback workspace', async () => { + window.history.replaceState(null, '', '/#/history?keyword=VIN-TRACK-QUALITY&protocol=JT808'); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const path = String(input); + if (path.includes('/api/history/locations')) { + return { + ok: true, + json: async () => ({ + data: { + items: [ + { vin: 'VIN-TRACK-QUALITY', plate: '粤A轨迹质量', protocol: 'JT808', longitude: 113.2, latitude: 23.1, speedKmh: 10, socPercent: 0, totalMileageKm: 100, lastSeen: '2026-07-03 10:00:00', deviceTime: '2026-07-03 10:00:00', serverTime: '2026-07-03 10:00:01' }, + { vin: 'VIN-TRACK-QUALITY', plate: '粤A轨迹质量', protocol: 'JT808', longitude: 113.3, latitude: 23.2, speedKmh: 42, socPercent: 0, totalMileageKm: 112.4, lastSeen: '2026-07-03 10:10:00', deviceTime: '2026-07-03 10:10:00', serverTime: '2026-07-03 10:10:01' } + ], + total: 2, + limit: 10, + offset: 0 + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/history/raw-frames/query')) { + expect(init?.method).toBe('POST'); + } + return { + ok: true, + json: async () => ({ + data: { items: [], total: 0, limit: 10, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + }); + + render(); + + expect(await screen.findByText('轨迹证据质量')).toBeInTheDocument(); + expect(screen.getByText('定位覆盖率')).toBeInTheDocument(); + expect(screen.getByText('100%')).toBeInTheDocument(); + expect(screen.getByText('回放跨度')).toBeInTheDocument(); + expect(screen.getByText('10 分钟')).toBeInTheDocument(); + expect(screen.getByText('采样间隔')).toBeInTheDocument(); + expect(screen.getByText('10 分钟/点')).toBeInTheDocument(); +}); + test('opens amap route from trajectory playback workspace', async () => { window.history.replaceState(null, '', '/#/history?keyword=VIN-TRACK-AMAP&protocol=JT808'); const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);