Files
frontend-miniapp/src/pages/exhibit/detail.vue
lyf 192641568d
Some checks failed
CI / verify (push) Has been cancelled
移除讲解详情关联展品内容
2026-07-09 20:06:02 +08:00

1282 lines
30 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"
:audio-player-avoid-bottom-nav="false"
:audio-player-closable="false"
:show-audio-player-host="true"
audio-player-mode="expanded"
@back="handleBack"
>
<view class="explain-detail-page" :class="{ 'with-audio-player': showAudioPlayer }">
<scroll-view
class="content"
:class="{ 'with-fixed-player': isDetailAudioDockVisible || showAudioPlayer }"
scroll-y
: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>
<view class="hero-back" @tap.stop="handleBack">
<text class="hero-back-icon"></text>
</view>
<view class="hero-copy">
<text class="detail-title">{{ exhibit.title }}</text>
<text class="detail-meta">{{ heroSubtitle }}</text>
<view class="language-anchor" v-if="contentLanguageOptions.length">
<view
v-for="option in contentLanguageOptions"
:key="option.value"
class="language-anchor-option"
:class="{ active: selectedAudioLanguage === option.value }"
@tap="handleLanguageChange(option.value)"
>
<text class="language-anchor-text">{{ option.label }}</text>
</view>
</view>
</view>
</view>
<view class="content-panel">
<view class="content-section">
<text class="section-text">{{ currentDetailText }}</text>
<text v-if="currentDetailTextLoading" class="section-hint">正在加载讲解正文</text>
</view>
</view>
</scroll-view>
<view
v-if="isDetailAudioDockVisible"
class="detail-audio-dock"
>
<view class="detail-audio-play" @tap="handlePlayAudio">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none">
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
</svg>
</view>
<view class="detail-audio-main" @tap="handlePlayAudio">
<text class="detail-audio-title">{{ audioDockTitle }}</text>
<text class="detail-audio-subtitle">{{ audioDockSubtitle }}</text>
<view class="detail-audio-progress">
<view class="detail-audio-progress-fill"></view>
</view>
</view>
</view>
<view v-if="isLocationActionVisible" 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>
</view>
</GuidePageFrame>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import type { AudioItem } from '@/components/audio/AudioPlayer.vue'
import { useGlobalAudioPlayer } from '@/composables/useGlobalAudioPlayer'
import {
explainUseCase
} from '@/usecases/explainUseCase'
import {
guideUseCase
} from '@/usecases/guideUseCase'
import {
toExplainDetailPageViewModel,
type ExplainDetailPageViewModel
} from '@/view-models/explainViewModels'
import type {
AudioPlayTargetType
} from '@/domain/museum'
import type {
AudioLanguage
} from '@/repositories/AudioPlayInfoRepository'
import {
isGuideTopTab,
type GuideTopTab
} from '@/utils/guideTopTabs'
import {
EXPLAIN_DETAIL_PLACEHOLDER_IMAGE
} from '@/utils/placeholders'
const defaultDetail: ExplainDetailPageViewModel = {
id: '',
title: '讲解内容',
subtitle: '位置待补充',
contentType: 'exhibit',
coverImages: [],
summary: '该讲解内容待补充。',
body: '该讲解内容待补充。',
audio: {
status: 'unavailable',
unavailableReason: '当前语言暂无语音讲解。'
},
chapters: [],
relatedItems: []
}
const exhibit = ref<ExplainDetailPageViewModel>(defaultDetail)
const globalAudioPlayer = useGlobalAudioPlayer()
const activeTopTab = ref<GuideTopTab>('explain')
const resolvedHallId = ref('')
const retryingAudio = ref(false)
const selectedAudioLanguage = ref<AudioLanguage>('zh-CN')
const detailEntryRequest = ref<{
exhibitId: string
targetType?: AudioPlayTargetType
targetId?: string
} | null>(null)
const detailTextByLanguage = ref<Record<AudioLanguage, string>>({
'zh-CN': defaultDetail.body,
'en-US': 'English narration text is not available yet.'
})
const detailTextLoadingByLanguage = ref<Record<AudioLanguage, boolean>>({
'zh-CN': false,
'en-US': false
})
const detailTextLoadedByLanguage = ref<Record<AudioLanguage, boolean>>({
'zh-CN': false,
'en-US': false
})
// 讲解详情页位置入口暂未开放,避免误导用户进入位置预览流程。
const isLocationActionVisible = false
const contentLanguageOptions: Array<{
value: AudioLanguage
label: string
}> = [
{
value: 'zh-CN',
label: '中文'
},
{
value: 'en-US',
label: 'English'
}
]
const isAudioLanguage = (value: unknown): value is AudioLanguage => (
value === 'zh-CN' || value === 'en-US'
)
const isRealHeroImage = (image?: string) => (
Boolean(image && image !== EXPLAIN_DETAIL_PLACEHOLDER_IMAGE)
)
const heroImage = computed(() => exhibit.value.coverImages.find(isRealHeroImage))
const detailMeta = computed(() => [
exhibit.value.hallName,
exhibit.value.floorLabel
].filter(Boolean).join(' · '))
const hallLocationId = computed(() => exhibit.value.hallId || resolvedHallId.value)
const heroSubtitle = computed(() => [
detailMeta.value
].filter(Boolean).join(' · '))
const currentAudioTarget = computed(() => ({
targetType: exhibit.value.audio.playTargetType || 'ITEM',
targetId: exhibit.value.audio.playTargetId || exhibit.value.id,
lang: selectedAudioLanguage.value
}))
const isCurrentDetailAudio = computed(() => globalAudioPlayer.isCurrentSource(currentAudioTarget.value))
const showAudioPlayer = computed(() => (
globalAudioPlayer.visible.value && globalAudioPlayer.hasAudio.value
))
const isDetailAudioDockVisible = computed(() => (
exhibit.value.audio.status === 'playable'
&& !isCurrentDetailAudio.value
))
const audioDockTitle = computed(() => (
selectedAudioLanguage.value === 'en-US' ? 'English audio' : '中文讲解音频'
))
const audioDockSubtitle = computed(() => [
exhibit.value.title,
exhibit.value.audio.durationLabel
].filter(Boolean).join(' · ') || '讲解音频')
const isCurrentDetailAudioTarget = computed(() => {
const source = globalAudioPlayer.currentSource.value
const targetType = exhibit.value.audio.playTargetType || 'ITEM'
const targetId = exhibit.value.audio.playTargetId || exhibit.value.id
return Boolean(
globalAudioPlayer.currentAudio.value
&& source?.targetType === targetType
&& source.targetId === targetId
)
})
// 讲解详情页只定位到所属展厅,不再追踪具体展品点位。
const resolveHallGuidePoi = async () => {
return guideUseCase.resolveContentLocationPreviewTarget({
directPoiId: exhibit.value.location?.poiId,
location: exhibit.value.location,
hallId: exhibit.value.hallId,
hallName: exhibit.value.hallName,
targetName: exhibit.value.title
})
}
const fallbackTextForLanguage = (lang: AudioLanguage) => (
lang === 'en-US'
? 'English narration text is not available yet.'
: '当前语言暂无讲解词。'
)
const applyDetailText = (lang: AudioLanguage, text?: string) => {
const normalized = (text || '').trim()
detailTextByLanguage.value = {
...detailTextByLanguage.value,
[lang]: normalized || fallbackTextForLanguage(lang)
}
detailTextLoadedByLanguage.value = {
...detailTextLoadedByLanguage.value,
[lang]: true
}
}
const buildTextSourceFromViewModel = (viewModel: ExplainDetailPageViewModel) => ({
id: viewModel.id,
name: viewModel.title,
hallId: viewModel.hallId,
hallName: viewModel.hallName,
floorId: viewModel.floorId,
floorLabel: viewModel.floorLabel,
image: viewModel.coverImages[0],
description: viewModel.summary,
guideText: viewModel.summary,
audioLanguage: viewModel.audio.language,
audioHasText: viewModel.audio.hasText,
playTargetType: viewModel.audio.playTargetType,
playTargetId: viewModel.audio.playTargetId
})
const loadFullDetailTextForLanguage = async (
request: NonNullable<typeof detailEntryRequest.value>,
lang: AudioLanguage,
preferredViewModel?: ExplainDetailPageViewModel
) => {
detailTextLoadingByLanguage.value = {
...detailTextLoadingByLanguage.value,
[lang]: true
}
try {
const viewModel = preferredViewModel || toExplainDetailPageViewModel(await explainUseCase.enterExplainDetail({
...request,
lang
}))
if (viewModel.audio.hasText) {
const selection = await explainUseCase.loadExplainDetailText(buildTextSourceFromViewModel(viewModel))
if (selection.available) {
const nextViewModel = toExplainDetailPageViewModel(selection.exhibit)
applyDetailText(lang, nextViewModel.body || nextViewModel.summary)
return
}
}
applyDetailText(lang, viewModel.body || viewModel.summary)
} catch (error) {
console.warn('讲解正文加载失败:', lang, error)
applyDetailText(lang)
} finally {
detailTextLoadingByLanguage.value = {
...detailTextLoadingByLanguage.value,
[lang]: false
}
}
}
const loadBilingualDetailText = async (
request: NonNullable<typeof detailEntryRequest.value>,
currentLang: AudioLanguage,
currentViewModel: ExplainDetailPageViewModel
) => {
await loadFullDetailTextForLanguage(request, currentLang, currentViewModel)
const nextLang: AudioLanguage = currentLang === 'zh-CN' ? 'en-US' : 'zh-CN'
void loadFullDetailTextForLanguage(request, nextLang)
}
const ensureDetailTextForLanguage = async (lang: AudioLanguage) => {
if (detailTextLoadedByLanguage.value[lang] || detailTextLoadingByLanguage.value[lang]) return
if (!detailEntryRequest.value) {
applyDetailText(lang)
return
}
await loadFullDetailTextForLanguage(detailEntryRequest.value, lang)
}
const loadExplainDetail = async (
request: NonNullable<typeof detailEntryRequest.value>,
lang: AudioLanguage
) => {
const exhibitData = await explainUseCase.enterExplainDetail({
...request,
lang
})
exhibit.value = toExplainDetailPageViewModel(exhibitData)
selectedAudioLanguage.value = lang
if (exhibitData.hallId) {
resolvedHallId.value = exhibitData.hallId
}
void loadBilingualDetailText(request, lang, exhibit.value)
}
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 rawTargetType = Array.isArray(options.targetType) ? options.targetType[0] : options.targetType
const targetType: AudioPlayTargetType | undefined = rawTargetType === 'ITEM'
? 'ITEM'
: rawTargetType === 'STOP' || rawTargetType === 'GUIDE_STOP'
? 'STOP'
: undefined
const rawTargetId = Array.isArray(options.targetId) ? options.targetId[0] : options.targetId
const targetId = rawTargetType === 'GUIDE_STOP'
? String(exhibitId)
: rawTargetId ? String(rawTargetId) : undefined
const rawLang = Array.isArray(options.lang) ? options.lang[0] : options.lang
const lang = isAudioLanguage(rawLang) ? rawLang : 'zh-CN'
try {
detailEntryRequest.value = {
exhibitId,
targetType,
targetId
}
await loadExplainDetail(detailEntryRequest.value, lang)
} catch (error) {
console.error('讲解详情加载失败:', error)
uni.showToast({
title: '讲解详情加载失败,请稍后重试',
icon: 'none'
})
}
})
onUnload(() => {
globalAudioPlayer.close()
})
const detailTextFor = (lang: AudioLanguage) => {
return detailTextByLanguage.value[lang] || fallbackTextForLanguage(lang)
}
const currentDetailText = computed(() => detailTextFor(selectedAudioLanguage.value))
const currentDetailTextLoading = computed(() => detailTextLoadingByLanguage.value[selectedAudioLanguage.value])
const replaceDetailRouteLanguage = (lang: AudioLanguage) => {
if (typeof window === 'undefined') return
const nextHash = `#${buildDetailRoute(lang)}`
if (window.location.hash === nextHash) return
window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}${nextHash}`)
}
const handleLanguageChange = async (lang: AudioLanguage, options: { syncAudio?: boolean } = {}) => {
if (lang === selectedAudioLanguage.value) return
const shouldSyncAudio = options.syncAudio !== false && isCurrentDetailAudioTarget.value
selectedAudioLanguage.value = lang
replaceDetailRouteLanguage(lang)
void ensureDetailTextForLanguage(lang)
if (shouldSyncAudio && globalAudioPlayer.currentSource.value?.lang !== lang) {
await globalAudioPlayer.switchLanguage(lang)
}
}
watch(
() => globalAudioPlayer.currentSource.value,
(source) => {
const lang = source?.lang
const targetType = exhibit.value.audio.playTargetType || 'ITEM'
const targetId = exhibit.value.audio.playTargetId || exhibit.value.id
if (
isAudioLanguage(lang)
&& source?.targetType === targetType
&& source.targetId === targetId
) {
selectedAudioLanguage.value = lang
replaceDetailRouteLanguage(lang)
void ensureDetailTextForLanguage(lang)
}
}
)
function buildDetailRoute(lang: AudioLanguage = selectedAudioLanguage.value) {
const request = detailEntryRequest.value
const params = new URLSearchParams({
id: request?.exhibitId || exhibit.value.id,
tab: activeTopTab.value
})
if (currentAudioTarget.value.targetType) {
params.set('targetType', currentAudioTarget.value.targetType)
}
if (currentAudioTarget.value.targetId) {
params.set('targetId', currentAudioTarget.value.targetId)
}
if (lang) {
params.set('lang', lang)
}
return `/pages/exhibit/detail?${params.toString()}`
}
const toAudioItem = (
selection: NonNullable<Awaited<ReturnType<typeof explainUseCase.selectAudioForExplainDetail>>>
): AudioItem | null => {
if (!selection.playable || !selection.media?.url) return null
return {
id: selection.media.id,
name: selection.playInfo?.title || selection.exhibit.name,
audioUrl: selection.media.url,
image: heroImage.value,
duration: selection.media.duration,
language: selection.media.language as AudioLanguage,
supportedLanguages: (selection.exhibit.supportedLanguages || exhibit.value.audio.supportedLanguages || [])
.filter(isAudioLanguage)
}
}
const refreshCurrentAudioOnce = async (message: string) => {
if (retryingAudio.value || exhibit.value.audio.status !== 'playable') return null
retryingAudio.value = true
const selection = await explainUseCase.selectAudioForExplainDetail({
id: exhibit.value.id,
name: exhibit.value.title,
image: heroImage.value,
description: exhibit.value.summary,
guideText: exhibit.value.summary,
audioUrl: exhibit.value.audio.url,
audioDuration: exhibit.value.audio.duration,
audioStatus: 'READY',
audioLanguage: selectedAudioLanguage.value,
playTargetType: exhibit.value.audio.playTargetType,
playTargetId: exhibit.value.audio.playTargetId
}, {
refreshPlayInfo: true
})
retryingAudio.value = false
const retryAudio = toAudioItem(selection)
if (retryAudio) return retryAudio
exhibit.value = {
...exhibit.value,
audio: {
...exhibit.value.audio,
status: 'unavailable',
url: undefined,
unavailableReason: selection.unavailableMessage || message || '音频加载失败,当前提供图文讲解。'
}
}
return null
}
const handlePlayAudio = async () => {
if (isCurrentDetailAudio.value) {
if (globalAudioPlayer.playing.value) {
globalAudioPlayer.pause()
return
}
void globalAudioPlayer.resume()
return
}
const selection = exhibit.value.id
? await explainUseCase.selectAudioForExplainDetail({
id: exhibit.value.id,
name: exhibit.value.title,
image: heroImage.value,
description: exhibit.value.summary,
guideText: exhibit.value.summary,
audioUrl: exhibit.value.audio.url,
audioDuration: exhibit.value.audio.duration,
audioStatus: exhibit.value.audio.status === 'playable' ? 'READY' : 'MISSING',
audioLanguage: selectedAudioLanguage.value,
audioUnavailableReason: exhibit.value.audio.unavailableReason,
playTargetType: exhibit.value.audio.playTargetType,
playTargetId: exhibit.value.audio.playTargetId
})
: null
if (!selection?.playable || !selection.media?.url) {
uni.showToast({
title: selection?.unavailableMessage || exhibit.value.audio.unavailableReason || '该讲解暂无可播放音频',
icon: 'none'
})
return
}
const audio = toAudioItem(selection)
if (!audio) return
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: 'expanded'
})
}
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 fallbackToExplainHome = () => {
uni.redirectTo({
url: '/pages/index/index?tab=explain',
fail: () => {
uni.reLaunch({
url: '/pages/index/index?tab=explain'
})
}
})
}
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({
delta: 1,
fail: fallbackToExplainHome
})
}
</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;
}
.content.without-location-action {
padding-bottom: calc(28px + env(safe-area-inset-bottom));
}
.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,
.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;
}
.detail-chip-row {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.detail-chip {
max-width: 100%;
height: 28px;
padding: 0 10px;
display: flex;
align-items: center;
box-sizing: border-box;
background: #f4f6ef;
border: 1px solid #dde2d3;
border-radius: 8px;
}
.detail-chip.shared {
background: #151713;
border-color: #151713;
}
.detail-chip-text {
display: block;
max-width: 100%;
font-size: 13px;
line-height: 18px;
font-weight: 700;
color: #555c51;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-chip.shared .detail-chip-text {
color: #e0df00;
}
.language-switch {
width: 100%;
height: 42px;
margin-top: 16px;
padding: 3px;
display: flex;
box-sizing: border-box;
background: #ecefe6;
border: 1px solid #dde2d3;
border-radius: 8px;
}
.language-option {
min-width: 0;
flex: 1;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 6px;
color: #555c51;
}
.language-option.active {
background: #151713;
color: #e0df00;
}
.language-option.disabled {
opacity: 0.56;
}
.language-option-text {
display: block;
max-width: 100%;
font-size: 14px;
line-height: 20px;
font-weight: 800;
color: currentColor;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.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%);
}
.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;
}
.text-load-btn {
width: 100%;
height: 44px;
margin-top: 14px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #151713;
border-radius: 8px;
color: #e0df00;
}
.text-load-btn.disabled {
opacity: 0.62;
}
.text-load-btn-text {
display: block;
max-width: 100%;
font-size: 15px;
line-height: 21px;
font-weight: 800;
color: currentColor;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.section-hint {
display: block;
margin-top: 12px;
font-size: 14px;
line-height: 20px;
color: #68725d;
}
.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;
}
.explain-detail-page {
background: #111a14;
}
.content {
height: 100%;
padding: 0;
box-sizing: border-box;
background: #111a14;
}
.content.with-fixed-player {
padding-bottom: 0;
}
.immersive-hero {
position: relative;
min-height: 392px;
padding: calc(env(safe-area-inset-top) + 20px) 24px 88px;
box-sizing: border-box;
overflow: hidden;
background:
radial-gradient(circle at 78% 18%, rgba(64, 88, 58, 0.3), rgba(64, 88, 58, 0) 30%),
linear-gradient(180deg, #24351e 0%, #111a14 100%);
color: #ffffff;
}
.hero-real-image {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
}
.hero-image-shade {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background:
linear-gradient(180deg, rgba(12, 20, 14, 0.34) 0%, rgba(13, 22, 16, 0.7) 62%, #111a14 100%),
linear-gradient(90deg, rgba(10, 16, 11, 0.72) 0%, rgba(10, 16, 11, 0.34) 58%, rgba(10, 16, 11, 0.68) 100%);
}
.immersive-hero.has-real-image {
background: #111a14;
}
.immersive-hero.has-real-image .hero-image-shade {
background:
linear-gradient(180deg, rgba(10, 16, 11, 0.12) 0%, rgba(10, 16, 11, 0.38) 58%, rgba(17, 26, 20, 0.78) 100%),
linear-gradient(90deg, rgba(10, 16, 11, 0.46) 0%, rgba(10, 16, 11, 0.1) 48%, rgba(10, 16, 11, 0.36) 100%);
}
.hero-back {
position: relative;
z-index: 4;
width: 38px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
}
.hero-back-icon {
display: block;
font-size: 34px;
line-height: 34px;
font-weight: 600;
color: currentColor;
}
.hero-copy {
position: relative;
z-index: 3;
min-height: 250px;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.detail-title,
.detail-meta {
display: block;
}
.detail-title {
max-width: 100%;
font-size: 34px;
line-height: 42px;
font-weight: 900;
color: #ffffff;
text-shadow: 0 3px 0 rgba(0, 0, 0, 0.18);
word-break: break-word;
}
.detail-meta {
margin-top: 8px;
font-size: 15px;
line-height: 22px;
font-weight: 600;
color: rgba(255, 255, 255, 0.88);
}
.detail-chip-row {
margin-top: 12px;
gap: 7px;
}
.detail-chip {
height: 28px;
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.22);
color: #ffffff;
}
.detail-chip.shared {
background: rgba(231, 233, 0, 0.14);
border-color: rgba(231, 233, 0, 0.38);
}
.detail-chip-text,
.detail-chip.shared .detail-chip-text {
color: rgba(255, 255, 255, 0.9);
}
.language-anchor {
width: min(315px, 100%);
height: 64px;
margin-top: 26px;
padding: 6px;
display: flex;
box-sizing: border-box;
background: rgba(17, 20, 15, 0.92);
border-radius: 16px;
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.24);
}
.language-anchor-option {
min-width: 0;
flex: 1;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
color: #ffffff;
}
.language-anchor-option.active {
background: #e7e900;
color: #0f140d;
}
.language-anchor-text {
max-width: 100%;
font-size: 18px;
line-height: 24px;
font-weight: 900;
color: currentColor;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.content-panel {
position: relative;
z-index: 2;
min-height: calc(100vh - 318px);
margin-top: -48px;
padding: 34px 26px calc(172px + env(safe-area-inset-bottom));
box-sizing: border-box;
background: #f8f9f4;
border-radius: 28px 28px 0 0;
box-shadow: 0 -18px 34px rgba(0, 0, 0, 0.18);
}
.content-section {
margin-top: 0;
padding-top: 22px;
scroll-margin-top: 18px;
}
.content-section + .content-section {
margin-top: 52px;
}
.section-heading {
display: flex;
align-items: center;
gap: 14px;
}
.section-heading.compact {
gap: 10px;
}
.section-marker {
flex-shrink: 0;
width: 9px;
height: 46px;
border-radius: 999px;
background: #e7e900;
}
.section-marker.english {
background: #7aa678;
}
.section-marker.muted {
height: 28px;
background: #b8c6af;
}
.section-title {
font-size: 24px;
line-height: 32px;
font-weight: 900;
color: #111827;
}
.section-text {
margin-top: 16px;
display: block;
font-size: 17px;
line-height: 29px;
color: #233044;
white-space: pre-line;
word-break: break-word;
}
.section-hint {
color: #6d7568;
}
.detail-audio-dock {
position: fixed;
left: 50%;
right: auto;
bottom: calc(12px + env(safe-area-inset-bottom));
z-index: 2201;
width: calc(min(100vw, 430px) - 28px);
min-height: 126px;
padding: 16px 14px 16px 16px;
display: flex;
align-items: center;
gap: 13px;
box-sizing: border-box;
background: #10140f;
border: 1px solid rgba(231, 233, 0, 0.18);
border-radius: 24px;
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.28);
transform: translateX(-50%);
color: #ffffff;
}
.detail-audio-play {
flex-shrink: 0;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #e7e900;
color: #0b0f0a;
}
.detail-audio-main {
min-width: 0;
flex: 1;
}
.detail-audio-title,
.detail-audio-subtitle,
.detail-audio-lang-text {
display: block;
}
.detail-audio-title {
font-size: 17px;
line-height: 23px;
font-weight: 900;
color: #ffffff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-audio-subtitle {
margin-top: 3px;
font-size: 13px;
line-height: 18px;
color: rgba(255, 255, 255, 0.72);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-audio-progress {
height: 5px;
margin-top: 13px;
overflow: hidden;
background: rgba(255, 255, 255, 0.28);
border-radius: 999px;
}
.detail-audio-progress-fill {
width: 22%;
height: 100%;
background: #e7e900;
border-radius: inherit;
}
.detail-audio-lang {
flex-shrink: 0;
min-width: 46px;
height: 36px;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.46);
}
@media (max-width: 360px) {
.immersive-hero {
padding-left: 20px;
padding-right: 20px;
}
.detail-title {
font-size: 30px;
line-height: 38px;
}
.content-panel {
padding-left: 22px;
padding-right: 22px;
}
.detail-audio-dock {
gap: 10px;
padding-left: 12px;
}
}
</style>