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

1490 lines
38 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"
:top-tabs-variant="currentTab === 'guide' ? 'segmented' : 'underline'"
:top-tabs-top="currentTab === 'guide' ? '80px' : '0'"
:show-top-tabs="currentTab === 'guide'"
@tab-change="handleTabChange"
>
<GuideMapShell
v-if="currentTab === 'guide'"
:search-text="guideSearchText"
:active-mode="guideShellMode"
:mode-layout="'status'"
:mode-status="guideStatusLabel"
mode-status-tone="glass"
search-top="20px"
search-variant="home"
: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="floor"
:indoor-view="indoorView"
:layer-mode="indoorLayerMode"
:active-floor="activeGuideFloor"
layer-mode-toggle-top="154px"
floor-top="420px"
floor-side="left"
show-floor-header
:show-layer-mode-toggle="false"
:show-floor="is3DMode"
tools-top="130px"
show-zoom-controls
zoom-controls-top="560px"
show-location-control
location-control-bottom="54px"
show-more-control
more-control-bottom="54px"
:tools="guide3DTools"
:route-preview="activeRoutePreview"
:show-route="Boolean(activeRoutePreview)"
@search-tap="handleGuideSearchTap"
@mode-change="handleModeChange"
@floor-change="handleFloorChange"
@indoor-view-change="handleIndoorViewChange"
@poi-click="handleGuidePoiClick"
@selection-clear="handleGuideSelectionClear"
@tool-click="handleGuideToolClick"
@more-click="handleGuideMoreClick"
@auto-switch="handleAutoSwitch"
>
<template #overlay>
<view v-if="guideOutdoorState === 'entrance' && !is3DMode" class="entrance-tip">
<text class="entrance-tip-text">主入口 4 分钟</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">位置预览 · 点按展开操作</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 class="poi-card-actions">
<view class="poi-action primary" @tap="handlePreviewSelectedPoi">
<text class="poi-action-text">查看位置</text>
</view>
<view class="poi-action" @tap="handleSetSelectedPoiAsStart">
<text class="poi-action-text">从这里出发</text>
</view>
<view class="poi-action" @tap="handleSetSelectedPoiAsEnd">
<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="showGuideMoreMenu && !showRoutePlanner" class="guide-more-popover">
<view class="more-popover-handle"></view>
<view class="more-popover-grid">
<view class="more-action" @tap="handleMoreRouteGuide">
<text class="more-action-title">线路预览</text>
<text class="more-action-desc">室内到室内</text>
</view>
<view class="more-action" @tap="handleMoreOutdoorReference">
<text class="more-action-title">室外参考</text>
<text class="more-action-desc">入口位置</text>
</view>
<view class="more-action" @tap="handleMoreRecommendedEntrance">
<text class="more-action-title">推荐入口</text>
<text class="more-action-desc">入口参考</text>
</view>
<view class="more-action" @tap="handleEnter3DMode">
<text class="more-action-title">室内3D</text>
<text class="more-action-desc">位置预览</text>
</view>
<view class="more-action" @tap="handleMoreToggleLayer">
<text class="more-action-title">{{ indoorLayerMode === 'multi' ? '单层' : '多层' }}</text>
<text class="more-action-desc">切换楼层</text>
</view>
</view>
</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"
@update:start-point="handleRouteStartPointChange"
@update:end-point="handleRouteEndPointChange"
@picker-open="handleRoutePickerOpen"
@search="handleRoutePickerSearch"
@swap="handleRouteSwap"
@clear="handleRouteClear"
@view-route="handleRouteView"
@simulate-guide="handleRouteSimulate"
/>
<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>
<view v-else-if="currentTab === 'explain'" class="explain-page">
<ExplainHallSelect
:halls="explainHallItems"
:loading="explainLoading"
:error="explainError"
@hall-click="handleExplainHallClick"
@back="handleExplainBack"
/>
</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 type {
RoutePointOption
} from '@/components/navigation/RoutePointPicker.vue'
import ExplainHallSelect, {
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 type {
GuideRenderPoi
} from '@/domain/guideModel'
import type {
GuideRouteResult,
GuideRouteTarget,
MuseumExhibit,
MuseumFloor
} 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 initialTopTab = (): GuideTopTab => topTabFromHash() || 'guide'
// 3D 模式状态
const is3DMode = ref(true)
const guideOutdoorState = ref<'home' | 'entrance'>('home')
const indoorView = ref<GuideIndoorView>('floor')
const activeGuideFloor = ref('1F')
const selectedGuidePoi = ref<GuideRenderPoi | null>(null)
// 状态
const currentTab = ref<GuideTopTab>(initialTopTab())
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 activeRoutePreview = ref<GuideRouteResult | null>(null)
const showGuideMoreMenu = ref(false)
const isSimulatingRoute = ref(false)
const isPoiCardCollapsed = ref(false)
const indoorNavAssetBaseUrl = guideUseCase.getAssetBaseUrl()
const indoorModelSource = guideUseCase.getModelSource()
const guideFloors = ref<MuseumFloor[]>([])
const normalizeGuideFloorId = (labelOrId: string) => guideUseCase.normalizeFloorId(labelOrId)
const indoorLayerMode = computed<LayerDisplayMode>(() => (
indoorView.value === 'multi' ? 'multi' : 'single'
))
const guide3DTools = computed(() => {
return is3DMode.value ? ['重置', '俯视', '斜视', '清除', '清路线'] : []
})
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 || routePreviewUnavailableMessage.value)
const toRoutePointOption = (target: GuideRouteTarget): RoutePointOption => ({
poiId: target.poiId,
name: target.name,
floorId: target.floorId,
floorLabel: target.floorLabel,
categoryLabel: target.categoryLabel
})
const selectedGuidePoiSubtitle = computed(() => {
const poi = selectedGuidePoi.value
if (!poi) return ''
const floor = guideFloors.value.find((item) => item.id === poi.floorId)
const floorLabel = floor?.label || poi.floorId
return `${floorLabel} · ${poi.primaryCategoryZh || '位置预览'}`
})
const 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 url = `#/pages/index/index?tab=${tabId}`
if (window.location.hash !== url) {
window.history.replaceState(null, '', url)
}
}
const loadTopTabData = (tabId: GuideTopTab) => {
if (tabId === 'explain') {
void loadExplainExhibits()
} 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)
// 关闭可能打开的面板
showGuideMoreMenu.value = false
showRoutePlanner.value = false
isSimulatingRoute.value = false
isPoiCardCollapsed.value = false
}
const syncTopTabFromHash = () => {
if (!isIndexHashRoute()) return
const tab = topTabFromHash()
if (tab) {
const tabChanged = currentTab.value !== tab
currentTab.value = tab
loadTopTabData(tab)
if (tabChanged) {
showGuideMoreMenu.value = false
showRoutePlanner.value = false
isSimulatingRoute.value = false
isPoiCardCollapsed.value = false
}
syncTopTabToUrl(tab)
return
}
syncTopTabToUrl(currentTab.value)
loadTopTabData(currentTab.value)
}
onMounted(() => {
if (typeof window === 'undefined') return
syncTopTabFromHash()
window.addEventListener('hashchange', syncTopTabFromHash)
})
onUnmounted(() => {
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
activeGuideFloor.value = floors.find((floor) => floor.label === activeGuideFloor.value)?.label
|| floors.find((floor) => floor.id === 'L1')?.label
|| floors[0]?.label
|| activeGuideFloor.value
} catch (error) {
console.error('加载导览楼层失败:', error)
guideFloors.value = []
}
}
const loadExplainExhibits = async () => {
if (explainHallItems.value.length) return
if (explainLoadPromise) return explainLoadPromise
explainLoading.value = true
explainError.value = ''
explainLoadPromise = (async () => {
try {
const [halls, exhibits] = await Promise.all([
explainUseCase.listHalls(),
explainUseCase.listExplainExhibits()
])
explainHallItems.value = buildExplainHallItems(halls, exhibits)
} 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.listHalls>>,
exhibits: MuseumExhibit[]
): ExplainHallSelectItem[] => {
const countsByHallId = new Map<string, number>()
const countsByHallName = new Map<string, number>()
const keywordsByHallId = new Map<string, Set<string>>()
const keywordsByHallName = new Map<string, Set<string>>()
exhibits.forEach((exhibit) => {
const keywordParts = [
exhibit.name,
exhibit.hallName,
exhibit.floorLabel,
exhibit.description,
exhibit.guideTitle,
exhibit.guideText,
...(exhibit.tags || [])
].filter(Boolean) as string[]
if (exhibit.hallId) {
countsByHallId.set(exhibit.hallId, (countsByHallId.get(exhibit.hallId) || 0) + 1)
const keywords = keywordsByHallId.get(exhibit.hallId) || new Set<string>()
keywordParts.forEach((part) => keywords.add(part))
keywordsByHallId.set(exhibit.hallId, keywords)
}
if (exhibit.hallName) {
countsByHallName.set(exhibit.hallName, (countsByHallName.get(exhibit.hallName) || 0) + 1)
const keywords = keywordsByHallName.get(exhibit.hallName) || new Set<string>()
keywordParts.forEach((part) => keywords.add(part))
keywordsByHallName.set(exhibit.hallName, keywords)
}
})
return halls.map((hall) => {
const explainCount = countsByHallId.get(hall.id)
|| countsByHallName.get(hall.name)
|| hall.exhibitCount
|| 0
const keywordParts = [
hall.name,
hall.floorLabel,
hall.description,
hall.area,
...Array.from(keywordsByHallId.get(hall.id) || []),
...Array.from(keywordsByHallName.get(hall.name) || [])
].filter(Boolean) as string[]
return {
id: hall.id,
name: hall.name,
floorLabel: hall.floorLabel,
image: hall.image,
explainCount,
searchText: normalizeExplainKeyword(keywordParts.join(' '))
}
})
}
const handleGuideSearchTap = () => {
uni.navigateTo({
url: '/pages/search/index'
})
}
const handleFloorChange = (floor: string) => {
activeGuideFloor.value = floor
indoorView.value = 'floor'
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
console.log('切换楼层:', floor)
}
const handleIndoorViewChange = (view: GuideIndoorView) => {
indoorView.value = view
if (view !== 'floor') {
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
}
}
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
}
}
const handleGuidePoiClick = (poi: GuideRenderPoi) => {
selectedGuidePoi.value = poi
isPoiCardCollapsed.value = false
indoorView.value = 'floor'
showGuideMoreMenu.value = false
isSimulatingRoute.value = false
const matchedFloor = guideFloors.value.find((floor) => floor.id === poi.floorId)
activeGuideFloor.value = matchedFloor?.label || poi.floorId
}
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 findRouteOptionForPoi = async (poi: GuideRenderPoi) => {
if (!routePointOptions.value.length) {
await loadRouteTargets()
}
return routePointOptions.value.find((option) => option.poiId === poi.id) || null
}
const setSelectedPoiAsRoutePoint = async (mode: 'start' | 'end') => {
if (!selectedGuidePoi.value) return
const option = await findRouteOptionForPoi(selectedGuidePoi.value)
if (!option) {
uni.showToast({
title: '该点位暂无线路数据,可先查看三维位置',
icon: 'none'
})
return
}
if (mode === 'start') {
routeStartPoint.value = option
} else {
routeEndPoint.value = option
}
clearRoutePreviewState()
openRoutePlanner()
}
const handlePreviewSelectedPoi = () => {
if (!selectedGuidePoi.value) return
indoorView.value = 'floor'
showRoutePlanner.value = false
showGuideMoreMenu.value = false
isSimulatingRoute.value = false
}
const handleSetSelectedPoiAsStart = () => {
void setSelectedPoiAsRoutePoint('start')
}
const handleSetSelectedPoiAsEnd = () => {
void setSelectedPoiAsRoutePoint('end')
}
const handleSelectedPoiRelated = () => {
if (!selectedGuidePoi.value) return
uni.navigateTo({
url: `/pages/search/index?keyword=${encodeURIComponent(selectedGuidePoi.value.name)}`
})
}
const handleGuideToolClick = (tool: string) => {
if (tool === '清除') {
selectedGuidePoi.value = null
} else if (tool === '清路线') {
clearRoutePreviewState()
isSimulatingRoute.value = false
}
}
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()
routePreviewUnavailableMessage.value = readiness.ready
? ''
: readiness.message || '当前暂不支持正式导航,可查看位置预览和路线示意'
} catch (error) {
console.warn('读取路线能力状态失败:', error)
routePreviewUnavailableMessage.value = '当前暂不支持正式导航,可查看位置预览和路线示意'
}
}
const openRoutePlanner = () => {
showRoutePlanner.value = true
is3DMode.value = true
indoorView.value = indoorView.value === 'overview' ? 'floor' : indoorView.value
guideOutdoorState.value = 'home'
showGuideMoreMenu.value = false
isPoiCardCollapsed.value = false
isSimulatingRoute.value = false
if (!routePointOptions.value.length) {
void loadRouteTargets()
}
void refreshRouteReadinessState()
}
const clearRoutePreviewState = () => {
activeRoutePreview.value = null
routePlanError.value = ''
isSimulatingRoute.value = false
}
const handleRouteClear = () => {
routeStartPoint.value = null
routeEndPoint.value = null
showRoutePlanner.value = false
clearRoutePreviewState()
}
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 floor = guideFloors.value.find((item) => item.id === route.start.floorId)
const routeFloorCount = new Set(route.floorSegments.map((segment) => segment.floorId)).size
activeGuideFloor.value = floor?.label || route.start.floorLabel
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
showGuideMoreMenu.value = false
isSimulatingRoute.value = true
}
// 进入 3D 室内模式
const handleEnter3DMode = () => {
console.log('进入 3D 室内模式')
indoorView.value = 'floor'
is3DMode.value = true
guideOutdoorState.value = 'home'
showGuideMoreMenu.value = false
if (!routePointOptions.value.length) {
void loadRouteTargets()
}
}
const handleModeChange = (mode: '2d' | '3d') => {
is3DMode.value = mode === '3d'
if (mode === '2d') {
indoorView.value = 'overview'
guideOutdoorState.value = 'home'
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
showRoutePlanner.value = false
showGuideMoreMenu.value = false
isSimulatingRoute.value = false
clearRoutePreviewState()
} else {
indoorView.value = 'floor'
if (!routePointOptions.value.length) {
void loadRouteTargets()
}
}
}
const guideStatusLabel = computed(() => {
if (is3DMode.value) {
if (indoorView.value === 'overview') return '建筑外观'
if (indoorView.value === 'multi') return '室内多层'
return '室内单层'
}
if (guideOutdoorState.value === 'entrance') {
return '推荐入口'
}
return '室外参考'
})
const handleSwitchEntrance = () => {
guideOutdoorState.value = 'home'
}
const handleGuideMoreClick = () => {
showGuideMoreMenu.value = !showGuideMoreMenu.value
selectedGuidePoi.value = null
}
const handleMoreRouteGuide = () => {
openRoutePlanner()
}
const openOutdoorReference = (state: 'home' | 'entrance') => {
is3DMode.value = false
guideOutdoorState.value = state
indoorView.value = 'overview'
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
showRoutePlanner.value = false
showGuideMoreMenu.value = false
clearRoutePreviewState()
}
const handleMoreOutdoorReference = () => {
openOutdoorReference('home')
}
const handleMoreRecommendedEntrance = () => {
openOutdoorReference('entrance')
}
const handleMoreToggleLayer = () => {
is3DMode.value = true
guideOutdoorState.value = 'home'
indoorView.value = indoorLayerMode.value === 'multi' ? 'floor' : 'multi'
showGuideMoreMenu.value = false
}
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 = (hallId: string) => {
uni.navigateTo({
url: `/pages/hall/detail?id=${encodeURIComponent(hallId)}&tab=explain`
})
}
const handleExplainBack = () => {
currentTab.value = 'guide'
syncTopTabToUrl('guide')
loadTopTabData('guide')
showGuideMoreMenu.value = false
showRoutePlanner.value = false
isSimulatingRoute.value = false
isPoiCardCollapsed.value = false
}
</script>
<style scoped lang="scss">
.explain-page {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #f6f7f4;
z-index: 10;
}
.guide-poi-card,
.guide-more-popover {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(env(safe-area-inset-bottom) + 30px);
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,
.more-popover-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(4, 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-more-popover {
padding: 10px 12px 12px;
}
.more-popover-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.more-action {
min-height: 64px;
padding: 10px 8px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
box-sizing: border-box;
background: #f6f7f4;
border: 1px solid #e5e6de;
border-radius: 8px;
}
.more-action-title {
font-size: 14px;
line-height: 20px;
font-weight: 700;
color: #151713;
text-align: center;
}
.more-action-desc {
font-size: 11px;
line-height: 15px;
color: #696962;
text-align: center;
}
.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) + 30px);
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;
}
.home-shortcuts-card {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(env(safe-area-inset-bottom) + 134px);
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) + 134px);
z-index: 1003;
}
.guide-task-card {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(env(safe-area-inset-bottom) + 30px);
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) + 34px);
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>