From 6352289c2578c6997451bdf81f7d7f623382cc9e Mon Sep 17 00:00:00 2001 From: lingniu Date: Fri, 3 Jul 2026 21:47:30 +0800 Subject: [PATCH] feat(platform): summarize vehicle source status --- .../api/internal/platform/handler_test.go | 2 +- .../apps/api/internal/platform/model.go | 25 ++-- .../apps/api/internal/platform/service.go | 117 +++++++++++++++--- .../apps/web/src/api/types.ts | 11 ++ .../apps/web/src/pages/VehicleDetail.tsx | 20 ++- 5 files changed, 147 insertions(+), 28 deletions(-) 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 c00135d1..8ccf0cab 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -41,7 +41,7 @@ func TestHandlerVehicleDetail(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) } - for _, want := range []string{"identity", "realtime", "history", "raw", "mileage", "sources"} { + for _, want := range []string{"identity", "realtime", "history", "raw", "mileage", "sources", "sourceStatus"} { if !strings.Contains(rec.Body.String(), want) { t.Fatalf("response missing %q: %s", want, rec.Body.String()) } diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index b7167a54..642f8e3b 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -42,13 +42,24 @@ type VehicleRow struct { } type VehicleDetail struct { - VIN string `json:"vin"` - Identity *VehicleRow `json:"identity,omitempty"` - Sources []string `json:"sources"` - Realtime []RealtimeLocationRow `json:"realtime"` - History Page[HistoryLocationRow] `json:"history"` - Raw Page[RawFrameRow] `json:"raw"` - Mileage Page[DailyMileageRow] `json:"mileage"` + VIN string `json:"vin"` + Identity *VehicleRow `json:"identity,omitempty"` + Sources []string `json:"sources"` + SourceStatus []VehicleSourceStatus `json:"sourceStatus"` + Realtime []RealtimeLocationRow `json:"realtime"` + History Page[HistoryLocationRow] `json:"history"` + Raw Page[RawFrameRow] `json:"raw"` + Mileage Page[DailyMileageRow] `json:"mileage"` +} + +type VehicleSourceStatus struct { + Protocol string `json:"protocol"` + Online bool `json:"online"` + LastSeen string `json:"lastSeen"` + HasRealtime bool `json:"hasRealtime"` + HasHistory bool `json:"hasHistory"` + HasRaw bool `json:"hasRaw"` + HasMileage bool `json:"hasMileage"` } type RealtimeLocationRow struct { diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index 0bc2f48a..07b3b8a4 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -85,17 +85,44 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string if err != nil { return VehicleDetail{}, err } + sourceStatus := vehicleSourceStatus(vehicles.Items, realtime.Items, history.Items, raw.Items, mileage.Items) + sourceStatus = s.enrichVehicleSourceStatus(ctx, resolvedVIN, sourceStatus) return VehicleDetail{ - VIN: resolvedVIN, - Identity: identity, - Sources: vehicleSources(vehicles.Items, realtime.Items, raw.Items, mileage.Items), - Realtime: realtime.Items, - History: history, - Raw: raw, - Mileage: mileage, + VIN: resolvedVIN, + Identity: identity, + Sources: sourceNames(sourceStatus), + SourceStatus: sourceStatus, + Realtime: realtime.Items, + History: history, + Raw: raw, + Mileage: mileage, }, nil } +func (s *Service) enrichVehicleSourceStatus(ctx context.Context, vin string, statuses []VehicleSourceStatus) []VehicleSourceStatus { + for index := range statuses { + protocol := statuses[index].Protocol + if protocol == "" { + continue + } + if !statuses[index].HasHistory { + query := url.Values{"vin": {vin}, "protocol": {protocol}, "limit": {"1"}} + if page, err := s.store.HistoryLocationsFromTDengine(ctx, query); err == nil && len(page.Items) > 0 { + statuses[index].HasHistory = true + statuses[index].LastSeen = latestString(statuses[index].LastSeen, firstNonEmpty(page.Items[0].ServerTime, page.Items[0].DeviceTime)) + } + } + if !statuses[index].HasRaw { + page, err := s.store.RawFrames(ctx, RawFrameQuery{VIN: vin, Protocol: protocol, Limit: 1}) + if err == nil && len(page.Items) > 0 { + statuses[index].HasRaw = true + statuses[index].LastSeen = latestString(statuses[index].LastSeen, firstNonEmpty(page.Items[0].ServerTime, page.Items[0].DeviceTime)) + } + } + } + return statuses +} + func resolveVehicleIdentity(keyword string, vehicles []VehicleRow) *VehicleRow { keyword = strings.TrimSpace(keyword) if keyword == "" { @@ -144,31 +171,83 @@ func (s *Service) OpsHealth(ctx context.Context) (OpsHealth, error) { return s.store.OpsHealth(ctx) } -func vehicleSources(vehicles []VehicleRow, realtime []RealtimeLocationRow, raw []RawFrameRow, mileage []DailyMileageRow) []string { - seen := map[string]struct{}{} - add := func(value string) { +func vehicleSourceStatus(vehicles []VehicleRow, realtime []RealtimeLocationRow, history []HistoryLocationRow, raw []RawFrameRow, mileage []DailyMileageRow) []VehicleSourceStatus { + statusByProtocol := map[string]*VehicleSourceStatus{} + ensure := func(protocol string) *VehicleSourceStatus { + protocol = strings.TrimSpace(protocol) + if protocol == "" { + return nil + } + if current := statusByProtocol[protocol]; current != nil { + return current + } + current := &VehicleSourceStatus{Protocol: protocol} + statusByProtocol[protocol] = current + return current + } + setLastSeen := func(current *VehicleSourceStatus, value string) { value = strings.TrimSpace(value) if value == "" { return } - seen[value] = struct{}{} + if current.LastSeen == "" || value > current.LastSeen { + current.LastSeen = value + } } for _, row := range vehicles { - add(row.Protocol) + if current := ensure(row.Protocol); current != nil { + current.Online = current.Online || row.Online + setLastSeen(current, row.LastSeen) + } } for _, row := range realtime { - add(row.Protocol) + if current := ensure(row.Protocol); current != nil { + current.HasRealtime = true + setLastSeen(current, row.LastSeen) + } + } + for _, row := range history { + if current := ensure(row.Protocol); current != nil { + current.HasHistory = true + setLastSeen(current, firstNonEmpty(row.ServerTime, row.DeviceTime)) + } } for _, row := range raw { - add(row.Protocol) + if current := ensure(row.Protocol); current != nil { + current.HasRaw = true + setLastSeen(current, firstNonEmpty(row.ServerTime, row.DeviceTime)) + } } for _, row := range mileage { - add(row.Source) + if current := ensure(row.Source); current != nil { + current.HasMileage = true + setLastSeen(current, row.Date) + } } - out := make([]string, 0, len(seen)) - for source := range seen { - out = append(out, source) + out := make([]VehicleSourceStatus, 0, len(statusByProtocol)) + for _, current := range statusByProtocol { + out = append(out, *current) + } + sort.Slice(out, func(i, j int) bool { return out[i].Protocol < out[j].Protocol }) + return out +} + +func latestString(left string, right string) string { + left = strings.TrimSpace(left) + right = strings.TrimSpace(right) + if left == "" { + return right + } + if right == "" || left >= right { + return left + } + return right +} + +func sourceNames(statuses []VehicleSourceStatus) []string { + out := make([]string, 0, len(statuses)) + for _, status := range statuses { + out = append(out, status.Protocol) } - sort.Strings(out) return out } diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index 5031609f..fcd362e5 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -42,12 +42,23 @@ export interface VehicleDetail { vin: string; identity?: VehicleRow; sources: string[]; + sourceStatus: VehicleSourceStatus[]; realtime: RealtimeLocationRow[]; history: Page; raw: Page; mileage: Page; } +export interface VehicleSourceStatus { + protocol: string; + online: boolean; + lastSeen: string; + hasRealtime: boolean; + hasHistory: boolean; + hasRaw: boolean; + hasMileage: boolean; +} + export interface RealtimeLocationRow { vin: string; plate: string; diff --git a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx index d7bfc2dc..e3d24789 100644 --- a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx +++ b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx @@ -2,7 +2,7 @@ import { Button, Card, Descriptions, Form, Select, Space, Table, Tabs, Tag, Toas import { IconRefresh, IconSearch } from '@douyinfe/semi-icons'; import { useEffect, useMemo, useState } from 'react'; import { api } from '../api/client'; -import type { VehicleDetail as VehicleDetailData } from '../api/types'; +import type { VehicleDetail as VehicleDetailData, VehicleSourceStatus } from '../api/types'; import { PageHeader } from '../components/PageHeader'; import { StatusTag } from '../components/StatusTag'; @@ -84,6 +84,24 @@ export function VehicleDetail({ vin }: { vin: string }) { + + }, + { title: '最后时间', dataIndex: 'lastSeen', width: 190 }, + { title: '实时', width: 90, render: (_: unknown, row: VehicleSourceStatus) => {row.hasRealtime ? '有' : '无'} }, + { title: '历史', width: 90, render: (_: unknown, row: VehicleSourceStatus) => {row.hasHistory ? '有' : '无'} }, + { title: 'RAW', width: 90, render: (_: unknown, row: VehicleSourceStatus) => {row.hasRaw ? '有' : '无'} }, + { title: '里程', width: 90, render: (_: unknown, row: VehicleSourceStatus) => {row.hasMileage ? '有' : '无'} } + ]} + /> + +