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

2635 lines
70 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<GuidePageFrame
:active-tab="currentTab"
variant="overlay"
:show-top-tabs="false"
@tab-change="handleTabChange"
>
<GuideMapShell
v-if="currentTab === 'guide'"
ref="guideMapShellRef"
:search-text="guideSearchText"
:active-mode="guideShellMode"
:mode-layout="'status'"
:mode-status="guideStatusLabel"
mode-status-tone="glass"
search-top="20px"
search-variant="home"
:show-search="false"
:show-mode-row="false"
mode-top="80px"
:map-type="guideMapType"
:outdoor-variant="guideOutdoorVariant"
:indoor-asset-base-url="indoorNavAssetBaseUrl"
:indoor-model-source="indoorModelSource"
:floors="guideFloors"
:normalize-floor-id="normalizeGuideFloorId"
indoor-initial-view="overview"
:indoor-view="indoorView"
:layer-mode="indoorLayerMode"
:active-floor="activeGuideFloor"
: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
:show-layer-mode-toggle="false"
:show-floor="is3DMode && !showRoutePlanner"
:show-zoom-controls="is3DMode"
zoom-controls-top="calc(100vh - 292px)"
:route-preview="activeRoutePreview"
:show-route="Boolean(activeRoutePreview)"
:outdoor-markers="homeArrivalFeatureEnabled ? arrivalOutdoorMarkers : []"
:active-outdoor-marker-id="homeArrivalFeatureEnabled ? activeArrivalMarkerId : ''"
:outdoor-focus-offset-y="homeArrivalFeatureEnabled ? arrivalOutdoorFocusOffsetY : 0"
:disable-auto-exit="disableIndoorAutoExit"
@mode-change="handleModeChange"
@floor-request="handleFloorRequest"
@floor-change="handleFloorChange"
@floor-switch-failed="handleFloorSwitchFailed"
@indoor-view-change="handleIndoorViewChange"
@poi-click="handleGuidePoiClick"
@selection-clear="handleGuideSelectionClear"
@auto-switch="handleAutoSwitch"
@initial-model-progress="handleInitialModelProgress"
@initial-model-ready="handleInitialModelReady"
@initial-model-failed="handleInitialModelFailed"
@tool-click="handleIndoorToolClick"
@outdoor-marker-click="handleArrivalMarkerClick"
>
<template #overlay>
<view v-if="homeArrivalFeatureEnabled && guideOutdoorState === 'entrance' && !is3DMode" class="entrance-tip">
<text class="entrance-tip-text">主入口 4 分钟</text>
</view>
<view v-if="is3DMode && showIndoorGestureHint" class="indoor-gesture-hint">
<text class="indoor-gesture-text">{{ indoorGestureHintText }}</text>
</view>
</template>
<view
v-if="selectedGuidePoi && !showRoutePlanner && !isSimulatingRoute"
class="guide-poi-card"
:class="{ collapsed: isPoiCardCollapsed }"
>
<view class="poi-card-handle" @tap.stop="togglePoiCardCollapsed"></view>
<view v-if="isPoiCardCollapsed" class="poi-card-collapsed" @tap="expandPoiCard">
<view class="poi-card-copy">
<text class="poi-card-title">{{ selectedGuidePoi.name }}</text>
<text class="poi-card-subtitle">{{ selectedGuidePoiCollapsedSubtitle }}</text>
</view>
<view class="poi-card-mini-action">
<text class="poi-card-mini-text">展开</text>
</view>
</view>
<template v-else>
<view class="poi-card-header">
<view class="poi-card-copy">
<text class="poi-card-title">{{ selectedGuidePoi.name }}</text>
<text class="poi-card-subtitle">{{ selectedGuidePoiSubtitle }}</text>
</view>
<view class="poi-card-header-actions">
<view class="poi-card-soft-action" @tap="collapsePoiCard">
<text class="poi-card-soft-text">收起</text>
</view>
<view class="poi-card-close" @tap="handleGuidePoiCardClose">
<text class="poi-card-close-text">×</text>
</view>
</view>
</view>
<view
v-if="selectedGuidePoiExplainHall"
class="poi-card-actions"
>
<view class="poi-action primary" @tap="handleViewSelectedHall">
<text class="poi-action-text">查看展厅</text>
</view>
</view>
</template>
</view>
<view
v-if="showGuideFloatingActions"
class="guide-quick-actions"
>
<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="is3DMode"
:visible="showRoutePlanner"
:start-point="routeStartPoint"
:end-point="routeEndPoint"
:point-options="routePointOptions"
:has-route-preview="Boolean(activeRoutePreview)"
:loading="routePlanning"
:error="routePanelError"
:summary="routePlanSummary"
:picker-loading="routeTargetsLoading"
:picker-error="routeTargetsError"
:route-ready="routeReady"
@update:start-point="handleRouteStartPointChange"
@update:end-point="handleRouteEndPointChange"
@picker-open="handleRoutePickerOpen"
@search="handleRoutePickerSearch"
@swap="handleRouteSwap"
@clear="handleRouteClear"
@view-route="handleRouteView"
@simulate-guide="handleRouteSimulate"
@back="handleRouteBack"
/>
<view v-if="isSimulatingRoute && activeRoutePreview" class="route-sim-top-pill">
<text class="route-sim-top-text">
{{ activeRoutePreview.start.name }} {{ activeRoutePreview.end.name }}
</text>
</view>
<view v-if="isSimulatingRoute && activeRoutePreview" class="route-sim-step-bar">
<text class="route-sim-step-text">{{ routeSimulationStepText }}</text>
<view class="route-sim-expand" @tap="showRoutePlanner = true">
<text class="route-sim-expand-text">展开预览</text>
</view>
</view>
<view v-if="homeArrivalFeatureEnabled && guideOutdoorState === 'entrance' && !is3DMode" class="entrance-bottom-card">
<text class="entrance-card-title">主入口</text>
<text class="entrance-card-desc">
可参考主入口位置也可手动进入馆内3D并选择楼层/区域
</text>
<view class="entrance-actions">
<view class="entrance-btn secondary" @tap="handleSwitchEntrance">
<text class="entrance-btn-text">切换入口</text>
</view>
<view class="entrance-btn primary" @tap="handleEnter3DMode">
<text class="entrance-btn-text">进入馆内3D</text>
</view>
</view>
</view>
</GuideMapShell>
<ArrivalPanel
ref="arrivalPanelRef"
v-if="homeArrivalFeatureEnabled && currentTab === 'guide'"
:visible="showArrivalPanel"
:active-type="activeArrivalType"
:targets="activeArrivalTargets"
:selected-target-id="selectedArrivalTargetId"
:selected-target="selectedArrivalTarget"
@update:active-type="handleArrivalTypeChange"
@select-target="handleArrivalTargetSelect"
@collapsed-change="handleArrivalPanelCollapsedChange"
@layout-change="handleArrivalPanelLayoutChange"
/>
<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 RoutePlannerPanel from '@/components/navigation/RoutePlannerPanel.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 {
guideUseCase
} from '@/usecases/guideUseCase'
import {
guideRouteUseCase
} from '@/usecases/guideRouteUseCase'
import {
explainUseCase
} from '@/usecases/explainUseCase'
import type {
GuideRenderPoi
} from '@/domain/guideModel'
import type {
PoiCategoryResultState,
PoiSearchContext
} from '@/domain/poiSearch'
import {
type TargetPoiFocusRequestViewModel
} from '@/view-models/guideViewModels'
import {
useGuideModelState,
type GuideModelResetReason,
type GuideModelResetRequest
} from '@/composables/useGuideModelState'
import type { ResetViewBaselineResult } from '@/components/map/ThreeMap.vue'
import {
ARRIVAL_TARGETS_BY_TYPE,
type ArrivalSearchTarget,
type ArrivalTargetType
} from '@/data/arrivalSearchTargets'
import type {
GuideRouteResult,
GuideRouteTarget,
MuseumFloor,
MuseumHall,
MuseumPoi
} from '@/domain/museum'
import { getFloorSortLevel } from '@/domain/guideFloor'
type GuideIndoorView = 'overview' | 'floor' | 'multi'
type LayerDisplayMode = 'single' | 'multi'
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 = false
// 3D 模式状态
const is3DMode = ref(true)
const guideOutdoorState = ref<'home' | 'entrance'>('home')
const indoorView = ref<GuideIndoorView>('overview')
const activeGuideFloor = ref('')
const requestedFloorId = ref('')
const requestedFloorLabel = ref('')
const loadingFloorId = ref('')
const renderedFloorId = ref('')
const failedFloorId = ref('')
const selectedGuidePoi = ref<GuideRenderPoi | null>(null)
const selectedGuidePoiExplainHall = ref<MuseumHall | null>(null)
let selectedGuidePoiExplainHallRequestId = 0
// 状态
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 routeEndPoint = ref<RoutePointOption | null>(null)
const routePointOptions = ref<RoutePointOption[]>([])
const routeTargetsLoading = ref(false)
const routeTargetsError = ref('')
const routePlanning = ref(false)
const routePlanError = ref('')
const routePreviewUnavailableMessage = ref('')
const routeReady = ref(false)
const activeRoutePreview = ref<GuideRouteResult | null>(null)
const isSimulatingRoute = ref(false)
const isPoiCardCollapsed = ref(false)
const returningToOverview = ref(false)
// GuideMapShell 组件引用,用于显式调用路线清除方法
const guideMapShellRef = ref<{
clearRoute?: () => 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'
&& is3DMode.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 showArrivalPanel = ref(false)
const showArrivalPanelCollapsed = ref(false)
const arrivalPanelRef = ref<InstanceType<typeof ArrivalPanel> | null>(null)
const activeArrivalType = ref<ArrivalTargetType>('bus')
const selectedArrivalTargetId = ref('')
const arrivalPanelHeight = ref(0)
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 showIndoorGestureHint = ref(true)
const indoorGestureHintText = ref('单指平移 · 双指缩放/平移 · 点选标记查看位置')
let indoorGestureHintTimer: ReturnType<typeof setTimeout> | null = null
const showIndoorHint = (text = '单指平移 · 双指缩放/平移 · 点选标记查看位置', durationMs = 5200) => {
indoorGestureHintText.value = text
showIndoorGestureHint.value = is3DMode.value
if (indoorGestureHintTimer) {
clearTimeout(indoorGestureHintTimer)
}
indoorGestureHintTimer = setTimeout(() => {
showIndoorGestureHint.value = false
indoorGestureHintTimer = null
}, durationMs)
}
const routePlanSummary = computed(() => {
if (activeRoutePreview.value) {
const floorCount = new Set(activeRoutePreview.value.floorSegments.map((segment) => segment.floorId)).size
const floorText = floorCount > 1 ? `${floorCount}` : activeRoutePreview.value.start.floorLabel
return `${floorText} · 约 ${Math.round(activeRoutePreview.value.distanceMeters)}`
}
if (routeStartPoint.value && routeEndPoint.value) {
return `${routeStartPoint.value.floorLabel}${routeEndPoint.value.floorLabel}`
}
return ''
})
const routePanelError = computed(() => routePlanError.value)
const disableIndoorAutoExit = computed(() => (
Boolean(selectedGuidePoi.value)
|| Boolean(activeRoutePreview.value)
|| showRoutePlanner.value
|| isSimulatingRoute.value
))
const showGuideHomeDock = computed(() => (
currentTab.value === 'guide'
&& is3DMode.value
&& !showRoutePlanner.value
&& !showArrivalPanel.value
&& !selectedGuidePoi.value
&& !isSimulatingRoute.value
&& !(guideOutdoorState.value === 'entrance' && !is3DMode.value)
))
const showGuideFloatingActions = computed(() => (
currentTab.value === 'guide'
&& !showRoutePlanner.value
&& (!showArrivalPanel.value || showArrivalPanelCollapsed.value)
&& !selectedGuidePoi.value
&& !isSimulatingRoute.value
&& (!showGuideHomeDock.value || !homeSearchExpanded.value)
))
const activeArrivalTargets = computed(() => ARRIVAL_TARGETS_BY_TYPE[activeArrivalType.value])
const selectedArrivalTarget = computed(() => (
activeArrivalTargets.value.find((target) => target.id === selectedArrivalTargetId.value)
|| activeArrivalTargets.value[0]
|| null
))
const arrivalOutdoorMarkers = computed(() => {
if (!showArrivalPanel.value || is3DMode.value) return []
return activeArrivalTargets.value.map((target) => ({
id: target.id,
title: target.title,
subtitle: target.subtitle,
latitude: target.latitude,
longitude: target.longitude
}))
})
const activeArrivalMarkerId = computed(() => (
showArrivalPanel.value && !is3DMode.value ? selectedArrivalTarget.value?.id || '' : ''
))
const arrivalOutdoorFocusOffsetY = computed(() => {
if (!showArrivalPanel.value || showArrivalPanelCollapsed.value || is3DMode.value) return 0
return Math.min(260, Math.max(0, Math.round(arrivalPanelHeight.value / 2 + 24)))
})
const guideQuickActiveAction = computed<'indoor' | 'arrival' | 'explain'>(() => {
if (currentTab.value === 'explain') return 'explain'
if (showArrivalPanel.value) return 'arrival'
return is3DMode.value ? 'indoor' : 'arrival'
})
const toRoutePointOption = (target: GuideRouteTarget): RoutePointOption => ({
poiId: target.poiId,
name: target.name,
floorId: target.floorId,
floorLabel: target.floorLabel,
categoryLabel: target.categoryLabel
})
const selectedGuidePoiSubtitle = computed(() => {
const poi = selectedGuidePoi.value
if (!poi) return ''
const floor = guideFloors.value.find((item) => item.id === poi.floorId)
const floorLabel = floor?.label || poi.floorId
return `${floorLabel} · ${poi.primaryCategoryZh || '位置预览'}`
})
const isGuidePoiHallLike = (poi: GuideRenderPoi | null) => Boolean(
poi
&& (
poi.kind === 'hall'
|| poi.primaryCategory === 'touring_poi'
|| poi.primaryCategory === 'exhibition_hall'
|| poi.primaryCategory === 'exhibition_hall_entrance'
)
)
const selectedGuidePoiIsHall = computed(() => isGuidePoiHallLike(selectedGuidePoi.value))
const selectedGuidePoiCollapsedSubtitle = computed(() => (
selectedGuidePoiExplainHall.value ? '点按展开查看展厅' : selectedGuidePoiSubtitle.value
))
const routeSimulationStepText = computed(() => {
if (!activeRoutePreview.value) return '位置预览 · 查看位置关系'
const hasConnector = activeRoutePreview.value.connectorPoints.length > 0
return hasConnector ? '位置预览 · 跨楼层位置关系' : '位置预览 · 同层位置关系'
})
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 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
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)
return
}
syncTopTabToUrl(currentTab.value)
loadTopTabData(currentTab.value)
}
onMounted(() => {
startLaunchOverlay()
showIndoorHint()
if (typeof window === 'undefined') return
syncTopTabFromHash()
removePoiSearchOverlayFromCurrentHistoryState()
window.addEventListener('hashchange', syncTopTabFromHash)
window.addEventListener('popstate', handlePoiSearchHistoryPopState)
})
onUnmounted(() => {
launchOverlayRunId += 1
clearLaunchTimers()
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) => {
activeGuideFloor.value = floorId
renderedFloorId.value = floorId
if (loadingFloorId.value === floorId) {
loadingFloorId.value = ''
}
if (failedFloorId.value === floorId) {
failedFloorId.value = ''
}
indoorView.value = 'floor'
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
showIndoorHint(`已切换到 ${getGuideFloorLabel(floorId)},单指平移、双指可缩放`, 3200)
console.log('楼层渲染完成:', floorId)
}
const handleFloorSwitchFailed = ({ floorId, floorLabel }: { floorId: string; floorLabel: string }) => {
if (loadingFloorId.value === floorId) {
loadingFloorId.value = ''
}
failedFloorId.value = floorId
showIndoorHint(`${floorLabel || getGuideFloorLabel(floorId)} 加载失败,请稍后重试`, 3600)
console.warn('楼层渲染失败:', floorId)
}
const handleIndoorViewChange = (view: GuideIndoorView) => {
indoorView.value = view
if (view !== 'floor') {
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
}
if (view === 'multi') {
showIndoorHint('已切换多层视图,可点选楼层查看', 3200)
} else if (view === 'floor') {
showIndoorHint('已切换单层视图,可点选标记查看位置', 3200)
}
}
const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: string; distance: number }) => {
console.log('自动切换视图:', event)
indoorView.value = event.to
if (event.to !== 'floor') {
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
}
showIndoorHint(event.to === 'floor' ? '已进入单层视图,可点选标记查看位置' : '已返回建筑外观', 3600)
}
const handleInitialModelReady = (event: { view: GuideIndoorView; floorId?: string; elapsedMs?: number }) => {
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)
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 }) => {
settleLaunchOverlayByModel('failed', event)
}
const handleIndoorToolClick = (tool: string) => {
if (!is3DMode.value) return
if (tool === '复位') {
showIndoorHint('已复位视角,单指平移、双指可缩放', 2800)
} else if (tool === '俯视') {
showIndoorHint('已切换俯视视角,可点选标记查看位置', 2800)
} else if (tool === '斜视') {
showIndoorHint('已切换斜视视角,可查看空间层次', 2800)
}
}
const handleGuidePoiClick = (poi: GuideRenderPoi) => {
selectedGuidePoi.value = poi
isPoiCardCollapsed.value = !selectedGuidePoiIsHall.value
indoorView.value = 'floor'
isSimulatingRoute.value = false
activeGuideFloor.value = poi.floorId
renderedFloorId.value = poi.floorId
showIndoorHint(`${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
})
}
const collapsePoiCard = () => {
isPoiCardCollapsed.value = true
}
const expandPoiCard = () => {
isPoiCardCollapsed.value = false
}
const togglePoiCardCollapsed = () => {
isPoiCardCollapsed.value = !isPoiCardCollapsed.value
}
const normalizeHallMatchText = (value?: string) => (value || '')
.trim()
.replace(/[\s()【】[\]_-]/g, '')
.replace(/^展厅\d+/, '')
.toLowerCase()
const resolvePoiExplainHall = async (poi: GuideRenderPoi): Promise<MuseumHall | null> => {
const halls = await explainUseCase.listHalls()
const matchedByPoiId = halls.find((hall) => (
hall.poiId === poi.id
|| hall.location?.poiId === poi.id
))
if (matchedByPoiId) return matchedByPoiId
const hallId = poi.hallId
if (hallId) {
const matchedById = halls.find((hall) => hall.id === hallId)
if (matchedById) return matchedById
}
const matchedByEntrance = halls.find((hall) => (
(poi.entrances || []).some((entrance) => (
hall.poiId === entrance.sourcePlaceId
|| hall.poiId === entrance.id
))
))
if (matchedByEntrance) return matchedByEntrance
const poiHallName = normalizeHallMatchText(poi.hallName || poi.name)
return halls.find((hall) => {
const hallName = normalizeHallMatchText(hall.name)
return hallName && poiHallName && hallName === poiHallName
}) || null
}
watch(selectedGuidePoi, (poi) => {
const requestId = ++selectedGuidePoiExplainHallRequestId
selectedGuidePoiExplainHall.value = null
if (!poi || !isGuidePoiHallLike(poi)) return
void resolvePoiExplainHall(poi)
.then((hall) => {
if (
requestId !== selectedGuidePoiExplainHallRequestId
|| selectedGuidePoi.value?.id !== poi.id
) return
selectedGuidePoiExplainHall.value = hall
})
.catch((error) => {
if (requestId !== selectedGuidePoiExplainHallRequestId) return
console.warn('点位讲解展厅关联解析失败:', error)
})
}, { flush: 'sync' })
const navigateToPoiDetail = ({
url,
floorId,
failureMessage
}: {
url: string
floorId: string
failureMessage: string
}) => {
const returnContext = guideModelState.beginPoiDetailReturn(floorId)
uni.navigateTo({
url,
success: () => {
guideModelState.confirmPoiDetailReturn(returnContext.requestId)
},
fail: () => {
guideModelState.cancelPoiDetailReturn(returnContext.requestId)
uni.showToast({
title: failureMessage,
icon: 'none'
})
}
})
}
const navigateFromSelectedPoiToDetail = (url: string) => {
const poi = selectedGuidePoi.value
if (!poi) return
// Detail pages do not own the mounted renderer. The homepage consumes this once on return.
navigateToPoiDetail({
url,
floorId: poi.floorId,
failureMessage: '讲解详情打开失败,请重试'
})
}
const navigateToHallExplainObjects = (hallId: string, hallName = '讲解') => {
navigateFromSelectedPoiToDetail(
explainGuideStopListUrl(hallId, hallName)
)
}
const handleViewSelectedHall = () => {
const hall = selectedGuidePoiExplainHall.value
if (!hall) return
navigateToHallExplainObjects(hall.id, hall.name)
}
const loadRouteTargets = async (keyword = '') => {
routeTargetsLoading.value = true
routeTargetsError.value = ''
try {
const targets = keyword
? await guideRouteUseCase.searchTargets(keyword)
: await guideRouteUseCase.listTargets()
const options = targets.map(toRoutePointOption)
routePointOptions.value = options
} catch (error) {
console.error('加载路线目标失败:', error)
routeTargetsError.value = error instanceof Error ? error.message : '路线目标加载失败'
routePointOptions.value = []
} finally {
routeTargetsLoading.value = false
}
}
const refreshRouteReadinessState = async () => {
try {
const readiness = await guideRouteUseCase.getRouteReadiness()
routeReady.value = readiness.ready
routePreviewUnavailableMessage.value = readiness.ready
? ''
: readiness.message || '当前可查看位置预览和位置关系,暂不提供正式室内导航'
} catch (error) {
console.warn('读取路线能力状态失败:', error)
routeReady.value = false
routePreviewUnavailableMessage.value = '当前可查看位置预览和位置关系,暂不提供正式室内导航'
}
}
const clearRoutePreviewState = () => {
guideMapShellRef.value?.clearRoute?.()
activeRoutePreview.value = null
routePlanError.value = ''
isSimulatingRoute.value = false
}
const handleRouteClear = () => {
routeStartPoint.value = null
routeEndPoint.value = null
showRoutePlanner.value = false
clearRoutePreviewState()
}
const handleRouteBack = () => {
if (activeRoutePreview.value) {
activeRoutePreview.value = null
routePlanError.value = ''
isSimulatingRoute.value = false
showRoutePlanner.value = true
return
}
showRoutePlanner.value = false
isSimulatingRoute.value = false
}
const handleRouteStartPointChange = (point: RoutePointOption | null) => {
routeStartPoint.value = point
clearRoutePreviewState()
}
const handleRouteEndPointChange = (point: RoutePointOption | null) => {
routeEndPoint.value = point
clearRoutePreviewState()
}
const handleRouteSwap = () => {
const nextStart = routeEndPoint.value
routeEndPoint.value = routeStartPoint.value
routeStartPoint.value = nextStart
clearRoutePreviewState()
}
const handleRoutePickerOpen = () => {
if (!routePointOptions.value.length) {
void loadRouteTargets()
}
}
const handleRoutePickerSearch = ({ keyword }: { mode: 'start' | 'end'; keyword: string }) => {
void loadRouteTargets(keyword)
}
const focusRouteFloor = (route: GuideRouteResult) => {
const routeFloorCount = new Set(route.floorSegments.map((segment) => segment.floorId)).size
activeGuideFloor.value = route.start.floorId
renderedFloorId.value = route.start.floorId
indoorView.value = routeFloorCount > 1 ? 'multi' : 'floor'
}
const handleRouteView = async ({ startPoint, endPoint }: { startPoint: RoutePointOption; endPoint: RoutePointOption }) => {
routePlanning.value = true
routePlanError.value = ''
isSimulatingRoute.value = false
try {
await refreshRouteReadinessState()
if (routePreviewUnavailableMessage.value) {
activeRoutePreview.value = null
routePlanError.value = routePreviewUnavailableMessage.value
return
}
const result = await guideRouteUseCase.planRoute({
startPoiId: startPoint.poiId,
endPoiId: endPoint.poiId
})
if (!result.route) {
activeRoutePreview.value = null
routePlanError.value = result.error || '馆内导览位置关系生成失败'
return
}
activeRoutePreview.value = result.route
focusRouteFloor(result.route)
showRoutePlanner.value = true
} catch (error) {
console.error('馆内导览位置关系生成失败:', error)
activeRoutePreview.value = null
routePlanError.value = error instanceof Error ? error.message : '馆内导览位置关系生成失败'
} finally {
routePlanning.value = false
}
}
const handleRouteSimulate = () => {
if (!activeRoutePreview.value) return
showRoutePlanner.value = false
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
isSimulatingRoute.value = true
}
// 进入 3D 馆内模式
const handleEnter3DMode = () => {
console.log('进入 3D 馆内模式')
closeArrivalPanel()
indoorView.value = 'overview'
is3DMode.value = true
guideOutdoorState.value = 'home'
showIndoorHint()
if (!routePointOptions.value.length) {
void loadRouteTargets()
}
}
const handleModeChange = (mode: '2d' | '3d') => {
is3DMode.value = mode === '3d'
if (mode === '2d') {
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()
if (!routePointOptions.value.length) {
void loadRouteTargets()
}
}
}
const guideStatusLabel = computed(() => {
if (is3DMode.value) {
if (indoorView.value === 'overview') return '建筑外观'
if (indoorView.value === 'multi') return '馆内多层'
if (loadingFloorId.value) {
return `切换${requestedFloorLabel.value || getGuideFloorLabel(requestedFloorId.value || loadingFloorId.value)}`
}
if (failedFloorId.value) {
return `${getGuideFloorLabel(failedFloorId.value)}失败`
}
return getGuideFloorLabel(renderedFloorId.value || activeGuideFloor.value) || '馆内单层'
}
if (guideOutdoorState.value === 'entrance') {
return '推荐入口'
}
return '馆外参考'
})
const handleSwitchEntrance = () => {
guideOutdoorState.value = 'home'
}
const handleMoreRouteGuide = async () => {
if (returningToOverview.value) return
if (is3DMode.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()
showIndoorHint('已返回建筑外观,可放大再次进入馆内', 3200)
} catch (error) {
console.error('返回建筑外观失败:', error)
showIndoorHint('建筑外观加载失败,请稍后重试', 3200)
} finally {
returningToOverview.value = false
}
return
}
closeArrivalPanel()
showRoutePlanner.value = false
is3DMode.value = true
guideOutdoorState.value = 'home'
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
isSimulatingRoute.value = false
const floorId = activeGuideFloor.value
const floorLabel = getGuideFloorLabel(floorId) || '默认楼层'
if (!floorId) {
showIndoorHint('暂未获取到可进入的楼层,请稍后重试', 3200)
return
}
requestedFloorId.value = floorId
requestedFloorLabel.value = floorLabel
loadingFloorId.value = floorId
failedFloorId.value = ''
// 渲染器提交 floor-change 前仍保持建筑外观,避免楼层控件显示假状态。
showIndoorHint(`正在进入 ${floorLabel}`, 3200)
await guideMapShellRef.value?.switchFloor?.(floorId)
}
const selectFirstArrivalTarget = () => {
selectedArrivalTargetId.value = activeArrivalTargets.value[0]?.id || ''
}
const resetArrivalPanelState = () => {
activeArrivalType.value = 'bus'
selectFirstArrivalTarget()
}
const closeArrivalPanel = () => {
showArrivalPanel.value = false
showArrivalPanelCollapsed.value = false
selectedArrivalTargetId.value = ''
arrivalPanelHeight.value = 0
}
const handleArrivalPanelCollapsedChange = (collapsed: boolean) => {
showArrivalPanelCollapsed.value = collapsed
}
const handleArrivalPanelLayoutChange = (layout: { height: number; collapsed: boolean }) => {
arrivalPanelHeight.value = layout.height
showArrivalPanelCollapsed.value = layout.collapsed
}
const handleArrivalTypeChange = (type: ArrivalTargetType) => {
activeArrivalType.value = type
selectFirstArrivalTarget()
}
const handleArrivalTargetSelect = (target: ArrivalSearchTarget) => {
selectedArrivalTargetId.value = target.id
}
const handleArrivalMarkerClick = (markerId: string) => {
if (!homeArrivalFeatureEnabled) return
const target = activeArrivalTargets.value.find((item) => item.id === markerId)
if (!target) return
selectedArrivalTargetId.value = target.id
}
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
routeEndPoint.value = null
routePlanError.value = ''
isSimulatingRoute.value = false
requestedFloorId.value = ''
requestedFloorLabel.value = ''
loadingFloorId.value = ''
failedFloorId.value = ''
is3DMode.value = true
guideOutdoorState.value = 'home'
indoorView.value = 'floor'
activeGuideFloor.value = 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) {
await resetGuideModelToFloorBaseline({
rendererReason: 'poi-detail-close',
floorId: detailReturnContext.floorId
})
await homeSearchPanelRef.value?.restoreResultListScroll?.()
return
}
await homeSearchPanelRef.value?.restoreResultListScroll?.()
})
const handleMoreOutdoorNav = () => {
if (!homeArrivalFeatureEnabled) return
if (showArrivalPanel.value && showArrivalPanelCollapsed.value) {
arrivalPanelRef.value?.expandPanel()
showArrivalPanelCollapsed.value = false
return
}
resetArrivalPanelState()
showArrivalPanel.value = true
showArrivalPanelCollapsed.value = false
showRoutePlanner.value = false
is3DMode.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()
return
}
removePoiSearchOverlayHistory()
}
const clearHomeSearchMapState = () => {
homeCategoryModeActive.value = false
searchVisiblePoiIds.value = null
searchTargetFocusRequest.value = null
}
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
is3DMode.value = true
indoorView.value = 'floor'
}
const handleHomeSearchResultsChange = (state: PoiCategoryResultState) => {
if (!state.active) {
searchVisiblePoiIds.value = null
return
}
searchVisiblePoiIds.value = [...state.visiblePoiIds]
if (state.floorId) {
activeGuideFloor.value = state.floorId
}
}
const createSearchDetailUrl = (poi: MuseumPoi, context: PoiSearchContext) => {
const resultCount = homeCategoryModeActive.value
? context.floorResultCount
: context.resultCount
const params = new URLSearchParams({
source: 'search',
searchOrigin: context.origin,
searchKeyword: context.keyword,
searchCategoryId: context.categoryId,
searchFloorId: context.floorId,
searchFloorLabel: context.floorLabel,
resultCount: String(Math.max(1, resultCount)),
floorResultCount: String(context.floorResultCount),
visiblePoiIds: context.visiblePoiIds.join(','),
listScrollTop: String(context.listScrollTop),
id: poi.id,
target: poi.name,
floorId: poi.floorId,
floorLabel: poi.floorLabel
})
return `/pages/facility/detail?${params.toString()}`
}
const handleHomeSearchResultTap = async (poi: MuseumPoi, context: PoiSearchContext) => {
// The homepage keeps its renderer mounted; detail close is consumed by onShow once.
searchTargetFocusRequest.value = null
navigateToPoiDetail({
url: createSearchDetailUrl(poi, context),
floorId: poi.floorId,
failureMessage: '点位详情打开失败,请重试'
})
}
const handleHomeSearchCancel = () => {
clearHomeSearchMapState()
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
homeSearchExpanded.value = false
}
const handleHomeSearchOutsideTap = () => {
closeHomeSearchDock()
}
const guideShellMode = computed(() => (is3DMode.value ? '3d' : '2d'))
const guideMapType = computed(() => (is3DMode.value ? 'indoor' : 'outdoor'))
const guideOutdoorVariant = computed(() => (
guideOutdoorState.value === 'entrance' ? 'entrance' : 'home'
))
const guideSearchText = computed(() => {
return '请输入地点进行搜索'
})
// 讲解页面处理
const handleExplainHallClick = async (hallId: string) => {
const hall = explainHallItems.value.find((item) => item.id === hallId)
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-poi-card {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(env(safe-area-inset-bottom) + 24px);
box-sizing: border-box;
background: rgba(255, 255, 255, 0.96);
border: 1px solid #e5e6de;
border-radius: 8px;
box-shadow: 0 10px 24px rgba(36, 49, 42, 0.12);
z-index: 1003;
}
.guide-poi-card {
padding: 10px 14px 14px;
}
.guide-poi-card.collapsed {
padding: 8px 10px 10px;
}
.poi-card-handle {
width: 38px;
height: 4px;
margin: 0 auto 10px;
background: #d8dbd2;
border-radius: 999px;
}
.poi-card-collapsed {
min-height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.poi-card-mini-action {
flex: 0 0 auto;
height: 30px;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: center;
background: #151713;
border-radius: 8px;
}
.poi-card-mini-text {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: var(--museum-accent);
}
.poi-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.poi-card-copy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.poi-card-title {
font-size: 17px;
line-height: 24px;
font-weight: 700;
color: #151713;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.poi-card-subtitle {
font-size: 12px;
line-height: 17px;
color: #696962;
}
.poi-card-header-actions {
flex: 0 0 auto;
display: flex;
gap: 6px;
}
.poi-card-soft-action {
height: 28px;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border: 1px solid #e4e5df;
border-radius: 8px;
background: #f5f7f2;
}
.poi-card-soft-text {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #545861;
}
.poi-card-close {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: #f5f7f2;
}
.poi-card-close-text {
font-size: 18px;
line-height: 20px;
color: #545861;
}
.poi-card-actions {
margin-top: 12px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.poi-action {
min-width: 0;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 6px;
box-sizing: border-box;
background: #f6f7f4;
border: 1px solid #e5e6de;
border-radius: 8px;
}
.poi-action.primary {
background: #151713;
border-color: #151713;
}
.poi-action-text {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #151713;
text-align: center;
}
.poi-action.primary .poi-action-text {
color: var(--museum-accent);
}
.guide-home-dock {
position: absolute;
--museum-h5-page-width: min(100vw, 430px);
left: 50%;
right: auto;
width: calc(var(--museum-h5-page-width) - 20px);
bottom: calc(env(safe-area-inset-bottom) + 10px);
padding: 7px;
box-sizing: border-box;
overflow: hidden;
background:
linear-gradient(180deg, rgba(224, 225, 0, 0.14) 0, rgba(224, 225, 0, 0) 46px),
#443c2d;
border: 1px solid #a66a42;
border-radius: 8px;
box-shadow: 0 12px 28px rgba(58, 50, 35, 0.26);
transform: translateX(-50%);
z-index: 1003;
}
.guide-home-dock::before {
content: '';
position: absolute;
left: 7px;
right: 7px;
top: 0;
height: 3px;
background: var(--museum-accent);
}
.guide-home-dock.expanded {
position: fixed;
left: 50%;
right: auto;
top: 0;
bottom: auto;
width: var(--museum-h5-page-width);
height: 100vh;
height: 100dvh;
height: var(--home-search-viewport-height, 100dvh);
max-height: none;
display: flex;
flex-direction: column;
padding: calc(env(safe-area-inset-top) + 12px) 14px calc(env(safe-area-inset-bottom) + 14px);
background: #f7f8f2;
border: 0;
border-radius: 0;
box-shadow: none;
transform: translateX(-50%);
overflow: hidden;
overscroll-behavior: contain;
touch-action: pan-x pan-y;
z-index: 1100;
}
.guide-home-dock.expanded::before {
display: none;
}
/* #ifdef H5 */
.guide-home-dock.expanded {
min-height: 100vh;
min-height: 100dvh;
max-width: var(--museum-h5-page-width);
}
/* #endif */
.guide-home-search-scrim {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: transparent;
z-index: 1002;
}
.guide-quick-actions {
position: absolute;
top: clamp(88px, 13vh, 112px);
right: 14px;
width: 52px;
display: flex;
flex-direction: column;
gap: 7px;
z-index: 1002;
}
.guide-quick-action {
width: 52px;
min-height: 58px;
padding: 7px 4px 6px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
box-sizing: border-box;
background: rgba(245, 245, 237, 0.94);
border: 1px solid rgba(255, 255, 255, 0.72);
border-radius: 8px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(10px);
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);
animation: guide-quick-active-in 0.22s ease-out both;
}
.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: 16px;
font-weight: 700;
color: #151713;
text-align: center;
white-space: nowrap;
}
.guide-quick-action.active .guide-quick-action-text {
color: #0a0b08;
}
@keyframes guide-quick-active-in {
from {
transform: scale(0.96);
}
to {
transform: scale(1);
}
}
.route-sim-top-pill {
position: absolute;
top: 126px;
left: 50%;
max-width: calc(100% - 40px);
height: 34px;
padding: 0 14px;
display: flex;
align-items: center;
box-sizing: border-box;
background: rgba(21, 23, 19, 0.86);
border-radius: 8px;
transform: translateX(-50%);
z-index: 1003;
}
.route-sim-top-text {
min-width: 0;
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #ffffff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.route-sim-step-bar {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(env(safe-area-inset-bottom) + 24px);
min-height: 54px;
padding: 10px 10px 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.96);
border: 1px solid #e5e6de;
border-radius: 8px;
box-shadow: 0 10px 24px rgba(36, 49, 42, 0.12);
z-index: 1003;
}
.route-sim-step-text {
min-width: 0;
flex: 1;
font-size: 14px;
line-height: 20px;
font-weight: 700;
color: #151713;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.route-sim-expand {
height: 34px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #000000;
border-radius: 8px;
}
.route-sim-expand-text {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: var(--museum-accent);
}
.entrance-tip {
position: absolute;
top: 126px;
left: 30px;
width: 168px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.96);
border: 1px solid #e5e6de;
border-radius: 12px;
z-index: 45;
}
.entrance-tip-text {
font-size: 14px;
line-height: 19px;
font-weight: 500;
color: #1f2329;
}
.indoor-gesture-hint {
position: absolute;
top: clamp(136px, 19vh, 190px);
left: 50%;
max-width: calc(100% - 112px);
min-height: 38px;
padding: 8px 14px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: rgba(9, 15, 10, 0.9);
border: 1px solid rgba(255, 235, 59, 0.36);
border-radius: 999px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.2);
transform: translateX(-50%);
pointer-events: none;
z-index: 58;
}
.indoor-gesture-text {
min-width: 0;
font-size: 13px;
line-height: 18px;
font-weight: 700;
color: #f4ff2f;
text-align: center;
white-space: normal;
}
.home-shortcuts-card {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(env(safe-area-inset-bottom) + 128px);
height: 84px;
padding: 16px 10px 8px;
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.96);
border: 1px solid #ffffff;
border-radius: 16px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08);
z-index: 45;
}
.home-shortcut-item {
width: 62px;
height: 60px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 4px;
}
.home-shortcut-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #d9dcd2;
border-radius: 10px;
}
.home-shortcut-abbr {
font-size: 15px;
line-height: 20px;
font-weight: 700;
color: #1f2329;
}
.home-shortcut-label {
font-size: 12px;
line-height: 16px;
color: #1f2329;
text-align: center;
}
.home-shortcut-item.active {
.home-shortcut-icon {
background: #000000;
border-color: #000000;
}
.home-shortcut-abbr {
color: var(--museum-accent);
}
}
.search-bar-wrapper {
position: absolute;
top: 44px;
left: 0;
right: 0;
z-index: 1003;
}
.guide-mode-row {
position: absolute;
top: 104px;
left: 16px;
right: 16px;
height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
z-index: 1003;
}
.mode-switch {
width: 220px;
height: 34px;
display: flex;
align-items: center;
padding: 4px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(230, 230, 230, 0.5);
border-radius: 17px;
}
.mode-option {
width: 106px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 13px;
}
.mode-option.active {
background: #000000;
}
.mode-label {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #1f2329;
}
.mode-option.active .mode-label {
color: var(--museum-accent);
}
.mode-status-chip {
width: 111px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.42);
border-radius: 14px;
}
.mode-status-text {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #696962;
}
.area-selector-wrapper {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(env(safe-area-inset-bottom) + 128px);
z-index: 1003;
}
.guide-task-card {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(env(safe-area-inset-bottom) + 24px);
height: 94px;
padding: 16px 16px 10px;
display: flex;
flex-direction: column;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #ffffff;
border-radius: 16px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08);
z-index: 1003;
}
.guide-task-card.has-subtitle {
height: auto;
min-height: 128px;
}
.task-title {
flex-shrink: 0;
font-size: 17px;
line-height: 24px;
font-weight: 700;
color: #000000;
}
.task-subtitle {
margin-top: 4px;
min-height: 20px;
font-size: 12px;
line-height: 18px;
color: #696962;
}
.task-primary-btn {
flex-shrink: 0;
margin-top: 4px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
background: #000000;
border-radius: 8px;
}
.task-primary-text {
font-size: 14px;
line-height: 19px;
font-weight: 500;
color: var(--museum-accent);
}
.entrance-bottom-card {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(env(safe-area-inset-bottom) + 24px);
height: 158px;
padding: 18px 16px 14px;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #ffffff;
border-radius: 16px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08);
z-index: 45;
}
.entrance-card-title {
display: block;
font-size: 17px;
line-height: 24px;
font-weight: 700;
color: #000000;
}
.entrance-card-desc {
display: block;
width: 305px;
margin-top: 10px;
font-size: 13px;
line-height: 18px;
color: #6b7178;
}
.entrance-actions {
position: absolute;
left: 16px;
right: 16px;
bottom: 14px;
display: flex;
gap: 12px;
}
.entrance-btn {
height: 40px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 8px;
}
.entrance-btn.secondary {
width: 126px;
background: #ffffff;
border: 1px solid #000000;
}
.entrance-btn.primary {
width: 181px;
background: #000000;
border: 1px solid #000000;
}
.entrance-btn-text {
font-size: 14px;
line-height: 19px;
font-weight: 500;
color: #000000;
}
.entrance-btn.primary .entrance-btn-text {
color: var(--museum-accent);
}
</style>