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(); prepareData();
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14'); renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
fireEvent.click(screen.getByRole('button', { name: /修改里程范围/ }));
fireEvent.click(screen.getByRole('button', { name: /数据源/ })); fireEvent.click(screen.getByRole('button', { name: /数据源/ }));
expect(screen.getByRole('dialog', { name: '数据源策略配置' })).toBeInTheDocument(); 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)' }); 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 () => { test('paginates all unique vehicles when no license plate is selected', async () => {
prepareData(); prepareData();
const firstPage = Array.from({ length: 20 }, (_, index) => ({ vin: `VIN${String(index + 1).padStart(14, '0')}`, plate: `粤A${String(index + 1).padStart(5, '0')}` })); 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 { formatZhNumber } from '../domain/formatters';
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState'; import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar'; import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { TablePagination } from '../shared/TablePagination'; import { TablePagination } from '../shared/TablePagination';
import { VehicleCandidateList } from '../shared/VehicleCandidateList'; import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { mergeVehicleCandidates } from '../shared/vehicleCandidates'; import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
@@ -354,6 +355,7 @@ const MileageTable = memo(function MileageTable({ rows, dates, scrollRef }: { ro
export default function StatisticsPage() { export default function StatisticsPage() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const monitorReturn = monitorReturnFromParams(searchParams); const monitorReturn = monitorReturnFromParams(searchParams);
const mobileLayout = useMobileLayout();
const [draft, setDraft] = useState<Criteria>(() => initialCriteria(searchParams)); const [draft, setDraft] = useState<Criteria>(() => initialCriteria(searchParams));
const [criteria, setCriteria] = useState<Criteria>(() => initialCriteria(searchParams)); const [criteria, setCriteria] = useState<Criteria>(() => initialCriteria(searchParams));
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -362,10 +364,12 @@ export default function StatisticsPage() {
const [exportProgress, setExportProgress] = useState<ExportProgress>(); const [exportProgress, setExportProgress] = useState<ExportProgress>();
const [validationError, setValidationError] = useState(''); const [validationError, setValidationError] = useState('');
const [filtersCollapsed, setFiltersCollapsed] = useState(true); const [filtersCollapsed, setFiltersCollapsed] = useState(true);
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
const exportControllerRef = useRef<AbortController | null>(null); const exportControllerRef = useRef<AbortController | null>(null);
const pageRef = useRef<HTMLDivElement>(null); const pageRef = useRef<HTMLDivElement>(null);
const tableScrollRef = useRef<HTMLDivElement>(null); const tableScrollRef = useRef<HTMLDivElement>(null);
const mountedRef = useRef(true); const mountedRef = useRef(true);
useSideSheetA11y(mobileFiltersOpen, '.v2-mileage-filter-sidesheet', 'v2-mileage-filter-panel', '里程范围筛选', '关闭里程范围筛选');
useEffect(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;
return () => { return () => {
@@ -435,14 +439,32 @@ export default function StatisticsPage() {
}), [displayVehicles, mileageByVin, rankingByVin]); }), [displayVehicles, mileageByVin, rankingByVin]);
const totalVehicles = hasVehicles ? criteria.vehicles.length : fleetVehicles.data?.total ?? 0; const totalVehicles = hasVehicles ? criteria.vehicles.length : fleetVehicles.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(totalVehicles / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(totalVehicles / PAGE_SIZE));
const submit = (event: FormEvent) => { const applyDraft = () => {
event.preventDefault();
const error = mileageDateRangeError(draft); const error = mileageDateRangeError(draft);
setValidationError(error); setValidationError(error);
if (error) return; if (error) return;
setPage(1); setExportFeedback(''); setCriteria(draft); setSearchParams(preserveMonitorReturn(mileageParams(draft, -1), monitorReturn), { replace: true }); setFiltersCollapsed(true); 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 todayRange = defaultWindow(1);
const yesterdayRange = defaultWindow(1, 1); const yesterdayRange = defaultWindow(1, 1);
const sevenDayRange = defaultWindow(7); const sevenDayRange = defaultWindow(7);
@@ -533,6 +555,17 @@ export default function StatisticsPage() {
exportControllerRef.current.abort(); 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}> return <div className="v2-mileage-page" ref={pageRef}>
<MonitorReturnBar /> <MonitorReturnBar />
<div className="v2-mileage-discovery-shell"> <div className="v2-mileage-discovery-shell">
@@ -545,28 +578,44 @@ export default function StatisticsPage() {
statusColor={criteria.vehicles.length ? 'blue' : 'grey'} statusColor={criteria.vehicles.length ? 'blue' : 'grey'}
meta={<span>{criteria.sources.find((source) => source.enabled)?.protocol ?? '未配置'} </span>} 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" className="v2-mileage-query-panel"
title="里程范围" title="里程范围"
description="车牌、自然日与里程数据源策略" description="车牌、自然日与里程数据源策略"
mobileSummary={criteria.vehicles.length ? `已选 ${criteria.vehicles.length} 辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)}` : `全部车辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)}`} mobileSummary={rangeSummary}
expanded={!filtersCollapsed} expanded={!filtersCollapsed}
status={`${criteria.vehicles.length ? `${criteria.vehicles.length}` : '全部授权车辆'} · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)}`} status={`${criteria.vehicles.length ? `${criteria.vehicles.length}` : '全部授权车辆'} · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)}`}
statusColor={criteria.vehicles.length ? 'blue' : 'grey'} statusColor={criteria.vehicles.length ? 'blue' : 'grey'}
collapsedLabel="修改" collapsedLabel="修改"
onToggle={() => setFiltersCollapsed((value) => !value)} onToggle={toggleFilters}
> >
<form className={`v2-mileage-filter${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}> {filterForm}
<VehicleMultiSelect value={draft.vehicles} onChange={(vehicles) => setDraft((current) => ({ ...current, vehicles }))} /> {filterValidation}
<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}
<SummaryRail data={statistics.data} criteria={criteria} fleetTotal={fleetVehicles.data?.total} loading={statistics.isLoading} /> <SummaryRail data={statistics.data} criteria={criteria} fleetTotal={fleetVehicles.data?.total} loading={statistics.isLoading} />
</WorkspaceFilterPanel> </WorkspaceFilterPanel>}
</div> </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} {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 }}> <Card className="v2-mileage-results" bodyStyle={{ padding: 0 }}>

View File

@@ -365,6 +365,9 @@ describe('V2 production entry', () => {
expect(corePageSources.StatisticsPage).toContain('<Tag'); expect(corePageSources.StatisticsPage).toContain('<Tag');
expect(corePageSources.StatisticsPage).toContain('<SideSheet'); expect(corePageSources.StatisticsPage).toContain('<SideSheet');
expect(corePageSources.StatisticsPage).toContain('v2-mileage-source-sidesheet'); expect(corePageSources.StatisticsPage).toContain('v2-mileage-source-sidesheet');
expect(corePageSources.StatisticsPage).toContain('className="v2-mileage-filter-sidesheet"');
expect(corePageSources.StatisticsPage).toContain('<MobileFilterToggle');
expect(corePageSources.StatisticsPage).toContain('placement="bottom"');
expect(corePageSources.StatisticsPage).not.toContain('v2-mileage-source-popover'); expect(corePageSources.StatisticsPage).not.toContain('v2-mileage-source-popover');
expect(corePageSources.StatisticsPage).toContain('<Table className="v2-mileage-table"'); expect(corePageSources.StatisticsPage).toContain('<Table className="v2-mileage-table"');
expect(v2Styles).toContain('.v2-mileage-table.semi-table-wrapper :is(.semi-table-row-head,.semi-table-row-cell).is-plate'); expect(v2Styles).toContain('.v2-mileage-table.semi-table-wrapper :is(.semi-table-row-head,.semi-table-row-cell).is-plate');

View File

@@ -11563,3 +11563,128 @@ button, a { -webkit-tap-highlight-color: transparent; }
font-size: 11px; font-size: 11px;
} }
} }
/* Semi UI mileage workflow: keep the mobile data canvas stable while filters open. */
.v2-mileage-filter-title {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.v2-mileage-filter-title strong {
color: #213149;
font-size: 15px;
line-height: 1.25;
}
.v2-mileage-filter-title span {
color: #7c8b9f;
font-size: 10px;
font-weight: 500;
}
.v2-mileage-filter-footer {
display: flex;
width: 100%;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.v2-mileage-filter-footer > span {
color: #7b899b;
font-size: 10px;
}
.v2-mileage-filter-footer > .semi-button {
min-width: 118px;
min-height: 38px;
border-radius: 8px;
font-weight: 700;
}
@media (max-width: 680px) {
.v2-mileage-mobile-discovery {
display: grid;
min-width: 0;
flex: 0 0 auto;
gap: 7px;
}
.v2-mileage-mobile-discovery > .v2-mobile-filter-toggle.semi-button {
min-height: 58px;
margin: 0;
}
.v2-mileage-filter-sidesheet .semi-sidesheet-inner {
width: 100vw !important;
overflow: hidden;
border-radius: 18px 18px 0 0;
background: #f4f7fb;
box-shadow: 0 -20px 56px rgba(25, 45, 72, .2);
}
.v2-mileage-filter-sidesheet .semi-sidesheet-header {
min-height: 68px;
border-bottom: 1px solid #dfe7f0;
background: rgba(255, 255, 255, .98);
padding: 11px 14px;
}
.v2-mileage-filter-sidesheet .semi-sidesheet-body {
overflow: auto;
background: #f4f7fb;
padding: 12px;
overscroll-behavior: contain;
}
.v2-mileage-filter-sidesheet .semi-sidesheet-footer {
border-top: 1px solid #dfe7f0;
background: rgba(255, 255, 255, .98);
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
}
.v2-mileage-filter-sidesheet .v2-mileage-filter {
display: grid !important;
min-height: 0;
grid-template-columns: 1fr;
gap: 12px;
border: 1px solid #dfe7f0;
border-radius: 12px;
background: #fff;
padding: 12px;
box-shadow: 0 8px 24px rgba(31, 53, 80, .06);
}
.v2-mileage-filter-sidesheet .v2-mileage-filter > label {
gap: 6px;
color: #5f7188;
font-size: 11px;
}
.v2-mileage-filter-sidesheet .v2-mileage-filter > label > .semi-input-wrapper,
.v2-mileage-filter-sidesheet .v2-mileage-source-trigger.semi-button {
height: 44px;
min-height: 44px;
border-radius: 9px;
}
.v2-mileage-filter-sidesheet .v2-mileage-selection > .semi-input-wrapper .semi-input,
.v2-mileage-filter-sidesheet .v2-mileage-filter > label > .semi-input-wrapper .semi-input {
font-size: 14px;
}
.v2-mileage-filter-sidesheet .v2-mileage-inline-submit {
display: none;
}
.v2-mileage-filter-sidesheet .v2-mileage-source-strategy {
width: 100%;
}
.v2-mileage-filter-sidesheet .v2-mileage-ranges {
height: 44px;
order: 5;
border: 1px solid #e0e7f0;
border-radius: 9px;
background: #f4f7fb;
padding: 4px;
}
.v2-mileage-filter-sidesheet .v2-mileage-ranges .semi-button {
height: 34px;
border-radius: 7px;
font-size: 10px;
}
.v2-mileage-filter-sidesheet .v2-mileage-validation {
margin: 9px 2px 0;
}
.v2-mileage-filter-footer > span {
max-width: 150px;
line-height: 1.4;
}
}