feat(platform): add online vehicle statistics detail
This commit is contained in:
@@ -43,6 +43,7 @@ func (h *Handler) routes() {
|
|||||||
h.mux.HandleFunc("GET /api/mileage/summary", h.handleMileageSummary)
|
h.mux.HandleFunc("GET /api/mileage/summary", h.handleMileageSummary)
|
||||||
h.mux.HandleFunc("GET /api/mileage/daily", h.handleDailyMileage)
|
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-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/summary", h.handleQualitySummary)
|
||||||
h.mux.HandleFunc("GET /api/quality/issues", h.handleQualityIssues)
|
h.mux.HandleFunc("GET /api/quality/issues", h.handleQualityIssues)
|
||||||
h.mux.HandleFunc("GET /api/quality/notification-plan", h.handleQualityNotificationPlan)
|
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)
|
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) {
|
func (h *Handler) handleQualityIssues(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := h.service.QualityIssues(r.Context(), r.URL.Query())
|
data, err := h.service.QualityIssues(r.Context(), r.URL.Query())
|
||||||
h.write(w, r, data, err)
|
h.write(w, r, data, err)
|
||||||
|
|||||||
@@ -746,6 +746,7 @@ func TestHandlerHistoryMileageQualityOps(t *testing.T) {
|
|||||||
{"/api/mileage/summary?limit=10", "totalMileageKm"},
|
{"/api/mileage/summary?limit=10", "totalMileageKm"},
|
||||||
{"/api/mileage/daily?limit=10", "dailyMileageKm"},
|
{"/api/mileage/daily?limit=10", "dailyMileageKm"},
|
||||||
{"/api/statistics/online-summary?limit=10", "onlineRatePercent"},
|
{"/api/statistics/online-summary?limit=10", "onlineRatePercent"},
|
||||||
|
{"/api/statistics/online-vehicles?limit=10", "offlineDurationMinutes"},
|
||||||
{"/api/quality/summary?limit=10", "issueVehicleCount"},
|
{"/api/quality/summary?limit=10", "issueVehicleCount"},
|
||||||
{"/api/quality/issues?limit=10", "sourceEndpoint"},
|
{"/api/quality/issues?limit=10", "sourceEndpoint"},
|
||||||
{"/api/ops/health", "linkHealth"},
|
{"/api/ops/health", "linkHealth"},
|
||||||
|
|||||||
@@ -275,6 +275,20 @@ type OnlineStatisticsSummary struct {
|
|||||||
Evidence string `json:"evidence"`
|
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 {
|
type QualityIssueRow struct {
|
||||||
VIN string `json:"vin"`
|
VIN string `json:"vin"`
|
||||||
Plate string `json:"plate"`
|
Plate string `json:"plate"`
|
||||||
|
|||||||
@@ -909,6 +909,56 @@ func (s *Service) OnlineStatisticsSummary(ctx context.Context, query url.Values)
|
|||||||
}, nil
|
}, 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) {
|
func (s *Service) QualitySummary(ctx context.Context, query url.Values) (QualitySummary, error) {
|
||||||
return s.store.QualitySummary(ctx, query)
|
return s.store.QualitySummary(ctx, query)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,6 +198,39 @@ test('qualityNotificationPlan reads alert rules and priority issues from backend
|
|||||||
expect(result.priorityIssues[0].notificationText).toContain('告警通知');
|
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 () => {
|
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,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
HistoryLocationRow,
|
HistoryLocationRow,
|
||||||
MileageSummary,
|
MileageSummary,
|
||||||
OnlineStatisticsSummary,
|
OnlineStatisticsSummary,
|
||||||
|
OnlineVehicleStatusRow,
|
||||||
OpsHealth,
|
OpsHealth,
|
||||||
QualityNotificationPlan,
|
QualityNotificationPlan,
|
||||||
Page,
|
Page,
|
||||||
@@ -107,6 +108,7 @@ export const api = {
|
|||||||
mileageSummary: (params = new URLSearchParams()) => request<MileageSummary>(`/api/mileage/summary?${params.toString()}`),
|
mileageSummary: (params = new URLSearchParams()) => request<MileageSummary>(`/api/mileage/summary?${params.toString()}`),
|
||||||
dailyMileage: (params = new URLSearchParams()) => request<Page<DailyMileageRow>>(`/api/mileage/daily?${params.toString()}`),
|
dailyMileage: (params = new URLSearchParams()) => request<Page<DailyMileageRow>>(`/api/mileage/daily?${params.toString()}`),
|
||||||
onlineStatisticsSummary: (params = new URLSearchParams()) => request<OnlineStatisticsSummary>(`/api/statistics/online-summary?${params.toString()}`),
|
onlineStatisticsSummary: (params = new URLSearchParams()) => request<OnlineStatisticsSummary>(`/api/statistics/online-summary?${params.toString()}`),
|
||||||
|
onlineVehicleStatuses: (params = new URLSearchParams()) => request<Page<OnlineVehicleStatusRow>>(`/api/statistics/online-vehicles?${params.toString()}`),
|
||||||
qualitySummary: (params = new URLSearchParams()) => request<QualitySummary>(`/api/quality/summary?${params.toString()}`),
|
qualitySummary: (params = new URLSearchParams()) => request<QualitySummary>(`/api/quality/summary?${params.toString()}`),
|
||||||
qualityIssues: (params = new URLSearchParams()) => request<Page<QualityIssueRow>>(`/api/quality/issues?${params.toString()}`),
|
qualityIssues: (params = new URLSearchParams()) => request<Page<QualityIssueRow>>(`/api/quality/issues?${params.toString()}`),
|
||||||
qualityNotificationPlan: (params = new URLSearchParams()) => request<QualityNotificationPlan>(`/api/quality/notification-plan?${params.toString()}`),
|
qualityNotificationPlan: (params = new URLSearchParams()) => request<QualityNotificationPlan>(`/api/quality/notification-plan?${params.toString()}`),
|
||||||
|
|||||||
@@ -265,6 +265,20 @@ export interface OnlineStatisticsSummary {
|
|||||||
evidence: string;
|
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 {
|
export interface QualityIssueRow {
|
||||||
vin: string;
|
vin: string;
|
||||||
plate: string;
|
plate: string;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Button, Card, Form, Select, Space, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui';
|
import { Button, Card, Form, Select, Space, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '../api/client';
|
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 { PageHeader } from '../components/PageHeader';
|
||||||
import { buildAppHash } from '../domain/appRoute';
|
import { buildAppHash } from '../domain/appRoute';
|
||||||
import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport';
|
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%';
|
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) {
|
function canOpenVehicle(vin?: string) {
|
||||||
const value = vin?.trim();
|
const value = vin?.trim();
|
||||||
return Boolean(value && value !== 'unknown');
|
return Boolean(value && value !== 'unknown');
|
||||||
@@ -200,12 +209,15 @@ export function Mileage({
|
|||||||
onOpenRaw?: (filters: Record<string, string>) => void;
|
onOpenRaw?: (filters: Record<string, string>) => void;
|
||||||
}) {
|
}) {
|
||||||
const [rows, setRows] = useState<DailyMileageRow[]>([]);
|
const [rows, setRows] = useState<DailyMileageRow[]>([]);
|
||||||
|
const [onlineRows, setOnlineRows] = useState<OnlineVehicleStatusRow[]>([]);
|
||||||
const [summary, setSummary] = useState<MileageSummary>(emptySummary);
|
const [summary, setSummary] = useState<MileageSummary>(emptySummary);
|
||||||
const [onlineSummary, setOnlineSummary] = useState<OnlineStatisticsSummary>(emptyOnlineSummary);
|
const [onlineSummary, setOnlineSummary] = useState<OnlineStatisticsSummary>(emptyOnlineSummary);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [onlineLoading, setOnlineLoading] = useState(true);
|
||||||
const [summaryLoading, setSummaryLoading] = useState(true);
|
const [summaryLoading, setSummaryLoading] = useState(true);
|
||||||
const [filters, setFilters] = useState<Record<string, string>>(mergeInitialFilters(initialVin, initialProtocol, initialFilters));
|
const [filters, setFilters] = useState<Record<string, string>>(mergeInitialFilters(initialVin, initialProtocol, initialFilters));
|
||||||
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 });
|
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 currentVehicleKeyword = filters.keyword?.trim() ?? '';
|
||||||
const currentProtocol = filters.protocol?.trim() ?? '';
|
const currentProtocol = filters.protocol?.trim() ?? '';
|
||||||
const dateSeries = groupMileageByDate(rows);
|
const dateSeries = groupMileageByDate(rows);
|
||||||
@@ -264,11 +276,23 @@ export function Mileage({
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadOnlineRows = (values: Record<string, string> = 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<string, string>) => {
|
const applyFilters = (nextFilters: Record<string, string>) => {
|
||||||
setFilters(nextFilters);
|
setFilters(nextFilters);
|
||||||
onFiltersChange?.(nextFilters);
|
onFiltersChange?.(nextFilters);
|
||||||
loadSummary(nextFilters);
|
loadSummary(nextFilters);
|
||||||
load(nextFilters, 1, pagination.pageSize);
|
load(nextFilters, 1, pagination.pageSize);
|
||||||
|
loadOnlineRows(nextFilters, 1, onlinePagination.pageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearRangeFilters = () => {
|
const clearRangeFilters = () => {
|
||||||
@@ -326,6 +350,7 @@ export function Mileage({
|
|||||||
setFilters(nextFilters);
|
setFilters(nextFilters);
|
||||||
loadSummary(nextFilters);
|
loadSummary(nextFilters);
|
||||||
load(nextFilters, 1, pagination.pageSize);
|
load(nextFilters, 1, pagination.pageSize);
|
||||||
|
loadOnlineRows(nextFilters, 1, onlinePagination.pageSize);
|
||||||
}, [initialVin, initialProtocol, JSON.stringify(initialFilters)]);
|
}, [initialVin, initialProtocol, JSON.stringify(initialFilters)]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -487,6 +512,66 @@ export function Mileage({
|
|||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<Card bordered title="在线状态明细" style={{ marginTop: 16 }}>
|
||||||
|
<div className="vp-table-toolbar">
|
||||||
|
<Space wrap>
|
||||||
|
<Tag color="green">在线 {onlineVehicleCount.toLocaleString()} 车</Tag>
|
||||||
|
<Tag color="orange">离线 {offlineVehicleCount.toLocaleString()} 车</Tag>
|
||||||
|
<Typography.Text type="secondary">按车辆服务归并 VIN,展示最近上报时间、离线时长和来源覆盖。</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
loading={onlineLoading}
|
||||||
|
rowKey={(row?: OnlineVehicleStatusRow) => 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) => <Tag color={row.online ? 'green' : 'orange'}>{row.online ? '在线' : '离线'}</Tag>
|
||||||
|
},
|
||||||
|
{ 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) => (
|
||||||
|
<Space spacing={4} wrap>
|
||||||
|
{(row.protocols ?? []).map((protocol) => <Tag key={protocol} color="blue">{protocol}</Tag>)}
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ title: '绑定状态', dataIndex: 'bindingStatus', width: 130 },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 120,
|
||||||
|
render: (_: unknown, row: OnlineVehicleStatusRow) => (
|
||||||
|
<Button disabled={!canOpenVehicle(row.vin)} onClick={() => onOpenVehicle(row.vin, currentProtocol)}>车辆服务</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
<Card bordered title="统计口径" style={{ marginTop: 16 }}>
|
<Card bordered title="统计口径" style={{ marginTop: 16 }}>
|
||||||
<div className="vp-table-toolbar">
|
<div className="vp-table-toolbar">
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
|
|||||||
@@ -6729,6 +6729,48 @@ test('shows vehicle-first statistics domains for one vehicle service', async ()
|
|||||||
})
|
})
|
||||||
} as Response;
|
} 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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@@ -6750,9 +6792,13 @@ 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(screen.getByText('3 车 / 2 来源 / 3 条明细')).toBeInTheDocument();
|
||||||
expect(await screen.findByText('75%')).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 在线 92 key'))).toBeInTheDocument();
|
||||||
expect(screen.getByText((content) => content.includes('由车辆服务覆盖汇总、实时来源状态和 Redis 在线 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 () => {
|
test('copies mileage statistics summary for operations reporting', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user