From 84fbbc0694ffa7ea4ac4368db19b3307709705ba Mon Sep 17 00:00:00 2001 From: lyf <2514544224@qq.com> Date: Tue, 14 Jul 2026 21:57:31 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=E9=A6=86=E5=86=85?= =?UTF-8?q?=E4=B8=89=E7=BB=B4=E6=A8=A1=E5=9E=8B=E8=BF=94=E5=9B=9E=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/map/ThreeMap.vue | 72 ++++++++- src/components/navigation/GuideMapShell.vue | 4 +- src/composables/useGuideModelState.ts | 85 +++++++++++ src/pages/facility/detail.vue | 150 ++++++++----------- src/pages/index/index.vue | 93 ++++++------ tests/e2e/three-map-camera-stability.spec.ts | 33 ++++ tests/unit/FacilityDetail.spec.ts | 33 ++-- tests/unit/IndexPoiSearchFlow.spec.ts | 17 +-- 8 files changed, 319 insertions(+), 168 deletions(-) create mode 100644 src/composables/useGuideModelState.ts diff --git a/src/components/map/ThreeMap.vue b/src/components/map/ThreeMap.vue index fc7c46d..3aed225 100644 --- a/src/components/map/ThreeMap.vue +++ b/src/components/map/ThreeMap.vue @@ -478,6 +478,7 @@ let resizeObserver: ResizeObserver | null = null let isDisposed = false let pendingTargetFocus: TargetPoiFocusRequest | null = null let targetFocusQueue: Promise = Promise.resolve() +let targetFocusGeneration = 0 let modelLoadVersion = 0 let pendingRequestedFloorId = '' let floorSwitchLoadToken = 0 @@ -515,6 +516,7 @@ let defaultFloorPreloadSeq = 0 let firstModelLoadStartedAt = 0 let firstModelVisibleReported = false let initialModelSettled = false +let initialGuideState: { floorId: string; camera: CameraSnapshot } | null = null const cameraTweenEnabled = true const cameraTweenDurationMs = 480 @@ -3519,6 +3521,8 @@ const getVisualStabilityReport = () => ({ anchors: getProjectedBuildingAnchors(), activeView: activeView.value, floorId: currentFloor.value, + activeFocusPoiId: activeFocusPoiId.value, + hasPendingTargetFocus: Boolean(pendingTargetFocus), isCameraTweening: Boolean(cameraTween), poiFocusCameraAnimationCount, cameraSnapshotRestoreCount, @@ -3558,7 +3562,8 @@ const installVisualStabilityDiagnostics = () => { queueTargetFocus(request) return targetFocusQueue }, - clearTargetFocus + clearTargetFocus, + resetToInitialState } } @@ -5144,6 +5149,7 @@ const emitTargetFocus = ( } const clearTargetFocus = () => { + targetFocusGeneration += 1 pendingTargetFocus = null clearMapSelection(false) } @@ -5176,7 +5182,10 @@ const focusCameraOnPoi = (poi: RenderPoi) => { moveCameraTo(nextPosition, target, { reason: 'poi-focus' }) } -const focusTargetPoi = async (request: TargetPoiFocusRequest) => { +const isCurrentTargetFocus = (generation: number) => generation === targetFocusGeneration + +const focusTargetPoi = async (request: TargetPoiFocusRequest, generation: number) => { + if (!isCurrentTargetFocus(generation)) return false if (!request.poiId || !request.floorId) { emitTargetFocus(request, 'missing', '目标位置数据不完整') return false @@ -5198,12 +5207,15 @@ const focusTargetPoi = async (request: TargetPoiFocusRequest) => { suppressProgress: true, detachPoiBeforeLoad: true }) + if (!isCurrentTargetFocus(generation)) return false isLoading.value = false emit('floorChange', request.floorId) } else if (shouldRenderPoiMarkers.value && !getPoiSprites().length) { await loadFloorPOIs(floor, modelLoadVersion) + if (!isCurrentTargetFocus(generation)) return false } + if (!isCurrentTargetFocus(generation)) return false const poi = findLoadedPoi(request.poiId) || addFocusMarkerFromRequest(request) || createPoiFromFocusRequest(request) if (!poi?.positionGltf) { @@ -5244,11 +5256,12 @@ const queueTargetFocus = (request: TargetPoiFocusRequest | null) => { if (!isSceneReadyForTargetFocus()) return const nextRequest = pendingTargetFocus + const requestGeneration = targetFocusGeneration if (!nextRequest) return pendingTargetFocus = null targetFocusQueue = targetFocusQueue - .then(() => focusTargetPoi(nextRequest)) + .then(() => focusTargetPoi(nextRequest, requestGeneration)) .finally(() => { if (pendingTargetFocus) { queueTargetFocus(pendingTargetFocus) @@ -5321,6 +5334,12 @@ const init3DScene = async () => { installVisualStabilityDiagnostics() await loadModelPackage() + // The package, not a hard-coded floor, determines the stable first-ready baseline. + initialGuideState = { + floorId: currentFloor.value, + camera: captureCameraSnapshot() + } + setProgress(100, '馆内三维模型加载完成') initialModelSettled = true isLoading.value = false @@ -5440,6 +5459,52 @@ const showMultiFloor = async () => { } } +// Restores business and camera state on the existing scene. It never initializes WebGL or +// reloads the model package, so closing a detail page cannot remount ThreeMap. +const resetToInitialState = async () => { + clearTargetFocus() + invalidateModelLoads() + adjacentPreloadSeq += 1 + defaultFloorPreloadSeq += 1 + cancelCameraTween() + clearProgrammaticCameraTimer() + clearRoutePreview() + selectedPOI.value = null + activeFocusPoiId.value = '' + disposeFocusLabel() + disposeFocusPulse() + disposeFocusBase() + clearFocusHallHighlight() + updatePoiMarkerFocus() + autoSwitchStateMachine.reset('overview') + resetInteractionGateState() + + const baseline = initialGuideState + if (!baseline || !scene || !camera || !controls || loadError.value) return false + + isLoading.value = true + try { + currentFloor.value = baseline.floorId + if (activeView.value !== 'overview') { + await loadOverview({ cameraSnapshot: baseline.camera }) + } + if (!scene || !isCurrentModelLoad(modelLoadVersion)) return false + restoreCameraSnapshot(baseline.camera) + currentFloor.value = baseline.floorId + activeView.value = 'overview' + autoSwitchStateMachine.reset('overview') + refreshPoiVisibilityByDistance() + return true + } catch (error) { + if (!isStaleModelLoadError(error)) { + console.error('恢复馆内导览初始状态失败:', error) + } + return false + } finally { + isLoading.value = false + } +} + const retryLoad = () => { init3DScene() } @@ -5530,6 +5595,7 @@ defineExpose({ }, clearSelection: clearMapSelection, clearRoute: clearRoutePreview, + resetToInitialState, clearNavigation: () => clearMapSelection(false), disableAutoSwitchTemporarily }) diff --git a/src/components/navigation/GuideMapShell.vue b/src/components/navigation/GuideMapShell.vue index 5b7e8fb..a4c3aa0 100644 --- a/src/components/navigation/GuideMapShell.vue +++ b/src/components/navigation/GuideMapShell.vue @@ -460,6 +460,7 @@ const indoorRendererRef = ref<{ zoomCamera?: (direction: 'in' | 'out', options?: { source?: 'button' | 'gesture' }) => void clearSelection?: (shouldEmit?: boolean) => void clearRoute?: () => Promise | void + resetToInitialState?: () => Promise | boolean disableAutoSwitchTemporarily?: (durationMs: number) => void } | null>(null) @@ -779,7 +780,8 @@ defineExpose({ clearRoute: () => { indoorRendererRef.value?.clearRoute?.() }, - showOverview: handleShowOverview + showOverview: handleShowOverview, + resetToInitialState: () => indoorRendererRef.value?.resetToInitialState?.() || false }) diff --git a/src/composables/useGuideModelState.ts b/src/composables/useGuideModelState.ts new file mode 100644 index 0000000..1b9d8eb --- /dev/null +++ b/src/composables/useGuideModelState.ts @@ -0,0 +1,85 @@ +import { readonly, ref } from 'vue' + +export type GuideModelInitialState = Readonly<{ + mode: '3d' + indoorView: 'overview' | 'floor' | 'multi' + layerMode: 'single' | 'multi' + defaultActiveFloorId: string + loadedFloorId: string + selectedPoiId: null + activeFocusPoiId: null + targetFocusRequest: null + pendingTargetFocus: null + visiblePoiIds: null + temporaryFocus: false + poiInfoLabelVisible: false + routePreviewActive: false + navigationSimulationActive: false + autoFloorSwitchActive: boolean +}> + +// The object is frozen so callers cannot accidentally turn a captured baseline into live state. +export const GUIDE_MODEL_INITIAL_STATE: GuideModelInitialState = Object.freeze({ + mode: '3d', + indoorView: 'overview', + layerMode: 'single', + defaultActiveFloorId: '', + loadedFloorId: '', + selectedPoiId: null, + activeFocusPoiId: null, + targetFocusRequest: null, + pendingTargetFocus: null, + visiblePoiIds: null, + temporaryFocus: false, + poiInfoLabelVisible: false, + routePreviewActive: false, + navigationSimulationActive: false, + autoFloorSwitchActive: true +}) + +const initialState = ref(GUIDE_MODEL_INITIAL_STATE) +const pendingModelResetAfterPoiDetail = ref(false) +const requestGeneration = ref(0) + +export const useGuideModelState = () => { + const initializeModel = (floorId: string) => { + if (initialState.value.defaultActiveFloorId) return initialState.value + initialState.value = Object.freeze({ + ...GUIDE_MODEL_INITIAL_STATE, + defaultActiveFloorId: floorId, + loadedFloorId: floorId + }) + return initialState.value + } + + const beginPoiDetailReturn = () => { + pendingModelResetAfterPoiDetail.value = true + } + + const cancelPoiDetailReturn = () => { + pendingModelResetAfterPoiDetail.value = false + } + + const consumePoiDetailReturn = () => { + if (!pendingModelResetAfterPoiDetail.value) return false + pendingModelResetAfterPoiDetail.value = false + return true + } + + // All async focus/floor operations compare this generation before committing. + const invalidatePendingRequests = () => { + requestGeneration.value += 1 + return requestGeneration.value + } + + return { + initialState: readonly(initialState), + pendingModelResetAfterPoiDetail: readonly(pendingModelResetAfterPoiDetail), + requestGeneration: readonly(requestGeneration), + initializeModel, + beginPoiDetailReturn, + cancelPoiDetailReturn, + consumePoiDetailReturn, + invalidatePendingRequests + } +} diff --git a/src/pages/facility/detail.vue b/src/pages/facility/detail.vue index b487ff2..3b82bd9 100644 --- a/src/pages/facility/detail.vue +++ b/src/pages/facility/detail.vue @@ -18,7 +18,7 @@ :active-floor="activeFloor" map-type="indoor" :tools="[]" - :indoor-model-source="indoorModelSource" + :indoor-model-source="modelSource" :floors="guideFloors" :normalize-floor-id="normalizeGuideFloorId" :target-focus-request="targetFocusRequest" @@ -105,18 +105,17 @@ import type { MuseumPoi } from '@/domain/museum' import type { - GuideModelSource, GuideRenderPoi } from '@/domain/guideModel' -const indoorModelSource: GuideModelSource = guideUseCase.getModelSource() +const modelSource = guideUseCase.getModelSource() const guideFloors = ref([]) const normalizeGuideFloorId = (labelOrId: string) => guideFloors.value.find((floor) => floor.id === labelOrId || floor.label === labelOrId)?.id || guideUseCase.normalizeFloorId(labelOrId) +const requestedDetailFloorId = ref('') const activeMode = ref<'2d' | '3d'>('3d') -const activeFloor = ref('1F') -const requestedTargetFloorId = ref('') +const activeFloor = ref('') const targetFocusRequest = ref(null) const targetFocusRequestId = ref(0) const locationErrorMessage = ref('') @@ -146,68 +145,13 @@ const searchContext = ref( const loadGuideFloors = async () => { try { guideFloors.value = await guideUseCase.getFloors() - const floors = guideFloors.value - const normalizedActiveFloorId = activeFloor.value - ? normalizeGuideFloorId(activeFloor.value) - : '' - const defaultFloor = floors.find((floor) => floor.id === normalizedActiveFloorId)?.id - || floors.find((floor) => floor.label === '1F')?.id - || floors[0]?.id - || activeFloor.value - activeFloor.value = defaultFloor + if (!activeFloor.value) activeFloor.value = guideFloors.value[0]?.id || '' } catch (error) { console.error('加载导览楼层失败:', error) guideFloors.value = [] } } -const focusFacilityPoi = (poi: MuseumPoi) => { - if (!poi.floorId) { - locationErrorMessage.value = '该点位缺少楼层信息,暂时无法在地图中定位。' - return - } - - locationErrorMessage.value = '' - activeMode.value = '3d' - activeFloor.value = poi.floorId - targetFocusRequestId.value += 1 - targetFocusRequest.value = toTargetFocusRequestViewModel({ - poiId: poi.id, - name: poi.name, - floorId: poi.floorId, - floorLabel: poi.floorLabel, - primaryCategoryZh: poi.primaryCategory.label, - positionGltf: poi.positionGltf, - sourceObjectName: poi.sourceObjectName - }, targetFocusRequestId.value) -} - -const focusRenderPoi = (poi: GuideRenderPoi) => { - if (!poi.floorId) { - locationErrorMessage.value = '该点位缺少楼层信息,暂时无法在地图中定位。' - return - } - - locationErrorMessage.value = '' - const floorLabel = guideFloors.value.find((floor) => floor.id === poi.floorId)?.label || poi.floorId - activeMode.value = '3d' - activeFloor.value = poi.floorId - targetFocusRequestId.value += 1 - targetFocusRequest.value = toTargetFocusRequestViewModel({ - poiId: poi.id, - name: poi.name, - floorId: poi.floorId, - floorLabel, - primaryCategoryZh: poi.primaryCategoryZh, - positionGltf: poi.positionGltf, - sourceObjectName: poi.sourceObjectName, - kind: poi.kind, - hallId: poi.hallId, - hallName: poi.hallName, - entrances: poi.entrances - }, targetFocusRequestId.value) -} - const getRenderPoiFloorLabel = (poi: GuideRenderPoi) => guideFloors.value.find((floor) => floor.id === poi.floorId)?.label || poi.floorId @@ -224,6 +168,50 @@ const applyRenderPoiToFacility = (poi: GuideRenderPoi) => { focusRenderPoi(poi) } +const focusFacilityPoi = (poi: MuseumPoi) => { + if (!poi.floorId) { + locationErrorMessage.value = '该点位缺少楼层信息,暂时无法提供位置预览。' + return + } + + locationErrorMessage.value = '' + activeFloor.value = poi.floorId + targetFocusRequestId.value += 1 + targetFocusRequest.value = toTargetFocusRequestViewModel({ + poiId: poi.id, + name: poi.name, + floorId: poi.floorId, + floorLabel: poi.floorLabel, + primaryCategoryZh: poi.primaryCategory.label, + positionGltf: poi.positionGltf, + sourceObjectName: poi.sourceObjectName + }, targetFocusRequestId.value) +} + +const focusRenderPoi = (poi: GuideRenderPoi) => { + if (!poi.floorId) { + locationErrorMessage.value = '该点位缺少楼层信息,暂时无法提供位置预览。' + return + } + + locationErrorMessage.value = '' + activeFloor.value = poi.floorId + targetFocusRequestId.value += 1 + targetFocusRequest.value = toTargetFocusRequestViewModel({ + poiId: poi.id, + name: poi.name, + floorId: poi.floorId, + floorLabel: getRenderPoiFloorLabel(poi), + primaryCategoryZh: poi.primaryCategoryZh, + positionGltf: poi.positionGltf, + sourceObjectName: poi.sourceObjectName, + kind: poi.kind, + hallId: poi.hallId, + hallName: poi.hallName, + entrances: poi.entrances + }, targetFocusRequestId.value) +} + const normalizePoiName = (value: string) => value .trim() .replace(/[\s()()【】[\]_-]/g, '') @@ -242,7 +230,7 @@ const decodeRouteText = (value: unknown) => ( ) const isPoiOnRequestedFloor = (poi: Pick) => ( - !requestedTargetFloorId.value || poi.floorId === requestedTargetFloorId.value + !requestedDetailFloorId.value || poi.floorId === requestedDetailFloorId.value ) const resolvePoiFromHall = async () => { @@ -269,7 +257,7 @@ const resolveFacilityPoi = async () => { if (!normalizedTarget) return null const candidates = await guideUseCase.searchPois(facility.value.name) - const floorMatchedCandidates = requestedTargetFloorId.value + const floorMatchedCandidates = requestedDetailFloorId.value ? candidates.filter(isPoiOnRequestedFloor) : candidates const candidatesToMatch = floorMatchedCandidates.length ? floorMatchedCandidates : candidates @@ -289,16 +277,16 @@ const resolveRenderPoi = async () => { const normalizedTarget = normalizePoiName(facility.value.name) if (!resolveByExactId && !normalizedTarget) return null - const modelPackage = await indoorModelSource.loadPackage() - const floorsToSearch = requestedTargetFloorId.value - ? modelPackage.floors.filter((floor) => floor.floorId === requestedTargetFloorId.value) + const modelPackage = await modelSource.loadPackage() + const floorsToSearch = requestedDetailFloorId.value + ? modelPackage.floors.filter((floor) => floor.floorId === requestedDetailFloorId.value) : modelPackage.floors for (const floor of floorsToSearch) { const floorId = floor.floorId let pois: GuideRenderPoi[] = [] try { - pois = await indoorModelSource.loadFloorPois(floorId) + pois = await modelSource.loadFloorPois(floorId) } catch (error) { resolutionLoadFailed.value = true console.warn(`楼层 ${floorId} 的模型点位加载失败:`, error) @@ -362,10 +350,8 @@ onLoad(async (options: Record = {}) => { const requestedFloorId = decodeRouteText(options.floorId) || searchContext.value.searchFloorId if (requestedFloorId) { - requestedTargetFloorId.value = normalizeGuideFloorId(requestedFloorId) - if (requestedTargetFloorId.value) { - activeFloor.value = requestedTargetFloorId.value - } + requestedDetailFloorId.value = normalizeGuideFloorId(requestedFloorId) + activeFloor.value = requestedDetailFloorId.value || activeFloor.value } const requestedFloorLabel = decodeRouteText(options.floorLabel) @@ -403,7 +389,7 @@ onLoad(async (options: Record = {}) => { console.error('加载设施位置详情失败:', error) locationErrorMessage.value = '位置数据加载失败,请稍后重试。' } finally { - // 目标楼层与聚焦请求准备完成后再挂载 ThreeMap,避免默认楼层的中间帧。 + // Wait for the target context so the preview does not flash an unrelated floor. mapContextReady.value = true } }) @@ -419,8 +405,8 @@ const handleTargetFocus = (result: TargetPoiFocusResult) => { } locationErrorMessage.value = result.status === 'missing' - ? '该点位缺少可用的位置数据,暂时无法在地图中定位。' - : '地图定位暂不可用,请稍后重试。' + ? '该点位缺少可用的位置数据,暂时无法提供位置预览。' + : '位置预览暂不可用,请稍后重试。' } const handleCloseDetailPanel = () => { @@ -432,21 +418,7 @@ const handleTopTabChange = (tab: GuideTopTab) => { } const handlePageBack = () => { - if (searchContext.value.source === 'search' && searchContext.value.resultCount === 1) { - uni.reLaunch({ - url: '/pages/index/index' - }) - return - } - - uni.navigateBack({ - delta: 1, - fail: () => { - uni.reLaunch({ - url: '/pages/index/index' - }) - } - }) + uni.navigateBack({ delta: 1 }) } diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue index dc194a1..84416f2 100644 --- a/src/pages/index/index.vue +++ b/src/pages/index/index.vue @@ -325,9 +325,9 @@ import type { PoiSearchContext } from '@/domain/poiSearch' import { - toTargetFocusRequestViewModel, type TargetPoiFocusRequestViewModel } from '@/view-models/guideViewModels' +import { useGuideModelState } from '@/composables/useGuideModelState' import { ARRIVAL_TARGETS_BY_TYPE, type ArrivalSearchTarget, @@ -344,6 +344,8 @@ import type { type GuideIndoorView = 'overview' | 'floor' | 'multi' type LayerDisplayMode = 'single' | 'multi' +const guideModelState = useGuideModelState() + const isIndexHashRoute = () => { if (typeof window === 'undefined') return false @@ -416,6 +418,7 @@ const returningToOverview = ref(false) const guideMapShellRef = ref<{ clearRoute?: () => void showOverview?: () => Promise | void + resetToInitialState?: () => Promise | boolean } | null>(null) const homeSearchPanelRef = ref<{ collapseHomePanel?: () => void @@ -427,9 +430,7 @@ const homeSearchExpanded = ref(false) const homeCategoryModeActive = ref(false) const searchVisiblePoiIds = ref(null) const searchTargetFocusRequest = ref(null) -let searchTargetFocusRequestId = 0 -let pendingCollapseSearchAfterDetail = false -let pendingSearchDetailFocusRequest: TargetPoiFocusRequestViewModel | null = null +let pendingGuideModelResetUntilReady = false type PoiSearchOverlayHistoryState = { museumGuideOverlay?: 'poi-search' @@ -916,6 +917,7 @@ const loadGuideFloors = async () => { || floors[0]?.id || activeGuideFloor.value renderedFloorId.value = activeGuideFloor.value + guideModelState.initializeModel(activeGuideFloor.value) } catch (error) { console.error('加载导览楼层失败:', error) guideFloors.value = [] @@ -1031,7 +1033,12 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' | } const handleInitialModelReady = (event: { view: GuideIndoorView; floorId?: string; elapsedMs?: number }) => { + guideModelState.initializeModel(event.floorId || activeGuideFloor.value) settleLaunchOverlayByModel('ready', event) + if (pendingGuideModelResetUntilReady) { + pendingGuideModelResetUntilReady = false + void resetGuideModelToInitial({ reason: 'queued-poi-detail-close' }) + } } const handleInitialModelFailed = (event: { view: GuideIndoorView; floorId?: string; message: string; elapsedMs?: number }) => { @@ -1449,27 +1456,45 @@ const closeHomeSearchDock = () => { homeSearchExpanded.value = false } +const resetGuideModelToInitial = async ({ reason }: { reason: 'poi-detail-close' | 'queued-poi-detail-close' }) => { + const initial = guideModelState.initialState.value + guideModelState.invalidatePendingRequests() + searchTargetFocusRequest.value = null + searchVisiblePoiIds.value = null + homeCategoryModeActive.value = false + selectedGuidePoi.value = null + isPoiCardCollapsed.value = false + showRoutePlanner.value = false + activeRoutePreview.value = null + routeStartPoint.value = null + routeEndPoint.value = null + routePlanError.value = '' + isSimulatingRoute.value = false + requestedFloorId.value = '' + requestedFloorLabel.value = '' + loadingFloorId.value = '' + failedFloorId.value = '' + is3DMode.value = true + guideOutdoorState.value = 'home' + indoorView.value = initial.indoorView + activeGuideFloor.value = initial.defaultActiveFloorId || activeGuideFloor.value + renderedFloorId.value = initial.loadedFloorId || activeGuideFloor.value + closeArrivalPanel() + await homeSearchPanelRef.value?.resetSearchState?.() + closeHomeSearchDock() + + const resetApplied = await guideMapShellRef.value?.resetToInitialState?.() + if (!resetApplied) { + // The renderer is still becoming ready; retain only the latest requested reset. + pendingGuideModelResetUntilReady = true + } + console.info('馆内三维模型已恢复统一初始状态:', { reason }) +} + onShow(async () => { await nextTick() - if (pendingCollapseSearchAfterDetail) { - pendingCollapseSearchAfterDetail = false - const pendingFocusRequest = pendingSearchDetailFocusRequest - pendingSearchDetailFocusRequest = null - await homeSearchPanelRef.value?.returnToCollapsedAfterDetail?.() - homeSearchExpanded.value = false - homeCategoryModeActive.value = false - searchVisiblePoiIds.value = null - if (pendingFocusRequest) { - closeArrivalPanel() - showRoutePlanner.value = false - isSimulatingRoute.value = false - selectedGuidePoi.value = null - isPoiCardCollapsed.value = false - is3DMode.value = true - indoorView.value = 'floor' - activeGuideFloor.value = pendingFocusRequest.floorId - searchTargetFocusRequest.value = pendingFocusRequest - } + if (guideModelState.consumePoiDetailReturn()) { + await resetGuideModelToInitial({ reason: 'poi-detail-close' }) return } @@ -1569,29 +1594,13 @@ const createSearchDetailUrl = (poi: MuseumPoi, context: PoiSearchContext) => { } const handleHomeSearchResultTap = async (poi: MuseumPoi, context: PoiSearchContext) => { - searchTargetFocusRequestId += 1 - pendingSearchDetailFocusRequest = toTargetFocusRequestViewModel({ - poiId: poi.id, - name: poi.name, - floorId: poi.floorId, - floorLabel: poi.floorLabel, - primaryCategoryZh: poi.primaryCategory.label, - positionGltf: poi.positionGltf, - sourceObjectName: poi.sourceObjectName, - kind: poi.kind, - hallId: poi.hallId, - hallName: poi.hallName, - entrances: poi.entrances - }, searchTargetFocusRequestId) - - // 详情页会一次性挂载目标楼层;首页不再先启动并销毁 ThreeMap。 + // The homepage keeps its renderer mounted; detail close is consumed by onShow once. searchTargetFocusRequest.value = null - pendingCollapseSearchAfterDetail = true + guideModelState.beginPoiDetailReturn() uni.navigateTo({ url: createSearchDetailUrl(poi, context), fail: () => { - pendingCollapseSearchAfterDetail = false - pendingSearchDetailFocusRequest = null + guideModelState.cancelPoiDetailReturn() uni.showToast({ title: '点位详情打开失败,请重试', icon: 'none' diff --git a/tests/e2e/three-map-camera-stability.spec.ts b/tests/e2e/three-map-camera-stability.spec.ts index 70daa52..c289ff6 100644 --- a/tests/e2e/three-map-camera-stability.spec.ts +++ b/tests/e2e/three-map-camera-stability.spec.ts @@ -29,6 +29,8 @@ interface VisualReport { anchors: AnchorReport[] activeView: string floorId: string + activeFocusPoiId: string + hasPendingTargetFocus: boolean isCameraTweening: boolean poiFocusCameraAnimationCount: number cameraSnapshotRestoreCount: number @@ -58,6 +60,7 @@ interface VisualStabilityApi { getFloors: () => FloorReport[] switchFloor: (floorId: string) => Promise showOverview: () => Promise + resetToInitialState: () => Promise getFloorPois: (floorId: string) => Promise focusTargetPoi: (request: { requestId: number | string @@ -399,3 +402,33 @@ test('queued cross-floor search POI focus keeps raw GLB_METER coordinates and do await switchFloor(page, testInfo, l1, 'search-focus-clear-lminus1-l1') await showOverview(page, testInfo, 'search-focus-clear-l1-exterior') }) + +test('reset invalidates an in-flight cross-floor focus before it can overwrite the initial state', async ({ page }) => { + await page.goto('/') + await Promise.all([ + page.waitForURL(/tab=guide/, { timeout: 30_000 }), + page.getByText('馆内', { exact: true }).click() + ]) + await expect.poll(async () => (await getApiState(page)) !== null, { timeout: 180_000 }).toBe(true) + await expect.poll(async () => (await getApiState(page))?.floors.length ?? 0, { timeout: 180_000 }).toBeGreaterThan(0) + const state = await getApiState(page) + expect(state).not.toBeNull() + const targetFloor = state!.floors.find((floor) => floor.floorId !== state!.report.floorId)?.floorId + || state!.floors[0]!.floorId + const targetPoi = (await getFloorPois(page, targetFloor))[0]! + + await page.evaluate(async (request) => { + const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi }) + .__GUIDE_3D_VISUAL_STABILITY__ + if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable') + const focus = api.focusTargetPoi({ requestId: 'reset-race', ...request }) + await api.resetToInitialState() + await focus + }, targetPoi) + + const report = await getReport(page) + expect(report.activeView).toBe('overview') + expect(report.activeFocusPoiId).toBe('') + expect(report.hasPendingTargetFocus).toBe(false) + expect(report.focus).toBeNull() +}) diff --git a/tests/unit/FacilityDetail.spec.ts b/tests/unit/FacilityDetail.spec.ts index c76a6a6..1cb286a 100644 --- a/tests/unit/FacilityDetail.spec.ts +++ b/tests/unit/FacilityDetail.spec.ts @@ -143,10 +143,9 @@ afterEach(() => { }) describe('点位详情实际渲染与返回闭环', () => { - it('解析目标楼层和点位后才挂载地图,避免默认楼层中间帧', async () => { + it('目标楼层与点位就绪后挂载位置预览,避免默认楼层中间帧', async () => { const wrapper = mountDetail() - expect(wrapper.find('[data-testid="guide-map-shell"]').exists()).toBe(false) expect(wrapper.get('[data-testid="facility-map-loading"]').text()).toContain('正在准备位置预览') await loadDetail({ @@ -163,7 +162,7 @@ describe('点位详情实际渲染与返回闭环', () => { }) }) - it('只展示真实详情字段,并向地图发送正确楼层与点位定位请求', async () => { + it('只展示真实详情字段,并发送正确的位置预览请求', async () => { const wrapper = mountDetail() await loadDetail({ @@ -174,7 +173,6 @@ describe('点位详情实际渲染与返回闭环', () => { }) const sheet = wrapper.get('[data-testid="facility-detail-sheet"]') - const mapShell = wrapper.getComponent(GuideMapShellStub) expect(sheet.text()).toContain('二层卫生间') expect(sheet.text()).toContain('类别洗手间') expect(sheet.text()).toContain('楼层2F') @@ -182,10 +180,11 @@ describe('点位详情实际渲染与返回闭环', () => { expect(sheet.text()).not.toContain('展示点位') expect(sheet.text()).not.toContain('已在对应楼层模型中标出点位') expect(wrapper.find('.detail-restore').exists()).toBe(false) + const mapShell = wrapper.getComponent(GuideMapShellStub) expect(mapShell.props('activeFloor')).toBe('floor-l2') expect(mapShell.props('targetFocusRequest')).toMatchObject({ - poiId: 'poi-restroom-l2', - floorId: 'floor-l2', + poiId: facilityPoi.id, + floorId: facilityPoi.floorId, positionGltf: [12, 3, -8] }) }) @@ -223,7 +222,7 @@ describe('点位详情实际渲染与返回闭环', () => { expect(uni.reLaunch).not.toHaveBeenCalled() }) - it('搜索单结果关闭详情后回到首页', async () => { + it('搜索单结果关闭详情同样仅使用页面栈返回', async () => { const wrapper = mountDetail() await loadDetail({ source: 'search', @@ -234,8 +233,8 @@ describe('点位详情实际渲染与返回闭环', () => { await wrapper.get('[data-testid="facility-detail-close"]').trigger('tap') - expect(uni.reLaunch).toHaveBeenCalledWith({ url: '/pages/index/index' }) - expect(uni.navigateBack).not.toHaveBeenCalled() + expect(uni.navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 })) + expect(uni.reLaunch).not.toHaveBeenCalled() }) it('直接进入详情时使用常规返回', async () => { @@ -248,11 +247,10 @@ describe('点位详情实际渲染与返回闭环', () => { expect(uni.reLaunch).not.toHaveBeenCalled() }) - it('地图报告点位缺失或定位失败时显示面向用户的错误反馈', async () => { + it('位置预览失败时显示面向用户的错误反馈', async () => { const wrapper = mountDetail() await loadDetail({ id: facilityPoi.id, target: facilityPoi.name }) const mapShell = wrapper.getComponent(GuideMapShellStub) - mapShell.vm.$emit('target-focus', { requestId: 1, poiId: facilityPoi.id, @@ -260,18 +258,7 @@ describe('点位详情实际渲染与返回闭环', () => { status: 'missing' }) await flushPromises() - expect(wrapper.get('[data-testid="facility-location-error"]').text()) - .toContain('缺少可用的位置数据') - - mapShell.vm.$emit('target-focus', { - requestId: 1, - poiId: facilityPoi.id, - floorId: facilityPoi.floorId, - status: 'error' - }) - await flushPromises() - expect(wrapper.get('[data-testid="facility-location-error"]').text()) - .toContain('地图定位暂不可用') + expect(wrapper.get('[data-testid="facility-location-error"]').text()).toContain('缺少可用的位置数据') }) it('位置数据源加载失败时提供明确重试反馈', async () => { diff --git a/tests/unit/IndexPoiSearchFlow.spec.ts b/tests/unit/IndexPoiSearchFlow.spec.ts index 485f52a..7ed34c6 100644 --- a/tests/unit/IndexPoiSearchFlow.spec.ts +++ b/tests/unit/IndexPoiSearchFlow.spec.ts @@ -321,7 +321,7 @@ describe('首页搜索与地图闭环', () => { expect(map.props('visiblePoiIds')).toEqual(categoryContext.visiblePoiIds) }) - it('分类结果进入详情时不触发首页地图,返回后才恢复目标焦点与楼层', async () => { + it('分类结果进入详情后统一恢复首页模型初始状态', async () => { const wrapper = await mountIndex() const search = wrapper.getComponent(PoiSearchPanelStub) const map = wrapper.getComponent(GuideMapShellStub) @@ -354,17 +354,14 @@ describe('首页搜索与地图闭环', () => { await testState.onShowHandler?.() await flushPromises() - expect(testState.searchCollapseAfterDetailCount).toBe(1) + expect(testState.searchResetCount).toBeGreaterThan(0) expect(testState.searchRestoreCount).toBe(0) - expect(map.props('activeFloor')).toBe('L2') + expect(map.props('activeFloor')).toBe('L1') expect(map.props('visiblePoiIds')).toBeNull() - expect(map.props('targetFocusRequest')).toMatchObject({ - poiId: poi.id, - floorId: 'L2' - }) + expect(map.props('targetFocusRequest')).toBeNull() }) - it('全屏搜索结果进入详情并返回后同样恢复为收缩态', async () => { + it('全屏搜索结果进入详情并返回后同样恢复为默认收缩态', async () => { const wrapper = await mountIndex() const search = wrapper.getComponent(PoiSearchPanelStub) const map = wrapper.getComponent(GuideMapShellStub) @@ -379,10 +376,10 @@ describe('首页搜索与地图闭环', () => { await testState.onShowHandler?.() await flushPromises() - expect(testState.searchCollapseAfterDetailCount).toBe(1) + expect(testState.searchResetCount).toBeGreaterThan(0) expect(testState.searchRestoreCount).toBe(0) expect(map.props('visiblePoiIds')).toBeNull() - expect(map.props('targetFocusRequest')).toMatchObject({ poiId: poi.id }) + expect(map.props('targetFocusRequest')).toBeNull() }) it('页面重新显示时要求搜索面板恢复原列表位置', async () => {