feat(platform): advance vehicle operations center
This commit is contained in:
@@ -27,6 +27,7 @@ type AMapMap = {
|
|||||||
|
|
||||||
type AMapOverlay = {
|
type AMapOverlay = {
|
||||||
setMap?: (map: AMapMap | null) => void;
|
setMap?: (map: AMapMap | null) => void;
|
||||||
|
on?: (eventName: string, handler: () => void) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let amapLoaderPromise: Promise<AMapLike> | null = null;
|
let amapLoaderPromise: Promise<AMapLike> | null = null;
|
||||||
@@ -93,6 +94,8 @@ export function VehicleMap({
|
|||||||
heightClassName = 'vp-map-monitor',
|
heightClassName = 'vp-map-monitor',
|
||||||
fallbackLabel,
|
fallbackLabel,
|
||||||
maxFallbackPoints = 120,
|
maxFallbackPoints = 120,
|
||||||
|
selectedId,
|
||||||
|
onPointSelect,
|
||||||
children
|
children
|
||||||
}: {
|
}: {
|
||||||
points: VehicleMapPoint[];
|
points: VehicleMapPoint[];
|
||||||
@@ -100,6 +103,8 @@ export function VehicleMap({
|
|||||||
heightClassName?: string;
|
heightClassName?: string;
|
||||||
fallbackLabel?: string;
|
fallbackLabel?: string;
|
||||||
maxFallbackPoints?: number;
|
maxFallbackPoints?: number;
|
||||||
|
selectedId?: string;
|
||||||
|
onPointSelect?: (point: VehicleMapPoint) => void;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -129,12 +134,16 @@ export function VehicleMap({
|
|||||||
mapRef.current.addControl(new AMap.Scale());
|
mapRef.current.addControl(new AMap.Scale());
|
||||||
}
|
}
|
||||||
overlaysRef.current.forEach((overlay) => overlay.setMap?.(null));
|
overlaysRef.current.forEach((overlay) => overlay.setMap?.(null));
|
||||||
const markers = validPoints.slice(0, 500).map((point, index) => new AMap.Marker({
|
const markers = validPoints.slice(0, 500).map((point, index) => {
|
||||||
|
const marker = new AMap.Marker({
|
||||||
position: [point.longitude, point.latitude],
|
position: [point.longitude, point.latitude],
|
||||||
title: point.title || point.label,
|
title: point.title || point.label,
|
||||||
zIndex: mode === 'track' ? 100 + index : 100,
|
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'}">${mode === 'track' ? index + 1 : ''}</div>`
|
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];
|
const nextOverlays: AMapOverlay[] = [...markers];
|
||||||
if (mode === 'track' && validPoints.length > 1) {
|
if (mode === 'track' && validPoints.length > 1) {
|
||||||
nextOverlays.push(new AMap.Polyline({
|
nextOverlays.push(new AMap.Polyline({
|
||||||
@@ -156,7 +165,7 @@ export function VehicleMap({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [config.webJsKey, config.securityJsCode, config.securityServiceHost, mode, validPoints]);
|
}, [config.webJsKey, config.securityJsCode, config.securityServiceHost, mode, onPointSelect, selectedId, validPoints]);
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
mapRef.current?.destroy();
|
mapRef.current?.destroy();
|
||||||
@@ -173,11 +182,14 @@ export function VehicleMap({
|
|||||||
{showFallback ? (
|
{showFallback ? (
|
||||||
<div className="vp-map-fallback">
|
<div className="vp-map-fallback">
|
||||||
{fallbackPoints.map((point, index) => (
|
{fallbackPoints.map((point, index) => (
|
||||||
<span
|
<button
|
||||||
key={`${point.id}-${index}`}
|
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}
|
title={point.title || point.label}
|
||||||
style={pointToPixelStyle(point, index)}
|
style={pointToPixelStyle(point, index)}
|
||||||
|
onClick={() => onPointSelect?.(point)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<Tag className="vp-map-fallback-status" color={isAMapConfigured(config) ? 'blue' : 'orange'}>
|
<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;
|
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) {
|
function amapMarkerURL(row: VehicleRealtimeRow) {
|
||||||
const name = encodeURIComponent(row.plate || row.vin || '车辆位置');
|
const name = encodeURIComponent(row.plate || row.vin || '车辆位置');
|
||||||
return `https://uri.amap.com/marker?position=${row.longitude},${row.latitude}&name=${name}&src=lingniu-vehicle-platform`;
|
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 [filters, setFilters] = useState<Record<string, string>>(initialFilters);
|
||||||
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 50, total: 0 });
|
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 50, total: 0 });
|
||||||
const [opsHealth, setOpsHealth] = useState<OpsHealth | null>(null);
|
const [opsHealth, setOpsHealth] = useState<OpsHealth | null>(null);
|
||||||
|
const [selectedMapPointId, setSelectedMapPointId] = useState('');
|
||||||
const amapConfig = getAMapConfig();
|
const amapConfig = getAMapConfig();
|
||||||
const runtime = opsHealth?.runtime;
|
const runtime = opsHealth?.runtime;
|
||||||
const amapConfigured = runtime?.amapWebJsConfigured ?? isAMapConfigured(amapConfig);
|
const amapConfigured = runtime?.amapWebJsConfigured ?? isAMapConfigured(amapConfig);
|
||||||
@@ -267,8 +272,10 @@ export function Realtime({
|
|||||||
return String(b.lastSeen ?? '').localeCompare(String(a.lastSeen ?? ''));
|
return String(b.lastSeen ?? '').localeCompare(String(a.lastSeen ?? ''));
|
||||||
})
|
})
|
||||||
.slice(0, 5);
|
.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) => ({
|
const mapPoints: VehicleMapPoint[] = rows.map((row, index) => ({
|
||||||
id: row.vin || `${row.primaryProtocol}-${index}`,
|
id: realtimeMapPointId(row, index),
|
||||||
label: row.plate || row.vin || 'unknown',
|
label: row.plate || row.vin || 'unknown',
|
||||||
longitude: row.longitude,
|
longitude: row.longitude,
|
||||||
latitude: row.latitude,
|
latitude: row.latitude,
|
||||||
@@ -381,6 +388,8 @@ export function Realtime({
|
|||||||
<VehicleMap
|
<VehicleMap
|
||||||
points={mapPoints}
|
points={mapPoints}
|
||||||
maxFallbackPoints={80}
|
maxFallbackPoints={80}
|
||||||
|
selectedId={selectedMapRow ? realtimeMapPointId(selectedMapRow, rows.indexOf(selectedMapRow)) : selectedMapPointId}
|
||||||
|
onPointSelect={(point) => setSelectedMapPointId(point.id)}
|
||||||
fallbackLabel="高德地图未配置,显示实时坐标预览"
|
fallbackLabel="高德地图未配置,显示实时坐标预览"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -400,6 +409,31 @@ export function Realtime({
|
|||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
高德 Web JS Key 和安全密钥通过运行环境配置,前端只读取公开 Key;安全密钥后续走服务端代理,避免明文下发。
|
高德 Web JS Key 和安全密钥通过运行环境配置,前端只读取公开 Key;安全密钥后续走服务端代理,避免明文下发。
|
||||||
</Typography.Text>
|
</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-integration-panel">
|
||||||
<div className="vp-map-service-queue-title">地图接入状态</div>
|
<div className="vp-map-service-queue-title">地图接入状态</div>
|
||||||
{mapIntegrationRows.map((item) => (
|
{mapIntegrationRows.map((item) => (
|
||||||
@@ -457,7 +491,19 @@ export function Realtime({
|
|||||||
mapServiceRows.map((row) => {
|
mapServiceRows.map((row) => {
|
||||||
const status = vehicleServiceStatus(row);
|
const status = vehicleServiceStatus(row);
|
||||||
return (
|
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 className="vp-map-service-item-main">
|
||||||
<div>
|
<div>
|
||||||
<Typography.Text strong>{row.plate || row.vin}</Typography.Text>
|
<Typography.Text strong>{row.plate || row.vin}</Typography.Text>
|
||||||
|
|||||||
@@ -296,6 +296,13 @@ body {
|
|||||||
background: var(--vp-warning);
|
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 {
|
.vp-monitor-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 260px;
|
grid-template-columns: minmax(0, 1fr) 260px;
|
||||||
@@ -326,9 +333,12 @@ body {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--vp-primary);
|
background: var(--vp-primary);
|
||||||
box-shadow: 0 0 0 5px rgba(22, 100, 255, 0.16);
|
box-shadow: 0 0 0 5px rgba(22, 100, 255, 0.16);
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vp-map-dot-online {
|
.vp-map-dot-online {
|
||||||
@@ -355,6 +365,20 @@ body {
|
|||||||
box-shadow: 0 0 0 6px rgba(247, 144, 9, 0.18);
|
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 {
|
.vp-monitor-side {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -437,6 +461,24 @@ body {
|
|||||||
background: #fff;
|
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-main,
|
||||||
.vp-map-service-item-footer {
|
.vp-map-service-item-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -450,6 +492,32 @@ body {
|
|||||||
min-width: 0;
|
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 {
|
.vp-alert-flow {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
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 { afterEach, expect, test, vi } from 'vitest';
|
||||||
import App from '../App';
|
import App from '../App';
|
||||||
|
|
||||||
@@ -7614,6 +7614,101 @@ test('shows production AMap integration status on realtime page', async () => {
|
|||||||
expect(screen.getByText('1 / 2')).toBeInTheDocument();
|
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 () => {
|
test('opens vehicle service from realtime map service queue', async () => {
|
||||||
window.history.replaceState(null, '', '/#/realtime');
|
window.history.replaceState(null, '', '/#/realtime');
|
||||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
|
|||||||
@@ -39,10 +39,12 @@ REQUEST_TIMEOUT_MS=5000
|
|||||||
AMAP_WEB_JS_KEY=***
|
AMAP_WEB_JS_KEY=***
|
||||||
AMAP_SECURITY_JS_CODE=***
|
AMAP_SECURITY_JS_CODE=***
|
||||||
AMAP_SECURITY_SERVICE_HOST=/_AMapService
|
AMAP_SECURITY_SERVICE_HOST=/_AMapService
|
||||||
|
# Optional reserved server-side key for AMap REST/track services.
|
||||||
|
AMAP_API_SERVICE_KEY=***
|
||||||
PLATFORM_RELEASE=platform-YYYYMMDDHHMMSS
|
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.
|
`PLATFORM_RELEASE` is surfaced by `/api/ops/health.runtime.platformRelease` so operators can confirm which ECS release is currently active after a deployment.
|
||||||
|
|
||||||
|
|||||||
@@ -2,17 +2,18 @@
|
|||||||
|
|
||||||
## Goal
|
## 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?
|
1. Is this vehicle online now?
|
||||||
2. Where is it and what is its latest state?
|
2. Where is it and what is its latest state?
|
||||||
3. Can I replay its trajectory and inspect evidence?
|
3. Can I replay its trajectory and inspect evidence?
|
||||||
4. Is its data reliable across sources?
|
4. Is its data reliable across sources?
|
||||||
5. Which alert or data-breakage event needs action?
|
5. Which alert or data-breakage event needs action?
|
||||||
|
6. Which operational statistic or data gap needs follow-up?
|
||||||
|
|
||||||
## Market Reference
|
## 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.
|
- 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.
|
- 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:
|
Relevant references:
|
||||||
|
|
||||||
- TDengine vehicle IoT scenario: online monitoring, trajectory replay, and high-volume time-series storage.
|
- 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.
|
- 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
|
## Information Architecture
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ The left navigation should be vehicle-service oriented:
|
|||||||
7. Alert Events
|
7. Alert Events
|
||||||
8. Statistics
|
8. Statistics
|
||||||
9. Ops Quality
|
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.
|
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:
|
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.
|
- Right-side vehicle queue: online, offline, degraded, abnormal, no location.
|
||||||
- Bottom table: latest position, speed, mileage, source freshness, service status.
|
- Bottom table: latest position, speed, mileage, source freshness, service status.
|
||||||
- Map overlays: online state, protocol source, alert severity.
|
- Map overlays: online state, protocol source, alert severity.
|
||||||
@@ -78,6 +81,8 @@ AMap behavior:
|
|||||||
|
|
||||||
- Use runtime `AMAP_WEB_JS_KEY`.
|
- Use runtime `AMAP_WEB_JS_KEY`.
|
||||||
- Use server-side security proxy through `AMAP_SECURITY_SERVICE_HOST`.
|
- 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.
|
- Fall back to coordinate preview when AMap cannot load.
|
||||||
- Support marker click, fit to filtered vehicles, selected vehicle highlight, and external AMap coordinate open.
|
- 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 2: email/webhook/enterprise chat.
|
||||||
- Phase 3: escalation for unresolved data breakage.
|
- 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
|
### Statistics
|
||||||
|
|
||||||
Purpose: business and data-quality 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 alert records and list/detail UI.
|
||||||
- Add rule labels and evidence links.
|
- Add rule labels and evidence links.
|
||||||
- Add manual status and notification plan preview.
|
- Add manual status and notification plan preview.
|
||||||
|
- Add notification rule configuration and send-log preview.
|
||||||
|
|
||||||
### Phase 4: Statistics And Data Reliability
|
### Phase 4: Statistics And Data Reliability
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user