diff --git a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx index 256c62b6..9c6a1609 100644 --- a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx +++ b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx @@ -6,6 +6,7 @@ import type { QualityIssueRow, RealtimeLocationRow, VehicleDetail as VehicleDeta import { PageHeader } from '../components/PageHeader'; import { SourceStatusTags } from '../components/SourceStatusTags'; import { StatusTag } from '../components/StatusTag'; +import { VehicleMap, type VehicleMapPoint } from '../components/VehicleMap'; import { buildAppHash } from '../domain/appRoute'; import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport'; import { isLikelyVIN } from '../domain/vehicleLookup'; @@ -182,6 +183,21 @@ export function VehicleDetail({ const lastSeen = resolution?.lastSeen || summary?.lastSeen || latest?.lastSeen || '-'; const serviceStatus = detail?.serviceStatus; const realtimeRows = detail?.realtime ?? []; + const locatedRealtimeRows = useMemo(() => realtimeRows.filter((row) => ( + isFiniteNumber(row.longitude) && + isFiniteNumber(row.latitude) && + row.longitude !== 0 && + row.latitude !== 0 + )), [realtimeRows]); + const sourceOnlineByProtocol = useMemo(() => new Map((detail?.sourceStatus ?? []).map((source) => [source.protocol, source.online])), [detail?.sourceStatus]); + const vehicleMapPoints = useMemo(() => locatedRealtimeRows.map((row) => ({ + id: `${row.protocol || 'source'}-${row.lastSeen || row.vin}`, + label: row.protocol || row.vin, + longitude: row.longitude, + latitude: row.latitude, + online: sourceOnlineByProtocol.get(row.protocol) ?? true, + title: `${row.protocol || '来源'} / ${formatCompactNumber(row.speedKmh, ' km/h')}` + })), [locatedRealtimeRows, sourceOnlineByProtocol]); const latestRealtimeByProtocol = useMemo(() => { const next = new Map(); for (const row of realtimeRows) { @@ -526,6 +542,41 @@ export function VehicleDetail({ ) : null} + {hasResolvedVIN && vehicleMapPoints.length > 0 ? ( + +
+ +
+ + {locatedRealtimeRows.length.toLocaleString()} 个来源有最新位置 + {scopeText} + + {locatedRealtimeRows.map((row) => ( +
+
+ {row.protocol} + + {row.protocol} / {formatCompactNumber(row.speedKmh, ' km/h')} + +
+ + {row.longitude}, {row.latitude} + + + {formatCompactNumber(row.totalMileageKm, ' km')} / {row.lastSeen || '-'} + +
+ ))} +
+
+
+ ) : null} +
{serviceActions.map((item) => ( diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css index 938ed138..c873b2a2 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -699,6 +699,41 @@ body { min-width: 0; } +.vp-vehicle-map-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 280px; + gap: 16px; +} + +.vp-vehicle-detail-map { + height: 300px; +} + +.vp-vehicle-map-side { + display: flex; + min-width: 0; + flex-direction: column; + gap: 10px; +} + +.vp-vehicle-map-source { + display: flex; + min-height: 84px; + flex-direction: column; + gap: 6px; + padding: 12px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + background: #fbfcff; +} + +.vp-vehicle-map-source-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + .vp-source-toolbar { margin-bottom: 12px; } @@ -747,6 +782,7 @@ body { .vp-stat-workspace, .vp-stat-insight-grid, .vp-stat-definition-grid, + .vp-vehicle-map-layout, .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 b8ab30ba..d590e6c3 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -7952,8 +7952,8 @@ test('switches vehicle detail to a source from the source matrix', async () => { expect(screen.getByText('暂无来源')).toBeInTheDocument(); expect(screen.getByText('最新位置')).toBeInTheDocument(); expect(screen.getAllByText('113.2, 23.1').length).toBeGreaterThan(0); - expect(screen.getByText(/100\s*km/)).toBeInTheDocument(); - expect(screen.getByText(/30\s*km\/h/)).toBeInTheDocument(); + expect(screen.getAllByText(/100\s*km/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/30\s*km\/h/).length).toBeGreaterThan(0); expect(screen.getAllByText('YUTONG_MQTT').length).toBeGreaterThan(0); expect(screen.getAllByText('无上报').length).toBeGreaterThan(0); fireEvent.click(screen.getByRole('button', { name: '仅看 JT808' })); @@ -8268,6 +8268,84 @@ test('shows vehicle service evidence chain before source details', async () => { expect(screen.getAllByText('MQTT 离线,32960 与 808 仍可支撑车辆服务').length).toBeGreaterThan(0); }); +test('shows unified latest location posture on vehicle detail', async () => { + window.history.replaceState(null, '', '/#/detail?keyword=VIN-MAP-001'); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const path = String(input); + if (path.includes('/api/vehicle-service')) { + return { + ok: true, + json: async () => ({ + data: { + vin: 'VIN-MAP-001', + lookupKey: 'VIN-MAP-001', + lookupResolved: true, + serviceOverview: { + vin: 'VIN-MAP-001', + plate: '粤A地图1', + phone: '13307795425', + oem: 'G7s', + protocols: ['GB32960', 'JT808', 'YUTONG_MQTT'], + primaryProtocol: 'GB32960', + coverageStatus: 'partial', + sourceCount: 3, + onlineSourceCount: 2, + lastSeen: '2026-07-03 20:12:10', + realtimeCount: 3, + historyCount: 12, + rawCount: 34, + mileageCount: 7, + qualityIssueCount: 0 + }, + serviceStatus: { + status: 'degraded', + severity: 'warning', + title: '来源不完整', + detail: '2/3 个来源在线', + sourceCount: 3, + onlineSourceCount: 2 + }, + sources: ['GB32960', 'JT808', 'YUTONG_MQTT'], + sourceStatus: [ + { protocol: 'GB32960', online: true, lastSeen: '2026-07-03 20:12:10', hasRealtime: true, hasHistory: true, hasRaw: true, hasMileage: true }, + { protocol: 'JT808', online: true, lastSeen: '2026-07-03 20:12:08', hasRealtime: true, hasHistory: true, hasRaw: true, hasMileage: true }, + { protocol: 'YUTONG_MQTT', online: false, lastSeen: '2026-07-03 20:10:05', hasRealtime: false, hasHistory: false, hasRaw: true, hasMileage: false } + ], + realtime: [ + { vin: 'VIN-MAP-001', plate: '粤A地图1', protocol: 'GB32960', longitude: 113.2, latitude: 23.1, speedKmh: 36, socPercent: 76, totalMileageKm: 1200.5, lastSeen: '2026-07-03 20:12:10' }, + { vin: 'VIN-MAP-001', plate: '粤A地图1', protocol: 'JT808', longitude: 113.21, latitude: 23.11, speedKmh: 35, socPercent: 0, totalMileageKm: 1200.2, lastSeen: '2026-07-03 20:12:08' } + ], + history: { items: [], total: 12, limit: 20, offset: 0 }, + raw: { items: [], total: 34, limit: 10, offset: 0 }, + mileage: { items: [], total: 7, limit: 20, offset: 0 }, + quality: { items: [], total: 0, limit: 20, offset: 0 } + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + return { + ok: true, + json: async () => ({ + data: path.includes('/api/ops/health') + ? { linkHealth: [], kafkaLag: 0, redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } } + : { items: [], total: 0, limit: 10, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + }); + + render(); + + expect(await screen.findByText('车辆位置态势')).toBeInTheDocument(); + expect(screen.getByText('2 个来源有最新位置')).toBeInTheDocument(); + expect(screen.getByText('GB32960 / 36 km/h')).toBeInTheDocument(); + expect(screen.getByText('JT808 / 35 km/h')).toBeInTheDocument(); + expect(screen.getByText('高德地图未配置,显示车辆服务坐标预览')).toBeInTheDocument(); +}); + test('copies shareable vehicle service detail link', async () => { window.history.replaceState(null, '', '/#/detail?keyword=VIN001&protocol=JT808'); const writeText = vi.fn(() => Promise.resolve());