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) => {