diff --git a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx index d503e5c4..3264e2da 100644 --- a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx @@ -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 = { YUTONG_MQTT: '缺 YUTONG_MQTT' }; +type DashboardSnapshotRow = { + section: string; + item: string; + value: string; + detail: string; +}; + +const dashboardSnapshotColumns: CsvColumn[] = [ + { 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 (
@@ -544,6 +665,7 @@ export function Dashboard({ 0 ? 'orange' : 'green'}>Kafka Lag {formatLag(summary?.kafkaLag)} + diff --git a/vehicle-data-platform/apps/web/src/test/App.test.tsx b/vehicle-data-platform/apps/web/src/test/App.test.tsx index 107d8a17..e8977564 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -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('历史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/#/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 () => {