Files
frontend-miniapp/src/components/map/TencentMap.vue

679 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<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>