diff --git a/vehicle-data-platform/apps/web/src/pages/Quality.tsx b/vehicle-data-platform/apps/web/src/pages/Quality.tsx index 03c01f8f..2e82d6c0 100644 --- a/vehicle-data-platform/apps/web/src/pages/Quality.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Quality.tsx @@ -764,6 +764,55 @@ function alertRecoveryReceiptText({ ].join('\n'); } +function alertNotificationCommandText({ + summary, + priorityRows, + primaryPolicy, + filters, + platformRelease +}: { + summary: QualitySummary; + priorityRows: PriorityIssueRow[]; + primaryPolicy?: QualityNotificationPolicy; + filters: Record; + platformRelease?: string; +}) { + const release = platformRelease?.trim(); + const p0Rows = priorityRows.filter((row) => row.priority === 'P0'); + const p1Rows = priorityRows.filter((row) => row.priority === 'P1'); + const focusIssue = priorityRows[0]; + const focusLookup = focusIssue ? qualityIssueVehicleLookup(focusIssue) : undefined; + const filterLines = [ + filters.keyword ? `关键词=${filters.keyword}` : '', + filters.protocol ? `数据来源=${qualityProtocolLabel(filters.protocol)}` : '', + filters.issueType ? `问题类型=${qualityIssueLabel(filters.issueType)}` : '' + ].filter(Boolean); + const primaryPolicyLine = primaryPolicy + ? [primaryPolicy.name, primaryPolicy.target, primaryPolicy.channel, formatEscalationMinutes(primaryPolicy.escalationMinutes)].filter(Boolean).join(' / ') + : '-'; + const evidenceKeyword = focusLookup?.key || filters.keyword || ''; + const evidenceProtocol = focusIssue?.protocol || filters.protocol; + return [ + '【客户告警通知总控包】', + ...(release ? [`运行版本:${release}`] : []), + `当前筛选:${filterLines.length > 0 ? filterLines.join(';') : '全部告警'}`, + '服务链路:影响车辆 -> 通知对象 -> SLA升级 -> 证据闭环 -> 恢复验收', + `影响范围:${Number(summary.issueVehicleCount ?? 0).toLocaleString()} 辆 / ${Number(summary.issueRecordCount ?? 0).toLocaleString()} 条`, + `优先级:P0 ${p0Rows.length.toLocaleString()} / P1 ${p1Rows.length.toLocaleString()}`, + `主通知策略:${primaryPolicyLine}`, + `恢复验收:${primaryPolicy?.acceptanceCriteria || '车辆服务状态恢复,实时、轨迹、历史和里程证据可查询。'}`, + `焦点车辆:${focusIssue ? focusIssue.vehicleLabel : '-'}`, + focusIssue ? `焦点问题:${qualityProtocolLabel(focusIssue.protocol)} / ${qualityIssueLabel(focusIssue.issueType)} / ${focusIssue.actionLabel}` : '', + '', + `告警事件:${qualityShareURL()}`, + `通知闭环:${appURL(buildAppHash({ page: 'notification-rules' }))}`, + `实时监控:${appURL(buildAppHash({ page: 'realtime', keyword: evidenceKeyword, protocol: evidenceProtocol }))}`, + `轨迹证据:${appURL(buildAppHash({ page: 'history', keyword: evidenceKeyword, protocol: evidenceProtocol }))}`, + `原始记录:${appURL(buildAppHash({ page: 'history-query', keyword: evidenceKeyword, protocol: evidenceProtocol, filters: { tab: 'raw', includeFields: 'true' } }))}`, + `车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: evidenceKeyword, protocol: evidenceProtocol }))}` + ].filter(Boolean).join('\n'); +} + async function copyText(value: string, label: string) { const text = value.trim(); if (!text) { @@ -1072,6 +1121,53 @@ export function Quality({ platformRelease: health?.runtime?.platformRelease }), '客户告警恢复验收回执'); }; + const copyAlertNotificationCommand = () => { + copyText(alertNotificationCommandText({ + summary: notificationPlan?.summary ?? summary, + priorityRows, + primaryPolicy, + filters, + platformRelease: health?.runtime?.platformRelease + }), '客户告警通知总控包'); + }; + const alertNotificationCommandItems = [ + { + label: '影响车辆', + value: `${issueVehicleCount.toLocaleString()} 辆`, + detail: `${issueRecordCount.toLocaleString()} 条告警记录,先判断实时、轨迹、里程和历史导出是否受影响。`, + color: issueVehicleCount > 0 ? 'orange' as const : 'green' as const, + action: '复制影响', + disabled: false, + onClick: () => copyBusinessImpact() + }, + { + label: '通知对象', + value: primaryPolicy?.target || '责任人待配置', + detail: primaryPolicy ? `${primaryPolicy.name} / ${primaryPolicy.channel}` : '需要配置客户通知对象、渠道和升级策略。', + color: primaryPolicy ? 'blue' as const : 'orange' as const, + action: '复制通知', + disabled: priorityRows.length === 0, + onClick: () => copyPriorityDigest() + }, + { + label: 'SLA升级', + value: formatEscalationMinutes(primaryPolicy?.escalationMinutes) || '未配置', + detail: primaryPolicy?.condition || '按 P0/P1 告警规则决定是否升级。', + color: primaryPolicy ? customerDecision.color : 'orange' as const, + action: '通知闭环', + disabled: false, + onClick: () => onOpenNotificationRules?.() + }, + { + label: '恢复验收', + value: primaryPolicy?.acceptanceCriteria ? '有标准' : '待补充', + detail: primaryPolicy?.acceptanceCriteria || '车辆服务状态恢复,实时、轨迹、历史和里程证据可查询。', + color: primaryPolicy?.acceptanceCriteria ? 'green' as const : 'orange' as const, + action: '复制回执', + disabled: false, + onClick: () => copyAlertRecoveryReceipt() + } + ]; const alertRecoveryAcceptanceItems = [ { label: '实时恢复', @@ -1268,6 +1364,41 @@ export function Quality({ return (
+
+
+ + 客户告警通知总控 + 0 ? 'red' : priorityP1Count > 0 ? 'orange' : 'green'}> + P0 {priorityP0Count.toLocaleString()} / P1 {priorityP1Count.toLocaleString()} + + {primaryPolicy?.target || '责任人待配置'} + + 把影响车辆、通知对象、SLA升级、证据闭环和恢复验收收敛成客户可执行的告警服务链路。 + + + {onOpenNotificationRules ? : null} + +
+
+ {alertNotificationCommandItems.map((item) => ( + + ))} +
+
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 d208f05c..2dbe5aac 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -12493,6 +12493,11 @@ test('loads realtime vehicles from shareable source filter hash', async () => { test('opens realtime status from quality issue row with source evidence', async () => { window.history.replaceState(null, '', '/#/alert-events'); + 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')) { @@ -12570,6 +12575,13 @@ test('opens realtime status from quality issue row with source evidence', async render(); expect(await screen.findByText('13307795425')).toBeInTheDocument(); + expect(screen.getByText('客户告警通知总控')).toBeInTheDocument(); + expect(screen.getByText('把影响车辆、通知对象、SLA升级、证据闭环和恢复验收收敛成客户可执行的告警服务链路。')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户告警通知总控 影响车辆 1 辆 复制影响' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户告警通知总控 通知对象 接入运维 + 业务责任人 复制通知' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户告警通知总控 SLA升级 30 分钟升级 通知闭环' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户告警通知总控 恢复验收 有标准 复制回执' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /复制告警总控包/ })).toBeInTheDocument(); expect(screen.getByText('告警处置结论栏')).toBeInTheDocument(); expect(screen.getByText('先判断客户受影响车辆,再确认通知、SLA、证据和恢复回执,避免告警停留在技术表格里。')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '告警处置结论栏 影响范围 1 辆 复制影响' })).toBeInTheDocument(); @@ -12584,6 +12596,16 @@ test('opens realtime status from quality issue row with source evidence', async expect(screen.getByRole('button', { name: '客户告警服务台 焦点车辆 粤AG18312 / 13307795425 车辆服务' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '客户告警服务台 证据闭环 可复核 复制交接' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '客户告警服务台 恢复验收 有标准 复制回执' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /复制告警总控包/ })); + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【客户告警通知总控包】')); + }); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('服务链路:影响车辆 -> 通知对象 -> SLA升级 -> 证据闭环 -> 恢复验收')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('影响范围:1 辆 / 1 条')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('优先级:P0 1 / P1 0')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('主通知策略:P0 实时中断 / 接入运维 + 业务责任人 / 站内告警 / 邮件 / 企业微信 / 30 分钟升级')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('焦点车辆:粤AG18312 / 13307795425')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('通知闭环:http://localhost:3000/#/notification-rules')); fireEvent.click(screen.getAllByRole('button', { name: '实时状态' })[0]); await waitFor(() => {