From f461f2e325b64b772447b6a97b7f1fba266b5f0b Mon Sep 17 00:00:00 2001 From: lyf <2514544224@qq.com> Date: Wed, 15 Jul 2026 17:26:11 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E6=95=B4=E8=AE=B2=E8=A7=A3=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=A1=B5=E9=9F=B3=E9=A2=91=E6=92=AD=E6=94=BE=E5=99=A8?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/exhibit/detail.vue | 516 ++++++++++++--------- tests/unit/ExhibitDetailAudioFirst.spec.ts | 91 +++- 2 files changed, 385 insertions(+), 222 deletions(-) diff --git a/src/pages/exhibit/detail.vue b/src/pages/exhibit/detail.vue index e65e01b..834ba64 100644 --- a/src/pages/exhibit/detail.vue +++ b/src/pages/exhibit/detail.vue @@ -6,72 +6,38 @@ @back="handleBack" > + + + + + + {{ displayTitle }} + {{ heroSubtitle }} + + + 标准解说 + + + + - - - - - - {{ displayTitle }} - {{ heroSubtitle }} - - - 标准解说 - - - - - - - - - {{ audioDockTitle }} - {{ audioDockSubtitle }} - - - - - {{ audioAvailabilityMessage }} - + + {{ audioDockTitle }} + {{ audioDockSubtitle }} + + + + + + + + + { && source.targetId === targetId ) }) -const isDetailAudioDockVisible = computed(() => ( - !languageSwitchLoading.value && exhibit.value.audio.status === 'playable' -)) const audioDockTitle = computed(() => displayTitle.value || '讲解内容') const formatDetailAudioTime = (seconds?: number) => { const normalizedSeconds = Number.isFinite(seconds || 0) @@ -304,7 +310,13 @@ const detailAudioTimeLabel = computed(() => ( )) const audioDockSubtitle = computed(() => [ audioLanguageLabel(selectedAudioLanguage.value), - detailAudioTimeLabel.value + audioDockState.value === 'failed' + ? globalAudioPlayer.error.value || '音频暂时无法播放' + : audioDockState.value === 'unavailable' + ? audioAvailabilityMessage.value + : audioDockState.value === 'loading' + ? '正在加载音频' + : detailAudioTimeLabel.value ].filter(Boolean).join(' · ') || '讲解') const detailAudioPlaying = computed(() => ( isCurrentDetailAudio.value @@ -321,6 +333,25 @@ const detailAudioProgressPercent = computed(() => { return Math.max(0, Math.min(100, (detailAudioCurrentSeconds.value / total) * 100)) }) +const isCurrentDetailAudioError = computed(() => { + const source = globalAudioPlayer.currentSource.value + return Boolean( + globalAudioPlayer.error.value + && source?.targetType === currentAudioTarget.value.targetType + && source.targetId === currentAudioTarget.value.targetId + ) +}) +const isDetailAudioLoading = computed(() => ( + languageSwitchLoading.value + || retryingAudio.value + || (isCurrentDetailAudioTarget.value && globalAudioPlayer.loading.value) +)) +const audioDockState = computed<'playable' | 'loading' | 'unavailable' | 'failed'>(() => { + if (isDetailAudioLoading.value) return 'loading' + if (isCurrentDetailAudioError.value) return 'failed' + if (exhibit.value.audio.status !== 'playable') return 'unavailable' + return 'playable' +}) // 讲解详情页只定位到所属展厅,不再追踪具体展品点位。 const resolveHallGuidePoi = async () => { return guideUseCase.resolveContentLocationPreviewTarget({ @@ -693,8 +724,25 @@ const refreshCurrentAudioOnce = async (message: string) => { return null } -const handlePlayAudio = async () => { - if (isCurrentDetailAudio.value) { +const playDetailAudio = async (audio: AudioItem) => { + await globalAudioPlayer.play(audio, { + source: { + exhibitId: exhibit.value.id, + targetType: currentAudioTarget.value.targetType, + targetId: currentAudioTarget.value.targetId, + lang: selectedAudioLanguage.value, + title: audio.name, + detailRoute: buildDetailRoute() + }, + retryOnError: refreshCurrentAudioOnce, + displayMode: 'mini' + }) +} + +const handlePlayAudio = async (options: { forceRefresh?: boolean } = {}) => { + if (audioDockState.value === 'unavailable') return + + if (isCurrentDetailAudio.value && !options.forceRefresh) { if (globalAudioPlayer.playing.value) { globalAudioPlayer.pause() return @@ -704,6 +752,14 @@ const handlePlayAudio = async () => { return } + const refreshedAudio = options.forceRefresh + ? await refreshCurrentAudioOnce(globalAudioPlayer.error.value) + : null + if (refreshedAudio) { + await playDetailAudio(refreshedAudio) + return + } + const selection = exhibit.value.id ? await explainUseCase.selectAudioForExplainDetail({ id: exhibit.value.id, @@ -732,18 +788,20 @@ const handlePlayAudio = async () => { const audio = toAudioItem(selection) if (!audio) return - await globalAudioPlayer.play(audio, { - source: { - exhibitId: exhibit.value.id, - targetType: currentAudioTarget.value.targetType, - targetId: currentAudioTarget.value.targetId, - lang: selectedAudioLanguage.value, - title: audio.name, - detailRoute: buildDetailRoute() - }, - retryOnError: refreshCurrentAudioOnce, - displayMode: 'mini' - }) + await playDetailAudio(audio) +} + +const handleRetryAudio = async () => { + await handlePlayAudio({ forceRefresh: true }) +} + +const handleSeekAudio = (event: { detail?: { x?: number }; currentTarget?: { offsetWidth?: number } }) => { + if (audioDockState.value !== 'playable' || !detailAudioDurationSeconds.value) return + + const width = event.currentTarget?.offsetWidth || 0 + const x = event.detail?.x + if (!width || typeof x !== 'number') return + globalAudioPlayer.seekToPercent(Math.max(0, Math.min(100, x / width * 100))) } const handleNavigate = async () => { @@ -790,19 +848,6 @@ const fallbackToExplainHome = () => { } const handleBack = () => { - if (typeof window !== 'undefined' && window.history.length > 1) { - const currentHref = window.location.href - window.history.back() - - // H5 深链或异常历史栈下 history.back 可能不改变页面,兜底回到讲解首页。 - window.setTimeout(() => { - if (window.location.href === currentHref) { - fallbackToExplainHome() - } - }, 800) - return - } - uni.navigateBack({ delta: 1, fail: fallbackToExplainHome @@ -820,7 +865,12 @@ const handleBack = () => { } .content { - height: 100%; + position: absolute; + top: 320px; + right: 0; + bottom: 0; + left: 0; + height: auto; padding: 0; box-sizing: border-box; background: #f7f8f3; @@ -829,8 +879,8 @@ const handleBack = () => { .immersive-hero { position: relative; min-height: 0; - height: clamp(238px, 30vh, 286px); - padding: calc(env(safe-area-inset-top) + 14px) 20px 20px; + height: 320px; + padding: calc(env(safe-area-inset-top) + 0px) 24px 24px; box-sizing: border-box; overflow: hidden; background: #111a14; @@ -847,7 +897,7 @@ const handleBack = () => { inset: 0; z-index: 1; pointer-events: none; - background: rgba(8, 14, 10, 0.44); + background: linear-gradient(180deg, rgba(0, 0, 0, 0.08) 42%, rgba(0, 0, 0, 0.58) 100%); } .hero-real-image { @@ -870,7 +920,7 @@ const handleBack = () => { justify-content: center; border: 0; border-radius: 50%; - background: rgba(7, 12, 8, 0.42); + background: transparent; color: #ffffff; } @@ -889,8 +939,8 @@ const handleBack = () => { position: relative; z-index: 2; min-height: 0; - height: calc(100% - 44px); - padding-top: 12px; + height: calc(100% - 44px - env(safe-area-inset-top)); + padding-top: 0; display: flex; flex-direction: column; justify-content: flex-end; @@ -899,8 +949,8 @@ const handleBack = () => { .detail-title { display: block; max-width: 100%; - font-size: 26px; - line-height: 32px; + font-size: 28px; + line-height: 34px; font-weight: 800; letter-spacing: 0; color: #ffffff; @@ -920,50 +970,15 @@ const handleBack = () => { white-space: nowrap; } -.detail-chip-row { - margin-top: 9px; -} - -.detail-chip { - height: 24px; - padding: 0 8px; - display: flex; - align-items: center; - box-sizing: border-box; - border: 1px solid transparent; - border-radius: 5px; -} - -.detail-chip.shared { - background: rgba(223, 225, 0, 0.15); - border-color: rgba(223, 225, 0, 0.62); -} - -.detail-chip-text, -.detail-chip.shared .detail-chip-text { - font-size: 12px; - line-height: 16px; - font-weight: 700; - color: #f0f19b; -} +.detail-chip-row { display: none; } .detail-content { - min-height: calc(70vh - env(safe-area-inset-top)); - padding: 16px 20px calc(28px + env(safe-area-inset-bottom)); + min-height: calc(100vh - 320px - 72px - env(safe-area-inset-bottom)); + padding: 25px 24px calc(32px + 72px + env(safe-area-inset-bottom)); box-sizing: border-box; background: #f7f8f3; } -.detail-audio-control { - min-height: 82px; - display: flex; - align-items: center; - gap: 12px; - padding: 10px 0; - box-sizing: border-box; - border-bottom: 1px solid #d8ddd2; -} - .detail-audio-play { width: 44px; height: 44px; @@ -974,8 +989,8 @@ const handleBack = () => { justify-content: center; border: 0; border-radius: 50%; - background: #e0e100; - color: #1a2017; + background: #e8e800; + color: #141412; } .detail-audio-play::after, @@ -983,73 +998,9 @@ const handleBack = () => { border: 0; } -.detail-audio-main { - min-width: 0; - flex: 1; - padding: 0; -} - -.detail-audio-title { - display: block; - font-size: 15px; - line-height: 21px; - font-weight: 800; - color: #20241e; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.detail-audio-subtitle { - display: block; - margin-top: 2px; - font-size: 13px; - line-height: 18px; - color: #62695f; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.detail-audio-progress { - height: 3px; - margin-top: 9px; - overflow: hidden; - background: #d6dbd1; - border-radius: 2px; -} - -.detail-audio-progress-fill { - width: 0; - height: 100%; - background: #b9bb00; - border-radius: inherit; -} - -.detail-audio-state { - width: 28px; - height: 44px; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - color: #4f584c; -} - -.detail-audio-unavailable { - min-height: 82px; - padding: 28px 0 18px; - display: block; - box-sizing: border-box; - border-bottom: 1px solid #d8ddd2; - font-size: 14px; - line-height: 20px; - color: #7a5a22; -} - .language-switch { - height: 44px; - margin-top: 14px; + height: 40px; + margin-top: 0; padding: 0; display: flex; box-sizing: border-box; @@ -1062,7 +1013,7 @@ const handleBack = () => { position: relative; min-width: 0; flex: 1; - height: 42px; + height: 40px; padding: 0 6px; display: flex; align-items: center; @@ -1079,18 +1030,18 @@ const handleBack = () => { } .language-option.active { - background: #eef0e8; - color: #1e251b; + background: #e8e800; + color: #141412; } .language-option.active::before { position: absolute; right: 10px; - bottom: 5px; + bottom: 1px; left: 10px; - height: 2px; + height: 3px; content: ''; - background: #b9bb00; + background: #141412; } .language-option.disabled { @@ -1159,6 +1110,142 @@ const handleBack = () => { color: #7a5a22; } +.detail-audio-dock { + position: absolute; + right: 0; + bottom: 0; + left: 0; + z-index: 10; + height: calc(72px + env(safe-area-inset-bottom)); + padding: 0 24px env(safe-area-inset-bottom); + box-sizing: border-box; + background: #ffffff; + border-top: 1px solid #e8e5de; +} + +.detail-audio-dock-main { + height: 50px; + display: flex; + align-items: center; + gap: 14px; +} + +.detail-audio-main { + min-width: 0; + flex: 1; +} + +.detail-audio-title, +.detail-audio-subtitle { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.detail-audio-title { + font-size: 14px; + line-height: 20px; + font-weight: 500; + color: #141412; +} + +.detail-audio-subtitle { + margin-top: 1px; + font-size: 12px; + line-height: 16px; + color: #666661; +} + +.detail-audio-play.disabled { + background: #e8e5de; + color: #94948f; +} + +.detail-audio-retry { + height: 32px; + padding: 0 10px; + flex-shrink: 0; + border: 0; + border-radius: 4px; + background: #e8e800; + color: #141412; + font-size: 13px; + line-height: 18px; + font-weight: 600; +} + +.detail-audio-retry::after { + border: 0; +} + +.detail-audio-progress-hit { + height: 22px; + margin: 0 0 0 58px; + display: flex; + align-items: center; +} + +.detail-audio-progress-hit.disabled { + opacity: 0.42; +} + +.detail-audio-progress { + position: relative; + width: 100%; + height: 4px; + overflow: visible; + background: #94948f; + border-radius: 2px; +} + +.detail-audio-progress-fill { + width: 0; + height: 100%; + background: #e0e100; + border-radius: inherit; +} + +.detail-audio-progress-thumb { + position: absolute; + top: 50%; + width: 10px; + height: 10px; + border-radius: 50%; + background: #141412; + transform: translate(-50%, -50%); +} + +.detail-audio-progress.is-indeterminate { + width: calc(100% - 58px); + height: 4px; + margin: 8px 0 0 58px; + overflow: hidden; + background: #deded6; +} + +.detail-audio-progress.is-indeterminate::after { + display: block; + width: 38%; + height: 100%; + content: ''; + background: #e0e100; + animation: audio-indeterminate 1.1s ease-in-out infinite; +} + +.audio-loading-spinner { + width: 18px; + height: 18px; + box-sizing: border-box; + border: 2px solid rgba(20, 20, 18, 0.28); + border-top-color: #141412; + border-radius: 50%; + animation: audio-spinner 0.8s linear infinite; +} + +@keyframes audio-spinner { to { transform: rotate(360deg); } } +@keyframes audio-indeterminate { from { transform: translateX(-110%); } to { transform: translateX(290%); } } + @media (min-width: 420px) { .immersive-hero { padding-right: 24px; @@ -1181,5 +1268,10 @@ const handleBack = () => { padding-right: 16px; padding-left: 16px; } + + .detail-audio-dock { + padding-right: 16px; + padding-left: 16px; + } } diff --git a/tests/unit/ExhibitDetailAudioFirst.spec.ts b/tests/unit/ExhibitDetailAudioFirst.spec.ts index fe45994..24f6a92 100644 --- a/tests/unit/ExhibitDetailAudioFirst.spec.ts +++ b/tests/unit/ExhibitDetailAudioFirst.spec.ts @@ -12,13 +12,16 @@ const mocks = vi.hoisted(() => ({ play: vi.fn(), pause: vi.fn(), resume: vi.fn(), - close: vi.fn() + close: vi.fn(), + seekToPercent: vi.fn() })) const audioState = vi.hoisted(() => ({ currentAudio: { value: null as any }, currentSource: { value: null as any }, playing: { value: false }, + loading: { value: false }, + error: { value: '' }, currentTime: { value: 0 }, duration: { value: 0 } })) @@ -52,7 +55,8 @@ vi.mock('@/composables/useGlobalAudioPlayer', () => ({ pause: mocks.pause, resume: mocks.resume, switchLanguage: vi.fn(), - close: mocks.close + close: mocks.close, + seekToPercent: mocks.seekToPercent }) })) @@ -88,6 +92,13 @@ const exhibit = { describe('讲解详情音频优先布局', () => { beforeEach(() => { mocks.onLoadHandler = null + audioState.currentAudio.value = null + audioState.currentSource.value = null + audioState.playing.value = false + audioState.loading.value = false + audioState.error.value = '' + audioState.currentTime.value = 0 + audioState.duration.value = 0 mocks.enterExplainDetail.mockResolvedValue(exhibit) mocks.loadExplainDetailText.mockResolvedValue({ available: false }) mocks.selectAudioForExplainDetail.mockResolvedValue({ @@ -102,7 +113,12 @@ describe('讲解详情音频优先布局', () => { playInfo: { title: exhibit.name } }) mocks.play.mockResolvedValue(undefined) - vi.stubGlobal('uni', { showToast: vi.fn(), navigateBack: vi.fn() }) + vi.stubGlobal('uni', { + showToast: vi.fn(), + navigateBack: vi.fn(), + redirectTo: vi.fn(), + reLaunch: vi.fn() + }) }) afterEach(() => { @@ -110,7 +126,7 @@ describe('讲解详情音频优先布局', () => { vi.unstubAllGlobals() }) - it('将展品身份、流内播放器、语言栏和英文正文按顺序渲染', async () => { + it('正常状态渲染固定底部播放器、语言栏和英文正文', async () => { const wrapper = mount(ExhibitDetail, { global: { stubs: { GuidePageFrame: GuidePageFrameStub } } }) @@ -122,8 +138,8 @@ describe('讲解详情音频优先布局', () => { expect(wrapper.get('.detail-audio-title').text()).toBe('Classical Astrolabe') expect(wrapper.get('.detail-meta').text()).toContain('Universe Hall · B2') expect(wrapper.get('.detail-chip').text()).toBe('标准解说') - expect(wrapper.find('.detail-audio-control').exists()).toBe(true) - expect(wrapper.find('.detail-audio-dock').exists()).toBe(false) + expect(wrapper.find('.detail-audio-dock').exists()).toBe(true) + expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-playable') expect(wrapper.find('.language-switch').text()).toContain('English') expect(wrapper.get('.content-section').classes()).toContain('is-english') expect(wrapper.get('.section-title').text()).toBe('Transcript') @@ -145,7 +161,23 @@ describe('讲解详情音频优先布局', () => { }), expect.objectContaining({ displayMode: 'mini' })) }) - it('音频不可用时只在音频控制区显示一次提示', async () => { + it('加载中使用同一高度底栏和不确定进度', async () => { + audioState.currentAudio.value = { id: 'audio-1' } + audioState.currentSource.value = { targetType: 'STOP', targetId: 'stop-1', lang: 'en-US' } + audioState.loading.value = true + const wrapper = mount(ExhibitDetail, { + global: { stubs: { GuidePageFrame: GuidePageFrameStub } } + }) + + await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' }) + await flushPromises() + + expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-loading') + expect(wrapper.find('.audio-loading-spinner').exists()).toBe(true) + expect(wrapper.find('.detail-audio-progress.is-indeterminate').exists()).toBe(true) + }) + + it('无音频时禁用播放按钮并显示原因', async () => { mocks.enterExplainDetail.mockResolvedValue({ ...exhibit, audioUrl: undefined, @@ -162,8 +194,47 @@ describe('讲解详情音频优先布局', () => { await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'yue-HK', targetType: 'STOP', targetId: 'stop-1' }) await flushPromises() - expect(wrapper.find('.detail-audio-unavailable').text()).toBe('当前语言暂无语音讲解。') - expect(wrapper.find('.audio-status-hint').exists()).toBe(false) - expect(wrapper.findAll('.section-hint')).toHaveLength(0) + expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-unavailable') + expect(wrapper.get('.detail-audio-play').attributes('disabled')).toBeDefined() + expect(wrapper.get('.detail-audio-subtitle').text()).toContain('当前语言暂无语音讲解。') + }) + + it('播放失败显示错误并通过既有刷新链路重试', async () => { + audioState.currentAudio.value = { id: 'audio-1', audioUrl: exhibit.audioUrl } + audioState.currentSource.value = { targetType: 'STOP', targetId: 'stop-1', lang: 'en-US' } + audioState.error.value = '音频暂时无法播放' + mocks.selectAudioForExplainDetail.mockResolvedValue({ + playable: true, + exhibit, + media: { id: 'audio-refresh', url: exhibit.audioUrl, duration: exhibit.audioDuration, language: 'en-US' }, + playInfo: { title: exhibit.name } + }) + const wrapper = mount(ExhibitDetail, { + global: { stubs: { GuidePageFrame: GuidePageFrameStub } } + }) + + await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' }) + await flushPromises() + expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-failed') + expect(wrapper.get('.detail-audio-retry').text()).toBe('重试') + + await wrapper.get('.detail-audio-retry').trigger('tap') + await flushPromises() + expect(mocks.selectAudioForExplainDetail).toHaveBeenCalledWith(expect.any(Object), { refreshPlayInfo: true }) + expect(mocks.play).toHaveBeenCalledWith(expect.objectContaining({ id: 'audio-refresh' }), expect.objectContaining({ + retryOnError: expect.any(Function), + displayMode: 'mini' + })) + }) + + it('顶部返回统一使用页面栈,以便导览首页 onShow 消费复位上下文', async () => { + const wrapper = mount(ExhibitDetail, { + global: { stubs: { GuidePageFrame: GuidePageFrameStub } } + }) + + wrapper.getComponent(GuidePageFrameStub).vm.$emit('back') + await flushPromises() + + expect(uni.navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 })) }) })