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 { IconCopy } from '@douyinfe/semi-icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { VehicleRealtimeRow } from '../api/types';
|
||||
@@ -105,6 +106,69 @@ function realtimeExportFileName(filters: Record<string, string>) {
|
||||
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({
|
||||
onOpenVehicle,
|
||||
onOpenHistory,
|
||||
@@ -165,12 +229,7 @@ export function Realtime({
|
||||
downloadCsv(realtimeExportFileName(filters), buildCsv(realtimeExportColumns, rows));
|
||||
Toast.success(`已导出 ${rows.length} 条实时车辆`);
|
||||
};
|
||||
const filterSummary = [
|
||||
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 filterSummary = realtimeFilterSummary(filters);
|
||||
const onlineCount = rows.filter((row) => row.online).length;
|
||||
const locatedCount = rows.filter(isValidCoordinate).length;
|
||||
const degradedCount = rows.filter((row) => row.onlineSourceCount < row.sourceCount).length;
|
||||
@@ -199,6 +258,17 @@ export function Realtime({
|
||||
online: row.online,
|
||||
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 (
|
||||
<div className="vp-page">
|
||||
@@ -250,6 +320,7 @@ export function Realtime({
|
||||
</Tag>
|
||||
<Tag color="blue">{locatedCount.toLocaleString()} 辆有定位</Tag>
|
||||
<Tag color="green">{onlineCount.toLocaleString()} 辆在线</Tag>
|
||||
<Button size="small" theme="light" icon={<IconCopy />} aria-label="复制实时摘要" onClick={copyRealtimeSummary}>复制实时摘要</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<VehicleMap
|
||||
|
||||
@@ -6362,6 +6362,155 @@ test('exports current realtime vehicles as CSV', async () => {
|
||||
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 () => {
|
||||
window.history.replaceState(null, '', '/#/realtime');
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
|
||||
Reference in New Issue
Block a user