feat: wire guide data source and POI focus UI

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lyf
2026-06-12 09:25:22 +08:00
parent a6bfda30e1
commit 2055b13b90
58 changed files with 5768 additions and 1898 deletions

View File

@@ -50,7 +50,7 @@
<view class="action-btn-group">
<view class="action-btn" @tap="handleNavigate">
<text class="btn-icon">🗺</text>
<text class="btn-text">导航</text>
<text class="btn-text">查看位置</text>
</view>
<view class="action-btn" @tap="handleCollect">
<text class="btn-icon">{{ isCollected ? '❤️' : '🤍' }}</text>
@@ -70,32 +70,39 @@
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import {
explainUseCase
} from '@/usecases/explainUseCase'
import {
toExplainDetailViewModel,
type ExplainDetailViewModel
} from '@/view-models/explainViewModels'
import {
isGuideTopTab,
navigateToGuideTopTab,
type GuideTopTab
} from '@/utils/guideTopTabs'
const exhibit = ref({
id: '1',
name: '蒙娜丽莎',
artist: '列奥纳多·达·芬奇',
year: '1503-1519',
material: '木板油画',
size: '77cm × 53cm',
hall: '1号展厅 1F',
const exhibit = ref<ExplainDetailViewModel>({
id: '',
name: '展项内容',
hall: '展厅位置待补充',
image: '/static/exhibit-placeholder.jpg',
description: '《蒙娜丽莎》是意大利文艺复兴时期画家列奥纳多·达·芬奇创作的油画,现收藏于法国卢浮宫博物馆。该画作主要表现了女性的典雅和恬静的典型形象,塑造了资本主义上升时期一位城市有产阶级的妇女形象。'
description: '该展项介绍待正式内容库补充。',
hasAudio: false,
audioUnavailableReason: '正式讲解音频尚未接入媒体仓储'
})
const isPlaying = ref(false)
const isCollected = ref(false)
const activeTopTab = ref<GuideTopTab>('guide')
onLoad((options: any) => {
onLoad(async (options: any) => {
if (options.id) {
// 根据 ID 加载展品数据
console.log('加载展品:', options.id)
const exhibitData = await explainUseCase.getExhibitById(options.id)
if (exhibitData) {
exhibit.value = toExplainDetailViewModel(exhibitData)
}
}
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
@@ -104,14 +111,29 @@ onLoad((options: any) => {
}
})
const handlePlayAudio = () => {
isPlaying.value = !isPlaying.value
console.log('播放/暂停音频')
const handlePlayAudio = async () => {
const selection = exhibit.value.id
? await explainUseCase.selectAudioForExhibit(exhibit.value.id)
: null
uni.showToast({
title: selection?.unavailableMessage || exhibit.value.audioUnavailableReason || '该展项暂无讲解音频',
icon: 'none'
})
}
const handleNavigate = () => {
console.log('导航到展品位置')
uni.navigateBack()
if (!exhibit.value.poiId) {
uni.showToast({
title: '该展项暂无三维位置数据',
icon: 'none'
})
return
}
uni.navigateTo({
url: `/pages/route/detail?facilityId=${encodeURIComponent(exhibit.value.poiId)}&target=${encodeURIComponent(exhibit.value.name)}&state=preview`
})
}
const handleCollect = () => {

View File

@@ -15,6 +15,9 @@
floor-top="208px"
tools-top="450px"
:tools="['回正', '2D']"
:indoor-model-source="indoorModelSource"
:floors="guideFloors"
:normalize-floor-id="normalizeGuideFloorId"
@search-tap="handleSearchTap"
@mode-change="handleModeChange"
@floor-change="handleFloorChange"
@@ -115,16 +118,19 @@ import { onLoad } from '@dcloudio/uni-app'
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
import {
NAV_FLOOR_OPTIONS,
NAV_ROUTE_UNAVAILABLE_MESSAGE,
formatNavFloorLabel,
isPoiAccessible,
loadCleanNavPoiById
} from '@/services/navAssets'
guideUseCase
} from '@/usecases/guideUseCase'
import {
toFacilityDetailViewModel,
type FacilityDetailViewModel
} from '@/view-models/guideViewModels'
import {
navigateToGuideTopTab,
type GuideTopTab
} from '@/utils/guideTopTabs'
import type {
MuseumFloor
} from '@/domain/museum'
interface ConfirmedStart {
label: string
@@ -132,19 +138,23 @@ interface ConfirmedStart {
source: 'facility-detail'
}
const facility = ref({
const indoorModelSource = guideUseCase.getModelSource()
const guideFloors = ref<MuseumFloor[]>([])
const normalizeGuideFloorId = (labelOrId: string) => guideUseCase.normalizeFloorId(labelOrId)
const facility = ref<FacilityDetailViewModel>({
id: '',
name: '目标位置',
status: '可预览',
location: 'clean 导览数据点位',
traffic: NAV_ROUTE_UNAVAILABLE_MESSAGE,
traffic: guideUseCase.getRouteReadiness().message,
tags: ['三维位置', 'clean 数据']
})
const isStartPanelOpen = ref(false)
const selectedStartFloor = ref('1F')
const selectedStartArea = ref('服务台附近')
const confirmedStart = ref<ConfirmedStart | null>(null)
const startFloors = NAV_FLOOR_OPTIONS.map((floor) => floor.label)
const startFloors = computed(() => guideFloors.value.map((floor) => floor.label))
const startAreas = ['主入口', '服务台附近', '停车场入口']
const pendingStartLabel = computed(() => `${selectedStartFloor.value} ${selectedStartArea.value}`)
@@ -165,7 +175,23 @@ const syncDraftStartSelection = () => {
selectedStartArea.value = resolveConfirmedStartArea()
}
const loadGuideFloors = async () => {
try {
const floors = await guideUseCase.getFloors()
guideFloors.value = floors
selectedStartFloor.value = floors.find((floor) => floor.label === selectedStartFloor.value)?.label
|| floors.find((floor) => floor.id === 'L1')?.label
|| floors[0]?.label
|| selectedStartFloor.value
} catch (error) {
console.error('加载导览楼层失败:', error)
guideFloors.value = []
}
}
onLoad(async (options: any) => {
void loadGuideFloors()
if (options.id) {
facility.value.id = options.id
}
@@ -177,22 +203,13 @@ onLoad(async (options: any) => {
if (!facility.value.id) return
try {
const poi = await loadCleanNavPoiById(facility.value.id)
const poi = await guideUseCase.getPoiById(facility.value.id)
if (!poi) return
const floor = formatNavFloorLabel(poi.floorId)
facility.value = {
id: poi.id,
name: poi.name,
status: '可预览',
location: `${floor} · ${poi.primaryCategoryZh}`,
traffic: NAV_ROUTE_UNAVAILABLE_MESSAGE,
tags: [
floor,
poi.primaryCategoryZh,
isPoiAccessible(poi) ? '无障碍相关' : '展示点位'
]
}
facility.value = toFacilityDetailViewModel(
poi,
guideUseCase.getRouteReadiness().message
)
} catch (error) {
console.error('加载 clean 设施详情失败:', error)
}

View File

@@ -10,7 +10,7 @@
<view class="hall-hero">
<image class="hero-image" :src="hall.image" mode="aspectFill" />
<view class="hall-badge">
<text class="badge-text">{{ hall.floor }}</text>
<text class="badge-text">{{ hall.floorLabel }}</text>
</view>
</view>
@@ -48,7 +48,7 @@
<!-- 底部操作栏 -->
<view class="action-bar">
<view class="action-btn primary" @tap="handleNavigate">
<text class="btn-text">导航到展厅</text>
<text class="btn-text">查看展厅位置</text>
</view>
</view>
</view>
@@ -60,6 +60,15 @@ import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import ExhibitCard from '@/components/content/ExhibitCard.vue'
import {
explainUseCase
} from '@/usecases/explainUseCase'
import {
toExplainExhibitViewModel,
toHallDetailViewModel,
type ExplainExhibitViewModel,
type HallDetailViewModel
} from '@/view-models/explainViewModels'
import {
isGuideTopTab,
navigateToGuideTopTab,
@@ -68,24 +77,26 @@ import {
const activeTopTab = ref<GuideTopTab>('guide')
const hall = ref({
id: '1',
name: '1号展厅',
floor: '1F',
description: '文艺复兴时期艺术作品展厅,展示了欧洲文艺复兴时期最具代表性的绘画和雕塑作品。',
const hall = ref<HallDetailViewModel>({
id: '',
name: '展厅内容',
floorLabel: '楼层待补充',
description: '该展厅介绍待正式内容库补充。',
image: '/static/hall-placeholder.jpg',
exhibitCount: 25,
area: '500㎡'
exhibitCount: 0,
area: '待补充'
})
const exhibits = ref([
{ id: '1', name: '蒙娜丽莎', artist: '达芬奇', hasAudio: true },
{ id: '2', name: '最后的晚餐', artist: '达芬奇', hasAudio: true }
])
const exhibits = ref<ExplainExhibitViewModel[]>([])
onLoad((options: any) => {
onLoad(async (options: any) => {
if (options.id) {
console.log('加载展厅:', options.id)
const hallData = await explainUseCase.getHallById(options.id)
if (hallData) {
hall.value = toHallDetailViewModel(hallData)
const hallExhibits = await explainUseCase.listExhibitsByHallId(hallData.id)
exhibits.value = hallExhibits.map(toExplainExhibitViewModel)
}
}
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
@@ -101,8 +112,17 @@ const handleExhibitClick = (exhibit: any) => {
}
const handleNavigate = () => {
console.log('导航到展厅')
uni.navigateBack()
if (!hall.value.poiId) {
uni.showToast({
title: '该展厅暂无三维位置数据',
icon: 'none'
})
return
}
uni.navigateTo({
url: `/pages/route/detail?facilityId=${encodeURIComponent(hall.value.poiId)}&target=${encodeURIComponent(hall.value.name)}&state=preview`
})
}
const handleTopTabChange = (tab: GuideTopTab) => {

View File

@@ -16,6 +16,9 @@
:map-type="guideMapType"
:outdoor-variant="guideOutdoorVariant"
:indoor-asset-base-url="indoorNavAssetBaseUrl"
:indoor-model-source="indoorModelSource"
:floors="guideFloors"
:normalize-floor-id="normalizeGuideFloorId"
indoor-initial-view="overview"
:indoor-view="indoorView"
:active-floor="activeGuideFloor"
@@ -80,6 +83,7 @@
<view v-else-if="currentTab === 'explain'" class="explain-page">
<ExplainList
:exhibits="explainExhibits"
@exhibit-click="handleExplainExhibitClick"
@featured-click="handleExplainFeaturedClick"
@audio-click="handleAudioClick"
@@ -109,7 +113,7 @@ import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
import ExplainList from '@/components/explain/ExplainList.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 {
@@ -117,9 +121,20 @@ import {
type GuideTopTab
} from '@/utils/guideTopTabs'
import {
NAV_ASSET_BASE_URL,
NAV_ROUTE_UNAVAILABLE_MESSAGE
} from '@/services/navAssets'
} from '@/domain/guideReadiness'
import {
guideUseCase
} from '@/usecases/guideUseCase'
import {
explainUseCase
} from '@/usecases/explainUseCase'
import {
toExplainExhibitViewModel
} from '@/view-models/explainViewModels'
import type {
MuseumFloor
} from '@/domain/museum'
// 3D 模式状态
const is3DMode = ref(false)
@@ -135,7 +150,10 @@ const showMarkerDetail = ref(false)
const showAreaSelector = ref(false)
const selectedAreaId = ref('service')
const indoorNavAssetBaseUrl = NAV_ASSET_BASE_URL
const indoorNavAssetBaseUrl = guideUseCase.getAssetBaseUrl()
const indoorModelSource = guideUseCase.getModelSource()
const guideFloors = ref<MuseumFloor[]>([])
const normalizeGuideFloorId = (labelOrId: string) => guideUseCase.normalizeFloorId(labelOrId)
const shortcutItems = [
{ id: 'toilet', name: '卫生间', abbr: '卫' },
@@ -149,6 +167,7 @@ const shortcutItems = [
const showAudioPlayer = ref(false)
const isAudioPlaying = ref(false)
const currentAudio = ref<AudioItem | null>(null)
const explainExhibits = ref<Exhibit[]>([])
// 选中的标记详情
interface MarkerDetail {
@@ -188,8 +207,35 @@ onLoad((options: any = {}) => {
if (isGuideTopTab(tab)) {
currentTab.value = tab
}
void loadGuideFloors()
void loadExplainExhibits()
})
const loadGuideFloors = async () => {
try {
const floors = await guideUseCase.getFloors()
guideFloors.value = floors
activeGuideFloor.value = floors.find((floor) => floor.label === activeGuideFloor.value)?.label
|| floors.find((floor) => floor.id === 'L1')?.label
|| floors[0]?.label
|| activeGuideFloor.value
} catch (error) {
console.error('加载导览楼层失败:', error)
guideFloors.value = []
}
}
const loadExplainExhibits = async () => {
try {
const exhibits = await explainUseCase.listExhibits()
explainExhibits.value = exhibits.map(toExplainExhibitViewModel)
} catch (error) {
console.error('加载讲解内容失败:', error)
explainExhibits.value = []
}
}
// 搜索处理
const handleSearchFocus = () => {
searchBarFocused.value = true
@@ -391,13 +437,23 @@ const guideSearchText = computed(() => {
})
// 音频相关处理
const handleAudioClick = (exhibit: any) => {
console.log('点击音频播放:', exhibit)
const handleAudioClick = async (exhibit: Exhibit) => {
const selection = await explainUseCase.selectAudioForExhibit(exhibit.id)
if (!selection?.playable || !selection.media?.url) {
uni.showToast({
title: selection?.unavailableMessage || exhibit.audioUnavailableReason || '该展项暂无讲解音频',
icon: 'none'
})
return
}
currentAudio.value = {
id: exhibit.id,
name: exhibit.name,
audioUrl: exhibit.audioUrl || 'https://example.com/audio.mp3',
image: exhibit.image
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
}
@@ -431,30 +487,14 @@ const handleAudioEnded = (audio: AudioItem) => {
}
// 讲解页面处理
const handleExplainExhibitClick = (exhibit: any) => {
console.log('点击讲解展品:', exhibit)
const handleExplainExhibitClick = (exhibit: Exhibit) => {
uni.navigateTo({
url: `/pages/exhibit/detail?id=${exhibit.id}&tab=explain`
})
}
const handleExplainFeaturedClick = (exhibit: any) => {
console.log('点击推荐讲解:', exhibit)
// 播放音频讲解
if (exhibit.hasAudio) {
currentAudio.value = {
id: exhibit.id,
name: exhibit.name,
audioUrl: exhibit.audioUrl || 'https://example.com/audio.mp3',
image: exhibit.image
}
showAudioPlayer.value = true
} else {
uni.showToast({
title: '该展品暂无讲解音频',
icon: 'none'
})
}
const handleExplainFeaturedClick = async (exhibit: Exhibit) => {
await handleAudioClick(exhibit)
}
</script>

View File

@@ -10,6 +10,9 @@
>
<GuideMapShell
v-bind="shellConfig"
:indoor-model-source="indoorModelSource"
:floors="guideFloors"
:normalize-floor-id="normalizeGuideFloorId"
:target-focus-request="targetFocusRequest"
@search-tap="handleSearchTap"
@mode-change="handleModeChange"
@@ -125,12 +128,18 @@ import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
import LocationPreview from '@/components/navigation/LocationPreview.vue'
import {
NAV_FLOOR_OPTIONS,
NAV_ROUTE_UNAVAILABLE_MESSAGE,
formatNavFloorLabel,
loadCleanNavPoiById,
type CleanNavPoi
} from '@/services/navAssets'
guideUseCase
} from '@/usecases/guideUseCase'
import {
initialLocationPreviewPlan,
toTargetFocusRequestViewModel
} from '@/view-models/guideViewModels'
import type {
GuideLocationPreview,
GuideLocationPreviewPlan,
GuideStartLocation,
MuseumFloor
} from '@/domain/museum'
import {
navigateToGuideTopTab,
type GuideTopTab
@@ -138,28 +147,11 @@ import {
type RouteViewState = 'preview' | 'location-error' | 'outdoor-preview'
type GuideMode = '2d' | '3d'
interface RouteStep {
id: string
text: string
}
interface RouteTargetLocation {
poiId: string
name: string
floorId: string
floorLabel?: string
primaryCategoryZh?: string
positionGltf?: [number, number, number]
}
interface RouteStartLocation {
label: string
floor?: string
source?: string
}
type RouteTargetLocation = GuideLocationPreview
type RouteStartLocation = GuideStartLocation
interface TargetPoiFocusRequest extends RouteTargetLocation {
requestId: number
requestId: number | string
}
interface TargetPoiFocusResult {
@@ -170,104 +162,44 @@ interface TargetPoiFocusResult {
message?: string
}
interface RoutePlan {
id: string
facilityId: string
target: string
summary: string
steps: RouteStep[]
targetLocation: RouteTargetLocation | null
startLocation: RouteStartLocation | null
}
type RoutePlan = GuideLocationPreviewPlan
const routeDeepLinkStates: RouteViewState[] = ['preview', 'outdoor-preview']
const indoorModelSource = guideUseCase.getModelSource()
const guideFloors = ref<MuseumFloor[]>([])
const normalizeGuideFloorId = (labelOrId: string) => guideUseCase.normalizeFloorId(labelOrId)
const startSourceLabels: Record<string, string> = {
'facility-detail': '设施详情选择',
manual: '手动选择'
}
const formatStartSourceLabel = (source?: string) => (
source ? startSourceLabels[source] || source : ''
)
const formatStartStepText = (startLocation: RouteStartLocation) => {
const sourceLabel = formatStartSourceLabel(startLocation.source)
const floorSuffix = startLocation.floor && !startLocation.label.includes(startLocation.floor)
? ` · ${startLocation.floor}`
: ''
const sourceSuffix = sourceLabel ? `${sourceLabel}` : ''
return `起点:${startLocation.label}${floorSuffix}${sourceSuffix}`
}
const createPreviewSteps = (
poi: CleanNavPoi | null,
startLocation: RouteStartLocation | null
): RouteStep[] => {
if (startLocation) {
return [
{
id: 'start',
text: formatStartStepText(startLocation)
},
{
id: 'target',
text: poi
? `目标:${formatNavFloorLabel(poi.floorId)} · ${poi.primaryCategoryZh} · 位置预览`
: '目标来自 clean 导览数据,暂仅支持位置预览'
}
]
}
return [
{
id: 'floor',
text: poi ? `目标位于 ${formatNavFloorLabel(poi.floorId)} · ${poi.primaryCategoryZh}` : '目标来自 clean 导览数据'
},
{
id: 'status',
text: NAV_ROUTE_UNAVAILABLE_MESSAGE
}
]
}
const createRoutePlan = (
facilityId: string,
target: string,
poi: CleanNavPoi | null = null,
startLocation: RouteStartLocation | null = null
): RoutePlan => ({
id: `route-${facilityId || 'custom'}`,
facilityId,
target,
summary: poi
? `${formatNavFloorLabel(poi.floorId)} · ${poi.primaryCategoryZh} · 位置预览`
: '位置预览 · 尚未接入正式路线图',
steps: createPreviewSteps(poi, startLocation),
targetLocation: poi ? {
poiId: poi.id,
name: poi.name,
floorId: poi.floorId,
floorLabel: formatNavFloorLabel(poi.floorId),
primaryCategoryZh: poi.primaryCategoryZh,
positionGltf: poi.positionGltf
} : null,
startLocation
})
const route = ref<RoutePlan>(createRoutePlan('', '目标地点'))
const route = ref<RoutePlan>(initialLocationPreviewPlan())
const hasRouteTarget = ref(false)
const routeViewState = ref<RouteViewState>('preview')
const activeFloor = ref('1F')
const targetFocusRequest = ref<TargetPoiFocusRequest | null>(null)
const targetFocusRequestId = ref(0)
const manualFloors = NAV_FLOOR_OPTIONS.map((floor) => floor.label)
const manualFloors = computed(() => guideFloors.value.map((floor) => floor.label))
const guideFloorLabel = (floorId: string) => (
guideFloors.value.find((floor) => floor.id === floorId)?.label || floorId
)
const defaultGuideFloorLabel = () => (
guideFloors.value.find((floor) => floor.id === 'L1')?.label
|| guideFloors.value[0]?.label
|| '1F'
)
const loadGuideFloors = async () => {
if (guideFloors.value.length) return guideFloors.value
try {
guideFloors.value = await guideUseCase.getFloors()
} catch (error) {
console.error('加载导览楼层失败:', error)
guideFloors.value = []
}
return guideFloors.value
}
const manualAreas = ['主入口', '服务台附近', '停车场入口']
const isManualLocationPanelOpen = ref(false)
const selectedManualFloor = ref('1F')
const selectedManualArea = ref('服务台附近')
const normalizeRouteOption = (value: unknown) => {
if (Array.isArray(value)) {
return value[0]
@@ -380,7 +312,36 @@ const createStartLocationFromOptions = (options: Record<string, unknown>) => {
}
}
const requestTargetFocus = (target: Partial<RouteTargetLocation> | null, fallbackName = route.value.target) => {
if (!target?.poiId || !target.floorId) {
targetFocusRequest.value = null
return false
}
const floorLabel = target.floorLabel || guideFloorLabel(target.floorId)
const focusTarget: RouteTargetLocation = {
poiId: target.poiId,
name: target.name || fallbackName,
floorId: target.floorId,
floorLabel,
primaryCategoryZh: target.primaryCategoryZh,
positionGltf: target.positionGltf
}
activeFloor.value = floorLabel
routeViewState.value = 'preview'
targetFocusRequestId.value += 1
targetFocusRequest.value = toTargetFocusRequestViewModel(
focusTarget,
targetFocusRequestId.value
)
return true
}
const applyRouteOptions = async (options: Record<string, unknown> = {}) => {
await loadGuideFloors()
const facilityId = normalizeStringOption(options.facilityId)
const target = normalizeStringOption(options.target)
const state = normalizeRouteOption(options.state)
@@ -388,39 +349,42 @@ const applyRouteOptions = async (options: Record<string, unknown> = {}) => {
if (!facilityId && !target) {
hasRouteTarget.value = false
route.value = createRoutePlan('', '目标地点')
route.value = initialLocationPreviewPlan()
routeViewState.value = 'preview'
activeFloor.value = '1F'
activeFloor.value = defaultGuideFloorLabel()
targetFocusRequest.value = null
isManualLocationPanelOpen.value = false
return false
}
const matchedPoi = facilityId ? await loadCleanNavPoiById(facilityId) : null
const matchedPoi = facilityId ? await guideUseCase.getPoiById(facilityId) : null
if (!matchedPoi) {
hasRouteTarget.value = false
route.value = createRoutePlan('', target || '目标地点')
route.value = await guideUseCase.createLocationPreviewPlan('', target || '目标地点', startLocation)
routeViewState.value = 'preview'
activeFloor.value = startLocation?.floor || '1F'
activeFloor.value = startLocation?.floor || defaultGuideFloorLabel()
targetFocusRequest.value = null
isManualLocationPanelOpen.value = false
return false
}
route.value = createRoutePlan(
route.value = await guideUseCase.createLocationPreviewPlan(
facilityId,
target || matchedPoi.name,
matchedPoi,
startLocation
)
hasRouteTarget.value = true
routeViewState.value = isRouteViewState(state) ? state : 'preview'
activeFloor.value = matchedPoi ? formatNavFloorLabel(matchedPoi.floorId) : startLocation?.floor || '1F'
targetFocusRequest.value = null
isManualLocationPanelOpen.value = false
if (routeViewState.value === 'preview') {
requestTargetFocus(route.value.targetLocation, matchedPoi.name)
} else {
activeFloor.value = matchedPoi.floorLabel || guideFloorLabel(matchedPoi.floorId)
}
return true
}
@@ -479,23 +443,14 @@ const handleViewOutdoorMap = () => {
routeViewState.value = 'outdoor-preview'
}
const handleShowTargetLocation = (target: RouteTargetLocation | null) => {
const targetLocation = target || route.value.targetLocation
const handleShowTargetLocation = (target: Partial<RouteTargetLocation> | null) => {
const focused = requestTargetFocus(target || route.value.targetLocation)
if (!targetLocation?.poiId || !targetLocation.floorId) {
if (!focused) {
uni.showToast({
title: '目标暂无三维位置数据',
icon: 'none'
})
return
}
routeViewState.value = 'preview'
activeFloor.value = targetLocation.floorLabel || formatNavFloorLabel(targetLocation.floorId)
targetFocusRequestId.value += 1
targetFocusRequest.value = {
...targetLocation,
requestId: targetFocusRequestId.value
}
}
@@ -511,6 +466,7 @@ const handleTargetFocus = (result: TargetPoiFocusResult) => {
const handleReturnToPreview = () => {
isManualLocationPanelOpen.value = false
routeViewState.value = 'preview'
requestTargetFocus(route.value.targetLocation)
}
const handleRelocate = () => {
@@ -540,39 +496,24 @@ const createManualStartLocation = (): RouteStartLocation => ({
source: 'manual'
})
const createCurrentTargetStep = (): RouteStep => {
const targetLocation = route.value.targetLocation
return {
id: 'target',
text: targetLocation
? `目标:${targetLocation.floorLabel || formatNavFloorLabel(targetLocation.floorId)} · ${targetLocation.primaryCategoryZh || route.value.target} · 位置预览`
: '目标来自 clean 导览数据,暂仅支持位置预览'
}
}
const handleConfirmLocation = () => {
const handleConfirmLocation = async () => {
const startLocation = createManualStartLocation()
route.value = {
...route.value,
startLocation,
steps: [
{
id: 'start',
text: formatStartStepText(startLocation)
},
createCurrentTargetStep()
]
}
route.value = await guideUseCase.createLocationPreviewPlan(
route.value.facilityId,
route.value.target,
startLocation
)
activeFloor.value = startLocation.floor || activeFloor.value
isManualLocationPanelOpen.value = false
routeViewState.value = 'preview'
requestTargetFocus(route.value.targetLocation)
}
const handleModeChange = (mode: GuideMode) => {
if (routeViewState.value === 'outdoor-preview' && mode === '3d') {
routeViewState.value = 'preview'
requestTargetFocus(route.value.targetLocation)
return
}
@@ -596,7 +537,7 @@ const handleToolClick = (tool: string) => {
}
uni.showToast({
title: tool === '回正' ? '已回到三维总览' : NAV_ROUTE_UNAVAILABLE_MESSAGE,
title: tool === '回正' ? '已回到三维总览' : guideUseCase.getRouteReadiness().message,
icon: 'none'
})
}

View File

@@ -13,6 +13,9 @@
search-top="60px"
mode-top="104px"
floor-top="208px"
:indoor-model-source="indoorModelSource"
:floors="guideFloors"
:normalize-floor-id="normalizeGuideFloorId"
@search-tap="handleSearchTap"
@mode-change="handleModeChange"
@floor-change="handleFloorChange"
@@ -100,31 +103,31 @@ import { onLoad } from '@dcloudio/uni-app'
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
import {
formatNavFloorLabel,
isPoiAccessible,
searchCleanNavPois,
type CleanNavPoi
} from '@/services/navAssets'
guideUseCase
} from '@/usecases/guideUseCase'
import {
toFacilityResultViewModel,
type FacilityResultViewModel
} from '@/view-models/guideViewModels'
import {
navigateToGuideTopTab,
type GuideTopTab
} from '@/utils/guideTopTabs'
import type {
MuseumFloor
} from '@/domain/museum'
interface FilterItem {
id: 'all' | 'accessible' | 'floor'
label: string
}
interface FacilityResult {
id: string
name: string
floor: string
meta: string
accessible: boolean
action: '查看'
}
interface FacilityResult extends FacilityResultViewModel {}
const searchKeyword = ref('卫生间')
const indoorModelSource = guideUseCase.getModelSource()
const guideFloors = ref<MuseumFloor[]>([])
const normalizeGuideFloorId = (labelOrId: string) => guideUseCase.normalizeFloorId(labelOrId)
const searchDraftKeyword = ref('')
const currentFilter = ref<FilterItem['id']>('all')
const isLoading = ref(false)
@@ -140,18 +143,6 @@ const filters: FilterItem[] = [
const facilities = ref<FacilityResult[]>([])
const toFacilityResult = (poi: CleanNavPoi): FacilityResult => {
const floor = formatNavFloorLabel(poi.floorId)
return {
id: poi.id,
name: poi.name,
floor,
meta: `${floor}${poi.primaryCategoryZh}${poi.navigationReadiness || '展示点位'}`,
accessible: isPoiAccessible(poi),
action: '查看'
}
}
const visibleFacilities = computed(() => {
if (currentFilter.value === 'accessible') {
return facilities.value.filter((item) => item.accessible)
@@ -170,10 +161,10 @@ const resultTitle = computed(() => {
const loadFacilityResults = async () => {
isLoading.value = true
try {
const pois = await searchCleanNavPois(searchKeyword.value)
const pois = await guideUseCase.searchPois(searchKeyword.value)
facilities.value = pois
.filter((poi) => poi.primaryCategory !== 'touring_poi')
.map(toFacilityResult)
.filter((poi) => poi.primaryCategory.id !== 'touring_poi')
.map(toFacilityResultViewModel)
} catch (error) {
console.error('加载 clean 导览搜索结果失败:', error)
facilities.value = []
@@ -186,11 +177,21 @@ const loadFacilityResults = async () => {
}
}
const loadGuideFloors = async () => {
try {
guideFloors.value = await guideUseCase.getFloors()
} catch (error) {
console.error('加载导览楼层失败:', error)
guideFloors.value = []
}
}
onLoad((options: any) => {
if (options.keyword) {
searchKeyword.value = decodeURIComponent(options.keyword)
}
searchDraftKeyword.value = searchKeyword.value
void loadGuideFloors()
void loadFacilityResults()
})