3264 lines
97 KiB
Vue
3264 lines
97 KiB
Vue
<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="guideIndoorInitialView"
|
||
:indoor-view="indoorView"
|
||
:layer-mode="indoorLayerMode"
|
||
:indoor-render-mode="indoorRenderMode"
|
||
:scene-revision="guideSceneRevision"
|
||
:scene-viewport="guideSceneViewport"
|
||
:show-indoor-render-mode-toggle="isBuildingGuideActive"
|
||
:active-floor="activeGuideFloor"
|
||
:visible-poi-ids="searchVisiblePoiIds"
|
||
:target-focus-request="searchTargetFocusRequest"
|
||
layer-mode-toggle-top="154px"
|
||
floor-bottom="193px"
|
||
floor-max-height="206px"
|
||
floor-side="left"
|
||
:show-floor-header="!isTwoDimensionalIndoorMode"
|
||
:show-layer-mode-toggle="false"
|
||
:show-floor="isBuildingGuideActive"
|
||
:show-zoom-controls="true"
|
||
zoom-controls-top="calc(100vh - 292px)"
|
||
:route-preview="activeRoutePreview"
|
||
:show-route="Boolean(activeRoutePreview)"
|
||
:route-navigation-active="isSimulatingRoute"
|
||
:route-start-selection-active="routeStartSelectionActive"
|
||
:route-selectable-poi-ids="routeSelectablePoiIds"
|
||
:route-selectable-points="routePointOptions"
|
||
:outdoor-nav-polylines="homeArrivalFeatureEnabled ? outdoorNavPolylines : []"
|
||
:outdoor-markers="homeArrivalFeatureEnabled ? outdoorMapMarkers : []"
|
||
:active-outdoor-marker-id="homeArrivalFeatureEnabled ? activeArrivalMarkerId : ''"
|
||
:outdoor-focus-offset-y="outdoorArrivalStage === 'route' ? 170 : (arrivalActiveType ? 180 : 0)"
|
||
:semantic-zoom-enabled="semanticMapZoomEnabled"
|
||
:disable-auto-exit="disableIndoorAutoExit"
|
||
@mode-change="handleModeChange"
|
||
@floor-request="handleFloorRequest"
|
||
@floor-change="handleFloorChange"
|
||
@floor-switch-failed="handleFloorSwitchFailed"
|
||
@indoor-view-change="handleIndoorViewChange"
|
||
@poi-click="handleGuidePoiClick"
|
||
@route-start-candidate="handleRouteStartCandidate"
|
||
@route-start-candidate-rejected="handleRouteStartCandidateRejected"
|
||
@route-roaming-progress="handleRouteRoamingProgress"
|
||
@route-roaming-transfer="handleRouteRoamingTransfer"
|
||
@selection-clear="handleGuideSelectionClear"
|
||
@auto-switch="handleAutoSwitch"
|
||
@initial-model-progress="handleInitialModelProgress"
|
||
@initial-model-ready="handleInitialModelReady"
|
||
@initial-model-failed="handleInitialModelFailed"
|
||
@indoor-render-mode-fallback="handleIndoorRenderModeFallback"
|
||
@indoor-render-mode-change="handleIndoorRenderModeChange"
|
||
@scene-viewport-change="handleGuideSceneViewportChange"
|
||
@tool-click="handleIndoorToolClick"
|
||
@outdoor-marker-click="handleOutdoorMarkerClick"
|
||
@map-tap="handleOutdoorMapTap"
|
||
@semantic-enter-building="handleSemanticEnterBuilding"
|
||
@semantic-exit-outdoor="handleSemanticExitOutdoor"
|
||
@outdoor-unavailable="handleOutdoorMapUnavailable"
|
||
>
|
||
<template #overlay>
|
||
<view v-if="homeArrivalFeatureEnabled && guideOutdoorState === 'entrance' && !isBuildingGuideActive" class="entrance-tip">
|
||
<text class="entrance-tip-text">主入口 约 4 分钟</text>
|
||
</view>
|
||
</template>
|
||
|
||
<GuidePoiSummaryCard
|
||
v-if="selectedGuidePoi && !showRoutePlanner && !isSimulatingRoute"
|
||
:title="normalizeVisitorPoiDisplayName(selectedGuidePoi.name)"
|
||
:subtitle="selectedGuidePoiSubtitle"
|
||
:actions="selectedGuidePoiActions"
|
||
@close="handleGuidePoiCardClose"
|
||
@action="handleSelectedGuidePoiAction"
|
||
/>
|
||
|
||
<view
|
||
v-if="showGuideFloatingActions"
|
||
class="guide-quick-actions"
|
||
:class="{ 'is-outdoor': showOutdoorNavigationPanel }"
|
||
>
|
||
<view
|
||
v-if="homeArrivalFeatureEnabled"
|
||
class="guide-quick-action"
|
||
:class="{ active: guideQuickActiveAction === 'arrival' }"
|
||
@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"
|
||
:class="{ active: guideQuickActiveAction === 'indoor' }"
|
||
@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">导览</text>
|
||
</view>
|
||
<view
|
||
class="guide-quick-action"
|
||
:class="{ active: guideQuickActiveAction === 'explain' }"
|
||
@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-show="showGuideHomeDock"
|
||
class="guide-home-dock"
|
||
:class="{ expanded: homeSearchExpanded }"
|
||
>
|
||
<PoiSearchPanel
|
||
ref="homeSearchPanelRef"
|
||
variant="home"
|
||
:current-floor-id="activeGuideFloor"
|
||
:current-floor-label="getGuideFloorLabel(activeGuideFloor)"
|
||
@expanded-change="handleHomeSearchExpandedChange"
|
||
@category-mode-change="handleHomeCategoryModeChange"
|
||
@results-change="handleHomeSearchResultsChange"
|
||
@result-tap="handleHomeSearchResultTap"
|
||
@cancel="handleHomeSearchCancel"
|
||
/>
|
||
</view>
|
||
|
||
<RoutePlannerPanel
|
||
v-if="isBuildingGuideActive"
|
||
:visible="showRoutePlanner"
|
||
:start-point="routeStartPoint"
|
||
:end-point="routeEndPoint"
|
||
:start-candidate="routeStartCandidate"
|
||
:has-route-preview="Boolean(activeRoutePreview)"
|
||
:loading="routePlanning"
|
||
:error="routePanelError"
|
||
:summary="routePlanSummary"
|
||
@confirm-start="handleRouteStartConfirm"
|
||
@cancel-start="handleRouteStartCancel"
|
||
@simulate-guide="handleRouteSimulate"
|
||
@back="handleRouteBack"
|
||
/>
|
||
|
||
<view v-if="isSimulatingRoute && activeRoutePreview" class="route-sim-step-bar">
|
||
<text class="route-sim-step-text">{{ routeTransferNotice || `距终点约 ${routeRemainingMeters} 米` }}</text>
|
||
<view class="route-sim-close" @tap="handleRouteBack">
|
||
<text class="route-sim-close-text">×</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="homeArrivalFeatureEnabled && guideOutdoorState === 'entrance' && !isBuildingGuideActive" 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="handleEnterBuildingGuide">
|
||
<text class="entrance-btn-text">进入馆内导览</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</GuideMapShell>
|
||
|
||
<OutdoorArrivalRoutePanel
|
||
v-if="outdoorArrivalStage === 'route' && homeArrivalFeatureEnabled && currentTab === 'guide'"
|
||
:visible="showOutdoorNavigationPanel"
|
||
:phase="outdoorRouteStartPhase"
|
||
:location-message="outdoorLocationMessage"
|
||
:location-help-visible="outdoorLocationHelpVisible"
|
||
:loading="outdoorRouteLoading"
|
||
:route-error="outdoorRouteError"
|
||
:route-distance="outdoorRoute?.distance || 0"
|
||
:route-duration="outdoorRoute?.duration || 0"
|
||
:start-point="outdoorRouteStart"
|
||
:destination="selectedArrivalTarget"
|
||
:manual-search-results="outdoorStartSearchResults"
|
||
:manual-search-loading="outdoorStartSearchLoading"
|
||
@retry-location="handleOutdoorLocationRetry"
|
||
@manual-start-request="handleOutdoorManualSearchRequest"
|
||
@manual-map-request="handleOutdoorManualMapRequest"
|
||
@manual-start-confirm="handleOutdoorManualStartConfirm"
|
||
@manual-start-search="handleOutdoorManualStartSearch"
|
||
@confirm-start="handleOutdoorRouteStartConfirm"
|
||
@retry-route="handleOutdoorRouteRetry"
|
||
@back="returnToArrivalDetail"
|
||
/>
|
||
|
||
<ArrivalPanel
|
||
v-if="showArrivalDestinationPanel && homeArrivalFeatureEnabled && currentTab === 'guide'"
|
||
:visible="showOutdoorNavigationPanel"
|
||
:mode="outdoorArrivalStage === 'detail' ? 'detail' : 'browse'"
|
||
:active-type="arrivalActiveType"
|
||
:targets="arrivalTargets"
|
||
:selected-target-id="selectedArrivalTarget?.id || ''"
|
||
:selected-target="selectedArrivalTarget"
|
||
:loading="arrivalTargetsLoading"
|
||
:error="arrivalTargetsError"
|
||
@type-change="handleArrivalTypeChange"
|
||
@select-target="handleArrivalTargetSelect"
|
||
@navigate="handleArrivalNavigate"
|
||
@back="returnToArrivalBrowse"
|
||
@collapse="collapseArrivalBrowse"
|
||
@retry="arrivalActiveType && loadArrivalTargets(arrivalActiveType)"
|
||
/>
|
||
|
||
<view v-else-if="currentTab === 'explain'" class="explain-page">
|
||
<ExplainHallSelect
|
||
:halls="explainHallItems"
|
||
stage="hall"
|
||
:loading="explainLoading"
|
||
:error="explainError"
|
||
@hall-click="handleExplainHallClick"
|
||
@retry="loadExplainHalls"
|
||
@back="handleExplainBack"
|
||
/>
|
||
</view>
|
||
|
||
<view
|
||
v-if="launchOverlayVisible"
|
||
class="app-launch-overlay"
|
||
:class="{ leaving: launchOverlayLeaving }"
|
||
>
|
||
<view class="app-launch-stage">
|
||
<view class="app-launch-content">
|
||
<image
|
||
class="app-launch-logo"
|
||
src="/static/guide/museum-brand-icons.webp"
|
||
mode="widthFix"
|
||
/>
|
||
<text class="app-launch-name-en">SHENZHEN NATURAL HISTORY MUSEUM</text>
|
||
<text class="app-launch-name-cn">深圳自然博物馆</text>
|
||
<progress
|
||
class="app-launch-progress"
|
||
:percent="launchProgress"
|
||
active
|
||
active-mode="forwards"
|
||
:stroke-width="3"
|
||
active-color="#e0e100"
|
||
background-color="#eceee7"
|
||
:show-info="false"
|
||
/>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</GuidePageFrame>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
||
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
|
||
import GuidePoiSummaryCard, {
|
||
type GuidePoiSummaryAction
|
||
} from '@/components/map/GuidePoiSummaryCard.vue'
|
||
import RoutePlannerPanel from '@/components/navigation/RoutePlannerPanel.vue'
|
||
import OutdoorArrivalRoutePanel, {
|
||
type OutdoorRouteStartPhase
|
||
} from '@/components/navigation/OutdoorArrivalRoutePanel.vue'
|
||
import ArrivalPanel from '@/components/navigation/ArrivalPanel.vue'
|
||
import PoiSearchPanel from '@/components/search/PoiSearchPanel.vue'
|
||
import type {
|
||
RoutePointOption
|
||
} from '@/components/navigation/RoutePointPicker.vue'
|
||
import ExplainHallSelect, {
|
||
type ExplainHallSelectItem
|
||
} from '@/components/explain/ExplainHallSelect.vue'
|
||
import { explainGuideStopListUrl } from '@/utils/explainNavigation'
|
||
import {
|
||
getLastGuideLocationPreviewUrl,
|
||
isGuideTopTab,
|
||
type GuideTopTab
|
||
} from '@/utils/guideTopTabs'
|
||
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
|
||
import { syncHostNavigationTitle } from '@/utils/hostNavigationTitle'
|
||
import {
|
||
guideUseCase
|
||
} from '@/usecases/guideUseCase'
|
||
import {
|
||
guideRouteUseCase
|
||
} from '@/usecases/guideRouteUseCase'
|
||
import {
|
||
explainUseCase
|
||
} from '@/usecases/explainUseCase'
|
||
import {
|
||
poiDetailUseCase,
|
||
type PoiDetailSource,
|
||
type PoiDetailTarget
|
||
} from '@/usecases/poiDetailUseCase'
|
||
import type {
|
||
GuideRenderPoi
|
||
} from '@/domain/guideModel'
|
||
import type {
|
||
PoiCategoryResultState,
|
||
PoiSearchContext
|
||
} from '@/domain/poiSearch'
|
||
import {
|
||
toTargetFocusRequestViewModel,
|
||
type TargetPoiFocusRequestViewModel
|
||
} from '@/view-models/guideViewModels'
|
||
import {
|
||
useGuideModelState,
|
||
type GuideModelResetReason,
|
||
type GuideModelResetRequest
|
||
} from '@/composables/useGuideModelState'
|
||
import {
|
||
useGuideSceneState,
|
||
type GuideViewportState,
|
||
type GuideRenderMode
|
||
} from '@/composables/useGuideSceneState'
|
||
import type { ResetViewBaselineResult } from '@/components/map/ThreeMap.vue'
|
||
import type {
|
||
GuideRouteResult,
|
||
GuideRouteTarget,
|
||
MuseumFloor,
|
||
MuseumPoi
|
||
} from '@/domain/museum'
|
||
import type {
|
||
RouteStartCandidatePayload
|
||
} from '@/components/map/routeStartCandidateResolver'
|
||
import { getFloorSortLevel } from '@/domain/guideFloor'
|
||
import { normalizeVisitorPoiDisplayName } from '@/view-models/visitorPoiPresentation'
|
||
import { getBrowserNetworkInfo } from '@/services/performance/backgroundPreloadScheduler'
|
||
import { resolveInitialGuideRenderMode } from '@/services/performance/guideRenderModePolicy'
|
||
import { MUSEUM_LOCATION, type TravelMode } from '@/config/museum'
|
||
import {
|
||
BrowserLocationError,
|
||
getBrowserLocationPermissionState,
|
||
getBrowserGcjLocation
|
||
} from '@/services/outdoor/BrowserLocationService'
|
||
import {
|
||
refreshOutdoorMapProfile,
|
||
subscribeOutdoorMapAvailability,
|
||
planOutdoorRoute,
|
||
reverseGeocodeOutdoorLocation,
|
||
searchOutdoorPlaces,
|
||
suggestOutdoorPlaces,
|
||
type OutdoorPlace,
|
||
type OutdoorRoutePlan
|
||
} from '@/services/outdoor/OutdoorMapAppService'
|
||
import {
|
||
ARRIVAL_SEARCH_REGION,
|
||
ARRIVAL_TARGET_TYPES,
|
||
type ArrivalSearchTarget,
|
||
type ArrivalTargetType
|
||
} from '@/data/arrivalSearchTargets'
|
||
// #ifdef H5
|
||
import { preloadTencentJsMap } from '@/services/outdoor/TencentJsMapLoader'
|
||
// #endif
|
||
|
||
type GuideIndoorView = 'overview' | 'floor' | 'multi'
|
||
type LayerDisplayMode = 'single' | 'multi'
|
||
type IndoorRenderMode = GuideRenderMode
|
||
|
||
const guideModelState = useGuideModelState()
|
||
|
||
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'
|
||
const homeArrivalFeatureEnabled = ref(false)
|
||
let outdoorMapPreloadStarted = false
|
||
let outdoorMapPreloadTimer: ReturnType<typeof setTimeout> | null = null
|
||
let stopOutdoorMapAvailabilitySubscription: (() => void) | null = null
|
||
|
||
const handleOutdoorMapUnavailable = (message?: string) => {
|
||
homeArrivalFeatureEnabled.value = false
|
||
if (showOutdoorNavigationPanel.value || !isBuildingGuideActive.value) {
|
||
closeArrivalPanel()
|
||
}
|
||
if (message) console.warn('来馆服务暂不可用:', message)
|
||
}
|
||
|
||
const refreshOutdoorMapAvailability = async () => {
|
||
try {
|
||
const profile = await refreshOutdoorMapProfile()
|
||
homeArrivalFeatureEnabled.value = profile.available
|
||
if (profile.available) {
|
||
preloadOutdoorMapAfterInitialView()
|
||
return
|
||
}
|
||
|
||
if (showOutdoorNavigationPanel.value || !isBuildingGuideActive.value) closeArrivalPanel()
|
||
} catch (error) {
|
||
console.warn('读取来馆服务状态失败:', error)
|
||
handleOutdoorMapUnavailable()
|
||
}
|
||
}
|
||
|
||
const preloadOutdoorMapAfterInitialView = () => {
|
||
// #ifdef H5
|
||
if (!homeArrivalFeatureEnabled.value || outdoorMapPreloadStarted || typeof window === 'undefined') return
|
||
outdoorMapPreloadStarted = true
|
||
outdoorMapPreloadTimer = setTimeout(() => {
|
||
outdoorMapPreloadTimer = null
|
||
void preloadTencentJsMap().catch((error) => {
|
||
console.warn('腾讯地图预加载失败:', error)
|
||
handleOutdoorMapUnavailable(error instanceof Error ? error.message : '腾讯地图预加载失败')
|
||
})
|
||
}, 600)
|
||
// #endif
|
||
}
|
||
|
||
// Building guide and outdoor-arrival maps are separate products. This flag is
|
||
// deliberately independent from the 2D/3D renderer used by the building guide.
|
||
const isBuildingGuideActive = ref(true)
|
||
const getInitialIndoorRenderMode = (): IndoorRenderMode => (
|
||
typeof window === 'undefined'
|
||
? 'three-d'
|
||
: resolveInitialGuideRenderMode(
|
||
new URLSearchParams(window.location.search).get('guide-render'),
|
||
getBrowserNetworkInfo()
|
||
)
|
||
)
|
||
const guideSceneState = useGuideSceneState({
|
||
view: 'overview',
|
||
renderMode: getInitialIndoorRenderMode()
|
||
})
|
||
const indoorRenderMode = guideSceneState.renderMode
|
||
const indoorView = guideSceneState.view
|
||
const activeGuideFloor = guideSceneState.floorId
|
||
const guideSceneRevision = guideSceneState.revision
|
||
const guideSceneViewport = guideSceneState.viewport
|
||
const isTwoDimensionalIndoorMode = computed(() => (
|
||
isBuildingGuideActive.value && indoorRenderMode.value === 'two-d'
|
||
))
|
||
const guideOutdoorState = ref<'home' | 'entrance'>('home')
|
||
const requestedFloorId = ref('')
|
||
const requestedFloorLabel = ref('')
|
||
const loadingFloorId = ref('')
|
||
const renderedFloorId = ref('')
|
||
const failedFloorId = ref('')
|
||
const selectedGuidePoi = ref<GuideRenderPoi | null>(null)
|
||
const selectedGuidePoiDetailTarget = ref<PoiDetailTarget | null>(null)
|
||
let searchTargetFocusRequestId = 0
|
||
let poiDetailResolutionRequestId = 0
|
||
|
||
type PoiDetailUiContext = Readonly<{
|
||
source: PoiDetailSource
|
||
categoryModeActive: boolean
|
||
visiblePoiIds: string[] | null
|
||
}>
|
||
|
||
let pendingPoiDetailUiContext: Readonly<{
|
||
requestId: number
|
||
value: PoiDetailUiContext
|
||
}> | null = null
|
||
|
||
// 状态
|
||
const currentTab = ref<GuideTopTab>(initialTopTab())
|
||
const launchOverlayVisible = ref(shouldShowLaunchOverlay())
|
||
const launchOverlayLeaving = ref(false)
|
||
const launchProgress = ref(8)
|
||
const launchInitialModelSettled = ref(false)
|
||
const showRoutePlanner = ref(false)
|
||
const routeStartPoint = ref<RoutePointOption | null>(null)
|
||
const routeStartCandidate = 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 routeRemainingMeters = ref(0)
|
||
const routeTransferNotice = ref('')
|
||
let routeTransferNoticeTimer: number | null = null
|
||
const routeReturnContext = ref<Readonly<{
|
||
poi: GuideRenderPoi
|
||
floorId: string
|
||
}> | null>(null)
|
||
const isPoiCardCollapsed = ref(false)
|
||
const returningToOverview = ref(false)
|
||
|
||
// GuideMapShell 组件引用,用于显式调用路线清除方法
|
||
const guideMapShellRef = ref<{
|
||
clearRoute?: () => void
|
||
clearSelection?: () => void
|
||
switchFloor?: (floorId: string) => Promise<void> | void
|
||
showOverview?: () => Promise<void> | void
|
||
resetToViewBaseline?: (options: {
|
||
view: 'overview' | 'floor'
|
||
floorId?: string
|
||
reason: 'poi-detail-close' | 'manual-reset' | 'floor-reset'
|
||
}) => Promise<ResetViewBaselineResult> | ResetViewBaselineResult
|
||
resetToInitialState?: () => Promise<boolean> | boolean
|
||
} | null>(null)
|
||
const homeSearchPanelRef = ref<{
|
||
collapseHomePanel?: () => void
|
||
resetSearchState?: () => Promise<void> | void
|
||
returnToCollapsedAfterDetail?: () => Promise<void>
|
||
restoreResultListScroll?: () => Promise<void>
|
||
} | null>(null)
|
||
const homeSearchExpanded = ref(false)
|
||
const homeCategoryModeActive = ref(false)
|
||
const searchVisiblePoiIds = ref<string[] | null>(null)
|
||
const searchTargetFocusRequest = ref<TargetPoiFocusRequestViewModel | null>(null)
|
||
type PoiSearchOverlayHistoryState = {
|
||
museumGuideOverlay?: 'poi-search'
|
||
[key: string]: unknown
|
||
}
|
||
|
||
let poiSearchHistoryPushed = false
|
||
let removingPoiSearchHistory = 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
|
||
let launchOverlayRunId = 0
|
||
|
||
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
|
||
|
||
const runId = launchOverlayRunId
|
||
launchProgress.value = 100
|
||
launchOverlayLeaving.value = true
|
||
clearLaunchTimers()
|
||
|
||
launchOverlayFadeTimer = setTimeout(() => {
|
||
if (runId !== launchOverlayRunId) return
|
||
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)
|
||
const runId = launchOverlayRunId
|
||
launchOverlayHideDelayTimer = setTimeout(() => {
|
||
if (runId !== launchOverlayRunId) return
|
||
launchOverlayHideDelayTimer = null
|
||
completeHideLaunchOverlay()
|
||
}, waitMs)
|
||
return
|
||
}
|
||
|
||
completeHideLaunchOverlay()
|
||
}
|
||
|
||
const shouldWaitForLaunchModel = () => (
|
||
currentTab.value === 'guide'
|
||
&& isBuildingGuideActive.value
|
||
)
|
||
|
||
// The launch overlay is independent from the selected map business mode. Releasing it
|
||
// must leave ThreeMap mounted so its loading/error/retry lifecycle can continue.
|
||
const releaseLaunchOverlayWithoutInitialModel = (reason: string, event?: { view: GuideIndoorView; floorId?: string; message?: string; elapsedMs?: number }) => {
|
||
if (!launchOverlayVisible.value || launchOverlayLeaving.value) return
|
||
|
||
console.warn('首页前置加载页未等待初始三维模型,已关闭启动遮罩:', {
|
||
reason,
|
||
event
|
||
})
|
||
|
||
launchInitialModelSettled.value = true
|
||
launchProgress.value = Math.max(launchProgress.value, 96)
|
||
hideLaunchOverlay()
|
||
}
|
||
|
||
const startLaunchOverlay = () => {
|
||
if (!launchOverlayVisible.value) return
|
||
|
||
clearLaunchTimers()
|
||
const runId = ++launchOverlayRunId
|
||
launchInitialModelSettled.value = false
|
||
launchOverlayStartedAt = Date.now()
|
||
launchProgress.value = shouldWaitForLaunchModel() ? 8 : 68
|
||
|
||
const timeoutMs = shouldWaitForLaunchModel()
|
||
? launchOverlayGuideTimeoutMs
|
||
: launchOverlayNonGuideTimeoutMs
|
||
|
||
launchOverlayFallbackTimer = setTimeout(() => {
|
||
if (runId !== launchOverlayRunId) return
|
||
if (shouldWaitForLaunchModel() && !launchInitialModelSettled.value) {
|
||
releaseLaunchOverlayWithoutInitialModel('timeout')
|
||
return
|
||
}
|
||
hideLaunchOverlay()
|
||
}, timeoutMs)
|
||
}
|
||
|
||
const handleInitialModelProgress = (event: { progress: number; message: string; view: GuideIndoorView; floorId?: string; elapsedMs?: number }) => {
|
||
if (!launchOverlayVisible.value || launchInitialModelSettled.value || !shouldWaitForLaunchModel()) return
|
||
|
||
launchProgress.value = Math.min(99, Math.max(8, event.progress))
|
||
}
|
||
|
||
const settleLaunchOverlayByModel = (status: 'ready' | 'failed', event?: { view: GuideIndoorView; floorId?: string; message?: string; elapsedMs?: number }) => {
|
||
launchInitialModelSettled.value = true
|
||
|
||
if (status === 'failed') {
|
||
releaseLaunchOverlayWithoutInitialModel('failed', event)
|
||
return
|
||
} else {
|
||
launchProgress.value = 100
|
||
console.log('首页前置加载页收到初始模型加载完成:', event)
|
||
}
|
||
|
||
hideLaunchOverlay()
|
||
}
|
||
|
||
const showOutdoorNavigationPanel = ref(false)
|
||
// The arrival journey keeps destination selection separate from route planning:
|
||
// browse nearby transport/parking, confirm one destination, then choose a start.
|
||
type OutdoorArrivalStage = 'browse' | 'detail' | 'route'
|
||
const ARRIVAL_TARGET_CACHE_TTL_MS = 5 * 60 * 1000
|
||
|
||
type ArrivalTargetCacheEntry = {
|
||
expiresAt: number
|
||
items: ArrivalSearchTarget[]
|
||
}
|
||
|
||
const outdoorArrivalStage = ref<OutdoorArrivalStage>('browse')
|
||
const outdoorRouteStartPhase = ref<OutdoorRouteStartPhase>('locating')
|
||
const outdoorLocationMessage = ref('')
|
||
const outdoorLocationHelpVisible = ref(false)
|
||
const arrivalActiveType = ref<ArrivalTargetType | null>(null)
|
||
const arrivalTargets = ref<ArrivalSearchTarget[]>([])
|
||
const arrivalTargetsLoading = ref(false)
|
||
const arrivalTargetsError = ref('')
|
||
const selectedArrivalTarget = ref<ArrivalSearchTarget | null>(null)
|
||
const outdoorStartSearchResults = ref<OutdoorPlace[]>([])
|
||
const outdoorStartSearchLoading = ref(false)
|
||
const arrivalTargetCache = new Map<ArrivalTargetType, ArrivalTargetCacheEntry>()
|
||
let arrivalTargetRequestId = 0
|
||
let outdoorStartSearchRequestId = 0
|
||
let outdoorManualReverseGeocodeRequestId = 0
|
||
let outdoorStartLocationRequestId = 0
|
||
let outdoorRoutePlanRequestId = 0
|
||
const outdoorRouteStart = ref<{ latitude: number; longitude: number; name?: string } | null>(null)
|
||
const outdoorTravelMode = ref<TravelMode>('walking')
|
||
const outdoorRoute = ref<OutdoorRoutePlan | null>(null)
|
||
const outdoorRouteLoading = ref(false)
|
||
const outdoorRouteError = ref('')
|
||
|
||
const indoorNavAssetBaseUrl = guideUseCase.getAssetBaseUrl()
|
||
const indoorModelSource = guideUseCase.getModelSource()
|
||
const guideFloors = ref<MuseumFloor[]>([])
|
||
const normalizeGuideFloorId = (labelOrId: string) => guideUseCase.normalizeFloorId(labelOrId)
|
||
const getPreferredInitialGuideFloorId = (floors: MuseumFloor[]) => (
|
||
floors.find((floor) => (
|
||
getFloorSortLevel(floor) === 1
|
||
|| normalizeGuideFloorId(floor.id) === 'L1'
|
||
|| normalizeGuideFloorId(floor.label) === 'L1'
|
||
))?.id
|
||
|| floors[0]?.id
|
||
|| ''
|
||
)
|
||
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 guideIndoorInitialView = computed<GuideIndoorView>(() => (
|
||
indoorView.value
|
||
))
|
||
// The map itself and the selected-POI sheet already communicate state. Do not
|
||
// cover the model with transient gesture, floor, or selection hint banners.
|
||
let indoorGestureHintTimer: ReturnType<typeof setTimeout> | null = null
|
||
const showIndoorHint = (_text?: string, _durationMs?: number) => undefined
|
||
const hideIndoorHint = () => undefined
|
||
|
||
const routePlanSummary = computed(() => {
|
||
if (activeRoutePreview.value) {
|
||
const indoorFloorIds = new Set(guideFloors.value.map((floor) => String(floor.id)))
|
||
const hasOutdoorSegment = activeRoutePreview.value.floorSegments.some((segment) => (
|
||
!indoorFloorIds.has(String(segment.floorId))
|
||
))
|
||
const indoorFloorCount = new Set(
|
||
activeRoutePreview.value.floorSegments
|
||
.filter((segment) => indoorFloorIds.has(String(segment.floorId)))
|
||
.map((segment) => segment.floorId)
|
||
).size
|
||
const routeText = hasOutdoorSegment
|
||
? '路线预览'
|
||
: indoorFloorCount > 1
|
||
? `跨 ${indoorFloorCount} 层`
|
||
: activeRoutePreview.value.start.floorLabel
|
||
return `${routeText} · 约 ${Math.round(activeRoutePreview.value.distanceMeters)} 米`
|
||
}
|
||
|
||
if (routeStartPoint.value && routeEndPoint.value) {
|
||
return `${routeStartPoint.value.floorLabel} 到 ${routeEndPoint.value.floorLabel}`
|
||
}
|
||
|
||
return ''
|
||
})
|
||
|
||
const routePanelError = computed(() => routePlanError.value)
|
||
|
||
const routeSelectablePoiIds = computed(() => routePointOptions.value.map((option) => option.poiId))
|
||
|
||
const routeStartSelectionActive = computed(() => (
|
||
showRoutePlanner.value
|
||
&& Boolean(routeEndPoint.value)
|
||
&& Boolean(routeEndPoint.value?.routeTargetId || routeEndPoint.value?.routeNodeId)
|
||
&& !routeStartCandidate.value
|
||
&& !routePlanning.value
|
||
&& !activeRoutePreview.value
|
||
&& !routePlanError.value
|
||
))
|
||
|
||
const disableIndoorAutoExit = computed(() => (
|
||
Boolean(selectedGuidePoi.value)
|
||
|| Boolean(activeRoutePreview.value)
|
||
|| showRoutePlanner.value
|
||
|| isSimulatingRoute.value
|
||
|| homeSearchExpanded.value
|
||
|| homeCategoryModeActive.value
|
||
|| searchVisiblePoiIds.value !== null
|
||
|| Boolean(searchTargetFocusRequest.value)
|
||
))
|
||
|
||
// Semantic zoom only changes the presentation layer while the visitor is
|
||
// browsing. Route planning and active navigation keep ownership of the map.
|
||
const semanticMapZoomEnabled = computed(() => (
|
||
currentTab.value === 'guide'
|
||
&& !showRoutePlanner.value
|
||
&& !routePlanning.value
|
||
&& !routeStartSelectionActive.value
|
||
&& !activeRoutePreview.value
|
||
&& !isSimulatingRoute.value
|
||
&& !selectedGuidePoi.value
|
||
&& !homeSearchExpanded.value
|
||
&& outdoorArrivalStage.value !== 'route'
|
||
&& !outdoorRouteLoading.value
|
||
&& !outdoorRoute.value
|
||
))
|
||
|
||
const showGuideHomeDock = computed(() => (
|
||
currentTab.value === 'guide'
|
||
&& isBuildingGuideActive.value
|
||
&& !showRoutePlanner.value
|
||
&& !showOutdoorNavigationPanel.value
|
||
&& !selectedGuidePoi.value
|
||
&& !isSimulatingRoute.value
|
||
&& !(guideOutdoorState.value === 'entrance' && !isBuildingGuideActive.value)
|
||
))
|
||
|
||
const showGuideFloatingActions = computed(() => (
|
||
currentTab.value === 'guide'
|
||
&& !showRoutePlanner.value
|
||
&& !selectedGuidePoi.value
|
||
&& !isSimulatingRoute.value
|
||
&& (!showGuideHomeDock.value || !homeSearchExpanded.value)
|
||
))
|
||
|
||
const outdoorNavPolylines = computed(() => outdoorRoute.value?.polyline.length
|
||
? [{ points: outdoorRoute.value.polyline, color: '#1565C0', width: 6 }]
|
||
: []
|
||
)
|
||
|
||
const showArrivalDestinationPanel = computed(() => (
|
||
showOutdoorNavigationPanel.value
|
||
&& (outdoorArrivalStage.value === 'browse' || outdoorArrivalStage.value === 'detail')
|
||
))
|
||
|
||
const outdoorMapMarkers = computed(() => {
|
||
if (!showOutdoorNavigationPanel.value) return []
|
||
|
||
if (outdoorArrivalStage.value === 'browse' || outdoorArrivalStage.value === 'detail') {
|
||
// Tencent base-map POIs are not interactive in the JS API. Every
|
||
// selected transport category therefore uses a tappable blue marker.
|
||
const museumMarker = {
|
||
id: 'museum-destination',
|
||
title: MUSEUM_LOCATION.name,
|
||
subtitle: MUSEUM_LOCATION.address,
|
||
latitude: MUSEUM_LOCATION.latitude,
|
||
longitude: MUSEUM_LOCATION.longitude,
|
||
markerKind: 'museum' as const
|
||
}
|
||
if (outdoorArrivalStage.value === 'browse' && arrivalActiveType.value) {
|
||
return [
|
||
museumMarker,
|
||
...arrivalTargets.value.map((target) => ({
|
||
id: target.id,
|
||
title: target.title,
|
||
subtitle: target.subtitle,
|
||
latitude: target.latitude,
|
||
longitude: target.longitude,
|
||
markerKind: target.type
|
||
}))
|
||
]
|
||
}
|
||
if (outdoorArrivalStage.value === 'detail' && selectedArrivalTarget.value) {
|
||
return [
|
||
museumMarker,
|
||
{
|
||
id: selectedArrivalTarget.value.id,
|
||
title: selectedArrivalTarget.value.title,
|
||
subtitle: selectedArrivalTarget.value.subtitle,
|
||
latitude: selectedArrivalTarget.value.latitude,
|
||
longitude: selectedArrivalTarget.value.longitude,
|
||
markerKind: selectedArrivalTarget.value.type
|
||
}
|
||
]
|
||
}
|
||
return [museumMarker]
|
||
}
|
||
|
||
if (outdoorArrivalStage.value === 'route' && selectedArrivalTarget.value) {
|
||
return [
|
||
...(outdoorRouteStart.value ? [{
|
||
id: 'outdoor-route-start',
|
||
title: outdoorRouteStart.value.name || '起点',
|
||
subtitle: '起点',
|
||
latitude: outdoorRouteStart.value.latitude,
|
||
longitude: outdoorRouteStart.value.longitude,
|
||
markerKind: 'user' as const
|
||
}] : []),
|
||
{
|
||
id: 'outdoor-route-destination',
|
||
title: selectedArrivalTarget.value.title,
|
||
subtitle: selectedArrivalTarget.value.subtitle,
|
||
latitude: selectedArrivalTarget.value.latitude,
|
||
longitude: selectedArrivalTarget.value.longitude
|
||
}
|
||
]
|
||
}
|
||
return []
|
||
})
|
||
|
||
const activeArrivalMarkerId = computed(() => (
|
||
!showOutdoorNavigationPanel.value ? ''
|
||
: outdoorArrivalStage.value === 'route' ? 'outdoor-route-destination'
|
||
: outdoorArrivalStage.value === 'detail' && selectedArrivalTarget.value
|
||
? selectedArrivalTarget.value.id
|
||
: 'museum-destination'
|
||
))
|
||
|
||
const guideQuickActiveAction = computed<'indoor' | 'arrival' | 'explain'>(() => {
|
||
if (currentTab.value === 'explain') return 'explain'
|
||
if (showOutdoorNavigationPanel.value) return 'arrival'
|
||
return isBuildingGuideActive.value ? 'indoor' : 'arrival'
|
||
})
|
||
|
||
const toImmediateRoutePoint = (poi: GuideRenderPoi): RoutePointOption => ({
|
||
routeTargetId: `coordinate:${poi.id}`,
|
||
poiId: poi.id,
|
||
sourceId: poi.spaceId || poi.sourceSpaceId || poi.sourcePlaceId,
|
||
name: poi.name,
|
||
floorId: poi.floorId,
|
||
floorLabel: getGuideFloorLabel(poi.floorId),
|
||
categoryLabel: poi.primaryCategoryZh,
|
||
positionGltf: poi.positionGltf,
|
||
coordinateFallback: true
|
||
})
|
||
|
||
const loadRouteTargets = async (floorId = activeGuideFloor.value): Promise<RoutePointOption[]> => {
|
||
routeTargetsLoading.value = true
|
||
routeTargetsError.value = ''
|
||
|
||
try {
|
||
const routeUseCase = guideRouteUseCase as typeof guideRouteUseCase & {
|
||
listTargetsForFloor?: (id: string) => Promise<GuideRouteTarget[]>
|
||
}
|
||
const targets = routeUseCase.listTargetsForFloor
|
||
? await routeUseCase.listTargetsForFloor(floorId)
|
||
: await routeUseCase.listTargets()
|
||
const options = targets.map((target) => ({
|
||
routeTargetId: target.routeTargetId,
|
||
poiId: target.poiId,
|
||
sourceId: target.sourceId,
|
||
name: target.name,
|
||
floorId: target.floorId,
|
||
floorLabel: target.floorLabel,
|
||
categoryLabel: target.categoryLabel,
|
||
positionGltf: target.positionGltf,
|
||
routeNodeId: target.routeNodeId
|
||
}))
|
||
routePointOptions.value = options
|
||
return options
|
||
} catch (error) {
|
||
console.error('加载路线目标失败:', error)
|
||
routeTargetsError.value = error instanceof Error ? error.message : '路线目标加载失败'
|
||
routePointOptions.value = []
|
||
return []
|
||
} finally {
|
||
routeTargetsLoading.value = false
|
||
}
|
||
}
|
||
|
||
const selectedGuidePoiSubtitle = computed(() => {
|
||
const poi = selectedGuidePoi.value
|
||
if (!poi) return ''
|
||
const floor = guideFloors.value.find((item) => item.id === poi.floorId)
|
||
return floor?.label || poi.floorId
|
||
})
|
||
|
||
const selectedGuidePoiActions = computed(() => {
|
||
const poi = selectedGuidePoi.value
|
||
if (!poi) return []
|
||
|
||
const actions: GuidePoiSummaryAction[] = [{
|
||
id: 'navigate',
|
||
label: '去这里',
|
||
variant: 'primary'
|
||
}]
|
||
const target = selectedGuidePoiDetailTarget.value
|
||
// 仅展示已进入讲解内容库的固定展厅。地图语义是“展厅”但没有讲解
|
||
// 数据的空间(如影院、临展、商业体验区)仍是可导航目的地,不展示灰色讲解入口。
|
||
if (target?.detailType !== 'hall' || !target.detailAvailable) return actions
|
||
|
||
actions.push({
|
||
id: 'detail',
|
||
label: '讲解',
|
||
variant: 'secondary'
|
||
})
|
||
return actions
|
||
})
|
||
|
||
const explainHallItems = ref<ExplainHallSelectItem[]>([])
|
||
const explainLoading = ref(false)
|
||
const explainError = ref('')
|
||
let explainLoadPromise: Promise<void> | null = null
|
||
|
||
// Tab 切换处理
|
||
const syncTopTabToUrl = (tabId: GuideTopTab) => {
|
||
if (typeof window === 'undefined') return
|
||
if (!isIndexHashRoute()) return
|
||
|
||
const currentHash = window.location.hash || ''
|
||
const queryStart = currentHash.indexOf('?')
|
||
const params = new URLSearchParams(queryStart >= 0 ? currentHash.slice(queryStart + 1) : '')
|
||
params.set('tab', tabId)
|
||
const url = `#/pages/index/index?${params.toString()}`
|
||
if (window.location.hash !== url) {
|
||
window.history.replaceState(window.history.state, '', url)
|
||
}
|
||
}
|
||
|
||
const syncEmbeddedHostNavigationTitle = (tabId: GuideTopTab) => {
|
||
if (!isEmbeddedInWechatMiniProgram()) return
|
||
syncHostNavigationTitle(tabId === 'explain' ? '免费讲解' : '地图导览')
|
||
}
|
||
|
||
const isPoiSearchOverlayHistoryState = (state: unknown): state is PoiSearchOverlayHistoryState => (
|
||
Boolean(
|
||
state
|
||
&& typeof state === 'object'
|
||
&& (state as PoiSearchOverlayHistoryState).museumGuideOverlay === 'poi-search'
|
||
)
|
||
)
|
||
|
||
const canManagePoiSearchOverlayHistory = () => (
|
||
typeof window !== 'undefined'
|
||
&& isIndexHashRoute()
|
||
&& isEmbeddedInWechatMiniProgram()
|
||
)
|
||
|
||
const removePoiSearchOverlayFromCurrentHistoryState = () => {
|
||
if (!canManagePoiSearchOverlayHistory()) return
|
||
if (!isPoiSearchOverlayHistoryState(window.history.state)) return
|
||
|
||
const nextState = { ...window.history.state }
|
||
delete nextState.museumGuideOverlay
|
||
window.history.replaceState(nextState, '', window.location.href)
|
||
}
|
||
|
||
const pushPoiSearchOverlayHistory = () => {
|
||
if (!canManagePoiSearchOverlayHistory() || !homeSearchExpanded.value) return
|
||
if (poiSearchHistoryPushed) return
|
||
|
||
if (isPoiSearchOverlayHistoryState(window.history.state)) {
|
||
poiSearchHistoryPushed = true
|
||
return
|
||
}
|
||
|
||
window.history.pushState({
|
||
...(window.history.state || {}),
|
||
museumGuideOverlay: 'poi-search'
|
||
} satisfies PoiSearchOverlayHistoryState, '', window.location.href)
|
||
poiSearchHistoryPushed = true
|
||
}
|
||
|
||
const removePoiSearchOverlayHistory = () => {
|
||
if (!canManagePoiSearchOverlayHistory() || !poiSearchHistoryPushed) return
|
||
|
||
if (!isPoiSearchOverlayHistoryState(window.history.state)) {
|
||
poiSearchHistoryPushed = false
|
||
return
|
||
}
|
||
|
||
poiSearchHistoryPushed = false
|
||
removingPoiSearchHistory = true
|
||
window.history.back()
|
||
}
|
||
|
||
const collapsePoiSearchAfterHistoryBack = async () => {
|
||
homeSearchPanelRef.value?.collapseHomePanel?.()
|
||
await homeSearchPanelRef.value?.resetSearchState?.()
|
||
clearHomeSearchMapState()
|
||
selectedGuidePoi.value = null
|
||
isPoiCardCollapsed.value = false
|
||
homeSearchExpanded.value = false
|
||
}
|
||
|
||
const handlePoiSearchHistoryPopState = (event: PopStateEvent) => {
|
||
if (!canManagePoiSearchOverlayHistory()) return
|
||
|
||
if (removingPoiSearchHistory) {
|
||
removingPoiSearchHistory = false
|
||
poiSearchHistoryPushed = false
|
||
if (homeSearchExpanded.value) pushPoiSearchOverlayHistory()
|
||
return
|
||
}
|
||
|
||
if (!homeSearchExpanded.value) return
|
||
if (isPoiSearchOverlayHistoryState(event.state)) {
|
||
poiSearchHistoryPushed = true
|
||
return
|
||
}
|
||
|
||
poiSearchHistoryPushed = false
|
||
void collapsePoiSearchAfterHistoryBack()
|
||
}
|
||
|
||
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
|
||
syncEmbeddedHostNavigationTitle(tabId)
|
||
syncTopTabToUrl(tabId)
|
||
loadTopTabData(tabId)
|
||
|
||
console.log('切换标签:', tabId)
|
||
|
||
// 关闭可能打开的面板
|
||
showRoutePlanner.value = false
|
||
isSimulatingRoute.value = false
|
||
isPoiCardCollapsed.value = false
|
||
closeArrivalPanel()
|
||
}
|
||
|
||
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
|
||
closeArrivalPanel()
|
||
}
|
||
syncTopTabToUrl(tab)
|
||
syncEmbeddedHostNavigationTitle(tab)
|
||
return
|
||
}
|
||
|
||
syncTopTabToUrl(currentTab.value)
|
||
loadTopTabData(currentTab.value)
|
||
syncEmbeddedHostNavigationTitle(currentTab.value)
|
||
}
|
||
|
||
onMounted(() => {
|
||
startLaunchOverlay()
|
||
showIndoorHint()
|
||
|
||
if (typeof window === 'undefined') return
|
||
|
||
stopOutdoorMapAvailabilitySubscription = subscribeOutdoorMapAvailability(handleOutdoorMapUnavailable)
|
||
void refreshOutdoorMapAvailability()
|
||
syncTopTabFromHash()
|
||
removePoiSearchOverlayFromCurrentHistoryState()
|
||
window.addEventListener('hashchange', syncTopTabFromHash)
|
||
window.addEventListener('popstate', handlePoiSearchHistoryPopState)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
launchOverlayRunId += 1
|
||
clearLaunchTimers()
|
||
|
||
if (outdoorMapPreloadTimer) {
|
||
clearTimeout(outdoorMapPreloadTimer)
|
||
outdoorMapPreloadTimer = null
|
||
}
|
||
|
||
stopOutdoorMapAvailabilitySubscription?.()
|
||
stopOutdoorMapAvailabilitySubscription = null
|
||
|
||
if (indoorGestureHintTimer) {
|
||
clearTimeout(indoorGestureHintTimer)
|
||
indoorGestureHintTimer = null
|
||
}
|
||
|
||
if (typeof window === 'undefined') return
|
||
|
||
window.removeEventListener('hashchange', syncTopTabFromHash)
|
||
window.removeEventListener('popstate', handlePoiSearchHistoryPopState)
|
||
})
|
||
|
||
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
|
||
|| getPreferredInitialGuideFloorId(floors)
|
||
|| 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,
|
||
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, sceneRevision?: number) => {
|
||
if (!guideSceneState.commitRendererScene({
|
||
floorId,
|
||
view: 'floor',
|
||
requestRevision: sceneRevision
|
||
})) return
|
||
renderedFloorId.value = floorId
|
||
if (loadingFloorId.value === floorId) {
|
||
loadingFloorId.value = ''
|
||
}
|
||
if (failedFloorId.value === floorId) {
|
||
failedFloorId.value = ''
|
||
}
|
||
selectedGuidePoi.value = null
|
||
isPoiCardCollapsed.value = false
|
||
hideIndoorHint()
|
||
if (showRoutePlanner.value && !activeRoutePreview.value) {
|
||
void loadRouteTargets(floorId)
|
||
}
|
||
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, sceneRevision?: number) => {
|
||
if (!guideSceneState.commitRendererScene({ view, requestRevision: sceneRevision })) return
|
||
if (view !== 'floor') {
|
||
selectedGuidePoi.value = null
|
||
isPoiCardCollapsed.value = false
|
||
}
|
||
|
||
if (view === 'multi') {
|
||
hideIndoorHint()
|
||
} else if (view === 'floor') {
|
||
hideIndoorHint()
|
||
}
|
||
}
|
||
|
||
const handleAutoSwitch = (event: {
|
||
from: 'overview' | 'floor'
|
||
to: 'overview' | 'floor'
|
||
trigger: string
|
||
distance: number
|
||
sceneRevision?: number
|
||
}) => {
|
||
console.log('自动切换视图:', event)
|
||
if (!guideSceneState.commitRendererScene({
|
||
view: event.to,
|
||
requestRevision: event.sceneRevision
|
||
})) return
|
||
if (event.to !== 'floor') {
|
||
selectedGuidePoi.value = null
|
||
isPoiCardCollapsed.value = false
|
||
}
|
||
// 缩放手势本身已表达视图切换意图,不再用提示条遮挡地图。
|
||
hideIndoorHint()
|
||
if (event.to === 'overview' && disableIndoorAutoExit.value) {
|
||
void nextTick(() => ensureGuideFloorCommitted(activeGuideFloor.value, { force: true }))
|
||
}
|
||
}
|
||
|
||
const handleInitialModelReady = (event: { view: GuideIndoorView; floorId?: string; elapsedMs?: number }) => {
|
||
const readyFloorId = event.floorId || activeGuideFloor.value
|
||
if (event.view === 'overview' && readyFloorId) {
|
||
activeGuideFloor.value = readyFloorId
|
||
renderedFloorId.value = readyFloorId
|
||
guideModelState.initializeModel({ view: event.view, floorId: readyFloorId })
|
||
}
|
||
settleLaunchOverlayByModel('ready', event)
|
||
preloadOutdoorMapAfterInitialView()
|
||
const context = guideModelState.consumePendingViewBaselineReset()
|
||
if (context) {
|
||
void resetGuideModelToFloorBaseline({
|
||
rendererReason: context.rendererReason,
|
||
floorId: context.floorId,
|
||
request: context
|
||
})
|
||
}
|
||
}
|
||
|
||
const handleInitialModelFailed = (event: {
|
||
view: GuideIndoorView
|
||
floorId?: string
|
||
message: string
|
||
elapsedMs?: number
|
||
fallbackAvailable?: boolean
|
||
actualRenderMode?: IndoorRenderMode
|
||
}) => {
|
||
if (event.fallbackAvailable && event.actualRenderMode === 'two-d') {
|
||
guideSceneState.commit({ renderMode: 'two-d' })
|
||
}
|
||
settleLaunchOverlayByModel('failed', event)
|
||
}
|
||
|
||
const handleIndoorRenderModeFallback = (event: {
|
||
mode: 'two-d'
|
||
view: 'overview' | 'floor'
|
||
floorId?: string
|
||
reason: string
|
||
sceneRevision?: number
|
||
}) => {
|
||
if (
|
||
event.sceneRevision !== undefined
|
||
&& !guideSceneState.isCurrentRevision(event.sceneRevision)
|
||
) return
|
||
|
||
guideSceneState.commit({
|
||
renderMode: event.mode,
|
||
view: event.view,
|
||
...(event.floorId ? { floorId: event.floorId } : {})
|
||
})
|
||
if (event.floorId) {
|
||
renderedFloorId.value = event.floorId
|
||
}
|
||
}
|
||
|
||
const handleGuideSceneViewportChange = (viewport: GuideViewportState) => {
|
||
guideSceneState.updateViewport(viewport)
|
||
}
|
||
|
||
const handleIndoorToolClick = (tool: string) => {
|
||
if (!isBuildingGuideActive.value) return
|
||
|
||
if (tool === '复位') {
|
||
showIndoorHint('已复位视角,单指平移、双指可缩放', 2800)
|
||
} else if (tool === '俯视') {
|
||
showIndoorHint('已切换俯视视角,可点选标记查看位置', 2800)
|
||
} else if (tool === '斜视') {
|
||
showIndoorHint('已切换斜视视角,可查看空间层次', 2800)
|
||
}
|
||
}
|
||
|
||
const handleGuidePoiClick = (poi: GuideRenderPoi) => {
|
||
// Do not let a delayed POI click leave the route-start selection session and
|
||
// reopen the ordinary detail card.
|
||
if (routeStartSelectionActive.value) {
|
||
// Space labels such as 5F "办公室" are valid objects on the map but do
|
||
// not necessarily have a row in navigable-places. Preserve the object
|
||
// coordinate so the route API can snap it to the nearest formal graph
|
||
// node, instead of dropping the click because only the name was passed.
|
||
const sourceIds = [poi.id, poi.spaceId, poi.sourceSpaceId, poi.sourcePlaceId]
|
||
.filter((value): value is string => Boolean(value))
|
||
const existingPoint = routePointOptions.value.find((option) => (
|
||
sourceIds.includes(option.poiId)
|
||
|| sourceIds.includes(option.sourceId || '')
|
||
|| sourceIds.some((sourceId) => option.routeTargetId?.startsWith(`${sourceId}:`))
|
||
))
|
||
|
||
handleRouteStartCandidate({
|
||
routeTargetId: existingPoint?.routeTargetId,
|
||
poiId: existingPoint?.poiId || poi.id,
|
||
sourceId: existingPoint?.sourceId || poi.spaceId || poi.sourceSpaceId || poi.sourcePlaceId,
|
||
sourceName: poi.name,
|
||
floorId: existingPoint?.floorId || poi.floorId,
|
||
positionGltf: existingPoint?.positionGltf || poi.positionGltf,
|
||
routeNodeId: existingPoint?.routeNodeId,
|
||
coordinateFallback: !existingPoint?.routeNodeId
|
||
})
|
||
return
|
||
}
|
||
|
||
selectedGuidePoi.value = poi
|
||
isPoiCardCollapsed.value = false
|
||
guideSceneState.commit({ view: 'floor', floorId: poi.floorId })
|
||
isSimulatingRoute.value = false
|
||
renderedFloorId.value = poi.floorId
|
||
showIndoorHint(`${normalizeVisitorPoiDisplayName(poi.name)} · ${getGuideFloorLabel(poi.floorId)}`, 3000)
|
||
}
|
||
|
||
const handleGuideSelectionClear = () => {
|
||
selectedGuidePoi.value = null
|
||
isPoiCardCollapsed.value = false
|
||
}
|
||
|
||
const handleGuidePoiCardClose = () => {
|
||
const floorId = selectedGuidePoi.value?.floorId
|
||
if (!floorId) {
|
||
handleGuideSelectionClear()
|
||
return
|
||
}
|
||
|
||
void resetGuideModelToFloorBaseline({
|
||
rendererReason: 'floor-reset',
|
||
floorId
|
||
})
|
||
}
|
||
|
||
watch(selectedGuidePoi, (poi) => {
|
||
const requestId = ++poiDetailResolutionRequestId
|
||
selectedGuidePoiDetailTarget.value = null
|
||
if (!poi) return
|
||
|
||
void poiDetailUseCase.resolve(poi)
|
||
.then((target) => {
|
||
if (
|
||
requestId !== poiDetailResolutionRequestId
|
||
|| selectedGuidePoi.value?.id !== poi.id
|
||
) return
|
||
|
||
selectedGuidePoiDetailTarget.value = target
|
||
})
|
||
.catch((error) => {
|
||
if (requestId !== poiDetailResolutionRequestId) return
|
||
console.warn('点位详情目标解析失败:', error)
|
||
})
|
||
}, { flush: 'sync' })
|
||
|
||
const navigateToPoiDetailTarget = (
|
||
target: PoiDetailTarget,
|
||
uiContext: PoiDetailUiContext
|
||
) => {
|
||
const returnContext = guideModelState.beginPoiDetailReturn(target.floorId)
|
||
pendingPoiDetailUiContext = {
|
||
requestId: returnContext.requestId,
|
||
value: uiContext
|
||
}
|
||
uni.navigateTo({
|
||
url: target.url,
|
||
success: () => {
|
||
guideModelState.confirmPoiDetailReturn(returnContext.requestId)
|
||
},
|
||
fail: () => {
|
||
guideModelState.cancelPoiDetailReturn(returnContext.requestId)
|
||
if (pendingPoiDetailUiContext?.requestId === returnContext.requestId) {
|
||
pendingPoiDetailUiContext = null
|
||
}
|
||
uni.showToast({
|
||
title: target.failureMessage,
|
||
icon: 'none'
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
const handleViewSelectedPoiDetail = () => {
|
||
const target = selectedGuidePoiDetailTarget.value
|
||
if (!target) return
|
||
navigateToPoiDetailTarget(target, {
|
||
source: 'map',
|
||
categoryModeActive: false,
|
||
visiblePoiIds: null
|
||
})
|
||
}
|
||
|
||
const handleNavigateToSelectedGuidePoi = () => {
|
||
const poi = selectedGuidePoi.value
|
||
if (!poi) return
|
||
|
||
clearRoutePreviewState()
|
||
routeReturnContext.value = {
|
||
poi,
|
||
floorId: poi.floorId || activeGuideFloor.value
|
||
}
|
||
routeStartPoint.value = null
|
||
routeStartCandidate.value = null
|
||
const destinationPoi = poi
|
||
routeEndPoint.value = {
|
||
poiId: poi.id,
|
||
name: poi.name,
|
||
floorId: poi.floorId,
|
||
floorLabel: getGuideFloorLabel(poi.floorId),
|
||
categoryLabel: poi.primaryCategoryZh,
|
||
positionGltf: poi.positionGltf
|
||
}
|
||
showRoutePlanner.value = true
|
||
isSimulatingRoute.value = false
|
||
routeRemainingMeters.value = 0
|
||
// Enter start-point selection immediately. The route service can snap this
|
||
// object's GLB coordinate to the formal graph. Do not fetch the all-floor
|
||
// target catalog here; eight floor requests would compete with the route
|
||
// request and are unnecessary for object-based start selection.
|
||
routeEndPoint.value = toImmediateRoutePoint(destinationPoi)
|
||
void loadRouteTargets(destinationPoi.floorId)
|
||
}
|
||
|
||
const handleSelectedGuidePoiAction = (actionId: string) => {
|
||
if (actionId === 'navigate') {
|
||
handleNavigateToSelectedGuidePoi()
|
||
return
|
||
}
|
||
if (actionId === 'detail') handleViewSelectedPoiDetail()
|
||
}
|
||
|
||
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
|
||
routeRemainingMeters.value = 0
|
||
routeTransferNotice.value = ''
|
||
if (routeTransferNoticeTimer !== null) {
|
||
window.clearTimeout(routeTransferNoticeTimer)
|
||
routeTransferNoticeTimer = null
|
||
}
|
||
}
|
||
|
||
const restoreRouteReturnContext = async () => {
|
||
const context = routeReturnContext.value
|
||
routeReturnContext.value = null
|
||
routeStartPoint.value = null
|
||
routeStartCandidate.value = null
|
||
routeEndPoint.value = null
|
||
showRoutePlanner.value = false
|
||
|
||
// Commit the browse floor before removing route props. Otherwise the
|
||
// same-ground composite watcher can treat the old route floor as current
|
||
// and briefly restore exterior POI labels over the indoor model.
|
||
if (context) {
|
||
guideSceneState.commit({ view: 'floor', floorId: context.floorId })
|
||
renderedFloorId.value = context.floorId
|
||
await nextTick()
|
||
}
|
||
|
||
clearRoutePreviewState()
|
||
|
||
if (!context) return
|
||
|
||
// Let the route props clear before restoring the browse baseline.
|
||
await nextTick()
|
||
|
||
await resetGuideModelToFloorBaseline({
|
||
rendererReason: 'floor-reset',
|
||
floorId: context.floorId
|
||
})
|
||
selectedGuidePoi.value = context.poi
|
||
isPoiCardCollapsed.value = false
|
||
}
|
||
|
||
const handleRouteBack = () => {
|
||
void restoreRouteReturnContext()
|
||
}
|
||
|
||
const handleRouteStartCandidate = (
|
||
candidate: string | GuideRenderPoi | RouteStartCandidatePayload
|
||
) => {
|
||
if (!routeStartSelectionActive.value) return
|
||
const candidateId = typeof candidate === 'string'
|
||
? candidate
|
||
: ('poiId' in candidate ? candidate.routeTargetId || candidate.poiId : candidate.id)
|
||
const sourceName = typeof candidate === 'string'
|
||
? ''
|
||
: ('sourceName' in candidate
|
||
? (candidate as RouteStartCandidatePayload).sourceName || ''
|
||
: (candidate as GuideRenderPoi).name)
|
||
const existingPoint = routePointOptions.value.find((option) => (
|
||
option.routeTargetId === candidateId
|
||
|| option.poiId === candidateId
|
||
|| option.sourceId === candidateId
|
||
|| (
|
||
typeof candidate !== 'string'
|
||
&& 'sourceId' in candidate
|
||
&& candidate.sourceId
|
||
&& option.sourceId === candidate.sourceId
|
||
)
|
||
))
|
||
const coordinateCandidate = typeof candidate !== 'string'
|
||
&& 'coordinateFallback' in candidate
|
||
&& candidate.coordinateFallback
|
||
? candidate
|
||
: null
|
||
const point = existingPoint || (
|
||
coordinateCandidate
|
||
&& coordinateCandidate.positionGltf
|
||
&& coordinateCandidate.floorId
|
||
? {
|
||
routeTargetId: coordinateCandidate.routeTargetId,
|
||
poiId: coordinateCandidate.poiId,
|
||
sourceId: coordinateCandidate.sourceId,
|
||
name: coordinateCandidate.sourceName || coordinateCandidate.poiId,
|
||
floorId: coordinateCandidate.floorId,
|
||
floorLabel: getGuideFloorLabel(coordinateCandidate.floorId),
|
||
positionGltf: coordinateCandidate.positionGltf,
|
||
routeNodeId: coordinateCandidate.routeNodeId,
|
||
coordinateFallback: true
|
||
}
|
||
: null
|
||
)
|
||
if (!point) return
|
||
routeStartCandidate.value = sourceName ? { ...point, name: sourceName } : point
|
||
}
|
||
|
||
const handleRouteStartCandidateRejected = () => {
|
||
if (!routeStartSelectionActive.value) return
|
||
uni.showToast({ title: '该位置附近没有可用起点', icon: 'none' })
|
||
}
|
||
|
||
const handleRouteStartCancel = () => {
|
||
routeStartCandidate.value = null
|
||
}
|
||
|
||
const handleRouteStartConfirm = (startPoint: RoutePointOption) => {
|
||
const endPoint = routeEndPoint.value
|
||
if (!endPoint) return
|
||
routeStartPoint.value = startPoint
|
||
routeStartCandidate.value = null
|
||
void handleRouteView({ startPoint, endPoint })
|
||
}
|
||
|
||
const focusRouteFloor = (route: GuideRouteResult) => {
|
||
const hasExteriorSegment = route.floorSegments.some((segment) => (
|
||
!guideFloors.value.some((floor) => String(floor.id) === String(segment.floorId))
|
||
))
|
||
guideSceneState.commit({
|
||
view: hasExteriorSegment ? 'overview' : 'floor',
|
||
floorId: route.start.floorId
|
||
})
|
||
renderedFloorId.value = route.start.floorId
|
||
// Mobile routes always present one physical floor at a time. Expanding GLB
|
||
// floors vertically makes DOM labels and route elevations diverge. L1 plus
|
||
// EXTERIOR is the sole exception and uses its dedicated ground composite.
|
||
}
|
||
|
||
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.routeTargetId || startPoint.poiId,
|
||
...(startPoint.positionGltf
|
||
? {
|
||
startTarget: {
|
||
routeTargetId: startPoint.routeTargetId,
|
||
poiId: startPoint.poiId,
|
||
sourceId: startPoint.sourceId,
|
||
name: startPoint.name,
|
||
floorId: startPoint.floorId,
|
||
floorLabel: startPoint.floorLabel,
|
||
categoryLabel: startPoint.categoryLabel,
|
||
positionGltf: startPoint.positionGltf,
|
||
routeNodeId: startPoint.routeNodeId
|
||
}
|
||
}
|
||
: {}),
|
||
endTarget: {
|
||
routeTargetId: endPoint.routeTargetId,
|
||
poiId: endPoint.poiId,
|
||
name: endPoint.name,
|
||
floorId: endPoint.floorId,
|
||
floorLabel: endPoint.floorLabel,
|
||
categoryLabel: endPoint.categoryLabel,
|
||
positionGltf: endPoint.positionGltf,
|
||
routeNodeId: endPoint.routeNodeId
|
||
}
|
||
})
|
||
|
||
if (!result.route) {
|
||
activeRoutePreview.value = null
|
||
routePlanError.value = result.error || '馆内导览位置关系生成失败'
|
||
return
|
||
}
|
||
|
||
if (result.route) {
|
||
// 路线内部使用接入点节点;界面始终显示用户选择的地点名称。
|
||
result.route.start.name = startPoint.name
|
||
result.route.end.name = endPoint.name
|
||
}
|
||
activeRoutePreview.value = result.route
|
||
routeRemainingMeters.value = Math.round(result.route.distanceMeters)
|
||
// 路线已确定后由起终点锚点承担强调,取消原点位焦点卡以恢复普通节点标签。
|
||
selectedGuidePoi.value = null
|
||
guideMapShellRef.value?.clearSelection?.()
|
||
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
|
||
routeRemainingMeters.value = Math.round(activeRoutePreview.value.distanceMeters)
|
||
}
|
||
|
||
const handleRouteRoamingProgress = (event: { remainingMeters: number; progress?: number }) => {
|
||
routeRemainingMeters.value = Math.max(0, Math.round(event.remainingMeters))
|
||
if (isSimulatingRoute.value && event.progress !== undefined && event.progress >= 1) {
|
||
void restoreRouteReturnContext()
|
||
}
|
||
}
|
||
|
||
const handleRouteRoamingTransfer = (event: {
|
||
toFloorId: string
|
||
transferType: string
|
||
connectorName?: string
|
||
}) => {
|
||
const transferLabel = event.transferType === 'STAIRS'
|
||
? '走楼梯'
|
||
: event.transferType === 'ESCALATOR'
|
||
? '乘扶梯'
|
||
: '乘电梯'
|
||
const destinationFloor = getGuideFloorLabel(event.toFloorId) || event.toFloorId
|
||
routeTransferNotice.value = `${event.connectorName || transferLabel},至 ${destinationFloor}`
|
||
if (routeTransferNoticeTimer !== null) window.clearTimeout(routeTransferNoticeTimer)
|
||
routeTransferNoticeTimer = window.setTimeout(() => {
|
||
routeTransferNotice.value = ''
|
||
routeTransferNoticeTimer = null
|
||
}, 2400)
|
||
}
|
||
|
||
// Return from the outdoor-arrival map to the building guide. The guide keeps
|
||
// whichever basemap renderer (2D/3D) the scene controller currently owns.
|
||
const handleEnterBuildingGuide = () => {
|
||
console.log('进入馆内导览模式')
|
||
closeArrivalPanel()
|
||
indoorView.value = 'overview'
|
||
isBuildingGuideActive.value = true
|
||
guideOutdoorState.value = 'home'
|
||
showIndoorHint()
|
||
}
|
||
|
||
const handleSemanticEnterBuilding = () => {
|
||
if (!semanticMapZoomEnabled.value || isBuildingGuideActive.value) return
|
||
|
||
showOutdoorNavigationPanel.value = false
|
||
clearOutdoorRoute()
|
||
indoorView.value = 'overview'
|
||
isBuildingGuideActive.value = true
|
||
guideOutdoorState.value = 'home'
|
||
guideSceneState.commit({
|
||
view: 'overview',
|
||
floorId: activeGuideFloor.value
|
||
})
|
||
}
|
||
|
||
const handleSemanticExitOutdoor = () => {
|
||
if (!homeArrivalFeatureEnabled.value || !semanticMapZoomEnabled.value || !isBuildingGuideActive.value) return
|
||
|
||
// Zooming out of the building is also an entry into the arrival map. Keep
|
||
// its first screen consistent with tapping the "来馆" quick action.
|
||
showOutdoorNavigationPanel.value = true
|
||
outdoorArrivalStage.value = 'browse'
|
||
clearOutdoorRoute()
|
||
arrivalActiveType.value = null
|
||
arrivalTargets.value = []
|
||
arrivalTargetsError.value = ''
|
||
selectedArrivalTarget.value = null
|
||
indoorView.value = 'overview'
|
||
isBuildingGuideActive.value = false
|
||
guideOutdoorState.value = 'home'
|
||
}
|
||
|
||
const handleModeChange = (mode: '2d' | '3d') => {
|
||
isBuildingGuideActive.value = mode === '3d'
|
||
|
||
if (mode === '2d') {
|
||
closeHomeSearchDock()
|
||
indoorView.value = 'overview'
|
||
guideOutdoorState.value = 'home'
|
||
selectedGuidePoi.value = null
|
||
isPoiCardCollapsed.value = false
|
||
showRoutePlanner.value = false
|
||
isSimulatingRoute.value = false
|
||
clearRoutePreviewState()
|
||
} else {
|
||
closeArrivalPanel()
|
||
indoorView.value = 'overview'
|
||
showIndoorHint()
|
||
}
|
||
}
|
||
|
||
const handleIndoorRenderModeChange = (mode: IndoorRenderMode) => {
|
||
if (indoorRenderMode.value === mode) return
|
||
guideSceneState.commit({ renderMode: mode })
|
||
}
|
||
|
||
const guideStatusLabel = computed(() => {
|
||
if (isBuildingGuideActive.value) {
|
||
if (isTwoDimensionalIndoorMode.value) {
|
||
return `${getGuideFloorLabel(renderedFloorId.value || activeGuideFloor.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 = async () => {
|
||
if (returningToOverview.value) return
|
||
|
||
if (isBuildingGuideActive.value && indoorView.value !== 'overview') {
|
||
returningToOverview.value = true
|
||
showRoutePlanner.value = false
|
||
selectedGuidePoi.value = null
|
||
isPoiCardCollapsed.value = false
|
||
isSimulatingRoute.value = false
|
||
requestedFloorId.value = ''
|
||
requestedFloorLabel.value = ''
|
||
loadingFloorId.value = ''
|
||
failedFloorId.value = ''
|
||
clearRoutePreviewState()
|
||
|
||
try {
|
||
await guideMapShellRef.value?.showOverview?.()
|
||
indoorView.value = 'overview'
|
||
guideOutdoorState.value = 'home'
|
||
closeHomeSearchDock()
|
||
hideIndoorHint()
|
||
} catch (error) {
|
||
console.error('返回建筑外观失败:', error)
|
||
showIndoorHint('建筑外观加载失败,请稍后重试', 3200)
|
||
} finally {
|
||
returningToOverview.value = false
|
||
}
|
||
return
|
||
}
|
||
|
||
closeArrivalPanel()
|
||
showRoutePlanner.value = false
|
||
isBuildingGuideActive.value = true
|
||
guideOutdoorState.value = 'home'
|
||
selectedGuidePoi.value = null
|
||
isPoiCardCollapsed.value = false
|
||
isSimulatingRoute.value = false
|
||
indoorView.value = 'overview'
|
||
requestedFloorId.value = ''
|
||
requestedFloorLabel.value = ''
|
||
loadingFloorId.value = ''
|
||
failedFloorId.value = ''
|
||
guideSceneState.commit({
|
||
view: 'overview',
|
||
floorId: activeGuideFloor.value
|
||
})
|
||
}
|
||
|
||
const toArrivalSearchTarget = (place: OutdoorPlace, type: ArrivalTargetType): ArrivalSearchTarget => ({
|
||
id: place.id || `${type}:${place.latitude}:${place.longitude}`,
|
||
type,
|
||
title: place.title,
|
||
subtitle: place.address || place.category || '周边服务点',
|
||
keyword: place.title,
|
||
region: ARRIVAL_SEARCH_REGION,
|
||
latitude: place.latitude,
|
||
longitude: place.longitude,
|
||
distance: place.distance
|
||
})
|
||
|
||
const loadArrivalTargets = async (type: ArrivalTargetType) => {
|
||
const requestId = ++arrivalTargetRequestId
|
||
const config = ARRIVAL_TARGET_TYPES.find((item) => item.type === type)
|
||
if (!config) return
|
||
|
||
const cached = arrivalTargetCache.get(type)
|
||
if (cached && cached.expiresAt > Date.now()) {
|
||
arrivalTargetsLoading.value = false
|
||
arrivalTargetsError.value = ''
|
||
arrivalTargets.value = cached.items
|
||
selectedArrivalTarget.value = null
|
||
return
|
||
}
|
||
arrivalTargetCache.delete(type)
|
||
arrivalTargetsLoading.value = true
|
||
arrivalTargetsError.value = ''
|
||
arrivalTargets.value = []
|
||
selectedArrivalTarget.value = null
|
||
try {
|
||
const response = await searchOutdoorPlaces({
|
||
keyword: config.keyword,
|
||
region: ARRIVAL_SEARCH_REGION,
|
||
latitude: MUSEUM_LOCATION.latitude,
|
||
longitude: MUSEUM_LOCATION.longitude,
|
||
pageSize: 12
|
||
})
|
||
if (requestId !== arrivalTargetRequestId) return
|
||
|
||
const uniqueTargets = new Map<string, ArrivalSearchTarget>()
|
||
response.items.forEach((place) => {
|
||
const target = toArrivalSearchTarget(place, type)
|
||
if (!uniqueTargets.has(target.id)) uniqueTargets.set(target.id, target)
|
||
})
|
||
const items = [...uniqueTargets.values()]
|
||
arrivalTargetCache.set(type, {
|
||
expiresAt: Date.now() + ARRIVAL_TARGET_CACHE_TTL_MS,
|
||
items
|
||
})
|
||
arrivalTargets.value = items
|
||
} catch (error) {
|
||
if (requestId !== arrivalTargetRequestId) return
|
||
console.error('读取来馆分类点位失败:', error)
|
||
arrivalTargetsError.value = '周边点位暂时无法加载'
|
||
} finally {
|
||
if (requestId === arrivalTargetRequestId) arrivalTargetsLoading.value = false
|
||
}
|
||
}
|
||
|
||
const handleArrivalTypeChange = (type: ArrivalTargetType) => {
|
||
if (type === arrivalActiveType.value) return
|
||
arrivalActiveType.value = type
|
||
outdoorArrivalStage.value = 'browse'
|
||
void loadArrivalTargets(type)
|
||
}
|
||
|
||
const handleArrivalTargetSelect = (target: ArrivalSearchTarget) => {
|
||
selectedArrivalTarget.value = target
|
||
outdoorArrivalStage.value = 'detail'
|
||
}
|
||
|
||
const handleArrivalNavigate = (target: ArrivalSearchTarget) => {
|
||
selectedArrivalTarget.value = target
|
||
// The selected arrival category already establishes the route strategy.
|
||
// Transit points use Tencent transit planning; parking uses driving.
|
||
outdoorTravelMode.value = target.type === 'parking' ? 'driving' : 'transit'
|
||
outdoorArrivalStage.value = 'route'
|
||
clearOutdoorRoute()
|
||
// Real-time location is the preferred zero-step route start. Manual input
|
||
// and map selection retain an explicit confirmation because they are
|
||
// visitor-selected coordinates.
|
||
void requestOutdoorCurrentLocation()
|
||
}
|
||
|
||
const returnToArrivalBrowse = () => {
|
||
outdoorArrivalStage.value = 'browse'
|
||
selectedArrivalTarget.value = null
|
||
clearOutdoorRoute()
|
||
}
|
||
|
||
const collapseArrivalBrowse = () => {
|
||
arrivalTargetRequestId += 1
|
||
outdoorArrivalStage.value = 'browse'
|
||
arrivalActiveType.value = null
|
||
arrivalTargets.value = []
|
||
selectedArrivalTarget.value = null
|
||
arrivalTargetsError.value = ''
|
||
arrivalTargetsLoading.value = false
|
||
clearOutdoorRoute()
|
||
}
|
||
|
||
const returnToArrivalDetail = () => {
|
||
outdoorArrivalStage.value = selectedArrivalTarget.value ? 'detail' : 'browse'
|
||
clearOutdoorRoute()
|
||
}
|
||
|
||
const closeArrivalPanel = () => {
|
||
arrivalTargetRequestId += 1
|
||
showOutdoorNavigationPanel.value = false
|
||
outdoorArrivalStage.value = 'browse'
|
||
arrivalActiveType.value = null
|
||
arrivalTargets.value = []
|
||
selectedArrivalTarget.value = null
|
||
arrivalTargetsError.value = ''
|
||
arrivalTargetsLoading.value = false
|
||
outdoorStartSearchResults.value = []
|
||
clearOutdoorRoute()
|
||
isBuildingGuideActive.value = true
|
||
guideOutdoorState.value = 'home'
|
||
}
|
||
|
||
const clearOutdoorRoute = () => {
|
||
outdoorStartLocationRequestId += 1
|
||
outdoorRoutePlanRequestId += 1
|
||
outdoorStartSearchRequestId += 1
|
||
outdoorManualReverseGeocodeRequestId += 1
|
||
outdoorRoute.value = null
|
||
outdoorRouteError.value = ''
|
||
outdoorRouteStart.value = null
|
||
outdoorRouteStartPhase.value = 'locating'
|
||
outdoorLocationMessage.value = ''
|
||
outdoorLocationHelpVisible.value = false
|
||
outdoorStartSearchResults.value = []
|
||
outdoorStartSearchLoading.value = false
|
||
outdoorRouteLoading.value = false
|
||
}
|
||
|
||
const planOutdoorRouteFromStart = async (start: { latitude: number; longitude: number; name?: string }) => {
|
||
const destination = selectedArrivalTarget.value
|
||
if (!destination) {
|
||
outdoorRouteError.value = '请先选择目的地'
|
||
return
|
||
}
|
||
|
||
const requestId = ++outdoorRoutePlanRequestId
|
||
outdoorRouteLoading.value = true
|
||
outdoorRouteError.value = ''
|
||
outdoorRoute.value = null
|
||
try {
|
||
const route = await planOutdoorRoute({
|
||
from: start,
|
||
to: destination,
|
||
mode: outdoorTravelMode.value.toUpperCase() as 'DRIVING' | 'WALKING' | 'TRANSIT'
|
||
})
|
||
if (requestId !== outdoorRoutePlanRequestId) return
|
||
outdoorRoute.value = route
|
||
} catch (error) {
|
||
if (requestId !== outdoorRoutePlanRequestId) return
|
||
outdoorRouteError.value = error instanceof Error ? error.message : '路线规划失败,请稍后重试'
|
||
} finally {
|
||
if (requestId === outdoorRoutePlanRequestId) outdoorRouteLoading.value = false
|
||
}
|
||
}
|
||
|
||
const messageForOutdoorLocationFailure = (error: unknown) => {
|
||
if (error instanceof BrowserLocationError) {
|
||
if (error.reason === 'denied') {
|
||
return '请在微信小程序的设置中开启位置权限后重试'
|
||
}
|
||
return error.message
|
||
}
|
||
return '暂时无法获取当前位置,请手动选择起点'
|
||
}
|
||
|
||
const requestOutdoorCurrentLocation = async () => {
|
||
const requestId = ++outdoorStartLocationRequestId
|
||
outdoorRoutePlanRequestId += 1
|
||
outdoorManualReverseGeocodeRequestId += 1
|
||
outdoorRouteStartPhase.value = 'locating'
|
||
outdoorLocationMessage.value = ''
|
||
outdoorLocationHelpVisible.value = false
|
||
outdoorRouteStart.value = null
|
||
outdoorRouteError.value = ''
|
||
outdoorRoute.value = null
|
||
outdoorRouteLoading.value = false
|
||
try {
|
||
const location = await getBrowserGcjLocation()
|
||
if (requestId !== outdoorStartLocationRequestId) return
|
||
outdoorRouteStart.value = {
|
||
latitude: location.latitude,
|
||
longitude: location.longitude,
|
||
name: '我的位置'
|
||
}
|
||
outdoorRouteStartPhase.value = 'route'
|
||
await planOutdoorRouteFromStart(outdoorRouteStart.value)
|
||
} catch (error) {
|
||
if (requestId !== outdoorStartLocationRequestId) return
|
||
outdoorLocationMessage.value = messageForOutdoorLocationFailure(error)
|
||
outdoorRouteStartPhase.value = 'permission-needed'
|
||
} finally {
|
||
if (requestId === outdoorStartLocationRequestId) outdoorRouteLoading.value = false
|
||
}
|
||
}
|
||
|
||
const handleOutdoorLocationRetry = async () => {
|
||
const permissionState = await getBrowserLocationPermissionState()
|
||
if (permissionState === 'denied') {
|
||
outdoorLocationHelpVisible.value = true
|
||
outdoorLocationMessage.value = '当前位置权限已关闭'
|
||
return
|
||
}
|
||
void requestOutdoorCurrentLocation()
|
||
}
|
||
|
||
const handleOutdoorManualSearchRequest = () => {
|
||
outdoorStartLocationRequestId += 1
|
||
outdoorRoutePlanRequestId += 1
|
||
outdoorStartSearchRequestId += 1
|
||
outdoorManualReverseGeocodeRequestId += 1
|
||
outdoorRouteStartPhase.value = 'manual-search'
|
||
outdoorLocationMessage.value = ''
|
||
outdoorLocationHelpVisible.value = false
|
||
outdoorRouteError.value = ''
|
||
outdoorRoute.value = null
|
||
outdoorRouteLoading.value = false
|
||
outdoorStartSearchResults.value = []
|
||
}
|
||
|
||
const handleOutdoorManualMapRequest = () => {
|
||
outdoorStartLocationRequestId += 1
|
||
outdoorRoutePlanRequestId += 1
|
||
outdoorStartSearchRequestId += 1
|
||
outdoorManualReverseGeocodeRequestId += 1
|
||
outdoorRouteStartPhase.value = 'manual-map'
|
||
outdoorLocationMessage.value = ''
|
||
outdoorLocationHelpVisible.value = false
|
||
outdoorRouteError.value = ''
|
||
outdoorRoute.value = null
|
||
outdoorRouteLoading.value = false
|
||
outdoorStartSearchResults.value = []
|
||
}
|
||
|
||
const handleOutdoorManualStartSearch = async (keyword: string) => {
|
||
const requestId = ++outdoorStartSearchRequestId
|
||
if (!keyword.trim()) {
|
||
outdoorStartSearchResults.value = []
|
||
outdoorStartSearchLoading.value = false
|
||
return
|
||
}
|
||
|
||
outdoorStartSearchLoading.value = true
|
||
try {
|
||
const response = await suggestOutdoorPlaces({
|
||
keyword,
|
||
region: ARRIVAL_SEARCH_REGION,
|
||
pageSize: 6
|
||
})
|
||
if (requestId !== outdoorStartSearchRequestId) return
|
||
outdoorStartSearchResults.value = response.items
|
||
} catch (error) {
|
||
if (requestId !== outdoorStartSearchRequestId) return
|
||
console.warn('手动起点地点检索失败:', error)
|
||
outdoorStartSearchResults.value = []
|
||
} finally {
|
||
if (requestId === outdoorStartSearchRequestId) outdoorStartSearchLoading.value = false
|
||
}
|
||
}
|
||
|
||
const handleOutdoorManualStartConfirm = (location: {
|
||
latitude: number
|
||
longitude: number
|
||
name?: string
|
||
title?: string
|
||
}) => {
|
||
outdoorStartLocationRequestId += 1
|
||
outdoorRoutePlanRequestId += 1
|
||
outdoorStartSearchRequestId += 1
|
||
outdoorManualReverseGeocodeRequestId += 1
|
||
outdoorStartSearchResults.value = []
|
||
const start = {
|
||
latitude: location.latitude,
|
||
longitude: location.longitude,
|
||
name: location.name || location.title || '手动选择起点'
|
||
}
|
||
outdoorRouteStart.value = start
|
||
outdoorLocationMessage.value = ''
|
||
outdoorLocationHelpVisible.value = false
|
||
outdoorRouteError.value = ''
|
||
outdoorRoute.value = null
|
||
outdoorRouteLoading.value = false
|
||
outdoorRouteStartPhase.value = 'confirm-start'
|
||
}
|
||
|
||
const resolveOutdoorManualMapStartName = async (
|
||
location: { latitude: number; longitude: number },
|
||
requestId: number
|
||
) => {
|
||
try {
|
||
const address = await reverseGeocodeOutdoorLocation(location)
|
||
if (requestId !== outdoorManualReverseGeocodeRequestId) return
|
||
|
||
const start = outdoorRouteStart.value
|
||
if (
|
||
!start
|
||
|| outdoorRouteStartPhase.value !== 'confirm-start'
|
||
|| start.latitude !== location.latitude
|
||
|| start.longitude !== location.longitude
|
||
) return
|
||
|
||
outdoorRouteStart.value = {
|
||
...start,
|
||
name: address.address?.trim() || start.name
|
||
}
|
||
} catch (error) {
|
||
// The coordinate remains usable when the optional address lookup fails.
|
||
console.warn('地图选点地址解析失败:', error)
|
||
}
|
||
}
|
||
|
||
const handleOutdoorMapTap = (location: { latitude: number; longitude: number }) => {
|
||
if (
|
||
outdoorArrivalStage.value !== 'route'
|
||
|| outdoorRouteStartPhase.value !== 'manual-map'
|
||
|| isBuildingGuideActive.value
|
||
) return
|
||
handleOutdoorManualStartConfirm({ ...location, name: '地图选点' })
|
||
const requestId = outdoorManualReverseGeocodeRequestId
|
||
void resolveOutdoorManualMapStartName(location, requestId)
|
||
}
|
||
|
||
const handleOutdoorRouteStartConfirm = () => {
|
||
if (!outdoorRouteStart.value) {
|
||
void requestOutdoorCurrentLocation()
|
||
return
|
||
}
|
||
outdoorRouteStartPhase.value = 'route'
|
||
void planOutdoorRouteFromStart(outdoorRouteStart.value)
|
||
}
|
||
|
||
const handleOutdoorRouteRetry = () => {
|
||
if (!outdoorRouteStart.value) return
|
||
void planOutdoorRouteFromStart(outdoorRouteStart.value)
|
||
}
|
||
|
||
const handleOutdoorMarkerClick = (markerId: string) => {
|
||
if (markerId === 'outdoor-route-start') {
|
||
if (outdoorArrivalStage.value === 'route') outdoorRouteStartPhase.value = 'confirm-start'
|
||
return
|
||
}
|
||
if (outdoorArrivalStage.value === 'route') return
|
||
|
||
const target = arrivalTargets.value.find((item) => item.id === markerId)
|
||
if (target) handleArrivalTargetSelect(target)
|
||
}
|
||
|
||
const closeHomeSearchDock = () => {
|
||
homeSearchPanelRef.value?.collapseHomePanel?.()
|
||
homeSearchExpanded.value = false
|
||
}
|
||
|
||
const resetGuideModelToFloorBaseline = async ({
|
||
rendererReason,
|
||
floorId,
|
||
request
|
||
}: {
|
||
rendererReason: GuideModelResetReason
|
||
floorId: string
|
||
request?: GuideModelResetRequest
|
||
}) => {
|
||
const initial = guideModelState.initialState.value
|
||
const resetRequest = request || guideModelState.beginViewBaselineReset({ floorId, rendererReason })
|
||
searchTargetFocusRequest.value = null
|
||
searchVisiblePoiIds.value = null
|
||
homeCategoryModeActive.value = false
|
||
selectedGuidePoi.value = null
|
||
isPoiCardCollapsed.value = false
|
||
showRoutePlanner.value = false
|
||
activeRoutePreview.value = null
|
||
routeStartPoint.value = null
|
||
routeStartCandidate.value = null
|
||
routeEndPoint.value = null
|
||
routePlanError.value = ''
|
||
isSimulatingRoute.value = false
|
||
requestedFloorId.value = ''
|
||
requestedFloorLabel.value = ''
|
||
loadingFloorId.value = ''
|
||
failedFloorId.value = ''
|
||
isBuildingGuideActive.value = true
|
||
guideOutdoorState.value = 'home'
|
||
guideSceneState.commit({
|
||
view: 'floor',
|
||
floorId: floorId || initial.defaultActiveFloorId || activeGuideFloor.value
|
||
})
|
||
closeArrivalPanel()
|
||
if (!guideModelState.isCurrentRequest(resetRequest.requestId)) return
|
||
|
||
const resetResult = await guideMapShellRef.value?.resetToViewBaseline?.({
|
||
view: 'floor',
|
||
floorId: activeGuideFloor.value,
|
||
reason: rendererReason
|
||
})
|
||
if (!guideModelState.isCurrentRequest(resetRequest.requestId)) return
|
||
if (resetResult === 'applied') {
|
||
renderedFloorId.value = activeGuideFloor.value
|
||
guideModelState.completeViewBaselineReset(resetRequest)
|
||
console.info('馆内三维模型已恢复楼层视觉基线:', { rendererReason })
|
||
} else if (resetResult === 'not-ready' || resetResult === undefined) {
|
||
guideModelState.queueViewBaselineReset(resetRequest)
|
||
}
|
||
}
|
||
|
||
onShow(async () => {
|
||
await nextTick()
|
||
const detailReturnContext = guideModelState.consumePoiDetailReturn()
|
||
if (detailReturnContext) {
|
||
const uiContext = pendingPoiDetailUiContext?.requestId === detailReturnContext.requestId
|
||
? pendingPoiDetailUiContext.value
|
||
: null
|
||
pendingPoiDetailUiContext = null
|
||
await resetGuideModelToFloorBaseline({
|
||
rendererReason: 'poi-detail-close',
|
||
floorId: detailReturnContext.floorId
|
||
})
|
||
if (uiContext?.source === 'search') {
|
||
homeCategoryModeActive.value = uiContext.categoryModeActive
|
||
searchVisiblePoiIds.value = uiContext.visiblePoiIds
|
||
? [...uiContext.visiblePoiIds]
|
||
: null
|
||
}
|
||
await homeSearchPanelRef.value?.restoreResultListScroll?.()
|
||
return
|
||
}
|
||
|
||
await homeSearchPanelRef.value?.restoreResultListScroll?.()
|
||
})
|
||
|
||
const handleMoreOutdoorNav = () => {
|
||
if (!homeArrivalFeatureEnabled.value) return
|
||
showOutdoorNavigationPanel.value = true
|
||
outdoorArrivalStage.value = 'browse'
|
||
clearOutdoorRoute()
|
||
arrivalActiveType.value = null
|
||
arrivalTargets.value = []
|
||
arrivalTargetsError.value = ''
|
||
selectedArrivalTarget.value = null
|
||
showRoutePlanner.value = false
|
||
isBuildingGuideActive.value = false
|
||
guideOutdoorState.value = 'home'
|
||
selectedGuidePoi.value = null
|
||
isPoiCardCollapsed.value = false
|
||
isSimulatingRoute.value = false
|
||
closeHomeSearchDock()
|
||
}
|
||
|
||
const handleExplainQuickTap = () => {
|
||
handleTabChange('explain')
|
||
}
|
||
|
||
const handleHomeSearchExpandedChange = (expanded: boolean) => {
|
||
homeSearchExpanded.value = expanded
|
||
if (expanded) {
|
||
pushPoiSearchOverlayHistory()
|
||
void nextTick(() => ensureGuideFloorCommitted(activeGuideFloor.value))
|
||
return
|
||
}
|
||
|
||
removePoiSearchOverlayHistory()
|
||
}
|
||
|
||
const clearHomeSearchMapState = () => {
|
||
homeCategoryModeActive.value = false
|
||
searchVisiblePoiIds.value = null
|
||
searchTargetFocusRequest.value = null
|
||
}
|
||
|
||
const ensureGuideFloorCommitted = async (
|
||
floorId: string,
|
||
options: { force?: boolean } = {}
|
||
) => {
|
||
if (!floorId) return
|
||
if (
|
||
!options.force
|
||
&& indoorView.value === 'floor'
|
||
&& renderedFloorId.value === floorId
|
||
&& !loadingFloorId.value
|
||
) return
|
||
|
||
requestedFloorId.value = floorId
|
||
requestedFloorLabel.value = getGuideFloorLabel(floorId) || floorId
|
||
loadingFloorId.value = floorId
|
||
failedFloorId.value = ''
|
||
await guideMapShellRef.value?.switchFloor?.(floorId)
|
||
}
|
||
|
||
const handleHomeCategoryModeChange = (active: boolean) => {
|
||
homeCategoryModeActive.value = active
|
||
if (!active) {
|
||
searchVisiblePoiIds.value = null
|
||
return
|
||
}
|
||
|
||
closeArrivalPanel()
|
||
showRoutePlanner.value = false
|
||
isSimulatingRoute.value = false
|
||
selectedGuidePoi.value = null
|
||
isPoiCardCollapsed.value = false
|
||
isBuildingGuideActive.value = true
|
||
void ensureGuideFloorCommitted(activeGuideFloor.value)
|
||
}
|
||
|
||
const handleHomeSearchResultsChange = (state: PoiCategoryResultState) => {
|
||
if (!state.active) {
|
||
searchVisiblePoiIds.value = null
|
||
return
|
||
}
|
||
|
||
searchVisiblePoiIds.value = [...state.visiblePoiIds]
|
||
if (state.floorId) {
|
||
void ensureGuideFloorCommitted(state.floorId)
|
||
}
|
||
}
|
||
|
||
const toGuideRenderPoi = (poi: MuseumPoi): GuideRenderPoi => ({
|
||
id: poi.id,
|
||
name: poi.name,
|
||
floorId: poi.floorId,
|
||
primaryCategory: poi.primaryCategory.id,
|
||
primaryCategoryZh: poi.primaryCategory.label,
|
||
iconType: poi.primaryCategory.iconType || poi.primaryCategory.id,
|
||
positionGltf: poi.positionGltf,
|
||
sourceObjectName: poi.sourceObjectName,
|
||
kind: poi.kind,
|
||
hallId: poi.hallId,
|
||
hallName: poi.hallName,
|
||
spaceId: poi.spaceId,
|
||
sourcePlaceId: poi.sourcePlaceId,
|
||
sourceSpaceId: poi.sourceSpaceId,
|
||
entrances: poi.entrances,
|
||
displayPolicy: poi.displayPolicy
|
||
})
|
||
|
||
const handleHomeSearchResultTap = (poi: MuseumPoi, _context: PoiSearchContext) => {
|
||
clearRoutePreviewState()
|
||
showRoutePlanner.value = false
|
||
selectedGuidePoi.value = toGuideRenderPoi(poi)
|
||
selectedGuidePoiDetailTarget.value = null
|
||
searchTargetFocusRequestId += 1
|
||
searchTargetFocusRequest.value = toTargetFocusRequestViewModel({
|
||
poiId: poi.id,
|
||
name: poi.name,
|
||
floorId: poi.floorId,
|
||
floorLabel: poi.floorLabel,
|
||
primaryCategoryZh: poi.primaryCategory.label,
|
||
positionGltf: poi.positionGltf,
|
||
sourceObjectName: poi.sourceObjectName,
|
||
kind: poi.kind,
|
||
hallId: poi.hallId,
|
||
hallName: poi.hallName,
|
||
entrances: poi.entrances
|
||
}, searchTargetFocusRequestId)
|
||
requestedFloorId.value = poi.floorId
|
||
requestedFloorLabel.value = poi.floorLabel || getGuideFloorLabel(poi.floorId)
|
||
loadingFloorId.value = poi.floorId
|
||
failedFloorId.value = ''
|
||
isBuildingGuideActive.value = true
|
||
isSimulatingRoute.value = false
|
||
}
|
||
|
||
const handleHomeSearchCancel = () => {
|
||
clearHomeSearchMapState()
|
||
selectedGuidePoi.value = null
|
||
isPoiCardCollapsed.value = false
|
||
homeSearchExpanded.value = false
|
||
}
|
||
|
||
const handleHomeSearchOutsideTap = () => {
|
||
closeHomeSearchDock()
|
||
}
|
||
|
||
const guideShellMode = computed(() => (isBuildingGuideActive.value ? '3d' : '2d'))
|
||
const guideMapType = computed(() => (isBuildingGuideActive.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)
|
||
uni.navigateTo({
|
||
url: explainGuideStopListUrl(hallId, hall?.name || '讲解')
|
||
})
|
||
}
|
||
|
||
const handleExplainBack = () => {
|
||
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: #ffffff;
|
||
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 {
|
||
width: 100%;
|
||
max-width: 430px;
|
||
height: 100vh;
|
||
height: 100dvh;
|
||
min-height: 480px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
overflow: hidden;
|
||
background: #ffffff;
|
||
}
|
||
|
||
.app-launch-content {
|
||
width: 100%;
|
||
padding: 0 28px;
|
||
display: flex;
|
||
align-items: center;
|
||
flex-direction: column;
|
||
box-sizing: border-box;
|
||
transform: translateY(-3vh);
|
||
}
|
||
|
||
.app-launch-logo {
|
||
width: min(84vw, 350px);
|
||
height: auto;
|
||
}
|
||
|
||
.app-launch-name-en {
|
||
margin-top: 20px;
|
||
white-space: nowrap;
|
||
font-family: 'HarmonyOS Sans SC', 'HarmonyOS Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||
font-size: 13px;
|
||
line-height: 18px;
|
||
font-weight: 500;
|
||
letter-spacing: 0;
|
||
color: #262421;
|
||
}
|
||
|
||
.app-launch-name-cn {
|
||
margin-top: 8px;
|
||
white-space: nowrap;
|
||
font-family: 'Songti SC', STSong, SimSun, serif;
|
||
font-size: 36px;
|
||
line-height: 48px;
|
||
font-weight: 500;
|
||
letter-spacing: 0;
|
||
color: #000000;
|
||
}
|
||
|
||
.app-launch-progress {
|
||
width: 112px;
|
||
margin-top: 32px;
|
||
}
|
||
|
||
@media (max-width: 360px) {
|
||
.app-launch-content {
|
||
padding: 0 24px;
|
||
}
|
||
|
||
.app-launch-logo {
|
||
width: calc(100vw - 52px);
|
||
}
|
||
|
||
.app-launch-name-cn {
|
||
font-size: 32px;
|
||
line-height: 43px;
|
||
}
|
||
}
|
||
|
||
@media (max-height: 680px) {
|
||
.app-launch-content {
|
||
transform: translateY(-1vh);
|
||
}
|
||
|
||
.app-launch-progress {
|
||
margin-top: 26px;
|
||
}
|
||
}
|
||
|
||
.explain-page {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: #f6f7f4;
|
||
z-index: 10;
|
||
}
|
||
|
||
.guide-home-dock {
|
||
position: absolute;
|
||
--museum-h5-page-width: min(100vw, 430px);
|
||
left: 50%;
|
||
right: auto;
|
||
width: calc(var(--museum-h5-page-width) - 20px);
|
||
bottom: calc(env(safe-area-inset-bottom) + 10px);
|
||
padding: 8px;
|
||
box-sizing: border-box;
|
||
overflow: hidden;
|
||
background: rgba(247, 248, 244, 0.92);
|
||
border: 1px solid rgba(26, 35, 126, 0.08);
|
||
border-radius: 8px;
|
||
box-shadow: 0 12px 30px rgba(43, 52, 61, 0.16);
|
||
backdrop-filter: blur(14px);
|
||
transform: translateX(-50%);
|
||
z-index: 1003;
|
||
}
|
||
|
||
.guide-home-dock::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 7px;
|
||
right: 7px;
|
||
top: 0;
|
||
height: 2px;
|
||
background: rgba(224, 225, 0, 0.82);
|
||
}
|
||
|
||
.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: 46px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 7px;
|
||
z-index: 1002;
|
||
}
|
||
|
||
.guide-quick-action {
|
||
width: 46px;
|
||
height: 46px;
|
||
min-height: 46px;
|
||
flex: 0 0 46px;
|
||
padding: 2px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 1px;
|
||
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);
|
||
transition:
|
||
background 0.18s ease,
|
||
border-color 0.18s ease,
|
||
box-shadow 0.18s ease,
|
||
transform 0.16s ease;
|
||
}
|
||
|
||
.guide-quick-action.active {
|
||
background: var(--museum-accent);
|
||
border-color: var(--museum-accent);
|
||
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(224, 225, 0, 0.35);
|
||
}
|
||
|
||
.guide-quick-action:active {
|
||
transform: translateY(1px) scale(0.98);
|
||
}
|
||
|
||
.guide-quick-icon {
|
||
width: 22px;
|
||
height: 22px;
|
||
flex: 0 0 22px;
|
||
position: relative;
|
||
color: #151713;
|
||
}
|
||
|
||
.guide-quick-action.active .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: 14px;
|
||
font-weight: 700;
|
||
color: #151713;
|
||
text-align: center;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.guide-quick-action.active .guide-quick-action-text {
|
||
color: #0a0b08;
|
||
}
|
||
|
||
.guide-quick-actions.is-outdoor {
|
||
right: 18px;
|
||
width: 46px;
|
||
}
|
||
|
||
.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-close {
|
||
width: 34px;
|
||
height: 34px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-sizing: border-box;
|
||
background: #f5f6f2;
|
||
border: 1px solid #e4e5df;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.route-sim-close-text {
|
||
color: #545861;
|
||
font-size: 20px;
|
||
line-height: 20px;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.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>
|