fix(platform): harden queries and batch vehicle search

This commit is contained in:
lingniu
2026-07-16 00:14:16 +08:00
parent c29ccdf2da
commit 53e1b57e86
19 changed files with 193 additions and 53 deletions

View File

@@ -1113,6 +1113,9 @@ func TestHandlerSourceReadiness(t *testing.T) {
if body.Data.PlatformRelease != "platform-source-test" { if body.Data.PlatformRelease != "platform-source-test" {
t.Fatalf("source readiness should expose runtime release, got %+v", body.Data) t.Fatalf("source readiness should expose runtime release, got %+v", body.Data)
} }
if body.Data.TotalVehicles != body.Data.BoundVehicles+body.Data.IdentityRequiredVehicles {
t.Fatalf("source readiness vehicle identity population should reconcile, got %+v", body.Data)
}
if len(body.Data.Sources) < len(canonicalVehicleProtocols) { if len(body.Data.Sources) < len(canonicalVehicleProtocols) {
t.Fatalf("source readiness should expose canonical protocol slots, got %+v", body.Data.Sources) t.Fatalf("source readiness should expose canonical protocol slots, got %+v", body.Data.Sources)
} }

View File

@@ -942,13 +942,15 @@ type MissingSourceStat struct {
} }
type SourceReadinessPlan struct { type SourceReadinessPlan struct {
TotalVehicles int `json:"totalVehicles"` TotalVehicles int `json:"totalVehicles"`
OnlineVehicles int `json:"onlineVehicles"` BoundVehicles int `json:"boundVehicles"`
KafkaLag *int `json:"kafkaLag"` IdentityRequiredVehicles int `json:"identityRequiredVehicles"`
ActiveConnections *int `json:"activeConnections"` OnlineVehicles int `json:"onlineVehicles"`
RedisOnlineKeys *int `json:"redisOnlineKeys"` KafkaLag *int `json:"kafkaLag"`
PlatformRelease string `json:"platformRelease"` ActiveConnections *int `json:"activeConnections"`
Sources []SourceReadinessRow `json:"sources"` RedisOnlineKeys *int `json:"redisOnlineKeys"`
PlatformRelease string `json:"platformRelease"`
Sources []SourceReadinessRow `json:"sources"`
} }
type SourceReadinessRow struct { type SourceReadinessRow struct {

View File

@@ -262,13 +262,15 @@ func buildSourceReadinessPlan(summary VehicleServiceSummary, health OpsHealth, q
rows = append(rows, row) rows = append(rows, row)
} }
return SourceReadinessPlan{ return SourceReadinessPlan{
TotalVehicles: summary.TotalVehicles, TotalVehicles: summary.TotalVehicles,
OnlineVehicles: summary.OnlineVehicles, BoundVehicles: summary.BoundVehicles,
KafkaLag: health.KafkaLag, IdentityRequiredVehicles: summary.IdentityRequiredVehicles,
ActiveConnections: health.ActiveConnections, OnlineVehicles: summary.OnlineVehicles,
RedisOnlineKeys: health.RedisOnlineKeys, KafkaLag: health.KafkaLag,
PlatformRelease: health.Runtime.PlatformRelease, ActiveConnections: health.ActiveConnections,
Sources: rows, RedisOnlineKeys: health.RedisOnlineKeys,
PlatformRelease: health.Runtime.PlatformRelease,
Sources: rows,
} }
} }

View File

@@ -91,6 +91,20 @@ test('monitor viewport requests forward AbortSignal so obsolete pans can be canc
expect(fetchMock).toHaveBeenCalledWith('/api/v2/monitor/map?zoom=13&bounds=113%2C22%2C114%2C23', { signal: controller.signal }); expect(fetchMock).toHaveBeenCalledWith('/api/v2/monitor/map?zoom=13&bounds=113%2C22%2C114%2C23', { signal: controller.signal });
}); });
test('high-volume history, track, and mileage requests forward AbortSignal', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {} }) } as Response);
const controller = new AbortController();
const params = new URLSearchParams({ keyword: 'VIN001' });
await api.trackPlayback(params, controller.signal);
await api.historyData(params, controller.signal);
await api.historySeries(params, controller.signal);
await api.dailyMileage(params, controller.signal);
await api.mileageStatistics(params, controller.signal);
for (const [, init] of fetchMock.mock.calls) expect(init).toEqual({ signal: controller.signal });
});
test('rawFramesQuery posts structured JSON instead of URL query strings', async () => { test('rawFramesQuery posts structured JSON instead of URL query strings', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true, ok: true,

View File

@@ -127,11 +127,11 @@ export const api = {
session: () => request<SessionInfo>('/api/v2/session'), session: () => request<SessionInfo>('/api/v2/session'),
monitorSummary: (params = new URLSearchParams(), signal?: AbortSignal) => request<MonitorSummary>(`/api/v2/monitor/summary?${params.toString()}`, signal ? { signal } : undefined), monitorSummary: (params = new URLSearchParams(), signal?: AbortSignal) => request<MonitorSummary>(`/api/v2/monitor/summary?${params.toString()}`, signal ? { signal } : undefined),
monitorMap: (params = new URLSearchParams(), signal?: AbortSignal) => request<MonitorMapResponse>(`/api/v2/monitor/map?${params.toString()}`, signal ? { signal } : undefined), monitorMap: (params = new URLSearchParams(), signal?: AbortSignal) => request<MonitorMapResponse>(`/api/v2/monitor/map?${params.toString()}`, signal ? { signal } : undefined),
trackPlayback: (params = new URLSearchParams()) => request<TrackPlaybackResponse>(`/api/v2/tracks?${params.toString()}`), trackPlayback: (params = new URLSearchParams(), signal?: AbortSignal) => request<TrackPlaybackResponse>(`/api/v2/tracks?${params.toString()}`, signal ? { signal } : undefined),
metricCatalog: () => request<MetricCatalog>('/api/v2/metrics'), metricCatalog: () => request<MetricCatalog>('/api/v2/metrics'),
historyMetricCatalog: () => request<HistoryMetricCatalog>('/api/v2/history/metrics'), historyMetricCatalog: () => request<HistoryMetricCatalog>('/api/v2/history/metrics'),
historyData: (params = new URLSearchParams()) => request<HistoryDataResponse>(`/api/v2/history/query?${params.toString()}`), historyData: (params = new URLSearchParams(), signal?: AbortSignal) => request<HistoryDataResponse>(`/api/v2/history/query?${params.toString()}`, signal ? { signal } : undefined),
historySeries: (params = new URLSearchParams()) => request<HistorySeriesResponse>(`/api/v2/history/series?${params.toString()}`), historySeries: (params = new URLSearchParams(), signal?: AbortSignal) => request<HistorySeriesResponse>(`/api/v2/history/series?${params.toString()}`, signal ? { signal } : undefined),
historyExports: () => request<HistoryExportJob[]>('/api/v2/exports'), historyExports: () => request<HistoryExportJob[]>('/api/v2/exports'),
createHistoryExport: (query: HistoryExportRequest) => request<HistoryExportJob>('/api/v2/exports', { createHistoryExport: (query: HistoryExportRequest) => request<HistoryExportJob>('/api/v2/exports', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
@@ -171,9 +171,9 @@ export const api = {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }) method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids })
}), }),
dashboardSummary: () => request<DashboardSummary>('/api/dashboard/summary'), dashboardSummary: () => request<DashboardSummary>('/api/dashboard/summary'),
vehicles: (params = new URLSearchParams()) => request<Page<VehicleRow>>(`/api/vehicles?${params.toString()}`), vehicles: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<VehicleRow>>(`/api/vehicles?${params.toString()}`, signal ? { signal } : undefined),
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(), signal?: AbortSignal) => request<Page<VehicleCoverageRow>>(`/api/vehicles/coverage?${params.toString()}`, signal ? { signal } : undefined),
vehicleCoverageSummary: (params = new URLSearchParams()) => request<VehicleCoverageSummary>(`/api/vehicles/coverage/summary?${params.toString()}`), vehicleCoverageSummary: (params = new URLSearchParams()) => request<VehicleCoverageSummary>(`/api/vehicles/coverage/summary?${params.toString()}`),
vehicleDetail: (params = new URLSearchParams()) => request<VehicleDetail>(`/api/vehicle-service?${params.toString()}`), vehicleDetail: (params = new URLSearchParams()) => request<VehicleDetail>(`/api/vehicle-service?${params.toString()}`),
vehicleProfile: (vin: string) => request<VehicleProfile>(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`), vehicleProfile: (vin: string) => request<VehicleProfile>(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`),
@@ -201,8 +201,8 @@ export const api = {
body: JSON.stringify(query) body: JSON.stringify(query)
}), }),
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(), signal?: AbortSignal) => request<Page<DailyMileageRow>>(`/api/mileage/daily?${params.toString()}`, signal ? { signal } : undefined),
mileageStatistics: (params = new URLSearchParams()) => request<MileageStatistics>(`/api/v2/statistics/mileage?${params.toString()}`), mileageStatistics: (params = new URLSearchParams(), signal?: AbortSignal) => request<MileageStatistics>(`/api/v2/statistics/mileage?${params.toString()}`, signal ? { signal } : undefined),
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()}`), 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()}`),

View File

@@ -709,6 +709,8 @@ export interface OpsHealth {
export interface SourceReadinessPlan { export interface SourceReadinessPlan {
totalVehicles: number; totalVehicles: number;
boundVehicles: number;
identityRequiredVehicles: number;
onlineVehicles: number; onlineVehicles: number;
kafkaLag: number | null; kafkaLag: number | null;
activeConnections: number | null; activeConnections: number | null;

View File

@@ -6,6 +6,7 @@ import { api } from '../../api/client';
import type { HistoryDataRow, HistoryExportRequest, HistoryMetricDefinition, HistorySeriesResponse } from '../../api/types'; import type { HistoryDataRow, HistoryExportRequest, HistoryMetricDefinition, HistorySeriesResponse } from '../../api/types';
import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, formatSeriesGrain, parseHistoryKeywords } from '../domain/history'; import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, formatSeriesGrain, parseHistoryKeywords } from '../domain/history';
import { InlineError } from '../shared/AsyncState'; import { InlineError } from '../shared/AsyncState';
import { QUERY_MEMORY } from '../queryPolicy';
function HistoryTrend({ response, category, loading, error }: { response?: HistorySeriesResponse; category: string; loading: boolean; error?: string }) { function HistoryTrend({ response, category, loading, error }: { response?: HistorySeriesResponse; category: string; loading: boolean; error?: string }) {
const panels = useMemo(() => buildHistorySeriesPanels(response), [response]); const panels = useMemo(() => buildHistorySeriesPanels(response), [response]);
@@ -79,8 +80,8 @@ export default function HistoryPage() {
return next; return next;
}, [criteria, keywords]); }, [criteria, keywords]);
const catalogQuery = useQuery({ queryKey: ['history-metric-catalog'], queryFn: api.historyMetricCatalog, staleTime: 30 * 60_000 }); const catalogQuery = useQuery({ queryKey: ['history-metric-catalog'], queryFn: api.historyMetricCatalog, staleTime: 30 * 60_000 });
const dataQuery = useQuery({ queryKey: ['history-data', params.toString()], enabled: keywords.length > 0, queryFn: () => api.historyData(params), placeholderData: (previous) => previous }); const dataQuery = useQuery({ queryKey: ['history-data', params.toString()], enabled: keywords.length > 0, queryFn: ({ signal }) => api.historyData(params, signal), placeholderData: (previous) => previous, gcTime: QUERY_MEMORY.highVolumeGcTime });
const seriesQuery = useQuery({ queryKey: ['history-series', seriesParams.toString()], enabled: keywords.length > 0 && criteria.category === 'location', queryFn: () => api.historySeries(seriesParams), placeholderData: (previous) => previous }); const seriesQuery = useQuery({ queryKey: ['history-series', seriesParams.toString()], enabled: keywords.length > 0 && criteria.category === 'location', queryFn: ({ signal }) => api.historySeries(seriesParams, signal), placeholderData: (previous) => previous, gcTime: QUERY_MEMORY.highVolumeGcTime });
const result = dataQuery.data; const result = dataQuery.data;
const allMetrics = result?.columns ?? catalogQuery.data?.metrics.filter((metric) => metric.category === criteria.category) ?? []; const allMetrics = result?.columns ?? catalogQuery.data?.metrics.filter((metric) => metric.category === criteria.category) ?? [];
const visibleKeys = visibleByCategory[criteria.category] ?? allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key); const visibleKeys = visibleByCategory[criteria.category] ?? allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key);

View File

@@ -17,6 +17,7 @@ const vehicles = [{
}] as VehicleRealtimeRow[]; }] as VehicleRealtimeRow[];
const monitorMap = { clusters: [], points: [], total: 2 }; const monitorMap = { clusters: [], points: [], total: 2 };
const fleetMapRenderSpy = vi.hoisted(() => vi.fn()); const fleetMapRenderSpy = vi.hoisted(() => vi.fn());
const monitorQueryFlags = vi.hoisted(() => ({ isLoading: false, isFetching: false, isPlaceholderData: false }));
vi.mock('../map/FleetMap', () => ({ vi.mock('../map/FleetMap', () => ({
FleetMap: ({ selectedVin, onSelectVin }: { selectedVin?: string; onSelectVin?: (vin: string) => void }) => { FleetMap: ({ selectedVin, onSelectVin }: { selectedVin?: string; onSelectVin?: (vin: string) => void }) => {
@@ -42,14 +43,19 @@ vi.mock('../hooks/useMonitorData', () => ({
}, },
useMonitorData: () => ({ useMonitorData: () => ({
summary: { data: { totalVehicles: 2, onlineVehicles: 2, offlineVehicles: 0, drivingVehicles: 1, idleVehicles: 1, frameToday: 10 } }, summary: { data: { totalVehicles: 2, onlineVehicles: 2, offlineVehicles: 0, drivingVehicles: 1, idleVehicles: 1, frameToday: 10 } },
vehicles: { data: { items: vehicles, total: 2 }, isError: false, isLoading: false, isFetching: false }, vehicles: { data: { items: vehicles, total: 2 }, isError: false, ...monitorQueryFlags },
map: { data: monitorMap }, map: { data: monitorMap, isPlaceholderData: monitorQueryFlags.isPlaceholderData },
selectedVehicle: { data: { items: [] } } selectedVehicle: { data: { items: [] } }
}), }),
useMonitorVehicleCard: () => ({ detail: {}, activeAlerts: {}, address: {} }) useMonitorVehicleCard: () => ({ detail: {}, activeAlerts: {}, address: {} })
})); }));
afterEach(cleanup); afterEach(() => {
cleanup();
monitorQueryFlags.isLoading = false;
monitorQueryFlags.isFetching = false;
monitorQueryFlags.isPlaceholderData = false;
});
test('starts without a selection and supports expand, collapse, reselection, and clear', () => { test('starts without a selection and supports expand, collapse, reselection, and clear', () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -63,6 +69,8 @@ test('starts without a selection and supports expand, collapse, reselection, and
expect(firstVehicle).not.toHaveClass('is-selected'); expect(firstVehicle).not.toHaveClass('is-selected');
expect(screen.queryByRole('button', { name: '取消选择车辆' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '取消选择车辆' })).not.toBeInTheDocument();
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', ''); expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', '');
expect(screen.getByText('有实时位置')).toBeInTheDocument();
expect(screen.queryByText('接入车辆')).not.toBeInTheDocument();
fireEvent.click(firstVehicle); fireEvent.click(firstVehicle);
expect(workspace).toHaveClass('is-detail-open'); expect(workspace).toHaveClass('is-detail-open');
@@ -124,8 +132,7 @@ test('pastes, deduplicates, and submits multiple plates as one batch search', as
const input = screen.getByRole('textbox', { name: '搜索车辆' }); const input = screen.getByRole('textbox', { name: '搜索车辆' });
fireEvent.paste(input, { clipboardData: { getData: () => '粤a12345\n粤B67890粤A12345' } }); fireEvent.paste(input, { clipboardData: { getData: () => '粤a12345\n粤B67890粤A12345' } });
expect(input).toHaveValue('粤A12345粤B67890'); expect(input).toHaveValue('粤A12345粤B67890');
expect(screen.getByText('已识别 2 辆')).toBeInTheDocument(); expect(screen.getAllByText('已找到 2/2')).toHaveLength(3);
expect(screen.getByText('正在批量筛选 2 辆')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /列表/ })); fireEvent.click(screen.getByRole('button', { name: /列表/ }));
await waitFor(() => { await waitFor(() => {
@@ -134,3 +141,24 @@ test('pastes, deduplicates, and submits multiple plates as one batch search', as
expect(params?.has('keyword')).toBe(false); expect(params?.has('keyword')).toBe(false);
}); });
}); });
test('hides stale fleet rows while a pasted batch is still loading', () => {
monitorQueryFlags.isPlaceholderData = true;
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={queryClient}><MemoryRouter><MonitorPage /></MemoryRouter></QueryClientProvider>);
fireEvent.paste(screen.getByRole('textbox', { name: '搜索车辆' }), { clipboardData: { getData: () => '粤A12345\n粤B67890' } });
expect(screen.getByText('正在查找 2 辆车')).toBeInTheDocument();
expect(screen.getByText('查询中')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /粤A12345 LTEST000000000001/ })).not.toBeInTheDocument();
});
test('reports pasted plates that have no exact realtime match', () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={queryClient}><MemoryRouter><MonitorPage /></MemoryRouter></QueryClientProvider>);
fireEvent.paste(screen.getByRole('textbox', { name: '搜索车辆' }), { clipboardData: { getData: () => '粤A12345\n粤Z99999' } });
expect(screen.getByText('已找到 1/2')).toBeInTheDocument();
expect(screen.getAllByText('已找到 1/2 · 未找到 1 辆')).toHaveLength(2);
expect(screen.getAllByTitle('未找到粤Z99999')).toHaveLength(2);
});

