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 5ff9e49c..880586b7 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -679,6 +679,18 @@ func TestHandlerVehicleRealtime(t *testing.T) { if len(body.Data.Items) == 0 || body.Data.Items[0].ServiceStatus == nil { t.Fatalf("realtime vehicle row should include canonical serviceStatus: %s", rec.Body.String()) } + byProtocol := map[string]VehicleSourceStatus{} + for _, source := range body.Data.Items[0].SourceStatus { + byProtocol[source.Protocol] = source + } + for _, protocol := range []string{"GB32960", "JT808", "YUTONG_MQTT"} { + if _, ok := byProtocol[protocol]; !ok { + t.Fatalf("realtime row should expose canonical source slot %s, got %+v body=%s", protocol, body.Data.Items[0].SourceStatus, rec.Body.String()) + } + } + if !byProtocol["JT808"].Online || byProtocol["YUTONG_MQTT"].Online || byProtocol["YUTONG_MQTT"].HasRealtime { + t.Fatalf("realtime source slots should expose online and missing evidence, got %+v", byProtocol) + } } func TestHandlerVehicleRealtimeFiltersServiceStatus(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 2490842c..2800c218 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -321,6 +321,13 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V items := make([]VehicleRealtimeRow, 0, len(byVIN)) for _, row := range byVIN { sort.Strings(row.Protocols) + onlineProtocols := make([]string, 0, row.OnlineSourceCount) + for _, location := range rows { + if location.VIN == row.VIN && location.LastSeen >= "2026-07-03 20:11:00" { + onlineProtocols = append(onlineProtocols, location.Protocol) + } + } + row.SourceStatus = buildVehicleCoverageSourceStatus(row.Protocols, onlineProtocols, row.LastSeen) row.ServiceStatus = buildRealtimeServiceStatus(*row) switch strings.TrimSpace(query.Get("online")) { case "online": diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index 273d2e1a..af8e2c88 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -197,6 +197,7 @@ type VehicleRealtimeRow struct { Phone string `json:"phone"` OEM string `json:"oem"` Protocols []string `json:"protocols"` + 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 880ff26f..ba925302 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -328,6 +328,7 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery { `COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), b.plate, '') AS plate, ` + `COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem, ` + `COALESCE(GROUP_CONCAT(DISTINCT l.protocol ORDER BY l.protocol SEPARATOR ','), '') AS protocols, ` + + `COALESCE(GROUP_CONCAT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END ORDER BY l.protocol SEPARATOR ','), '') AS online_protocols, ` + `COUNT(DISTINCT l.protocol) AS source_count, ` + `COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) AS online_source_count, ` + `CASE WHEN COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.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 752b86bf..0abc8ed3 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -339,12 +339,14 @@ func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values) for rows.Next() { var row VehicleRealtimeRow var protocols string + var onlineProtocols string var online int var longitude, latitude, speed, soc, mileage string - if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.BindingStatus, &row.PrimaryProtocol, &longitude, &latitude, &speed, &soc, &mileage, &row.LastSeen); err != nil { + if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.BindingStatus, &row.PrimaryProtocol, &longitude, &latitude, &speed, &soc, &mileage, &row.LastSeen); err != nil { return Page[VehicleRealtimeRow]{}, err } row.Protocols = splitCSV(protocols) + row.SourceStatus = buildVehicleCoverageSourceStatus(row.Protocols, splitCSV(onlineProtocols), row.LastSeen) row.Online = online == 1 row.ServiceStatus = buildRealtimeServiceStatus(row) row.Longitude = parseFloatString(longitude) diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index 4a7d2215..ec55110d 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -194,6 +194,7 @@ export interface VehicleRealtimeRow { phone: string; oem: string; protocols: string[]; + sourceStatus: VehicleSourceStatus[]; sourceCount: number; onlineSourceCount: number; online: boolean; diff --git a/vehicle-data-platform/apps/web/src/pages/Realtime.tsx b/vehicle-data-platform/apps/web/src/pages/Realtime.tsx index ea496560..7c1763bd 100644 --- a/vehicle-data-platform/apps/web/src/pages/Realtime.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Realtime.tsx @@ -4,6 +4,7 @@ import { api } from '../api/client'; import type { VehicleRealtimeRow } from '../api/types'; import { DataEmpty } from '../components/DataEmpty'; import { PageHeader } from '../components/PageHeader'; +import { SourceStatusTags } from '../components/SourceStatusTags'; import { StatusTag } from '../components/StatusTag'; function canOpenVehicle(vin?: string) { @@ -146,11 +147,7 @@ export function Realtime({ title: '来源证据', width: 260, render: (_: unknown, row: VehicleRealtimeRow) => ( - - {row.protocols.map((protocol) => ( - {protocol} - ))} - + ) }, { title: '证据覆盖', width: 120, render: (_: unknown, row: VehicleRealtimeRow) => sourceEvidenceText(row) }, 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 024f5595..6e1136ff 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -2671,6 +2671,11 @@ test('opens vehicle service from realtime vehicles with current source filter', phone: '', oem: 'G7s', protocols: ['GB32960', 'JT808'], + sourceStatus: [ + { protocol: 'GB32960', online: true, lastSeen: '2026-07-03 20:12:10', hasRealtime: true, hasHistory: false, hasRaw: false, hasMileage: false }, + { protocol: 'JT808', online: true, lastSeen: '2026-07-03 20:11:10', 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: 2, online: true, @@ -2809,6 +2814,11 @@ test('frames realtime page as one vehicle service with source evidence', async ( phone: '', oem: 'G7s', protocols: ['GB32960', 'JT808'], + sourceStatus: [ + { protocol: 'GB32960', online: true, lastSeen: '2026-07-03 20:12:10', hasRealtime: true, hasHistory: false, hasRaw: false, hasMileage: false }, + { protocol: 'JT808', online: true, lastSeen: '2026-07-03 20:11:10', 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: 2, online: true, @@ -2853,6 +2863,9 @@ test('frames realtime page as one vehicle service with source evidence', async ( expect(await screen.findByRole('heading', { name: '车辆服务实时态' })).toBeInTheDocument(); expect(screen.getByText('车辆核心数据')).toBeInTheDocument(); expect(screen.getByText('来源证据')).toBeInTheDocument(); + expect(screen.getByText('GB32960 在线')).toBeInTheDocument(); + expect(screen.getByText('JT808 在线')).toBeInTheDocument(); + expect(screen.getByText('YUTONG_MQTT 未接入')).toBeInTheDocument(); expect(screen.getByText('2/2 来源在线')).toBeInTheDocument(); });