Files
frontend-miniapp/src/pages/exhibit/detail.vue
lyf 8b3e3f8d55
Some checks failed
CI / verify (push) Has been cancelled
修复小程序讲解页面返回回退
2026-07-28 10:40:25 +08:00

1451 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 { onBackPress, 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 {
AudioPlayTargetType,
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 { returnToWechatMiniProgram } from '@/services/WechatMiniProgramBridgeService'
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>('male')
let languageSwitchSequence = 0
const detailEntryRequest = ref<{
exhibitId: string
targetType?: AudioPlayTargetType
targetId?: 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 isAudioLanguage = (value: unknown): value is AudioLanguage => (
value === 'zh-CN' || value === 'yue-HK' || value === 'en-US'
)
const supportedDetailLanguages = computed(() => (
resolveGuideAudioLanguages(
exhibit.value.audio.audioOptions,
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(
exhibit.value.audio.audioOptions,
selectedAudioLanguage.value,
selectedAudioGender.value
))
const currentDetailAudioGender = computed(() => (
selectedDetailAudioOption.value?.gender
|| resolveGuideAudioGender(selectedAudioLanguage.value, selectedAudioGender.value)
))
const canSwitchDetailAudioVoice = computed(() => canToggleGuideAudioGender(
exhibit.value.audio.audioOptions,
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(() => ({
targetType: exhibit.value.audio.playTargetType || 'ITEM',
targetId: exhibit.value.audio.playTargetId || 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 targetType = exhibit.value.audio.playTargetType || 'ITEM'
const targetId = exhibit.value.audio.playTargetId || exhibit.value.id
return Boolean(
globalAudioPlayer.currentAudio.value
&& source?.targetType === targetType
&& source.targetId === targetId
)
})
let detailAudioClosedOnExit = false
let returningToMiniProgram = 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?.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 (!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 buildTextSourceFromViewModel = (viewModel: ExplainDetailPageViewModel) => ({
id: viewModel.id,
name: viewModel.title,
hallId: viewModel.hallId,
hallName: viewModel.hallName,
floorId: viewModel.floorId,
floorLabel: viewModel.floorLabel,
image: viewModel.coverImages[0],
description: viewModel.summary,
guideText: viewModel.summary,
audioLanguage: viewModel.audio.language,
audioHasText: viewModel.audio.hasText,
audioOptions: viewModel.audio.audioOptions,
playTargetType: viewModel.audio.playTargetType,
playTargetId: viewModel.audio.playTargetId
})
const loadFullDetailTextForLanguage = async (
request: NonNullable<typeof detailEntryRequest.value>,
lang: AudioLanguage,
preferredViewModel?: ExplainDetailPageViewModel
) => {
detailTextLoadingByLanguage.value = {
...detailTextLoadingByLanguage.value,
[lang]: true
}
try {
const viewModel = preferredViewModel || toExplainDetailPageViewModel(await explainUseCase.enterExplainDetail({
...request,
lang
}))
if (viewModel.audio.hasText) {
const selection = await explainUseCase.loadExplainDetailText(buildTextSourceFromViewModel(viewModel))
if (selection.available) {
const nextViewModel = toExplainDetailPageViewModel(selection.exhibit)
applyDetailText(lang, nextViewModel.body || nextViewModel.summary)
return
}
}
applyDetailText(lang, viewModel.body || 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 resolvedLanguage = resolveSupportedDetailLanguage(lang)
selectedAudioLanguage.value = resolvedLanguage
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 = 'male'
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
if (isGuideTopTab(tab)) {
activeTopTab.value = tab
}
const exhibitId = Array.isArray(options.id) ? options.id[0] : options.id
if (!exhibitId) {
detailState.value = 'missing'
return
}
const rawTargetType = Array.isArray(options.targetType) ? options.targetType[0] : options.targetType
const targetType: AudioPlayTargetType | undefined = rawTargetType === 'ITEM'
? 'ITEM'
: rawTargetType === 'STOP' || rawTargetType === 'GUIDE_STOP'
? 'STOP'
: undefined
const rawTargetId = Array.isArray(options.targetId) ? options.targetId[0] : options.targetId
const targetId = rawTargetType === 'GUIDE_STOP'
? String(exhibitId)
: rawTargetId ? String(rawTargetId) : undefined
const rawLang = Array.isArray(options.lang) ? options.lang[0] : options.lang
const lang = typeof rawLang === 'string'
? normalizeGuideAudioLanguage(rawLang)
: 'zh-CN'
try {
detailEntryRequest.value = {
exhibitId,
targetType,
targetId,
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)
} 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
}
const exhibitData = await explainUseCase.enterExplainDetail({
...request,
lang
})
if (requestSequence !== languageSwitchSequence) return
heroImageIndex.value = 0
const nextViewModel = toExplainDetailPageViewModel(exhibitData)
exhibit.value = nextViewModel
void loadFullDetailTextForLanguage(request, lang, nextViewModel)
if (shouldContinueAudio) {
// The player must become actionable before resuming with the new language track.
languageSwitchLoading.value = false
await handlePlayAudio()
}
} 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 shouldContinueAudio = isCurrentDetailAudioTarget.value && globalAudioPlayer.playing.value
selectedAudioGender.value = currentDetailAudioGender.value === 'male' ? 'female' : 'male'
if (shouldContinueAudio) {
await handlePlayAudio()
}
}
watch(
() => globalAudioPlayer.currentSource.value,
(source) => {
const lang = source?.lang
const targetType = exhibit.value.audio.playTargetType || 'ITEM'
const targetId = exhibit.value.audio.playTargetId || exhibit.value.id
if (
isAudioLanguage(lang)
&& source?.targetType === targetType
&& source.targetId === targetId
) {
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
})
if (currentAudioTarget.value.targetType) {
params.set('targetType', currentAudioTarget.value.targetType)
}
if (currentAudioTarget.value.targetId) {
params.set('targetId', currentAudioTarget.value.targetId)
}
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.playInfo?.title || 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: exhibit.value.audio.audioOptions,
playTargetType: exhibit.value.audio.playTargetType,
playTargetId: exhibit.value.audio.playTargetId
}, {
refreshPlayInfo: true
})
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 playDetailAudio = async (audio: AudioItem) => {
await globalAudioPlayer.play(audio, {
source: {
exhibitId: exhibit.value.id,
targetType: currentAudioTarget.value.targetType,
targetId: currentAudioTarget.value.targetId,
lang: selectedAudioLanguage.value,
channelCode: selectedDetailAudioOption.value?.channelCode,
voiceGender: currentDetailAudioGender.value,
title: audio.name,
detailRoute: buildDetailRoute()
},
retryOnError: refreshCurrentAudioOnce,
displayMode: 'mini'
})
}
const handlePlayAudio = async (options: { forceRefresh?: 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) {
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: exhibit.value.audio.audioOptions,
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
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 })
}
})
}
const cameFromExplainObjectList = () => {
const pages = getCurrentPages()
const previousPage = pages[pages.length - 2] as { route?: string } | undefined
return previousPage?.route === 'pages/explain/guide-stop-list'
}
const returnToExplainObjectList = async () => {
closeDetailAudioOnExit()
if (cameFromExplainObjectList()) {
uni.navigateBack({
delta: 1,
fail: fallbackToExplainObjectList
})
return
}
if (isEmbeddedInWechatMiniProgram()) {
if (returningToMiniProgram) return
returningToMiniProgram = true
const result = await returnToWechatMiniProgram()
if (result.status === 'returned') return
returningToMiniProgram = false
uni.showToast({ title: '返回小程序失败,请重试', icon: 'none' })
return
}
fallbackToExplainObjectList()
}
const handleBack = () => { void returnToExplainObjectList() }
onBackPress(() => {
void returnToExplainObjectList()
return true
})
</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>