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 0ad2aed0..b0420f40 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 @@ -196,8 +196,10 @@ test('shows deduplicated Semi vehicle candidates and opens the selected VIN', as const view = render(} />} />); - expect(await screen.findByRole('heading', { name: '车辆快速定位', level: 5 })).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: '查找车辆', level: 5 })).toBeInTheDocument(); expect(screen.getByLabelText('查车操作')).toHaveClass('v2-workspace-command-bar', 'v2-vehicle-command-bar'); + expect(screen.getByRole('tablist', { name: '车辆目录视图' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: '全部车辆' })).toHaveAttribute('aria-selected', 'true'); expect(screen.getByRole('heading', { name: '车辆定位', level: 5 }).closest('.semi-card')).toHaveClass('v2-vehicle-query-panel', 'v2-workspace-filter-panel'); const input = screen.getByRole('textbox', { name: '搜索车辆' }); expect(input).toHaveAttribute('aria-expanded', 'false'); @@ -210,10 +212,10 @@ test('shows deduplicated Semi vehicle candidates and opens the selected VIN', as expect(screen.getByRole('heading', { name: '授权车辆目录', level: 5 })).toBeInTheDocument(); const directoryMetrics = screen.getByRole('list', { name: '车辆目录摘要' }); expect(directoryMetrics.closest('.v2-vehicle-directory-metric-rail')).toHaveClass('semi-card', 'v2-workspace-metric-rail', 'is-queue'); - expect(Array.from(directoryMetrics.querySelectorAll(':scope > [role="listitem"] > small')).map((item) => item.textContent)).toEqual(['授权车辆', '本页在线', '多源车辆']); + expect(Array.from(directoryMetrics.querySelectorAll(':scope > [role="listitem"] > small')).map((item) => item.textContent)).toEqual(['授权车辆', '本页在线', '本页多源']); expect(directoryMetrics.querySelector(':scope > [role="listitem"]:nth-child(1)')).toHaveTextContent('授权车辆2辆账号范围'); expect(directoryMetrics.querySelector(':scope > [role="listitem"]:nth-child(2)')).toHaveTextContent('本页在线1辆本页 1 辆'); - expect(directoryMetrics.querySelector(':scope > [role="listitem"]:nth-child(3)')).toHaveTextContent('多源车辆0辆2+ 协议来源'); + expect(directoryMetrics.querySelector(':scope > [role="listitem"]:nth-child(3)')).toHaveTextContent('本页多源0辆2+ 协议来源'); expect(screen.getByText('第 1 / 1 页').closest('.semi-tag')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '刷新车辆目录' })).toHaveClass('semi-button'); expect(view.container.querySelector('.v2-vehicle-directory-table.semi-table-wrapper')).toBeInTheDocument(); @@ -280,6 +282,8 @@ test('keeps the mobile vehicle directory primary and applies search from a Semi expect(view.container.querySelector('.v2-vehicle-directory-table')).not.toBeInTheDocument(); expect(view.container.querySelector('.v2-vehicle-query-panel')).not.toBeInTheDocument(); expect(screen.queryByLabelText('查车操作')).not.toBeInTheDocument(); + expect(screen.getByRole('tablist', { name: '车辆目录视图' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: '全部车辆' })).toHaveAttribute('aria-selected', 'true'); const toggle = screen.getByRole('button', { name: '打开车辆搜索:10 辆授权车辆' }); expect(toggle).toHaveAttribute('aria-expanded', 'false'); fireEvent.click(toggle); @@ -310,6 +314,50 @@ test('keeps the mobile vehicle directory primary and applies search from a Semi expect(screen.getByRole('heading', { name: '授权车辆', level: 5 })).toBeInTheDocument(); }); +test('filters the authorized directory with one consistent Semi view switcher', async () => { + const candidate = { + vin: initialRealtime.vin, + plate: initialRealtime.plate, + phone: '', + oem: '测试品牌', + protocols: ['JT808', 'GB32960'], + missingProtocols: [], + sourceStatus: [], + sourceCount: 2, + onlineSourceCount: 2, + online: true, + lastSeen: initialRealtime.lastSeen, + locationText: '广东省广州市', + bindingScore: 100, + bindingStatus: 'bound' + }; + const vehicles = vi.spyOn(api, 'vehicleCoverage').mockResolvedValue({ items: [candidate], total: 1, limit: 10, offset: 0 }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + render(} />); + + await screen.findByText(initialRealtime.plate); + const viewTabs = screen.getByRole('tablist', { name: '车辆目录视图' }); + expect(viewTabs).toHaveTextContent('全部车辆当前在线当前离线多源车辆'); + + fireEvent.click(screen.getByRole('tab', { name: '当前在线' })); + await waitFor(() => expect(vehicles.mock.calls[vehicles.mock.calls.length - 1]?.[0]?.get('online')).toBe('online')); + expect(screen.getByRole('tab', { name: '当前在线' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('heading', { name: '当前在线', level: 5 })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('tab', { name: '当前离线' })); + await waitFor(() => expect(vehicles.mock.calls[vehicles.mock.calls.length - 1]?.[0]?.get('online')).toBe('offline')); + + fireEvent.click(screen.getByRole('tab', { name: '多源车辆' })); + await waitFor(() => expect(vehicles.mock.calls[vehicles.mock.calls.length - 1]?.[0]?.get('coverage')).toBe('multi')); + + fireEvent.click(screen.getByRole('tab', { name: '全部车辆' })); + expect(screen.getByRole('tab', { name: '全部车辆' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('heading', { name: '授权车辆目录', level: 5 })).toBeInTheDocument(); + expect(vehicles.mock.calls[0]?.[0]?.has('online')).toBe(false); + expect(vehicles.mock.calls[0]?.[0]?.has('coverage')).toBe(false); +}); + test('keeps a partial vehicle query in the directory instead of navigating to an invalid detail route', async () => { const candidate = { vin: initialRealtime.vin, diff --git a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx index f32661ec..6870d43e 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx @@ -92,7 +92,7 @@ function Archive({ detail, editable, onUpdated }: { detail: VehicleDetail; edita return 维护档案 : null} /> @@ -126,7 +126,7 @@ function Events({ detail }: { detail: VehicleDetail }) { return } /> @@ -306,7 +306,7 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon return
@@ -596,7 +596,7 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele undefined} /> 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 c7ca6e99..495be815 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/VehicleSearchWorkspace.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/VehicleSearchWorkspace.tsx @@ -14,6 +14,7 @@ import { PanelEmpty, PanelLoading } from '../shared/AsyncState'; import { MobileFilterToggle } from '../shared/MobileFilterToggle'; import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet'; import { ProtocolTag } from '../shared/ProtocolTag'; +import { SegmentedTabs } from '../shared/SegmentedTabs'; import { TablePagination } from '../shared/TablePagination'; import { VehicleCandidateList } from '../shared/VehicleCandidateList'; import { mergeVehicleCandidates } from '../shared/vehicleCandidates'; @@ -23,6 +24,20 @@ import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../share const VehicleProfileSyncPanel = lazy(() => import('./VehicleProfileSyncPanel')); +type VehicleDirectoryView = 'all' | 'online' | 'offline' | 'multi'; + +const vehicleDirectoryViews = [ + { key: 'all', label: '全部车辆' }, + { key: 'online', label: '当前在线' }, + { key: 'offline', label: '当前离线' }, + { key: 'multi', label: '多源车辆' } +] satisfies { key: VehicleDirectoryView; label: string }[]; + +function applyDirectoryView(params: URLSearchParams, view: VehicleDirectoryView) { + if (view === 'online' || view === 'offline') params.set('online', view); + if (view === 'multi') params.set('coverage', 'multi'); +} + function resetWorkspaceScroll() { const targets = [ document.scrollingElement, @@ -56,17 +71,19 @@ export default function VehicleSearchWorkspace() { const [syncOpen, setSyncOpen] = useState(false); const [candidatesOpen, setCandidatesOpen] = useState(false); const [filtersCollapsed, setFiltersCollapsed] = useState(mobileLayout); + const [directoryView, setDirectoryView] = useState('all'); 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 directoryScope = useMemo(() => queryScopeKey({ keyword: deferredKeyword, view: directoryView }), [deferredKeyword, directoryView]); const candidateParams = useMemo(() => { const params = new URLSearchParams({ limit: String(pageSize), offset: String((page - 1) * pageSize) }); if (deferredKeyword) params.set('keyword', deferredKeyword); + applyDirectoryView(params, directoryView); return params; - }, [deferredKeyword, page, pageSize]); + }, [deferredKeyword, directoryView, page, pageSize]); const candidates = useQuery>({ queryKey: ['vehicle-directory', directoryScope, pageSize, page], queryFn: ({ signal }) => api.vehicleCoverage(candidateParams, signal), @@ -77,10 +94,11 @@ export default function VehicleSearchWorkspace() { const mobileCandidateParams = useMemo(() => { const params = new URLSearchParams({ limit: '6', offset: '0' }); if (deferredDraftKeyword) params.set('keyword', deferredDraftKeyword); + applyDirectoryView(params, directoryView); return params; - }, [deferredDraftKeyword]); + }, [deferredDraftKeyword, directoryView]); const mobileCandidates = useQuery>({ - queryKey: ['vehicle-mobile-candidates', queryScopeKey({ keyword: deferredDraftKeyword })], + queryKey: ['vehicle-mobile-candidates', queryScopeKey({ keyword: deferredDraftKeyword, view: directoryView })], queryFn: ({ signal }) => api.vehicleCoverage(mobileCandidateParams, signal), enabled: mobileFiltersOpen && Boolean(deferredDraftKeyword), staleTime: 30_000, @@ -147,15 +165,23 @@ export default function VehicleSearchWorkspace() { event.preventDefault(); applyMobileFilters(); }; + const selectDirectoryView = (nextView: VehicleDirectoryView) => { + setDirectoryView(nextView); + setPage(1); + setCandidatesOpen(false); + }; + const directoryViewLabel = vehicleDirectoryViews.find((item) => item.key === directoryView)?.label ?? '全部车辆'; const vehicleScope = candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆授权` : '授权范围'; const mobileFilterSummary = deferredKeyword - ? `${deferredKeyword} · ${candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆匹配` : '正在查找'}` - : candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆授权车辆` : '正在读取授权范围'; + ? `${deferredKeyword}${directoryView === 'all' ? '' : ` · ${directoryViewLabel}`} · ${candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆匹配` : '正在查找'}` + : candidates.data + ? directoryView === 'all' ? `${candidates.data.total.toLocaleString('zh-CN')} 辆授权车辆` : `${directoryViewLabel} · ${candidates.data.total.toLocaleString('zh-CN')} 辆` + : '正在读取授权范围'; const directoryMetrics: WorkspaceQueueMetricRailItem[] = [ { - label: deferredKeyword ? '匹配车辆' : '授权车辆', + label: deferredKeyword ? '匹配车辆' : directoryView === 'all' ? '授权车辆' : directoryViewLabel, value: <>{candidates.data ? formatZhNumber(total) : '—'}, - note: deferredKeyword ? '当前条件' : '账号范围', + note: deferredKeyword || directoryView !== 'all' ? '当前视图' : '账号范围', tone: 'primary', emphasis: 'primary' }, @@ -167,29 +193,45 @@ export default function VehicleSearchWorkspace() { emphasis: 'primary' }, { - label: '多源车辆', + label: '本页多源', value: <>{candidates.data ? formatZhNumber(multiSourceOnPage) : '—'}, note: '2+ 协议来源', tone: 'primary', emphasis: 'secondary' } ]; - const directoryTitle = deferredKeyword ? '匹配车辆' : mobileLayout ? '授权车辆' : '授权车辆目录'; + const directoryTitle = deferredKeyword + ? '匹配车辆' + : directoryView === 'all' + ? mobileLayout ? '授权车辆' : '授权车辆目录' + : directoryViewLabel; const directoryDescription = deferredKeyword ? '按当前搜索条件展示,可直接进入车辆数字档案' - : '按照最新上报时间排列,分页浏览全部授权车辆'; + : directoryView === 'all' + ? '按照最新上报时间排列,分页浏览全部授权车辆' + : `仅展示${directoryViewLabel},按最新上报时间排列`; return
{!mobileLayout || administrator ? : } - title={mobileLayout ? '车辆主档' : '车辆快速定位'} + title={mobileLayout ? '车辆主档' : '查找车辆'} description={mobileLayout ? '批量维护品牌、车型与运营属性' : '车牌、VIN 或手机号快速查车'} status={mobileLayout ? undefined : candidates.isPending ? '正在读取授权范围' : vehicleScope} statusColor={candidates.isError ? 'red' : 'blue'} - actions={administrator ? : null} + actions={
+ {!mobileLayout ? : null} + {administrator ? : null} +
} /> : null} {mobileLayout ?
: null} } + {mobileLayout ? : null}
.semi-button { + min-width: 0; + min-height: 30px; + border: 1px solid transparent; + border-radius: 7px; + background: transparent; + color: #64758b; + font-size: 10px; + font-weight: 680; + box-shadow: none; +} + +.v2-vehicle-directory-view-tabs.v2-segmented-tabs > .semi-button:hover { + border-color: #dce5ef; + background: #fff; + color: #315f98; +} + +.v2-vehicle-directory-view-tabs.v2-segmented-tabs > .semi-button.is-active, +.v2-vehicle-directory-view-tabs.v2-segmented-tabs > .semi-button.is-active:hover { + border-color: #c5d8f4; + background: #fff !important; + color: #1268df; + box-shadow: 0 2px 8px rgba(18, 104, 223, .08), inset 0 -2px #6fa4ed; +} + +.v2-vehicle-directory-view-tabs .v2-segmented-tab-label { + width: 100%; + justify-content: center; +} + .v2-vehicle-directory-v3 > .v2-vehicle-recent-card.semi-card { width: min(1280px, 100%); min-height: 0; @@ -18802,6 +18852,22 @@ width: 100%; } + .v2-vehicle-directory-v3 > .v2-vehicle-discovery-shell > .v2-vehicle-directory-view-tabs { + width: 100%; + min-width: 0; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border-width: 1px 0 0; + border-radius: 0 0 13px 13px; + background: #f7f9fc; + padding: 4px 6px 6px; + } + + .v2-vehicle-directory-view-tabs.v2-segmented-tabs > .semi-button { + min-height: 34px; + padding-inline: 2px; + font-size: 9px; + } + .v2-vehicle-directory-v3 .v2-vehicle-command-bar.is-mobile-admin { height: 44px; min-height: 44px;