diff --git a/vehicle-data-platform/apps/web/src/App.tsx b/vehicle-data-platform/apps/web/src/App.tsx index c306316d..d915770b 100644 --- a/vehicle-data-platform/apps/web/src/App.tsx +++ b/vehicle-data-platform/apps/web/src/App.tsx @@ -401,7 +401,7 @@ export default function App() { dashboard: , vehicles: , realtime: , - detail: , + detail: , history: , mileage: , quality: setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length)} onFiltersChange={updateQualityFilters} initialFilters={qualityFilters} /> diff --git a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx index 9c6a1609..d5d92961 100644 --- a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx +++ b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx @@ -9,6 +9,7 @@ import { StatusTag } from '../components/StatusTag'; import { VehicleMap, type VehicleMapPoint } from '../components/VehicleMap'; import { buildAppHash } from '../domain/appRoute'; import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport'; +import { qualityIssueLabel } from '../domain/qualityIssue'; import { isLikelyVIN } from '../domain/vehicleLookup'; import { summarizeVehicleService } from '../domain/vehicleService'; @@ -95,6 +96,28 @@ function vehicleReportFileName(vin: string, protocol?: string) { return `vehicle-service-${vin || 'unknown'}-${protocol || 'all-source'}.csv`; } +function qualityIssuePriority(row: QualityIssueRow) { + if (row.severity === 'error') return 0; + if (row.severity === 'warning') return 1; + return 2; +} + +function issueEvidenceDate(value?: string) { + const match = value?.match(/\d{4}-\d{2}-\d{2}/); + return match?.[0]; +} + +function nextDate(value: string) { + const [year, month, day] = value.split('-').map(Number); + const date = new Date(Date.UTC(year, month - 1, day + 1)); + return date.toISOString().slice(0, 10); +} + +function qualityIssueVehicleLabel(row: QualityIssueRow, fallbackVIN: string) { + const vin = row.vin?.trim() || fallbackVIN; + return [row.plate?.trim(), vin].filter(Boolean).join(' / ') || row.phone || row.sourceEndpoint || '-'; +} + async function copyText(value: string, label: string) { try { await navigator.clipboard.writeText(value); @@ -113,6 +136,8 @@ export function VehicleDetail({ onOpenMileage, onOpenVehicles, onOpenQuality, + onOpenHistoryEvidence, + onOpenRawEvidence, onQueryChange }: { vin: string; @@ -123,6 +148,8 @@ export function VehicleDetail({ onOpenMileage: (vin: string, protocol?: string) => void; onOpenVehicles?: (filters?: Record) => void; onOpenQuality?: (filters?: Record) => void; + onOpenHistoryEvidence?: (filters?: Record) => void; + onOpenRawEvidence?: (filters?: Record) => void; onQueryChange?: (keyword: string, protocol?: string) => void; }) { const [query, setQuery] = useState({ keyword: vin, protocol }); @@ -179,6 +206,14 @@ export function VehicleDetail({ const protocols = useMemo(() => resolution?.protocols?.length ? resolution.protocols : detail?.sources ?? [], [detail?.sources, resolution?.protocols]); const latestRaw = detail?.raw?.items?.[0]; const qualityCount = detail?.quality?.total ?? 0; + const priorityQualityIssue = useMemo(() => { + const items = detail?.quality?.items ?? []; + return [...items].sort((a, b) => { + const severityDelta = qualityIssuePriority(a) - qualityIssuePriority(b); + if (severityDelta !== 0) return severityDelta; + return (Date.parse(b.lastSeen || '') || 0) - (Date.parse(a.lastSeen || '') || 0); + })[0]; + }, [detail?.quality?.items]); const online = resolution?.online || summary?.online || identity?.online || false; const lastSeen = resolution?.lastSeen || summary?.lastSeen || latest?.lastSeen || '-'; const serviceStatus = detail?.serviceStatus; @@ -283,6 +318,31 @@ export function VehicleDetail({ keyword: resolvedVIN, ...(activeProtocol ? { protocol: activeProtocol } : {}) }); + const qualityIssueEvidenceFilters = (row: QualityIssueRow, tab?: 'raw') => { + const dateFrom = issueEvidenceDate(row.lastSeen); + return { + keyword: row.vin?.trim() || resolvedVIN, + protocol: row.protocol || activeProtocol, + ...(dateFrom ? { dateFrom, dateTo: nextDate(dateFrom) } : {}), + ...(tab === 'raw' ? { tab: 'raw', includeFields: 'true' } : {}) + }; + }; + const openQualityIssueHistoryEvidence = (row: QualityIssueRow) => { + const filters = qualityIssueEvidenceFilters(row); + if (onOpenHistoryEvidence) { + onOpenHistoryEvidence(filters); + return; + } + onOpenHistory(filters.keyword, filters.protocol); + }; + const openQualityIssueRawEvidence = (row: QualityIssueRow) => { + const filters = qualityIssueEvidenceFilters(row, 'raw'); + if (onOpenRawEvidence) { + onOpenRawEvidence(filters); + return; + } + onOpenRaw(filters.keyword, filters.protocol); + }; const vehicleServiceURL = (hash: string) => `${window.location.origin}${window.location.pathname}${hash}`; const vehicleServiceHash = (page: 'detail' | 'realtime' | 'history' | 'mileage', filters?: Record) => buildAppHash({ page, @@ -593,6 +653,55 @@ export function VehicleDetail({ + {priorityQualityIssue ? ( + +
+
+ + + {qualityIssueLabel(priorityQualityIssue.issueType)} + + {priorityQualityIssue.protocol || activeProtocol || '全部来源'} + {priorityQualityIssue.lastSeen || '无时间'} + + + {qualityIssueVehicleLabel(priorityQualityIssue, resolvedVIN)} + + + {priorityQualityIssue.detail || '该车存在质量问题,需要结合轨迹和 RAW 证据确认影响范围。'} + +
+ + + + + +
+
+ ) : null} +
{ expect(window.location.hash).toBe('#/vehicles?serviceStatus=degraded&missingProtocol=YUTONG_MQTT'); }); +test('opens single vehicle quality evidence from vehicle detail', async () => { + window.history.replaceState(null, '', '/#/detail?keyword=VIN-DETAIL-QUALITY&protocol=JT808'); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const path = String(input); + if (path.includes('/api/vehicle-service')) { + return { + ok: true, + json: async () => ({ + data: { + vin: 'VIN-DETAIL-QUALITY', + lookupKey: 'VIN-DETAIL-QUALITY', + lookupResolved: true, + resolution: { + lookupKey: 'VIN-DETAIL-QUALITY', + resolved: true, + vin: 'VIN-DETAIL-QUALITY', + plate: '粤A详情告警', + phone: '13307795425', + oem: 'G7s', + protocols: ['JT808'], + online: true, + lastSeen: '2026-07-03T20:12:10+08:00' + }, + realtimeSummary: { + vin: 'VIN-DETAIL-QUALITY', + plate: '粤A详情告警', + phone: '13307795425', + oem: 'G7s', + protocols: ['JT808'], + sourceCount: 1, + onlineSourceCount: 1, + online: true, + primaryProtocol: 'JT808', + longitude: 113.2, + latitude: 23.1, + speedKmh: 32, + socPercent: 0, + totalMileageKm: 1000.5, + lastSeen: '2026-07-03T20:12:10+08:00' + }, + serviceOverview: { + vin: 'VIN-DETAIL-QUALITY', + plate: '粤A详情告警', + phone: '13307795425', + oem: 'G7s', + protocols: ['JT808'], + primaryProtocol: 'JT808', + coverageStatus: 'online', + sourceCount: 1, + onlineSourceCount: 1, + lastSeen: '2026-07-03T20:12:10+08:00', + realtimeCount: 1, + historyCount: 8, + rawCount: 16, + mileageCount: 2, + qualityIssueCount: 1 + }, + sources: ['JT808'], + sourceStatus: [ + { protocol: 'JT808', online: true, lastSeen: '2026-07-03T20:12:10+08:00', hasRealtime: true, hasHistory: true, hasRaw: true, hasMileage: true } + ], + realtime: [], + history: { items: [], total: 8, limit: 20, offset: 0 }, + raw: { items: [], total: 16, limit: 10, offset: 0 }, + mileage: { items: [], total: 2, limit: 20, offset: 0 }, + quality: { + items: [ + { + vin: 'VIN-DETAIL-QUALITY', + plate: '粤A详情告警', + phone: '13307795425', + protocol: 'JT808', + issueType: 'VIN_MISSING', + severity: 'error', + lastSeen: '2026-07-03 20:12:10', + detail: '详情页质量问题需要处理', + sourceEndpoint: '115.231.168.135:43625' + } + ], + total: 1, + limit: 20, + offset: 0 + } + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + return { + ok: true, + json: async () => ({ + data: path.includes('/api/ops/health') + ? { linkHealth: [], kafkaLag: 0, redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } } + : { items: [], total: 0, limit: 10, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + }); + + render(); + + expect(await screen.findByText('单车处置建议')).toBeInTheDocument(); + expect(screen.getByText('VIN 缺失')).toBeInTheDocument(); + expect(screen.getAllByText('详情页质量问题需要处理').length).toBeGreaterThan(0); + expect(screen.getAllByText('粤A详情告警 / VIN-DETAIL-QUALITY').length).toBeGreaterThan(0); + + fireEvent.click(screen.getByRole('button', { name: '单车告警轨迹证据' })); + await waitFor(() => { + expect(window.location.hash).toBe('#/history?keyword=VIN-DETAIL-QUALITY&protocol=JT808&dateFrom=2026-07-03&dateTo=2026-07-04'); + }); +}); + test('shows canonical vehicle archive on detail', async () => { window.history.replaceState(null, '', '/#/detail?keyword=VIN-ARCHIVE-001'); vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {