Compare commits

..

2 Commits

Author SHA1 Message Date
lyf
180d3e7c96 修复讲解播放器跨页面状态
Some checks failed
CI / verify (push) Has been cancelled
2026-07-08 22:17:29 +08:00
lyf
9cdb2c08b2 修复讲解全局音频播放器交互 2026-07-08 21:22:27 +08:00
12 changed files with 1725 additions and 324 deletions

View File

@@ -1,84 +1,181 @@
<template> <template>
<view class="audio-player-host"> <view class="audio-player-host">
<view <view
class="audio-player" v-if="visible && displayAudio"
:class="{ 'avoid-bottom-nav': avoidBottomNav }" class="audio-player"
v-if="visible && displayAudio" :class="[`mode-${displayMode}`, { 'avoid-bottom-nav': avoidBottomNav }]"
> >
<!-- 迷你播放器 (底部固定) --> <view v-if="displayMode === 'expanded'" class="expanded-player">
<view class="mini-player" @tap="handleExpand"> <view class="player-handle"></view>
<!-- 封面图 --> <view class="expanded-main">
<view class="player-cover"> <image
<image v-if="displayAudio.image"
class="cover-image" class="expanded-cover"
:src="displayAudio?.image || EXHIBIT_PLACEHOLDER_IMAGE" :src="displayAudio.image"
mode="aspectFill" mode="aspectFill"
/> />
<view class="expanded-copy">
<text class="player-kicker">讲解音频</text>
<text class="expanded-title">{{ displayAudio.name }}</text>
<text class="expanded-subtitle" :class="{ error: displayPlaybackError }">{{ expandedSubtitle }}</text>
</view>
<view class="expanded-side">
<view
v-if="isLanguageSwitchable"
class="player-language-switch expanded-language"
@tap.stop
>
<view
v-for="option in audioLanguageOptions"
:key="option.value"
class="player-language-option"
:class="{
active: effectiveAudioLanguage === option.value,
pending: displayPendingLanguage === option.value,
disabled: isLanguagePending && displayPendingLanguage !== option.value
}"
@tap.stop="handleLanguageChange(option.value)"
>
<text class="player-language-text">{{ formatLanguageOptionText(option) }}</text>
</view>
</view>
<view v-else class="player-language-state expanded-language">
<text class="player-language-text">{{ displayAudioLanguageShort }}</text>
</view>
<view class="control-btn play-btn expanded-play" @tap.stop="togglePlay">
<svg v-if="displayPlaying" width="30" height="30" 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>
<svg v-else width="30" height="30" viewBox="0 0 24 24" fill="none">
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
</svg>
</view>
</view>
</view>
<view class="expanded-progress-row">
<text class="time-text active">{{ formattedCurrentTime }}</text>
<view class="progress-track expanded-track" @tap="handleSeek">
<view class="progress-fill" :style="{ width: progressPercent + '%' }"></view>
<view class="progress-thumb" :style="{ left: progressPercent + '%' }"></view>
</view>
<text class="time-text">{{ formattedDuration }}</text>
</view>
<view class="expanded-actions">
<view class="action-btn collapse-action" @tap.stop="handleCollapse">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M6 15L12 9L18 15" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<text class="action-text">收起</text>
</view>
<view class="action-divider"></view>
<view class="action-btn stop-action" @tap.stop="handleStop">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<rect x="7" y="7" width="10" height="10" rx="1.5" stroke="currentColor" stroke-width="2"/>
</svg>
<text class="action-text">停止播放</text>
</view>
</view>
</view> </view>
<!-- 播放信息 --> <view v-else-if="displayMode === 'floating'" class="floating-player" @tap="handleExpand">
<view class="player-info"> <view class="floating-ring" :style="{ '--progress': progressPercent + '%' }">
<text class="player-title">{{ displayAudio?.name || '讲解音频' }}</text> <view class="control-btn floating-play" @tap.stop="togglePlay">
<text class="player-subtitle" :class="{ error: displayPlaybackError }">{{ playerSubtitle }}</text> <svg v-if="displayPlaying" 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>
<svg v-else width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
</svg>
</view>
<text class="floating-label">讲解</text>
<text class="floating-lang">{{ displayAudioLanguageShort }}</text>
</view>
</view> </view>
<!-- 播放/暂停按钮 --> <view v-else class="mini-player" @tap="handleExpand">
<view class="player-controls"> <view class="control-btn play-btn mini-play" @tap.stop="togglePlay">
<view class="control-btn play-btn" @tap.stop="togglePlay"> <svg v-if="displayPlaying" width="26" height="26" viewBox="0 0 24 24" fill="none">
<svg v-if="displayPlaying" 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="6" y="5" width="4" height="14" rx="1" fill="currentColor"/>
<rect x="14" 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>
<svg v-else width="24" height="24" viewBox="0 0 24 24" fill="none"> <svg v-else width="26" height="26" viewBox="0 0 24 24" fill="none">
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/> <path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
</svg> </svg>
</view> </view>
<!-- 关闭按钮 --> <view class="mini-copy">
<view class="control-btn close-btn" @tap.stop="handleClose"> <text class="player-kicker">讲解音频</text>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"> <text class="mini-title">{{ displayAudio.name }}</text>
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> </view>
<view class="mini-progress">
<view class="progress-track" @tap.stop="handleSeek">
<view class="progress-fill" :style="{ width: progressPercent + '%' }"></view>
</view>
</view>
<view v-if="isLanguageSwitchable" class="player-language-switch mini-language" @tap.stop>
<view
v-for="option in audioLanguageOptions"
:key="option.value"
class="player-language-option"
:class="{
active: effectiveAudioLanguage === option.value,
pending: displayPendingLanguage === option.value,
disabled: isLanguagePending && displayPendingLanguage !== option.value
}"
@tap.stop="handleLanguageChange(option.value)"
>
<text class="player-language-text">{{ formatLanguageOptionText(option) }}</text>
</view>
</view>
<view v-else class="player-language-state mini-language">
<text class="player-language-text">{{ displayAudioLanguageShort }}</text>
</view>
<view class="expand-action">
<text class="expand-text">展开</text>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none">
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
</view> </view>
</view> </view>
</view> </view>
<!-- 播放进度条 -->
<view class="progress-bar">
<view class="progress-track" @tap="handleSeek">
<view class="progress-fill" :style="{ width: progressPercent + '%' }"></view>
<view class="progress-thumb" :style="{ left: progressPercent + '%' }"></view>
</view>
</view>
</view>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import {
EXHIBIT_PLACEHOLDER_IMAGE
} from '@/utils/placeholders'
export interface AudioItem { export interface AudioItem {
id: string id: string
name: string name: string
audioUrl: string audioUrl: string
image?: string image?: string
duration?: number duration?: number
language?: AudioLanguage
supportedLanguages?: AudioLanguage[]
} }
export type AudioLanguage = 'zh-CN' | 'en-US'
export type AudioDisplayMode = 'expanded' | 'mini' | 'floating'
const props = defineProps<{ const props = defineProps<{
visible?: boolean visible?: boolean
audio?: AudioItem | null audio?: AudioItem | null
autoPlay?: boolean autoPlay?: boolean
avoidBottomNav?: boolean avoidBottomNav?: boolean
mode?: AudioDisplayMode
controlled?: boolean controlled?: boolean
playing?: boolean playing?: boolean
loading?: boolean loading?: boolean
playbackError?: string playbackError?: string
currentTime?: number currentTime?: number
durationValue?: number durationValue?: number
pendingLanguage?: AudioLanguage | ''
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -90,8 +187,11 @@ const emit = defineEmits<{
error: [audio: AudioItem | null, message: string] error: [audio: AudioItem | null, message: string]
toggle: [] toggle: []
close: [] close: []
collapse: []
stop: []
expand: [] expand: []
seek: [percent: number] seek: [percent: number]
'language-change': [language: AudioLanguage]
}>() }>()
// 状态 // 状态
@@ -129,15 +229,39 @@ const displayPlaying = computed(() => props.controlled ? props.playing === true
const displayLoading = computed(() => props.controlled ? props.loading === true : isLoading.value) const displayLoading = computed(() => props.controlled ? props.loading === true : isLoading.value)
const displayPlaybackError = computed(() => props.controlled ? props.playbackError || '' : localPlaybackError.value) const displayPlaybackError = computed(() => props.controlled ? props.playbackError || '' : localPlaybackError.value)
const displayCurrentTime = computed(() => props.controlled ? props.currentTime || 0 : localCurrentTime.value) const displayCurrentTime = computed(() => props.controlled ? props.currentTime || 0 : localCurrentTime.value)
const displayMode = computed<AudioDisplayMode>(() => props.mode || 'mini')
const audioLanguageOptions: Array<{ value: AudioLanguage; label: string; shortLabel: string }> = [
{ value: 'zh-CN', label: '中文', shortLabel: '中文' },
{ value: 'en-US', label: 'English', shortLabel: 'EN' }
]
const displayAudioLanguage = computed<AudioLanguage>(() => (
displayAudio.value?.language === 'en-US' ? 'en-US' : 'zh-CN'
))
const supportedAudioLanguages = computed(() => {
const languages = displayAudio.value?.supportedLanguages?.length
? displayAudio.value.supportedLanguages
: [displayAudioLanguage.value]
const playerSubtitle = computed(() => { return Array.from(new Set(languages.filter((language): language is AudioLanguage => (
language === 'zh-CN' || language === 'en-US'
))))
})
const isLanguageSwitchable = computed(() => supportedAudioLanguages.value.length > 1)
const displayPendingLanguage = computed<AudioLanguage | ''>(() => props.pendingLanguage || '')
const effectiveAudioLanguage = computed<AudioLanguage>(() => (
displayPendingLanguage.value || displayAudioLanguage.value
))
const isLanguagePending = computed(() => Boolean(displayPendingLanguage.value))
const displayAudioLanguageShort = computed(() => (
displayAudioLanguage.value === 'en-US' ? 'EN' : '中文'
))
const formattedCurrentTime = computed(() => formatTime(displayCurrentTime.value))
const formattedDuration = computed(() => formatTime(resolvedDuration.value))
const expandedSubtitle = computed(() => {
if (displayPlaybackError.value) return displayPlaybackError.value if (displayPlaybackError.value) return displayPlaybackError.value
if (displayLoading.value) return '音频加载中' if (displayLoading.value) return '音频加载中'
if (!displayAudio.value) return '请选择讲解' if (!displayAudio.value?.audioUrl) return '当前语言暂无语音讲解'
if (resolvedDuration.value > 0) { return displayAudioLanguage.value === 'en-US' ? '英文语音讲解' : '中文语音讲解'
return `${formatTime(displayCurrentTime.value)} / ${formatTime(resolvedDuration.value)}`
}
return displayPlaying.value ? '正在播放' : '点击播放按钮开始'
}) })
// 格式化时间 // 格式化时间
@@ -147,6 +271,10 @@ const formatTime = (seconds: number): string => {
return `${mins}:${secs.toString().padStart(2, '0')}` return `${mins}:${secs.toString().padStart(2, '0')}`
} }
const formatLanguageOptionText = (option: { shortLabel: string; value: AudioLanguage }) => (
displayPendingLanguage.value === option.value ? `${option.shortLabel}...` : option.shortLabel
)
// 初始化音频上下文 // 初始化音频上下文
const initAudio = () => { const initAudio = () => {
if (h5AudioElement.value) return if (h5AudioElement.value) return
@@ -427,17 +555,18 @@ const handleExpand = () => {
emit('expand') emit('expand')
} }
// 关闭播放器 const handleCollapse = () => {
const handleClose = () => { emit('collapse')
}
const handleStop = () => {
if (props.controlled) { if (props.controlled) {
emit('close') emit('stop')
emit('update:visible', false)
return return
} }
stopAudio() stopAudio()
emit('update:visible', false) emit('stop')
emit('close')
} }
const handleH5LoadedMetadata = () => { const handleH5LoadedMetadata = () => {
@@ -455,6 +584,12 @@ const handleH5Play = () => {
} }
} }
const handleLanguageChange = (language: AudioLanguage) => {
if (isLanguagePending.value) return
if (!supportedAudioLanguages.value.includes(language) || language === displayAudioLanguage.value) return
emit('language-change', language)
}
const handleH5Pause = () => { const handleH5Pause = () => {
isPlaying.value = false isPlaying.value = false
if (currentAudio.value) { if (currentAudio.value) {
@@ -558,84 +693,160 @@ defineExpose({
} }
.audio-player { .audio-player {
--audio-accent: #e0e100;
--audio-surface: #050505;
--audio-text: #ffffff;
--audio-muted: rgba(255, 255, 255, 0.68);
position: fixed; position: fixed;
bottom: 0; left: 50%;
left: 0; right: auto;
right: 0; bottom: calc(12px + env(safe-area-inset-bottom));
z-index: 2200;
width: calc(min(100vw, 430px) - 28px);
box-sizing: border-box; box-sizing: border-box;
background-color: var(--museum-bg-surface); transform: translateX(-50%);
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1); color: var(--audio-text);
z-index: 1000; font-family: '鸿蒙黑体', 'HarmonyOS Sans SC', 'HarmonyOS Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
padding-bottom: env(safe-area-inset-bottom);
} }
.audio-player.avoid-bottom-nav { .audio-player.avoid-bottom-nav {
bottom: calc(82px + env(safe-area-inset-bottom)); bottom: calc(82px + env(safe-area-inset-bottom));
padding-bottom: 0;
} }
@media (min-width: 768px) { .audio-player.mode-floating {
.audio-player { left: auto;
left: 50%; right: max(16px, calc((100vw - 430px) / 2 + 16px));
right: auto; bottom: calc(102px + env(safe-area-inset-bottom));
width: 430px; width: 116px;
transform: translateX(-50%); transform: none;
}
} }
.mini-player { .audio-player.mode-floating.avoid-bottom-nav {
display: flex; bottom: calc(104px + env(safe-area-inset-bottom));
}
.expanded-player,
.mini-player,
.floating-ring {
position: relative;
overflow: hidden;
background:
radial-gradient(circle at 14% 18%, rgba(224, 225, 0, 0.08), transparent 28%),
linear-gradient(135deg, #161714 0%, #050505 100%);
border: 1px solid rgba(224, 225, 0, 0.2);
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.28);
}
.expanded-player::after,
.mini-player::after,
.floating-ring::after {
content: '';
position: absolute;
inset: auto -24px -42px auto;
width: 150px;
height: 110px;
border: 1px solid rgba(224, 225, 0, 0.13);
border-radius: 50%;
transform: rotate(-16deg);
pointer-events: none;
}
.mini-player::after {
inset: auto 8px 8px auto;
width: 120px;
height: 78px;
}
.expanded-player {
min-height: 292px;
padding: 28px 22px 16px;
box-sizing: border-box;
border-radius: 24px;
}
.player-handle {
position: absolute;
top: 11px;
left: 50%;
width: 54px;
height: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.22);
transform: translateX(-50%);
}
.expanded-main {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
gap: 14px 16px;
align-items: center; align-items: center;
padding: 12px 16px;
gap: 12px;
} }
.player-cover { .expanded-cover {
flex-shrink: 0; width: 92px;
width: 44px; height: 92px;
height: 44px; border-radius: 14px;
border-radius: var(--radius-small); background: #262421;
overflow: hidden;
background-color: var(--museum-bg-light);
} }
.cover-image { .expanded-copy,
width: 100%; .mini-copy {
height: 100%; min-width: 0;
} }
.player-info { .player-kicker,
flex: 1; .expanded-title,
display: flex; .expanded-subtitle,
flex-direction: column; .mini-title,
justify-content: center; .time-text,
overflow: hidden; .action-text,
.floating-label,
.floating-lang,
.expand-text {
display: block;
} }
.player-title { .player-kicker {
font-size: 14px; font-size: 17px;
font-weight: 500; line-height: 23px;
color: var(--museum-text-primary); font-weight: 900;
color: #ffffff;
}
.expanded-title {
margin-top: 8px;
font-size: 30px;
line-height: 37px;
font-weight: 900;
color: #ffffff;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.player-subtitle { .expanded-subtitle {
font-size: 12px; margin-top: 3px;
color: var(--museum-text-secondary); font-size: 15px;
margin-top: 2px; line-height: 21px;
color: var(--audio-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.player-subtitle.error { .expanded-subtitle.error {
color: #9b3d2e; color: #ffb0a2;
} }
.player-controls { .expanded-side {
flex-shrink: 0; grid-column: 1 / -1;
display: flex; display: flex;
flex-direction: row;
align-items: center; align-items: center;
gap: 8px; justify-content: space-between;
gap: 12px;
} }
.control-btn { .control-btn {
@@ -643,60 +854,365 @@ defineExpose({
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: transform 0.2s ease, opacity 0.2s ease;
&:active { &:active {
transform: scale(0.9); transform: scale(0.92);
opacity: 0.8; opacity: 0.84;
} }
} }
.play-btn { .play-btn,
width: 40px; .floating-play {
height: 40px; flex-shrink: 0;
background-color: var(--museum-accent); background: var(--audio-accent);
color: #050505;
border-radius: 50%; border-radius: 50%;
color: var(--museum-text-primary);
} }
.close-btn { .expanded-play {
width: 32px; width: 64px;
height: 32px; height: 64px;
color: var(--museum-text-secondary);
} }
.progress-bar { .expanded-progress-row {
width: 100%; position: relative;
padding: 0 16px 12px; z-index: 1;
margin-top: 22px;
display: grid;
grid-template-columns: 66px minmax(0, 1fr) 58px;
gap: 12px;
align-items: center;
}
.time-text {
font-size: 18px;
line-height: 24px;
font-weight: 700;
color: rgba(255, 255, 255, 0.66);
}
.time-text.active {
color: var(--audio-accent);
} }
.progress-track { .progress-track {
position: relative; position: relative;
width: 100%; width: 100%;
height: 4px; height: 5px;
background-color: var(--museum-border-light); background: rgba(255, 255, 255, 0.24);
border-radius: 2px; border-radius: 999px;
cursor: pointer; cursor: pointer;
} }
.progress-fill { .progress-fill {
position: absolute; position: absolute;
top: 0; inset: 0 auto 0 0;
left: 0;
height: 100%; height: 100%;
background-color: var(--museum-accent); background: var(--audio-accent);
border-radius: 2px; border-radius: inherit;
transition: width 0.1s linear; transition: width 0.1s linear;
} }
.progress-thumb { .progress-thumb {
position: absolute; position: absolute;
top: 50%; top: 50%;
transform: translate(-50%, -50%); width: 18px;
width: 12px; height: 18px;
height: 12px; background: var(--audio-accent);
background-color: var(--museum-accent);
border-radius: 50%; border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); box-shadow: 0 4px 10px rgba(0, 0, 0, 0.28);
transform: translate(-50%, -50%);
}
.expanded-actions {
position: relative;
z-index: 1;
margin-top: 24px;
padding-top: 15px;
display: grid;
grid-template-columns: 1fr 1px 1fr;
align-items: center;
border-top: 1px solid rgba(255, 255, 255, 0.14);
}
.action-btn {
min-width: 0;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
color: #ffffff;
}
.stop-action {
color: rgba(255, 255, 255, 0.92);
}
.action-divider {
width: 1px;
height: 26px;
background: rgba(255, 255, 255, 0.16);
}
.action-text {
font-size: 17px;
line-height: 22px;
font-weight: 800;
color: currentColor;
}
.mini-player {
min-height: 116px;
padding: 12px 14px 14px;
display: grid;
grid-template-columns: 56px minmax(0, 1fr) auto auto;
grid-template-areas:
"play spacer language expand"
"copy copy copy copy"
"progress progress progress progress";
column-gap: 10px;
row-gap: 7px;
align-items: center;
box-sizing: border-box;
border-radius: 30px;
}
.mini-play {
grid-area: play;
width: 56px;
height: 56px;
}
.mini-copy {
grid-area: copy;
display: flex;
align-items: baseline;
gap: 8px;
overflow: hidden;
}
.mini-copy .player-kicker {
flex: 0 0 auto;
white-space: nowrap;
}
.mini-title {
flex: 1 1 auto;
min-width: 0;
margin-top: 2px;
font-size: 22px;
line-height: 28px;
font-weight: 900;
color: #ffffff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mini-progress {
grid-area: progress;
min-width: 0;
width: 100%;
}
.mini-progress .progress-track {
height: 4px;
}
.player-language-state,
.player-language-switch {
flex-shrink: 0;
min-width: 56px;
height: 34px;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 999px;
border: 1px solid rgba(224, 225, 0, 0.52);
background: rgba(255, 255, 255, 0.02);
color: var(--audio-accent);
}
.mini-language {
grid-area: language;
justify-self: end;
}
.player-language-switch {
min-width: 92px;
padding: 3px;
justify-content: flex-start;
gap: 2px;
}
.player-language-option {
min-width: 40px;
height: 26px;
padding: 0 8px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 999px;
color: #ffffff;
}
.player-language-option.active {
background: var(--audio-accent);
color: #050505;
}
.player-language-option.pending {
background: rgba(224, 225, 0, 0.76);
color: #050505;
box-shadow: 0 0 0 1px rgba(224, 225, 0, 0.26);
}
.player-language-option.disabled {
opacity: 0.52;
}
.player-language-text {
font-size: 14px;
line-height: 18px;
font-weight: 900;
color: currentColor;
}
.expand-action {
grid-area: expand;
min-width: 70px;
height: 42px;
padding-left: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
box-sizing: border-box;
border-left: 1px solid rgba(255, 255, 255, 0.16);
color: #ffffff;
}
.expand-text {
font-size: 18px;
line-height: 22px;
font-weight: 900;
color: currentColor;
}
.floating-player {
width: 116px;
height: 116px;
}
.floating-ring {
width: 116px;
height: 116px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 50%;
}
.floating-ring::before {
content: '';
position: absolute;
inset: 8px;
border-radius: 50%;
background:
conic-gradient(var(--audio-accent) 0 var(--progress), rgba(255, 255, 255, 0.24) var(--progress) 100%);
mask: radial-gradient(circle, transparent 65%, #000 66%);
pointer-events: none;
}
.floating-play {
position: relative;
z-index: 1;
width: 48px;
height: 48px;
}
.floating-label {
position: relative;
z-index: 1;
margin-top: 7px;
font-size: 18px;
line-height: 22px;
font-weight: 900;
color: #ffffff;
}
.floating-lang {
position: relative;
z-index: 1;
min-width: 46px;
height: 23px;
margin-top: 2px;
padding: 0 8px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border: 1px solid rgba(224, 225, 0, 0.7);
border-radius: 999px;
font-size: 13px;
line-height: 18px;
font-weight: 900;
color: var(--audio-accent);
}
@media (min-width: 768px) {
.audio-player:not(.mode-floating) {
width: 402px;
}
}
@media (max-width: 390px) {
.expanded-player {
padding-left: 18px;
padding-right: 18px;
}
.expanded-main {
grid-template-columns: 76px minmax(0, 1fr);
}
.expanded-cover {
width: 76px;
height: 76px;
}
.expanded-title {
font-size: 26px;
line-height: 32px;
}
.mini-player {
min-height: 110px;
padding: 10px 12px 12px;
grid-template-columns: 50px minmax(0, 1fr) auto;
grid-template-areas:
"play language expand"
"copy copy copy"
"progress progress progress";
column-gap: 8px;
row-gap: 6px;
}
.mini-play {
width: 50px;
height: 50px;
}
.mini-title {
font-size: 21px;
line-height: 27px;
}
} }
</style> </style>

View File

@@ -9,21 +9,36 @@
:playback-error="error" :playback-error="error"
:current-time="currentTime" :current-time="currentTime"
:duration-value="duration" :duration-value="duration"
avoid-bottom-nav :pending-language="pendingLanguage"
:avoid-bottom-nav="avoidBottomNav"
:mode="currentDisplayMode"
@toggle="handleToggle" @toggle="handleToggle"
@close="player.close" @collapse="handleCollapse"
@stop="handleStop"
@seek="player.seekToPercent" @seek="player.seekToPercent"
@expand="handleExpand" @expand="handleExpand"
@language-change="handleLanguageChange"
@update:visible="handleVisibleChange" @update:visible="handleVisibleChange"
/> />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue' import { computed, onMounted, onUnmounted, watch } from 'vue'
import { onShow } from '@dcloudio/uni-app' import { onShow } from '@dcloudio/uni-app'
import AudioPlayer from '@/components/audio/AudioPlayer.vue' import AudioPlayer, {
type AudioDisplayMode,
type AudioLanguage
} from '@/components/audio/AudioPlayer.vue'
import { useGlobalAudioPlayer } from '@/composables/useGlobalAudioPlayer' import { useGlobalAudioPlayer } from '@/composables/useGlobalAudioPlayer'
const props = withDefaults(defineProps<{
avoidBottomNav?: boolean
displayMode?: AudioDisplayMode
}>(), {
avoidBottomNav: true,
displayMode: 'mini'
})
const player = useGlobalAudioPlayer() const player = useGlobalAudioPlayer()
const { const {
currentAudio, currentAudio,
@@ -34,14 +49,14 @@ const {
error, error,
currentTime, currentTime,
duration, duration,
activeHostId, displayMode: currentDisplayMode,
pendingLanguage,
hasAudio hasAudio
} = player } = player
const hostId = player.registerHost() const hostId = player.registerHost()
const isHostVisible = computed(() => ( const isHostVisible = computed(() => (
visible.value visible.value
&& hasAudio.value && hasAudio.value
&& activeHostId.value === hostId
)) ))
const handleToggle = () => { const handleToggle = () => {
@@ -53,15 +68,56 @@ const handleToggle = () => {
void player.resume() void player.resume()
} }
const applyHostDisplayMode = () => {
if (!hasAudio.value) {
return
}
if (currentDisplayMode.value !== props.displayMode) {
player.setDisplayMode(props.displayMode)
}
}
const isCurrentH5Route = (route: string) => {
if (typeof window === 'undefined') return false
return window.location.hash.replace(/^#/, '') === route
}
const handleStop = () => {
player.stop()
}
const handleCollapse = () => {
player.collapse(props.displayMode === 'floating' ? 'floating' : 'mini')
}
const handleLanguageChange = (language: AudioLanguage) => {
void player.switchLanguage(language)
}
const handleVisibleChange = (visible: boolean) => { const handleVisibleChange = (visible: boolean) => {
if (!visible) { if (!visible && hasAudio.value) {
player.close() player.collapse(props.displayMode === 'floating' ? 'floating' : 'mini')
} }
} }
const handleExpand = () => { const handleExpand = () => {
if (currentDisplayMode.value === 'floating') {
player.setDisplayMode('mini')
return
}
player.setDisplayMode('expanded')
const route = currentSource.value?.detailRoute const route = currentSource.value?.detailRoute
if (!route) return if (!route) {
return
}
if (isCurrentH5Route(route)) {
return
}
uni.navigateTo({ uni.navigateTo({
url: route url: route
@@ -70,13 +126,26 @@ const handleExpand = () => {
onMounted(() => { onMounted(() => {
player.activateHost(hostId) player.activateHost(hostId)
applyHostDisplayMode()
}) })
onShow(() => { onShow(() => {
player.activateHost(hostId) player.activateHost(hostId)
applyHostDisplayMode()
}) })
onUnmounted(() => { onUnmounted(() => {
player.unregisterHost(hostId) player.unregisterHost(hostId)
}) })
watch(
() => props.displayMode,
() => {
applyHostDisplayMode()
}
)
watch(hasAudio, () => {
applyHostDisplayMode()
})
</script> </script>

View File

@@ -32,13 +32,17 @@
<view class="guide-page-frame-body"> <view class="guide-page-frame-body">
<slot></slot> <slot></slot>
</view> </view>
<GlobalAudioPlayerHost /> <GlobalAudioPlayerHost
:avoid-bottom-nav="audioPlayerAvoidBottomNav"
:display-mode="audioPlayerMode"
/>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import GuideTopTabs from '@/components/navigation/GuideTopTabs.vue' import GuideTopTabs from '@/components/navigation/GuideTopTabs.vue'
import GlobalAudioPlayerHost from '@/components/audio/GlobalAudioPlayerHost.vue' import GlobalAudioPlayerHost from '@/components/audio/GlobalAudioPlayerHost.vue'
import type { AudioDisplayMode } from '@/components/audio/AudioPlayer.vue'
import type { GuideTopTab } from '@/utils/guideTopTabs' import type { GuideTopTab } from '@/utils/guideTopTabs'
withDefaults(defineProps<{ withDefaults(defineProps<{
@@ -51,6 +55,8 @@ withDefaults(defineProps<{
showCancel?: boolean showCancel?: boolean
backLabel?: string backLabel?: string
cancelLabel?: string cancelLabel?: string
audioPlayerAvoidBottomNav?: boolean
audioPlayerMode?: AudioDisplayMode
}>(), { }>(), {
activeTab: 'guide', activeTab: 'guide',
variant: 'overlay', variant: 'overlay',
@@ -60,7 +66,9 @@ withDefaults(defineProps<{
showBack: false, showBack: false,
showCancel: false, showCancel: false,
backLabel: '返回', backLabel: '返回',
cancelLabel: '取消' cancelLabel: '取消',
audioPlayerAvoidBottomNav: true,
audioPlayerMode: 'mini'
}) })
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -1,6 +1,12 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { AudioItem } from '@/components/audio/AudioPlayer.vue' import type {
import type { AudioLanguage } from '@/repositories/AudioPlayInfoRepository' AudioDisplayMode,
AudioItem
} from '@/components/audio/AudioPlayer.vue'
import {
audioPlayInfoRepository,
type AudioLanguage
} from '@/repositories/AudioPlayInfoRepository'
import type { AudioPlayTargetType } from '@/domain/museum' import type { AudioPlayTargetType } from '@/domain/museum'
export interface GlobalAudioSource { export interface GlobalAudioSource {
@@ -18,6 +24,7 @@ export interface GlobalAudioPlayOptions {
source?: GlobalAudioSource source?: GlobalAudioSource
retryOnError?: GlobalAudioRetryHandler retryOnError?: GlobalAudioRetryHandler
retryAttempt?: boolean retryAttempt?: boolean
displayMode?: AudioDisplayMode
} }
const currentAudio = ref<AudioItem | null>(null) const currentAudio = ref<AudioItem | null>(null)
@@ -28,7 +35,11 @@ const loading = ref(false)
const error = ref('') const error = ref('')
const currentTime = ref(0) const currentTime = ref(0)
const duration = ref(0) const duration = ref(0)
const displayMode = ref<AudioDisplayMode>('mini')
const pendingLanguage = ref<AudioLanguage | ''>('')
const activeHostId = ref('') const activeHostId = ref('')
const lastClosedSource = ref<GlobalAudioSource | null>(null)
const closeVersion = ref(0)
let audioElement: HTMLAudioElement | null = null let audioElement: HTMLAudioElement | null = null
let retryOnError: GlobalAudioRetryHandler | null = null let retryOnError: GlobalAudioRetryHandler | null = null
@@ -110,6 +121,8 @@ const resetState = () => {
error.value = '' error.value = ''
currentTime.value = 0 currentTime.value = 0
duration.value = 0 duration.value = 0
displayMode.value = 'mini'
pendingLanguage.value = ''
retryOnError = null retryOnError = null
retrying = false retrying = false
retryUsed = false retryUsed = false
@@ -134,9 +147,52 @@ const stopAudioElement = () => {
resetStopping() resetStopping()
} }
const stopPlayback = () => {
stopAudioElement()
playing.value = false
loading.value = false
pendingLanguage.value = ''
currentTime.value = 0
duration.value = currentAudio.value?.duration || 0
}
const updateDetailRouteLanguage = (route: string | undefined, lang: AudioLanguage) => {
if (!route) return route
const [path, rawQuery = ''] = route.split('?')
const params = new URLSearchParams(rawQuery)
params.set('lang', lang)
return `${path}?${params.toString()}`
}
const toSwitchedAudioItem = (
playInfo: Awaited<ReturnType<typeof audioPlayInfoRepository.getPlayInfo>>,
lang: AudioLanguage
): AudioItem => ({
id: `play-${playInfo.audioId || `${playInfo.targetType}-${playInfo.targetId}-${lang}`}`,
name: currentAudio.value?.name || currentSource.value?.title || playInfo.title || '讲解音频',
audioUrl: playInfo.playUrl || '',
image: currentAudio.value?.image,
duration: typeof playInfo.duration === 'number' ? playInfo.duration : currentAudio.value?.duration,
language: lang,
supportedLanguages: currentAudio.value?.supportedLanguages
})
const play = async (audio: AudioItem, options: GlobalAudioPlayOptions = {}) => { const play = async (audio: AudioItem, options: GlobalAudioPlayOptions = {}) => {
if (!audio.audioUrl) { if (!audio.audioUrl) {
handleError('当前语言暂无语音讲解。') const preservedMode = options.displayMode || displayMode.value
stopAudioElement()
currentAudio.value = audio
currentSource.value = options.source || currentSource.value
retryOnError = options.retryOnError || retryOnError
visible.value = true
displayMode.value = preservedMode
playing.value = false
loading.value = false
error.value = '当前语言暂无语音讲解'
currentTime.value = 0
duration.value = 0
showToast(error.value)
return false return false
} }
@@ -155,6 +211,9 @@ const play = async (audio: AudioItem, options: GlobalAudioPlayOptions = {}) => {
currentSource.value = options.source || currentSource.value currentSource.value = options.source || currentSource.value
retryOnError = options.retryOnError || retryOnError retryOnError = options.retryOnError || retryOnError
visible.value = true visible.value = true
if (options.displayMode) {
displayMode.value = options.displayMode
}
loading.value = true loading.value = true
error.value = '' error.value = ''
@@ -194,17 +253,122 @@ const resume = () => {
if (!currentAudio.value) return Promise.resolve(false) if (!currentAudio.value) return Promise.resolve(false)
return play(currentAudio.value, { return play(currentAudio.value, {
source: currentSource.value || undefined, source: currentSource.value || undefined,
retryOnError: retryOnError || undefined retryOnError: retryOnError || undefined,
displayMode: displayMode.value
}) })
} }
const switchLanguage = async (lang: AudioLanguage) => {
if (pendingLanguage.value) {
return false
}
const source = currentSource.value
if (!source?.targetType || !source.targetId) {
error.value = '当前讲解暂不支持语言切换'
showToast(error.value)
return false
}
if (currentAudio.value?.language === lang && source.lang === lang) {
return true
}
const preservedMode = displayMode.value
pendingLanguage.value = lang
loading.value = true
error.value = ''
const nextSource: GlobalAudioSource = {
...source,
lang,
detailRoute: updateDetailRouteLanguage(source.detailRoute, lang)
}
try {
const playInfo = await audioPlayInfoRepository.getPlayInfo({
targetType: source.targetType,
targetId: source.targetId,
lang,
refresh: true
})
if (!playInfo.playable || !playInfo.playUrl) {
stopAudioElement()
currentSource.value = nextSource
currentAudio.value = currentAudio.value
? {
...currentAudio.value,
id: `unavailable-${source.targetType}-${source.targetId}-${lang}`,
audioUrl: '',
duration: 0,
language: lang
}
: null
visible.value = Boolean(currentAudio.value)
displayMode.value = preservedMode
playing.value = false
loading.value = false
currentTime.value = 0
duration.value = 0
error.value = '当前语言暂无语音讲解'
showToast(error.value)
return false
}
currentSource.value = nextSource
return await play(toSwitchedAudioItem(playInfo, lang), {
source: nextSource,
retryOnError: retryOnError || undefined,
displayMode: preservedMode
})
} catch (err) {
console.warn('讲解音频语言切换失败:', err)
stopAudioElement()
currentSource.value = nextSource
if (currentAudio.value) {
currentAudio.value = {
...currentAudio.value,
id: `unavailable-${source.targetType}-${source.targetId}-${lang}`,
audioUrl: '',
duration: 0,
language: lang
}
visible.value = true
}
displayMode.value = preservedMode
playing.value = false
loading.value = false
currentTime.value = 0
duration.value = 0
error.value = '当前语言暂无语音讲解'
showToast(error.value)
return false
} finally {
pendingLanguage.value = ''
}
}
const stop = () => { const stop = () => {
stopPlayback()
}
const close = () => {
lastClosedSource.value = currentSource.value ? { ...currentSource.value } : null
closeVersion.value += 1
stopAudioElement() stopAudioElement()
resetState() resetState()
} }
const close = () => { const setDisplayMode = (mode: AudioDisplayMode) => {
stop() displayMode.value = mode
if (currentAudio.value) {
visible.value = true
}
}
const collapse = (mode: AudioDisplayMode = 'mini') => {
setDisplayMode(mode)
} }
function handleEnded() { function handleEnded() {
@@ -230,13 +394,14 @@ async function handleError(message: string) {
return play(retryAudio, { return play(retryAudio, {
source: currentSource.value || undefined, source: currentSource.value || undefined,
retryOnError: retryOnError || undefined, retryOnError: retryOnError || undefined,
retryAttempt: true retryAttempt: true,
displayMode: displayMode.value
}) })
} }
} }
showToast(message || '讲解音频播放失败') showToast(message || '讲解音频播放失败')
stop() stopPlayback()
return false return false
} }
@@ -287,13 +452,20 @@ export const useGlobalAudioPlayer = () => ({
error, error,
currentTime, currentTime,
duration, duration,
displayMode,
pendingLanguage,
activeHostId, activeHostId,
lastClosedSource,
closeVersion,
hasAudio: computed(() => Boolean(currentAudio.value)), hasAudio: computed(() => Boolean(currentAudio.value)),
play, play,
pause, pause,
resume, resume,
stop, stop,
close, close,
setDisplayMode,
collapse,
switchLanguage,
handleEnded, handleEnded,
handleError, handleError,
seekToPercent, seekToPercent,

View File

@@ -473,6 +473,7 @@ export const createGuideExplainDataAdapter = (dataset: GuideStaticExplainDataset
const visibleHalls = visibleHallsFrom(dataset.halls) const visibleHalls = visibleHallsFrom(dataset.halls)
const visibleOutlines = visibleOutlinesFrom(dataset.outlines) const visibleOutlines = visibleOutlinesFrom(dataset.outlines)
const visibleStops = visibleStopsFrom(dataset.guideStops) const visibleStops = visibleStopsFrom(dataset.guideStops)
const visibleExhibits = dataset.exhibits.filter(isVisibleRecord)
const visibleGuides = visibleGuidesFrom(dataset.guideContents) const visibleGuides = visibleGuidesFrom(dataset.guideContents)
const hallById = new Map(visibleHalls.map((hall) => [stringifyId(hall.id), hall])) const hallById = new Map(visibleHalls.map((hall) => [stringifyId(hall.id), hall]))
@@ -626,6 +627,10 @@ export const createGuideExplainDataAdapter = (dataset: GuideStaticExplainDataset
const stop = guide.targetType?.toUpperCase() === 'STOP' const stop = guide.targetType?.toUpperCase() === 'STOP'
? stopById.get(stringifyId(guide.targetId)) ? stopById.get(stringifyId(guide.targetId))
: undefined : undefined
const linkedExhibit = visibleExhibits.find((exhibit) => (
stringifyId(exhibit.id) === stringifyId(guide.exhibitId)
|| Boolean(stop?.id && stringifyId(exhibit.stopId) === stringifyId(stop.id))
))
const outline = outlineById.get(stringifyId(stop?.outlineId)) const outline = outlineById.get(stringifyId(stop?.outlineId))
const hall = findHallForStop(stop) const hall = findHallForStop(stop)
const sourcePoiId = stringifyId(guide.poiId || stop?.poiId || hall?.poiId) const sourcePoiId = stringifyId(guide.poiId || stop?.poiId || hall?.poiId)
@@ -644,7 +649,12 @@ export const createGuideExplainDataAdapter = (dataset: GuideStaticExplainDataset
hallName: hall?.name || undefined, hallName: hall?.name || undefined,
floorId, floorId,
floorLabel: formatFloorLabel(floorId), floorLabel: formatFloorLabel(floorId),
image: resolveImage(stop?.coverImageUrl, guide.mediaGallery), image: resolveImage(
stop?.coverImageUrl,
linkedExhibit?.coverImageUrl,
linkedExhibit?.galleryUrls,
guide.mediaGallery
) || getExplainUnitPreviewImage(stringifyId(outline?.id)),
description: firstText(guideTextForLanguage(guide, 'zh-CN'), stop?.name), description: firstText(guideTextForLanguage(guide, 'zh-CN'), stop?.name),
poiId: location?.poiId, poiId: location?.poiId,
sourcePoiId, sourcePoiId,

View File

@@ -237,6 +237,7 @@ export interface GuideStaticExplainDataset {
halls: GuideStaticHallPayload[] halls: GuideStaticHallPayload[]
outlines: GuideStaticOutlinePayload[] outlines: GuideStaticOutlinePayload[]
guideStops: GuideStaticStopPayload[] guideStops: GuideStaticStopPayload[]
exhibits: GuideStaticExhibitPayload[]
guideContents: GuideStaticContentPayload[] guideContents: GuideStaticContentPayload[]
poiBridge: GuideStaticPoiBridgePayload poiBridge: GuideStaticPoiBridgePayload
} }
@@ -407,6 +408,7 @@ export const createStaticGuideDataProvider = (
halls: datasetCache.halls, halls: datasetCache.halls,
outlines: datasetCache.outlines, outlines: datasetCache.outlines,
guideStops: datasetCache.guideStops, guideStops: datasetCache.guideStops,
exhibits: datasetCache.exhibits,
guideContents: datasetCache.guideContents, guideContents: datasetCache.guideContents,
poiBridge: datasetCache.poiBridge poiBridge: datasetCache.poiBridge
} }
@@ -417,9 +419,11 @@ export const createStaticGuideDataProvider = (
explainDatasetRequest = provider.loadExplainNavigationDataset() explainDatasetRequest = provider.loadExplainNavigationDataset()
.then(async (navigationDataset) => { .then(async (navigationDataset) => {
const [ const [
exhibits,
guideContents, guideContents,
poiBridge poiBridge
] = await Promise.all([ ] = await Promise.all([
requestJson<GuideStaticPackagePayload<GuideStaticExhibitPayload>>(provider.assetUrl(navigationDataset.manifest.files.exhibits)),
requestJson<GuideStaticPackagePayload<GuideStaticContentPayload>>(provider.assetUrl(navigationDataset.manifest.files.guideContents)), requestJson<GuideStaticPackagePayload<GuideStaticContentPayload>>(provider.assetUrl(navigationDataset.manifest.files.guideContents)),
navigationDataset.manifest.files.poiBridge navigationDataset.manifest.files.poiBridge
? requestJson<GuideStaticPoiBridgePayload>(provider.assetUrl(navigationDataset.manifest.files.poiBridge)) ? requestJson<GuideStaticPoiBridgePayload>(provider.assetUrl(navigationDataset.manifest.files.poiBridge))
@@ -428,6 +432,7 @@ export const createStaticGuideDataProvider = (
explainDatasetCache = { explainDatasetCache = {
...navigationDataset, ...navigationDataset,
exhibits: exhibits.rows,
guideContents: guideContents.rows, guideContents: guideContents.rows,
poiBridge poiBridge
} }

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
active-tab="guide" active-tab="guide"
variant="overlay" variant="overlay"
:show-top-tabs="false" :show-top-tabs="false"
audio-player-mode="floating"
show-back show-back
@tab-change="handleTopTabChange" @tab-change="handleTopTabChange"
@back="handlePageBack" @back="handlePageBack"

View File

@@ -3,6 +3,7 @@
:active-tab="currentTab" :active-tab="currentTab"
variant="overlay" variant="overlay"
:show-top-tabs="false" :show-top-tabs="false"
:audio-player-mode="currentTab === 'guide' ? 'floating' : 'mini'"
@tab-change="handleTabChange" @tab-change="handleTabChange"
> >
<GuideMapShell <GuideMapShell

View File

@@ -3,6 +3,7 @@
active-tab="guide" active-tab="guide"
variant="overlay" variant="overlay"
:show-top-tabs="false" :show-top-tabs="false"
audio-player-mode="floating"
show-back show-back
:show-cancel="isManualLocationPanelOpen" :show-cancel="isManualLocationPanelOpen"
@tab-change="handleTopTabChange" @tab-change="handleTopTabChange"

View File

@@ -17,12 +17,17 @@
autofocus autofocus
/> />
</view> </view>
<GlobalAudioPlayerHost
display-mode="floating"
:avoid-bottom-nav="false"
/>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import GlobalAudioPlayerHost from '@/components/audio/GlobalAudioPlayerHost.vue'
import PoiSearchPanel from '@/components/search/PoiSearchPanel.vue' import PoiSearchPanel from '@/components/search/PoiSearchPanel.vue'
const initialKeyword = ref('') const initialKeyword = ref('')

View File

@@ -191,7 +191,8 @@ export class ExplainUseCase {
fallback?: MuseumExhibit | null fallback?: MuseumExhibit | null
): MuseumExhibit { ): MuseumExhibit {
const linkedPrimary = stopInfo.linkedExhibits[0] const linkedPrimary = stopInfo.linkedExhibits[0]
const coverImage = stopInfo.coverImageUrl || (stopInfo.imageStatus === 'READY' ? fallback?.image : undefined) const linkedCoverImage = stopInfo.linkedExhibits.find((item) => item.coverImageUrl)?.coverImageUrl
const coverImage = stopInfo.coverImageUrl || linkedCoverImage || fallback?.image
const description = stopInfo.description || fallback?.description || '该讲解暂无简介。' const description = stopInfo.description || fallback?.description || '该讲解暂无简介。'
const audioTarget = this.resolveStopInfoAudioTarget(stopInfo) const audioTarget = this.resolveStopInfoAudioTarget(stopInfo)
const audioAvailable = stopInfo.audioStatus === 'READY' const audioAvailable = stopInfo.audioStatus === 'READY'