refactor: polish semi mileage workspace
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user