diff --git a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx index 6f84563e..93a066b6 100644 --- a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx +++ b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx @@ -2,7 +2,7 @@ import { Banner, Button, Card, Descriptions, Form, Select, Space, Table, Tabs, T import { IconCopy, IconRefresh, IconSearch } from '@douyinfe/semi-icons'; import { useEffect, useMemo, useRef, useState } from 'react'; import { api } from '../api/client'; -import type { QualityIssueRow, VehicleDetail as VehicleDetailData, VehicleSourceStatus } from '../api/types'; +import type { QualityIssueRow, RealtimeLocationRow, VehicleDetail as VehicleDetailData, VehicleSourceStatus } from '../api/types'; import { PageHeader } from '../components/PageHeader'; import { SourceStatusTags } from '../components/SourceStatusTags'; import { StatusTag } from '../components/StatusTag'; @@ -36,6 +36,11 @@ function formatCompactNumber(value?: number, suffix = '') { return `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}${suffix}`; } +function formatLocation(row?: RealtimeLocationRow) { + if (!row || !isFiniteNumber(row.longitude) || !isFiniteNumber(row.latitude)) return '-'; + return `${row.longitude}, ${row.latitude}`; +} + function parseTimeMs(value?: string) { if (!value) return undefined; const ms = Date.parse(value); @@ -152,6 +157,14 @@ export function VehicleDetail({ const lastSeen = resolution?.lastSeen || summary?.lastSeen || latest?.lastSeen || '-'; const serviceStatus = detail?.serviceStatus; const realtimeRows = detail?.realtime ?? []; + const latestRealtimeByProtocol = useMemo(() => { + const next = new Map(); + for (const row of realtimeRows) { + if (!row.protocol || next.has(row.protocol)) continue; + next.set(row.protocol, row); + } + return next; + }, [realtimeRows]); const realtimeSources = new Set(realtimeRows.map((row) => row.protocol).filter(Boolean)); const sourceCount = Math.max(detail?.sourceStatus?.length ?? 0, realtimeSources.size, summary?.sourceCount ?? 0, protocols.length); const onlineSourceCount = detail?.sourceStatus?.length @@ -488,6 +501,26 @@ export function VehicleDetail({ }, { title: '在线', width: 100, render: (_: unknown, row: VehicleSourceStatus) => }, { title: '最后时间', dataIndex: 'lastSeen', width: 190, render: (value?: string) => value || '无上报' }, + { + title: '最新位置', + width: 170, + render: (_: unknown, row: VehicleSourceStatus) => formatLocation(latestRealtimeByProtocol.get(row.protocol)) + }, + { + title: '里程', + width: 110, + render: (_: unknown, row: VehicleSourceStatus) => formatCompactNumber(latestRealtimeByProtocol.get(row.protocol)?.totalMileageKm, ' km') + }, + { + title: '速度', + width: 110, + render: (_: unknown, row: VehicleSourceStatus) => formatCompactNumber(latestRealtimeByProtocol.get(row.protocol)?.speedKmh, ' km/h') + }, + { + title: 'SOC', + width: 90, + render: (_: unknown, row: VehicleSourceStatus) => formatCompactNumber(latestRealtimeByProtocol.get(row.protocol)?.socPercent, '%') + }, ...sourceCapabilities.map((item) => ({ title: item.label, width: 90, 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 54af4b7e..12adae1e 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -4337,7 +4337,30 @@ test('switches vehicle detail to a source from the source matrix', async () => { { 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: '', hasRealtime: false, hasHistory: false, hasRaw: false, hasMileage: false } ], - realtime: [], + realtime: [ + { + vin: 'VIN001', + plate: '粤AG18312', + protocol: 'GB32960', + longitude: 113.2, + latitude: 23.1, + speedKmh: 30, + socPercent: 78, + totalMileageKm: 100, + lastSeen: '2026-07-03 20:12:10' + }, + { + vin: 'VIN001', + plate: '粤AG18312', + protocol: 'JT808', + longitude: 113.21, + latitude: 23.11, + speedKmh: 28, + socPercent: 0, + totalMileageKm: 99.5, + lastSeen: '2026-07-03 20:12:08' + } + ], history: { items: [], total: 0, limit: 20, offset: 0 }, raw: { items: [], total: 0, limit: 10, offset: 0 }, mileage: { items: [], total: 0, limit: 20, offset: 0 }, @@ -4368,6 +4391,10 @@ test('switches vehicle detail to a source from the source matrix', async () => { expect(screen.getByText('主来源')).toBeInTheDocument(); expect(screen.getByText('在线证据')).toBeInTheDocument(); 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('YUTONG_MQTT').length).toBeGreaterThan(0); expect(screen.getAllByText('无上报').length).toBeGreaterThan(0); fireEvent.click(screen.getByRole('button', { name: '仅看 JT808' }));