Files
frontend-miniapp/src/pages/index/index.vue

2551 lines
67 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="currentTab"
variant="overlay"
:show-top-tabs="false"
@tab-change="handleTabChange"
>
<GuideMapShell
v-if="currentTab === 'guide'"
ref="guideMapShellRef"
:search-text="guideSearchText"
:active-mode="guideShellMode"
:mode-layout="'status'"
:mode-status="guideStatusLabel"
mode-status-tone="glass"
search-top="20px"
search-variant="home"
:show-search="false"
:show-mode-row="false"
mode-top="80px"
: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"
:layer-mode="indoorLayerMode"
:active-floor="activeGuideFloor"
layer-mode-toggle-top="154px"
floor-bottom="153px"
floor-max-height="206px"
floor-side="left"
show-floor-header
:show-layer-mode-toggle="false"
:show-floor="is3DMode && !showRoutePlanner"
show-zoom-controls
zoom-controls-top="calc(100vh - 292px)"
:route-preview="activeRoutePreview"
:show-route="Boolean(activeRoutePreview)"
:disable-auto-exit="disableIndoorAutoExit"
:auto-switch-threshold-low="0.58"
:auto-switch-threshold-high="1.18"
:outdoor-nav-polylines="outdoorNavPolylines"
@mode-change="handleModeChange"
@floor-request="handleFloorRequest"
@floor-change="handleFloorChange"
@floor-switch-failed="handleFloorSwitchFailed"
@indoor-view-change="handleIndoorViewChange"
@poi-click="handleGuidePoiClick"
@selection-clear="handleGuideSelectionClear"
@auto-switch="handleAutoSwitch"
@initial-model-progress="handleInitialModelProgress"
@initial-model-ready="handleInitialModelReady"
@initial-model-failed="handleInitialModelFailed"
@tool-click="handleIndoorToolClick"
@map-tap="handleMapTapForOutdoorNav"
>
<template #overlay>
<view v-if="guideOutdoorState === 'entrance' && !is3DMode" class="entrance-tip">
<text class="entrance-tip-text">主入口 4 分钟</text>
</view>
<view v-if="is3DMode && showIndoorGestureHint" class="indoor-gesture-hint">
<text class="indoor-gesture-text">{{ indoorGestureHintText }}</text>
</view>
</template>
<view
v-if="selectedGuidePoi && !showRoutePlanner && !isSimulatingRoute"
class="guide-poi-card"
:class="{ collapsed: isPoiCardCollapsed }"
>
<view class="poi-card-handle" @tap.stop="togglePoiCardCollapsed"></view>
<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">{{ selectedGuidePoiCollapsedSubtitle }}</text>
</view>
<view class="poi-card-mini-action">
<text class="poi-card-mini-text">展开</text>
</view>
</view>
<template v-else>
<view class="poi-card-header">
<view class="poi-card-copy">
<text class="poi-card-title">{{ selectedGuidePoi.name }}</text>
<text class="poi-card-subtitle">{{ selectedGuidePoiSubtitle }}</text>
</view>
<view class="poi-card-header-actions">
<view class="poi-card-soft-action" @tap="collapsePoiCard">
<text class="poi-card-soft-text">收起</text>
</view>
<view class="poi-card-close" @tap="handleGuideSelectionClear">
<text class="poi-card-close-text">×</text>
</view>
</view>
</view>
<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>
</view>
</view>
</template>
</view>
<view
v-if="showGuideFloatingActions"
class="guide-quick-actions"
>
<view class="guide-quick-action primary" @tap="handleMoreRouteGuide">
<view class="guide-quick-icon indoor">
<view class="guide-quick-icon-floor top"></view>
<view class="guide-quick-icon-floor bottom"></view>
</view>
<text class="guide-quick-action-text primary">馆内</text>
</view>
<view class="guide-quick-action" @tap="handleMoreOutdoorNav">
<view class="guide-quick-icon arrival">
<view class="guide-quick-pin"></view>
</view>
<text class="guide-quick-action-text">来馆</text>
</view>
<view class="guide-quick-action" @tap="handleExplainQuickTap">
<view class="guide-quick-icon explain">
<view class="guide-quick-explain-wave top"></view>
<view class="guide-quick-explain-wave middle"></view>
<view class="guide-quick-explain-wave bottom"></view>
</view>
<text class="guide-quick-action-text">讲解</text>
</view>
</view>
<view
v-if="showGuideHomeDock"
v-show="homeSearchExpanded"
class="guide-home-search-scrim"
@mousedown.stop.prevent="handleHomeSearchOutsideTap"
@click.stop="handleHomeSearchOutsideTap"
@touchstart.stop.prevent="handleHomeSearchOutsideTap"
@tap.stop="handleHomeSearchOutsideTap"
></view>
<view
v-if="showGuideHomeDock"
class="guide-home-dock"
:class="{ expanded: homeSearchExpanded }"
@mousedown.capture="handleHomeDockActivate"
@click.capture="handleHomeDockActivate"
@tap.capture="handleHomeDockActivate"
>
<PoiSearchPanel
ref="homeSearchPanelRef"
variant="home"
@expanded-change="handleHomeSearchExpandedChange"
/>
</view>
<RoutePlannerPanel
v-if="is3DMode"
:visible="showRoutePlanner"
:start-point="routeStartPoint"
:end-point="routeEndPoint"
:point-options="routePointOptions"
:has-route-preview="Boolean(activeRoutePreview)"
:loading="routePlanning"
:error="routePanelError"
:summary="routePlanSummary"
:picker-loading="routeTargetsLoading"
:picker-error="routeTargetsError"
:route-ready="routeReady"
@update:start-point="handleRouteStartPointChange"
@update:end-point="handleRouteEndPointChange"
@picker-open="handleRoutePickerOpen"
@search="handleRoutePickerSearch"
@swap="handleRouteSwap"
@clear="handleRouteClear"
@view-route="handleRouteView"
@simulate-guide="handleRouteSimulate"
@back="handleRouteBack"
/>
<view v-if="isSimulatingRoute && activeRoutePreview" class="route-sim-top-pill">
<text class="route-sim-top-text">
{{ activeRoutePreview.start.name }} {{ activeRoutePreview.end.name }}
</text>
</view>
<view v-if="isSimulatingRoute && activeRoutePreview" class="route-sim-step-bar">
<text class="route-sim-step-text">{{ routeSimulationStepText }}</text>
<view class="route-sim-expand" @tap="showRoutePlanner = true">
<text class="route-sim-expand-text">展开预览</text>
</view>
</view>
<view v-if="guideOutdoorState === 'entrance' && !is3DMode" class="entrance-bottom-card">
<text class="entrance-card-title">主入口</text>
<text class="entrance-card-desc">
可参考主入口位置也可手动进入馆内3D并选择楼层/区域
</text>
<view class="entrance-actions">
<view class="entrance-btn secondary" @tap="handleSwitchEntrance">
<text class="entrance-btn-text">切换入口</text>
</view>
<view class="entrance-btn primary" @tap="handleEnter3DMode">
<text class="entrance-btn-text">进入馆内3D</text>
</view>
</view>
</view>
</GuideMapShell>
<OutdoorNavigationPanel
v-if="currentTab === 'guide' && isOutdoorGuidePanelEnabled"
:visible="showOutdoorNavPanel"
:loading="outdoorNavLoading"
:route-error="outdoorNavError"
:route-distance="outdoorNavRoute?.distance ?? 0"
:route-duration="outdoorNavRoute?.duration ?? 0"
:selecting-manual-start="outdoorNavSelectingManualStart"
:start-point="outdoorNavStartPoint ? { ...outdoorNavStartPoint, name: '已选位置' } : null"
:start-mode="outdoorNavManualMode || outdoorNavStartPoint ? 'manual' : 'gps'"
@close="handleOutdoorNavClose"
@gps-start="handleOutdoorNavGpsStart"
@manual-start-request="handleOutdoorNavManualStartRequest"
@manual-start-cancel="handleOutdoorNavManualStartCancel"
@manual-start-confirm="handleOutdoorNavManualStartConfirm"
@travel-mode-change="handleOutdoorNavTravelModeChange"
@start-navigation="handleOutdoorNavStartNavigation"
@clear-route="handleOutdoorNavClearRoute"
@back="handleOutdoorNavBack"
/>
<view v-else-if="currentTab === 'explain'" class="explain-page">
<ExplainHallSelect
:halls="explainHallItems"
:business-units="explainBusinessUnitItems"
:guide-stops="explainGuideStopItems"
:stage="explainStage"
:selected-hall-name="selectedExplainHallName"
:selected-business-unit-name="selectedExplainBusinessUnitName"
:loading="explainLoading"
:error="explainError"
@hall-click="handleExplainHallClick"
@business-unit-click="handleExplainBusinessUnitClick"
@guide-stop-click="handleExplainGuideStopClick"
@back="handleExplainBack"
/>
</view>
<view
v-if="launchOverlayVisible"
class="app-launch-overlay"
:class="{ leaving: launchOverlayLeaving }"
>
<view class="app-launch-stage">
<image
class="app-launch-image"
src="/static/guide/app-launch-loading.webp"
mode="aspectFill"
/>
<view class="app-launch-shade"></view>
<view class="app-launch-brand">
<text class="app-launch-kicker">探索万物生息</text>
<text class="app-launch-title">深圳自然博物馆</text>
<text class="app-launch-subtitle">Shenzhen Natural History Museum</text>
</view>
<view class="app-launch-status">
<text class="app-launch-loading-text">{{ launchLoadingText }}</text>
<view class="app-launch-progress">
<view class="app-launch-progress-bar" :style="launchProgressStyle"></view>
</view>
<view v-if="launchOverlayRecoverable" class="app-launch-actions">
<view class="app-launch-action secondary" @tap="handleLaunchRetry">
<text class="app-launch-action-text">重新加载</text>
</view>
<view class="app-launch-action primary" @tap="handleLaunchContinue">
<text class="app-launch-action-text">继续进入</text>
</view>
</view>
</view>
</view>
</view>
</GuidePageFrame>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
import RoutePlannerPanel from '@/components/navigation/RoutePlannerPanel.vue'
import OutdoorNavigationPanel from '@/components/navigation/OutdoorNavigationPanel.vue'
import PoiSearchPanel from '@/components/search/PoiSearchPanel.vue'
import type {
RoutePointOption
} from '@/components/navigation/RoutePointPicker.vue'
import ExplainHallSelect, {
type ExplainBusinessUnitSelectItem,
type ExplainGuideStopSelectItem,
type ExplainSelectStage,
type ExplainHallSelectItem
} from '@/components/explain/ExplainHallSelect.vue'
import {
getLastGuideLocationPreviewUrl,
isGuideTopTab,
type GuideTopTab
} from '@/utils/guideTopTabs'
import {
guideUseCase
} from '@/usecases/guideUseCase'
import {
guideRouteUseCase
} from '@/usecases/guideRouteUseCase'
import {
explainUseCase
} from '@/usecases/explainUseCase'
import {
normalizeExplainDetailTargetFromGuideStop
} from '@/domain/explainDetailTarget'
import type {
GuideRenderPoi
} from '@/domain/guideModel'
import type {
GuideRouteResult,
GuideRouteTarget,
ExplainBusinessUnit,
MuseumFloor,
MuseumHall
} from '@/domain/museum'
import { MUSEUM_LOCATION, type TravelMode } from '@/config/museum'
import {
planWalkingRoute,
planDrivingRoute,
planTransitRoute,
type Route,
type RoutePoint
} from '@/services/tencent/TencentMapService'
import {
navigateToMuseum
} from '@/services/MapNavigationService'
type GuideIndoorView = 'overview' | 'floor' | 'multi'
type LayerDisplayMode = 'single' | 'multi'
const isIndexHashRoute = () => {
if (typeof window === 'undefined') return false
const hash = window.location.hash || ''
return (
!hash
|| hash === '#'
|| hash === '#/'
|| hash.startsWith('#/?')
|| hash.startsWith('#/pages/index/index')
)
}
const topTabFromHash = () => {
if (typeof window === 'undefined') return null
if (!isIndexHashRoute()) return null
const queryString = window.location.hash.split('?')[1] || ''
const tab = new URLSearchParams(queryString).get('tab')
return isGuideTopTab(tab) ? tab : null
}
const launchOverlayMinDisplayMs = 700
const launchOverlayGuideTimeoutMs = 12000
const launchOverlayNonGuideTimeoutMs = 900
const shouldShowLaunchOverlay = () => {
if (typeof window === 'undefined') return false
return isIndexHashRoute()
}
const initialTopTab = (): GuideTopTab => topTabFromHash() || 'guide'
// 3D 模式状态
const is3DMode = ref(true)
const guideOutdoorState = ref<'home' | 'entrance'>('home')
const indoorView = ref<GuideIndoorView>('overview')
const activeGuideFloor = ref('')
const requestedFloorId = ref('')
const requestedFloorLabel = ref('')
const loadingFloorId = ref('')
const renderedFloorId = ref('')
const failedFloorId = ref('')
const selectedGuidePoi = ref<GuideRenderPoi | null>(null)
// 状态
const currentTab = ref<GuideTopTab>(initialTopTab())
const launchOverlayVisible = ref(shouldShowLaunchOverlay())
const launchOverlayLeaving = ref(false)
const launchProgress = ref(8)
const launchLoadingText = ref('正在初始化三维场景')
const launchInitialModelSettled = ref(false)
const launchOverlayRecoverable = ref(false)
const showRoutePlanner = ref(false)
const routeStartPoint = ref<RoutePointOption | null>(null)
const routeEndPoint = ref<RoutePointOption | null>(null)
const routePointOptions = ref<RoutePointOption[]>([])
const routeTargetsLoading = ref(false)
const routeTargetsError = ref('')
const routePlanning = ref(false)
const routePlanError = ref('')
const routePreviewUnavailableMessage = ref('')
const routeReady = ref(false)
const activeRoutePreview = ref<GuideRouteResult | null>(null)
const isSimulatingRoute = ref(false)
const isPoiCardCollapsed = ref(false)
// GuideMapShell 组件引用,用于显式调用路线清除方法
const guideMapShellRef = ref<{
clearRoute?: () => void
} | null>(null)
const homeSearchPanelRef = ref<{
expandHomePanel?: () => void
collapseHomePanel?: () => void
} | null>(null)
const homeSearchExpanded = ref(false)
let launchOverlayFallbackTimer: ReturnType<typeof setTimeout> | null = null
let launchOverlayFadeTimer: ReturnType<typeof setTimeout> | null = null
let launchOverlayHideDelayTimer: ReturnType<typeof setTimeout> | null = null
let launchOverlayStartedAt = 0
const launchProgressStyle = computed(() => ({
width: `${launchProgress.value}%`
}))
const clearLaunchTimers = () => {
if (launchOverlayFallbackTimer) {
clearTimeout(launchOverlayFallbackTimer)
launchOverlayFallbackTimer = null
}
if (launchOverlayFadeTimer) {
clearTimeout(launchOverlayFadeTimer)
launchOverlayFadeTimer = null
}
if (launchOverlayHideDelayTimer) {
clearTimeout(launchOverlayHideDelayTimer)
launchOverlayHideDelayTimer = null
}
}
const completeHideLaunchOverlay = () => {
if (!launchOverlayVisible.value) return
launchProgress.value = 100
launchOverlayLeaving.value = true
clearLaunchTimers()
launchOverlayFadeTimer = setTimeout(() => {
launchOverlayVisible.value = false
launchOverlayLeaving.value = false
launchOverlayFadeTimer = null
}, 360)
}
const hideLaunchOverlay = () => {
if (!launchOverlayVisible.value || launchOverlayLeaving.value) return
const elapsedMs = launchOverlayStartedAt ? Date.now() - launchOverlayStartedAt : launchOverlayMinDisplayMs
const waitMs = Math.max(0, launchOverlayMinDisplayMs - elapsedMs)
if (waitMs > 0) {
if (launchOverlayHideDelayTimer) clearTimeout(launchOverlayHideDelayTimer)
launchOverlayHideDelayTimer = setTimeout(() => {
launchOverlayHideDelayTimer = null
completeHideLaunchOverlay()
}, waitMs)
return
}
completeHideLaunchOverlay()
}
const shouldWaitForLaunchModel = () => (
currentTab.value === 'guide'
&& is3DMode.value
)
const startLaunchOverlay = () => {
if (!launchOverlayVisible.value) return
clearLaunchTimers()
launchInitialModelSettled.value = false
launchOverlayRecoverable.value = false
launchOverlayStartedAt = Date.now()
launchProgress.value = shouldWaitForLaunchModel() ? 8 : 68
launchLoadingText.value = shouldWaitForLaunchModel()
? '正在初始化三维场景'
: '正在进入自然导览'
const timeoutMs = shouldWaitForLaunchModel()
? launchOverlayGuideTimeoutMs
: launchOverlayNonGuideTimeoutMs
launchOverlayFallbackTimer = setTimeout(() => {
if (shouldWaitForLaunchModel() && !launchInitialModelSettled.value) {
launchOverlayRecoverable.value = true
launchProgress.value = Math.min(96, Math.max(launchProgress.value, 92))
launchLoadingText.value = '三维场景加载较慢,仍在等待模型就绪'
console.warn(`首页前置加载页等待模型超过 ${timeoutMs}ms继续等待真实模型 ready`)
return
}
hideLaunchOverlay()
}, timeoutMs)
}
const handleInitialModelProgress = (event: { progress: number; message: string; view: GuideIndoorView; floorId?: string; elapsedMs?: number }) => {
if (!launchOverlayVisible.value || launchInitialModelSettled.value || !shouldWaitForLaunchModel()) return
launchOverlayRecoverable.value = false
launchProgress.value = Math.min(99, Math.max(8, event.progress))
launchLoadingText.value = event.message || '正在加载三维场景'
}
const settleLaunchOverlayByModel = (status: 'ready' | 'failed', event?: { view: GuideIndoorView; floorId?: string; message?: string; elapsedMs?: number }) => {
launchInitialModelSettled.value = true
launchOverlayRecoverable.value = status === 'failed'
if (status === 'failed') {
launchProgress.value = Math.min(launchProgress.value, 96)
launchLoadingText.value = event?.message || '三维场景加载失败'
console.warn('首页前置加载页收到初始模型加载失败,等待用户选择重试或进入降级首页:', event)
return
} else {
launchProgress.value = 100
launchLoadingText.value = '三维场景已就绪'
console.log('首页前置加载页收到初始模型加载完成:', event)
}
hideLaunchOverlay()
}
const handleLaunchRetry = () => {
if (typeof window !== 'undefined') {
window.location.reload()
}
}
const handleLaunchContinue = () => {
launchOverlayRecoverable.value = false
hideLaunchOverlay()
}
// 馆外导航相关状态
const isOutdoorGuidePanelEnabled = false
const showOutdoorNavPanel = ref(false)
const outdoorNavLoading = ref(false)
const outdoorNavError = ref('')
const outdoorNavRoute = ref<Route | null>(null)
const outdoorNavPolylines = ref<Array<{ points: Array<{ latitude: number; longitude: number }>; color: string; width: number }>>([])
const outdoorNavStartPoint = ref<{ latitude: number; longitude: number } | null>(null)
const outdoorNavTravelMode = ref<TravelMode>('walking')
const outdoorNavSelectingManualStart = ref(false)
const outdoorNavManualMode = ref(false)
const indoorNavAssetBaseUrl = guideUseCase.getAssetBaseUrl()
const indoorModelSource = guideUseCase.getModelSource()
const guideFloors = ref<MuseumFloor[]>([])
const normalizeGuideFloorId = (labelOrId: string) => guideUseCase.normalizeFloorId(labelOrId)
const getGuideFloorById = (floorId: string) => (
guideFloors.value.find((floor) => floor.id === floorId)
)
const getGuideFloorLabel = (floorId: string) => (
getGuideFloorById(floorId)?.label || floorId
)
const indoorLayerMode = computed<LayerDisplayMode>(() => (
indoorView.value === 'multi' ? 'multi' : 'single'
))
const showIndoorGestureHint = ref(true)
const indoorGestureHintText = ref('单指旋转 · 双指缩放/平移 · 点选标记查看位置')
let indoorGestureHintTimer: ReturnType<typeof setTimeout> | null = null
const showIndoorHint = (text = '单指旋转 · 双指缩放/平移 · 点选标记查看位置', durationMs = 5200) => {
indoorGestureHintText.value = text
showIndoorGestureHint.value = is3DMode.value
if (indoorGestureHintTimer) {
clearTimeout(indoorGestureHintTimer)
}
indoorGestureHintTimer = setTimeout(() => {
showIndoorGestureHint.value = false
indoorGestureHintTimer = null
}, durationMs)
}
const routePlanSummary = computed(() => {
if (activeRoutePreview.value) {
const floorCount = new Set(activeRoutePreview.value.floorSegments.map((segment) => segment.floorId)).size
const floorText = floorCount > 1 ? `${floorCount}` : activeRoutePreview.value.start.floorLabel
return `${floorText} · 约 ${Math.round(activeRoutePreview.value.distanceMeters)}`
}
if (routeStartPoint.value && routeEndPoint.value) {
return `${routeStartPoint.value.floorLabel}${routeEndPoint.value.floorLabel}`
}
return ''
})
const routePanelError = computed(() => routePlanError.value)
const disableIndoorAutoExit = computed(() => (
Boolean(selectedGuidePoi.value)
|| Boolean(activeRoutePreview.value)
|| showRoutePlanner.value
|| isSimulatingRoute.value
))
const showGuideHomeDock = computed(() => (
currentTab.value === 'guide'
&& !showRoutePlanner.value
&& !showOutdoorNavPanel.value
&& !selectedGuidePoi.value
&& !isSimulatingRoute.value
&& !(guideOutdoorState.value === 'entrance' && !is3DMode.value)
))
const showGuideFloatingActions = computed(() => (
currentTab.value === 'guide'
&& !showRoutePlanner.value
&& !showOutdoorNavPanel.value
&& !selectedGuidePoi.value
&& !isSimulatingRoute.value
&& (!showGuideHomeDock.value || !homeSearchExpanded.value)
))
const toRoutePointOption = (target: GuideRouteTarget): RoutePointOption => ({
poiId: target.poiId,
name: target.name,
floorId: target.floorId,
floorLabel: target.floorLabel,
categoryLabel: target.categoryLabel
})
const selectedGuidePoiSubtitle = computed(() => {
const poi = selectedGuidePoi.value
if (!poi) return ''
const floor = guideFloors.value.find((item) => item.id === poi.floorId)
const floorLabel = floor?.label || poi.floorId
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 '位置预览 · 查看位置关系'
const hasConnector = activeRoutePreview.value.connectorPoints.length > 0
return hasConnector ? '位置预览 · 跨楼层位置关系' : '位置预览 · 同层位置关系'
})
const explainHallItems = ref<ExplainHallSelectItem[]>([])
const explainBusinessUnits = ref<ExplainBusinessUnit[]>([])
const explainStage = ref<ExplainSelectStage>('hall')
const selectedExplainHallId = ref('')
const selectedExplainHallName = ref('')
const selectedExplainBusinessUnitId = ref('')
const selectedExplainBusinessUnitName = ref('')
const explainLoading = ref(false)
const explainError = ref('')
let explainLoadPromise: Promise<void> | null = null
const selectedExplainBusinessUnit = computed(() => (
explainBusinessUnits.value.find((unit) => unit.id === selectedExplainBusinessUnitId.value) || null
))
const explainBusinessUnitItems = computed<ExplainBusinessUnitSelectItem[]>(() => (
explainBusinessUnits.value.map((unit) => ({
id: unit.id,
name: unit.name,
hallId: unit.hallId,
previewImageUrl: unit.previewImageUrl,
guideStopCount: unit.guideStopCount
}))
))
const explainGuideStopItems = computed<ExplainGuideStopSelectItem[]>(() => (
selectedExplainBusinessUnit.value?.stops.map((stop) => ({
id: stop.id,
name: stop.name,
hallId: stop.hallId,
hallName: stop.hallName,
floorId: stop.floorId,
coverImageUrl: stop.coverImageUrl,
description: stop.description,
hasAudio: stop.hasAudio,
audioStatus: stop.audioStatus,
guideLevel: stop.guideLevel
})) || []
))
// Tab 切换处理
const syncTopTabToUrl = (tabId: GuideTopTab) => {
if (typeof window === 'undefined') return
if (!isIndexHashRoute()) return
const url = `#/pages/index/index?tab=${tabId}`
if (window.location.hash !== url) {
window.history.replaceState(window.history.state, '', url)
}
}
const loadTopTabData = (tabId: GuideTopTab) => {
if (tabId === 'explain') {
void loadExplainHalls()
} else {
void loadGuideFloors()
}
}
const handleTabChange = (tabId: GuideTopTab) => {
if (tabId === 'guide' && currentTab.value === 'explain') {
const lastLocationPreviewUrl = getLastGuideLocationPreviewUrl()
if (lastLocationPreviewUrl) {
uni.reLaunch({
url: lastLocationPreviewUrl
})
return
}
}
currentTab.value = tabId
syncTopTabToUrl(tabId)
loadTopTabData(tabId)
console.log('切换标签:', tabId)
// 关闭可能打开的面板
showRoutePlanner.value = false
isSimulatingRoute.value = false
isPoiCardCollapsed.value = false
showOutdoorNavPanel.value = false
clearOutdoorNavState()
}
const syncTopTabFromHash = () => {
if (!isIndexHashRoute()) return
const tab = topTabFromHash()
if (tab) {
const tabChanged = currentTab.value !== tab
currentTab.value = tab
loadTopTabData(tab)
if (tabChanged) {
showRoutePlanner.value = false
isSimulatingRoute.value = false
isPoiCardCollapsed.value = false
}
syncTopTabToUrl(tab)
return
}
syncTopTabToUrl(currentTab.value)
loadTopTabData(currentTab.value)
}
onMounted(() => {
startLaunchOverlay()
showIndoorHint()
if (typeof window === 'undefined') return
syncTopTabFromHash()
window.addEventListener('hashchange', syncTopTabFromHash)
})
onUnmounted(() => {
clearLaunchTimers()
if (indoorGestureHintTimer) {
clearTimeout(indoorGestureHintTimer)
indoorGestureHintTimer = null
}
if (typeof window === 'undefined') return
window.removeEventListener('hashchange', syncTopTabFromHash)
})
onLoad((options: any = {}) => {
const routeTab = Array.isArray(options.tab) ? options.tab[0] : options.tab
const tab = topTabFromHash() || routeTab
if (isGuideTopTab(tab)) {
currentTab.value = tab
} else {
syncTopTabToUrl(currentTab.value)
}
loadTopTabData(currentTab.value)
})
const loadGuideFloors = async () => {
try {
const floors = await guideUseCase.getFloors()
guideFloors.value = floors
const normalizedActiveFloorId = activeGuideFloor.value
? normalizeGuideFloorId(activeGuideFloor.value)
: ''
activeGuideFloor.value = floors.find((floor) => floor.id === normalizedActiveFloorId)?.id
|| floors.find((floor) => floor.label === '1F')?.id
|| floors[0]?.id
|| activeGuideFloor.value
renderedFloorId.value = activeGuideFloor.value
} catch (error) {
console.error('加载导览楼层失败:', error)
guideFloors.value = []
}
}
const loadExplainHalls = async () => {
if (explainHallItems.value.length) return
if (explainLoadPromise) return explainLoadPromise
explainLoading.value = true
explainError.value = ''
explainLoadPromise = (async () => {
try {
const halls = await explainUseCase.loadExplainHalls()
const summaries = await explainUseCase.loadExplainHallSummaries(halls.map((hall) => hall.id))
explainHallItems.value = buildExplainHallItems(halls, summaries)
} catch (error) {
console.error('加载讲解内容失败:', error)
explainError.value = '讲解内容加载失败,请稍后重试'
explainHallItems.value = []
} finally {
explainLoading.value = false
explainLoadPromise = null
}
})()
return explainLoadPromise
}
const normalizeExplainKeyword = (value?: string) => (value || '').trim().toLowerCase()
const buildExplainHallItems = (
halls: Awaited<ReturnType<typeof explainUseCase.loadExplainHalls>>,
summaries: Awaited<ReturnType<typeof explainUseCase.loadExplainHallSummaries>> = {}
): ExplainHallSelectItem[] => {
return halls.map((hall) => {
const keywordParts = [
hall.name,
hall.floorLabel,
hall.description,
hall.area
].filter(Boolean) as string[]
return {
id: hall.id,
name: hall.name,
floorLabel: hall.floorLabel,
image: hall.image,
explainCount: hall.exhibitCount || 0,
businessUnitCount: summaries[hall.id]?.businessUnitCount,
guideStopCount: summaries[hall.id]?.guideStopCount,
searchText: normalizeExplainKeyword(keywordParts.join(' '))
}
})
}
const handleFloorRequest = ({ floorId, floorLabel }: { floorId: string; floorLabel: string }) => {
requestedFloorId.value = floorId
requestedFloorLabel.value = floorLabel
loadingFloorId.value = floorId
failedFloorId.value = ''
}
const handleFloorChange = (floorId: string) => {
activeGuideFloor.value = floorId
renderedFloorId.value = floorId
if (loadingFloorId.value === floorId) {
loadingFloorId.value = ''
}
if (failedFloorId.value === floorId) {
failedFloorId.value = ''
}
indoorView.value = 'floor'
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
showIndoorHint(`已切换到 ${getGuideFloorLabel(floorId)},单指旋转、双指可平移`, 3200)
console.log('楼层渲染完成:', floorId)
}
const handleFloorSwitchFailed = ({ floorId, floorLabel }: { floorId: string; floorLabel: string }) => {
if (loadingFloorId.value === floorId) {
loadingFloorId.value = ''
}
failedFloorId.value = floorId
showIndoorHint(`${floorLabel || getGuideFloorLabel(floorId)} 加载失败,请稍后重试`, 3600)
console.warn('楼层渲染失败:', floorId)
}
const handleIndoorViewChange = (view: GuideIndoorView) => {
indoorView.value = view
if (view !== 'floor') {
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
}
if (view === 'multi') {
showIndoorHint('已切换多层视图,可点选楼层查看', 3200)
} else if (view === 'floor') {
showIndoorHint('已切换单层视图,可点选标记查看位置', 3200)
}
}
const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: string; distance: number }) => {
console.log('自动切换视图:', event)
indoorView.value = event.to
if (event.to !== 'floor') {
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
}
showIndoorHint(event.to === 'floor' ? '已进入单层视图,可点选标记查看位置' : '已返回建筑外观', 3600)
}
const handleInitialModelReady = (event: { view: GuideIndoorView; floorId?: string; elapsedMs?: number }) => {
settleLaunchOverlayByModel('ready', event)
}
const handleInitialModelFailed = (event: { view: GuideIndoorView; floorId?: string; message: string; elapsedMs?: number }) => {
settleLaunchOverlayByModel('failed', event)
}
const handleIndoorToolClick = (tool: string) => {
if (!is3DMode.value) return
if (tool === '复位') {
showIndoorHint('已复位视角,单指旋转、双指可平移', 2800)
} else if (tool === '俯视') {
showIndoorHint('已切换俯视视角,可点选标记查看位置', 2800)
} else if (tool === '斜视') {
showIndoorHint('已切换斜视视角,可查看空间层次', 2800)
}
}
const handleGuidePoiClick = (poi: GuideRenderPoi) => {
selectedGuidePoi.value = poi
isPoiCardCollapsed.value = !selectedGuidePoiIsHall.value
indoorView.value = 'floor'
isSimulatingRoute.value = false
activeGuideFloor.value = poi.floorId
renderedFloorId.value = poi.floorId
showIndoorHint('已在当前楼层标出点位', 3000)
}
const handleGuideSelectionClear = () => {
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
}
const collapsePoiCard = () => {
isPoiCardCollapsed.value = true
}
const expandPoiCard = () => {
isPoiCardCollapsed.value = false
}
const togglePoiCardCollapsed = () => {
isPoiCardCollapsed.value = !isPoiCardCollapsed.value
}
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 matchedByPoiId = halls.find((hall) => (
hall.poiId === poi.id
|| hall.location?.poiId === poi.id
))
if (matchedByPoiId) return matchedByPoiId
const hallId = poi.hallId
if (hallId) {
const matchedById = halls.find((hall) => hall.id === hallId)
if (matchedById) return matchedById
}
const matchedByEntrance = halls.find((hall) => (
(poi.entrances || []).some((entrance) => (
hall.poiId === entrance.sourcePlaceId
|| hall.poiId === entrance.id
))
))
if (matchedByEntrance) return matchedByEntrance
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 navigateToHallDetail = (hallId: string) => {
uni.navigateTo({
url: `/pages/hall/detail?id=${encodeURIComponent(hallId)}&tab=explain`
})
}
const handleViewSelectedHall = async () => {
const hall = await resolveSelectedPoiHall()
if (!hall) {
uni.showToast({
title: '该展厅详情暂未接入',
icon: 'none'
})
return
}
navigateToHallDetail(hall.id)
}
const handleSelectedPoiRelated = async () => {
if (!selectedGuidePoi.value) return
const hall = await resolveSelectedPoiHall()
if (hall) {
navigateToHallDetail(hall.id)
return
}
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)
if (firstHall?.hallId) {
navigateToHallDetail(firstHall.hallId)
return
}
if (firstExhibit?.exhibitId) {
uni.navigateTo({
url: `/pages/exhibit/detail?id=${encodeURIComponent(firstExhibit.exhibitId)}&tab=explain`
})
return
}
uni.showToast({
title: '该展厅暂无对应讲解',
icon: 'none'
})
}
const loadRouteTargets = async (keyword = '') => {
routeTargetsLoading.value = true
routeTargetsError.value = ''
try {
const targets = keyword
? await guideRouteUseCase.searchTargets(keyword)
: await guideRouteUseCase.listTargets()
const options = targets.map(toRoutePointOption)
routePointOptions.value = options
} catch (error) {
console.error('加载路线目标失败:', error)
routeTargetsError.value = error instanceof Error ? error.message : '路线目标加载失败'
routePointOptions.value = []
} finally {
routeTargetsLoading.value = false
}
}
const refreshRouteReadinessState = async () => {
try {
const readiness = await guideRouteUseCase.getRouteReadiness()
routeReady.value = readiness.ready
routePreviewUnavailableMessage.value = readiness.ready
? ''
: readiness.message || '当前可查看位置预览和位置关系,暂不提供正式室内导航'
} catch (error) {
console.warn('读取路线能力状态失败:', error)
routeReady.value = false
routePreviewUnavailableMessage.value = '当前可查看位置预览和位置关系,暂不提供正式室内导航'
}
}
const clearRoutePreviewState = () => {
guideMapShellRef.value?.clearRoute?.()
activeRoutePreview.value = null
routePlanError.value = ''
isSimulatingRoute.value = false
}
const handleRouteClear = () => {
routeStartPoint.value = null
routeEndPoint.value = null
showRoutePlanner.value = false
clearRoutePreviewState()
}
const handleRouteBack = () => {
if (activeRoutePreview.value) {
activeRoutePreview.value = null
routePlanError.value = ''
isSimulatingRoute.value = false
showRoutePlanner.value = true
return
}
showRoutePlanner.value = false
isSimulatingRoute.value = false
}
const handleRouteStartPointChange = (point: RoutePointOption | null) => {
routeStartPoint.value = point
clearRoutePreviewState()
}
const handleRouteEndPointChange = (point: RoutePointOption | null) => {
routeEndPoint.value = point
clearRoutePreviewState()
}
const handleRouteSwap = () => {
const nextStart = routeEndPoint.value
routeEndPoint.value = routeStartPoint.value
routeStartPoint.value = nextStart
clearRoutePreviewState()
}
const handleRoutePickerOpen = () => {
if (!routePointOptions.value.length) {
void loadRouteTargets()
}
}
const handleRoutePickerSearch = ({ keyword }: { mode: 'start' | 'end'; keyword: string }) => {
void loadRouteTargets(keyword)
}
const focusRouteFloor = (route: GuideRouteResult) => {
const routeFloorCount = new Set(route.floorSegments.map((segment) => segment.floorId)).size
activeGuideFloor.value = route.start.floorId
renderedFloorId.value = route.start.floorId
indoorView.value = routeFloorCount > 1 ? 'multi' : 'floor'
}
const handleRouteView = async ({ startPoint, endPoint }: { startPoint: RoutePointOption; endPoint: RoutePointOption }) => {
routePlanning.value = true
routePlanError.value = ''
isSimulatingRoute.value = false
try {
await refreshRouteReadinessState()
if (routePreviewUnavailableMessage.value) {
activeRoutePreview.value = null
routePlanError.value = routePreviewUnavailableMessage.value
return
}
const result = await guideRouteUseCase.planRoute({
startPoiId: startPoint.poiId,
endPoiId: endPoint.poiId
})
if (!result.route) {
activeRoutePreview.value = null
routePlanError.value = result.error || '馆内导览位置关系生成失败'
return
}
activeRoutePreview.value = result.route
focusRouteFloor(result.route)
showRoutePlanner.value = true
} catch (error) {
console.error('馆内导览位置关系生成失败:', error)
activeRoutePreview.value = null
routePlanError.value = error instanceof Error ? error.message : '馆内导览位置关系生成失败'
} finally {
routePlanning.value = false
}
}
const handleRouteSimulate = () => {
if (!activeRoutePreview.value) return
showRoutePlanner.value = false
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
isSimulatingRoute.value = true
}
// 进入 3D 馆内模式
const handleEnter3DMode = () => {
console.log('进入 3D 馆内模式')
closeOutdoorNavPanel()
indoorView.value = 'overview'
is3DMode.value = true
guideOutdoorState.value = 'home'
showIndoorHint()
if (!routePointOptions.value.length) {
void loadRouteTargets()
}
}
const handleModeChange = (mode: '2d' | '3d') => {
is3DMode.value = mode === '3d'
if (mode === '2d') {
indoorView.value = 'overview'
guideOutdoorState.value = 'home'
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
showRoutePlanner.value = false
isSimulatingRoute.value = false
clearRoutePreviewState()
} else {
closeOutdoorNavPanel()
indoorView.value = 'overview'
showIndoorHint()
if (!routePointOptions.value.length) {
void loadRouteTargets()
}
}
}
const guideStatusLabel = computed(() => {
if (is3DMode.value) {
if (indoorView.value === 'overview') return '建筑外观'
if (indoorView.value === 'multi') return '馆内多层'
if (loadingFloorId.value) {
return `切换${requestedFloorLabel.value || getGuideFloorLabel(requestedFloorId.value || loadingFloorId.value)}`
}
if (failedFloorId.value) {
return `${getGuideFloorLabel(failedFloorId.value)}失败`
}
return getGuideFloorLabel(renderedFloorId.value || activeGuideFloor.value) || '馆内单层'
}
if (guideOutdoorState.value === 'entrance') {
return '推荐入口'
}
return '馆外参考'
})
const handleSwitchEntrance = () => {
guideOutdoorState.value = 'home'
}
const handleMoreRouteGuide = () => {
closeOutdoorNavPanel()
showRoutePlanner.value = false
is3DMode.value = true
indoorView.value = 'floor'
guideOutdoorState.value = 'home'
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
isSimulatingRoute.value = false
showIndoorHint(`当前楼层:${getGuideFloorLabel(renderedFloorId.value || activeGuideFloor.value) || '馆内单层'}`, 3200)
}
// 馆外导航处理函数
const openOutdoorNavPanel = () => {
if (!isOutdoorGuidePanelEnabled) {
showOutdoorNavPanel.value = false
showRoutePlanner.value = false
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
is3DMode.value = false
clearOutdoorNavState()
return
}
showOutdoorNavPanel.value = true
showRoutePlanner.value = false
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
// 切换到馆外 2D 地图
is3DMode.value = false
clearOutdoorNavState()
}
const clearOutdoorNavState = () => {
outdoorNavRoute.value = null
outdoorNavPolylines.value = []
outdoorNavStartPoint.value = null
outdoorNavError.value = ''
outdoorNavLoading.value = false
outdoorNavSelectingManualStart.value = false
outdoorNavManualMode.value = false
}
const closeOutdoorNavPanel = () => {
showOutdoorNavPanel.value = false
clearOutdoorNavState()
}
const handleMoreOutdoorNav = () => {
openOutdoorNavPanel()
}
const handleExplainQuickTap = () => {
handleTabChange('explain')
}
const handleHomeSearchExpandedChange = (expanded: boolean) => {
homeSearchExpanded.value = expanded
}
const handleHomeSearchOutsideTap = () => {
homeSearchPanelRef.value?.collapseHomePanel?.()
homeSearchExpanded.value = false
}
const handleHomeDockActivate = () => {
if (homeSearchExpanded.value) return
homeSearchPanelRef.value?.expandHomePanel?.()
homeSearchExpanded.value = true
}
const handleOutdoorNavClose = () => {
closeOutdoorNavPanel()
}
const handleOutdoorNavBack = () => {
closeOutdoorNavPanel()
}
const handleOutdoorNavGpsStart = () => {
outdoorNavLoading.value = true
outdoorNavError.value = ''
uni.getLocation({
type: 'gcj02',
success: (res) => {
console.log('GPS 定位成功:', res)
outdoorNavStartPoint.value = {
latitude: res.latitude,
longitude: res.longitude
}
void planOutdoorRoute({
latitude: res.latitude,
longitude: res.longitude
})
},
fail: (err) => {
console.error('GPS 定位失败:', err)
const errMsg = err.errMsg || ''
if (errMsg.includes('auth deny') || errMsg.includes('auth reject')) {
outdoorNavError.value = '定位权限被拒绝,请在设置中开启'
} else if (errMsg.includes('timeout')) {
outdoorNavError.value = '定位超时,请重试'
} else if (errMsg.includes('unavailable')) {
outdoorNavError.value = '位置服务不可用,请检查设备设置'
} else {
outdoorNavError.value = '定位失败,请检查位置权限'
}
outdoorNavLoading.value = false
}
})
}
const handleOutdoorNavManualStartRequest = () => {
outdoorNavManualMode.value = true
outdoorNavSelectingManualStart.value = true
uni.showToast({
title: '输入地址搜索,或点击地图选择起点',
icon: 'none'
})
}
const handleOutdoorNavManualStartCancel = () => {
outdoorNavManualMode.value = false
outdoorNavSelectingManualStart.value = false
}
const handleOutdoorNavManualStartConfirm = (location: { latitude: number; longitude: number }) => {
outdoorNavStartPoint.value = location
outdoorNavManualMode.value = false
outdoorNavSelectingManualStart.value = false
void planOutdoorRoute(location)
}
const handleOutdoorNavTravelModeChange = (mode: TravelMode) => {
outdoorNavTravelMode.value = mode
if (outdoorNavStartPoint.value) {
outdoorNavLoading.value = true
void planOutdoorRoute(outdoorNavStartPoint.value)
}
}
const planOutdoorRoute = async (from: { latitude: number; longitude: number }) => {
outdoorNavLoading.value = true
outdoorNavError.value = ''
const to: RoutePoint = {
latitude: MUSEUM_LOCATION.latitude,
longitude: MUSEUM_LOCATION.longitude
}
try {
let result
switch (outdoorNavTravelMode.value) {
case 'walking':
result = await planWalkingRoute(from, to)
break
case 'driving':
result = await planDrivingRoute(from, to)
break
case 'transit':
result = await planTransitRoute(from, to)
break
default:
result = await planWalkingRoute(from, to)
}
if (result.status === 0 && result.routes && result.routes.length > 0) {
const route = result.routes[0]
outdoorNavRoute.value = route
outdoorNavPolylines.value = [{
points: route.polyline,
color: '#E0E100',
width: 6
}]
console.log('路线规划成功:', {
distance: route.distance,
duration: route.duration,
steps: route.steps.length
})
} else {
outdoorNavError.value = result.message || '路线规划失败'
outdoorNavRoute.value = null
outdoorNavPolylines.value = []
}
} catch (error) {
console.error('路线规划异常:', error)
outdoorNavError.value = '路线规划失败,请重试'
outdoorNavRoute.value = null
outdoorNavPolylines.value = []
} finally {
outdoorNavLoading.value = false
}
}
const handleOutdoorNavStartNavigation = async () => {
// 直接调起导航
await navigateToMuseum()
}
const handleOutdoorNavClearRoute = () => {
outdoorNavRoute.value = null
outdoorNavPolylines.value = []
outdoorNavStartPoint.value = null
outdoorNavError.value = ''
}
// 处理地图点击(用于手动选择起点)
const handleMapTapForOutdoorNav = (location: { latitude?: number; longitude?: number }) => {
if (!outdoorNavSelectingManualStart.value) return
const latitude = location.latitude
const longitude = location.longitude
if (latitude !== undefined && longitude !== undefined) {
outdoorNavStartPoint.value = { latitude, longitude }
outdoorNavSelectingManualStart.value = false
void planOutdoorRoute({ latitude, longitude })
}
}
const guideShellMode = computed(() => (is3DMode.value ? '3d' : '2d'))
const guideMapType = computed(() => (is3DMode.value ? 'indoor' : 'outdoor'))
const guideOutdoorVariant = computed(() => (
guideOutdoorState.value === 'entrance' ? 'entrance' : 'home'
))
const guideSearchText = computed(() => {
return '请输入地点进行搜索'
})
// 讲解页面处理
const handleExplainHallClick = async (hallId: string) => {
const hall = explainHallItems.value.find((item) => item.id === hallId)
selectedExplainHallId.value = hallId
selectedExplainHallName.value = hall?.name || '业务单元'
selectedExplainBusinessUnitId.value = ''
selectedExplainBusinessUnitName.value = ''
explainBusinessUnits.value = []
explainError.value = ''
explainLoading.value = true
try {
explainBusinessUnits.value = await explainUseCase.loadTemporaryBusinessUnitsByHall(hallId)
explainStage.value = 'unit'
} catch (error) {
console.error('加载展厅讲解点失败:', error)
explainError.value = '展厅讲解点加载失败,请稍后重试'
explainStage.value = 'unit'
} finally {
explainLoading.value = false
}
}
const handleExplainBusinessUnitClick = (unitId: string) => {
const unit = explainBusinessUnits.value.find((item) => item.id === unitId)
selectedExplainBusinessUnitId.value = unitId
selectedExplainBusinessUnitName.value = unit?.name || '讲解点'
explainStage.value = 'stop'
}
const handleExplainGuideStopClick = (stopId: string) => {
const target = normalizeExplainDetailTargetFromGuideStop({ id: stopId })
const params = new URLSearchParams({
id: target.targetId,
tab: 'explain',
targetType: target.targetType,
targetId: target.targetId
})
uni.navigateTo({
url: `/pages/exhibit/detail?${params.toString()}`
})
}
const handleExplainBack = () => {
if (explainStage.value === 'stop') {
explainStage.value = 'unit'
selectedExplainBusinessUnitId.value = ''
selectedExplainBusinessUnitName.value = ''
explainError.value = ''
return
}
if (explainStage.value === 'unit') {
explainStage.value = 'hall'
selectedExplainHallId.value = ''
selectedExplainHallName.value = ''
selectedExplainBusinessUnitId.value = ''
selectedExplainBusinessUnitName.value = ''
explainBusinessUnits.value = []
explainError.value = ''
return
}
currentTab.value = 'guide'
syncTopTabToUrl('guide')
loadTopTabData('guide')
showRoutePlanner.value = false
isSimulatingRoute.value = false
isPoiCardCollapsed.value = false
}
</script>
<style scoped lang="scss">
.app-launch-overlay {
position: fixed;
inset: 0;
z-index: 5000;
display: flex;
justify-content: center;
overflow: hidden;
background: #e9e8dc;
opacity: 1;
transition: opacity 0.36s ease, transform 0.36s ease;
}
.app-launch-overlay.leaving {
opacity: 0;
transform: scale(1.015);
pointer-events: none;
}
.app-launch-stage {
position: relative;
width: 100%;
max-width: 430px;
height: 100vh;
height: 100dvh;
min-height: 560px;
overflow: hidden;
background: #f5f5ed;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04);
}
.app-launch-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.app-launch-shade {
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(245, 245, 237, 0.92) 0%, rgba(245, 245, 237, 0.62) 24%, rgba(10, 12, 9, 0.08) 52%, rgba(0, 0, 0, 0.56) 100%),
linear-gradient(180deg, rgba(224, 225, 0, 0.28) 0, rgba(224, 225, 0, 0) 138px);
}
.app-launch-brand {
position: absolute;
top: calc(env(safe-area-inset-top) + 58px);
left: 24px;
right: 24px;
display: flex;
flex-direction: column;
gap: 6px;
}
.app-launch-kicker {
width: fit-content;
padding: 4px 8px;
box-sizing: border-box;
background: #000000;
font-size: 12px;
line-height: 17px;
font-weight: 700;
color: var(--museum-accent);
}
.app-launch-title {
font-size: 31px;
line-height: 39px;
font-weight: 800;
letter-spacing: 0;
color: #000000;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.46);
}
.app-launch-subtitle {
max-width: 300px;
font-size: 14px;
line-height: 19px;
font-weight: 500;
color: #424754;
}
.app-launch-status {
position: absolute;
left: 24px;
right: 24px;
bottom: calc(env(safe-area-inset-bottom) + 56px);
padding: 16px;
box-sizing: border-box;
background: rgba(0, 0, 0, 0.88);
border: 1px solid rgba(224, 225, 0, 0.34);
}
.app-launch-loading-text {
display: block;
font-size: 15px;
line-height: 21px;
font-weight: 700;
color: #ffffff;
}
.app-launch-progress {
height: 4px;
margin-top: 12px;
overflow: hidden;
background: rgba(255, 255, 255, 0.18);
}
.app-launch-progress-bar {
height: 100%;
width: 18%;
background: var(--museum-accent);
transition: width 0.22s ease;
}
.app-launch-actions {
margin-top: 14px;
display: flex;
gap: 10px;
}
.app-launch-action {
height: 36px;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 8px;
}
.app-launch-action.secondary {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.38);
}
.app-launch-action.primary {
background: var(--museum-accent);
border: 1px solid var(--museum-accent);
}
.app-launch-action-text {
font-size: 13px;
line-height: 18px;
font-weight: 700;
color: #ffffff;
}
.app-launch-action.primary .app-launch-action-text {
color: #151713;
}
@media (max-width: 360px) {
.app-launch-brand {
top: calc(env(safe-area-inset-top) + 48px);
left: 20px;
right: 20px;
}
.app-launch-title {
font-size: 28px;
line-height: 36px;
}
.app-launch-subtitle {
font-size: 13px;
line-height: 18px;
}
.app-launch-status {
left: 20px;
right: 20px;
bottom: calc(env(safe-area-inset-bottom) + 42px);
padding: 14px;
}
}
@media (max-height: 680px) {
.app-launch-brand {
top: calc(env(safe-area-inset-top) + 38px);
}
.app-launch-status {
bottom: calc(env(safe-area-inset-bottom) + 32px);
}
}
.explain-page {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #f6f7f4;
z-index: 10;
}
.guide-poi-card {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(env(safe-area-inset-bottom) + 24px);
box-sizing: border-box;
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: 1003;
}
.guide-poi-card {
padding: 10px 14px 14px;
}
.guide-poi-card.collapsed {
padding: 8px 10px 10px;
}
.poi-card-handle {
width: 38px;
height: 4px;
margin: 0 auto 10px;
background: #d8dbd2;
border-radius: 999px;
}
.poi-card-collapsed {
min-height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.poi-card-mini-action {
flex: 0 0 auto;
height: 30px;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: center;
background: #151713;
border-radius: 8px;
}
.poi-card-mini-text {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: var(--museum-accent);
}
.poi-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.poi-card-copy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.poi-card-title {
font-size: 17px;
line-height: 24px;
font-weight: 700;
color: #151713;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.poi-card-subtitle {
font-size: 12px;
line-height: 17px;
color: #696962;
}
.poi-card-header-actions {
flex: 0 0 auto;
display: flex;
gap: 6px;
}
.poi-card-soft-action {
height: 28px;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border: 1px solid #e4e5df;
border-radius: 8px;
background: #f5f7f2;
}
.poi-card-soft-text {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #545861;
}
.poi-card-close {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: #f5f7f2;
}
.poi-card-close-text {
font-size: 18px;
line-height: 20px;
color: #545861;
}
.poi-card-actions {
margin-top: 12px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.poi-action {
min-width: 0;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 6px;
box-sizing: border-box;
background: #f6f7f4;
border: 1px solid #e5e6de;
border-radius: 8px;
}
.poi-action.primary {
background: #151713;
border-color: #151713;
}
.poi-action-text {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #151713;
text-align: center;
}
.poi-action.primary .poi-action-text {
color: var(--museum-accent);
}
.guide-home-dock {
position: absolute;
--museum-h5-page-width: min(100vw, 430px);
left: 50%;
right: auto;
width: calc(var(--museum-h5-page-width) - 24px);
bottom: calc(env(safe-area-inset-bottom) + 14px);
padding: 10px;
box-sizing: border-box;
overflow: hidden;
background:
linear-gradient(180deg, rgba(224, 225, 0, 0.22) 0, rgba(224, 225, 0, 0) 58px),
#060704;
border: 1px solid rgba(224, 225, 0, 0.34);
border-radius: 8px;
box-shadow: 0 18px 38px rgba(0, 0, 0, 0.34);
transform: translateX(-50%);
z-index: 1003;
}
.guide-home-dock::before {
content: '';
position: absolute;
left: 10px;
right: 10px;
top: 0;
height: 4px;
background: var(--museum-accent);
}
.guide-home-dock.expanded {
position: fixed;
left: 50%;
right: auto;
top: 0;
bottom: auto;
width: var(--museum-h5-page-width);
height: 100vh;
height: 100dvh;
height: var(--home-search-viewport-height, 100dvh);
max-height: none;
display: flex;
flex-direction: column;
padding: calc(env(safe-area-inset-top) + 12px) 14px calc(env(safe-area-inset-bottom) + 14px);
background: #f7f8f2;
border: 0;
border-radius: 0;
box-shadow: none;
transform: translateX(-50%);
overflow: hidden;
overscroll-behavior: contain;
touch-action: pan-x pan-y;
z-index: 1100;
}
.guide-home-dock.expanded::before {
display: none;
}
/* #ifdef H5 */
.guide-home-dock.expanded {
min-height: 100vh;
min-height: 100dvh;
max-width: var(--museum-h5-page-width);
}
/* #endif */
.guide-home-search-scrim {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: transparent;
z-index: 1002;
}
.guide-quick-actions {
position: absolute;
top: clamp(88px, 13vh, 112px);
right: 14px;
width: 52px;
display: flex;
flex-direction: column;
gap: 7px;
z-index: 1002;
}
.guide-quick-action {
width: 52px;
min-height: 58px;
padding: 7px 4px 6px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
box-sizing: border-box;
background: rgba(245, 245, 237, 0.94);
border: 1px solid rgba(255, 255, 255, 0.72);
border-radius: 8px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(10px);
}
.guide-quick-action.primary {
background: var(--museum-accent);
border-color: var(--museum-accent);
}
.guide-quick-action:active {
transform: translateY(1px);
}
.guide-quick-icon {
width: 22px;
height: 22px;
flex: 0 0 22px;
position: relative;
color: #151713;
}
.guide-quick-action.primary .guide-quick-icon {
color: #0a0b08;
}
.guide-quick-icon-floor {
position: absolute;
left: 3px;
width: 16px;
height: 9px;
box-sizing: border-box;
border: 1.7px solid currentColor;
border-radius: 2px;
transform: skewY(-18deg);
}
.guide-quick-icon-floor.top {
top: 2px;
}
.guide-quick-icon-floor.bottom {
top: 10px;
}
.guide-quick-pin {
position: absolute;
left: 5px;
top: 2px;
width: 12px;
height: 16px;
box-sizing: border-box;
border: 1.8px solid currentColor;
border-radius: 7px 7px 7px 0;
transform: rotate(-45deg);
}
.guide-quick-pin::after {
content: '';
position: absolute;
left: 3px;
top: 3px;
width: 4px;
height: 4px;
background: currentColor;
border-radius: 50%;
}
.guide-quick-explain-wave {
position: absolute;
left: 2px;
right: 2px;
height: 4px;
border: 1.8px solid currentColor;
border-left: 0;
border-right: 0;
border-bottom: 0;
border-radius: 999px 999px 0 0;
}
.guide-quick-explain-wave.top {
top: 3px;
}
.guide-quick-explain-wave.middle {
top: 9px;
left: 5px;
}
.guide-quick-explain-wave.bottom {
top: 15px;
right: 6px;
}
.guide-quick-action-text {
width: 100%;
font-size: 12px;
line-height: 16px;
font-weight: 700;
color: #151713;
text-align: center;
white-space: nowrap;
}
.guide-quick-action-text.primary {
color: #0a0b08;
}
.route-sim-top-pill {
position: absolute;
top: 126px;
left: 50%;
max-width: calc(100% - 40px);
height: 34px;
padding: 0 14px;
display: flex;
align-items: center;
box-sizing: border-box;
background: rgba(21, 23, 19, 0.86);
border-radius: 8px;
transform: translateX(-50%);
z-index: 1003;
}
.route-sim-top-text {
min-width: 0;
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #ffffff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.route-sim-step-bar {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(env(safe-area-inset-bottom) + 24px);
min-height: 54px;
padding: 10px 10px 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
box-sizing: border-box;
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: 1003;
}
.route-sim-step-text {
min-width: 0;
flex: 1;
font-size: 14px;
line-height: 20px;
font-weight: 700;
color: #151713;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.route-sim-expand {
height: 34px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #000000;
border-radius: 8px;
}
.route-sim-expand-text {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: var(--museum-accent);
}
.entrance-tip {
position: absolute;
top: 126px;
left: 30px;
width: 168px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.96);
border: 1px solid #e5e6de;
border-radius: 12px;
z-index: 45;
}
.entrance-tip-text {
font-size: 14px;
line-height: 19px;
font-weight: 500;
color: #1f2329;
}
.indoor-gesture-hint {
position: absolute;
top: clamp(136px, 19vh, 190px);
left: 50%;
max-width: calc(100% - 112px);
min-height: 38px;
padding: 8px 14px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: rgba(9, 15, 10, 0.9);
border: 1px solid rgba(255, 235, 59, 0.36);
border-radius: 999px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.2);
transform: translateX(-50%);
pointer-events: none;
z-index: 58;
}
.indoor-gesture-text {
min-width: 0;
font-size: 13px;
line-height: 18px;
font-weight: 700;
color: #f4ff2f;
text-align: center;
white-space: normal;
}
.home-shortcuts-card {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(env(safe-area-inset-bottom) + 128px);
height: 84px;
padding: 16px 10px 8px;
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.96);
border: 1px solid #ffffff;
border-radius: 16px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08);
z-index: 45;
}
.home-shortcut-item {
width: 62px;
height: 60px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 4px;
}
.home-shortcut-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #d9dcd2;
border-radius: 10px;
}
.home-shortcut-abbr {
font-size: 15px;
line-height: 20px;
font-weight: 700;
color: #1f2329;
}
.home-shortcut-label {
font-size: 12px;
line-height: 16px;
color: #1f2329;
text-align: center;
}
.home-shortcut-item.active {
.home-shortcut-icon {
background: #000000;
border-color: #000000;
}
.home-shortcut-abbr {
color: var(--museum-accent);
}
}
.search-bar-wrapper {
position: absolute;
top: 44px;
left: 0;
right: 0;
z-index: 1003;
}
.guide-mode-row {
position: absolute;
top: 104px;
left: 16px;
right: 16px;
height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
z-index: 1003;
}
.mode-switch {
width: 220px;
height: 34px;
display: flex;
align-items: center;
padding: 4px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(230, 230, 230, 0.5);
border-radius: 17px;
}
.mode-option {
width: 106px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 13px;
}
.mode-option.active {
background: #000000;
}
.mode-label {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #1f2329;
}
.mode-option.active .mode-label {
color: var(--museum-accent);
}
.mode-status-chip {
width: 111px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.42);
border-radius: 14px;
}
.mode-status-text {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #696962;
}
.area-selector-wrapper {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(env(safe-area-inset-bottom) + 128px);
z-index: 1003;
}
.guide-task-card {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(env(safe-area-inset-bottom) + 24px);
height: 94px;
padding: 16px 16px 10px;
display: flex;
flex-direction: column;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #ffffff;
border-radius: 16px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08);
z-index: 1003;
}
.guide-task-card.has-subtitle {
height: auto;
min-height: 128px;
}
.task-title {
flex-shrink: 0;
font-size: 17px;
line-height: 24px;
font-weight: 700;
color: #000000;
}
.task-subtitle {
margin-top: 4px;
min-height: 20px;
font-size: 12px;
line-height: 18px;
color: #696962;
}
.task-primary-btn {
flex-shrink: 0;
margin-top: 4px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
background: #000000;
border-radius: 8px;
}
.task-primary-text {
font-size: 14px;
line-height: 19px;
font-weight: 500;
color: var(--museum-accent);
}
.entrance-bottom-card {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(env(safe-area-inset-bottom) + 24px);
height: 158px;
padding: 18px 16px 14px;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #ffffff;
border-radius: 16px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08);
z-index: 45;
}
.entrance-card-title {
display: block;
font-size: 17px;
line-height: 24px;
font-weight: 700;
color: #000000;
}
.entrance-card-desc {
display: block;
width: 305px;
margin-top: 10px;
font-size: 13px;
line-height: 18px;
color: #6b7178;
}
.entrance-actions {
position: absolute;
left: 16px;
right: 16px;
bottom: 14px;
display: flex;
gap: 12px;
}
.entrance-btn {
height: 40px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 8px;
}
.entrance-btn.secondary {
width: 126px;
background: #ffffff;
border: 1px solid #000000;
}
.entrance-btn.primary {
width: 181px;
background: #000000;
border: 1px solid #000000;
}
.entrance-btn-text {
font-size: 14px;
line-height: 19px;
font-weight: 500;
color: #000000;
}
.entrance-btn.primary .entrance-btn-text {
color: var(--museum-accent);
}
</style>