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

535 lines
13 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"
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="true"
@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
}
}
})
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
await nextTick()
if (!isPlaying.value) {
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()
if (!isPlaying.value) {
audioPlayerRef.value?.play(audio)
}
}
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>