diff --git a/vehicle-data-platform/apps/api/internal/platform/handler_test.go b/vehicle-data-platform/apps/api/internal/platform/handler_test.go index 203eaf7e..c518f00c 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -172,6 +172,38 @@ func TestHandlerVehicleServiceIncludesVehicleLevelStatus(t *testing.T) { } } +func TestHandlerVehicleServiceIncludesUnifiedOverview(t *testing.T) { + handler := NewHandler(NewService(NewMockStore())) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/vehicle-service?keyword=粤AG18312", nil) + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Data VehicleDetail `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String()) + } + if body.Data.ServiceOverview == nil { + t.Fatalf("vehicle service should include serviceOverview: %s", rec.Body.String()) + } + overview := body.Data.ServiceOverview + if overview.VIN != "LB9A32A24R0LS1426" || overview.Plate != "粤AG18312" { + t.Fatalf("overview should expose canonical vehicle identity, got %+v", overview) + } + if overview.SourceCount != 2 || overview.OnlineSourceCount != 1 || overview.CoverageStatus != "partial" { + t.Fatalf("overview should summarize source coverage, got %+v", overview) + } + if overview.LastSeen == "" || overview.PrimaryProtocol == "" { + t.Fatalf("overview should expose latest service time and primary source, got %+v", overview) + } + if overview.RealtimeCount == 0 || overview.HistoryCount == 0 || overview.RawCount == 0 || overview.MileageCount == 0 { + t.Fatalf("overview should expose available data counts, got %+v", overview) + } +} + func TestHandlerVehicleDetailResolvesPlateToVIN(t *testing.T) { handler := NewHandler(NewService(NewMockStore())) rec := httptest.NewRecorder() diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index 1a48988a..81efec57 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -84,6 +84,7 @@ type VehicleDetail struct { Identity *VehicleRow `json:"identity,omitempty"` RealtimeSummary *VehicleRealtimeRow `json:"realtimeSummary,omitempty"` ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"` + ServiceOverview *VehicleServiceOverview `json:"serviceOverview,omitempty"` Sources []string `json:"sources"` SourceStatus []VehicleSourceStatus `json:"sourceStatus"` Realtime []RealtimeLocationRow `json:"realtime"` @@ -93,6 +94,24 @@ type VehicleDetail struct { Quality Page[QualityIssueRow] `json:"quality"` } +type VehicleServiceOverview struct { + VIN string `json:"vin"` + Plate string `json:"plate"` + Phone string `json:"phone"` + OEM string `json:"oem"` + Protocols []string `json:"protocols"` + PrimaryProtocol string `json:"primaryProtocol"` + CoverageStatus string `json:"coverageStatus"` + SourceCount int `json:"sourceCount"` + OnlineSourceCount int `json:"onlineSourceCount"` + LastSeen string `json:"lastSeen"` + RealtimeCount int `json:"realtimeCount"` + HistoryCount int `json:"historyCount"` + RawCount int `json:"rawCount"` + MileageCount int `json:"mileageCount"` + QualityIssueCount int `json:"qualityIssueCount"` +} + type VehicleServiceStatus struct { Status string `json:"status"` Severity string `json:"severity"` diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index 474288a6..e36c9aa2 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -115,18 +115,19 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string return VehicleDetail{}, err } return VehicleDetail{ - VIN: "", - LookupKey: keyword, - LookupResolved: false, - Resolution: &resolution, - ServiceStatus: buildVehicleServiceStatus(false, nil), - Sources: []string{}, - SourceStatus: []VehicleSourceStatus{}, - Realtime: []RealtimeLocationRow{}, - History: Page[HistoryLocationRow]{Items: []HistoryLocationRow{}, Limit: 20, Offset: 0}, - Raw: Page[RawFrameRow]{Items: []RawFrameRow{}, Limit: 10, Offset: 0}, - Mileage: Page[DailyMileageRow]{Items: []DailyMileageRow{}, Limit: 20, Offset: 0}, - Quality: quality, + VIN: "", + LookupKey: keyword, + LookupResolved: false, + Resolution: &resolution, + ServiceStatus: buildVehicleServiceStatus(false, nil), + ServiceOverview: buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, quality), + Sources: []string{}, + SourceStatus: []VehicleSourceStatus{}, + Realtime: []RealtimeLocationRow{}, + History: Page[HistoryLocationRow]{Items: []HistoryLocationRow{}, Limit: 20, Offset: 0}, + Raw: Page[RawFrameRow]{Items: []RawFrameRow{}, Limit: 10, Offset: 0}, + Mileage: Page[DailyMileageRow]{Items: []DailyMileageRow{}, Limit: 20, Offset: 0}, + Quality: quality, }, nil } @@ -179,6 +180,7 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string Identity: identity, RealtimeSummary: summary, ServiceStatus: buildVehicleServiceStatus(true, sourceStatus), + ServiceOverview: buildVehicleServiceOverview(resolvedVIN, keyword, &resolution, identity, summary, sourceStatus, history, raw, mileage, quality), Sources: sourceNames(sourceStatus), SourceStatus: sourceStatus, Realtime: realtime.Items, @@ -189,6 +191,94 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string }, nil } +func buildVehicleServiceOverview(vin string, lookupKey string, resolution *VehicleIdentityResolution, identity *VehicleRow, summary *VehicleRealtimeRow, statuses []VehicleSourceStatus, history Page[HistoryLocationRow], raw Page[RawFrameRow], mileage Page[DailyMileageRow], quality Page[QualityIssueRow]) *VehicleServiceOverview { + overview := &VehicleServiceOverview{ + VIN: strings.TrimSpace(vin), + Plate: "", + Phone: "", + OEM: "", + Protocols: sourceNames(statuses), + CoverageStatus: "no_data", + SourceCount: len(statuses), + OnlineSourceCount: 0, + HistoryCount: pageCount(history.Total, len(history.Items)), + RawCount: pageCount(raw.Total, len(raw.Items)), + MileageCount: pageCount(mileage.Total, len(mileage.Items)), + QualityIssueCount: pageCount(quality.Total, len(quality.Items)), + } + if overview.VIN == "" && resolution != nil && resolution.Resolved { + overview.VIN = strings.TrimSpace(resolution.VIN) + } + if overview.VIN == "" { + overview.VIN = strings.TrimSpace(lookupKey) + } + if resolution != nil { + overview.Plate = resolution.Plate + overview.Phone = resolution.Phone + overview.OEM = resolution.OEM + if len(overview.Protocols) == 0 { + overview.Protocols = append([]string(nil), resolution.Protocols...) + overview.SourceCount = len(overview.Protocols) + } + overview.LastSeen = latestString(overview.LastSeen, resolution.LastSeen) + } + if identity != nil { + overview.Plate = firstNonEmpty(overview.Plate, identity.Plate) + overview.Phone = firstNonEmpty(overview.Phone, identity.Phone) + overview.OEM = firstNonEmpty(overview.OEM, identity.OEM) + overview.LastSeen = latestString(overview.LastSeen, identity.LastSeen) + } + if summary != nil { + overview.Plate = firstNonEmpty(overview.Plate, summary.Plate) + overview.Phone = firstNonEmpty(overview.Phone, summary.Phone) + overview.OEM = firstNonEmpty(overview.OEM, summary.OEM) + overview.PrimaryProtocol = summary.PrimaryProtocol + overview.LastSeen = latestString(overview.LastSeen, summary.LastSeen) + overview.RealtimeCount = pageCount(summary.SourceCount, len(summary.Protocols)) + if len(statuses) == 0 { + overview.SourceCount = summary.SourceCount + overview.OnlineSourceCount = summary.OnlineSourceCount + } + if len(overview.Protocols) == 0 { + overview.Protocols = append([]string(nil), summary.Protocols...) + } + } + for _, status := range statuses { + if status.Online { + overview.OnlineSourceCount++ + } + if overview.PrimaryProtocol == "" && status.Online { + overview.PrimaryProtocol = status.Protocol + } + overview.LastSeen = latestString(overview.LastSeen, status.LastSeen) + } + if overview.PrimaryProtocol == "" && len(overview.Protocols) > 0 { + overview.PrimaryProtocol = overview.Protocols[0] + } + overview.CoverageStatus = coverageStatus(overview.SourceCount, overview.OnlineSourceCount) + return overview +} + +func pageCount(total int, itemCount int) int { + if total > 0 { + return total + } + return itemCount +} + +func coverageStatus(sourceCount int, onlineSourceCount int) string { + if sourceCount <= 0 { + return "no_data" + } + if onlineSourceCount <= 0 { + return "offline" + } + if onlineSourceCount < sourceCount { + return "partial" + } + return "online" +} + func (s *Service) enrichVehicleSourceStatus(ctx context.Context, vin string, statuses []VehicleSourceStatus) []VehicleSourceStatus { for index := range statuses { protocol := statuses[index].Protocol diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index aaad15d8..b9317f42 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -81,6 +81,7 @@ export interface VehicleDetail { identity?: VehicleRow; realtimeSummary?: VehicleRealtimeRow; serviceStatus?: VehicleServiceStatus; + serviceOverview?: VehicleServiceOverview; sources: string[]; sourceStatus: VehicleSourceStatus[]; realtime: RealtimeLocationRow[]; @@ -90,6 +91,24 @@ export interface VehicleDetail { quality: Page; } +export interface VehicleServiceOverview { + vin: string; + plate: string; + phone: string; + oem: string; + protocols: string[]; + primaryProtocol: string; + coverageStatus: string; + sourceCount: number; + onlineSourceCount: number; + lastSeen: string; + realtimeCount: number; + historyCount: number; + rawCount: number; + mileageCount: number; + qualityIssueCount: number; +} + export interface VehicleServiceStatus { status: string; severity: string; diff --git a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx index be227519..17d2fe33 100644 --- a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx +++ b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx @@ -19,6 +19,13 @@ const sourceCapabilities: Array<{ key: keyof Pick = { + online: '全部在线', + partial: '部分在线', + offline: '全部离线', + no_data: '暂无来源' +}; + export function VehicleDetail({ vin, protocol, @@ -78,6 +85,7 @@ export function VehicleDetail({ const summary = detail?.realtimeSummary; const latest = detail?.realtime?.[0]; const resolution = detail?.resolution; + const overview = detail?.serviceOverview; const resolvedVIN = resolution?.vin || detail?.vin || summary?.vin || identity?.vin || latest?.vin || query.keyword; const hasResolvedVIN = resolution?.resolved ?? detail?.lookupResolved ?? isLikelyVIN(resolvedVIN); const displayVIN = hasResolvedVIN ? resolvedVIN : '-'; @@ -156,6 +164,24 @@ export function VehicleDetail({ ) : null} + {overview ? ( + + {coverageStatusText[overview.coverageStatus] ?? overview.coverageStatus} }, + { key: '主来源', value: overview.primaryProtocol || '-' }, + { key: '最后上报', value: overview.lastSeen || '-' }, + { key: '数据规模', value: `历史 ${overview.historyCount} / RAW ${overview.rawCount} / 里程 ${overview.mileageCount}` }, + { key: '实时来源', value: overview.realtimeCount }, + { key: '质量问题', value: 0 ? 'orange' : 'green'}>{overview.qualityIssueCount} } + ]} + /> + + ) : null} +
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicle-service?keyword=VIN001&protocol=JT808'), undefined); }); +test('shows unified service overview on vehicle detail', async () => { + window.history.replaceState(null, '', '/#/detail?keyword=VIN001'); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const path = String(input); + if (path.includes('/api/vehicle-service')) { + return { + ok: true, + json: async () => ({ + data: { + vin: 'VIN001', + lookupKey: 'VIN001', + lookupResolved: true, + serviceOverview: { + vin: 'VIN001', + plate: '粤AG18312', + phone: '13307795425', + oem: 'G7s', + protocols: ['GB32960', 'JT808', 'YUTONG_MQTT'], + primaryProtocol: 'GB32960', + coverageStatus: 'partial', + sourceCount: 3, + onlineSourceCount: 2, + lastSeen: '2026-07-03 20:12:10', + realtimeCount: 3, + historyCount: 12, + rawCount: 34, + mileageCount: 7, + qualityIssueCount: 1 + }, + serviceStatus: { + status: 'degraded', + severity: 'warning', + title: '部分来源离线', + detail: '2/3 个来源在线', + sourceCount: 3, + onlineSourceCount: 2 + }, + sources: ['GB32960', 'JT808', 'YUTONG_MQTT'], + sourceStatus: [], + realtime: [], + history: { items: [], total: 12, limit: 20, offset: 0 }, + raw: { items: [], total: 34, limit: 10, offset: 0 }, + mileage: { items: [], total: 7, limit: 20, offset: 0 }, + quality: { items: [], 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('2/3')).toBeInTheDocument(); + expect(screen.getByText('历史 12 / RAW 34 / 里程 7')).toBeInTheDocument(); +}); + test('opens history with the currently selected vehicle detail source', async () => { window.history.replaceState(null, '', '/#/detail?keyword=VIN001'); vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {