feat: 地图实时显示设备位置,功能代码提交。

This commit is contained in:
tianyongbao
2025-11-07 08:06:43 +08:00
parent a3ff726073
commit a8f0d20761
8 changed files with 780 additions and 6 deletions

View File

@@ -1,5 +1,5 @@
# 页面标题
VITE_APP_TITLE = 海纳云智慧照明管理平台
VITE_APP_TITLE = 智聪物联网管理平台
# 开发环境配置
VITE_APP_ENV = 'development'

View File

@@ -1,5 +1,5 @@
# 页面标题
VITE_APP_TITLE = 海纳云智慧照明管理平台
VITE_APP_TITLE = 智聪物联网管理平台
# 生产环境配置
VITE_APP_ENV = 'production'

View File

@@ -1,5 +1,5 @@
# 页面标题
VITE_APP_TITLE = 海纳云智慧照明管理平台
VITE_APP_TITLE = 智聪物联网管理平台
# 生产环境配置
VITE_APP_ENV = 'staging'

9
src/api/td/tdEngine.js Normal file
View File

@@ -0,0 +1,9 @@
import request from '@/utils/request'
// 获取最新设备传感器数据
export function getLastData() {
return request({
url: '/tdengine/td/getLastData',
method: 'get'
})
}

View File

@@ -1,11 +1,11 @@
import { getAssetsFile } from '@/utils'
export const mapConfig = {
zoom: 13,
zoom: 10,
minZoom: 4,
maxZoom: 18,
pitch: 0,
center: ['120.43325056066504', '36.183868828044005'],
center: ['121.00', '35.80'],
logoVisible: false
}

View File

@@ -11,7 +11,7 @@ import usePermissionStore from '@/store/modules/permission'
NProgress.configure({ showSpinner: false })
const whiteList = ['/login', '/register', '/redirectTo']
const whiteList = ['/login', '/register', '/redirectTo', '/bdsLocation']
router.beforeEach((to, from, next) => {
NProgress.start()

View File

@@ -101,6 +101,12 @@ export const constantRoutes = [
meta: { title: '个人中心', icon: 'user' }
}
]
},
{
path: '/bdsLocation',
component: () => import('@/views/mapTest/index.vue'),
hidden: true,
meta: { title: '北斗上位机实时显示位置' }
}
]

759
src/views/mapTest/index.vue Normal file
View File

