Compare commits

...

2 Commits

Author SHA1 Message Date
lyf
6db0b71562 修复首页模型加载超时切换室外地图
Some checks failed
CI / verify (push) Has been cancelled
2026-07-16 00:05:18 +08:00
lyf
96541c5a89 固定讲解详情页语言切换栏 2026-07-15 23:42:33 +08:00
4 changed files with 235 additions and 53 deletions

View File

@@ -32,13 +32,8 @@
</view> </view>
</view> </view>
</view> </view>
<scroll-view <view v-if="contentLanguageOptions.length" class="detail-language-bar">
class="content" <view class="language-switch" role="tablist" aria-label="讲解语言">
scroll-y
:show-scrollbar="false"
>
<view class="detail-content">
<view v-if="contentLanguageOptions.length" class="language-switch" role="tablist" aria-label="讲解语言">
<button <button
v-for="option in contentLanguageOptions" v-for="option in contentLanguageOptions"
:key="option.value" :key="option.value"
@@ -56,7 +51,13 @@
<text class="language-option-text">{{ option.label }}</text> <text class="language-option-text">{{ option.label }}</text>
</button> </button>
</view> </view>
</view>
<scroll-view
class="content"
scroll-y
:show-scrollbar="false"
>
<view class="detail-content">
<view class="content-section" :class="{ 'is-english': selectedAudioLanguage === 'en-US' }"> <view class="content-section" :class="{ 'is-english': selectedAudioLanguage === 'en-US' }">
<template v-if="currentDetailParagraphs.length"> <template v-if="currentDetailParagraphs.length">
<text <text
@@ -862,7 +863,7 @@ const handleBack = () => {
.content { .content {
position: absolute; position: absolute;
top: 320px; top: 392px;
right: 0; right: 0;
bottom: 0; bottom: 0;
left: 0; left: 0;
@@ -872,6 +873,18 @@ const handleBack = () => {
background: #f7f8f3; background: #f7f8f3;
} }
.detail-language-bar {
position: absolute;
top: 320px;
right: 0;
left: 0;
z-index: 2;
height: 72px;
padding: 25px 24px 7px;
box-sizing: border-box;
background: #f7f8f3;
}
.immersive-hero { .immersive-hero {
position: relative; position: relative;
min-height: 0; min-height: 0;
@@ -969,8 +982,8 @@ const handleBack = () => {
.detail-chip-row { display: none; } .detail-chip-row { display: none; }
.detail-content { .detail-content {
min-height: calc(100vh - 320px - 72px - env(safe-area-inset-bottom)); min-height: calc(100vh - 392px - 72px - env(safe-area-inset-bottom));
padding: 25px 24px calc(32px + 72px + env(safe-area-inset-bottom)); padding: 24px 24px calc(32px + 72px + env(safe-area-inset-bottom));
box-sizing: border-box; box-sizing: border-box;
background: #f7f8f3; background: #f7f8f3;
} }
@@ -1067,7 +1080,7 @@ const handleBack = () => {
} }
.content-section { .content-section {
margin-top: 24px; margin-top: 0;
padding-top: 0; padding-top: 0;
} }

View File

@@ -449,6 +449,7 @@ let launchOverlayFallbackTimer: ReturnType<typeof setTimeout> | null = null
let launchOverlayFadeTimer: ReturnType<typeof setTimeout> | null = null let launchOverlayFadeTimer: ReturnType<typeof setTimeout> | null = null
let launchOverlayHideDelayTimer: ReturnType<typeof setTimeout> | null = null let launchOverlayHideDelayTimer: ReturnType<typeof setTimeout> | null = null
let launchOverlayStartedAt = 0 let launchOverlayStartedAt = 0
let launchOverlayRunId = 0
const clearLaunchTimers = () => { const clearLaunchTimers = () => {
if (launchOverlayFallbackTimer) { if (launchOverlayFallbackTimer) {
@@ -470,11 +471,13 @@ const clearLaunchTimers = () => {
const completeHideLaunchOverlay = () => { const completeHideLaunchOverlay = () => {
if (!launchOverlayVisible.value) return if (!launchOverlayVisible.value) return
const runId = launchOverlayRunId
launchProgress.value = 100 launchProgress.value = 100
launchOverlayLeaving.value = true launchOverlayLeaving.value = true
clearLaunchTimers() clearLaunchTimers()
launchOverlayFadeTimer = setTimeout(() => { launchOverlayFadeTimer = setTimeout(() => {
if (runId !== launchOverlayRunId) return
launchOverlayVisible.value = false launchOverlayVisible.value = false
launchOverlayLeaving.value = false launchOverlayLeaving.value = false
launchOverlayFadeTimer = null launchOverlayFadeTimer = null
@@ -489,7 +492,9 @@ const hideLaunchOverlay = () => {
if (waitMs > 0) { if (waitMs > 0) {
if (launchOverlayHideDelayTimer) clearTimeout(launchOverlayHideDelayTimer) if (launchOverlayHideDelayTimer) clearTimeout(launchOverlayHideDelayTimer)
const runId = launchOverlayRunId
launchOverlayHideDelayTimer = setTimeout(() => { launchOverlayHideDelayTimer = setTimeout(() => {
if (runId !== launchOverlayRunId) return
launchOverlayHideDelayTimer = null launchOverlayHideDelayTimer = null
completeHideLaunchOverlay() completeHideLaunchOverlay()
}, waitMs) }, waitMs)
@@ -504,21 +509,18 @@ const shouldWaitForLaunchModel = () => (
&& is3DMode.value && 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 if (!launchOverlayVisible.value || launchOverlayLeaving.value) return
console.warn('首页前置加载页未等待初始三维模型,已进入馆外导览:', { console.warn('首页前置加载页未等待初始三维模型,已关闭启动遮罩:', {
reason, reason,
event event
}) })
launchInitialModelSettled.value = true launchInitialModelSettled.value = true
launchProgress.value = Math.max(launchProgress.value, 96) launchProgress.value = Math.max(launchProgress.value, 96)
is3DMode.value = false
guideOutdoorState.value = 'home'
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
isSimulatingRoute.value = false
hideLaunchOverlay() hideLaunchOverlay()
} }
@@ -526,6 +528,7 @@ const startLaunchOverlay = () => {
if (!launchOverlayVisible.value) return if (!launchOverlayVisible.value) return
clearLaunchTimers() clearLaunchTimers()
const runId = ++launchOverlayRunId
launchInitialModelSettled.value = false launchInitialModelSettled.value = false
launchOverlayStartedAt = Date.now() launchOverlayStartedAt = Date.now()
launchProgress.value = shouldWaitForLaunchModel() ? 8 : 68 launchProgress.value = shouldWaitForLaunchModel() ? 8 : 68
@@ -535,8 +538,9 @@ const startLaunchOverlay = () => {
: launchOverlayNonGuideTimeoutMs : launchOverlayNonGuideTimeoutMs
launchOverlayFallbackTimer = setTimeout(() => { launchOverlayFallbackTimer = setTimeout(() => {
if (runId !== launchOverlayRunId) return
if (shouldWaitForLaunchModel() && !launchInitialModelSettled.value) { if (shouldWaitForLaunchModel() && !launchInitialModelSettled.value) {
continueLaunchWithoutInitialModel('timeout') releaseLaunchOverlayWithoutInitialModel('timeout')
return return
} }
hideLaunchOverlay() hideLaunchOverlay()
@@ -553,7 +557,7 @@ const settleLaunchOverlayByModel = (status: 'ready' | 'failed', event?: { view:
launchInitialModelSettled.value = true launchInitialModelSettled.value = true
if (status === 'failed') { if (status === 'failed') {
continueLaunchWithoutInitialModel('failed', event) releaseLaunchOverlayWithoutInitialModel('failed', event)
return return
} else { } else {
launchProgress.value = 100 launchProgress.value = 100
@@ -885,6 +889,7 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
launchOverlayRunId += 1
clearLaunchTimers() clearLaunchTimers()
if (indoorGestureHintTimer) { if (indoorGestureHintTimer) {

View File

@@ -141,6 +141,8 @@ describe('讲解详情音频优先布局', () => {
expect(wrapper.find('.detail-audio-dock').exists()).toBe(true) expect(wrapper.find('.detail-audio-dock').exists()).toBe(true)
expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-playable') expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-playable')
expect(wrapper.find('.language-switch').text()).toContain('English') expect(wrapper.find('.language-switch').text()).toContain('English')
expect(wrapper.find('.detail-language-bar').exists()).toBe(true)
expect(wrapper.find('.content .language-switch').exists()).toBe(false)
expect(wrapper.get('.content-section').classes()).toContain('is-english') expect(wrapper.get('.content-section').classes()).toContain('is-english')
expect(wrapper.find('.section-title').exists()).toBe(false) expect(wrapper.find('.section-title').exists()).toBe(false)
expect(wrapper.get('.section-text').text()).toContain('detailed English narration') expect(wrapper.get('.section-text').text()).toContain('detailed English narration')

View File

@@ -1,26 +1,188 @@
import { readFileSync } from 'node:fs' // @vitest-environment happy-dom
import { fileURLToPath, URL } from 'node:url'
import { describe, expect, it } from 'vitest'
const indexPageSource = readFileSync( import { defineComponent, nextTick, onMounted, onUnmounted } from 'vue'
fileURLToPath(new URL('../../src/pages/index/index.vue', import.meta.url)), import { flushPromises, mount } from '@vue/test-utils'
'utf8' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
) import IndexPage from '@/pages/index/index.vue'
const launchOverlaySource = indexPageSource.slice( const testState = vi.hoisted(() => ({
indexPageSource.indexOf('v-if="launchOverlayVisible"'), threeMapMountCount: 0,
indexPageSource.indexOf('</GuidePageFrame>') threeMapUnmountCount: 0,
) onShowHandler: null as null | (() => Promise<void> | void)
}))
describe('首页精简加载页', () => { vi.mock('@dcloudio/uni-app', () => ({
it('仅展示图形标识、文字馆名和原生加载动效', () => { onLoad: vi.fn(),
expect(launchOverlaySource).toContain('/static/guide/museum-brand-icons.webp') onShow: (handler: () => Promise<void> | void) => {
expect(launchOverlaySource).toContain('SHENZHEN NATURAL HISTORY MUSEUM') testState.onShowHandler = handler
expect(launchOverlaySource).toContain('深圳自然博物馆') }
expect(launchOverlaySource).toContain('<progress') }))
expect(launchOverlaySource).not.toContain('app-launch-loading-lqip.webp')
expect(launchOverlaySource).not.toContain('app-launch-loading.webp') vi.mock('@/utils/hostEnvironment', () => ({
expect(launchOverlaySource).not.toContain('app-launch-kicker') isEmbeddedInWechatMiniProgram: () => false
expect(launchOverlaySource).not.toContain('app-launch-status') }))
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: '<main><slot /></main>'
})
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: `
<section data-testid="guide-map-shell">
<div v-if="mapType === 'indoor'" data-testid="three-map"></div>
<div v-else data-testid="tencent-map"></div>
</section>
`
})
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()
}) })
}) })