From 110041a9dbcdd354de9c71ce1b2c1b452e4b9996 Mon Sep 17 00:00:00 2001 From: lingniu Date: Fri, 3 Jul 2026 21:54:09 +0800 Subject: [PATCH] feat(platform): add vehicle coverage view --- .../apps/api/internal/platform/handler.go | 6 ++ .../api/internal/platform/handler_test.go | 15 +++++ .../apps/api/internal/platform/mock_store.go | 56 +++++++++++++++++++ .../apps/api/internal/platform/model.go | 13 +++++ .../api/internal/platform/mysql_queries.go | 34 +++++++++++ .../api/internal/platform/production_store.go | 26 +++++++++ .../internal/platform/query_builders_test.go | 13 +++++ .../apps/api/internal/platform/service.go | 5 ++ vehicle-data-platform/apps/web/src/App.tsx | 2 +- .../apps/web/src/api/client.ts | 2 + .../apps/web/src/api/types.ts | 13 +++++ .../apps/web/src/pages/Dashboard.tsx | 41 ++++++++++++-- vehicle-data-platform/docs/product-spec.md | 2 + 13 files changed, 223 insertions(+), 5 deletions(-) diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index 5393b7a6..58ad6347 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler.go @@ -27,6 +27,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *Handler) routes() { h.mux.HandleFunc("GET /api/dashboard/summary", h.handleDashboardSummary) h.mux.HandleFunc("GET /api/vehicles", h.handleVehicles) + h.mux.HandleFunc("GET /api/vehicles/coverage", h.handleVehicleCoverage) h.mux.HandleFunc("GET /api/vehicles/detail", h.handleVehicleDetail) h.mux.HandleFunc("GET /api/realtime/locations", h.handleRealtimeLocations) h.mux.HandleFunc("GET /api/history/locations", h.handleHistoryLocations) @@ -47,6 +48,11 @@ func (h *Handler) handleVehicles(w http.ResponseWriter, r *http.Request) { h.write(w, r, data, err) } +func (h *Handler) handleVehicleCoverage(w http.ResponseWriter, r *http.Request) { + data, err := h.service.VehicleCoverage(r.Context(), r.URL.Query()) + h.write(w, r, data, err) +} + func (h *Handler) handleVehicleDetail(w http.ResponseWriter, r *http.Request) { vin := strings.TrimSpace(r.URL.Query().Get("vin")) if vin == "" { 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 8ccf0cab..40a8a075 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -33,6 +33,21 @@ func TestHandlerVehicles(t *testing.T) { } } +func TestHandlerVehicleCoverage(t *testing.T) { + handler := NewHandler(NewService(NewMockStore())) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/vehicles/coverage?limit=10", nil) + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + for _, want := range []string{"sourceCount", "onlineSourceCount", "protocols", "LB9A32A24R0LS1426"} { + if !strings.Contains(rec.Body.String(), want) { + t.Fatalf("response missing %q: %s", want, rec.Body.String()) + } + } +} + func TestHandlerVehicleDetail(t *testing.T) { handler := NewHandler(NewService(NewMockStore())) rec := httptest.NewRecorder() 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 c1338e29..f7bd42ca 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -3,6 +3,7 @@ package platform import ( "context" "net/url" + "sort" "strconv" "strings" ) @@ -15,6 +16,7 @@ type MockStore struct { func NewMockStore() *MockStore { vehicles := []VehicleRow{ {VIN: "LB9A32A24R0LS1426", Plate: "粤AG18312", Phone: "13307795425", OEM: "G7s", Protocol: "JT808", Online: true, LastSeen: "2026-07-03 20:12:10", LocationText: "广东省广州市", BindingScore: 96}, + {VIN: "LB9A32A24R0LS1426", Plate: "粤AG18312", Phone: "13307795425", OEM: "G7s", Protocol: "GB32960", Online: false, LastSeen: "2026-07-03 20:10:10", LocationText: "广东省广州市", BindingScore: 96}, {VIN: "LNXNEGRR7SR318212", Plate: "川AHTWO1", Phone: "", OEM: "Hyundai", Protocol: "GB32960", Online: true, LastSeen: "2026-07-03 20:12:06", LocationText: "四川省成都市", BindingScore: 100}, {VIN: "LMRKH9AC2R1004087", Plate: "豫A88888", Phone: "", OEM: "宇通", Protocol: "YUTONG_MQTT", Online: true, LastSeen: "2026-07-03 20:11:59", LocationText: "上海市临港", BindingScore: 92}, {VIN: "LB9A32A24P0LS1230", Plate: "粤AFF7936", Phone: "13307795426", OEM: "广安车联", Protocol: "JT808", Online: false, LastSeen: "2026-07-03 19:58:00", LocationText: "广东省佛山市", BindingScore: 88}, @@ -55,6 +57,42 @@ func (m *MockStore) Vehicles(_ context.Context, query url.Values) (Page[VehicleR return page(items, query), nil } +func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[VehicleCoverageRow], error) { + vehicles := filterVehicles(m.vehicles, query) + byVIN := map[string]*VehicleCoverageRow{} + for _, vehicle := range vehicles { + current := byVIN[vehicle.VIN] + if current == nil { + current = &VehicleCoverageRow{ + VIN: vehicle.VIN, + Plate: vehicle.Plate, + Phone: vehicle.Phone, + OEM: vehicle.OEM, + LastSeen: vehicle.LastSeen, + BindingStatus: "bound", + } + byVIN[vehicle.VIN] = current + } + if vehicle.LastSeen > current.LastSeen { + current.LastSeen = vehicle.LastSeen + } + if vehicle.Online { + current.Online = true + current.OnlineSourceCount++ + } + if !containsString(current.Protocols, vehicle.Protocol) { + current.Protocols = append(current.Protocols, vehicle.Protocol) + current.SourceCount = len(current.Protocols) + } + } + items := make([]VehicleCoverageRow, 0, len(byVIN)) + for _, row := range byVIN { + items = append(items, *row) + } + sortVehicleCoverage(items) + return page(items, query), nil +} + func (m *MockStore) RealtimeLocations(_ context.Context, query url.Values) (Page[RealtimeLocationRow], error) { items := m.locations if vin := strings.TrimSpace(query.Get("vin")); vin != "" { @@ -181,6 +219,24 @@ func keep[T any](rows []T, fn func(T) bool) []T { return out } +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func sortVehicleCoverage(rows []VehicleCoverageRow) { + sort.Slice(rows, func(i, j int) bool { + if rows[i].LastSeen == rows[j].LastSeen { + return rows[i].VIN < rows[j].VIN + } + return rows[i].LastSeen > rows[j].LastSeen + }) +} + func parsePositive(raw string, fallback int) int { value, err := strconv.Atoi(raw) if err != nil || value < 0 { diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index 642f8e3b..a7592ff0 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -41,6 +41,19 @@ type VehicleRow struct { BindingScore int `json:"bindingScore"` } +type VehicleCoverageRow struct { + VIN string `json:"vin"` + Plate string `json:"plate"` + Phone string `json:"phone"` + OEM string `json:"oem"` + Protocols []string `json:"protocols"` + SourceCount int `json:"sourceCount"` + OnlineSourceCount int `json:"onlineSourceCount"` + Online bool `json:"online"` + LastSeen string `json:"lastSeen"` + BindingStatus string `json:"bindingStatus"` +} + type VehicleDetail struct { VIN string `json:"vin"` Identity *VehicleRow `json:"identity,omitempty"` 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 2ce10aac..da18c38d 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -40,6 +40,40 @@ func buildVehicleListSQL(query url.Values) SQLQuery { } } +func buildVehicleCoverageSQL(query url.Values) SQLQuery { + limit := parsePositive(query.Get("limit"), 20) + offset := parsePositive(query.Get("offset"), 0) + args := []any{} + where := []string{"s.vin IS NOT NULL", "s.vin <> ''"} + if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" { + where = append(where, "(s.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)") + like := "%" + keyword + "%" + args = append(args, like, like, like, like, like, like) + } + if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { + where = append(where, "s.protocol = ?") + args = append(args, protocol) + } + args = append(args, limit, offset) + return SQLQuery{ + Text: `SELECT s.vin, ` + + `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, ` + + `COUNT(DISTINCT s.protocol) AS source_count, ` + + `SUM(CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN 1 ELSE 0 END) AS online_source_count, ` + + `CASE WHEN SUM(CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN 1 ELSE 0 END) > 0 THEN 1 ELSE 0 END AS online, ` + + `COALESCE(DATE_FORMAT(MAX(s.updated_at), '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` + + `CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 'bound' ELSE 'unbound' END AS binding_status ` + + `FROM vehicle_realtime_snapshot s ` + + `LEFT JOIN vehicle_identity_binding b ON b.vin = s.vin ` + + `WHERE ` + strings.Join(where, " AND ") + ` ` + + `GROUP BY s.vin, b.plate, b.phone, b.oem, b.vin ` + + `ORDER BY MAX(s.updated_at) DESC LIMIT ? OFFSET ?`, + Args: args, + } +} + func buildDailyMileageSQL(query url.Values) SQLQuery { limit := parsePositive(query.Get("limit"), 20) offset := parsePositive(query.Get("offset"), 0) 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 14e0333d..f6cfdb2d 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -89,6 +89,32 @@ func (s *ProductionStore) Vehicles(ctx context.Context, query url.Values) (Page[ return Page[VehicleRow]{Items: items, Total: len(items), Limit: limit, Offset: offset}, nil } +func (s *ProductionStore) VehicleCoverage(ctx context.Context, query url.Values) (Page[VehicleCoverageRow], error) { + built := buildVehicleCoverageSQL(query) + rows, err := s.db.QueryContext(ctx, built.Text, built.Args...) + if err != nil { + return Page[VehicleCoverageRow]{}, err + } + defer rows.Close() + items := make([]VehicleCoverageRow, 0) + for rows.Next() { + var row VehicleCoverageRow + var protocols 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 { + return Page[VehicleCoverageRow]{}, err + } + row.Protocols = splitCSV(protocols) + row.Online = online == 1 + items = append(items, row) + } + if err := rows.Err(); err != nil { + return Page[VehicleCoverageRow]{}, err + } + limit, offset := buildLimitOffset(query) + return Page[VehicleCoverageRow]{Items: items, Total: len(items), Limit: limit, Offset: offset}, nil +} + func (s *ProductionStore) RealtimeLocations(ctx context.Context, query url.Values) (Page[RealtimeLocationRow], error) { limit, offset := buildLimitOffset(query) where := []string{"1 = 1"} diff --git a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go index 882ae2d4..3de78ef1 100644 --- a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go @@ -19,6 +19,19 @@ func TestBuildVehicleListSQL(t *testing.T) { } } +func TestBuildVehicleCoverageSQL(t *testing.T) { + query := url.Values{"keyword": {"粤A"}, "protocol": {"GB32960"}, "limit": {"8"}, "offset": {"16"}} + built := buildVehicleCoverageSQL(query) + for _, want := range []string{"GROUP BY s.vin", "GROUP_CONCAT(DISTINCT s.protocol", "source_count", "online_source_count"} { + if !strings.Contains(built.Text, want) { + t.Fatalf("SQL missing %q: %s", want, built.Text) + } + } + if len(built.Args) != 9 || built.Args[0] != "%粤A%" || built.Args[6] != "GB32960" || built.Args[7] != 8 || built.Args[8] != 16 { + t.Fatalf("args = %#v", built.Args) + } +} + func TestBuildDailyMileageSQL(t *testing.T) { query := url.Values{"vin": {"VIN001"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-03"}} built := buildDailyMileageSQL(query) diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index 07b3b8a4..7e0d770b 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -10,6 +10,7 @@ import ( type Store interface { DashboardSummary(context.Context) (DashboardSummary, error) Vehicles(context.Context, url.Values) (Page[VehicleRow], error) + VehicleCoverage(context.Context, url.Values) (Page[VehicleCoverageRow], error) RealtimeLocations(context.Context, url.Values) (Page[RealtimeLocationRow], error) HistoryLocations(context.Context, url.Values) (Page[HistoryLocationRow], error) HistoryLocationsFromTDengine(context.Context, url.Values) (Page[HistoryLocationRow], error) @@ -46,6 +47,10 @@ func (s *Service) Vehicles(ctx context.Context, query url.Values) (Page[VehicleR return s.store.Vehicles(ctx, query) } +func (s *Service) VehicleCoverage(ctx context.Context, query url.Values) (Page[VehicleCoverageRow], error) { + return s.store.VehicleCoverage(ctx, query) +} + func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string) (VehicleDetail, error) { keyword := strings.TrimSpace(vin) protocol = strings.TrimSpace(protocol) diff --git a/vehicle-data-platform/apps/web/src/App.tsx b/vehicle-data-platform/apps/web/src/App.tsx index 5c832aa7..f678c223 100644 --- a/vehicle-data-platform/apps/web/src/App.tsx +++ b/vehicle-data-platform/apps/web/src/App.tsx @@ -22,7 +22,7 @@ export default function App() { }; const pages: Record = { - dashboard: , + dashboard: , vehicles: , realtime: , detail: , diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index 90476070..5c625435 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -8,6 +8,7 @@ import type { QualityIssueRow, RawFrameRow, RealtimeLocationRow, + VehicleCoverageRow, VehicleDetail, VehicleRow } from './types'; @@ -24,6 +25,7 @@ async function request(path: string, init?: RequestInit): Promise { export const api = { dashboardSummary: () => request('/api/dashboard/summary'), vehicles: (params = new URLSearchParams()) => request>(`/api/vehicles?${params.toString()}`), + vehicleCoverage: (params = new URLSearchParams()) => request>(`/api/vehicles/coverage?${params.toString()}`), vehicleDetail: (params = new URLSearchParams()) => request(`/api/vehicles/detail?${params.toString()}`), realtimeLocations: (params = new URLSearchParams()) => request>(`/api/realtime/locations?${params.toString()}`), historyLocations: (params = new URLSearchParams()) => request>(`/api/history/locations?${params.toString()}`), diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index fcd362e5..ab44c70c 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -38,6 +38,19 @@ export interface VehicleRow { bindingScore: number; } +export interface VehicleCoverageRow { + vin: string; + plate: string; + phone: string; + oem: string; + protocols: string[]; + sourceCount: number; + onlineSourceCount: number; + online: boolean; + lastSeen: string; + bindingStatus: string; +} + export interface VehicleDetail { vin: string; identity?: VehicleRow; diff --git a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx index c8fefe1a..d07bfcfa 100644 --- a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx @@ -1,8 +1,9 @@ -import { Card, Col, Row, Spin, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui'; +import { Button, Card, Col, Row, Space, Spin, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui'; import { useEffect, useState } from 'react'; import { api } from '../api/client'; -import type { DashboardSummary, LinkHealth, ProtocolStat, RealtimeLocationRow, VehicleRow } from '../api/types'; +import type { DashboardSummary, LinkHealth, ProtocolStat, RealtimeLocationRow, VehicleCoverageRow, VehicleRow } from '../api/types'; import { PageHeader } from '../components/PageHeader'; +import { StatusTag } from '../components/StatusTag'; const statusColor: Record = { ok: 'green', @@ -10,9 +11,10 @@ const statusColor: Record = { error: 'red' }; -export function Dashboard() { +export function Dashboard({ onOpenVehicle }: { onOpenVehicle: (vin: string) => void }) { const [summary, setSummary] = useState(null); const [vehicles, setVehicles] = useState([]); + const [coverage, setCoverage] = useState([]); const [locations, setLocations] = useState([]); const [loading, setLoading] = useState(true); @@ -20,11 +22,13 @@ export function Dashboard() { Promise.all([ api.dashboardSummary(), api.vehicles(new URLSearchParams({ limit: '5' })), + api.vehicleCoverage(new URLSearchParams({ limit: '8' })), api.realtimeLocations(new URLSearchParams({ limit: '8' })) ]) - .then(([nextSummary, vehiclePage, locationPage]) => { + .then(([nextSummary, vehiclePage, coveragePage, locationPage]) => { setSummary(nextSummary); setVehicles(vehiclePage.items); + setCoverage(coveragePage.items); setLocations(locationPage.items); }) .catch((error: Error) => Toast.error(error.message)) @@ -88,6 +92,35 @@ export function Dashboard() { Kafka 当前消费积压:{summary?.kafkaLag ?? 0} + + ( + + {row.protocols.map((protocol) => {protocol})} + + ) + }, + { + title: '覆盖', + width: 110, + render: (_: unknown, row: VehicleCoverageRow) => `${row.onlineSourceCount}/${row.sourceCount}` + }, + { title: '在线', width: 90, render: (_: unknown, row: VehicleCoverageRow) => }, + { title: '绑定', width: 90, render: (_: unknown, row: VehicleCoverageRow) => {row.bindingStatus === 'bound' ? '已绑定' : '未绑定'} }, + { title: '最后时间', dataIndex: 'lastSeen', width: 170 }, + { title: '操作', width: 110, render: (_: unknown, row: VehicleCoverageRow) => } + ]} + /> + diff --git a/vehicle-data-platform/docs/product-spec.md b/vehicle-data-platform/docs/product-spec.md index 8aaab9b9..5f9c6a26 100644 --- a/vehicle-data-platform/docs/product-spec.md +++ b/vehicle-data-platform/docs/product-spec.md @@ -18,6 +18,8 @@ The UI is a professional vehicle data operations console. It uses Semi UI as the The product model is vehicle-first. GB32960, JT808, and Yutong MQTT are ingestion sources for one vehicle service, not separate business products. The UI should lead with VIN, plate, online state, location, mileage, and data quality; protocol appears only as source attribution, diagnosis, and raw-frame traceability. +Vehicle service coverage is always shown at VIN level. A vehicle may have one or more ingestion sources, but upper product views should summarize source count, online source count, last seen time, and binding status before drilling into protocol-specific diagnostics. + ## Interaction Rules - Tables are the default data surface.