refine Semi UI mileage matrix workspace

This commit is contained in:
lingniu
2026-07-18 12:41:41 +08:00
parent e5bf76ea15
commit 8aa273e579
3 changed files with 225 additions and 18 deletions

View File

@@ -8,10 +8,12 @@ import { ROUTER_FUTURE } from '../routing/routerConfig';
const mocks = vi.hoisted(() => ({ mileageStatistics: vi.fn(), dailyMileage: vi.fn(), vehicles: vi.fn(), vehicleCoverage: vi.fn() }));
const exportMocks = vi.hoisted(() => ({ createMileageExportStream: vi.fn(), appendRows: vi.fn(), finish: vi.fn(), dispose: vi.fn() }));
const layout = vi.hoisted(() => ({ mobile: false }));
vi.mock('../../api/client', () => ({ api: mocks }));
vi.mock('../domain/mileageExport', () => ({ createMileageExportStream: exportMocks.createMileageExportStream }));
vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => layout.mobile }));
afterEach(() => { cleanup(); vi.restoreAllMocks(); window.localStorage.clear(); Object.values(mocks).forEach((mock) => mock.mockReset()); Object.values(exportMocks).forEach((mock) => mock.mockReset()); });
afterEach(() => { cleanup(); vi.restoreAllMocks(); layout.mobile = false; window.localStorage.clear(); Object.values(mocks).forEach((mock) => mock.mockReset()); Object.values(exportMocks).forEach((mock) => mock.mockReset()); });
function renderPage(initialEntry = '/statistics') {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -64,6 +66,21 @@ test('renders only the desktop matrix with dates as columns and a period total',
expect(view.container.querySelectorAll('.v2-mileage-summary-card.semi-card')).toHaveLength(4);
expect(view.container.querySelector('.v2-mileage-summary-card.is-primary .v2-mileage-summary-value')).toHaveTextContent('193.3 km');
expect(view.container.querySelector('.v2-mileage-table-wrap')).toBeInTheDocument();
expect(screen.getByRole('note', { name: '里程矩阵读表说明' })).toHaveTextContent('0 km已上报、无里程增量');
expect(screen.getByRole('note', { name: '里程矩阵读表说明' })).toHaveTextContent('—无可用里程');
expect(screen.getByRole('note', { name: '里程矩阵读表说明' })).toHaveTextContent('颜色越深、里程越高');
const matrixRegion = screen.getByRole('region', { name: '车辆每日里程矩阵,可横向滚动查看日期' });
expect(matrixRegion).toHaveAttribute('tabindex', '0');
const matrixScroller = matrixRegion.querySelector<HTMLElement>('.semi-table-body');
expect(matrixScroller).toBeInTheDocument();
Object.defineProperties(matrixScroller!, {
clientWidth: { configurable: true, value: 320 },
scrollWidth: { configurable: true, value: 720 },
scrollLeft: { configurable: true, value: 0, writable: true }
});
fireEvent.keyDown(matrixRegion, { key: 'ArrowRight' });
expect(matrixScroller!.scrollLeft).toBe(96);
expect(view.container.querySelector('[aria-label="2026-07-1388.7 公里,来源 GB32960"]')).toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-table.semi-table-wrapper')).toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-table-wrap > table')).not.toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-mobile-list')).not.toBeInTheDocument();
@@ -253,6 +270,18 @@ test('lets users disable mileage sources and persists the source priority', asyn
expect(screen.getByText('来源优先级YUTONG_MQTT JT808')).toBeInTheDocument();
});
test('uses a compact bottom sheet for mileage source strategy on mobile', async () => {
layout.mobile = true;
prepareData();
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
fireEvent.click(screen.getByRole('button', { name: /数据源/ }));
expect(screen.getByRole('dialog', { name: '数据源策略配置' })).toBeInTheDocument();
expect(document.querySelector('.v2-mileage-source-sidesheet')).toHaveClass('semi-sidesheet-bottom');
expect(document.querySelector('.v2-mileage-source-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(58dvh, 500px)' });
});
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

