feat(mileage): compact mobile results and add date presets

This commit is contained in:
lingniu
2026-07-16 12:53:32 +08:00
parent c1b4703810
commit 4877c3fe8f
3 changed files with 69 additions and 31 deletions

View File

@@ -58,6 +58,33 @@ 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('defaults to seven days and supports today and yesterday shortcuts', async () => {
prepareData();
renderPage();
const localDate = (value: Date) => new Date(value.getTime() - value.getTimezoneOffset() * 60_000).toISOString().slice(0, 10);
const today = localDate(new Date());
const yesterday = localDate(new Date(Date.now() - 86_400_000));
const sevenDaysAgo = localDate(new Date(Date.now() - 6 * 86_400_000));
await waitFor(() => expect(mocks.mileageStatistics).toHaveBeenCalled());
expect(mocks.mileageStatistics.mock.calls[0][0].get('dateFrom')).toBe(sevenDaysAgo);
expect(mocks.mileageStatistics.mock.calls[0][0].get('dateTo')).toBe(today);
fireEvent.click(screen.getByRole('button', { name: '昨天' }));
await waitFor(() => {
const params = mocks.mileageStatistics.mock.calls[mocks.mileageStatistics.mock.calls.length - 1]?.[0];
expect(params.get('dateFrom')).toBe(yesterday);
expect(params.get('dateTo')).toBe(yesterday);
});
fireEvent.click(screen.getByRole('button', { name: '今天' }));
await waitFor(() => {
const params = mocks.mileageStatistics.mock.calls[mocks.mileageStatistics.mock.calls.length - 1]?.[0];
expect(params.get('dateFrom')).toBe(today);
expect(params.get('dateTo')).toBe(today);
});
});
test('rejects overlong mileage ranges before querying or rendering a huge matrix', async () => {
prepareData();
const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2025-01-01&dateTo=2026-07-14');

View File

@@ -36,11 +36,15 @@ function localDate(value = new Date()) {
return new Date(value.getTime() - offset).toISOString().slice(0, 10);
}
function defaultWindow(days = 30) {
const end = new Date();
function defaultWindow(days = 7, endOffsetDays = 0) {
const end = new Date(Date.now() - endOffsetDays * DAY);
return { dateFrom: localDate(new Date(end.getTime() - (days - 1) * DAY)), dateTo: localDate(end) };
}
function isSameRange(criteria: Pick<Criteria, 'dateFrom' | 'dateTo'>, range: { dateFrom: string; dateTo: string }) {
return criteria.dateFrom === range.dateFrom && criteria.dateTo === range.dateTo;
}
function formatKm(value?: number) {
if (value == null || !Number.isFinite(value)) return '—';
return formatZhNumber(value, 1);
@@ -74,7 +78,7 @@ function mileageParams(criteria: Criteria, offset = 0) {
}
function initialCriteria(searchParams: URLSearchParams): Criteria {
const defaults = defaultWindow(30);
const defaults = defaultWindow();
const vins = (searchParams.get('vins') ?? '').split(',').map((vin) => vin.trim()).filter(Boolean).slice(0, MAX_SELECTED_VEHICLES);
const requestedProtocols = (searchParams.get('protocols') ?? '').split(',').filter((protocol): protocol is MileageProtocol => DEFAULT_SOURCES.some((source) => source.protocol === protocol));
let sources = DEFAULT_SOURCES.map((source) => ({ ...source }));
@@ -249,7 +253,7 @@ function dateLabel(date: string) {
function MileageTable({ rows, dates }: { rows: VehicleMileageMatrix[]; dates: string[] }) {
const mobile = useMobileMileageLayout();
if (mobile) return <div className="v2-mileage-mobile-list">{rows.map((row) => <article key={row.vin}><header><div><strong>{row.plate || '未绑定'}</strong><span>{row.vin}</span></div><b>{formatKm(row.totalMileageKm)} km<small></small></b></header><div className="v2-mileage-mobile-day-heading"><span></span><em> {dates.length} </em></div><div className="v2-mileage-mobile-days">{dates.map((date) => { const source = row.sources.get(date); return <div key={date}><time>{dateLabel(date)}</time><strong>{row.days.has(date) ? `${formatKm(row.days.get(date))} km` : '—'}</strong>{source ? <small>{source === 'YUTONG_MQTT' ? 'YUTONG' : source}</small> : null}</div>; })}</div></article>)}</div>;
if (mobile) return <div className="v2-mileage-mobile-list">{rows.map((row) => <article key={row.vin}><header><div><strong>{row.plate || '未绑定'}</strong><span>{row.vin}</span></div><b>{formatKm(row.totalMileageKm)} km<small></small></b></header><div className="v2-mileage-mobile-days" aria-label={`${row.plate || row.vin}每日里程,左右滑动查看 ${dates.length}`}>{dates.map((date) => { const source = row.sources.get(date); return <div key={date} aria-label={`${dateLabel(date)}${row.days.has(date) ? `${formatKm(row.days.get(date))}公里` : '无数据'}${source ? `,来源${source}` : ''}`}><time>{dateLabel(date)}</time><strong>{row.days.has(date) ? `${formatKm(row.days.get(date))} km` : '—'}</strong>{source ? <small>{source === 'YUTONG_MQTT' ? 'YUTONG' : source}</small> : null}</div>; })}</div></article>)}</div>;
let maxDailyMileage = 1;
for (const row of rows) {
for (const mileage of row.days.values()) maxDailyMileage = Math.max(maxDailyMileage, mileage);
@@ -337,7 +341,12 @@ export default function StatisticsPage() {
if (error) return;
setPage(1); setExportFeedback(''); setCriteria(draft); setSearchParams(mileageParams(draft, -1), { replace: true }); setFiltersCollapsed(true);
};
const setDays = (days: number) => { const range = defaultWindow(days); const next = { ...draft, ...range }; setPage(1); setExportFeedback(''); setValidationError(''); setDraft(next); setCriteria(next); setSearchParams(mileageParams(next, -1), { replace: true }); };
const setRange = (range: { dateFrom: string; dateTo: string }) => { const next = { ...draft, ...range }; setPage(1); setExportFeedback(''); setValidationError(''); setDraft(next); setCriteria(next); setSearchParams(mileageParams(next, -1), { replace: true }); };
const todayRange = defaultWindow(1);
const yesterdayRange = defaultWindow(1, 1);
const sevenDayRange = defaultWindow(7);
const thirtyDayRange = defaultWindow(30);
const ninetyDayRange = defaultWindow(90);
const refreshing = statistics.isFetching || mileage.isFetching || fleetVehicles.isFetching;
const resultsLoading = fleetVehicles.isLoading || mileage.isLoading || mileage.isPlaceholderData;
const exportPercent = exportProgress?.total
@@ -432,7 +441,7 @@ export default function StatisticsPage() {
<label><span></span><input type="date" value={draft.dateTo} min={draft.dateFrom} onChange={(event) => setDraft((current) => ({ ...current, dateTo: event.target.value }))} /></label>
<button className="v2-primary-button" type="submit"></button>
<SourceStrategy value={draft.sources} onChange={(sources) => setDraft((current) => ({ ...current, sources }))} />
<div className="v2-mileage-ranges"><span></span><button className={inclusiveDays(draft.dateFrom, draft.dateTo) === 7 ? 'is-active' : ''} type="button" onClick={() => setDays(7)}> 7 </button><button className={inclusiveDays(draft.dateFrom, draft.dateTo) === 30 ? 'is-active' : ''} type="button" onClick={() => setDays(30)}> 30 </button><button className={inclusiveDays(draft.dateFrom, draft.dateTo) === 90 ? 'is-active' : ''} type="button" onClick={() => setDays(90)}> 90 </button></div>
<div className="v2-mileage-ranges"><span></span><button className={isSameRange(draft, todayRange) ? 'is-active' : ''} type="button" onClick={() => setRange(todayRange)}></button><button className={isSameRange(draft, yesterdayRange) ? 'is-active' : ''} type="button" onClick={() => setRange(yesterdayRange)}></button><button className={isSameRange(draft, sevenDayRange) ? 'is-active' : ''} type="button" onClick={() => setRange(sevenDayRange)}> 7 </button><button className={isSameRange(draft, thirtyDayRange) ? 'is-active' : ''} type="button" onClick={() => setRange(thirtyDayRange)}> 30 </button><button className={isSameRange(draft, ninetyDayRange) ? 'is-active' : ''} type="button" onClick={() => setRange(ninetyDayRange)}> 90 </button></div>
</form>
{validationError || criteriaError ? <p className="v2-mileage-validation" role="alert">{validationError || criteriaError}</p> : null}
<SummaryRail data={statistics.data} criteria={criteria} fleetTotal={fleetVehicles.data?.total} loading={statistics.isLoading} />