View File

@@ -12,6 +12,7 @@ import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorQueryParams, parseMon
const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT']; const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT'];
const statuses = ['', 'online', 'offline', 'driving', 'idle']; const statuses = ['', 'online', 'offline', 'driving', 'idle'];
const EMPTY_VEHICLES: VehicleRealtimeRow[] = [];
type AddressCoordinate = { longitude: number; latitude: number; key: string }; type AddressCoordinate = { longitude: number; latitude: number; key: string };
@@ -194,6 +195,30 @@ export default function MonitorPage() {
if (status === 'driving' || status === 'idle') return data.filter((vehicle) => vehicleStatus(vehicle) === status); if (status === 'driving' || status === 'idle') return data.filter((vehicle) => vehicleStatus(vehicle) === status);
return data; return data;
}, [status, vehicles.data?.items]); }, [status, vehicles.data?.items]);
const listRows = realtimeListQuery.data?.items ?? EMPTY_VEHICLES;
const activeBatchRows = mode === 'map' ? rows : listRows;
const activeBatchQuery = mode === 'map' ? vehicles : realtimeListQuery;
const batchSearchPending = searchTerms.length > 1 && (
keyword !== deferredKeyword || activeBatchQuery.isLoading || activeBatchQuery.isPlaceholderData
);
const batchMatch = useMemo(() => {
const identities = new Set<string>();
for (const vehicle of activeBatchRows) {
identities.add(vehicle.vin.toLocaleUpperCase());
if (vehicle.plate) identities.add(vehicle.plate.toLocaleUpperCase());
}
const missing = searchTerms.filter((term) => !identities.has(term));
return { matched: searchTerms.length - missing.length, missing };
}, [activeBatchRows, searchTerms]);
const visibleRows = batchSearchPending ? [] : rows;
const batchStatus = batchSearchPending
? `正在查找 ${searchTerms.length}`
: batchMatch.missing.length
? `已找到 ${batchMatch.matched}/${searchTerms.length} · 未找到 ${batchMatch.missing.length}`
: `已找到 ${batchMatch.matched}/${searchTerms.length}`;
const batchStatusTitle = batchMatch.missing.length
? `未找到:${batchMatch.missing.join('、')}`
: searchTerms.join('、');
const selected = selectedVin const selected = selectedVin
? rows.find((vehicle) => vehicle.vin === selectedVin) ?? selectedVehicle.data?.items[0] ? rows.find((vehicle) => vehicle.vin === selectedVin) ?? selectedVehicle.data?.items[0]
: undefined; : undefined;
@@ -232,7 +257,7 @@ export default function MonitorPage() {
}} }}
placeholder="车牌 / VIN可批量粘贴车牌" placeholder="车牌 / VIN可批量粘贴车牌"
/> />
{searchTerms.length > 1 ? <span className="v2-search-batch-count" aria-live="polite" title={searchTerms.length === MAX_MONITOR_SEARCH_TERMS ? `最多支持 ${MAX_MONITOR_SEARCH_TERMS} 条;${searchTerms.join('、')}` : searchTerms.join('、')}> {searchTerms.length} </span> : null} {searchTerms.length > 1 ? <span className={`v2-search-batch-count${batchMatch.missing.length && !batchSearchPending ? ' has-missing' : ''}`} aria-live="polite" title={searchTerms.length === MAX_MONITOR_SEARCH_TERMS ? `最多支持 ${MAX_MONITOR_SEARCH_TERMS} 条;${batchStatusTitle}` : batchStatusTitle}>{batchSearchPending ? `已识别 ${searchTerms.length}` : `已找到 ${batchMatch.matched}/${searchTerms.length}`}</span> : null}
</label> </label>
<select value={protocol} onChange={(event) => { setProtocol(event.target.value); setListOffset(0); }} aria-label="协议"> <select value={protocol} onChange={(event) => { setProtocol(event.target.value); setListOffset(0); }} aria-label="协议">
{protocols.map((item) => <option key={item} value={item}>{item || '全部协议'}</option>)} {protocols.map((item) => <option key={item} value={item}>{item || '全部协议'}</option>)}
@@ -248,7 +273,7 @@ export default function MonitorPage() {
<section className="v2-kpis" aria-label="车辆整体统计"> <section className="v2-kpis" aria-label="车辆整体统计">
{[ {[
['接入车辆', formatNumber(summary.data?.totalVehicles ?? vehicles.data?.total ?? rows.length), 'fleet'], ['有实时位置', formatNumber(summary.data?.totalVehicles ?? vehicles.data?.total ?? rows.length), 'fleet'],
['当前在线', formatNumber(summary.data?.onlineVehicles ?? rows.length - offline), 'online'], ['当前在线', formatNumber(summary.data?.onlineVehicles ?? rows.length - offline), 'online'],
['当前离线', formatNumber(summary.data?.offlineVehicles ?? offline), 'offline'], ['当前离线', formatNumber(summary.data?.offlineVehicles ?? offline), 'offline'],
['行驶车辆', formatNumber(summary.data?.drivingVehicles ?? driving), 'driving'], ['行驶车辆', formatNumber(summary.data?.drivingVehicles ?? driving), 'driving'],
@@ -262,18 +287,18 @@ export default function MonitorPage() {
{mode === 'list' && realtimeListQuery.isError ? <InlineError message={realtimeListQuery.error instanceof Error ? realtimeListQuery.error.message : '车辆列表加载失败'} onRetry={() => realtimeListQuery.refetch()} /> : null} {mode === 'list' && realtimeListQuery.isError ? <InlineError message={realtimeListQuery.error instanceof Error ? realtimeListQuery.error.message : '车辆列表加载失败'} onRetry={() => realtimeListQuery.refetch()} /> : null}
{mode === 'map' ? <section className={`v2-monitor-workspace${selected && detailOpen ? ' is-detail-open' : ''}${selected && !detailOpen ? ' is-detail-collapsed' : ''}`}> {mode === 'map' ? <section className={`v2-monitor-workspace${selected && detailOpen ? ' is-detail-open' : ''}${selected && !detailOpen ? ' is-detail-collapsed' : ''}`}>
<div className="v2-vehicle-rail"> <div className="v2-vehicle-rail">
<header><strong></strong><span>{formatNumber(vehicles.data?.total ?? rows.length)} </span></header> <header><strong></strong><span>{batchSearchPending ? '查询中' : `${formatNumber(vehicles.data?.total ?? visibleRows.length)}`}</span></header>
<div className="v2-rail-search"><IconSearch /><span>{searchTerms.length > 1 ? `正在批量筛选 ${searchTerms.length}` : deferredKeyword ? `正在筛选“${deferredKeyword}` : '按最新上报排序'}</span></div> <div className={`v2-rail-search${batchMatch.missing.length && !batchSearchPending ? ' has-missing' : ''}`} title={searchTerms.length > 1 ? batchStatusTitle : undefined}><IconSearch /><span>{searchTerms.length > 1 ? batchStatus : deferredKeyword ? `正在筛选“${deferredKeyword}` : '按最新上报排序'}</span></div>
<div className="v2-vehicle-scroll"> <div className="v2-vehicle-scroll">
{vehicles.isLoading ? <div className="v2-list-loading"><span className="v2-spinner" /></div> : null} {vehicles.isLoading || batchSearchPending ? <div className="v2-list-loading"><span className="v2-spinner" />{batchSearchPending ? `正在查找 ${searchTerms.length} 辆车` : '加载车辆'}</div> : null}
{!vehicles.isLoading && rows.length === 0 ? <EmptyState /> : null} {!vehicles.isLoading && !batchSearchPending && visibleRows.length === 0 ? <EmptyState /> : null}
{rows.map((vehicle) => <VehicleRow key={vehicle.vin} vehicle={vehicle} selected={vehicle.vin === selectedVin} onSelect={selectVehicle} />)} {visibleRows.map((vehicle) => <VehicleRow key={vehicle.vin} vehicle={vehicle} selected={vehicle.vin === selectedVin} onSelect={selectVehicle} />)}
</div> </div>
<footer> {rows.length} / {vehicles.data?.total ?? rows.length} </footer> <footer>{searchTerms.length > 1 && !batchSearchPending ? batchStatus : `当前载入 ${visibleRows.length} / ${vehicles.data?.total ?? visibleRows.length}`}</footer>
</div> </div>
<MemoFleetMap <MemoFleetMap
vehicles={rows} vehicles={visibleRows}
monitorMap={map.data} monitorMap={searchTerms.length > 1 && (batchSearchPending || map.isPlaceholderData) ? undefined : map.data}
selectedVin={selectedVin || undefined} selectedVin={selectedVin || undefined}
onSelect={selectMapVehicle} onSelect={selectMapVehicle}
onSelectVin={selectVehicle} onSelectVin={selectVehicle}
@@ -303,7 +328,7 @@ export default function MonitorPage() {
<section className="v2-event-strip"> <section className="v2-event-strip">
<strong></strong> <strong></strong>
<span><i className="is-online" />{vehicles.isFetching ? '正在刷新' : '实时车辆已同步'}</span> <span><i className="is-online" />{vehicles.isFetching ? '正在刷新' : '实时车辆已同步'}</span>
<span>{mode === 'map' ? `列表 ${rows.length} 条 · 地图 ${map.data?.clusters.length ? `${map.data.clusters.length} 个聚合 + ${map.data.points.length} 个车辆点` : `${map.data?.points.length ?? 0} 个点`}` : `实时列表 ${realtimeListQuery.data?.items.length ?? 0} / ${realtimeListQuery.data?.total ?? 0}`}</span> <span>{mode === 'map' ? `列表 ${visibleRows.length} 条 · 地图 ${map.data?.clusters.length ? `${map.data.clusters.length} 个聚合 + ${map.data.points.length} 个车辆点` : `${map.data?.points.length ?? 0} 个点`}` : `实时列表 ${realtimeListQuery.data?.items.length ?? 0} / ${realtimeListQuery.data?.total ?? 0}`}</span>
<span className="v2-refresh-cadence"><b></b> {MONITOR_REFRESH.selected / 1000} · {MONITOR_REFRESH.fleet / 1000} · {MONITOR_REFRESH.summary / 1000} </span> <span className="v2-refresh-cadence"><b></b> {MONITOR_REFRESH.selected / 1000} · {MONITOR_REFRESH.fleet / 1000} · {MONITOR_REFRESH.summary / 1000} </span>
<time>{new Date().toLocaleString('zh-CN', { hour12: false })}</time> <time>{new Date().toLocaleString('zh-CN', { hour12: false })}</time>
</section> </section>

View File

@@ -0,0 +1,28 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import OperationsPage from './OperationsPage';
const mocks = vi.hoisted(() => ({ opsHealth: vi.fn(), sourceReadiness: vi.fn() }));
vi.mock('../../api/client', () => ({ api: mocks }));
afterEach(() => { cleanup(); mocks.opsHealth.mockReset(); mocks.sourceReadiness.mockReset(); });
test('reconciles service identities with bound and identity-required vehicles', async () => {
mocks.opsHealth.mockResolvedValue({
linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
tdengineWritable: true, mysqlWritable: true,
runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false }
});
mocks.sourceReadiness.mockResolvedValue({
totalVehicles: 1035, boundVehicles: 1024, identityRequiredVehicles: 11, onlineVehicles: 234,
kafkaLag: 0, activeConnections: 10, redisOnlineKeys: 5, platformRelease: 'test-release', sources: []
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
expect(await screen.findByText('1035 / 234')).toBeInTheDocument();
expect(screen.getByText('服务身份 / 在线')).toBeInTheDocument();
expect(screen.getByText('已绑定 1024 · 待绑定 11')).toBeInTheDocument();
expect(screen.queryByText('统一车辆视角')).not.toBeInTheDocument();
});

View File

@@ -13,14 +13,14 @@ export default function OperationsPage() {
const data = health.data; const sources = readiness.data; const data = health.data; const sources = readiness.data;
const refresh = () => Promise.all([health.refetch(), readiness.refetch()]); const refresh = () => Promise.all([health.refetch(), readiness.refetch()]);
return <div className="v2-ops-page"> return <div className="v2-ops-page">
<header className="v2-ops-heading"><div><h2></h2><p> ECS </p></div><button onClick={refresh} disabled={health.isFetching || readiness.isFetching}><IconRefresh /></button></header> <header className="v2-ops-heading"><div><h2></h2><p></p></div><button onClick={refresh} disabled={health.isFetching || readiness.isFetching}><IconRefresh /></button></header>
{health.isError ? <InlineError message={health.error.message} onRetry={refresh} /> : null} {health.isError ? <InlineError message={health.error.message} onRetry={refresh} /> : null}
<section className="v2-ops-kpis"> <section className="v2-ops-kpis">
<article><small></small><strong>{data?.runtime.platformRelease || '未注入'}</strong><span className={data?.runtime.dataMode === 'production' ? 'is-ok' : 'is-error'}>{data?.runtime.dataMode || 'unknown'}</span></article> <article><small></small><strong>{data?.runtime.platformRelease || '未注入'}</strong><span className={data?.runtime.dataMode === 'production' ? 'is-ok' : 'is-error'}>{data?.runtime.dataMode || 'unknown'}</span></article>
<article><small></small><strong>{data?.activeConnections?.toLocaleString('zh-CN') ?? '—'}</strong><span>capacity-check</span></article> <article><small></small><strong>{data?.activeConnections?.toLocaleString('zh-CN') ?? '—'}</strong><span>capacity-check</span></article>
<article><small>Kafka Lag</small><strong>{data?.kafkaLag?.toLocaleString('zh-CN') ?? '—'}</strong><span className={data?.kafkaLag === 0 ? 'is-ok' : 'is-warning'}>{data?.kafkaLag === 0 ? '已回零' : '需检查'}</span></article> <article><small>Kafka Lag</small><strong>{data?.kafkaLag?.toLocaleString('zh-CN') ?? '—'}</strong><span className={data?.kafkaLag === 0 ? 'is-ok' : 'is-warning'}>{data?.kafkaLag === 0 ? '已回零' : '需检查'}</span></article>
<article><small>Redis 线 Key</small><strong>{data?.redisOnlineKeys?.toLocaleString('zh-CN') ?? '—'}</strong><span></span></article> <article><small>Redis 线 Key</small><strong>{data?.redisOnlineKeys?.toLocaleString('zh-CN') ?? '—'}</strong><span></span></article>
<article><small> / 线</small><strong>{sources ? `${sources.onlineVehicles} / ${sources.totalVehicles}` : '—'}</strong><span></span></article> <article><small> / 线</small><strong>{sources ? `${sources.totalVehicles} / ${sources.onlineVehicles}` : '—'}</strong><span>{sources ? `已绑定 ${sources.boundVehicles} · 待绑定 ${sources.identityRequiredVehicles}` : '档案与快照并集'}</span></article>
</section> </section>
<div className="v2-ops-grid"><section className="v2-ops-links"><header><strong></strong><span>15 </span></header><div>{data?.linkHealth.map((item) => <article key={item.name}><i className={`is-${item.status}`} /><div><strong>{item.name}</strong><p>{item.detail || '无补充信息'}</p></div><span className={`is-${item.status}`}>{statusLabel(item.status)}</span></article>)}</div></section> <div className="v2-ops-grid"><section className="v2-ops-links"><header><strong></strong><span>15 </span></header><div>{data?.linkHealth.map((item) => <article key={item.name}><i className={`is-${item.status}`} /><div><strong>{item.name}</strong><p>{item.detail || '无补充信息'}</p></div><span className={`is-${item.status}`}>{statusLabel(item.status)}</span></article>)}</div></section>
<section className="v2-ops-runtime"><header><strong></strong></header><dl><div><dt></dt><dd>{data?.runtime.dataMode === 'production' ? '已启用' : '未启用'}</dd></div><div><dt>MySQL </dt><dd className={data?.mysqlWritable ? 'is-ok' : 'is-error'}>{data?.mysqlWritable ? '正常' : '异常'}</dd></div><div><dt>TDengine </dt><dd className={data?.tdengineWritable ? 'is-ok' : 'is-error'}>{data?.tdengineWritable ? '正常' : '异常'}</dd></div><div><dt></dt><dd>{data?.runtime.requestTimeoutMs ?? '—'} ms</dd></div><div><dt></dt><dd className={data?.runtime.amapSecurityProxyEnabled && !data?.runtime.amapSecurityCodeExposed ? 'is-ok' : 'is-warning'}>{data?.runtime.amapSecurityProxyEnabled ? '服务端代理' : '未启用'}</dd></div></dl>{data?.capacityFindings?.length ? <div className="v2-ops-findings">{data.capacityFindings.map((item) => <p key={item}>{item}</p>)}</div> : <p className="v2-ops-clear"></p>}</section></div> <section className="v2-ops-runtime"><header><strong></strong></header><dl><div><dt></dt><dd>{data?.runtime.dataMode === 'production' ? '已启用' : '未启用'}</dd></div><div><dt>MySQL </dt><dd className={data?.mysqlWritable ? 'is-ok' : 'is-error'}>{data?.mysqlWritable ? '正常' : '异常'}</dd></div><div><dt>TDengine </dt><dd className={data?.tdengineWritable ? 'is-ok' : 'is-error'}>{data?.tdengineWritable ? '正常' : '异常'}</dd></div><div><dt></dt><dd>{data?.runtime.requestTimeoutMs ?? '—'} ms</dd></div><div><dt></dt><dd className={data?.runtime.amapSecurityProxyEnabled && !data?.runtime.amapSecurityCodeExposed ? 'is-ok' : 'is-warning'}>{data?.runtime.amapSecurityProxyEnabled ? '服务端代理' : '未启用'}</dd></div></dl>{data?.capacityFindings?.length ? <div className="v2-ops-findings">{data.capacityFindings.map((item) => <p key={item}>{item}</p>)}</div> : <p className="v2-ops-clear"></p>}</section></div>

View File

@@ -46,6 +46,8 @@ test('renders one vehicle per row with dates as columns and a period total', asy
expect(screen.queryByText('数据来源')).not.toBeInTheDocument(); expect(screen.queryByText('数据来源')).not.toBeInTheDocument();
expect(screen.queryByRole('img', { name: '每日行驶里程趋势图' })).not.toBeInTheDocument(); expect(screen.queryByRole('img', { name: '每日行驶里程趋势图' })).not.toBeInTheDocument();
expect(screen.queryByText('车辆里程排名')).not.toBeInTheDocument(); expect(screen.queryByText('车辆里程排名')).not.toBeInTheDocument();
expect(screen.getByText('已绑定主车辆')).toBeInTheDocument();
expect(screen.getByText('已选择 1 辆')).toBeInTheDocument();
await waitFor(() => expect(mocks.dailyMileage).toHaveBeenCalledTimes(1)); await waitFor(() => expect(mocks.dailyMileage).toHaveBeenCalledTimes(1));
expect(mocks.dailyMileage.mock.calls[0][0].get('limit')).toBe('10000'); expect(mocks.dailyMileage.mock.calls[0][0].get('limit')).toBe('10000');
expect(mocks.dailyMileage.mock.calls[0][0].get('protocols')).toBe('GB32960,JT808,YUTONG_MQTT'); expect(mocks.dailyMileage.mock.calls[0][0].get('protocols')).toBe('GB32960,JT808,YUTONG_MQTT');
@@ -100,6 +102,7 @@ test('paginates all unique vehicles when no license plate is selected', async ()
expect((await screen.findAllByText('粤A00001')).length).toBeGreaterThan(0); expect((await screen.findAllByText('粤A00001')).length).toBeGreaterThan(0);
expect(screen.getByText('第 1 / 2 页 · 共 32 辆 · 每页 20 辆')).toBeInTheDocument(); expect(screen.getByText('第 1 / 2 页 · 共 32 辆 · 每页 20 辆')).toBeInTheDocument();
expect(screen.getByText('档案口径 · 1 辆有里程')).toBeInTheDocument();
expect(mocks.vehicleCoverage.mock.calls[0][0].get('bindingStatus')).toBe('bound'); expect(mocks.vehicleCoverage.mock.calls[0][0].get('bindingStatus')).toBe('bound');
await waitFor(() => expect(mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1]?.[0].get('vins')).toContain('VIN00000000000001')); await waitFor(() => expect(mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1]?.[0].get('vins')).toContain('VIN00000000000001'));

View File

@@ -6,6 +6,7 @@ import { api } from '../../api/client';
import type { DailyMileageRow, MileageStatistics, VehicleRow } from '../../api/types'; import type { DailyMileageRow, MileageStatistics, VehicleRow } from '../../api/types';
import { downloadMileageWorkbook } from '../domain/mileageExport'; import { downloadMileageWorkbook } from '../domain/mileageExport';
import { InlineError } from '../shared/AsyncState'; import { InlineError } from '../shared/AsyncState';
import { QUERY_MEMORY } from '../queryPolicy';
const DAY = 86_400_000; const DAY = 86_400_000;
const DETAIL_LIMIT = 10_000; const DETAIL_LIMIT = 10_000;
@@ -134,9 +135,10 @@ function VehicleMultiSelect({ value, onChange }: { value: VehicleOption[]; onCha
}, [debounced]); }, [debounced]);
const candidates = useQuery({ const candidates = useQuery({
queryKey: ['mileage-vehicle-options', candidateParams.toString()], queryKey: ['mileage-vehicle-options', candidateParams.toString()],
queryFn: () => api.vehicles(candidateParams), queryFn: ({ signal }) => api.vehicles(candidateParams, signal),
enabled: open, enabled: open,
staleTime: 60_000 staleTime: 30_000,
gcTime: QUERY_MEMORY.optionGcTime
}); });
const selected = useMemo(() => new Set(value.map((vehicle) => vehicle.vin)), [value]); const selected = useMemo(() => new Set(value.map((vehicle) => vehicle.vin)), [value]);
const options = (candidates.data?.items ?? []).filter((vehicle, index, rows) => rows.findIndex((item) => item.vin === vehicle.vin) === index); const options = (candidates.data?.items ?? []).filter((vehicle, index, rows) => rows.findIndex((item) => item.vin === vehicle.vin) === index);
@@ -174,7 +176,7 @@ function SummaryRail({ data, criteria, fleetTotal }: { data?: MileageStatistics;
const days = inclusiveDays(criteria.dateFrom, criteria.dateTo); const days = inclusiveDays(criteria.dateFrom, criteria.dateTo);
const vehicleCount = criteria.vehicles.length ? data?.vehicleCount ?? 0 : fleetTotal ?? 0; const vehicleCount = criteria.vehicles.length ? data?.vehicleCount ?? 0 : fleetTotal ?? 0;
const items = [ const items = [
['查询车辆', `${vehicleCount}`, criteria.vehicles.length ? `已选择 ${criteria.vehicles.length}` : `全部车辆 · ${data?.vehicleCount ?? 0} 辆有里程`], ['已绑定主车辆', `${vehicleCount}`, criteria.vehicles.length ? `已选择 ${criteria.vehicles.length}` : `档案口径 · ${data?.vehicleCount ?? 0} 辆有里程`],
['统计天数', `${days}`, `${criteria.dateFrom}${criteria.dateTo}`], ['统计天数', `${days}`, `${criteria.dateFrom}${criteria.dateTo}`],
['区间总里程', `${formatKm(data?.periodMileageKm)} km`, `${data?.recordCount ?? 0} 条车辆日记录`], ['区间总里程', `${formatKm(data?.periodMileageKm)} km`, `${data?.recordCount ?? 0} 条车辆日记录`],
['日均里程', `${formatKm(data?.averageDailyMileageKm)} km`, '按有效车辆日平均'] ['日均里程', `${formatKm(data?.averageDailyMileageKm)} km`, '按有效车辆日平均']
@@ -225,9 +227,10 @@ export default function StatisticsPage() {
const fleetParams = useMemo(() => new URLSearchParams({ limit: String(PAGE_SIZE), offset: String((page - 1) * PAGE_SIZE), bindingStatus: 'bound' }), [page]); const fleetParams = useMemo(() => new URLSearchParams({ limit: String(PAGE_SIZE), offset: String((page - 1) * PAGE_SIZE), bindingStatus: 'bound' }), [page]);
const fleetVehicles = useQuery({ const fleetVehicles = useQuery({
queryKey: ['mileage-fleet-page', fleetParams.toString()], queryKey: ['mileage-fleet-page', fleetParams.toString()],
queryFn: () => api.vehicleCoverage(fleetParams), queryFn: ({ signal }) => api.vehicleCoverage(fleetParams, signal),
enabled: !hasVehicles, enabled: !hasVehicles,
staleTime: 60_000 staleTime: 60_000,
gcTime: QUERY_MEMORY.summaryGcTime
}); });
const displayVehicles = useMemo<VehicleOption[]>(() => hasVehicles const displayVehicles = useMemo<VehicleOption[]>(() => hasVehicles
? criteria.vehicles ? criteria.vehicles
@@ -235,8 +238,8 @@ export default function StatisticsPage() {
const statisticsParams = useMemo(() => mileageParams(criteria, -1), [criteria]); const statisticsParams = useMemo(() => mileageParams(criteria, -1), [criteria]);
const rowsCriteria = useMemo(() => ({ ...criteria, vehicles: displayVehicles }), [criteria, displayVehicles]); const rowsCriteria = useMemo(() => ({ ...criteria, vehicles: displayVehicles }), [criteria, displayVehicles]);
const rowsParams = useMemo(() => mileageParams(rowsCriteria, 0), [rowsCriteria]); const rowsParams = useMemo(() => mileageParams(rowsCriteria, 0), [rowsCriteria]);
const statistics = useQuery({ queryKey: ['mileage-statistics', statisticsParams.toString()], queryFn: () => api.mileageStatistics(statisticsParams), staleTime: 60_000, placeholderData: (previous) => previous }); const statistics = useQuery({ queryKey: ['mileage-statistics', statisticsParams.toString()], queryFn: ({ signal }) => api.mileageStatistics(statisticsParams, signal), staleTime: 60_000, gcTime: QUERY_MEMORY.summaryGcTime, placeholderData: (previous) => previous });
const mileage = useQuery({ queryKey: ['daily-mileage-query', rowsParams.toString()], queryFn: () => api.dailyMileage(rowsParams), enabled: displayVehicles.length > 0, staleTime: 60_000, placeholderData: (previous) => previous }); const mileage = useQuery({ queryKey: ['daily-mileage-query', rowsParams.toString()], queryFn: ({ signal }) => api.dailyMileage(rowsParams, signal), enabled: displayVehicles.length > 0, staleTime: 60_000, gcTime: QUERY_MEMORY.highVolumeGcTime, placeholderData: (previous) => previous });
const totals = useMemo(() => new Map((statistics.data?.ranking ?? []).map((row) => [row.vin, row.mileageKm])), [statistics.data?.ranking]); const totals = useMemo(() => new Map((statistics.data?.ranking ?? []).map((row) => [row.vin, row.mileageKm])), [statistics.data?.ranking]);
const dates = useMemo(() => rangeDates(criteria.dateFrom, criteria.dateTo), [criteria.dateFrom, criteria.dateTo]); const dates = useMemo(() => rangeDates(criteria.dateFrom, criteria.dateTo), [criteria.dateFrom, criteria.dateTo]);
const matrixRows = useMemo(() => displayVehicles.map((vehicle) => { const matrixRows = useMemo(() => displayVehicles.map((vehicle) => {
@@ -304,7 +307,7 @@ export default function StatisticsPage() {
}; };
return <div className="v2-mileage-page"> return <div className="v2-mileage-page">
<header className="v2-mileage-heading"><div><h2></h2><p></p></div><button type="button" onClick={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} disabled={refreshing}><IconRefresh />{refreshing ? '更新中' : '刷新数据'}</button></header> <header className="v2-mileage-heading"><div><h2></h2><p></p></div><button type="button" onClick={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} disabled={refreshing}><IconRefresh />{refreshing ? '更新中' : '刷新数据'}</button></header>
<section className="v2-mileage-query-panel"> <section className="v2-mileage-query-panel">
<form className="v2-mileage-filter" onSubmit={submit}> <form className="v2-mileage-filter" onSubmit={submit}>
<VehicleMultiSelect value={draft.vehicles} onChange={(vehicles) => setDraft((current) => ({ ...current, vehicles }))} /> <VehicleMultiSelect value={draft.vehicles} onChange={(vehicles) => setDraft((current) => ({ ...current, vehicles }))} />
@@ -323,6 +326,6 @@ export default function StatisticsPage() {
{!fleetVehicles.isLoading && !displayVehicles.length ? <div className="v2-mileage-empty"></div> : null} {!fleetVehicles.isLoading && !displayVehicles.length ? <div className="v2-mileage-empty"></div> : null}
<footer><span>{hasVehicles ? `已选择 ${totalVehicles} 辆车辆` : `${page} / ${totalPages} 页 · 共 ${totalVehicles} 辆 · 每页 ${PAGE_SIZE}`}{exportFeedback ? ` · ${exportFeedback}` : ''}</span>{!hasVehicles && totalVehicles ? <div><button type="button" disabled={page <= 1 || fleetVehicles.isFetching} onClick={() => setPage((current) => Math.max(1, current - 1))}></button><button type="button" disabled={page >= totalPages || fleetVehicles.isFetching} onClick={() => setPage((current) => Math.min(totalPages, current + 1))}></button></div> : null}</footer> <footer><span>{hasVehicles ? `已选择 ${totalVehicles} 辆车辆` : `${page} / ${totalPages} 页 · 共 ${totalVehicles} 辆 · 每页 ${PAGE_SIZE}`}{exportFeedback ? ` · ${exportFeedback}` : ''}</span>{!hasVehicles && totalVehicles ? <div><button type="button" disabled={page <= 1 || fleetVehicles.isFetching} onClick={() => setPage((current) => Math.max(1, current - 1))}></button><button type="button" disabled={page >= totalPages || fleetVehicles.isFetching} onClick={() => setPage((current) => Math.min(totalPages, current + 1))}></button></div> : null}</footer>
</section> </section>
<footer className="v2-mileage-evidence"><span>{statistics.data?.asOf || '—'}</span><span>{criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(' ')}</span><span> 1 </span></footer> <footer className="v2-mileage-evidence"><span>{statistics.data?.asOf || '—'}</span><span>{criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(' ')}</span><span> 1 · </span></footer>
</div>; </div>;
} }

View File

@@ -39,7 +39,7 @@ afterEach(() => { cleanup(); Object.values(mocks).forEach((mock) => mock.mockRes
test('renders a map-first replay workspace and connects stop, event, and panel interactions', async () => { test('renders a map-first replay workspace and connects stop, event, and panel interactions', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackPage /></MemoryRouter></QueryClientProvider>); const view = render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackPage /></MemoryRouter></QueryClientProvider>);
expect((await screen.findAllByText('粤A12345')).length).toBeGreaterThan(0); expect((await screen.findAllByText('粤A12345')).length).toBeGreaterThan(0);
expect(screen.getByTestId('track-map')).toHaveAttribute('data-active-index', '0'); expect(screen.getByTestId('track-map')).toHaveAttribute('data-active-index', '0');
@@ -57,4 +57,8 @@ test('renders a map-first replay workspace and connects stop, event, and panel i
await waitFor(() => expect(mocks.trackPlayback).toHaveBeenCalledTimes(1)); await waitFor(() => expect(mocks.trackPlayback).toHaveBeenCalledTimes(1));
expect(mocks.trackPlayback.mock.calls[0][0].get('maxPoints')).toBe('1600'); expect(mocks.trackPlayback.mock.calls[0][0].get('maxPoints')).toBe('1600');
expect(mocks.trackPlayback.mock.calls[0][1]).toBeInstanceOf(AbortSignal);
expect(client.getQueryCache().findAll({ queryKey: ['track-playback'] })).toHaveLength(1);
view.unmount();
await waitFor(() => expect(client.getQueryCache().findAll({ queryKey: ['track-playback'] })).toHaveLength(0));
}); });

View File

@@ -10,6 +10,7 @@ import type { TrackPlaybackEvent, TrackPlaybackResponse, VehicleRow } from '../.
import { downloadTrackCsv, formatDuration, sampledEventIndex } from '../domain/track'; import { downloadTrackCsv, formatDuration, sampledEventIndex } from '../domain/track';
import { TrackMap } from '../map/TrackMap'; import { TrackMap } from '../map/TrackMap';
import { InlineError } from '../shared/AsyncState'; import { InlineError } from '../shared/AsyncState';
import { QUERY_MEMORY } from '../queryPolicy';
const speedOptions = [0.5, 1, 2, 4] as const; const speedOptions = [0.5, 1, 2, 4] as const;
type PlaybackSpeed = (typeof speedOptions)[number]; type PlaybackSpeed = (typeof speedOptions)[number];
@@ -80,7 +81,7 @@ function VehiclePicker({ value, onChange, onSelect }: { value: string; onChange:
if (debounced) next.set('keyword', debounced); if (debounced) next.set('keyword', debounced);
return next; return next;
}, [debounced]); }, [debounced]);
const candidates = useQuery({ queryKey: ['track-vehicle-options', params.toString()], queryFn: () => api.vehicles(params), enabled: open, staleTime: 60_000 }); const candidates = useQuery({ queryKey: ['track-vehicle-options', params.toString()], queryFn: ({ signal }) => api.vehicles(params, signal), enabled: open, staleTime: 30_000, gcTime: QUERY_MEMORY.optionGcTime });
return <div className={`v2-track-vehicle-picker${open ? ' is-open' : ''}`}> return <div className={`v2-track-vehicle-picker${open ? ' is-open' : ''}`}>
<IconSearch /> <IconSearch />
@@ -192,7 +193,7 @@ export default function TrackPage() {
if (criteria.protocol) next.set('protocol', criteria.protocol); if (criteria.protocol) next.set('protocol', criteria.protocol);
return next; return next;
}, [criteria]); }, [criteria]);
const query = useQuery({ queryKey: ['track-playback', params.toString()], enabled: Boolean(criteria.keyword), queryFn: () => api.trackPlayback(params) }); const query = useQuery({ queryKey: ['track-playback', params.toString()], enabled: Boolean(criteria.keyword), queryFn: ({ signal }) => api.trackPlayback(params, signal), gcTime: QUERY_MEMORY.highVolumeGcTime });
const track = query.data; const track = query.data;
const points = track?.points ?? []; const points = track?.points ?? [];
const boundedIndex = Math.min(activeIndex, Math.max(points.length - 1, 0)); const boundedIndex = Math.min(activeIndex, Math.max(points.length - 1, 0));

View File

@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest';
import { QUERY_MEMORY } from './queryPolicy';
describe('query memory policy', () => {
it('drops high-volume route results immediately and bounds reusable small results', () => {
expect(QUERY_MEMORY.highVolumeGcTime).toBe(0);
expect(QUERY_MEMORY.optionGcTime).toBeLessThanOrEqual(30_000);
expect(QUERY_MEMORY.summaryGcTime).toBeLessThanOrEqual(60_000);
});
});

View File

@@ -0,0 +1,5 @@
export const QUERY_MEMORY = {
highVolumeGcTime: 0,
optionGcTime: 30_000,
summaryGcTime: 60_000
} as const;

View File

@@ -61,6 +61,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-search-field input { min-width: 0; flex: 1; border: 0; outline: 0; color: var(--v2-text); font-size: 12px; } .v2-search-field input { min-width: 0; flex: 1; border: 0; outline: 0; color: var(--v2-text); font-size: 12px; }
.v2-search-field.is-batch { border-color: #9fc0f7; background: #fbfdff; } .v2-search-field.is-batch { border-color: #9fc0f7; background: #fbfdff; }
.v2-search-batch-count { flex: 0 0 auto; border-radius: 999px; background: var(--v2-blue-soft); padding: 3px 7px; color: var(--v2-blue); font-size: 10px; font-weight: 700; line-height: 1; white-space: nowrap; } .v2-search-batch-count { flex: 0 0 auto; border-radius: 999px; background: var(--v2-blue-soft); padding: 3px 7px; color: var(--v2-blue); font-size: 10px; font-weight: 700; line-height: 1; white-space: nowrap; }
.v2-search-batch-count.has-missing { background: #fff4e5; color: #b45309; }
.v2-filterbar select { height: 36px; border: 1px solid #dce4ef; border-radius: 7px; background: #fff; padding: 0 30px 0 11px; color: #4d5c70; outline: 0; font-size: 12px; } .v2-filterbar select { height: 36px; border: 1px solid #dce4ef; border-radius: 7px; background: #fff; padding: 0 30px 0 11px; color: #4d5c70; outline: 0; font-size: 12px; }
.v2-primary-button, .v2-secondary-button { display: flex; height: 36px; align-items: center; justify-content: center; gap: 7px; border-radius: 7px; padding: 0 14px; cursor: pointer; font-size: 12px; font-weight: 700; } .v2-primary-button, .v2-secondary-button { display: flex; height: 36px; align-items: center; justify-content: center; gap: 7px; border-radius: 7px; padding: 0 14px; cursor: pointer; font-size: 12px; font-weight: 700; }
.v2-primary-button { border: 1px solid var(--v2-blue); background: var(--v2-blue); color: #fff; box-shadow: 0 5px 12px rgba(18,104,243,.18); } .v2-primary-button { border: 1px solid var(--v2-blue); background: var(--v2-blue); color: #fff; box-shadow: 0 5px 12px rgba(18,104,243,.18); }
@@ -88,6 +89,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-vehicle-rail > header strong { font-size: 13px; } .v2-vehicle-rail > header strong { font-size: 13px; }
.v2-vehicle-rail > header span, .v2-vehicle-rail > footer { color: var(--v2-muted); font-size: 10px; } .v2-vehicle-rail > header span, .v2-vehicle-rail > footer { color: var(--v2-muted); font-size: 10px; }
.v2-rail-search { display: flex; height: 34px; align-items: center; gap: 7px; margin: 0 9px 8px; border: 1px solid var(--v2-border); border-radius: 7px; padding: 0 9px; color: #8a98aa; font-size: 10px; } .v2-rail-search { display: flex; height: 34px; align-items: center; gap: 7px; margin: 0 9px 8px; border: 1px solid var(--v2-border); border-radius: 7px; padding: 0 9px; color: #8a98aa; font-size: 10px; }
.v2-rail-search.has-missing { border-color: #fed7aa; background: #fffaf3; color: #b45309; }
.v2-vehicle-scroll { min-height: 0; flex: 1; overflow: auto; overscroll-behavior: contain; content-visibility: auto; } .v2-vehicle-scroll { min-height: 0; flex: 1; overflow: auto; overscroll-behavior: contain; content-visibility: auto; }
.v2-vehicle-row { display: grid; width: 100%; min-height: 60px; grid-template-columns: 10px minmax(0, 1fr) auto; align-items: center; gap: 8px; border: 0; border-top: 1px solid #eef2f7; background: #fff; padding: 8px 10px; text-align: left; cursor: pointer; } .v2-vehicle-row { display: grid; width: 100%; min-height: 60px; grid-template-columns: 10px minmax(0, 1fr) auto; align-items: center; gap: 8px; border: 0; border-top: 1px solid #eef2f7; background: #fff; padding: 8px 10px; text-align: left; cursor: pointer; }
.v2-vehicle-row:hover { background: #f8fbff; } .v2-vehicle-row:hover { background: #f8fbff; }

View File

@@ -9,6 +9,7 @@ This document records verified risks, the production controls that address them,
| Risk | User-visible failure | Control | Evidence gate | | Risk | User-visible failure | Control | Evidence gate |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| A stale lazy route asset disappears during release | Navigation leaves the content area blank | Preserve the previous build's original hashed assets for one generation; isolate every route with a recoverable error boundary and one-shot reload | Request an old route asset after the symlink switch; navigate through every primary route without a blank shell | | A stale lazy route asset disappears during release | Navigation leaves the content area blank | Preserve the previous build's original hashed assets for one generation; isolate every route with a recoverable error boundary and one-shot reload | Request an old route asset after the symlink switch; navigate through every primary route without a blank shell |
| A pasted batch search temporarily shows the previous fleet | Operators may mistake stale vehicles for batch matches | Hide placeholder rows while `keywords` is changing; report matched and missing plates after the request resolves | Paste newline/comma-separated plates with a duplicate and an unknown plate; verify exact results and missing-count feedback |
| A route is first fetched only after click | Slow transitions and a longer loading flash | Deduplicated route import cache plus pointer, focus and pointer-down preload | Route module unit tests and rendered navigation loop | | A route is first fetched only after click | Slow transitions and a longer loading flash | Deduplicated route import cache plus pointer, focus and pointer-down preload | Route module unit tests and rendered navigation loop |
| Rapid map pans retain large query payloads | Heap growth and eventual interaction jank | Consume React Query `AbortSignal` and immediately garbage-collect inactive map viewport queries | Query-cache churn test keeps exactly one map payload after repeated viewport changes | | Rapid map pans retain large query payloads | Heap growth and eventual interaction jank | Consume React Query `AbortSignal` and immediately garbage-collect inactive map viewport queries | Query-cache churn test keeps exactly one map payload after repeated viewport changes |
| Superseded monitor requests continue in the background | Wasted server traffic and stale responses | Pass request cancellation signals through the API client | API client signal test and browser network/console smoke | | Superseded monitor requests continue in the background | Wasted server traffic and stale responses | Pass request cancellation signals through the API client | API client signal test and browser network/console smoke |
@@ -26,6 +27,12 @@ The navigation and progressive-loading direction follows mature observability sy
4. Reduce the shared CSS and Semi UI payload; preserve ExcelJS as an action-only dynamic import and consider moving large workbook generation off the main thread. 4. Reduce the shared CSS and Semi UI payload; preserve ExcelJS as an action-only dynamic import and consider moving large workbook generation off the main thread.
5. Add an automated release smoke that reads the previous `.release-assets` manifest and asserts every listed compatibility asset returns HTTP 200. 5. Add an automated release smoke that reads the previous `.release-assets` manifest and asserts every listed compatibility asset returns HTTP 200.
## 2026-07-15: query memory and vehicle-count semantics
Production reconciliation proved that the three visible vehicle counts represent different populations rather than a data loss: 1,035 service identities equal 1,024 bound master vehicles plus 11 realtime identities awaiting binding; 738 vehicles currently have a realtime location row and can enter the global-monitor map. The UI must name these populations explicitly instead of presenting them as interchangeable totals.
High-volume history rows, aggregated series, track playback and daily-mileage matrices consume the request `AbortSignal` and use zero inactive-cache retention. Small vehicle-option and summary results retain only a bounded 3060 second cache. This preserves responsive repeated lookups without retaining multiple large arbitrary date-window payloads after their observer is gone.
## Release evidence template ## Release evidence template
- Commit and release identifier - Commit and release identifier