fix(statistics): isolate mileage query scopes
This commit is contained in:
@@ -36,8 +36,8 @@ test('renders only the desktop matrix with dates as columns and a period total',
|
||||
prepareData();
|
||||
const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
|
||||
expect(await screen.findByText('车辆每日里程')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('区间总里程').length).toBeGreaterThan(1);
|
||||
expect((await screen.findAllByText('193.3 km')).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('区间总里程').length).toBeGreaterThan(1);
|
||||
expect(screen.getAllByText('104.6 km').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('88.7 km').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('7/13').length).toBeGreaterThan(0);
|
||||
@@ -56,6 +56,25 @@ test('renders only the desktop matrix with dates as columns and a period total',
|
||||
expect(mocks.dailyMileage.mock.calls[0][0].get('protocols')).toBe('GB32960,JT808,YUTONG_MQTT');
|
||||
});
|
||||
|
||||
test('removes the previous mileage scope while a new date range is loading', async () => {
|
||||
prepareData();
|
||||
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
|
||||
expect((await screen.findAllByText('193.3 km')).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('104.6 km').length).toBeGreaterThan(0);
|
||||
await waitFor(() => expect(mocks.dailyMileage).toHaveBeenCalledTimes(1));
|
||||
|
||||
mocks.mileageStatistics.mockImplementation(() => new Promise(() => undefined));
|
||||
mocks.dailyMileage.mockImplementation(() => new Promise(() => undefined));
|
||||
fireEvent.click(screen.getByRole('button', { name: '近 7 天' }));
|
||||
|
||||
expect(await screen.findByText('正在查询里程')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('正在按新筛选范围查询').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('193.3 km')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('104.6 km')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('88.7 km')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText('2026-07-10 至 2026-07-16').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('mounts only the mobile mileage cards and removes the viewport listener', async () => {
|
||||
prepareData();
|
||||
const addEventListener = vi.fn();
|
||||
|
||||
@@ -3,10 +3,10 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { DailyMileageRow, MileageStatistics, VehicleRow } from '../../api/types';
|
||||
import type { DailyMileageRow, MileageStatistics, Page, VehicleRow } from '../../api/types';
|
||||
import { downloadMileageWorkbook } from '../domain/mileageExport';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { QUERY_MEMORY } from '../queryPolicy';
|
||||
import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
|
||||
|
||||
const DAY = 86_400_000;
|
||||
const DETAIL_LIMIT = 10_000;
|
||||
@@ -184,10 +184,15 @@ function VehicleMultiSelect({ value, onChange }: { value: VehicleOption[]; onCha
|
||||
</label>;
|
||||
}
|
||||
|
||||
function SummaryRail({ data, criteria, fleetTotal }: { data?: MileageStatistics; criteria: Criteria; fleetTotal?: number }) {
|
||||
function SummaryRail({ data, criteria, fleetTotal, loading }: { data?: MileageStatistics; criteria: Criteria; fleetTotal?: number; loading: boolean }) {
|
||||
const days = inclusiveDays(criteria.dateFrom, criteria.dateTo);
|
||||
const vehicleCount = criteria.vehicles.length ? data?.vehicleCount ?? 0 : fleetTotal ?? 0;
|
||||
const items = [
|
||||
const items = loading ? [
|
||||
['已绑定主车辆', '—', '正在按新筛选范围查询'],
|
||||
['统计天数', `${days} 天`, `${criteria.dateFrom} 至 ${criteria.dateTo}`],
|
||||
['区间总里程', '—', '正在计算区间汇总'],
|
||||
['日均里程', '—', '正在计算有效车辆日']
|
||||
] : [
|
||||
['已绑定主车辆', `${vehicleCount} 辆`, criteria.vehicles.length ? `已选择 ${criteria.vehicles.length} 辆` : `档案口径 · ${data?.vehicleCount ?? 0} 辆有里程`],
|
||||
['统计天数', `${days} 天`, `${criteria.dateFrom} 至 ${criteria.dateTo}`],
|
||||
['区间总里程', `${formatKm(data?.periodMileageKm)} km`, `${data?.recordCount ?? 0} 条车辆日记录`],
|
||||
@@ -272,10 +277,11 @@ export default function StatisticsPage() {
|
||||
? criteria.vehicles
|
||||
: (fleetVehicles.data?.items ?? []).map((vehicle) => ({ vin: vehicle.vin, plate: vehicle.plate })), [criteria.vehicles, fleetVehicles.data?.items, hasVehicles]);
|
||||
const statisticsParams = useMemo(() => mileageParams(criteria, -1), [criteria]);
|
||||
const statisticsScope = statisticsParams.toString();
|
||||
const rowsCriteria = useMemo(() => ({ ...criteria, vehicles: displayVehicles }), [criteria, displayVehicles]);
|
||||
const rowsParams = useMemo(() => mileageParams(rowsCriteria, 0), [rowsCriteria]);
|
||||
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 statistics = useQuery({ queryKey: ['mileage-statistics', statisticsScope], queryFn: ({ signal }) => api.mileageStatistics(statisticsParams, signal), staleTime: 60_000, gcTime: QUERY_MEMORY.summaryGcTime });
|
||||
const mileage = useQuery<Page<DailyMileageRow>>({ queryKey: ['daily-mileage-query', statisticsScope, rowsParams.toString()], queryFn: ({ signal }) => api.dailyMileage(rowsParams, signal), enabled: displayVehicles.length > 0, staleTime: 60_000, gcTime: QUERY_MEMORY.highVolumeGcTime, placeholderData: retainPreviousPageWithinScope<Page<DailyMileageRow>>(statisticsScope, 2) });
|
||||
const dates = useMemo(() => rangeDates(criteria.dateFrom, criteria.dateTo), [criteria.dateFrom, criteria.dateTo]);
|
||||
const mileageByVin = useMemo(() => {
|
||||
const index = new Map<string, { plate: string; days: Map<string, number>; sources: Map<string, string> }>();
|
||||
@@ -305,6 +311,7 @@ export default function StatisticsPage() {
|
||||
const submit = (event: FormEvent) => { event.preventDefault(); setPage(1); setExportFeedback(''); setCriteria(draft); setSearchParams(mileageParams(draft, -1), { replace: true }); };
|
||||
const setDays = (days: number) => { const range = defaultWindow(days); const next = { ...draft, ...range }; setPage(1); setExportFeedback(''); setDraft(next); setCriteria(next); setSearchParams(mileageParams(next, -1), { replace: true }); };
|
||||
const refreshing = statistics.isFetching || mileage.isFetching || fleetVehicles.isFetching;
|
||||
const resultsLoading = fleetVehicles.isLoading || mileage.isLoading || mileage.isPlaceholderData;
|
||||
const exportPercent = exportProgress?.total
|
||||
? Math.min(100, Math.round((exportProgress.completed ?? 0) / exportProgress.total * 100))
|
||||
: undefined;
|
||||
@@ -397,7 +404,7 @@ export default function StatisticsPage() {
|
||||
<SourceStrategy value={draft.sources} onChange={(sources) => setDraft((current) => ({ ...current, sources }))} />
|
||||
<div className="v2-mileage-ranges"><span>快捷范围</span><button type="button" onClick={() => setDays(7)}>近 7 天</button><button type="button" onClick={() => setDays(30)}>近 30 天</button><button type="button" onClick={() => setDays(90)}>近 90 天</button></div>
|
||||
</form>
|
||||
<SummaryRail data={statistics.data} criteria={criteria} fleetTotal={fleetVehicles.data?.total} />
|
||||
<SummaryRail data={statistics.data} criteria={criteria} fleetTotal={fleetVehicles.data?.total} loading={statistics.isLoading} />
|
||||
</section>
|
||||
{statistics.isError || mileage.isError || fleetVehicles.isError ? <InlineError message={(statistics.error ?? mileage.error ?? fleetVehicles.error) instanceof Error ? (statistics.error ?? mileage.error ?? fleetVehicles.error as Error).message : '里程数据加载失败'} onRetry={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} /> : null}
|
||||
<section className="v2-mileage-results">
|
||||
@@ -406,8 +413,8 @@ export default function StatisticsPage() {
|
||||
<span><strong>{exportProgress.label}</strong><small>{exportPercent == null ? '处理中' : `${exportPercent}%`}</small></span>
|
||||
<i className={exportPercent == null ? 'is-indeterminate' : ''}><b style={exportPercent == null ? undefined : { width: `${exportPercent}%` }} /></i>
|
||||
</div> : null}
|
||||
<MileageTable rows={matrixRows} dates={dates} />
|
||||
{!fleetVehicles.isLoading && !displayVehicles.length ? <div className="v2-mileage-empty">当前没有可展示的车辆</div> : null}
|
||||
{resultsLoading ? <div className="v2-mileage-loading" role="status" aria-live="polite"><span className="v2-spinner" /><div><strong>正在查询里程</strong><small>新筛选范围返回前不会展示上一范围的数据</small></div></div> : <MileageTable rows={matrixRows} dates={dates} />}
|
||||
{!resultsLoading && !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>
|
||||
|
||||
@@ -1070,6 +1070,10 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-mileage-results > footer button:hover:not(:disabled) { border-color: #a9bdd7; background: #f5f8fc; color: #315f9f; }
|
||||
.v2-mileage-results > footer button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.v2-mileage-mobile-list { display: none; }
|
||||
.v2-mileage-loading { display: flex; min-height: 210px; flex: 1; align-items: center; justify-content: center; gap: 10px; color: #52637a; }
|
||||
.v2-mileage-loading > div { display: flex; flex-direction: column; gap: 3px; }
|
||||
.v2-mileage-loading strong { color: #2c3d54; font-size: 12px; }
|
||||
.v2-mileage-loading small { color: #8491a3; font-size: 9px; }
|
||||
.v2-mileage-empty { display: grid; min-height: 210px; flex: 1; place-items: center; color: #8290a3; font-size: 12px; }
|
||||
.v2-mileage-evidence { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px 16px; color: var(--v2-muted); font-size: 9px; }
|
||||
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
|
||||
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 mileage queries
|
||||
|
||||
Mileage Statistics previously reused every successful summary and daily-mileage response as placeholder data for the next query key. After applying another vehicle, date range or source strategy, the new controls and column dates appeared immediately while the summary cards and cells could still contain values from the preceding scope. That visual mismatch could make an operator attribute mileage to the wrong period.
|
||||
|
||||
The page now treats vehicle selection, date range and enabled source priority as one semantic scope. A new scope removes the preceding summary and matrix and renders an explicit in-panel loading state until the replacement result arrives. Daily rows may be retained only while paging the bound fleet inside the same scope; that placeholder is hidden behind the loading state so old page values cannot be read as the new page. Same-key manual refresh continues displaying the current result because it is not a scope transition.
|
||||
|
||||
This follows TanStack Query's definition of placeholder data as observer-level temporary data and applies its previous-data pattern only to safe pagination. It also follows Elastic's dashboard model where query, filters and global time range define the visible dataset:
|
||||
|
||||
- <https://tanstack.com/query/v5/docs/framework/react/reference/useQuery>
|
||||
- <https://tanstack.com/query/v5/docs/framework/react/guides/paginated-queries>
|
||||
- <https://www.elastic.co/docs/explore-analyze/dashboards/using>
|
||||
|
||||
A delayed-response component test holds both replacement requests open after a date-range change and proves that the old period total and daily cells disappear while the new date range and loading boundary are visible.
|
||||
|
||||
## 2026-07-16: scope-safe monitor batch search
|
||||
|
||||
The monitor already accepts copied plate rows from Excel or text, normalizes separators and duplicates, and submits one bounded batch parameter. Its workspace placeholder, however, previously ignored the semantic filter boundary: changing plates, protocol or status could momentarily render the preceding vehicles under the new controls. Conversely, treating every workspace key change as a new scope would blank the map during ordinary pan and zoom.
|
||||
|
||||
Reference in New Issue
Block a user