diff --git a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx index a8d85c9d..f7044e1f 100644 --- a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx @@ -8,6 +8,7 @@ import { SourceStatusTags } from '../components/SourceStatusTags'; import { StatusTag } from '../components/StatusTag'; import { VehicleMap, type VehicleMapPoint } from '../components/VehicleMap'; import { isAMapConfigured } from '../config/appConfig'; +import { qualityIssueLabel, qualityProtocolLabel } from '../domain/qualityIssue'; import { qualityIssueVehicleLookup } from '../domain/vehicleLookup'; const statusColor: Record = { @@ -86,6 +87,33 @@ function hasValidCoordinate(row: VehicleRealtimeRow) { return Number.isFinite(row.longitude) && Number.isFinite(row.latitude) && row.longitude !== 0 && row.latitude !== 0; } +function issueEvidenceDate(value?: string) { + const match = String(value ?? '').match(/^(\d{4})-(\d{2})-(\d{2})/); + return match ? `${match[1]}-${match[2]}-${match[3]}` : ''; +} + +function nextDate(value: string) { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim()); + if (!match) return ''; + const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]) + 1)); + return date.toISOString().slice(0, 10); +} + +function priorityIssueVehicleLabel(row: QualityIssueRow) { + const identity = row.vin?.trim() && row.vin !== 'unknown' ? row.vin.trim() : row.phone?.trim(); + return [row.plate?.trim(), identity].filter(Boolean).join(' / ') || row.sourceEndpoint || '-'; +} + +function priorityIssueEvidenceFilters(row: QualityIssueRow) { + const lookup = qualityIssueVehicleLookup(row); + const dateFrom = issueEvidenceDate(row.lastSeen); + return { + keyword: lookup.key, + protocol: row.protocol, + ...(dateFrom ? { dateFrom, dateTo: nextDate(dateFrom) } : {}) + }; +} + function sourceConsistencyAction(row: VehicleCoverageRow, onFilter: (filters: Record) => void) { const consistency = row.sourceConsistency; if (!consistency) { @@ -235,6 +263,8 @@ export function Dashboard({ const commandLocatedCount = locations.filter(hasValidCoordinate).length; const commandDegradedCount = locations.filter((row) => row.onlineSourceCount <= 0 || row.onlineSourceCount < row.sourceCount).length; const highPriorityIssue = qualityIssues.find((item) => item.severity === 'error') ?? qualityIssues[0]; + const highPriorityLookup = highPriorityIssue ? qualityIssueVehicleLookup(highPriorityIssue) : undefined; + const highPriorityEvidenceFilters = highPriorityIssue ? priorityIssueEvidenceFilters(highPriorityIssue) : undefined; const unhealthyLinkCount = (summary?.linkHealth ?? []).filter((item) => item.status !== 'ok').length; const commandMapPoints: VehicleMapPoint[] = locations.map((row, index) => ({ id: row.vin || `${row.primaryProtocol || 'source'}-${index}`, @@ -389,6 +419,57 @@ export function Dashboard({ ))} + {highPriorityIssue ? ( + +
+
+ + + {highPriorityIssue.severity === 'error' ? 'P0' : 'P1'} + + {qualityIssueLabel(highPriorityIssue.issueType)} + {qualityProtocolLabel(highPriorityIssue.protocol)} + {highPriorityIssue.lastSeen || '-'} + + + {priorityIssueVehicleLabel(highPriorityIssue)} + + {highPriorityIssue.detail || '暂无告警详情'} +
+ + + + + + +
+
+ ) : null}
{capabilities.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 98d04965..1487bef0 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -740,6 +740,18 @@ body { line-height: 20px; } +.vp-priority-alert { + min-height: 92px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.vp-priority-alert-main { + min-width: 0; +} + .vp-capability-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); 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 3072c27c..461b59de 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -820,6 +820,121 @@ test('dashboard exposes end-to-end operations workflow entries', async () => { }); }); +test('dashboard surfaces highest priority quality issue with evidence shortcuts', async () => { + window.history.replaceState(null, '', '/#/dashboard'); + 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: 0, capacityFindings: [], redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/dashboard/summary')) { + return { + ok: true, + json: async () => ({ + data: { + onlineVehicles: 88, + activeToday: 96, + frameToday: 1286320, + issueVehicles: 7, + kafkaLag: 0, + protocols: [], + serviceStatuses: [], + linkHealth: [] + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/vehicle-service/summary')) { + return { + ok: true, + json: async () => ({ + data: { + totalVehicles: 1033, + boundVehicles: 1024, + onlineVehicles: 88, + singleSourceVehicles: 391, + multiSourceVehicles: 181, + noDataVehicles: 0, + identityRequiredVehicles: 0, + archiveIncompleteVehicles: 0, + serviceStatuses: [], + protocols: [], + missingSources: [], + archiveMissingFields: [] + }, + 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: 'JT808 数据缺少 VIN 映射' + }], + total: 1, + limit: 5, + offset: 0 + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/quality/summary')) { + return { + ok: true, + json: async () => ({ + data: { issueVehicleCount: 7, issueRecordCount: 9, errorCount: 1, warningCount: 8, protocols: [], issueTypes: [] }, + 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.getByText('粤A告警1 / 13307795425')).toBeInTheDocument(); + expect(screen.getByText('VIN 缺失')).toBeInTheDocument(); + expect(screen.getByText('JT808 数据缺少 VIN 映射')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: '首页告警轨迹证据' })); + + await waitFor(() => { + expect(window.location.hash).toBe('#/history?keyword=%E7%B2%A4A%E5%91%8A%E8%AD%A61&protocol=JT808&dateFrom=2026-07-03&dateTo=2026-07-04'); + }); +}); + test('dashboard keeps core vehicle service metrics when preview requests fail', async () => { window.history.replaceState(null, '', '/#/dashboard'); vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {