1281 lines
35 KiB
Vue
1281 lines
35 KiB
Vue
<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>
|