feat(platform): advance vehicle operations center
This commit is contained in:
@@ -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'}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user