diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index 769a098f..5393b7a6 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/detail", h.handleVehicleDetail) 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) @@ -46,6 +47,16 @@ func (h *Handler) handleVehicles(w http.ResponseWriter, r *http.Request) { 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) { data, err := h.service.RealtimeLocations(r.Context(), r.URL.Query()) h.write(w, r, data, err) 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 6a0d568d..8b0f2339 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 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) { handler := NewHandler(NewService(NewMockStore())) rec := httptest.NewRecorder() diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index dee91a3b..b7167a54 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -41,6 +41,16 @@ type VehicleRow struct { 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 { VIN string `json:"vin"` Plate string `json:"plate"` diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index 42b0d6c7..2a5e53ca 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -3,6 +3,8 @@ package platform import ( "context" "net/url" + "sort" + "strings" ) 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) } +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) { 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) { 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 +} diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index 8989358c..90476070 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, + VehicleDetail, VehicleRow } from './types'; @@ -23,6 +24,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()}`), + 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()}`), rawFrames: (params = new URLSearchParams()) => request>(`/api/history/raw-frames?${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 a975ff99..5031609f 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -38,6 +38,16 @@ export interface VehicleRow { bindingScore: number; } +export interface VehicleDetail { + vin: string; + identity?: VehicleRow; + sources: string[]; + realtime: RealtimeLocationRow[]; + history: Page; + raw: Page; + mileage: Page; +} + 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 93247464..b4f863e1 100644 --- a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx +++ b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx @@ -2,18 +2,10 @@ 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 { DailyMileageRow, HistoryLocationRow, RawFrameRow, RealtimeLocationRow, VehicleRow } from '../api/types'; +import type { VehicleDetail as VehicleDetailData } from '../api/types'; import { PageHeader } from '../components/PageHeader'; import { StatusTag } from '../components/StatusTag'; -type VehicleServiceState = { - vehicles: VehicleRow[]; - realtime: RealtimeLocationRow[]; - history: HistoryLocationRow[]; - raw: RawFrameRow[]; - mileage: DailyMileageRow[]; -}; - type VehicleQuery = { vin: string; protocol?: string; @@ -23,17 +15,9 @@ const defaultQuery: VehicleQuery = { vin: 'LB9A32A24R0LS1426' }; -const emptyState: VehicleServiceState = { - vehicles: [], - realtime: [], - history: [], - raw: [], - mileage: [] -}; - export function VehicleDetail() { const [query, setQuery] = useState(defaultQuery); - const [state, setState] = useState(emptyState); + const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(false); const load = (nextQuery = query) => { @@ -43,30 +27,12 @@ export function VehicleDetail() { return; } setLoading(true); - const scoped = new URLSearchParams({ vin, limit: '20' }); - const vehicleParams = new URLSearchParams({ keyword: vin, limit: '5' }); - const rawParams = new URLSearchParams({ vin, limit: '10', includeFields: 'true' }); - const mileageParams = new URLSearchParams({ vin, limit: '20' }); + const params = new URLSearchParams({ vin }); if (nextQuery.protocol?.trim()) { - scoped.set('protocol', nextQuery.protocol.trim()); - rawParams.set('protocol', nextQuery.protocol.trim()); + params.set('protocol', nextQuery.protocol.trim()); } - Promise.all([ - api.vehicles(vehicleParams), - 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 - }); - }) + api.vehicleDetail(params) + .then(setDetail) .catch((error: Error) => Toast.error(error.message)) .finally(() => setLoading(false)); }; @@ -75,13 +41,10 @@ export function VehicleDetail() { load(defaultQuery); }, []); - const identity = state.vehicles[0]; - const latest = state.realtime[0]; - const protocols = useMemo( - () => Array.from(new Set([...state.vehicles.map((row) => row.protocol), ...state.realtime.map((row) => row.protocol)].filter(Boolean))), - [state.realtime, state.vehicles] - ); - const latestRaw = state.raw[0]; + const identity = detail?.identity; + const latest = detail?.realtime[0]; + const protocols = useMemo(() => detail?.sources ?? [], [detail?.sources]); + const latestRaw = detail?.raw.items[0]; return (
@@ -130,7 +93,7 @@ export function VehicleDetail() { loading={loading} pagination={false} rowKey="protocol" - dataSource={state.realtime} + dataSource={detail?.realtime ?? []} columns={[ { title: '来源', dataIndex: 'protocol', width: 130 }, { title: '经度', dataIndex: 'longitude', width: 120 }, @@ -147,7 +110,7 @@ export function VehicleDetail() { loading={loading} pagination={false} rowKey="deviceTime" - dataSource={state.history} + dataSource={detail?.history.items ?? []} columns={[ { title: '来源', dataIndex: 'protocol', width: 130 }, { title: '经度', dataIndex: 'longitude', width: 120 }, @@ -164,7 +127,7 @@ export function VehicleDetail() { loading={loading} pagination={false} rowKey="id" - dataSource={state.raw} + dataSource={detail?.raw.items ?? []} columns={[ { title: '来源', dataIndex: 'protocol', width: 130 }, { title: '帧类型', dataIndex: 'frameType', width: 190 }, @@ -181,7 +144,7 @@ export function VehicleDetail() { loading={loading} pagination={false} rowKey="date" - dataSource={state.mileage} + dataSource={detail?.mileage.items ?? []} columns={[ { title: '日期', dataIndex: 'date', width: 130 }, { title: '来源', dataIndex: 'source', width: 130 },