feat(platform): add vehicle detail aggregate api
This commit is contained in:
@@ -27,6 +27,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (h *Handler) routes() {
|
func (h *Handler) routes() {
|
||||||
h.mux.HandleFunc("GET /api/dashboard/summary", h.handleDashboardSummary)
|
h.mux.HandleFunc("GET /api/dashboard/summary", h.handleDashboardSummary)
|
||||||
h.mux.HandleFunc("GET /api/vehicles", h.handleVehicles)
|
h.mux.HandleFunc("GET /api/vehicles", h.handleVehicles)
|
||||||
|
h.mux.HandleFunc("GET /api/vehicles/detail", h.handleVehicleDetail)
|
||||||
h.mux.HandleFunc("GET /api/realtime/locations", h.handleRealtimeLocations)
|
h.mux.HandleFunc("GET /api/realtime/locations", h.handleRealtimeLocations)
|
||||||
h.mux.HandleFunc("GET /api/history/locations", h.handleHistoryLocations)
|
h.mux.HandleFunc("GET /api/history/locations", h.handleHistoryLocations)
|
||||||
h.mux.HandleFunc("GET /api/history/raw-frames", h.handleRawFramesGet)
|
h.mux.HandleFunc("GET /api/history/raw-frames", h.handleRawFramesGet)
|
||||||
@@ -46,6 +47,16 @@ func (h *Handler) handleVehicles(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.write(w, r, data, err)
|
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 == "" {
|
||||||
|
httpx.WriteError(w, http.StatusBadRequest, "VIN_REQUIRED", "VIN 不能为空", "", traceID(r))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := h.service.VehicleDetail(r.Context(), vin, r.URL.Query().Get("protocol"))
|
||||||
|
h.write(w, r, data, err)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) handleRealtimeLocations(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleRealtimeLocations(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := h.service.RealtimeLocations(r.Context(), r.URL.Query())
|
data, err := h.service.RealtimeLocations(r.Context(), r.URL.Query())
|
||||||
h.write(w, r, data, err)
|
h.write(w, r, data, err)
|
||||||
|
|||||||
@@ -33,6 +33,21 @@ func TestHandlerVehicles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandlerVehicleDetail(t *testing.T) {
|
||||||
|
handler := NewHandler(NewService(NewMockStore()))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/vehicles/detail?vin=LB9A32A24R0LS1426", 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{"identity", "realtime", "history", "raw", "mileage", "sources"} {
|
||||||
|
if !strings.Contains(rec.Body.String(), want) {
|
||||||
|
t.Fatalf("response missing %q: %s", want, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandlerRealtimeLocations(t *testing.T) {
|
func TestHandlerRealtimeLocations(t *testing.T) {
|
||||||
handler := NewHandler(NewService(NewMockStore()))
|
handler := NewHandler(NewService(NewMockStore()))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -41,6 +41,16 @@ type VehicleRow struct {
|
|||||||
BindingScore int `json:"bindingScore"`
|
BindingScore int `json:"bindingScore"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
type RealtimeLocationRow struct {
|
type RealtimeLocationRow struct {
|
||||||
VIN string `json:"vin"`
|
VIN string `json:"vin"`
|
||||||
Plate string `json:"plate"`
|
Plate string `json:"plate"`
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package platform
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Store interface {
|
type Store interface {
|
||||||
@@ -44,6 +46,60 @@ func (s *Service) Vehicles(ctx context.Context, query url.Values) (Page[VehicleR
|
|||||||
return s.store.Vehicles(ctx, query)
|
return s.store.Vehicles(ctx, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string) (VehicleDetail, error) {
|
||||||
|
vin = strings.TrimSpace(vin)
|
||||||
|
protocol = strings.TrimSpace(protocol)
|
||||||
|
vehicleQuery := url.Values{"keyword": {vin}, "limit": {"10"}}
|
||||||
|
realtimeQuery := url.Values{"vin": {vin}, "limit": {"20"}}
|
||||||
|
historyQuery := url.Values{"vin": {vin}, "limit": {"20"}}
|
||||||
|
rawQuery := RawFrameQuery{VIN: vin, IncludeFields: true, Limit: 10}
|
||||||
|
mileageQuery := url.Values{"vin": {vin}, "limit": {"20"}}
|
||||||
|
if protocol != "" {
|
||||||
|
realtimeQuery.Set("protocol", protocol)
|
||||||
|
historyQuery.Set("protocol", protocol)
|
||||||
|
rawQuery.Protocol = protocol
|
||||||
|
}
|
||||||
|
vehicles, err := s.store.Vehicles(ctx, vehicleQuery)
|
||||||
|
if err != nil {
|
||||||
|
return VehicleDetail{}, err
|
||||||
|
}
|
||||||
|
realtime, err := s.store.RealtimeLocations(ctx, realtimeQuery)
|
||||||
|
if err != nil {
|
||||||
|
return VehicleDetail{}, err
|
||||||
|
}
|
||||||
|
history, err := s.store.HistoryLocationsFromTDengine(ctx, historyQuery)
|
||||||
|
if err != nil {
|
||||||
|
return VehicleDetail{}, err
|
||||||
|
}
|
||||||
|
raw, err := s.RawFrames(ctx, rawQuery)
|
||||||
|
if err != nil {
|
||||||
|
return VehicleDetail{}, err
|
||||||
|
}
|
||||||
|
mileage, err := s.store.DailyMileage(ctx, mileageQuery)
|
||||||
|
if err != nil {
|
||||||
|
return VehicleDetail{}, err
|
||||||
|
}
|
||||||
|
var identity *VehicleRow
|
||||||
|
for index := range vehicles.Items {
|
||||||
|
if strings.EqualFold(vehicles.Items[index].VIN, vin) {
|
||||||
|
identity = &vehicles.Items[index]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if identity == nil && len(vehicles.Items) > 0 {
|
||||||
|
identity = &vehicles.Items[0]
|
||||||
|
}
|
||||||
|
return VehicleDetail{
|
||||||
|
VIN: vin,
|
||||||
|
Identity: identity,
|
||||||
|
Sources: vehicleSources(vehicles.Items, realtime.Items, raw.Items, mileage.Items),
|
||||||
|
Realtime: realtime.Items,
|
||||||
|
History: history,
|
||||||
|
Raw: raw,
|
||||||
|
Mileage: mileage,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) RealtimeLocations(ctx context.Context, query url.Values) (Page[RealtimeLocationRow], error) {
|
func (s *Service) RealtimeLocations(ctx context.Context, query url.Values) (Page[RealtimeLocationRow], error) {
|
||||||
return s.store.RealtimeLocations(ctx, query)
|
return s.store.RealtimeLocations(ctx, query)
|
||||||
}
|
}
|
||||||
@@ -70,3 +126,32 @@ func (s *Service) QualityIssues(ctx context.Context, query url.Values) (Page[Qua
|
|||||||
func (s *Service) OpsHealth(ctx context.Context) (OpsHealth, error) {
|
func (s *Service) OpsHealth(ctx context.Context) (OpsHealth, error) {
|
||||||
return s.store.OpsHealth(ctx)
|
return s.store.OpsHealth(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func vehicleSources(vehicles []VehicleRow, realtime []RealtimeLocationRow, raw []RawFrameRow, mileage []DailyMileageRow) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
add := func(value string) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, row := range vehicles {
|
||||||
|
add(row.Protocol)
|
||||||
|
}
|
||||||
|
for _, row := range realtime {
|
||||||
|
add(row.Protocol)
|
||||||
|
}
|
||||||
|
for _, row := range raw {
|
||||||
|
add(row.Protocol)
|
||||||
|
}
|
||||||
|
for _, row := range mileage {
|
||||||
|
add(row.Source)
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(seen))
|
||||||
|
for source := range seen {
|
||||||
|
out = append(out, source)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
QualityIssueRow,
|
QualityIssueRow,
|
||||||
RawFrameRow,
|
RawFrameRow,
|
||||||
RealtimeLocationRow,
|
RealtimeLocationRow,
|
||||||
|
VehicleDetail,
|
||||||
VehicleRow
|
VehicleRow
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
export const api = {
|
export const api = {
|
||||||
dashboardSummary: () => request<DashboardSummary>('/api/dashboard/summary'),
|
dashboardSummary: () => request<DashboardSummary>('/api/dashboard/summary'),
|
||||||
vehicles: (params = new URLSearchParams()) => request<Page<VehicleRow>>(`/api/vehicles?${params.toString()}`),
|
vehicles: (params = new URLSearchParams()) => request<Page<VehicleRow>>(`/api/vehicles?${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()}`),
|
realtimeLocations: (params = new URLSearchParams()) => request<Page<RealtimeLocationRow>>(`/api/realtime/locations?${params.toString()}`),
|
||||||
historyLocations: (params = new URLSearchParams()) => request<Page<HistoryLocationRow>>(`/api/history/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()}`),
|
rawFrames: (params = new URLSearchParams()) => request<Page<RawFrameRow>>(`/api/history/raw-frames?${params.toString()}`),
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ export interface VehicleRow {
|
|||||||
bindingScore: number;
|
bindingScore: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VehicleDetail {
|
||||||
|
vin: string;
|
||||||
|
identity?: VehicleRow;
|
||||||
|
sources: string[];
|
||||||
|
realtime: RealtimeLocationRow[];
|
||||||
|
history: Page<HistoryLocationRow>;
|
||||||
|
raw: Page<RawFrameRow>;
|
||||||
|
mileage: Page<DailyMileageRow>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RealtimeLocationRow {
|
export interface RealtimeLocationRow {
|
||||||
vin: string;
|
vin: string;
|
||||||
plate: string;
|
plate: string;
|
||||||
|
|||||||
@@ -2,18 +2,10 @@ import { Button, Card, Descriptions, Form, Select, Space, Table, Tabs, Tag, Toas
|
|||||||
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { DailyMileageRow, HistoryLocationRow, RawFrameRow, RealtimeLocationRow, VehicleRow } from '../api/types';
|
import type { VehicleDetail as VehicleDetailData } from '../api/types';
|
||||||
import { PageHeader } from '../components/PageHeader';
|
import { PageHeader } from '../components/PageHeader';
|
||||||
import { StatusTag } from '../components/StatusTag';
|
import { StatusTag } from '../components/StatusTag';
|
||||||
|
|
||||||
type VehicleServiceState = {
|
|
||||||
vehicles: VehicleRow[];
|
|
||||||
realtime: RealtimeLocationRow[];
|
|
||||||
history: HistoryLocationRow[];
|
|
||||||
raw: RawFrameRow[];
|
|
||||||
mileage: DailyMileageRow[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type VehicleQuery = {
|
type VehicleQuery = {
|
||||||
vin: string;
|
vin: string;
|
||||||
protocol?: string;
|
protocol?: string;
|
||||||
@@ -23,17 +15,9 @@ const defaultQuery: VehicleQuery = {
|
|||||||
vin: 'LB9A32A24R0LS1426'
|
vin: 'LB9A32A24R0LS1426'
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyState: VehicleServiceState = {
|
|
||||||
vehicles: [],
|
|
||||||
realtime: [],
|
|
||||||
history: [],
|
|
||||||
raw: [],
|
|
||||||
mileage: []
|
|
||||||
};
|
|
||||||
|
|
||||||
export function VehicleDetail() {
|
export function VehicleDetail() {
|
||||||
const [query, setQuery] = useState<VehicleQuery>(defaultQuery);
|
const [query, setQuery] = useState<VehicleQuery>(defaultQuery);
|
||||||
const [state, setState] = useState<VehicleServiceState>(emptyState);
|
const [detail, setDetail] = useState<VehicleDetailData | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const load = (nextQuery = query) => {
|
const load = (nextQuery = query) => {
|
||||||
@@ -43,30 +27,12 @@ export function VehicleDetail() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const scoped = new URLSearchParams({ vin, limit: '20' });
|
const params = new URLSearchParams({ vin });
|
||||||
const vehicleParams = new URLSearchParams({ keyword: vin, limit: '5' });
|
|
||||||
const rawParams = new URLSearchParams({ vin, limit: '10', includeFields: 'true' });
|
|
||||||
const mileageParams = new URLSearchParams({ vin, limit: '20' });
|
|
||||||
if (nextQuery.protocol?.trim()) {
|
if (nextQuery.protocol?.trim()) {
|
||||||
scoped.set('protocol', nextQuery.protocol.trim());
|
params.set('protocol', nextQuery.protocol.trim());
|
||||||
rawParams.set('protocol', nextQuery.protocol.trim());
|
|
||||||
}
|
}
|
||||||
Promise.all([
|
api.vehicleDetail(params)
|
||||||
api.vehicles(vehicleParams),
|
.then(setDetail)
|
||||||
api.realtimeLocations(scoped),
|
|
||||||
api.historyLocations(scoped),
|
|
||||||
api.rawFrames(rawParams),
|
|
||||||
api.dailyMileage(mileageParams)
|
|
||||||
])
|
|
||||||
.then(([vehicles, realtime, history, raw, mileage]) => {
|
|
||||||
setState({
|
|
||||||
vehicles: vehicles.items,
|
|
||||||
realtime: realtime.items,
|
|
||||||
history: history.items,
|
|
||||||
raw: raw.items,
|
|
||||||
mileage: mileage.items
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((error: Error) => Toast.error(error.message))
|
.catch((error: Error) => Toast.error(error.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
@@ -75,13 +41,10 @@ export function VehicleDetail() {
|
|||||||
load(defaultQuery);
|
load(defaultQuery);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const identity = state.vehicles[0];
|
const identity = detail?.identity;
|
||||||
const latest = state.realtime[0];
|
const latest = detail?.realtime[0];
|
||||||
const protocols = useMemo(
|
const protocols = useMemo(() => detail?.sources ?? [], [detail?.sources]);
|
||||||
() => Array.from(new Set([...state.vehicles.map((row) => row.protocol), ...state.realtime.map((row) => row.protocol)].filter(Boolean))),
|
const latestRaw = detail?.raw.items[0];
|
||||||
[state.realtime, state.vehicles]
|
|
||||||
);
|
|
||||||
const latestRaw = state.raw[0];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="vp-page">
|
<div className="vp-page">
|
||||||
@@ -130,7 +93,7 @@ export function VehicleDetail() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
rowKey="protocol"
|
rowKey="protocol"
|
||||||
dataSource={state.realtime}
|
dataSource={detail?.realtime ?? []}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '来源', dataIndex: 'protocol', width: 130 },
|
{ title: '来源', dataIndex: 'protocol', width: 130 },
|
||||||
{ title: '经度', dataIndex: 'longitude', width: 120 },
|
{ title: '经度', dataIndex: 'longitude', width: 120 },
|
||||||
@@ -147,7 +110,7 @@ export function VehicleDetail() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
rowKey="deviceTime"
|
rowKey="deviceTime"
|
||||||
dataSource={state.history}
|
dataSource={detail?.history.items ?? []}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '来源', dataIndex: 'protocol', width: 130 },
|
{ title: '来源', dataIndex: 'protocol', width: 130 },
|
||||||
{ title: '经度', dataIndex: 'longitude', width: 120 },
|
{ title: '经度', dataIndex: 'longitude', width: 120 },
|
||||||
@@ -164,7 +127,7 @@ export function VehicleDetail() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
dataSource={state.raw}
|
dataSource={detail?.raw.items ?? []}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '来源', dataIndex: 'protocol', width: 130 },
|
{ title: '来源', dataIndex: 'protocol', width: 130 },
|
||||||
{ title: '帧类型', dataIndex: 'frameType', width: 190 },
|
{ title: '帧类型', dataIndex: 'frameType', width: 190 },
|
||||||
@@ -181,7 +144,7 @@ export function VehicleDetail() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
rowKey="date"
|
rowKey="date"
|
||||||
dataSource={state.mileage}
|
dataSource={detail?.mileage.items ?? []}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '日期', dataIndex: 'date', width: 130 },
|
{ title: '日期', dataIndex: 'date', width: 130 },
|
||||||
{ title: '来源', dataIndex: 'source', width: 130 },
|
{ title: '来源', dataIndex: 'source', width: 130 },
|
||||||
|
|||||||
Reference in New Issue
Block a user