diff --git a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.test.tsx index 21dbb3aa..77c6b87c 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.test.tsx @@ -136,6 +136,20 @@ test('clears stale rows, charts, and evidence as soon as the history scope chang expect(screen.queryByRole('heading', { level: 5, name: '导出任务' })).not.toBeInTheDocument(); expect(view.container.querySelector('.v2-history-data-table.semi-table-wrapper')).toBeInTheDocument(); expect(view.container.querySelector('.v2-history-table-scroll > table')).not.toBeInTheDocument(); + const desktopHistoryScroll = view.container.querySelector('.v2-history-table-scroll'); + expect(desktopHistoryScroll).toHaveAttribute('tabindex', '0'); + expect(desktopHistoryScroll).toHaveAttribute('aria-label', '历史明细表格,可使用左右方向键浏览业务字段'); + const desktopTableScroller = desktopHistoryScroll?.querySelector('.semi-table-body'); + expect(desktopTableScroller).toBeInTheDocument(); + const scrollBy = vi.fn(); + Object.defineProperty(desktopTableScroller!, 'scrollBy', { configurable: true, value: scrollBy }); + fireEvent.keyDown(desktopHistoryScroll!, { key: 'ArrowRight' }); + expect(scrollBy).toHaveBeenCalledWith({ left: 260, behavior: 'smooth' }); + fireEvent.keyDown(desktopHistoryScroll!, { key: 'ArrowLeft' }); + expect(scrollBy).toHaveBeenCalledWith({ left: -260, behavior: 'smooth' }); + expect(screen.getByRole('columnheader', { name: '设备时间' })).toHaveClass('semi-table-cell-fixed-left', 'v2-history-device-time-column'); + expect(screen.getByRole('columnheader', { name: '车牌' })).toHaveClass('semi-table-cell-fixed-left', 'v2-history-plate-column'); + expect(view.container.querySelector('th.v2-history-selection-column')).toHaveClass('semi-table-cell-fixed-left'); expect(view.container.querySelector('.v2-history-mobile-list')).not.toBeInTheDocument(); const desktopHistoryRow = screen.getByTestId('history-row-OLDVIN-row'); expect(desktopHistoryRow).toHaveAttribute('role', 'button'); @@ -205,6 +219,8 @@ test('renders only selectable Semi history cards on mobile', async () => { expect(action).toHaveAttribute('aria-pressed', 'true'); expect(action).toHaveAttribute('aria-expanded', 'true'); expect(await screen.findByRole('dialog', { name: '历史数据详情' })).toBeInTheDocument(); + expect(document.querySelector('.v2-history-detail-sidesheet')).toHaveClass('semi-sidesheet-bottom'); + expect(document.querySelector('.v2-history-detail-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(82dvh, 720px)' }); expect(await screen.findByText('MOBILEVIN-evidence')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '关闭历史数据详情' })); expect(action).toHaveAttribute('aria-pressed', 'false'); @@ -215,12 +231,22 @@ test('renders only selectable Semi history cards on mobile', async () => { const exportJobsButton = screen.getByRole('button', { name: '查看导出任务' }); fireEvent.click(exportJobsButton); expect(await screen.findByRole('dialog', { name: '历史数据导出任务' })).toBeInTheDocument(); + expect(document.querySelector('.v2-history-export-sidesheet')).toHaveClass('semi-sidesheet-bottom'); + expect(document.querySelector('.v2-history-export-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(76dvh, 680px)' }); await waitFor(() => expect(mocks.historyExports).toHaveBeenCalledTimes(1)); expect(screen.getByText('暂无导出任务')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '关闭历史数据导出任务' })); expect(exportJobsButton).toHaveAttribute('aria-expanded', 'false'); expect(document.body.style.overflow).not.toBe('hidden'); expect(screen.queryByText('暂无导出任务')).not.toBeInTheDocument(); + + const columnSettingsButton = screen.getByRole('button', { name: '列设置' }); + fireEvent.click(columnSettingsButton); + expect(await screen.findByRole('dialog', { name: '列显示设置' })).toBeInTheDocument(); + expect(document.querySelector('.v2-history-column-sidesheet')).toHaveClass('semi-sidesheet-bottom'); + expect(document.querySelector('.v2-history-column-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(68dvh, 580px)' }); + fireEvent.click(screen.getByRole('button', { name: '关闭列显示设置' })); + expect(columnSettingsButton).toHaveAttribute('aria-expanded', 'false'); }); test('releases export job state immediately after leaving the history page', async () => { diff --git a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx index 82b61dfc..fd48b95c 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { IconChevronRight, IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons'; import { Button, Card, CardGroup, Checkbox, Descriptions, Empty, Input, Progress, Select, SideSheet, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui'; -import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; +import { FormEvent, KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { api } from '../../api/client'; import type { HistoryDataResponse, HistoryDataRow, HistoryExportRequest, HistoryMetricDefinition, HistorySeriesResponse } from '../../api/types'; @@ -161,6 +161,7 @@ function ColumnVisibilityPanel({ metrics, visible, visibleKeys, onToggle, onShow onReset: () => void; onClose: () => void; }) { + const mobileLayout = useMobileLayout(); const [search, setSearch] = useState(''); const filteredMetrics = useMemo(() => { const keyword = search.trim().toLowerCase(); @@ -173,7 +174,9 @@ function ColumnVisibilityPanel({ metrics, visible, visibleKeys, onToggle, onShow className="v2-history-column-sidesheet" visible={visible} aria-label="列显示设置" - width={460} + placement={mobileLayout ? 'bottom' : 'right'} + width={mobileLayout ? undefined : 460} + height={mobileLayout ? 'min(68dvh, 580px)' : undefined} title={
列显示设置基础身份与时间列保持显示
} onCancel={onClose} footer={
已选 {visibleKeys.length} / {metrics.length}
} @@ -188,12 +191,12 @@ function ColumnVisibilityPanel({ metrics, visible, visibleKeys, onToggle, onShow function HistoryDataTable({ rows, metrics, selectedRowID, onSelect }: { rows: HistoryDataRow[]; metrics: HistoryMetricDefinition[]; selectedRowID?: string; onSelect: (row: HistoryDataRow) => void }) { const columns = useMemo(() => [ { - title: '', dataIndex: 'selection', width: 42, + title: '', dataIndex: 'selection', width: 42, fixed: 'left' as const, className: 'v2-history-selection-column', render: (_: unknown, row: HistoryDataRow) => onSelect(row)} aria-label={`选择 ${row.plate || row.vin} ${row.deviceTime}`} /> }, - { title: '设备时间', dataIndex: 'deviceTime', width: 150, render: (value: string) => }, + { title: '设备时间', dataIndex: 'deviceTime', width: 150, fixed: 'left' as const, className: 'v2-history-device-time-column', render: (value: string) => }, + { title: '车牌', dataIndex: 'plate', width: 110, fixed: 'left' as const, className: 'v2-history-plate-column', render: (value: string) => value || '—' }, { title: '服务时间', dataIndex: 'serverTime', width: 150, render: (value: string) => }, - { title: '车牌', dataIndex: 'plate', width: 110, render: (value: string) => value || '—' }, { title: 'VIN', dataIndex: 'vin', width: 160, render: (value: string) => {value} }, { title: '协议', dataIndex: 'protocol', width: 100 }, ...metrics.map((metric) => ({ @@ -220,7 +223,8 @@ function HistoryDataTable({ rows, metrics, selectedRowID, onSelect }: { rows: Hi return setVisibleByCategory((current) => ({ ...current, [criteria.category]: keys })); + const scrollHistoryTable = (event: KeyboardEvent) => { + if (mobileLayout || event.currentTarget !== event.target || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')) return; + const tableScroller = event.currentTarget.querySelector('.semi-table-body'); + if (!tableScroller) return; + event.preventDefault(); + tableScroller.scrollBy({ left: event.key === 'ArrowRight' ? 260 : -260, behavior: 'smooth' }); + }; const totalPages = Math.max(1, Math.ceil((result?.total ?? 0) / limit)); const page = Math.floor(offset / limit) + 1; const summarySources = resultSummary?.sources?.join('、') || '—'; @@ -424,8 +435,9 @@ export default function HistoryPage() { setVisibleMetrics(allMetrics.map((metric) => metric.key))} onReset={() => setVisibleMetrics(allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key))} onClose={() => setColumnSettingsOpen(false)} />
{mobileLayout ?
{resultRows.map((row) => )}
@@ -445,7 +457,8 @@ export default function HistoryPage() { className="v2-history-detail-sidesheet" visible={mobileLayout && Boolean(selectedRow)} aria-label="历史数据详情" - width="100%" + placement="bottom" + height="min(82dvh, 720px)" title={
数据详情{selectedRow ? `${selectedRow.plate || '未绑定车牌'} · ${selectedRow.protocol}` : '来源、质量与原始证据'}
} onCancel={closeSelectedRow} > @@ -455,7 +468,9 @@ export default function HistoryPage() { className="v2-history-export-sidesheet" visible={exportAllowed && exportJobsOpen} aria-label="历史数据导出任务" - width={mobileLayout ? '100%' : 520} + placement={mobileLayout ? 'bottom' : 'right'} + width={mobileLayout ? undefined : 520} + height={mobileLayout ? 'min(76dvh, 680px)' : undefined} title={
导出任务查看生成进度与下载记录
} onCancel={() => setExportJobsOpen(false)} > 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 413cbc94..7bcb015c 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css @@ -6126,25 +6126,74 @@ scrollbar-width: thin; } +.v2-history-data-table .semi-table-body { + scroll-padding-inline-start: 302px; + scrollbar-color: #b7c4d4 #f4f7fa; + scrollbar-width: thin; +} + +.v2-history-table-scroll:focus-visible { + outline: 2px solid rgba(18, 104, 223, .38); + outline-offset: -2px; +} + +.v2-history-data-table :is(.semi-table-row-head, .semi-table-row-cell).semi-table-cell-fixed-left { + z-index: 5; + background: #fff; +} + +.v2-history-table-scroll .v2-history-data-table.semi-table-wrapper .semi-table-thead > .semi-table-row > .semi-table-row-head.semi-table-cell-fixed-left { + z-index: 8; + background: #f7f9fc; +} + +.v2-history-data-table .semi-table-tbody > .semi-table-row:hover > .semi-table-row-cell.semi-table-cell-fixed-left, +.v2-history-data-table .semi-table-tbody > .semi-table-row.is-selected > .semi-table-row-cell.semi-table-cell-fixed-left { + background: #f2f7ff; +} + +.v2-history-data-table :is(.semi-table-row-head, .semi-table-row-cell).v2-history-plate-column { + box-shadow: 12px 0 18px -18px rgba(35, 55, 82, .72); +} + .v2-history-table-scroll::-webkit-scrollbar { width: 10px; height: 10px; } +.v2-history-data-table .semi-table-body::-webkit-scrollbar { + width: 10px; + height: 10px; +} + .v2-history-table-scroll::-webkit-scrollbar-track { background: #f4f7fa; } +.v2-history-data-table .semi-table-body::-webkit-scrollbar-track { + background: #f4f7fa; +} + .v2-history-table-scroll::-webkit-scrollbar-thumb { border: 2px solid #f4f7fa; border-radius: 999px; background: #b7c4d4; } +.v2-history-data-table .semi-table-body::-webkit-scrollbar-thumb { + border: 2px solid #f4f7fa; + border-radius: 999px; + background: #b7c4d4; +} + .v2-history-table-scroll::-webkit-scrollbar-thumb:hover { background: #91a4ba; } +.v2-history-data-table .semi-table-body::-webkit-scrollbar-thumb:hover { + background: #91a4ba; +} + @media (min-width: 681px) { .v2-history-page { height: 100%; @@ -6403,6 +6452,63 @@ outline-offset: -2px; } + :is( + .v2-history-column-sidesheet, + .v2-history-detail-sidesheet, + .v2-history-export-sidesheet + ).semi-sidesheet-bottom .semi-sidesheet-inner { + width: 100% !important; + max-width: 100%; + overflow: hidden; + border: 1px solid #d9e3ef; + border-bottom: 0; + border-radius: 18px 18px 0 0; + background: #f4f7fb; + box-shadow: 0 -18px 52px rgba(20, 38, 66, .18); + padding-bottom: env(safe-area-inset-bottom); + } + + :is( + .v2-history-column-sidesheet, + .v2-history-detail-sidesheet, + .v2-history-export-sidesheet + ).semi-sidesheet-bottom .semi-sidesheet-header { + min-height: 64px; + border-bottom: 1px solid #dfe6ef; + padding: 12px 15px 10px; + } + + :is( + .v2-history-column-sidesheet, + .v2-history-detail-sidesheet, + .v2-history-export-sidesheet + ).semi-sidesheet-bottom .semi-sidesheet-body { + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + } + + .v2-history-column-sidesheet.semi-sidesheet-bottom .semi-sidesheet-body { + overflow: hidden; + } + + .v2-history-column-sidesheet.semi-sidesheet-bottom .semi-sidesheet-footer { + padding: 9px 12px 10px; + } + + :is( + .v2-history-detail-sidesheet, + .v2-history-export-sidesheet + ).semi-sidesheet-bottom .semi-sidesheet-body { + padding: 8px 8px 12px; + } + + .v2-history-detail-sidesheet.semi-sidesheet-bottom .v2-history-evidence.semi-card, + .v2-history-export-sidesheet.semi-sidesheet-bottom .v2-export-jobs.semi-card { + border-radius: 11px; + box-shadow: none; + } + .v2-history-mobile-list { gap: 6px; padding: 6px;