1282 lines
30 KiB
Vue
1282 lines
30 KiB
Vue
<template>
|
||
<GuidePageFrame
|
||
:active-tab="activeTopTab"
|
||
variant="static"
|
||
:show-top-tabs="false"
|
||
:audio-player-avoid-bottom-nav="false"
|
||
:audio-player-closable="false"
|
||
:show-audio-player-host="true"
|
||
audio-player-mode="expanded"
|
||
@back="handleBack"
|
||
>
|
||
<view class="explain-detail-page" :class="{ 'with-audio-player': showAudioPlayer }">
|
||
<scroll-view
|
||
class="content"
|
||
:class="{ 'with-fixed-player': isDetailAudioDockVisible || showAudioPlayer }"
|
||
scroll-y
|
||
:show-scrollbar="false"
|
||
>
|
||
<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>
|
||
<view class="hero-back" @tap.stop="handleBack">
|
||
<text class="hero-back-icon">‹</text>
|
||
</view>
|
||
<view class="hero-copy">
|
||
<text class="detail-title">{{ exhibit.title }}</text>
|
||
<text class="detail-meta">{{ heroSubtitle }}</text>
|
||
|
||
<view class="language-anchor" v-if="contentLanguageOptions.length">
|
||
<view
|
||
v-for="option in contentLanguageOptions"
|
||
:key="option.value"
|
||
class="language-anchor-option"
|
||
:class="{ active: selectedAudioLanguage === option.value }"
|
||
@tap="handleLanguageChange(option.value)"
|
||
>
|
||
<text class="language-anchor-text">{{ option.label }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="content-panel">
|
||
<view class="content-section">
|
||
<text class="section-text">{{ currentDetailText }}</text>
|
||
<text v-if="currentDetailTextLoading" class="section-hint">正在加载讲解正文</text>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<view
|
||
v-if="isDetailAudioDockVisible"
|
||
class="detail-audio-dock"
|
||
>
|
||
<view class="detail-audio-play" @tap="handlePlayAudio">
|
||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none">
|
||
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
|
||
</svg>
|
||
</view>
|
||
<view class="detail-audio-main" @tap="handlePlayAudio">
|
||
<text class="detail-audio-title">{{ audioDockTitle }}</text>
|
||
<text class="detail-audio-subtitle">{{ audioDockSubtitle }}</text>
|
||
<view class="detail-audio-progress">
|
||
<view class="detail-audio-progress-fill"></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'
|
||
|
||
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 selectedAudioLanguage = ref<AudioLanguage>('zh-CN')
|
||
const detailEntryRequest = ref<{
|
||
exhibitId: string
|
||
targetType?: AudioPlayTargetType
|
||
targetId?: string
|
||
} | null>(null)
|
||
const detailTextByLanguage = ref<Record<AudioLanguage, string>>({
|
||
'zh-CN': defaultDetail.body,
|
||
'en-US': 'English narration text is not available yet.'
|
||
})
|
||
const detailTextLoadingByLanguage = ref<Record<AudioLanguage, boolean>>({
|
||
'zh-CN': false,
|
||
'en-US': false
|
||
})
|
||
const detailTextLoadedByLanguage = ref<Record<AudioLanguage, boolean>>({
|
||
'zh-CN': false,
|
||
'en-US': false
|
||
})
|
||
// 讲解详情页位置入口暂未开放,避免误导用户进入位置预览流程。
|
||
const isLocationActionVisible = false
|
||
|
||
const contentLanguageOptions: Array<{
|
||
value: AudioLanguage
|
||
label: string
|
||
}> = [
|
||
{
|
||
value: 'zh-CN',
|
||
label: '中文'
|
||
},
|
||
{
|
||
value: 'en-US',
|
||
label: 'English'
|
||
}
|
||
]
|
||
|
||
const isAudioLanguage = (value: unknown): value is AudioLanguage => (
|
||
value === 'zh-CN' || value === 'en-US'
|
||
)
|
||
|
||
const isRealHeroImage = (image?: string) => (
|
||
Boolean(image && image !== EXPLAIN_DETAIL_PLACEHOLDER_IMAGE)
|
||
)
|
||
|
||
const heroImage = computed(() => exhibit.value.coverImages.find(isRealHeroImage))
|
||
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 showAudioPlayer = computed(() => (
|
||
globalAudioPlayer.visible.value && globalAudioPlayer.hasAudio.value
|
||
))
|
||
const isDetailAudioDockVisible = computed(() => (
|
||
exhibit.value.audio.status === 'playable'
|
||
&& !isCurrentDetailAudio.value
|
||
))
|
||
const audioDockTitle = computed(() => (
|
||
selectedAudioLanguage.value === 'en-US' ? 'English audio' : '中文讲解音频'
|
||
))
|
||
const audioDockSubtitle = computed(() => [
|
||
exhibit.value.title,
|
||
exhibit.value.audio.durationLabel
|
||
].filter(Boolean).join(' · ') || '讲解音频')
|
||
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 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 loadBilingualDetailText = async (
|
||
request: NonNullable<typeof detailEntryRequest.value>,
|
||
currentLang: AudioLanguage,
|
||
currentViewModel: ExplainDetailPageViewModel
|
||
) => {
|
||
await loadFullDetailTextForLanguage(request, currentLang, currentViewModel)
|
||
const nextLang: AudioLanguage = currentLang === 'zh-CN' ? 'en-US' : 'zh-CN'
|
||
void loadFullDetailTextForLanguage(request, nextLang)
|
||
}
|
||
|
||
const ensureDetailTextForLanguage = async (lang: AudioLanguage) => {
|
||
if (detailTextLoadedByLanguage.value[lang] || detailTextLoadingByLanguage.value[lang]) return
|
||
if (!detailEntryRequest.value) {
|
||
applyDetailText(lang)
|
||
return
|
||
}
|
||
|
||
await loadFullDetailTextForLanguage(detailEntryRequest.value, lang)
|
||
}
|
||
|
||
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 loadBilingualDetailText(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 = isAudioLanguage(rawLang) ? 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 currentDetailText = computed(() => detailTextFor(selectedAudioLanguage.value))
|
||
const currentDetailTextLoading = computed(() => detailTextLoadingByLanguage.value[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) return
|
||
|
||
const shouldSyncAudio = options.syncAudio !== false && isCurrentDetailAudioTarget.value
|
||
selectedAudioLanguage.value = lang
|
||
replaceDetailRouteLanguage(lang)
|
||
void ensureDetailTextForLanguage(lang)
|
||
|
||
if (shouldSyncAudio && globalAudioPlayer.currentSource.value?.lang !== lang) {
|
||
await globalAudioPlayer.switchLanguage(lang)
|
||
}
|
||
}
|
||
|
||
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
|
||
) {
|
||
selectedAudioLanguage.value = lang
|
||
replaceDetailRouteLanguage(lang)
|
||
void ensureDetailTextForLanguage(lang)
|
||
}
|
||
}
|
||
)
|
||
|
||
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 handlePlayAudio = async () => {
|
||
if (isCurrentDetailAudio.value) {
|
||
if (globalAudioPlayer.playing.value) {
|
||
globalAudioPlayer.pause()
|
||
return
|
||
}
|
||
|
||
void globalAudioPlayer.resume()
|
||
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 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: 'expanded'
|
||
})
|
||
}
|
||
|
||
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 = () => {
|
||
if (typeof window !== 'undefined' && window.history.length > 1) {
|
||
const currentHref = window.location.href
|
||
window.history.back()
|
||
|
||
// H5 深链或异常历史栈下 history.back 可能不改变页面,兜底回到讲解首页。
|
||
window.setTimeout(() => {
|
||
if (window.location.href === currentHref) {
|
||
fallbackToExplainHome()
|
||
}
|
||
}, 800)
|
||
return
|
||
}
|
||
|
||
uni.navigateBack({
|
||
delta: 1,
|
||
fail: fallbackToExplainHome
|
||
})
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.explain-detail-page {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
background: #f9fafb;
|
||
}
|
||
|
||
.content {
|
||
height: 100%;
|
||
padding: 84px 20px calc(104px + env(safe-area-inset-bottom));
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.content.without-location-action {
|
||
padding-bottom: calc(28px + env(safe-area-inset-bottom));
|
||
}
|
||
|
||
.hero-image,
|
||
.hero-placeholder {
|
||
width: 100%;
|
||
height: 250px;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.hero-image {
|
||
background: #e9ece5;
|
||
}
|
||
|
||
.hero-placeholder {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: #e9ece5;
|
||
}
|
||
|
||
.hero-placeholder-text {
|
||
font-size: 20px;
|
||
line-height: 28px;
|
||
font-weight: 800;
|
||
color: #68725d;
|
||
}
|
||
|
||
.detail-body {
|
||
padding-top: 22px;
|
||
}
|
||
|
||
.detail-title,
|
||
.detail-meta,
|
||
.audio-time,
|
||
.section-title,
|
||
.section-text,
|
||
.location-text {
|
||
display: block;
|
||
}
|
||
|
||
.detail-title {
|
||
font-size: 29px;
|
||
line-height: 38px;
|
||
font-weight: 800;
|
||
color: #151713;
|
||
}
|
||
|
||
.detail-meta {
|
||
margin-top: 8px;
|
||
font-size: 16px;
|
||
line-height: 22px;
|
||
color: #555c51;
|
||
}
|
||
|
||
.detail-chip-row {
|
||
margin-top: 12px;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.detail-chip {
|
||
max-width: 100%;
|
||
height: 28px;
|
||
padding: 0 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
box-sizing: border-box;
|
||
background: #f4f6ef;
|
||
border: 1px solid #dde2d3;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.detail-chip.shared {
|
||
background: #151713;
|
||
border-color: #151713;
|
||
}
|
||
|
||
.detail-chip-text {
|
||
display: block;
|
||
max-width: 100%;
|
||
font-size: 13px;
|
||
line-height: 18px;
|
||
font-weight: 700;
|
||
color: #555c51;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.detail-chip.shared .detail-chip-text {
|
||
color: #e0df00;
|
||
}
|
||
|
||
.language-switch {
|
||
width: 100%;
|
||
height: 42px;
|
||
margin-top: 16px;
|
||
padding: 3px;
|
||
display: flex;
|
||
box-sizing: border-box;
|
||
background: #ecefe6;
|
||
border: 1px solid #dde2d3;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.language-option {
|
||
min-width: 0;
|
||
flex: 1;
|
||
height: 34px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-sizing: border-box;
|
||
border-radius: 6px;
|
||
color: #555c51;
|
||
}
|
||
|
||
.language-option.active {
|
||
background: #151713;
|
||
color: #e0df00;
|
||
}
|
||
|
||
.language-option.disabled {
|
||
opacity: 0.56;
|
||
}
|
||
|
||
.language-option-text {
|
||
display: block;
|
||
max-width: 100%;
|
||
font-size: 14px;
|
||
line-height: 20px;
|
||
font-weight: 800;
|
||
color: currentColor;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.audio-panel {
|
||
height: 76px;
|
||
margin-top: 24px;
|
||
padding: 0 15px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
box-sizing: border-box;
|
||
background: #0b0c0a;
|
||
border-radius: 8px;
|
||
color: #ffffff;
|
||
}
|
||
|
||
.audio-play {
|
||
flex-shrink: 0;
|
||
width: 42px;
|
||
height: 42px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 50%;
|
||
color: #151713;
|
||
background: #e0df00;
|
||
}
|
||
|
||
.audio-time {
|
||
flex-shrink: 0;
|
||
font-size: 14px;
|
||
line-height: 20px;
|
||
color: #ffffff;
|
||
}
|
||
|
||
.audio-track {
|
||
position: relative;
|
||
flex: 1;
|
||
height: 3px;
|
||
background: rgba(255, 255, 255, 0.24);
|
||
border-radius: 999px;
|
||
}
|
||
|
||
.audio-dot {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 0;
|
||
width: 12px;
|
||
height: 12px;
|
||
background: #e0df00;
|
||
border-radius: 50%;
|
||
transform: translateY(-50%);
|
||
}
|
||
|
||
.content-section {
|
||
margin-top: 28px;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 19px;
|
||
line-height: 27px;
|
||
font-weight: 800;
|
||
color: #151713;
|
||
}
|
||
|
||
.section-text {
|
||
margin-top: 13px;
|
||
font-size: 16px;
|
||
line-height: 28px;
|
||
color: #2f352d;
|
||
white-space: pre-line;
|
||
}
|
||
|
||
.text-load-btn {
|
||
width: 100%;
|
||
height: 44px;
|
||
margin-top: 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-sizing: border-box;
|
||
background: #151713;
|
||
border-radius: 8px;
|
||
color: #e0df00;
|
||
}
|
||
|
||
.text-load-btn.disabled {
|
||
opacity: 0.62;
|
||
}
|
||
|
||
.text-load-btn-text {
|
||
display: block;
|
||
max-width: 100%;
|
||
font-size: 15px;
|
||
line-height: 21px;
|
||
font-weight: 800;
|
||
color: currentColor;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.section-hint {
|
||
display: block;
|
||
margin-top: 12px;
|
||
font-size: 14px;
|
||
line-height: 20px;
|
||
color: #68725d;
|
||
}
|
||
|
||
.action-bar {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
padding: 12px 20px calc(18px + env(safe-area-inset-bottom));
|
||
box-sizing: border-box;
|
||
background: rgba(249, 250, 251, 0.96);
|
||
}
|
||
|
||
.explain-detail-page.with-audio-player .action-bar {
|
||
bottom: 88px;
|
||
}
|
||
|
||
.location-btn {
|
||
height: 52px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
box-sizing: border-box;
|
||
background: #ffffff;
|
||
border: 1px solid #151713;
|
||
border-radius: 8px;
|
||
color: #151713;
|
||
}
|
||
|
||
.location-btn.disabled {
|
||
color: #8d9488;
|
||
border-color: #d8dcd3;
|
||
background: #f2f4ef;
|
||
}
|
||
|
||
.location-icon {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.location-text {
|
||
font-size: 16px;
|
||
line-height: 22px;
|
||
font-weight: 700;
|
||
color: currentColor;
|
||
}
|
||
|
||
.explain-detail-page {
|
||
background: #111a14;
|
||
}
|
||
|
||
.content {
|
||
height: 100%;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
background: #111a14;
|
||
}
|
||
|
||
.content.with-fixed-player {
|
||
padding-bottom: 0;
|
||
}
|
||
|
||
.immersive-hero {
|
||
position: relative;
|
||
min-height: 392px;
|
||
padding: calc(env(safe-area-inset-top) + 20px) 24px 88px;
|
||
box-sizing: border-box;
|
||
overflow: hidden;
|
||
background:
|
||
radial-gradient(circle at 78% 18%, rgba(64, 88, 58, 0.3), rgba(64, 88, 58, 0) 30%),
|
||
linear-gradient(180deg, #24351e 0%, #111a14 100%);
|
||
color: #ffffff;
|
||
}
|
||
|
||
.hero-real-image {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.hero-image-shade {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 1;
|
||
pointer-events: none;
|
||
background:
|
||
linear-gradient(180deg, rgba(12, 20, 14, 0.34) 0%, rgba(13, 22, 16, 0.7) 62%, #111a14 100%),
|
||
linear-gradient(90deg, rgba(10, 16, 11, 0.72) 0%, rgba(10, 16, 11, 0.34) 58%, rgba(10, 16, 11, 0.68) 100%);
|
||
}
|
||
|
||
.immersive-hero.has-real-image {
|
||
background: #111a14;
|
||
}
|
||
|
||
.immersive-hero.has-real-image .hero-image-shade {
|
||
background:
|
||
linear-gradient(180deg, rgba(10, 16, 11, 0.12) 0%, rgba(10, 16, 11, 0.38) 58%, rgba(17, 26, 20, 0.78) 100%),
|
||
linear-gradient(90deg, rgba(10, 16, 11, 0.46) 0%, rgba(10, 16, 11, 0.1) 48%, rgba(10, 16, 11, 0.36) 100%);
|
||
}
|
||
|
||
.hero-back {
|
||
position: relative;
|
||
z-index: 4;
|
||
width: 38px;
|
||
height: 38px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #ffffff;
|
||
}
|
||
|
||
.hero-back-icon {
|
||
display: block;
|
||
font-size: 34px;
|
||
line-height: 34px;
|
||
font-weight: 600;
|
||
color: currentColor;
|
||
}
|
||
|
||
.hero-copy {
|
||
position: relative;
|
||
z-index: 3;
|
||
min-height: 250px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.detail-title,
|
||
.detail-meta {
|
||
display: block;
|
||
}
|
||
|
||
.detail-title {
|
||
max-width: 100%;
|
||
font-size: 34px;
|
||
line-height: 42px;
|
||
font-weight: 900;
|
||
color: #ffffff;
|
||
text-shadow: 0 3px 0 rgba(0, 0, 0, 0.18);
|
||
word-break: break-word;
|
||
}
|
||
|
||
.detail-meta {
|
||
margin-top: 8px;
|
||
font-size: 15px;
|
||
line-height: 22px;
|
||
font-weight: 600;
|
||
color: rgba(255, 255, 255, 0.88);
|
||
}
|
||
|
||
.detail-chip-row {
|
||
margin-top: 12px;
|
||
gap: 7px;
|
||
}
|
||
|
||
.detail-chip {
|
||
height: 28px;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
border-color: rgba(255, 255, 255, 0.22);
|
||
color: #ffffff;
|
||
}
|
||
|
||
.detail-chip.shared {
|
||
background: rgba(231, 233, 0, 0.14);
|
||
border-color: rgba(231, 233, 0, 0.38);
|
||
}
|
||
|
||
.detail-chip-text,
|
||
.detail-chip.shared .detail-chip-text {
|
||
color: rgba(255, 255, 255, 0.9);
|
||
}
|
||
|
||
.language-anchor {
|
||
width: min(315px, 100%);
|
||
height: 64px;
|
||
margin-top: 26px;
|
||
padding: 6px;
|
||
display: flex;
|
||
box-sizing: border-box;
|
||
background: rgba(17, 20, 15, 0.92);
|
||
border-radius: 16px;
|
||
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.24);
|
||
}
|
||
|
||
.language-anchor-option {
|
||
min-width: 0;
|
||
flex: 1;
|
||
height: 52px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 12px;
|
||
color: #ffffff;
|
||
}
|
||
|
||
.language-anchor-option.active {
|
||
background: #e7e900;
|
||
color: #0f140d;
|
||
}
|
||
|
||
.language-anchor-text {
|
||
max-width: 100%;
|
||
font-size: 18px;
|
||
line-height: 24px;
|
||
font-weight: 900;
|
||
color: currentColor;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.content-panel {
|
||
position: relative;
|
||
z-index: 2;
|
||
min-height: calc(100vh - 318px);
|
||
margin-top: -48px;
|
||
padding: 34px 26px calc(172px + env(safe-area-inset-bottom));
|
||
box-sizing: border-box;
|
||
background: #f8f9f4;
|
||
border-radius: 28px 28px 0 0;
|
||
box-shadow: 0 -18px 34px rgba(0, 0, 0, 0.18);
|
||
}
|
||
|
||
.content-section {
|
||
margin-top: 0;
|
||
padding-top: 22px;
|
||
scroll-margin-top: 18px;
|
||
}
|
||
|
||
.content-section + .content-section {
|
||
margin-top: 52px;
|
||
}
|
||
|
||
.section-heading {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
}
|
||
|
||
.section-heading.compact {
|
||
gap: 10px;
|
||
}
|
||
|
||
.section-marker {
|
||
flex-shrink: 0;
|
||
width: 9px;
|
||
height: 46px;
|
||
border-radius: 999px;
|
||
background: #e7e900;
|
||
}
|
||
|
||
.section-marker.english {
|
||
background: #7aa678;
|
||
}
|
||
|
||
.section-marker.muted {
|
||
height: 28px;
|
||
background: #b8c6af;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 24px;
|
||
line-height: 32px;
|
||
font-weight: 900;
|
||
color: #111827;
|
||
}
|
||
|
||
.section-text {
|
||
margin-top: 16px;
|
||
display: block;
|
||
font-size: 17px;
|
||
line-height: 29px;
|
||
color: #233044;
|
||
white-space: pre-line;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.section-hint {
|
||
color: #6d7568;
|
||
}
|
||
|
||
.detail-audio-dock {
|
||
position: fixed;
|
||
left: 50%;
|
||
right: auto;
|
||
bottom: calc(12px + env(safe-area-inset-bottom));
|
||
z-index: 2201;
|
||
width: calc(min(100vw, 430px) - 28px);
|
||
min-height: 126px;
|
||
padding: 16px 14px 16px 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 13px;
|
||
box-sizing: border-box;
|
||
background: #10140f;
|
||
border: 1px solid rgba(231, 233, 0, 0.18);
|
||
border-radius: 24px;
|
||
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.28);
|
||
transform: translateX(-50%);
|
||
color: #ffffff;
|
||
}
|
||
|
||
.detail-audio-play {
|
||
flex-shrink: 0;
|
||
width: 56px;
|
||
height: 56px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 50%;
|
||
background: #e7e900;
|
||
color: #0b0f0a;
|
||
}
|
||
|
||
.detail-audio-main {
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
|
||
.detail-audio-title,
|
||
.detail-audio-subtitle,
|
||
.detail-audio-lang-text {
|
||
display: block;
|
||
}
|
||
|
||
.detail-audio-title {
|
||
font-size: 17px;
|
||
line-height: 23px;
|
||
font-weight: 900;
|
||
color: #ffffff;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.detail-audio-subtitle {
|
||
margin-top: 3px;
|
||
font-size: 13px;
|
||
line-height: 18px;
|
||
color: rgba(255, 255, 255, 0.72);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.detail-audio-progress {
|
||
height: 5px;
|
||
margin-top: 13px;
|
||
overflow: hidden;
|
||
background: rgba(255, 255, 255, 0.28);
|
||
border-radius: 999px;
|
||
}
|
||
|
||
.detail-audio-progress-fill {
|
||
width: 22%;
|
||
height: 100%;
|
||
background: #e7e900;
|
||
border-radius: inherit;
|
||
}
|
||
|
||
.detail-audio-lang {
|
||
flex-shrink: 0;
|
||
min-width: 46px;
|
||
height: 36px;
|
||
padding: 0 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-sizing: border-box;
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(255, 255, 255, 0.46);
|
||
}
|
||
|
||
@media (max-width: 360px) {
|
||
.immersive-hero {
|
||
padding-left: 20px;
|
||
padding-right: 20px;
|
||
}
|
||
|
||
.detail-title {
|
||
font-size: 30px;
|
||
line-height: 38px;
|
||
}
|
||
|
||
.content-panel {
|
||
padding-left: 22px;
|
||
padding-right: 22px;
|
||
}
|
||
|
||
.detail-audio-dock {
|
||
gap: 10px;
|
||
padding-left: 12px;
|
||
}
|
||
|
||
}
|
||
</style>
|