feat(platform): export dashboard snapshot
This commit is contained in:
@@ -9,6 +9,7 @@ import { StatusTag } from '../components/StatusTag';
|
|||||||
import { VehicleMap, type VehicleMapPoint } from '../components/VehicleMap';
|
import { VehicleMap, type VehicleMapPoint } from '../components/VehicleMap';
|
||||||
import { isAMapConfigured } from '../config/appConfig';
|
import { isAMapConfigured } from '../config/appConfig';
|
||||||
import { buildAppHash } from '../domain/appRoute';
|
import { buildAppHash } from '../domain/appRoute';
|
||||||
|
import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport';
|
||||||
import { qualityIssueLabel, qualityProtocolLabel } from '../domain/qualityIssue';
|
import { qualityIssueLabel, qualityProtocolLabel } from '../domain/qualityIssue';
|
||||||
import { qualityIssueVehicleLookup } from '../domain/vehicleLookup';
|
import { qualityIssueVehicleLookup } from '../domain/vehicleLookup';
|
||||||
|
|
||||||
@@ -40,6 +41,20 @@ const missingProtocolTitle: Record<string, string> = {
|
|||||||
YUTONG_MQTT: '缺 YUTONG_MQTT'
|
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) {
|
function formatLag(value?: number | null) {
|
||||||
return value == null ? '未接入' : value.toLocaleString();
|
return value == null ? '未接入' : value.toLocaleString();
|
||||||
}
|
}
|
||||||
@@ -517,6 +532,112 @@ export function Dashboard({
|
|||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
copyText(lines.join('\n'), '运营交接摘要');
|
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 (
|
return (
|
||||||
<div className="vp-page">
|
<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>
|
<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>
|
<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="solid" type="primary" onClick={copyOperationsHandoff}>复制运营交接摘要</Button>
|
||||||
|
<Button size="small" theme="light" type="primary" onClick={exportDashboardSnapshot}>导出驾驶舱 CSV</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
<Card bordered title="车辆服务作业台" style={{ marginBottom: 16 }}>
|
<Card bordered title="车辆服务作业台" style={{ marginBottom: 16 }}>
|
||||||
|
|||||||
@@ -1109,6 +1109,8 @@ test('dashboard copies highest priority quality issue notification text', async
|
|||||||
configurable: true,
|
configurable: true,
|
||||||
value: { writeText }
|
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) => {
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
const path = String(input);
|
const path = String(input);
|
||||||
if (path.includes('/api/ops/health')) {
|
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('历史RAW:http://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('历史RAW:http://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/#/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'));
|
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 () => {
|
test('dashboard keeps core vehicle service metrics when preview requests fail', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user