优化导览预览与讲解展示层

This commit is contained in:
lyf
2026-06-25 02:48:33 +08:00
parent 19516a507b
commit 33641e75df
17 changed files with 1224 additions and 793 deletions

View File

@@ -1,9 +1,10 @@
<template>
<GuidePageFrame
:active-tab="currentTab"
:variant="currentTab === 'explain' ? 'static' : 'overlay'"
variant="overlay"
:top-tabs-variant="currentTab === 'guide' ? 'segmented' : 'underline'"
:top-tabs-top="currentTab === 'guide' ? '108px' : '0'"
:top-tabs-top="currentTab === 'guide' ? '80px' : '0'"
:show-top-tabs="currentTab === 'guide'"
@tab-change="handleTabChange"
>
<GuideMapShell
@@ -13,10 +14,10 @@
:mode-layout="'status'"
:mode-status="guideStatusLabel"
mode-status-tone="glass"
search-top="46px"
search-top="20px"
search-variant="home"
:show-mode-row="false"
mode-top="104px"
mode-top="80px"
:map-type="guideMapType"
:outdoor-variant="guideOutdoorVariant"
:indoor-asset-base-url="indoorNavAssetBaseUrl"
@@ -33,7 +34,7 @@
show-floor-header
:show-layer-mode-toggle="false"
:show-floor="is3DMode"
tools-top="154px"
tools-top="130px"
show-zoom-controls
zoom-controls-top="560px"
show-location-control
@@ -184,37 +185,14 @@
</GuideMapShell>
<view v-else-if="currentTab === 'explain'" class="explain-page">
<ExplainList
:cards="explainExhibits"
<ExplainHallSelect
:halls="explainHallItems"
:loading="explainLoading"
:error="explainError"
show-browse-all
@exhibit-click="handleExplainExhibitClick"
@featured-click="handleExplainFeaturedClick"
@audio-click="handleAudioClick"
@location-click="handleExplainLocationClick"
@hall-click="handleExplainHallClick"
@browse-all="handleExplainBrowseAll"
/>
<FloatingAudioButton
v-if="currentAudio"
:is-playing="isAudioPlaying"
@click="handleFloatingButtonClick"
@back="handleExplainBack"
/>
</view>
<!-- 音频播放器 -->
<AudioPlayer
:visible="showAudioPlayer"
:audio="currentAudio"
:auto-play="true"
@update:visible="handleAudioVisibleChange"
@play="handleAudioPlay"
@pause="handleAudioPause"
@ended="handleAudioEnded"
@error="handleAudioError"
/>
</GuidePageFrame>
</template>
@@ -227,9 +205,9 @@ import RoutePlannerPanel from '@/components/navigation/RoutePlannerPanel.vue'
import type {
RoutePointOption
} from '@/components/navigation/RoutePointPicker.vue'
import ExplainList, { type Exhibit } from '@/components/explain/ExplainList.vue'
import FloatingAudioButton from '@/components/audio/FloatingAudioButton.vue'
import AudioPlayer, { type AudioItem } from '@/components/audio/AudioPlayer.vue'
import ExplainHallSelect, {
type ExplainHallSelectItem
} from '@/components/explain/ExplainHallSelect.vue'
import {
getLastGuideLocationPreviewUrl,
isGuideTopTab,
@@ -244,15 +222,13 @@ import {
import {
explainUseCase
} from '@/usecases/explainUseCase'
import {
toExplainCardViewModel
} from '@/view-models/explainViewModels'
import type {
GuideRenderPoi
} from '@/domain/guideModel'
import type {
GuideRouteResult,
GuideRouteTarget,
MuseumExhibit,
MuseumFloor
} from '@/domain/museum'
@@ -355,11 +331,7 @@ const routeSimulationStepText = computed(() => {
return hasConnector ? '路线示意 · 跨楼层连接点' : '路线示意 · 同层位置关系'
})
// 音频播放器状态
const showAudioPlayer = ref(false)
const isAudioPlaying = ref(false)
const currentAudio = ref<AudioItem | null>(null)
const explainExhibits = ref<Exhibit[]>([])
const explainHallItems = ref<ExplainHallSelectItem[]>([])
const explainLoading = ref(false)
const explainError = ref('')
let explainLoadPromise: Promise<void> | null = null
@@ -469,7 +441,7 @@ const loadGuideFloors = async () => {
}
const loadExplainExhibits = async () => {
if (explainExhibits.value.length) return
if (explainHallItems.value.length) return
if (explainLoadPromise) return explainLoadPromise
explainLoading.value = true
@@ -477,12 +449,15 @@ const loadExplainExhibits = async () => {
explainLoadPromise = (async () => {
try {
const exhibits = await explainUseCase.listExplainExhibits()
explainExhibits.value = exhibits.map(toExplainCardViewModel)
const [halls, exhibits] = await Promise.all([
explainUseCase.listHalls(),
explainUseCase.listExplainExhibits()
])
explainHallItems.value = buildExplainHallItems(halls, exhibits)
} catch (error) {
console.error('加载讲解内容失败:', error)
explainError.value = '讲解内容加载失败,请稍后重试'
explainExhibits.value = []
explainHallItems.value = []
} finally {
explainLoading.value = false
explainLoadPromise = null
@@ -492,6 +467,68 @@ const loadExplainExhibits = async () => {
return explainLoadPromise
}
const normalizeExplainKeyword = (value?: string) => (value || '').trim().toLowerCase()
const buildExplainHallItems = (
halls: Awaited<ReturnType<typeof explainUseCase.listHalls>>,
exhibits: MuseumExhibit[]
): ExplainHallSelectItem[] => {
const countsByHallId = new Map<string, number>()
const countsByHallName = new Map<string, number>()
const keywordsByHallId = new Map<string, Set<string>>()
const keywordsByHallName = new Map<string, Set<string>>()
exhibits.forEach((exhibit) => {
const keywordParts = [
exhibit.name,
exhibit.hallName,
exhibit.floorLabel,
exhibit.description,
exhibit.guideTitle,
exhibit.guideText,
...(exhibit.tags || [])
].filter(Boolean) as string[]
if (exhibit.hallId) {
countsByHallId.set(exhibit.hallId, (countsByHallId.get(exhibit.hallId) || 0) + 1)
const keywords = keywordsByHallId.get(exhibit.hallId) || new Set<string>()
keywordParts.forEach((part) => keywords.add(part))
keywordsByHallId.set(exhibit.hallId, keywords)
}
if (exhibit.hallName) {
countsByHallName.set(exhibit.hallName, (countsByHallName.get(exhibit.hallName) || 0) + 1)
const keywords = keywordsByHallName.get(exhibit.hallName) || new Set<string>()
keywordParts.forEach((part) => keywords.add(part))
keywordsByHallName.set(exhibit.hallName, keywords)
}
})
return halls.map((hall) => {
const explainCount = countsByHallId.get(hall.id)
|| countsByHallName.get(hall.name)
|| hall.exhibitCount
|| 0
const keywordParts = [
hall.name,
hall.floorLabel,
hall.description,
hall.area,
...Array.from(keywordsByHallId.get(hall.id) || []),
...Array.from(keywordsByHallName.get(hall.name) || [])
].filter(Boolean) as string[]
return {
id: hall.id,
name: hall.name,
floorLabel: hall.floorLabel,
image: hall.image,
explainCount,
searchText: normalizeExplainKeyword(keywordParts.join(' '))
}
})
}
const handleGuideSearchTap = () => {
uni.navigateTo({
url: '/pages/search/index'
@@ -843,131 +880,21 @@ const guideSearchText = computed(() => {
return '请输入地点进行搜索'
})
// 音频相关处理
const handleAudioClick = async (exhibit: Exhibit) => {
if (exhibit.audioUrl) {
currentAudio.value = {
id: `media-${exhibit.id}`,
name: exhibit.title,
audioUrl: exhibit.audioUrl,
image: exhibit.coverImage,
duration: exhibit.audioDuration
}
showAudioPlayer.value = true
return
}
const selection = await explainUseCase.selectAudioForExhibit(exhibit.id)
if (!selection?.playable || !selection.media?.url) {
uni.showToast({
title: selection?.unavailableMessage || exhibit.audioStatusText || '该讲解暂无可播放音频',
icon: 'none'
})
return
}
currentAudio.value = {
id: selection.media.id,
name: selection.track?.title || selection.exhibit.name,
audioUrl: selection.media.url,
image: selection.exhibit.image,
duration: selection.media.duration
}
showAudioPlayer.value = true
}
const handleFloatingButtonClick = () => {
if (showAudioPlayer.value) {
showAudioPlayer.value = false
} else if (currentAudio.value) {
showAudioPlayer.value = true
} else {
uni.showToast({
title: '请先选择一个讲解内容',
icon: 'none'
})
}
}
const clearAudioState = () => {
showAudioPlayer.value = false
isAudioPlaying.value = false
currentAudio.value = null
}
const handleAudioVisibleChange = (visible: boolean) => {
showAudioPlayer.value = visible
if (!visible) {
isAudioPlaying.value = false
currentAudio.value = null
}
}
const handleAudioPlay = (audio: AudioItem) => {
console.log('开始播放:', audio)
isAudioPlaying.value = true
}
const handleAudioPause = (audio: AudioItem) => {
console.log('暂停播放:', audio)
isAudioPlaying.value = false
}
const handleAudioEnded = (audio: AudioItem) => {
console.log('播放结束:', audio)
clearAudioState()
}
const handleAudioError = (_audio: AudioItem | null, message: string) => {
console.warn('讲解音频不可播放:', message)
clearAudioState()
}
// 讲解页面处理
const handleExplainExhibitClick = (exhibit: Exhibit) => {
uni.navigateTo({
url: `/pages/exhibit/detail?id=${exhibit.id}&tab=explain`
})
}
const handleExplainFeaturedClick = async (exhibit: Exhibit) => {
await handleAudioClick(exhibit)
}
const handleExplainHallClick = (hallId: string) => {
uni.navigateTo({
url: `/pages/hall/detail?id=${encodeURIComponent(hallId)}&tab=explain`
})
}
const handleExplainBrowseAll = () => {
uni.navigateTo({
url: '/pages/explain/list'
})
}
const handleExplainLocationClick = async (exhibit: Exhibit) => {
if (!exhibit.poiId) {
uni.showToast({
title: '该讲解暂无三维位置数据',
icon: 'none'
})
return
}
const matchedPoi = await guideUseCase.getPoiById(exhibit.poiId)
if (!matchedPoi) {
uni.showToast({
title: '该讲解位置尚未映射到当前三维导览资源',
icon: 'none'
})
return
}
uni.navigateTo({
url: `/pages/route/detail?facilityId=${encodeURIComponent(exhibit.poiId)}&target=${encodeURIComponent(exhibit.title)}&state=preview`
})
const handleExplainBack = () => {
currentTab.value = 'guide'
syncTopTabToUrl('guide')
loadTopTabData('guide')
showGuideMoreMenu.value = false
showRoutePlanner.value = false
isSimulatingRoute.value = false
isPoiCardCollapsed.value = false
}
</script>
@@ -1187,7 +1114,7 @@ const handleExplainLocationClick = async (exhibit: Exhibit) => {
.route-sim-top-pill {
position: absolute;
top: 154px;
top: 126px;
left: 50%;
max-width: calc(100% - 40px);
height: 34px;
@@ -1263,7 +1190,7 @@ const handleExplainLocationClick = async (exhibit: Exhibit) => {
.entrance-tip {
position: absolute;
top: 154px;
top: 126px;
left: 30px;
width: 168px;
height: 42px;