feat(platform): add vehicle coverage view
This commit is contained in:
@@ -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 == "" {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function App() {
|
||||
};
|
||||
|
||||
const pages: Record<PageKey, JSX.Element> = {
|
||||
dashboard: <Dashboard />,
|
||||
dashboard: <Dashboard onOpenVehicle={openVehicle} />,
|
||||
vehicles: <Vehicles onOpenVehicle={openVehicle} />,
|
||||
realtime: <Realtime />,
|
||||
detail: <VehicleDetail vin={activeVin} />,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
QualityIssueRow,
|
||||
RawFrameRow,
|
||||
RealtimeLocationRow,
|
||||
VehicleCoverageRow,
|
||||
VehicleDetail,
|
||||
VehicleRow
|
||||
} from './types';
|
||||
@@ -24,6 +25,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
export const api = {
|
||||
dashboardSummary: () => request<DashboardSummary>('/api/dashboard/summary'),
|
||||
vehicles: (params = new URLSearchParams()) => request<Page<VehicleRow>>(`/api/vehicles?${params.toString()}`),
|
||||
vehicleCoverage: (params = new URLSearchParams()) => request<Page<VehicleCoverageRow>>(`/api/vehicles/coverage?${params.toString()}`),
|
||||
vehicleDetail: (params = new URLSearchParams()) => request<VehicleDetail>(`/api/vehicles/detail?${params.toString()}`),
|
||||
realtimeLocations: (params = new URLSearchParams()) => request<Page<RealtimeLocationRow>>(`/api/realtime/locations?${params.toString()}`),
|
||||
historyLocations: (params = new URLSearchParams()) => request<Page<HistoryLocationRow>>(`/api/history/locations?${params.toString()}`),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, 'green' | 'orange' | 'red' | 'grey'> = {
|
||||
ok: 'green',
|
||||
@@ -10,9 +11,10 @@ const statusColor: Record<string, 'green' | 'orange' | 'red' | 'grey'> = {
|
||||
error: 'red'
|
||||
};
|
||||
|
||||
export function Dashboard() {
|
||||
export function Dashboard({ onOpenVehicle }: { onOpenVehicle: (vin: string) => void }) {
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [vehicles, setVehicles] = useState<VehicleRow[]>([]);
|
||||
const [coverage, setCoverage] = useState<VehicleCoverageRow[]>([]);
|
||||
const [locations, setLocations] = useState<RealtimeLocationRow[]>([]);
|
||||
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() {
|
||||
<Card title="实时积压" bordered style={{ marginTop: 16 }}>
|
||||
<Typography.Text>Kafka 当前消费积压:{summary?.kafkaLag ?? 0}</Typography.Text>
|
||||
</Card>
|
||||
<Card title="车辆服务覆盖" bordered style={{ marginTop: 16 }}>
|
||||
<Table
|
||||
pagination={false}
|
||||
rowKey="vin"
|
||||
dataSource={coverage}
|
||||
columns={[
|
||||
{ title: '车牌', dataIndex: 'plate', width: 110 },
|
||||
{ title: 'VIN', dataIndex: 'vin', width: 190 },
|
||||
{
|
||||
title: '来源',
|
||||
width: 240,
|
||||
render: (_: unknown, row: VehicleCoverageRow) => (
|
||||
<Space spacing={4} wrap>
|
||||
{row.protocols.map((protocol) => <Tag key={protocol} color="blue">{protocol}</Tag>)}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '覆盖',
|
||||
width: 110,
|
||||
render: (_: unknown, row: VehicleCoverageRow) => `${row.onlineSourceCount}/${row.sourceCount}`
|
||||
},
|
||||
{ title: '在线', width: 90, render: (_: unknown, row: VehicleCoverageRow) => <StatusTag status={row.online ? 'ok' : 'offline'} /> },
|
||||
{ title: '绑定', width: 90, render: (_: unknown, row: VehicleCoverageRow) => <Tag color={row.bindingStatus === 'bound' ? 'green' : 'orange'}>{row.bindingStatus === 'bound' ? '已绑定' : '未绑定'}</Tag> },
|
||||
{ title: '最后时间', dataIndex: 'lastSeen', width: 170 },
|
||||
{ title: '操作', width: 110, render: (_: unknown, row: VehicleCoverageRow) => <Button onClick={() => onOpenVehicle(row.vin)}>车辆服务</Button> }
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
<Row gutter={16} style={{ marginTop: 16 }}>
|
||||
<Col span={12}>
|
||||
<Card title="实时位置预览" bordered>
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user