@@ -1,7 +1,7 @@
import { IconArrowDown, IconArrowUp, IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { IconArrowDown, IconArrowUp, IconClose, IconDownload, IconInfoCircle, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { Button, Card, Empty, Input, SideSheet, Spin, Switch, Table, Tag } from '@douyinfe/semi-ui';
import { useQuery } from '@tanstack/react-query';
import { type CSSProperties, FormEvent, memo, type RefObject, useEffect, useMemo, useRef, useState } from 'react';
import { type CSSProperties, FormEvent, type KeyboardEvent, memo, type RefObject, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { DailyMileageRow, MileageStatistics, Page, VehicleRow } from '../../api/types';
@@ -17,6 +17,7 @@ import { WorkspaceMetricRail } from '../shared/WorkspaceMetricRail';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
import { monitorReturnFromParams, preserveMonitorReturn } from '../routing/monitorContext';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
const DAY = 86_400_000;
@@ -113,6 +114,7 @@ function initialCriteria(searchParams: URLSearchParams): Criteria {
function SourceStrategy({ value, onChange }: { value: MileageSourceOption[]; onChange: (sources: MileageSourceOption[]) => void }) {
const [open, setOpen] = useState(false);
const mobileLayout = useMobileLayout();
const enabled = value.filter((source) => source.enabled);
const primarySource = enabled[0];
useSideSheetA11y(open, '.v2-mileage-source-sidesheet', 'v2-mileage-source-strategy', '数据源策略配置', '关闭数据源策略');
@@ -141,7 +143,9 @@ function SourceStrategy({ value, onChange }: { value: MileageSourceOption[]; onC
className="v2-mileage-source-sidesheet"
visible={open}
aria-label="数据源策略"
width={430}
placement={mobileLayout ? 'bottom' : 'right'}
width={mobileLayout ? undefined : 430}
height={mobileLayout ? 'min(58dvh, 500px)' : undefined}
title={<div className="v2-mileage-source-title"><strong></strong><span></span></div>}
onCancel={() => setOpen(false)}
footer={<div className="v2-mileage-source-footer"><span>使</span><Button theme="solid" onClick={() => setOpen(false)}></Button></div>}
@@ -279,6 +283,26 @@ function dateLabel(date: string) {
return `${Number(month)}/${Number(day)}`;
}
function MileageMatrixGuide() {
return <aside id="v2-mileage-matrix-guide" className="v2-mileage-matrix-guide" role="note" aria-label="里程矩阵读表说明">
<span className="v2-mileage-matrix-guide-title"><IconInfoCircle /><strong></strong></span>
<span><Tag color="blue" type="light" size="small">0 km</Tag></span>
<span><Tag color="grey" type="light" size="small"></Tag></span>
<span><Tag color="blue" type="light" size="small"></Tag></span>
<span className="is-mobile-hint"> · </span>
</aside>;
}
function scrollMileageMatrixFromKeyboard(event: KeyboardEvent<HTMLDivElement>) {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
const scroller = event.currentTarget.querySelector<HTMLElement>('.semi-table-body');
if (!scroller || scroller.scrollWidth <= scroller.clientWidth) return;
const maximum = scroller.scrollWidth - scroller.clientWidth;
const next = scroller.scrollLeft + (event.key === 'ArrowRight' ? 96 : -96);
scroller.scrollLeft = Math.max(0, Math.min(maximum, next));
event.preventDefault();
}
const MileageTable = memo(function MileageTable({ rows, dates, scrollRef }: { rows: VehicleMileageMatrix[]; dates: string[]; scrollRef: RefObject<HTMLDivElement> }) {
const { columns, tableWidth } = useMemo(() => {
let maxDailyMileage = 1;
@@ -290,13 +314,15 @@ const MileageTable = memo(function MileageTable({ rows, dates, scrollRef }: { ro
{ title: '车牌', dataIndex: 'plate', className: 'is-plate', width: 120, render: (_value: string, row: VehicleMileageMatrix) => <strong>{row.plate || '未绑定'}</strong> },
...dates.map((date) => ({
title: dateLabel(date), dataIndex: date, className: 'is-number is-date', width: 96,
onHeaderCell: () => ({ title: date }),
onHeaderCell: () => ({ title: date, 'aria-label': `${date} 每日里程` }),
onCell: (row?: VehicleMileageMatrix) => {
const mileage = row?.days.get(date);
const source = row?.sources.get(date);
const intensity = mileage && mileage > 0 ? .035 + mileage / maxDailyMileage * .13 : 0;
return {
className: `is-number is-date${mileage != null ? ' is-daily' : ' is-empty'}`,
title: mileage != null ? `来源:${row?.sources.get(date) || '—'}` : undefined,
title: mileage != null ? `来源:${source || '—'}` : '无可用里程',
'aria-label': mileage != null ? `${date}${formatKm(mileage)} 公里,来源 ${source || '未知'}` : `${date},无可用里程`,
style: intensity ? { backgroundColor: `rgba(37, 99, 235, ${intensity.toFixed(3)})` } : undefined
};
},
@@ -310,7 +336,16 @@ const MileageTable = memo(function MileageTable({ rows, dates, scrollRef }: { ro
tableWidth: 120 + dates.length * 96 + 128
};
}, [dates, rows]);
return <div className="v2-mileage-table-wrap" ref={scrollRef} style={{ '--v2-mileage-mobile-table-width': `${96 + dates.length * 78 + 104}px` } as CSSProperties}>
return <div
className="v2-mileage-table-wrap"
ref={scrollRef}
role="region"
aria-label="车辆每日里程矩阵,可横向滚动查看日期"
aria-describedby="v2-mileage-matrix-guide"
tabIndex={0}
onKeyDown={scrollMileageMatrixFromKeyboard}
style={{ '--v2-mileage-mobile-table-width': `${96 + dates.length * 78 + 104}px` } as CSSProperties}
>
<Table className="v2-mileage-table" columns={columns} dataSource={rows} rowKey="vin" pagination={false} scroll={{ x: Math.max(556, tableWidth) }} />
</div>;
});
@@ -534,6 +569,7 @@ export default function StatisticsPage() {
<span><strong>{exportProgress.label}</strong><small>{exportPercent == null ? '处理中' : `${exportPercent}%`}</small></span>
<i className={exportPercent == null ? 'is-indeterminate' : ''}><b style={exportPercent == null ? undefined : { width: `${exportPercent}%` }} /></i>
</div> : null}
{!resultsLoading && displayVehicles.length ? <MileageMatrixGuide /> : null}
{resultsLoading ? <PanelLoading className="v2-mileage-loading" title="正在查询里程" description="新筛选范围返回前不会展示上一范围的数据。" /> : displayVehicles.length ? <MileageTable rows={matrixRows} dates={dates} scrollRef={tableScrollRef} /> : null}
{!resultsLoading && !displayVehicles.length ? <PanelEmpty className="v2-mileage-empty" title="当前没有可展示的车辆" description="选择车牌或调整车辆授权范围后重试。" /> : null}
<footer>{!hasVehicles && totalVehicles ? <TablePagination page={page} totalPages={totalPages} info={`${totalVehicles.toLocaleString('zh-CN')} 辆 · 每页 ${PAGE_SIZE}${exportFeedback ? ` · ${exportFeedback}` : ''}`} disabled={fleetVehicles.isFetching} onPageChange={setPage} /> : <span className="v2-table-pagination-info"> {totalVehicles.toLocaleString('zh-CN')} {exportFeedback ? ` · ${exportFeedback}` : ''}</span>}</footer>