From 8aa273e5790d761a8de566a675e6de6b8c24a2f1 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 18 Jul 2026 12:41:41 +0800 Subject: [PATCH] refine Semi UI mileage matrix workspace --- .../web/src/v2/pages/StatisticsPage.test.tsx | 31 +++- .../apps/web/src/v2/pages/StatisticsPage.tsx | 48 ++++- .../apps/web/src/v2/styles/workspace.css | 164 ++++++++++++++++-- 3 files changed, 225 insertions(+), 18 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx index 501efd50..a078a0be 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.test.tsx @@ -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('.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')}` })); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx index d97ea40f..4e451d30 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx @@ -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={
数据源策略同车同日按优先级选择一个里程来源
} onCancel={() => setOpen(false)} footer={
同车同日只使用一个来源,避免重复累计;至少保留一个来源。
} @@ -279,6 +283,26 @@ function dateLabel(date: string) { return `${Number(month)}/${Number(day)}`; } +function MileageMatrixGuide() { + return ; +} + +function scrollMileageMatrixFromKeyboard(event: KeyboardEvent) { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + const scroller = event.currentTarget.querySelector('.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 }) { 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) => {row.plate || '未绑定'} }, ...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
+ return
; }); @@ -534,6 +569,7 @@ export default function StatisticsPage() { {exportProgress.label}{exportPercent == null ? '处理中' : `${exportPercent}%`} : null} + {!resultsLoading && displayVehicles.length ? : null} {resultsLoading ? : displayVehicles.length ? : null} {!resultsLoading && !displayVehicles.length ? : null}
{!hasVehicles && totalVehicles ? : 已选择 {totalVehicles.toLocaleString('zh-CN')} 辆车辆{exportFeedback ? ` · ${exportFeedback}` : ''}}
diff --git a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css index 0f1bf798..413cbc94 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css @@ -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; }