This commit is contained in:
@@ -6,72 +6,38 @@
|
|||||||
@back="handleBack"
|
@back="handleBack"
|
||||||
>
|
>
|
||||||
<view class="explain-detail-page">
|
<view class="explain-detail-page">
|
||||||
|
<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>
|
||||||
|
<button
|
||||||
|
v-if="!shouldUseHostNavigation"
|
||||||
|
class="hero-back"
|
||||||
|
aria-label="返回讲解详情"
|
||||||
|
@tap.stop="handleBack"
|
||||||
|
>
|
||||||
|
<text class="hero-back-icon">‹</text>
|
||||||
|
</button>
|
||||||
|
<view class="hero-copy">
|
||||||
|
<text class="detail-title">{{ displayTitle }}</text>
|
||||||
|
<text class="detail-meta">{{ heroSubtitle }}</text>
|
||||||
|
<view class="detail-chip-row">
|
||||||
|
<view class="detail-chip shared">
|
||||||
|
<text class="detail-chip-text">标准解说</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
<scroll-view
|
<scroll-view
|
||||||
class="content"
|
class="content"
|
||||||
scroll-y
|
scroll-y
|
||||||
:show-scrollbar="false"
|
: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>
|
|
||||||
<button
|
|
||||||
v-if="!shouldUseHostNavigation"
|
|
||||||
class="hero-back"
|
|
||||||
aria-label="返回讲解详情"
|
|
||||||
@tap.stop="handleBack"
|
|
||||||
>
|
|
||||||
<text class="hero-back-icon">‹</text>
|
|
||||||
</button>
|
|
||||||
<view class="hero-copy">
|
|
||||||
<text class="detail-title">{{ displayTitle }}</text>
|
|
||||||
<text class="detail-meta">{{ heroSubtitle }}</text>
|
|
||||||
<view class="detail-chip-row">
|
|
||||||
<view class="detail-chip shared">
|
|
||||||
<text class="detail-chip-text">标准解说</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="detail-content">
|
<view class="detail-content">
|
||||||
<view v-if="isDetailAudioDockVisible" class="detail-audio-control">
|
|
||||||
<button
|
|
||||||
class="detail-audio-play"
|
|
||||||
:aria-label="detailAudioActionLabel"
|
|
||||||
@tap="handlePlayAudio"
|
|
||||||
>
|
|
||||||
<svg v-if="detailAudioPlaying" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
||||||
<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="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
||||||
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<view class="detail-audio-main">
|
|
||||||
<text class="detail-audio-title">{{ audioDockTitle }}</text>
|
|
||||||
<text class="detail-audio-subtitle">{{ audioDockSubtitle }}</text>
|
|
||||||
<view class="detail-audio-progress" aria-hidden="true">
|
|
||||||
<view
|
|
||||||
class="detail-audio-progress-fill"
|
|
||||||
:style="{ width: `${detailAudioProgressPercent}%` }"
|
|
||||||
></view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="detail-audio-state" aria-hidden="true">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M4 10V14H8L13 18V6L8 10H4Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
|
|
||||||
<path d="M16 9C17.2 10.2 17.2 13.8 16 15" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<text v-else class="detail-audio-unavailable">{{ audioAvailabilityMessage }}</text>
|
|
||||||
|
|
||||||
<view v-if="contentLanguageOptions.length" class="language-switch" role="tablist" aria-label="讲解语言">
|
<view v-if="contentLanguageOptions.length" class="language-switch" role="tablist" aria-label="讲解语言">
|
||||||
<button
|
<button
|
||||||
v-for="option in contentLanguageOptions"
|
v-for="option in contentLanguageOptions"
|
||||||
@@ -110,6 +76,49 @@
|
|||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
|
<view class="detail-audio-dock" :class="`is-${audioDockState}`">
|
||||||
|
<view class="detail-audio-dock-main">
|
||||||
|
<button
|
||||||
|
class="detail-audio-play"
|
||||||
|
:class="{ disabled: audioDockState === 'unavailable' }"
|
||||||
|
:disabled="audioDockState === 'unavailable'"
|
||||||
|
:aria-label="detailAudioActionLabel"
|
||||||
|
@tap="handlePlayAudio"
|
||||||
|
>
|
||||||
|
<view v-if="audioDockState === 'loading'" class="audio-loading-spinner" aria-label="音频加载中"></view>
|
||||||
|
<svg v-else-if="detailAudioPlaying" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<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="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<view class="detail-audio-main">
|
||||||
|
<text class="detail-audio-title">{{ audioDockTitle }}</text>
|
||||||
|
<text class="detail-audio-subtitle">{{ audioDockSubtitle }}</text>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
v-if="audioDockState === 'failed'"
|
||||||
|
class="detail-audio-retry"
|
||||||
|
aria-label="重试播放音频"
|
||||||
|
@tap="handleRetryAudio"
|
||||||
|
>重试</button>
|
||||||
|
</view>
|
||||||
|
<view v-if="audioDockState === 'loading'" class="detail-audio-progress is-indeterminate" aria-label="音频加载中"></view>
|
||||||
|
<view
|
||||||
|
v-else
|
||||||
|
class="detail-audio-progress-hit"
|
||||||
|
:class="{ disabled: audioDockState !== 'playable' }"
|
||||||
|
@tap="handleSeekAudio"
|
||||||
|
>
|
||||||
|
<view class="detail-audio-progress" aria-hidden="true">
|
||||||
|
<view class="detail-audio-progress-fill" :style="{ width: `${detailAudioProgressPercent}%` }"></view>
|
||||||
|
<view class="detail-audio-progress-thumb" :style="{ left: `${detailAudioProgressPercent}%` }"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view v-if="isLocationActionVisible" class="action-bar">
|
<view v-if="isLocationActionVisible" class="action-bar">
|
||||||
<view
|
<view
|
||||||
class="location-btn"
|
class="location-btn"
|
||||||
@@ -275,9 +284,6 @@ const isCurrentDetailAudioTarget = computed(() => {
|
|||||||
&& source.targetId === targetId
|
&& source.targetId === targetId
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
const isDetailAudioDockVisible = computed(() => (
|
|
||||||
!languageSwitchLoading.value && exhibit.value.audio.status === 'playable'
|
|
||||||
))
|
|
||||||
const audioDockTitle = computed(() => displayTitle.value || '讲解内容')
|
const audioDockTitle = computed(() => displayTitle.value || '讲解内容')
|
||||||
const formatDetailAudioTime = (seconds?: number) => {
|
const formatDetailAudioTime = (seconds?: number) => {
|
||||||
const normalizedSeconds = Number.isFinite(seconds || 0)
|
const normalizedSeconds = Number.isFinite(seconds || 0)
|
||||||
@@ -304,7 +310,13 @@ const detailAudioTimeLabel = computed(() => (
|
|||||||
))
|
))
|
||||||
const audioDockSubtitle = computed(() => [
|
const audioDockSubtitle = computed(() => [
|
||||||
audioLanguageLabel(selectedAudioLanguage.value),
|
audioLanguageLabel(selectedAudioLanguage.value),
|
||||||
detailAudioTimeLabel.value
|
audioDockState.value === 'failed'
|
||||||
|
? globalAudioPlayer.error.value || '音频暂时无法播放'
|
||||||
|
: audioDockState.value === 'unavailable'
|
||||||
|
? audioAvailabilityMessage.value
|
||||||
|
: audioDockState.value === 'loading'
|
||||||
|
? '正在加载音频'
|
||||||
|
: detailAudioTimeLabel.value
|
||||||
].filter(Boolean).join(' · ') || '讲解')
|
].filter(Boolean).join(' · ') || '讲解')
|
||||||
const detailAudioPlaying = computed(() => (
|
const detailAudioPlaying = computed(() => (
|
||||||
isCurrentDetailAudio.value
|
isCurrentDetailAudio.value
|
||||||
@@ -321,6 +333,25 @@ const detailAudioProgressPercent = computed(() => {
|
|||||||
|
|
||||||
return Math.max(0, Math.min(100, (detailAudioCurrentSeconds.value / total) * 100))
|
return Math.max(0, Math.min(100, (detailAudioCurrentSeconds.value / total) * 100))
|
||||||
})
|
})
|
||||||
|
const isCurrentDetailAudioError = computed(() => {
|
||||||
|
const source = globalAudioPlayer.currentSource.value
|
||||||
|
return Boolean(
|
||||||
|
globalAudioPlayer.error.value
|
||||||
|
&& source?.targetType === currentAudioTarget.value.targetType
|
||||||
|
&& source.targetId === currentAudioTarget.value.targetId
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const isDetailAudioLoading = computed(() => (
|
||||||
|
languageSwitchLoading.value
|
||||||
|
|| retryingAudio.value
|
||||||
|
|| (isCurrentDetailAudioTarget.value && globalAudioPlayer.loading.value)
|
||||||
|
))
|
||||||
|
const audioDockState = computed<'playable' | 'loading' | 'unavailable' | 'failed'>(() => {
|
||||||
|
if (isDetailAudioLoading.value) return 'loading'
|
||||||
|
if (isCurrentDetailAudioError.value) return 'failed'
|
||||||
|
if (exhibit.value.audio.status !== 'playable') return 'unavailable'
|
||||||
|
return 'playable'
|
||||||
|
})
|
||||||
// 讲解详情页只定位到所属展厅,不再追踪具体展品点位。
|
// 讲解详情页只定位到所属展厅,不再追踪具体展品点位。
|
||||||
const resolveHallGuidePoi = async () => {
|
const resolveHallGuidePoi = async () => {
|
||||||
return guideUseCase.resolveContentLocationPreviewTarget({
|
return guideUseCase.resolveContentLocationPreviewTarget({
|
||||||
@@ -693,8 +724,25 @@ const refreshCurrentAudioOnce = async (message: string) => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePlayAudio = async () => {
|
const playDetailAudio = async (audio: AudioItem) => {
|
||||||
if (isCurrentDetailAudio.value) {
|
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: 'mini'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePlayAudio = async (options: { forceRefresh?: boolean } = {}) => {
|
||||||
|
if (audioDockState.value === 'unavailable') return
|
||||||
|
|
||||||
|
if (isCurrentDetailAudio.value && !options.forceRefresh) {
|
||||||
if (globalAudioPlayer.playing.value) {
|
if (globalAudioPlayer.playing.value) {
|
||||||
globalAudioPlayer.pause()
|
globalAudioPlayer.pause()
|
||||||
return
|
return
|
||||||
@@ -704,6 +752,14 @@ const handlePlayAudio = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refreshedAudio = options.forceRefresh
|
||||||
|
? await refreshCurrentAudioOnce(globalAudioPlayer.error.value)
|
||||||
|
: null
|
||||||
|
if (refreshedAudio) {
|
||||||
|
await playDetailAudio(refreshedAudio)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const selection = exhibit.value.id
|
const selection = exhibit.value.id
|
||||||
? await explainUseCase.selectAudioForExplainDetail({
|
? await explainUseCase.selectAudioForExplainDetail({
|
||||||
id: exhibit.value.id,
|
id: exhibit.value.id,
|
||||||
@@ -732,18 +788,20 @@ const handlePlayAudio = async () => {
|
|||||||
const audio = toAudioItem(selection)
|
const audio = toAudioItem(selection)
|
||||||
if (!audio) return
|
if (!audio) return
|
||||||
|
|
||||||
await globalAudioPlayer.play(audio, {
|
await playDetailAudio(audio)
|
||||||
source: {
|
}
|
||||||
exhibitId: exhibit.value.id,
|
|
||||||
targetType: currentAudioTarget.value.targetType,
|
const handleRetryAudio = async () => {
|
||||||
targetId: currentAudioTarget.value.targetId,
|
await handlePlayAudio({ forceRefresh: true })
|
||||||
lang: selectedAudioLanguage.value,
|
}
|
||||||
title: audio.name,
|
|
||||||
detailRoute: buildDetailRoute()
|
const handleSeekAudio = (event: { detail?: { x?: number }; currentTarget?: { offsetWidth?: number } }) => {
|
||||||
},
|
if (audioDockState.value !== 'playable' || !detailAudioDurationSeconds.value) return
|
||||||
retryOnError: refreshCurrentAudioOnce,
|
|
||||||
displayMode: 'mini'
|
const width = event.currentTarget?.offsetWidth || 0
|
||||||
})
|
const x = event.detail?.x
|
||||||
|
if (!width || typeof x !== 'number') return
|
||||||
|
globalAudioPlayer.seekToPercent(Math.max(0, Math.min(100, x / width * 100)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNavigate = async () => {
|
const handleNavigate = async () => {
|
||||||
@@ -790,19 +848,6 @@ const fallbackToExplainHome = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleBack = () => {
|
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({
|
uni.navigateBack({
|
||||||
delta: 1,
|
delta: 1,
|
||||||
fail: fallbackToExplainHome
|
fail: fallbackToExplainHome
|
||||||
@@ -820,7 +865,12 @@ const handleBack = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
height: 100%;
|
position: absolute;
|
||||||
|
top: 320px;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
height: auto;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: #f7f8f3;
|
background: #f7f8f3;
|
||||||
@@ -829,8 +879,8 @@ const handleBack = () => {
|
|||||||
.immersive-hero {
|
.immersive-hero {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: clamp(238px, 30vh, 286px);
|
height: 320px;
|
||||||
padding: calc(env(safe-area-inset-top) + 14px) 20px 20px;
|
padding: calc(env(safe-area-inset-top) + 0px) 24px 24px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #111a14;
|
background: #111a14;
|
||||||
@@ -847,7 +897,7 @@ const handleBack = () => {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background: rgba(8, 14, 10, 0.44);
|
background: linear-gradient(180deg, rgba(0, 0, 0, 0.08) 42%, rgba(0, 0, 0, 0.58) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-real-image {
|
.hero-real-image {
|
||||||
@@ -870,7 +920,7 @@ const handleBack = () => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(7, 12, 8, 0.42);
|
background: transparent;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -889,8 +939,8 @@ const handleBack = () => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: calc(100% - 44px);
|
height: calc(100% - 44px - env(safe-area-inset-top));
|
||||||
padding-top: 12px;
|
padding-top: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -899,8 +949,8 @@ const handleBack = () => {
|
|||||||
.detail-title {
|
.detail-title {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
font-size: 26px;
|
font-size: 28px;
|
||||||
line-height: 32px;
|
line-height: 34px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -920,50 +970,15 @@ const handleBack = () => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-chip-row {
|
.detail-chip-row { display: none; }
|
||||||
margin-top: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-chip {
|
|
||||||
height: 24px;
|
|
||||||
padding: 0 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-chip.shared {
|
|
||||||
background: rgba(223, 225, 0, 0.15);
|
|
||||||
border-color: rgba(223, 225, 0, 0.62);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-chip-text,
|
|
||||||
.detail-chip.shared .detail-chip-text {
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #f0f19b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-content {
|
.detail-content {
|
||||||
min-height: calc(70vh - env(safe-area-inset-top));
|
min-height: calc(100vh - 320px - 72px - env(safe-area-inset-bottom));
|
||||||
padding: 16px 20px calc(28px + env(safe-area-inset-bottom));
|
padding: 25px 24px calc(32px + 72px + env(safe-area-inset-bottom));
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: #f7f8f3;
|
background: #f7f8f3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-audio-control {
|
|
||||||
min-height: 82px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border-bottom: 1px solid #d8ddd2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-audio-play {
|
.detail-audio-play {
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
@@ -974,8 +989,8 @@ const handleBack = () => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #e0e100;
|
background: #e8e800;
|
||||||
color: #1a2017;
|
color: #141412;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-audio-play::after,
|
.detail-audio-play::after,
|
||||||
@@ -983,73 +998,9 @@ const handleBack = () => {
|
|||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-audio-main {
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-audio-title {
|
|
||||||
display: block;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 21px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #20241e;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-audio-subtitle {
|
|
||||||
display: block;
|
|
||||||
margin-top: 2px;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 18px;
|
|
||||||
color: #62695f;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-audio-progress {
|
|
||||||
height: 3px;
|
|
||||||
margin-top: 9px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #d6dbd1;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-audio-progress-fill {
|
|
||||||
width: 0;
|
|
||||||
height: 100%;
|
|
||||||
background: #b9bb00;
|
|
||||||
border-radius: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-audio-state {
|
|
||||||
width: 28px;
|
|
||||||
height: 44px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: #4f584c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-audio-unavailable {
|
|
||||||
min-height: 82px;
|
|
||||||
padding: 28px 0 18px;
|
|
||||||
display: block;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border-bottom: 1px solid #d8ddd2;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 20px;
|
|
||||||
color: #7a5a22;
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-switch {
|
.language-switch {
|
||||||
height: 44px;
|
height: 40px;
|
||||||
margin-top: 14px;
|
margin-top: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -1062,7 +1013,7 @@ const handleBack = () => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 42px;
|
height: 40px;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1079,18 +1030,18 @@ const handleBack = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.language-option.active {
|
.language-option.active {
|
||||||
background: #eef0e8;
|
background: #e8e800;
|
||||||
color: #1e251b;
|
color: #141412;
|
||||||
}
|
}
|
||||||
|
|
||||||
.language-option.active::before {
|
.language-option.active::before {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
bottom: 5px;
|
bottom: 1px;
|
||||||
left: 10px;
|
left: 10px;
|
||||||
height: 2px;
|
height: 3px;
|
||||||
content: '';
|
content: '';
|
||||||
background: #b9bb00;
|
background: #141412;
|
||||||
}
|
}
|
||||||
|
|
||||||
.language-option.disabled {
|
.language-option.disabled {
|
||||||
@@ -1159,6 +1110,142 @@ const handleBack = () => {
|
|||||||
color: #7a5a22;
|
color: #7a5a22;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-audio-dock {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 10;
|
||||||
|
height: calc(72px + env(safe-area-inset-bottom));
|
||||||
|
padding: 0 24px env(safe-area-inset-bottom);
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #e8e5de;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-audio-dock-main {
|
||||||
|
height: 50px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-audio-main {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-audio-title,
|
||||||
|
.detail-audio-subtitle {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-audio-title {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #141412;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-audio-subtitle {
|
||||||
|
margin-top: 1px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
color: #666661;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-audio-play.disabled {
|
||||||
|
background: #e8e5de;
|
||||||
|
color: #94948f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-audio-retry {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #e8e800;
|
||||||
|
color: #141412;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-audio-retry::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-audio-progress-hit {
|
||||||
|
height: 22px;
|
||||||
|
margin: 0 0 0 58px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-audio-progress-hit.disabled {
|
||||||
|
opacity: 0.42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-audio-progress {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
overflow: visible;
|
||||||
|
background: #94948f;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-audio-progress-fill {
|
||||||
|
width: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: #e0e100;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-audio-progress-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #141412;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-audio-progress.is-indeterminate {
|
||||||
|
width: calc(100% - 58px);
|
||||||
|
height: 4px;
|
||||||
|
margin: 8px 0 0 58px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #deded6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-audio-progress.is-indeterminate::after {
|
||||||
|
display: block;
|
||||||
|
width: 38%;
|
||||||
|
height: 100%;
|
||||||
|
content: '';
|
||||||
|
background: #e0e100;
|
||||||
|
animation: audio-indeterminate 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-loading-spinner {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 2px solid rgba(20, 20, 18, 0.28);
|
||||||
|
border-top-color: #141412;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: audio-spinner 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes audio-spinner { to { transform: rotate(360deg); } }
|
||||||
|
@keyframes audio-indeterminate { from { transform: translateX(-110%); } to { transform: translateX(290%); } }
|
||||||
|
|
||||||
@media (min-width: 420px) {
|
@media (min-width: 420px) {
|
||||||
.immersive-hero {
|
.immersive-hero {
|
||||||
padding-right: 24px;
|
padding-right: 24px;
|
||||||
@@ -1181,5 +1268,10 @@ const handleBack = () => {
|
|||||||
padding-right: 16px;
|
padding-right: 16px;
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-audio-dock {
|
||||||
|
padding-right: 16px;
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -12,13 +12,16 @@ const mocks = vi.hoisted(() => ({
|
|||||||
play: vi.fn(),
|
play: vi.fn(),
|
||||||
pause: vi.fn(),
|
pause: vi.fn(),
|
||||||
resume: vi.fn(),
|
resume: vi.fn(),
|
||||||
close: vi.fn()
|
close: vi.fn(),
|
||||||
|
seekToPercent: vi.fn()
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const audioState = vi.hoisted(() => ({
|
const audioState = vi.hoisted(() => ({
|
||||||
currentAudio: { value: null as any },
|
currentAudio: { value: null as any },
|
||||||
currentSource: { value: null as any },
|
currentSource: { value: null as any },
|
||||||
playing: { value: false },
|
playing: { value: false },
|
||||||
|
loading: { value: false },
|
||||||
|
error: { value: '' },
|
||||||
currentTime: { value: 0 },
|
currentTime: { value: 0 },
|
||||||
duration: { value: 0 }
|
duration: { value: 0 }
|
||||||
}))
|
}))
|
||||||
@@ -52,7 +55,8 @@ vi.mock('@/composables/useGlobalAudioPlayer', () => ({
|
|||||||
pause: mocks.pause,
|
pause: mocks.pause,
|
||||||
resume: mocks.resume,
|
resume: mocks.resume,
|
||||||
switchLanguage: vi.fn(),
|
switchLanguage: vi.fn(),
|
||||||
close: mocks.close
|
close: mocks.close,
|
||||||
|
seekToPercent: mocks.seekToPercent
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -88,6 +92,13 @@ const exhibit = {
|
|||||||
describe('讲解详情音频优先布局', () => {
|
describe('讲解详情音频优先布局', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.onLoadHandler = null
|
mocks.onLoadHandler = null
|
||||||
|
audioState.currentAudio.value = null
|
||||||
|
audioState.currentSource.value = null
|
||||||
|
audioState.playing.value = false
|
||||||
|
audioState.loading.value = false
|
||||||
|
audioState.error.value = ''
|
||||||
|
audioState.currentTime.value = 0
|
||||||
|
audioState.duration.value = 0
|
||||||
mocks.enterExplainDetail.mockResolvedValue(exhibit)
|
mocks.enterExplainDetail.mockResolvedValue(exhibit)
|
||||||
mocks.loadExplainDetailText.mockResolvedValue({ available: false })
|
mocks.loadExplainDetailText.mockResolvedValue({ available: false })
|
||||||
mocks.selectAudioForExplainDetail.mockResolvedValue({
|
mocks.selectAudioForExplainDetail.mockResolvedValue({
|
||||||
@@ -102,7 +113,12 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
playInfo: { title: exhibit.name }
|
playInfo: { title: exhibit.name }
|
||||||
})
|
})
|
||||||
mocks.play.mockResolvedValue(undefined)
|
mocks.play.mockResolvedValue(undefined)
|
||||||
vi.stubGlobal('uni', { showToast: vi.fn(), navigateBack: vi.fn() })
|
vi.stubGlobal('uni', {
|
||||||
|
showToast: vi.fn(),
|
||||||
|
navigateBack: vi.fn(),
|
||||||
|
redirectTo: vi.fn(),
|
||||||
|
reLaunch: vi.fn()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -110,7 +126,7 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('将展品身份、流内播放器、语言栏和英文正文按顺序渲染', async () => {
|
it('正常状态渲染固定底部播放器、语言栏和英文正文', async () => {
|
||||||
const wrapper = mount(ExhibitDetail, {
|
const wrapper = mount(ExhibitDetail, {
|
||||||
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
||||||
})
|
})
|
||||||
@@ -122,8 +138,8 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
expect(wrapper.get('.detail-audio-title').text()).toBe('Classical Astrolabe')
|
expect(wrapper.get('.detail-audio-title').text()).toBe('Classical Astrolabe')
|
||||||
expect(wrapper.get('.detail-meta').text()).toContain('Universe Hall · B2')
|
expect(wrapper.get('.detail-meta').text()).toContain('Universe Hall · B2')
|
||||||
expect(wrapper.get('.detail-chip').text()).toBe('标准解说')
|
expect(wrapper.get('.detail-chip').text()).toBe('标准解说')
|
||||||
expect(wrapper.find('.detail-audio-control').exists()).toBe(true)
|
expect(wrapper.find('.detail-audio-dock').exists()).toBe(true)
|
||||||
expect(wrapper.find('.detail-audio-dock').exists()).toBe(false)
|
expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-playable')
|
||||||
expect(wrapper.find('.language-switch').text()).toContain('English')
|
expect(wrapper.find('.language-switch').text()).toContain('English')
|
||||||
expect(wrapper.get('.content-section').classes()).toContain('is-english')
|
expect(wrapper.get('.content-section').classes()).toContain('is-english')
|
||||||
expect(wrapper.get('.section-title').text()).toBe('Transcript')
|
expect(wrapper.get('.section-title').text()).toBe('Transcript')
|
||||||
@@ -145,7 +161,23 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
}), expect.objectContaining({ displayMode: 'mini' }))
|
}), expect.objectContaining({ displayMode: 'mini' }))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('音频不可用时只在音频控制区显示一次提示', async () => {
|
it('加载中使用同一高度底栏和不确定进度', async () => {
|
||||||
|
audioState.currentAudio.value = { id: 'audio-1' }
|
||||||
|
audioState.currentSource.value = { targetType: 'STOP', targetId: 'stop-1', lang: 'en-US' }
|
||||||
|
audioState.loading.value = true
|
||||||
|
const wrapper = mount(ExhibitDetail, {
|
||||||
|
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
||||||
|
})
|
||||||
|
|
||||||
|
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-loading')
|
||||||
|
expect(wrapper.find('.audio-loading-spinner').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('.detail-audio-progress.is-indeterminate').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无音频时禁用播放按钮并显示原因', async () => {
|
||||||
mocks.enterExplainDetail.mockResolvedValue({
|
mocks.enterExplainDetail.mockResolvedValue({
|
||||||
...exhibit,
|
...exhibit,
|
||||||
audioUrl: undefined,
|
audioUrl: undefined,
|
||||||
@@ -162,8 +194,47 @@ describe('讲解详情音频优先布局', () => {
|
|||||||
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'yue-HK', targetType: 'STOP', targetId: 'stop-1' })
|
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'yue-HK', targetType: 'STOP', targetId: 'stop-1' })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('.detail-audio-unavailable').text()).toBe('当前语言暂无语音讲解。')
|
expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-unavailable')
|
||||||
expect(wrapper.find('.audio-status-hint').exists()).toBe(false)
|
expect(wrapper.get('.detail-audio-play').attributes('disabled')).toBeDefined()
|
||||||
expect(wrapper.findAll('.section-hint')).toHaveLength(0)
|
expect(wrapper.get('.detail-audio-subtitle').text()).toContain('当前语言暂无语音讲解。')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('播放失败显示错误并通过既有刷新链路重试', async () => {
|
||||||
|
audioState.currentAudio.value = { id: 'audio-1', audioUrl: exhibit.audioUrl }
|
||||||
|
audioState.currentSource.value = { targetType: 'STOP', targetId: 'stop-1', lang: 'en-US' }
|
||||||
|
audioState.error.value = '音频暂时无法播放'
|
||||||
|
mocks.selectAudioForExplainDetail.mockResolvedValue({
|
||||||
|
playable: true,
|
||||||
|
exhibit,
|
||||||
|
media: { id: 'audio-refresh', url: exhibit.audioUrl, duration: exhibit.audioDuration, language: 'en-US' },
|
||||||
|
playInfo: { title: exhibit.name }
|
||||||
|
})
|
||||||
|
const wrapper = mount(ExhibitDetail, {
|
||||||
|
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
||||||
|
})
|
||||||
|
|
||||||
|
await mocks.onLoadHandler?.({ id: exhibit.id, lang: 'en-US', targetType: 'STOP', targetId: 'stop-1' })
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.get('.detail-audio-dock').classes()).toContain('is-failed')
|
||||||
|
expect(wrapper.get('.detail-audio-retry').text()).toBe('重试')
|
||||||
|
|
||||||
|
await wrapper.get('.detail-audio-retry').trigger('tap')
|
||||||
|
await flushPromises()
|
||||||
|
expect(mocks.selectAudioForExplainDetail).toHaveBeenCalledWith(expect.any(Object), { refreshPlayInfo: true })
|
||||||
|
expect(mocks.play).toHaveBeenCalledWith(expect.objectContaining({ id: 'audio-refresh' }), expect.objectContaining({
|
||||||
|
retryOnError: expect.any(Function),
|
||||||
|
displayMode: 'mini'
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('顶部返回统一使用页面栈,以便导览首页 onShow 消费复位上下文', async () => {
|
||||||
|
const wrapper = mount(ExhibitDetail, {
|
||||||
|
global: { stubs: { GuidePageFrame: GuidePageFrameStub } }
|
||||||
|
})
|
||||||
|
|
||||||
|
wrapper.getComponent(GuidePageFrameStub).vm.$emit('back')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(uni.navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 }))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user