From fc57dea551f4b12120dd9d0a4b57d8b0c74c1788 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 20:26:09 +0800 Subject: [PATCH] feat(platform): export notification rules --- .../apps/web/src/pages/NotificationRules.tsx | 67 ++++++++++++++++++- .../apps/web/src/test/App.test.tsx | 5 ++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/vehicle-data-platform/apps/web/src/pages/NotificationRules.tsx b/vehicle-data-platform/apps/web/src/pages/NotificationRules.tsx index c2bf16a7..d0df540f 100644 --- a/vehicle-data-platform/apps/web/src/pages/NotificationRules.tsx +++ b/vehicle-data-platform/apps/web/src/pages/NotificationRules.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import { api } from '../api/client'; import type { OpsHealth, QualityAlertRule, QualityNotificationPlan, QualityNotificationPolicy, QualityPriorityIssue } from '../api/types'; import { PageHeader } from '../components/PageHeader'; +import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport'; import { qualityIssueLabel } from '../domain/qualityIssue'; function formatEscalationMinutes(minutes?: number) { @@ -26,6 +27,32 @@ function ruleTitle(rule: QualityAlertRule) { return rule.title || qualityIssueLabel(rule.issueType); } +type NotificationExportRow = { + category: string; + name: string; + level: string; + ownerOrTarget: string; + triggerOrCondition: string; + notifyOrChannel: string; + slaOrEscalation: string; + acceptance: string; + count: number | string; + release: string; +}; + +const notificationExportColumns: CsvColumn[] = [ + { title: '类型', value: (row) => row.category }, + { title: '名称', value: (row) => row.name }, + { title: '级别', value: (row) => row.level }, + { title: '责任方', value: (row) => row.ownerOrTarget }, + { title: '触发条件', value: (row) => row.triggerOrCondition }, + { title: '通知方式', value: (row) => row.notifyOrChannel }, + { title: 'SLA/升级', value: (row) => row.slaOrEscalation }, + { title: '验收口径', value: (row) => row.acceptance }, + { title: '当前命中', value: (row) => row.count }, + { title: '运行版本', value: (row) => row.release } +]; + function notificationRulesRunbook(plan: QualityNotificationPlan | null, release?: string) { const rules = plan?.rules ?? []; const policies = plan?.policies ?? []; @@ -129,6 +156,35 @@ function evidenceColor(issue: QualityPriorityIssue): 'green' | 'orange' { return countEvidenceLinks(issue) >= 4 ? 'green' : 'orange'; } +function notificationExportRows(plan: QualityNotificationPlan | null, release = ''): NotificationExportRow[] { + const version = release.trim() || '-'; + const ruleRows = (plan?.rules ?? []).map((rule) => ({ + category: '触发规则', + name: ruleTitle(rule), + level: rule.level, + ownerOrTarget: rule.owner, + triggerOrCondition: rule.trigger, + notifyOrChannel: rule.notify, + slaOrEscalation: rule.sla, + acceptance: '-', + count: Number(rule.count ?? 0), + release: version + })); + const policyRows = (plan?.policies ?? []).map((policy) => ({ + category: '通知策略', + name: policy.name, + level: policy.name.startsWith('P0') ? 'P0' : policy.name.startsWith('P1') ? 'P1' : '-', + ownerOrTarget: policy.target, + triggerOrCondition: policy.condition, + notifyOrChannel: policy.channel, + slaOrEscalation: formatEscalationMinutes(policy.escalationMinutes), + acceptance: policy.acceptanceCriteria || '-', + count: '-', + release: version + })); + return [...ruleRows, ...policyRows]; +} + function navigateHash(hash: string) { if (!hash) return; window.location.hash = hash; @@ -176,6 +232,15 @@ export function NotificationRules() { const evidenceCompleteCount = priorityIssues.filter((issue) => countEvidenceLinks(issue) >= 4).length; const primaryOwner = rules.find((rule) => rule.count > 0)?.owner || policies[0]?.target || '-'; const nextPriorityIssue = priorityIssues[0]; + const exportNotificationRules = () => { + const rows = notificationExportRows(plan, release); + if (rows.length === 0) { + Toast.warning('当前没有可导出的通知规则'); + return; + } + downloadCsv(`notification-rules-${release || 'current'}.csv`, buildCsv(notificationExportColumns, rows)); + Toast.success(`已导出 ${rows.length.toLocaleString()} 条通知规则`); + }; return (
@@ -250,7 +315,7 @@ export function NotificationRules() { 规则运行手册} + title={规则运行手册} >
{[ 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 b9652a6e..107d8a17 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -3360,6 +3360,8 @@ test('renders notification rules as a standalone operations page', async () => { configurable: true, value: { writeText } }); + const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:notification-rules'); + const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { const path = String(input); if (path.includes('/api/ops/health')) { @@ -3456,6 +3458,9 @@ test('renders notification rules as a standalone operations page', async () => { fireEvent.click(screen.getByRole('button', { name: '复制通知规则Runbook' })); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【通知规则Runbook】')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('无来源规则 / P0 / 平台接入')); + fireEvent.click(screen.getByRole('button', { name: '导出通知规则CSV' })); + expect(createObjectURL).toHaveBeenCalled(); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:notification-rules'); fireEvent.click(screen.getByRole('button', { name: '复制待通知汇总' })); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【当前待通知告警】')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('粤A规则1 / VIN-RULES-001'));