@@ -0,0 +1,759 @@
<template>
<div class="map-test-container">
<div id="mapContainer" class="map-container" v-loading="loading" element-loading-text="加载设备数据中..."></div>
<div class="info-panel">
<h3>地图信息</h3>
<p>总点位数: {{ points.length }}</p>
<p>当前缩放级别: {{ currentZoom }}</p>
<div class="legend">
<h4>图例</h4>
<div class="legend-item">
<span class="dot"></span>
<span>设备点位</span>
</div>
</div>
</div>
<!-- 隐藏的SVG定义 -->
<svg width="0" height="0" style="position: absolute;">
<defs>
<filter id="glow">
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
</svg>
<!-- 点位信息弹出框 -->
<el-dialog
v-model="dialogVisible"
title="位置详情"
width="400px"
:close-on-click-modal="true"
>
<div class="point-info">
<div class="info-row">
<span class="label">编号:</span>
<span class="value">{{ selectedPoint.id }}</span>
</div>
<div class="info-row">
<span class="label">上报时间:</span>
<span class="value">{{ selectedPoint.reportTime }}</span>
</div>
<div class="info-row">
<span class="label">经度:</span>
<span class="value">{{ selectedPoint.longitude }}</span>
</div>
<div class="info-row">
<span class="label">纬度:</span>
<span class="value">{{ selectedPoint.latitude }}</span>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup name="MapTest">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { initMap, initLayer, addImage } from '@/utils/map'
import { mapConfig, layerUrl, imageArr } from '@/config/map'
import { PointLayer, Marker } from '@antv/l7'
import { getLastData } from '@/api/td/tdEngine'
import { ElMessage } from 'element-plus'
const scene = ref(null)
const pointLayer = ref(null)
const currentZoom = ref(13)
const points = ref([])
const dialogVisible = ref(false)
const selectedPoint = ref({
id: '',
reportTime: '',
longitude: '',
latitude: ''
})
const loading = ref(false)
let refreshTimer = null // 定时刷新定时器
// 从接口获取设备数据
const fetchDeviceData = async () => {
loading.value = true
try {
const response = await getLastData()
// 转换后端数据格式为前端需要的格式
const devicePoints = response.map((item, index) => {
return {
id: item.deviceId || `PT${String(index + 1).padStart(4, '0')}`,
name: `设备${item.deviceId || index + 1}`,
longitude: item.longitude,
latitude: item.latitude,
reportTime: item.createTime || item.time || new Date().toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}),
status: 'online'
}
})
points.value = devicePoints
console.log('从接口获取到', points.value.length, '个设备点位')
// 如果已经有图层,更新图层数据;否则创建新图层
if (scene.value) {
updatePointLayer()
}
return devicePoints
} catch (error) {
console.error('获取设备数据失败:', error)
ElMessage.error('获取设备数据失败,请稍后重试')
return []
} finally {
loading.value = false
}
}
// 初始化地图
const initializeMap = async () => {
try {
// 从接口获取设备数据
await fetchDeviceData()
// 如果有数据,使用第一个设备的经纬度作为地图中心
let mapCenter = mapConfig.center
if (points.value.length > 0) {
const firstPoint = points.value[0]
mapCenter = [firstPoint.longitude, firstPoint.latitude]
console.log('地图中心设置为:', mapCenter)
}
// 创建地图场景,使用动态中心点
const mapScene = await initMap('mapContainer', {
...mapConfig,
center: mapCenter
})
scene.value = mapScene
// 添加图层
initLayer(mapScene, layerUrl)
// 添加图片资源
addImage(mapScene, imageArr)
// 创建点图层
if (points.value.length > 0) {
createPointLayer()
// 启动定时刷新
startAutoRefresh()
} else {
ElMessage.warning('暂无设备数据')
}
// 监听缩放事件
mapScene.on('zoom', () => {
currentZoom.value = Math.round(mapScene.getZoom())
})
console.log('地图初始化成功,已添加', points.value.length, '个点位')
} catch (error) {
console.error('地图初始化失败:', error)
ElMessage.error('地图初始化失败')
}
}
// 创建3D立体标记图标HTML
const create3DMarkerElement = (point) => {
const element = document.createElement('div')
element.className = 'marker-3d'
element.innerHTML = `
<div class="marker-container">
<div class="pulse-ring"></div>
<div class="outer-glow"></div>
<div class="marker-pin">
<div class="marker-glow"></div>
<div class="inner-shine"></div>
<svg class="marker-icon" viewBox="0 0 24 24" fill="white">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
</svg>
</div>
<div class="marker-shadow"></div>
<div class="status-ring"></div>
</div>
`
// 添加点击事件
element.addEventListener('click', () => {
selectedPoint.value = {
id: point.id,
reportTime: point.reportTime,
longitude: point.longitude.toFixed(6),
latitude: point.latitude.toFixed(6)
}
dialogVisible.value = true
})
return element
}
// 创建点图层(使用Marker实现3D效果)
const createPointLayer = () => {
if (!scene.value || points.value.length === 0) return
const markers = []
// 为每个点创建3D Marker
points.value.forEach(point => {
const element = create3DMarkerElement(point)
const marker = new Marker({
element: element,
anchor: 'bottom'
}).setLnglat([point.longitude, point.latitude])
scene.value.addMarker(marker)
markers.push(marker)
})
// 脉冲动画底层光圈
const pulseLayer = new PointLayer({
name: 'pulsePoints',
zIndex: 100
})
.source(points.value, {
parser: {
type: 'json',
x: 'longitude',
y: 'latitude'
}
})
.shape('circle')
.size(40)
.color('#5B8FF9')
.style({
opacity: 0.15
})
.animate({
enable: true,
type: 'breathe',
duration: 2000,
interval: 0.5,
trailLength: 0.1
})
scene.value.addLayer(pulseLayer)
pointLayer.value = { markers, pulseLayer }
}
// 更新点图层数据
const updatePointLayer = () => {
if (!scene.value || points.value.length === 0) return
// 只更新脉冲图层的数据源,不重建整个图层
if (pointLayer.value && pointLayer.value.pulseLayer) {
pointLayer.value.pulseLayer.setData(points.value)
}
// 更新现有markers的位置和数据
if (pointLayer.value && pointLayer.value.markers) {
const oldMarkers = pointLayer.value.markers
const newPoints = points.value
// 如果点位数量相同,只更新位置
if (oldMarkers.length === newPoints.length) {
oldMarkers.forEach((marker, index) => {
const newPoint = newPoints[index]
if (newPoint) {
// 更新位置
marker.setLnglat([newPoint.longitude, newPoint.latitude])
// 更新点击事件数据
const element = marker.getElement()
element.onclick = () => {
selectedPoint.value = {
id: newPoint.id,
reportTime: newPoint.reportTime,
longitude: newPoint.longitude.toFixed(6),
latitude: newPoint.latitude.toFixed(6)
}
dialogVisible.value = true
}
}
})
console.log('已无感更新', newPoints.length, '个点位位置')
} else {
// 点位数量变化时才完全重建
rebuildPointLayer()
}
} else {
// 首次创建或图层不存在时
createPointLayer()
}
}
// 完全重建点图层(仅在点位数量变化时使用)
const rebuildPointLayer = () => {
if (!scene.value) return
// 清除旧的markers和图层
if (pointLayer.value) {
if (pointLayer.value.markers) {
pointLayer.value.markers.forEach(marker => {
marker.remove()
})
}
if (pointLayer.value.pulseLayer) {
scene.value.removeLayer(pointLayer.value.pulseLayer)
}
}
// 重新创建图层
createPointLayer()
console.log('点位数量变化,已重建图层')
}
// 启动定时刷新
const startAutoRefresh = () => {
// 清除已存在的定时器
if (refreshTimer) {
clearInterval(refreshTimer)
}
// 每10秒刷新一次数据
refreshTimer = setInterval(() => {
console.log('定时刷新设备数据...')
fetchDeviceData()
}, 10000)
console.log('已启动自动刷新每10秒更新一次')
}
// 停止定时刷新
const stopAutoRefresh = () => {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
console.log('已停止自动刷新')
}
}
onMounted(() => {
initializeMap()
})
onBeforeUnmount(() => {
// 停止定时刷新
stopAutoRefresh()
if (pointLayer.value) {
// 清理markers
if (pointLayer.value.markers) {
pointLayer.value.markers.forEach(marker => {
marker.remove() // 使用marker.remove()方法移除标记
})
}
// 清理图层
if (pointLayer.value.pulseLayer) {
scene.value?.removeLayer(pointLayer.value.pulseLayer)
}
}
if (scene.value) {
scene.value.destroy()
}
})
</script>
<style scoped lang="scss">
.map-test-container {
width: 100%;
height: 100vh;
position: relative;
.map-container {
width: 100%;
height: 100%;
}
.info-panel {
position: absolute;
top: 20px;
right: 20px;
background: rgba(255, 255, 255, 0.95);
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
min-width: 220px;
backdrop-filter: blur(10px);
h3 {
margin: 0 0 15px 0;
font-size: 16px;
color: #333;
border-bottom: 2px solid #00D9FF;
padding-bottom: 10px;
}
p {
margin: 8px 0;
font-size: 14px;
color: #666;
&:first-of-type {
font-weight: bold;
color: #00D9FF;
font-size: 16px;
}
}
.legend {
margin-top: 20px;
padding-top: 15px;
border-top: 1px solid #e8e8e8;
h4 {
margin: 0 0 12px 0;
font-size: 14px;
color: #666;
}
.legend-item {
display: flex;
align-items: center;
margin-bottom: 10px;
font-size: 13px;
color: #666;
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 8px;
border: 2px solid #fff;
background-color: #5B8FF9;
box-shadow: 0 0 6px rgba(91, 143, 249, 0.5);
animation: pulse 2s ease-in-out infinite;
}
}
}
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
}
}
// 3D标记样式
:deep(.marker-3d) {
cursor: pointer;
transform-origin: center bottom;
.marker-container {
position: relative;
width: 40px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
}
.pulse-ring {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 50px;
height: 50px;
border: 2px solid #5B8FF9;
border-radius: 50%;
opacity: 0;
animation: pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.outer-glow {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 42px;
height: 42px;
border-radius: 50%;
background: radial-gradient(circle, rgba(91, 143, 249, 0.4) 0%, transparent 70%);
animation: glow-pulse 2s ease-in-out infinite;
}
.marker-pin {
position: relative;
width: 28px;
height: 28px;
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
background: linear-gradient(135deg, #5B8FF9 0%, #7BA3FF 100%);
border: 2px solid rgba(255, 255, 255, 0.95);
box-shadow:
0 3px 6px rgba(0, 0, 0, 0.3),
0 6px 12px rgba(0, 0, 0, 0.2),
0 0 0 1px rgba(255, 255, 255, 0.1),
inset 0 -2px 3px rgba(0, 0, 0, 0.2),
inset 0 2px 3px rgba(255, 255, 255, 0.3);
transition: all 0.3s ease;
z-index: 2;
}
.marker-glow {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 120%;
height: 120%;
border-radius: 50%;
box-shadow: 0 0 20px rgba(91, 143, 249, 0.5), 0 0 35px rgba(91, 143, 249, 0.3);
animation: glow-pulse 2s ease-in-out infinite;
}
.inner-shine {
position: absolute;
top: 20%;
left: 20%;
width: 40%;
height: 40%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.6) 0%, transparent 70%);
border-radius: 50%;
transform: rotate(45deg);
}
.marker-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(45deg);
width: 16px;
height: 16px;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3));
}
.marker-shadow {
position: absolute;
bottom: -5px;
left: 50%;
transform: translateX(-50%);
width: 20px;
height: 8px;
background: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.35) 0%, transparent 70%);
border-radius: 50%;
animation: shadow-pulse 3s ease-in-out infinite;
}
.status-ring {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 38px;
height: 38px;
border: 2px solid #5B8FF9;
border-radius: 50%;
opacity: 0.4;
animation: status-ring-pulse 2s ease-in-out infinite;
}
&:hover {
.marker-pin {
transform: rotate(-45deg) scale(1.1);
box-shadow:
0 4px 8px rgba(0, 0, 0, 0.4),
0 8px 16px rgba(0, 0, 0, 0.3),
0 0 0 2px rgba(255, 255, 255, 0.2),
inset 0 -2px 3px rgba(0, 0, 0, 0.2),
inset 0 2px 3px rgba(255, 255, 255, 0.4);
}
.status-ring {
animation: status-ring-hover 0.6s ease-in-out infinite;
}
}
@keyframes pulse-ring {
0% {
transform: translate(-50%, -50%) scale(0.75);
opacity: 0.8;
}
50% {
transform: translate(-50%, -50%) scale(1.2);
opacity: 0.4;
}
100% {
transform: translate(-50%, -50%) scale(1.6);
opacity: 0;
}
}
@keyframes glow-pulse {
0%, 100% {
opacity: 0.6;
transform: translate(-50%, -50%) scale(1);
}
50% {
opacity: 0.9;
transform: translate(-50%, -50%) scale(1.08);
}
}
@keyframes shadow-pulse {
0%, 100% {
transform: translateX(-50%) scale(1);
opacity: 0.4;
}
50% {
transform: translateX(-50%) scale(0.88);
opacity: 0.25;
}
}
@keyframes status-ring-pulse {
0%, 100% {
transform: translate(-50%, -50%) scale(1);
opacity: 0.5;
}
50% {
transform: translate(-50%, -50%) scale(1.12);
opacity: 0.25;
}
}
@keyframes status-ring-hover {
0%, 100% {
transform: translate(-50%, -50%) scale(1.08);
opacity: 0.7;
}
50% {
transform: translate(-50%, -50%) scale(1.2);
opacity: 0.35;
}
}
}
// 点位信息弹出框样式
:deep(.el-dialog) {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
.el-dialog__header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 24px 30px;
margin: 0;
border-bottom: none;
.el-dialog__title {
color: #fff;
font-size: 18px;
font-weight: 600;
letter-spacing: 0.5px;
}
.el-dialog__headerbtn {
top: 24px;
right: 30px;
width: 32px;
height: 32px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
transition: all 0.3s ease;
&:hover {
background: rgba(255, 255, 255, 0.3);
transform: rotate(90deg);
}
.el-dialog__close {
color: #fff;
font-size: 18px;
font-weight: bold;
}
}
}
.el-dialog__body {
padding: 32px 30px;
background: #fff;
}
}
.point-info {
.info-row {
display: flex;
align-items: center;
margin-bottom: 18px;
padding: 16px 18px;
background: linear-gradient(135deg, #f8f9fa 0%, #f1f3f5 100%);
border-radius: 8px;
border-left: 4px solid #667eea;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 0;
background: linear-gradient(90deg, rgba(102, 126, 234, 0.08) 0%, transparent 100%);
transition: width 0.3s ease;
}
&:last-child {
margin-bottom: 0;
}
&:hover {
background: linear-gradient(135deg, #fff 0%, #f8f9fa 100%);
transform: translateX(4px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
border-left-color: #764ba2;
&::before {
width: 100%;
}
}
.label {
font-weight: 600;
color: #495057;
min-width: 95px;
font-size: 14px;
display: flex;
align-items: center;
position: relative;
&::after {
content: ':';
margin-left: 4px;
color: #667eea;
}
}
.value {
color: #212529;
font-size: 14px;
flex: 1;
word-break: break-all;
font-family: 'Consolas', 'Monaco', monospace;
letter-spacing: 0.3px;
}
}
}
</style>