diff --git a/vehicle-data-platform/apps/web/src/App.tsx b/vehicle-data-platform/apps/web/src/App.tsx index cc0fd648..cbc50726 100644 --- a/vehicle-data-platform/apps/web/src/App.tsx +++ b/vehicle-data-platform/apps/web/src/App.tsx @@ -250,6 +250,12 @@ export default function App() { replaceQualityHash(filters); }; + const openRealtime = (filters: Record = {}) => { + setRealtimeFilters(filters); + setActivePage('realtime'); + replaceRealtimeHash(filters); + }; + const openVehicle = async (keyword: string, protocol?: string) => { const lookupKey = keyword.trim(); const nextProtocol = protocol?.trim() ?? ''; @@ -339,7 +345,7 @@ export default function App() { }; const pages: Record = { - dashboard: openQuality(qualityFilters)} onOpenVehicles={openVehicles} />, + dashboard: , vehicles: , realtime: , detail: , diff --git a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx index 1d15f5aa..1760e3f1 100644 --- a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx @@ -6,6 +6,8 @@ import type { DashboardSummary, LinkHealth, ProtocolStat, QualityIssueRow, Servi import { PageHeader } from '../components/PageHeader'; import { SourceStatusTags } from '../components/SourceStatusTags'; import { StatusTag } from '../components/StatusTag'; +import { VehicleMap, type VehicleMapPoint } from '../components/VehicleMap'; +import { isAMapConfigured } from '../config/appConfig'; import { qualityIssueVehicleLookup } from '../domain/vehicleLookup'; const statusColor: Record = { @@ -80,6 +82,10 @@ function sourceEvidenceText(row: { onlineSourceCount: number; sourceCount: numbe return `${row.onlineSourceCount}/${row.sourceCount} 来源在线`; } +function hasValidCoordinate(row: VehicleRealtimeRow) { + return Number.isFinite(row.longitude) && Number.isFinite(row.latitude) && row.longitude !== 0 && row.latitude !== 0; +} + function sourceConsistencyAction(row: VehicleCoverageRow, onFilter: (filters: Record) => void) { const consistency = row.sourceConsistency; if (!consistency) { @@ -102,7 +108,17 @@ function sourceConsistencyAction(row: VehicleCoverageRow, onFilter: (filters: Re return {label}; } -export function Dashboard({ onOpenVehicle, onOpenQuality, onOpenVehicles }: { onOpenVehicle: (vin: string, protocol?: string) => void; onOpenQuality: () => void; onOpenVehicles: (filters?: Record) => void }) { +export function Dashboard({ + onOpenVehicle, + onOpenQuality, + onOpenRealtime, + onOpenVehicles +}: { + onOpenVehicle: (vin: string, protocol?: string) => void; + onOpenQuality: (filters?: Record) => void; + onOpenRealtime: (filters?: Record) => void; + onOpenVehicles: (filters?: Record) => void; +}) { const [summary, setSummary] = useState(null); const [serviceSummary, setServiceSummary] = useState(null); const [coverage, setCoverage] = useState([]); @@ -112,6 +128,7 @@ export function Dashboard({ onOpenVehicle, onOpenQuality, onOpenVehicles }: { on const [coverageLoading, setCoverageLoading] = useState(false); const [coverageServiceStatusTitle, setCoverageServiceStatusTitle] = useState(''); const [coverageFilters, setCoverageFilters] = useState>({}); + const amapConfigured = isAMapConfigured(); const loadCoverage = (values?: Record) => { const nextValues = values ?? {}; @@ -210,6 +227,18 @@ export function Dashboard({ onOpenVehicle, onOpenQuality, onOpenVehicles }: { on } return items; }, [serviceSummary]); + const commandOnlineCount = locations.filter((row) => row.online).length; + const commandLocatedCount = locations.filter(hasValidCoordinate).length; + const commandDegradedCount = locations.filter((row) => row.onlineSourceCount <= 0 || row.onlineSourceCount < row.sourceCount).length; + const highPriorityIssue = qualityIssues.find((item) => item.severity === 'error') ?? qualityIssues[0]; + const commandMapPoints: VehicleMapPoint[] = locations.map((row, index) => ({ + id: row.vin || `${row.primaryProtocol || 'source'}-${index}`, + label: row.plate || row.vin || 'unknown', + longitude: row.longitude, + latitude: row.latitude, + online: row.online, + title: `${row.plate || row.vin || '-'} ${row.primaryProtocol || ''} ${row.lastSeen || ''}` + })); return (
@@ -234,10 +263,59 @@ export function Dashboard({ onOpenVehicle, onOpenQuality, onOpenVehicles }: { on 0 ? 'orange' : 'green'}> {formatCount(summary?.issueVehicles)} 质量关注 - + 0 ? 'orange' : 'green'}>Kafka Lag {formatLag(summary?.kafkaLag)} + +
+
+
+ + + {amapConfigured ? '高德地图配置就绪' : '高德地图待配置'} + + 在线 {commandOnlineCount.toLocaleString()} / {locations.length.toLocaleString()} + 有效定位 {commandLocatedCount.toLocaleString()} + 0 ? 'orange' : 'green'}>降级/离线 {commandDegradedCount.toLocaleString()} + +
+ +
+
+
+ 实时作业 +
{commandOnlineCount.toLocaleString()}
+ 当前预览车辆在线数,进入实时监控可按车辆、来源和服务状态筛选。 +
+
+ 0 ? 'orange' : 'green'}>处置优先级 +
{commandDegradedCount.toLocaleString()}
+ 优先处理离线、无来源、身份未绑定和来源不完整车辆。 +
+ + + + + +
+
+
{serviceActionQueue.length > 0 ? (
@@ -339,7 +417,7 @@ export function Dashboard({ onOpenVehicle, onOpenQuality, onOpenVehicles }: { on Kafka 当前消费积压:{formatLag(summary?.kafkaLag)} 质量问题预览} + title={质量问题预览} bordered style={{ marginTop: 16 }} > 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 e63f4e50..1fc10fac 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -211,6 +211,146 @@ test('dashboard presents one vehicle service operating posture', async () => { expect(screen.getByText('7 质量关注')).toBeInTheDocument(); }); +test('dashboard exposes vehicle command center map actions', async () => { + window.history.replaceState(null, '', '/#/dashboard'); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const path = String(input); + if (path.includes('/api/ops/health')) { + return { + ok: true, + json: async () => ({ + data: { linkHealth: [], kafkaLag: 0, activeConnections: 0, capacityFindings: [], redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/dashboard/summary')) { + return { + ok: true, + json: async () => ({ + data: { + onlineVehicles: 3, + activeToday: 4, + frameToday: 1286320, + issueVehicles: 7, + kafkaLag: 0, + protocols: [], + serviceStatuses: [], + linkHealth: [] + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/vehicle-service/summary')) { + return { + ok: true, + json: async () => ({ + data: { + totalVehicles: 1033, + boundVehicles: 1024, + onlineVehicles: 208, + singleSourceVehicles: 391, + multiSourceVehicles: 181, + noDataVehicles: 461, + identityRequiredVehicles: 9, + serviceStatuses: [], + protocols: [], + missingSources: [] + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/realtime/vehicles')) { + return { + ok: true, + json: async () => ({ + data: { + items: [ + { + vin: 'VIN-COMMAND-001', + plate: '粤A指挥1', + protocols: ['GB32960', 'JT808', 'YUTONG_MQTT'], + primaryProtocol: 'GB32960', + sourceCount: 3, + onlineSourceCount: 2, + online: true, + longitude: 113.2, + latitude: 23.1, + speedKmh: 31, + socPercent: 78, + totalMileageKm: 100.5, + lastSeen: '2026-07-03 20:12:10' + }, + { + vin: 'VIN-COMMAND-002', + plate: '粤A指挥2', + protocols: ['JT808'], + primaryProtocol: 'JT808', + sourceCount: 3, + onlineSourceCount: 0, + online: false, + longitude: 0, + latitude: 0, + lastSeen: '2026-07-03 19:55:10' + } + ], + total: 2, + limit: 8, + offset: 0 + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/quality/issues')) { + return { + ok: true, + json: async () => ({ + data: { + items: [{ vin: 'VIN-COMMAND-002', plate: '粤A指挥2', protocol: 'VEHICLE_SERVICE', issueType: 'NO_SOURCE', severity: 'error', detail: '无来源', lastSeen: '2026-07-03 19:55:10' }], + total: 1, + limit: 5, + offset: 0 + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + return { + ok: true, + json: async () => ({ + data: { items: [], total: 0, limit: 8, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + }); + + render(); + + expect(await screen.findByText('车辆态势指挥台')).toBeInTheDocument(); + expect(screen.getByText('在线 1 / 2')).toBeInTheDocument(); + expect(screen.getByText('有效定位 1')).toBeInTheDocument(); + expect(screen.getByText('降级/离线 2')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '处理高优先级告警' })); + expect(window.location.hash).toBe('#/quality?issueType=NO_SOURCE'); + + cleanup(); + window.history.replaceState(null, '', '/#/dashboard'); + render(); + + expect(await screen.findByText('车辆态势指挥台')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '查看实时态势' })); + expect(window.location.hash).toBe('#/realtime?online=online'); +}); + test('dashboard shows vehicle service action queue', async () => { window.history.replaceState(null, '', '/#/dashboard'); vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {