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 4393a8ca..d8c3e0b1 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 @@ -409,18 +409,32 @@ test('allows provider maintenance but keeps canonical source policy read only', const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); renderOperations(client); fireEvent.click(screen.getByRole('tab', { name: '单车诊断' })); - fireEvent.change(screen.getByLabelText('按车牌或 VIN 搜索诊断车辆'), { target: { value: '沪A' } }); + const filterToggle = screen.getByRole('button', { name: '修改诊断车辆:尚未选择车辆' }); + expect(filterToggle).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(filterToggle); + 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)' }); + fireEvent.change(within(filterDialog).getByLabelText('按车牌或 VIN 搜索诊断车辆'), { target: { value: '沪A' } }); const candidateButton = await waitFor(() => { - const button = document.querySelector('.v2-source-candidates button'); + const button = filterDialog.querySelector('.v2-source-candidates button'); expect(button).toBeTruthy(); return button!; }); fireEvent.click(candidateButton); expect(await screen.findByText('协议融合快照只能维护提供方')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: '修改诊断车辆:已选 沪A00001' })).toHaveAttribute('aria-expanded', 'false'); - expect(document.querySelector('.v2-source-filter-panel')).toHaveClass('is-mobile-collapsed'); - expect(document.querySelector('.v2-source-search')).toHaveClass('is-mobile-collapsed'); + const selectedToggle = screen.getByRole('button', { name: '修改诊断车辆:已选 沪A00001' }); + expect(selectedToggle).toHaveAttribute('aria-expanded', 'false'); + expect(filterDialog).toHaveClass('semi-sidesheet-animation-content_hide_bottom'); + fireEvent.click(selectedToggle); + const selectedSearch = within(filterDialog).getByLabelText('按车牌或 VIN 搜索诊断车辆'); + expect(selectedSearch).toHaveValue('沪A00001'); + fireEvent.change(selectedSearch, { target: { value: '其他车辆' } }); + fireEvent.click(within(filterDialog).getByRole('button', { name: '关闭诊断车辆筛选' })); + fireEvent.click(selectedToggle); + expect(selectedSearch).toHaveValue('沪A00001'); + fireEvent.click(within(filterDialog).getByRole('button', { name: '关闭诊断车辆筛选' })); expect(document.querySelector('.v2-source-table')).not.toBeInTheDocument(); expect(document.querySelector('.v2-source-mobile-card.semi-card')).toBeInTheDocument(); expect(document.querySelector('.v2-source-mobile-descriptions.semi-descriptions')).toBeInTheDocument(); 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 9e8f5f2f..04a772bf 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx @@ -6,6 +6,7 @@ import { useSearchParams } from 'react-router-dom'; import { api } from '../../api/client'; import type { VehicleCoverageRow, VehicleLocationSourceEvidence, VehicleSourceDiagnostic } from '../../api/types'; import { InlineError } from '../shared/AsyncState'; +import { MobileFilterToggle } from '../shared/MobileFilterToggle'; import { PlatformTime } from '../shared/PlatformTime'; import { SegmentedTabs } from '../shared/SegmentedTabs'; import { VehicleCandidateList } from '../shared/VehicleCandidateList'; @@ -238,7 +239,7 @@ function SourceDiagnosticWorkspace() { const deferredKeyword = useDeferredValue(keyword.trim()); const [selected, setSelected] = useState(); const [candidateOffset, setCandidateOffset] = useState(0); - const [filtersCollapsed, setFiltersCollapsed] = useState(false); + const [filtersCollapsed, setFiltersCollapsed] = useState(() => mobileLayout); useEffect(() => setCandidateOffset(0), [deferredKeyword]); const candidates = useQuery({ queryKey: ['ops-source-candidates', deferredKeyword, candidateOffset], @@ -260,14 +261,29 @@ function SourceDiagnosticWorkspace() { setKeyword(vehicle.plate || vehicle.vin); if (mobileLayout) setFiltersCollapsed(true); }; - const submit = (event: FormEvent) => { - event.preventDefault(); + const chooseFirst = () => { const first = candidates.data?.items[0]; if (first) choose(first); }; + const submit = (event: FormEvent) => { + event.preventDefault(); + chooseFirst(); + }; + const clearSelection = () => { + setKeyword(''); + setSelected(undefined); + setFiltersCollapsed(true); + }; const data = diagnostic.data; const editable = session.data?.role === 'admin'; const selectedLabel = selected ? selected.plate || selected.vin : ''; + const browsingCandidates = Boolean(deferredKeyword) && deferredKeyword !== selectedLabel; + const mobileFiltersOpen = mobileLayout && !filtersCollapsed; + const closeMobileFilters = () => { + if (selectedLabel) setKeyword(selectedLabel); + setFiltersCollapsed(true); + }; + useSideSheetA11y(mobileFiltersOpen, '.v2-source-mobile-filter-sidesheet', 'v2-source-mobile-filter-sheet', '诊断车辆筛选', '关闭诊断车辆筛选'); const filterStatus = selectedLabel ? `已选 ${selectedLabel}` : deferredKeyword @@ -281,8 +297,53 @@ function SourceDiagnosticWorkspace() { queryClient.invalidateQueries({ queryKey: ['ops-source-readiness-v2'] }) ]); }; + const sourceSearchInput = } + value={keyword} + onChange={setKeyword} + placeholder="输入车牌或 VIN,支持模糊搜索" + />; + const sourceCandidateList = browsingCandidates ? 20 ? <>第 {Math.floor(candidateOffset / 20) + 1} / {Math.ceil((candidates.data?.total ?? 0) / 20)} 页
: undefined} + /> : null; return <> - + mobileFiltersOpen ? closeMobileFilters() : setFiltersCollapsed(false)} + /> + 选择诊断车辆按车牌或 VIN 搜索已绑定车辆} + onCancel={closeMobileFilters} + footer={
} + > +
+
+
车辆范围{selectedLabel ? `当前诊断 ${selectedLabel},选择其他车辆后再切换` : '每次只读取一辆车的来源证据,不扫描整车队'}
+ {sourceSearchInput} +
+ {sourceCandidateList} +
+
+ : setFiltersCollapsed((value) => !value)} >
- } value={keyword} onChange={(value) => { setKeyword(value); setSelected(undefined); }} placeholder="输入车牌或 VIN,支持模糊搜索" /> - - {deferredKeyword && !selected ? 20 ? <>第 {Math.floor(candidateOffset / 20) + 1} / {Math.ceil((candidates.data?.total ?? 0) / 20)} 页
: undefined} - /> : null} + {sourceSearchInput} + + {sourceCandidateList} -
+
} .v2-mobile-filter-toggle.semi-button { + min-height: 58px; + margin: 0; + } + + .v2-source-mobile-filter-sidesheet .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-source-mobile-filter-sidesheet.semi-sidesheet-bottom .semi-sidesheet-header { + min-height: 68px; + border-bottom: 1px solid #dfe7f0; + background: rgba(255, 255, 255, .98); + padding: 11px 14px; + } + + .v2-source-mobile-filter-sidesheet.semi-sidesheet-bottom .semi-sidesheet-body { + min-height: 0; + overflow-y: auto; + background: #f4f7fb; + padding: 12px; + overscroll-behavior: contain; + } + + .v2-source-mobile-filter-sidesheet.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-source-mobile-filter-title { + display: grid; + gap: 3px; + } + + .v2-source-mobile-filter-title > strong { + color: #253a52; + font-size: 15px; + line-height: 1.3; + } + + .v2-source-mobile-filter-title > span { + color: #8090a3; + font-size: 10px; + font-weight: 500; + line-height: 1.4; + } + + .v2-source-mobile-filter-form { + display: grid; + gap: 10px; + } + + .v2-source-mobile-filter-section { + display: grid; + gap: 12px; + border: 1px solid #dfe7f0; + border-radius: 12px; + background: #fff; + padding: 12px; + box-shadow: 0 8px 24px rgba(31, 53, 80, .055); + } + + .v2-source-mobile-filter-section > header { + display: grid; + min-width: 0; + gap: 3px; + } + + .v2-source-mobile-filter-section > header > strong { + color: #2b4058; + font-size: 13px; + line-height: 1.3; + } + + .v2-source-mobile-filter-section > header > span { + color: #7c8b9e; + font-size: 10px; + line-height: 1.45; + } + + .v2-source-mobile-filter-section > .semi-input-wrapper { + width: 100%; + min-height: 44px; + border-radius: 9px; + background: #fff; + } + + .v2-source-mobile-filter-section > .semi-input-wrapper .semi-input { + height: 42px; + font-size: 13px; + } + + .v2-source-mobile-filter-sidesheet .v2-source-candidates.v2-vehicle-candidate-list { + position: static; + max-height: min(390px, 43dvh); + overflow-y: auto; + border: 1px solid #dfe7f0; + border-radius: 12px; + background: #fff; + box-shadow: 0 8px 24px rgba(31, 53, 80, .055); + overscroll-behavior: contain; + } + + .v2-source-mobile-filter-sidesheet .v2-source-candidates > .v2-vehicle-option.semi-button { + min-height: 64px; + padding: 9px 11px; + } + + .v2-source-mobile-filter-footer { + display: grid; + width: 100%; + grid-template-columns: minmax(0, .72fr) minmax(0, 1.28fr); + gap: 9px; + } + + .v2-source-mobile-filter-footer > .semi-button { + width: 100%; + min-height: 42px; + border-radius: 9px; + font-weight: 700; + } +}