feat(mileage): compact mobile results and add date presets
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -1914,34 +1914,36 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
:is(.v2-alert-filter,.v2-access-filter-v3,.v2-history-toolbar,.v2-mileage-filter).is-mobile-collapsed { display: none !important; }
|
||||
.v2-track-page.is-rail-collapsed { display: grid; grid-template-columns: minmax(0,1fr); }
|
||||
|
||||
.v2-mileage-page { gap: 10px; padding: 8px 8px 18px; }
|
||||
.v2-mileage-query-panel { padding: 8px; border-radius: 12px; }
|
||||
.v2-mileage-query-panel > .v2-mobile-filter-toggle { margin: 0 0 8px; }
|
||||
.v2-mileage-summary { display: grid; grid-template-columns: 1fr; gap: 8px; border: 0; background: transparent; }
|
||||
.v2-mileage-summary > .is-primary { min-height: 112px; border-radius: 10px; padding: 16px 17px 17px; }
|
||||
.v2-mileage-summary > .is-primary strong { margin: 8px 0 5px; font-size: 27px; }
|
||||
.v2-mileage-page { gap: 7px; padding: 7px 7px 14px; }
|
||||
.v2-mileage-query-panel { padding: 7px; border-radius: 11px; }
|
||||
.v2-mileage-query-panel > .v2-mobile-filter-toggle { min-height: 48px; margin: 0 0 7px; padding: 7px 10px; }
|
||||
.v2-mileage-ranges { grid-template-columns: repeat(5,minmax(0,1fr)); }
|
||||
.v2-mileage-ranges button { height: 36px; padding: 0 3px; font-size: 10px; }
|
||||
.v2-mileage-summary { display: grid; grid-template-columns: 1fr; gap: 6px; border: 0; background: transparent; }
|
||||
.v2-mileage-summary > .is-primary { min-height: 82px; border-radius: 9px; padding: 11px 14px 12px; }
|
||||
.v2-mileage-summary > .is-primary strong { margin: 4px 0 2px; font-size: 25px; }
|
||||
.v2-mileage-summary-secondary { grid-template-columns: repeat(3,minmax(0,1fr)); overflow: hidden; border: 1px solid #e1e7ef; border-radius: 10px; }
|
||||
.v2-mileage-summary-secondary article { min-height: 94px; justify-content: flex-start; border-bottom: 0 !important; padding: 13px 11px 12px; }
|
||||
.v2-mileage-summary-secondary article + article::before { inset: 13px auto 13px 0; }
|
||||
.v2-mileage-summary-secondary strong { margin: 7px 0 4px; font-size: 17px; }
|
||||
.v2-mileage-summary-secondary article { min-height: 70px; justify-content: flex-start; border-bottom: 0 !important; padding: 9px 9px 8px; }
|
||||
.v2-mileage-summary-secondary article + article::before { inset: 9px auto 9px 0; }
|
||||
.v2-mileage-summary-secondary strong { margin: 4px 0 2px; font-size: 16px; }
|
||||
.v2-mileage-summary-secondary span { overflow: hidden; font-size: 9px; line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-mileage-results { min-height: 430px; border-radius: 12px; }
|
||||
.v2-mileage-results > header { gap: 8px; padding: 12px; }
|
||||
.v2-mileage-results > header strong { font-size: 15px; }
|
||||
.v2-mileage-results > header { gap: 5px; padding: 9px 10px; }
|
||||
.v2-mileage-results > header div:first-child { width: 100%; flex-direction: row; align-items: baseline; justify-content: space-between; gap: 8px; }
|
||||
.v2-mileage-results > header strong { font-size: 14px; }
|
||||
.v2-mileage-results > header .v2-mileage-result-actions { gap: 6px; }
|
||||
.v2-mileage-results > header .v2-mileage-result-actions em { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-mileage-result-actions > button { height: 36px; padding: 0 10px; font-size: 10px; }
|
||||
.v2-mileage-mobile-list { gap: 9px; background: #f4f7fb; padding: 9px; }
|
||||
.v2-mileage-mobile-list article { overflow: hidden; border: 1px solid #dfe6ef; border-radius: 10px; background: #fff; padding: 14px 13px 13px; box-shadow: 0 2px 8px rgba(23,39,62,.035); }
|
||||
.v2-mileage-mobile-list article > header strong { font-size: 15px; }
|
||||
.v2-mileage-mobile-list article > header b { color: #135fcb; font-size: 19px; letter-spacing: -.025em; }
|
||||
.v2-mileage-mobile-list article > header b small { margin-top: 4px; font-size: 9px; }
|
||||
.v2-mileage-mobile-day-heading { display: flex; align-items: center; justify-content: space-between; margin-top: 13px; border-top: 1px solid #edf1f5; padding-top: 11px; }
|
||||
.v2-mileage-mobile-day-heading span { color: #526176; font-size: 11px; font-weight: 700; }
|
||||
.v2-mileage-mobile-day-heading em { color: #8b97a8; font-size: 9px; font-style: normal; }
|
||||
.v2-mileage-mobile-days { grid-auto-columns: 88px; gap: 6px; margin-top: 8px; padding-bottom: 2px; scroll-snap-type: x proximity; }
|
||||
.v2-mileage-mobile-days > div { min-height: 66px; border-color: #e3e9f1; border-radius: 8px; background: #f8fafc; padding: 8px 9px; scroll-snap-align: start; }
|
||||
.v2-mileage-mobile-days time { font-size: 9px; }
|
||||
.v2-mileage-mobile-days strong { margin-top: 6px; color: #26364b; font-size: 11px; }
|
||||
.v2-mileage-mobile-days small { display: block; margin-top: 4px; overflow: hidden; color: #7d8ba0; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-mileage-result-actions > button { height: 32px; padding: 0 9px; font-size: 10px; }
|
||||
.v2-mileage-mobile-list { gap: 5px; background: #f4f7fb; padding: 5px; }
|
||||
.v2-mileage-mobile-list article { overflow: hidden; border: 1px solid #dfe6ef; border-radius: 8px; background: #fff; padding: 9px 10px 8px; box-shadow: 0 1px 4px rgba(23,39,62,.03); }
|
||||
.v2-mileage-mobile-list article > header { align-items: center; }
|
||||
.v2-mileage-mobile-list article > header strong { font-size: 14px; }
|
||||
.v2-mileage-mobile-list article > header span { margin-top: 1px; font-size: 8px; }
|
||||
.v2-mileage-mobile-list article > header b { color: #135fcb; font-size: 17px; letter-spacing: -.025em; }
|
||||
.v2-mileage-mobile-list article > header b small { margin-top: 1px; font-size: 8px; }
|
||||
.v2-mileage-mobile-days { grid-auto-columns: 70px; gap: 4px; margin-top: 7px; border-top: 1px solid #edf1f5; padding-top: 6px; padding-bottom: 1px; scroll-snap-type: x proximity; }
|
||||
.v2-mileage-mobile-days > div { min-height: 38px; border: 0; border-radius: 6px; background: #f6f8fb; padding: 5px 7px; scroll-snap-align: start; }
|
||||
.v2-mileage-mobile-days time { font-size: 8px; }
|
||||
.v2-mileage-mobile-days strong { margin-top: 3px; color: #26364b; font-size: 10px; }
|
||||
.v2-mileage-mobile-days small { display: none; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user