feat(platform-web): surface vehicle service summary
This commit is contained in:
@@ -141,6 +141,33 @@ test('vehicleServiceOverviews posts keywords to the batch overview endpoint', as
|
|||||||
expect(result.total).toBe(2);
|
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 () => {
|
test('api errors include backend message, detail, and trace id', async () => {
|
||||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
VehicleDetail,
|
VehicleDetail,
|
||||||
VehicleIdentityResolution,
|
VehicleIdentityResolution,
|
||||||
VehicleServiceOverview,
|
VehicleServiceOverview,
|
||||||
|
VehicleServiceSummary,
|
||||||
VehicleRow
|
VehicleRow
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ export const api = {
|
|||||||
vehicleResolve: (params = new URLSearchParams()) => request<VehicleIdentityResolution>(`/api/vehicles/resolve?${params.toString()}`),
|
vehicleResolve: (params = new URLSearchParams()) => request<VehicleIdentityResolution>(`/api/vehicles/resolve?${params.toString()}`),
|
||||||
vehicleCoverage: (params = new URLSearchParams()) => request<Page<VehicleCoverageRow>>(`/api/vehicles/coverage?${params.toString()}`),
|
vehicleCoverage: (params = new URLSearchParams()) => request<Page<VehicleCoverageRow>>(`/api/vehicles/coverage?${params.toString()}`),
|
||||||
vehicleDetail: (params = new URLSearchParams()) => request<VehicleDetail>(`/api/vehicle-service?${params.toString()}`),
|
vehicleDetail: (params = new URLSearchParams()) => request<VehicleDetail>(`/api/vehicle-service?${params.toString()}`),
|
||||||
|
vehicleServiceSummary: () => request<VehicleServiceSummary>('/api/vehicle-service/summary'),
|
||||||
vehicleServiceOverview: (params = new URLSearchParams()) => request<VehicleServiceOverview>(`/api/vehicle-service/overview?${params.toString()}`),
|
vehicleServiceOverview: (params = new URLSearchParams()) => request<VehicleServiceOverview>(`/api/vehicle-service/overview?${params.toString()}`),
|
||||||
vehicleServiceOverviews: (query: VehicleOverviewBatchQuery) => request<Page<VehicleServiceOverview>>('/api/vehicle-service/overviews', {
|
vehicleServiceOverviews: (query: VehicleOverviewBatchQuery) => request<Page<VehicleServiceOverview>>('/api/vehicle-service/overviews', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -110,6 +110,18 @@ export interface VehicleServiceOverview {
|
|||||||
serviceStatus?: VehicleServiceStatus;
|
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 {
|
export interface VehicleServiceStatus {
|
||||||
status: string;
|
status: string;
|
||||||
severity: string;
|
severity: string;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Button, Card, Col, Form, Row, Select, Space, Spin, Table, Tag, Toast, T
|
|||||||
import { IconSearch } from '@douyinfe/semi-icons';
|
import { IconSearch } from '@douyinfe/semi-icons';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '../api/client';
|
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 { PageHeader } from '../components/PageHeader';
|
||||||
import { StatusTag } from '../components/StatusTag';
|
import { StatusTag } from '../components/StatusTag';
|
||||||
import { qualityIssueVehicleLookup } from '../domain/vehicleLookup';
|
import { qualityIssueVehicleLookup } from '../domain/vehicleLookup';
|
||||||
@@ -31,6 +31,10 @@ function formatLag(value?: number | null) {
|
|||||||
return value == null ? '未接入' : value.toLocaleString();
|
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 }) {
|
function rowServiceStatus(row: { serviceStatus?: { title: string; severity: string }; onlineSourceCount: number; sourceCount: number }) {
|
||||||
if (row.serviceStatus) {
|
if (row.serviceStatus) {
|
||||||
return {
|
return {
|
||||||
@@ -49,6 +53,7 @@ function rowServiceStatus(row: { serviceStatus?: { title: string; severity: stri
|
|||||||
|
|
||||||
export function Dashboard({ onOpenVehicle, onOpenQuality }: { onOpenVehicle: (vin: string) => void; onOpenQuality: () => void }) {
|
export function Dashboard({ onOpenVehicle, onOpenQuality }: { onOpenVehicle: (vin: string) => void; onOpenQuality: () => void }) {
|
||||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||||
|
const [serviceSummary, setServiceSummary] = useState<VehicleServiceSummary | null>(null);
|
||||||
const [coverage, setCoverage] = useState<VehicleCoverageRow[]>([]);
|
const [coverage, setCoverage] = useState<VehicleCoverageRow[]>([]);
|
||||||
const [locations, setLocations] = useState<VehicleRealtimeRow[]>([]);
|
const [locations, setLocations] = useState<VehicleRealtimeRow[]>([]);
|
||||||
const [qualityIssues, setQualityIssues] = useState<QualityIssueRow[]>([]);
|
const [qualityIssues, setQualityIssues] = useState<QualityIssueRow[]>([]);
|
||||||
@@ -74,12 +79,14 @@ export function Dashboard({ onOpenVehicle, onOpenQuality }: { onOpenVehicle: (vi
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
api.dashboardSummary(),
|
api.dashboardSummary(),
|
||||||
|
api.vehicleServiceSummary(),
|
||||||
api.vehicleCoverage(new URLSearchParams({ limit: '8' })),
|
api.vehicleCoverage(new URLSearchParams({ limit: '8' })),
|
||||||
api.vehicleRealtime(new URLSearchParams({ limit: '8' })),
|
api.vehicleRealtime(new URLSearchParams({ limit: '8' })),
|
||||||
api.qualityIssues(new URLSearchParams({ limit: '5' }))
|
api.qualityIssues(new URLSearchParams({ limit: '5' }))
|
||||||
])
|
])
|
||||||
.then(([nextSummary, coveragePage, locationPage, qualityPage]) => {
|
.then(([nextSummary, nextServiceSummary, coveragePage, locationPage, qualityPage]) => {
|
||||||
setSummary(nextSummary);
|
setSummary(nextSummary);
|
||||||
|
setServiceSummary(nextServiceSummary);
|
||||||
setCoverage(coveragePage.items);
|
setCoverage(coveragePage.items);
|
||||||
setLocations(locationPage.items);
|
setLocations(locationPage.items);
|
||||||
setQualityIssues(qualityPage.items);
|
setQualityIssues(qualityPage.items);
|
||||||
@@ -89,10 +96,10 @@ export function Dashboard({ onOpenVehicle, onOpenQuality }: { onOpenVehicle: (vi
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const kpis = [
|
const kpis = [
|
||||||
{ label: '在线车辆', value: summary?.onlineVehicles ?? 0 },
|
{ label: '总车辆', value: formatCount(serviceSummary?.totalVehicles) },
|
||||||
{ label: '今日活跃', value: summary?.activeToday ?? 0 },
|
{ label: '在线车辆', value: formatCount(serviceSummary?.onlineVehicles ?? summary?.onlineVehicles) },
|
||||||
{ label: '今日帧数', value: summary?.frameToday.toLocaleString() ?? '0' },
|
{ label: '多源车辆', value: formatCount(serviceSummary?.multiSourceVehicles) },
|
||||||
{ label: '质量问题', value: summary?.issueVehicles ?? 0 }
|
{ label: '身份未绑定', value: formatCount(serviceSummary?.identityRequiredVehicles) }
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -112,7 +119,7 @@ export function Dashboard({ onOpenVehicle, onOpenQuality }: { onOpenVehicle: (vi
|
|||||||
<Card title="车辆服务状态" bordered>
|
<Card title="车辆服务状态" bordered>
|
||||||
<Table
|
<Table
|
||||||
pagination={false}
|
pagination={false}
|
||||||
dataSource={summary?.serviceStatuses ?? []}
|
dataSource={serviceSummary?.serviceStatuses ?? summary?.serviceStatuses ?? []}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
@@ -139,7 +146,7 @@ export function Dashboard({ onOpenVehicle, onOpenQuality }: { onOpenVehicle: (vi
|
|||||||
<Card title="数据来源在线分布" bordered>
|
<Card title="数据来源在线分布" bordered>
|
||||||
<Table
|
<Table
|
||||||
pagination={false}
|
pagination={false}
|
||||||
dataSource={summary?.protocols ?? []}
|
dataSource={serviceSummary?.protocols ?? summary?.protocols ?? []}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '数据来源', dataIndex: 'protocol' },
|
{ title: '数据来源', dataIndex: 'protocol' },
|
||||||
{ title: '在线', dataIndex: 'online' },
|
{ title: '在线', dataIndex: 'online' },
|
||||||
|
|||||||
@@ -57,6 +57,72 @@ test('shows vehicle service status distribution on dashboard', async () => {
|
|||||||
expect(screen.getByText('39')).toBeInTheDocument();
|
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(<App />);
|
||||||
|
|
||||||
|
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 () => {
|
test('shows resolved vehicle service status after topbar search', async () => {
|
||||||
window.history.replaceState(null, '', '/#/dashboard');
|
window.history.replaceState(null, '', '/#/dashboard');
|
||||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user