修复导览与讲解位置预览逻辑

This commit is contained in:
lyf
2026-06-30 11:00:49 +08:00
parent f2a33888b1
commit 1c2cc788d1
29 changed files with 1111 additions and 756 deletions

View File

@@ -61,7 +61,7 @@
<view class="action-bar">
<view
class="location-btn"
:class="{ disabled: !exhibit.location }"
:class="{ disabled: !hallLocationId }"
@tap="handleNavigate"
>
<svg class="location-icon" width="19" height="19" viewBox="0 0 24 24" fill="none">
@@ -134,12 +134,45 @@ const showAudioPlayer = ref(false)
const currentAudio = ref<AudioItem | null>(null)
const isPlaying = ref(false)
const activeTopTab = ref<GuideTopTab>('explain')
const resolvedHallId = ref('')
const heroImage = computed(() => exhibit.value.coverImages[0])
const detailMeta = computed(() => [
exhibit.value.hallName,
exhibit.value.floorLabel
].filter(Boolean).join(' · '))
const hallLocationId = computed(() => exhibit.value.hallId || resolvedHallId.value)
const normalizePoiName = (value: string) => value
.trim()
.replace(/[\s()【】[\]_-]/g, '')
.toLowerCase()
// 讲解详情页只定位到所属展厅,不再追踪具体展品点位。
const resolveHallGuidePoi = async () => {
const hallId = exhibit.value.hallId
if (hallId) {
const hallPoi = await guideUseCase.getPoiById(hallId)
if (hallPoi?.kind === 'hall' || hallPoi?.primaryCategory.id === 'exhibition_hall') {
return hallPoi
}
}
const normalizedTarget = normalizePoiName(exhibit.value.hallName || '')
if (!normalizedTarget) return null
const candidates = await guideUseCase.searchPois(exhibit.value.hallName || exhibit.value.title || '')
return candidates.find((candidate) => {
const candidateName = normalizePoiName(candidate.name)
const candidateHallName = normalizePoiName(candidate.hallName || '')
return candidate.kind === 'hall'
|| candidate.primaryCategory.id === 'exhibition_hall'
|| candidateName === normalizedTarget
|| candidateHallName === normalizedTarget
|| candidateName.includes(normalizedTarget)
|| normalizedTarget.includes(candidateName)
}) || candidates[0] || null
}
onLoad(async (options: any = {}) => {
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
@@ -153,6 +186,9 @@ onLoad(async (options: any = {}) => {
const exhibitData = await explainUseCase.getExhibitById(exhibitId)
if (exhibitData) {
exhibit.value = toExplainDetailPageViewModel(exhibitData)
if (exhibitData.hallId) {
resolvedHallId.value = exhibitData.hallId
}
}
})
@@ -247,25 +283,34 @@ const handleAudioError = (_audio: AudioItem | null, message: string) => {
}
const handleNavigate = async () => {
if (!exhibit.value.location?.poiId) {
const matchedHallPoi = await resolveHallGuidePoi()
if (!matchedHallPoi) {
uni.showToast({
title: '该讲解暂无三维位置数据',
title: '该讲解暂无所属展厅位置数据',
icon: 'none'
})
return
}
const matchedPoi = await guideUseCase.getPoiById(exhibit.value.location.poiId)
if (!matchedPoi) {
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(exhibit.value.location.poiId)}&target=${encodeURIComponent(exhibit.value.title)}&state=preview`
url: `/pages/route/detail?facilityId=${encodeURIComponent(matchedHallPoi.id)}&target=${encodeURIComponent(exhibit.value.hallName || exhibit.value.title)}&state=preview`
})
}

View File

@@ -5,39 +5,41 @@
:show-top-tabs="false"
show-bottom-nav
show-back
:show-cancel="isStartPanelOpen"
@tab-change="handleTopTabChange"
@back="handlePageBack"
@cancel="handleCancelStartSelection"
>
<GuideMapShell
:search-text="facility.name"
search-top="60px"
mode-top="104px"
floor-top="208px"
tools-top="176px"
search-top="20px"
floor-bottom="153px"
floor-max-height="206px"
floor-side="left"
:active-mode="activeMode"
:active-floor="activeFloor"
:map-type="activeMode === '2d' ? 'outdoor' : 'indoor'"
:tools="mapTools"
map-type="indoor"
:tools="[]"
:indoor-model-source="indoorModelSource"
:floors="guideFloors"
:normalize-floor-id="normalizeGuideFloorId"
@search-tap="handleSearchTap"
@mode-change="handleModeChange"
:target-focus-request="targetFocusRequest"
:target-focus-distance-factor="0.86"
:show-search="false"
:show-mode-row="false"
show-floor-header
show-zoom-controls
zoom-controls-top="calc(100vh - 252px)"
@floor-change="handleFloorChange"
@tool-click="handleToolClick"
>
<view class="detail-sheet">
<view v-if="isDetailPanelVisible" class="detail-sheet">
<view class="detail-title-row">
<text class="detail-title">{{ facility.name }}</text>
<text class="open-status">{{ facility.status }}</text>
</view>
<view class="detail-lines">
<text class="detail-line">所在区域{{ facility.location }}</text>
<text class="detail-line">能力说明{{ facility.traffic }}</text>
<text v-if="confirmedStart" class="detail-line">预览起点{{ confirmedStart.label }}</text>
<view class="target-dot"></view>
<view class="detail-title-copy">
<text class="detail-title">{{ facility.name }}</text>
<text class="detail-subtitle">{{ facility.location }}</text>
</view>
<view class="detail-close" @tap.stop="handleCloseDetailPanel">
<text class="detail-close-text">×</text>
</view>
</view>
<view class="tag-row">
@@ -51,82 +53,35 @@
</view>
</view>
<view class="action-row">
<view class="action-btn secondary" @tap="handleChooseStart">
<text class="action-text">{{ startButtonLabel }}</text>
</view>
<view class="action-btn primary" @tap="handleStartNavigation">
<text class="action-text">查看位置</text>
</view>
</view>
<text class="detail-hint">已在对应楼层模型中标出点位</text>
</view>
<view
v-if="isStartPanelOpen"
class="start-panel-mask"
@tap="handleCancelStartSelection"
></view>
<view
v-if="isStartPanelOpen"
class="start-select-sheet"
@tap.stop=""
v-else
class="detail-restore"
@tap.stop="handleOpenDetailPanel"
>
<text class="start-select-title">选择预览起点</text>
<text class="start-select-desc">当前支持选择预览起点用于三维位置预览</text>
<view class="start-chip-section">
<text class="start-chip-label">楼层</text>
<view class="start-chip-row">
<view
v-for="floor in startFloors"
:key="floor"
class="start-chip"
:class="{ active: floor === selectedStartFloor }"
@tap="handleStartFloorChange(floor)"
>
<text class="start-chip-text">{{ floor }}</text>
</view>
</view>
</view>
<view class="start-chip-section area">
<text class="start-chip-label">区域</text>
<view class="start-chip-row">
<view
v-for="area in startAreas"
:key="area"
class="start-chip area"
:class="{ active: area === selectedStartArea }"
@tap="handleStartAreaChange(area)"
>
<text class="start-chip-text">{{ area }}</text>
</view>
</view>
</view>
<view class="start-panel-actions">
<view class="start-panel-btn secondary" @tap="handleCancelStartSelection">
<text class="start-panel-btn-text">取消</text>
</view>
<view class="start-panel-btn primary" @tap="handleConfirmStartSelection">
<text class="start-panel-btn-text">确认起点</text>
</view>
</view>
<view class="target-dot small"></view>
<text class="detail-restore-text">点位</text>
</view>
</GuideMapShell>
</GuidePageFrame>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
import {
guideUseCase
} from '@/usecases/guideUseCase'
import {
explainUseCase
} from '@/usecases/explainUseCase'
import {
toFacilityDetailViewModel,
toTargetFocusRequestViewModel,
type TargetPoiFocusRequestViewModel,
type FacilityDetailViewModel
} from '@/view-models/guideViewModels'
import {
@@ -134,64 +89,51 @@ import {
type GuideTopTab
} from '@/utils/guideTopTabs'
import type {
MuseumFloor
MuseumFloor,
MuseumPoi
} from '@/domain/museum'
import type {
GuideModelSource,
GuideRenderPoi
} from '@/domain/guideModel'
import {
StaticGuideModelRepository
} from '@/repositories/GuideModelRepository'
interface ConfirmedStart {
label: string
floor: string
source: 'facility-detail'
}
const indoorModelSource = guideUseCase.getModelSource()
const fallbackIndoorModelSource = new StaticGuideModelRepository()
const indoorModelSource = ref<GuideModelSource>(fallbackIndoorModelSource)
const guideFloors = ref<MuseumFloor[]>([])
const normalizeGuideFloorId = (labelOrId: string) => guideUseCase.normalizeFloorId(labelOrId)
const normalizeGuideFloorId = (labelOrId: string) =>
guideFloors.value.find((floor) => floor.id === labelOrId || floor.label === labelOrId)?.id
|| guideUseCase.normalizeFloorId(labelOrId)
const activeMode = ref<'2d' | '3d'>('3d')
const activeFloor = ref('1F')
const targetFocusRequest = ref<TargetPoiFocusRequestViewModel | null>(null)
const targetFocusRequestId = ref(0)
const isDetailPanelVisible = ref(true)
const facility = ref<FacilityDetailViewModel>({
id: '',
name: '目标位置',
status: '可预览',
location: '中心线路网导览点位',
location: '正在定位三维点位',
traffic: guideUseCase.getRouteReadinessSnapshot().message,
tags: ['三维位置', '中心线路网']
tags: ['三维位置', '位置预览']
})
const isStartPanelOpen = ref(false)
const selectedStartFloor = ref('1F')
const selectedStartArea = ref('服务台附近')
const confirmedStart = ref<ConfirmedStart | null>(null)
const startFloors = computed(() => guideFloors.value.map((floor) => floor.label))
const mapTools = computed(() => ['重置', activeMode.value === '2d' ? '3D' : '2D'])
const startAreas = ['主入口', '服务台附近', '停车场入口']
const pendingStartLabel = computed(() => `${selectedStartFloor.value} ${selectedStartArea.value}`)
const startButtonLabel = computed(() => confirmedStart.value ? '更换起点' : '选择起点')
const resolveConfirmedStartArea = () => {
if (!confirmedStart.value) return '服务台附近'
const area = confirmedStart.value.floor
? confirmedStart.value.label.replace(`${confirmedStart.value.floor} `, '').trim()
: confirmedStart.value.label.trim()
return startAreas.includes(area) ? area : '服务台附近'
}
const syncDraftStartSelection = () => {
selectedStartFloor.value = confirmedStart.value?.floor || '1F'
selectedStartArea.value = resolveConfirmedStartArea()
}
const loadGuideFloors = async () => {
try {
const floors = await guideUseCase.getFloors()
guideFloors.value = floors
const defaultFloor = floors.find((floor) => floor.label === selectedStartFloor.value)?.label
const modelPackage = await fallbackIndoorModelSource.loadPackage()
guideFloors.value = modelPackage.floors.map((floor) => ({
id: floor.floorId,
label: floor.label,
order: floor.order
}))
const floors = guideFloors.value
const defaultFloor = floors.find((floor) => floor.label === activeFloor.value)?.label
|| floors.find((floor) => floor.id === 'L1')?.label
|| floors[0]?.label
|| selectedStartFloor.value
selectedStartFloor.value = defaultFloor
|| activeFloor.value
activeFloor.value = floors.find((floor) => floor.label === activeFloor.value)?.label
|| defaultFloor
} catch (error) {
@@ -200,8 +142,148 @@ const loadGuideFloors = async () => {
}
}
const focusFacilityPoi = (poi: MuseumPoi) => {
if (!poi.floorId) return
activeMode.value = '3d'
activeFloor.value = poi.floorLabel
targetFocusRequestId.value += 1
targetFocusRequest.value = toTargetFocusRequestViewModel({
poiId: poi.id,
name: poi.name,
floorId: poi.floorId,
floorLabel: poi.floorLabel,
primaryCategoryZh: poi.primaryCategory.label,
positionGltf: poi.positionGltf,
sourceObjectName: poi.sourceObjectName
}, targetFocusRequestId.value)
}
const focusRenderPoi = (poi: GuideRenderPoi) => {
if (!poi.floorId) return
const floorLabel = guideFloors.value.find((floor) => floor.id === poi.floorId)?.label || poi.floorId
activeMode.value = '3d'
activeFloor.value = floorLabel
targetFocusRequestId.value += 1
targetFocusRequest.value = toTargetFocusRequestViewModel({
poiId: poi.id,
name: poi.name,
floorId: poi.floorId,
floorLabel,
primaryCategoryZh: poi.primaryCategoryZh,
positionGltf: poi.positionGltf,
sourceObjectName: poi.sourceObjectName,
kind: poi.kind,
hallId: poi.hallId,
hallName: poi.hallName,
entrances: poi.entrances
}, targetFocusRequestId.value)
}
const getRenderPoiFloorLabel = (poi: GuideRenderPoi) =>
guideFloors.value.find((floor) => floor.id === poi.floorId)?.label || poi.floorId
const applyRenderPoiToFacility = (poi: GuideRenderPoi, routeMessage: string) => {
const floorLabel = getRenderPoiFloorLabel(poi)
const categoryLabel = poi.primaryCategoryZh || '三维位置'
facility.value = {
id: poi.id,
name: poi.name,
status: '可预览',
location: `${floorLabel} · ${categoryLabel}`,
traffic: routeMessage,
tags: [
floorLabel,
categoryLabel,
'展示点位'
]
}
focusRenderPoi(poi)
}
const normalizePoiName = (value: string) => value
.trim()
.replace(/[\s()【】[\]_-]/g, '')
.toLowerCase()
const resolvePoiFromHall = async () => {
if (!facility.value.id) return null
const hall = await explainUseCase.getHallById(facility.value.id).catch(() => null)
const poiId = hall?.location?.poiId || hall?.poiId
return poiId ? guideUseCase.getPoiById(poiId) : null
}
const resolveFacilityPoi = async () => {
const poiById = facility.value.id
? await guideUseCase.getPoiById(facility.value.id)
: null
if (poiById) return poiById
const poiByHall = await resolvePoiFromHall()
if (poiByHall) return poiByHall
const normalizedTarget = normalizePoiName(facility.value.name)
if (!normalizedTarget) return null
const candidates = await guideUseCase.searchPois(facility.value.name)
return candidates.find((candidate) => {
const candidateName = normalizePoiName(candidate.name)
const candidateHallName = normalizePoiName(candidate.hallName || '')
return candidateName === normalizedTarget
|| candidateHallName === normalizedTarget
|| candidateName.includes(normalizedTarget)
|| normalizedTarget.includes(candidateName)
}) || candidates[0] || null
}
const resolveRenderPoiByName = async () => {
const normalizedTarget = normalizePoiName(facility.value.name)
if (!normalizedTarget) return null
const modelPackage = await indoorModelSource.value.loadPackage()
for (const floor of modelPackage.floors) {
const floorId = floor.floorId
const pois = await indoorModelSource.value.loadFloorPois(floorId).catch(() => [])
const matchedPoi = pois.find((poi) => {
const poiName = normalizePoiName(poi.name)
const hallName = normalizePoiName(poi.hallName || '')
const sourceObjectName = normalizePoiName(poi.sourceObjectName || '')
return poiName === normalizedTarget
|| hallName === normalizedTarget
|| sourceObjectName === normalizedTarget
|| poiName.includes(normalizedTarget)
|| normalizedTarget.includes(poiName)
|| sourceObjectName.includes(normalizedTarget)
|| normalizedTarget.includes(sourceObjectName)
})
if (matchedPoi) return matchedPoi
}
return null
}
const tryResolveRenderPoiByName = async () => {
try {
return await resolveRenderPoiByName()
} catch (error) {
console.warn('当前模型源点位解析失败,尝试静态资源兜底:', error)
return null
}
}
const tryResolveFacilityPoi = async () => {
try {
return await resolveFacilityPoi()
} catch (error) {
console.warn('内容点位解析失败,继续使用模型点位预览:', error)
return null
}
}
onLoad(async (options: any) => {
void loadGuideFloors()
await loadGuideFloors()
if (options.id) {
facility.value.id = options.id
@@ -211,108 +293,41 @@ onLoad(async (options: any) => {
facility.value.name = decodeURIComponent(options.target)
}
if (!facility.value.id) return
if (!facility.value.id && !normalizePoiName(facility.value.name)) return
try {
const routeReadiness = await guideUseCase.getRouteReadiness()
const poi = await guideUseCase.getPoiById(facility.value.id)
if (!poi) return
const routeReadinessMessage = guideUseCase.getRouteReadinessSnapshot().message
facility.value = toFacilityDetailViewModel(
poi,
routeReadiness.message
)
// 详情页以位置预览为主,优先使用模型点位,避免旧内容兜底覆盖真实三维点位。
const renderPoi = await tryResolveRenderPoiByName()
if (renderPoi) {
applyRenderPoiToFacility(renderPoi, routeReadinessMessage)
return
}
const poi = await tryResolveFacilityPoi()
if (poi) {
facility.value = toFacilityDetailViewModel(
poi,
routeReadinessMessage
)
focusFacilityPoi(poi)
}
} catch (error) {
console.error('加载中心线路网设施详情失败:', error)
console.error('加载设施位置详情失败:', error)
}
})
const handleSearchTap = () => {
uni.navigateTo({
url: `/pages/search/index?keyword=${encodeURIComponent(facility.value.name)}`
})
}
const handleChooseStart = () => {
syncDraftStartSelection()
isStartPanelOpen.value = true
}
const handleStartFloorChange = (floor: string) => {
selectedStartFloor.value = floor
}
const handleStartAreaChange = (area: string) => {
selectedStartArea.value = area
}
const handleCancelStartSelection = () => {
syncDraftStartSelection()
isStartPanelOpen.value = false
}
const handleConfirmStartSelection = () => {
confirmedStart.value = {
label: pendingStartLabel.value,
floor: selectedStartFloor.value,
source: 'facility-detail'
}
isStartPanelOpen.value = false
}
const handleStartNavigation = () => {
const params: Record<string, string> = {
facilityId: facility.value.id,
target: facility.value.name,
state: 'preview'
}
if (confirmedStart.value) {
params.startLabel = confirmedStart.value.label
params.startFloor = confirmedStart.value.floor
params.startSource = confirmedStart.value.source
}
const query = Object.entries(params)
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join('&')
uni.navigateTo({
url: `/pages/route/detail?${query}`
})
}
const handleModeChange = (mode: '2d' | '3d') => {
activeMode.value = mode
uni.showToast({
title: mode === '2d' ? '已切换馆外入口参考' : '已切换馆内三维位置',
icon: 'none'
})
}
const handleFloorChange = (floor: string) => {
activeFloor.value = floor
}
const handleToolClick = (tool: string) => {
if (tool === '2D') {
handleModeChange('2d')
return
}
const handleCloseDetailPanel = () => {
isDetailPanelVisible.value = false
}
if (tool === '3D') {
handleModeChange('3d')
return
}
if (tool === '重置') {
activeMode.value = '3d'
}
uni.showToast({
title: tool === '重置' ? '已回到三维位置预览' : '该工具暂未开放',
icon: 'none'
})
const handleOpenDetailPanel = () => {
isDetailPanelVisible.value = true
}
const handleTopTabChange = (tab: GuideTopTab) => {
@@ -335,86 +350,106 @@ const handlePageBack = () => {
<style scoped lang="scss">
.detail-sheet {
position: absolute;
left: 0;
right: 0;
left: 84px;
right: 16px;
bottom: calc(env(safe-area-inset-bottom) + 116px);
height: 252px;
padding: 22px 20px 20px;
min-height: 110px;
padding: 14px 14px 12px;
box-sizing: border-box;
background: #ffffff;
border: 0;
border-radius: 20px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.14);
background: rgba(255, 255, 255, 0.96);
border: 1px solid #e5e6de;
border-radius: 8px;
box-shadow: 0 10px 24px rgba(36, 49, 42, 0.12);
z-index: 45;
}
.detail-title-row {
display: flex;
align-items: flex-start;
gap: 10px;
}
.target-dot {
width: 14px;
height: 14px;
margin-top: 5px;
flex-shrink: 0;
box-sizing: border-box;
background: var(--museum-accent);
border: 3px solid #151713;
border-radius: 999px;
box-shadow: 0 0 0 4px rgba(224, 225, 0, 0.18);
}
.detail-title-copy {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.detail-close {
width: 28px;
height: 28px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
justify-content: center;
box-sizing: border-box;
background: #f4f5ef;
border: 1px solid #dde5df;
border-radius: 8px;
}
.detail-close-text {
font-size: 18px;
line-height: 20px;
font-weight: 500;
color: #30352f;
}
.detail-title {
flex: 1;
min-width: 0;
font-size: 18px;
font-size: 17px;
line-height: 24px;
font-weight: 700;
color: #000000;
color: #151713;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.open-status {
width: 60px;
flex-shrink: 0;
text-align: right;
font-size: 13px;
line-height: 18px;
font-weight: 500;
color: #1a7f37;
}
.detail-lines {
margin-top: 17px;
display: flex;
flex-direction: column;
gap: 8px;
}
.detail-line {
font-size: 13px;
line-height: 18px;
color: #6b7178;
.detail-subtitle {
font-size: 12px;
line-height: 17px;
color: #696962;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tag-row {
margin-top: 21px;
margin-top: 12px;
display: flex;
gap: 8px;
}
.detail-tag {
height: 28px;
min-width: 88px;
padding: 0 15px;
height: 26px;
min-width: 68px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #d9d9d9;
border-radius: 14px;
border: 1px solid #e5e6de;
border-radius: 8px;
}
.detail-tag.active {
background: #000000;
border-color: #000000;
background: #151713;
border-color: #151713;
}
.detail-tag-text {
@@ -428,191 +463,58 @@ const handlePageBack = () => {
color: var(--museum-accent);
}
.action-row {
position: absolute;
left: 20px;
right: 20px;
bottom: 20px;
display: flex;
gap: 16px;
}
.action-btn {
height: 44px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 8px;
}
.action-btn.secondary {
width: 151px;
background: #ffffff;
border: 1px solid #000000;
}
.action-btn.primary {
width: 168px;
background: #000000;
border: 1px solid #000000;
}
.action-text {
font-size: 14px;
line-height: 19px;
font-weight: 500;
color: #000000;
}
.action-btn.primary .action-text {
color: var(--museum-accent);
}
.start-panel-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.2);
z-index: 52;
}
.start-select-sheet {
position: absolute;
left: 0;
right: 0;
bottom: calc(env(safe-area-inset-bottom) + 116px);
height: 276px;
padding: 22px 20px 16px;
box-sizing: border-box;
background: #ffffff;
border-radius: 20px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.16);
z-index: 55;
}
.start-select-title {
.detail-hint {
display: block;
font-size: 18px;
line-height: 24px;
font-weight: 700;
color: #000000;
}
.start-select-desc {
display: block;
margin-top: 6px;
margin-top: 10px;
font-size: 12px;
line-height: 17px;
color: #626970;
}
.start-chip-section {
margin-top: 16px;
}
.start-chip-section.area {
margin-top: 14px;
}
.start-chip-label {
display: block;
margin-bottom: 8px;
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #60666d;
color: #1a7f37;
}
.start-chip-row {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
}
.start-chip {
height: 28px;
min-width: 48px;
padding: 0 13px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #f4f4ef;
border: 1px solid #e7e8e2;
border-radius: 14px;
}
.start-chip.area {
min-width: 72px;
}
.start-chip.active {
background: #000000;
border-color: #000000;
}
.start-chip-text {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #60666d;
}
.start-chip.active .start-chip-text {
color: var(--museum-accent);
}
.start-panel-actions {
.detail-restore {
position: absolute;
left: 20px;
right: 20px;
bottom: 16px;
display: flex;
gap: 16px;
}
.start-panel-btn {
height: 42px;
right: 18px;
bottom: calc(env(safe-area-inset-bottom) + 266px);
height: 40px;
min-width: 74px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.94);
border: 1px solid #dde5df;
border-radius: 8px;
box-shadow: 0 8px 18px rgba(110, 127, 115, 0.12);
z-index: 45;
}
.start-panel-btn.secondary {
width: 151px;
background: #ffffff;
border: 1px solid #000000;
.target-dot.small {
width: 10px;
height: 10px;
margin-top: 0;
border-width: 2px;
box-shadow: none;
}
.start-panel-btn.primary {
width: 168px;
background: #000000;
border: 1px solid #000000;
}
.start-panel-btn-text {
font-size: 14px;
line-height: 19px;
font-weight: 500;
color: #000000;
}
.start-panel-btn.primary .start-panel-btn-text {
color: var(--museum-accent);
.detail-restore-text {
font-size: 13px;
line-height: 18px;
font-weight: 700;
color: #151713;
}
@media (max-width: 360px) {
.start-panel-actions {
gap: 10px;
.detail-sheet {
left: 78px;
right: 12px;
}
.start-panel-btn.secondary,
.start-panel-btn.primary {
width: auto;
flex: 1;
.detail-tag {
min-width: 0;
padding: 0 9px;
}
}
</style>

View File

@@ -14,7 +14,7 @@
<text class="hall-meta">{{ hall.floorLabel }} · {{ explainCountText }}</text>
<view
class="location-action"
:class="{ disabled: !hall.poiId }"
:class="{ disabled: !hallLocationPoiId }"
@tap="handleNavigate"
>
<svg class="location-icon" width="16" height="16" viewBox="0 0 24 24" fill="none">
@@ -99,6 +99,7 @@ import {
const activeTopTab = ref<GuideTopTab>('explain')
const loading = ref(false)
const error = ref('')
const resolvedHallPoiId = ref('')
const hall = ref<HallDetailViewModel>({
id: '',
@@ -116,6 +117,37 @@ const explainCountText = computed(() => (
exhibits.value.length > 0 ? `${exhibits.value.length} 条讲解` : '暂无讲解'
))
const hallLocationPoiId = computed(() => hall.value.poiId || resolvedHallPoiId.value)
const normalizePoiName = (value: string) => value
.trim()
.replace(/[\s()【】[\]_-]/g, '')
.toLowerCase()
// 展厅数据里可能没有直接写入 poiId优先走内容层映射再按展厅名称兜底匹配导览点位。
const resolveHallGuidePoi = async () => {
const directPoiId = hall.value.location?.poiId || hall.value.poiId
if (directPoiId) {
const directPoi = await guideUseCase.getPoiById(directPoiId)
if (directPoi) return directPoi
}
const normalizedTarget = normalizePoiName(hall.value.name)
if (!normalizedTarget) return null
const candidates = await guideUseCase.searchPois(hall.value.name)
return candidates.find((candidate) => {
const candidateName = normalizePoiName(candidate.name)
const candidateHallName = normalizePoiName(candidate.hallName || '')
return candidate.kind === 'hall'
|| candidate.primaryCategory.id === 'exhibition_hall'
|| candidateName === normalizedTarget
|| candidateHallName === normalizedTarget
|| candidateName.includes(normalizedTarget)
|| normalizedTarget.includes(candidateName)
}) || candidates[0] || null
}
onLoad(async (options: any = {}) => {
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
if (isGuideTopTab(tab)) {
@@ -141,6 +173,25 @@ onLoad(async (options: any = {}) => {
hall.value = toHallDetailViewModel(hallData)
const hallExhibits = await explainUseCase.listExhibitsByHallId(hallData.id)
exhibits.value = hallExhibits.map(toExplainExhibitViewModel)
const matchedPoi = await resolveHallGuidePoi()
if (matchedPoi) {
resolvedHallPoiId.value = matchedPoi.id
hall.value = {
...hall.value,
poiId: matchedPoi.id,
location: hall.value.location || {
status: 'exact',
poiId: matchedPoi.id,
sourcePoiId: matchedPoi.id,
actionText: '查看三维位置',
previewOnly: true,
floorId: matchedPoi.floorId,
floorLabel: matchedPoi.floorLabel,
note: '已定位到该展厅的三维点位。'
}
}
}
} catch (err) {
console.error('加载展厅讲解失败:', err)
error.value = '展厅讲解加载失败,请稍后重试'
@@ -163,7 +214,8 @@ const handleExhibitClick = (exhibit: ExplainExhibitViewModel) => {
}
const handleNavigate = async () => {
if (!hall.value.poiId) {
const matchedPoi = await resolveHallGuidePoi()
if (!matchedPoi) {
uni.showToast({
title: '该展厅暂无三维位置数据',
icon: 'none'
@@ -171,17 +223,24 @@ const handleNavigate = async () => {
return
}
const matchedPoi = await guideUseCase.getPoiById(hall.value.poiId)
if (!matchedPoi) {
uni.showToast({
title: '该展厅位置尚未映射到当前三维导览资源',
icon: 'none'
})
return
resolvedHallPoiId.value = matchedPoi.id
hall.value = {
...hall.value,
poiId: matchedPoi.id,
location: hall.value.location || {
status: 'exact',
poiId: matchedPoi.id,
sourcePoiId: matchedPoi.id,
actionText: '查看三维位置',
previewOnly: true,
floorId: matchedPoi.floorId,
floorLabel: matchedPoi.floorLabel,
note: '已定位到该展厅的三维点位。'
}
}
uni.navigateTo({
url: `/pages/route/detail?facilityId=${encodeURIComponent(hall.value.poiId)}&target=${encodeURIComponent(hall.value.name)}&state=preview`
url: `/pages/route/detail?facilityId=${encodeURIComponent(matchedPoi.id)}&target=${encodeURIComponent(hall.value.name)}&state=preview`
})
}

View File

@@ -29,14 +29,14 @@
:layer-mode="indoorLayerMode"
:active-floor="activeGuideFloor"
layer-mode-toggle-top="154px"
floor-bottom="185px"
floor-bottom="153px"
floor-max-height="206px"
floor-side="left"
show-floor-header
:show-layer-mode-toggle="false"
:show-floor="is3DMode"
show-zoom-controls
zoom-controls-top="calc(100vh - 284px)"
zoom-controls-top="calc(100vh - 252px)"
:route-preview="activeRoutePreview"
:show-route="Boolean(activeRoutePreview)"
:disable-auto-exit="disableIndoorAutoExit"
@@ -71,7 +71,7 @@
<view v-if="isPoiCardCollapsed" class="poi-card-collapsed" @tap="expandPoiCard">
<view class="poi-card-copy">
<text class="poi-card-title">{{ selectedGuidePoi.name }}</text>
<text class="poi-card-subtitle">位置预览 · 点按展开操作</text>
<text class="poi-card-subtitle">{{ selectedGuidePoiCollapsedSubtitle }}</text>
</view>
<view class="poi-card-mini-action">
<text class="poi-card-mini-text">展开</text>
@@ -92,15 +92,12 @@
</view>
</view>
</view>
<view class="poi-card-actions">
<view class="poi-action primary" @tap="handlePreviewSelectedPoi">
<text class="poi-action-text">查看位置</text>
</view>
<view class="poi-action" @tap="handleSetSelectedPoiAsStart">
<text class="poi-action-text">从这里出发</text>
</view>
<view class="poi-action" @tap="handleSetSelectedPoiAsEnd">
<text class="poi-action-text">去这里</text>
<view
v-if="selectedGuidePoiIsHall"
class="poi-card-actions"
>
<view class="poi-action primary" @tap="handleViewSelectedHall">
<text class="poi-action-text">查看展厅</text>
</view>
<view class="poi-action" @tap="handleSelectedPoiRelated">
<text class="poi-action-text">相关讲解</text>
@@ -248,7 +245,8 @@ import type {
GuideRouteResult,
GuideRouteTarget,
MuseumExhibit,
MuseumFloor
MuseumFloor,
MuseumHall
} from '@/domain/museum'
import { MUSEUM_LOCATION, type TravelMode } from '@/config/museum'
import {
@@ -400,13 +398,27 @@ const selectedGuidePoiSubtitle = computed(() => {
return `${floorLabel} · ${poi.primaryCategoryZh || '位置预览'}`
})
const selectedGuidePoiIsHall = computed(() => {
const poi = selectedGuidePoi.value
return Boolean(
poi
&& (
poi.kind === 'hall'
|| poi.primaryCategory === 'touring_poi'
|| poi.primaryCategory === 'exhibition_hall'
|| poi.primaryCategory === 'exhibition_hall_entrance'
)
)
})
const selectedGuidePoiCollapsedSubtitle = computed(() => (
selectedGuidePoiIsHall.value ? '已定位展厅 · 点按展开操作' : '已定位点位'
))
const routeSimulationStepText = computed(() => {
if (!activeRoutePreview.value) return routeReady.value ? '馆内导览 · 查看路线' : '馆内导览 · 查看位置'
if (!activeRoutePreview.value) return '位置预览 · 查看位置关系'
const hasConnector = activeRoutePreview.value.connectorPoints.length > 0
if (routeReady.value) {
return hasConnector ? '馆内导览 · 跨楼层路线' : '馆内导览 · 同层路线'
}
return hasConnector ? '馆内导览 · 跨楼层位置关系' : '馆内导览 · 同层位置关系'
return hasConnector ? '位置预览 · 跨楼层位置关系' : '位置预览 · 同层位置关系'
})
const explainHallItems = ref<ExplainHallSelectItem[]>([])
@@ -667,12 +679,12 @@ const handleIndoorToolClick = (tool: string) => {
const handleGuidePoiClick = (poi: GuideRenderPoi) => {
selectedGuidePoi.value = poi
isPoiCardCollapsed.value = false
isPoiCardCollapsed.value = !selectedGuidePoiIsHall.value
indoorView.value = 'floor'
isSimulatingRoute.value = false
const matchedFloor = guideFloors.value.find((floor) => floor.id === poi.floorId)
activeGuideFloor.value = matchedFloor?.label || poi.floorId
showIndoorHint('已选中标记,可查看位置预览', 3200)
showIndoorHint('已在当前楼层标出点位', 3000)
}
const handleGuideSelectionClear = () => {
@@ -692,55 +704,84 @@ const togglePoiCardCollapsed = () => {
isPoiCardCollapsed.value = !isPoiCardCollapsed.value
}
const findRouteOptionForPoi = async (poi: GuideRenderPoi) => {
if (!routePointOptions.value.length) {
await loadRouteTargets()
const normalizeHallMatchText = (value?: string) => (value || '')
.trim()
.replace(/[\s()【】[\]_-]/g, '')
.toLowerCase()
const resolveSelectedPoiHall = async (): Promise<MuseumHall | null> => {
const poi = selectedGuidePoi.value
if (!poi) return null
const halls = await explainUseCase.listHalls()
const hallId = poi.hallId
if (hallId) {
const matchedById = halls.find((hall) => hall.id === hallId || hall.poiId === hallId)
if (matchedById) return matchedById
}
return routePointOptions.value.find((option) => option.poiId === poi.id) || null
const matchedByPoiId = halls.find((hall) => (
hall.poiId === poi.id
|| hall.location?.poiId === poi.id
|| (poi.entrances || []).some((entrance) => hall.poiId === entrance.sourcePlaceId)
))
if (matchedByPoiId) return matchedByPoiId
const poiHallName = normalizeHallMatchText(poi.hallName || poi.name)
return halls.find((hall) => {
const hallName = normalizeHallMatchText(hall.name)
return hallName && poiHallName && (hallName.includes(poiHallName) || poiHallName.includes(hallName))
}) || null
}
const setSelectedPoiAsRoutePoint = async (mode: 'start' | 'end') => {
if (!selectedGuidePoi.value) return
const navigateToHallDetail = (hallId: string) => {
uni.navigateTo({
url: `/pages/hall/detail?id=${encodeURIComponent(hallId)}&tab=explain`
})
}
const option = await findRouteOptionForPoi(selectedGuidePoi.value)
if (!option) {
const handleViewSelectedHall = async () => {
const hall = await resolveSelectedPoiHall()
if (!hall) {
uni.showToast({
title: '该点位暂无线路数据,可先查看三维位置',
title: '该展厅详情暂未接入',
icon: 'none'
})
return
}
if (mode === 'start') {
routeStartPoint.value = option
} else {
routeEndPoint.value = option
navigateToHallDetail(hall.id)
}
const handleSelectedPoiRelated = async () => {
if (!selectedGuidePoi.value) return
const hall = await resolveSelectedPoiHall()
if (hall) {
navigateToHallDetail(hall.id)
return
}
clearRoutePreviewState()
openRoutePlanner()
}
const keyword = selectedGuidePoi.value.hallName || selectedGuidePoi.value.name
const results = await explainUseCase.searchExplain(keyword)
const firstHall = results.find((item) => item.type === 'hall' && item.hallId)
const firstExhibit = results.find((item) => item.type === 'exhibit' && item.exhibitId)
const handlePreviewSelectedPoi = () => {
if (!selectedGuidePoi.value) return
indoorView.value = 'floor'
showRoutePlanner.value = false
isSimulatingRoute.value = false
}
if (firstHall?.hallId) {
navigateToHallDetail(firstHall.hallId)
return
}
const handleSetSelectedPoiAsStart = () => {
void setSelectedPoiAsRoutePoint('start')
}
if (firstExhibit?.exhibitId) {
uni.navigateTo({
url: `/pages/exhibit/detail?id=${encodeURIComponent(firstExhibit.exhibitId)}&tab=explain`
})
return
}
const handleSetSelectedPoiAsEnd = () => {
void setSelectedPoiAsRoutePoint('end')
}
const handleSelectedPoiRelated = () => {
if (!selectedGuidePoi.value) return
uni.navigateTo({
url: `/pages/search/index?keyword=${encodeURIComponent(selectedGuidePoi.value.name)}`
uni.showToast({
title: '该展厅暂无对应讲解',
icon: 'none'
})
}
@@ -769,15 +810,16 @@ const refreshRouteReadinessState = async () => {
routeReady.value = readiness.ready
routePreviewUnavailableMessage.value = readiness.ready
? ''
: readiness.message || '当前暂不支持正式导航,可查看位置预览和路线示意'
: readiness.message || '当前可查看位置预览和位置关系,暂不提供正式室内导航'
} catch (error) {
console.warn('读取路线能力状态失败:', error)
routeReady.value = false
routePreviewUnavailableMessage.value = '当前暂不支持正式导航,可查看位置预览和路线示意'
routePreviewUnavailableMessage.value = '当前可查看位置预览和位置关系,暂不提供正式室内导航'
}
}
const openRoutePlanner = () => {
closeOutdoorNavPanel()
showRoutePlanner.value = true
is3DMode.value = true
indoorView.value = indoorView.value === 'overview' ? 'floor' : indoorView.value
@@ -898,6 +940,7 @@ const handleRouteSimulate = () => {
// 进入 3D 馆内模式
const handleEnter3DMode = () => {
console.log('进入 3D 馆内模式')
closeOutdoorNavPanel()
indoorView.value = 'overview'
is3DMode.value = true
guideOutdoorState.value = 'home'
@@ -919,6 +962,7 @@ const handleModeChange = (mode: '2d' | '3d') => {
isSimulatingRoute.value = false
clearRoutePreviewState()
} else {
closeOutdoorNavPanel()
indoorView.value = 'overview'
showIndoorHint()
if (!routePointOptions.value.length) {
@@ -970,18 +1014,21 @@ const clearOutdoorNavState = () => {
outdoorNavManualMode.value = false
}
const closeOutdoorNavPanel = () => {
showOutdoorNavPanel.value = false
clearOutdoorNavState()
}
const handleMoreOutdoorNav = () => {
openOutdoorNavPanel()
}
const handleOutdoorNavClose = () => {
showOutdoorNavPanel.value = false
clearOutdoorNavState()
closeOutdoorNavPanel()
}
const handleOutdoorNavBack = () => {
showOutdoorNavPanel.value = false
clearOutdoorNavState()
closeOutdoorNavPanel()
}
const handleOutdoorNavGpsStart = () => {
@@ -1291,7 +1338,7 @@ const handleExplainBack = () => {
.poi-card-actions {
margin-top: 12px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}

View File

@@ -135,7 +135,7 @@
@tap="handleStartIndoorGuide"
>
<text class="indoor-guide-btn-text">
{{ routePlanning ? '规划中' : routeReady ? '开始导览' : '导航不可用' }}
{{ routePlanning ? '生成中' : routeReady ? '查看位置关系' : '位置关系不可用' }}
</text>
</view>
</view>
@@ -304,7 +304,7 @@ const shellConfig = computed(() => {
}
return {
searchText: routeReady.value ? `馆内导览:${route.value.target}` : `位置预览:${route.value.target}`,
searchText: `位置预览:${route.value.target}`,
searchTop: '60px',
activeMode: '3d' as GuideMode,
activeFloor: activeFloor.value,
@@ -323,17 +323,17 @@ const shellConfig = computed(() => {
})
const indoorGuideTitle = computed(() => (
routeReady.value ? `馆内导览:${route.value.target}` : `位置预览:${route.value.target}`
`位置预览:${route.value.target}`
))
const indoorGuideDesc = computed(() => {
if (activeRoutePreview.value) {
return `规划 ${activeRoutePreview.value.start.name}${activeRoutePreview.value.end.name},约 ${Math.round(activeRoutePreview.value.distanceMeters)} 米。`
return `生成 ${activeRoutePreview.value.start.name}${activeRoutePreview.value.end.name} 的位置关系,约 ${Math.round(activeRoutePreview.value.distanceMeters)} 米。`
}
return routeReady.value
? 'SGS 路线数据已就绪,可从已选起点开始规划馆内路线。'
: routeReadinessMessage.value || '当前暂不支持正式导航,可查看位置预览。'
? '可选择起点查看馆内位置关系,当前不作为正式室内导航。'
: routeReadinessMessage.value || '当前可查看位置预览,暂不提供正式室内导航。'
})
const safeDecode = (value: string) => {
@@ -635,7 +635,7 @@ const handleStartIndoorGuide = async () => {
: null)
if (!fallbackStart || !endTarget) {
routePlanError.value = '未找到可用于路线规划的起点或终点'
routePlanError.value = '未找到可用于位置关系预览的起点或终点'
return
}
@@ -645,7 +645,7 @@ const handleStartIndoorGuide = async () => {
})
if (!result.route) {
routePlanError.value = result.error || '馆内导览路线规划失败'
routePlanError.value = result.error || '馆内位置关系生成失败'
activeRoutePreview.value = null
return
}
@@ -653,7 +653,7 @@ const handleStartIndoorGuide = async () => {
activeRoutePreview.value = result.route
activeFloor.value = result.route.start.floorLabel
} catch (error) {
routePlanError.value = error instanceof Error ? error.message : '馆内导览路线规划失败'
routePlanError.value = error instanceof Error ? error.message : '馆内位置关系生成失败'
activeRoutePreview.value = null
} finally {
routePlanning.value = false