Compare commits
2 Commits
e41b86d35d
...
6db0b71562
| Author | SHA1 | Date | |
|---|---|---|---|
| 6db0b71562 | |||
| 96541c5a89 |
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user