Files
frontend-miniapp/src/pages/index/index.vue
lyf 93bfb7e778
Some checks failed
CI / verify (push) Has been cancelled
同步小程序讲解页面标题
2026-07-28 19:42:33 +08:00

3264 lines
97 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<GuidePageFrame
:active-tab="currentTab"
variant="overlay"
:show-top-tabs="false"
@tab-change="handleTabChange"
>
<GuideMapShell
v-if="currentTab === 'guide'"
ref="guideMapShellRef"
:search-text="guideSearchText"
:active-mode="guideShellMode"
:mode-layout="'status'"
:mode-status="guideStatusLabel"
mode-status-tone="glass"
search-top="20px"
search-variant="home"
:show-search="false"
:show-mode-row="false"
mode-top="80px"
:map-type="guideMapType"
:outdoor-variant="guideOutdoorVariant"
:indoor-asset-base-url="indoorNavAssetBaseUrl"
:indoor-model-source="indoorModelSource"
:floors="guideFloors"
:normalize-floor-id="normalizeGuideFloorId"
:indoor-initial-view="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>