refactor: polish semi mileage workspace

This commit is contained in:
lingniu
2026-07-19 14:19:43 +08:00
parent 4da26ad3ba
commit 2d987fd185
3 changed files with 245 additions and 49 deletions

View File

@@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import StatisticsPage from './StatisticsPage';
import StatisticsPage, { mileageRangeContainsDate } from './StatisticsPage';
import { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext';
import { ROUTER_FUTURE } from '../routing/routerConfig';
@@ -37,6 +37,13 @@ function prepareData() {
mocks.vehicleCoverage.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345' }], total: 1, limit: 20, offset: 0 });
}
test('refreshes only ranges that include the current business date', () => {
expect(mileageRangeContainsDate({ dateFrom: '2026-07-13', dateTo: '2026-07-19' }, '2026-07-19')).toBe(true);
expect(mileageRangeContainsDate({ dateFrom: '2026-07-19', dateTo: '2026-07-19' }, '2026-07-19')).toBe(true);
expect(mileageRangeContainsDate({ dateFrom: '2026-07-13', dateTo: '2026-07-18' }, '2026-07-19')).toBe(false);
expect(mileageRangeContainsDate({ dateFrom: '2026-07-20', dateTo: '2026-07-20' }, '2026-07-19')).toBe(false);
});
test('renders only the desktop matrix with dates as columns and a period total', async () => {
prepareData();
const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
@@ -53,6 +60,10 @@ test('renders only the desktop matrix with dates as columns and a period total',
expect(screen.getByLabelText('里程查询工作区')).toHaveTextContent('GB32960 优先');
expect(screen.getByRole('heading', { name: '里程范围', level: 5 })).toBeInTheDocument();
expect(screen.getByText('车牌、自然日与里程数据源策略')).toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-date-range.semi-datepicker')).toBeInTheDocument();
expect(screen.getByRole('group', { name: '快捷日期范围' })).toHaveClass('semi-button-group');
expect(screen.getByPlaceholderText('开始日期')).toHaveValue('2026-07-13');
expect(screen.getByPlaceholderText('结束日期')).toHaveValue('2026-07-14');
expect(screen.getAllByText('1 辆 · 2 天').some((element) => element.closest('.semi-tag'))).toBe(true);
expect(screen.queryByText('当日起始里程')).not.toBeInTheDocument();
expect(screen.queryByText('数据来源')).not.toBeInTheDocument();

View File

@@ -1,5 +1,5 @@
import { IconArrowDown, IconArrowUp, IconClose, IconDownload, IconInfoCircle, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { Button, Card, Empty, Input, Spin, Switch, Table, Tag } from '@douyinfe/semi-ui';
import { Button, ButtonGroup, Card, DatePicker, Empty, Input, Progress, Spin, Switch, Table, Tag } from '@douyinfe/semi-ui';
import { useQuery } from '@tanstack/react-query';
import { type CSSProperties, FormEvent, type KeyboardEvent, memo, type RefObject, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
@@ -30,6 +30,7 @@ const PAGE_SIZE = 20;
const EXPORT_VEHICLE_PAGE_SIZE = 2_000;
const EXPORT_VIN_BATCH_SIZE = 50;
const MAX_MILEAGE_RANGE_DAYS = 366;
const CURRENT_DAY_REFRESH_MS = 60_000;
type VehicleOption = Pick<VehicleRow, 'vin' | 'plate'>;
type MileageProtocol = 'GB32960' | 'JT808' | 'YUTONG_MQTT';
@@ -58,6 +59,13 @@ function isSameRange(criteria: Pick<Criteria, 'dateFrom' | 'dateTo'>, range: { d
return criteria.dateFrom === range.dateFrom && criteria.dateTo === range.dateTo;
}
export function mileageRangeContainsDate(
range: { dateFrom: string; dateTo: string },
date = localDate()
) {
return Boolean(range.dateFrom && range.dateTo && range.dateFrom <= date && range.dateTo >= date);
}
function formatKm(value?: number) {
if (value == null || !Number.isFinite(value)) return '—';
return formatZhNumber(value, 1);
@@ -77,6 +85,20 @@ function mileageDateRangeError(criteria: Pick<Criteria, 'dateFrom' | 'dateTo'>)
return '';
}
function datePickerRange(dateValue?: Date | Date[] | string | string[], formattedValue?: string | string[] | Date | Date[]) {
const values = Array.isArray(formattedValue) && formattedValue.length === 2
? formattedValue
: Array.isArray(dateValue) && dateValue.length === 2 ? dateValue : [];
if (values.length !== 2) return;
const normalized = values.map((value) => {
if (value instanceof Date) return localDate(value);
if (typeof value === 'number') return localDate(new Date(value));
return String(value).slice(0, 10);
});
if (normalized.some((value) => !/^\d{4}-\d{2}-\d{2}$/.test(value))) return;
return { dateFrom: normalized[0], dateTo: normalized[1] };
}
function mileageParams(criteria: Criteria, offset = 0) {
const params = new URLSearchParams({ dateFrom: criteria.dateFrom, dateTo: criteria.dateTo });
if (criteria.vehicles.length) params.set('vins', criteria.vehicles.map((vehicle) => vehicle.vin).join(','));
@@ -441,8 +463,27 @@ export default function StatisticsPage() {
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', statisticsScope], queryFn: ({ signal }) => api.mileageStatistics(statisticsParams, signal), enabled: !criteriaError, 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: !criteriaError && displayVehicles.length > 0, staleTime: 60_000, gcTime: QUERY_MEMORY.highVolumeGcTime, placeholderData: retainPreviousPageWithinScope<Page<DailyMileageRow>>(statisticsScope, 2) });
const currentDayRange = !criteriaError && mileageRangeContainsDate(criteria);
const currentDayRefreshInterval = currentDayRange ? CURRENT_DAY_REFRESH_MS : false;
const statistics = useQuery({
queryKey: ['mileage-statistics', statisticsScope],
queryFn: ({ signal }) => api.mileageStatistics(statisticsParams, signal),
enabled: !criteriaError,
staleTime: CURRENT_DAY_REFRESH_MS,
gcTime: QUERY_MEMORY.summaryGcTime,
refetchInterval: currentDayRefreshInterval,
refetchIntervalInBackground: false
});
const mileage = useQuery<Page<DailyMileageRow>>({
queryKey: ['daily-mileage-query', statisticsScope, rowsParams.toString()],
queryFn: ({ signal }) => api.dailyMileage(rowsParams, signal),
enabled: !criteriaError && displayVehicles.length > 0,
staleTime: CURRENT_DAY_REFRESH_MS,
gcTime: QUERY_MEMORY.highVolumeGcTime,
placeholderData: retainPreviousPageWithinScope<Page<DailyMileageRow>>(statisticsScope, 2),
refetchInterval: currentDayRefreshInterval,
refetchIntervalInBackground: false
});
const dates = useMemo(() => criteriaError ? [] : rangeDates(criteria.dateFrom, criteria.dateTo), [criteria.dateFrom, criteria.dateTo, criteriaError]);
const mileageByVin = useMemo(() => {
const index = new Map<string, { plate: string; days: Map<string, number>; sources: Map<string, string> }>();
@@ -592,11 +633,27 @@ export default function StatisticsPage() {
const rangeSummary = criteria.vehicles.length ? `已选 ${criteria.vehicles.length} 辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)}` : `全部车辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)}`;
const filterForm = <form className="v2-mileage-filter" onSubmit={submit}>
<VehicleMultiSelect value={draft.vehicles} onChange={(vehicles) => setDraft((current) => ({ ...current, vehicles }))} />
<label><span></span><Input aria-label="开始日期" type="date" value={draft.dateFrom} max={draft.dateTo} onChange={(value) => setDraft((current) => ({ ...current, dateFrom: value }))} /></label>
<label><span></span><Input aria-label="结束日期" type="date" value={draft.dateTo} min={draft.dateFrom} onChange={(value) => setDraft((current) => ({ ...current, dateTo: value }))} /></label>
<label className="v2-mileage-date-field">
<span id="v2-mileage-date-range-label"></span>
<DatePicker
className="v2-mileage-date-range"
type="dateRange"
density="compact"
format="yyyy-MM-dd"
value={[draft.dateFrom, draft.dateTo]}
placeholder={['开始日期', '结束日期']}
showClear={false}
onChangeWithDateFirst
aria-labelledby="v2-mileage-date-range-label"
onChange={(value, formattedValue) => {
const range = datePickerRange(value, formattedValue);
if (range) setDraft((current) => ({ ...current, ...range }));
}}
/>
</label>
<Button className="v2-primary-button v2-mileage-inline-submit" theme="solid" htmlType="submit"></Button>
<SourceStrategy value={draft.sources} onChange={(sources) => setDraft((current) => ({ ...current, sources }))} />
<div className="v2-mileage-ranges"><span></span><Button theme={isSameRange(draft, todayRange) ? 'solid' : 'light'} onClick={() => setRange(todayRange)}></Button><Button theme={isSameRange(draft, yesterdayRange) ? 'solid' : 'light'} onClick={() => setRange(yesterdayRange)}></Button><Button theme={isSameRange(draft, sevenDayRange) ? 'solid' : 'light'} onClick={() => setRange(sevenDayRange)}> 7 </Button><Button theme={isSameRange(draft, thirtyDayRange) ? 'solid' : 'light'} onClick={() => setRange(thirtyDayRange)}> 30 </Button><Button theme={isSameRange(draft, ninetyDayRange) ? 'solid' : 'light'} onClick={() => setRange(ninetyDayRange)}> 90 </Button></div>
<div className="v2-mileage-ranges"><span></span><ButtonGroup aria-label="快捷日期范围"><Button theme={isSameRange(draft, todayRange) ? 'solid' : 'light'} onClick={() => setRange(todayRange)}></Button><Button theme={isSameRange(draft, yesterdayRange) ? 'solid' : 'light'} onClick={() => setRange(yesterdayRange)}></Button><Button theme={isSameRange(draft, sevenDayRange) ? 'solid' : 'light'} onClick={() => setRange(sevenDayRange)}> 7 </Button><Button theme={isSameRange(draft, thirtyDayRange) ? 'solid' : 'light'} onClick={() => setRange(thirtyDayRange)}> 30 </Button><Button theme={isSameRange(draft, ninetyDayRange) ? 'solid' : 'light'} onClick={() => setRange(ninetyDayRange)}> 90 </Button></ButtonGroup></div>
</form>;
const filterValidation = validationError || criteriaError ? <p className="v2-mileage-validation" role="alert">{validationError || criteriaError}</p> : null;
@@ -668,7 +725,9 @@ export default function StatisticsPage() {
/>
{exportProgress ? <div className="v2-mileage-export-progress" role="progressbar" aria-label={exportProgress.label} aria-valuemin={0} aria-valuemax={100} aria-valuenow={exportPercent}>
<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>
{exportPercent == null
? <Spin className="v2-mileage-export-spinner" size="small" />
: <Progress className="v2-mileage-export-bar" percent={exportPercent} showInfo={false} size="small" strokeLinecap="round" aria-label={`${exportProgress.label} ${exportPercent}%`} />}
</div> : null}
{!resultsLoading && displayVehicles.length ? <MileageMatrixGuide /> : null}
{resultsLoading ? <PanelLoading className="v2-mileage-loading" title="正在查询里程" description="新筛选范围返回前不会展示上一范围的数据。" /> : displayVehicles.length ? <MileageTable rows={matrixRows} dates={dates} scrollRef={tableScrollRef} /> : null}