refine Semi UI mileage matrix workspace
This commit is contained in:
@@ -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-13,88.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')}` }));
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user