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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user