diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index df8a7e0d..55cf23f5 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler.go @@ -42,6 +42,7 @@ func (h *Handler) routes() { h.mux.HandleFunc("POST /api/history/raw-frames/query", h.handleRawFramesPost) 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/quality/summary", h.handleQualitySummary) h.mux.HandleFunc("GET /api/quality/issues", h.handleQualityIssues) h.mux.HandleFunc("GET /api/quality/notification-plan", h.handleQualityNotificationPlan) @@ -170,6 +171,11 @@ func (h *Handler) handleMileageSummary(w http.ResponseWriter, r *http.Request) { h.write(w, r, data, err) } +func (h *Handler) handleOnlineStatisticsSummary(w http.ResponseWriter, r *http.Request) { + data, err := h.service.OnlineStatisticsSummary(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 c30c30f9..70b3efd0 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -745,6 +745,7 @@ func TestHandlerHistoryMileageQualityOps(t *testing.T) { {"/api/history/raw-frames?protocol=GB32960&vin=LB9A32A24R0LS1426&limit=1&includeFields=true", "plate"}, {"/api/mileage/summary?limit=10", "totalMileageKm"}, {"/api/mileage/daily?limit=10", "dailyMileageKm"}, + {"/api/statistics/online-summary?limit=10", "onlineRatePercent"}, {"/api/quality/summary?limit=10", "issueVehicleCount"}, {"/api/quality/issues?limit=10", "sourceEndpoint"}, {"/api/ops/health", "linkHealth"}, @@ -894,12 +895,12 @@ func TestHandlerQualityNotificationPlan(t *testing.T) { func TestHandlerOpsHealthIncludesVehicleServiceRuntime(t *testing.T) { handler := NewHandler(NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ - RequestTimeoutMs: 1500, - AMapWebJSConfigured: true, - AMapSecurityProxyEnabled: true, - AMapSecurityCodeExposed: false, - AMapSecurityServiceHost: "/_AMapService", - PlatformRelease: "platform-20260704153005", + RequestTimeoutMs: 1500, + AMapWebJSConfigured: true, + AMapSecurityProxyEnabled: true, + AMapSecurityCodeExposed: false, + AMapSecurityServiceHost: "/_AMapService", + PlatformRelease: "platform-20260704153005", })) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/ops/health", nil) diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index 238dc627..ee007cd5 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -265,6 +265,16 @@ type MileageSummary struct { AverageMileagePerVIN float64 `json:"averageMileagePerVin"` } +type OnlineStatisticsSummary struct { + VehicleCount int `json:"vehicleCount"` + OnlineVehicleCount int `json:"onlineVehicleCount"` + OfflineVehicleCount int `json:"offlineVehicleCount"` + OnlineRatePercent float64 `json:"onlineRatePercent"` + RedisOnlineKeys *int `json:"redisOnlineKeys"` + ProtocolStats []ProtocolStat `json:"protocolStats"` + Evidence string `json:"evidence"` +} + 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 24524900..81675c7b 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -871,6 +871,44 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail return s.store.DailyMileage(ctx, resolvedQuery) } +func (s *Service) OnlineStatisticsSummary(ctx context.Context, query url.Values) (OnlineStatisticsSummary, error) { + resolvedQuery, err := s.resolveVehicleQuery(ctx, query) + if err != nil { + return OnlineStatisticsSummary{}, err + } + coverage, err := s.store.VehicleCoverageSummary(ctx, resolvedQuery) + if err != nil { + return OnlineStatisticsSummary{}, err + } + serviceSummary, err := s.store.VehicleServiceSummary(ctx) + if err != nil { + return OnlineStatisticsSummary{}, err + } + opsHealth, err := s.store.OpsHealth(ctx) + if err != nil { + return OnlineStatisticsSummary{}, err + } + total := coverage.TotalVehicles + online := coverage.OnlineVehicles + offline := total - online + if offline < 0 { + offline = 0 + } + rate := 0.0 + if total > 0 { + rate = float64(online) / float64(total) * 100 + } + return OnlineStatisticsSummary{ + VehicleCount: total, + OnlineVehicleCount: online, + OfflineVehicleCount: offline, + OnlineRatePercent: rate, + RedisOnlineKeys: opsHealth.RedisOnlineKeys, + ProtocolStats: serviceSummary.Protocols, + Evidence: "由车辆服务覆盖汇总、实时来源状态和 Redis 在线 key 计算", + }, nil +} + 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.ts b/vehicle-data-platform/apps/web/src/api/client.ts index 33146ab4..f654d074 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -4,6 +4,7 @@ import type { DashboardSummary, HistoryLocationRow, MileageSummary, + OnlineStatisticsSummary, OpsHealth, QualityNotificationPlan, Page, @@ -105,6 +106,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()}`), 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 5c609855..1f9f5c9e 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -255,6 +255,16 @@ export interface MileageSummary { averageMileagePerVin: number; } +export interface OnlineStatisticsSummary { + vehicleCount: number; + onlineVehicleCount: number; + offlineVehicleCount: number; + onlineRatePercent: number; + redisOnlineKeys?: number | null; + protocolStats: ProtocolStat[]; + evidence: 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 13d246fe..c73ed969 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 } from '../api/types'; +import type { DailyMileageRow, MileageSummary, OnlineStatisticsSummary } from '../api/types'; import { PageHeader } from '../components/PageHeader'; import { buildAppHash } from '../domain/appRoute'; import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport'; @@ -14,6 +14,16 @@ const emptySummary: MileageSummary = { averageMileagePerVin: 0 }; +const emptyOnlineSummary: OnlineStatisticsSummary = { + vehicleCount: 0, + onlineVehicleCount: 0, + offlineVehicleCount: 0, + onlineRatePercent: 0, + redisOnlineKeys: null, + protocolStats: [], + evidence: '' +}; + function mileageParams(values: Record, pageSize?: number, offset?: number) { const params = new URLSearchParams(); if (pageSize != null) params.set('limit', String(pageSize)); @@ -191,6 +201,7 @@ export function Mileage({ }) { const [rows, setRows] = useState([]); const [summary, setSummary] = useState(emptySummary); + const [onlineSummary, setOnlineSummary] = useState(emptyOnlineSummary); const [loading, setLoading] = useState(true); const [summaryLoading, setSummaryLoading] = useState(true); const [filters, setFilters] = useState>(mergeInitialFilters(initialVin, initialProtocol, initialFilters)); @@ -212,6 +223,16 @@ export function Mileage({ const confidenceColor = confidenceStatus === '可用于 BI' ? 'green' as const : 'orange' as const; const currentEvidenceText = `${summary.vehicleCount.toLocaleString()} 车 / ${summary.sourceCount.toLocaleString()} 来源 / ${rows.length.toLocaleString()} 条明细`; const sourceConsistencyText = summary.sourceCount > 1 ? '可做跨来源核对' : summary.sourceCount === 1 ? '单来源车辆需关注' : '等待来源证据'; + const onlineVehicleCount = Number(onlineSummary.onlineVehicleCount ?? 0); + const offlineVehicleCount = Number(onlineSummary.offlineVehicleCount ?? 0); + const onlineRatePercent = Number(onlineSummary.onlineRatePercent ?? 0); + const redisOnlineKeyText = onlineSummary.redisOnlineKeys == null ? '' : `Redis 在线 ${onlineSummary.redisOnlineKeys.toLocaleString()} key`; + const onlineStatsDetail = [ + `在线 ${onlineVehicleCount.toLocaleString()} 车`, + `离线 ${offlineVehicleCount.toLocaleString()} 车`, + redisOnlineKeyText, + onlineSummary.evidence + ].filter(Boolean).join(','); const maxDateMileage = Math.max(...dateSeries.map((item) => item.value), 0); const maxSourceMileage = Math.max(...sourceSeries.map((item) => item.value), 0); const filterSummary = [ @@ -227,6 +248,9 @@ export function Mileage({ .then(setSummary) .catch((error: Error) => Toast.error(error.message)) .finally(() => setSummaryLoading(false)); + api.onlineStatisticsSummary(mileageParams(values)) + .then(setOnlineSummary) + .catch((error: Error) => Toast.error(error.message)); }; const load = (values: Record = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => { @@ -338,9 +362,9 @@ export function Mileage({ }, { title: '在线率与离线时长', - value: currentVehicleKeyword ? '车辆维度' : '车队维度', + value: formatPercent(onlineRatePercent), color: 'green' as const, - detail: '后续接入在线区间聚合,按 VIN 统计在线率、离线时间和无更新时长。' + detail: onlineStatsDetail || '按 VIN 统计在线率、离线时间和无更新时长。' }, { title: '数据完整性', 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 6652f536..d9899822 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -4516,6 +4516,27 @@ test('opens same-day mileage statistics from quality issue row', async () => { }) } as Response; } + if (path.includes('/api/statistics/online-summary')) { + return { + ok: true, + json: async () => ({ + data: { + vehicleCount: 4, + onlineVehicleCount: 3, + offlineVehicleCount: 1, + onlineRatePercent: 75, + redisOnlineKeys: 92, + protocolStats: [ + { protocol: 'JT808', online: 2, total: 3 }, + { protocol: 'GB32960', online: 1, total: 2 } + ], + evidence: '由车辆服务覆盖汇总、实时来源状态和 Redis 在线 key 计算' + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } return { ok: true, json: async () => ({ @@ -6541,6 +6562,27 @@ test('shows vehicle-first statistics domains for one vehicle service', async () }) } as Response; } + if (path.includes('/api/statistics/online-summary')) { + return { + ok: true, + json: async () => ({ + data: { + vehicleCount: 4, + onlineVehicleCount: 3, + offlineVehicleCount: 1, + onlineRatePercent: 75, + redisOnlineKeys: 92, + protocolStats: [ + { protocol: 'JT808', online: 2, total: 3 }, + { protocol: 'GB32960', online: 1, total: 2 } + ], + evidence: '由车辆服务覆盖汇总、实时来源状态和 Redis 在线 key 计算' + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } return { ok: true, json: async () => ({ @@ -6561,6 +6603,10 @@ test('shows vehicle-first statistics domains for one vehicle service', async () expect(screen.getByText('三类数据源最终汇总为一个车辆服务统计视图')).toBeInTheDocument(); 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.getByText((content) => content.includes('Redis 在线 92 key'))).toBeInTheDocument(); + expect(screen.getByText((content) => content.includes('由车辆服务覆盖汇总、实时来源状态和 Redis 在线 key 计算'))).toBeInTheDocument(); }); test('copies mileage statistics summary for operations reporting', async () => {