From be5a0cf92c3d5404eddaaf50f94a23b5ed457afe Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 10:38:10 +0800 Subject: [PATCH] feat(platform): add trajectory playback workspace --- .../apps/web/src/pages/History.tsx | 90 ++++++++++++++++++- .../apps/web/src/styles/global.css | 53 ++++++++++- .../apps/web/src/test/App.test.tsx | 62 ++++++++++++- 3 files changed, 200 insertions(+), 5 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/pages/History.tsx b/vehicle-data-platform/apps/web/src/pages/History.tsx index 83747b4b..922c07e5 100644 --- a/vehicle-data-platform/apps/web/src/pages/History.tsx +++ b/vehicle-data-platform/apps/web/src/pages/History.tsx @@ -43,6 +43,28 @@ function isIncludeFieldsEnabled(value?: boolean | string) { return value === true || value === 'true'; } +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function hasValidCoordinate(row: HistoryLocationRow) { + return isFiniteNumber(row.longitude) && isFiniteNumber(row.latitude) && row.longitude !== 0 && row.latitude !== 0; +} + +function mapPointStyle(row: HistoryLocationRow, index: number) { + if (!hasValidCoordinate(row)) { + return { left: `${18 + (index % 4) * 18}%`, top: `${28 + (index % 3) * 17}%` }; + } + const left = Math.min(88, Math.max(10, ((row.longitude + 180) / 360) * 100)); + const top = Math.min(82, Math.max(12, ((90 - row.latitude) / 180) * 100)); + return { left: `${left}%`, top: `${top}%` }; +} + +function formatNumber(value?: number, suffix = '') { + if (!isFiniteNumber(value)) return '-'; + return `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}${suffix}`; +} + function mergeInitialFilters(initialVin: string, initialProtocol?: string, initialFilters: Record = {}): HistoryFilters { return { ...defaultFilters, @@ -166,6 +188,13 @@ export function History({ }; const rawFieldCount = useMemo(() => Object.keys(selectedRaw?.parsedFields ?? {}).length, [selectedRaw]); + const locationItems = locations.items; + const validLocations = locationItems.filter(hasValidCoordinate); + const mileageValues = locationItems.map((item) => item.totalMileageKm).filter(isFiniteNumber); + const mileageDelta = mileageValues.length > 1 ? Math.max(...mileageValues) - Math.min(...mileageValues) : undefined; + 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 currentVehicleKeyword = filters.keyword?.trim() ?? ''; const currentProtocol = filters.protocol?.trim() ?? ''; const selectedFieldCount = splitFields(filters.fields).length; @@ -184,8 +213,8 @@ export function History({ return (
onOpenVehicle(currentVehicleKeyword, currentProtocol)}> 当前车辆服务 @@ -233,6 +262,63 @@ export function History({ ) : null} + +
+
+
+ + {validLocations.length.toLocaleString()} 个有效轨迹点 + {currentProtocol || '全部来源'} + {formatNumber(mileageDelta, ' km')} + +
+
+ {validLocations.slice(0, 120).map((row, index) => ( + + ))} +
+
+
+ {[ + { label: '轨迹点', value: locations.total.toLocaleString(), color: 'blue' as const }, + { label: '有效定位', value: validLocations.length.toLocaleString(), color: 'green' as const }, + { label: '区间里程', value: formatNumber(mileageDelta, ' km'), color: 'green' as const }, + { label: '最高速度', value: formatNumber(maxSpeed, ' km/h'), color: 'orange' as const } + ].map((item) => ( +
+ {item.label} +
{item.value}
+
+ ))} +
+
+
+ {(validLocations.length > 0 ? validLocations : locationItems).slice(0, 8).map((row, index) => ( +
+ + {index === 0 ? '起点' : index === Math.min((validLocations.length || locationItems.length), 8) - 1 ? '终点' : `点 ${index + 1}`} + +
{row.deviceTime || row.serverTime || '-'}
+ + {formatNumber(row.speedKmh, ' km/h')} / {formatNumber(row.totalMileageKm, ' km')} + +
+ ))} + {locationItems.length === 0 ? ( + 当前筛选范围暂无轨迹点,请调整车辆、来源或时间范围。 + ) : null} +
+ + 起点:{firstLocation?.deviceTime || firstLocation?.serverTime || '-'} + 终点:{lastLocation?.deviceTime || lastLocation?.serverTime || '-'} + 地图接入:高德 JS API 运行配置预留 + +
changeTab(String(key))}> diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css index 2bbc5a28..148f4903 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -297,6 +297,20 @@ body { box-shadow: 0 0 0 5px rgba(247, 144, 9, 0.16); } +.vp-map-dot-start { + width: 12px; + height: 12px; + background: var(--vp-success); + box-shadow: 0 0 0 6px rgba(18, 183, 106, 0.18); +} + +.vp-map-dot-end { + width: 12px; + height: 12px; + background: var(--vp-warning); + box-shadow: 0 0 0 6px rgba(247, 144, 9, 0.18); +} + .vp-monitor-side { display: grid; gap: 12px; @@ -336,6 +350,41 @@ body { align-content: start; } +.vp-playback-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 260px; + gap: 16px; +} + +.vp-playback-map { + min-height: 430px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + overflow: hidden; + background: var(--vp-surface); +} + +.vp-playback-side { + display: grid; + gap: 12px; + align-content: start; +} + +.vp-playback-timeline { + margin-top: 16px; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.vp-playback-step { + min-height: 112px; + padding: 12px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + background: #fbfcff; +} + .vp-json { margin: 0; padding: 16px; @@ -472,7 +521,9 @@ body { .vp-action-grid, .vp-conclusion-grid, .vp-monitor-layout, - .vp-alert-flow { + .vp-alert-flow, + .vp-playback-layout, + .vp-playback-timeline { grid-template-columns: 1fr; } } 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 e170a24b..93c9a594 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -2878,9 +2878,9 @@ test('updates history hash when vehicle history filters are submitted', async () render(); - await screen.findByRole('heading', { name: '历史数据' }); + await screen.findByRole('heading', { name: '轨迹回放' }); fireEvent.change(screen.getByPlaceholderText('VIN / 车牌 / 手机号'), { target: { value: '粤AG18312' } }); - fireEvent.click(screen.getByText('全部来源')); + fireEvent.click(screen.getAllByText('全部来源')[0]); fireEvent.click(await screen.findByText('JT808')); fireEvent.change(screen.getByPlaceholderText('2026-07-03 00:00:00'), { target: { value: '2026-07-01 00:00:00' } }); fireEvent.change(screen.getByPlaceholderText('2026-07-03 23:59:59'), { target: { value: '2026-07-01 23:59:59' } }); @@ -2897,6 +2897,64 @@ test('updates history hash when vehicle history filters are submitted', async () })); }); +test('shows trajectory playback workspace from history locations', async () => { + window.history.replaceState(null, '', '/#/history?keyword=VIN-TRACK-001&protocol=JT808'); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const path = String(input); + if (path.includes('/api/ops/health')) { + return { + ok: true, + json: async () => ({ + data: { linkHealth: [], kafkaLag: 0, redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/history/locations')) { + return { + ok: true, + json: async () => ({ + data: { + items: [ + { vin: 'VIN-TRACK-001', plate: '粤A轨迹1', 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-001', plate: '粤A轨迹1', 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.findByRole('heading', { name: '轨迹回放' })).toBeInTheDocument(); + expect(screen.getByText('轨迹回放作业台')).toBeInTheDocument(); + expect(screen.getByText('2 个有效轨迹点')).toBeInTheDocument(); + expect(screen.getByText('区间里程')).toBeInTheDocument(); + expect(screen.getAllByText('12.4 km').length).toBeGreaterThan(0); + expect(screen.getByText('最高速度')).toBeInTheDocument(); + expect(screen.getByText('42 km/h')).toBeInTheDocument(); + expect(screen.getByText('起点')).toBeInTheDocument(); + expect(screen.getByText('终点')).toBeInTheDocument(); +}); + test('shows vehicle and source scope on mileage hash', async () => { window.history.replaceState(null, '', '/#/mileage?keyword=VIN-MILEAGE-001&protocol=GB32960'); vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {