diff --git a/vehicle-data-platform/apps/web/src/pages/Quality.tsx b/vehicle-data-platform/apps/web/src/pages/Quality.tsx index 1cf116df..08b93819 100644 --- a/vehicle-data-platform/apps/web/src/pages/Quality.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Quality.tsx @@ -102,6 +102,13 @@ type AlertRuleRow = { count: number; }; +type PriorityIssueRow = QualityIssueRow & { + priority: 'P0' | 'P1'; + actionLabel: string; + sla: string; + vehicleLabel: string; +}; + function qualityParams(values: Record) { const params = new URLSearchParams(); if (values?.keyword) params.set('keyword', values.keyword); @@ -154,6 +161,13 @@ function issueCount(summary: QualitySummary, issueType: string) { return summary.issueTypes.find((item) => item.name === issueType)?.count ?? 0; } +const issuePriorityWeight: Record = { + NO_SOURCE: 10, + VIN_MISSING: 9, + LINK_GAP: 8, + FIELD_MISSING: 7 +}; + function alertRuleRows(summary: QualitySummary, health: OpsHealth | null): AlertRuleRow[] { const storageWritable = health == null || (health.tdengineWritable && health.mysqlWritable); const capacityCount = health?.capacityFindings?.length ?? 0; @@ -173,6 +187,43 @@ function alertRuleRows(summary: QualitySummary, health: OpsHealth | null): Alert return rows; } +function issueSla(issueType: string) { + return alertRuleTemplates.find((item) => item.issueType === issueType)?.sla ?? '当日闭环'; +} + +function priorityIssueLevel(row: QualityIssueRow): 'P0' | 'P1' { + if (row.severity === 'error' || row.issueType === 'NO_SOURCE') { + return 'P0'; + } + return 'P1'; +} + +function priorityVehicleLabel(row: QualityIssueRow) { + const identity = row.vin?.trim() || row.phone?.trim() || row.sourceEndpoint?.trim() || '-'; + return [row.plate?.trim(), identity].filter(Boolean).join(' / '); +} + +function priorityIssueRows(rows: QualityIssueRow[]): PriorityIssueRow[] { + return rows + .map((row) => { + const action = qualityActionRecommendation(row); + return { + ...row, + priority: priorityIssueLevel(row), + actionLabel: action.label, + sla: issueSla(row.issueType), + vehicleLabel: priorityVehicleLabel(row) + }; + }) + .sort((a, b) => { + if (a.priority !== b.priority) return a.priority === 'P0' ? -1 : 1; + const weightDelta = (issuePriorityWeight[b.issueType] ?? 0) - (issuePriorityWeight[a.issueType] ?? 0); + if (weightDelta !== 0) return weightDelta; + return String(b.lastSeen ?? '').localeCompare(String(a.lastSeen ?? '')); + }) + .slice(0, 5); +} + function ruleStatusColor(count: number, level: string): 'green' | 'orange' | 'red' | 'grey' { if (count <= 0) return 'green'; if (level === 'P0') return 'red'; @@ -221,6 +272,7 @@ export function Quality({ const rules = alertRuleRows(summary, health); const activeRuleCount = rules.filter((item) => item.count > 0).length; const p0RuleCount = rules.filter((item) => item.count > 0 && item.level === 'P0').length; + const priorityRows = priorityIssueRows(issues); const loadIssues = (values: Record = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => { setLoadingIssues(true); @@ -348,6 +400,31 @@ export function Quality({ ))} + + + loading={loadingIssues} + pagination={false} + rowKey={(row?: PriorityIssueRow) => `${row?.priority ?? ''}-${row?.protocol ?? ''}-${row?.issueType ?? ''}-${row?.vehicleLabel ?? ''}-${row?.lastSeen ?? ''}`} + dataSource={priorityRows} + columns={[ + { title: '优先级', width: 90, render: (_: unknown, row: PriorityIssueRow) => {row.priority} }, + { title: '车辆', width: 210, dataIndex: 'vehicleLabel' }, + { title: '问题', width: 140, render: (_: unknown, row: PriorityIssueRow) => qualityIssueLabel(row.issueType) }, + { title: '建议动作', width: 150, dataIndex: 'actionLabel' }, + { title: 'SLA', width: 130, dataIndex: 'sla' }, + { title: '最后时间', width: 170, dataIndex: 'lastSeen' }, + { title: '说明', dataIndex: 'detail' }, + { + title: '操作', + width: 130, + render: (_: unknown, row: PriorityIssueRow) => { + const lookup = qualityIssueVehicleLookup(row); + return ; + } + } + ]} + /> +
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 1fc10fac..ba956995 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -2210,7 +2210,7 @@ test('renders quality issues as vehicle-service governance labels', async () => expect(await screen.findByText('VIN-NO-SOURCE-001')).toBeInTheDocument(); expect(screen.getAllByText('暂无数据来源').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('车辆服务').length).toBeGreaterThanOrEqual(1); - expect(screen.getByText('确认平台转发')).toBeInTheDocument(); + expect(screen.getAllByText('确认平台转发').length).toBeGreaterThan(0); expect(screen.getByText('车辆已绑定但没有任何来源证据,先确认平台转发、端口和订阅。')).toBeInTheDocument(); }); @@ -2296,6 +2296,123 @@ test('shows alert rule and notification policy workspace on quality page', async expect(screen.getByText('站内告警 / 邮件 / 企业微信')).toBeInTheDocument(); }); +test('shows actionable priority queue on quality page', 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, 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: 2, + issueRecordCount: 2, + errorCount: 1, + warningCount: 1, + protocols: [{ name: 'VEHICLE_SERVICE', count: 1 }, { name: 'JT808', count: 1 }], + issueTypes: [{ name: 'NO_SOURCE', count: 1 }, { 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: 'VIN-P0-001', + plate: '粤A优先1', + phone: '13307795425', + sourceEndpoint: '115.231.168.135:43625', + protocol: 'VEHICLE_SERVICE', + issueType: 'NO_SOURCE', + severity: 'error', + lastSeen: '2026-07-03 20:12:10', + detail: '车辆无任何来源' + }, + { + vin: '', + plate: '粤A优先2', + phone: '13307795426', + sourceEndpoint: '115.231.168.135:43626', + protocol: 'JT808', + issueType: 'VIN_MISSING', + severity: 'warning', + lastSeen: '2026-07-03 20:10:10', + detail: '手机号未映射 VIN' + } + ], + total: 2, + limit: 20, + offset: 0 + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/vehicle-service/overview')) { + return { + ok: true, + json: async () => ({ + data: { + vin: 'VIN-P0-001', + plate: '粤A优先1', + sourceCount: 0, + onlineSourceCount: 0, + coverageStatus: 'no_data', + primaryProtocol: 'VEHICLE_SERVICE', + lastSeen: '2026-07-03 20:12:10', + historyCount: 0, + rawCount: 0, + mileageCount: 0, + qualityIssueCount: 1 + }, + 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(screen.getAllByText('P0').length).toBeGreaterThan(0); + expect(screen.getByText('粤A优先1 / VIN-P0-001')).toBeInTheDocument(); + 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(); + fireEvent.click(screen.getAllByRole('button', { name: '进入车辆服务' })[0]); + + await waitFor(() => { + expect(window.location.hash).toBe('#/detail?keyword=VIN-P0-001&protocol=VEHICLE_SERVICE'); + }); +}); + test('opens vehicle service from quality issue with issue source evidence', async () => { window.history.replaceState(null, '', '/#/quality'); vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { @@ -2387,7 +2504,7 @@ test('opens vehicle service from quality issue with issue source evidence', asyn render(); expect(await screen.findByText('13307795425')).toBeInTheDocument(); - expect(screen.getByText('维护身份绑定')).toBeInTheDocument(); + expect(screen.getAllByText('维护身份绑定').length).toBeGreaterThan(0); expect(screen.getByText('数据已有来源但无法归并到 VIN,优先用车牌/手机号补齐绑定。')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '按车牌查车' }));