feat: 临时改造讲解页方案 B 链路

接入展厅列表、展厅讲解点分组生成临时业务单元,并迁移讲解详情播放正文到新接口链路。
This commit is contained in:
lyf
2026-07-02 00:51:27 +08:00
parent e0aeafe062
commit 9efcef5190
14 changed files with 1429 additions and 288 deletions

View File

@@ -54,6 +54,15 @@
<view class="content-section">
<text class="section-title">讲解内容</text>
<text class="section-text">{{ exhibit.body || exhibit.summary }}</text>
<view
v-if="canExpandText"
class="text-expand-btn"
:class="{ disabled: textLoading }"
@tap="handleExpandText"
>
<text class="text-expand-label">{{ textLoading ? '正在加载讲解词' : '展开讲解词全文' }}</text>
</view>
<text v-if="textError" class="text-error">{{ textError }}</text>
</view>
</view>
</scroll-view>
@@ -102,6 +111,9 @@ import {
toExplainDetailPageViewModel,
type ExplainDetailPageViewModel
} from '@/view-models/explainViewModels'
import type {
AudioPlayTargetType
} from '@/domain/museum'
import {
isGuideTopTab,
type GuideTopTab
@@ -117,7 +129,7 @@ const defaultDetail: ExplainDetailPageViewModel = {
body: '该讲解内容待补充。',
audio: {
status: 'unavailable',
unavailableReason: '该讲解暂无已发布音频,当前提供图文讲解。'
unavailableReason: '当前语言暂无语音讲解。'
},
chapters: [],
relatedItems: []
@@ -135,6 +147,10 @@ const currentAudio = ref<AudioItem | null>(null)
const isPlaying = ref(false)
const activeTopTab = ref<GuideTopTab>('explain')
const resolvedHallId = ref('')
const textLoading = ref(false)
const textExpanded = ref(false)
const textError = ref('')
const retryingAudio = ref(false)
const heroImage = computed(() => exhibit.value.coverImages[0])
const detailMeta = computed(() => [
@@ -142,6 +158,11 @@ const detailMeta = computed(() => [
exhibit.value.floorLabel
].filter(Boolean).join(' · '))
const hallLocationId = computed(() => exhibit.value.hallId || resolvedHallId.value)
const canExpandText = computed(() => (
exhibit.value.audio.hasText === true
&& !textExpanded.value
&& !textLoading.value
))
// 讲解详情页只定位到所属展厅,不再追踪具体展品点位。
const resolveHallGuidePoi = async () => {
@@ -163,51 +184,44 @@ onLoad(async (options: any = {}) => {
const exhibitId = Array.isArray(options.id) ? options.id[0] : options.id
if (!exhibitId) return
const exhibitData = await explainUseCase.getExhibitById(exhibitId)
if (exhibitData) {
const rawTargetType = Array.isArray(options.targetType) ? options.targetType[0] : options.targetType
const targetType: AudioPlayTargetType | undefined = rawTargetType === 'ITEM' || rawTargetType === 'STOP'
? rawTargetType
: undefined
const rawTargetId = Array.isArray(options.targetId) ? options.targetId[0] : options.targetId
const targetId = rawTargetId ? String(rawTargetId) : undefined
try {
const exhibitData = await explainUseCase.enterExplainDetail({
exhibitId,
targetType,
targetId
})
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)
})
} catch (error) {
console.error('讲解详情加载失败:', error)
uni.showToast({
title: '讲解详情加载失败,请稍后重试',
icon: 'none'
})
}
})
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)
? await explainUseCase.selectAudioForExplainDetail({
id: exhibit.value.id,
name: exhibit.value.title,
image: heroImage.value,
audioStatus: exhibit.value.audio.status === 'playable' ? 'READY' : 'MISSING',
audioLanguage: exhibit.value.audio.language,
audioUnavailableReason: exhibit.value.audio.unavailableReason,
playTargetType: exhibit.value.audio.playTargetType,
playTargetId: exhibit.value.audio.playTargetId
})
: null
if (!selection?.playable || !selection.media?.url) {
@@ -220,9 +234,9 @@ const handlePlayAudio = async () => {
const audio: AudioItem = {
id: selection.media.id,
name: selection.track?.title || selection.exhibit.name,
name: selection.playInfo?.title || selection.exhibit.name,
audioUrl: selection.media.url,
image: selection.exhibit.image || heroImage.value,
image: heroImage.value,
duration: selection.media.duration
}
@@ -235,10 +249,41 @@ const handlePlayAudio = async () => {
showAudioPlayer.value = true
currentAudio.value = audio
await nextTick()
uni.showToast({
title: '音频已准备好,请点击底部播放按钮',
icon: 'none'
})
audioPlayerRef.value?.play(audio)
}
const handleExpandText = async () => {
if (!exhibit.value.id || textLoading.value || textExpanded.value) return
textLoading.value = true
textError.value = ''
try {
const selection = await explainUseCase.loadExplainDetailText({
id: exhibit.value.id,
name: exhibit.value.title,
image: heroImage.value,
audioLanguage: exhibit.value.audio.language,
audioStatus: exhibit.value.audio.status === 'playable' ? 'READY' : 'MISSING',
hallId: exhibit.value.hallId,
hallName: exhibit.value.hallName,
floorLabel: exhibit.value.floorLabel,
location: exhibit.value.location,
playTargetType: exhibit.value.audio.playTargetType,
playTargetId: exhibit.value.audio.playTargetId,
audioHasText: exhibit.value.audio.hasText
})
if (!selection.available) {
textError.value = selection.unavailableMessage || '当前语言暂无讲解词'
return
}
exhibit.value = toExplainDetailPageViewModel(selection.exhibit)
textExpanded.value = true
} finally {
textLoading.value = false
}
}
const handleAudioVisibleChange = (visible: boolean) => {
@@ -250,6 +295,7 @@ const handleAudioVisibleChange = (visible: boolean) => {
}
const handleAudioPlay = () => {
retryingAudio.value = false
isPlaying.value = true
}
@@ -261,11 +307,43 @@ const handleAudioEnded = () => {
isPlaying.value = false
showAudioPlayer.value = false
currentAudio.value = null
retryingAudio.value = false
}
const handleAudioError = (_audio: AudioItem | null, message: string) => {
const handleAudioError = async (_audio: AudioItem | null, message: string) => {
console.warn('详情音频不可播放:', message)
isPlaying.value = false
if (!retryingAudio.value && exhibit.value.audio.status === 'playable') {
retryingAudio.value = true
const selection = await explainUseCase.selectAudioForExplainDetail({
id: exhibit.value.id,
name: exhibit.value.title,
image: heroImage.value,
audioStatus: 'READY',
audioLanguage: exhibit.value.audio.language,
playTargetType: exhibit.value.audio.playTargetType,
playTargetId: exhibit.value.audio.playTargetId
})
if (selection.playable && selection.media?.url) {
const retryAudio: AudioItem = {
id: selection.media.id,
name: selection.playInfo?.title || selection.exhibit.name,
audioUrl: selection.media.url,
image: heroImage.value,
duration: selection.media.duration
}
currentAudio.value = retryAudio
showAudioPlayer.value = true
await nextTick()
audioPlayerRef.value?.play(retryAudio)
return
}
}
retryingAudio.value = false
showAudioPlayer.value = false
currentAudio.value = null
exhibit.value = {
@@ -485,6 +563,39 @@ const handleBack = () => {
white-space: pre-line;
}
.text-expand-btn {
width: 100%;
height: 46px;
margin-top: 18px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #151713;
border-radius: 8px;
color: #ffffff;
}
.text-expand-btn.disabled {
opacity: 0.56;
}
.text-expand-label {
display: block;
font-size: 15px;
line-height: 21px;
font-weight: 700;
color: currentColor;
}
.text-error {
display: block;
margin-top: 12px;
font-size: 13px;
line-height: 20px;
color: #8a4a31;
}
.action-bar {
position: absolute;
left: 0;