Files
frontend-miniapp/src/pages/exhibit/detail.vue

1281 lines
35 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">
<view class="immersive-hero" :class="{ 'has-real-image': heroImage }">
<image
v-if="heroImage"
class="hero-real-image"
:src="heroImage"
mode="aspectFill"
/>
<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-dock-main">
<button
class="detail-audio-play"
:class="{ disabled: audioDockState === 'unavailable' }"
:disabled="audioDockState === 'unavailable'"
:aria-label="detailAudioActionLabel"
@tap="handlePlayAudio"
>
<view v-if="audioDockState === 'loading'" class="audio-loading-spinner" aria-label="音频加载中"></view>
<svg v-else-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-main">
<text class="detail-audio-title">{{ audioDockTitle }}</text>
<text class="detail-audio-subtitle">{{ audioDockSubtitle }}</text>
</view>
<button
v-if="audioDockState === 'failed'"
class="detail-audio-retry"
aria-label="重试播放音频"
@tap="handleRetryAudio"
>重试</button>
</view>
<view v-if="audioDockState === 'loading'" class="detail-audio-progress is-indeterminate" aria-label="音频加载中"></view>
<view
v-else
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>
</view>
<view v-if="isLocationActionVisible" class="action-bar">
<view
class="location-btn"
:class="{ disabled: !hallLocationId }"
@tap="handleNavigate"
>
<svg class="location-icon" width="19" height="19" viewBox="0 0 24 24" fill="none">
<path d="M12 21C12 21 18 14.9 18 9.8C18 6.5 15.3 4 12 4C8.7 4 6 6.5 6 9.8C6 14.9 12 21 12 21Z" stroke="currentColor" stroke-width="1.7"/>
<circle cx="12" cy="10" r="2.2" stroke="currentColor" stroke-width="1.7"/>
</svg>
<text class="location-text">查看位置</text>
</view>
</view>
</view>
</GuidePageFrame>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import type { AudioItem } from '@/components/audio/AudioPlayer.vue'
import { useGlobalAudioPlayer } from '@/composables/useGlobalAudioPlayer'
import {
explainUseCase
} from '@/usecases/explainUseCase'
import {
guideUseCase
} from '@/usecases/guideUseCase'
import {
toExplainDetailPageViewModel,
type ExplainDetailPageViewModel
} from '@/view-models/explainViewModels'
import type {
AudioPlayTargetType
} 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 { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
import { normalizeGuideAudioLanguage } from '@/data/adapters/guideStopInfoAdapter'
const defaultDetail: ExplainDetailPageViewModel = {
id: '',
title: '讲解内容',
subtitle: '位置待补充',
contentType: 'exhibit',
coverImages: [],
summary: '该讲解内容待补充。',
body: '该讲解内容待补充。',
audio: {
status: 'unavailable',
unavailableReason: '当前语言暂无语音讲解。'
},
chapters: [],
relatedItems: []
}
const exhibit = ref<ExplainDetailPageViewModel>(defaultDetail)
const globalAudioPlayer = useGlobalAudioPlayer()
const activeTopTab = ref<GuideTopTab>('explain')
const resolvedHallId = ref('')
const retryingAudio = ref(false)
const languageSwitchLoading = ref(false)
const selectedAudioLanguage = ref<AudioLanguage>('zh-CN')
let languageSwitchSequence = 0
const detailEntryRequest = ref<{
exhibitId: string
targetType?: AudioPlayTargetType
targetId?: 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 isLocationActionVisible = false
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
const contentLanguageOptions: Array<{
value: AudioLanguage
label: string
}> = [
{
value: 'zh-CN',
label: '中文'
},
{
value: 'yue-HK',
label: '粤语'
},
{
value: 'en-US',
label: 'English'
}
]
const isAudioLanguage = (value: unknown): value is AudioLanguage => (
value === 'zh-CN' || value === 'yue-HK' || value === 'en-US'
)
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 heroImage = computed(() => exhibit.value.coverImages.find(isRealHeroImage))
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 hallLocationId = computed(() => exhibit.value.hallId || resolvedHallId.value)
const heroSubtitle = computed(() => [
detailMeta.value
].filter(Boolean).join(' · '))
const currentAudioTarget = computed(() => ({
targetType: exhibit.value.audio.playTargetType || 'ITEM',
targetId: exhibit.value.audio.playTargetId || exhibit.value.id,
lang: selectedAudioLanguage.value
}))
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
)
})
const audioDockTitle = computed(() => displayTitle.value || '讲解内容')
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 audioDockSubtitle = computed(() => [
audioLanguageLabel(selectedAudioLanguage.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
&& 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 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({
directPoiId: exhibit.value.location?.poiId,
location: exhibit.value.location,
hallId: exhibit.value.hallId,
hallName: exhibit.value.hallName,
targetName: exhibit.value.title
})
}
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,
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
) => {
const exhibitData = await explainUseCase.enterExplainDetail({
...request,
lang
})
exhibit.value = toExplainDetailPageViewModel(exhibitData)
selectedAudioLanguage.value = lang
if (exhibitData.hallId) {
resolvedHallId.value = exhibitData.hallId
}
void loadFullDetailTextForLanguage(request, lang, exhibit.value)
}
onLoad(async (options: any = {}) => {
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) 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
}
await loadExplainDetail(detailEntryRequest.value, lang)
} catch (error) {
console.error('讲解详情加载失败:', error)
uni.showToast({
title: '讲解详情加载失败,请稍后重试',
icon: 'none'
})
}
})
onUnload(() => {
globalAudioPlayer.close()
})
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 audioAvailabilityMessage = computed(() => (
exhibit.value.audio.status === 'playable'
? ''
: exhibit.value.audio.unavailableReason || '当前语言暂无语音讲解'
))
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) return
const shouldSyncAudio = options.syncAudio !== false && isCurrentDetailAudioTarget.value
const request = detailEntryRequest.value
const requestSequence = ++languageSwitchSequence
selectedAudioLanguage.value = lang
replaceDetailRouteLanguage(lang)
languageSwitchLoading.value = true
const audioSwitch = shouldSyncAudio && globalAudioPlayer.currentSource.value?.lang !== lang
? globalAudioPlayer.switchLanguage(lang)
: Promise.resolve(true)
try {
if (!request) {
applyDetailText(lang)
await audioSwitch
return
}
const exhibitData = await explainUseCase.enterExplainDetail({
...request,
lang
})
if (requestSequence !== languageSwitchSequence) return
const nextViewModel = toExplainDetailPageViewModel(exhibitData)
exhibit.value = nextViewModel
if (exhibitData.hallId) {
resolvedHallId.value = exhibitData.hallId
}
void loadFullDetailTextForLanguage(request, lang, nextViewModel)
await audioSwitch
} 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
}
}
}
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 => {
if (!selection.playable || !selection.media?.url) return null
return {
id: selection.media.id,
name: selection.playInfo?.title || selection.exhibit.name,
audioUrl: selection.media.url,
image: heroImage.value,
duration: selection.media.duration,
language: selection.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,
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,
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
}
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,
playTargetType: exhibit.value.audio.playTargetType,
playTargetId: exhibit.value.audio.playTargetId
})
: 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 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 () => {
const matchedHallPoi = await resolveHallGuidePoi()
if (!matchedHallPoi) {
uni.showToast({
title: '该讲解暂无所属展厅位置数据',
icon: 'none'
})
return
}
resolvedHallId.value = matchedHallPoi.id
exhibit.value = {
...exhibit.value,
hallId: matchedHallPoi.id,
hallName: matchedHallPoi.hallName || matchedHallPoi.name,
location: exhibit.value.location || {
status: 'hallFallback',
poiId: matchedHallPoi.id,
sourcePoiId: matchedHallPoi.id,
actionText: '查看所属展厅',
previewOnly: true,
floorId: matchedHallPoi.floorId,
floorLabel: matchedHallPoi.floorLabel,
note: '已定位到该讲解所属展厅。'
}
}
uni.navigateTo({
url: `/pages/route/detail?facilityId=${encodeURIComponent(matchedHallPoi.id)}&target=${encodeURIComponent(exhibit.value.hallName || exhibit.value.title)}&state=preview`
})
}
const fallbackToExplainHome = () => {
uni.redirectTo({
url: '/pages/index/index?tab=explain',
fail: () => {
uni.reLaunch({
url: '/pages/index/index?tab=explain'
})
}
})
}
const handleBack = () => {
uni.navigateBack({
delta: 1,
fail: fallbackToExplainHome
})
}
</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;
}
.hero-back::after {
border: 0;
}
.hero-back-icon {
margin-top: -3px;
font-size: 32px;
line-height: 32px;
font-weight: 400;
}
.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: #141412;
}
.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: #141412;
}
.language-option.active::before {
position: absolute;
right: 10px;
bottom: 1px;
left: 10px;
height: 3px;
content: '';
background: #141412;
}
.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 24px env(safe-area-inset-bottom);
box-sizing: border-box;
background: #ffffff;
border-top: 1px solid #e8e5de;
}
.detail-audio-dock-main {
height: 50px;
padding-left: 58px;
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-play {
position: absolute;
top: 14px;
left: 19px;
z-index: 1;
}
.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;
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>