543 lines
13 KiB
Vue
543 lines
13 KiB
Vue
<template>
|
||
<GuidePageFrame
|
||
:active-tab="activeTopTab"
|
||
variant="static"
|
||
:show-top-tabs="false"
|
||
show-back
|
||
@back="handleBack"
|
||
>
|
||
<view class="explain-detail-page" :class="{ 'with-audio-player': showAudioPlayer }">
|
||
<scroll-view class="content" scroll-y :show-scrollbar="false">
|
||
<image
|
||
v-if="heroImage"
|
||
class="hero-image"
|
||
:src="heroImage"
|
||
mode="aspectFill"
|
||
/>
|
||
|
||
<view v-else class="hero-placeholder">
|
||
<text class="hero-placeholder-text">讲解</text>
|
||
</view>
|
||
|
||
<view class="detail-body">
|
||
<text class="detail-title">{{ exhibit.title }}</text>
|
||
<text v-if="detailMeta" class="detail-meta">{{ detailMeta }}</text>
|
||
|
||
<view
|
||
v-if="exhibit.audio.status === 'playable'"
|
||
class="audio-panel"
|
||
@tap="handlePlayAudio"
|
||
>
|
||
<view class="audio-play">
|
||
<svg v-if="!isPlaying" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
|
||
</svg>
|
||
<svg v-else width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||
<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>
|
||
</view>
|
||
<text class="audio-time">00:00</text>
|
||
<view class="audio-track">
|
||
<view class="audio-dot"></view>
|
||
</view>
|
||
<text class="audio-time">{{ exhibit.audio.durationLabel || '音频讲解' }}</text>
|
||
</view>
|
||
|
||
<view v-else class="text-status-panel">
|
||
<text class="text-status-title">图文讲解</text>
|
||
<text class="text-status-desc">
|
||
{{ exhibit.audio.unavailableReason || '该讲解暂无已发布音频,当前提供图文讲解。' }}
|
||
</text>
|
||
</view>
|
||
|
||
<view class="content-section">
|
||
<text class="section-title">讲解内容</text>
|
||
<text class="section-text">{{ exhibit.body || exhibit.summary }}</text>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<view 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>
|
||
|
||
<AudioPlayer
|
||
ref="audioPlayerRef"
|
||
:visible="showAudioPlayer"
|
||
:audio="currentAudio"
|
||
:auto-play="false"
|
||
@update:visible="handleAudioVisibleChange"
|
||
@play="handleAudioPlay"
|
||
@pause="handleAudioPause"
|
||
@ended="handleAudioEnded"
|
||
@error="handleAudioError"
|
||
/>
|
||
</view>
|
||
</GuidePageFrame>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, nextTick, ref } from 'vue'
|
||
import { onLoad } from '@dcloudio/uni-app'
|
||
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
||
import AudioPlayer, { type AudioItem } from '@/components/audio/AudioPlayer.vue'
|
||
import {
|
||
explainUseCase
|
||
} from '@/usecases/explainUseCase'
|
||
import {
|
||
guideUseCase
|
||
} from '@/usecases/guideUseCase'
|
||
import {
|
||
toExplainDetailPageViewModel,
|
||
type ExplainDetailPageViewModel
|
||
} from '@/view-models/explainViewModels'
|
||
import {
|
||
isGuideTopTab,
|
||
type GuideTopTab
|
||
} from '@/utils/guideTopTabs'
|
||
|
||
const defaultDetail: ExplainDetailPageViewModel = {
|
||
id: '',
|
||
title: '讲解内容',
|
||
subtitle: '位置待补充',
|
||
contentType: 'exhibit',
|
||
coverImages: [],
|
||
summary: '该讲解内容待补充。',
|
||
body: '该讲解内容待补充。',
|
||
audio: {
|
||
status: 'unavailable',
|
||
unavailableReason: '该讲解暂无已发布音频,当前提供图文讲解。'
|
||
},
|
||
chapters: [],
|
||
relatedItems: []
|
||
}
|
||
|
||
interface AudioPlayerExpose {
|
||
play: (audio: AudioItem) => void
|
||
pause: () => void
|
||
}
|
||
|
||
const exhibit = ref<ExplainDetailPageViewModel>(defaultDetail)
|
||
const audioPlayerRef = ref<AudioPlayerExpose | null>(null)
|
||
const showAudioPlayer = ref(false)
|
||
const currentAudio = ref<AudioItem | null>(null)
|
||
const isPlaying = ref(false)
|
||
const activeTopTab = ref<GuideTopTab>('explain')
|
||
const resolvedHallId = ref('')
|
||
|
||
const heroImage = computed(() => exhibit.value.coverImages[0])
|
||
const detailMeta = computed(() => [
|
||
exhibit.value.hallName,
|
||
exhibit.value.floorLabel
|
||
].filter(Boolean).join(' · '))
|
||
const hallLocationId = computed(() => exhibit.value.hallId || resolvedHallId.value)
|
||
|
||
const normalizePoiName = (value: string) => value
|
||
.trim()
|
||
.replace(/[\s()()【】[\]_-]/g, '')
|
||
.toLowerCase()
|
||
|
||
// 讲解详情页只定位到所属展厅,不再追踪具体展品点位。
|
||
const resolveHallGuidePoi = async () => {
|
||
const hallId = exhibit.value.hallId
|
||
if (hallId) {
|
||
const hallPoi = await guideUseCase.getPoiById(hallId)
|
||
if (hallPoi?.kind === 'hall' || hallPoi?.primaryCategory.id === 'exhibition_hall') {
|
||
return hallPoi
|
||
}
|
||
}
|
||
|
||
const normalizedTarget = normalizePoiName(exhibit.value.hallName || '')
|
||
if (!normalizedTarget) return null
|
||
|
||
const candidates = await guideUseCase.searchPois(exhibit.value.hallName || exhibit.value.title || '')
|
||
return candidates.find((candidate) => {
|
||
const candidateName = normalizePoiName(candidate.name)
|
||
const candidateHallName = normalizePoiName(candidate.hallName || '')
|
||
return candidate.kind === 'hall'
|
||
|| candidate.primaryCategory.id === 'exhibition_hall'
|
||
|| candidateName === normalizedTarget
|
||
|| candidateHallName === normalizedTarget
|
||
|| candidateName.includes(normalizedTarget)
|
||
|| normalizedTarget.includes(candidateName)
|
||
}) || candidates[0] || null
|
||
}
|
||
|
||
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 exhibitData = await explainUseCase.getExhibitById(exhibitId)
|
||
if (exhibitData) {
|
||
exhibit.value = toExplainDetailPageViewModel(exhibitData)
|
||
if (exhibitData.hallId) {
|
||
resolvedHallId.value = exhibitData.hallId
|
||
}
|
||
void explainUseCase.enrichExhibitDetailAudio(exhibitId)
|
||
.then((enrichedExhibit) => {
|
||
if (!enrichedExhibit || exhibit.value.id !== exhibitId) return
|
||
|
||
exhibit.value = toExplainDetailPageViewModel(enrichedExhibit)
|
||
if (enrichedExhibit.hallId) {
|
||
resolvedHallId.value = enrichedExhibit.hallId
|
||
}
|
||
})
|
||
.catch((error) => {
|
||
console.warn('讲解详情音频正文后台补充失败:', error)
|
||
})
|
||
}
|
||
})
|
||
|
||
const handlePlayAudio = async () => {
|
||
if (exhibit.value.audio.url) {
|
||
const audio: AudioItem = {
|
||
id: `media-${exhibit.value.id}`,
|
||
name: exhibit.value.title,
|
||
audioUrl: exhibit.value.audio.url,
|
||
image: heroImage.value,
|
||
duration: undefined
|
||
}
|
||
|
||
const isSameAudio = currentAudio.value?.id === audio.id
|
||
if (showAudioPlayer.value && isSameAudio && isPlaying.value) {
|
||
audioPlayerRef.value?.pause()
|
||
return
|
||
}
|
||
|
||
showAudioPlayer.value = true
|
||
currentAudio.value = audio
|
||
audioPlayerRef.value?.play(audio)
|
||
return
|
||
}
|
||
|
||
const selection = exhibit.value.id
|
||
? await explainUseCase.selectAudioForExhibit(exhibit.value.id)
|
||
: null
|
||
|
||
if (!selection?.playable || !selection.media?.url) {
|
||
uni.showToast({
|
||
title: selection?.unavailableMessage || exhibit.value.audio.unavailableReason || '该讲解暂无可播放音频',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
const audio: AudioItem = {
|
||
id: selection.media.id,
|
||
name: selection.track?.title || selection.exhibit.name,
|
||
audioUrl: selection.media.url,
|
||
image: selection.exhibit.image || heroImage.value,
|
||
duration: selection.media.duration
|
||
}
|
||
|
||
const isSameAudio = currentAudio.value?.id === audio.id
|
||
if (showAudioPlayer.value && isSameAudio && isPlaying.value) {
|
||
audioPlayerRef.value?.pause()
|
||
return
|
||
}
|
||
|
||
showAudioPlayer.value = true
|
||
currentAudio.value = audio
|
||
await nextTick()
|
||
uni.showToast({
|
||
title: '音频已准备好,请点击底部播放按钮',
|
||
icon: 'none'
|
||
})
|
||
}
|
||
|
||
const handleAudioVisibleChange = (visible: boolean) => {
|
||
showAudioPlayer.value = visible
|
||
if (!visible) {
|
||
isPlaying.value = false
|
||
currentAudio.value = null
|
||
}
|
||
}
|
||
|
||
const handleAudioPlay = () => {
|
||
isPlaying.value = true
|
||
}
|
||
|
||
const handleAudioPause = () => {
|
||
isPlaying.value = false
|
||
}
|
||
|
||
const handleAudioEnded = () => {
|
||
isPlaying.value = false
|
||
showAudioPlayer.value = false
|
||
currentAudio.value = null
|
||
}
|
||
|
||
const handleAudioError = (_audio: AudioItem | null, message: string) => {
|
||
console.warn('详情音频不可播放:', message)
|
||
isPlaying.value = false
|
||
showAudioPlayer.value = false
|
||
currentAudio.value = null
|
||
}
|
||
|
||
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 handleBack = () => {
|
||
uni.navigateBack({
|
||
delta: 1,
|
||
fail: () => {
|
||
uni.reLaunch({
|
||
url: '/pages/index/index?tab=explain'
|
||
})
|
||
}
|
||
})
|
||
}
|
||
</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;
|
||
}
|
||
|
||
.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,
|
||
.text-status-title,
|
||
.text-status-desc,
|
||
.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;
|
||
}
|
||
|
||
.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%);
|
||
}
|
||
|
||
.text-status-panel {
|
||
margin-top: 24px;
|
||
padding: 14px;
|
||
box-sizing: border-box;
|
||
background: #ffffff;
|
||
border: 1px solid #e4e6df;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.text-status-title {
|
||
font-size: 15px;
|
||
line-height: 21px;
|
||
font-weight: 800;
|
||
color: #151713;
|
||
}
|
||
|
||
.text-status-desc {
|
||
margin-top: 5px;
|
||
font-size: 13px;
|
||
line-height: 20px;
|
||
color: #5d6656;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
</style>
|