refine Semi UI mileage filters

This commit is contained in:
lingniu
2026-07-18 22:41:46 +08:00
parent b465d1ff7a
commit 2824c08eb6
4 changed files with 221 additions and 16 deletions

View File

@@ -279,6 +279,7 @@ test('uses a compact bottom sheet for mileage source strategy on mobile', async
prepareData();
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
fireEvent.click(screen.getByRole('button', { name: /修改里程范围/ }));
fireEvent.click(screen.getByRole('button', { name: /数据源/ }));
expect(screen.getByRole('dialog', { name: '数据源策略配置' })).toBeInTheDocument();
@@ -286,6 +287,33 @@ test('uses a compact bottom sheet for mileage source strategy on mobile', async
expect(document.querySelector('.v2-mileage-source-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(58dvh, 500px)' });
});
test('opens mobile mileage filters in a bottom task sheet without displacing the data canvas', async () => {
layout.mobile = true;
prepareData();
const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
expect(await screen.findByText('车辆每日里程')).toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-mobile-discovery')).toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-query-panel')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog', { name: '里程范围筛选' })).not.toBeInTheDocument();
expect(screen.queryByRole('textbox', { name: '搜索车牌' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /修改里程范围/ }));
expect(await screen.findByRole('dialog', { name: '里程范围筛选' })).toBeInTheDocument();
expect(document.querySelector('.v2-mileage-filter-sidesheet')).toHaveClass('semi-sidesheet-bottom');
expect(document.querySelector('.v2-mileage-filter-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(82dvh, 690px)' });
expect(screen.getByRole('textbox', { name: '搜索车牌' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '应用并查询' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '关闭里程范围筛选' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '关闭里程范围筛选' }));
expect(screen.getByRole('dialog', { name: '里程范围筛选' })).toHaveClass('semi-sidesheet-animation-content_hide_bottom');
expect(screen.getByText('车辆每日里程')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /修改里程范围/ })).toHaveAttribute('aria-expanded', 'false');
});
test('paginates all unique vehicles when no license plate is selected', async () => {
prepareData();
const firstPage = Array.from({ length: 20 }, (_, index) => ({ vin: `VIN${String(index + 1).padStart(14, '0')}`, plate: `粤A${String(index + 1).padStart(5, '0')}` }));

View File

@@ -9,6 +9,7 @@ import { createMileageExportStream, type MileageExportStream } from '../domain/m
import { formatZhNumber } from '../domain/formatters';
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { TablePagination } from '../shared/TablePagination';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
@@ -354,6 +355,7 @@ const MileageTable = memo(function MileageTable({ rows, dates, scrollRef }: { ro
export default function StatisticsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const monitorReturn = monitorReturnFromParams(searchParams);
const mobileLayout = useMobileLayout();
const [draft, setDraft] = useState<Criteria>(() => initialCriteria(searchParams));
const [criteria, setCriteria] = useState<Criteria>(() => initialCriteria(searchParams));
const [page, setPage] = useState(1);
@@ -362,10 +364,12 @@ export default function StatisticsPage() {
const [exportProgress, setExportProgress] = useState<ExportProgress>();
const [validationError, setValidationError] = useState('');
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
const exportControllerRef = useRef<AbortController | null>(null);
const pageRef = useRef<HTMLDivElement>(null);
const tableScrollRef = useRef<HTMLDivElement>(null);
const mountedRef = useRef(true);
useSideSheetA11y(mobileFiltersOpen, '.v2-mileage-filter-sidesheet', 'v2-mileage-filter-panel', '里程范围筛选', '关闭里程范围筛选');
useEffect(() => {
mountedRef.current = true;
return () => {
@@ -435,14 +439,32 @@ export default function StatisticsPage() {
}), [displayVehicles, mileageByVin, rankingByVin]);
const totalVehicles = hasVehicles ? criteria.vehicles.length : fleetVehicles.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(totalVehicles / PAGE_SIZE));
const submit = (event: FormEvent) => {
event.preventDefault();
const applyDraft = () => {
const error = mileageDateRangeError(draft);
setValidationError(error);
if (error) return;
setPage(1); setExportFeedback(''); setCriteria(draft); setSearchParams(preserveMonitorReturn(mileageParams(draft, -1), monitorReturn), { replace: true }); setFiltersCollapsed(true);
};
const setRange = (range: { dateFrom: string; dateTo: string }) => { const next = { ...draft, ...range }; setPage(1); setExportFeedback(''); setValidationError(''); setDraft(next); setCriteria(next); setSearchParams(preserveMonitorReturn(mileageParams(next, -1), monitorReturn), { replace: true }); };
const submit = (event: FormEvent) => { event.preventDefault(); applyDraft(); };
const setRange = (range: { dateFrom: string; dateTo: string }) => { const next = { ...draft, ...range }; setPage(1); setExportFeedback(''); setValidationError(''); setDraft(next); setCriteria(next); setSearchParams(preserveMonitorReturn(mileageParams(next, -1), monitorReturn), { replace: true }); setFiltersCollapsed(true); };
const closeMobileFilters = () => {
setDraft(criteria);
setValidationError('');
setFiltersCollapsed(true);
};
const toggleFilters = () => {
if (!mobileLayout) {
setFiltersCollapsed((value) => !value);
return;
}
if (mobileFiltersOpen) {
closeMobileFilters();
return;
}
setDraft(criteria);
setValidationError('');
setFiltersCollapsed(false);
};
const todayRange = defaultWindow(1);
const yesterdayRange = defaultWindow(1, 1);
const sevenDayRange = defaultWindow(7);
@@ -533,6 +555,17 @@ export default function StatisticsPage() {
exportControllerRef.current.abort();
};
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>
<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>
</form>;
const filterValidation = validationError || criteriaError ? <p className="v2-mileage-validation" role="alert">{validationError || criteriaError}</p> : null;
return <div className="v2-mileage-page" ref={pageRef}>
<MonitorReturnBar />
<div className="v2-mileage-discovery-shell">
@@ -545,28 +578,44 @@ export default function StatisticsPage() {
statusColor={criteria.vehicles.length ? 'blue' : 'grey'}
meta={<span>{criteria.sources.find((source) => source.enabled)?.protocol ?? '未配置'} </span>}
/>
<WorkspaceFilterPanel
{mobileLayout ? <div className="v2-mileage-mobile-discovery">
<MobileFilterToggle
title="里程范围"
summary={rangeSummary}
expanded={mobileFiltersOpen}
expandedLabel="关闭"
collapsedLabel="修改"
onToggle={toggleFilters}
/>
<SummaryRail data={statistics.data} criteria={criteria} fleetTotal={fleetVehicles.data?.total} loading={statistics.isLoading} />
<SideSheet
className="v2-mileage-filter-sidesheet"
visible={mobileFiltersOpen}
placement="bottom"
height="min(82dvh, 690px)"
aria-label="里程范围筛选"
title={<div className="v2-mileage-filter-title"><strong></strong><span></span></div>}
onCancel={closeMobileFilters}
footer={<div className="v2-mileage-filter-footer"><span></span><Button theme="solid" onClick={applyDraft}></Button></div>}
>
{filterForm}
{filterValidation}
</SideSheet>
</div> : <WorkspaceFilterPanel
className="v2-mileage-query-panel"
title="里程范围"
description="车牌、自然日与里程数据源策略"
mobileSummary={criteria.vehicles.length ? `已选 ${criteria.vehicles.length} 辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)}` : `全部车辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)}`}
mobileSummary={rangeSummary}
expanded={!filtersCollapsed}
status={`${criteria.vehicles.length ? `${criteria.vehicles.length}` : '全部授权车辆'} · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)}`}
statusColor={criteria.vehicles.length ? 'blue' : 'grey'}
collapsedLabel="修改"
onToggle={() => setFiltersCollapsed((value) => !value)}
onToggle={toggleFilters}
>
<form className={`v2-mileage-filter${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} 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>
<Button className="v2-primary-button" 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>
</form>
{validationError || criteriaError ? <p className="v2-mileage-validation" role="alert">{validationError || criteriaError}</p> : null}
{filterForm}
{filterValidation}
<SummaryRail data={statistics.data} criteria={criteria} fleetTotal={fleetVehicles.data?.total} loading={statistics.isLoading} />
</WorkspaceFilterPanel>
</WorkspaceFilterPanel>}
</div>
{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}
<Card className="v2-mileage-results" bodyStyle={{ padding: 0 }}>