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>

View File

@@ -11438,7 +11438,7 @@
.v2-mileage-filter > label {
gap: 5px;
color: #607187;
font-size: 11px;
font-size: 12px;
font-weight: 650;
}
@@ -11467,7 +11467,7 @@
.v2-mileage-selection > .semi-input-wrapper .semi-input,
.v2-mileage-filter > label > .semi-input-wrapper .semi-input {
color: #2d4058;
font-size: 12px;
font-size: 13px;
}
.v2-mileage-filter > .v2-primary-button.semi-button {
@@ -11485,7 +11485,7 @@
border-radius: 8px;
background: #f8fbff;
color: #49647f;
font-size: 11px;
font-size: 12px;
}
.v2-mileage-source-trigger.semi-button:hover,
@@ -11497,7 +11497,7 @@
.v2-mileage-source-trigger em {
color: #3d6594;
font-size: 9px;
font-size: 10px;
}
.v2-mileage-ranges {
@@ -11584,13 +11584,13 @@
.v2-mileage-results .v2-workspace-panel-copy > h5.semi-typography {
color: #23384f;
font-size: 15px;
font-size: 16px;
}
.v2-mileage-results .v2-workspace-panel-copy > .semi-typography,
.v2-mileage-results .v2-workspace-result-meta {
color: #748499;
font-size: 10px;
font-size: 11px;
}
.v2-mileage-result-actions > .semi-button {
@@ -11600,10 +11600,63 @@
font-weight: 650;
}
.v2-mileage-matrix-guide {
display: flex;
min-height: 36px;
overflow-x: auto;
align-items: center;
gap: 14px;
border-bottom: 1px solid #e2e9f1;
background: linear-gradient(90deg, #f9fbfe 0%, #fff 100%);
padding: 5px 14px;
color: #6c7d92;
font-size: 11px;
line-height: 1.25;
scrollbar-width: none;
white-space: nowrap;
}
.v2-mileage-matrix-guide::-webkit-scrollbar {
display: none;
}
.v2-mileage-matrix-guide > span {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 5px;
}
.v2-mileage-matrix-guide-title {
color: #385a80;
}
.v2-mileage-matrix-guide-title .semi-icon {
color: #1268df;
font-size: 14px;
}
.v2-mileage-matrix-guide .semi-tag {
min-width: 36px;
justify-content: center;
border-radius: 999px;
font-size: 9px;
font-weight: 700;
}
.v2-mileage-matrix-guide .is-mobile-hint {
display: none;
}
.v2-mileage-table-wrap {
scrollbar-color: #9eb0c4 #f2f5f9;
scrollbar-gutter: stable;
scrollbar-width: auto;
outline: none;
}
.v2-mileage-table-wrap:focus-visible {
box-shadow: inset 0 0 0 2px rgba(18, 104, 223, .72);
}
.v2-mileage-table-wrap::-webkit-scrollbar {
@@ -11637,14 +11690,14 @@
border-bottom-color: #dde6f0;
background: #f6f8fb;
color: #5a6d84;
font-size: 11px;
font-size: 12px;
}
.v2-mileage-table .semi-table-tbody > .semi-table-row > .semi-table-row-cell {
height: 52px;
border-bottom-color: #e9eef4;
color: #43566d;
font-size: 12px;
font-size: 13px;
}
.v2-mileage-table .semi-table-tbody > .semi-table-row:hover {
@@ -11653,7 +11706,7 @@
.v2-mileage-table .semi-table-row-cell.is-plate strong {
color: #243950;
font-size: 12px;
font-size: 13px;
font-weight: 720;
}
@@ -11670,7 +11723,7 @@
.v2-mileage-table .semi-table-row-cell.is-period {
color: #0e62cf;
font-size: 12px;
font-size: 13px;
font-weight: 780;
}
@@ -11686,7 +11739,7 @@
justify-content: flex-start;
gap: 0;
color: #8290a2;
font-size: 9px;
font-size: 10px;
}
.v2-mileage-evidence > span {
@@ -11818,6 +11871,95 @@
font-size: 9px;
}
.v2-mileage-matrix-guide {
min-height: 34px;
gap: 11px;
padding: 4px 9px;
font-size: 9px;
}
.v2-mileage-matrix-guide-title .semi-icon {
font-size: 12px;
}
.v2-mileage-matrix-guide-title {
order: 0;
}
.v2-mileage-matrix-guide > span:not(.v2-mileage-matrix-guide-title):not(.is-mobile-hint) {
order: 2;
}
.v2-mileage-matrix-guide .semi-tag {
min-width: 31px;
height: 19px;
padding-inline: 5px;
font-size: 8px;
}
.v2-mileage-matrix-guide .is-mobile-hint {
display: inline-flex;
order: 1;
color: #3e6798;
font-weight: 650;
}
.v2-mileage-source-sidesheet .semi-sidesheet-inner {
overflow: hidden;
border: 1px solid #d9e3ef;
border-bottom: 0;
border-radius: 18px 18px 0 0;
box-shadow: 0 -18px 52px rgba(20, 38, 66, .18);
padding-bottom: env(safe-area-inset-bottom);
}
.v2-mileage-source-sidesheet .semi-sidesheet-header {
min-height: 64px;
padding: 13px 16px 11px;
}
.v2-mileage-source-sidesheet .semi-sidesheet-body {
min-height: 0;
overflow-y: auto;
padding: 9px 10px;
}
.v2-mileage-source-sidesheet .semi-sidesheet-footer {
padding: 9px 14px 10px;
}
.v2-mileage-source-list {
gap: 8px;
}
.v2-mileage-source-card.semi-card,
.v2-mileage-source-card > .semi-card-body {
min-height: 78px;
}
.v2-mileage-source-card > .semi-card-body {
gap: 7px 10px;
padding: 10px 11px;
}
.v2-mileage-source-order > .semi-button {
width: 32px;
min-width: 32px;
height: 32px;
}
.v2-mileage-source-footer {
gap: 8px;
}
.v2-mileage-source-footer > span {
font-size: 9px;
}
.v2-mileage-source-footer > .semi-button {
height: 40px;
}
.v2-mileage-table table {
width: max(100%, var(--v2-mileage-mobile-table-width)) !important;
}