fix(history): isolate query scope transitions

This commit is contained in:
lingniu
2026-07-16 04:36:41 +08:00
parent 6b4a0726b2
commit d95d7ab735
6 changed files with 131 additions and 10 deletions

View File

@@ -0,0 +1,78 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import type { HistoryDataResponse, HistorySeriesResponse } from '../../api/types';
import { ROUTER_FUTURE } from '../routing/routerConfig';
import HistoryPage from './HistoryPage';
const mocks = vi.hoisted(() => ({
historyMetricCatalog: vi.fn(),
historyData: vi.fn(),
historySeries: vi.fn(),
historyExports: vi.fn(),
createHistoryExport: vi.fn()
}));
vi.mock('../../api/client', () => ({ api: mocks }));
afterEach(() => {
cleanup();
Object.values(mocks).forEach((mock) => mock.mockReset());
});
const metric = { key: 'speedKmh', label: '速度', unit: 'km/h', category: 'location', valueType: 'number', defaultVisible: true };
function historyData(vin: string, plate: string, asOf: string): HistoryDataResponse {
return {
category: 'location', columns: [metric], total: 1, limit: 50, offset: 0, asOf,
summary: { resultRows: 1, vehicleCount: 1, sources: ['JT808'], queryDurationMs: 12 },
rows: [{ id: `${vin}-row`, vin, plate, protocol: 'JT808', deviceTime: '2026-07-16 04:00:00', serverTime: '2026-07-16 04:00:01', quality: 'normal', evidenceId: `${vin}-evidence`, values: { speedKmh: 32 } }]
};
}
function historySeries(vin: string, plate: string, asOf: string): HistorySeriesResponse {
return {
metrics: [metric], dateFrom: '2026-07-16T00:00:00Z', dateTo: '2026-07-16T05:00:00Z', asOf,
summary: { rawPointCount: 2, bucketCount: 2, returnedPointCount: 2, seriesCount: 1, grainSeconds: 60, targetPoints: 240, expectedBucketCount: 2, missingBucketCount: 0, queryDurationMs: 8, complete: true, evidence: 'test series' },
series: [{ vin, plate, protocol: 'JT808', metric: 'speedKmh', label: '速度', unit: 'km/h', aggregation: 'avg', points: [{ time: '2026-07-16 04:00:00', value: 30, min: 30, max: 30, count: 1 }, { time: '2026-07-16 04:01:00', value: 32, min: 32, max: 32, count: 1 }] }]
};
}
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/history?keywords=OLDVIN&dateFrom=2026-07-16T00%3A00&dateTo=2026-07-16T05%3A00']}><HistoryPage /></MemoryRouter></QueryClientProvider>);
}
test('clears stale rows, charts, and evidence as soon as the history scope changes', async () => {
let resolveNewData!: (value: HistoryDataResponse) => void;
let resolveNewSeries!: (value: HistorySeriesResponse) => void;
mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric] });
mocks.historyExports.mockResolvedValue([]);
mocks.historyData.mockImplementation((params: URLSearchParams) => params.get('keywords') === 'OLDVIN'
? Promise.resolve(historyData('OLDVIN', '旧车牌', 'old-as-of'))
: new Promise<HistoryDataResponse>((resolve) => { resolveNewData = resolve; }));
mocks.historySeries.mockImplementation((params: URLSearchParams) => params.get('keywords') === 'OLDVIN'
? Promise.resolve(historySeries('OLDVIN', '旧车牌', 'old-as-of'))
: new Promise<HistorySeriesResponse>((resolve) => { resolveNewSeries = resolve; }));
renderPage();
expect((await screen.findAllByText('旧车牌')).length).toBeGreaterThan(0);
expect(await screen.findByText('OLDVIN-evidence')).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('车牌 / VIN多台用逗号分隔'), { target: { value: 'NEWVIN' } });
fireEvent.click(screen.getByRole('button', { name: '查询' }));
expect(await screen.findByRole('status')).toHaveTextContent('正在加载当前筛选范围的历史数据');
expect(screen.queryByText('旧车牌')).not.toBeInTheDocument();
expect(screen.queryByText('OLDVIN-evidence')).not.toBeInTheDocument();
expect(screen.getByText('正在聚合时间序列…')).toBeInTheDocument();
await act(async () => {
resolveNewData(historyData('NEWVIN', '新车牌', 'new-as-of'));
resolveNewSeries(historySeries('NEWVIN', '新车牌', 'new-as-of'));
});
expect((await screen.findAllByText('新车牌')).length).toBeGreaterThan(0);
expect(await screen.findByText('NEWVIN-evidence')).toBeInTheDocument();
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
});

