feat(vehicle): live-update single vehicle map

This commit is contained in:
lingniu
2026-07-16 10:20:18 +08:00
parent e0e7e28762
commit 59f220885b
2 changed files with 84 additions and 4 deletions

View File

@@ -0,0 +1,69 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, waitFor } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { api } from '../../api/client';
import type { VehicleDetail, VehicleRealtimeRow } from '../../api/types';
import { ROUTER_FUTURE } from '../routing/routerConfig';
import VehiclePage from './VehiclePage';
const fleetMapVehicles = vi.hoisted(() => vi.fn());
vi.mock('../map/FleetMap', () => ({
FleetMap: ({ vehicles }: { vehicles: VehicleRealtimeRow[] }) => {
fleetMapVehicles(vehicles);
return <div data-testid="fleet-map" />;
}
}));
vi.mock('../auth/AuthGate', () => ({
usePlatformSession: () => ({ session: { name: 'test-viewer', role: 'viewer', authMode: 'enforce' } })
}));
const initialRealtime = {
vin: 'LTEST000000000001', plate: '粤A12345', phone: '', oem: '', protocols: ['JT808'], sourceStatus: [],
sourceCount: 1, onlineSourceCount: 1, online: true, bindingStatus: 'bound', primaryProtocol: 'JT808',
longitude: 113.26, latitude: 23.13, speedKmh: 20, socPercent: 80, totalMileageKm: 1000,
lastSeen: '2026-07-16 10:00:00', reportIntervalMs: 30_000
} satisfies VehicleRealtimeRow;
const detail = {
vin: initialRealtime.vin,
lookupKey: initialRealtime.vin,
lookupResolved: true,
realtimeSummary: initialRealtime,
sources: ['JT808'],
sourceStatus: [],
realtime: [],
history: { items: [], total: 0, limit: 20, offset: 0 },
raw: { items: [], total: 0, limit: 20, offset: 0 },
mileage: { items: [], total: 0, limit: 20, offset: 0 },
quality: { items: [], total: 0, limit: 20, offset: 0 }
} satisfies VehicleDetail;
afterEach(() => {
cleanup();
vi.restoreAllMocks();
fleetMapVehicles.mockReset();
});
test('polls lightweight single-vehicle realtime data and passes its report interval to the map', async () => {
const movedRealtime = { ...initialRealtime, longitude: 113.28, latitude: 23.15, lastSeen: '2026-07-16 10:00:30' };
vi.spyOn(api, 'vehicleDetail').mockResolvedValue(detail);
const realtimeSpy = vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [movedRealtime], total: 1, limit: 1, offset: 0 });
vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ values: [], categories: [], scannedFrames: 0 } as never);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/vehicles/${initialRealtime.vin}`]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
await waitFor(() => expect(realtimeSpy).toHaveBeenCalledTimes(1));
const realtimeQuery = client.getQueryCache().find({ queryKey: ['vehicle-detail-realtime', initialRealtime.vin] });
expect((realtimeQuery?.options as { refetchInterval?: number }).refetchInterval).toBe(10_000);
const params = realtimeSpy.mock.calls[0][0];
expect(params).toBeDefined();
expect(params!.get('keywords')).toBe(initialRealtime.vin);
expect(params!.get('limit')).toBe('1');
await waitFor(() => expect(fleetMapVehicles).toHaveBeenLastCalledWith([
expect.objectContaining({ longitude: 113.28, latitude: 23.15, reportIntervalMs: 30_000 })
]));
});

View File

@@ -6,7 +6,7 @@ import {
import { FormEvent, useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { LatestTelemetryResponse, QualityIssueRow, VehicleDetail, VehicleProfileSyncItem, VehicleProfileSyncResult } from '../../api/types';
import type { LatestTelemetryResponse, QualityIssueRow, VehicleDetail, VehicleProfileSyncItem, VehicleProfileSyncResult, VehicleRealtimeRow } from '../../api/types';
import { usePlatformSession } from '../auth/AuthGate';
import { canAdminister } from '../auth/session';
import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy';
@@ -23,6 +23,7 @@ function issueTone(issue: QualityIssueRow) { return issue.severity === 'error' ?
function durationHours(seconds?: number | null) { return seconds == null ? '—' : `${formatZhNumber(seconds / 3600, 1)} 小时`; }
function localDateTime(value?: string) { return value ? value.slice(0, 16) : ''; }
const operationStatusLabels = { unknown: '待维护', active: '运营中', inactive: '停运', maintenance: '维保中', retired: '已退役' } as const;
const SINGLE_VEHICLE_REFRESH_MS = 10_000;
function ProfileSyncPanel({ onClose }: { onClose: () => void }) {
const [sourceSystem, setSourceSystem] = useState('');
@@ -170,9 +171,9 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon
</section>;
}
function VehicleRecord({ detail, telemetry, telemetryPending, telemetryError, onUpdated }: { detail: VehicleDetail; telemetry?: LatestTelemetryResponse; telemetryPending: boolean; telemetryError?: string; onUpdated: () => void }) {
function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, telemetryError, onUpdated }: { detail: VehicleDetail; liveRealtime?: VehicleRealtimeRow; telemetry?: LatestTelemetryResponse; telemetryPending: boolean; telemetryError?: string; onUpdated: () => void }) {
const { session } = usePlatformSession();
const realtime = detail.realtimeSummary;
const realtime = liveRealtime ?? detail.realtimeSummary;
const identity = detail.identity;
const mapVehicles = realtime ? [realtime] : [];
const lastMileage = detail.mileage.items[0];
@@ -198,10 +199,20 @@ function VehicleRecord({ detail, telemetry, telemetryPending, telemetryError, on
export default function VehiclePage() {
const { vin } = useParams();
const query = useQuery({ queryKey: ['vehicle-detail', vin], enabled: Boolean(vin), queryFn: ({ signal }) => api.vehicleDetail(new URLSearchParams({ keyword: vin!, limit: '20' }), signal), gcTime: QUERY_MEMORY.summaryGcTime });
const resolvedVin = query.data?.lookupResolved ? query.data.vin : '';
const realtime = useQuery({
queryKey: ['vehicle-detail-realtime', resolvedVin],
queryFn: ({ signal }) => api.vehicleRealtime(new URLSearchParams({ keywords: resolvedVin, limit: '1', offset: '0' }), signal),
enabled: Boolean(resolvedVin),
staleTime: 5_000,
gcTime: QUERY_MEMORY.highVolumeGcTime,
refetchInterval: resolvedVin ? SINGLE_VEHICLE_REFRESH_MS : false,
...LIVE_QUERY_POLICY
});
const telemetry = useQuery({ queryKey: ['vehicle-latest-telemetry', vin], enabled: Boolean(vin), queryFn: ({ signal }) => api.latestTelemetry(vin!, signal), staleTime: 10_000, gcTime: QUERY_MEMORY.summaryGcTime, refetchInterval: 20_000, ...LIVE_QUERY_POLICY });
if (!vin) return <VehicleSearch />;
if (query.isPending) return <PageLoading />;
if (query.isError) return <div className="v2-page-error"><InlineError message={query.error instanceof Error ? query.error.message : '车辆档案加载失败'} onRetry={() => query.refetch()} /></div>;
if (!query.data.lookupResolved) return <section className="v2-not-found"><IconSearch size="extra-large" /><h2></h2><p>{vin}VIN </p><Link to="/vehicles"></Link></section>;
return <VehicleRecord detail={query.data} telemetry={telemetry.data} telemetryPending={telemetry.isPending} telemetryError={telemetry.isError ? (telemetry.error instanceof Error ? telemetry.error.message : '请求失败') : undefined} onUpdated={() => { void query.refetch(); void telemetry.refetch(); }} />;
return <VehicleRecord detail={query.data} liveRealtime={realtime.data?.items[0]} telemetry={telemetry.data} telemetryPending={telemetry.isPending} telemetryError={telemetry.isError ? (telemetry.error instanceof Error ? telemetry.error.message : '请求失败') : undefined} onUpdated={() => { void query.refetch(); void realtime.refetch(); void telemetry.refetch(); }} />;
}