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

@@ -6,6 +6,7 @@ import { api } from '../../api/client';
import type { HistoryDataRow, HistoryExportRequest, HistoryMetricDefinition, HistorySeriesResponse } from '../../api/types';
import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, formatSeriesGrain, parseHistoryKeywords } from '../domain/history';
import { InlineError } from '../shared/AsyncState';
import { QUERY_MEMORY } from '../queryPolicy';
function HistoryTrend({ response, category, loading, error }: { response?: HistorySeriesResponse; category: string; loading: boolean; error?: string }) {
const panels = useMemo(() => buildHistorySeriesPanels(response), [response]);
@@ -79,8 +80,8 @@ export default function HistoryPage() {
return next;
}, [criteria, keywords]);
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 seriesQuery = useQuery({ queryKey: ['history-series', seriesParams.toString()], enabled: keywords.length > 0 && criteria.category === 'location', queryFn: () => api.historySeries(seriesParams), 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: ({ signal }) => api.historySeries(seriesParams, signal), placeholderData: (previous) => previous, gcTime: QUERY_MEMORY.highVolumeGcTime });
const result = dataQuery.data;
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);

View File

@@ -17,6 +17,7 @@ const vehicles = [{
}] as VehicleRealtimeRow[];
const monitorMap = { clusters: [], points: [], total: 2 };
const fleetMapRenderSpy = vi.hoisted(() => vi.fn());
const monitorQueryFlags = vi.hoisted(() => ({ isLoading: false, isFetching: false, isPlaceholderData: false }));
vi.mock('../map/FleetMap', () => ({
FleetMap: ({ selectedVin, onSelectVin }: { selectedVin?: string; onSelectVin?: (vin: string) => void }) => {
@@ -42,14 +43,19 @@ vi.mock('../hooks/useMonitorData', () => ({
},
useMonitorData: () => ({
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 },
map: { data: monitorMap },
vehicles: { data: { items: vehicles, total: 2 }, isError: false, ...monitorQueryFlags },
map: { data: monitorMap, isPlaceholderData: monitorQueryFlags.isPlaceholderData },
selectedVehicle: { data: { items: [] } }
}),
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', () => {
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(screen.queryByRole('button', { name: '取消选择车辆' })).not.toBeInTheDocument();
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', '');
expect(screen.getByText('有实时位置')).toBeInTheDocument();
expect(screen.queryByText('接入车辆')).not.toBeInTheDocument();
fireEvent.click(firstVehicle);
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: '搜索车辆' });
fireEvent.paste(input, { clipboardData: { getData: () => '粤a12345\n粤B67890粤A12345' } });
expect(input).toHaveValue('粤A12345粤B67890');
expect(screen.getByText('已识别 2 辆')).toBeInTheDocument();
expect(screen.getByText('正在批量筛选 2 辆')).toBeInTheDocument();
expect(screen.getAllByText('已找到 2/2')).toHaveLength(3);
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
await waitFor(() => {
@@ -134,3 +141,24 @@ test('pastes, deduplicates, and submits multiple plates as one batch search', as
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 statuses = ['', 'online', 'offline', 'driving', 'idle'];
const EMPTY_VEHICLES: VehicleRealtimeRow[] = [];
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);
return data;
}, [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
? rows.find((vehicle) => vehicle.vin === selectedVin) ?? selectedVehicle.data?.items[0]
: undefined;
@@ -232,7 +257,7 @@ export default function MonitorPage() {
}}
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>
<select value={protocol} onChange={(event) => { setProtocol(event.target.value); setListOffset(0); }} aria-label="协议">
{protocols.map((item) => <option key={item} value={item}>{item || '全部协议'}</option>)}
@@ -248,7 +273,7 @@ export default function MonitorPage() {
<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?.offlineVehicles ?? offline), 'offline'],
['行驶车辆', 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 === 'map' ? <section className={`v2-monitor-workspace${selected && detailOpen ? ' is-detail-open' : ''}${selected && !detailOpen ? ' is-detail-collapsed' : ''}`}>
<div className="v2-vehicle-rail">
<header><strong></strong><span>{formatNumber(vehicles.data?.total ?? rows.length)} </span></header>
<div className="v2-rail-search"><IconSearch /><span>{searchTerms.length > 1 ? `正在批量筛选 ${searchTerms.length}` : deferredKeyword ? `正在筛选“${deferredKeyword}` : '按最新上报排序'}</span></div>
<header><strong></strong><span>{batchSearchPending ? '查询中' : `${formatNumber(vehicles.data?.total ?? visibleRows.length)}`}</span></header>
<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">
{vehicles.isLoading ? <div className="v2-list-loading"><span className="v2-spinner" /></div> : null}
{!vehicles.isLoading && rows.length === 0 ? <EmptyState /> : null}
{rows.map((vehicle) => <VehicleRow key={vehicle.vin} vehicle={vehicle} selected={vehicle.vin === selectedVin} onSelect={selectVehicle} />)}
{vehicles.isLoading || batchSearchPending ? <div className="v2-list-loading"><span className="v2-spinner" />{batchSearchPending ? `正在查找 ${searchTerms.length} 辆车` : '加载车辆'}</div> : null}
{!vehicles.isLoading && !batchSearchPending && visibleRows.length === 0 ? <EmptyState /> : null}
{visibleRows.map((vehicle) => <VehicleRow key={vehicle.vin} vehicle={vehicle} selected={vehicle.vin === selectedVin} onSelect={selectVehicle} />)}
</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>
<MemoFleetMap
vehicles={rows}
monitorMap={map.data}
vehicles={visibleRows}
monitorMap={searchTerms.length > 1 && (batchSearchPending || map.isPlaceholderData) ? undefined : map.data}
selectedVin={selectedVin || undefined}
onSelect={selectMapVehicle}
onSelectVin={selectVehicle}
@@ -303,7 +328,7 @@ export default function MonitorPage() {
<section className="v2-event-strip">
<strong></strong>
<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>
<time>{new Date().toLocaleString('zh-CN', { hour12: false })}</time>
</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 refresh = () => Promise.all([health.refetch(), readiness.refetch()]);
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}
<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?.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>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>
<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>

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.queryByRole('img', { name: '每日行驶里程趋势图' })).not.toBeInTheDocument();
expect(screen.queryByText('车辆里程排名')).not.toBeInTheDocument();
expect(screen.getByText('已绑定主车辆')).toBeInTheDocument();
expect(screen.getByText('已选择 1 辆')).toBeInTheDocument();
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('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(screen.getByText('第 1 / 2 页 · 共 32 辆 · 每页 20 辆')).toBeInTheDocument();
expect(screen.getByText('档案口径 · 1 辆有里程')).toBeInTheDocument();
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'));

View File

@@ -6,6 +6,7 @@ import { api } from '../../api/client';
import type { DailyMileageRow, MileageStatistics, VehicleRow } from '../../api/types';
import { downloadMileageWorkbook } from '../domain/mileageExport';
import { InlineError } from '../shared/AsyncState';
import { QUERY_MEMORY } from '../queryPolicy';
const DAY = 86_400_000;
const DETAIL_LIMIT = 10_000;
@@ -134,9 +135,10 @@ function VehicleMultiSelect({ value, onChange }: { value: VehicleOption[]; onCha
}, [debounced]);
const candidates = useQuery({
queryKey: ['mileage-vehicle-options', candidateParams.toString()],
queryFn: () => api.vehicles(candidateParams),
queryFn: ({ signal }) => api.vehicles(candidateParams, signal),
enabled: open,
staleTime: 60_000
staleTime: 30_000,
gcTime: QUERY_MEMORY.optionGcTime
});
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);
@@ -174,7 +176,7 @@ function SummaryRail({ data, criteria, fleetTotal }: { data?: MileageStatistics;
const days = inclusiveDays(criteria.dateFrom, criteria.dateTo);
const vehicleCount = criteria.vehicles.length ? data?.vehicleCount ?? 0 : fleetTotal ?? 0;
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}`],
['区间总里程', `${formatKm(data?.periodMileageKm)} km`, `${data?.recordCount ?? 0} 条车辆日记录`],
['日均里程', `${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 fleetVehicles = useQuery({
queryKey: ['mileage-fleet-page', fleetParams.toString()],
queryFn: () => api.vehicleCoverage(fleetParams),
queryFn: ({ signal }) => api.vehicleCoverage(fleetParams, signal),
enabled: !hasVehicles,
staleTime: 60_000
staleTime: 60_000,
gcTime: QUERY_MEMORY.summaryGcTime
});
const displayVehicles = useMemo<VehicleOption[]>(() => hasVehicles
? criteria.vehicles
@@ -235,8 +238,8 @@ export default function StatisticsPage() {
const statisticsParams = useMemo(() => mileageParams(criteria, -1), [criteria]);
const rowsCriteria = useMemo(() => ({ ...criteria, vehicles: displayVehicles }), [criteria, displayVehicles]);
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 mileage = useQuery({ queryKey: ['daily-mileage-query', rowsParams.toString()], queryFn: () => api.dailyMileage(rowsParams), enabled: displayVehicles.length > 0, 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: ({ 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 dates = useMemo(() => rangeDates(criteria.dateFrom, criteria.dateTo), [criteria.dateFrom, criteria.dateTo]);
const matrixRows = useMemo(() => displayVehicles.map((vehicle) => {
@@ -304,7 +307,7 @@ export default function StatisticsPage() {
};
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">
<form className="v2-mileage-filter" onSubmit={submit}>
<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}
<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>
<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>;
}

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 () => {
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(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));
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 { TrackMap } from '../map/TrackMap';
import { InlineError } from '../shared/AsyncState';
import { QUERY_MEMORY } from '../queryPolicy';
const speedOptions = [0.5, 1, 2, 4] as const;
type PlaybackSpeed = (typeof speedOptions)[number];
@@ -80,7 +81,7 @@ function VehiclePicker({ value, onChange, onSelect }: { value: string; onChange:
if (debounced) next.set('keyword', debounced);
return next;
}, [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' : ''}`}>
<IconSearch />
@@ -192,7 +193,7 @@ export default function TrackPage() {
if (criteria.protocol) next.set('protocol', criteria.protocol);
return next;
}, [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 points = track?.points ?? [];
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.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.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-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); }
@@ -88,6 +89,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.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-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-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; }