diff --git a/src/components/search/PoiSearchPanel.vue b/src/components/search/PoiSearchPanel.vue index 774270e..09a3eb5 100644 --- a/src/components/search/PoiSearchPanel.vue +++ b/src/components/search/PoiSearchPanel.vue @@ -627,6 +627,12 @@ const resetSearchState = async () => { emitResultsState() } +// 从详情页返回时只清理搜索 UI 与分类 marker,不参与地图目标焦点的复位。 +const returnToCollapsedAfterDetail = async () => { + await resetSearchState() + uni.hideKeyboard() +} + const handleCancel = async () => { await resetSearchState() uni.hideKeyboard() @@ -744,14 +750,27 @@ const handleSearchClear = async () => { await focusSearchInput() } -const searchShortcut = async (categoryInput: PoiCategoryDefinition | PoiCategoryId) => { +type SearchShortcutOptions = { + homeCategoryResults?: boolean +} + +const searchShortcut = async ( + categoryInput: PoiCategoryDefinition | PoiCategoryId, + options: SearchShortcutOptions = {} +) => { const category = typeof categoryInput === 'string' ? getPoiCategoryById(categoryInput) : categoryInput if (!category) return const requestSeq = ++searchRequestSeq - const useHomeResultList = props.variant === 'home' && !homeExpanded.value + const useHomeResultList = props.variant === 'home' + && (options.homeCategoryResults ?? !homeExpanded.value) + if (useHomeResultList) { + homeExpanded.value = false + searchInputFocused.value = false + uni.hideKeyboard() + } searchKeyword.value = category.label searchDraftKeyword.value = category.label activeCategoryId.value = category.id @@ -778,7 +797,10 @@ const searchShortcut = async (categoryInput: PoiCategoryDefinition | PoiCategory } const handleFacilityShortcut = (category: PoiCategoryDefinition) => { - void searchShortcut(category) + void searchShortcut(category, { + // 快捷入口始终进入首页底部分类结果;关键词确认仍保留全屏搜索结果。 + homeCategoryResults: props.variant === 'home' + }) } const handleSearchConfirm = async (event?: any) => { @@ -959,6 +981,7 @@ defineExpose({ expandHomePanel, collapseHomePanel, resetSearchState, + returnToCollapsedAfterDetail, focusSearchInput, searchShortcut, retryCurrentSearch, diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue index 7535acc..f380a3d 100644 --- a/src/pages/index/index.vue +++ b/src/pages/index/index.vue @@ -416,6 +416,7 @@ const guideMapShellRef = ref<{ const homeSearchPanelRef = ref<{ collapseHomePanel?: () => void resetSearchState?: () => void + returnToCollapsedAfterDetail?: () => Promise restoreResultListScroll?: () => Promise } | null>(null) const homeSearchExpanded = ref(false) @@ -423,6 +424,7 @@ const homeCategoryModeActive = ref(false) const searchVisiblePoiIds = ref(null) const searchTargetFocusRequest = ref(null) let searchTargetFocusRequestId = 0 +let pendingCollapseSearchAfterDetail = false let launchOverlayFallbackTimer: ReturnType | null = null let launchOverlayFadeTimer: ReturnType | null = null @@ -1350,6 +1352,15 @@ const closeHomeSearchDock = () => { onShow(async () => { await nextTick() + if (pendingCollapseSearchAfterDetail) { + pendingCollapseSearchAfterDetail = false + await homeSearchPanelRef.value?.returnToCollapsedAfterDetail?.() + homeSearchExpanded.value = false + homeCategoryModeActive.value = false + searchVisiblePoiIds.value = null + return + } + await homeSearchPanelRef.value?.restoreResultListScroll?.() }) @@ -1462,11 +1473,13 @@ const handleHomeSearchResultTap = async (poi: MuseumPoi, context: PoiSearchConte entrances: poi.entrances }, searchTargetFocusRequestId) - // 先把楼层与目标请求交给地图,再进入详情页;返回时列表组件仍保持原状态。 + // 先把楼层与目标请求交给地图,再进入详情页;返回时仅收起搜索 UI,不清焦点。 await nextTick() + pendingCollapseSearchAfterDetail = true uni.navigateTo({ url: createSearchDetailUrl(poi, context), fail: () => { + pendingCollapseSearchAfterDetail = false uni.showToast({ title: '点位详情打开失败,请重试', icon: 'none' diff --git a/tests/unit/IndexPoiSearchFlow.spec.ts b/tests/unit/IndexPoiSearchFlow.spec.ts index 2558f6c..9ffe00f 100644 --- a/tests/unit/IndexPoiSearchFlow.spec.ts +++ b/tests/unit/IndexPoiSearchFlow.spec.ts @@ -9,6 +9,7 @@ const testState = vi.hoisted(() => ({ searchMountCount: 0, searchUnmountCount: 0, searchRestoreCount: 0, + searchCollapseAfterDetailCount: 0, onShowHandler: null as null | (() => Promise | void) })) @@ -100,6 +101,9 @@ const PoiSearchPanelStub = defineComponent({ methods: { collapseHomePanel() {}, resetSearchState() {}, + returnToCollapsedAfterDetail() { + testState.searchCollapseAfterDetailCount += 1 + }, restoreResultListScroll() { testState.searchRestoreCount += 1 } @@ -151,6 +155,7 @@ beforeEach(() => { testState.searchMountCount = 0 testState.searchUnmountCount = 0 testState.searchRestoreCount = 0 + testState.searchCollapseAfterDetailCount = 0 testState.onShowHandler = null window.history.replaceState({}, '', '/#/pages/index/index?tab=guide') vi.stubGlobal('uni', { @@ -187,7 +192,7 @@ describe('首页搜索与地图闭环', () => { expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds) }) - it('跨楼层结果使用既有 target-focus,并携带返回上下文打开详情', async () => { + it('分类结果进入详情并返回后收起搜索、清空 marker,但保留目标焦点与楼层', async () => { const wrapper = await mountIndex() const search = wrapper.getComponent(PoiSearchPanelStub) const map = wrapper.getComponent(GuideMapShellStub) @@ -222,6 +227,39 @@ describe('首页搜索与地图闭环', () => { expect(query.get('listScrollTop')).toBe('126') expect(testState.searchMountCount).toBe(1) expect(testState.searchUnmountCount).toBe(0) + + await testState.onShowHandler?.() + await flushPromises() + + expect(testState.searchCollapseAfterDetailCount).toBe(1) + expect(testState.searchRestoreCount).toBe(0) + expect(map.props('activeFloor')).toBe('L2') + expect(map.props('visiblePoiIds')).toBeNull() + expect(map.props('targetFocusRequest')).toMatchObject({ + poiId: poi.id, + floorId: 'L2' + }) + }) + + it('全屏搜索结果进入详情并返回后同样恢复为收缩态', async () => { + const wrapper = await mountIndex() + const search = wrapper.getComponent(PoiSearchPanelStub) + const map = wrapper.getComponent(GuideMapShellStub) + const fullSearchContext = { + ...categoryContext, + categoryId: '' as const, + keyword: '二层卫生间' + } + + search.vm.$emit('result-tap', poi, fullSearchContext) + await flushPromises() + await testState.onShowHandler?.() + await flushPromises() + + expect(testState.searchCollapseAfterDetailCount).toBe(1) + expect(testState.searchRestoreCount).toBe(0) + expect(map.props('visiblePoiIds')).toBeNull() + expect(map.props('targetFocusRequest')).toMatchObject({ poiId: poi.id }) }) it('页面重新显示时要求搜索面板恢复原列表位置', async () => { @@ -232,6 +270,27 @@ describe('首页搜索与地图闭环', () => { expect(testState.searchRestoreCount).toBe(1) }) + it('详情打开失败会撤销收缩标记并保持当前搜索状态', async () => { + const wrapper = await mountIndex() + const search = wrapper.getComponent(PoiSearchPanelStub) + const map = wrapper.getComponent(GuideMapShellStub) + + search.vm.$emit('category-mode-change', true) + search.vm.$emit('results-change', { ...categoryContext, active: true }) + search.vm.$emit('result-tap', poi, categoryContext) + await flushPromises() + + const navigateToCall = vi.mocked(uni.navigateTo).mock.calls[0]?.[0] + navigateToCall?.fail?.({ errMsg: 'navigateTo:fail simulated' }) + await testState.onShowHandler?.() + await flushPromises() + + expect(testState.searchCollapseAfterDetailCount).toBe(0) + expect(testState.searchRestoreCount).toBe(1) + expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds) + expect(map.props('targetFocusRequest')).toMatchObject({ poiId: poi.id }) + }) + it('地图点位卡隐藏首页 dock 时仍保留搜索组件,关闭后可继续使用原列表', async () => { const wrapper = await mountIndex() const map = wrapper.getComponent(GuideMapShellStub) diff --git a/tests/unit/PoiSearchPanel.spec.ts b/tests/unit/PoiSearchPanel.spec.ts index a64630b..747d83a 100644 --- a/tests/unit/PoiSearchPanel.spec.ts +++ b/tests/unit/PoiSearchPanel.spec.ts @@ -130,6 +130,91 @@ describe('POI 搜索面板实际渲染与状态闭环', () => { pageWrapper.unmount() }) + it('展开态快捷入口退出全屏并进入当前楼层的分类结果', async () => { + const wrapper = mount(PoiSearchPanel, { + props: { + variant: 'home', + currentFloorId: 'floor-1', + currentFloorLabel: '1F' + } + }) + await flushMountedSearch() + + await wrapper.get('.search-box').trigger('tap') + await flushMountedSearch() + expect(wrapper.find('.poi-search-content').exists()).toBe(true) + + await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap') + await flushMountedSearch() + + expect(wrapper.find('.poi-search-content').exists()).toBe(false) + expect(wrapper.find('[data-testid="poi-home-category-results"]').exists()).toBe(true) + expect(wrapper.get('[data-testid="poi-home-category-results"]').text()) + .toContain('当前楼层 1F · 2 个点位') + expect(uni.hideKeyboard).toHaveBeenCalled() + expect((wrapper.emitted('results-change') || []).at(-1)?.[0]).toMatchObject({ + active: true, + categoryId: 'restroom', + floorId: 'floor-1', + visiblePoiIds: ['restroom-1'] + }) + wrapper.unmount() + }) + + it('关键词确认命中分类时仍保留全屏搜索结果', async () => { + const wrapper = mount(PoiSearchPanel, { + props: { + variant: 'home', + currentFloorId: 'floor-1', + currentFloorLabel: '1F' + } + }) + await flushMountedSearch() + + await wrapper.get('.search-box').trigger('tap') + await wrapper.get('.search-input').trigger('confirm', { + detail: { value: '洗手间' } + }) + await flushMountedSearch() + + expect(wrapper.find('.poi-search-content').exists()).toBe(true) + expect(wrapper.find('[data-testid="poi-home-category-results"]').exists()).toBe(false) + wrapper.unmount() + }) + + it('详情返回专用复位恢复默认快捷入口并清空搜索状态', async () => { + const wrapper = mount(PoiSearchPanel, { + props: { + variant: 'home', + currentFloorId: 'floor-1', + currentFloorLabel: '1F' + } + }) + await flushMountedSearch() + await wrapper.get('[data-testid="poi-category-restroom"]').trigger('tap') + await flushMountedSearch() + + wrapper.get('[data-testid="poi-result-list"]').element.scrollTop = 126 + await (wrapper.vm as unknown as { + returnToCollapsedAfterDetail: () => Promise + }).returnToCollapsedAfterDetail() + await flushMountedSearch() + + expect(wrapper.find('[data-testid="poi-home-category-results"]').exists()).toBe(false) + expect(wrapper.find('.poi-search-content').exists()).toBe(false) + expect(wrapper.findAll('.home-category-chip')).toHaveLength(5) + expect(wrapper.findAll('.home-category-chip.active')).toHaveLength(0) + expect((wrapper.get('.search-input').element as HTMLInputElement).value).toBe('') + expect((wrapper.emitted('results-change') || []).at(-1)?.[0]).toMatchObject({ + active: false, + keyword: '', + categoryId: '', + visiblePoiIds: [], + listScrollTop: 0 + }) + wrapper.unmount() + }) + it('首页分类只展示当前楼层结果,并上报可定位点位的精确 marker ID', async () => { const wrapper = mount(PoiSearchPanel, { props: { @@ -210,7 +295,7 @@ describe('POI 搜索面板实际渲染与状态闭环', () => { await flushMountedSearch() expect(wrapper.get('[data-testid="poi-empty"]').text()).toContain('当前楼层暂无匹配点位') expect((wrapper.emitted('results-change') || []).at(-1)?.[0]).toMatchObject({ - active: false, + active: true, visiblePoiIds: [] }) wrapper.unmount()