From 33da8ba5a41257a78aba2108702cab98540a5850 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 16:22:05 +0800 Subject: [PATCH] feat(platform): advance vehicle operations center --- .../apps/web/src/components/VehicleMap.tsx | 30 ++++-- .../apps/web/src/pages/Realtime.tsx | 50 +++++++++- .../apps/web/src/styles/global.css | 68 +++++++++++++ .../apps/web/src/test/App.test.tsx | 97 ++++++++++++++++++- vehicle-data-platform/docs/deployment.md | 4 +- .../docs/vehicle-platform-blueprint.md | 31 +++++- 6 files changed, 262 insertions(+), 18 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/components/VehicleMap.tsx b/vehicle-data-platform/apps/web/src/components/VehicleMap.tsx index 223b8f2b..3ff414ba 100644 --- a/vehicle-data-platform/apps/web/src/components/VehicleMap.tsx +++ b/vehicle-data-platform/apps/web/src/components/VehicleMap.tsx @@ -27,6 +27,7 @@ type AMapMap = { type AMapOverlay = { setMap?: (map: AMapMap | null) => void; + on?: (eventName: string, handler: () => void) => void; }; let amapLoaderPromise: Promise | null = null; @@ -93,6 +94,8 @@ export function VehicleMap({ heightClassName = 'vp-map-monitor', fallbackLabel, maxFallbackPoints = 120, + selectedId, + onPointSelect, children }: { points: VehicleMapPoint[]; @@ -100,6 +103,8 @@ export function VehicleMap({ heightClassName?: string; fallbackLabel?: string; maxFallbackPoints?: number; + selectedId?: string; + onPointSelect?: (point: VehicleMapPoint) => void; children?: ReactNode; }) { const containerRef = useRef(null); @@ -129,12 +134,16 @@ export function VehicleMap({ mapRef.current.addControl(new AMap.Scale()); } overlaysRef.current.forEach((overlay) => overlay.setMap?.(null)); - const markers = validPoints.slice(0, 500).map((point, index) => new AMap.Marker({ - position: [point.longitude, point.latitude], - title: point.title || point.label, - zIndex: mode === 'track' ? 100 + index : 100, - content: `
${mode === 'track' ? index + 1 : ''}
` - })); + const markers = validPoints.slice(0, 500).map((point, index) => { + const marker = new AMap.Marker({ + position: [point.longitude, point.latitude], + title: point.title || point.label, + zIndex: point.id === selectedId ? 300 : mode === 'track' ? 100 + index : 100, + content: `
${mode === 'track' ? index + 1 : ''}
` + }); + marker.on?.('click', () => onPointSelect?.(point)); + return marker; + }); const nextOverlays: AMapOverlay[] = [...markers]; if (mode === 'track' && validPoints.length > 1) { nextOverlays.push(new AMap.Polyline({ @@ -156,7 +165,7 @@ export function VehicleMap({ return () => { cancelled = true; }; - }, [config.webJsKey, config.securityJsCode, config.securityServiceHost, mode, validPoints]); + }, [config.webJsKey, config.securityJsCode, config.securityServiceHost, mode, onPointSelect, selectedId, validPoints]); useEffect(() => () => { mapRef.current?.destroy(); @@ -173,11 +182,14 @@ export function VehicleMap({ {showFallback ? (
{fallbackPoints.map((point, index) => ( - onPointSelect?.(point)} /> ))} diff --git a/vehicle-data-platform/apps/web/src/pages/Realtime.tsx b/vehicle-data-platform/apps/web/src/pages/Realtime.tsx index c862d86c..9f308b32 100644 --- a/vehicle-data-platform/apps/web/src/pages/Realtime.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Realtime.tsx @@ -67,6 +67,10 @@ function isValidCoordinate(row: VehicleRealtimeRow) { return Number.isFinite(row.longitude) && Number.isFinite(row.latitude) && row.longitude !== 0 && row.latitude !== 0; } +function realtimeMapPointId(row: VehicleRealtimeRow, index = 0) { + return row.vin?.trim() || `${row.primaryProtocol || 'source'}-${index}`; +} + function amapMarkerURL(row: VehicleRealtimeRow) { const name = encodeURIComponent(row.plate || row.vin || '车辆位置'); return `https://uri.amap.com/marker?position=${row.longitude},${row.latitude}&name=${name}&src=lingniu-vehicle-platform`; @@ -191,6 +195,7 @@ export function Realtime({ const [filters, setFilters] = useState>(initialFilters); const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 50, total: 0 }); const [opsHealth, setOpsHealth] = useState(null); + const [selectedMapPointId, setSelectedMapPointId] = useState(''); const amapConfig = getAMapConfig(); const runtime = opsHealth?.runtime; const amapConfigured = runtime?.amapWebJsConfigured ?? isAMapConfigured(amapConfig); @@ -267,8 +272,10 @@ export function Realtime({ return String(b.lastSeen ?? '').localeCompare(String(a.lastSeen ?? '')); }) .slice(0, 5); + const defaultMapRow = mapServiceRows[0] ?? rows.find((row) => isValidCoordinate(row) && canOpenVehicle(row.vin)); + const selectedMapRow = rows.find((row, index) => realtimeMapPointId(row, index) === selectedMapPointId) ?? defaultMapRow; const mapPoints: VehicleMapPoint[] = rows.map((row, index) => ({ - id: row.vin || `${row.primaryProtocol}-${index}`, + id: realtimeMapPointId(row, index), label: row.plate || row.vin || 'unknown', longitude: row.longitude, latitude: row.latitude, @@ -381,6 +388,8 @@ export function Realtime({ setSelectedMapPointId(point.id)} fallbackLabel="高德地图未配置,显示实时坐标预览" />
@@ -400,6 +409,31 @@ export function Realtime({ 高德 Web JS Key 和安全密钥通过运行环境配置,前端只读取公开 Key;安全密钥后续走服务端代理,避免明文下发。 + {selectedMapRow ? ( +
+
当前选中车辆
+ + {vehicleServiceStatus(selectedMapRow).label} + {selectedMapRow.online ? '在线' : '离线'} + {selectedMapRow.primaryProtocol || '未知来源'} + + + {selectedMapRow.plate || selectedMapRow.vin} + + {selectedMapRow.vin} +
+ 速度{selectedMapRow.speedKmh ?? '-'} km/h + SOC{selectedMapRow.socPercent ?? '-'}% + 里程{selectedMapRow.totalMileageKm ?? '-'} km + 最后时间{selectedMapRow.lastSeen || '-'} +
+ + + + + +
+ ) : null}
地图接入状态
{mapIntegrationRows.map((item) => ( @@ -457,7 +491,19 @@ export function Realtime({ mapServiceRows.map((row) => { const status = vehicleServiceStatus(row); return ( -
+
setSelectedMapPointId(realtimeMapPointId(row, rows.indexOf(row)))} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setSelectedMapPointId(realtimeMapPointId(row, rows.indexOf(row))); + } + }} + >
{row.plate || row.vin} diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css index 99506395..0b2eead5 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -296,6 +296,13 @@ body { background: var(--vp-warning); } +.vp-amap-marker-selected { + border-color: #fff; + box-shadow: + 0 0 0 4px rgba(22, 100, 255, 0.28), + 0 10px 22px rgba(16, 24, 40, 0.24); +} + .vp-monitor-layout { display: grid; grid-template-columns: minmax(0, 1fr) 260px; @@ -326,9 +333,12 @@ body { position: absolute; width: 10px; height: 10px; + padding: 0; + border: 0; border-radius: 50%; background: var(--vp-primary); box-shadow: 0 0 0 5px rgba(22, 100, 255, 0.16); + cursor: pointer; } .vp-map-dot-online { @@ -355,6 +365,20 @@ body { box-shadow: 0 0 0 6px rgba(247, 144, 9, 0.18); } +.vp-map-dot-selected { + width: 16px; + height: 16px; + border: 3px solid #fff; + box-shadow: + 0 0 0 7px rgba(22, 100, 255, 0.2), + 0 10px 22px rgba(16, 24, 40, 0.2); +} + +.vp-map-dot:focus-visible { + outline: 2px solid var(--vp-primary); + outline-offset: 6px; +} + .vp-monitor-side { display: grid; gap: 12px; @@ -437,6 +461,24 @@ body { background: #fff; } +.vp-map-service-item-button { + width: 100%; + color: inherit; + text-align: left; + cursor: pointer; +} + +.vp-map-service-item-button:focus-visible { + outline: 2px solid rgba(22, 100, 255, 0.45); + outline-offset: 2px; +} + +.vp-map-service-item-selected { + border-color: rgba(22, 100, 255, 0.62); + background: #f5f9ff; + box-shadow: 0 0 0 3px rgba(22, 100, 255, 0.08); +} + .vp-map-service-item-main, .vp-map-service-item-footer { display: flex; @@ -450,6 +492,32 @@ body { min-width: 0; } +.vp-selected-vehicle-panel { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid rgba(22, 100, 255, 0.28); + border-radius: var(--vp-radius); + background: #f5f9ff; +} + +.vp-selected-vehicle-grid { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 6px 10px; + font-size: 12px; +} + +.vp-selected-vehicle-grid span { + color: var(--vp-text-muted); +} + +.vp-selected-vehicle-grid strong { + color: var(--vp-text); + font-weight: 600; + text-align: right; +} + .vp-alert-flow { display: grid; grid-template-columns: repeat(3, minmax(0, 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 18f2dd33..f7badd4e 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, expect, test, vi } from 'vitest'; import App from '../App'; @@ -7614,6 +7614,101 @@ test('shows production AMap integration status on realtime page', async () => { expect(screen.getByText('1 / 2')).toBeInTheDocument(); }); +test('selects realtime map vehicle from fallback map point', async () => { + window.history.replaceState(null, '', '/#/realtime'); + 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, redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/realtime/vehicles')) { + return { + ok: true, + json: async () => ({ + data: { + items: [ + { + vin: 'VIN-MAP-SELECT-1', + plate: '粤A选中1', + phone: '', + oem: 'G7s', + protocols: ['GB32960'], + sourceStatus: [{ protocol: 'GB32960', online: true, lastSeen: '2026-07-03 20:12:10', hasRealtime: true, hasHistory: true, hasRaw: true, hasMileage: true }], + sourceCount: 1, + onlineSourceCount: 1, + online: true, + primaryProtocol: 'GB32960', + longitude: 113.2, + latitude: 23.1, + speedKmh: 30, + socPercent: 78, + totalMileageKm: 119925, + lastSeen: '2026-07-03 20:12:10', + bindingStatus: 'bound', + serviceStatus: { status: 'healthy', severity: 'ok', title: '服务正常', detail: '1/1 来源在线', sourceCount: 1, onlineSourceCount: 1 } + }, + { + vin: 'VIN-MAP-SELECT-2', + plate: '粤A选中2', + phone: '', + oem: 'G7s', + protocols: ['JT808'], + sourceStatus: [{ protocol: 'JT808', online: true, lastSeen: '2026-07-03 20:11:10', hasRealtime: true, hasHistory: true, hasRaw: true, hasMileage: true }], + sourceCount: 1, + onlineSourceCount: 1, + online: true, + primaryProtocol: 'JT808', + longitude: 113.4, + latitude: 23.2, + speedKmh: 42, + socPercent: 66, + totalMileageKm: 2200, + lastSeen: '2026-07-03 20:11:10', + bindingStatus: 'bound', + serviceStatus: { status: 'healthy', severity: 'ok', title: '服务正常', detail: '1/1 来源在线', sourceCount: 1, onlineSourceCount: 1 } + } + ], + total: 2, + limit: 50, + offset: 0 + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + return { + ok: true, + json: async () => ({ + data: { items: [], total: 0, limit: 10, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + }); + + render(); + + const selectedPanelTitle = await screen.findByText('当前选中车辆'); + const selectedPanel = selectedPanelTitle.closest('.vp-selected-vehicle-panel'); + expect(selectedPanel).not.toBeNull(); + expect(within(selectedPanel as HTMLElement).getByText('粤A选中1')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: '选择地图车辆 粤A选中2' })); + + await waitFor(() => { + expect(within(selectedPanel as HTMLElement).getByText('粤A选中2')).toBeInTheDocument(); + }); + expect(within(selectedPanel as HTMLElement).getByText('42 km/h')).toBeInTheDocument(); +}); + test('opens vehicle service from realtime map service queue', async () => { window.history.replaceState(null, '', '/#/realtime'); vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { diff --git a/vehicle-data-platform/docs/deployment.md b/vehicle-data-platform/docs/deployment.md index 853fdee3..760de5e6 100644 --- a/vehicle-data-platform/docs/deployment.md +++ b/vehicle-data-platform/docs/deployment.md @@ -39,10 +39,12 @@ REQUEST_TIMEOUT_MS=5000 AMAP_WEB_JS_KEY=*** AMAP_SECURITY_JS_CODE=*** AMAP_SECURITY_SERVICE_HOST=/_AMapService +# Optional reserved server-side key for AMap REST/track services. +AMAP_API_SERVICE_KEY=*** PLATFORM_RELEASE=platform-YYYYMMDDHHMMSS ``` -`AMAP_WEB_JS_KEY` is served to the browser through `/app-config.js` so the same static build can be reused across environments. For production, set both `AMAP_SECURITY_JS_CODE` and `AMAP_SECURITY_SERVICE_HOST=/_AMapService`; the API service keeps the security code on the server and proxies AMap requests with `jscode` appended. Only omit `AMAP_SECURITY_SERVICE_HOST` for controlled debugging where exposing `AMAP_SECURITY_JS_CODE` to the browser is acceptable. +`AMAP_WEB_JS_KEY` is served to the browser through `/app-config.js` so the same static build can be reused across environments. For production, set both `AMAP_SECURITY_JS_CODE` and `AMAP_SECURITY_SERVICE_HOST=/_AMapService`; the API service keeps the security code on the server and proxies AMap requests with `jscode` appended. Only omit `AMAP_SECURITY_SERVICE_HOST` for controlled debugging where exposing `AMAP_SECURITY_JS_CODE` to the browser is acceptable. `AMAP_API_SERVICE_KEY` is reserved for backend-only AMap service APIs such as geocoding, route planning, geofence, or trajectory service integration. `PLATFORM_RELEASE` is surfaced by `/api/ops/health.runtime.platformRelease` so operators can confirm which ECS release is currently active after a deployment. diff --git a/vehicle-data-platform/docs/vehicle-platform-blueprint.md b/vehicle-data-platform/docs/vehicle-platform-blueprint.md index c527e48e..43304a6e 100644 --- a/vehicle-data-platform/docs/vehicle-platform-blueprint.md +++ b/vehicle-data-platform/docs/vehicle-platform-blueprint.md @@ -2,17 +2,18 @@ ## Goal -Build one vehicle service platform. GB32960, JT808, and Yutong MQTT are source evidence for the same vehicle, not separate applications. The platform should help operations users answer five questions quickly: +Build one vehicle data management platform around a single vehicle service. GB32960, JT808, and Yutong MQTT are source evidence for the same vehicle, not separate applications. The platform should help operations users answer six questions quickly: 1. Is this vehicle online now? 2. Where is it and what is its latest state? 3. Can I replay its trajectory and inspect evidence? 4. Is its data reliable across sources? 5. Which alert or data-breakage event needs action? +6. Which operational statistic or data gap needs follow-up? ## Market Reference -Common telematics and fleet-management platforms converge on a small set of durable workflows: +Common telematics, fleet-management, and vehicle IoT platforms converge on a small set of durable workflows: - Live map: current vehicle distribution, online state, abnormal state, geofence or area context. - Trip replay: historical polyline, playback marker, speed/time panel, raw evidence drill-down. @@ -23,9 +24,10 @@ Common telematics and fleet-management platforms converge on a small set of dura Relevant references: - TDengine vehicle IoT scenario: online monitoring, trajectory replay, and high-volume time-series storage. -- AMap JavaScript API 2.0: production WebGL map rendering and browser map controls. +- AMap Open Platform: Web JS map, geocoding, route planning, geofence, logistics, and trajectory services. - AMap trajectory replay demo: marker replay over historical points. -- Fleet platforms such as Verizon Connect and similar commercial systems: live maps, geofences, route replay, alerts, and reports. +- Commercial fleet platforms: live GPS tracking, historical route playback, geofence, driver behavior, alerts, reports, and asset management. +- Domestic vehicle-monitoring platforms: monitoring cockpit, vehicle monitor, trip replay, realtime alerts, running statistics, and alert management. ## Information Architecture @@ -40,6 +42,7 @@ The left navigation should be vehicle-service oriented: 7. Alert Events 8. Statistics 9. Ops Quality +10. Notification Rules The global search stays persistent in the top bar. It accepts VIN, plate, or JT808 phone and resolves to one vehicle context. The selected vehicle context should travel across all pages. @@ -69,7 +72,7 @@ Purpose: near-realtime vehicle monitoring. Primary modules: -- Full-width AMap panel with clustered vehicle points. +- Full-width AMap panel with clustered vehicle points and selected-vehicle context. - Right-side vehicle queue: online, offline, degraded, abnormal, no location. - Bottom table: latest position, speed, mileage, source freshness, service status. - Map overlays: online state, protocol source, alert severity. @@ -78,6 +81,8 @@ AMap behavior: - Use runtime `AMAP_WEB_JS_KEY`. - Use server-side security proxy through `AMAP_SECURITY_SERVICE_HOST`. +- Keep `AMAP_SECURITY_JS_CODE` on the API service. Do not hardcode it into frontend bundles. +- Keep the AMap service/API key for future server-side calls such as geocoding, route planning, geofence, and trajectory services. - Fall back to coordinate preview when AMap cannot load. - Support marker click, fit to filtered vehicles, selected vehicle highlight, and external AMap coordinate open. @@ -162,6 +167,21 @@ Notification phases: - Phase 2: email/webhook/enterprise chat. - Phase 3: escalation for unresolved data breakage. +### Notification Rules + +Purpose: make alert triggering and notification strategy configurable. + +Primary modules: + +- Rule list: offline timeout, source no-update, location missing, mileage jump, parse failure, binding missing, gateway stopped. +- Rule editor: protocol/source scope, vehicle/OEM scope, threshold, silence window, severity, notification target. +- Recipient groups: operations, data platform owner, affected BI users, external platform contacts. +- Notification log: sent, suppressed, failed, acknowledged, escalated. + +Rule: + +- Data-breakage notifications should be evidence-first: every notification links to affected vehicles, last good data time, source endpoint, and current recovery status. + ### Statistics Purpose: business and data-quality statistics. @@ -251,6 +271,7 @@ Protocol-specific data appears as source evidence fields in these responses. New - Add alert records and list/detail UI. - Add rule labels and evidence links. - Add manual status and notification plan preview. +- Add notification rule configuration and send-log preview. ### Phase 4: Statistics And Data Reliability