diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx index 9243804a..265df8a5 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx @@ -66,6 +66,21 @@ test('removes old access rows immediately when the vehicle filter scope changes' expect(within(filterActions!).getByRole('button', { name: '重置' })).toBeInTheDocument(); expect(view.container.querySelector('.v2-access-semi-table.semi-table-wrapper')).toBeInTheDocument(); expect(view.container.querySelector('.v2-access-table-scroll-v3 > table')).not.toBeInTheDocument(); + const desktopAccessScroll = view.container.querySelector('.v2-access-table-scroll-v3'); + expect(desktopAccessScroll).toHaveAttribute('tabindex', '0'); + expect(desktopAccessScroll).toHaveAttribute('aria-label', '车辆接入表格,可使用左右方向键浏览协议来源'); + const desktopTableScroller = desktopAccessScroll?.querySelector('.semi-table-body'); + expect(desktopTableScroller).toBeInTheDocument(); + Object.defineProperty(desktopAccessScroll!, 'clientWidth', { configurable: true, value: 760 }); + Object.defineProperty(desktopAccessScroll!, 'scrollWidth', { configurable: true, value: 760 }); + const scrollBy = vi.fn(); + Object.defineProperty(desktopTableScroller!, 'scrollBy', { configurable: true, value: scrollBy }); + fireEvent.keyDown(desktopAccessScroll!, { key: 'ArrowRight' }); + expect(scrollBy).toHaveBeenCalledWith({ left: 260, behavior: 'smooth' }); + fireEvent.keyDown(desktopAccessScroll!, { key: 'ArrowLeft' }); + expect(scrollBy).toHaveBeenCalledWith({ left: -260, behavior: 'smooth' }); + expect(screen.getByRole('columnheader', { name: '车辆' })).toHaveClass('semi-table-cell-fixed-left', 'v2-access-vehicle-column'); + expect(screen.getByRole('columnheader', { name: '品牌 / 车型' })).toHaveClass('semi-table-cell-fixed-left', 'v2-access-model-column'); const desktopRow = screen.getByTestId('access-row-OLDVIN'); expect(desktopRow).toHaveAttribute('role', 'button'); expect(desktopRow).toHaveAttribute('aria-expanded', 'false'); @@ -154,6 +169,7 @@ test('renders mobile access vehicles as selectable Semi cards', async () => { expect(within(action).getByText('32960')).toBeInTheDocument(); expect(within(action).getByText('808')).toBeInTheDocument(); expect(within(action).getByText('宇通')).toBeInTheDocument(); + expect(screen.queryByLabelText('每页车辆数')).not.toBeInTheDocument(); expect(action).toHaveAttribute('aria-pressed', 'false'); expect(action).toHaveAttribute('aria-expanded', 'false'); fireEvent.click(action); @@ -166,4 +182,19 @@ test('renders mobile access vehicles as selectable Semi cards', async () => { expect(dialog.querySelector('.v2-access-inspector-summary.semi-descriptions')).toBeInTheDocument(); expect(dialog.querySelector('.v2-access-protocol-details.semi-card-group')).toBeInTheDocument(); expect(dialog.querySelectorAll('.v2-access-protocol-detail.semi-card')).toHaveLength(3); + expect(document.querySelector('.v2-access-detail-sidesheet')).toHaveClass('semi-sidesheet-bottom'); + expect(document.querySelector('.v2-access-detail-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(84dvh, 740px)' }); + fireEvent.click(within(dialog).getByRole('button', { name: '关闭车辆接入详情' })); + expect(action).toHaveAttribute('aria-pressed', 'false'); + expect(action).toHaveAttribute('aria-expanded', 'false'); + expect(document.body.style.overflow).not.toBe('hidden'); + + const governanceButton = screen.getByRole('button', { name: /接入治理/ }); + fireEvent.click(governanceButton); + const governanceDialog = await screen.findByRole('dialog', { name: '接入治理配置' }); + expect(document.querySelector('.v2-access-governance-sidesheet')).toHaveClass('semi-sidesheet-bottom'); + expect(document.querySelector('.v2-access-governance-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(86dvh, 760px)' }); + fireEvent.click(within(governanceDialog).getByRole('button', { name: '关闭接入治理配置' })); + expect(governanceButton).toHaveAttribute('aria-expanded', 'false'); + expect(document.body.style.overflow).not.toBe('hidden'); }); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx index 19c10764..13620c5d 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx @@ -1,7 +1,7 @@ import { IconChevronRight, IconClose, IconDownload, IconRefresh, IconSave, IconSearch, IconSetting } from '@douyinfe/semi-icons'; import { Button, Card, CardGroup, Collapse, Descriptions, Empty, Input, Select, SideSheet, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { FormEvent, memo, useEffect, useMemo, useState } from 'react'; +import { FormEvent, KeyboardEvent, memo, useEffect, useMemo, useState } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; import { api } from '../../api/client'; import type { AccessProtocolStatus, AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow, Page } from '../../api/types'; @@ -121,11 +121,11 @@ function ProtocolCoverage({ summary }: { summary?: AccessSummary }) { const AccessVehicleTable = memo(function AccessVehicleTable({ rows, selectedVIN, onSelect }: { rows: AccessVehicleRow[]; selectedVIN: string; onSelect: (vin: string) => void }) { const columns = useMemo(() => [ { - title: '车辆', dataIndex: 'plate', width: 165, + title: '车辆', dataIndex: 'plate', width: 165, fixed: 'left' as const, className: 'v2-access-vehicle-column', render: (_: string, row: AccessVehicleRow) =>
{row.plate || '未绑定车牌'}{row.vin}
}, { - title: '品牌 / 车型', dataIndex: 'oem', width: 140, + title: '品牌 / 车型', dataIndex: 'oem', width: 140, fixed: 'left' as const, className: 'v2-access-model-column', render: (_: string, row: AccessVehicleRow) =>
{row.oem || '品牌未维护'}{row.model || row.company || '车型未维护'}
}, ...PROTOCOLS.map((protocol) => ({ @@ -140,6 +140,8 @@ const AccessVehicleTable = memo(function AccessVehicleTable({ rows, selectedVIN, return { event.preventDefault(); apply(draft); setFiltersCollapsed(true); }; const page = Math.floor(offset / limit) + 1; const totalPages = Math.max(1, Math.ceil((vehiclesQuery.data?.total ?? 0) / limit)); const summary = summaryQuery.data; const refresh = () => Promise.all([summaryQuery.refetch(), vehiclesQuery.refetch(), ...(editable ? [unresolvedQuery.refetch(), thresholdQuery.refetch()] : [])]); + const scrollAccessTable = (event: KeyboardEvent) => { + if (mobileLayout || event.currentTarget !== event.target || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')) return; + const tableScroller = event.currentTarget.scrollWidth > event.currentTarget.clientWidth + ? event.currentTarget + : event.currentTarget.querySelector('.semi-table-body'); + if (!tableScroller) return; + event.preventDefault(); + tableScroller.scrollBy({ left: event.key === 'ArrowRight' ? 260 : -260, behavior: 'smooth' }); + }; return
@@ -294,8 +305,9 @@ export default function AccessPage() { })}
{mobileLayout ?
{rows.map((row) => )}
@@ -303,14 +315,16 @@ export default function AccessPage() { {vehiclesQuery.isFetching ? : null} {!vehiclesQuery.isFetching && !rows.length ? : null}
-
setOffset((next - 1) * limit)} pageSize={limit} pageSizeLabel="每页车辆数" onPageSizeChange={(next) => { setLimit(next); setOffset(0); }} pageSizeOptions={[{ value: 20, label: '20 辆/页' }, { value: 50, label: '50 辆/页' }, { value: 100, label: '100 辆/页' }]} />
+
setOffset((next - 1) * limit)} pageSize={mobileLayout ? undefined : limit} pageSizeLabel="每页车辆数" onPageSizeChange={(next) => { setLimit(next); setOffset(0); }} pageSizeOptions={mobileLayout ? undefined : [{ value: 20, label: '20 辆/页' }, { value: 50, label: '50 辆/页' }, { value: 100, label: '100 辆/页' }]} />
车辆接入详情{selected ? `${selected.plate || '未绑定车牌'} · ${selected.vin}` : '来源、在线状态与接入证据'}
} onCancel={() => setSelectedVIN('')} > @@ -320,7 +334,9 @@ export default function AccessPage() { className="v2-access-governance-sidesheet" visible={governanceOpen} aria-label="接入治理配置" - width={560} + placement={mobileLayout ? 'bottom' : 'right'} + width={mobileLayout ? undefined : 560} + height={mobileLayout ? 'min(86dvh, 760px)' : undefined} title={
接入治理集中处理待绑定来源与在线判定阈值
} onCancel={() => setGovernanceOpen(false)} footer={} 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 7bcb015c..26f954b7 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css @@ -8632,6 +8632,67 @@ min-width: 1005px; } +.v2-access-table-scroll-v3 { + scrollbar-color: #b7c4d4 #f4f7fa; + scrollbar-width: thin; +} + +.v2-access-table-scroll-v3:not(.is-mobile-scroll) { + overflow-x: hidden; +} + +.v2-access-table-scroll-v3 > .v2-access-semi-table.semi-table-wrapper { + width: 100%; + min-width: 0; +} + +.v2-access-table-scroll-v3:focus-visible { + outline: 2px solid rgba(18, 104, 243, .34); + outline-offset: -2px; +} + +.v2-access-semi-table .semi-table-body { + overflow-x: auto; + scroll-padding-inline-start: 305px; + scrollbar-color: #b7c4d4 #f4f7fa; + scrollbar-width: thin; +} + +.v2-access-semi-table :is(.semi-table-row-head, .semi-table-row-cell).semi-table-cell-fixed-left { + z-index: 5; + background: #fff; +} + +.v2-access-table-scroll-v3 .v2-access-semi-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-access-semi-table .semi-table-tbody > .semi-table-row:hover > .semi-table-row-cell.semi-table-cell-fixed-left, +.v2-access-semi-table .semi-table-tbody > .semi-table-row.is-selected > .semi-table-row-cell.semi-table-cell-fixed-left { + background: #f2f7ff; +} + +.v2-access-semi-table :is(.semi-table-row-head, .semi-table-row-cell).v2-access-model-column { + box-shadow: 12px 0 18px -18px rgba(35, 55, 82, .72); +} + +.v2-access-semi-table .semi-table-body::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.v2-access-semi-table .semi-table-body::-webkit-scrollbar-track { + background: #f4f7fa; +} + +.v2-access-semi-table .semi-table-body::-webkit-scrollbar-thumb { + border: 2px solid #f4f7fa; + border-radius: 999px; + background: #b7c4d4; + background-clip: padding-box; +} + .v2-access-table-scroll-v3 th:nth-child(1) { width: 165px; } .v2-access-table-scroll-v3 th:nth-child(2) { width: 140px; } .v2-access-table-scroll-v3 th:nth-child(3), @@ -9026,11 +9087,90 @@ } .v2-access-table-v3 > .semi-card-body > footer .v2-table-pagination-button.semi-button, - .v2-access-table-v3 > .semi-card-body > footer .v2-table-pagination-current, - .v2-access-table-v3 > .semi-card-body > footer .v2-table-pagination-size.semi-select { + .v2-access-table-v3 > .semi-card-body > footer .v2-table-pagination-current { height: 34px; } + .v2-access-table-v3 > .semi-card-body > footer .v2-table-pagination-controls { + width: 100%; + justify-content: center; + } + + :is( + .v2-access-detail-sidesheet, + .v2-access-governance-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-access-detail-sidesheet, + .v2-access-governance-sidesheet + ).semi-sidesheet-bottom .semi-sidesheet-header { + min-height: 64px; + border-bottom: 1px solid #dfe6ef; + padding: 12px 15px 10px; + } + + :is( + .v2-access-detail-sidesheet, + .v2-access-governance-sidesheet + ).semi-sidesheet-bottom .semi-sidesheet-body { + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + } + + .v2-access-detail-sidesheet.semi-sidesheet-bottom .semi-sidesheet-body { + padding: 0 7px 12px; + } + + .v2-access-detail-sidesheet.semi-sidesheet-bottom .v2-access-inspector-v3.is-sheet.semi-card { + border-radius: 11px; + background: transparent; + } + + .v2-access-governance-sidesheet.semi-sidesheet-bottom .semi-sidesheet-body { + padding: 8px 8px 12px; + } + + .v2-access-governance-sidesheet.semi-sidesheet-bottom .semi-sidesheet-footer { + padding: 9px 12px 10px; + } + + .v2-access-governance-sidesheet.semi-sidesheet-bottom .semi-sidesheet-footer .semi-button { + width: 100%; + min-height: 40px; + } + + .v2-access-governance-sidesheet .v2-access-governance-summary > .semi-card-body { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .v2-access-governance-sidesheet .v2-access-governance-summary > .semi-card-body > div { + display: grid; + grid-template-columns: 1fr; + gap: 4px; + padding: 10px 8px; + } + + .v2-access-governance-sidesheet .v2-access-governance-summary > .semi-card-body > div + div { + border-top: 0; + border-left: 1px solid var(--v2-border); + } + + .v2-access-governance-sidesheet .v2-access-governance-summary strong { + font-size: 16px; + } + .v2-access-detail-sidesheet .v2-access-protocol-details.semi-card-group { display: grid; grid-template-columns: minmax(0, 1fr);