View File

@@ -3,10 +3,10 @@ import { IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@
import { FormEvent, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { HistoryDataRow, HistoryExportRequest, HistoryMetricDefinition, HistorySeriesResponse } from '../../api/types';
import type { HistoryDataResponse, 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';
import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
function HistoryTrend({ response, category, loading, error }: { response?: HistorySeriesResponse; category: string; loading: boolean; error?: string }) {
const panels = useMemo(() => buildHistorySeriesPanels(response), [response]);
@@ -62,7 +62,7 @@ export default function HistoryPage() {
const [offset, setOffset] = useState(0);
const [limit, setLimit] = useState(50);
const [visibleByCategory, setVisibleByCategory] = useState<Record<string, string[]>>({});
const [selectedRow, setSelectedRow] = useState<HistoryDataRow>();
const [selectedRowRef, setSelectedRowRef] = useState<{ scope: string; id: string }>();
const [density, setDensity] = useState<'compact' | 'comfortable'>('compact');
const keywords = useMemo(() => parseHistoryKeywords(criteria.keywords), [criteria.keywords]);
const params = useMemo(() => {
@@ -79,15 +79,31 @@ export default function HistoryPage() {
if (criteria.protocol) next.set('protocol', criteria.protocol);
return next;
}, [criteria, keywords]);
const dataScope = useMemo(() => {
const scope = new URLSearchParams(params);
scope.delete('limit');
scope.delete('offset');
return scope.toString();
}, [params]);
const catalogQuery = useQuery({ queryKey: ['history-metric-catalog'], queryFn: ({ signal }) => api.historyMetricCatalog(signal), staleTime: 30 * 60_000 });
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 dataQuery = useQuery<HistoryDataResponse>({
queryKey: ['history-data', dataScope, limit, offset],
enabled: keywords.length > 0,
queryFn: ({ signal }) => api.historyData(params, signal),
placeholderData: retainPreviousPageWithinScope<HistoryDataResponse>(dataScope),
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), 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);
const visibleMetrics = allMetrics.filter((metric) => visibleKeys.includes(metric.key));
useEffect(() => { setSelectedRow(result?.rows[0]); }, [result?.asOf]);
const selectedRow = selectedRowRef?.scope === dataScope ? result?.rows.find((row) => row.id === selectedRowRef.id) : undefined;
useEffect(() => {
const first = result?.rows[0];
setSelectedRowRef(first ? { scope: dataScope, id: first.id } : undefined);
}, [dataScope, result]);
const submit = (event: FormEvent) => {
event.preventDefault();
@@ -123,11 +139,11 @@ export default function HistoryPage() {
{dataQuery.isError ? <InlineError message={dataQuery.error instanceof Error ? dataQuery.error.message : '历史查询失败'} onRetry={() => dataQuery.refetch()} /> : null}
<div className="v2-history-workspace">
<div className="v2-history-main">
<div className="v2-history-summary"><div><small></small><strong>{result?.total.toLocaleString('zh-CN') ?? 0}</strong></div><div><small></small><strong>{result?.summary.vehicleCount ?? 0}</strong></div><div><small></small><strong>{result?.summary.sources.join('、') || '—'}</strong></div><div><small></small><strong>{result ? `${result.summary.queryDurationMs} ms` : '—'}</strong></div></div>
<div className="v2-history-summary"><div><small></small><strong>{result?.total.toLocaleString('zh-CN') ?? '—'}</strong></div><div><small></small><strong>{result?.summary.vehicleCount ?? '—'}</strong></div><div><small></small><strong>{result?.summary.sources.join('、') || '—'}</strong></div><div><small></small><strong>{result ? `${result.summary.queryDurationMs} ms` : '—'}</strong></div></div>
<HistoryTrend response={seriesQuery.data} category={criteria.category} loading={seriesQuery.isFetching} error={seriesQuery.isError ? (seriesQuery.error instanceof Error ? seriesQuery.error.message : '未知错误') : undefined} />
<section className={`v2-history-table-card is-${density}`}><header><strong></strong><div><button type="button"><IconSetting /></button><select value={density} onChange={(event) => setDensity(event.target.value as typeof density)}><option value="compact"></option><option value="comfortable"></option></select><button type="button" onClick={() => dataQuery.refetch()} aria-label="刷新历史数据"><IconRefresh /></button></div></header><div className="v2-history-table-scroll"><table><thead><tr><th aria-label="选择行" /><th></th><th></th><th></th><th>VIN</th><th></th>{visibleMetrics.map((metric) => <th key={metric.key}>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</th>)}<th></th><th></th></tr></thead><tbody>{result?.rows.map((row) => <tr className={selectedRow?.id === row.id ? 'is-selected' : ''} key={row.id}><td><input type="checkbox" checked={selectedRow?.id === row.id} onChange={() => setSelectedRow(selectedRow?.id === row.id ? undefined : row)} aria-label={`选择 ${row.plate || row.vin} ${row.deviceTime}`} /></td><td>{row.deviceTime}</td><td>{row.serverTime}</td><td>{row.plate || '—'}</td><td title={row.vin}>{row.vin}</td><td>{row.protocol}</td>{visibleMetrics.map((metric) => <td key={metric.key}>{formatHistoryValue(row.values[metric.key], metric)}</td>)}<td><span className={`v2-quality is-${row.quality}`}><i />{row.quality === 'normal' ? '正常' : row.quality}</span></td><td><button type="button" onClick={() => setSelectedRow(row)}></button></td></tr>)}</tbody></table>{!result?.rows.length ? <div className="v2-history-empty">{keywords.length ? '当前条件没有历史记录' : '输入车辆并查询历史数据'}</div> : null}</div><footer><span> {page} / {totalPages} {result?.total ?? 0} </span><div><button type="button" disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}></button><button type="button" disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}></button><select value={limit} onChange={(event) => { setLimit(Number(event.target.value)); setOffset(0); }}><option value="20">20 /</option><option value="50">50 /</option><option value="100">100 /</option></select></div></footer></section>
<section className={`v2-history-table-card is-${density}`}><header><strong></strong><div><button type="button"><IconSetting /></button><select value={density} onChange={(event) => setDensity(event.target.value as typeof density)}><option value="compact"></option><option value="comfortable"></option></select><button type="button" onClick={() => dataQuery.refetch()} aria-label="刷新历史数据"><IconRefresh /></button></div></header><div className="v2-history-table-scroll"><table><thead><tr><th aria-label="选择行" /><th></th><th></th><th></th><th>VIN</th><th></th>{visibleMetrics.map((metric) => <th key={metric.key}>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</th>)}<th></th><th></th></tr></thead><tbody>{result?.rows.map((row) => <tr className={selectedRow?.id === row.id ? 'is-selected' : ''} key={row.id}><td><input type="checkbox" checked={selectedRow?.id === row.id} onChange={() => setSelectedRowRef(selectedRow?.id === row.id ? undefined : { scope: dataScope, id: row.id })} aria-label={`选择 ${row.plate || row.vin} ${row.deviceTime}`} /></td><td>{row.deviceTime}</td><td>{row.serverTime}</td><td>{row.plate || '—'}</td><td title={row.vin}>{row.vin}</td><td>{row.protocol}</td>{visibleMetrics.map((metric) => <td key={metric.key}>{formatHistoryValue(row.values[metric.key], metric)}</td>)}<td><span className={`v2-quality is-${row.quality}`}><i />{row.quality === 'normal' ? '正常' : row.quality}</span></td><td><button type="button" onClick={() => setSelectedRowRef({ scope: dataScope, id: row.id })}></button></td></tr>)}</tbody></table>{dataQuery.isPending && keywords.length ? <div className="v2-history-loading" role="status"><i /></div> : !result?.rows.length ? <div className="v2-history-empty">{keywords.length ? '当前条件没有历史记录' : '输入车辆并查询历史数据'}</div> : null}</div><footer><span> {page} / {totalPages} {result?.total ?? 0} </span><div><button type="button" disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}></button><button type="button" disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}></button><select value={limit} onChange={(event) => { setLimit(Number(event.target.value)); setOffset(0); }}><option value="20">20 /</option><option value="50">50 /</option><option value="100">100 /</option></select></div></footer></section>
</div>
<aside className="v2-history-side"><EvidencePanel row={selectedRow} metrics={allMetrics} onClose={() => setSelectedRow(undefined)} /><ExportJobsPanel /></aside>
<aside className="v2-history-side"><EvidencePanel row={selectedRow} metrics={allMetrics} onClose={() => setSelectedRowRef(undefined)} /><ExportJobsPanel /></aside>
</div>
</div>;
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { QUERY_MEMORY } from './queryPolicy';
import { QUERY_MEMORY, retainPreviousPageWithinScope } from './queryPolicy';
describe('query memory policy', () => {
it('drops high-volume route results immediately and bounds reusable small results', () => {
@@ -7,4 +7,13 @@ describe('query memory policy', () => {
expect(QUERY_MEMORY.optionGcTime).toBeLessThanOrEqual(30_000);
expect(QUERY_MEMORY.summaryGcTime).toBeLessThanOrEqual(60_000);
});
it('retains placeholder rows only while paging inside the same query scope', () => {
const previous = { rows: ['page-one'] };
const placeholder = retainPreviousPageWithinScope<typeof previous>('vehicle=A&date=2026-07-16');
expect(placeholder(previous, { queryKey: ['history-data', 'vehicle=A&date=2026-07-16', 50, 50] })).toBe(previous);
expect(placeholder(previous, { queryKey: ['history-data', 'vehicle=B&date=2026-07-16', 50, 0] })).toBeUndefined();
expect(placeholder(previous)).toBeUndefined();
});
});

View File

@@ -3,3 +3,7 @@ export const QUERY_MEMORY = {
optionGcTime: 30_000,
summaryGcTime: 60_000
} as const;
export function retainPreviousPageWithinScope<T>(scope: string) {
return (previous: T | undefined, previousQuery?: { queryKey: readonly unknown[] }) => previousQuery?.queryKey[1] === scope ? previous : undefined;
}

View File

@@ -554,6 +554,8 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-quality { display: inline-flex; align-items: center; gap: 5px; }
.v2-quality i { width: 6px; height: 6px; border-radius: 50%; background: var(--v2-green); }
.v2-quality:not(.is-normal) i { background: var(--v2-orange); }
.v2-history-loading { position: sticky; left: 0; display: flex; min-height: 90px; align-items: center; justify-content: center; gap: 7px; color: #607086; font-size: 9px; }
.v2-history-loading i { width: 13px; height: 13px; border: 2px solid #cfe0fb; border-top-color: var(--v2-blue); border-radius: 50%; animation: v2-spin .8s linear infinite; }
.v2-history-empty { position: sticky; left: 0; display: flex; min-height: 90px; align-items: center; justify-content: center; color: var(--v2-muted); font-size: 9px; }
.v2-history-table-card > footer { display: flex; min-height: 38px; align-items: center; justify-content: space-between; border-top: 1px solid var(--v2-border); padding: 0 10px; color: #657286; font-size: 8px; }
.v2-history-table-card > footer div { display: flex; gap: 5px; }

View File

@@ -2,6 +2,18 @@
This document records verified risks, the production controls that address them, and the next evidence to collect. It is intentionally operational: a passing build alone is not proof that the browser application is production-ready.
## 2026-07-16: scope-safe history transitions
The history page previously used every successful detail and trend response as placeholder data for the next query key. Changing the vehicle, time window, protocol or data category could therefore leave the old table, chart legend and selected-row evidence visible under the new filters until both replacement requests completed. Besides retaining the old high-cardinality payload during the transition, this could make an operator attribute evidence to the wrong search scope.
Placeholder reuse is now limited to pagination inside an identical history scope. A scope change immediately removes the old detail and trend payloads, resets the evidence panel and displays an explicit loading state; same-scope page changes retain the last page to avoid layout jumps. Selected evidence stores only the scope and row ID instead of a complete row object. TanStack Query documents previous-data placeholders specifically as a pagination technique, while Elastic Discover treats the query, filters and time range as the search context that defines the displayed result:
- <https://tanstack.com/query/v5/docs/framework/react/guides/paginated-queries>
- <https://tanstack.com/query/v5/docs/framework/react/guides/placeholder-query-data>
- <https://www.elastic.co/docs/explore-analyze/discover>
The regression suite holds the replacement requests open after a scope change and proves that the old plate, trend and evidence ID are absent while the new loading state is visible. A query-policy test independently proves that page placeholders survive only when the scope key is unchanged.
## 2026-07-16: session-scoped client cache
Logout previously removed only the bearer token. TanStack Query's inactive query results and mutation records could therefore remain in browser memory for their normal lifecycle and be reused after another operator logged in on the same workstation. An expired token also left the current route and its cached vehicle data visible while protected requests repeatedly returned 401.