From ff04a5c93f3941b19249e9784fa98d10d91a2fbe Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 19:34:58 +0800 Subject: [PATCH] feat(platform): flag trajectory playback anomalies --- .../apps/web/src/pages/History.tsx | 85 +++++++++++++++++++ .../apps/web/src/styles/global.css | 30 +++++++ .../apps/web/src/test/App.test.tsx | 48 +++++++++++ 3 files changed, 163 insertions(+) diff --git a/vehicle-data-platform/apps/web/src/pages/History.tsx b/vehicle-data-platform/apps/web/src/pages/History.tsx index e9f3dc35..18fc1044 100644 --- a/vehicle-data-platform/apps/web/src/pages/History.tsx +++ b/vehicle-data-platform/apps/web/src/pages/History.tsx @@ -119,6 +119,53 @@ function formatDurationMinutes(value?: number, suffix = '') { return `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })} 分钟${suffix}`; } +type TrajectoryAnomalySummary = { + gapCount: number; + maxGapMinutes?: number; + mileageRollbackCount: number; + maxMileageRollbackKm?: number; + overspeedCount: number; + maxSpeedKmh?: number; +}; + +function analyzeTrajectoryAnomalies(rows: HistoryLocationRow[]): TrajectoryAnomalySummary { + const summary: TrajectoryAnomalySummary = { + gapCount: 0, + mileageRollbackCount: 0, + overspeedCount: 0 + }; + let previousTime: number | undefined; + let previousMileage: number | undefined; + for (const row of rows) { + const currentTime = timeValue(row); + if (currentTime != null && previousTime != null) { + const gapMinutes = Math.abs(currentTime - previousTime) / 60000; + if (gapMinutes > 30) { + summary.gapCount += 1; + summary.maxGapMinutes = summary.maxGapMinutes == null ? gapMinutes : Math.max(summary.maxGapMinutes, gapMinutes); + } + } + if (currentTime != null) { + previousTime = currentTime; + } + + if (isFiniteNumber(row.totalMileageKm) && previousMileage != null && row.totalMileageKm < previousMileage) { + const rollback = previousMileage - row.totalMileageKm; + summary.mileageRollbackCount += 1; + summary.maxMileageRollbackKm = summary.maxMileageRollbackKm == null ? rollback : Math.max(summary.maxMileageRollbackKm, rollback); + } + if (isFiniteNumber(row.totalMileageKm)) { + previousMileage = row.totalMileageKm; + } + + if (isFiniteNumber(row.speedKmh) && row.speedKmh > 120) { + summary.overspeedCount += 1; + summary.maxSpeedKmh = summary.maxSpeedKmh == null ? row.speedKmh : Math.max(summary.maxSpeedKmh, row.speedKmh); + } + } + return summary; +} + function mergeInitialFilters(initialVin: string, initialProtocol?: string, initialFilters: Record = {}): HistoryFilters { const hasExplicitFilters = Object.keys(initialFilters).length > 0; return { @@ -434,6 +481,8 @@ export function History({ 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 trajectoryAnomalies = useMemo(() => analyzeTrajectoryAnomalies(locationItems), [locationItems]); + const hasTrajectoryAnomaly = trajectoryAnomalies.gapCount > 0 || trajectoryAnomalies.mileageRollbackCount > 0 || trajectoryAnomalies.overspeedCount > 0; const currentVehicleKeyword = filters.keyword?.trim() ?? ''; const currentProtocol = filters.protocol?.trim() ?? ''; const pageTitle = mode === 'query' ? '历史查询' : '轨迹回放'; @@ -790,6 +839,42 @@ export function History({ ))} + +
+ {[ + { + label: '数据断点', + value: trajectoryAnomalies.gapCount > 0 ? `${trajectoryAnomalies.gapCount.toLocaleString()} 处` : '无', + detail: trajectoryAnomalies.gapCount > 0 ? `最大断点 ${formatDurationMinutes(trajectoryAnomalies.maxGapMinutes)}` : '相邻轨迹点未发现超过 30 分钟断点。', + color: trajectoryAnomalies.gapCount > 0 ? 'orange' as const : 'green' as const + }, + { + label: '里程回退', + value: trajectoryAnomalies.mileageRollbackCount > 0 ? formatNumber(trajectoryAnomalies.maxMileageRollbackKm, ' km') : '无', + detail: trajectoryAnomalies.mileageRollbackCount > 0 ? `${trajectoryAnomalies.mileageRollbackCount.toLocaleString()} 处总里程低于上一点。` : '当前页总里程单调不回退。', + color: trajectoryAnomalies.mileageRollbackCount > 0 ? 'red' as const : 'green' as const + }, + { + label: '速度异常', + value: trajectoryAnomalies.overspeedCount > 0 ? formatNumber(trajectoryAnomalies.maxSpeedKmh, ' km/h') : '无', + detail: trajectoryAnomalies.overspeedCount > 0 ? `${trajectoryAnomalies.overspeedCount.toLocaleString()} 个点超过 120 km/h。` : '当前页未发现超过 120 km/h 的速度点。', + color: trajectoryAnomalies.overspeedCount > 0 ? 'orange' as const : 'green' as const + } + ].map((item) => ( +
+ {item.label} +
{item.value}
+ {item.detail} +
+ ))} +
+
+ {hasTrajectoryAnomaly ? '建议核对 RAW 帧和当日里程统计' : '轨迹质量可用'} + + {hasTrajectoryAnomaly ? '异常点会影响轨迹回放、定位复盘和区间里程判断,请结合 RAW 解析字段与里程统计交叉确认。' : '当前页轨迹未发现明显断点、里程回退或速度异常。'} + +
+
起点:{firstLocation?.deviceTime || firstLocation?.serverTime || '-'} 终点:{lastLocation?.deviceTime || lastLocation?.serverTime || '-'} diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css index 5a2133b0..7317d6e8 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -866,6 +866,35 @@ button.vp-realtime-command-item:focus-visible { line-height: 20px; } +.vp-trajectory-anomaly-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.vp-trajectory-anomaly-item { + min-height: 112px; + padding: 12px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + background: #fbfcff; + display: grid; + gap: 8px; + align-content: start; +} + +.vp-trajectory-anomaly-action { + margin-top: 12px; + min-height: 44px; + padding: 10px 12px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + background: #f5f9ff; + display: flex; + align-items: center; + gap: 10px; +} + .vp-playback-layout { display: grid; grid-template-columns: minmax(0, 1fr) 260px; @@ -1233,6 +1262,7 @@ button.vp-realtime-command-item:focus-visible { .vp-stat-workspace, .vp-stat-insight-grid, .vp-stat-definition-grid, + .vp-trajectory-anomaly-grid, .vp-vehicle-map-layout, .vp-playback-layout, .vp-playback-timeline { 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 4be9f4da..76e27150 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -5737,6 +5737,54 @@ test('shows trajectory evidence quality on history playback workspace', async () expect(screen.getByText('10 分钟/点')).toBeInTheDocument(); }); +test('flags trajectory playback anomalies for gap mileage and speed review', async () => { + window.history.replaceState(null, '', '/#/history?keyword=VIN-TRACK-ANOMALY&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-ANOMALY', plate: '粤A轨迹异', protocol: 'JT808', longitude: 113.2, latitude: 23.1, speedKmh: 18, socPercent: 0, totalMileageKm: 300, lastSeen: '2026-07-03 10:00:00', deviceTime: '2026-07-03 10:00:00', serverTime: '2026-07-03 10:00:01' }, + { vin: 'VIN-TRACK-ANOMALY', plate: '粤A轨迹异', protocol: 'JT808', longitude: 113.3, latitude: 23.2, speedKmh: 132, socPercent: 0, totalMileageKm: 318, lastSeen: '2026-07-03 10:40:00', deviceTime: '2026-07-03 10:40:00', serverTime: '2026-07-03 10:40:01' }, + { vin: 'VIN-TRACK-ANOMALY', plate: '粤A轨迹异', protocol: 'JT808', longitude: 113.4, latitude: 23.3, speedKmh: 22, socPercent: 0, totalMileageKm: 316, lastSeen: '2026-07-03 10:45:00', deviceTime: '2026-07-03 10:45:00', serverTime: '2026-07-03 10:45:01' } + ], + total: 3, + 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('1 处')).toBeInTheDocument(); + expect(screen.getByText('里程回退')).toBeInTheDocument(); + expect(screen.getByText('2 km')).toBeInTheDocument(); + expect(screen.getByText('速度异常')).toBeInTheDocument(); + expect(screen.getAllByText('132 km/h').length).toBeGreaterThan(0); + expect(screen.getByText('建议核对 RAW 帧和当日里程统计')).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);