From b90c58451c0879d70b873a8b913011f0f7495c05 Mon Sep 17 00:00:00 2001 From: cxk <119064883@qq.com> Date: Tue, 21 Jul 2026 00:40:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A6=96=E9=A1=B5=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E5=85=A5=E5=8F=A3=E4=B8=8E=E4=B8=89=E7=BB=B4=E7=82=B9?= =?UTF-8?q?=E4=BD=8D=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/navigation/GuideMapShell.vue | 24 +++- src/components/search/PoiSearchPanel.vue | 7 +- .../search/homeSearchResultVersion.ts | 7 ++ src/domain/poiSearch.ts | 2 + src/pages/index/index.vue | 109 +++++++++++++++++- tests/unit/IndexPoiSearchFlow.spec.ts | 93 ++++++++++++++- tests/unit/PoiSearchPanel.spec.ts | 31 +++++ 7 files changed, 257 insertions(+), 16 deletions(-) create mode 100644 src/components/search/homeSearchResultVersion.ts diff --git a/src/components/navigation/GuideMapShell.vue b/src/components/navigation/GuideMapShell.vue index a6517c7..9e2b599 100644 --- a/src/components/navigation/GuideMapShell.vue +++ b/src/components/navigation/GuideMapShell.vue @@ -583,17 +583,21 @@ const markFloorSwitchFailedIfUnrendered = (floorId: string, requestSeq: number) }) } -const handleFloorChange = (floor: { id: string; label: string }) => { +const requestFloorSwitch = ( + floor: { id: string; label: string }, + options: { force?: boolean } = {} +) => { const floorId = floor.id - if (!floorId || loadingFloorId.value) return + if (!floorId) return Promise.resolve() + if (loadingFloorId.value === floorId) return Promise.resolve() if ( - floorId === renderedFloorId.value + !options.force && floorId === renderedFloorId.value && activeFloorId.value === floorId && props.indoorView === 'floor' && props.layerMode !== 'multi' ) { emit('floorChange', floorId) - return + return Promise.resolve() } requestedFloorId.value = floorId @@ -607,7 +611,7 @@ const handleFloorChange = (floor: { id: string; label: string }) => { floorId, floorLabel: floor.label }) - Promise.resolve(indoorRendererRef.value?.switchFloor?.(floorId)) + return Promise.resolve(indoorRendererRef.value?.switchFloor?.(floorId)) .then(() => { markFloorSwitchFailedIfUnrendered(floorId, requestSeq) }) @@ -623,6 +627,10 @@ const handleFloorChange = (floor: { id: string; label: string }) => { }) } +const handleFloorChange = (floor: { id: string; label: string }) => { + void requestFloorSwitch(floor) +} + const handleLayerModeChange = (mode: LayerDisplayMode) => { // 手动切换展示层数时使用统一的短保护期。 indoorRendererRef.value?.disableAutoSwitchTemporarily?.(manualAutoSwitchPauseMs) @@ -789,7 +797,11 @@ defineExpose({ indoorRendererRef.value?.clearRoute?.() }, // 仅发起切换;父级必须以 floor-change 作为已提交的唯一依据。 - switchFloor: (floorId: string) => indoorRendererRef.value?.switchFloor?.(floorId), + switchFloor: (floorId: string) => { + const floor = findFloorItemById(floorId) + if (!floor) return Promise.resolve() + return requestFloorSwitch(floor, { force: true }) + }, showOverview: handleShowOverview, resetToViewBaseline: (options: { view: 'overview' | 'floor' diff --git a/src/components/search/PoiSearchPanel.vue b/src/components/search/PoiSearchPanel.vue index a65fd8f..410fde9 100644 --- a/src/components/search/PoiSearchPanel.vue +++ b/src/components/search/PoiSearchPanel.vue @@ -248,6 +248,7 @@ import type { PoiCategoryResultState, PoiSearchContext } from '@/domain/poiSearch' +import { nextHomeSearchResultVersion } from './homeSearchResultVersion' import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment' const props = withDefaults(defineProps<{ @@ -397,7 +398,8 @@ const emitResultsState = () => { emit('results-change', { ...context, visiblePoiIds: active ? context.visiblePoiIds : [], - active + active, + requestId: props.variant === 'home' ? nextHomeSearchResultVersion() : undefined }) } @@ -411,7 +413,8 @@ const emitPendingHomeSearchResults = (floor?: Pick) floorLabel: floor?.label || context.floorLabel, visiblePoiIds: [], active: true, - pending: true + pending: true, + requestId: nextHomeSearchResultVersion() }) } diff --git a/src/components/search/homeSearchResultVersion.ts b/src/components/search/homeSearchResultVersion.ts new file mode 100644 index 0000000..ecd1130 --- /dev/null +++ b/src/components/search/homeSearchResultVersion.ts @@ -0,0 +1,7 @@ +// This version survives a PoiSearchPanel remount, unlike its local request sequence. +let latestHomeSearchResultVersion = 0 + +export const nextHomeSearchResultVersion = () => { + latestHomeSearchResultVersion += 1 + return latestHomeSearchResultVersion +} diff --git a/src/domain/poiSearch.ts b/src/domain/poiSearch.ts index d346ef2..deb3ceb 100644 --- a/src/domain/poiSearch.ts +++ b/src/domain/poiSearch.ts @@ -28,6 +28,8 @@ export interface PoiSearchSelection { export interface PoiCategoryResultState extends PoiSearchContext { active: boolean pending?: boolean + /** Monotonic home-result event version used to reject stale cross-component state. */ + requestId?: number } export type GuidePoiSearchMode = 'default' | 'category' | 'keyword' diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue index 4bce795..264792d 100644 --- a/src/pages/index/index.vue +++ b/src/pages/index/index.vue @@ -373,6 +373,8 @@ const requestedFloorId = ref('') const requestedFloorLabel = ref('') const loadingFloorId = ref('') const renderedFloorId = ref('') +// Only GuideMapShell's floor-change confirms that the Three.js floor is usable. +const committedFloorId = ref('') const failedFloorId = ref('') const selectedGuidePoi = ref(null) const selectedGuidePoiDetailTarget = ref(null) @@ -432,6 +434,8 @@ const homeSearchExpanded = ref(false) const homeCategoryModeActive = ref(false) const searchVisiblePoiIds = ref(null) const searchTargetFocusRequest = ref(null) +const homeSearchMapState = ref(null) +let latestHomeSearchMapRequestId = 0 type PoiSearchOverlayHistoryState = { museumGuideOverlay?: 'poi-search' [key: string]: unknown @@ -853,6 +857,9 @@ const handleTabChange = (tabId: GuideTopTab) => { } } + if (tabId !== currentTab.value) { + invalidateHomeSearchFloorCommit() + } currentTab.value = tabId syncTopTabToUrl(tabId) loadTopTabData(tabId) @@ -875,6 +882,7 @@ const syncTopTabFromHash = () => { currentTab.value = tab loadTopTabData(tab) if (tabChanged) { + invalidateHomeSearchFloorCommit() showRoutePlanner.value = false isSimulatingRoute.value = false isPoiCardCollapsed.value = false @@ -1000,11 +1008,19 @@ const handleFloorRequest = ({ floorId, floorLabel }: { floorId: string; floorLab requestedFloorLabel.value = floorLabel loadingFloorId.value = floorId failedFloorId.value = '' + if (homeSearchMapState.value?.active) { + searchVisiblePoiIds.value = [] + } } const handleFloorChange = (floorId: string) => { activeGuideFloor.value = floorId renderedFloorId.value = floorId + committedFloorId.value = floorId + if (requestedFloorId.value === floorId) { + requestedFloorId.value = '' + requestedFloorLabel.value = '' + } if (loadingFloorId.value === floorId) { loadingFloorId.value = '' } @@ -1014,6 +1030,7 @@ const handleFloorChange = (floorId: string) => { indoorView.value = 'floor' selectedGuidePoi.value = null isPoiCardCollapsed.value = false + syncHomeSearchMarkersForCommittedFloor(floorId) showIndoorHint(`已切换到 ${getGuideFloorLabel(floorId)},单指平移、双指可缩放`, 3200) console.log('楼层渲染完成:', floorId) } @@ -1023,6 +1040,13 @@ const handleFloorSwitchFailed = ({ floorId, floorLabel }: { floorId: string; flo loadingFloorId.value = '' } failedFloorId.value = floorId + if (requestedFloorId.value === floorId) { + requestedFloorId.value = '' + requestedFloorLabel.value = '' + } + if (homeSearchMapState.value?.floorId === floorId) { + searchVisiblePoiIds.value = [] + } showIndoorHint(`${floorLabel || getGuideFloorLabel(floorId)} 加载失败,请稍后重试`, 3600) console.warn('楼层渲染失败:', floorId) } @@ -1030,6 +1054,8 @@ const handleFloorSwitchFailed = ({ floorId, floorLabel }: { floorId: string; flo const handleIndoorViewChange = (view: GuideIndoorView) => { indoorView.value = view if (view !== 'floor') { + committedFloorId.value = '' + syncHomeSearchMarkersForCommittedFloor() selectedGuidePoi.value = null isPoiCardCollapsed.value = false } @@ -1045,6 +1071,8 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' | console.log('自动切换视图:', event) indoorView.value = event.to if (event.to !== 'floor') { + committedFloorId.value = '' + syncHomeSearchMarkersForCommittedFloor() selectedGuidePoi.value = null isPoiCardCollapsed.value = false } @@ -1053,6 +1081,11 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' | const handleInitialModelReady = (event: { view: GuideIndoorView; floorId?: string; elapsedMs?: number }) => { const readyFloorId = event.floorId || activeGuideFloor.value + indoorView.value = event.view + if (event.view !== 'floor') { + committedFloorId.value = '' + syncHomeSearchMarkersForCommittedFloor() + } if (event.view === 'overview' && readyFloorId) { activeGuideFloor.value = readyFloorId renderedFloorId.value = readyFloorId @@ -1092,6 +1125,7 @@ const handleGuidePoiClick = (poi: GuideRenderPoi) => { isSimulatingRoute.value = false activeGuideFloor.value = poi.floorId renderedFloorId.value = poi.floorId + committedFloorId.value = poi.floorId showIndoorHint(`${poi.name} · ${getGuideFloorLabel(poi.floorId)}`, 3000) } @@ -1519,6 +1553,7 @@ const resetGuideModelToFloorBaseline = async ({ if (!guideModelState.isCurrentRequest(resetRequest.requestId)) return if (resetResult === 'applied') { renderedFloorId.value = activeGuideFloor.value + committedFloorId.value = activeGuideFloor.value guideModelState.completeViewBaselineReset(resetRequest) console.info('馆内三维模型已恢复楼层视觉基线:', { rendererReason }) } else if (resetResult === 'not-ready' || resetResult === undefined) { @@ -1589,12 +1624,20 @@ const clearHomeSearchMapState = () => { homeCategoryModeActive.value = false searchVisiblePoiIds.value = null searchTargetFocusRequest.value = null + homeSearchMapState.value = null +} + +const invalidateHomeSearchFloorCommit = () => { + clearHomeSearchMapState() + committedFloorId.value = '' + indoorView.value = 'overview' } const handleHomeCategoryModeChange = (active: boolean) => { homeCategoryModeActive.value = active if (!active) { searchVisiblePoiIds.value = null + homeSearchMapState.value = null return } @@ -1604,19 +1647,72 @@ const handleHomeCategoryModeChange = (active: boolean) => { selectedGuidePoi.value = null isPoiCardCollapsed.value = false is3DMode.value = true - indoorView.value = 'floor' + searchVisiblePoiIds.value = [] } -const handleHomeSearchResultsChange = (state: PoiCategoryResultState) => { - if (!state.active) { - searchVisiblePoiIds.value = null +const isHomeSearchFloorCommitted = (floorId: string) => ( + Boolean(floorId) + && committedFloorId.value === floorId + && indoorView.value === 'floor' +) + +const requestHomeSearchFloor = (floorId: string, floorLabel = '') => { + if (!floorId || requestedFloorId.value === floorId || loadingFloorId.value === floorId) return + + requestedFloorId.value = floorId + requestedFloorLabel.value = floorLabel || getGuideFloorLabel(floorId) + loadingFloorId.value = floorId + failedFloorId.value = '' + void Promise.resolve(guideMapShellRef.value?.switchFloor?.(floorId)) + .catch((error) => { + console.error('快捷入口楼层切换失败:', error) + }) +} + +const syncHomeSearchMarkersForCommittedFloor = (floorId = committedFloorId.value) => { + const state = homeSearchMapState.value + if ( + !state?.active + || state.pending + || state.floorId !== floorId + || indoorView.value !== 'floor' + ) { + searchVisiblePoiIds.value = state?.active ? [] : null return } searchVisiblePoiIds.value = [...state.visiblePoiIds] - if (state.floorId && !state.pending) { - activeGuideFloor.value = state.floorId +} + +const handleHomeSearchResultsChange = (state: PoiCategoryResultState) => { + if ( + typeof state.requestId === 'number' + && state.requestId < latestHomeSearchMapRequestId + ) return + if (typeof state.requestId === 'number') { + latestHomeSearchMapRequestId = state.requestId } + + if (!state.active) { + clearHomeSearchMapState() + return + } + + homeSearchMapState.value = { + ...state, + visiblePoiIds: [...state.visiblePoiIds] + } + if (state.pending) { + searchVisiblePoiIds.value = [] + } + + if (!isHomeSearchFloorCommitted(state.floorId)) { + searchVisiblePoiIds.value = [] + requestHomeSearchFloor(state.floorId, state.floorLabel) + return + } + + syncHomeSearchMarkersForCommittedFloor(state.floorId) } const handleHomeSearchResultTap = async (poi: MuseumPoi, context: PoiSearchContext) => { @@ -1672,6 +1768,7 @@ const handleExplainHallClick = async (hallId: string) => { } const handleExplainBack = () => { + invalidateHomeSearchFloorCommit() currentTab.value = 'guide' syncTopTabToUrl('guide') loadTopTabData('guide') diff --git a/tests/unit/IndexPoiSearchFlow.spec.ts b/tests/unit/IndexPoiSearchFlow.spec.ts index b84982d..af9c7ff 100644 --- a/tests/unit/IndexPoiSearchFlow.spec.ts +++ b/tests/unit/IndexPoiSearchFlow.spec.ts @@ -18,6 +18,7 @@ const testState = vi.hoisted(() => ({ searchRestoreCount: 0, searchCollapseAfterDetailCount: 0, searchResetCount: 0, + floorSwitchRequests: [] as string[], onShowHandler: null as null | (() => Promise | void), halls: [] as Array<{ id: string; poiId?: string; location?: { poiId?: string }; name: string }>, hallResolutionPromises: [] as Array { testState.mapMountCount += 1 @@ -113,6 +114,10 @@ const GuideMapShellStub = defineComponent({ return {} }, methods: { + switchFloor(floorId: string) { + testState.floorSwitchRequests.push(floorId) + return Promise.resolve() + }, resetToViewBaseline(options: { view: 'overview' | 'floor' floorId?: string @@ -217,6 +222,7 @@ beforeEach(() => { testState.searchRestoreCount = 0 testState.searchCollapseAfterDetailCount = 0 testState.searchResetCount = 0 + testState.floorSwitchRequests = [] testState.onShowHandler = null testState.halls = [] testState.hallResolutionPromises = [] @@ -355,7 +361,7 @@ describe('首页搜索与地图闭环', () => { expect(wrapper.findAll('.guide-quick-action')[0]?.text()).toBe('导览') }) - it('向搜索面板传入当前楼层,并将分类结果 ID 精确传给地图', async () => { + it('等待渲染器提交目标楼层后,才将快捷分类结果 ID 传给地图', async () => { const wrapper = await mountIndex() const search = wrapper.getComponent(PoiSearchPanelStub) const map = wrapper.getComponent(GuideMapShellStub) @@ -370,6 +376,13 @@ describe('首页搜索与地图闭环', () => { }) await wrapper.vm.$nextTick() + expect(testState.floorSwitchRequests).toEqual(['L2']) + expect(map.props('activeFloor')).toBe('L1') + expect(map.props('visiblePoiIds')).toEqual([]) + + map.vm.$emit('floor-change', 'L2') + await wrapper.vm.$nextTick() + expect(map.props('activeFloor')).toBe('L2') expect(map.props('indoorView')).toBe('floor') expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds) @@ -388,6 +401,78 @@ describe('首页搜索与地图闭环', () => { expect(map.props('visiblePoiIds')).toEqual([]) }) + it('三维模型重新以总览就绪后,不将旧单层状态误作楼层提交', async () => { + const wrapper = await mountIndex() + const search = wrapper.getComponent(PoiSearchPanelStub) + const map = wrapper.getComponent(GuideMapShellStub) + + map.vm.$emit('floor-change', 'L1') + await wrapper.vm.$nextTick() + expect(map.props('indoorView')).toBe('floor') + + map.vm.$emit('initial-model-ready', { view: 'overview', floorId: 'L1' }) + await wrapper.vm.$nextTick() + expect(map.props('indoorView')).toBe('overview') + + search.vm.$emit('category-mode-change', true) + search.vm.$emit('results-change', { + ...categoryContext, + floorId: 'L1', + floorLabel: '1F', + active: true, + requestId: 20 + }) + await wrapper.vm.$nextTick() + + expect(testState.floorSwitchRequests).toEqual(['L1']) + expect(map.props('visiblePoiIds')).toEqual([]) + + map.vm.$emit('floor-change', 'L1') + await wrapper.vm.$nextTick() + expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds) + }) + + 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, requestId: 8 }) + search.vm.$emit('results-change', { + ...categoryContext, + floorId: 'L1', + floorLabel: '1F', + visiblePoiIds: ['stale-l1'], + active: true, + requestId: 7 + }) + await wrapper.vm.$nextTick() + + expect(testState.floorSwitchRequests).toEqual(['L2']) + map.vm.$emit('floor-change', 'L2') + await wrapper.vm.$nextTick() + expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds) + }) + + it('楼层切换失败时清空快捷分类标记,重试提交后恢复与列表一致的 ID', 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, requestId: 12 }) + await wrapper.vm.$nextTick() + map.vm.$emit('floor-switch-failed', { floorId: 'L2', floorLabel: '2F' }) + await wrapper.vm.$nextTick() + expect(map.props('activeFloor')).toBe('L1') + expect(map.props('visiblePoiIds')).toEqual([]) + + map.vm.$emit('floor-change', 'L2') + await wrapper.vm.$nextTick() + expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds) + }) + it('楼层数据按高到低返回时,首次进入仍将 1F 作为自动缩放的默认目标', async () => { const originalFloors = [...floors] floors.splice(0, floors.length, @@ -417,6 +502,8 @@ describe('首页搜索与地图闭环', () => { ...categoryContext, active: true }) + map.vm.$emit('floor-change', 'L2') + await wrapper.vm.$nextTick() search.vm.$emit('result-tap', poi, categoryContext) await flushPromises() @@ -486,6 +573,8 @@ describe('首页搜索与地图闭环', () => { search.vm.$emit('category-mode-change', true) search.vm.$emit('results-change', { ...categoryContext, active: true }) + map.vm.$emit('floor-change', 'L2') + await wrapper.vm.$nextTick() search.vm.$emit('result-tap', poi, categoryContext) await flushPromises() diff --git a/tests/unit/PoiSearchPanel.spec.ts b/tests/unit/PoiSearchPanel.spec.ts index 621ba7d..94f9328 100644 --- a/tests/unit/PoiSearchPanel.spec.ts +++ b/tests/unit/PoiSearchPanel.spec.ts @@ -149,6 +149,7 @@ const lastResultState = (wrapper: ReturnType) => ( categoryId: string keyword: string pending?: boolean + requestId?: number } ) @@ -323,6 +324,36 @@ describe('PoiSearchPanel 搜索状态契约', () => { wrapper.unmount() }) + it('首页分类结果在组件重挂载后仍发送递增的跨组件版本号', async () => { + const firstWrapper = mount(PoiSearchPanel, { + props: { + variant: 'home', + currentFloorId: 'floor-1', + currentFloorLabel: '1F' + } + }) + await flushMountedSearch() + await firstWrapper.get('[data-testid="poi-category-restroom"]').trigger('tap') + await flushMountedSearch() + const firstRequestId = lastResultState(firstWrapper).requestId + firstWrapper.unmount() + + const secondWrapper = mount(PoiSearchPanel, { + props: { + variant: 'home', + currentFloorId: 'floor-1', + currentFloorLabel: '1F' + } + }) + await flushMountedSearch() + await secondWrapper.get('[data-testid="poi-category-restroom"]').trigger('tap') + await flushMountedSearch() + + expect(firstRequestId).toEqual(expect.any(Number)) + expect(lastResultState(secondWrapper).requestId).toBeGreaterThan(firstRequestId as number) + secondWrapper.unmount() + }) + it('首页快捷分类请求期间清空列表并下发 pending 空地图 ID', async () => { const initialState = createSearchState() const deferredCategory = createDeferred()