feat(platform): advance vehicle operations center

This commit is contained in:
lingniu
2026-07-04 16:22:05 +08:00
parent 775073a95c
commit 33da8ba5a4
6 changed files with 262 additions and 18 deletions

View File

@@ -27,6 +27,7 @@ type AMapMap = {
type AMapOverlay = {
setMap?: (map: AMapMap | null) => void;
on?: (eventName: string, handler: () => void) => void;
};
let amapLoaderPromise: Promise<AMapLike> | 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<HTMLDivElement | null>(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: `<div class="vp-amap-marker ${point.online === false ? 'vp-amap-marker-offline' : 'vp-amap-marker-online'}">${mode === 'track' ? index + 1 : ''}</div>`
}));
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: `<div class="vp-amap-marker ${point.online === false ? 'vp-amap-marker-offline' : 'vp-amap-marker-online'} ${point.id === selectedId ? 'vp-amap-marker-selected' : ''}">${mode === 'track' ? index + 1 : ''}</div>`
});
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 ? (
<div className="vp-map-fallback">
{fallbackPoints.map((point, index) => (
<span
<button
key={`${point.id}-${index}`}
className={`vp-map-dot ${mode === 'track' && index === 0 ? 'vp-map-dot-start' : mode === 'track' && index === fallbackPoints.length - 1 ? 'vp-map-dot-end' : point.online === false ? 'vp-map-dot-offline' : 'vp-map-dot-online'}`}
type="button"
aria-label={`选择地图车辆 ${point.label}`}
className={`vp-map-dot ${mode === 'track' && index === 0 ? 'vp-map-dot-start' : mode === 'track' && index === fallbackPoints.length - 1 ? 'vp-map-dot-end' : point.online === false ? 'vp-map-dot-offline' : 'vp-map-dot-online'} ${point.id === selectedId ? 'vp-map-dot-selected' : ''}`}
title={point.title || point.label}
style={pointToPixelStyle(point, index)}
onClick={() => onPointSelect?.(point)}
/>
))}
<Tag className="vp-map-fallback-status" color={isAMapConfigured(config) ? 'blue' : 'orange'}>

View File

@@ -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<Record<string, string>>(initialFilters);
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 50, total: 0 });
const [opsHealth, setOpsHealth] = useState<OpsHealth | null>(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({
<VehicleMap
points={mapPoints}
maxFallbackPoints={80}
selectedId={selectedMapRow ? realtimeMapPointId(selectedMapRow, rows.indexOf(selectedMapRow)) : selectedMapPointId}
onPointSelect={(point) => setSelectedMapPointId(point.id)}
fallbackLabel="高德地图未配置,显示实时坐标预览"
/>
</div>
@@ -400,6 +409,31 @@ export function Realtime({
<Typography.Text type="secondary">
Web JS Key Key
</Typography.Text>
{selectedMapRow ? (
<div className="vp-selected-vehicle-panel">
<div className="vp-map-service-queue-title"></div>
<Space wrap>
<Tag color={vehicleServiceStatus(selectedMapRow).color}>{vehicleServiceStatus(selectedMapRow).label}</Tag>
<Tag color={selectedMapRow.online ? 'green' : 'orange'}>{selectedMapRow.online ? '在线' : '离线'}</Tag>
<Tag color="blue">{selectedMapRow.primaryProtocol || '未知来源'}</Tag>
</Space>
<Typography.Title heading={6} style={{ margin: '8px 0 0' }}>
{selectedMapRow.plate || selectedMapRow.vin}
</Typography.Title>
<Typography.Text type="tertiary">{selectedMapRow.vin}</Typography.Text>
<div className="vp-selected-vehicle-grid">
<span></span><strong>{selectedMapRow.speedKmh ?? '-'} km/h</strong>
<span>SOC</span><strong>{selectedMapRow.socPercent ?? '-'}%</strong>
<span></span><strong>{selectedMapRow.totalMileageKm ?? '-'} km</strong>
<span></span><strong>{selectedMapRow.lastSeen || '-'}</strong>
</div>
<Space spacing={6} wrap>
<Button size="small" onClick={() => onOpenVehicle(selectedMapRow.vin, filters.protocol || selectedMapRow.primaryProtocol)}></Button>
<Button size="small" onClick={() => onOpenHistory?.(selectedMapRow.vin, filters.protocol || selectedMapRow.primaryProtocol)}></Button>
<Button size="small" disabled={!isValidCoordinate(selectedMapRow)} onClick={() => window.open(amapMarkerURL(selectedMapRow), '_blank', 'noopener,noreferrer')}></Button>
</Space>
</div>
) : null}
<div className="vp-map-integration-panel">
<div className="vp-map-service-queue-title"></div>
{mapIntegrationRows.map((item) => (
@@ -457,7 +491,19 @@ export function Realtime({
mapServiceRows.map((row) => {
const status = vehicleServiceStatus(row);
return (
<div key={`${row.vin}-${row.primaryProtocol}`} className="vp-map-service-item">
<div
key={`${row.vin}-${row.primaryProtocol}`}
role="button"
tabIndex={0}
className={`vp-map-service-item vp-map-service-item-button ${selectedMapRow?.vin === row.vin ? 'vp-map-service-item-selected' : ''}`}
onClick={() => setSelectedMapPointId(realtimeMapPointId(row, rows.indexOf(row)))}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setSelectedMapPointId(realtimeMapPointId(row, rows.indexOf(row)));
}
}}
>
<div className="vp-map-service-item-main">
<div>
<Typography.Text strong>{row.plate || row.vin}</Typography.Text>

View File

@@ -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));

View File

@@ -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(<App />);
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) => {

View File

@@ -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.

View File

@@ -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