679 lines
17 KiB
Vue
679 lines
17 KiB
Vue
<template>
|
||
<view ref="mapComponentRef" class="map-component">
|
||
<map
|
||
id="museum-map"
|
||
class="map"
|
||
:latitude="mapCenter.latitude"
|
||
:longitude="mapCenter.longitude"
|
||
:scale="mapScale"
|
||
:markers="markers"
|
||
:polygons="polygons"
|
||
:polylines="displayPolylines"
|
||
:show-location="!isH5"
|
||
:enable-3D="false"
|
||
:enable-overlooking="false"
|
||
:enable-zoom="true"
|
||
:enable-scroll="true"
|
||
:enable-rotate="false"
|
||
@markertap="handleMarkerTap"
|
||
@regionchange="handleRegionChange"
|
||
@tap="handleMapTap"
|
||
>
|
||
<!-- 自定义控件 -->
|
||
<cover-view class="map-controls">
|
||
<!-- 楼层选择器 -->
|
||
<cover-view class="floor-selector-wrapper">
|
||
<cover-view class="floor-selector">
|
||
<cover-view
|
||
v-for="floor in floors"
|
||
:key="floor.id"
|
||
class="floor-item"
|
||
:class="{ active: currentFloor === floor.id }"
|
||
@tap="handleFloorClick(floor.id)"
|
||
>
|
||
<cover-view class="floor-label">{{ floor.label }}</cover-view>
|
||
</cover-view>
|
||
</cover-view>
|
||
</cover-view>
|
||
|
||
<!-- 功能按钮 -->
|
||
<cover-view class="action-buttons">
|
||
<!-- 进入 3D 馆内模式按钮 -->
|
||
<cover-view class="action-btn indoor-mode-btn" @tap="handleEnter3D">
|
||
<cover-view class="btn-text">🏛</cover-view>
|
||
<cover-view class="btn-sub-text">馆内</cover-view>
|
||
</cover-view>
|
||
<cover-view class="action-btn" @tap="handleLocation">
|
||
<cover-view class="btn-text">📍</cover-view>
|
||
</cover-view>
|
||
<cover-view class="action-btn" @tap="handleZoomIn">
|
||
<cover-view class="btn-text">+</cover-view>
|
||
</cover-view>
|
||
<cover-view class="action-btn" @tap="handleZoomOut">
|
||
<cover-view class="btn-text">-</cover-view>
|
||
</cover-view>
|
||
</cover-view>
|
||
</cover-view>
|
||
</map>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref, onBeforeUnmount, onMounted, nextTick, watch } from 'vue'
|
||
import { getMuseumEntranceLocation } from '@/services/tencent/TencentMapService'
|
||
|
||
const isH5 = typeof window !== 'undefined'
|
||
|
||
interface MapCenter {
|
||
latitude: number
|
||
longitude: number
|
||
}
|
||
|
||
interface Marker {
|
||
id: number
|
||
latitude: number
|
||
longitude: number
|
||
iconPath: string
|
||
width: number
|
||
height: number
|
||
title?: string
|
||
callout?: {
|
||
content: string
|
||
display: 'ALWAYS' | 'BYCLICK'
|
||
padding: number
|
||
borderRadius: number
|
||
bgColor: string
|
||
color: string
|
||
fontSize: number
|
||
}
|
||
}
|
||
|
||
interface Polygon {
|
||
points: Array<{ latitude: number; longitude: number }>
|
||
strokeWidth: number
|
||
strokeColor: string
|
||
fillColor: string
|
||
zIndex: number
|
||
}
|
||
|
||
interface Polyline {
|
||
points: Array<{ latitude: number; longitude: number }>
|
||
color: string
|
||
width: number
|
||
dottedLine?: boolean
|
||
}
|
||
|
||
export interface OutdoorMapMarker {
|
||
id: string
|
||
title: string
|
||
subtitle?: string
|
||
latitude: number
|
||
longitude: number
|
||
}
|
||
|
||
interface MapFloor {
|
||
id: string
|
||
label: string
|
||
}
|
||
|
||
const props = withDefaults(defineProps<{
|
||
activeFloor?: string
|
||
floors?: MapFloor[]
|
||
polylines?: Polyline[]
|
||
outdoorMarkers?: OutdoorMapMarker[]
|
||
activeOutdoorMarkerId?: string
|
||
outdoorFocusOffsetY?: number
|
||
}>(), {
|
||
activeFloor: '1F',
|
||
floors: () => [] as MapFloor[],
|
||
polylines: () => [] as Polyline[],
|
||
outdoorMarkers: () => [] as OutdoorMapMarker[],
|
||
activeOutdoorMarkerId: '',
|
||
outdoorFocusOffsetY: 0
|
||
})
|
||
|
||
const emit = defineEmits<{
|
||
markerClick: [markerId: number]
|
||
outdoorMarkerClick: [markerId: string]
|
||
floorChange: [floor: string]
|
||
enter3DMode: []
|
||
mapTap: [location: { latitude: number; longitude: number }]
|
||
}>()
|
||
|
||
// 深圳自然博物馆实际坐标
|
||
// POI ID: 7043042949572197975
|
||
const mapCenter = ref<MapCenter>({
|
||
latitude: 22.692763, // 深圳自然博物馆纬度
|
||
longitude: 114.363572 // 深圳自然博物馆经度(调整到馆外 2D 视觉中心)
|
||
})
|
||
|
||
// 地图缩放级别(调整为适中比例,既能看到建筑全貌又能看清细节)
|
||
const mapScale = ref(17)
|
||
|
||
// 当前楼层
|
||
const currentFloor = ref(props.activeFloor)
|
||
const mapComponentRef = ref<unknown>(null)
|
||
const userLocationMarker = ref<Marker | null>(null)
|
||
let mapAuthErrorObserver: MutationObserver | null = null
|
||
let mapAuthErrorLogged = false
|
||
const mapAuthErrorText = '鉴权失败,请传入正确的key'
|
||
const OUTDOOR_MARKER_ID_BASE = 20000
|
||
|
||
// 楼层列表
|
||
const floors = computed(() => props.floors)
|
||
|
||
// 博物馆建筑围栏(多边形)
|
||
// 深圳自然博物馆实际建筑轮廓坐标
|
||
const polygons = ref<Polygon[]>([
|
||
{
|
||
points: [
|
||
{ latitude: 22.693738, longitude: 114.362331 },
|
||
{ latitude: 22.69358, longitude: 114.362239 },
|
||
{ latitude: 22.693513, longitude: 114.362239 },
|
||
{ latitude: 22.691953, longitude: 114.362252 },
|
||
{ latitude: 22.692106, longitude: 114.365058 },
|
||
{ latitude: 22.692167, longitude: 114.365051 },
|
||
{ latitude: 22.693129, longitude: 114.365018 },
|
||
{ latitude: 22.693135, longitude: 114.364655 },
|
||
{ latitude: 22.693196, longitude: 114.363982 },
|
||
{ latitude: 22.693232, longitude: 114.363764 },
|
||
{ latitude: 22.693348, longitude: 114.363473 },
|
||
{ latitude: 22.693476, longitude: 114.36319 },
|
||
{ latitude: 22.693653, longitude: 114.362714 },
|
||
{ latitude: 22.693701, longitude: 114.362523 },
|
||
{ latitude: 22.693738, longitude: 114.362351 },
|
||
],
|
||
strokeWidth: 3,
|
||
strokeColor: '#E0E100', // 黄色边框
|
||
fillColor: '#E0E10020', // 半透明黄色填充
|
||
zIndex: 1
|
||
}
|
||
])
|
||
|
||
// 博物馆标记点(初始占位,API 获取后更新)
|
||
const museumMarker = ref<Marker>({
|
||
id: 0,
|
||
latitude: 22.692763,
|
||
longitude: 114.363487,
|
||
iconPath: '/static/icons/marker-museum.svg',
|
||
width: 40,
|
||
height: 40,
|
||
title: '深圳自然博物馆',
|
||
callout: {
|
||
content: '深圳自然博物馆',
|
||
display: 'ALWAYS',
|
||
padding: 12,
|
||
borderRadius: 6,
|
||
bgColor: '#E0E100',
|
||
color: '#000000',
|
||
fontSize: 14
|
||
}
|
||
})
|
||
|
||
// 主入口标记点
|
||
const entranceMarker = ref<Marker>({
|
||
id: 1,
|
||
latitude: 22.692363,
|
||
longitude: 114.363487,
|
||
iconPath: '/static/icons/marker-entrance.svg',
|
||
width: 30,
|
||
height: 30,
|
||
title: '主入口',
|
||
callout: {
|
||
content: '主入口',
|
||
display: 'BYCLICK',
|
||
padding: 10,
|
||
borderRadius: 5,
|
||
bgColor: '#000000',
|
||
color: '#FFFFFF',
|
||
fontSize: 12
|
||
}
|
||
})
|
||
|
||
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,
|
||
...outdoorDisplayMarkers.value,
|
||
...(userLocationMarker.value ? [userLocationMarker.value] : [])
|
||
])
|
||
|
||
// 显示的路线列表(转换格式以适配 uni-app map 组件)
|
||
const displayPolylines = computed(() => {
|
||
return props.polylines.map((polyline, index) => ({
|
||
...polyline,
|
||
id: index,
|
||
// 确保点格式正确
|
||
points: polyline.points.map((p) => ({
|
||
latitude: p.latitude,
|
||
longitude: p.longitude
|
||
}))
|
||
}))
|
||
})
|
||
|
||
/**
|
||
* 更新用户位置标记
|
||
*/
|
||
const updateUserLocation = (location: { latitude: number; longitude: number }) => {
|
||
userLocationMarker.value = {
|
||
id: 999,
|
||
latitude: location.latitude,
|
||
longitude: location.longitude,
|
||
iconPath: '/static/icons/marker-user-location.svg',
|
||
width: 40,
|
||
height: 40,
|
||
title: '我的位置',
|
||
callout: {
|
||
content: '您在这里',
|
||
display: 'BYCLICK',
|
||
padding: 10,
|
||
borderRadius: 5,
|
||
bgColor: '#000000',
|
||
color: '#FFFFFF',
|
||
fontSize: 12
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清除用户位置标记
|
||
*/
|
||
const clearUserLocation = () => {
|
||
userLocationMarker.value = null
|
||
}
|
||
|
||
/**
|
||
* 移动地图中心到指定位置
|
||
*/
|
||
const moveTo = (location: { latitude: number; longitude: number }) => {
|
||
mapCenter.value = {
|
||
latitude: location.latitude,
|
||
longitude: location.longitude
|
||
}
|
||
}
|
||
|
||
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({
|
||
updateUserLocation,
|
||
clearUserLocation,
|
||
moveTo
|
||
})
|
||
|
||
// 处理标记点点击
|
||
const handleMarkerTap = (e: any) => {
|
||
const markerId = Number(e.detail?.markerId ?? e.markerId)
|
||
console.log('点击标记点:', markerId)
|
||
const outdoorMarkerId = outdoorMarkerIdMap.value.get(markerId)
|
||
if (outdoorMarkerId) {
|
||
emit('outdoorMarkerClick', outdoorMarkerId)
|
||
}
|
||
emit('markerClick', markerId)
|
||
}
|
||
|
||
// 处理地图区域变化
|
||
const handleRegionChange = (e: any) => {
|
||
if (e.type === 'end') {
|
||
console.log('地图区域变化:', e.detail)
|
||
}
|
||
}
|
||
|
||
// 处理地图点击
|
||
const handleMapTap = (e: any) => {
|
||
console.log('点击地图:', e.detail)
|
||
const { latitude, longitude } = e.detail || {}
|
||
if (latitude !== undefined && longitude !== undefined) {
|
||
emit('mapTap', { latitude, longitude })
|
||
}
|
||
}
|
||
|
||
// 处理楼层点击
|
||
const handleFloorClick = (floorId: string) => {
|
||
currentFloor.value = floorId
|
||
emit('floorChange', floorId)
|
||
console.log('切换楼层:', floorId)
|
||
}
|
||
|
||
// 定位到当前位置
|
||
const handleLocation = () => {
|
||
uni.getLocation({
|
||
type: 'gcj02',
|
||
success: (res) => {
|
||
mapCenter.value = {
|
||
latitude: res.latitude,
|
||
longitude: res.longitude
|
||
}
|
||
|
||
// 添加或更新用户位置标记
|
||
userLocationMarker.value = {
|
||
id: 999,
|
||
latitude: res.latitude,
|
||
longitude: res.longitude,
|
||
iconPath: '/static/icons/marker-user-location.svg',
|
||
width: 40,
|
||
height: 40,
|
||
title: '我的位置',
|
||
callout: {
|
||
content: '您在这里',
|
||
display: 'BYCLICK',
|
||
padding: 10,
|
||
borderRadius: 5,
|
||
bgColor: '#000000',
|
||
color: '#FFFFFF',
|
||
fontSize: 12
|
||
}
|
||
}
|
||
|
||
console.log('定位成功:', res)
|
||
},
|
||
fail: (err) => {
|
||
console.error('定位失败:', err)
|
||
uni.showToast({
|
||
title: '定位失败',
|
||
icon: 'none'
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
// 放大地图
|
||
const handleZoomIn = () => {
|
||
if (mapScale.value < 20) {
|
||
mapScale.value += 1
|
||
console.log('放大地图,当前缩放级别:', mapScale.value)
|
||
}
|
||
}
|
||
|
||
// 缩小地图
|
||
const handleZoomOut = () => {
|
||
if (mapScale.value > 3) {
|
||
mapScale.value -= 1
|
||
console.log('缩小地图,当前缩放级别:', mapScale.value)
|
||
}
|
||
}
|
||
|
||
// 进入 3D 馆内模式
|
||
const handleEnter3D = () => {
|
||
console.log('进入 3D 馆内模式')
|
||
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 rawRef = mapComponentRef.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>('.map-component')
|
||
}
|
||
|
||
const hideAuthErrorElement = (element: Element) => {
|
||
const target = element instanceof HTMLElement ? element : element.parentElement
|
||
if (!target) return
|
||
|
||
target.style.setProperty('display', 'none', 'important')
|
||
target.style.setProperty('visibility', 'hidden', 'important')
|
||
target.style.setProperty('opacity', '0', 'important')
|
||
target.style.setProperty('pointer-events', 'none', 'important')
|
||
target.setAttribute('aria-hidden', 'true')
|
||
}
|
||
|
||
const hasDirectAuthErrorText = (element: Element) => {
|
||
return Array.from(element.childNodes).some((node) => (
|
||
node.nodeType === Node.TEXT_NODE
|
||
&& (node.textContent || '').trim() === mapAuthErrorText
|
||
))
|
||
}
|
||
|
||
const detectAndHideMapAuthError = () => {
|
||
const root = getMapComponentElement()
|
||
if (!root) return
|
||
|
||
const text = root.textContent || ''
|
||
if (!text.includes('鉴权失败')) return
|
||
|
||
root.querySelectorAll('*').forEach((element) => {
|
||
const elementText = (element.textContent || '').trim()
|
||
if (elementText === mapAuthErrorText && hasDirectAuthErrorText(element)) {
|
||
hideAuthErrorElement(element)
|
||
}
|
||
})
|
||
|
||
if (!mapAuthErrorLogged) {
|
||
mapAuthErrorLogged = true
|
||
console.error('腾讯地图鉴权失败,已隐藏页面内置错误提示:', text.trim())
|
||
}
|
||
}
|
||
|
||
const startMapAuthErrorMonitor = () => {
|
||
if (typeof window === 'undefined') return
|
||
|
||
void nextTick(() => {
|
||
detectAndHideMapAuthError()
|
||
|
||
const root = getMapComponentElement()
|
||
if (!root || typeof MutationObserver === 'undefined') return
|
||
|
||
mapAuthErrorObserver = new MutationObserver(() => {
|
||
detectAndHideMapAuthError()
|
||
})
|
||
mapAuthErrorObserver.observe(root, {
|
||
childList: true,
|
||
subtree: true,
|
||
characterData: true
|
||
})
|
||
|
||
window.setTimeout(detectAndHideMapAuthError, 600)
|
||
window.setTimeout(detectAndHideMapAuthError, 1600)
|
||
window.setTimeout(detectAndHideMapAuthError, 3000)
|
||
})
|
||
}
|
||
|
||
onMounted(async () => {
|
||
startMapAuthErrorMonitor()
|
||
console.log('地图组件已挂载,开始获取入口位置...')
|
||
|
||
// 从腾讯地图 API 获取入口位置
|
||
const entranceLocation = await getMuseumEntranceLocation()
|
||
|
||
if (entranceLocation) {
|
||
entranceMarker.value = {
|
||
...entranceMarker.value,
|
||
latitude: entranceLocation.lat,
|
||
longitude: entranceLocation.lng
|
||
}
|
||
console.log('已更新入口位置:', entranceLocation)
|
||
}
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
mapAuthErrorObserver?.disconnect()
|
||
mapAuthErrorObserver = null
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.map-component {
|
||
width: 100%;
|
||
height: 100%;
|
||
position: relative;
|
||
background: var(--museum-bg-map);
|
||
}
|
||
|
||
.map {
|
||
width: 100%;
|
||
height: 100%;
|
||
background: var(--museum-bg-map);
|
||
}
|
||
|
||
.map-controls {
|
||
display: none;
|
||
}
|
||
|
||
.floor-selector-wrapper {
|
||
position: absolute;
|
||
right: 12px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.floor-selector {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
background-color: rgba(255, 255, 255, 0.8);
|
||
border-radius: 8px;
|
||
padding: 8px;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||
}
|
||
|
||
.floor-item {
|
||
width: 46px;
|
||
height: 44px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background-color: transparent;
|
||
border-radius: 8px;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.floor-item.active {
|
||
background-color: #000000;
|
||
}
|
||
|
||
.floor-label {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: #424754;
|
||
}
|
||
|
||
.floor-item.active .floor-label {
|
||
color: #E0E100;
|
||
}
|
||
|
||
.action-buttons {
|
||
position: absolute;
|
||
right: 12px;
|
||
bottom: 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-end;
|
||
gap: 12px;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.action-btn {
|
||
width: 46px;
|
||
height: 46px;
|
||
background-color: rgba(255, 255, 255, 0.9);
|
||
border-radius: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||
}
|
||
|
||
.btn-text {
|
||
font-size: 24px;
|
||
font-weight: 500;
|
||
color: #333333;
|
||
}
|
||
|
||
// 馆内模式按钮
|
||
.indoor-mode-btn {
|
||
width: 46px;
|
||
height: 64px !important;
|
||
background-color: #E0E100;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
|
||
.btn-text {
|
||
font-size: 20px;
|
||
}
|
||
|
||
.btn-sub-text {
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
color: #000000;
|
||
}
|
||
}
|
||
</style>
|