diff --git a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx index 2084ebd6..588322cc 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx @@ -237,7 +237,7 @@ test('shows deduplicated Semi vehicle candidates and opens the selected VIN', as workspace.remove(); }); -test('defaults to a compact mobile vehicle directory and keeps eight recent vehicles', async () => { +test('keeps the mobile vehicle directory primary and applies search from a Semi bottom sheet', async () => { layout.mobile = true; const items = Array.from({ length: 10 }, (_, index) => ({ vin: `LTEST${String(index).padStart(12, '0')}`, @@ -265,24 +265,31 @@ test('defaults to a compact mobile vehicle directory and keeps eight recent vehi expect(screen.getAllByRole('button', { name: /打开 .* 车辆档案/ })).toHaveLength(8); expect(screen.getAllByText(/GB\/T 32960|JT\/T 808/).length).toBeGreaterThan(0); expect(view.container.querySelector('.v2-vehicle-directory-table')).not.toBeInTheDocument(); - expect(view.container.querySelector('.v2-vehicle-query-panel')).toHaveClass('is-mobile-collapsed'); + expect(view.container.querySelector('.v2-vehicle-query-panel')).not.toBeInTheDocument(); expect(screen.queryByLabelText('查车操作')).not.toBeInTheDocument(); - const toggle = screen.getByRole('button', { name: '修改车辆筛选:10 辆授权车辆' }); + const toggle = screen.getByRole('button', { name: '修改车辆范围:10 辆授权车辆' }); expect(toggle).toHaveAttribute('aria-expanded', 'false'); fireEvent.click(toggle); - expect(view.container.querySelector('.v2-vehicle-query-panel')).not.toHaveClass('is-mobile-collapsed'); const input = screen.getByRole('textbox', { name: '搜索车辆' }); expect(input).toBeVisible(); - expect(view.container.querySelector('.v2-vehicle-search-options')).not.toBeInTheDocument(); - fireEvent.focus(input); - expect(view.container.querySelector('.v2-vehicle-search-options')).not.toBeInTheDocument(); + expect(document.querySelector('.v2-vehicle-mobile-filter-sidesheet')).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: /打开 .* 车辆档案/ })).toHaveLength(8); fireEvent.change(input, { target: { value: items[0].plate } }); - await waitFor(() => expect(view.container.querySelector('.v2-vehicle-search-options')).toBeInTheDocument()); - expect(view.container.querySelector('.v2-vehicle-search-page')).toHaveClass('has-candidates'); - fireEvent.click(screen.getByRole('button', { name: /查询车辆/ })); - expect(view.container.querySelector('.v2-vehicle-search-options')).not.toBeInTheDocument(); - expect(view.container.querySelector('.v2-vehicle-search-page')).not.toHaveClass('has-candidates'); + expect(await screen.findByRole('listbox')).toHaveAttribute('id', 'v2-vehicle-mobile-search-options'); + expect(screen.getAllByRole('button', { name: /打开 .* 车辆档案/ })).toHaveLength(8); + fireEvent.click(screen.getByRole('button', { name: '应用搜索' })); + await waitFor(() => expect(screen.getByRole('button', { name: `修改车辆范围:${items[0].plate} · 10 辆匹配` })).toHaveAttribute('aria-expanded', 'false')); expect(screen.getByRole('heading', { name: '匹配车辆', level: 5 })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: `修改车辆范围:${items[0].plate} · 10 辆匹配` })); + fireEvent.change(screen.getByRole('textbox', { name: '搜索车辆' }), { target: { value: items[1].plate } }); + fireEvent.click(screen.getByRole('button', { name: '关闭车辆搜索' })); + await waitFor(() => expect(screen.getByRole('button', { name: `修改车辆范围:${items[0].plate} · 10 辆匹配` })).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: `修改车辆范围:${items[0].plate} · 10 辆匹配` })); + fireEvent.click(screen.getByRole('button', { name: '重置范围' })); + await waitFor(() => expect(screen.getByRole('button', { name: '修改车辆范围:10 辆授权车辆' })).toBeInTheDocument()); + expect(screen.getByRole('heading', { name: '授权车辆', level: 5 })).toBeInTheDocument(); }); test('keeps a partial vehicle query in the directory instead of navigating to an invalid detail route', async () => { 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 9dd57b74..28c18869 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, Table, Tag } from '@douyinfe/semi-ui'; +import { Button, Card, Empty, Input, SideSheet, 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'; @@ -9,7 +9,9 @@ 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 { ProtocolTag } from '../shared/ProtocolTag'; import { TablePagination } from '../shared/TablePagination'; import { VehicleCandidateList } from '../shared/VehicleCandidateList'; @@ -48,12 +50,15 @@ export default function VehicleSearchWorkspace() { const { session } = usePlatformSession(); const mobileLayout = useMobileLayout(); const [keyword, setKeyword] = useState(''); + const [draftKeyword, setDraftKeyword] = useState(''); const [page, setPage] = useState(1); const [syncOpen, setSyncOpen] = useState(false); const [candidatesOpen, setCandidatesOpen] = useState(false); const [filtersCollapsed, setFiltersCollapsed] = useState(mobileLayout); const closeTimerRef = useRef(); const deferredKeyword = useDeferredValue(keyword.trim()); + const deferredDraftKeyword = useDeferredValue(draftKeyword.trim()); + const mobileFiltersOpen = mobileLayout && !filtersCollapsed; const pageSize = mobileLayout ? 8 : 10; const directoryScope = useMemo(() => queryScopeKey({ keyword: deferredKeyword }), [deferredKeyword]); const candidateParams = useMemo(() => { @@ -68,7 +73,20 @@ export default function VehicleSearchWorkspace() { staleTime: 30_000, gcTime: QUERY_MEMORY.optionGcTime }); + const mobileCandidateParams = useMemo(() => { + const params = new URLSearchParams({ limit: '6', offset: '0' }); + if (deferredDraftKeyword) params.set('keyword', deferredDraftKeyword); + return params; + }, [deferredDraftKeyword]); + const mobileCandidates = useQuery>({ + queryKey: ['vehicle-mobile-candidates', queryScopeKey({ keyword: deferredDraftKeyword })], + queryFn: ({ signal }) => api.vehicleCoverage(mobileCandidateParams, signal), + enabled: mobileFiltersOpen && Boolean(deferredDraftKeyword), + staleTime: 30_000, + gcTime: QUERY_MEMORY.optionGcTime + }); const options = useMemo(() => mergeVehicleCandidates(candidates.data?.items ?? []), [candidates.data?.items]); + const mobileOptions = useMemo(() => mergeVehicleCandidates(mobileCandidates.data?.items ?? []).slice(0, 6), [mobileCandidates.data?.items]); const pageVehicles = useMemo(() => options.slice(0, pageSize), [options, pageSize]); const total = candidates.data?.total ?? 0; const totalPages = Math.max(1, Math.ceil(total / pageSize)); @@ -80,9 +98,10 @@ 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); - setCandidatesOpen(Boolean(keyword.trim())); + if (!mobileLayout) setCandidatesOpen(Boolean(keyword.trim())); }; const closeCandidates = () => { window.clearTimeout(closeTimerRef.current); @@ -99,13 +118,43 @@ export default function VehicleSearchWorkspace() { window.requestAnimationFrame(resetWorkspaceScroll); window.setTimeout(resetWorkspaceScroll, 120); }; + const openMobileFilters = () => { + setDraftKeyword(keyword); + setCandidatesOpen(false); + setFiltersCollapsed(false); + }; + const closeMobileFilters = () => { + setDraftKeyword(keyword); + setCandidatesOpen(false); + setFiltersCollapsed(true); + }; + const applyMobileFilters = () => { + setKeyword(draftKeyword.trim()); + setPage(1); + setCandidatesOpen(false); + setFiltersCollapsed(true); + }; + const resetMobileFilters = () => { + setDraftKeyword(''); + setKeyword(''); + setPage(1); + setCandidatesOpen(false); + setFiltersCollapsed(true); + }; const submit = (event: FormEvent) => { event.preventDefault(); setCandidatesOpen(false); if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); }; + const submitMobileFilters = (event: FormEvent) => { + event.preventDefault(); + applyMobileFilters(); + }; const vehicleScope = candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆授权` : '授权范围'; - return
+ const mobileFilterSummary = deferredKeyword + ? `${deferredKeyword} · ${candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆匹配` : '正在查找'}` + : candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆授权车辆` : '正在读取授权范围'; + return
{!mobileLayout || administrator ? : undefined} onClick={() => setSyncOpen((value) => !value)}>{syncOpen ? '收起同步' : mobileLayout ? '主档同步' : '批量同步主档'} : null} /> : null} - + + 查找车辆车牌优先,支持 VIN 与终端手机号
} + onCancel={closeMobileFilters} + footer={
} + > +
+
+
车辆身份输入关键词可直接打开候选车辆,或应用到下方目录。
+ + {deferredDraftKeyword ? mobileCandidates.refetch()} + emptyText="没有匹配的授权车辆" + header="快速结果" + meta={mobileCandidates.data ? `${mobileCandidates.data.total.toLocaleString('zh-CN')} 辆匹配` : '授权范围'} + showProtocols + layout="list" + actionLabel="打开" + onSelect={(vehicle) => openVehicle(vehicle.vin)} + /> :
快速定位车辆输入完整或部分车牌即可查看候选。
} +
+
+ + : openVehicle(vehicle.vin)} /> : null} - + } .v2-mobile-filter-toggle.semi-button { + min-height: 58px; + margin: 0; + } + + .v2-vehicle-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-vehicle-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-vehicle-mobile-filter-sidesheet.semi-sidesheet-bottom .semi-sidesheet-body { + min-height: 0; + overflow-y: auto; + background: #f4f7fb; + padding: 12px; + overscroll-behavior: contain; + } + + .v2-vehicle-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-vehicle-mobile-filter-title { + display: grid; + gap: 3px; + } + + .v2-vehicle-mobile-filter-title > strong { + color: #253a52; + font-size: 15px; + line-height: 1.3; + } + + .v2-vehicle-mobile-filter-title > span { + color: #8090a3; + font-size: 10px; + font-weight: 500; + line-height: 1.4; + } + + .v2-vehicle-mobile-filter-form, + .v2-vehicle-mobile-filter-form > section { + display: grid; + gap: 12px; + } + + .v2-vehicle-mobile-filter-form > section { + border: 1px solid #dfe7f0; + border-radius: 12px; + background: #fff; + padding: 12px; + box-shadow: 0 8px 24px rgba(31, 53, 80, .055); + } + + .v2-vehicle-mobile-filter-form > section > header { + display: grid; + min-width: 0; + gap: 3px; + } + + .v2-vehicle-mobile-filter-form > section > header > strong { + color: #2b4058; + font-size: 13px; + line-height: 1.3; + } + + .v2-vehicle-mobile-filter-form > section > header > span { + color: #7c8b9e; + font-size: 10px; + line-height: 1.45; + } + + .v2-vehicle-mobile-filter-form label { + display: grid; + min-width: 0; + gap: 6px; + color: #5f7188; + font-size: 11px; + font-weight: 650; + } + + .v2-vehicle-mobile-filter-form label > .semi-input-wrapper { + width: 100%; + min-height: 46px; + border-radius: 9px; + background: #fff; + } + + .v2-vehicle-mobile-filter-form label .semi-input { + height: 44px; + font-size: 14px; + } + + .v2-vehicle-mobile-filter-hint { + display: flex; + min-height: 86px; + align-items: center; + gap: 11px; + border: 1px dashed #d6e1ee; + border-radius: 10px; + background: #f8fbff; + padding: 14px; + color: #6e8db8; + } + + .v2-vehicle-mobile-filter-hint > .semi-icon { + font-size: 22px; + } + + .v2-vehicle-mobile-filter-hint > span { + display: grid; + gap: 3px; + } + + .v2-vehicle-mobile-filter-hint strong { + color: #344c69; + font-size: 12px; + } + + .v2-vehicle-mobile-filter-hint small { + color: #8392a5; + font-size: 10px; + } + + .v2-vehicle-mobile-filter-sidesheet .v2-vehicle-mobile-candidates.v2-vehicle-candidate-list { + position: static; + width: 100%; + max-height: min(32dvh, 260px); + overflow-y: auto; + border-color: #dce5ef; + border-radius: 10px; + box-shadow: none; + overscroll-behavior: contain; + } + + .v2-vehicle-mobile-filter-sidesheet .v2-vehicle-mobile-candidates > header { + position: sticky; + z-index: 1; + top: 0; + min-height: 38px; + background: rgba(248, 250, 253, .98); + backdrop-filter: blur(10px); + } + + .v2-vehicle-mobile-filter-sidesheet .v2-vehicle-mobile-candidates > .v2-vehicle-option.semi-button { + min-height: 62px; + } + + .v2-vehicle-mobile-filter-sidesheet .v2-vehicle-mobile-candidates .v2-vehicle-option-action.semi-tag { + color: #1268f3; + } + + .v2-vehicle-mobile-filter-footer { + display: grid; + width: 100%; + grid-template-columns: minmax(0, .72fr) minmax(0, 1.28fr); + gap: 9px; + } + + .v2-vehicle-mobile-filter-footer > .semi-button { + width: 100%; + min-height: 44px; + border-radius: 9px; + font-weight: 700; + } +}