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

View File

@@ -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) {

View File

@@ -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":

View File

@@ -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"`

View File

@@ -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, ` +

View File

@@ -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)

View File

@@ -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;

View File

@@ -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) },

View File

@@ -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();
}); });