Files
frontend-miniapp/src/pages/index/index.vue
lyf d165e17b4d
Some checks failed
CI / verify (push) Has been cancelled
优化来馆面板收起入口
2026-07-09 00:29:01 +08:00

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