From 6db0b71562d72662fbd8837f7f44d9012b622332 Mon Sep 17 00:00:00 2001 From: lyf <2514544224@qq.com> Date: Thu, 16 Jul 2026 00:05:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A6=96=E9=A1=B5=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=8A=A0=E8=BD=BD=E8=B6=85=E6=97=B6=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E5=AE=A4=E5=A4=96=E5=9C=B0=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/index/index.vue | 23 +-- tests/unit/IndexLaunchOverlay.spec.ts | 204 +++++++++++++++++++++++--- 2 files changed, 197 insertions(+), 30 deletions(-) diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue index d62dd68..8d22a68 100644 --- a/src/pages/index/index.vue +++ b/src/pages/index/index.vue @@ -449,6 +449,7 @@ let launchOverlayFallbackTimer: ReturnType | null = null let launchOverlayFadeTimer: ReturnType | null = null let launchOverlayHideDelayTimer: ReturnType | null = null let launchOverlayStartedAt = 0 +let launchOverlayRunId = 0 const clearLaunchTimers = () => { if (launchOverlayFallbackTimer) { @@ -470,11 +471,13 @@ const clearLaunchTimers = () => { const completeHideLaunchOverlay = () => { if (!launchOverlayVisible.value) return + const runId = launchOverlayRunId launchProgress.value = 100 launchOverlayLeaving.value = true clearLaunchTimers() launchOverlayFadeTimer = setTimeout(() => { + if (runId !== launchOverlayRunId) return launchOverlayVisible.value = false launchOverlayLeaving.value = false launchOverlayFadeTimer = null @@ -489,7 +492,9 @@ const hideLaunchOverlay = () => { if (waitMs > 0) { if (launchOverlayHideDelayTimer) clearTimeout(launchOverlayHideDelayTimer) + const runId = launchOverlayRunId launchOverlayHideDelayTimer = setTimeout(() => { + if (runId !== launchOverlayRunId) return launchOverlayHideDelayTimer = null completeHideLaunchOverlay() }, waitMs) @@ -504,21 +509,18 @@ const shouldWaitForLaunchModel = () => ( && is3DMode.value ) -const continueLaunchWithoutInitialModel = (reason: string, event?: { view: GuideIndoorView; floorId?: string; message?: string; elapsedMs?: number }) => { +// The launch overlay is independent from the selected map business mode. Releasing it +// must leave ThreeMap mounted so its loading/error/retry lifecycle can continue. +const releaseLaunchOverlayWithoutInitialModel = (reason: string, event?: { view: GuideIndoorView; floorId?: string; message?: string; elapsedMs?: number }) => { if (!launchOverlayVisible.value || launchOverlayLeaving.value) return - console.warn('首页前置加载页未等待初始三维模型,已进入馆外导览:', { + console.warn('首页前置加载页未等待初始三维模型,已关闭启动遮罩:', { reason, event }) launchInitialModelSettled.value = true launchProgress.value = Math.max(launchProgress.value, 96) - is3DMode.value = false - guideOutdoorState.value = 'home' - selectedGuidePoi.value = null - isPoiCardCollapsed.value = false - isSimulatingRoute.value = false hideLaunchOverlay() } @@ -526,6 +528,7 @@ const startLaunchOverlay = () => { if (!launchOverlayVisible.value) return clearLaunchTimers() + const runId = ++launchOverlayRunId launchInitialModelSettled.value = false launchOverlayStartedAt = Date.now() launchProgress.value = shouldWaitForLaunchModel() ? 8 : 68 @@ -535,8 +538,9 @@ const startLaunchOverlay = () => { : launchOverlayNonGuideTimeoutMs launchOverlayFallbackTimer = setTimeout(() => { + if (runId !== launchOverlayRunId) return if (shouldWaitForLaunchModel() && !launchInitialModelSettled.value) { - continueLaunchWithoutInitialModel('timeout') + releaseLaunchOverlayWithoutInitialModel('timeout') return } hideLaunchOverlay() @@ -553,7 +557,7 @@ const settleLaunchOverlayByModel = (status: 'ready' | 'failed', event?: { view: launchInitialModelSettled.value = true if (status === 'failed') { - continueLaunchWithoutInitialModel('failed', event) + releaseLaunchOverlayWithoutInitialModel('failed', event) return } else { launchProgress.value = 100 @@ -885,6 +889,7 @@ onMounted(() => { }) onUnmounted(() => { + launchOverlayRunId += 1 clearLaunchTimers() if (indoorGestureHintTimer) { diff --git a/tests/unit/IndexLaunchOverlay.spec.ts b/tests/unit/IndexLaunchOverlay.spec.ts index eb7ee84..9da3226 100644 --- a/tests/unit/IndexLaunchOverlay.spec.ts +++ b/tests/unit/IndexLaunchOverlay.spec.ts @@ -1,26 +1,188 @@ -import { readFileSync } from 'node:fs' -import { fileURLToPath, URL } from 'node:url' -import { describe, expect, it } from 'vitest' +// @vitest-environment happy-dom -const indexPageSource = readFileSync( - fileURLToPath(new URL('../../src/pages/index/index.vue', import.meta.url)), - 'utf8' -) +import { defineComponent, nextTick, onMounted, onUnmounted } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import IndexPage from '@/pages/index/index.vue' -const launchOverlaySource = indexPageSource.slice( - indexPageSource.indexOf('v-if="launchOverlayVisible"'), - indexPageSource.indexOf('') -) +const testState = vi.hoisted(() => ({ + threeMapMountCount: 0, + threeMapUnmountCount: 0, + onShowHandler: null as null | (() => Promise | void) +})) -describe('首页精简加载页', () => { - it('仅展示图形标识、文字馆名和原生加载动效', () => { - expect(launchOverlaySource).toContain('/static/guide/museum-brand-icons.webp') - expect(launchOverlaySource).toContain('SHENZHEN NATURAL HISTORY MUSEUM') - expect(launchOverlaySource).toContain('深圳自然博物馆') - expect(launchOverlaySource).toContain(' ({ + onLoad: vi.fn(), + onShow: (handler: () => Promise | void) => { + testState.onShowHandler = handler + } +})) + +vi.mock('@/utils/hostEnvironment', () => ({ + isEmbeddedInWechatMiniProgram: () => false +})) + +const modelSource = { + loadPackage: async () => ({ overviewModelUrl: '', floors: [] }), + loadFloorPois: async () => [] +} + +vi.mock('@/usecases/guideUseCase', () => ({ + guideUseCase: { + getAssetBaseUrl: () => '/static/nav-assets', + getModelSource: () => modelSource, + normalizeFloorId: (value: string) => value, + getFloors: async () => [] + } +})) + +vi.mock('@/usecases/guideRouteUseCase', () => ({ + guideRouteUseCase: { + listTargets: async () => [], + searchTargets: async () => [], + getRouteReadiness: async () => ({ ready: false, message: '' }), + planRoute: async () => null + } +})) + +vi.mock('@/usecases/explainUseCase', () => ({ + explainUseCase: { + loadExplainHalls: async () => [], + loadExplainHallSummaries: async () => ({}), + listHalls: async () => [], + searchExplain: async () => [] + } +})) + +const GuidePageFrameStub = defineComponent({ + name: 'GuidePageFrame', + template: '
' +}) + +const GuideMapShellStub = defineComponent({ + name: 'GuideMapShell', + props: { + mapType: { type: String, default: 'indoor' } + }, + emits: ['initial-model-ready', 'initial-model-failed', 'mode-change'], + setup() { + onMounted(() => { + testState.threeMapMountCount += 1 + }) + onUnmounted(() => { + testState.threeMapUnmountCount += 1 + }) + return {} + }, + template: ` +
+
+
+
+ ` +}) + +const stubs = { + GuidePageFrame: GuidePageFrameStub, + GuideMapShell: GuideMapShellStub, + PoiSearchPanel: true, + RoutePlannerPanel: true, + ArrivalPanel: true, + ExplainHallSelect: true +} + +const mountIndex = async () => { + const wrapper = mount(IndexPage, { global: { stubs } }) + await flushPromises() + return wrapper +} + +const initialModelReady = { view: 'overview' as const, floorId: 'L1' } +const initialModelFailed = { view: 'overview' as const, floorId: 'L1', message: 'network failed' } + +beforeEach(() => { + vi.useFakeTimers() + testState.threeMapMountCount = 0 + testState.threeMapUnmountCount = 0 + testState.onShowHandler = null + window.history.replaceState({}, '', '/#/pages/index/index?tab=guide') + vi.stubGlobal('uni', { + navigateTo: vi.fn(), + navigateBack: vi.fn(), + reLaunch: vi.fn(), + showToast: vi.fn(), + hideKeyboard: vi.fn() + }) +}) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('首页启动遮罩与馆内模型生命周期', () => { + it('12 秒超时后仍保持 indoor,且不会挂载 TencentMap', async () => { + const wrapper = await mountIndex() + + await vi.advanceTimersByTimeAsync(12000) + await vi.advanceTimersByTimeAsync(360) + + expect(wrapper.getComponent(GuideMapShellStub).props('mapType')).toBe('indoor') + expect(wrapper.get('[data-testid="three-map"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="tencent-map"]').exists()).toBe(false) + expect(testState.threeMapMountCount).toBe(1) + expect(testState.threeMapUnmountCount).toBe(0) + wrapper.unmount() + }) + + it('超时后的迟到 initialModelReady 仍可完成当前 ThreeMap 加载', async () => { + const wrapper = await mountIndex() + const shell = wrapper.getComponent(GuideMapShellStub) + + await vi.advanceTimersByTimeAsync(12000) + shell.vm.$emit('initial-model-ready', initialModelReady) + await nextTick() + await vi.advanceTimersByTimeAsync(360) + + expect(shell.props('mapType')).toBe('indoor') + expect(wrapper.find('.app-launch-overlay').exists()).toBe(false) + expect(testState.threeMapMountCount).toBe(1) + expect(testState.threeMapUnmountCount).toBe(0) + wrapper.unmount() + }) + + it('initialModelFailed 后保留 ThreeMap,重试就绪不产生额外挂载', async () => { + const wrapper = await mountIndex() + const shell = wrapper.getComponent(GuideMapShellStub) + + shell.vm.$emit('initial-model-failed', initialModelFailed) + await nextTick() + await vi.advanceTimersByTimeAsync(360) + + expect(shell.props('mapType')).toBe('indoor') + expect(wrapper.get('[data-testid="three-map"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="tencent-map"]').exists()).toBe(false) + + shell.vm.$emit('initial-model-ready', initialModelReady) + await nextTick() + expect(testState.threeMapMountCount).toBe(1) + expect(testState.threeMapUnmountCount).toBe(0) + wrapper.unmount() + }) + + it('仅在用户主动切换到馆外 2D 时才挂载 TencentMap', async () => { + const wrapper = await mountIndex() + const shell = wrapper.getComponent(GuideMapShellStub) + + shell.vm.$emit('mode-change', '2d') + await nextTick() + expect(shell.props('mapType')).toBe('outdoor') + expect(wrapper.get('[data-testid="tencent-map"]').exists()).toBe(true) + + shell.vm.$emit('mode-change', '3d') + await nextTick() + expect(shell.props('mapType')).toBe('indoor') + wrapper.unmount() }) })