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 ab143291..db94ff96 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx @@ -9,6 +9,7 @@ import { accessIssueSummary, accessRowsToCSV, formatAccessTime, formatSeconds, u import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState'; import { MetricActionButton } from '../shared/MetricActionButton'; import { MobileFilterToggle } from '../shared/MobileFilterToggle'; +import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet'; import { TablePagination } from '../shared/TablePagination'; import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar'; import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel'; @@ -415,21 +416,23 @@ export default function AccessPage() { collapsedLabel="修改" onToggle={() => setFiltersCollapsed((value) => !value)} /> - 筛选接入车辆车辆、接入状态、真实协议与品牌} + ariaLabel="接入车辆筛选" + title="筛选接入车辆" + description="车辆、接入状态、真实协议与品牌" onCancel={() => setFiltersCollapsed(true)} - footer={重置条件应用并查询} + secondaryAction={{ label: '重置条件', onClick: resetFilters }} + primaryAction={{ label: '应用并查询', onClick: applyDraft }} > - 接入范围优先定位异常、离线、尚无来源和资料差异车辆 - {filterFields} + + {filterFields} + - + : setFiltersCollapsed((value) => !value)} /> - 筛选告警事件车辆、状态、规则与时间范围} + ariaLabel="告警事件筛选" + title="筛选告警事件" + description="车辆、状态、规则与时间范围" onCancel={() => setFiltersCollapsed(true)} - footer={重置条件应用并查询} + secondaryAction={{ label: '重置条件', onClick: resetFilters }} + primaryAction={{ label: '应用并查询', onClick: applyDraft }} > - - 常用条件快速定位车辆与待处置事件 + {coreFilterFields} - - - 精确范围按规则、协议和触发时间进一步收窄 + + {advancedFilterFields} - + - + : setFiltersCollapsed((value) => !value)} /> - 筛选历史数据车辆、时间、数据类型与协议来源} + ariaLabel="历史查询范围" + title="筛选历史数据" + description="车辆、时间、数据类型与协议来源" onCancel={() => setFiltersCollapsed(true)} - footer={重置条件应用并查询} + secondaryAction={{ label: '重置条件', onClick: reset }} + primaryAction={{ label: '应用并查询', disabled: !parseHistoryKeywords(draft.keywords).length, onClick: applyDraft }} > - - 查询范围最多 5 台车辆,按上海时区检索可追溯数据 + {historyFilterFields} - + {exportAllowed ? 导出当前结果按已应用范围与当前字段生成 CSV : 只读} - + : { + setDraft(initialCriteria(new URLSearchParams())); + setValidationError(''); + }; const submit = (event: FormEvent) => { event.preventDefault(); applyDraft(); }; const setRange = (range: { dateFrom: string; dateTo: string }) => { const next = { ...draft, ...range }; setPage(1); setExportFeedback(''); setValidationError(''); setDraft(next); setCriteria(next); setSearchParams(preserveMonitorReturn(mileageParams(next, -1), monitorReturn), { replace: true }); setFiltersCollapsed(true); }; const closeMobileFilters = () => { @@ -594,19 +599,22 @@ export default function StatisticsPage() { onToggle={toggleFilters} /> - 里程范围选择车辆、自然日与数据源} + ariaLabel="里程范围筛选" + title="里程范围" + description="选择车辆、自然日与数据源" onCancel={closeMobileFilters} - footer={应用后刷新里程统计与车辆矩阵应用并查询} + secondaryAction={{ label: '重置条件', onClick: resetDraft }} + primaryAction={{ label: '应用并查询', onClick: applyDraft }} > - {filterForm} + + {filterForm} + {filterValidation} - + : - 筛选客户账号按账号信息与权限状态缩小客户范围} + ariaLabel="客户账号筛选" + title="筛选客户账号" + description="按账号信息与权限状态缩小客户范围" onCancel={closeMobileFilters} - footer={重置条件应用筛选} + secondaryAction={{ label: '重置条件', onClick: resetMobileFilters }} + primaryAction={{ label: '应用筛选', onClick: applyMobileFilters }} > - - 账号范围筛选仅在应用后更新客户目录,关闭不会丢失当前结果。 + 客户账号 @@ -417,9 +418,9 @@ export default function UsersPage() { /> - + - + : inspectedValue ? telemetryValuePresentation(inspectedValue) : undefined, [inspectedValue]); const inspectedDetail = useMemo(() => inspectedValue ? telemetryValueDetail(inspectedValue) : '', [inspectedValue]); - useSideSheetA11y(Boolean(inspectedValue), '.v2-telemetry-value-sidesheet', 'v2-telemetry-value-detail', '遥测字段详情', '关闭遥测字段详情'); + useSideSheetA11y(!!inspectedValue, '.v2-telemetry-value-sidesheet', 'v2-telemetry-value-detail', '遥测字段详情', '关闭遥测字段详情'); const indexed = useMemo(() => { const valuesByProtocolCategory = new Map(); const sources = new Map(); @@ -314,7 +314,7 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon 当前位置{coordinate ? `${vehicle!.longitude.toFixed(6)}, ${vehicle!.latitude.toFixed(6)}` : '—'} @@ -433,7 +433,7 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele const [sourceEvidenceOpen, setSourceEvidenceOpen] = useState(false); const realtime = liveRealtime ?? detail.realtimeSummary; const identity = detail.identity; - const hasLocation = Boolean(realtime && realtime.locationAvailable !== false && isValidAMapCoordinate(realtime.longitude, realtime.latitude)); + const hasLocation = !!(realtime && realtime.locationAvailable !== false && isValidAMapCoordinate(realtime.longitude, realtime.latitude)); const mapVehicles = hasLocation && realtime ? [realtime] : []; const actions = [ { key: 'switch', label: '切换车辆', icon: , to: '/vehicles', type: 'primary' as const }, @@ -543,18 +543,18 @@ export default function VehiclePage() { const { vin } = useParams(); const [searchParams] = useSearchParams(); const monitorReturn = monitorReturnFromParams(searchParams); - const query = useQuery({ queryKey: ['vehicle-detail', vin], enabled: Boolean(vin), queryFn: ({ signal }) => api.vehicleDetail(new URLSearchParams({ keyword: vin!, limit: '20' }), signal), gcTime: QUERY_MEMORY.summaryGcTime }); + const query = useQuery({ queryKey: ['vehicle-detail', vin], enabled: !!vin, queryFn: ({ signal }) => api.vehicleDetail(new URLSearchParams({ keyword: vin!, limit: '20' }), signal), gcTime: QUERY_MEMORY.summaryGcTime }); const resolvedVin = query.data?.lookupResolved ? query.data.vin : ''; const realtime = useQuery({ queryKey: ['vehicle-detail-realtime', resolvedVin], queryFn: ({ signal }) => api.vehicleRealtime(new URLSearchParams({ keywords: resolvedVin, limit: '1', offset: '0' }), signal), - enabled: Boolean(resolvedVin), + enabled: !!resolvedVin, staleTime: 5_000, gcTime: QUERY_MEMORY.highVolumeGcTime, refetchInterval: resolvedVin ? SINGLE_VEHICLE_REFRESH_MS : false, ...LIVE_QUERY_POLICY }); - const telemetry = useQuery({ queryKey: ['vehicle-latest-telemetry', vin], enabled: Boolean(vin), queryFn: ({ signal }) => api.latestTelemetry(vin!, signal), staleTime: 10_000, gcTime: QUERY_MEMORY.summaryGcTime, refetchInterval: 20_000, ...LIVE_QUERY_POLICY }); + const telemetry = useQuery({ queryKey: ['vehicle-latest-telemetry', vin], enabled: !!vin, queryFn: ({ signal }) => api.latestTelemetry(vin!, signal), staleTime: 10_000, gcTime: QUERY_MEMORY.summaryGcTime, refetchInterval: 20_000, ...LIVE_QUERY_POLICY }); useEffect(() => { resetWorkspaceScroll(); let trailingFrame = 0; diff --git a/vehicle-data-platform/apps/web/src/v2/pages/VehicleSearchWorkspace.tsx b/vehicle-data-platform/apps/web/src/v2/pages/VehicleSearchWorkspace.tsx index 28c18869..50f6ab53 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/VehicleSearchWorkspace.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/VehicleSearchWorkspace.tsx @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { IconArrowRight, IconBox, IconChevronRight, IconRefresh, IconSearch } from '@douyinfe/semi-icons'; -import { Button, Card, Empty, Input, SideSheet, Table, Tag } from '@douyinfe/semi-ui'; +import { Button, Card, Empty, Input, Table, Tag } from '@douyinfe/semi-ui'; import { type FormEvent, lazy, Suspense, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { api } from '../../api/client'; @@ -12,6 +12,7 @@ import { useMobileLayout } from '../hooks/useMobileLayout'; import { useSideSheetA11y } from '../hooks/useSideSheetA11y'; import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy'; import { MobileFilterToggle } from '../shared/MobileFilterToggle'; +import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet'; import { ProtocolTag } from '../shared/ProtocolTag'; import { TablePagination } from '../shared/TablePagination'; import { VehicleCandidateList } from '../shared/VehicleCandidateList'; @@ -174,19 +175,19 @@ export default function VehicleSearchWorkspace() { collapsedLabel="修改" onToggle={mobileFiltersOpen ? closeMobileFilters : openMobileFilters} /> - 查找车辆车牌优先,支持 VIN 与终端手机号} + ariaLabel="车辆搜索" + title="查找车辆" + description="车牌优先,支持 VIN 与终端手机号" onCancel={closeMobileFilters} - footer={重置范围应用搜索} + secondaryAction={{ label: '重置范围', onClick: resetMobileFilters }} + primaryAction={{ label: '应用搜索', onClick: applyMobileFilters }} > - - 车辆身份输入关键词可直接打开候选车辆,或应用到下方目录。 + 车牌 / VIN / 终端手机号 openVehicle(vehicle.vin)} /> : 快速定位车辆输入完整或部分车牌即可查看候选。} - + - + : { expect(corePageSources.StatisticsPage).toContain('v2-mileage-source-sidesheet'); expect(corePageSources.StatisticsPage).toContain('className="v2-mileage-filter-sidesheet"'); expect(corePageSources.StatisticsPage).toContain(' { + const onCancel = vi.fn(); + const onReset = vi.fn(); + const onApply = vi.fn(); + render( + + 关键词 + + ); + + const dialog = screen.getByRole('dialog', { name: '测试筛选' }); + expect(dialog.closest('.semi-sidesheet')).toHaveClass('v2-mobile-filter-sheet', 'test-filter-sheet'); + expect(screen.getByText('按车辆和状态缩小范围')).toBeInTheDocument(); + expect(screen.getByRole('region', { name: '车辆条件' })).toHaveTextContent('支持车牌或 VIN'); + fireEvent.click(screen.getByRole('button', { name: '重置条件' })); + fireEvent.click(screen.getByRole('button', { name: '应用并查询' })); + expect(onReset).toHaveBeenCalledTimes(1); + expect(onApply).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: 'close' })); + expect(onCancel).toHaveBeenCalledTimes(1); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/shared/MobileFilterSheet.tsx b/vehicle-data-platform/apps/web/src/v2/shared/MobileFilterSheet.tsx new file mode 100644 index 00000000..7394d03f --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/shared/MobileFilterSheet.tsx @@ -0,0 +1,93 @@ +import { Button, SideSheet } from '@douyinfe/semi-ui'; +import { useEffect, useId, type ReactNode } from 'react'; + +type MobileFilterSheetAction = { + label: string; + onClick: () => void; + disabled?: boolean; + loading?: boolean; +}; + +type MobileFilterSheetProps = { + className?: string; + visible: boolean; + height?: string | number; + ariaLabel: string; + title: string; + description: string; + onCancel: () => void; + secondaryAction?: MobileFilterSheetAction; + primaryAction: MobileFilterSheetAction; + children: ReactNode; +}; + +export function MobileFilterSheet({ + className = '', + visible, + height = 'min(82dvh, 700px)', + ariaLabel, + title, + description, + onCancel, + secondaryAction, + primaryAction, + children +}: MobileFilterSheetProps) { + const sheetClassName = ['v2-mobile-filter-sheet', className].filter(Boolean).join(' '); + const sheetId = useId(); + + useEffect(() => { + if (!visible) return; + const dialog = document.querySelector(`[data-mobile-filter-sheet-id="${sheetId}"] .semi-sidesheet-inner`); + dialog?.setAttribute('aria-label', ariaLabel); + }, [ariaLabel, sheetId, visible]); + + return {title}{description}} + onCancel={onCancel} + footer={ + {secondaryAction ? {secondaryAction.label} : } + {primaryAction.label} + } + > + {children} + ; +} + +type MobileFilterSheetSectionProps = { + className?: string; + ariaLabel: string; + title: string; + description: string; + children: ReactNode; +}; + +export function MobileFilterSheetSection({ + className = '', + ariaLabel, + title, + description, + children +}: MobileFilterSheetSectionProps) { + const sectionClassName = ['v2-mobile-filter-sheet-section', className].filter(Boolean).join(' '); + return + {title}{description} + {children} + ; +} 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 d07a740d..e805f926 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css @@ -18,6 +18,208 @@ padding: 8px 14px; } +/* + * Shared Semi UI mobile filter sheet. + * Keep the same title hierarchy, scroll surface, condition card and action + * boundary across vehicle, mileage, history, alerts, accounts and access. + */ +@media (max-width: 680px) { + .v2-mobile-filter-sheet .semi-sidesheet-inner { + width: 100vw !important; + max-width: 100%; + overflow: hidden; + border-radius: 18px 18px 0 0; + background: #f4f7fb; + box-shadow: 0 -20px 56px rgba(25, 45, 72, .2); + padding-bottom: env(safe-area-inset-bottom); + } + + .v2-mobile-filter-sheet.semi-sidesheet-bottom .semi-sidesheet-header { + min-height: 68px; + border-bottom: 1px solid #dfe7f0; + background: rgba(255, 255, 255, .98); + padding: 11px 14px; + } + + .v2-mobile-filter-sheet.semi-sidesheet-bottom .semi-sidesheet-body { + min-height: 0; + overflow-y: auto; + background: #f4f7fb; + padding: 12px; + overscroll-behavior: contain; + } + + .v2-mobile-filter-sheet.semi-sidesheet-bottom .semi-sidesheet-footer { + border-top: 1px solid #dfe7f0; + background: rgba(255, 255, 255, .98); + padding: 10px 12px calc(10px + env(safe-area-inset-bottom)); + } + + .v2-mobile-filter-sheet-title { + display: grid; + min-width: 0; + gap: 3px; + } + + .v2-mobile-filter-sheet-title > strong { + color: #253a52; + font-size: 15px; + line-height: 1.3; + } + + .v2-mobile-filter-sheet-title > span { + color: #8090a3; + font-size: 10px; + font-weight: 500; + line-height: 1.4; + } + + .v2-mobile-filter-sheet-footer { + display: grid; + width: 100%; + min-width: 0; + grid-template-columns: minmax(0, .72fr) minmax(0, 1.28fr); + gap: 9px; + } + + .v2-mobile-filter-sheet-footer > .semi-button { + width: 100%; + min-height: 42px; + justify-content: center; + border-radius: 9px; + font-weight: 700; + } + + .v2-mobile-filter-sheet-section { + display: grid; + min-width: 0; + gap: 12px; + border: 1px solid #dfe7f0; + border-radius: 12px; + background: #fff; + padding: 12px; + box-shadow: 0 8px 24px rgba(31, 53, 80, .055); + } + + .v2-mobile-filter-sheet-section > header { + display: grid; + min-width: 0; + gap: 3px; + } + + .v2-mobile-filter-sheet-section > header > strong { + color: #2b4058; + font-size: 13px; + line-height: 1.3; + } + + .v2-mobile-filter-sheet-section > header > span { + color: #7c8b9e; + font-size: 10px; + line-height: 1.45; + } + + .v2-mobile-filter-sheet-section > .v2-mileage-filter { + border: 0; + border-radius: 0; + background: transparent; + padding: 0; + box-shadow: none; + } + + .v2-mobile-filter-sheet .v2-access-mobile-filter-form { + gap: 0; + border: 0; + border-radius: 0; + background: transparent; + padding: 0; + box-shadow: none; + } + + .v2-access-mobile-filter-form > .v2-mobile-filter-sheet-section > div { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 11px 9px; + } + + .v2-access-mobile-filter-form > .v2-mobile-filter-sheet-section > div > label.is-search, + .v2-access-mobile-filter-form > .v2-mobile-filter-sheet-section > div > label:last-child { + grid-column: 1 / -1; + } + + .v2-access-mobile-filter-form > .v2-mobile-filter-sheet-section > div > label { + display: grid; + min-width: 0; + gap: 6px; + color: #5f7188; + font-size: 11px; + font-weight: 650; + } + + .v2-access-mobile-filter-form > .v2-mobile-filter-sheet-section > div > label > .semi-input-wrapper, + .v2-access-mobile-filter-form > .v2-mobile-filter-sheet-section > div > label > .semi-select { + width: 100%; + min-height: 44px; + border-radius: 9px; + background: #fff; + } +} + +@media (max-width: 900px) and (max-height: 480px) and (orientation: landscape) { + .v2-mobile-filter-sheet.semi-sidesheet-bottom .semi-sidesheet-inner { + height: 100dvh !important; + max-height: 100dvh; + border-radius: 0; + } + + .v2-mobile-filter-sheet.semi-sidesheet-bottom .semi-sidesheet-header { + min-height: 54px; + padding: 7px 12px; + } + + .v2-mobile-filter-sheet.semi-sidesheet-bottom .semi-sidesheet-body { + padding: 8px; + } + + .v2-mobile-filter-sheet.semi-sidesheet-bottom .semi-sidesheet-footer { + padding: 7px 10px; + } + + .v2-mobile-filter-sheet-title > span, + .v2-mobile-filter-sheet-section > header { + display: none; + } + + .v2-mobile-filter-sheet-section { + gap: 8px; + padding: 9px; + } + + .v2-mobile-filter-sheet-footer > .semi-button { + min-height: 36px; + } + + .v2-access-mobile-filter-form > .v2-mobile-filter-sheet-section > div { + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 7px; + } + + .v2-access-mobile-filter-form > .v2-mobile-filter-sheet-section > div > label.is-search, + .v2-access-mobile-filter-form > .v2-mobile-filter-sheet-section > div > label:last-child { + grid-column: auto; + } + + .v2-access-mobile-filter-form > .v2-mobile-filter-sheet-section > div > label { + gap: 4px; + font-size: 9px; + } + + .v2-access-mobile-filter-form > .v2-mobile-filter-sheet-section > div > label > .semi-input-wrapper, + .v2-access-mobile-filter-form > .v2-mobile-filter-sheet-section > div > label > .semi-select { + min-height: 38px; + } +} + /* * Global short-landscape entry and navigation. * Keep every authentication action in the first viewport, preserve practical