diff --git a/vehicle-data-platform/apps/web/src/pages/Quality.tsx b/vehicle-data-platform/apps/web/src/pages/Quality.tsx index da0e098a..6e4ea07e 100644 --- a/vehicle-data-platform/apps/web/src/pages/Quality.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Quality.tsx @@ -235,6 +235,7 @@ function priorityIssueRows(rows: QualityIssueRow[]): PriorityIssueRow[] { ...row, priority: priorityIssueLevel(row), actionLabel: action.label, + actionDetail: action.detail, sla: issueSla(row.issueType), vehicleLabel: priorityVehicleLabel(row) }; @@ -273,6 +274,63 @@ function priorityIssueNotificationText(row: PriorityIssueRow) { ].join('\n'); } +function alertRuleTemplate(issueType: string) { + return alertRuleTemplates.find((item) => item.issueType === issueType); +} + +function acceptanceCriteria(row: PriorityIssueRow) { + if (row.issueType === 'VIN_MISSING') { + return '车辆能解析到 VIN,实时/历史/RAW 证据可通过同一车辆服务查询'; + } + if (row.issueType === 'NO_SOURCE') { + return '至少一个生产来源恢复在线,车辆实时状态、历史轨迹和 RAW 证据可查询'; + } + if (row.issueType === 'LINK_GAP') { + return '来源恢复连续上报,实时在线窗口和历史轨迹无异常断点'; + } + if (row.issueType === 'FIELD_MISSING') { + return '缺失字段恢复解析,统计、定位或里程依赖的核心字段可查询'; + } + return '车辆服务状态恢复正常,告警不再命中当前规则'; +} + +function priorityIssueTicketText(row: PriorityIssueRow) { + const lookup = qualityIssueVehicleLookup(row); + const dateFrom = issueEvidenceDate(row.lastSeen); + const dateTo = nextDate(dateFrom); + const evidenceFilters = { + ...(dateFrom ? { dateFrom } : {}), + ...(dateTo ? { dateTo } : {}) + }; + const rule = alertRuleTemplate(row.issueType); + return [ + '【告警处置工单】', + `标题:[${row.priority}] ${qualityIssueLabel(row.issueType)} - ${row.vehicleLabel}`, + `优先级:${row.priority}`, + `责任团队:${rule?.owner || '数据治理'}`, + `触发规则:${rule?.trigger || qualityIssueLabel(row.issueType)}`, + `车辆:${row.vehicleLabel}`, + `数据来源:${qualityProtocolLabel(row.protocol)}`, + `来源地址:${row.sourceEndpoint || '-'}`, + `问题:${qualityIssueLabel(row.issueType)}`, + `建议动作:${row.actionLabel}`, + `SLA:${row.sla}`, + `最后时间:${row.lastSeen || '-'}`, + `问题说明:${row.detail || '-'}`, + `处置说明:${row.actionDetail || '-'}`, + '处置步骤:', + '1. 打开车辆服务核对当前身份解析结果', + '2. 核对实时定位、轨迹证据和 RAW 证据', + '3. 完成后确认车辆服务状态恢复,问题不再命中当前告警', + `验收标准:${acceptanceCriteria(row)}`, + `实时定位:${appURL(buildAppHash({ page: 'realtime', keyword: lookup.key, protocol: row.protocol }))}`, + `轨迹证据:${appURL(buildAppHash({ page: 'history', keyword: lookup.key, protocol: row.protocol, filters: evidenceFilters }))}`, + `RAW证据:${appURL(buildAppHash({ page: 'history', keyword: lookup.key, protocol: row.protocol, filters: { tab: 'raw', ...evidenceFilters, includeFields: 'true' } }))}`, + `车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: lookup.key, protocol: row.protocol }))}`, + `告警筛选:${qualityShareURL()}` + ].join('\n'); +} + function priorityIssueDigestText(rows: PriorityIssueRow[], summary: QualitySummary) { const p0Count = rows.filter((row) => row.priority === 'P0').length; const p1Count = rows.filter((row) => row.priority === 'P1').length; @@ -591,6 +649,7 @@ export function Quality({ + ); 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 358875c9..adde10a3 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -2723,6 +2723,97 @@ test('copies notification text from quality priority queue', async () => { expect(writeText).toHaveBeenCalledWith(expect.stringContaining('告警筛选:http://localhost:3000/#/quality')); }); +test('copies dispatch ticket from quality priority queue', async () => { + window.history.replaceState(null, '', '/#/quality'); + 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')) { + return { + ok: true, + json: async () => ({ + data: { linkHealth: [], kafkaLag: 0, activeConnections: 120000, capacityFindings: [], redisOnlineKeys: 368, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/quality/summary')) { + return { + ok: true, + json: async () => ({ + data: { + issueVehicleCount: 1, + issueRecordCount: 1, + errorCount: 1, + warningCount: 0, + protocols: [{ name: 'JT808', count: 1 }], + issueTypes: [{ name: 'VIN_MISSING', count: 1 }] + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/quality/issues')) { + return { + ok: true, + json: async () => ({ + data: { + items: [{ + vin: '', + plate: '粤A工单1', + phone: '13307795425', + sourceEndpoint: '115.231.168.135:43625', + protocol: 'JT808', + issueType: 'VIN_MISSING', + severity: 'error', + lastSeen: '2026-07-03 20:12:10', + detail: '手机号未映射 VIN,需要维护 binding' + }], + total: 1, + limit: 20, + offset: 0 + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + return { + ok: true, + json: async () => ({ + data: { items: [], total: 0, limit: 20, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + }); + + render(); + + expect(await screen.findByText('处置优先队列')).toBeInTheDocument(); + fireEvent.click(await screen.findByRole('button', { name: '复制工单' })); + + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【告警处置工单】')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('标题:[P0] VIN 缺失 - 粤A工单1 / 13307795425')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('责任团队:车辆档案')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('触发规则:来源有数据但无法归并到 VIN')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('数据来源:JT808')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('来源地址:115.231.168.135:43625')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('建议动作:维护身份绑定')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('处置步骤:')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('1. 打开车辆服务核对当前身份解析结果')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('2. 核对实时定位、轨迹证据和 RAW 证据')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('3. 完成后确认车辆服务状态恢复,问题不再命中当前告警')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('验收标准:车辆能解析到 VIN,实时/历史/RAW 证据可通过同一车辆服务查询')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('车辆服务:http://localhost:3000/#/detail?keyword=%E7%B2%A4A%E5%B7%A5%E5%8D%951&protocol=JT808')); +}); + test('copies priority queue notification digest on quality page', async () => { window.history.replaceState(null, '', '/#/quality'); const writeText = vi.fn(() => Promise.resolve());