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 3171f846..85195388 100644 --- a/vehicle-data-platform/apps/web/src/api/client.test.ts +++ b/vehicle-data-platform/apps/web/src/api/client.test.ts @@ -141,6 +141,33 @@ test('vehicleServiceOverviews posts keywords to the batch overview endpoint', as expect(result.total).toBe(2); }); +test('vehicleServiceSummary reads the vehicle service summary endpoint', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + totalVehicles: 1033, + boundVehicles: 1024, + onlineVehicles: 208, + singleSourceVehicles: 391, + multiSourceVehicles: 181, + noDataVehicles: 461, + identityRequiredVehicles: 9, + serviceStatuses: [{ status: 'healthy', title: '服务正常', count: 161 }], + protocols: [{ protocol: 'JT808', online: 157, total: 388 }] + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response); + + const result = await api.vehicleServiceSummary(); + + expect(fetchMock).toHaveBeenCalledWith('/api/vehicle-service/summary', undefined); + expect(result.totalVehicles).toBe(1033); + expect(result.multiSourceVehicles).toBe(181); +}); + 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 1904f6a7..4b6f08ed 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -15,6 +15,7 @@ import type { VehicleDetail, VehicleIdentityResolution, VehicleServiceOverview, + VehicleServiceSummary, VehicleRow } from './types'; @@ -83,6 +84,7 @@ export const api = { vehicleResolve: (params = new URLSearchParams()) => request(`/api/vehicles/resolve?${params.toString()}`), vehicleCoverage: (params = new URLSearchParams()) => request>(`/api/vehicles/coverage?${params.toString()}`), vehicleDetail: (params = new URLSearchParams()) => request(`/api/vehicle-service?${params.toString()}`), + vehicleServiceSummary: () => request('/api/vehicle-service/summary'), vehicleServiceOverview: (params = new URLSearchParams()) => request(`/api/vehicle-service/overview?${params.toString()}`), vehicleServiceOverviews: (query: VehicleOverviewBatchQuery) => request>('/api/vehicle-service/overviews', { method: 'POST', diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index d9ade0a1..65d3284d 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -110,6 +110,18 @@ export interface VehicleServiceOverview { serviceStatus?: VehicleServiceStatus; } +export interface VehicleServiceSummary { + totalVehicles: number; + boundVehicles: number; + onlineVehicles: number; + singleSourceVehicles: number; + multiSourceVehicles: number; + noDataVehicles: number; + identityRequiredVehicles: number; + serviceStatuses: ServiceStatusStat[]; + protocols: ProtocolStat[]; +} + export interface VehicleServiceStatus { status: string; severity: string; diff --git a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx index 9e0a55d4..8cf89c9a 100644 --- a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx @@ -2,7 +2,7 @@ import { Button, Card, Col, Form, Row, Select, Space, Spin, Table, Tag, Toast, T import { IconSearch } from '@douyinfe/semi-icons'; import { useEffect, useState } from 'react'; import { api } from '../api/client'; -import type { DashboardSummary, LinkHealth, ProtocolStat, QualityIssueRow, ServiceStatusStat, VehicleCoverageRow, VehicleRealtimeRow } from '../api/types'; +import type { DashboardSummary, LinkHealth, ProtocolStat, QualityIssueRow, ServiceStatusStat, VehicleCoverageRow, VehicleRealtimeRow, VehicleServiceSummary } from '../api/types'; import { PageHeader } from '../components/PageHeader'; import { StatusTag } from '../components/StatusTag'; import { qualityIssueVehicleLookup } from '../domain/vehicleLookup'; @@ -31,6 +31,10 @@ function formatLag(value?: number | null) { return value == null ? '未接入' : value.toLocaleString(); } +function formatCount(value?: number | null) { + return value == null ? '0' : value.toLocaleString(); +} + function rowServiceStatus(row: { serviceStatus?: { title: string; severity: string }; onlineSourceCount: number; sourceCount: number }) { if (row.serviceStatus) { return { @@ -49,6 +53,7 @@ function rowServiceStatus(row: { serviceStatus?: { title: string; severity: stri export function Dashboard({ onOpenVehicle, onOpenQuality }: { onOpenVehicle: (vin: string) => void; onOpenQuality: () => void }) { const [summary, setSummary] = useState(null); + const [serviceSummary, setServiceSummary] = useState(null); const [coverage, setCoverage] = useState([]); const [locations, setLocations] = useState([]); const [qualityIssues, setQualityIssues] = useState([]); @@ -74,12 +79,14 @@ export function Dashboard({ onOpenVehicle, onOpenQuality }: { onOpenVehicle: (vi useEffect(() => { Promise.all([ api.dashboardSummary(), + api.vehicleServiceSummary(), api.vehicleCoverage(new URLSearchParams({ limit: '8' })), api.vehicleRealtime(new URLSearchParams({ limit: '8' })), api.qualityIssues(new URLSearchParams({ limit: '5' })) ]) - .then(([nextSummary, coveragePage, locationPage, qualityPage]) => { + .then(([nextSummary, nextServiceSummary, coveragePage, locationPage, qualityPage]) => { setSummary(nextSummary); + setServiceSummary(nextServiceSummary); setCoverage(coveragePage.items); setLocations(locationPage.items); setQualityIssues(qualityPage.items); @@ -89,10 +96,10 @@ export function Dashboard({ onOpenVehicle, onOpenQuality }: { onOpenVehicle: (vi }, []); const kpis = [ - { label: '在线车辆', value: summary?.onlineVehicles ?? 0 }, - { label: '今日活跃', value: summary?.activeToday ?? 0 }, - { label: '今日帧数', value: summary?.frameToday.toLocaleString() ?? '0' }, - { label: '质量问题', value: summary?.issueVehicles ?? 0 } + { label: '总车辆', value: formatCount(serviceSummary?.totalVehicles) }, + { label: '在线车辆', value: formatCount(serviceSummary?.onlineVehicles ?? summary?.onlineVehicles) }, + { label: '多源车辆', value: formatCount(serviceSummary?.multiSourceVehicles) }, + { label: '身份未绑定', value: formatCount(serviceSummary?.identityRequiredVehicles) } ]; return ( @@ -112,7 +119,7 @@ export function Dashboard({ onOpenVehicle, onOpenQuality }: { onOpenVehicle: (vi
{ expect(screen.getByText('39')).toBeInTheDocument(); }); +test('dashboard renders vehicle service summary metrics', async () => { + window.history.replaceState(null, '', '/#/dashboard'); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const path = String(input); + if (path.includes('/api/dashboard/summary')) { + return { + ok: true, + json: async () => ({ + data: { + onlineVehicles: 3, + activeToday: 4, + frameToday: 1286320, + issueVehicles: 7, + kafkaLag: 0, + protocols: [], + serviceStatuses: [], + linkHealth: [] + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/vehicle-service/summary')) { + return { + ok: true, + json: async () => ({ + data: { + totalVehicles: 1033, + boundVehicles: 1024, + onlineVehicles: 208, + singleSourceVehicles: 391, + multiSourceVehicles: 181, + noDataVehicles: 461, + identityRequiredVehicles: 9, + serviceStatuses: [ + { status: 'healthy', title: '服务正常', count: 161 }, + { status: 'degraded', title: '部分来源离线', count: 41 } + ], + protocols: [{ protocol: 'JT808', online: 157, total: 388 }] + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + return { + ok: true, + json: async () => ({ + data: { items: [], total: 0, limit: 10, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + }); + + render(); + + expect(await screen.findByText('总车辆')).toBeInTheDocument(); + expect(screen.getByText('1,033')).toBeInTheDocument(); + expect(screen.getByText('多源车辆')).toBeInTheDocument(); + expect(screen.getByText('181')).toBeInTheDocument(); + expect(screen.getByText('身份未绑定')).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith('/api/vehicle-service/summary', undefined); +}); + test('shows resolved vehicle service status after topbar search', async () => { window.history.replaceState(null, '', '/#/dashboard'); const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {