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 817585ed..0453f36c 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx @@ -22,7 +22,6 @@ import { canAdminister } from '../auth/session'; import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy'; import { downloadBlob } from '../domain/download'; import { useMobileLayout } from '../hooks/useMobileLayout'; -import { useSideSheetA11y } from '../hooks/useSideSheetA11y'; const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'] as const; const protocolThresholdMeta: Record<(typeof PROTOCOLS)[number], { label: string; description: string }> = { @@ -357,7 +356,6 @@ export default function AccessPage() { }, [mobileLayout]); const rows = vehiclesQuery.data?.items ?? []; const selected = rows.find((row) => row.vin === selectedVIN); const mobileFiltersOpen = mobileLayout && !filtersCollapsed; - useSideSheetA11y(mobileFiltersOpen, '.v2-access-mobile-filter-sidesheet', 'v2-access-mobile-filters', '接入车辆筛选', '关闭接入车辆筛选'); const syncURL = (filters: Filters) => { const next = new URLSearchParams(); Object.entries(filters).forEach(([key, value]) => { if (value) next.set(key, value); }); setSearchParams(next, { replace: true }); }; const apply = (next: Filters) => { setDraft(next); setCriteria(next); setOffset(0); setSelectedVIN(''); syncURL(next); }; const applyDraft = () => { apply(draft); setFiltersCollapsed(true); }; @@ -416,6 +414,7 @@ export default function AccessPage() { title="接入筛选" summary={activeFilterCount ? `已启用 ${activeFilterCount} 项` : '全部主车辆'} expanded={mobileFiltersOpen} + controls="v2-access-mobile-filters" expandedLabel="关闭" collapsedLabel="修改" onToggle={() => setFiltersCollapsed((value) => !value)} @@ -425,6 +424,8 @@ export default function AccessPage() { visible={mobileFiltersOpen} height="min(76dvh, 640px)" ariaLabel="接入车辆筛选" + dialogId="v2-access-mobile-filters" + closeLabel="关闭接入车辆筛选" title="筛选接入车辆" description="车辆、接入状态、真实协议与品牌" onCancel={() => setFiltersCollapsed(true)} diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx index a7c7277c..5e334e62 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx @@ -1,5 +1,5 @@ import { IconAlarm, IconBell, IconChevronRight, IconClose, IconFilter, IconPlus, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons'; -import { Button, Card, CardGroup, Collapse, Descriptions, Empty, Input, Radio, Select, SideSheet, Spin, Table, Tag, TextArea, Timeline, Typography } from '@douyinfe/semi-ui'; +import { Button, Card, CardGroup, Collapse, Descriptions, Empty, Input, Radio, Select, Spin, Table, Tag, TextArea, Timeline, Typography } from '@douyinfe/semi-ui'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { FormEvent, memo, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; @@ -23,7 +23,6 @@ import { usePlatformSession } from '../auth/AuthGate'; import { canAdminister, canOperate } from '../auth/session'; import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy'; import { useMobileLayout } from '../hooks/useMobileLayout'; -import { useSideSheetA11y } from '../hooks/useSideSheetA11y'; type Tab = 'events' | 'rules' | 'notifications'; type Filters = { keyword: string; severity: string; status: string; ruleId: string; protocol: string; dateFrom: string; dateTo: string }; @@ -196,8 +195,6 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e const selectEvent = useCallback((id: string) => setSelection({ scope: eventScope, id }), [eventScope]); const selectedEvent = detail.data ?? rows.find((row) => row.id === selectedID); const mobileFiltersOpen = mobileLayout && !filtersCollapsed; - useSideSheetA11y(!mobileLayout && advancedOpen, '.v2-alert-filter-sidesheet', 'v2-alert-advanced-filters', '告警高级筛选', '关闭告警高级筛选'); - useSideSheetA11y(mobileFiltersOpen, '.v2-alert-mobile-filter-sidesheet', 'v2-alert-mobile-filters', '告警事件筛选', '关闭告警事件筛选'); const applyDraft = () => { setFilters(draft); setOffset(0); @@ -218,6 +215,7 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e const sums = summary.data; const activeFilterCount = Object.values(filters).filter(Boolean).length; const advancedFilterCount = [filters.ruleId, filters.protocol, filters.dateFrom, filters.dateTo].filter(Boolean).length; + const draftAdvancedFilterCount = [draft.ruleId, draft.protocol, draft.dateFrom, draft.dateTo].filter(Boolean).length; const allEventCount = Number(sums?.active ?? 0) + Number(sums?.recovered ?? 0) + Number(sums?.closed ?? 0) + Number(sums?.ignored ?? 0); const inspector = { const event = detail.data; if (event) action.mutate({ name, event }); }} onClose={closeDetail} />; const coreFilterFields = <> @@ -249,6 +247,7 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e title="事件筛选" summary={activeFilterCount ? `已启用 ${activeFilterCount} 项` : '全部告警'} expanded={mobileFiltersOpen} + controls="v2-alert-mobile-filters" expandedLabel="关闭" collapsedLabel="修改" onToggle={() => setFiltersCollapsed((value) => !value)} @@ -258,6 +257,8 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e visible={mobileFiltersOpen} height="min(82dvh, 690px)" ariaLabel="告警事件筛选" + dialogId="v2-alert-mobile-filters" + closeLabel="关闭告警事件筛选" title="筛选告警事件" description="车辆、状态、规则与时间范围" onCancel={() => setFiltersCollapsed(true)} @@ -328,18 +329,31 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e {!mobileLayout && selectedID ? inspector : null} - {!mobileLayout ? 更多筛选按规则、协议和触发时间收窄告警范围} + title="更多筛选" + description="按规则、协议和触发时间收窄告警范围" + icon={} + badge={draftAdvancedFilterCount ? `${draftAdvancedFilterCount} 项待应用` : '全部范围'} + badgeColor={draftAdvancedFilterCount ? 'blue' : 'grey'} + summaryItems={[ + { label: '规则', value: rules.find((rule) => rule.id === draft.ruleId)?.name || '全部规则', tone: draft.ruleId ? 'primary' : 'neutral' }, + { label: '协议', value: draft.protocol || '全部协议', tone: draft.protocol ? 'primary' : 'neutral' }, + { label: '触发时间', value: draft.dateFrom || draft.dateTo ? '已限定' : '不限时间', detail: draft.dateFrom || draft.dateTo ? '按本地时间查询' : undefined, tone: draft.dateFrom || draft.dateTo ? 'primary' : 'neutral' } + ]} + footerNote="高级条件仅在应用后更新事件列表" + secondaryActions={[{ label: '清空高级条件', onClick: () => setDraft({ ...draft, ruleId: '', protocol: '', dateFrom: '', dateTo: '' }) }]} + primaryAction={{ label: '应用筛选', onClick: () => { applyDraft(); setAdvancedOpen(false); } }} onCancel={() => setAdvancedOpen(false)} - footer={
} >
{advancedFilterFields}
-
+ : null} { const parsed = parseHistoryKeywords(draft.keywords); @@ -453,6 +452,7 @@ export default function HistoryPage() { title="查询范围" summary={keywords.length ? `${keywords.length} 辆 · ${criteria.category === 'location' ? '位置数据' : criteria.category === 'raw' ? '原始报文' : '日里程'}` : '请选择车辆'} expanded={mobileFiltersOpen} + controls="v2-history-filter-sheet" expandedLabel="关闭" collapsedLabel="修改" onToggle={() => setFiltersCollapsed((value) => !value)} @@ -462,6 +462,8 @@ export default function HistoryPage() { visible={mobileFiltersOpen} height="min(82dvh, 700px)" ariaLabel="历史查询范围" + dialogId="v2-history-filter-sheet" + closeLabel="关闭历史查询范围" title="筛选历史数据" description="车辆、时间、数据类型与协议来源" onCancel={() => setFiltersCollapsed(true)} diff --git a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx index 4e147d88..3bee16fa 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx @@ -423,6 +423,7 @@ test('allows provider maintenance but keeps canonical source policy read only', const filterDialog = await screen.findByRole('dialog', { name: '诊断车辆筛选' }); expect(document.querySelector('.v2-source-mobile-filter-sidesheet')).toHaveClass('semi-sidesheet-bottom'); expect(document.querySelector('.v2-source-mobile-filter-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(78dvh, 660px)' }); + expect(within(filterDialog).getByText('输入车牌或 VIN 开始查找')).toBeInTheDocument(); fireEvent.change(within(filterDialog).getByLabelText('按车牌或 VIN 搜索诊断车辆'), { target: { value: '沪A' } }); const candidateButton = await waitFor(() => { const button = filterDialog.querySelector('.v2-source-candidates button'); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx index c192d048..eea9ff4a 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx @@ -1,5 +1,5 @@ import { IconRefresh, IconSearch, IconTickCircle } from '@douyinfe/semi-icons'; -import { Button, Card, CardGroup, Checkbox, Descriptions, Empty, Input, Progress, SideSheet, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui'; +import { Button, Card, CardGroup, Checkbox, Descriptions, Empty, Input, Progress, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; @@ -7,6 +7,7 @@ import { api } from '../../api/client'; import type { VehicleCoverageRow, VehicleLocationSourceEvidence, VehicleSourceDiagnostic } from '../../api/types'; import { InlineError } from '../shared/AsyncState'; import { MobileFilterToggle } from '../shared/MobileFilterToggle'; +import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet'; import { PlatformTime } from '../shared/PlatformTime'; import { SegmentedTabs } from '../shared/SegmentedTabs'; import { VehicleCandidateList } from '../shared/VehicleCandidateList'; @@ -16,7 +17,6 @@ import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader'; import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet'; import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy'; import { useMobileLayout } from '../hooks/useMobileLayout'; -import { useSideSheetA11y } from '../hooks/useSideSheetA11y'; import ReconciliationCenter from './ReconciliationCenter'; type OperationsWorkspace = 'reconciliation' | 'diagnostic' | 'health'; @@ -296,7 +296,6 @@ function SourceDiagnosticWorkspace() { if (selectedLabel) setKeyword(selectedLabel); setFiltersCollapsed(true); }; - useSideSheetA11y(mobileFiltersOpen, '.v2-source-mobile-filter-sidesheet', 'v2-source-mobile-filter-sheet', '诊断车辆筛选', '关闭诊断车辆筛选'); const filterStatus = selectedLabel ? `已选 ${selectedLabel}` : deferredKeyword @@ -334,28 +333,35 @@ function SourceDiagnosticWorkspace() { title="诊断车辆" summary={selectedLabel ? `已选 ${selectedLabel}` : '尚未选择车辆'} expanded={mobileFiltersOpen} + controls="v2-source-mobile-filter-sheet" expandedLabel="关闭" collapsedLabel="修改" onToggle={() => mobileFiltersOpen ? closeMobileFilters() : setFiltersCollapsed(false)} /> - 选择诊断车辆按车牌或 VIN 搜索已绑定车辆} + ariaLabel="诊断车辆筛选" + dialogId="v2-source-mobile-filter-sheet" + closeLabel="关闭诊断车辆筛选" + title="选择诊断车辆" + description="按车牌或 VIN 搜索已绑定车辆" onCancel={closeMobileFilters} - footer={
} + secondaryAction={{ label: '清除选择', disabled: !selected && !keyword, onClick: clearSelection }} + primaryAction={{ label: '诊断首个结果', disabled: !browsingCandidates || !candidates.data?.items.length, onClick: chooseFirst }} >
-
-
车辆范围{selectedLabel ? `当前诊断 ${selectedLabel},选择其他车辆后再切换` : '每次只读取一辆车的来源证据,不扫描整车队'}
+ {sourceSearchInput} -
- {sourceCandidateList} + + {sourceCandidateList ??
+ + {selectedLabel ? '输入新关键词切换车辆' : '输入车牌或 VIN 开始查找'} + {selectedLabel ? `当前保留 ${selectedLabel},关闭筛选不会改变诊断结果` : '候选结果会同时展示车牌、VIN 与已接协议'} +
}
-
+ : (null); const tableScrollRef = useRef(null); const mountedRef = useRef(true); - useSideSheetA11y(mobileFiltersOpen, '.v2-mileage-filter-sidesheet', 'v2-mileage-filter-panel', '里程范围筛选', '关闭里程范围筛选'); useEffect(() => { mountedRef.current = true; return () => { @@ -604,6 +602,7 @@ export default function StatisticsPage() { title="里程范围" summary={rangeSummary} expanded={mobileFiltersOpen} + controls="v2-mileage-filter-panel" expandedLabel="关闭" collapsedLabel="修改" onToggle={toggleFilters} @@ -614,6 +613,8 @@ export default function StatisticsPage() { visible={mobileFiltersOpen} height="min(82dvh, 690px)" ariaLabel="里程范围筛选" + dialogId="v2-mileage-filter-panel" + closeLabel="关闭里程范围筛选" title="里程范围" description="选择车辆、自然日与数据源" onCancel={closeMobileFilters} diff --git a/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx index acfd05d0..0c165c45 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx @@ -5,7 +5,6 @@ import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from 'react import { api } from '../../api/client'; import type { AdminUser, CustomerUserInput, CustomerVehicleGrantInput } from '../../api/types'; import { useMobileLayout } from '../hooks/useMobileLayout'; -import { useSideSheetA11y } from '../hooks/useSideSheetA11y'; import { MobileFilterToggle } from '../shared/MobileFilterToggle'; import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet'; import { TablePagination } from '../shared/TablePagination'; @@ -160,7 +159,6 @@ export default function UsersPage() { const vehicleComposerVisible = !mobileLayout || creating || draft.vehicleGrants.length === 0 || vehicleComposerOpen; const hasUnsavedChanges = useMemo(() => draftSignature(draft) !== draftSignature(baselineDraft), [baselineDraft, draft]); const draftAccessState = customerAccessState(draft.status, draft.menuKeys.length, draft.vehicleGrants.length); - useSideSheetA11y(mobileFiltersOpen, '.v2-user-mobile-filter-sidesheet', 'v2-user-mobile-filters', '客户账号筛选', '关闭客户账号筛选'); useEffect(() => { if (!creating && selected) { @@ -387,6 +385,7 @@ export default function UsersPage() { title="账号范围" summary={`${customerScopeLabel(customerStatus)}${customerKeyword.trim() ? ` · ${customerKeyword.trim()}` : ''}`} expanded={mobileFiltersOpen} + controls="v2-user-mobile-filters" expandedLabel="关闭" collapsedLabel="修改" onToggle={mobileFiltersOpen ? closeMobileFilters : openMobileFilters} @@ -396,6 +395,8 @@ export default function UsersPage() { visible={mobileFiltersOpen} height="min(62dvh, 480px)" ariaLabel="客户账号筛选" + dialogId="v2-user-mobile-filters" + closeLabel="关闭客户账号筛选" title="筛选客户账号" description="按账号信息与权限状态缩小客户范围" onCancel={closeMobileFilters} 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 503ee1b0..c3cf425a 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/VehicleSearchWorkspace.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/VehicleSearchWorkspace.tsx @@ -9,7 +9,6 @@ import { usePlatformSession } from '../auth/AuthGate'; import { canAdminister } from '../auth/session'; import { formatZhNumber } from '../domain/formatters'; 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'; @@ -99,7 +98,6 @@ export default function VehicleSearchWorkspace() { setFiltersCollapsed(mobileLayout); setCandidatesOpen(false); }, [mobileLayout]); - useSideSheetA11y(mobileFiltersOpen, '.v2-vehicle-mobile-filter-sidesheet', 'v2-vehicle-mobile-filter-dialog', '车辆搜索', '关闭车辆搜索'); const openCandidates = () => { window.clearTimeout(closeTimerRef.current); if (!mobileLayout) setCandidatesOpen(Boolean(keyword.trim())); @@ -168,6 +166,7 @@ export default function VehicleSearchWorkspace() { title="车辆范围" summary={mobileFilterSummary} expanded={mobileFiltersOpen} + controls="v2-vehicle-mobile-filter-dialog" expandedLabel="关闭" collapsedLabel="修改" onToggle={mobileFiltersOpen ? closeMobileFilters : openMobileFilters} @@ -177,6 +176,8 @@ export default function VehicleSearchWorkspace() { visible={mobileFiltersOpen} height="min(68dvh, 560px)" ariaLabel="车辆搜索" + dialogId="v2-vehicle-mobile-filter-dialog" + closeLabel="关闭车辆搜索" title="查找车辆" description="车牌优先,支持 VIN 与终端手机号" onCancel={closeMobileFilters} diff --git a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts index 55286883..5ae4f9fa 100644 --- a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts @@ -189,7 +189,8 @@ describe('V2 production entry', () => { expect(corePageSources.AlertsPage).toContain(' { expect(corePageSources.OperationsPage).toContain(' { expect(corePageSources.StatisticsPage).toContain(' { expect(workspaceSideSheetSource).toContain("from '@douyinfe/semi-ui'"); expect(workspaceSideSheetSource).toContain('); const dialog = screen.getByRole('dialog', { name: '测试筛选' }); - expect(dialog.closest('.semi-sidesheet')).toHaveClass('v2-mobile-filter-sheet', 'test-filter-sheet'); + expect(dialog).toHaveAttribute('id', 'test-filter-dialog'); + expect(dialog.closest('.semi-sidesheet')).toHaveClass('v2-workspace-filter-sidesheet', 'v2-mobile-filter-sheet', 'test-filter-sheet'); expect(screen.getByText('按车辆和状态缩小范围')).toBeInTheDocument(); + expect(screen.getByText('筛选范围')).toBeInTheDocument(); + expect(screen.getByText('筛选条件仅在应用后更新当前结果')).toBeInTheDocument(); expect(screen.getByRole('region', { name: '车辆条件' })).toHaveTextContent('支持车牌或 VIN'); fireEvent.click(screen.getByRole('button', { name: '重置条件' })); expect(screen.getByRole('dialog', { name: '测试筛选' })).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '应用并查询' })); expect(onReset).toHaveBeenCalledTimes(1); expect(onApply).toHaveBeenCalledTimes(1); - fireEvent.click(screen.getByRole('button', { name: 'close' })); + fireEvent.click(screen.getByRole('button', { name: '关闭测试筛选' })); 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 index 7394d03f..f93d5107 100644 --- a/vehicle-data-platform/apps/web/src/v2/shared/MobileFilterSheet.tsx +++ b/vehicle-data-platform/apps/web/src/v2/shared/MobileFilterSheet.tsx @@ -1,5 +1,6 @@ -import { Button, SideSheet } from '@douyinfe/semi-ui'; -import { useEffect, useId, type ReactNode } from 'react'; +import { IconFilter } from '@douyinfe/semi-icons'; +import { type ReactNode } from 'react'; +import { WorkspaceSideSheet } from './WorkspaceSideSheet'; type MobileFilterSheetAction = { label: string; @@ -13,6 +14,8 @@ type MobileFilterSheetProps = { visible: boolean; height?: string | number; ariaLabel: string; + dialogId?: string; + closeLabel?: string; title: string; description: string; onCancel: () => void; @@ -26,6 +29,8 @@ export function MobileFilterSheet({ visible, height = 'min(82dvh, 700px)', ariaLabel, + dialogId, + closeLabel, title, description, onCancel, @@ -34,40 +39,27 @@ export function MobileFilterSheet({ 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}} + title={title} + description={description} + icon={} + badge="筛选范围" onCancel={onCancel} - footer={
- {secondaryAction ? :
} + footerNote="筛选条件仅在应用后更新当前结果" + secondaryActions={secondaryAction ? [secondaryAction] : []} + primaryAction={primaryAction} > {children} -
; + ; } type MobileFilterSheetSectionProps = { diff --git a/vehicle-data-platform/apps/web/src/v2/shared/MobileFilterToggle.test.tsx b/vehicle-data-platform/apps/web/src/v2/shared/MobileFilterToggle.test.tsx index 984a3956..1fd9a378 100644 --- a/vehicle-data-platform/apps/web/src/v2/shared/MobileFilterToggle.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/shared/MobileFilterToggle.test.tsx @@ -5,15 +5,16 @@ import { MobileFilterToggle } from './MobileFilterToggle'; describe('MobileFilterToggle', () => { test('exposes the current filter summary and expanded state through a Semi button', () => { const onToggle = vi.fn(); - const { rerender } = render(); + const { rerender } = render(); const collapsed = screen.getByRole('button', { name: '修改筛选条件:全部车辆 · 7 天' }); expect(collapsed).toHaveClass('semi-button'); expect(collapsed).toHaveAttribute('aria-expanded', 'false'); + expect(collapsed).toHaveAttribute('aria-controls', 'vehicle-filter-dialog'); fireEvent.click(collapsed); expect(onToggle).toHaveBeenCalledTimes(1); - rerender(); + rerender(); expect(screen.getByRole('button', { name: '收起筛选条件:已选 2 辆 · 7 天' })).toHaveAttribute('aria-expanded', 'true'); }); }); diff --git a/vehicle-data-platform/apps/web/src/v2/shared/MobileFilterToggle.tsx b/vehicle-data-platform/apps/web/src/v2/shared/MobileFilterToggle.tsx index 2b4e3714..6ff7e692 100644 --- a/vehicle-data-platform/apps/web/src/v2/shared/MobileFilterToggle.tsx +++ b/vehicle-data-platform/apps/web/src/v2/shared/MobileFilterToggle.tsx @@ -6,6 +6,7 @@ type MobileFilterToggleProps = { summary: string; expanded: boolean; onToggle: () => void; + controls?: string; expandedLabel?: string; collapsedLabel?: string; }; @@ -15,6 +16,7 @@ export function MobileFilterToggle({ summary, expanded, onToggle, + controls, expandedLabel = '收起', collapsedLabel = '展开' }: MobileFilterToggleProps) { @@ -26,6 +28,7 @@ export function MobileFilterToggle({ block htmlType="button" aria-expanded={expanded} + aria-controls={controls} aria-label={`${actionLabel}${title}:${summary}`} onClick={onToggle} > diff --git a/vehicle-data-platform/apps/web/src/v2/shared/WorkspaceSideSheet.test.tsx b/vehicle-data-platform/apps/web/src/v2/shared/WorkspaceSideSheet.test.tsx index 074888a5..6155701f 100644 --- a/vehicle-data-platform/apps/web/src/v2/shared/WorkspaceSideSheet.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/shared/WorkspaceSideSheet.test.tsx @@ -41,7 +41,7 @@ test('renders one accessible Semi configuration sheet with shared summary and ac expect(onCancel).toHaveBeenCalledTimes(1); }); -test('exposes task, editor, help and action variants without changing the accessible dialog contract', () => { +test('exposes task, editor, help, action and filter variants without changing the accessible dialog contract', () => { const view = render(<> 操作内容 + undefined} + > + 筛选内容 + ); expect(screen.getByRole('dialog', { name: '后台任务' }).closest('.semi-sidesheet')).toHaveClass('v2-workspace-task-sidesheet'); expect(screen.getByRole('dialog', { name: '策略编辑' }).closest('.semi-sidesheet')).toHaveClass('v2-workspace-editor-sidesheet'); expect(screen.getByRole('dialog', { name: '页面帮助' }).closest('.semi-sidesheet')).toHaveClass('v2-workspace-help-sidesheet'); expect(screen.getByRole('dialog', { name: '快捷操作' }).closest('.semi-sidesheet')).toHaveClass('v2-workspace-action-sidesheet'); + expect(screen.getByRole('dialog', { name: '范围筛选' }).closest('.semi-sidesheet')).toHaveClass('v2-workspace-filter-sidesheet'); expect(screen.getByText('任务内容')).toBeInTheDocument(); expect(screen.getByText('编辑内容')).toBeInTheDocument(); expect(screen.getByText('帮助内容')).toBeInTheDocument(); expect(screen.getByText('操作内容')).toBeInTheDocument(); + expect(screen.getByText('筛选内容')).toBeInTheDocument(); expect(screen.getByTestId('help-icon').closest('.v2-workspace-config-title-icon')).toHaveAttribute('aria-hidden', 'true'); expect(view.baseElement).toHaveTextContent('查看生成进度'); expect(view.baseElement).toHaveTextContent('保存后生效'); diff --git a/vehicle-data-platform/apps/web/src/v2/shared/WorkspaceSideSheet.tsx b/vehicle-data-platform/apps/web/src/v2/shared/WorkspaceSideSheet.tsx index 6cb99f5a..3fb3fc14 100644 --- a/vehicle-data-platform/apps/web/src/v2/shared/WorkspaceSideSheet.tsx +++ b/vehicle-data-platform/apps/web/src/v2/shared/WorkspaceSideSheet.tsx @@ -21,7 +21,7 @@ export type WorkspaceSideSheetBadgeColor = 'blue' | 'green' | 'orange' | 'red' | export type WorkspaceSideSheetProps = { className?: string; - variant?: 'config' | 'detail' | 'task' | 'editor' | 'help' | 'action'; + variant?: 'config' | 'detail' | 'task' | 'editor' | 'help' | 'action' | 'filter'; visible: boolean; ariaLabel: string; closeLabel?: string; 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 d69ed93b..516d0f30 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css @@ -20599,14 +20599,14 @@ * Shared Semi UI configuration SideSheet: one hierarchy for strategy, * column and source-policy work across desktop and mobile. */ -.v2-workspace-config-sidesheet .semi-sidesheet-inner { +:is(.v2-workspace-config-sidesheet, .v2-workspace-filter-sidesheet) .semi-sidesheet-inner { overflow: hidden; border-left: 1px solid #d9e3ef; background: #f5f7fa; box-shadow: -22px 0 64px rgba(24, 43, 68, .16); } -.v2-workspace-config-sidesheet .semi-sidesheet-header { +:is(.v2-workspace-config-sidesheet, .v2-workspace-filter-sidesheet) .semi-sidesheet-header { min-height: 78px; border-bottom: 1px solid #e1e8f1; background: @@ -20615,7 +20615,7 @@ padding: 14px 18px; } -.v2-workspace-config-sidesheet .semi-sidesheet-title { +:is(.v2-workspace-config-sidesheet, .v2-workspace-filter-sidesheet) .semi-sidesheet-title { min-width: 0; } @@ -20667,7 +20667,7 @@ font-weight: 700; } -.v2-workspace-config-sidesheet .semi-sidesheet-body { +:is(.v2-workspace-config-sidesheet, .v2-workspace-filter-sidesheet) .semi-sidesheet-body { min-height: 0; overflow: hidden; background: #f5f7fa; @@ -20760,7 +20760,7 @@ scrollbar-gutter: stable; } -.v2-workspace-config-sidesheet .semi-sidesheet-footer { +:is(.v2-workspace-config-sidesheet, .v2-workspace-filter-sidesheet) .semi-sidesheet-footer { min-height: 64px; border-top: 1px solid #e1e8f1; background: rgba(255, 255, 255, .97); @@ -20981,14 +20981,14 @@ padding: 0; } - .v2-workspace-config-sidesheet.semi-sidesheet-bottom .semi-sidesheet-inner { + :is(.v2-workspace-config-sidesheet, .v2-workspace-filter-sidesheet).semi-sidesheet-bottom .semi-sidesheet-inner { border: 1px solid #d8e2ed; border-bottom: 0; border-radius: 18px 18px 0 0; box-shadow: 0 -18px 48px rgba(23, 43, 68, .17); } - .v2-workspace-config-sidesheet.semi-sidesheet-bottom .semi-sidesheet-header { + :is(.v2-workspace-config-sidesheet, .v2-workspace-filter-sidesheet).semi-sidesheet-bottom .semi-sidesheet-header { min-height: 68px; padding: 10px 12px 10px 14px; } @@ -21032,7 +21032,7 @@ padding: 8px; } - .v2-workspace-config-sidesheet.semi-sidesheet-bottom .semi-sidesheet-footer { + :is(.v2-workspace-config-sidesheet, .v2-workspace-filter-sidesheet).semi-sidesheet-bottom .semi-sidesheet-footer { min-height: 58px; padding: 7px 9px calc(7px + env(safe-area-inset-bottom)); } @@ -21971,3 +21971,159 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); } } + +/* + * Unified Semi UI filter workbench. + * Desktop advanced ranges and mobile discovery sheets now share one identity, + * one scroll owner and one explicit draft-to-apply action boundary. + */ +.v2-workspace-filter-sidesheet .semi-sidesheet-inner { + background: #f4f7fb; +} + +.v2-workspace-filter-sidesheet .semi-sidesheet-header { + background: + radial-gradient(circle at 10% 0%, rgba(18, 104, 243, .1), transparent 38%), + linear-gradient(180deg, #fff 0%, #fbfcfe 100%); +} + +.v2-workspace-filter-sidesheet .v2-workspace-config-title-icon { + border-color: #cfe0f7; + background: linear-gradient(145deg, #f4f8ff 0%, #e7f0ff 100%); +} + +.v2-workspace-filter-sidesheet .v2-workspace-config-summary { + min-height: 74px; +} + +.v2-workspace-filter-sidesheet .v2-workspace-config-summary strong { + font-size: 12px; +} + +.v2-workspace-filter-sidesheet .v2-workspace-config-content { + padding: 10px; +} + +.v2-workspace-filter-sidesheet .v2-alert-advanced-filter { + gap: 13px; + border: 1px solid #dce5ef; + border-radius: 12px; + background: #fff; + padding: 14px; + box-shadow: 0 8px 24px rgba(31, 53, 80, .05); +} + +.v2-workspace-filter-sidesheet .v2-alert-advanced-filter label { + gap: 6px; +} + +@media (max-width: 680px) { + .v2-workspace-filter-sidesheet.semi-sidesheet-bottom .semi-sidesheet-inner { + width: 100% !important; + max-width: 100%; + padding-bottom: env(safe-area-inset-bottom); + } + + .v2-workspace-filter-sidesheet .v2-workspace-config-title { + gap: 8px; + } + + .v2-workspace-filter-sidesheet .v2-workspace-config-title-icon { + width: 34px; + height: 34px; + border-radius: 10px; + font-size: 16px; + } + + .v2-workspace-filter-sidesheet .v2-workspace-config-title strong { + font-size: 16px; + } + + .v2-workspace-filter-sidesheet .v2-workspace-config-title small { + max-width: min(48vw, 210px); + } + + .v2-workspace-filter-sidesheet .v2-workspace-config-content { + padding: 8px; + } + + .v2-workspace-filter-sidesheet .v2-mobile-filter-sheet-section { + border-color: #dce5ef; + border-radius: 11px; + box-shadow: 0 7px 22px rgba(31, 53, 80, .045); + } + + .v2-source-mobile-filter-sidesheet .v2-source-candidates.v2-vehicle-candidate-list { + position: static; + max-height: min(390px, 43dvh); + } + + .v2-source-mobile-filter-empty { + display: grid; + min-height: 190px; + place-content: center; + justify-items: center; + gap: 7px; + border: 1px dashed #cad8e9; + border-radius: 12px; + background: + radial-gradient(circle at 50% 0%, rgba(18, 104, 243, .07), transparent 48%), + rgba(255, 255, 255, .72); + color: #7b8b9f; + padding: 20px; + text-align: center; + } + + .v2-source-mobile-filter-empty > i { + display: grid; + width: 42px; + height: 42px; + place-items: center; + border: 1px solid #d4e2f5; + border-radius: 12px; + background: #eef5ff; + color: #1268f3; + font-size: 18px; + } + + .v2-source-mobile-filter-empty > strong { + color: #2b4058; + font-size: 13px; + } + + .v2-source-mobile-filter-empty > small { + max-width: 250px; + color: #7c8b9e; + font-size: 9px; + line-height: 1.55; + } +} + +@media (max-width: 900px) and (max-height: 480px) and (orientation: landscape) { + .v2-workspace-filter-sidesheet.semi-sidesheet-bottom .semi-sidesheet-inner { + height: 100dvh !important; + max-height: 100dvh; + border-radius: 0; + } + + .v2-workspace-filter-sidesheet.semi-sidesheet-bottom .semi-sidesheet-header { + min-height: 54px; + padding: 7px 10px 7px 12px; + } + + .v2-workspace-filter-sidesheet .v2-workspace-config-title-icon, + .v2-workspace-filter-sidesheet .v2-workspace-config-title small, + .v2-workspace-filter-sidesheet .v2-workspace-config-title > .semi-tag, + .v2-workspace-filter-sidesheet .v2-mobile-filter-sheet-section > header { + display: none; + } + + .v2-workspace-filter-sidesheet .v2-workspace-config-content { + padding: 7px; + } + + .v2-source-mobile-filter-empty { + min-height: 88px; + padding: 10px; + } +}