feat(platform): aggregate realtime by vehicle
This commit is contained in:
@@ -29,6 +29,7 @@ func (h *Handler) routes() {
|
||||
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/vehicles", h.handleVehicleRealtime)
|
||||
h.mux.HandleFunc("GET /api/realtime/locations", h.handleRealtimeLocations)
|
||||
h.mux.HandleFunc("GET /api/history/locations", h.handleHistoryLocations)
|
||||
h.mux.HandleFunc("GET /api/history/raw-frames", h.handleRawFramesGet)
|
||||
@@ -63,6 +64,11 @@ func (h *Handler) handleVehicleDetail(w http.ResponseWriter, r *http.Request) {
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleVehicleRealtime(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.VehicleRealtime(r.Context(), r.URL.Query())
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleRealtimeLocations(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.RealtimeLocations(r.Context(), r.URL.Query())
|
||||
h.write(w, r, data, err)
|
||||
|
||||
@@ -89,6 +89,21 @@ func TestHandlerRealtimeLocations(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerVehicleRealtime(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/realtime/vehicles?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{"protocols", "sourceCount", "primaryProtocol", "LB9A32A24R0LS1426"} {
|
||||
if !strings.Contains(rec.Body.String(), want) {
|
||||
t.Fatalf("response missing %q: %s", want, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerHistoryMileageQualityOps(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
|
||||
@@ -96,6 +96,82 @@ func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[V
|
||||
return page(items, query), nil
|
||||
}
|
||||
|
||||
func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[VehicleRealtimeRow], error) {
|
||||
rows := m.locations
|
||||
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
||||
keyword := strings.ToLower(vin)
|
||||
rows = keep(rows, func(row RealtimeLocationRow) bool {
|
||||
return strings.Contains(strings.ToLower(row.VIN+row.Plate), keyword)
|
||||
})
|
||||
}
|
||||
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
rows = keep(rows, func(row RealtimeLocationRow) bool { return row.Protocol == protocol })
|
||||
}
|
||||
byVIN := map[string]*VehicleRealtimeRow{}
|
||||
for _, location := range rows {
|
||||
current := byVIN[location.VIN]
|
||||
if current == nil {
|
||||
vehicle := m.vehicleByVIN(location.VIN)
|
||||
current = &VehicleRealtimeRow{
|
||||
VIN: location.VIN,
|
||||
Plate: firstNonEmpty(location.Plate, vehicle.Plate),
|
||||
Phone: vehicle.Phone,
|
||||
OEM: vehicle.OEM,
|
||||
LastSeen: location.LastSeen,
|
||||
}
|
||||
byVIN[location.VIN] = current
|
||||
}
|
||||
if !containsString(current.Protocols, location.Protocol) {
|
||||
current.Protocols = append(current.Protocols, location.Protocol)
|
||||
current.SourceCount = len(current.Protocols)
|
||||
}
|
||||
if location.LastSeen >= current.LastSeen {
|
||||
current.PrimaryProtocol = location.Protocol
|
||||
current.Longitude = location.Longitude
|
||||
current.Latitude = location.Latitude
|
||||
current.SpeedKmh = location.SpeedKmh
|
||||
current.SOCPercent = location.SOCPercent
|
||||
current.TotalMileageKm = location.TotalMileageKm
|
||||
current.LastSeen = location.LastSeen
|
||||
}
|
||||
if location.LastSeen >= "2026-07-03 20:11:00" {
|
||||
current.Online = true
|
||||
current.OnlineSourceCount++
|
||||
}
|
||||
}
|
||||
items := make([]VehicleRealtimeRow, 0, len(byVIN))
|
||||
for _, row := range byVIN {
|
||||
sort.Strings(row.Protocols)
|
||||
switch strings.TrimSpace(query.Get("online")) {
|
||||
case "online":
|
||||
if !row.Online {
|
||||
continue
|
||||
}
|
||||
case "offline":
|
||||
if row.Online {
|
||||
continue
|
||||
}
|
||||
}
|
||||
items = append(items, *row)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].LastSeen == items[j].LastSeen {
|
||||
return items[i].VIN < items[j].VIN
|
||||
}
|
||||
return items[i].LastSeen > items[j].LastSeen
|
||||
})
|
||||
return page(items, query), nil
|
||||
}
|
||||
|
||||
func (m *MockStore) vehicleByVIN(vin string) VehicleRow {
|
||||
for _, vehicle := range m.vehicles {
|
||||
if vehicle.VIN == vin {
|
||||
return vehicle
|
||||
}
|
||||
}
|
||||
return VehicleRow{}
|
||||
}
|
||||
|
||||
func (m *MockStore) RealtimeLocations(_ context.Context, query url.Values) (Page[RealtimeLocationRow], error) {
|
||||
items := m.locations
|
||||
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
||||
|
||||
@@ -88,6 +88,24 @@ type RealtimeLocationRow struct {
|
||||
LastSeen string `json:"lastSeen"`
|
||||
}
|
||||
|
||||
type VehicleRealtimeRow 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"`
|
||||
PrimaryProtocol string `json:"primaryProtocol"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
SpeedKmh float64 `json:"speedKmh"`
|
||||
SOCPercent float64 `json:"socPercent"`
|
||||
TotalMileageKm float64 `json:"totalMileageKm"`
|
||||
LastSeen string `json:"lastSeen"`
|
||||
}
|
||||
|
||||
type HistoryLocationRow struct {
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
|
||||
@@ -136,6 +136,62 @@ func buildRealtimeLocationSQL(query url.Values) SQLQuery {
|
||||
}
|
||||
}
|
||||
|
||||
func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
|
||||
limit := parsePositive(query.Get("limit"), 20)
|
||||
offset := parsePositive(query.Get("offset"), 0)
|
||||
args := []any{}
|
||||
where := []string{"l.vin IS NOT NULL", "l.vin <> ''"}
|
||||
having := []string{}
|
||||
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
where = append(where, "l.protocol = ?")
|
||||
args = append(args, protocol)
|
||||
}
|
||||
if keyword := strings.TrimSpace(query.Get("vin")); keyword != "" {
|
||||
where = append(where, "(l.vin LIKE ? OR l.plate LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + keyword + "%"
|
||||
args = append(args, like, like, like, like, like)
|
||||
}
|
||||
switch strings.TrimSpace(query.Get("online")) {
|
||||
case "online":
|
||||
having = append(having, "COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) > 0")
|
||||
case "offline":
|
||||
having = append(having, "COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) = 0")
|
||||
}
|
||||
countArgs := append([]any(nil), args...)
|
||||
args = append(args, limit, offset)
|
||||
havingSQL := ""
|
||||
if len(having) > 0 {
|
||||
havingSQL = ` HAVING ` + strings.Join(having, " AND ") + ` `
|
||||
}
|
||||
groupSQL := `FROM vehicle_realtime_location l ` +
|
||||
`LEFT JOIN vehicle_identity_binding b ON b.vin = l.vin ` +
|
||||
`WHERE ` + strings.Join(where, " AND ") + ` ` +
|
||||
`GROUP BY l.vin, b.plate, b.phone, b.oem ` +
|
||||
havingSQL
|
||||
orderExpr := `l.updated_at DESC, l.protocol ASC`
|
||||
return SQLQuery{
|
||||
Text: `SELECT l.vin, ` +
|
||||
`COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), b.plate, '') AS plate, ` +
|
||||
`COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem, ` +
|
||||
`COALESCE(GROUP_CONCAT(DISTINCT l.protocol ORDER BY l.protocol SEPARATOR ','), '') AS protocols, ` +
|
||||
`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, ` +
|
||||
`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, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(l.protocol ORDER BY ` + orderExpr + `), ',', 1), '') AS primary_protocol, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.longitude AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS longitude, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.latitude AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS latitude, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.speed_kmh AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS speed_kmh, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.soc_percent AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS soc_percent, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.total_mileage_km AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS total_mileage_km, ` +
|
||||
`COALESCE(DATE_FORMAT(MAX(l.updated_at), '%Y-%m-%d %H:%i:%s'), '') AS last_seen ` +
|
||||
groupSQL +
|
||||
`ORDER BY MAX(l.updated_at) DESC, l.vin ASC LIMIT ? OFFSET ?`,
|
||||
Args: args,
|
||||
CountText: `SELECT COUNT(*) FROM (SELECT l.vin ` + groupSQL + `) vehicle_realtime_count`,
|
||||
CountArgs: countArgs,
|
||||
}
|
||||
}
|
||||
|
||||
func buildDailyMileageSQL(query url.Values) SQLQuery {
|
||||
limit := parsePositive(query.Get("limit"), 20)
|
||||
offset := parsePositive(query.Get("offset"), 0)
|
||||
|
||||
@@ -149,6 +149,42 @@ func (s *ProductionStore) VehicleCoverage(ctx context.Context, query url.Values)
|
||||
return Page[VehicleCoverageRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) {
|
||||
built := buildVehicleRealtimeSQL(query)
|
||||
total := 0
|
||||
if err := s.db.QueryRowContext(ctx, built.CountText, built.CountArgs...).Scan(&total); err != nil {
|
||||
return Page[VehicleRealtimeRow]{}, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, built.Text, built.Args...)
|
||||
if err != nil {
|
||||
return Page[VehicleRealtimeRow]{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]VehicleRealtimeRow, 0)
|
||||
for rows.Next() {
|
||||
var row VehicleRealtimeRow
|
||||
var protocols string
|
||||
var online int
|
||||
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.PrimaryProtocol, &longitude, &latitude, &speed, &soc, &mileage, &row.LastSeen); err != nil {
|
||||
return Page[VehicleRealtimeRow]{}, err
|
||||
}
|
||||
row.Protocols = splitCSV(protocols)
|
||||
row.Online = online == 1
|
||||
row.Longitude = parseFloatString(longitude)
|
||||
row.Latitude = parseFloatString(latitude)
|
||||
row.SpeedKmh = parseFloatString(speed)
|
||||
row.SOCPercent = parseFloatString(soc)
|
||||
row.TotalMileageKm = parseFloatString(mileage)
|
||||
items = append(items, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return Page[VehicleRealtimeRow]{}, err
|
||||
}
|
||||
limit, offset := buildLimitOffset(query)
|
||||
return Page[VehicleRealtimeRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) RealtimeLocations(ctx context.Context, query url.Values) (Page[RealtimeLocationRow], error) {
|
||||
built := buildRealtimeLocationSQL(query)
|
||||
total := 0
|
||||
@@ -175,6 +211,14 @@ func (s *ProductionStore) RealtimeLocations(ctx context.Context, query url.Value
|
||||
return Page[RealtimeLocationRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
|
||||
}
|
||||
|
||||
func parseFloatString(value string) float64 {
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func (s *ProductionStore) HistoryLocations(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) {
|
||||
realtime, err := s.RealtimeLocations(ctx, query)
|
||||
if err != nil {
|
||||
|
||||
@@ -67,6 +67,25 @@ func TestBuildRealtimeLocationSQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleRealtimeSQL(t *testing.T) {
|
||||
query := url.Values{"vin": {"粤A"}, "protocol": {"JT808"}, "online": {"online"}, "limit": {"10"}, "offset": {"20"}}
|
||||
built := buildVehicleRealtimeSQL(query)
|
||||
for _, want := range []string{"vehicle_realtime_location", "vehicle_identity_binding", "GROUP BY l.vin", "GROUP_CONCAT(DISTINCT l.protocol", "online_source_count", "vehicle_realtime_count"} {
|
||||
if !strings.Contains(built.Text+built.CountText, want) {
|
||||
t.Fatalf("SQL missing %q: %s / %s", want, built.Text, built.CountText)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(built.Text, "ORDER BY MAX(l.updated_at) DESC, l.vin ASC") {
|
||||
t.Fatalf("SQL should keep vehicle-level stable pagination order: %s", built.Text)
|
||||
}
|
||||
if len(built.Args) != 8 || built.Args[0] != "JT808" || built.Args[1] != "%粤A%" || built.Args[6] != 10 || built.Args[7] != 20 {
|
||||
t.Fatalf("args = %#v", built.Args)
|
||||
}
|
||||
if len(built.CountArgs) != 6 || built.CountArgs[0] != "JT808" || built.CountArgs[1] != "%粤A%" {
|
||||
t.Fatalf("count args = %#v", built.CountArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDailyMileageSQL(t *testing.T) {
|
||||
query := url.Values{"vin": {"VIN001"}, "protocol": {"JT808"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-03"}}
|
||||
built := buildDailyMileageSQL(query)
|
||||
|
||||
@@ -11,6 +11,7 @@ type Store interface {
|
||||
DashboardSummary(context.Context) (DashboardSummary, error)
|
||||
Vehicles(context.Context, url.Values) (Page[VehicleRow], error)
|
||||
VehicleCoverage(context.Context, url.Values) (Page[VehicleCoverageRow], error)
|
||||
VehicleRealtime(context.Context, url.Values) (Page[VehicleRealtimeRow], 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)
|
||||
@@ -51,6 +52,10 @@ func (s *Service) VehicleCoverage(ctx context.Context, query url.Values) (Page[V
|
||||
return s.store.VehicleCoverage(ctx, query)
|
||||
}
|
||||
|
||||
func (s *Service) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) {
|
||||
return s.store.VehicleRealtime(ctx, query)
|
||||
}
|
||||
|
||||
func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string) (VehicleDetail, error) {
|
||||
keyword := strings.TrimSpace(vin)
|
||||
protocol = strings.TrimSpace(protocol)
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
QualityIssueRow,
|
||||
RawFrameRow,
|
||||
RealtimeLocationRow,
|
||||
VehicleRealtimeRow,
|
||||
VehicleCoverageRow,
|
||||
VehicleDetail,
|
||||
VehicleRow
|
||||
@@ -27,6 +28,7 @@ export const api = {
|
||||
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()}`),
|
||||
vehicleRealtime: (params = new URLSearchParams()) => request<Page<VehicleRealtimeRow>>(`/api/realtime/vehicles?${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()}`),
|
||||
rawFrames: (params = new URLSearchParams()) => request<Page<RawFrameRow>>(`/api/history/raw-frames?${params.toString()}`),
|
||||
|
||||
@@ -85,6 +85,24 @@ export interface RealtimeLocationRow {
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
export interface VehicleRealtimeRow {
|
||||
vin: string;
|
||||
plate: string;
|
||||
phone: string;
|
||||
oem: string;
|
||||
protocols: string[];
|
||||
sourceCount: number;
|
||||
onlineSourceCount: number;
|
||||
online: boolean;
|
||||
primaryProtocol: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
speedKmh: number;
|
||||
socPercent: number;
|
||||
totalMileageKm: number;
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
export interface HistoryLocationRow extends RealtimeLocationRow {
|
||||
deviceTime: string;
|
||||
serverTime: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button, Card, Col, Form, Row, Select, 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, QualityIssueRow, RealtimeLocationRow, VehicleCoverageRow, VehicleRow } from '../api/types';
|
||||
import type { DashboardSummary, LinkHealth, ProtocolStat, QualityIssueRow, VehicleCoverageRow, VehicleRealtimeRow, VehicleRow } from '../api/types';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { StatusTag } from '../components/StatusTag';
|
||||
|
||||
@@ -19,7 +19,7 @@ export function Dashboard({ onOpenVehicle, onOpenQuality }: { onOpenVehicle: (vi
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [vehicles, setVehicles] = useState<VehicleRow[]>([]);
|
||||
const [coverage, setCoverage] = useState<VehicleCoverageRow[]>([]);
|
||||
const [locations, setLocations] = useState<RealtimeLocationRow[]>([]);
|
||||
const [locations, setLocations] = useState<VehicleRealtimeRow[]>([]);
|
||||
const [qualityIssues, setQualityIssues] = useState<QualityIssueRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [coverageLoading, setCoverageLoading] = useState(false);
|
||||
@@ -42,7 +42,7 @@ export function Dashboard({ onOpenVehicle, onOpenQuality }: { onOpenVehicle: (vi
|
||||
api.dashboardSummary(),
|
||||
api.vehicles(new URLSearchParams({ limit: '5' })),
|
||||
api.vehicleCoverage(new URLSearchParams({ limit: '8' })),
|
||||
api.realtimeLocations(new URLSearchParams({ limit: '8' })),
|
||||
api.vehicleRealtime(new URLSearchParams({ limit: '8' })),
|
||||
api.qualityIssues(new URLSearchParams({ limit: '5' }))
|
||||
])
|
||||
.then(([nextSummary, vehiclePage, coveragePage, locationPage, qualityPage]) => {
|
||||
@@ -190,7 +190,7 @@ export function Dashboard({ onOpenVehicle, onOpenQuality }: { onOpenVehicle: (vi
|
||||
<span
|
||||
key={row.vin}
|
||||
className="vp-map-dot"
|
||||
title={`${row.plate} ${row.protocol}`}
|
||||
title={`${row.plate} ${row.primaryProtocol}`}
|
||||
style={{ left: `${18 + index * 13}%`, top: `${24 + (index % 4) * 15}%` }}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Button, Card, Form, Select, Space, Table, Tabs, Toast } from '@douyinfe/semi-ui';
|
||||
import { Button, Card, Form, Select, Space, Table, Tabs, Tag, Toast } from '@douyinfe/semi-ui';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { RealtimeLocationRow } from '../api/types';
|
||||
import type { VehicleRealtimeRow } from '../api/types';
|
||||
import { DataEmpty } from '../components/DataEmpty';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { StatusTag } from '../components/StatusTag';
|
||||
|
||||
export function Realtime() {
|
||||
const [rows, setRows] = useState<RealtimeLocationRow[]>([]);
|
||||
const [rows, setRows] = useState<VehicleRealtimeRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 50, total: 0 });
|
||||
@@ -16,7 +17,8 @@ export function Realtime() {
|
||||
const params = new URLSearchParams({ limit: String(pageSize), offset: String((page - 1) * pageSize) });
|
||||
if (values?.vin) params.set('vin', values.vin);
|
||||
if (values?.protocol) params.set('protocol', values.protocol);
|
||||
api.realtimeLocations(params)
|
||||
if (values?.online) params.set('online', values.online);
|
||||
api.vehicleRealtime(params)
|
||||
.then((nextPage) => {
|
||||
setRows(nextPage.items);
|
||||
setPagination({ currentPage: page, pageSize, total: nextPage.total });
|
||||
@@ -31,19 +33,23 @@ export function Realtime() {
|
||||
|
||||
return (
|
||||
<div className="vp-page">
|
||||
<PageHeader title="实时状态" description="按协议和车辆查看最新实时位置、在线状态和核心数据" />
|
||||
<PageHeader title="实时状态" description="以车辆为主对象查看最新位置、在线来源和核心实时数据" />
|
||||
<Card bordered>
|
||||
<Form layout="horizontal" onSubmit={(values) => {
|
||||
const nextFilters = values as Record<string, string>;
|
||||
setFilters(nextFilters);
|
||||
load(nextFilters, 1, pagination.pageSize);
|
||||
}} style={{ marginBottom: 12 }}>
|
||||
<Form.Input field="vin" label="关键词" placeholder="VIN / 车牌" style={{ width: 260 }} />
|
||||
<Form.Input field="vin" label="关键词" placeholder="VIN / 车牌 / 手机号 / OEM" style={{ width: 260 }} />
|
||||
<Form.Select field="protocol" label="协议" placeholder="全部协议" style={{ width: 180 }}>
|
||||
<Select.Option value="GB32960">GB32960</Select.Option>
|
||||
<Select.Option value="JT808">JT808</Select.Option>
|
||||
<Select.Option value="YUTONG_MQTT">YUTONG_MQTT</Select.Option>
|
||||
</Form.Select>
|
||||
<Form.Select field="online" label="在线" placeholder="全部" style={{ width: 130 }}>
|
||||
<Select.Option value="online">在线</Select.Option>
|
||||
<Select.Option value="offline">离线</Select.Option>
|
||||
</Form.Select>
|
||||
<Space>
|
||||
<Button htmlType="submit" theme="solid" type="primary">查询</Button>
|
||||
<Button onClick={() => {
|
||||
@@ -59,7 +65,7 @@ export function Realtime() {
|
||||
) : (
|
||||
<Table
|
||||
loading={loading}
|
||||
rowKey={(row?: RealtimeLocationRow) => `${row?.vin ?? ''}-${row?.protocol ?? ''}`}
|
||||
rowKey="vin"
|
||||
dataSource={rows}
|
||||
pagination={{
|
||||
currentPage: pagination.currentPage,
|
||||
@@ -72,10 +78,23 @@ export function Realtime() {
|
||||
columns={[
|
||||
{ title: 'VIN', dataIndex: 'vin' },
|
||||
{ title: '车牌', dataIndex: 'plate' },
|
||||
{ title: '协议', dataIndex: 'protocol' },
|
||||
{
|
||||
title: '来源',
|
||||
width: 260,
|
||||
render: (_: unknown, row: VehicleRealtimeRow) => (
|
||||
<Space spacing={4} wrap>
|
||||
{row.protocols.map((protocol) => (
|
||||
<Tag key={protocol} color={protocol === row.primaryProtocol ? 'blue' : 'grey'}>{protocol}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{ title: '覆盖', width: 90, render: (_: unknown, row: VehicleRealtimeRow) => `${row.onlineSourceCount}/${row.sourceCount}` },
|
||||
{ title: '在线', width: 90, render: (_: unknown, row: VehicleRealtimeRow) => <StatusTag status={row.online ? 'ok' : 'offline'} /> },
|
||||
{ title: '速度 km/h', dataIndex: 'speedKmh' },
|
||||
{ title: 'SOC %', dataIndex: 'socPercent' },
|
||||
{ title: '总里程 km', dataIndex: 'totalMileageKm' },
|
||||
{ title: '最新来源', dataIndex: 'primaryProtocol' },
|
||||
{ title: '最后时间', dataIndex: 'lastSeen' }
|
||||
]}
|
||||
/>
|
||||
@@ -87,7 +106,7 @@ export function Realtime() {
|
||||
<span
|
||||
key={row.vin}
|
||||
className="vp-map-dot"
|
||||
title={`${row.plate} ${row.protocol}`}
|
||||
title={`${row.plate} ${row.primaryProtocol}`}
|
||||
style={{ left: `${18 + index * 24}%`, top: `${26 + (index % 3) * 18}%` }}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user