feat(platform): export dashboard snapshot

This commit is contained in:
lingniu
2026-07-04 20:30:47 +08:00
parent fc57dea551
commit e6188e2569
2 changed files with 129 additions and 0 deletions

View File

@@ -9,6 +9,7 @@ import { StatusTag } from '../components/StatusTag';
import { VehicleMap, type VehicleMapPoint } from '../components/VehicleMap';
import { isAMapConfigured } from '../config/appConfig';
import { buildAppHash } from '../domain/appRoute';
import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport';
import { qualityIssueLabel, qualityProtocolLabel } from '../domain/qualityIssue';
import { qualityIssueVehicleLookup } from '../domain/vehicleLookup';
@@ -40,6 +41,20 @@ const missingProtocolTitle: Record<string, string> = {
YUTONG_MQTT: '缺 YUTONG_MQTT'
};
type DashboardSnapshotRow = {
section: string;
item: string;
value: string;
detail: string;
};
const dashboardSnapshotColumns: CsvColumn<DashboardSnapshotRow>[] = [
{ title: '模块', value: (row) => row.section },
{ title: '指标', value: (row) => row.item },
{ title: '值', value: (row) => row.value },
{ title: '说明', value: (row) => row.detail }
];
function formatLag(value?: number | null) {
return value == null ? '未接入' : value.toLocaleString();
}
@@ -517,6 +532,112 @@ export function Dashboard({
].filter(Boolean);
copyText(lines.join('\n'), '运营交接摘要');
};
const buildDashboardSnapshotRows = () => {
const unhealthyLinks = (summary?.linkHealth ?? []).filter((item) => item.status !== 'ok');
const priorityAction = serviceActionQueue[0];
const rows: DashboardSnapshotRow[] = [
{
section: '车辆服务',
item: '在线车辆',
value: `${formatCount(serviceSummary?.onlineVehicles ?? summary?.onlineVehicles)} / ${formatCount(serviceSummary?.totalVehicles)}`,
detail: '统一车辆服务在线态势'
},
{
section: '车辆服务',
item: '多源覆盖车辆',
value: formatCount(serviceSummary?.multiSourceVehicles),
detail: '同一车辆可由多个协议交叉验证'
},
{
section: '车辆服务',
item: '单源车辆',
value: formatCount(serviceSummary?.singleSourceVehicles),
detail: '仅有一个协议来源,需要持续补齐'
},
{
section: '车辆服务',
item: '暂无来源车辆',
value: formatCount(serviceSummary?.noDataVehicles),
detail: '车辆档案存在但暂无实时来源'
},
{
section: '实时监控',
item: '有效定位',
value: commandLocatedCount.toLocaleString(),
detail: `当前预览 ${locations.length.toLocaleString()} 条车辆实时数据`
},
{
section: '实时监控',
item: '来源异常',
value: commandDegradedCount.toLocaleString(),
detail: '当前预览中离线或来源不完整车辆'
},
{
section: '历史数据',
item: '今日帧量',
value: formatCount(summary?.frameToday),
detail: `Kafka Lag ${formatLag(summary?.kafkaLag)}`
},
{
section: '告警事件',
item: '告警车辆',
value: formatCount(summary?.issueVehicles),
detail: highPriorityIssue ? `${qualityIssueLabel(highPriorityIssue.issueType)} / ${priorityIssueVehicleLabel(highPriorityIssue)}` : '暂无最高优先级告警'
},
{
section: '链路健康',
item: '异常链路',
value: unhealthyLinks.length.toLocaleString(),
detail: unhealthyLinks.length > 0 ? unhealthyLinks.map((item) => `${item.name}=${item.status}`).join('') : '正常'
},
{
section: '地图能力',
item: '高德地图',
value: amapConfigured ? '已配置' : '待配置',
detail: '用于实时监控和轨迹回放地图底座'
},
{
section: '优先动作',
item: priorityAction?.label ?? '暂无待办',
value: priorityAction ? priorityAction.count.toLocaleString() : '0',
detail: priorityAction?.detail ?? '当前没有必须立即处理的车辆服务事项'
}
];
(serviceSummary?.protocols ?? summary?.protocols ?? []).forEach((item) => {
rows.push({
section: '协议来源',
item: item.protocol,
value: `${item.online.toLocaleString()} / ${item.total.toLocaleString()}`,
detail: `在线率 ${formatProtocolRate(item)}`
});
});
(serviceSummary?.serviceStatuses ?? summary?.serviceStatuses ?? []).forEach((item) => {
rows.push({
section: '服务状态',
item: item.title || serviceStatusTitle[item.status] || item.status,
value: item.count.toLocaleString(),
detail: serviceStatusTitle[item.status] || item.status
});
});
(summary?.linkHealth ?? []).forEach((item) => {
rows.push({
section: '链路健康',
item: item.name,
value: item.status,
detail: item.detail ?? ''
});
});
return rows;
};
const exportDashboardSnapshot = () => {
const rows = buildDashboardSnapshotRows();
if (rows.length === 0) {
Toast.warning('当前没有可导出的驾驶舱摘要');
return;
}
downloadCsv('dashboard-snapshot.csv', buildCsv(dashboardSnapshotColumns, rows));
Toast.success(`已导出 ${rows.length.toLocaleString()} 条驾驶舱摘要`);
};
return (
<div className="vp-page">
@@ -544,6 +665,7 @@ export function Dashboard({
<Button size="small" theme="light" type={(summary?.issueVehicles ?? 0) > 0 ? 'warning' : 'tertiary'} onClick={() => onOpenQuality()}></Button>
<Tag color={(summary?.kafkaLag ?? 0) > 0 ? 'orange' : 'green'}>Kafka Lag {formatLag(summary?.kafkaLag)}</Tag>
<Button size="small" theme="solid" type="primary" onClick={copyOperationsHandoff}></Button>
<Button size="small" theme="light" type="primary" onClick={exportDashboardSnapshot}> CSV</Button>
</Space>
</Card>
<Card bordered title="车辆服务作业台" style={{ marginBottom: 16 }}>

View File

@@ -1109,6 +1109,8 @@ test('dashboard copies highest priority quality issue notification text', async
configurable: true,
value: { writeText }
});
const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:dashboard-snapshot');
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const path = String(input);
if (path.includes('/api/ops/health')) {
@@ -1238,6 +1240,11 @@ test('dashboard copies highest priority quality issue notification text', async
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('历史RAWhttp://localhost:3000/#/history-query?keyword=%E7%B2%A4A%E5%91%8A%E8%AD%A61&protocol=JT808&tab=raw&dateFrom=2026-07-03&dateTo=2026-07-04&includeFields=true'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('告警事件http://localhost:3000/#/alert-events?issueType=VIN_MISSING'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('车辆服务http://localhost:3000/#/detail?keyword=%E7%B2%A4A%E5%91%8A%E8%AD%A61&protocol=JT808'));
fireEvent.click(screen.getByRole('button', { name: '导出驾驶舱 CSV' }));
expect(createObjectURL).toHaveBeenCalled();
expect(revokeObjectURL).toHaveBeenCalledWith('blob:dashboard-snapshot');
});
test('dashboard keeps core vehicle service metrics when preview requests fail', async () => {