feat(platform): add realtime map service queue
This commit is contained in:
@@ -17,12 +17,12 @@ export function SourceStatusTags({
|
|||||||
lastSeen
|
lastSeen
|
||||||
}: {
|
}: {
|
||||||
sourceStatus?: VehicleSourceStatus[];
|
sourceStatus?: VehicleSourceStatus[];
|
||||||
protocols: string[];
|
protocols?: string[];
|
||||||
lastSeen: string;
|
lastSeen: string;
|
||||||
}) {
|
}) {
|
||||||
const sources = sourceStatus?.length
|
const sources = sourceStatus?.length
|
||||||
? sourceStatus
|
? sourceStatus
|
||||||
: protocols.map((protocol) => ({ protocol, online: false, hasRealtime: true, lastSeen }));
|
: (protocols ?? []).map((protocol) => ({ protocol, online: false, hasRealtime: true, lastSeen }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space spacing={4} wrap>
|
<Space spacing={4} wrap>
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ function sourceEvidenceText(row: VehicleRealtimeRow) {
|
|||||||
return `${row.onlineSourceCount}/${row.sourceCount} 来源在线`;
|
return `${row.onlineSourceCount}/${row.sourceCount} 来源在线`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serviceStatusWeight(row: VehicleRealtimeRow) {
|
||||||
|
const severity = row.serviceStatus?.severity;
|
||||||
|
if (severity === 'error') return 4;
|
||||||
|
if (severity === 'warning') return 3;
|
||||||
|
if (row.onlineSourceCount <= 0) return 4;
|
||||||
|
if (row.onlineSourceCount < row.sourceCount) return 3;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
function isValidCoordinate(row: VehicleRealtimeRow) {
|
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;
|
||||||
}
|
}
|
||||||
@@ -101,13 +110,21 @@ export function Realtime({
|
|||||||
const locatedCount = rows.filter(isValidCoordinate).length;
|
const locatedCount = rows.filter(isValidCoordinate).length;
|
||||||
const degradedCount = rows.filter((row) => row.onlineSourceCount < row.sourceCount).length;
|
const degradedCount = rows.filter((row) => row.onlineSourceCount < row.sourceCount).length;
|
||||||
const primaryProtocols = new Set(rows.map((row) => row.primaryProtocol).filter(Boolean));
|
const primaryProtocols = new Set(rows.map((row) => row.primaryProtocol).filter(Boolean));
|
||||||
|
const mapServiceRows = rows
|
||||||
|
.filter((row) => isValidCoordinate(row) && canOpenVehicle(row.vin))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const statusDelta = serviceStatusWeight(b) - serviceStatusWeight(a);
|
||||||
|
if (statusDelta !== 0) return statusDelta;
|
||||||
|
return String(b.lastSeen ?? '').localeCompare(String(a.lastSeen ?? ''));
|
||||||
|
})
|
||||||
|
.slice(0, 5);
|
||||||
const mapPoints: VehicleMapPoint[] = rows.map((row, index) => ({
|
const mapPoints: VehicleMapPoint[] = rows.map((row, index) => ({
|
||||||
id: row.vin || `${row.primaryProtocol}-${index}`,
|
id: row.vin || `${row.primaryProtocol}-${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,
|
||||||
online: row.online,
|
online: row.online,
|
||||||
title: `${row.plate || row.vin || '-'} ${row.primaryProtocol || ''} ${row.lastSeen || ''}`
|
title: `${row.plate || row.vin || '-'} ${vehicleServiceStatus(row).label} ${row.primaryProtocol || ''} ${row.lastSeen || ''}`
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -184,6 +201,34 @@ export function Realtime({
|
|||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
高德 Web JS Key 和安全密钥通过运行环境配置,前端只读取公开 Key;安全密钥后续走服务端代理,避免明文下发。
|
高德 Web JS Key 和安全密钥通过运行环境配置,前端只读取公开 Key;安全密钥后续走服务端代理,避免明文下发。
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
|
<div className="vp-map-service-queue">
|
||||||
|
<div className="vp-map-service-queue-title">地图车辆服务队列</div>
|
||||||
|
{mapServiceRows.length === 0 ? (
|
||||||
|
<Typography.Text type="tertiary">暂无可定位车辆</Typography.Text>
|
||||||
|
) : (
|
||||||
|
mapServiceRows.map((row) => {
|
||||||
|
const status = vehicleServiceStatus(row);
|
||||||
|
return (
|
||||||
|
<div key={`${row.vin}-${row.primaryProtocol}`} className="vp-map-service-item">
|
||||||
|
<div className="vp-map-service-item-main">
|
||||||
|
<div>
|
||||||
|
<Typography.Text strong>{row.plate || row.vin}</Typography.Text>
|
||||||
|
<Typography.Text type="tertiary" size="small">{row.vin}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Tag color={status.color}>{status.label}</Tag>
|
||||||
|
</div>
|
||||||
|
<Typography.Text type="secondary" size="small">
|
||||||
|
{row.serviceStatus?.detail || sourceEvidenceText(row)}
|
||||||
|
</Typography.Text>
|
||||||
|
<div className="vp-map-service-item-footer">
|
||||||
|
<Typography.Text type="tertiary" size="small">{row.lastSeen || '-'}</Typography.Text>
|
||||||
|
<Button size="small" onClick={() => onOpenVehicle(row.vin, filters.protocol || row.primaryProtocol)}>地图车辆服务</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Tabs type="line">
|
<Tabs type="line">
|
||||||
|
|||||||
@@ -377,6 +377,42 @@ body {
|
|||||||
line-height: 30px;
|
line-height: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vp-map-service-queue {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--vp-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-map-service-queue-title {
|
||||||
|
color: var(--vp-text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-map-service-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--vp-border);
|
||||||
|
border-radius: var(--vp-radius);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-map-service-item-main,
|
||||||
|
.vp-map-service-item-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-map-service-item-main > div {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.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));
|
||||||
|
|||||||
@@ -4287,7 +4287,7 @@ test('opens vehicle service from realtime vehicles with primary source evidence'
|
|||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
expect(await screen.findByText('VIN-MQTT-001')).toBeInTheDocument();
|
expect((await screen.findAllByText('VIN-MQTT-001')).length).toBeGreaterThan(0);
|
||||||
fireEvent.click(screen.getByRole('button', { name: '车辆服务' }));
|
fireEvent.click(screen.getByRole('button', { name: '车辆服务' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -4370,7 +4370,7 @@ test('opens vehicle service from realtime vehicles with current source filter',
|
|||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
expect(await screen.findByText('VIN-RT-FILTER')).toBeInTheDocument();
|
expect((await screen.findAllByText('VIN-RT-FILTER')).length).toBeGreaterThan(0);
|
||||||
fireEvent.click(screen.getByRole('button', { name: '车辆服务' }));
|
fireEvent.click(screen.getByRole('button', { name: '车辆服务' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -4435,8 +4435,8 @@ test('shows canonical service status in realtime vehicles', async () => {
|
|||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
expect(await screen.findByText('VIN-RT-DEGRADED')).toBeInTheDocument();
|
expect((await screen.findAllByText('VIN-RT-DEGRADED')).length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText('来源不完整')).toBeInTheDocument();
|
expect(screen.getAllByText('来源不完整').length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('frames realtime page as one vehicle service with source evidence', async () => {
|
test('frames realtime page as one vehicle service with source evidence', async () => {
|
||||||
@@ -4508,7 +4508,99 @@ test('frames realtime page as one vehicle service with source evidence', async (
|
|||||||
expect(screen.getByText('GB32960 在线')).toBeInTheDocument();
|
expect(screen.getByText('GB32960 在线')).toBeInTheDocument();
|
||||||
expect(screen.getByText('JT808 在线')).toBeInTheDocument();
|
expect(screen.getByText('JT808 在线')).toBeInTheDocument();
|
||||||
expect(screen.getByText('YUTONG_MQTT 未接入')).toBeInTheDocument();
|
expect(screen.getByText('YUTONG_MQTT 未接入')).toBeInTheDocument();
|
||||||
expect(screen.getByText('2/2 来源在线')).toBeInTheDocument();
|
expect(screen.getAllByText('2/2 来源在线').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opens vehicle service from realtime map service queue', async () => {
|
||||||
|
window.history.replaceState(null, '', '/#/realtime');
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
|
const path = String(input);
|
||||||
|
if (path.includes('/api/realtime/vehicles')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
items: [{
|
||||||
|
vin: 'VIN-MAP-001',
|
||||||
|
plate: '粤AMAP01',
|
||||||
|
phone: '',
|
||||||
|
oem: 'G7s',
|
||||||
|
protocols: ['GB32960', 'JT808'],
|
||||||
|
sourceStatus: [
|
||||||
|
{ protocol: 'GB32960', online: true, lastSeen: '2026-07-03 20:12:10', hasRealtime: true, hasHistory: false, hasRaw: false, hasMileage: false },
|
||||||
|
{ protocol: 'JT808', online: false, lastSeen: '2026-07-03 19:12:10', hasRealtime: true, hasHistory: false, hasRaw: false, hasMileage: false }
|
||||||
|
],
|
||||||
|
sourceCount: 2,
|
||||||
|
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: 'degraded',
|
||||||
|
severity: 'warning',
|
||||||
|
title: '来源不完整',
|
||||||
|
detail: 'JT808 离线,GB32960 仍可支撑车辆服务',
|
||||||
|
sourceCount: 2,
|
||||||
|
onlineSourceCount: 1
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
total: 1,
|
||||||
|
limit: 50,
|
||||||
|
offset: 0
|
||||||
|
},
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
if (path.includes('/api/vehicle-service/overview')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
vin: 'VIN-MAP-001',
|
||||||
|
plate: '粤AMAP01',
|
||||||
|
sourceCount: 2,
|
||||||
|
onlineSourceCount: 1,
|
||||||
|
coverageStatus: 'degraded',
|
||||||
|
primaryProtocol: 'GB32960',
|
||||||
|
lastSeen: '2026-07-03 20:12:10',
|
||||||
|
historyCount: 0,
|
||||||
|
rawCount: 0,
|
||||||
|
mileageCount: 0,
|
||||||
|
qualityIssueCount: 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 />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('地图车辆服务队列')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('粤AMAP01').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText('JT808 离线,GB32960 仍可支撑车辆服务')).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '地图车辆服务' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.location.hash).toBe('#/detail?keyword=VIN-MAP-001&protocol=GB32960');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('loads realtime vehicles from shareable source filter hash', async () => {
|
test('loads realtime vehicles from shareable source filter hash', async () => {
|
||||||
@@ -4559,7 +4651,7 @@ test('loads realtime vehicles from shareable source filter hash', async () => {
|
|||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
expect(await screen.findByText('VIN-RT-FILTERED')).toBeInTheDocument();
|
expect((await screen.findAllByText('VIN-RT-FILTERED')).length).toBeGreaterThan(0);
|
||||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/realtime/vehicles?limit=50&offset=0&keyword=%E7%B2%A4ART002&protocol=JT808&online=online&serviceStatus=degraded'), undefined);
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/realtime/vehicles?limit=50&offset=0&keyword=%E7%B2%A4ART002&protocol=JT808&online=online&serviceStatus=degraded'), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user