Compare commits

..

3 Commits

Author SHA1 Message Date
lyf
52f5217252 同步讲解详情播放器时间进度
Some checks failed
CI / verify (push) Has been cancelled
2026-07-10 11:03:30 +08:00
lyf
2a21108157 修复 H5 嵌入小程序导航栏冲突
Some checks failed
CI / verify (push) Has been cancelled
- 识别微信小程序 web-view 宿主环境
- 宿主导航存在时隐藏 H5 自绘返回与页面标题
- 收回详情、搜索、讲解列表页面顶部预留空间

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:57:06 +08:00
lyf
8758fe66c6 调整讲解详情播放器为常驻样式 2026-07-10 10:46:10 +08:00
6 changed files with 141 additions and 35 deletions

View File

@@ -1,6 +1,6 @@
<template> <template>
<view class="explain-hall-select"> <view class="explain-hall-select" :class="{ 'host-navigation': shouldUseHostNavigation }">
<view class="explain-page-header"> <view v-if="!shouldUseHostNavigation" class="explain-page-header">
<view class="header-back" @tap="handleBack"> <view class="header-back" @tap="handleBack">
<text class="header-back-icon"></text> <text class="header-back-icon"></text>
<text class="header-back-text">返回</text> <text class="header-back-text">返回</text>
@@ -153,6 +153,7 @@ import { computed, ref } from 'vue'
import { import {
EXPLAIN_UNIT_PREVIEW_FALLBACK EXPLAIN_UNIT_PREVIEW_FALLBACK
} from '@/utils/explainUnitPreviews' } from '@/utils/explainUnitPreviews'
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
export interface ExplainHallSelectItem { export interface ExplainHallSelectItem {
id: string id: string
@@ -239,6 +240,7 @@ const hallPreviewMap: Record<string, string> = {
} }
const filteredHalls = computed(() => props.halls) const filteredHalls = computed(() => props.halls)
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
const headerTitle = computed(() => { const headerTitle = computed(() => {
if (props.stage === 'unit') return props.selectedHallName || '选择业务单元' if (props.stage === 'unit') return props.selectedHallName || '选择业务单元'
if (props.stage === 'stop') return props.selectedBusinessUnitName || '选择讲解点' if (props.stage === 'stop') return props.selectedBusinessUnitName || '选择讲解点'
@@ -356,6 +358,14 @@ const handleBack = () => {
padding-bottom: 0; padding-bottom: 0;
} }
.explain-hall-select.host-navigation .explain-scroll {
padding-top: 12px;
}
.explain-hall-select.host-navigation .explain-scroll.hall-stage {
padding-top: 12px;
}
.state-title, .state-title,
.state-desc, .state-desc,
.hall-name, .hall-name,

View File

@@ -1,28 +1,35 @@
<template> <template>
<view <view
class="guide-page-frame" class="guide-page-frame"
:class="[`variant-${variant}`, { 'no-top-tabs': !showTopTabs }]" :class="[
`variant-${variant}`,
{
'no-top-tabs': !effectiveShowTopTabs,
'no-frame-header': !hasFrameHeader
}
]"
:style="frameStyle"
> >
<GuideTopTabs <GuideTopTabs
v-if="showTopTabs" v-if="effectiveShowTopTabs"
:active-tab="activeTab" :active-tab="activeTab"
:variant="topTabsVariant" :variant="topTabsVariant"
:top="topTabsTop" :top="topTabsTop"
@tab-change="handleTabChange" @tab-change="handleTabChange"
/> />
<view <view
v-if="showBack || showCancel" v-if="effectiveShowBack || effectiveShowCancel"
class="guide-page-frame-actions" class="guide-page-frame-actions"
> >
<view <view
v-if="showBack" v-if="effectiveShowBack"
class="frame-action frame-action-back" class="frame-action frame-action-back"
@tap.stop="handleBackTap" @tap.stop="handleBackTap"
> >
<text class="frame-action-text">{{ backLabel }}</text> <text class="frame-action-text">{{ backLabel }}</text>
</view> </view>
<view <view
v-if="showCancel" v-if="effectiveShowCancel"
class="frame-action frame-action-cancel" class="frame-action frame-action-cancel"
@tap.stop="handleCancelTap" @tap.stop="handleCancelTap"
> >
@@ -43,12 +50,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
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 { AudioDisplayMode } from '@/components/audio/AudioPlayer.vue'
import type { GuideTopTab } from '@/utils/guideTopTabs' import type { GuideTopTab } from '@/utils/guideTopTabs'
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
withDefaults(defineProps<{ const props = withDefaults(defineProps<{
activeTab?: GuideTopTab activeTab?: GuideTopTab
variant?: 'overlay' | 'static' variant?: 'overlay' | 'static'
topTabsVariant?: 'underline' | 'segmented' topTabsVariant?: 'underline' | 'segmented'
@@ -86,6 +95,18 @@ const emit = defineEmits<{
cancel: [] cancel: []
}>() }>()
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
const effectiveShowTopTabs = computed(() => props.showTopTabs && !shouldUseHostNavigation.value)
const effectiveShowBack = computed(() => props.showBack && !shouldUseHostNavigation.value)
const effectiveShowCancel = computed(() => props.showCancel && !shouldUseHostNavigation.value)
const hasFrameHeader = computed(() => (
effectiveShowTopTabs.value || effectiveShowBack.value || effectiveShowCancel.value
))
const frameStyle = computed(() => ({
'--guide-page-top-offset': shouldUseHostNavigation.value ? '0px' : '44px',
'--guide-page-detail-top-padding': shouldUseHostNavigation.value ? '24px' : '84px'
}))
const handleTabChange = (tab: GuideTopTab) => { const handleTabChange = (tab: GuideTopTab) => {
emit('tabChange', tab) emit('tabChange', tab)
} }
@@ -123,11 +144,11 @@ const handleCancelTap = () => {
} }
.variant-static .guide-page-frame-body { .variant-static .guide-page-frame-body {
top: 44px; top: var(--guide-page-top-offset, 44px);
background: var(--museum-bg-light); background: var(--museum-bg-light);
} }
.variant-static.no-top-tabs .guide-page-frame-body { .variant-static.no-frame-header .guide-page-frame-body {
top: 0; top: 0;
} }

View File

@@ -3,17 +3,12 @@
:active-tab="activeTopTab" :active-tab="activeTopTab"
variant="static" variant="static"
:show-top-tabs="false" :show-top-tabs="false"
:audio-player-avoid-bottom-nav="false"
:audio-player-closable="false"
:audio-player-expandable="false"
:show-audio-player-host="true"
audio-player-mode="mini"
@back="handleBack" @back="handleBack"
> >
<view class="explain-detail-page" :class="{ 'with-audio-player': showAudioPlayer }"> <view class="explain-detail-page">
<scroll-view <scroll-view
class="content" class="content"
:class="{ 'with-fixed-player': isDetailAudioDockVisible || showAudioPlayer }" :class="{ 'with-fixed-player': isDetailAudioDockVisible }"
scroll-y scroll-y
:show-scrollbar="false" :show-scrollbar="false"
> >
@@ -25,7 +20,7 @@
mode="aspectFill" mode="aspectFill"
/> />
<view class="hero-image-shade"></view> <view class="hero-image-shade"></view>
<view class="hero-back" @tap.stop="handleBack"> <view v-if="!shouldUseHostNavigation" class="hero-back" @tap.stop="handleBack">
<text class="hero-back-icon"></text> <text class="hero-back-icon"></text>
</view> </view>
<view class="hero-copy"> <view class="hero-copy">
@@ -67,7 +62,11 @@
class="detail-audio-dock" class="detail-audio-dock"
> >
<view class="detail-audio-play" @tap="handlePlayAudio"> <view class="detail-audio-play" @tap="handlePlayAudio">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none"> <svg v-if="detailAudioPlaying" width="26" height="26" 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="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>
@@ -75,7 +74,10 @@
<text class="detail-audio-title">{{ audioDockTitle }}</text> <text class="detail-audio-title">{{ audioDockTitle }}</text>
<text class="detail-audio-subtitle">{{ audioDockSubtitle }}</text> <text class="detail-audio-subtitle">{{ audioDockSubtitle }}</text>
<view class="detail-audio-progress"> <view class="detail-audio-progress">
<view class="detail-audio-progress-fill"></view> <view
class="detail-audio-progress-fill"
:style="{ width: `${detailAudioProgressPercent}%` }"
></view>
</view> </view>
</view> </view>
</view> </view>
@@ -126,6 +128,7 @@ import {
import { import {
EXPLAIN_DETAIL_PLACEHOLDER_IMAGE EXPLAIN_DETAIL_PLACEHOLDER_IMAGE
} from '@/utils/placeholders' } from '@/utils/placeholders'
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
const defaultDetail: ExplainDetailPageViewModel = { const defaultDetail: ExplainDetailPageViewModel = {
id: '', id: '',
@@ -168,6 +171,7 @@ const detailTextLoadedByLanguage = ref<Record<AudioLanguage, boolean>>({
}) })
// 讲解详情页位置入口暂未开放,避免误导用户进入位置预览流程。 // 讲解详情页位置入口暂未开放,避免误导用户进入位置预览流程。
const isLocationActionVisible = false const isLocationActionVisible = false
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
const contentLanguageOptions: Array<{ const contentLanguageOptions: Array<{
value: AudioLanguage value: AudioLanguage
@@ -206,9 +210,6 @@ const currentAudioTarget = computed(() => ({
lang: selectedAudioLanguage.value lang: selectedAudioLanguage.value
})) }))
const isCurrentDetailAudio = computed(() => globalAudioPlayer.isCurrentSource(currentAudioTarget.value)) const isCurrentDetailAudio = computed(() => globalAudioPlayer.isCurrentSource(currentAudioTarget.value))
const showAudioPlayer = computed(() => (
globalAudioPlayer.visible.value && globalAudioPlayer.hasAudio.value
))
const isCurrentDetailAudioTarget = computed(() => { const isCurrentDetailAudioTarget = computed(() => {
const source = globalAudioPlayer.currentSource.value const source = globalAudioPlayer.currentSource.value
const targetType = exhibit.value.audio.playTargetType || 'ITEM' const targetType = exhibit.value.audio.playTargetType || 'ITEM'
@@ -222,13 +223,47 @@ const isCurrentDetailAudioTarget = computed(() => {
}) })
const isDetailAudioDockVisible = computed(() => ( const isDetailAudioDockVisible = computed(() => (
exhibit.value.audio.status === 'playable' exhibit.value.audio.status === 'playable'
&& !isCurrentDetailAudioTarget.value
)) ))
const audioDockTitle = computed(() => exhibit.value.title || '讲解内容') const audioDockTitle = computed(() => exhibit.value.title || '讲解内容')
const formatDetailAudioTime = (seconds?: number) => {
const normalizedSeconds = Number.isFinite(seconds || 0)
? Math.max(0, Math.floor(seconds || 0))
: 0
const minutes = Math.floor(normalizedSeconds / 60)
const remainSeconds = normalizedSeconds % 60
return `${minutes}:${remainSeconds.toString().padStart(2, '0')}`
}
const detailAudioDurationSeconds = computed(() => {
if (!isCurrentDetailAudioTarget.value) return exhibit.value.audio.duration || 0
return globalAudioPlayer.duration.value
|| globalAudioPlayer.currentAudio.value?.duration
|| exhibit.value.audio.duration
|| 0
})
const detailAudioCurrentSeconds = computed(() => (
isCurrentDetailAudioTarget.value ? globalAudioPlayer.currentTime.value : 0
))
const detailAudioTimeLabel = computed(() => (
`${formatDetailAudioTime(detailAudioCurrentSeconds.value)} / ${formatDetailAudioTime(detailAudioDurationSeconds.value)}`
))
const audioDockSubtitle = computed(() => [ const audioDockSubtitle = computed(() => [
selectedAudioLanguage.value === 'en-US' ? 'English' : '中文', selectedAudioLanguage.value === 'en-US' ? 'English' : '中文',
exhibit.value.audio.durationLabel detailAudioTimeLabel.value
].filter(Boolean).join(' · ') || '讲解') ].filter(Boolean).join(' · ') || '讲解')
const detailAudioPlaying = computed(() => (
isCurrentDetailAudio.value
&& globalAudioPlayer.playing.value
))
const detailAudioProgressPercent = computed(() => {
if (!isCurrentDetailAudioTarget.value) return 0
const total = detailAudioDurationSeconds.value
if (!total) return 0
return Math.max(0, Math.min(100, (detailAudioCurrentSeconds.value / total) * 100))
})
// 讲解详情页只定位到所属展厅,不再追踪具体展品点位。 // 讲解详情页只定位到所属展厅,不再追踪具体展品点位。
const resolveHallGuidePoi = async () => { const resolveHallGuidePoi = async () => {
return guideUseCase.resolveContentLocationPreviewTarget({ return guideUseCase.resolveContentLocationPreviewTarget({
@@ -688,7 +723,7 @@ const handleBack = () => {
.content { .content {
height: 100%; height: 100%;
padding: 84px 20px calc(104px + env(safe-area-inset-bottom)); padding: var(--guide-page-detail-top-padding, 84px) 20px calc(104px + env(safe-area-inset-bottom));
box-sizing: border-box; box-sizing: border-box;
} }
@@ -956,10 +991,6 @@ const handleBack = () => {
background: rgba(249, 250, 251, 0.96); background: rgba(249, 250, 251, 0.96);
} }
.explain-detail-page.with-audio-player .action-bar {
bottom: 88px;
}
.location-btn { .location-btn {
height: 52px; height: 52px;
display: flex; display: flex;
@@ -1308,7 +1339,7 @@ const handleBack = () => {
} }
.detail-audio-progress-fill { .detail-audio-progress-fill {
width: 22%; width: 0;
height: 100%; height: 100%;
background: #e7e900; background: #e7e900;
border-radius: inherit; border-radius: inherit;

View File

@@ -264,7 +264,7 @@ const handleBack = () => {
.content { .content {
height: 100%; height: 100%;
padding: 84px 20px calc(28px + env(safe-area-inset-bottom)); padding: var(--guide-page-detail-top-padding, 84px) 20px calc(28px + env(safe-area-inset-bottom));
box-sizing: border-box; box-sizing: border-box;
} }

View File

@@ -1,6 +1,6 @@
<template> <template>
<view class="poi-search-page"> <view class="poi-search-page" :class="{ 'host-navigation': shouldUseHostNavigation }">
<view class="search-header"> <view v-if="!shouldUseHostNavigation" class="search-header">
<view class="search-header-copy"> <view class="search-header-copy">
<text class="page-title">点位搜索</text> <text class="page-title">点位搜索</text>
<text class="page-subtitle">Search Museum Locations</text> <text class="page-subtitle">Search Museum Locations</text>
@@ -21,11 +21,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import PoiSearchPanel from '@/components/search/PoiSearchPanel.vue' import PoiSearchPanel from '@/components/search/PoiSearchPanel.vue'
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
const initialKeyword = ref('') const initialKeyword = ref('')
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
onLoad((options: any) => { onLoad((options: any) => {
const keyword = typeof options.keyword === 'string' const keyword = typeof options.keyword === 'string'
@@ -119,6 +121,14 @@ onUnmounted(() => {
} }
/* #endif */ /* #endif */
.poi-search-page.host-navigation {
padding-top: 0;
}
.poi-search-page.host-navigation::before {
display: none;
}
.search-header { .search-header {
position: relative; position: relative;
z-index: 1; z-index: 1;

View File

@@ -0,0 +1,34 @@
const getCurrentUrlSearchParams = () => {
if (typeof window === 'undefined') return new URLSearchParams()
const params = new URLSearchParams(window.location.search)
const hash = window.location.hash || ''
const hashQueryStart = hash.indexOf('?')
if (hashQueryStart >= 0) {
const hashParams = new URLSearchParams(hash.slice(hashQueryStart + 1))
hashParams.forEach((value, key) => {
if (!params.has(key)) params.set(key, value)
})
}
return params
}
export const isEmbeddedInWechatMiniProgram = () => {
if (typeof window === 'undefined') return false
const params = getCurrentUrlSearchParams()
const embedded = params.get('embedded') || params.get('embed') || params.get('host')
if (embedded === 'wechat-mini-program' || embedded === 'weapp' || embedded === 'mini-program') {
return true
}
const wxEnvironment = (window as Window & {
__wxjs_environment?: string
}).__wxjs_environment
if (wxEnvironment === 'miniprogram') return true
return /miniProgram/i.test(window.navigator?.userAgent || '')
}