From 3b817ef8740ea9e2665743dfe963937bbb8ae367 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 17:47:38 +0800 Subject: [PATCH] feat(platform): add online vehicle statistics detail --- .../apps/api/internal/platform/handler.go | 6 ++ .../api/internal/platform/handler_test.go | 1 + .../apps/api/internal/platform/model.go | 14 +++ .../apps/api/internal/platform/service.go | 50 +++++++++++ .../apps/web/src/api/client.test.ts | 33 +++++++ .../apps/web/src/api/client.ts | 2 + .../apps/web/src/api/types.ts | 14 +++ .../apps/web/src/pages/Mileage.tsx | 87 ++++++++++++++++++- .../apps/web/src/test/App.test.tsx | 48 +++++++++- 9 files changed, 253 insertions(+), 2 deletions(-) diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index 55cf23f5..e447a205 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler.go @@ -43,6 +43,7 @@ func (h *Handler) routes() { h.mux.HandleFunc("GET /api/mileage/summary", h.handleMileageSummary) h.mux.HandleFunc("GET /api/mileage/daily", h.handleDailyMileage) h.mux.HandleFunc("GET /api/statistics/online-summary", h.handleOnlineStatisticsSummary) + h.mux.HandleFunc("GET /api/statistics/online-vehicles", h.handleOnlineVehicleStatuses) h.mux.HandleFunc("GET /api/quality/summary", h.handleQualitySummary) h.mux.HandleFunc("GET /api/quality/issues", h.handleQualityIssues) h.mux.HandleFunc("GET /api/quality/notification-plan", h.handleQualityNotificationPlan) @@ -176,6 +177,11 @@ func (h *Handler) handleOnlineStatisticsSummary(w http.ResponseWriter, r *http.R h.write(w, r, data, err) } +func (h *Handler) handleOnlineVehicleStatuses(w http.ResponseWriter, r *http.Request) { + data, err := h.service.OnlineVehicleStatuses(r.Context(), r.URL.Query()) + h.write(w, r, data, err) +} + func (h *Handler) handleQualityIssues(w http.ResponseWriter, r *http.Request) { data, err := h.service.QualityIssues(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 70b3efd0..5685c87a 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -746,6 +746,7 @@ func TestHandlerHistoryMileageQualityOps(t *testing.T) { {"/api/mileage/summary?limit=10", "totalMileageKm"}, {"/api/mileage/daily?limit=10", "dailyMileageKm"}, {"/api/statistics/online-summary?limit=10", "onlineRatePercent"}, + {"/api/statistics/online-vehicles?limit=10", "offlineDurationMinutes"}, {"/api/quality/summary?limit=10", "issueVehicleCount"}, {"/api/quality/issues?limit=10", "sourceEndpoint"}, {"/api/ops/health", "linkHealth"}, diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index ee007cd5..11fa52aa 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -275,6 +275,20 @@ type OnlineStatisticsSummary struct { Evidence string `json:"evidence"` } +type OnlineVehicleStatusRow struct { + VIN string `json:"vin"` + Plate string `json:"plate"` + Phone string `json:"phone"` + OEM string `json:"oem"` + Protocols []string `json:"protocols"` + Online bool `json:"online"` + LastSeen string `json:"lastSeen"` + OfflineDurationMinutes *int `json:"offlineDurationMinutes"` + SourceCount int `json:"sourceCount"` + OnlineSourceCount int `json:"onlineSourceCount"` + BindingStatus string `json:"bindingStatus"` +} + type QualityIssueRow 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 973d0636..3e1f3e05 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -909,6 +909,56 @@ func (s *Service) OnlineStatisticsSummary(ctx context.Context, query url.Values) }, nil } +func (s *Service) OnlineVehicleStatuses(ctx context.Context, query url.Values) (Page[OnlineVehicleStatusRow], error) { + resolvedQuery, err := s.resolveVehicleQuery(ctx, query) + if err != nil { + return Page[OnlineVehicleStatusRow]{}, err + } + coverage, err := s.store.VehicleCoverage(ctx, resolvedQuery) + if err != nil { + return Page[OnlineVehicleStatusRow]{}, err + } + now := time.Now() + items := make([]OnlineVehicleStatusRow, 0, len(coverage.Items)) + for _, row := range coverage.Items { + items = append(items, OnlineVehicleStatusRow{ + VIN: row.VIN, + Plate: row.Plate, + Phone: row.Phone, + OEM: row.OEM, + Protocols: append([]string(nil), row.Protocols...), + Online: row.Online, + LastSeen: row.LastSeen, + OfflineDurationMinutes: offlineDurationMinutes(row.Online, row.LastSeen, now), + SourceCount: row.SourceCount, + OnlineSourceCount: row.OnlineSourceCount, + BindingStatus: row.BindingStatus, + }) + } + return Page[OnlineVehicleStatusRow]{ + Items: items, + Total: coverage.Total, + Limit: coverage.Limit, + Offset: coverage.Offset, + }, nil +} + +func offlineDurationMinutes(online bool, lastSeen string, now time.Time) *int { + if online { + value := 0 + return &value + } + parsed, ok := parseVehicleServiceTime(lastSeen) + if !ok { + return nil + } + minutes := int(now.Sub(parsed).Minutes()) + if minutes < 0 { + minutes = 0 + } + return &minutes +} + func (s *Service) QualitySummary(ctx context.Context, query url.Values) (QualitySummary, error) { return s.store.QualitySummary(ctx, query) } diff --git a/vehicle-data-platform/apps/web/src/api/client.test.ts b/vehicle-data-platform/apps/web/src/api/client.test.ts index 8a3aff85..61cffccf 100644 --- a/vehicle-data-platform/apps/web/src/api/client.test.ts +++ b/vehicle-data-platform/apps/web/src/api/client.test.ts @@ -198,6 +198,39 @@ test('qualityNotificationPlan reads alert rules and priority issues from backend expect(result.priorityIssues[0].notificationText).toContain('告警通知'); }); +test('onlineVehicleStatuses reads paged online vehicle status rows', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + items: [{ + vin: 'LB9A32A24R0LS1426', + plate: '粤AG18312', + phone: '13307795425', + oem: 'G7s', + protocols: ['JT808', 'GB32960'], + online: false, + lastSeen: '2026-07-03 20:12:10', + offlineDurationMinutes: 18, + sourceCount: 2, + onlineSourceCount: 0, + bindingStatus: 'bound' + }], + total: 1, + limit: 10, + offset: 0 + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response); + + const result = await api.onlineVehicleStatuses(new URLSearchParams({ keyword: '粤AG18312', limit: '10' })); + + expect(fetchMock).toHaveBeenCalledWith('/api/statistics/online-vehicles?keyword=%E7%B2%A4AG18312&limit=10', undefined); + expect(result.items[0].offlineDurationMinutes).toBe(18); +}); + test('api errors include backend message, detail, and trace id', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: false, diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index f654d074..9e746231 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -5,6 +5,7 @@ import type { HistoryLocationRow, MileageSummary, OnlineStatisticsSummary, + OnlineVehicleStatusRow, OpsHealth, QualityNotificationPlan, Page, @@ -107,6 +108,7 @@ export const api = { mileageSummary: (params = new URLSearchParams()) => request(`/api/mileage/summary?${params.toString()}`), dailyMileage: (params = new URLSearchParams()) => request>(`/api/mileage/daily?${params.toString()}`), onlineStatisticsSummary: (params = new URLSearchParams()) => request(`/api/statistics/online-summary?${params.toString()}`), + onlineVehicleStatuses: (params = new URLSearchParams()) => request>(`/api/statistics/online-vehicles?${params.toString()}`), qualitySummary: (params = new URLSearchParams()) => request(`/api/quality/summary?${params.toString()}`), qualityIssues: (params = new URLSearchParams()) => request>(`/api/quality/issues?${params.toString()}`), qualityNotificationPlan: (params = new URLSearchParams()) => request(`/api/quality/notification-plan?${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 1f9f5c9e..ff0840c9 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -265,6 +265,20 @@ export interface OnlineStatisticsSummary { evidence: string; } +export interface OnlineVehicleStatusRow { + vin: string; + plate: string; + phone: string; + oem: string; + protocols: string[]; + online: boolean; + lastSeen: string; + offlineDurationMinutes?: number | null; + sourceCount: number; + onlineSourceCount: number; + bindingStatus: string; +} + export interface QualityIssueRow { vin: string; plate: string; diff --git a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx index c73ed969..c49e4200 100644 --- a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx @@ -1,7 +1,7 @@ import { Button, Card, Form, Select, Space, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui'; import { useEffect, useState } from 'react'; import { api } from '../api/client'; -import type { DailyMileageRow, MileageSummary, OnlineStatisticsSummary } from '../api/types'; +import type { DailyMileageRow, MileageSummary, OnlineStatisticsSummary, OnlineVehicleStatusRow } from '../api/types'; import { PageHeader } from '../components/PageHeader'; import { buildAppHash } from '../domain/appRoute'; import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport'; @@ -43,6 +43,15 @@ function formatPercent(value: number) { return Number.isFinite(value) ? `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}%` : '0%'; } +function formatOfflineDuration(minutes?: number | null) { + if (minutes == null || !Number.isFinite(Number(minutes))) return '-'; + const value = Math.max(0, Number(minutes)); + if (value < 60) return `${value.toLocaleString()} 分钟`; + const hours = Math.floor(value / 60); + const rest = Math.round(value % 60); + return rest > 0 ? `${hours.toLocaleString()} 小时 ${rest} 分钟` : `${hours.toLocaleString()} 小时`; +} + function canOpenVehicle(vin?: string) { const value = vin?.trim(); return Boolean(value && value !== 'unknown'); @@ -200,12 +209,15 @@ export function Mileage({ onOpenRaw?: (filters: Record) => void; }) { const [rows, setRows] = useState([]); + const [onlineRows, setOnlineRows] = useState([]); const [summary, setSummary] = useState(emptySummary); const [onlineSummary, setOnlineSummary] = useState(emptyOnlineSummary); const [loading, setLoading] = useState(true); + const [onlineLoading, setOnlineLoading] = useState(true); const [summaryLoading, setSummaryLoading] = useState(true); const [filters, setFilters] = useState>(mergeInitialFilters(initialVin, initialProtocol, initialFilters)); const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 }); + const [onlinePagination, setOnlinePagination] = useState({ currentPage: 1, pageSize: 10, total: 0 }); const currentVehicleKeyword = filters.keyword?.trim() ?? ''; const currentProtocol = filters.protocol?.trim() ?? ''; const dateSeries = groupMileageByDate(rows); @@ -264,11 +276,23 @@ export function Mileage({ .finally(() => setLoading(false)); }; + const loadOnlineRows = (values: Record = filters, page = onlinePagination.currentPage, pageSize = onlinePagination.pageSize) => { + setOnlineLoading(true); + api.onlineVehicleStatuses(mileageParams(values, pageSize, (page - 1) * pageSize)) + .then((nextPage) => { + setOnlineRows(nextPage.items); + setOnlinePagination({ currentPage: page, pageSize, total: nextPage.total }); + }) + .catch((error: Error) => Toast.error(error.message)) + .finally(() => setOnlineLoading(false)); + }; + const applyFilters = (nextFilters: Record) => { setFilters(nextFilters); onFiltersChange?.(nextFilters); loadSummary(nextFilters); load(nextFilters, 1, pagination.pageSize); + loadOnlineRows(nextFilters, 1, onlinePagination.pageSize); }; const clearRangeFilters = () => { @@ -326,6 +350,7 @@ export function Mileage({ setFilters(nextFilters); loadSummary(nextFilters); load(nextFilters, 1, pagination.pageSize); + loadOnlineRows(nextFilters, 1, onlinePagination.pageSize); }, [initialVin, initialProtocol, JSON.stringify(initialFilters)]); return ( @@ -487,6 +512,66 @@ export function Mileage({ ))} + +
+ + 在线 {onlineVehicleCount.toLocaleString()} 车 + 离线 {offlineVehicleCount.toLocaleString()} 车 + 按车辆服务归并 VIN,展示最近上报时间、离线时长和来源覆盖。 + +
+ row?.vin ?? ''} + dataSource={onlineRows} + pagination={{ + currentPage: onlinePagination.currentPage, + pageSize: onlinePagination.pageSize, + total: onlinePagination.total, + showSizeChanger: true, + onPageChange: (page) => loadOnlineRows(filters, page, onlinePagination.pageSize), + onPageSizeChange: (pageSize) => loadOnlineRows(filters, 1, pageSize) + }} + columns={[ + { title: 'VIN', dataIndex: 'vin', width: 190 }, + { title: '车牌', dataIndex: 'plate', width: 120 }, + { title: 'OEM', dataIndex: 'oem', width: 120 }, + { + title: '在线', + width: 90, + render: (_: unknown, row: OnlineVehicleStatusRow) => {row.online ? '在线' : '离线'} + }, + { title: '最近上报', dataIndex: 'lastSeen', width: 180 }, + { + title: '离线时长', + width: 130, + render: (_: unknown, row: OnlineVehicleStatusRow) => formatOfflineDuration(row.offlineDurationMinutes) + }, + { + title: '来源覆盖', + width: 150, + render: (_: unknown, row: OnlineVehicleStatusRow) => `${row.onlineSourceCount}/${row.sourceCount}` + }, + { + title: '协议', + width: 240, + render: (_: unknown, row: OnlineVehicleStatusRow) => ( + + {(row.protocols ?? []).map((protocol) => {protocol})} + + ) + }, + { title: '绑定状态', dataIndex: 'bindingStatus', width: 130 }, + { + title: '操作', + width: 120, + render: (_: unknown, row: OnlineVehicleStatusRow) => ( + + ) + } + ]} + /> +
diff --git a/vehicle-data-platform/apps/web/src/test/App.test.tsx b/vehicle-data-platform/apps/web/src/test/App.test.tsx index 5f931c4e..54ff94f4 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -6729,6 +6729,48 @@ test('shows vehicle-first statistics domains for one vehicle service', async () }) } as Response; } + if (path.includes('/api/statistics/online-vehicles')) { + return { + ok: true, + json: async () => ({ + data: { + items: [ + { + vin: 'VIN-STATS-SERVICE', + plate: '粤A统计服务1', + phone: '13307795425', + oem: 'G7s', + protocols: ['JT808', 'GB32960'], + online: true, + lastSeen: '2026-07-03 20:12:10', + offlineDurationMinutes: 0, + sourceCount: 2, + onlineSourceCount: 2, + bindingStatus: 'bound' + }, + { + vin: 'VIN-STATS-SERVICE-OFFLINE', + plate: '粤A离线1', + phone: '13307795426', + oem: '广安车联', + protocols: ['JT808'], + online: false, + lastSeen: '2026-07-03 18:12:10', + offlineDurationMinutes: 120, + sourceCount: 1, + onlineSourceCount: 0, + bindingStatus: 'bound' + } + ], + total: 2, + limit: 10, + offset: 0 + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } return { ok: true, json: async () => ({ @@ -6750,9 +6792,13 @@ test('shows vehicle-first statistics domains for one vehicle service', async () expect(screen.getByText('当前统计证据')).toBeInTheDocument(); expect(screen.getByText('3 车 / 2 来源 / 3 条明细')).toBeInTheDocument(); expect(await screen.findByText('75%')).toBeInTheDocument(); - expect(screen.getByText((content) => content.includes('离线 1 车'))).toBeInTheDocument(); + expect(screen.getAllByText((content) => content.includes('离线 1 车')).length).toBeGreaterThanOrEqual(1); expect(screen.getByText((content) => content.includes('Redis 在线 92 key'))).toBeInTheDocument(); expect(screen.getByText((content) => content.includes('由车辆服务覆盖汇总、实时来源状态和 Redis 在线 key 计算'))).toBeInTheDocument(); + expect(await screen.findByText('在线状态明细')).toBeInTheDocument(); + expect(await screen.findByText('VIN-STATS-SERVICE-OFFLINE')).toBeInTheDocument(); + expect(screen.getByText('粤A离线1')).toBeInTheDocument(); + expect(screen.getByText('2 小时')).toBeInTheDocument(); }); test('copies mileage statistics summary for operations reporting', async () => {