feat(platform): copy realtime operations summary
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { Button, Card, Form, Select, Space, Table, Tabs, Tag, Toast, Typography } from '@douyinfe/semi-ui';
|
import { Button, Card, Form, Select, Space, Table, Tabs, Tag, Toast, Typography } from '@douyinfe/semi-ui';
|
||||||
|
import { IconCopy } from '@douyinfe/semi-icons';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { VehicleRealtimeRow } from '../api/types';
|
import type { VehicleRealtimeRow } from '../api/types';
|
||||||
@@ -105,6 +106,69 @@ function realtimeExportFileName(filters: Record<string, string>) {
|
|||||||
return `realtime-vehicles-${keyword}-${protocol}-${online}.csv`;
|
return `realtime-vehicles-${keyword}-${protocol}-${online}.csv`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function realtimeFilterSummary(filters: Record<string, string>) {
|
||||||
|
return [
|
||||||
|
filters.keyword ? `关键词:${filters.keyword}` : '',
|
||||||
|
filters.protocol ? `数据来源:${filters.protocol}` : '',
|
||||||
|
filters.online ? `在线:${onlineLabel[filters.online] ?? filters.online}` : '',
|
||||||
|
filters.serviceStatus ? `服务状态:${serviceStatusLabel[filters.serviceStatus] ?? filters.serviceStatus}` : ''
|
||||||
|
].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function realtimeOperationsSummaryText({
|
||||||
|
filters,
|
||||||
|
rows,
|
||||||
|
total,
|
||||||
|
onlineCount,
|
||||||
|
locatedCount,
|
||||||
|
degradedCount,
|
||||||
|
sourceTypeCount,
|
||||||
|
amapConfigured,
|
||||||
|
sourceIssueRows
|
||||||
|
}: {
|
||||||
|
filters: Record<string, string>;
|
||||||
|
rows: VehicleRealtimeRow[];
|
||||||
|
total: number;
|
||||||
|
onlineCount: number;
|
||||||
|
locatedCount: number;
|
||||||
|
degradedCount: number;
|
||||||
|
sourceTypeCount: number;
|
||||||
|
amapConfigured: boolean;
|
||||||
|
sourceIssueRows: VehicleRealtimeRow[];
|
||||||
|
}) {
|
||||||
|
const issueLines = sourceIssueRows.length > 0
|
||||||
|
? sourceIssueRows.map((row, index) => {
|
||||||
|
const status = vehicleServiceStatus(row);
|
||||||
|
return `${index + 1}. ${row.plate || row.vin} / ${row.primaryProtocol || '-'} / ${status.label} / ${row.serviceStatus?.detail || sourceEvidenceText(row)}`;
|
||||||
|
}).join('\n')
|
||||||
|
: '暂无重点车辆';
|
||||||
|
return [
|
||||||
|
'【实时监控摘要】',
|
||||||
|
`当前筛选:${realtimeFilterSummary(filters).join(';') || '全部实时车辆'}`,
|
||||||
|
`车辆总数:${total.toLocaleString()},当前页:${rows.length.toLocaleString()}`,
|
||||||
|
`在线车辆:${onlineCount.toLocaleString()},定位有效:${locatedCount.toLocaleString()}`,
|
||||||
|
`降级服务:${degradedCount.toLocaleString()},来源类型:${sourceTypeCount.toLocaleString()}`,
|
||||||
|
`地图配置:${amapConfigured ? '已配置' : '未配置'}`,
|
||||||
|
'重点车辆:',
|
||||||
|
issueLines,
|
||||||
|
`实时页面:${window.location.origin}${window.location.pathname}${window.location.hash}`
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyText(value: string, label: string) {
|
||||||
|
const text = value.trim();
|
||||||
|
if (!text) {
|
||||||
|
Toast.warning(`${label}为空`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
Toast.success(`已复制${label}`);
|
||||||
|
} catch {
|
||||||
|
Toast.error(`复制${label}失败`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function Realtime({
|
export function Realtime({
|
||||||
onOpenVehicle,
|
onOpenVehicle,
|
||||||
onOpenHistory,
|
onOpenHistory,
|
||||||
@@ -165,12 +229,7 @@ export function Realtime({
|
|||||||
downloadCsv(realtimeExportFileName(filters), buildCsv(realtimeExportColumns, rows));
|
downloadCsv(realtimeExportFileName(filters), buildCsv(realtimeExportColumns, rows));
|
||||||
Toast.success(`已导出 ${rows.length} 条实时车辆`);
|
Toast.success(`已导出 ${rows.length} 条实时车辆`);
|
||||||
};
|
};
|
||||||
const filterSummary = [
|
const filterSummary = realtimeFilterSummary(filters);
|
||||||
filters.keyword ? `关键词:${filters.keyword}` : '',
|
|
||||||
filters.protocol ? `数据来源:${filters.protocol}` : '',
|
|
||||||
filters.online ? `在线:${onlineLabel[filters.online] ?? filters.online}` : '',
|
|
||||||
filters.serviceStatus ? `服务状态:${serviceStatusLabel[filters.serviceStatus] ?? filters.serviceStatus}` : ''
|
|
||||||
].filter(Boolean);
|
|
||||||
const onlineCount = rows.filter((row) => row.online).length;
|
const onlineCount = rows.filter((row) => row.online).length;
|
||||||
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;
|
||||||
@@ -199,6 +258,17 @@ export function Realtime({
|
|||||||
online: row.online,
|
online: row.online,
|
||||||
title: `${row.plate || row.vin || '-'} ${vehicleServiceStatus(row).label} ${row.primaryProtocol || ''} ${row.lastSeen || ''}`
|
title: `${row.plate || row.vin || '-'} ${vehicleServiceStatus(row).label} ${row.primaryProtocol || ''} ${row.lastSeen || ''}`
|
||||||
}));
|
}));
|
||||||
|
const copyRealtimeSummary = () => copyText(realtimeOperationsSummaryText({
|
||||||
|
filters,
|
||||||
|
rows,
|
||||||
|
total: pagination.total,
|
||||||
|
onlineCount,
|
||||||
|
locatedCount,
|
||||||
|
degradedCount,
|
||||||
|
sourceTypeCount: primaryProtocols.size,
|
||||||
|
amapConfigured,
|
||||||
|
sourceIssueRows
|
||||||
|
}), '实时摘要');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="vp-page">
|
<div className="vp-page">
|
||||||
@@ -250,6 +320,7 @@ export function Realtime({
|
|||||||
</Tag>
|
</Tag>
|
||||||
<Tag color="blue">{locatedCount.toLocaleString()} 辆有定位</Tag>
|
<Tag color="blue">{locatedCount.toLocaleString()} 辆有定位</Tag>
|
||||||
<Tag color="green">{onlineCount.toLocaleString()} 辆在线</Tag>
|
<Tag color="green">{onlineCount.toLocaleString()} 辆在线</Tag>
|
||||||
|
<Button size="small" theme="light" icon={<IconCopy />} aria-label="复制实时摘要" onClick={copyRealtimeSummary}>复制实时摘要</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
<VehicleMap
|
<VehicleMap
|
||||||
|
|||||||
@@ -6362,6 +6362,155 @@ test('exports current realtime vehicles as CSV', async () => {
|
|||||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:realtime-export');
|
expect(revokeObjectURL).toHaveBeenCalledWith('blob:realtime-export');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('copies realtime operations summary from realtime page', async () => {
|
||||||
|
window.history.replaceState(null, '', '/#/realtime?protocol=JT808&online=online');
|
||||||
|
Object.defineProperty(window, '__LINGNIU_APP_CONFIG__', {
|
||||||
|
configurable: true,
|
||||||
|
value: { amapWebJsKey: 'amap-key' }
|
||||||
|
});
|
||||||
|
const writeText = vi.fn(() => Promise.resolve());
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
configurable: true,
|
||||||
|
value: { writeText }
|
||||||
|
});
|
||||||
|
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-RT-SUMMARY-001',
|
||||||
|
plate: '粤A摘要1',
|
||||||
|
phone: '13300000001',
|
||||||
|
oem: 'G7s',
|
||||||
|
protocols: ['JT808'],
|
||||||
|
sourceStatus: [{ protocol: 'JT808', online: true, lastSeen: '2026-07-03 20:12:10', hasRealtime: true, hasHistory: true, hasRaw: true, hasMileage: true }],
|
||||||
|
sourceCount: 1,
|
||||||
|
onlineSourceCount: 1,
|
||||||
|
online: true,
|
||||||
|
bindingStatus: 'bound',
|
||||||
|
primaryProtocol: 'JT808',
|
||||||
|
longitude: 113.2,
|
||||||
|
latitude: 23.1,
|
||||||
|
speedKmh: 32,
|
||||||
|
socPercent: 76,
|
||||||
|
totalMileageKm: 1200.5,
|
||||||
|
lastSeen: '2026-07-03 20:12:10',
|
||||||
|
serviceStatus: {
|
||||||
|
status: 'healthy',
|
||||||
|
severity: 'ok',
|
||||||
|
title: '服务正常',
|
||||||
|
detail: '1/1 来源在线',
|
||||||
|
sourceCount: 1,
|
||||||
|
onlineSourceCount: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
vin: 'VIN-RT-SUMMARY-002',
|
||||||
|
plate: '粤A摘要2',
|
||||||
|
phone: '13300000002',
|
||||||
|
oem: '现代',
|
||||||
|
protocols: ['GB32960', 'JT808'],
|
||||||
|
sourceStatus: [
|
||||||
|
{ protocol: 'GB32960', online: true, lastSeen: '2026-07-03 20:12:10', hasRealtime: true, hasHistory: true, hasRaw: true, hasMileage: true },
|
||||||
|
{ protocol: 'JT808', online: false, lastSeen: '2026-07-03 19:58:10', hasRealtime: true, hasHistory: true, hasRaw: true, hasMileage: true }
|
||||||
|
],
|
||||||
|
sourceCount: 2,
|
||||||
|
onlineSourceCount: 1,
|
||||||
|
online: true,
|
||||||
|
bindingStatus: 'bound',
|
||||||
|
primaryProtocol: 'GB32960',
|
||||||
|
longitude: 113.4,
|
||||||
|
latitude: 23.2,
|
||||||
|
speedKmh: 18,
|
||||||
|
socPercent: 69,
|
||||||
|
totalMileageKm: 2200.5,
|
||||||
|
lastSeen: '2026-07-03 20:12:08',
|
||||||
|
serviceStatus: {
|
||||||
|
status: 'degraded',
|
||||||
|
severity: 'warning',
|
||||||
|
title: '来源不完整',
|
||||||
|
detail: 'JT808 离线,GB32960 仍可支撑车辆服务',
|
||||||
|
sourceCount: 2,
|
||||||
|
onlineSourceCount: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
vin: 'VIN-RT-SUMMARY-003',
|
||||||
|
plate: '粤A摘要3',
|
||||||
|
phone: '13300000003',
|
||||||
|
oem: '宇通',
|
||||||
|
protocols: ['YUTONG_MQTT'],
|
||||||
|
sourceStatus: [{ protocol: 'YUTONG_MQTT', online: false, lastSeen: '2026-07-03 18:12:10', hasRealtime: true, hasHistory: true, hasRaw: true, hasMileage: true }],
|
||||||
|
sourceCount: 1,
|
||||||
|
onlineSourceCount: 0,
|
||||||
|
online: false,
|
||||||
|
bindingStatus: 'bound',
|
||||||
|
primaryProtocol: 'YUTONG_MQTT',
|
||||||
|
longitude: 0,
|
||||||
|
latitude: 0,
|
||||||
|
speedKmh: 0,
|
||||||
|
socPercent: 60,
|
||||||
|
totalMileageKm: 3200.5,
|
||||||
|
lastSeen: '2026-07-03 18:12:10',
|
||||||
|
serviceStatus: {
|
||||||
|
status: 'offline',
|
||||||
|
severity: 'error',
|
||||||
|
title: '车辆离线',
|
||||||
|
detail: 'YUTONG_MQTT 超过在线窗口',
|
||||||
|
sourceCount: 1,
|
||||||
|
onlineSourceCount: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
total: 3,
|
||||||
|
limit: 50,
|
||||||
|
offset: 0
|
||||||
|
},
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: { items: [], total: 0, limit: 50, offset: 0 },
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
expect((await screen.findAllByText('VIN-RT-SUMMARY-001')).length).toBeGreaterThan(0);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '复制实时摘要' }));
|
||||||
|
|
||||||
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【实时监控摘要】'));
|
||||||
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('当前筛选:数据来源:JT808;在线:在线'));
|
||||||
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('车辆总数:3,当前页:3'));
|
||||||
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('在线车辆:2,定位有效:2'));
|
||||||
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('降级服务:2,来源类型:3'));
|
||||||
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('地图配置:已配置'));
|
||||||
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('重点车辆:'));
|
||||||
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('粤A摘要3 / YUTONG_MQTT / 车辆离线'));
|
||||||
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('实时页面:http://localhost:3000/#/realtime?protocol=JT808&online=online'));
|
||||||
|
});
|
||||||
|
|
||||||
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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user