feat(platform): expose source slots in realtime vehicles

This commit is contained in:
lingniu
2026-07-04 07:39:09 +08:00
parent f93c8d6ccc
commit 999d36c94d
8 changed files with 40 additions and 6 deletions
@@ -679,6 +679,18 @@ func TestHandlerVehicleRealtime(t *testing.T) {
if len(body.Data.Items) == 0 || body.Data.Items[0].ServiceStatus == nil { 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()) 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) { func TestHandlerVehicleRealtimeFiltersServiceStatus(t *testing.T) {
@@ -321,6 +321,13 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V
items := make([]VehicleRealtimeRow, 0, len(byVIN)) items := make([]VehicleRealtimeRow, 0, len(byVIN))
for _, row := range byVIN { for _, row := range byVIN {
sort.Strings(row.Protocols) 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) row.ServiceStatus = buildRealtimeServiceStatus(*row)
switch strings.TrimSpace(query.Get("online")) { switch strings.TrimSpace(query.Get("online")) {
case "online": case "online":
@@ -197,6 +197,7 @@ type VehicleRealtimeRow struct {
Phone string `json:"phone"` Phone string `json:"phone"`
OEM string `json:"oem"` OEM string `json:"oem"`
Protocols []string `json:"protocols"` Protocols []string `json:"protocols"`
SourceStatus []VehicleSourceStatus `json:"sourceStatus"`
SourceCount int `json:"sourceCount"` SourceCount int `json:"sourceCount"`
OnlineSourceCount int `json:"onlineSourceCount"` OnlineSourceCount int `json:"onlineSourceCount"`
Online bool `json:"online"` Online bool `json:"online"`
@@ -328,6 +328,7 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
`COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), b.plate, '') AS plate, ` + `COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), b.plate, '') AS plate, ` +
`COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem, ` + `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 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 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, ` + `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, ` + `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, ` +
@@ -339,12 +339,14 @@ func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values)
for rows.Next() { for rows.Next() {
var row VehicleRealtimeRow var row VehicleRealtimeRow
var protocols string var protocols string
var onlineProtocols string
var online int var online int
var longitude, latitude, speed, soc, mileage string 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 return Page[VehicleRealtimeRow]{}, err
} }
row.Protocols = splitCSV(protocols) row.Protocols = splitCSV(protocols)
row.SourceStatus = buildVehicleCoverageSourceStatus(row.Protocols, splitCSV(onlineProtocols), row.LastSeen)
row.Online = online == 1 row.Online = online == 1
row.ServiceStatus = buildRealtimeServiceStatus(row) row.ServiceStatus = buildRealtimeServiceStatus(row)
row.Longitude = parseFloatString(longitude) row.Longitude = parseFloatString(longitude)
@@ -194,6 +194,7 @@ export interface VehicleRealtimeRow {
phone: string; phone: string;
oem: string; oem: string;
protocols: string[]; protocols: string[];
sourceStatus: VehicleSourceStatus[];
sourceCount: number; sourceCount: number;
onlineSourceCount: number; onlineSourceCount: number;
online: boolean; online: boolean;
@@ -4,6 +4,7 @@ import { api } from '../api/client';
import type { VehicleRealtimeRow } from '../api/types'; import type { VehicleRealtimeRow } from '../api/types';
import { DataEmpty } from '../components/DataEmpty'; import { DataEmpty } from '../components/DataEmpty';
import { PageHeader } from '../components/PageHeader'; import { PageHeader } from '../components/PageHeader';
import { SourceStatusTags } from '../components/SourceStatusTags';
import { StatusTag } from '../components/StatusTag'; import { StatusTag } from '../components/StatusTag';
function canOpenVehicle(vin?: string) { function canOpenVehicle(vin?: string) {
@@ -146,11 +147,7 @@ export function Realtime({
title: '来源证据', title: '来源证据',
width: 260, width: 260,
render: (_: unknown, row: VehicleRealtimeRow) => ( render: (_: unknown, row: VehicleRealtimeRow) => (
<Space spacing={4} wrap> <SourceStatusTags sourceStatus={row.sourceStatus} protocols={row.protocols} lastSeen={row.lastSeen} />
{row.protocols.map((protocol) => (
<Tag key={protocol} color={protocol === row.primaryProtocol ? 'blue' : 'grey'}>{protocol}</Tag>
))}
</Space>
) )
}, },
{ title: '证据覆盖', width: 120, render: (_: unknown, row: VehicleRealtimeRow) => sourceEvidenceText(row) }, { title: '证据覆盖', width: 120, render: (_: unknown, row: VehicleRealtimeRow) => sourceEvidenceText(row) },
@@ -2671,6 +2671,11 @@ test('opens vehicle service from realtime vehicles with current source filter',
phone: '', phone: '',
oem: 'G7s', oem: 'G7s',
protocols: ['GB32960', 'JT808'], 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, sourceCount: 2,
onlineSourceCount: 2, onlineSourceCount: 2,
online: true, online: true,
@@ -2809,6 +2814,11 @@ test('frames realtime page as one vehicle service with source evidence', async (
phone: '', phone: '',
oem: 'G7s', oem: 'G7s',
protocols: ['GB32960', 'JT808'], 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, sourceCount: 2,
onlineSourceCount: 2, onlineSourceCount: 2,
online: true, 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(await screen.findByRole('heading', { name: '车辆服务实时态' })).toBeInTheDocument();
expect(screen.getByText('车辆核心数据')).toBeInTheDocument(); expect(screen.getByText('车辆核心数据')).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(); expect(screen.getByText('2/2 来源在线')).toBeInTheDocument();
}); });