同步移动端源码并修复小程序嵌入标题
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

@@ -1,11 +1,11 @@
<template>
<view class="guide-stop-catalog" :class="{ 'host-navigation': shouldUseHostNavigation, 'has-embedded-back': showEmbeddedBack }">
<view v-if="showInternalHeader || showEmbeddedBack" class="catalog-header">
<view v-if="showInternalBack" class="header-back" @tap="emit('back')">
<view class="guide-stop-catalog" :class="{ 'host-navigation': shouldUseHostNavigation }">
<view v-if="showInternalHeader" class="catalog-header">
<view class="header-back" @tap="emit('back')">
<text class="header-back-icon" />
<text class="header-back-text">返回</text>
</view>
<text v-if="showInternalHeader" class="header-title">{{ selectedHallName || '讲解对象' }}</text>
<text class="header-title">{{ selectedHallName || '讲解对象' }}</text>
</view>
<scroll-view class="explain-scroll" scroll-y :show-scrollbar="false" @scroll="handleScroll" @scrolltolower="requestMore">
@@ -71,18 +71,16 @@ export interface ExplainGuideStopCatalogItem {
const props = withDefaults(defineProps<{
guideStops?: ExplainGuideStopCatalogItem[]
selectedHallName?: string
showEmbeddedBack?: boolean
loading?: boolean
loadingMore?: boolean
hasMore?: boolean
error?: string
loadMoreError?: string
}>(), { guideStops: () => [], selectedHallName: '', showEmbeddedBack: false, loading: false, loadingMore: false, hasMore: false, error: '', loadMoreError: '' })
}>(), { guideStops: () => [], selectedHallName: '', loading: false, loadingMore: false, hasMore: false, error: '', loadMoreError: '' })
const emit = defineEmits<{ guideStopClick: [stop: ExplainGuideStopCatalogItem], back: [], retry: [], retryMore: [] }>()
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
const showInternalHeader = computed(() => !shouldUseHostNavigation.value)
const showInternalBack = computed(() => !shouldUseHostNavigation.value || props.showEmbeddedBack)
const failedImageIds = ref(new Set<string>())
const visibleCoverIds = ref(new Set<string>())
const coverSlots = new Map<string, Element>()
@@ -157,7 +155,7 @@ const handleScroll = (event: Event) => { const target = event.target as HTMLElem
.catalog-header { position: absolute; z-index: 2; top: 0; left: 0; right: 0; height: 64px; display: flex; align-items: center; justify-content: center; background: #f7f9f2; }
.header-back { position: absolute; left: 12px; top: 10px; width: 44px; height: 44px; display: flex; align-items: center; }.header-back-icon { width: 20px; height: 20px; position: relative; } .header-back-icon::before { content: ''; position: absolute; left: 3px; top: 4px; width: 10px; height: 10px; border-left: 1.5px solid #262421; border-bottom: 1.5px solid #262421; transform: rotate(45deg); }
.header-back-text { display: none; }.header-title { max-width: calc(100% - 152px); padding: 0 8px; font-size: 18px; line-height: 26px; font-weight: 700; color: #262421; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.explain-scroll { height: 100%; padding: 80px 16px calc(16px + env(safe-area-inset-bottom)); box-sizing: border-box; background: #f7f9f2; }.host-navigation .explain-scroll { padding-top: 16px; }.host-navigation.has-embedded-back .explain-scroll { padding-top: 80px; }
.explain-scroll { height: 100%; padding: 80px 16px calc(16px + env(safe-area-inset-bottom)); box-sizing: border-box; background: #f7f9f2; }.host-navigation .explain-scroll { padding-top: 16px; }
.stop-list { display: flex; width: 100%; min-width: 0; flex-direction: column; gap: 4px; }.stop-card { width: 100%; min-width: 0; height: 80px; padding: 8px 0; display: flex; align-items: center; gap: 12px; box-sizing: border-box; overflow: hidden; border: 0; border-bottom: 1px solid #dbded4; border-radius: 0; background: transparent; }.stop-card:active { background: rgba(227, 235, 201, 0.4); }.stop-cover { flex: 0 0 64px; width: 64px; height: 64px; overflow: hidden; border-radius: 6px; background: #f5f5ed; }.stop-cover-fallback { font-size: 22px; line-height: 28px; color: #a9b29b; }.stop-copy { min-width: 0; display: flex; flex: 1; overflow: hidden; }.stop-name { display: -webkit-box; max-height: 40px; font-size: 14px; line-height: 20px; font-weight: 700; color: #262421; overflow: hidden; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.stop-status { flex: 0 0 44px; width: 44px; height: 24px; display: flex; align-items: center; justify-content: center; border-radius: 12px; background: #e3ebc9; }.stop-status-text { font-size: 11px; line-height: 16px; color: #262421; white-space: nowrap; }
.state-block { margin: 34px 0; padding: 22px 16px; box-sizing: border-box; text-align: center; border: 1px solid #e4e6df; border-radius: 8px; background: #fff; }.state-block.error { border-color: #e5c2c2; background: #fff7f7; }.state-title,.state-desc { display: block; }.state-title { font-size: 15px; line-height: 21px; font-weight: 700; color: #141412; }.state-desc { margin-top: 6px; font-size: 12px; line-height: 18px; color: #68725d; }.state-retry { margin-top: 12px; padding: 0 14px; font-size: 13px; line-height: 32px; color: #141412; background: #e0df00; border: 0; border-radius: 6px; }.load-more-state { min-height: 52px; padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); text-align: center; }
</style>

View File

@@ -53,13 +53,19 @@
v-if="!isHallPreviewError(hall.id)"
class="hall-full-card-image"
:src="hallPreviewUrl(hall)"
mode="aspectFill"
mode="aspectFit"
:lazy-load="isHallPreviewLazy(index)"
@load="handleHallPreviewLoad(hall.id)"
@error="handleHallPreviewError(hall.id)"
/>
<view v-else class="hall-full-card-fallback">
<text class="hall-name">{{ hall.name }}</text>
<view v-if="isHallPreviewError(hall.id)" class="hall-full-card-fallback">
<text class="hall-thumb-text">{{ hallIconText(hall.name) }}</text>
</view>
<view class="hall-full-card-copy">
<text class="hall-full-card-name">{{ hall.name }}</text>
<text v-if="hallCardTheme(hall).englishName" class="hall-full-card-english">
{{ hallCardTheme(hall).englishName }}
</text>
</view>
</view>
<template v-else>
@@ -253,7 +259,7 @@ const emit = defineEmits<{
retryMore: []
}>()
const HALL_PREVIEW_BASE = '/static/icons/halls/custom-cards'
const HALL_PREVIEW_BASE = '/static/icons/halls/portal-icons'
const HALL_PREVIEW_EAGER_COUNT = 4
const loadedHallPreviewIds = ref(new Set<string>())
const failedHallPreviewIds = ref(new Set<string>())
@@ -271,15 +277,15 @@ const hallPreviewMap: Record<string, string> = {
}
const hallCardThemeMap: Record<string, { color: string; englishName: string }> = {
宇宙厅: { color: '#65d2e4', englishName: '' },
地球厅: { color: '#b8e51b', englishName: '' },
演化厅: { color: '#cba7ff', englishName: '' },
恐龙厅: { color: '#d9b06c', englishName: '' },
人类厅: { color: '#e9ea16', englishName: '' },
动物厅: { color: '#20c9c0', englishName: '' },
生物厅: { color: '#20c9c0', englishName: '' },
生态厅: { color: '#94e7be', englishName: '' },
家园厅: { color: '#ffc6a5', englishName: '' }
宇宙厅: { color: '#5ed0e4', englishName: 'Cosmos Hall' },
地球厅: { color: '#94d40b', englishName: 'Earth Hall' },
演化厅: { color: '#bc9afb', englishName: 'Evolution Hall' },
恐龙厅: { color: '#bb965a', englishName: 'Dinosaur Hall' },
人类厅: { color: '#e0e100', englishName: 'Human Hall' },
动物厅: { color: '#00c3b3', englishName: 'Biology Hall' },
生物厅: { color: '#00c3b3', englishName: 'Biology Hall' },
生态厅: { color: '#87e0b0', englishName: 'Ecology Hall' },
家园厅: { color: '#ffbe9e', englishName: 'Homeland Hall' }
}
const filteredHalls = computed(() => props.halls)
@@ -935,9 +941,13 @@ const handleBack = () => {
}
.hall-full-card-image {
position: absolute;
left: 50%;
top: 12px;
display: block;
width: 100%;
height: 100%;
width: 78%;
height: calc(100% - 66px);
transform: translateX(-50%);
}
.hall-full-card-fallback {
@@ -945,10 +955,47 @@ const handleBack = () => {
inset: 0;
display: flex;
align-items: center;
padding: 24px;
justify-content: center;
padding-bottom: 46px;
box-sizing: border-box;
}
.hall-full-card-copy {
position: absolute;
left: 8px;
right: 8px;
bottom: 9px;
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
}
.hall-full-card-name,
.hall-full-card-english {
display: block;
max-width: 100%;
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.hall-full-card-name {
font-size: 19px;
line-height: 25px;
font-weight: 500;
color: #151713;
}
.hall-full-card-english {
margin-top: 0;
font-size: 12px;
line-height: 16px;
font-weight: 300;
color: rgba(21, 23, 19, 0.72);
}
.hall-overview-copy {
top: 24px;
}

View File

@@ -0,0 +1,608 @@
<template>
<view class="tencent-js-map">
<view ref="mapElementRef" class="tencent-js-map-canvas"></view>
<view v-if="mapLoadState !== 'ready'" class="tencent-js-map-status">
<view v-if="mapLoadState === 'loading'" class="map-loading-indicator"></view>
<text class="map-status-text">{{ mapLoadState === 'error' ? '来馆地图加载失败' : '正在加载来馆地图' }}</text>
<view v-if="mapLoadState === 'error'" class="map-retry-button" @tap="retryMapLoad">
<text class="map-retry-text">重新加载</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
canEnterBuildingSemanticMap,
OUTDOOR_BUILDING_EXIT_ZOOM
} from '@/domain/guideSemanticZoom'
import { MUSEUM_LOCATION } from '@/config/museum'
import { getOutdoorMapProfile } from '@/services/outdoor/OutdoorMapAppService'
import {
loadTencentJsMapScript,
type TencentLatLng,
type TencentMapApi,
type TencentMapInstance
} from '@/services/outdoor/TencentJsMapLoader'
interface Coordinate {
latitude: number
longitude: number
}
interface OutdoorMapMarker extends Coordinate {
id: string
title: string
subtitle?: string
markerKind?: 'bus' | 'metro' | 'parking' | 'museum' | 'user'
}
interface OutdoorMapPolyline {
points: Coordinate[]
color: string
width: number
}
interface TencentMapEvent {
latLng?: TencentLatLng
geometry?: { id?: string | number }
}
interface TencentLayer {
setGeometries: (geometries: unknown[]) => void
setMap?: (map: TencentMapInstance | null) => void
on?: (event: string, listener: (event: TencentMapEvent) => void) => void
}
const props = withDefaults(defineProps<{
polylines?: OutdoorMapPolyline[]
outdoorMarkers?: OutdoorMapMarker[]
activeOutdoorMarkerId?: string
outdoorFocusOffsetY?: number
semanticZoomEnabled?: boolean
}>(), {
polylines: () => [],
outdoorMarkers: () => [],
activeOutdoorMarkerId: '',
outdoorFocusOffsetY: 0,
semanticZoomEnabled: false
})
const emit = defineEmits<{
outdoorMarkerClick: [markerId: string]
mapTap: [location: Coordinate]
semanticEnterBuilding: [viewport: Coordinate & { zoom: number }]
unavailable: [message: string]
}>()
const MUSEUM_CENTER: Coordinate = MUSEUM_LOCATION
const USER_MARKER_ID = 'outdoor-user-location'
const mapElementRef = ref<unknown>(null)
const userLocation = ref<Coordinate | null>(null)
const mapLoadState = ref<'loading' | 'ready' | 'error'>('loading')
let map: TencentMapInstance | null = null
let mapApi: TencentMapApi | null = null
let markerLayer: TencentLayer | null = null
let polylineLayer: TencentLayer | null = null
let mapReady = false
let activeInitializationId = 0
let destroyed = false
let semanticViewportTimer: ReturnType<typeof setTimeout> | null = null
let museumCenteredZoomTimer: ReturnType<typeof setTimeout> | null = null
let routeViewportTimer: ReturnType<typeof setTimeout> | null = null
let museumCenteredZoomRequestId = 0
let semanticBuildingRequested = false
const activeMarker = computed(() => props.outdoorMarkers.find((marker) => marker.id === props.activeOutdoorMarkerId) || null)
const resolveMapContainer = () => {
const refValue = mapElementRef.value
if (refValue instanceof HTMLElement) return refValue
// uni-app H5 may return the built-in <view> component proxy instead of its
// native element. Tencent JS Map requires a real HTMLElement container.
if (refValue && typeof refValue === 'object' && '$el' in refValue) {
const element = (refValue as { $el?: unknown }).$el
if (element instanceof HTMLElement) return element
}
return null
}
const waitForMapContainerLayout = async (container: HTMLElement) => {
await nextTick()
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))
if (container.clientWidth <= 0 || container.clientHeight <= 0) {
throw new Error('腾讯地图容器尚未完成布局')
}
}
const isValidCoordinate = (coordinate: Coordinate) => {
const latitude = Number(coordinate.latitude)
const longitude = Number(coordinate.longitude)
return Number.isFinite(latitude)
&& Number.isFinite(longitude)
&& latitude >= -90
&& latitude <= 90
&& longitude >= -180
&& longitude <= 180
}
const getCoordinateValue = (location: TencentLatLng, key: 'latitude' | 'longitude') => {
if (key === 'latitude') return location.getLat?.() ?? location.lat
return location.getLng?.() ?? location.lng
}
const clearSemanticViewportCheck = () => {
if (semanticViewportTimer) {
clearTimeout(semanticViewportTimer)
semanticViewportTimer = null
}
}
const clearMuseumCenteredZoom = () => {
museumCenteredZoomRequestId += 1
if (museumCenteredZoomTimer) {
clearTimeout(museumCenteredZoomTimer)
museumCenteredZoomTimer = null
}
}
const clearRouteViewportFit = () => {
if (routeViewportTimer) {
clearTimeout(routeViewportTimer)
routeViewportTimer = null
}
}
const centerMapOnGuideFocus = () => {
if (!map || !mapReady) return
// Browsing starts at the museum. Once a visitor chooses a transport or
// parking destination, the same controls must keep that destination stable.
map.setCenter(toLatLng(activeMarker.value || MUSEUM_CENTER))
}
const requestSemanticBuildingEntry = (viewportOverride?: Coordinate & { zoom: number }) => {
if (!props.semanticZoomEnabled || semanticBuildingRequested || !map || !mapReady) return
const center = map.getCenter?.()
const zoom = map.getZoom?.()
if (!viewportOverride && (!center || zoom === undefined)) return
const latitude = viewportOverride?.latitude ?? getCoordinateValue(center!, 'latitude')
const longitude = viewportOverride?.longitude ?? getCoordinateValue(center!, 'longitude')
const viewportZoom = viewportOverride?.zoom ?? zoom
if (latitude === undefined || longitude === undefined || viewportZoom === undefined) return
const viewport = { latitude, longitude, zoom: viewportZoom }
if (!canEnterBuildingSemanticMap(viewport, MUSEUM_LOCATION)) return
semanticBuildingRequested = true
emit('semanticEnterBuilding', viewport)
}
const scheduleSemanticBuildingEntry = () => {
clearSemanticViewportCheck()
if (!props.semanticZoomEnabled || semanticBuildingRequested) return
semanticViewportTimer = setTimeout(() => {
semanticViewportTimer = null
requestSemanticBuildingEntry()
}, 140)
}
const toLatLng = (coordinate: Coordinate) => {
if (!mapApi) throw new Error('腾讯 JS 地图尚未就绪')
if (!isValidCoordinate(coordinate)) {
throw new Error('腾讯地图坐标无效')
}
return new mapApi.LatLng(Number(coordinate.latitude), Number(coordinate.longitude))
}
const markerStyle = (src: string, width: number, height: number) => {
if (!mapApi) return null
return new mapApi.MarkerStyle({
width,
height,
anchor: { x: Math.round(width / 2), y: height },
src
})
}
const renderMarkers = () => {
if (!mapApi || !map || !mapReady) return
const candidates = [
...props.outdoorMarkers.map((marker) => ({
id: marker.id,
styleId: marker.markerKind || (marker.id === props.activeOutdoorMarkerId ? 'active' : 'poi'),
coordinate: marker,
properties: { title: marker.title, subtitle: marker.subtitle || '' }
})),
...(userLocation.value ? [{
id: USER_MARKER_ID,
styleId: 'user',
coordinate: userLocation.value,
properties: { title: '我的位置' }
}] : [])
]
const markers = candidates
.filter((marker) => isValidCoordinate(marker.coordinate))
.map((marker) => ({ ...marker, position: toLatLng(marker.coordinate) }))
if (!markerLayer) {
const layer = new mapApi.MultiMarker({
map,
styles: {
poi: markerStyle('/static/icons/marker-location.svg', 30, 36),
active: markerStyle('/static/icons/marker-selected.svg', 32, 32),
museum: markerStyle('/static/icons/marker-selected.svg', 32, 32),
bus: markerStyle('/static/icons/marker-bus.svg', 36, 44),
metro: markerStyle('/static/icons/marker-metro.svg', 36, 44),
parking: markerStyle('/static/icons/marker-parking.svg', 36, 44),
user: markerStyle('/static/icons/marker-user-location.svg', 36, 42)
},
geometries: markers
}) as TencentLayer
markerLayer = layer
layer.on?.('click', (event) => {
const markerId = String(event.geometry?.id || '')
if (props.outdoorMarkers.some((marker) => marker.id === markerId)) {
emit('outdoorMarkerClick', markerId)
}
})
return
}
markerLayer.setGeometries(markers)
}
const renderPolylines = () => {
if (!mapApi || !map || !mapReady) return
const routes = props.polylines
.filter((polyline) => polyline.points.length > 1)
.map((polyline, index) => ({
id: `route-${index}`,
styleId: 'route',
paths: polyline.points.map(toLatLng)
}))
if (!polylineLayer) {
polylineLayer = new mapApi.MultiPolyline({
map,
styles: {
route: new mapApi.PolylineStyle({
color: '#1565C0',
width: 6,
borderWidth: 2,
borderColor: '#FFFFFF',
lineCap: 'round'
})
},
geometries: routes
}) as TencentLayer
return
}
polylineLayer.setGeometries(routes)
}
const longitudeToWorldX = (longitude: number) => (longitude + 180) / 360
const latitudeToWorldY = (latitude: number) => {
const radians = latitude * Math.PI / 180
const sine = Math.min(Math.max(Math.sin(radians), -0.9999), 0.9999)
return 0.5 - Math.log((1 + sine) / (1 - sine)) / (4 * Math.PI)
}
const worldYToLatitude = (worldY: number) => (
Math.atan(Math.sinh(Math.PI * (1 - 2 * worldY))) * 180 / Math.PI
)
const fitRouteInVisibleMapArea = () => {
if (!map || !mapReady || !map.setZoom) return
const points = props.polylines.flatMap((polyline) => polyline.points)
.filter(isValidCoordinate)
if (points.length < 2) return
const mapContainer = resolveMapContainer()
if (!mapContainer || mapContainer.clientWidth <= 0 || mapContainer.clientHeight <= 0) return
const longitudes = points.map((point) => point.longitude)
const worldYs = points.map((point) => latitudeToWorldY(point.latitude))
const minLongitude = Math.min(...longitudes)
const maxLongitude = Math.max(...longitudes)
const minWorldY = Math.min(...worldYs)
const maxWorldY = Math.max(...worldYs)
const longitudeSpan = Math.max(longitudeToWorldX(maxLongitude) - longitudeToWorldX(minLongitude), 1e-6)
const latitudeSpan = Math.max(maxWorldY - minWorldY, 1e-6)
const usableWidth = Math.max(mapContainer.clientWidth * 0.82, 1)
// The route panel covers the lower part of the map. Fit the path into the
// remaining upper canvas so both start and destination stay visible.
const usableHeight = Math.max(
mapContainer.clientHeight - Math.max(props.outdoorFocusOffsetY, 0) * 1.65,
mapContainer.clientHeight * 0.42
)
const zoom = Math.max(4, Math.min(18, Math.floor(Math.min(
Math.log2(usableWidth / (256 * longitudeSpan)),
Math.log2(usableHeight * 0.84 / (256 * latitudeSpan))
))))
const center: Coordinate = {
latitude: worldYToLatitude((minWorldY + maxWorldY) / 2),
longitude: (minLongitude + maxLongitude) / 2
}
clearMuseumCenteredZoom()
map.setCenter(toLatLng(center))
map.setZoom(zoom)
clearRouteViewportFit()
routeViewportTimer = setTimeout(() => {
routeViewportTimer = null
if (!map || !mapReady) return
map.setCenter(toLatLng(center))
if (props.outdoorFocusOffsetY > 0) {
map.panBy?.({ x: 0, y: Math.min(props.outdoorFocusOffsetY, mapContainer.clientHeight * 0.22) })
}
}, 260)
}
const centerInVisibleMapArea = (coordinate: Coordinate, offsetY = props.outdoorFocusOffsetY) => {
if (!map || !mapReady || !isValidCoordinate(coordinate)) return
map.setCenter(toLatLng(coordinate))
if (!offsetY || !map.panBy) return
requestAnimationFrame(() => {
if (!map || !mapReady) return
// Keep the focused point above the arrival panel instead of under it.
map.panBy?.({ x: 0, y: Math.abs(offsetY) })
})
}
const syncActiveFocus = () => {
if (!map || !mapReady || !activeMarker.value || !isValidCoordinate(activeMarker.value)) return
centerInVisibleMapArea(activeMarker.value)
}
const updateUserLocation = (location: Coordinate) => {
if (!isValidCoordinate(location)) return
userLocation.value = location
renderMarkers()
centerInVisibleMapArea(location)
}
const clearUserLocation = () => {
userLocation.value = null
renderMarkers()
}
const moveTo = (location: Coordinate) => {
if (!isValidCoordinate(location)) return
centerInVisibleMapArea(location)
}
const zoom = (direction: 'in' | 'out') => {
if (!map || !mapReady || !map.getZoom || !map.setZoom) return
const nextZoom = Math.max(4, Math.min(20, map.getZoom() + (direction === 'in' ? 1 : -1)))
clearMuseumCenteredZoom()
const requestId = museumCenteredZoomRequestId
// The guide controls are museum-centric. Tencent GL completes a zoom after
// setZoom returns, so reapply the center after that animation rather than
// trusting its transient event viewport. Pan and pinch remain unrestricted.
centerMapOnGuideFocus()
map.setZoom(nextZoom)
requestAnimationFrame(() => {
if (!map || !mapReady || requestId !== museumCenteredZoomRequestId) return
centerMapOnGuideFocus()
museumCenteredZoomTimer = setTimeout(() => {
museumCenteredZoomTimer = null
if (!map || !mapReady || requestId !== museumCenteredZoomRequestId) return
centerMapOnGuideFocus()
// Route to the building only when the actual post-zoom viewport is
// around the museum. A selected bus stop or parking lot must stay on
// the outdoor map even if the visitor uses the shared zoom controls.
requestSemanticBuildingEntry()
}, 260)
})
}
defineExpose({ updateUserLocation, clearUserLocation, moveTo, zoom })
watch(
() => [props.outdoorMarkers, props.activeOutdoorMarkerId, userLocation.value] as const,
() => {
renderMarkers()
syncActiveFocus()
},
{ deep: true }
)
watch(
() => props.polylines,
() => {
renderPolylines()
fitRouteInVisibleMapArea()
},
{ deep: true }
)
watch(
() => props.outdoorFocusOffsetY,
syncActiveFocus
)
watch(
() => props.semanticZoomEnabled,
(enabled) => {
if (!enabled) {
semanticBuildingRequested = false
clearSemanticViewportCheck()
}
}
)
const clearMapInstance = () => {
clearSemanticViewportCheck()
clearMuseumCenteredZoom()
clearRouteViewportFit()
semanticBuildingRequested = false
mapReady = false
markerLayer?.setMap?.(null)
polylineLayer?.setMap?.(null)
markerLayer = null
polylineLayer = null
map?.destroy()
map = null
mapApi = null
}
const initializeMap = async () => {
const initializationId = ++activeInitializationId
mapLoadState.value = 'loading'
clearMapInstance()
try {
const profile = await getOutdoorMapProfile()
const mapContainer = resolveMapContainer()
if (!profile.available || !profile.jsMapEnabled || !profile.jsMapKey || !mapContainer) {
throw new Error(profile.message || '来馆地图服务暂不可用')
}
const loadedMapApi = await loadTencentJsMapScript(profile.jsMapKey)
if (destroyed || initializationId !== activeInitializationId) return
mapApi = loadedMapApi
await waitForMapContainerLayout(mapContainer)
if (destroyed || initializationId !== activeInitializationId) return
map = new mapApi.Map(mapContainer, {
center: toLatLng(MUSEUM_CENTER),
zoom: OUTDOOR_BUILDING_EXIT_ZOOM,
zoomControl: false,
rotationControl: false,
scaleControl: false,
mapTypeControl: false
})
map.on('click', (event) => {
if (!event.latLng) return
const latitude = getCoordinateValue(event.latLng, 'latitude')
const longitude = getCoordinateValue(event.latLng, 'longitude')
if (latitude === undefined || longitude === undefined) return
emit('mapTap', { latitude, longitude })
})
map.on('zoom_changed', scheduleSemanticBuildingEntry)
map.on('center_changed', scheduleSemanticBuildingEntry)
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))
if (destroyed || initializationId !== activeInitializationId) return
mapReady = true
centerMapOnGuideFocus()
renderMarkers()
renderPolylines()
syncActiveFocus()
fitRouteInVisibleMapArea()
mapLoadState.value = 'ready'
} catch (error) {
if (destroyed || initializationId !== activeInitializationId) return
clearMapInstance()
mapLoadState.value = 'error'
const message = error instanceof Error ? error.message : '腾讯 JS 地图初始化失败'
console.error('腾讯 JS 地图初始化失败:', error)
emit('unavailable', message)
}
}
const retryMapLoad = () => {
void initializeMap()
}
onMounted(() => {
destroyed = false
void initializeMap()
})
onBeforeUnmount(() => {
destroyed = true
activeInitializationId += 1
clearMapInstance()
})
</script>
<style scoped lang="scss">
.tencent-js-map,
.tencent-js-map-canvas {
width: 100%;
height: 100%;
background: var(--museum-bg-map);
}
.tencent-js-map {
position: relative;
overflow: hidden;
}
// Keep the guide's 46px control stack as the only zoom affordance.
.tencent-js-map :deep(.tmap-scale-control),
.tencent-js-map :deep(.tmap-zoom-control) {
display: none !important;
}
// Tencent JS Map renders its rotation compass independently of the map option.
// The guide keeps north-up, so the control has no user-facing purpose here.
.tencent-js-map :deep(.rotate-circle) {
display: none !important;
}
.tencent-js-map-status {
position: absolute;
inset: 0;
z-index: 4;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
background: rgba(236, 239, 241, 0.94);
}
.map-loading-indicator {
width: 28px;
height: 28px;
box-sizing: border-box;
border: 3px solid rgba(21, 101, 192, 0.2);
border-top-color: var(--museum-accent);
border-radius: 50%;
animation: tencent-map-loading 0.8s linear infinite;
}
.map-status-text {
font-size: 14px;
line-height: 20px;
font-weight: 500;
color: var(--museum-text-secondary);
}
.map-retry-button {
min-width: 88px;
height: 34px;
padding: 0 14px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--museum-accent);
border-radius: var(--radius-button);
background: var(--museum-bg-surface);
}
.map-retry-text {
font-size: 13px;
line-height: 18px;
font-weight: 500;
color: var(--museum-accent);
}
@keyframes tencent-map-loading {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -164,6 +164,10 @@ import {
createGuideObliqueReturnPose,
createGuideTopViewPose
} from '@/domain/guideTopView'
import {
hasReachedOutdoorExitBoundary,
OUTDOOR_OUTWARD_INTENT_GRACE_MS
} from '@/domain/guideSemanticZoom'
import {
getOverviewMapLabelDefinition,
isOverviewMapLabelMatch,
@@ -193,6 +197,7 @@ import {
type PoiVisibilityTier
} from '@/domain/poiDisplay'
import {
ensurePoiIconSprite,
getPoiIconHref,
resolvePoiIconKey,
type PoiIconKey
@@ -306,7 +311,9 @@ const SGS_VISUAL_RENDER_CONFIG = {
framing: {
overviewScreenOffsetRatio: new THREE.Vector2(-0.045, -0.095),
overviewReferenceVerticalOffset: 44,
overviewMaxDistanceFactor: 1.35,
// Keep two to three zoom-out levels of the 3D building overview before
// the semantic boundary hands the visitor to the Tencent outdoor map.
overviewMaxDistanceFactor: 1.85,
// The left floor rail and right zoom tools make the geometric center look
// left-biased. Shift the floor framing right into the visible map area.
floorScreenOffsetRatio: new THREE.Vector2(-0.05, -0.055)
@@ -712,6 +719,7 @@ const props = withDefaults(defineProps<{
autoSwitch?: boolean
disableAutoExit?: boolean
autoSwitchCooldown?: number
semanticZoomEnabled?: boolean
}>(), {
assetBaseUrl: '',
activeFloor: '',
@@ -735,7 +743,8 @@ const props = withDefaults(defineProps<{
sceneViewport: null,
autoSwitch: true,
disableAutoExit: false,
autoSwitchCooldown: 1500
autoSwitchCooldown: 1500,
semanticZoomEnabled: false
})
const emit = defineEmits<{
@@ -760,6 +769,7 @@ const emit = defineEmits<{
sceneRevision: number
}]
autoSwitchBlocked: [reason: { reason: 'loading-locked' | 'cooldown' | 'disabled' }]
semanticExteriorExit: []
initialModelProgress: [event: InitialModelProgressEvent]
initialModelReady: [event: { view: ViewMode; floorId?: string; elapsedMs?: number }]
initialModelFailed: [event: {
@@ -983,6 +993,9 @@ let hasPendingManualModelAdjustment = false
let activeAutoSwitchInputSource: GuideAutoSwitchInputSource = 'gesture'
let isProgrammaticCameraChange = false
let hasActiveUserCameraGesture = false
let overviewGestureStartDistance = 0
let semanticOutwardZoomIntentAt = 0
let semanticExteriorExitRequested = false
let programmaticCameraTimer: ReturnType<typeof setTimeout> | null = null
let cameraTween: CameraTweenState | null = null
let buttonAutoSwitchTimer: ReturnType<typeof setTimeout> | null = null
@@ -2572,6 +2585,7 @@ const handleControlStart = () => {
const distance = controls?.getDistance()
if (!isProgrammaticCameraChange && typeof distance === 'number' && Number.isFinite(distance)) {
hasActiveUserCameraGesture = true
overviewGestureStartDistance = activeView.value === 'overview' ? distance : 0
autoSwitchStateMachine.beginInput(distance, activeAutoSwitchInputSource)
}
if (!isProgrammaticCameraChange) {
@@ -2582,7 +2596,9 @@ const handleControlStart = () => {
}
const handleControlEnd = () => {
requestSemanticExteriorExitFromGesture()
hasActiveUserCameraGesture = false
overviewGestureStartDistance = 0
activeAutoSwitchInputSource = 'gesture'
scheduleModelAdjustIdleReport()
}
@@ -2797,6 +2813,7 @@ const waitForContainer = async () => {
}
const initThree = async () => {
ensurePoiIconSprite()
const container = await waitForContainer()
if (!container) {
throw new Error('3D 容器未就绪')
@@ -5144,9 +5161,46 @@ const handleWheelIntent = (event: WheelEvent) => {
if (!Number.isFinite(distance)) return
activeAutoSwitchInputSource = 'wheel'
if (event.deltaY > 0) semanticOutwardZoomIntentAt = Date.now()
autoSwitchStateMachine.beginInput(distance, 'wheel')
}
const canRequestSemanticExteriorExit = () => (
props.semanticZoomEnabled
&& !semanticExteriorExitRequested
&& !weakNetworkFallbackActive.value
&& !isLoading.value
&& !isProgrammaticCameraChange
&& Boolean(controls)
&& activeView.value === 'overview'
&& !props.showRoute
&& !props.routeNavigationActive
&& !props.routeStartSelectionActive
&& !props.targetFocus
&& !selectedPOI.value
)
const requestSemanticExteriorExit = () => {
if (!canRequestSemanticExteriorExit()) return false
semanticExteriorExitRequested = true
emit('semanticExteriorExit')
return true
}
const requestSemanticExteriorExitFromGesture = () => {
if (!canRequestSemanticExteriorExit() || !controls || !overviewGestureStartDistance) return
const currentDistance = controls.getDistance()
const maxDistance = controls.maxDistance
const outwardGesture = currentDistance > overviewGestureStartDistance * 1.025
const atExteriorBoundary = hasReachedOutdoorExitBoundary(currentDistance, maxDistance)
const recentWheelIntent = Date.now() - semanticOutwardZoomIntentAt < OUTDOOR_OUTWARD_INTENT_GRACE_MS
if (atExteriorBoundary && (outwardGesture || recentWheelIntent)) {
requestSemanticExteriorExit()
}
}
const canRunAutoSwitch = (direction: GuideAutoSwitchDirection) => {
if (
!props.autoSwitch
@@ -6262,15 +6316,24 @@ const handleWeakNetworkZoomIntent = async (event: {
boundaryAttempt: boolean
referenceVisibleWorldSpan: number | null
thresholdReached: boolean
outdoorExitReached: boolean
viewport: GuideViewportState
}) => {
if (
!weakNetworkFallbackActive.value
|| isLoading.value
|| !props.autoSwitch
|| weakNetworkSceneTransitionInFlight
) return
const from = activeView.value
if (
from === 'overview'
&& event.direction === 'out'
&& event.outdoorExitReached
&& requestWeakNetworkSemanticExteriorExit()
) return
if (!props.autoSwitch) return
const shouldEnterFloor = from === 'overview' && event.direction === 'in'
const shouldExitOverview = from === 'floor' && event.direction === 'out'
if (!shouldEnterFloor && !shouldExitOverview) return
@@ -6340,6 +6403,26 @@ const handleWeakNetworkZoomIntent = async (event: {
}
}
const canRequestWeakNetworkSemanticExteriorExit = () => (
props.semanticZoomEnabled
&& !semanticExteriorExitRequested
&& weakNetworkFallbackActive.value
&& !isLoading.value
&& activeView.value === 'overview'
&& !props.showRoute
&& !props.routeNavigationActive
&& !props.routeStartSelectionActive
&& !props.targetFocus
&& !selectedPOI.value
)
const requestWeakNetworkSemanticExteriorExit = () => {
if (!canRequestWeakNetworkSemanticExteriorExit()) return false
semanticExteriorExitRequested = true
emit('semanticExteriorExit')
return true
}
const zoomCamera = (direction: 'in' | 'out', options: { source?: ZoomCameraSource } = {}) => {
if (weakNetworkFallbackActive.value) {
const source = options.source || 'button'
@@ -6369,6 +6452,13 @@ const zoomCamera = (direction: 'in' | 'out', options: { source?: ZoomCameraSourc
if (!Number.isFinite(nextDistance) || nextDistance <= 0) return
if (
direction === 'out'
&& activeView.value === 'overview'
&& hasReachedOutdoorExitBoundary(nextDistance, maxDistance)
&& requestSemanticExteriorExit()
) return
const shouldTrackUserZoom = options.source === 'button'
if (shouldTrackUserZoom) {
trackButtonZoomForAutoSwitch(currentDistance, nextDistance)
@@ -7115,7 +7205,9 @@ const createPoiDomLabelIcon = (iconKey: PoiIconKey) => {
svg.setAttribute('data-poi-icon', iconKey)
const use = document.createElementNS('http://www.w3.org/2000/svg', 'use')
use.setAttribute('href', getPoiIconHref(iconKey))
const iconHref = getPoiIconHref(iconKey)
use.setAttribute('href', iconHref)
use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', iconHref)
svg.appendChild(use)
return svg
}

View File

@@ -110,7 +110,7 @@
>
<view class="weak-network-marker-anchor">
<svg class="weak-network-marker-anchor-icon" viewBox="0 0 32 32" aria-hidden="true">
<use :href="marker.iconHref"></use>
<use :href="marker.iconHref" :xlink:href="marker.iconHref"></use>
</svg>
</view>
<view
@@ -119,7 +119,7 @@
:data-testid="`weak-network-marker-label-${marker.poi.id}`"
>
<svg class="weak-network-marker-label-icon" viewBox="0 0 32 32" aria-hidden="true">
<use :href="marker.iconHref"></use>
<use :href="marker.iconHref" :xlink:href="marker.iconHref"></use>
</svg>
<text class="weak-network-marker-label-title">{{ marker.label }}</text>
</view>
@@ -153,7 +153,12 @@ import {
type AmbientFacilityKind,
type PoiDisplayPolicy
} from '@/domain/poiDisplay'
import { getPoiIconHref, resolvePoiIconKey, type PoiIconKey } from '@/domain/poiCategories'
import {
ensurePoiIconSprite,
getPoiIconHref,
resolvePoiIconKey,
type PoiIconKey
} from '@/domain/poiCategories'
import {
getOverviewMapLabelDefinition,
OVERVIEW_MAP_LABEL_DEFINITIONS
@@ -239,6 +244,10 @@ type SceneView = 'overview' | 'floor'
const MAX_WEAK_MAP_MARKERS = 72
const DEFAULT_VIEWPORT_WIDTH = 390
const DEFAULT_VIEWPORT_HEIGHT = 844
// Exterior WebP maps have a deliberately compact zoom-out range. Reserve the
// final step for the semantic handoff so one accidental zoom-out does not
// immediately replace the indoor exterior with Tencent Map.
const OUTDOOR_MAP_EXIT_VISIBLE_SPAN_RATIO = 0.975
const ROUTE_START_SNAP_RADIUS_PX = 44
const ROUTE_NAVIGATION_ENTRY_MS = 460
const MIN_ROUTE_SEGMENT_DURATION_MS = 6000
@@ -307,6 +316,7 @@ const emit = defineEmits<{
boundaryAttempt: boolean
referenceVisibleWorldSpan: number | null
thresholdReached: boolean
outdoorExitReached: boolean
viewport: GuideViewportState
}]
viewportChange: [event: {
@@ -359,6 +369,7 @@ const updateSurfaceSize = () => {
}
onMounted(() => {
ensurePoiIconSprite()
updateSurfaceSize()
const surfaceElement = getSurfaceElement()
if (typeof ResizeObserver !== 'undefined' && surfaceElement) {
@@ -861,6 +872,20 @@ const referenceVisibleWorldSpan = computed<number | null>(() => {
return Number.isFinite(span) && span > 0 ? span : null
})
const hasReachedOutdoorMapExitBoundary = (viewport: GuideViewportState) => {
if (viewport.scene !== 'overview') return false
const maximumViewport = clampGuideViewport({
...viewport,
visibleWorldSpan: Number.MAX_SAFE_INTEGER
}, activeWorldBounds.value, surfaceSize.value)
const maximumVisibleWorldSpan = maximumViewport.visibleWorldSpan
return Number.isFinite(maximumVisibleWorldSpan)
&& maximumVisibleWorldSpan > 0
&& viewport.visibleWorldSpan >= maximumVisibleWorldSpan * OUTDOOR_MAP_EXIT_VISIBLE_SPAN_RATIO
}
const poiVisibilityTier = computed(() => getPoiVisibilityTier(
referenceVisibleWorldSpan.value
? (localViewport.value?.visibleWorldSpan || referenceVisibleWorldSpan.value)
@@ -1053,6 +1078,7 @@ const emitZoomIntent = (
currentVisibleWorldSpan: viewport.visibleWorldSpan,
referenceVisibleWorldSpan: referenceSpan
}),
outdoorExitReached: direction === 'out' && hasReachedOutdoorMapExitBoundary(viewport),
viewport
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,7 @@
:scene-revision="sceneRevision"
:scene-viewport="sceneViewport"
:disable-auto-exit="disableAutoExit"
:semantic-zoom-enabled="semanticZoomEnabled"
@floor-change="handleThreeFloorChange"
@poi-click="handlePoiClick"
@route-start-candidate="handleRouteStartCandidate"
@@ -38,6 +39,7 @@
@selection-clear="handleSelectionClear"
@target-focus="handleTargetFocus"
@auto-switch="handleAutoSwitch"
@semantic-exterior-exit="handleSemanticExteriorExit"
@initial-model-progress="handleInitialModelProgress"
@initial-model-ready="handleInitialModelReady"
@initial-model-failed="handleInitialModelFailed"
@@ -53,6 +55,22 @@
<!-- #endif -->
</template>
<view v-else class="outdoor-map-bg">
<!-- #ifdef H5 -->
<TencentJsMap
ref="outdoorRendererRef"
class="outdoor-map"
:polylines="outdoorNavPolylines"
:outdoor-markers="outdoorMarkers"
:active-outdoor-marker-id="activeOutdoorMarkerId"
:outdoor-focus-offset-y="outdoorFocusOffsetY"
:semantic-zoom-enabled="semanticZoomEnabled"
@outdoor-marker-click="handleOutdoorMarkerClick"
@map-tap="handleMapTap"
@semantic-enter-building="handleSemanticEnterBuilding"
@unavailable="emit('outdoorUnavailable', $event)"
/>
<!-- #endif -->
<!-- #ifndef H5 -->
<TencentMap
class="outdoor-map"
:active-floor="activeFloor"
@@ -64,6 +82,7 @@
@outdoor-marker-click="handleOutdoorMarkerClick"
@map-tap="handleMapTap"
/>
<!-- #endif -->
</view>
<view v-if="dimmed" class="dim-layer"></view>
</view>
@@ -275,6 +294,7 @@
import { computed, ref, watch } from 'vue'
import TencentMap from '@/components/map/TencentMap.vue'
// #ifdef H5
import TencentJsMap from '@/components/map/TencentJsMap.vue'
import ThreeMap from '@/components/map/ThreeMap.vue'
import type { ResetViewBaselineResult } from '@/components/map/ThreeMap.vue'
// #endif
@@ -317,6 +337,7 @@ interface OutdoorMapMarker {
subtitle?: string
latitude: number
longitude: number
markerKind?: 'bus' | 'metro' | 'parking' | 'museum' | 'user'
}
type CameraViewMode = 'reset' | 'top' | 'oblique'
@@ -412,6 +433,7 @@ const props = withDefaults(defineProps<{
outdoorMarkers?: OutdoorMapMarker[]
activeOutdoorMarkerId?: string
outdoorFocusOffsetY?: number
semanticZoomEnabled?: boolean
}>(), {
searchText: '请输入地点进行搜索',
activeMode: '3d',
@@ -470,7 +492,8 @@ const props = withDefaults(defineProps<{
outdoorNavPolylines: () => [] as OutdoorNavPolyline[],
outdoorMarkers: () => [] as OutdoorMapMarker[],
activeOutdoorMarkerId: '',
outdoorFocusOffsetY: 0
outdoorFocusOffsetY: 0,
semanticZoomEnabled: false
})
const emit = defineEmits<{
@@ -523,6 +546,9 @@ const emit = defineEmits<{
}]
mapTap: [location: { latitude: number; longitude: number }]
outdoorMarkerClick: [markerId: string]
semanticEnterBuilding: [viewport: { latitude: number; longitude: number; zoom: number }]
semanticExitOutdoor: []
outdoorUnavailable: [message: string]
}>()
// 手动切换与自动切换共用 1500ms 保护,避免操作完成后立即反向跳转。
@@ -560,6 +586,9 @@ const indoorRendererRef = ref<{
disableAutoSwitchTemporarily?: (durationMs: number) => void
getGuideViewportState?: () => GuideViewportState | null
} | null>(null)
const outdoorRendererRef = ref<{
zoom?: (direction: 'in' | 'out') => void
} | null>(null)
const indoorFloors = computed(() => props.floors.filter((floor) => isIndoorNavigableFloor(floor)))
const floorItems = computed(() => indoorFloors.value)
@@ -706,6 +735,16 @@ const handleModeChange = (mode: '2d' | '3d') => {
emit('modeChange', mode)
}
const handleSemanticEnterBuilding = (viewport: { latitude: number; longitude: number; zoom: number }) => {
if (!props.semanticZoomEnabled || props.mapType !== 'outdoor') return
emit('semanticEnterBuilding', viewport)
}
const handleSemanticExteriorExit = () => {
if (!props.semanticZoomEnabled || props.mapType !== 'indoor') return
emit('semanticExitOutdoor')
}
const isStaleFloorSwitchError = (error: unknown) => (
error instanceof Error && error.message === 'STALE_MODEL_LOAD'
)
@@ -934,7 +973,11 @@ const handleZoomClick = (direction: 'in' | 'out') => {
const now = Date.now()
if (now - lastZoomActionAt < 100) return
lastZoomActionAt = now
indoorRendererRef.value?.zoomCamera?.(direction, { source: 'button' })
if (props.mapType === 'indoor') {
indoorRendererRef.value?.zoomCamera?.(direction, { source: 'button' })
} else {
outdoorRendererRef.value?.zoom?.(direction)
}
emit('toolClick', direction === 'in' ? '放大' : '缩小')
}

View File

@@ -0,0 +1,716 @@
<template>
<view v-if="visible" class="outdoor-route-panel">
<view class="outdoor-route-handle"></view>
<view class="outdoor-route-header">
<view class="outdoor-route-heading">
<text class="outdoor-route-title">{{ panelTitle }}</text>
<text v-if="destination" class="outdoor-route-destination">前往 {{ destination.title }}</text>
</view>
<view class="outdoor-route-back" role="button" aria-label="返回目的地详情" @tap="emit('back')">
<view class="outdoor-route-back-icon"></view>
</view>
</view>
<template v-if="phase === 'locating'">
<view class="outdoor-route-state">
<view class="outdoor-route-spinner"></view>
<text class="outdoor-route-state-title">正在确认当前位置</text>
<text class="outdoor-route-state-copy">将以实时位置作为来馆起点</text>
</view>
</template>
<template v-else-if="phase === 'permission-needed'">
<view v-if="locationHelpVisible" class="outdoor-route-permission-help">
<text class="outdoor-route-state-title">请开启位置权限</text>
<text class="outdoor-route-state-copy">请在微信小程序的设置中开启位置权限然后返回此页面重新定位</text>
</view>
<view v-else class="outdoor-route-state location-needed">
<view class="outdoor-route-location-icon"></view>
<text class="outdoor-route-state-title">暂未获取实时位置</text>
<text class="outdoor-route-state-copy">{{ locationMessage || '请开启位置权限,或手动选择起点' }}</text>
</view>
<view class="outdoor-route-actions two-columns">
<view class="outdoor-route-action secondary" @tap="emit('retryLocation')">
<text class="outdoor-route-action-text">重新检测</text>
</view>
<view class="outdoor-route-action primary" @tap="emit('manualStartRequest')">
<text class="outdoor-route-action-text">手动选择起点</text>
</view>
</view>
</template>
<template v-else-if="phase === 'confirm-start'">
<view class="outdoor-route-point-card">
<view class="outdoor-route-point-icon start"></view>
<view class="outdoor-route-point-copy">
<text class="outdoor-route-point-label">起点</text>
<text class="outdoor-route-point-name">{{ startPoint?.name || '当前位置' }}</text>
</view>
</view>
<view class="outdoor-route-actions">
<view class="outdoor-route-action primary full" @tap="emit('confirmStart')">
<text class="outdoor-route-action-text">从这里出发</text>
</view>
<view class="outdoor-route-text-action" @tap="emit('manualStartRequest')">
<text class="outdoor-route-text-action-label">更换起点</text>
</view>
</view>
</template>
<template v-else-if="phase === 'manual-search'">
<view class="outdoor-route-switches">
<view class="outdoor-route-switch active">
<text class="outdoor-route-switch-text">输入地点</text>
</view>
<view class="outdoor-route-switch" @tap="emit('manualMapRequest')">
<text class="outdoor-route-switch-text">地图选点</text>
</view>
</view>
<view class="outdoor-route-search-field">
<input
class="outdoor-route-search-input"
type="text"
:value="searchKeyword"
placeholder="输入起点地址或地名"
@input="handleSearchInput"
/>
<view v-if="manualSearchLoading" class="outdoor-route-search-loading"></view>
</view>
<scroll-view v-if="manualSearchResults.length" class="outdoor-route-search-results" scroll-y :show-scrollbar="false">
<view
v-for="result in manualSearchResults"
:key="result.id"
class="outdoor-route-search-result"
@tap="emit('manualStartConfirm', result)"
>
<text class="outdoor-route-search-result-title">{{ result.title }}</text>
<text class="outdoor-route-search-result-address">{{ result.address }}</text>
</view>
</scroll-view>
</template>
<template v-else-if="phase === 'manual-map'">
<view class="outdoor-route-switches">
<view class="outdoor-route-switch" @tap="emit('manualStartRequest')">
<text class="outdoor-route-switch-text">输入地点</text>
</view>
<view class="outdoor-route-switch active">
<text class="outdoor-route-switch-text">地图选点</text>
</view>
</view>
<view class="outdoor-route-map-prompt">
<view class="outdoor-route-map-prompt-icon"></view>
<view class="outdoor-route-map-prompt-copy">
<text class="outdoor-route-map-prompt-title">请在地图上点选起点</text>
<text class="outdoor-route-map-prompt-desc">确认位置后即可规划来馆路线</text>
</view>
</view>
</template>
<template v-else>
<view class="outdoor-route-journey">
<view class="outdoor-route-journey-item">
<view class="outdoor-route-point-icon start"></view>
<view class="outdoor-route-point-copy">
<text class="outdoor-route-point-label">起点</text>
<text class="outdoor-route-point-name">{{ startPoint?.name || '当前位置' }}</text>
</view>
</view>
<view class="outdoor-route-journey-line"></view>
<view class="outdoor-route-journey-item">
<view class="outdoor-route-point-icon end"></view>
<view class="outdoor-route-point-copy">
<text class="outdoor-route-point-label">终点</text>
<text class="outdoor-route-point-name">{{ destination?.title || '深圳自然博物馆' }}</text>
</view>
</view>
</view>
<view v-if="loading" class="outdoor-route-result loading">
<view class="outdoor-route-spinner small"></view>
<text class="outdoor-route-result-text">正在规划路线</text>
</view>
<view v-else-if="routeError" class="outdoor-route-result error">
<text class="outdoor-route-result-text">{{ routeError }}</text>
<view class="outdoor-route-retry" @tap="emit('retryRoute')">
<text class="outdoor-route-retry-text">重试</text>
</view>
</view>
<view v-else-if="routeSummary" class="outdoor-route-result">
<text class="outdoor-route-result-text">{{ routeSummary }}</text>
<view class="outdoor-route-status-dot"></view>
<text class="outdoor-route-result-note">路线已显示在地图上</text>
</view>
<view class="outdoor-route-text-action route-change-start" @tap="emit('manualStartRequest')">
<text class="outdoor-route-text-action-label">更换起点</text>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { OutdoorPlace } from '@/services/outdoor/OutdoorMapAppService'
export type OutdoorRouteStartPhase =
| 'locating'
| 'permission-needed'
| 'confirm-start'
| 'manual-search'
| 'manual-map'
| 'route'
const props = withDefaults(defineProps<{
visible?: boolean
phase?: OutdoorRouteStartPhase
locationMessage?: string
locationHelpVisible?: boolean
destination?: { title: string; address?: string; latitude: number; longitude: number } | null
startPoint?: { latitude: number; longitude: number; name?: string } | null
loading?: boolean
routeError?: string
routeDistance?: number
routeDuration?: number
manualSearchResults?: OutdoorPlace[]
manualSearchLoading?: boolean
}>(), {
visible: false,
phase: 'locating',
locationMessage: '',
locationHelpVisible: false,
destination: null,
startPoint: null,
loading: false,
routeError: '',
routeDistance: 0,
routeDuration: 0,
manualSearchResults: () => [],
manualSearchLoading: false
})
const emit = defineEmits<{
back: []
retryLocation: []
manualStartRequest: []
manualMapRequest: []
manualStartSearch: [keyword: string]
manualStartConfirm: [location: OutdoorPlace]
confirmStart: []
retryRoute: []
}>()
const searchKeyword = ref('')
let searchTimer: ReturnType<typeof setTimeout> | null = null
const panelTitle = computed(() => {
if (props.phase === 'route') return '来馆路线'
if (props.phase === 'manual-search' || props.phase === 'manual-map') return '选择起点'
if (props.phase === 'locating' || props.phase === 'permission-needed') return '获取位置'
return '确认起点'
})
const routeSummary = computed(() => {
if (props.routeDistance <= 0) return ''
const distance = props.routeDistance >= 1000
? `${(props.routeDistance / 1000).toFixed(1)} 公里`
: `${Math.round(props.routeDistance)}`
const duration = props.routeDuration > 0
? `${Math.max(1, Math.round(props.routeDuration))} 分钟`
: ''
return [distance, duration].filter(Boolean).join(' · ')
})
const handleSearchInput = (event: { detail?: { value?: string } } | Event) => {
const target = event as { detail?: { value?: string }; target?: { value?: string } }
searchKeyword.value = target.detail?.value ?? target.target?.value ?? ''
if (searchTimer) clearTimeout(searchTimer)
const keyword = searchKeyword.value.trim()
if (keyword.length < 2) {
emit('manualStartSearch', '')
return
}
searchTimer = setTimeout(() => emit('manualStartSearch', keyword), 220)
}
</script>
<style scoped lang="scss">
.outdoor-route-panel {
position: absolute;
z-index: 1102;
right: 12px;
bottom: calc(env(safe-area-inset-bottom) + 12px);
left: 12px;
max-width: 406px;
margin: 0 auto;
padding: 10px 14px 14px;
overflow: hidden;
box-sizing: border-box;
border: 1px solid rgba(26, 35, 126, 0.11);
border-radius: 8px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 12px 30px rgba(35, 46, 66, 0.18);
}
.outdoor-route-handle {
width: 34px;
height: 4px;
margin: 0 auto 11px;
border-radius: 99px;
background: #d7dce5;
}
.outdoor-route-header,
.outdoor-route-heading,
.outdoor-route-actions,
.outdoor-route-switches,
.outdoor-route-point-card,
.outdoor-route-journey-item,
.outdoor-route-result,
.outdoor-route-map-prompt {
display: flex;
}
.outdoor-route-header {
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.outdoor-route-heading {
min-width: 0;
flex-direction: column;
gap: 3px;
}
.outdoor-route-title {
color: #263447;
font-size: 18px;
font-weight: 500;
line-height: 25px;
}
.outdoor-route-destination {
overflow: hidden;
color: #69778c;
font-size: 13px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.outdoor-route-back {
width: 28px;
height: 28px;
flex: 0 0 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #f4f7fb;
}
.outdoor-route-back-icon {
width: 9px;
height: 9px;
margin-left: 3px;
border-bottom: 1.5px solid #647286;
border-left: 1.5px solid #647286;
transform: rotate(45deg);
}
.outdoor-route-state {
min-height: 116px;
padding: 22px 10px 8px;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
text-align: center;
}
.outdoor-route-state.location-needed {
min-height: 104px;
}
.outdoor-route-permission-help {
min-height: 104px;
padding: 17px 10px 8px;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
text-align: center;
}
.outdoor-route-permission-help .outdoor-route-state-copy {
max-width: 304px;
}
.outdoor-route-spinner {
width: 24px;
height: 24px;
margin-bottom: 12px;
box-sizing: border-box;
border: 2px solid rgba(21, 101, 192, 0.16);
border-top-color: #1565c0;
border-radius: 50%;
animation: outdoor-route-spin 0.8s linear infinite;
}
.outdoor-route-spinner.small {
width: 16px;
height: 16px;
margin: 0;
}
.outdoor-route-location-icon,
.outdoor-route-map-prompt-icon {
position: relative;
width: 24px;
height: 24px;
margin-bottom: 10px;
box-sizing: border-box;
border: 2px solid #4778bd;
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
}
.outdoor-route-location-icon::after,
.outdoor-route-map-prompt-icon::after {
position: absolute;
top: 6px;
left: 6px;
width: 7px;
height: 7px;
border-radius: 50%;
background: #4778bd;
content: '';
}
.outdoor-route-state-title,
.outdoor-route-map-prompt-title {
color: #34435a;
font-size: 15px;
font-weight: 500;
line-height: 21px;
}
.outdoor-route-state-copy,
.outdoor-route-map-prompt-desc {
margin-top: 4px;
color: #7c8898;
font-size: 12px;
line-height: 17px;
}
.outdoor-route-actions {
flex-direction: column;
gap: 8px;
}
.outdoor-route-actions.two-columns {
flex-direction: row;
}
.outdoor-route-action {
height: 44px;
min-width: 0;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border: 1px solid #c7d8ec;
border-radius: 7px;
}
.outdoor-route-action.primary {
border-color: #1565c0;
background: #1565c0;
}
.outdoor-route-action.secondary {
background: #f8fbff;
}
.outdoor-route-action.full {
width: 100%;
}
.outdoor-route-action-text {
color: #1565c0;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.outdoor-route-action.primary .outdoor-route-action-text {
color: #ffffff;
}
.outdoor-route-text-action {
min-height: 28px;
display: flex;
align-items: center;
justify-content: center;
}
.outdoor-route-text-action-label {
color: #5172a0;
font-size: 13px;
line-height: 18px;
}
.outdoor-route-point-card,
.outdoor-route-journey {
margin: 18px 0 14px;
padding: 12px;
border: 1px solid #e1e8f0;
border-radius: 7px;
background: #f9fbfd;
}
.outdoor-route-point-card,
.outdoor-route-journey-item {
align-items: center;
gap: 10px;
}
.outdoor-route-point-icon {
position: relative;
width: 20px;
height: 20px;
flex: 0 0 20px;
box-sizing: border-box;
border-radius: 50%;
}
.outdoor-route-point-icon.start {
border: 5px solid #2f7d65;
background: #ffffff;
}
.outdoor-route-point-icon.end {
border: 5px solid #c8904f;
background: #ffffff;
}
.outdoor-route-point-copy {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.outdoor-route-point-label {
color: #8290a1;
font-size: 11px;
line-height: 15px;
}
.outdoor-route-point-name {
overflow: hidden;
color: #34435a;
font-size: 14px;
font-weight: 500;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.outdoor-route-switches {
height: 38px;
margin-top: 16px;
overflow: hidden;
border: 1px solid #dbe5f0;
border-radius: 7px;
}
.outdoor-route-switch {
min-width: 0;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #f9fbfd;
}
.outdoor-route-switch.active {
background: #eaf3ff;
}
.outdoor-route-switch-text {
color: #5d6d80;
font-size: 13px;
line-height: 18px;
}
.outdoor-route-switch.active .outdoor-route-switch-text {
color: #1565c0;
font-weight: 500;
}
.outdoor-route-search-field {
position: relative;
margin-top: 10px;
}
.outdoor-route-search-input {
width: 100%;
height: 42px;
padding: 0 38px 0 12px;
box-sizing: border-box;
border: 1px solid #cfd9e5;
border-radius: 7px;
background: #ffffff;
color: #34435a;
font-size: 14px;
}
.outdoor-route-search-loading {
position: absolute;
top: 13px;
right: 12px;
width: 14px;
height: 14px;
border: 2px solid rgba(21, 101, 192, 0.16);
border-top-color: #1565c0;
border-radius: 50%;
animation: outdoor-route-spin 0.8s linear infinite;
}
.outdoor-route-search-results {
max-height: 154px;
margin-top: 8px;
overflow: auto;
border-top: 1px solid #edf1f5;
}
.outdoor-route-search-result {
padding: 10px 4px;
border-bottom: 1px solid #edf1f5;
}
.outdoor-route-search-result-title,
.outdoor-route-search-result-address {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.outdoor-route-search-result-title {
color: #34435a;
font-size: 14px;
line-height: 20px;
}
.outdoor-route-search-result-address {
margin-top: 2px;
color: #8490a0;
font-size: 12px;
line-height: 17px;
}
.outdoor-route-map-prompt {
min-height: 72px;
margin-top: 12px;
align-items: center;
gap: 10px;
padding: 12px;
box-sizing: border-box;
border: 1px solid #dbe7f5;
border-radius: 7px;
background: #f7fbff;
}
.outdoor-route-map-prompt-icon {
flex: 0 0 24px;
margin: 0;
}
.outdoor-route-map-prompt-copy {
min-width: 0;
flex: 1;
}
.outdoor-route-journey {
display: block;
}
.outdoor-route-journey-line {
width: 1px;
height: 14px;
margin: 3px 0 3px 9px;
border-left: 1px dashed #b9c8da;
}
.outdoor-route-result {
min-height: 42px;
align-items: center;
gap: 8px;
padding: 0 11px;
box-sizing: border-box;
border-radius: 7px;
background: #f6f9fc;
}
.outdoor-route-result.loading {
justify-content: center;
}
.outdoor-route-result.error {
justify-content: space-between;
background: #fff8f2;
}
.outdoor-route-result-text {
min-width: 0;
overflow: hidden;
color: #53647b;
font-size: 13px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.outdoor-route-status-dot {
width: 6px;
height: 6px;
flex: 0 0 6px;
border-radius: 50%;
background: #2f7d65;
}
.outdoor-route-result-note {
color: #8190a1;
font-size: 11px;
line-height: 15px;
}
.outdoor-route-retry {
height: 26px;
flex: 0 0 auto;
display: flex;
align-items: center;
padding: 0 8px;
border: 1px solid #e2bd99;
border-radius: 5px;
}
.outdoor-route-retry-text {
color: #b66a2b;
font-size: 12px;
line-height: 16px;
}
.route-change-start {
margin-top: 9px;
}
@keyframes outdoor-route-spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -61,7 +61,7 @@
>
<view class="home-category-icon">
<svg class="poi-category-icon" viewBox="0 0 32 32" aria-hidden="true">
<use :href="getPoiCategoryIconHref(item.id)" />
<use :href="getPoiCategoryIconHref(item.id)" :xlink:href="getPoiCategoryIconHref(item.id)" />
</svg>
</view>
<text class="home-category-label">{{ item.label }}</text>
@@ -149,7 +149,7 @@
>
<view class="category-icon-shell">
<svg class="poi-category-icon" viewBox="0 0 32 32" aria-hidden="true">
<use :href="getPoiCategoryIconHref(item.id)" />
<use :href="getPoiCategoryIconHref(item.id)" :xlink:href="getPoiCategoryIconHref(item.id)" />
</svg>
</view>
<text class="category-label">{{ item.label }}</text>
@@ -236,6 +236,7 @@ import {
import {
HOME_POI_CATEGORIES,
POI_CATEGORIES,
ensurePoiIconSprite,
getPoiCategoryIconHref,
getPoiCategoryById,
resolvePoiCategory,
@@ -1062,6 +1063,7 @@ watch(showSearchContent, (expanded) => {
}, { immediate: true })
onMounted(async () => {
ensurePoiIconSprite()
await loadFloors()
// 首页默认是折叠态。先只同步楼层,避免首屏为了填充未展开的搜索结果而读取全馆点位。
// 用户展开搜索、点击分类或提交关键词时再按需建立搜索状态。