同步移动端源码并修复小程序嵌入标题
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-28 18:37:40 +08:00
parent 06ad609e1f
commit 540bb8627f
62 changed files with 4196 additions and 1410 deletions

View File

@@ -41,17 +41,19 @@
:show-floor-header="!isTwoDimensionalIndoorMode"
:show-layer-mode-toggle="false"
:show-floor="isBuildingGuideActive"
:show-zoom-controls="isBuildingGuideActive"
:show-zoom-controls="true"
zoom-controls-top="calc(100vh - 292px)"
:route-preview="activeRoutePreview"
:show-route="Boolean(activeRoutePreview)"
:route-navigation-active="isSimulatingRoute"
:route-start-selection-active="routeStartSelectionActive"
:route-selectable-poi-ids="routeSelectablePoiIds"
:route-selectable-points="routePointOptions"
:outdoor-markers="homeArrivalFeatureEnabled ? arrivalOutdoorMarkers : []"
:active-outdoor-marker-id="homeArrivalFeatureEnabled ? activeArrivalMarkerId : ''"
:outdoor-focus-offset-y="homeArrivalFeatureEnabled ? arrivalOutdoorFocusOffsetY : 0"
:route-selectable-poi-ids="routeSelectablePoiIds"
:route-selectable-points="routePointOptions"
:outdoor-nav-polylines="homeArrivalFeatureEnabled ? outdoorNavPolylines : []"
:outdoor-markers="homeArrivalFeatureEnabled ? outdoorMapMarkers : []"
:active-outdoor-marker-id="homeArrivalFeatureEnabled ? activeArrivalMarkerId : ''"
:outdoor-focus-offset-y="outdoorArrivalStage === 'route' ? 170 : (arrivalActiveType ? 180 : 0)"
:semantic-zoom-enabled="semanticMapZoomEnabled"
:disable-auto-exit="disableIndoorAutoExit"
@mode-change="handleModeChange"
@floor-request="handleFloorRequest"
@@ -71,8 +73,12 @@
@indoor-render-mode-fallback="handleIndoorRenderModeFallback"
@indoor-render-mode-change="handleIndoorRenderModeChange"
@scene-viewport-change="handleGuideSceneViewportChange"
@tool-click="handleIndoorToolClick"
@outdoor-marker-click="handleArrivalMarkerClick"
@tool-click="handleIndoorToolClick"
@outdoor-marker-click="handleOutdoorMarkerClick"
@map-tap="handleOutdoorMapTap"
@semantic-enter-building="handleSemanticEnterBuilding"
@semantic-exit-outdoor="handleSemanticExitOutdoor"
@outdoor-unavailable="handleOutdoorMapUnavailable"
>
<template #overlay>
<view v-if="homeArrivalFeatureEnabled && guideOutdoorState === 'entrance' && !isBuildingGuideActive" class="entrance-tip">
@@ -92,6 +98,7 @@
<view
v-if="showGuideFloatingActions"
class="guide-quick-actions"
:class="{ 'is-outdoor': showOutdoorNavigationPanel }"
>
<view
v-if="homeArrivalFeatureEnabled"
@@ -196,20 +203,48 @@
</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"
<OutdoorArrivalRoutePanel
v-if="outdoorArrivalStage === 'route' && homeArrivalFeatureEnabled && currentTab === 'guide'"
:visible="showOutdoorNavigationPanel"
:phase="outdoorRouteStartPhase"
:location-message="outdoorLocationMessage"
:location-help-visible="outdoorLocationHelpVisible"
:loading="outdoorRouteLoading"
:route-error="outdoorRouteError"
:route-distance="outdoorRoute?.distance || 0"
:route-duration="outdoorRoute?.duration || 0"
:start-point="outdoorRouteStart"
:destination="selectedArrivalTarget"
:manual-search-results="outdoorStartSearchResults"
:manual-search-loading="outdoorStartSearchLoading"
@retry-location="handleOutdoorLocationRetry"
@manual-start-request="handleOutdoorManualSearchRequest"
@manual-map-request="handleOutdoorManualMapRequest"
@manual-start-confirm="handleOutdoorManualStartConfirm"
@manual-start-search="handleOutdoorManualStartSearch"
@confirm-start="handleOutdoorRouteStartConfirm"
@retry-route="handleOutdoorRouteRetry"
@back="returnToArrivalDetail"
/>
<ArrivalPanel
v-if="showArrivalDestinationPanel && homeArrivalFeatureEnabled && currentTab === 'guide'"
:visible="showOutdoorNavigationPanel"
:mode="outdoorArrivalStage === 'detail' ? 'detail' : 'browse'"
:active-type="arrivalActiveType"
:targets="arrivalTargets"
:selected-target-id="selectedArrivalTarget?.id || ''"
:selected-target="selectedArrivalTarget"
:loading="arrivalTargetsLoading"
:error="arrivalTargetsError"
@type-change="handleArrivalTypeChange"
@select-target="handleArrivalTargetSelect"
@navigate="handleArrivalNavigate"
@back="returnToArrivalBrowse"
@collapse="collapseArrivalBrowse"
@retry="arrivalActiveType && loadArrivalTargets(arrivalActiveType)"
/>
<view v-else-if="currentTab === 'explain'" class="explain-page">
<ExplainHallSelect
:halls="explainHallItems"
@@ -261,6 +296,9 @@ import GuidePoiSummaryCard, {
type GuidePoiSummaryAction
} from '@/components/map/GuidePoiSummaryCard.vue'
import RoutePlannerPanel from '@/components/navigation/RoutePlannerPanel.vue'
import OutdoorArrivalRoutePanel, {
type OutdoorRouteStartPhase
} from '@/components/navigation/OutdoorArrivalRoutePanel.vue'
import ArrivalPanel from '@/components/navigation/ArrivalPanel.vue'
import PoiSearchPanel from '@/components/search/PoiSearchPanel.vue'
import type {
@@ -313,11 +351,6 @@ import {
type GuideRenderMode
} from '@/composables/useGuideSceneState'
import type { ResetViewBaselineResult } from '@/components/map/ThreeMap.vue'
import {
ARRIVAL_TARGETS_BY_TYPE,
type ArrivalSearchTarget,
type ArrivalTargetType
} from '@/data/arrivalSearchTargets'
import type {
GuideRouteResult,
GuideRouteTarget,
@@ -331,6 +364,31 @@ import { getFloorSortLevel } from '@/domain/guideFloor'
import { normalizeVisitorPoiDisplayName } from '@/view-models/visitorPoiPresentation'
import { getBrowserNetworkInfo } from '@/services/performance/backgroundPreloadScheduler'
import { resolveInitialGuideRenderMode } from '@/services/performance/guideRenderModePolicy'
import { MUSEUM_LOCATION, type TravelMode } from '@/config/museum'
import {
BrowserLocationError,
getBrowserLocationPermissionState,
getBrowserGcjLocation
} from '@/services/outdoor/BrowserLocationService'
import {
refreshOutdoorMapProfile,
subscribeOutdoorMapAvailability,
planOutdoorRoute,
reverseGeocodeOutdoorLocation,
searchOutdoorPlaces,
suggestOutdoorPlaces,
type OutdoorPlace,
type OutdoorRoutePlan
} from '@/services/outdoor/OutdoorMapAppService'
import {
ARRIVAL_SEARCH_REGION,
ARRIVAL_TARGET_TYPES,
type ArrivalSearchTarget,
type ArrivalTargetType
} from '@/data/arrivalSearchTargets'
// #ifdef H5
import { preloadTencentJsMap } from '@/services/outdoor/TencentJsMapLoader'
// #endif
type GuideIndoorView = 'overview' | 'floor' | 'multi'
type LayerDisplayMode = 'single' | 'multi'
@@ -370,8 +428,48 @@ const shouldShowLaunchOverlay = () => {
}
const initialTopTab = (): GuideTopTab => topTabFromHash() || 'guide'
// 到馆服务暂不对外开放;保留实现,后续仅需恢复此开关即可。
const homeArrivalFeatureEnabled = false
const homeArrivalFeatureEnabled = ref(false)
let outdoorMapPreloadStarted = false
let outdoorMapPreloadTimer: ReturnType<typeof setTimeout> | null = null
let stopOutdoorMapAvailabilitySubscription: (() => void) | null = null
const handleOutdoorMapUnavailable = (message?: string) => {
homeArrivalFeatureEnabled.value = false
if (showOutdoorNavigationPanel.value || !isBuildingGuideActive.value) {
closeArrivalPanel()
}
if (message) console.warn('来馆服务暂不可用:', message)
}
const refreshOutdoorMapAvailability = async () => {
try {
const profile = await refreshOutdoorMapProfile()
homeArrivalFeatureEnabled.value = profile.available
if (profile.available) {
preloadOutdoorMapAfterInitialView()
return
}
if (showOutdoorNavigationPanel.value || !isBuildingGuideActive.value) closeArrivalPanel()
} catch (error) {
console.warn('读取来馆服务状态失败:', error)
handleOutdoorMapUnavailable()
}
}
const preloadOutdoorMapAfterInitialView = () => {
// #ifdef H5
if (!homeArrivalFeatureEnabled.value || outdoorMapPreloadStarted || typeof window === 'undefined') return
outdoorMapPreloadStarted = true
outdoorMapPreloadTimer = setTimeout(() => {
outdoorMapPreloadTimer = null
void preloadTencentJsMap().catch((error) => {
console.warn('腾讯地图预加载失败:', error)
handleOutdoorMapUnavailable(error instanceof Error ? error.message : '腾讯地图预加载失败')
})
}, 600)
// #endif
}
// Building guide and outdoor-arrival maps are separate products. This flag is
// deliberately independent from the 2D/3D renderer used by the building guide.
@@ -420,14 +518,6 @@ let pendingPoiDetailUiContext: Readonly<{
// 状态
const currentTab = ref<GuideTopTab>(initialTopTab())
const topTabNavigationTitle = (tab: GuideTopTab) => (
tab === 'explain' ? '免费讲解' : '地图导览'
)
watch(currentTab, (tab) => {
syncHostNavigationTitle(topTabNavigationTitle(tab))
}, { immediate: true })
const launchOverlayVisible = ref(shouldShowLaunchOverlay())
const launchOverlayLeaving = ref(false)
const launchProgress = ref(8)
@@ -608,12 +698,39 @@ const settleLaunchOverlayByModel = (status: 'ready' | 'failed', event?: { view:
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 showOutdoorNavigationPanel = ref(false)
// The arrival journey keeps destination selection separate from route planning:
// browse nearby transport/parking, confirm one destination, then choose a start.
type OutdoorArrivalStage = 'browse' | 'detail' | 'route'
const ARRIVAL_TARGET_CACHE_TTL_MS = 5 * 60 * 1000
type ArrivalTargetCacheEntry = {
expiresAt: number
items: ArrivalSearchTarget[]
}
const outdoorArrivalStage = ref<OutdoorArrivalStage>('browse')
const outdoorRouteStartPhase = ref<OutdoorRouteStartPhase>('locating')
const outdoorLocationMessage = ref('')
const outdoorLocationHelpVisible = ref(false)
const arrivalActiveType = ref<ArrivalTargetType | null>(null)
const arrivalTargets = ref<ArrivalSearchTarget[]>([])
const arrivalTargetsLoading = ref(false)
const arrivalTargetsError = ref('')
const selectedArrivalTarget = ref<ArrivalSearchTarget | null>(null)
const outdoorStartSearchResults = ref<OutdoorPlace[]>([])
const outdoorStartSearchLoading = ref(false)
const arrivalTargetCache = new Map<ArrivalTargetType, ArrivalTargetCacheEntry>()
let arrivalTargetRequestId = 0
let outdoorStartSearchRequestId = 0
let outdoorManualReverseGeocodeRequestId = 0
let outdoorStartLocationRequestId = 0
let outdoorRoutePlanRequestId = 0
const outdoorRouteStart = ref<{ latitude: number; longitude: number; name?: string } | null>(null)
const outdoorTravelMode = ref<TravelMode>('walking')
const outdoorRoute = ref<OutdoorRoutePlan | null>(null)
const outdoorRouteLoading = ref(false)
const outdoorRouteError = ref('')
const indoorNavAssetBaseUrl = guideUseCase.getAssetBaseUrl()
const indoorModelSource = guideUseCase.getModelSource()
@@ -697,11 +814,27 @@ const disableIndoorAutoExit = computed(() => (
|| Boolean(searchTargetFocusRequest.value)
))
// Semantic zoom only changes the presentation layer while the visitor is
// browsing. Route planning and active navigation keep ownership of the map.
const semanticMapZoomEnabled = computed(() => (
currentTab.value === 'guide'
&& !showRoutePlanner.value
&& !routePlanning.value
&& !routeStartSelectionActive.value
&& !activeRoutePreview.value
&& !isSimulatingRoute.value
&& !selectedGuidePoi.value
&& !homeSearchExpanded.value
&& outdoorArrivalStage.value !== 'route'
&& !outdoorRouteLoading.value
&& !outdoorRoute.value
))
const showGuideHomeDock = computed(() => (
currentTab.value === 'guide'
&& isBuildingGuideActive.value
&& !showRoutePlanner.value
&& !showArrivalPanel.value
&& !showOutdoorNavigationPanel.value
&& !selectedGuidePoi.value
&& !isSimulatingRoute.value
&& !(guideOutdoorState.value === 'entrance' && !isBuildingGuideActive.value)
@@ -710,44 +843,97 @@ const showGuideHomeDock = computed(() => (
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 outdoorNavPolylines = computed(() => outdoorRoute.value?.polyline.length
? [{ points: outdoorRoute.value.polyline, color: '#1565C0', width: 6 }]
: []
)
const selectedArrivalTarget = computed(() => (
activeArrivalTargets.value.find((target) => target.id === selectedArrivalTargetId.value)
|| activeArrivalTargets.value[0]
|| null
const showArrivalDestinationPanel = computed(() => (
showOutdoorNavigationPanel.value
&& (outdoorArrivalStage.value === 'browse' || outdoorArrivalStage.value === 'detail')
))
const arrivalOutdoorMarkers = computed(() => {
if (!showArrivalPanel.value || isBuildingGuideActive.value) return []
const outdoorMapMarkers = computed(() => {
if (!showOutdoorNavigationPanel.value) return []
return activeArrivalTargets.value.map((target) => ({
id: target.id,
title: target.title,
subtitle: target.subtitle,
latitude: target.latitude,
longitude: target.longitude
}))
if (outdoorArrivalStage.value === 'browse' || outdoorArrivalStage.value === 'detail') {
// Tencent base-map POIs are not interactive in the JS API. Every
// selected transport category therefore uses a tappable blue marker.
const museumMarker = {
id: 'museum-destination',
title: MUSEUM_LOCATION.name,
subtitle: MUSEUM_LOCATION.address,
latitude: MUSEUM_LOCATION.latitude,
longitude: MUSEUM_LOCATION.longitude,
markerKind: 'museum' as const
}
if (outdoorArrivalStage.value === 'browse' && arrivalActiveType.value) {
return [
museumMarker,
...arrivalTargets.value.map((target) => ({
id: target.id,
title: target.title,
subtitle: target.subtitle,
latitude: target.latitude,
longitude: target.longitude,
markerKind: target.type
}))
]
}
if (outdoorArrivalStage.value === 'detail' && selectedArrivalTarget.value) {
return [
museumMarker,
{
id: selectedArrivalTarget.value.id,
title: selectedArrivalTarget.value.title,
subtitle: selectedArrivalTarget.value.subtitle,
latitude: selectedArrivalTarget.value.latitude,
longitude: selectedArrivalTarget.value.longitude,
markerKind: selectedArrivalTarget.value.type
}
]
}
return [museumMarker]
}
if (outdoorArrivalStage.value === 'route' && selectedArrivalTarget.value) {
return [
...(outdoorRouteStart.value ? [{
id: 'outdoor-route-start',
title: outdoorRouteStart.value.name || '起点',
subtitle: '起点',
latitude: outdoorRouteStart.value.latitude,
longitude: outdoorRouteStart.value.longitude,
markerKind: 'user' as const
}] : []),
{
id: 'outdoor-route-destination',
title: selectedArrivalTarget.value.title,
subtitle: selectedArrivalTarget.value.subtitle,
latitude: selectedArrivalTarget.value.latitude,
longitude: selectedArrivalTarget.value.longitude
}
]
}
return []
})
const activeArrivalMarkerId = computed(() => (
showArrivalPanel.value && !isBuildingGuideActive.value ? selectedArrivalTarget.value?.id || '' : ''
))
const arrivalOutdoorFocusOffsetY = computed(() => {
if (!showArrivalPanel.value || showArrivalPanelCollapsed.value || isBuildingGuideActive.value) return 0
return Math.min(260, Math.max(0, Math.round(arrivalPanelHeight.value / 2 + 24)))
})
const activeArrivalMarkerId = computed(() => (
!showOutdoorNavigationPanel.value ? ''
: outdoorArrivalStage.value === 'route' ? 'outdoor-route-destination'
: outdoorArrivalStage.value === 'detail' && selectedArrivalTarget.value
? selectedArrivalTarget.value.id
: 'museum-destination'
))
const guideQuickActiveAction = computed<'indoor' | 'arrival' | 'explain'>(() => {
if (currentTab.value === 'explain') return 'explain'
if (showArrivalPanel.value) return 'arrival'
if (showOutdoorNavigationPanel.value) return 'arrival'
return isBuildingGuideActive.value ? 'indoor' : 'arrival'
})
@@ -987,6 +1173,12 @@ onMounted(() => {
if (typeof window === 'undefined') return
if (isEmbeddedInWechatMiniProgram()) {
syncHostNavigationTitle('地图导览')
}
stopOutdoorMapAvailabilitySubscription = subscribeOutdoorMapAvailability(handleOutdoorMapUnavailable)
void refreshOutdoorMapAvailability()
syncTopTabFromHash()
removePoiSearchOverlayFromCurrentHistoryState()
window.addEventListener('hashchange', syncTopTabFromHash)
@@ -997,6 +1189,14 @@ onUnmounted(() => {
launchOverlayRunId += 1
clearLaunchTimers()
if (outdoorMapPreloadTimer) {
clearTimeout(outdoorMapPreloadTimer)
outdoorMapPreloadTimer = null
}
stopOutdoorMapAvailabilitySubscription?.()
stopOutdoorMapAvailabilitySubscription = null
if (indoorGestureHintTimer) {
clearTimeout(indoorGestureHintTimer)
indoorGestureHintTimer = null
@@ -1171,6 +1371,7 @@ const handleInitialModelReady = (event: { view: GuideIndoorView; floorId?: strin
guideModelState.initializeModel({ view: event.view, floorId: readyFloorId })
}
settleLaunchOverlayByModel('ready', event)
preloadOutdoorMapAfterInitialView()
const context = guideModelState.consumePendingViewBaselineReset()
if (context) {
void resetGuideModelToFloorBaseline({
@@ -1642,6 +1843,37 @@ const handleEnterBuildingGuide = () => {
showIndoorHint()
}
const handleSemanticEnterBuilding = () => {
if (!semanticMapZoomEnabled.value || isBuildingGuideActive.value) return
showOutdoorNavigationPanel.value = false
clearOutdoorRoute()
indoorView.value = 'overview'
isBuildingGuideActive.value = true
guideOutdoorState.value = 'home'
guideSceneState.commit({
view: 'overview',
floorId: activeGuideFloor.value
})
}
const handleSemanticExitOutdoor = () => {
if (!homeArrivalFeatureEnabled.value || !semanticMapZoomEnabled.value || !isBuildingGuideActive.value) return
// Zooming out of the building is also an entry into the arrival map. Keep
// its first screen consistent with tapping the "来馆" quick action.
showOutdoorNavigationPanel.value = true
outdoorArrivalStage.value = 'browse'
clearOutdoorRoute()
arrivalActiveType.value = null
arrivalTargets.value = []
arrivalTargetsError.value = ''
selectedArrivalTarget.value = null
indoorView.value = 'overview'
isBuildingGuideActive.value = false
guideOutdoorState.value = 'home'
}
const handleModeChange = (mode: '2d' | '3d') => {
isBuildingGuideActive.value = mode === '3d'
@@ -1730,61 +1962,371 @@ const handleMoreRouteGuide = async () => {
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
isSimulatingRoute.value = false
const floorId = activeGuideFloor.value
const floorLabel = getGuideFloorLabel(floorId) || '默认楼层'
if (!floorId) {
showIndoorHint('暂未获取到可进入的楼层,请稍后重试', 3200)
indoorView.value = 'overview'
requestedFloorId.value = ''
requestedFloorLabel.value = ''
loadingFloorId.value = ''
failedFloorId.value = ''
guideSceneState.commit({
view: 'overview',
floorId: activeGuideFloor.value
})
}
const toArrivalSearchTarget = (place: OutdoorPlace, type: ArrivalTargetType): ArrivalSearchTarget => ({
id: place.id || `${type}:${place.latitude}:${place.longitude}`,
type,
title: place.title,
subtitle: place.address || place.category || '周边服务点',
keyword: place.title,
region: ARRIVAL_SEARCH_REGION,
latitude: place.latitude,
longitude: place.longitude,
distance: place.distance
})
const loadArrivalTargets = async (type: ArrivalTargetType) => {
const requestId = ++arrivalTargetRequestId
const config = ARRIVAL_TARGET_TYPES.find((item) => item.type === type)
if (!config) return
const cached = arrivalTargetCache.get(type)
if (cached && cached.expiresAt > Date.now()) {
arrivalTargetsLoading.value = false
arrivalTargetsError.value = ''
arrivalTargets.value = cached.items
selectedArrivalTarget.value = null
return
}
arrivalTargetCache.delete(type)
arrivalTargetsLoading.value = true
arrivalTargetsError.value = ''
arrivalTargets.value = []
selectedArrivalTarget.value = null
try {
const response = await searchOutdoorPlaces({
keyword: config.keyword,
region: ARRIVAL_SEARCH_REGION,
latitude: MUSEUM_LOCATION.latitude,
longitude: MUSEUM_LOCATION.longitude,
pageSize: 12
})
if (requestId !== arrivalTargetRequestId) return
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 uniqueTargets = new Map<string, ArrivalSearchTarget>()
response.items.forEach((place) => {
const target = toArrivalSearchTarget(place, type)
if (!uniqueTargets.has(target.id)) uniqueTargets.set(target.id, target)
})
const items = [...uniqueTargets.values()]
arrivalTargetCache.set(type, {
expiresAt: Date.now() + ARRIVAL_TARGET_CACHE_TTL_MS,
items
})
arrivalTargets.value = items
} catch (error) {
if (requestId !== arrivalTargetRequestId) return
console.error('读取来馆分类点位失败:', error)
arrivalTargetsError.value = '周边点位暂时无法加载'
} finally {
if (requestId === arrivalTargetRequestId) arrivalTargetsLoading.value = false
}
}
const handleArrivalTypeChange = (type: ArrivalTargetType) => {
activeArrivalType.value = type
selectFirstArrivalTarget()
if (type === arrivalActiveType.value) return
arrivalActiveType.value = type
outdoorArrivalStage.value = 'browse'
void loadArrivalTargets(type)
}
const handleArrivalTargetSelect = (target: ArrivalSearchTarget) => {
selectedArrivalTargetId.value = target.id
selectedArrivalTarget.value = target
outdoorArrivalStage.value = 'detail'
}
const handleArrivalMarkerClick = (markerId: string) => {
if (!homeArrivalFeatureEnabled) return
const target = activeArrivalTargets.value.find((item) => item.id === markerId)
if (!target) return
selectedArrivalTargetId.value = target.id
const handleArrivalNavigate = (target: ArrivalSearchTarget) => {
selectedArrivalTarget.value = target
// The selected arrival category already establishes the route strategy.
// Transit points use Tencent transit planning; parking uses driving.
outdoorTravelMode.value = target.type === 'parking' ? 'driving' : 'transit'
outdoorArrivalStage.value = 'route'
clearOutdoorRoute()
// Real-time location is the preferred zero-step route start. Manual input
// and map selection retain an explicit confirmation because they are
// visitor-selected coordinates.
void requestOutdoorCurrentLocation()
}
const returnToArrivalBrowse = () => {
outdoorArrivalStage.value = 'browse'
selectedArrivalTarget.value = null
clearOutdoorRoute()
}
const collapseArrivalBrowse = () => {
arrivalTargetRequestId += 1
outdoorArrivalStage.value = 'browse'
arrivalActiveType.value = null
arrivalTargets.value = []
selectedArrivalTarget.value = null
arrivalTargetsError.value = ''
arrivalTargetsLoading.value = false
clearOutdoorRoute()
}
const returnToArrivalDetail = () => {
outdoorArrivalStage.value = selectedArrivalTarget.value ? 'detail' : 'browse'
clearOutdoorRoute()
}
const closeArrivalPanel = () => {
arrivalTargetRequestId += 1
showOutdoorNavigationPanel.value = false
outdoorArrivalStage.value = 'browse'
arrivalActiveType.value = null
arrivalTargets.value = []
selectedArrivalTarget.value = null
arrivalTargetsError.value = ''
arrivalTargetsLoading.value = false
outdoorStartSearchResults.value = []
clearOutdoorRoute()
isBuildingGuideActive.value = true
guideOutdoorState.value = 'home'
}
const clearOutdoorRoute = () => {
outdoorStartLocationRequestId += 1
outdoorRoutePlanRequestId += 1
outdoorStartSearchRequestId += 1
outdoorManualReverseGeocodeRequestId += 1
outdoorRoute.value = null
outdoorRouteError.value = ''
outdoorRouteStart.value = null
outdoorRouteStartPhase.value = 'locating'
outdoorLocationMessage.value = ''
outdoorLocationHelpVisible.value = false
outdoorStartSearchResults.value = []
outdoorStartSearchLoading.value = false
outdoorRouteLoading.value = false
}
const planOutdoorRouteFromStart = async (start: { latitude: number; longitude: number; name?: string }) => {
const destination = selectedArrivalTarget.value
if (!destination) {
outdoorRouteError.value = '请先选择目的地'
return
}
const requestId = ++outdoorRoutePlanRequestId
outdoorRouteLoading.value = true
outdoorRouteError.value = ''
outdoorRoute.value = null
try {
const route = await planOutdoorRoute({
from: start,
to: destination,
mode: outdoorTravelMode.value.toUpperCase() as 'DRIVING' | 'WALKING' | 'TRANSIT'
})
if (requestId !== outdoorRoutePlanRequestId) return
outdoorRoute.value = route
} catch (error) {
if (requestId !== outdoorRoutePlanRequestId) return
outdoorRouteError.value = error instanceof Error ? error.message : '路线规划失败,请稍后重试'
} finally {
if (requestId === outdoorRoutePlanRequestId) outdoorRouteLoading.value = false
}
}
const messageForOutdoorLocationFailure = (error: unknown) => {
if (error instanceof BrowserLocationError) {
if (error.reason === 'denied') {
return '请在微信小程序的设置中开启位置权限后重试'
}
return error.message
}
return '暂时无法获取当前位置,请手动选择起点'
}
const requestOutdoorCurrentLocation = async () => {
const requestId = ++outdoorStartLocationRequestId
outdoorRoutePlanRequestId += 1
outdoorManualReverseGeocodeRequestId += 1
outdoorRouteStartPhase.value = 'locating'
outdoorLocationMessage.value = ''
outdoorLocationHelpVisible.value = false
outdoorRouteStart.value = null
outdoorRouteError.value = ''
outdoorRoute.value = null
outdoorRouteLoading.value = false
try {
const location = await getBrowserGcjLocation()
if (requestId !== outdoorStartLocationRequestId) return
outdoorRouteStart.value = {
latitude: location.latitude,
longitude: location.longitude,
name: '我的位置'
}
outdoorRouteStartPhase.value = 'route'
await planOutdoorRouteFromStart(outdoorRouteStart.value)
} catch (error) {
if (requestId !== outdoorStartLocationRequestId) return
outdoorLocationMessage.value = messageForOutdoorLocationFailure(error)
outdoorRouteStartPhase.value = 'permission-needed'
} finally {
if (requestId === outdoorStartLocationRequestId) outdoorRouteLoading.value = false
}
}
const handleOutdoorLocationRetry = async () => {
const permissionState = await getBrowserLocationPermissionState()
if (permissionState === 'denied') {
outdoorLocationHelpVisible.value = true
outdoorLocationMessage.value = '当前位置权限已关闭'
return
}
void requestOutdoorCurrentLocation()
}
const handleOutdoorManualSearchRequest = () => {
outdoorStartLocationRequestId += 1
outdoorRoutePlanRequestId += 1
outdoorStartSearchRequestId += 1
outdoorManualReverseGeocodeRequestId += 1
outdoorRouteStartPhase.value = 'manual-search'
outdoorLocationMessage.value = ''
outdoorLocationHelpVisible.value = false
outdoorRouteError.value = ''
outdoorRoute.value = null
outdoorRouteLoading.value = false
outdoorStartSearchResults.value = []
}
const handleOutdoorManualMapRequest = () => {
outdoorStartLocationRequestId += 1
outdoorRoutePlanRequestId += 1
outdoorStartSearchRequestId += 1
outdoorManualReverseGeocodeRequestId += 1
outdoorRouteStartPhase.value = 'manual-map'
outdoorLocationMessage.value = ''
outdoorLocationHelpVisible.value = false
outdoorRouteError.value = ''
outdoorRoute.value = null
outdoorRouteLoading.value = false
outdoorStartSearchResults.value = []
}
const handleOutdoorManualStartSearch = async (keyword: string) => {
const requestId = ++outdoorStartSearchRequestId
if (!keyword.trim()) {
outdoorStartSearchResults.value = []
outdoorStartSearchLoading.value = false
return
}
outdoorStartSearchLoading.value = true
try {
const response = await suggestOutdoorPlaces({
keyword,
region: ARRIVAL_SEARCH_REGION,
pageSize: 6
})
if (requestId !== outdoorStartSearchRequestId) return
outdoorStartSearchResults.value = response.items
} catch (error) {
if (requestId !== outdoorStartSearchRequestId) return
console.warn('手动起点地点检索失败:', error)
outdoorStartSearchResults.value = []
} finally {
if (requestId === outdoorStartSearchRequestId) outdoorStartSearchLoading.value = false
}
}
const handleOutdoorManualStartConfirm = (location: {
latitude: number
longitude: number
name?: string
title?: string
}) => {
outdoorStartLocationRequestId += 1
outdoorRoutePlanRequestId += 1
outdoorStartSearchRequestId += 1
outdoorManualReverseGeocodeRequestId += 1
outdoorStartSearchResults.value = []
const start = {
latitude: location.latitude,
longitude: location.longitude,
name: location.name || location.title || '手动选择起点'
}
outdoorRouteStart.value = start
outdoorLocationMessage.value = ''
outdoorLocationHelpVisible.value = false
outdoorRouteError.value = ''
outdoorRoute.value = null
outdoorRouteLoading.value = false
outdoorRouteStartPhase.value = 'confirm-start'
}
const resolveOutdoorManualMapStartName = async (
location: { latitude: number; longitude: number },
requestId: number
) => {
try {
const address = await reverseGeocodeOutdoorLocation(location)
if (requestId !== outdoorManualReverseGeocodeRequestId) return
const start = outdoorRouteStart.value
if (
!start
|| outdoorRouteStartPhase.value !== 'confirm-start'
|| start.latitude !== location.latitude
|| start.longitude !== location.longitude
) return
outdoorRouteStart.value = {
...start,
name: address.address?.trim() || start.name
}
} catch (error) {
// The coordinate remains usable when the optional address lookup fails.
console.warn('地图选点地址解析失败:', error)
}
}
const handleOutdoorMapTap = (location: { latitude: number; longitude: number }) => {
if (
outdoorArrivalStage.value !== 'route'
|| outdoorRouteStartPhase.value !== 'manual-map'
|| isBuildingGuideActive.value
) return
handleOutdoorManualStartConfirm({ ...location, name: '地图选点' })
const requestId = outdoorManualReverseGeocodeRequestId
void resolveOutdoorManualMapStartName(location, requestId)
}
const handleOutdoorRouteStartConfirm = () => {
if (!outdoorRouteStart.value) {
void requestOutdoorCurrentLocation()
return
}
outdoorRouteStartPhase.value = 'route'
void planOutdoorRouteFromStart(outdoorRouteStart.value)
}
const handleOutdoorRouteRetry = () => {
if (!outdoorRouteStart.value) return
void planOutdoorRouteFromStart(outdoorRouteStart.value)
}
const handleOutdoorMarkerClick = (markerId: string) => {
if (markerId === 'outdoor-route-start') {
if (outdoorArrivalStage.value === 'route') outdoorRouteStartPhase.value = 'confirm-start'
return
}
if (outdoorArrivalStage.value === 'route') return
const target = arrivalTargets.value.find((item) => item.id === markerId)
if (target) handleArrivalTargetSelect(target)
}
const closeHomeSearchDock = () => {
@@ -1868,26 +2410,24 @@ onShow(async () => {
await homeSearchPanelRef.value?.restoreResultListScroll?.()
})
const handleMoreOutdoorNav = () => {
if (!homeArrivalFeatureEnabled) return
if (showArrivalPanel.value && showArrivalPanelCollapsed.value) {
arrivalPanelRef.value?.expandPanel()
showArrivalPanelCollapsed.value = false
return
const handleMoreOutdoorNav = () => {
if (!homeArrivalFeatureEnabled.value) return
showOutdoorNavigationPanel.value = true
outdoorArrivalStage.value = 'browse'
clearOutdoorRoute()
arrivalActiveType.value = null
arrivalTargets.value = []
arrivalTargetsError.value = ''
selectedArrivalTarget.value = null
showRoutePlanner.value = false
isBuildingGuideActive.value = false
guideOutdoorState.value = 'home'
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
isSimulatingRoute.value = false
closeHomeSearchDock()
}
resetArrivalPanelState()
showArrivalPanel.value = true
showArrivalPanelCollapsed.value = false
showRoutePlanner.value = false
isBuildingGuideActive.value = false
guideOutdoorState.value = 'home'
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
isSimulatingRoute.value = false
closeHomeSearchDock()
}
const handleExplainQuickTap = () => {
handleTabChange('explain')
}
@@ -2238,13 +2778,15 @@ const handleExplainBack = () => {
.guide-quick-action {
width: 46px;
min-height: 58px;
padding: 7px 4px 6px;
height: 46px;
min-height: 46px;
flex: 0 0 46px;
padding: 2px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
gap: 1px;
box-sizing: border-box;
background: rgba(245, 245, 237, 0.94);
border: 1px solid rgba(255, 255, 255, 0.72);
@@ -2262,7 +2804,6 @@ const handleExplainBack = () => {
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 {
@@ -2352,7 +2893,7 @@ const handleExplainBack = () => {
.guide-quick-action-text {
width: 100%;
font-size: 12px;
line-height: 16px;
line-height: 14px;
font-weight: 700;
color: #151713;
text-align: center;
@@ -2363,14 +2904,9 @@ const handleExplainBack = () => {
color: #0a0b08;
}
@keyframes guide-quick-active-in {
from {
transform: scale(0.96);
}
to {
transform: scale(1);
}
.guide-quick-actions.is-outdoor {
right: 18px;
width: 46px;
}
.route-sim-step-bar {