From cb49798b6d85871c5ee4399fd9caee0d09d71dca Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 14:45:39 +0800 Subject: [PATCH] feat(platform): add quality escalation clock --- .../apps/web/src/pages/Quality.tsx | 80 ++++++++++++++++ .../apps/web/src/test/App.test.tsx | 95 ++++++++++++++++++- 2 files changed, 171 insertions(+), 4 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/pages/Quality.tsx b/vehicle-data-platform/apps/web/src/pages/Quality.tsx index 347ded73..26265e38 100644 --- a/vehicle-data-platform/apps/web/src/pages/Quality.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Quality.tsx @@ -355,6 +355,53 @@ function priorityIssueDigestText(rows: PriorityIssueRow[], summary: QualitySumma ].join('\n'); } +function slaMinutes(row: PriorityIssueRow) { + if (row.issueType === 'NO_SOURCE') return 30; + if (row.issueType === 'VIN_MISSING') return 120; + if (row.issueType === 'LINK_GAP') return 60; + if (row.issueType === 'FIELD_MISSING') return 24 * 60; + if (row.sla.includes('15 分钟')) return 15; + if (row.sla.includes('30 分钟')) return 30; + if (row.sla.includes('1 小时')) return 60; + if (row.sla.includes('2 小时')) return 120; + return 24 * 60; +} + +function formatDuration(minutes: number) { + const value = Math.max(0, Math.round(minutes)); + if (value < 60) return `${value} 分钟`; + const hours = Math.floor(value / 60); + const rest = value % 60; + return rest > 0 ? `${hours} 小时 ${rest} 分钟` : `${hours} 小时`; +} + +function parseIssueTime(value?: string) { + const raw = String(value ?? '').trim(); + if (!raw) return Number.NaN; + const normalized = raw.includes('T') ? raw : raw.replace(' ', 'T'); + const parsed = Date.parse(normalized); + if (Number.isFinite(parsed)) return parsed; + return Date.parse(`${raw.replace(' ', 'T')}+08:00`); +} + +function escalationClock(row: PriorityIssueRow, nowMs = Date.now()) { + const lastSeenMs = parseIssueTime(row.lastSeen); + if (!Number.isFinite(lastSeenMs)) { + return { status: 'SLA 待确认', color: 'grey' as const, detail: '缺少告警时间', deadline: '-' }; + } + const minutes = slaMinutes(row); + const deadlineMs = lastSeenMs + minutes * 60 * 1000; + const diffMinutes = (deadlineMs - nowMs) / 60000; + const deadline = new Date(deadlineMs).toLocaleString('zh-CN', { hour12: false }); + if (diffMinutes < 0) { + return { status: '超 SLA', color: 'red' as const, detail: `已超 ${formatDuration(Math.abs(diffMinutes))}`, deadline }; + } + if (diffMinutes <= Math.max(15, minutes * 0.2)) { + return { status: '即将升级', color: 'orange' as const, detail: `剩余 ${formatDuration(diffMinutes)}`, deadline }; + } + return { status: 'SLA 正常', color: 'green' as const, detail: `剩余 ${formatDuration(diffMinutes)}`, deadline }; +} + function ruleStatusColor(count: number, level: string): 'green' | 'orange' | 'red' | 'grey' { if (count <= 0) return 'green'; if (level === 'P0') return 'red'; @@ -662,6 +709,39 @@ export function Quality({ ]} /> + + + loading={loadingIssues} + pagination={false} + rowKey={(row?: PriorityIssueRow) => `clock-${row?.priority ?? ''}-${row?.protocol ?? ''}-${row?.issueType ?? ''}-${row?.vehicleLabel ?? ''}-${row?.lastSeen ?? ''}`} + dataSource={priorityRows} + columns={[ + { + title: '升级状态', + width: 120, + render: (_: unknown, row: PriorityIssueRow) => { + const clock = escalationClock(row); + return {clock.status}; + } + }, + { title: '车辆', width: 210, dataIndex: 'vehicleLabel' }, + { title: '优先级', width: 90, render: (_: unknown, row: PriorityIssueRow) => {row.priority} }, + { title: '问题', width: 140, render: (_: unknown, row: PriorityIssueRow) => qualityIssueLabel(row.issueType) }, + { title: 'SLA', width: 130, dataIndex: 'sla' }, + { + title: '剩余/超时', + width: 150, + render: (_: unknown, row: PriorityIssueRow) => escalationClock(row).detail + }, + { + title: '升级截止', + width: 210, + render: (_: unknown, row: PriorityIssueRow) => escalationClock(row).deadline + }, + { title: '建议动作', dataIndex: 'actionLabel' } + ]} + /> +
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 8b230e1a..2e0145d2 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -2705,11 +2705,11 @@ test('shows actionable priority queue on quality page', async () => { expect(await screen.findByText('处置优先队列')).toBeInTheDocument(); expect(screen.getAllByText('P0').length).toBeGreaterThan(0); - expect(screen.getByText('粤A优先1 / VIN-P0-001')).toBeInTheDocument(); + expect(screen.getAllByText('粤A优先1 / VIN-P0-001').length).toBeGreaterThan(0); expect(screen.getAllByText('确认平台转发').length).toBeGreaterThan(0); expect(screen.getAllByText('30 分钟确认').length).toBeGreaterThan(0); expect(screen.getAllByText('P1').length).toBeGreaterThan(0); - expect(screen.getByText('粤A优先2 / 13307795426')).toBeInTheDocument(); + expect(screen.getAllByText('粤A优先2 / 13307795426').length).toBeGreaterThan(0); fireEvent.click(screen.getAllByRole('button', { name: '进入车辆服务' })[0]); await waitFor(() => { @@ -2800,8 +2800,8 @@ test('uses backend quality notification plan on quality page', async () => { render(); - expect(await screen.findByText('后端车辆标签 / VIN-PLAN-001')).toBeInTheDocument(); - expect(screen.getByText('后端建议动作')).toBeInTheDocument(); + expect((await screen.findAllByText('后端车辆标签 / VIN-PLAN-001')).length).toBeGreaterThan(0); + expect(screen.getAllByText('后端建议动作').length).toBeGreaterThan(0); expect(screen.getAllByText('10 分钟确认').length).toBeGreaterThan(0); expect(screen.getByText('后端无来源规则')).toBeInTheDocument(); expect(screen.getByText('后端 P0 通知策略')).toBeInTheDocument(); @@ -3638,6 +3638,93 @@ test('opens same-day history evidence from quality issue row', async () => { }); }); +test('quality page surfaces escalation clock for priority issues', async () => { + window.history.replaceState(null, '', '/#/quality'); + 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, redisOnlineKeys: 0, 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: 2, + issueRecordCount: 2, + errorCount: 1, + warningCount: 1, + protocols: [{ name: 'VEHICLE_SERVICE', count: 1 }, { name: 'JT808', count: 1 }], + issueTypes: [{ name: 'NO_SOURCE', count: 1 }, { name: 'LINK_GAP', count: 1 }] + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/quality/issues')) { + return { + ok: true, + json: async () => ({ + data: { + items: [ + { + vin: 'VIN-QUALITY-OVERDUE', + plate: '粤A超时', + phone: '13307790001', + sourceEndpoint: 'vehicle_identity_binding', + protocol: 'VEHICLE_SERVICE', + issueType: 'NO_SOURCE', + severity: 'error', + lastSeen: '2026-07-03 08:00:00', + detail: '绑定车辆无来源' + }, + { + vin: 'VIN-QUALITY-RECENT', + plate: '粤A正常', + phone: '13307790002', + sourceEndpoint: '115.231.168.135:43625', + protocol: 'JT808', + issueType: 'LINK_GAP', + severity: 'warning', + lastSeen: new Date().toISOString(), + detail: '最近链路间断' + } + ], + total: 2, + 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(); + expect(await screen.findByText('超 SLA')).toBeInTheDocument(); + expect(screen.getByText('SLA 正常')).toBeInTheDocument(); + expect(screen.getByText('VIN-QUALITY-OVERDUE')).toBeInTheDocument(); +}); + test('opens same-day raw evidence from quality issue row', async () => { window.history.replaceState(null, '', '/#/quality'); vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {