Files
frontend-miniapp/src/pages/exhibit/detail.vue
lyf ed52d22b63 修复讲解语音切换后播放器未刷新
- 暂停时切换语言或男女声同步刷新音轨、时长和进度
- 保持暂停状态,播放中继续自动切换新音轨
- 补充语音切换播放器刷新回归测试

Made-with: Proma
2026-08-27 11:00:45 +08:00

1446 lines
41 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<GuidePageFrame
:active-tab="activeTopTab"
variant="static"
:show-top-tabs="false"
@back="handleBack"
>
<view class="explain-detail-page">
<GuideLoadingState
v-if="detailState === 'loading'"
title="正在加载讲解对象"
description="请稍候"
fullscreen
data-testid="exhibit-detail-state"
/>
<GuideFeedbackState
v-else-if="detailState !== 'ready'"
:title="detailState === 'missing' ? '缺少讲解对象参数' : '讲解对象加载失败'"
:description="detailStateMessage"
:primary-label="detailState === 'error' ? '重新加载' : ''"
secondary-label="返回讲解列表"
fullscreen
data-testid="exhibit-detail-state"
@primary="retryDetail"
@secondary="handleBack"
/>
<template v-else>
<view class="immersive-hero" :class="{ 'has-real-image': heroImage }">
<image
v-if="heroImage"
class="hero-real-image"
:src="heroImage"
mode="aspectFill"
@error="handleHeroImageError"
/>
<view class="hero-image-shade"></view>
<button
v-if="!shouldUseHostNavigation"
class="hero-back"
aria-label="返回上级"
@tap.stop="handleBack"
>
<text class="hero-back-icon"></text>
</button>
<view class="hero-copy">
<text class="detail-title">{{ displayTitle }}</text>
<text class="detail-meta">{{ heroSubtitle }}</text>
<view class="detail-chip-row">
<view class="detail-chip shared">
<text class="detail-chip-text">标准解说</text>
</view>
</view>
</view>
</view>
<view v-if="contentLanguageOptions.length" class="detail-language-bar">
<view class="language-switch" role="tablist" aria-label="讲解语言">
<button
v-for="option in contentLanguageOptions"
:key="option.value"
class="language-option"
:class="{
active: selectedAudioLanguage === option.value,
disabled: languageSwitchLoading
}"
:disabled="languageSwitchLoading"
:aria-selected="selectedAudioLanguage === option.value"
:aria-label="`切换至${option.label}`"
role="tab"
@tap="handleLanguageChange(option.value)"
>
<text class="language-option-text">{{ option.label }}</text>
</button>
</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' }">
<template v-if="currentDetailParagraphs.length">
<text
v-for="(paragraph, paragraphIndex) in currentDetailParagraphs"
:key="`${selectedAudioLanguage}-${paragraphIndex}`"
class="section-text"
>
{{ paragraph }}
</text>
</template>
<text v-if="currentDetailTextLoading" class="section-hint">{{ detailLoadingMessage }}</text>
</view>
</view>
</scroll-view>
<view class="detail-audio-dock" :class="`is-${audioDockState}`">
<view class="detail-audio-row">
<button
class="detail-audio-play"
:class="{ disabled: audioDockState === 'unavailable' || audioDockState === 'loading' }"
:disabled="audioDockState === 'unavailable' || audioDockState === 'loading'"
:aria-label="detailAudioActionLabel"
@tap="handlePlayAudio"
>
<svg v-if="detailAudioPlaying" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="6" y="5" width="4" height="14" rx="1" fill="currentColor"/>
<rect x="14" y="5" width="4" height="14" rx="1" fill="currentColor"/>
</svg>
<svg v-else width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
</svg>
</button>
<view class="detail-audio-time-block">
<text class="detail-audio-time">{{ detailAudioTimeLabel }}</text>
</view>
<view
class="detail-audio-progress-hit"
:class="{ disabled: audioDockState !== 'playable' }"
@tap="handleSeekAudio"
>
<view class="detail-audio-progress" aria-hidden="true">
<view class="detail-audio-progress-fill" :style="{ width: `${detailAudioProgressPercent}%` }"></view>
<view class="detail-audio-progress-thumb" :style="{ left: `${detailAudioProgressPercent}%` }"></view>
</view>
</view>
<button
class="detail-audio-speed"
:class="{ disabled: audioDockState !== 'playable' }"
:disabled="audioDockState !== 'playable'"
:aria-label="`播放速度 ${detailPlaybackRateLabel}`"
@tap="handleCyclePlaybackRate"
>
<view class="detail-speed-icon" aria-hidden="true">
<text class="detail-speed-label">{{ detailPlaybackRateLabel }}</text>
</view>
</button>
<button
class="detail-audio-voice"
:class="{ disabled: audioDockState !== 'playable' || !canSwitchDetailAudioVoice }"
:disabled="audioDockState !== 'playable' || !canSwitchDetailAudioVoice"
:aria-label="detailAudioVoiceLabel"
@tap="handleToggleDetailAudioVoice"
>
<image
class="detail-audio-voice-avatar"
:src="currentDetailAudioGender === 'male'
? '/static/icons/voice/male-avatar.png'
: '/static/icons/voice/female-avatar.png'"
mode="aspectFill"
/>
</button>
</view>
</view>
</template>
</view>
</GuidePageFrame>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { onHide, onLoad, onUnload } from '@dcloudio/uni-app'
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import GuideFeedbackState from '@/components/navigation/GuideFeedbackState.vue'
import GuideLoadingState from '@/components/navigation/GuideLoadingState.vue'
import type { AudioItem } from '@/components/audio/AudioPlayer.vue'
import { useGlobalAudioPlayer } from '@/composables/useGlobalAudioPlayer'
import {
explainUseCase
} from '@/usecases/explainUseCase'
import {
toExplainDetailPageViewModel,
type ExplainDetailPageViewModel
} from '@/view-models/explainViewModels'
import type { GuideAudioGender } from '@/domain/museum'
import type {
AudioLanguage
} from '@/repositories/AudioPlayInfoRepository'
import {
isGuideTopTab,
type GuideTopTab
} from '@/utils/guideTopTabs'
import {
EXPLAIN_DETAIL_PLACEHOLDER_IMAGE
} from '@/utils/placeholders'
import { normalizeSameOriginPublicUrl } from '@/utils/publicUrl'
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
import { normalizeGuideAudioLanguage } from '@/data/adapters/guideStopInfoAdapter'
import { explainGuideStopListUrl } from '@/utils/explainNavigation'
import {
canToggleGuideAudioGender,
GUIDE_AUDIO_LANGUAGE_OPTIONS,
resolveGuideAudioGender,
resolveGuideAudioLanguages,
resolveGuideAudioOption
} from '@/domain/guideAudioOptions'
const defaultDetail: ExplainDetailPageViewModel = {
id: '',
title: '讲解内容',
subtitle: '位置待补充',
contentType: 'exhibit',
coverImages: [],
summary: '该讲解内容待补充。',
body: '该讲解内容待补充。',
audio: {
status: 'unavailable',
unavailableReason: '当前语言暂无语音讲解。'
},
chapters: [],
relatedItems: []
}
const exhibit = ref<ExplainDetailPageViewModel>(defaultDetail)
const detailState = ref<'idle' | 'loading' | 'ready' | 'missing' | 'error'>('idle')
const detailStateMessage = ref('请检查链接后重试。')
const globalAudioPlayer = useGlobalAudioPlayer()
const activeTopTab = ref<GuideTopTab>('explain')
const retryingAudio = ref(false)
const languageSwitchLoading = ref(false)
const selectedAudioLanguage = ref<AudioLanguage>('zh-CN')
const selectedAudioGender = ref<GuideAudioGender>('female')
let languageSwitchSequence = 0
const detailEntryRequest = ref<{
exhibitId: string
stopId: string
hallId?: string
hallName?: string
floorId?: string
floorLabel?: string
poiId?: string
} | null>(null)
const detailTextByLanguage = ref<Record<AudioLanguage, string>>({
'zh-CN': defaultDetail.body,
'yue-HK': '当前语言暂无讲解词。',
'en-US': 'English narration text is not available yet.'
})
const detailTextLoadingByLanguage = ref<Record<AudioLanguage, boolean>>({
'zh-CN': false,
'yue-HK': false,
'en-US': false
})
const detailTextLoadedByLanguage = ref<Record<AudioLanguage, boolean>>({
'zh-CN': false,
'yue-HK': false,
'en-US': false
})
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
const shouldAutoplayOnEntry = ref(false)
const isAudioLanguage = (value: unknown): value is AudioLanguage => (
value === 'zh-CN' || value === 'yue-HK' || value === 'en-US'
)
const detailAudioVersion = computed<'standard' | 'extended'>(() => (
exhibit.value.audio.version === 'extended' ? 'extended' : 'standard'
))
const activeDetailAudioOptions = computed(() => (
(exhibit.value.audio.audioOptions || []).filter((option) => (
!option.version || option.version === detailAudioVersion.value
))
))
const supportedDetailLanguages = computed(() => (
resolveGuideAudioLanguages(
activeDetailAudioOptions.value,
exhibit.value.audio.supportedLanguages
).filter(isAudioLanguage)
))
const contentLanguageOptions = computed(() => {
const supportedLanguages = supportedDetailLanguages.value
return GUIDE_AUDIO_LANGUAGE_OPTIONS.filter((option) => supportedLanguages.includes(option.value))
})
const resolveSupportedDetailLanguage = (requestedLanguage: AudioLanguage) => {
const supportedLanguages = supportedDetailLanguages.value
if (!supportedLanguages.length || supportedLanguages.includes(requestedLanguage)) {
return requestedLanguage
}
return supportedLanguages.includes('zh-CN') ? 'zh-CN' : supportedLanguages[0]
}
const audioLanguageLabel = (lang: AudioLanguage) => {
if (lang === 'en-US') return 'English'
if (lang === 'yue-HK') return '粤语'
return '中文'
}
const isRealHeroImage = (image?: string) => (
Boolean(image && image !== EXPLAIN_DETAIL_PLACEHOLDER_IMAGE)
)
const heroImageIndex = ref(0)
const heroImageCandidates = computed(() => (
exhibit.value.coverImages
.map((image) => normalizeSameOriginPublicUrl(image))
.filter(isRealHeroImage)
))
const heroImage = computed(() => heroImageCandidates.value[heroImageIndex.value] || '')
const handleHeroImageError = () => {
if (heroImageIndex.value < heroImageCandidates.value.length - 1) {
heroImageIndex.value += 1
}
}
const displayTitle = computed(() => {
const withoutExplainType = exhibit.value.title
.replace(/\s*(?:(?:\[||【)\s*)?标准解说\s*(?:(?:\]||】))?\s*$/, '')
.trim()
const withoutWrappingBrackets = withoutExplainType
.replace(/^(?:\[||【)\s*(.*?)\s*(?:\]||】)$/, '$1')
.trim()
return withoutWrappingBrackets || withoutExplainType || exhibit.value.title
})
const detailMeta = computed(() => [
exhibit.value.hallName,
exhibit.value.floorLabel
].filter(Boolean).join(' · '))
const heroSubtitle = computed(() => [
detailMeta.value
].filter(Boolean).join(' · '))
const selectedDetailAudioOption = computed(() => resolveGuideAudioOption(
activeDetailAudioOptions.value,
selectedAudioLanguage.value,
selectedAudioGender.value
))
const currentDetailAudioGender = computed(() => (
selectedDetailAudioOption.value?.gender
|| resolveGuideAudioGender(selectedAudioLanguage.value, selectedAudioGender.value)
))
const canSwitchDetailAudioVoice = computed(() => canToggleGuideAudioGender(
activeDetailAudioOptions.value,
selectedAudioLanguage.value
))
const detailAudioVoiceLabel = computed(() => {
if (selectedAudioLanguage.value === 'yue-HK') return '粤语仅提供女声讲解'
return currentDetailAudioGender.value === 'male' ? '切换为女声讲解' : '切换为男声讲解'
})
const selectedDetailAudioAvailable = computed(() => (
Boolean(selectedDetailAudioOption.value?.playUrl)
|| exhibit.value.audio.status === 'playable'
))
const currentAudioTarget = computed(() => ({
stopId: exhibit.value.audio.stopId || exhibit.value.id,
lang: selectedAudioLanguage.value,
channelCode: selectedDetailAudioOption.value?.channelCode
}))
const isCurrentDetailAudio = computed(() => globalAudioPlayer.isCurrentSource(currentAudioTarget.value))
const isCurrentDetailAudioTarget = computed(() => {
const source = globalAudioPlayer.currentSource.value
const stopId = exhibit.value.audio.stopId || exhibit.value.id
return Boolean(
globalAudioPlayer.currentAudio.value
&& (source?.stopId === stopId || (source?.targetType === 'STOP' && source.targetId === stopId))
)
})
let detailAudioClosedOnExit = false
const closeDetailAudioOnExit = () => {
if (detailAudioClosedOnExit || !isCurrentDetailAudioTarget.value) return
detailAudioClosedOnExit = true
globalAudioPlayer.close()
}
const formatDetailAudioTime = (seconds?: number) => {
const normalizedSeconds = Number.isFinite(seconds || 0)
? Math.max(0, Math.floor(seconds || 0))
: 0
const minutes = Math.floor(normalizedSeconds / 60)
const remainSeconds = normalizedSeconds % 60
return `${minutes}:${remainSeconds.toString().padStart(2, '0')}`
}
const detailAudioDurationSeconds = computed(() => {
if (!isCurrentDetailAudioTarget.value) return exhibit.value.audio.duration || 0
return globalAudioPlayer.duration.value
|| globalAudioPlayer.currentAudio.value?.duration
|| exhibit.value.audio.duration
|| 0
})
const detailAudioCurrentSeconds = computed(() => (
isCurrentDetailAudioTarget.value ? globalAudioPlayer.currentTime.value : 0
))
const detailAudioTimeLabel = computed(() => (
`${formatDetailAudioTime(detailAudioCurrentSeconds.value)} / ${formatDetailAudioTime(detailAudioDurationSeconds.value)}`
))
const detailAudioPlaying = computed(() => (
isCurrentDetailAudio.value
&& globalAudioPlayer.playing.value
))
const detailAudioActionLabel = computed(() => (
detailAudioPlaying.value ? '暂停讲解' : '播放讲解'
))
const detailAudioProgressPercent = computed(() => {
if (!isCurrentDetailAudioTarget.value) return 0
const total = detailAudioDurationSeconds.value
if (!total) return 0
return Math.max(0, Math.min(100, (detailAudioCurrentSeconds.value / total) * 100))
})
const detailPlaybackRates = [1, 1.25, 1.5, 2]
const detailPlaybackRate = computed(() => globalAudioPlayer.playbackRate.value || 1)
const detailPlaybackRateLabel = computed(() => (
detailPlaybackRate.value.toFixed(2).replace(/0$/, '')
))
const handleCyclePlaybackRate = () => {
if (audioDockState.value !== 'playable') return
const currentIndex = detailPlaybackRates.findIndex((rate) => rate === detailPlaybackRate.value)
const nextRate = detailPlaybackRates[(currentIndex + 1) % detailPlaybackRates.length] || detailPlaybackRates[0]
globalAudioPlayer.setPlaybackRate(nextRate)
}
const isCurrentDetailAudioError = computed(() => {
const source = globalAudioPlayer.currentSource.value
return Boolean(
globalAudioPlayer.error.value
&& (source?.stopId === currentAudioTarget.value.stopId
|| (source?.targetType === 'STOP' && source.targetId === currentAudioTarget.value.stopId))
)
})
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 (!selectedDetailAudioAvailable.value) return 'unavailable'
return 'playable'
})
const fallbackTextForLanguage = (lang: AudioLanguage) => (
lang === 'en-US'
? 'English narration text is not available yet.'
: '当前语言暂无讲解词。'
)
const applyDetailText = (lang: AudioLanguage, text?: string) => {
const normalized = (text || '').trim()
detailTextByLanguage.value = {
...detailTextByLanguage.value,
[lang]: normalized || fallbackTextForLanguage(lang)
}
detailTextLoadedByLanguage.value = {
...detailTextLoadedByLanguage.value,
[lang]: true
}
}
const loadFullDetailTextForLanguage = async (
_request: NonNullable<typeof detailEntryRequest.value>,
lang: AudioLanguage,
preferredViewModel?: ExplainDetailPageViewModel
) => {
detailTextLoadingByLanguage.value = {
...detailTextLoadingByLanguage.value,
[lang]: true
}
try {
const viewModel = preferredViewModel || exhibit.value
const variants = viewModel.audio.textVariants || []
const version = viewModel.audio.version || 'standard'
const detailText = variants.find((variant) => (
variant.version === version && variant.languageCode === lang
))?.text || (lang === 'yue-HK'
? variants.find((variant) => variant.version === version && variant.languageCode === 'zh-CN')?.text
: undefined)
applyDetailText(lang, detailText || viewModel.summary)
} catch (error) {
console.warn('讲解正文加载失败:', lang, error)
applyDetailText(lang)
} finally {
detailTextLoadingByLanguage.value = {
...detailTextLoadingByLanguage.value,
[lang]: false
}
}
}
const loadExplainDetail = async (
request: NonNullable<typeof detailEntryRequest.value>,
lang: AudioLanguage
) => {
detailState.value = 'loading'
const exhibitData = await explainUseCase.enterExplainDetail({
...request,
lang
})
heroImageIndex.value = 0
exhibit.value = toExplainDetailPageViewModel(exhibitData)
const recommended = exhibit.value.audio.audioOptions?.find((option) => (
option.channelCode === exhibit.value.audio.recommendedTrackCode && option.version === detailAudioVersion.value
))
const resolvedLanguage = resolveSupportedDetailLanguage(recommended?.languageCode as AudioLanguage || lang)
selectedAudioLanguage.value = resolvedLanguage
selectedAudioGender.value = recommended?.gender || resolveGuideAudioGender(resolvedLanguage, 'female')
if (resolvedLanguage !== lang) {
replaceDetailRouteLanguage(resolvedLanguage)
await loadExplainDetail(request, resolvedLanguage)
return
}
void loadFullDetailTextForLanguage(request, resolvedLanguage, exhibit.value)
detailState.value = 'ready'
}
onLoad(async (options: any = {}) => {
detailAudioClosedOnExit = false
selectedAudioGender.value = 'female'
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
if (isGuideTopTab(tab)) {
activeTopTab.value = tab
}
const rawStopId = Array.isArray(options.stopId) ? options.stopId[0] : options.stopId
const rawExhibitId = Array.isArray(options.id) ? options.id[0] : options.id
// New detail links are keyed by stopId. id remains optional so bookmarked
// historical routes and surrounding page metadata can still be preserved.
const stopId = rawStopId ? String(rawStopId) : String(rawExhibitId || '')
const exhibitId = rawExhibitId ? String(rawExhibitId) : stopId
if (!stopId) {
detailState.value = 'missing'
return
}
const rawLang = Array.isArray(options.lang) ? options.lang[0] : options.lang
const rawAutoplay = Array.isArray(options.autoplay) ? options.autoplay[0] : options.autoplay
shouldAutoplayOnEntry.value = rawAutoplay === '1' || rawAutoplay === 'true'
const lang = typeof rawLang === 'string'
? normalizeGuideAudioLanguage(rawLang)
: 'zh-CN'
try {
detailEntryRequest.value = {
exhibitId,
stopId,
hallId: Array.isArray(options.hallId) ? options.hallId[0] : options.hallId,
hallName: Array.isArray(options.hallName) ? options.hallName[0] : options.hallName,
floorId: Array.isArray(options.floorId) ? options.floorId[0] : options.floorId,
poiId: Array.isArray(options.poiId) ? options.poiId[0] : options.poiId
}
await loadExplainDetail(detailEntryRequest.value, lang)
if (shouldAutoplayOnEntry.value) {
shouldAutoplayOnEntry.value = false
try {
await handlePlayAudio()
} catch (error) {
console.warn('自动播放讲解失败,将等待用户手动播放:', error)
uni.showToast({
title: '点击底部播放键开始讲解',
icon: 'none'
})
}
}
} catch (error) {
console.error('讲解详情加载失败:', error)
detailState.value = 'error'
detailStateMessage.value = error instanceof Error ? error.message : '请检查网络后重试。'
}
})
const retryDetail = async () => {
if (!detailEntryRequest.value || detailState.value === 'loading') return
try {
await loadExplainDetail(detailEntryRequest.value, selectedAudioLanguage.value)
} catch (error) {
detailState.value = 'error'
detailStateMessage.value = error instanceof Error ? error.message : '请检查网络后重试。'
}
}
// iOS H5 may hide a uni page before its route is fully unloaded.
onHide(closeDetailAudioOnExit)
onUnload(closeDetailAudioOnExit)
onBeforeUnmount(closeDetailAudioOnExit)
const detailTextFor = (lang: AudioLanguage) => {
return detailTextByLanguage.value[lang] || fallbackTextForLanguage(lang)
}
const splitDetailTextIntoParagraphs = (text: string): string[] => {
const normalizedText = text
.replace(/\r\n?/g, '\n')
.replace(/\u3000/g, ' ')
.trim()
if (!normalizedText) return []
const numberedPrefixPattern = '(?:[一二三四五六七八九十]+、|[一二三四五六七八九十]+|[0-9]{1,2}[、.](?!\\d))'
const textWithNumberBreaks = normalizedText
.replace(new RegExp(`([。!?!?;])\\s*(?=${numberedPrefixPattern})`, 'g'), '$1\n')
.replace(new RegExp(`\\s+(?=${numberedPrefixPattern})`, 'g'), '\n')
return textWithNumberBreaks
.split(/\n+/)
.flatMap((block) => {
const cleanedBlock = block.replace(/\s+/g, ' ').trim()
if (!cleanedBlock) return []
const sentences = cleanedBlock.match(/[^。!?!?;]+[。!?!?;]?/g)
if (!sentences || sentences.length <= 1 || cleanedBlock.length <= 90) {
return [cleanedBlock]
}
const paragraphs: string[] = []
let currentParagraph = ''
sentences.forEach((sentence) => {
const cleanedSentence = sentence.trim()
if (!cleanedSentence) return
if (currentParagraph && `${currentParagraph}${cleanedSentence}`.length > 90) {
paragraphs.push(currentParagraph)
currentParagraph = cleanedSentence
return
}
currentParagraph = `${currentParagraph}${cleanedSentence}`
})
if (currentParagraph) {
paragraphs.push(currentParagraph)
}
return paragraphs
})
}
const currentDetailText = computed(() => detailTextFor(selectedAudioLanguage.value))
const currentDetailParagraphs = computed(() => splitDetailTextIntoParagraphs(currentDetailText.value))
const currentDetailTextLoading = computed(() => (
languageSwitchLoading.value || detailTextLoadingByLanguage.value[selectedAudioLanguage.value]
))
const detailLoadingMessage = computed(() => (
languageSwitchLoading.value
? `正在切换到${audioLanguageLabel(selectedAudioLanguage.value)}`
: '正在加载讲解正文'
))
const replaceDetailRouteLanguage = (lang: AudioLanguage) => {
if (typeof window === 'undefined') return
const nextHash = `#${buildDetailRoute(lang)}`
if (window.location.hash === nextHash) return
window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}${nextHash}`)
}
const handleLanguageChange = async (lang: AudioLanguage, options: { syncAudio?: boolean } = {}) => {
if (
lang === selectedAudioLanguage.value
|| languageSwitchLoading.value
|| (supportedDetailLanguages.value.length > 0 && !supportedDetailLanguages.value.includes(lang))
) return
const shouldContinueAudio = options.syncAudio !== false
&& isCurrentDetailAudioTarget.value
&& globalAudioPlayer.playing.value
const request = detailEntryRequest.value
const requestSequence = ++languageSwitchSequence
selectedAudioLanguage.value = lang
replaceDetailRouteLanguage(lang)
languageSwitchLoading.value = true
try {
if (!request) {
applyDetailText(lang)
return
}
if (requestSequence !== languageSwitchSequence) return
const audioOption = resolveGuideAudioOption(
activeDetailAudioOptions.value,
lang,
selectedAudioGender.value
)
exhibit.value = {
...exhibit.value,
audio: {
...exhibit.value.audio,
language: lang,
url: audioOption?.playUrl,
duration: audioOption?.duration,
status: audioOption?.playUrl ? 'playable' : 'unavailable',
unavailableReason: audioOption?.playUrl ? undefined : '当前语言暂无语音讲解',
hasText: Boolean(exhibit.value.audio.textVariants?.some((variant) => (
variant.version === detailAudioVersion.value && (variant.languageCode === lang || (lang === 'yue-HK' && variant.languageCode === 'zh-CN'))
)))
}
}
void loadFullDetailTextForLanguage(request, lang, exhibit.value)
if (options.syncAudio !== false && isCurrentDetailAudioTarget.value) {
// 语言切换后立即刷新播放器;原先暂停时不会更新音轨,导致组件仍显示旧内容。
languageSwitchLoading.value = false
await handlePlayAudio({ autoplay: shouldContinueAudio })
}
} catch (error) {
if (requestSequence !== languageSwitchSequence) return
console.warn('讲解语言切换失败:', lang, error)
exhibit.value = {
...exhibit.value,
audio: {
...exhibit.value.audio,
status: 'unavailable',
url: undefined,
duration: undefined,
language: lang,
unavailableReason: '讲解服务暂不可用,请稍后重试'
}
}
applyDetailText(lang)
uni.showToast({
title: '讲解语言切换失败,请稍后重试',
icon: 'none'
})
} finally {
if (requestSequence === languageSwitchSequence) {
languageSwitchLoading.value = false
}
}
}
const handleToggleDetailAudioVoice = async () => {
if (audioDockState.value !== 'playable' || !canSwitchDetailAudioVoice.value) return
const hasCurrentDetailAudio = isCurrentDetailAudioTarget.value
const shouldContinueAudio = hasCurrentDetailAudio && globalAudioPlayer.playing.value
selectedAudioGender.value = currentDetailAudioGender.value === 'male' ? 'female' : 'male'
if (hasCurrentDetailAudio) {
await handlePlayAudio({ autoplay: shouldContinueAudio })
}
}
watch(
() => globalAudioPlayer.currentSource.value,
(source) => {
const lang = source?.lang
const stopId = exhibit.value.audio.stopId || exhibit.value.id
if (
isAudioLanguage(lang)
&& (source?.stopId === stopId || (source?.targetType === 'STOP' && source.targetId === stopId))
) {
void handleLanguageChange(lang, { syncAudio: false })
}
}
)
function buildDetailRoute(lang: AudioLanguage = selectedAudioLanguage.value) {
const request = detailEntryRequest.value
const params = new URLSearchParams({
id: request?.exhibitId || exhibit.value.id,
tab: activeTopTab.value
})
params.set('stopId', request?.stopId || exhibit.value.audio.stopId || exhibit.value.id)
if (lang) {
params.set('lang', lang)
}
return `/pages/exhibit/detail?${params.toString()}`
}
const toAudioItem = (
selection: NonNullable<Awaited<ReturnType<typeof explainUseCase.selectAudioForExplainDetail>>>
): AudioItem | null => {
const media = selection.media
const audioUrl = normalizeSameOriginPublicUrl(media?.url)
if (!selection.playable || !media || !audioUrl) return null
return {
id: media.id,
name: selection.exhibit.name,
audioUrl,
image: heroImage.value,
duration: media.duration,
language: media.language as AudioLanguage,
supportedLanguages: (selection.exhibit.supportedLanguages || exhibit.value.audio.supportedLanguages || [])
.filter(isAudioLanguage)
}
}
const refreshCurrentAudioOnce = async (message: string) => {
if (retryingAudio.value || exhibit.value.audio.status !== 'playable') return null
retryingAudio.value = true
const selection = await explainUseCase.selectAudioForExplainDetail({
id: exhibit.value.id,
name: exhibit.value.title,
image: heroImage.value,
description: exhibit.value.summary,
guideText: exhibit.value.summary,
audioUrl: exhibit.value.audio.url,
audioDuration: exhibit.value.audio.duration,
audioStatus: 'READY',
audioLanguage: selectedAudioLanguage.value,
audioOptions: activeDetailAudioOptions.value,
guideVersion: detailAudioVersion.value,
playTargetType: exhibit.value.audio.playTargetType,
playTargetId: exhibit.value.audio.playTargetId
})
retryingAudio.value = false
const retryAudio = toAudioItem(selection)
if (retryAudio) return retryAudio
exhibit.value = {
...exhibit.value,
audio: {
...exhibit.value.audio,
status: 'unavailable',
url: undefined,
unavailableReason: selection.unavailableMessage || message || '音频加载失败,当前提供图文讲解。'
}
}
return null
}
const detailAudioPlayOptions = (audio: AudioItem) => ({
source: {
exhibitId: exhibit.value.id,
stopId: currentAudioTarget.value.stopId,
lang: selectedAudioLanguage.value,
channelCode: selectedDetailAudioOption.value?.channelCode,
voiceGender: currentDetailAudioGender.value,
audioOptions: activeDetailAudioOptions.value,
title: audio.name,
detailRoute: buildDetailRoute()
},
retryOnError: refreshCurrentAudioOnce,
displayMode: 'mini' as const
})
const playDetailAudio = async (audio: AudioItem) => {
await globalAudioPlayer.play(audio, detailAudioPlayOptions(audio))
}
const prepareDetailAudio = (audio: AudioItem) => {
globalAudioPlayer.prepare(audio, detailAudioPlayOptions(audio))
}
const handlePlayAudio = async (options: { forceRefresh?: boolean; autoplay?: boolean } = {}) => {
if (audioDockState.value === 'unavailable' || audioDockState.value === 'loading') return
if (audioDockState.value === 'failed' && !options.forceRefresh) {
options = { ...options, forceRefresh: true }
}
if (isCurrentDetailAudio.value && !options.forceRefresh) {
if (globalAudioPlayer.playing.value) {
globalAudioPlayer.pause()
return
}
void globalAudioPlayer.resume()
return
}
const refreshedAudio = options.forceRefresh
? await refreshCurrentAudioOnce(globalAudioPlayer.error.value)
: null
if (refreshedAudio) {
if (options.autoplay === false) prepareDetailAudio(refreshedAudio)
else await playDetailAudio(refreshedAudio)
return
}
const selection = exhibit.value.id
? await explainUseCase.selectAudioForExplainDetail({
id: exhibit.value.id,
name: exhibit.value.title,
image: heroImage.value,
description: exhibit.value.summary,
guideText: exhibit.value.summary,
audioUrl: exhibit.value.audio.url,
audioDuration: exhibit.value.audio.duration,
audioStatus: exhibit.value.audio.status === 'playable' ? 'READY' : 'MISSING',
audioLanguage: selectedAudioLanguage.value,
audioUnavailableReason: exhibit.value.audio.unavailableReason,
audioOptions: activeDetailAudioOptions.value,
guideVersion: detailAudioVersion.value,
playTargetType: exhibit.value.audio.playTargetType,
playTargetId: exhibit.value.audio.playTargetId
}, {
voiceGender: currentDetailAudioGender.value
})
: null
if (!selection?.playable || !selection.media?.url) {
uni.showToast({
title: selection?.unavailableMessage || exhibit.value.audio.unavailableReason || '该讲解暂无可播放音频',
icon: 'none'
})
return
}
const audio = toAudioItem(selection)
if (!audio) return
if (options.autoplay === false) {
prepareDetailAudio(audio)
return
}
await playDetailAudio(audio)
}
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 decodeRouteParam = (value: string) => {
let decoded = value
// Hash route parameters can accumulate extra encoding layers across H5 transitions.
for (let index = 0; index < 8; index += 1) {
try {
const next = decodeURIComponent(decoded)
if (next === decoded) break
decoded = next
} catch {
break
}
}
return decoded
}
const fallbackToExplainObjectList = () => {
const hallId = detailEntryRequest.value?.hallId || exhibit.value.hallId
const hallName = decodeRouteParam(detailEntryRequest.value?.hallName || exhibit.value.hallName || '讲解')
const url = hallId
? explainGuideStopListUrl(hallId, hallName)
: '/pages/explain/list'
uni.redirectTo({
url,
fail: () => {
uni.reLaunch({ url })
}
})
}
type ExplainDetailPageStackEntry = {
route?: string
}
const returnToExplainObjectList = () => {
closeDetailAudioOnExit()
// H5 history can omit intermediate uni-app pages after a refresh or deep link.
if (typeof window !== 'undefined' && window.location.hash.startsWith('#/')) {
fallbackToExplainObjectList()
return
}
const pages = getCurrentPages()
const previousPage = pages[pages.length - 2] as ExplainDetailPageStackEntry | undefined
if (previousPage?.route === 'pages/explain/guide-stop-list') {
uni.navigateBack({
delta: 1,
fail: fallbackToExplainObjectList
})
return
}
fallbackToExplainObjectList()
}
const handleBack = returnToExplainObjectList
</script>
<style scoped lang="scss">
.explain-detail-page {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: #f7f8f3;
}
.content {
position: absolute;
top: 392px;
right: 0;
bottom: 0;
left: 0;
height: auto;
padding: 0;
box-sizing: border-box;
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 {
position: relative;
min-height: 0;
height: 320px;
padding: calc(env(safe-area-inset-top) + 0px) 24px 24px;
box-sizing: border-box;
overflow: hidden;
background: #111a14;
color: #ffffff;
}
.immersive-hero.has-real-image {
background: #111a14;
}
.hero-image-shade,
.immersive-hero.has-real-image .hero-image-shade {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.08) 42%, rgba(0, 0, 0, 0.58) 100%);
}
.hero-real-image {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
}
.hero-back {
position: relative;
z-index: 2;
width: 44px;
height: 44px;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 50%;
background: transparent;
color: #ffffff;
transform: translate(-12px, 10px);
}
.hero-back::after {
border: 0;
}
.hero-back-icon {
position: relative;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0;
line-height: 0;
}
.hero-back-icon::before {
width: 10px;
height: 10px;
content: '';
border-left: 1.5px solid currentColor;
border-bottom: 1.5px solid currentColor;
transform: translateX(3px) rotate(45deg);
}
.hero-copy {
position: relative;
z-index: 2;
min-height: 0;
height: calc(100% - 44px - env(safe-area-inset-top));
padding-top: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.detail-title {
display: block;
max-width: 100%;
font-size: 28px;
line-height: 34px;
font-weight: 800;
letter-spacing: 0;
color: #ffffff;
text-shadow: none;
word-break: break-word;
}
.detail-meta {
display: block;
margin-top: 6px;
font-size: 14px;
line-height: 20px;
font-weight: 600;
color: rgba(255, 255, 255, 0.86);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-chip-row { display: none; }
.detail-content {
min-height: calc(100vh - 392px - 72px - env(safe-area-inset-bottom));
padding: 24px 24px calc(32px + 72px + env(safe-area-inset-bottom));
box-sizing: border-box;
background: #f7f8f3;
}
.detail-audio-play {
position: relative;
width: 44px;
height: 44px;
min-width: 44px;
flex: 0 0 44px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 50%;
background: #e8e800;
color: #1565c0;
}
.detail-audio-play svg {
display: block;
flex: none;
}
.detail-audio-play svg path {
transform: translateX(1px);
}
.detail-audio-play::after,
.language-option::after {
border: 0;
}
.language-switch {
height: 40px;
margin-top: 0;
padding: 0;
display: flex;
box-sizing: border-box;
background: transparent;
border: 1px solid #d8ddd2;
border-radius: 6px;
}
.language-option {
position: relative;
min-width: 0;
flex: 1;
height: 40px;
padding: 0 6px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border: 0;
border-radius: 0;
background: transparent;
color: #5d6659;
}
.language-option + .language-option {
border-left: 1px solid #d8ddd2;
}
.language-option.active {
background: #e8e800;
color: #1565c0;
}
.language-option.active::before {
position: absolute;
right: 10px;
bottom: 1px;
left: 10px;
height: 3px;
content: '';
background: #1565c0;
}
.language-option.disabled {
opacity: 0.56;
}
.language-option-text {
display: block;
max-width: 100%;
font-size: 14px;
line-height: 20px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.content-section {
margin-top: 0;
padding-top: 0;
}
.section-text {
display: block;
margin-top: 0;
font-size: 16px;
line-height: 26px;
color: #31392e;
text-indent: 2em;
word-break: break-word;
overflow-wrap: anywhere;
}
.section-hint {
margin-top: 14px;
font-size: 14px;
line-height: 22px;
}
.audio-status-hint {
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 16px env(safe-area-inset-bottom);
box-sizing: border-box;
background: #ffffff;
border-top: 1px solid #e8e5de;
}
.detail-audio-row {
height: 72px;
display: flex;
align-items: center;
gap: 8px;
}
.detail-audio-time-block {
min-width: 0;
flex: 0 0 66px;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 2px;
overflow: hidden;
}
.detail-audio-time {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-audio-time {
flex: 0 0 auto;
font-size: 12px;
line-height: 18px;
font-weight: 600;
color: #1a237e;
}
.detail-audio-play.disabled {
background: #e8e5de;
color: #94948f;
}
.detail-audio-progress-hit {
position: relative;
flex: 1 1 auto;
min-width: 28px;
height: 28px;
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: #1565c0;
transform: translate(-50%, -50%);
}
.detail-audio-speed,
.detail-audio-voice {
flex: 0 0 auto;
height: 36px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border: 0;
background: transparent;
color: #1a237e;
}
.detail-audio-speed {
width: 42px;
}
.detail-speed-icon {
position: relative;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border: 1.5px solid currentColor;
border-radius: 50%;
}
.detail-speed-icon::after {
position: absolute;
top: -1px;
right: 1px;
width: 7px;
height: 7px;
content: '';
border-top: 1.5px solid currentColor;
border-right: 1.5px solid currentColor;
border-radius: 0 5px 0 0;
transform: rotate(18deg);
}
.detail-speed-label {
font-size: 7px;
line-height: 9px;
font-weight: 700;
color: currentColor;
}
.detail-audio-voice {
width: 36px;
}
.detail-audio-voice-avatar {
width: 28px;
height: 28px;
display: block;
border-radius: 50%;
box-shadow: 0 0 0 1px rgba(26, 35, 126, 0.12);
overflow: hidden;
}
.detail-audio-speed.disabled,
.detail-audio-voice.disabled {
color: #94948f;
}
.detail-audio-voice.disabled .detail-audio-voice-avatar {
opacity: 0.52;
}
.detail-audio-speed::after,
.detail-audio-voice::after {
border: 0;
}
@media (min-width: 420px) {
.immersive-hero {
padding-right: 24px;
padding-left: 24px;
}
.detail-content {
padding-right: 24px;
padding-left: 24px;
}
}
@media (max-width: 360px) {
.detail-title {
font-size: 24px;
line-height: 30px;
}
.detail-content {
padding-right: 16px;
padding-left: 16px;
}
.detail-audio-dock {
padding-right: 16px;
padding-left: 16px;
}
}
</style>