简化来馆面板并优化地图点位展示

This commit is contained in:
lyf
2026-07-09 18:46:56 +08:00
parent 5b4fe19ce3
commit 3f9c64a36e
5 changed files with 587 additions and 221 deletions

View File

@@ -59,7 +59,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, onBeforeUnmount, onMounted, nextTick } from 'vue' import { computed, ref, onBeforeUnmount, onMounted, nextTick, watch } from 'vue'
import { getMuseumEntranceLocation } from '@/services/tencent/TencentMapService' import { getMuseumEntranceLocation } from '@/services/tencent/TencentMapService'
const isH5 = typeof window !== 'undefined' const isH5 = typeof window !== 'undefined'
@@ -103,6 +103,14 @@ interface Polyline {
dottedLine?: boolean dottedLine?: boolean
} }
export interface OutdoorMapMarker {
id: string
title: string
subtitle?: string
latitude: number
longitude: number
}
interface MapFloor { interface MapFloor {
id: string id: string
label: string label: string
@@ -112,14 +120,21 @@ const props = withDefaults(defineProps<{
activeFloor?: string activeFloor?: string
floors?: MapFloor[] floors?: MapFloor[]
polylines?: Polyline[] polylines?: Polyline[]
outdoorMarkers?: OutdoorMapMarker[]
activeOutdoorMarkerId?: string
outdoorFocusOffsetY?: number
}>(), { }>(), {
activeFloor: '1F', activeFloor: '1F',
floors: () => [] as MapFloor[], floors: () => [] as MapFloor[],
polylines: () => [] as Polyline[] polylines: () => [] as Polyline[],
outdoorMarkers: () => [] as OutdoorMapMarker[],
activeOutdoorMarkerId: '',
outdoorFocusOffsetY: 0
}) })
const emit = defineEmits<{ const emit = defineEmits<{
markerClick: [markerId: number] markerClick: [markerId: number]
outdoorMarkerClick: [markerId: string]
floorChange: [floor: string] floorChange: [floor: string]
enter3DMode: [] enter3DMode: []
mapTap: [location: { latitude: number; longitude: number }] mapTap: [location: { latitude: number; longitude: number }]
@@ -138,9 +153,11 @@ const mapScale = ref(17)
// 当前楼层 // 当前楼层
const currentFloor = ref(props.activeFloor) const currentFloor = ref(props.activeFloor)
const mapComponentRef = ref<unknown>(null) const mapComponentRef = ref<unknown>(null)
const userLocationMarker = ref<Marker | null>(null)
let mapAuthErrorObserver: MutationObserver | null = null let mapAuthErrorObserver: MutationObserver | null = null
let mapAuthErrorLogged = false let mapAuthErrorLogged = false
const mapAuthErrorText = '鉴权失败请传入正确的key' const mapAuthErrorText = '鉴权失败请传入正确的key'
const OUTDOOR_MARKER_ID_BASE = 20000
// 楼层列表 // 楼层列表
const floors = computed(() => props.floors) const floors = computed(() => props.floors)
@@ -213,8 +230,46 @@ const entranceMarker = ref<Marker>({
} }
}) })
const outdoorMarkerIdMap = computed(() => {
const markerMap = new Map<number, string>()
props.outdoorMarkers.forEach((marker, index) => {
markerMap.set(OUTDOOR_MARKER_ID_BASE + index, marker.id)
})
return markerMap
})
const outdoorDisplayMarkers = computed<Marker[]>(() => (
props.outdoorMarkers.map((marker, index) => {
const isActive = marker.id === props.activeOutdoorMarkerId
return {
id: OUTDOOR_MARKER_ID_BASE + index,
latitude: marker.latitude,
longitude: marker.longitude,
iconPath: '/static/icons/marker-location.svg',
width: isActive ? 42 : 32,
height: isActive ? 42 : 32,
title: marker.title,
callout: {
content: marker.subtitle ? `${marker.title}\n${marker.subtitle}` : marker.title,
display: isActive ? 'ALWAYS' : 'BYCLICK',
padding: 10,
borderRadius: 6,
bgColor: isActive ? '#262421' : '#FFFFFF',
color: isActive ? '#E0E100' : '#262421',
fontSize: 12
}
}
})
))
// 地图标记点列表 // 地图标记点列表
const markers = computed(() => [museumMarker.value, entranceMarker.value]) const markers = computed(() => [
museumMarker.value,
entranceMarker.value,
...outdoorDisplayMarkers.value,
...(userLocationMarker.value ? [userLocationMarker.value] : [])
])
// 显示的路线列表(转换格式以适配 uni-app map 组件) // 显示的路线列表(转换格式以适配 uni-app map 组件)
const displayPolylines = computed(() => { const displayPolylines = computed(() => {
@@ -233,8 +288,7 @@ const displayPolylines = computed(() => {
* 更新用户位置标记 * 更新用户位置标记
*/ */
const updateUserLocation = (location: { latitude: number; longitude: number }) => { const updateUserLocation = (location: { latitude: number; longitude: number }) => {
const userMarkerIndex = markers.value.findIndex((m) => m.id === 999) userLocationMarker.value = {
const userMarker: Marker = {
id: 999, id: 999,
latitude: location.latitude, latitude: location.latitude,
longitude: location.longitude, longitude: location.longitude,
@@ -252,22 +306,13 @@ const updateUserLocation = (location: { latitude: number; longitude: number }) =
fontSize: 12 fontSize: 12
} }
} }
if (userMarkerIndex >= 0) {
markers.value[userMarkerIndex] = userMarker
} else {
markers.value.push(userMarker)
}
} }
/** /**
* 清除用户位置标记 * 清除用户位置标记
*/ */
const clearUserLocation = () => { const clearUserLocation = () => {
const userMarkerIndex = markers.value.findIndex((m) => m.id === 999) userLocationMarker.value = null
if (userMarkerIndex >= 0) {
markers.value.splice(userMarkerIndex, 1)
}
} }
/** /**
@@ -280,6 +325,31 @@ const moveTo = (location: { latitude: number; longitude: number }) => {
} }
} }
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
const getLatitudeDegreesPerPixel = (latitude: number, scale: number) => {
const latitudeRadians = latitude * Math.PI / 180
const worldPixelSize = 256 * (2 ** scale)
return 360 * Math.cos(latitudeRadians) / worldPixelSize
}
const moveToWithVerticalOffset = (
location: { latitude: number; longitude: number },
offsetY: number
) => {
const effectiveOffsetY = clamp(offsetY, 0, 360)
if (effectiveOffsetY <= 0) {
moveTo(location)
return
}
const latitudeOffset = getLatitudeDegreesPerPixel(location.latitude, mapScale.value) * effectiveOffsetY
mapCenter.value = {
latitude: location.latitude - latitudeOffset,
longitude: location.longitude
}
}
// 暴露方法给父组件 // 暴露方法给父组件
defineExpose({ defineExpose({
updateUserLocation, updateUserLocation,
@@ -289,8 +359,12 @@ defineExpose({
// 处理标记点点击 // 处理标记点点击
const handleMarkerTap = (e: any) => { const handleMarkerTap = (e: any) => {
const markerId = e.detail.markerId || e.markerId const markerId = Number(e.detail?.markerId ?? e.markerId)
console.log('点击标记点:', markerId) console.log('点击标记点:', markerId)
const outdoorMarkerId = outdoorMarkerIdMap.value.get(markerId)
if (outdoorMarkerId) {
emit('outdoorMarkerClick', outdoorMarkerId)
}
emit('markerClick', markerId) emit('markerClick', markerId)
} }
@@ -328,8 +402,7 @@ const handleLocation = () => {
} }
// 添加或更新用户位置标记 // 添加或更新用户位置标记
const userMarkerIndex = markers.value.findIndex(m => m.id === 999) userLocationMarker.value = {
const userMarker: Marker = {
id: 999, id: 999,
latitude: res.latitude, latitude: res.latitude,
longitude: res.longitude, longitude: res.longitude,
@@ -348,12 +421,6 @@ const handleLocation = () => {
} }
} }
if (userMarkerIndex >= 0) {
markers.value[userMarkerIndex] = userMarker
} else {
markers.value.push(userMarker)
}
console.log('定位成功:', res) console.log('定位成功:', res)
}, },
fail: (err) => { fail: (err) => {
@@ -388,6 +455,23 @@ const handleEnter3D = () => {
emit('enter3DMode') emit('enter3DMode')
} }
watch(
() => [props.activeOutdoorMarkerId, props.outdoorMarkers, props.outdoorFocusOffsetY] as const,
([activeOutdoorMarkerId, outdoorMarkers, outdoorFocusOffsetY]) => {
if (!activeOutdoorMarkerId) return
const activeMarker = outdoorMarkers.find((marker) => marker.id === activeOutdoorMarkerId)
if (!activeMarker) return
moveToWithVerticalOffset(
{
latitude: activeMarker.latitude,
longitude: activeMarker.longitude
},
outdoorFocusOffsetY
)
},
{ immediate: true }
)
const getMapComponentElement = () => { const getMapComponentElement = () => {
const rawRef = mapComponentRef.value const rawRef = mapComponentRef.value
if (rawRef instanceof HTMLElement) return rawRef if (rawRef instanceof HTMLElement) return rawRef

View File

@@ -5,6 +5,7 @@
@tap="collapsePanel" @tap="collapsePanel"
></view> ></view>
<view <view
ref="arrivalPanelRef"
v-if="visible" v-if="visible"
class="arrival-panel" class="arrival-panel"
:class="{ collapsed: isCollapsed }" :class="{ collapsed: isCollapsed }"
@@ -13,7 +14,7 @@
<view v-if="isCollapsed" class="arrival-collapsed" @tap="expandPanel"> <view v-if="isCollapsed" class="arrival-collapsed" @tap="expandPanel">
<view class="arrival-collapsed-copy"> <view class="arrival-collapsed-copy">
<text class="arrival-collapsed-title">来馆</text> <text class="arrival-collapsed-title">来馆</text>
<text class="arrival-collapsed-summary">公交 / 地铁 / 停车</text> <text class="arrival-collapsed-summary">{{ selectedTarget?.title || activeTypeLabel }}</text>
</view> </view>
<view class="arrival-collapsed-action"> <view class="arrival-collapsed-action">
<text class="arrival-collapsed-action-text">展开</text> <text class="arrival-collapsed-action-text">展开</text>
@@ -21,83 +22,64 @@
</view> </view>
<template v-else> <template v-else>
<view class="arrival-handle" @tap="collapsePanel"></view> <view class="arrival-handle" @tap="collapsePanel"></view>
<view class="arrival-header"> <view class="arrival-header">
<view class="arrival-title-group"> <view class="arrival-title-group">
<text class="arrival-kicker">来馆</text> <text class="arrival-kicker">来馆</text>
<text class="arrival-title">选择第三方地图检索目标</text>
</view>
<view class="arrival-collapse" @tap="collapsePanel">
<text class="arrival-collapse-text">收起</text>
</view>
</view>
<view class="arrival-notes">
<text class="arrival-note">公交站停车场信息以第三方地图实时结果为准</text>
<text class="arrival-note">暂未接入停车余位</text>
</view>
<view class="arrival-section">
<text class="arrival-section-title">导航到场馆</text>
<view
v-for="target in venueTargets"
:key="target.id"
class="arrival-target-row"
@tap="handleTargetSelect(target)"
>
<view class="arrival-target-dot"></view>
<view class="arrival-target-copy">
<text class="arrival-target-title">{{ target.title }}</text>
<text class="arrival-target-keyword">{{ target.keyword }}</text>
</view> </view>
<text class="arrival-target-action">选择地图</text> <view class="arrival-collapse" @tap="collapsePanel">
</view> <text class="arrival-collapse-text">收起</text>
</view>
<view class="arrival-section">
<text class="arrival-section-title">推荐公共交通</text>
<view
v-for="target in transitTargets"
:key="target.id"
class="arrival-target-row"
@tap="handleTargetSelect(target)"
>
<view class="arrival-target-dot"></view>
<view class="arrival-target-copy">
<text class="arrival-target-title">{{ target.title }}</text>
<text class="arrival-target-keyword">{{ target.keyword }}</text>
</view> </view>
<text class="arrival-target-action">选择地图</text>
</view> </view>
</view>
<view class="arrival-section"> <view class="arrival-tabs">
<text class="arrival-section-title">实时查询</text> <view
<view v-for="option in typeOptions"
v-for="target in realtimeTargets" :key="option.type"
:key="target.id" class="arrival-tab"
class="arrival-target-row" :class="{ active: option.type === activeType }"
@tap="handleTargetSelect(target)" @tap="handleTypeChange(option.type)"
> >
<view class="arrival-target-dot"></view> <text class="arrival-tab-text">{{ option.label }}</text>
<view class="arrival-target-copy">
<text class="arrival-target-title">{{ target.title }}</text>
<text class="arrival-target-keyword">{{ target.keyword }}</text>
</view> </view>
<text class="arrival-target-action">选择地图</text>
</view> </view>
</view>
<view class="arrival-list">
<view
v-for="target in targets"
:key="target.id"
class="arrival-target-row"
:class="{ active: target.id === selectedTargetId }"
@tap="handleTargetSelect(target)"
>
<view class="arrival-target-dot"></view>
<view class="arrival-target-copy">
<text class="arrival-target-title">{{ target.title }}</text>
<text class="arrival-target-subtitle">{{ target.subtitle }}</text>
<text v-if="target.description" class="arrival-target-desc">{{ target.description }}</text>
</view>
<text v-if="target.id === selectedTargetId" class="arrival-target-state">已选</text>
</view>
</view>
<view
class="arrival-primary"
:class="{ disabled: !selectedTarget }"
@tap="handleNavigateTap"
>
<text class="arrival-primary-text">第三方导航</text>
</view>
</template> </template>
</view> </view>
<view <view
v-if="selectedTarget" v-if="providerSheetVisible && selectedTarget"
class="provider-scrim" class="provider-scrim"
@tap="closeProviderSheet" @tap="closeProviderSheet"
></view> ></view>
<view <view
v-if="selectedTarget" v-if="providerSheetVisible && selectedTarget"
class="provider-sheet" class="provider-sheet"
@tap.stop @tap.stop
> >
@@ -126,12 +108,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { import {
ARRIVAL_REALTIME_TARGETS, ARRIVAL_TARGET_TYPES,
ARRIVAL_TRANSIT_TARGETS, type ArrivalSearchTarget,
ARRIVAL_VENUE_TARGETS, type ArrivalTargetType
type ArrivalSearchTarget
} from '@/data/arrivalSearchTargets' } from '@/data/arrivalSearchTargets'
import { import {
openThirdPartyMapSearch, openThirdPartyMapSearch,
@@ -141,25 +122,44 @@ import {
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
visible?: boolean visible?: boolean
defaultCollapsed?: boolean activeType: ArrivalTargetType
targets?: ArrivalSearchTarget[]
selectedTargetId?: string
selectedTarget?: ArrivalSearchTarget | null
}>(), { }>(), {
visible: false, visible: false,
defaultCollapsed: false targets: () => [] as ArrivalSearchTarget[],
selectedTargetId: '',
selectedTarget: null
}) })
const emit = defineEmits<{ const emit = defineEmits<{
'update:activeType': [type: ArrivalTargetType]
selectTarget: [target: ArrivalSearchTarget]
collapsedChange: [collapsed: boolean] collapsedChange: [collapsed: boolean]
layoutChange: [layout: { height: number; collapsed: boolean }]
}>() }>()
const venueTargets = computed(() => ARRIVAL_VENUE_TARGETS) const typeOptions = computed(() => ARRIVAL_TARGET_TYPES)
const transitTargets = computed(() => ARRIVAL_TRANSIT_TARGETS)
const realtimeTargets = computed(() => ARRIVAL_REALTIME_TARGETS)
const mapProviders = computed(() => THIRD_PARTY_MAP_PROVIDERS) const mapProviders = computed(() => THIRD_PARTY_MAP_PROVIDERS)
const selectedTarget = ref<ArrivalSearchTarget | null>(null) const arrivalPanelRef = ref<unknown>(null)
const isCollapsed = ref(false) const isCollapsed = ref(false)
const providerSheetVisible = ref(false)
let resizeObserver: ResizeObserver | null = null
const activeTypeLabel = computed(() => (
typeOptions.value.find((option) => option.type === props.activeType)?.label || '来馆'
))
const handleTypeChange = (type: ArrivalTargetType) => {
if (type === props.activeType) return
closeProviderSheet()
emit('update:activeType', type)
}
const handleTargetSelect = (target: ArrivalSearchTarget) => { const handleTargetSelect = (target: ArrivalSearchTarget) => {
selectedTarget.value = target closeProviderSheet()
emit('selectTarget', target)
} }
const collapsePanel = () => { const collapsePanel = () => {
@@ -173,11 +173,76 @@ const expandPanel = () => {
} }
const closeProviderSheet = () => { const closeProviderSheet = () => {
selectedTarget.value = null providerSheetVisible.value = false
}
const getArrivalPanelElement = () => {
const rawRef = arrivalPanelRef.value
if (rawRef instanceof HTMLElement) return rawRef
const maybeComponent = rawRef as { $el?: Element } | null
if (maybeComponent?.$el instanceof HTMLElement) return maybeComponent.$el
if (typeof document === 'undefined') return null
return document.querySelector<HTMLElement>('.arrival-panel')
}
const emitLayoutChange = () => {
if (!props.visible) {
emit('layoutChange', { height: 0, collapsed: false })
return
}
void nextTick(() => {
const panelElement = getArrivalPanelElement()
emit('layoutChange', {
height: panelElement?.getBoundingClientRect().height || 0,
collapsed: isCollapsed.value
})
})
}
const disconnectLayoutObserver = () => {
resizeObserver?.disconnect()
resizeObserver = null
}
const startLayoutObserver = () => {
if (typeof window === 'undefined' || typeof ResizeObserver === 'undefined') {
emitLayoutChange()
return
}
void nextTick(() => {
const panelElement = getArrivalPanelElement()
if (!panelElement) {
emitLayoutChange()
return
}
disconnectLayoutObserver()
resizeObserver = new ResizeObserver(() => {
emitLayoutChange()
})
resizeObserver.observe(panelElement)
emitLayoutChange()
})
}
const handleNavigateTap = () => {
if (!props.selectedTarget) {
uni.showToast({
title: '请先选择点位',
icon: 'none'
})
return
}
providerSheetVisible.value = true
} }
const handleProviderSelect = (provider: ThirdPartyMapProviderOption) => { const handleProviderSelect = (provider: ThirdPartyMapProviderOption) => {
const target = selectedTarget.value const target = props.selectedTarget
if (!target) return if (!target) return
openThirdPartyMapSearch(provider.provider, { openThirdPartyMapSearch(provider.provider, {
@@ -190,16 +255,37 @@ watch(
() => props.visible, () => props.visible,
(visible) => { (visible) => {
if (visible) { if (visible) {
isCollapsed.value = props.defaultCollapsed
emit('collapsedChange', props.defaultCollapsed)
} else {
selectedTarget.value = null
isCollapsed.value = false isCollapsed.value = false
emit('collapsedChange', false) emit('collapsedChange', false)
startLayoutObserver()
} else {
closeProviderSheet()
isCollapsed.value = false
disconnectLayoutObserver()
emit('collapsedChange', false)
emit('layoutChange', { height: 0, collapsed: false })
} }
} }
) )
watch(
() => props.selectedTargetId,
() => {
closeProviderSheet()
}
)
watch(
() => [props.targets.length, isCollapsed.value] as const,
() => {
startLayoutObserver()
}
)
onBeforeUnmount(() => {
disconnectLayoutObserver()
})
defineExpose({ defineExpose({
expandPanel expandPanel
}) })
@@ -210,7 +296,7 @@ defineExpose({
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 2090; z-index: 2090;
background: rgba(0, 0, 0, 0.18); background: rgba(0, 0, 0, 0.08);
} }
.arrival-panel { .arrival-panel {
@@ -219,8 +305,8 @@ defineExpose({
bottom: calc(env(safe-area-inset-bottom) + 16px); bottom: calc(env(safe-area-inset-bottom) + 16px);
z-index: 2100; z-index: 2100;
width: min(430px, calc(100vw - 24px)); width: min(430px, calc(100vw - 24px));
max-height: min(72vh, 620px); max-height: min(58vh, 520px);
padding: 10px 14px 16px; padding: 10px 14px 14px;
overflow-y: auto; overflow-y: auto;
box-sizing: border-box; box-sizing: border-box;
background: #ffffff; background: #ffffff;
@@ -346,12 +432,115 @@ defineExpose({
color: #424754; color: #424754;
} }
.arrival-notes { .arrival-tabs {
margin-top: 12px; height: 40px;
padding: 10px 12px; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px;
margin-top: 14px;
padding: 4px;
box-sizing: border-box;
background: #f3f3f3;
border-radius: 8px;
}
.arrival-tab {
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
}
.arrival-tab.active {
background: #262421;
}
.arrival-tab-text {
font-size: 13px;
line-height: 18px;
font-weight: 700;
color: #424754;
}
.arrival-tab.active .arrival-tab-text {
color: #e0e100;
}
.arrival-list {
margin-top: 10px;
}
.arrival-target-row {
min-height: 68px;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 8px;
border-top: 1px solid #eceee7;
border-radius: 8px;
box-sizing: border-box;
}
.arrival-target-row:first-child {
border-top: 0;
}
.arrival-target-row.active {
background: #f5f5ed;
}
.arrival-target-dot {
width: 10px;
height: 10px;
flex-shrink: 0;
background: #d7d9cf;
border: 2px solid #696962;
border-radius: 50%;
}
.arrival-target-row.active .arrival-target-dot {
background: #e0e100;
border-color: #262421;
}
.arrival-target-copy {
min-width: 0;
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 2px;
}
.arrival-target-title {
font-size: 15px;
line-height: 21px;
font-weight: 700;
color: #262421;
}
.arrival-target-subtitle,
.arrival-target-desc {
font-size: 12px;
line-height: 17px;
color: #696962;
}
.arrival-target-desc {
color: #424754;
}
.arrival-target-state {
flex-shrink: 0;
font-size: 12px;
line-height: 17px;
font-weight: 700;
color: #262421;
}
.arrival-notes {
margin-top: 10px;
padding: 9px 10px;
background: #f5f5ed; background: #f5f5ed;
border-radius: 8px; border-radius: 8px;
} }
@@ -362,68 +551,25 @@ defineExpose({
color: #424754; color: #424754;
} }
.arrival-section { .arrival-primary {
margin-top: 16px; height: 44px;
}
.arrival-section-title {
display: block;
margin-bottom: 8px;
font-size: 14px;
line-height: 20px;
font-weight: 700;
color: #262421;
}
.arrival-target-row {
min-height: 58px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; justify-content: center;
padding: 10px 0; margin-top: 12px;
border-top: 1px solid #eceee7; background: #262421;
border-radius: 8px;
} }
.arrival-section-title + .arrival-target-row { .arrival-primary.disabled {
border-top: 0; opacity: 0.45;
} }
.arrival-target-dot { .arrival-primary-text {
width: 9px;
height: 9px;
flex-shrink: 0;
background: #e0e100;
border: 2px solid #262421;
border-radius: 50%;
}
.arrival-target-copy {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
}
.arrival-target-title {
font-size: 15px; font-size: 15px;
line-height: 21px; line-height: 21px;
font-weight: 600; font-weight: 700;
color: #262421; color: #e0e100;
}
.arrival-target-keyword {
font-size: 12px;
line-height: 17px;
color: #696962;
}
.arrival-target-action {
flex-shrink: 0;
font-size: 12px;
line-height: 17px;
font-weight: 600;
color: #262421;
} }
.provider-scrim { .provider-scrim {

View File

@@ -44,6 +44,10 @@
:active-floor="activeFloor" :active-floor="activeFloor"
:floors="props.floors" :floors="props.floors"
:polylines="outdoorNavPolylines" :polylines="outdoorNavPolylines"
:outdoor-markers="outdoorMarkers"
:active-outdoor-marker-id="activeOutdoorMarkerId"
:outdoor-focus-offset-y="outdoorFocusOffsetY"
@outdoor-marker-click="handleOutdoorMarkerClick"
@map-tap="handleMapTap" @map-tap="handleMapTap"
/> />
</view> </view>
@@ -265,6 +269,14 @@ interface OutdoorNavPolyline {
width: number width: number
} }
interface OutdoorMapMarker {
id: string
title: string
subtitle?: string
latitude: number
longitude: number
}
type CameraViewMode = 'reset' | 'top' | 'oblique' type CameraViewMode = 'reset' | 'top' | 'oblique'
interface TargetPoiFocusRequest { interface TargetPoiFocusRequest {
@@ -347,6 +359,9 @@ const props = withDefaults(defineProps<{
autoSwitchThresholdLow?: number autoSwitchThresholdLow?: number
autoSwitchThresholdHigh?: number autoSwitchThresholdHigh?: number
outdoorNavPolylines?: OutdoorNavPolyline[] outdoorNavPolylines?: OutdoorNavPolyline[]
outdoorMarkers?: OutdoorMapMarker[]
activeOutdoorMarkerId?: string
outdoorFocusOffsetY?: number
}>(), { }>(), {
searchText: '请输入地点进行搜索', searchText: '请输入地点进行搜索',
activeMode: '3d', activeMode: '3d',
@@ -395,7 +410,10 @@ const props = withDefaults(defineProps<{
disableAutoExit: false, disableAutoExit: false,
autoSwitchThresholdLow: 1.0, autoSwitchThresholdLow: 1.0,
autoSwitchThresholdHigh: 1.3, autoSwitchThresholdHigh: 1.3,
outdoorNavPolylines: () => [] as OutdoorNavPolyline[] outdoorNavPolylines: () => [] as OutdoorNavPolyline[],
outdoorMarkers: () => [] as OutdoorMapMarker[],
activeOutdoorMarkerId: '',
outdoorFocusOffsetY: 0
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -416,6 +434,7 @@ const emit = defineEmits<{
initialModelReady: [event: { view: IndoorViewMode; floorId?: string; elapsedMs?: number }] initialModelReady: [event: { view: IndoorViewMode; floorId?: string; elapsedMs?: number }]
initialModelFailed: [event: { view: IndoorViewMode; floorId?: string; message: string; elapsedMs?: number }] initialModelFailed: [event: { view: IndoorViewMode; floorId?: string; message: string; elapsedMs?: number }]
mapTap: [location: { latitude: number; longitude: number }] mapTap: [location: { latitude: number; longitude: number }]
outdoorMarkerClick: [markerId: string]
}>() }>()
// 监听视角切换循环切换reset -> top -> oblique -> reset // 监听视角切换循环切换reset -> top -> oblique -> reset
@@ -739,6 +758,10 @@ const handleMapTap = (location: { latitude?: number; longitude?: number }) => {
} }
} }
const handleOutdoorMarkerClick = (markerId: string) => {
emit('outdoorMarkerClick', markerId)
}
// 暴露 clearRoute 方法供父组件显式调用路线清除 // 暴露 clearRoute 方法供父组件显式调用路线清除
defineExpose({ defineExpose({
clearRoute: () => { clearRoute: () => {

View File

@@ -1,89 +1,123 @@
export type ArrivalTargetGroup = 'venue' | 'transit' | 'realtime' export type ArrivalTargetType = 'bus' | 'metro' | 'parking'
export type ArrivalRealtimeKind = 'bus' | 'parking'
export interface ArrivalTargetTypeOption {
type: ArrivalTargetType
label: string
}
export interface ArrivalSearchTarget { export interface ArrivalSearchTarget {
id: string id: string
type: ArrivalTargetType
title: string title: string
subtitle: string
description?: string
keyword: string keyword: string
region: string region: string
group: ArrivalTargetGroup latitude: number
realtimeKind?: ArrivalRealtimeKind longitude: number
} }
export const ARRIVAL_SEARCH_REGION = '深圳市' export const ARRIVAL_SEARCH_REGION = '深圳市'
export const ARRIVAL_SEARCH_TARGETS: ArrivalSearchTarget[] = [ export const ARRIVAL_TARGET_TYPES: ArrivalTargetTypeOption[] = [
{ { type: 'bus', label: '公交' },
id: 'shenzhen-natural-history-museum', { type: 'metro', label: '地铁' },
title: '深圳自然博物馆', { type: 'parking', label: '停车场' }
keyword: '深圳自然博物馆', ]
region: ARRIVAL_SEARCH_REGION,
group: 'venue' export const ARRIVAL_TARGETS: ArrivalSearchTarget[] = [
},
{
id: 'shabo-station-exit-d',
title: '沙壆站 D口',
keyword: '沙壆站D口',
region: ARRIVAL_SEARCH_REGION,
group: 'transit'
},
{
id: 'natural-museum-west-station',
title: '自然博物馆西站',
keyword: '自然博物馆西站',
region: ARRIVAL_SEARCH_REGION,
group: 'transit'
},
{ {
id: 'museum-bus-stop', id: 'museum-bus-stop',
title: '深圳自然博物馆 公交站', type: 'bus',
title: '深圳自然博物馆公交站',
subtitle: '场馆周边公交接驳点',
description: '适合到馆前在第三方地图中继续查看实时公交和步行路径。',
keyword: '深圳自然博物馆 公交站', keyword: '深圳自然博物馆 公交站',
region: ARRIVAL_SEARCH_REGION, region: ARRIVAL_SEARCH_REGION,
group: 'realtime', latitude: 22.69258,
realtimeKind: 'bus' longitude: 114.36372
},
{
id: 'shabo-metro-bus-stop',
title: '沙壆地铁站 公交站',
keyword: '沙壆地铁站 公交站',
region: ARRIVAL_SEARCH_REGION,
group: 'realtime',
realtimeKind: 'bus'
}, },
{ {
id: 'natural-museum-west-bus-stop', id: 'natural-museum-west-bus-stop',
title: '自然博物馆西 公交站', type: 'bus',
title: '自然博物馆西公交站',
subtitle: '龙坪路周边公交接驳点',
description: '靠近坪山云巴自然博物馆西站,可作为公共交通换乘参考。',
keyword: '自然博物馆西 公交站', keyword: '自然博物馆西 公交站',
region: ARRIVAL_SEARCH_REGION, region: ARRIVAL_SEARCH_REGION,
group: 'realtime', latitude: 22.69193,
realtimeKind: 'bus' longitude: 114.35821
},
{
id: 'shabo-metro-bus-stop',
type: 'bus',
title: '沙壆地铁站公交站',
subtitle: '地铁 16 号线周边公交接驳',
description: '适合先到沙壆站后换乘公交或步行前往场馆。',
keyword: '沙壆地铁站 公交站',
region: ARRIVAL_SEARCH_REGION,
latitude: 22.68676,
longitude: 114.36441
},
{
id: 'shabo-station-exit-d',
type: 'metro',
title: '沙壆站 D口',
subtitle: '地铁 16 号线出口',
description: '出站后请以第三方地图展示的步行路线为准。',
keyword: '沙壆站D口',
region: ARRIVAL_SEARCH_REGION,
latitude: 22.68676,
longitude: 114.36441
},
{
id: 'natural-museum-west-station',
type: 'metro',
title: '自然博物馆西站',
subtitle: '坪山云巴 1 号线',
description: '云巴站点位于龙坪路与三洋湖工业一路交叉口以北。',
keyword: '自然博物馆西站',
region: ARRIVAL_SEARCH_REGION,
latitude: 22.69193,
longitude: 114.35821
}, },
{ {
id: 'museum-parking', id: 'museum-parking',
title: '深圳自然博物馆 停车场', type: 'parking',
title: '深圳自然博物馆停车场',
subtitle: '场馆停车点位',
description: '停车开放规则、入口和余位请以现场及第三方地图为准。',
keyword: '深圳自然博物馆 停车场', keyword: '深圳自然博物馆 停车场',
region: ARRIVAL_SEARCH_REGION, region: ARRIVAL_SEARCH_REGION,
group: 'realtime', latitude: 22.69228,
realtimeKind: 'parking' longitude: 114.36318
}, },
{ {
id: 'pingshan-cultural-cluster-parking', id: 'pingshan-cultural-cluster-parking',
title: '坪山文化聚落 停车场', type: 'parking',
title: '坪山文化聚落停车场',
subtitle: '周边文化设施停车参考',
description: '适合作为周边停车备选,具体开放情况请以第三方地图为准。',
keyword: '坪山文化聚落 停车场', keyword: '坪山文化聚落 停车场',
region: ARRIVAL_SEARCH_REGION, region: ARRIVAL_SEARCH_REGION,
group: 'realtime', latitude: 22.69618,
realtimeKind: 'parking' longitude: 114.34652
}, },
{ {
id: 'yanzi-lake-area-parking', id: 'yanzi-lake-area-parking',
title: '燕子湖片区 停车场', type: 'parking',
title: '燕子湖片区停车场',
subtitle: '片区停车参考',
description: '请在第三方地图中确认入口、距离与可停情况。',
keyword: '燕子湖片区 停车场', keyword: '燕子湖片区 停车场',
region: ARRIVAL_SEARCH_REGION, region: ARRIVAL_SEARCH_REGION,
group: 'realtime', latitude: 22.69084,
realtimeKind: 'parking' longitude: 114.36522
} }
] ]
export const ARRIVAL_VENUE_TARGETS = ARRIVAL_SEARCH_TARGETS.filter((target) => target.group === 'venue') export const ARRIVAL_TARGETS_BY_TYPE: Record<ArrivalTargetType, ArrivalSearchTarget[]> = {
export const ARRIVAL_TRANSIT_TARGETS = ARRIVAL_SEARCH_TARGETS.filter((target) => target.group === 'transit') bus: ARRIVAL_TARGETS.filter((target) => target.type === 'bus'),
export const ARRIVAL_REALTIME_TARGETS = ARRIVAL_SEARCH_TARGETS.filter((target) => target.group === 'realtime') metro: ARRIVAL_TARGETS.filter((target) => target.type === 'metro'),
parking: ARRIVAL_TARGETS.filter((target) => target.type === 'parking')
}

View File

@@ -40,6 +40,9 @@
zoom-controls-top="calc(100vh - 292px)" zoom-controls-top="calc(100vh - 292px)"
:route-preview="activeRoutePreview" :route-preview="activeRoutePreview"
:show-route="Boolean(activeRoutePreview)" :show-route="Boolean(activeRoutePreview)"
:outdoor-markers="arrivalOutdoorMarkers"
:active-outdoor-marker-id="activeArrivalMarkerId"
:outdoor-focus-offset-y="arrivalOutdoorFocusOffsetY"
:disable-auto-exit="disableIndoorAutoExit" :disable-auto-exit="disableIndoorAutoExit"
:auto-switch-threshold-low="0.58" :auto-switch-threshold-low="0.58"
:auto-switch-threshold-high="1.18" :auto-switch-threshold-high="1.18"
@@ -55,6 +58,7 @@
@initial-model-ready="handleInitialModelReady" @initial-model-ready="handleInitialModelReady"
@initial-model-failed="handleInitialModelFailed" @initial-model-failed="handleInitialModelFailed"
@tool-click="handleIndoorToolClick" @tool-click="handleIndoorToolClick"
@outdoor-marker-click="handleArrivalMarkerClick"
> >
<template #overlay> <template #overlay>
<view v-if="guideOutdoorState === 'entrance' && !is3DMode" class="entrance-tip"> <view v-if="guideOutdoorState === 'entrance' && !is3DMode" class="entrance-tip">
@@ -230,8 +234,14 @@
ref="arrivalPanelRef" ref="arrivalPanelRef"
v-if="currentTab === 'guide'" v-if="currentTab === 'guide'"
:visible="showArrivalPanel" :visible="showArrivalPanel"
:default-collapsed="true" :active-type="activeArrivalType"
:targets="activeArrivalTargets"
:selected-target-id="selectedArrivalTargetId"
:selected-target="selectedArrivalTarget"
@update:active-type="handleArrivalTypeChange"
@select-target="handleArrivalTargetSelect"
@collapsed-change="handleArrivalPanelCollapsedChange" @collapsed-change="handleArrivalPanelCollapsedChange"
@layout-change="handleArrivalPanelLayoutChange"
/> />
<view v-else-if="currentTab === 'explain'" class="explain-page"> <view v-else-if="currentTab === 'explain'" class="explain-page">
@@ -332,6 +342,11 @@ import {
import type { import type {
GuideRenderPoi GuideRenderPoi
} from '@/domain/guideModel' } from '@/domain/guideModel'
import {
ARRIVAL_TARGETS_BY_TYPE,
type ArrivalSearchTarget,
type ArrivalTargetType
} from '@/data/arrivalSearchTargets'
import type { import type {
GuideRouteResult, GuideRouteResult,
GuideRouteTarget, GuideRouteTarget,
@@ -563,6 +578,9 @@ const handleLaunchContinue = () => {
const showArrivalPanel = ref(false) const showArrivalPanel = ref(false)
const showArrivalPanelCollapsed = ref(false) const showArrivalPanelCollapsed = ref(false)
const arrivalPanelRef = ref<InstanceType<typeof ArrivalPanel> | null>(null) const arrivalPanelRef = ref<InstanceType<typeof ArrivalPanel> | null>(null)
const activeArrivalType = ref<ArrivalTargetType>('bus')
const selectedArrivalTargetId = ref('')
const arrivalPanelHeight = ref(0)
const indoorNavAssetBaseUrl = guideUseCase.getAssetBaseUrl() const indoorNavAssetBaseUrl = guideUseCase.getAssetBaseUrl()
const indoorModelSource = guideUseCase.getModelSource() const indoorModelSource = guideUseCase.getModelSource()
@@ -637,6 +655,35 @@ const showGuideFloatingActions = computed(() => (
&& (!showGuideHomeDock.value || !homeSearchExpanded.value) && (!showGuideHomeDock.value || !homeSearchExpanded.value)
)) ))
const activeArrivalTargets = computed(() => ARRIVAL_TARGETS_BY_TYPE[activeArrivalType.value])
const selectedArrivalTarget = computed(() => (
activeArrivalTargets.value.find((target) => target.id === selectedArrivalTargetId.value)
|| activeArrivalTargets.value[0]
|| null
))
const arrivalOutdoorMarkers = computed(() => {
if (!showArrivalPanel.value || is3DMode.value) return []
return activeArrivalTargets.value.map((target) => ({
id: target.id,
title: target.title,
subtitle: target.subtitle,
latitude: target.latitude,
longitude: target.longitude
}))
})
const activeArrivalMarkerId = computed(() => (
showArrivalPanel.value && !is3DMode.value ? selectedArrivalTarget.value?.id || '' : ''
))
const arrivalOutdoorFocusOffsetY = computed(() => {
if (!showArrivalPanel.value || showArrivalPanelCollapsed.value || is3DMode.value) return 0
return Math.min(260, Math.max(0, Math.round(arrivalPanelHeight.value / 2 + 24)))
})
const guideQuickActiveAction = computed<'indoor' | 'arrival' | 'explain'>(() => { const guideQuickActiveAction = computed<'indoor' | 'arrival' | 'explain'>(() => {
if (currentTab.value === 'explain') return 'explain' if (currentTab.value === 'explain') return 'explain'
if (showArrivalPanel.value) return 'arrival' if (showArrivalPanel.value) return 'arrival'
@@ -1295,15 +1342,46 @@ const handleMoreRouteGuide = () => {
showIndoorHint(`当前楼层:${getGuideFloorLabel(renderedFloorId.value || activeGuideFloor.value) || '馆内单层'}`, 3200) showIndoorHint(`当前楼层:${getGuideFloorLabel(renderedFloorId.value || activeGuideFloor.value) || '馆内单层'}`, 3200)
} }
const selectFirstArrivalTarget = () => {
selectedArrivalTargetId.value = activeArrivalTargets.value[0]?.id || ''
}
const resetArrivalPanelState = () => {
activeArrivalType.value = 'bus'
selectFirstArrivalTarget()
}
const closeArrivalPanel = () => { const closeArrivalPanel = () => {
showArrivalPanel.value = false showArrivalPanel.value = false
showArrivalPanelCollapsed.value = false showArrivalPanelCollapsed.value = false
selectedArrivalTargetId.value = ''
arrivalPanelHeight.value = 0
} }
const handleArrivalPanelCollapsedChange = (collapsed: boolean) => { const handleArrivalPanelCollapsedChange = (collapsed: boolean) => {
showArrivalPanelCollapsed.value = collapsed showArrivalPanelCollapsed.value = collapsed
} }
const handleArrivalPanelLayoutChange = (layout: { height: number; collapsed: boolean }) => {
arrivalPanelHeight.value = layout.height
showArrivalPanelCollapsed.value = layout.collapsed
}
const handleArrivalTypeChange = (type: ArrivalTargetType) => {
activeArrivalType.value = type
selectFirstArrivalTarget()
}
const handleArrivalTargetSelect = (target: ArrivalSearchTarget) => {
selectedArrivalTargetId.value = target.id
}
const handleArrivalMarkerClick = (markerId: string) => {
const target = activeArrivalTargets.value.find((item) => item.id === markerId)
if (!target) return
selectedArrivalTargetId.value = target.id
}
const closeHomeSearchDock = () => { const closeHomeSearchDock = () => {
homeSearchPanelRef.value?.collapseHomePanel?.() homeSearchPanelRef.value?.collapseHomePanel?.()
homeSearchExpanded.value = false homeSearchExpanded.value = false
@@ -1316,8 +1394,9 @@ const handleMoreOutdoorNav = () => {
return return
} }
resetArrivalPanelState()
showArrivalPanel.value = true showArrivalPanel.value = true
showArrivalPanelCollapsed.value = true showArrivalPanelCollapsed.value = false
showRoutePlanner.value = false showRoutePlanner.value = false
is3DMode.value = false is3DMode.value = false
guideOutdoorState.value = 'home' guideOutdoorState.value = 'home'