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 f5acd1e7..5ff9e49c 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -96,6 +96,7 @@ func TestHandlerVehicleCoverage(t *testing.T) { Data Page[struct { VIN string `json:"vin"` MissingProtocols []string `json:"missingProtocols"` + SourceStatus []VehicleSourceStatus `json:"sourceStatus"` SourceConsistency *VehicleSourceConsistency `json:"sourceConsistency"` }] `json:"data"` } @@ -111,6 +112,18 @@ func TestHandlerVehicleCoverage(t *testing.T) { if rawBody.Data.Items[0].SourceConsistency.Status != "degraded" || !containsString(rawBody.Data.Items[0].SourceConsistency.MissingProtocols, "YUTONG_MQTT") { t.Fatalf("coverage source consistency should diagnose missing source evidence, got %+v body=%s", rawBody.Data.Items[0].SourceConsistency, rec.Body.String()) } + byProtocol := map[string]VehicleSourceStatus{} + for _, source := range rawBody.Data.Items[0].SourceStatus { + byProtocol[source.Protocol] = source + } + for _, protocol := range []string{"GB32960", "JT808", "YUTONG_MQTT"} { + if _, ok := byProtocol[protocol]; !ok { + t.Fatalf("coverage row should expose canonical source slot %s, got %+v body=%s", protocol, rawBody.Data.Items[0].SourceStatus, rec.Body.String()) + } + } + if !byProtocol["JT808"].Online || byProtocol["YUTONG_MQTT"].Online || byProtocol["YUTONG_MQTT"].HasRealtime { + t.Fatalf("coverage source slots should expose online and missing evidence, got %+v", byProtocol) + } } func TestHandlerVehicleCoverageFiltersServiceStatus(t *testing.T) { diff --git a/vehicle-data-platform/apps/api/internal/platform/mock_store.go b/vehicle-data-platform/apps/api/internal/platform/mock_store.go index 000b7826..2490842c 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -133,6 +133,13 @@ func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[V items := make([]VehicleCoverageRow, 0, len(byVIN)) for _, row := range byVIN { row.MissingProtocols = missingCanonicalProtocols(row.Protocols) + onlineProtocols := make([]string, 0, row.OnlineSourceCount) + for _, vehicle := range vehicles { + if vehicle.VIN == row.VIN && vehicle.Online { + onlineProtocols = append(onlineProtocols, vehicle.Protocol) + } + } + row.SourceStatus = buildVehicleCoverageSourceStatus(row.Protocols, onlineProtocols, row.LastSeen) row.ServiceStatus = buildVehicleCoverageServiceStatus(*row) row.SourceConsistency = buildVehicleCoverageSourceConsistency(*row) if keepCoverageRow(*row, query) { diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index e9e3909c..273d2e1a 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -56,6 +56,7 @@ type VehicleCoverageRow struct { OEM string `json:"oem"` Protocols []string `json:"protocols"` MissingProtocols []string `json:"missingProtocols"` + SourceStatus []VehicleSourceStatus `json:"sourceStatus"` SourceCount int `json:"sourceCount"` OnlineSourceCount int `json:"onlineSourceCount"` Online bool `json:"online"` diff --git a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go index de6dd6a7..880ff26f 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -141,6 +141,7 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery { `COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), b.plate, '') AS plate, ` + `COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem, ` + `COALESCE(GROUP_CONCAT(DISTINCT s.protocol ORDER BY s.protocol SEPARATOR ','), '') AS protocols, ` + + `COALESCE(GROUP_CONCAT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END ORDER BY s.protocol SEPARATOR ','), '') AS online_protocols, ` + `COUNT(DISTINCT s.protocol) AS source_count, ` + `COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) AS online_source_count, ` + `CASE WHEN COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) > 0 THEN 1 ELSE 0 END AS online, ` + diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store.go b/vehicle-data-platform/apps/api/internal/platform/production_store.go index 77a226aa..752b86bf 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -264,12 +264,14 @@ func (s *ProductionStore) VehicleCoverage(ctx context.Context, query url.Values) for rows.Next() { var row VehicleCoverageRow var protocols string + var onlineProtocols string var online int - if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.LastSeen, &row.BindingStatus); err != nil { + if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.LastSeen, &row.BindingStatus); err != nil { return Page[VehicleCoverageRow]{}, err } row.Protocols = splitCSV(protocols) row.MissingProtocols = missingCanonicalProtocols(row.Protocols) + row.SourceStatus = buildVehicleCoverageSourceStatus(row.Protocols, splitCSV(onlineProtocols), row.LastSeen) row.Online = online == 1 row.ServiceStatus = buildVehicleCoverageServiceStatus(row) row.SourceConsistency = buildVehicleCoverageSourceConsistency(row) diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index 50bba5fd..9838657e 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -68,6 +68,23 @@ func missingCanonicalProtocols(protocols []string) []string { return missing } +func buildVehicleCoverageSourceStatus(protocols []string, onlineProtocols []string, lastSeen string) []VehicleSourceStatus { + statuses := make([]VehicleSourceStatus, 0, len(protocols)) + for _, protocol := range protocols { + protocol = strings.TrimSpace(protocol) + if protocol == "" { + continue + } + statuses = append(statuses, VehicleSourceStatus{ + Protocol: protocol, + Online: containsString(onlineProtocols, protocol), + LastSeen: lastSeen, + HasRealtime: true, + }) + } + return completeVehicleSourceStatus(statuses, "") +} + func NewService(store Store) *Service { return &Service{store: store} } diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index 72b38c6e..4a7d2215 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -53,6 +53,7 @@ export interface VehicleCoverageRow { oem: string; protocols: string[]; missingProtocols: string[]; + sourceStatus: VehicleSourceStatus[]; sourceCount: number; onlineSourceCount: number; online: boolean; diff --git a/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx b/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx index 4d43c3da..06d92c6b 100644 --- a/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx @@ -33,6 +33,16 @@ function sourceEvidenceText(row: VehicleCoverageRow) { return `${row.onlineSourceCount}/${row.sourceCount} 来源在线`; } +function sourceStatusLabel(source: { online: boolean; hasRealtime: boolean; lastSeen: string }) { + if (source.online) { + return { text: '在线', color: 'green' as const }; + } + if (source.hasRealtime || source.lastSeen) { + return { text: '离线', color: 'orange' as const }; + } + return { text: '未接入', color: 'grey' as const }; +} + function sourceConsistencyAction(row: VehicleCoverageRow, onFilter: (filters: Record) => void) { const consistency = row.sourceConsistency; if (!consistency) { @@ -148,10 +158,13 @@ export function Vehicles({ }, { title: '来源证据', - width: 240, + width: 300, render: (_: unknown, row: VehicleCoverageRow) => ( - {row.protocols.map((protocol) => {protocol})} + {(row.sourceStatus?.length ? row.sourceStatus : row.protocols.map((protocol) => ({ protocol, online: false, hasRealtime: true, lastSeen: row.lastSeen }))).map((source) => { + const status = sourceStatusLabel(source); + return {source.protocol} {status.text}; + })} ) }, 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 cf964344..a11a84f4 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -289,6 +289,12 @@ test('shows vehicle service result summary on vehicle list filters', async () => phone: '13307795425', oem: 'G7s', protocols: ['GB32960', 'JT808'], + missingProtocols: ['YUTONG_MQTT'], + sourceStatus: [ + { protocol: 'GB32960', online: true, lastSeen: '2026-07-03 20:12:10', hasRealtime: true, hasHistory: false, hasRaw: false, hasMileage: false }, + { protocol: 'JT808', online: false, lastSeen: '2026-07-03 20:10:00', hasRealtime: true, hasHistory: false, hasRaw: false, hasMileage: false }, + { protocol: 'YUTONG_MQTT', online: false, lastSeen: '', hasRealtime: false, hasHistory: false, hasRaw: false, hasMileage: false } + ], sourceCount: 2, onlineSourceCount: 1, online: true, @@ -334,6 +340,9 @@ test('shows vehicle service result summary on vehicle list filters', async () => expect(screen.getByText('待绑定')).toBeInTheDocument(); expect(screen.getByText('VIN-MULTI-001')).toBeInTheDocument(); expect(screen.getAllByText('来源证据').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('GB32960 在线')).toBeInTheDocument(); + expect(screen.getByText('JT808 离线')).toBeInTheDocument(); + expect(screen.getByText('YUTONG_MQTT 未接入')).toBeInTheDocument(); expect(screen.getByText('1/2 来源在线')).toBeInTheDocument(); });