refine Semi UI mileage filters
This commit is contained in:
@@ -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')}` }));
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -365,6 +365,9 @@ describe('V2 production entry', () => {
|
||||
expect(corePageSources.StatisticsPage).toContain('<Tag');
|
||||
expect(corePageSources.StatisticsPage).toContain('<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).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');
|
||||
|
||||
@@ -11563,3 +11563,128 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user