diff --git a/vehicle-data-platform/apps/web/src/pages/Quality.tsx b/vehicle-data-platform/apps/web/src/pages/Quality.tsx index c89f7b3e..db0ab3b2 100644 --- a/vehicle-data-platform/apps/web/src/pages/Quality.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Quality.tsx @@ -658,6 +658,66 @@ function alertBusinessImpactText({ ].join('\n'); } +function alertCustomerDecisionText({ + summary, + rules, + priorityRows, + health, + filters, + platformRelease +}: { + summary: QualitySummary; + rules: AlertRuleRow[]; + priorityRows: PriorityIssueRow[]; + health: OpsHealth | null; + filters: Record; + platformRelease?: string; +}) { + const release = platformRelease?.trim(); + const activeRules = rules.filter((row) => row.count > 0); + const p0Rows = priorityRows.filter((row) => row.priority === 'P0'); + const p1Rows = priorityRows.filter((row) => row.priority === 'P1'); + const primaryProtocol = summary.protocols?.[0]; + const primaryIssue = summary.issueTypes?.[0]; + const storageOk = !health || (health.tdengineWritable && health.mysqlWritable); + const capacityCount = Number(health?.capacityFindings?.length ?? 0); + const decision = !storageOk || p0Rows.length > 0 || activeRules.some((row) => row.level === 'P0') || Number(summary.errorCount ?? 0) > 0 + ? '立即处置' + : Number(summary.issueVehicleCount ?? 0) > 0 || capacityCount > 0 + ? '当日跟进' + : '持续观察'; + 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); + return [ + '【客户告警决策说明】', + ...(release ? [`运行版本:${release}`] : []), + `决策结论:${decision}`, + `筛选范围:${filterLines.length > 0 ? filterLines.join(';') : '全部告警'}`, + `影响车辆:${Number(summary.issueVehicleCount ?? 0).toLocaleString()} 辆;问题记录:${Number(summary.issueRecordCount ?? 0).toLocaleString()} 条`, + `优先队列:P0 ${p0Rows.length.toLocaleString()} / P1 ${p1Rows.length.toLocaleString()}`, + `主要来源:${primaryProtocol ? `${qualityProtocolLabel(primaryProtocol.name)} ${primaryProtocol.count.toLocaleString()} 条` : '-'}`, + `主要问题:${primaryIssue ? `${qualityIssueLabel(primaryIssue.name)} ${primaryIssue.count.toLocaleString()} 条` : '-'}`, + `链路状态:Kafka Lag ${formatLag(health?.kafkaLag)};Redis 在线 ${formatLag(health?.redisOnlineKeys)};存储${storageOk ? '可写' : '异常'};容量发现 ${capacityCount.toLocaleString()} 项`, + `焦点车辆:${focusIssue ? `${focusIssue.vehicleLabel} / ${qualityProtocolLabel(focusIssue.protocol)} / ${qualityIssueLabel(focusIssue.issueType)}` : '-'}`, + '', + '客户处置路径:', + '1. 先确认影响车辆和 P0/P1 数量。', + '2. P0 立即通知责任团队,超过 SLA 自动升级。', + '3. 每辆焦点车都要打开实时、轨迹、RAW、里程证据闭环。', + '4. 恢复后确认告警不再命中,并保留交接包。', + '', + `告警事件:${qualityShareURL()}`, + `实时监控:${appURL(buildAppHash({ page: 'realtime', keyword: focusLookup?.key || filters.keyword || '', protocol: focusIssue?.protocol || filters.protocol }))}`, + `轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: focusLookup?.key || filters.keyword || '', protocol: focusIssue?.protocol || filters.protocol }))}`, + `通知规则:${appURL(buildAppHash({ page: 'notification-rules' }))}` + ].join('\n'); +} + async function copyText(value: string, label: string) { const text = value.trim(); if (!text) { @@ -733,6 +793,11 @@ export function Quality({ : issueVehicleCount > 0 || capacityFindingCount > 0 ? { label: '需关注', color: 'orange' as const, detail: '纳入当日治理,持续观察来源覆盖和字段完整性。' } : { label: '正常', color: 'green' as const, detail: '当前没有明显业务影响,保持监控。' }; + const customerDecision = impactSeverity.label === '高风险' + ? { label: '立即处置', color: 'red' as const, detail: '优先确认客户车辆服务是否受影响,并立即触发通知闭环。' } + : impactSeverity.label === '需关注' + ? { label: '当日跟进', color: 'orange' as const, detail: '纳入当日数据治理,持续观察来源恢复和车辆证据。' } + : { label: '持续观察', color: 'green' as const, detail: '当前没有客户侧高优先级影响,保持常规监控。' }; const primaryProtocolImpact = summaryProtocols[0]; const primaryIssueImpact = summaryIssueTypes[0]; const impactCards = [ @@ -894,10 +959,104 @@ export function Quality({ platformRelease: health?.runtime?.platformRelease }), '业务影响报告'); }; + const copyAlertCustomerDecision = () => { + copyText(alertCustomerDecisionText({ + summary: notificationPlan?.summary ?? summary, + rules, + priorityRows, + health, + filters, + platformRelease: health?.runtime?.platformRelease + }), '客户告警决策说明'); + }; return (
+ +
+
+ + {customerDecision.label} + 0 ? 'red' : 'green'}>P0 {priorityP0Count.toLocaleString()} + {storageWritable ? '存储可写' : '存储异常'} + + 先判断影响车辆,再通知责任团队并回到车辆证据闭环 + + 客户关心的是哪些车辆受影响、是否影响实时/轨迹/里程、是否需要通知、何时恢复;协议来源只作为证据辅助判断。 + + + + + {onOpenNotificationRules ? : null} + +
+
+ {[ + { + label: '影响结论', + value: customerDecision.label, + detail: customerDecision.detail, + color: customerDecision.color, + action: '复制决策', + disabled: false, + onClick: copyAlertCustomerDecision + }, + { + label: '待通知车辆', + value: `P0 ${priorityP0Count.toLocaleString()} / P1 ${priorityP1Count.toLocaleString()}`, + detail: priorityRows.length > 0 ? '已按车辆生成通知、证据和交接包。' : '当前没有进入优先队列的车辆。', + color: priorityP0Count > 0 ? 'red' as const : priorityP1Count > 0 ? 'orange' as const : 'green' as const, + action: '通知汇总', + disabled: priorityRows.length === 0, + onClick: copyPriorityDigest + }, + { + label: '焦点车辆', + value: focusIssue?.vehicleLabel || '暂无', + detail: focusIssue ? `${qualityProtocolLabel(focusIssue.protocol)} / ${qualityIssueLabel(focusIssue.issueType)} / ${focusIssue.sla}` : '没有待处置车辆时保持监控。', + color: focusIssue ? 'blue' as const : 'green' as const, + action: '车辆服务', + disabled: !focusIssue || !focusLookup?.key, + onClick: () => focusIssue && onOpenVehicle(focusLookup?.key ?? '', focusIssue.protocol) + }, + { + label: '主要问题', + value: primaryIssueImpact ? qualityIssueLabel(primaryIssueImpact.name) : '-', + detail: primaryIssueImpact ? `${primaryIssueImpact.count.toLocaleString()} 条命中,点击筛选该问题。` : '暂无客户侧问题类型。', + color: primaryIssueImpact ? 'orange' as const : 'green' as const, + action: '筛选问题', + disabled: !primaryIssueImpact, + onClick: () => primaryIssueImpact && drillIssueType(primaryIssueImpact.name) + }, + { + label: '证据闭环', + value: '实时/轨迹/RAW/里程', + detail: '所有通知都必须回到车辆证据,不只停留在来源或链路指标。', + color: 'blue' as const, + action: '交接包', + disabled: false, + onClick: copyNotificationHandoff + } + ].map((item) => ( + + ))} +
+
+
diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css index 2dd4eb10..358fde76 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -1925,6 +1925,87 @@ button.vp-realtime-command-item:focus-visible { gap: 12px; } +.vp-alert-decision-board { + display: grid; + grid-template-columns: minmax(280px, 0.74fr) minmax(0, 1.26fr); + gap: 16px; +} + +.vp-alert-decision-summary { + min-height: 176px; + padding: 12px; + border: 1px solid rgba(22, 100, 255, 0.28); + border-radius: var(--vp-radius); + background: #f5f9ff; + display: grid; + gap: 10px; + align-content: start; +} + +.vp-alert-decision-summary strong { + color: var(--vp-text); + font-size: 20px; + line-height: 26px; + word-break: break-word; +} + +.vp-alert-decision-summary span { + color: var(--vp-text-muted); + font-size: 13px; + line-height: 20px; +} + +.vp-alert-decision-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +.vp-alert-decision-item { + min-height: 176px; + padding: 12px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + background: #fbfcff; + display: grid; + gap: 10px; + align-content: start; + text-align: left; + font: inherit; + cursor: pointer; +} + +.vp-alert-decision-item:disabled { + cursor: not-allowed; + opacity: 0.68; +} + +.vp-alert-decision-item:not(:disabled):hover, +.vp-alert-decision-item:not(:disabled):focus-visible { + border-color: rgba(22, 100, 255, 0.42); + background: #f5f9ff; + outline: none; +} + +.vp-alert-decision-item > div { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.vp-alert-decision-item strong { + color: var(--vp-text); + font-size: 20px; + line-height: 26px; + word-break: break-word; +} + +.vp-alert-decision-item span { + color: var(--vp-text-muted); + font-size: 13px; + line-height: 20px; +} + .vp-alert-center-board { display: grid; grid-template-columns: minmax(280px, 0.7fr) minmax(0, 1.3fr); @@ -4414,6 +4495,8 @@ button.vp-realtime-command-item:focus-visible { .vp-conclusion-grid, .vp-monitor-layout, .vp-alert-flow, + .vp-alert-decision-board, + .vp-alert-decision-grid, .vp-alert-impact-board, .vp-alert-impact-grid, .vp-alert-dispatch-grid, 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 aea2465b..c34571c2 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -3639,6 +3639,17 @@ test('shows alert rule and notification policy workspace on quality page', async render(); + expect(await screen.findByText('客户告警决策台')).toBeInTheDocument(); + expect(screen.getByText('先判断影响车辆,再通知责任团队并回到车辆证据闭环')).toBeInTheDocument(); + expect(screen.getByText('客户关心的是哪些车辆受影响、是否影响实时/轨迹/里程、是否需要通知、何时恢复;协议来源只作为证据辅助判断。')).toBeInTheDocument(); + expect(screen.getAllByText('立即处置').length).toBeGreaterThan(0); + expect(screen.getByText('待通知车辆')).toBeInTheDocument(); + expect(screen.getByText('证据闭环')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '复制告警决策' })); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【客户告警决策说明】')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('决策结论:立即处置')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('影响车辆:3 辆;问题记录:5 条')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('客户处置路径:')); expect(await screen.findByText('告警事件与通知中心')).toBeInTheDocument(); expect(screen.getByText('客户处置:先判断影响车辆和优先级,再通知责任团队,并用实时、轨迹、RAW、里程证据闭环。', { exact: false })).toBeInTheDocument(); expect(screen.getByText('链路状态')).toBeInTheDocument();