fix(statistics): isolate mileage query scopes

This commit is contained in:
lingniu
2026-07-16 04:59:13 +08:00
parent 25f3fa9c64
commit 6f0c15fe6d
4 changed files with 54 additions and 10 deletions

View File

@@ -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();

View File

@@ -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>

View File

@@ -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; }