oldMap.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. <template>
  2. <div class="map-container">
  3. <div class="status-info">
  4. <div class="status-item">
  5. <span class="online-icon"></span>
  6. <span style="font-size: 17px; font-weight: bold">在线数量 {{ inlineCount }}</span>
  7. </div>
  8. <div class="status-item">
  9. <span class="offline-icon"></span>
  10. <span style="font-size: 17px; font-weight: bold">离线数量 {{ outlineCount }}</span>
  11. </div>
  12. </div>
  13. <div id="baidu-map" ref="mapContainer"></div>
  14. <div class="map-controls">
  15. <button @click="zoomIn">{{ t('map.amplify') }}</button>
  16. <button @click="zoomOut">{{ t('map.reduce') }}</button>
  17. <button @click="toggleMapType"
  18. >{{ t('map.SwitchMapType') }}({{ mapType === 'BMAP_NORMAL_MAP' ? '地图' : '卫星' }})</button
  19. >
  20. <el-tree-select
  21. class="my-el-select"
  22. v-model="queryParams.deptId"
  23. :data="treeList"
  24. :props="defaultProps"
  25. check-strictly
  26. node-key="id"
  27. :placeholder="t('deviceForm.deptHolder')"
  28. filterable
  29. @change="getData"
  30. />
  31. </div>
  32. </div>
  33. <DeviceMonitorDrawer
  34. :model-value="drawerVisible"
  35. @update:model-value="(val) => (drawerVisible = val)"
  36. :id="deviceId"
  37. :deviceName="deviceName"
  38. :lastLineTime="lastLineTime"
  39. :ifLine="ifLine"
  40. :dept="dept"
  41. :deviceCode="deviceCode"
  42. ref="showDrawer"
  43. />
  44. </template>
  45. <script setup lang="ts">
  46. import { onBeforeUnmount, onMounted, ref } from 'vue'
  47. import { DICT_TYPE, getDictLabel } from '@/utils/dict'
  48. import { IotDeviceApi, IotDeviceVO } from '@/api/pms/device'
  49. import DeviceMonitorDrawer from '@/views/pms/map/DeviceMonitorDrawer.vue'
  50. import * as DeptApi from '@/api/system/dept'
  51. import { defaultProps, handleTree } from '@/utils/tree'
  52. interface Cluster {
  53. lng: number
  54. lat: number
  55. count: number
  56. devices?: IotDeviceVO[]
  57. }
  58. defineOptions({ name: 'DeviceMap' })
  59. const showDrawer = ref()
  60. const drawerVisible = ref<boolean>(false)
  61. const mapContainer = ref<HTMLElement | null>(null)
  62. const map = ref<any>(null)
  63. const mapType = ref<'BMAP_NORMAL_MAP' | 'BMAP_SATELLITE_MAP'>('BMAP_NORMAL_MAP')
  64. const clusters = ref<Cluster[]>([])
  65. const deviceId = ref()
  66. const deviceName = ref('')
  67. const lastLineTime = ref('')
  68. const ifLine = ref()
  69. const dept = ref('')
  70. const deviceCode = ref('')
  71. const { t } = useI18n() // 国际化
  72. const inlineCount = ref(0)
  73. const outlineCount = ref(0)
  74. // 设备数据示例
  75. const devices = ref<IotDeviceVO[]>()
  76. const treeList = ref<Tree[]>([]) // 树形结构
  77. // 初始化地图
  78. const initMap = () => {
  79. if (!mapContainer.value) return
  80. const script = document.createElement('script')
  81. script.src = `https://api.map.baidu.com/api?v=3.0&ak=c0crhdxQ5H7WcqbcazGr7mnHrLa4GmO0&type=webgl`
  82. script.async = true
  83. script.onload = () => {
  84. if ((window as any).BMap) {
  85. map.value = new (window as any).BMap.Map(mapContainer.value)
  86. const point = new (window as any).BMap.Point(104.114129, 37.550339)
  87. map.value.centerAndZoom(point, 5)
  88. map.value.enableScrollWheelZoom(true)
  89. map.value.setMapType((window as any)[mapType.value])
  90. map.value.addControl(new (window as any).BMap.NavigationControl())
  91. map.value.addControl(new (window as any).BMap.ScaleControl())
  92. initDeviceMarkers()
  93. map.value.addEventListener('zoomend', () => {
  94. initDeviceMarkers()
  95. })
  96. } else {
  97. console.error('百度地图API加载失败')
  98. }
  99. }
  100. script.onerror = () => {
  101. console.error('百度地图API加载失败')
  102. }
  103. document.head.appendChild(script)
  104. }
  105. const initDeviceMarkers = () => {
  106. if (!map.value) return
  107. map.value.clearOverlays()
  108. const zoomLevel = map.value.getZoom()
  109. if (zoomLevel > 8) {
  110. debugger
  111. // 高缩放级别下显示单个设备标记
  112. devices.value.forEach((device) => {
  113. const point = new (window as any).BMap.Point(device.lng, device.lat)
  114. const marker = createDeviceMarker(device, point)
  115. map.value.addOverlay(marker)
  116. })
  117. } else {
  118. debugger
  119. // 低缩放级别下进行聚合
  120. clusters.value = clusterDevices(devices.value, map.value)
  121. clusters.value.forEach((cluster) => {
  122. if (cluster.count === 1) {
  123. // 只有一个设备时显示设备图标
  124. const device = cluster.devices?.[0]
  125. if (device) {
  126. const point = new (window as any).BMap.Point(device.lng, device.lat)
  127. const marker = createDeviceMarker(device, point)
  128. map.value.addOverlay(marker)
  129. }
  130. } else if (cluster.count > 1) {
  131. // 多个设备时显示聚合标签
  132. const point = new (window as any).BMap.Point(cluster.lng, cluster.lat)
  133. const label = createClusterLabel(cluster, point)
  134. map.value.addOverlay(label)
  135. }
  136. })
  137. }
  138. }
  139. const createDeviceMarker = (device: IotDeviceVO, point: any) => {
  140. // 根据设备是否在线选择不同的图标
  141. const iconUrl =
  142. device.ifInline === 3
  143. ? import.meta.env.VITE_BASE_URL + '/images/newding.svg'
  144. : import.meta.env.VITE_BASE_URL + '/images/newfail.svg'
  145. debugger
  146. const marker = new (window as any).BMap.Marker(point, {
  147. icon: new (window as any).BMap.Icon(iconUrl, new (window as any).BMap.Size(40, 47), {
  148. anchor: new (window as any).BMap.Size(25, 40)
  149. // imageOffset: new (window as any).BMap.Size(0, -5)
  150. })
  151. })
  152. // 添加点击事件
  153. marker.addEventListener('click', () => {
  154. showDeviceInfoWindow(device, point)
  155. })
  156. return marker
  157. }
  158. const createClusterLabel = (cluster: Cluster, point: any) => {
  159. const label = new (window as any).BMap.Label(cluster.count.toString(), {
  160. position: point,
  161. offset: new (window as any).BMap.Size(-10, -10)
  162. })
  163. // 创建一个 style 标签并添加到 head 中,定义呼吸动画
  164. const style = document.createElement('style')
  165. style.textContent = `
  166. @keyframes breathing {
  167. 0% {
  168. border-color: rgba(255, 255, 255, 0.3);
  169. }
  170. 50% {
  171. border-color: rgba(255, 255, 255, 0.8);
  172. }
  173. 100% {
  174. border-color: rgba(255, 255, 255, 0.3);
  175. }
  176. }
  177. `
  178. document.head.appendChild(style)
  179. // 根据 cluster.count 的值设置不同的背景颜色
  180. const backgroundColor = cluster.count > 10 ? '#1d4eed' : '#f67d1a'
  181. // 初始样式
  182. label.setStyle({
  183. color: '#fff',
  184. backgroundColor: backgroundColor,
  185. borderRadius: '50%',
  186. width: '50px',
  187. height: '50px',
  188. textAlign: 'center',
  189. lineHeight: '40px',
  190. cursor: 'pointer',
  191. fontSize: '18px',
  192. fontWeight: 'bold',
  193. boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)',
  194. transition: 'all 0.3s ease',
  195. border: '6px solid rgba(255, 255, 255, 0.5)', // 添加带有不透明度的边框
  196. animation: 'breathing 1.5s infinite' // 添加呼吸动画
  197. })
  198. // 鼠标悬停样式
  199. const hoverStyle = {
  200. backgroundColor: '#2196df',
  201. transform: 'scale(1.1)'
  202. }
  203. // 鼠标移出样式
  204. const normalStyle = {
  205. backgroundColor: backgroundColor,
  206. transform: 'scale(1)'
  207. }
  208. // 添加点击事件
  209. label.addEventListener('click', () => {
  210. if (map.value) {
  211. const currentZoom = map.value.getZoom()
  212. const MAX_ZOOM = 12 // 手动设定最大缩放级别
  213. const newZoom = Math.min(currentZoom + 2, MAX_ZOOM)
  214. map.value.setZoom(newZoom)
  215. map.value.panTo(point)
  216. initDeviceMarkers() // 重新计算并显示标记和聚合标签
  217. }
  218. })
  219. return label
  220. }
  221. const clusterDevices = (devices: IotDeviceVO[], map: any): Cluster[] => {
  222. const clusters: Cluster[] = []
  223. const gridSize = getGridSize(map.getZoom())
  224. const gridMap = new Map<string, Cluster>()
  225. devices.forEach((device) => {
  226. const gridKey = `${Math.floor(device.lng / gridSize)}_${Math.floor(device.lat / gridSize)}`
  227. if (!gridMap.has(gridKey)) {
  228. gridMap.set(gridKey, {
  229. lng: Math.floor(device.lng / gridSize) * gridSize + gridSize / 2,
  230. lat: Math.floor(device.lat / gridSize) * gridSize + gridSize / 2,
  231. count: 0,
  232. devices: []
  233. })
  234. }
  235. const cluster = gridMap.get(gridKey)!
  236. cluster.count++
  237. if (!cluster.devices) cluster.devices = []
  238. cluster.devices.push(device)
  239. })
  240. return Array.from(gridMap.values())
  241. }
  242. const getGridSize = (zoom: number): number => {
  243. debugger
  244. if (zoom <= 5) return 5
  245. if (zoom <= 8) return 3
  246. if (zoom < 10) return 0.1
  247. return 0.1
  248. }
  249. const showDeviceInfoWindow = (device: IotDeviceVO, point: any) => {
  250. DeptApi.getDept(device.deptId).then((res) => {
  251. dept.value = res.name
  252. const content = `
  253. <div style="display: flex;flex-direction: column;justify-content: center;border: 1px solid #ccc;">
  254. <div style="margin-top: 1px;padding: 8px">
  255. <p><strong>${t('iotDevice.code')}:</strong> ${device.deviceCode}</p>
  256. <p><strong>${t('iotDevice.name')}:</strong> ${device.deviceName}</p>
  257. <p><strong>${t('iotDevice.dept')}:</strong> ${res.name}</p>
  258. <p><strong>${t('form.position')}:</strong> ${device.location ? device.location.replaceAll('"', '') : ''}</p>
  259. <p><strong>${t('dict.status')}:</strong> ${getDictLabel(DICT_TYPE.PMS_DEVICE_STATUS, device.deviceStatus)}</p>
  260. <p><strong>${t('monitor.online')}:</strong> ${getDictLabel(DICT_TYPE.IOT_DEVICE_STATUS, device.ifInline)}</p>
  261. <p><strong>${t('monitor.latestDataTime')}:</strong> ${device.lastInlineTime}</p>
  262. </div>
  263. <div style="margin-bottom: 5px;padding: 8px">
  264. <button id="device-detail-btn" style=" background-color: #2196f3;
  265. color: white;
  266. border: none;
  267. padding: 8px 16px;
  268. border-radius: 4px;
  269. cursor: pointer;">${t('fault.view')}</button>
  270. </div>
  271. </div>
  272. `
  273. const infoWindow = new (window as any).BMap.InfoWindow(content, {
  274. width: 350,
  275. height: 270
  276. })
  277. map.value.openInfoWindow(infoWindow, point)
  278. // 事件绑定(需延迟确保DOM加载)
  279. setTimeout(function () {
  280. document.getElementById('device-detail-btn').addEventListener('click', function () {
  281. drawerVisible.value = true
  282. deviceId.value = device.id
  283. deviceCode.value = device.deviceCode
  284. deviceName.value = device.deviceName
  285. ifLine.value = device.ifInline
  286. lastLineTime.value = device.lastInlineTime
  287. showDrawer.value.openDrawer()
  288. })
  289. }, 200)
  290. })
  291. }
  292. const zoomIn = () => {
  293. if (map.value) {
  294. map.value.zoomIn()
  295. }
  296. }
  297. const zoomOut = () => {
  298. if (map.value) {
  299. map.value.zoomOut()
  300. }
  301. }
  302. const toggleMapType = () => {
  303. if (!map.value) return
  304. mapType.value = mapType.value === 'BMAP_NORMAL_MAP' ? 'BMAP_SATELLITE_MAP' : 'BMAP_NORMAL_MAP'
  305. map.value.setMapType((window as any)[mapType.value])
  306. }
  307. const queryParams = reactive({
  308. deptId: undefined
  309. })
  310. const getData = async () => {
  311. debugger
  312. await IotDeviceApi.getMapDevice(queryParams).then((res) => {
  313. devices.value = res.filter((item) => item.lat != 0 && item.lng != 0)
  314. outlineCount.value = devices.value.filter((item) => item.ifInline === 4).length
  315. inlineCount.value = devices.value.filter((item) => item.ifInline === 3).length
  316. initMap()
  317. })
  318. }
  319. onMounted(async () => {
  320. await getData()
  321. const res = await DeptApi.getSimpleDeptList()
  322. treeList.value = []
  323. treeList.value.push(...handleTree(res))
  324. debugger
  325. })
  326. onBeforeUnmount(() => {
  327. if (map.value) {
  328. // 清除地图上的覆盖物
  329. map.value.clearOverlays()
  330. // 移除地图容器中的内容
  331. if (mapContainer.value) {
  332. mapContainer.value.innerHTML = ''
  333. }
  334. // 解除地图的事件绑定
  335. map.value.removeEventListener('zoomend', initDeviceMarkers)
  336. }
  337. })
  338. // return {
  339. // mapContainer,
  340. // selectedDevice,
  341. // hoverDevice,
  342. // // deviceStatusMap,
  343. // zoomIn,
  344. // zoomOut,
  345. // toggleMapType
  346. // }
  347. // })
  348. </script>
  349. <style scoped>
  350. .map-container {
  351. position: relative;
  352. width: 100%;
  353. height: 90vh;
  354. }
  355. #baidu-map {
  356. width: 100%;
  357. height: 100%;
  358. }
  359. .map-controls {
  360. position: absolute;
  361. top: 20px;
  362. right: 20px; /* 修改为right定位 */
  363. z-index: 1000;
  364. display: flex;
  365. flex-direction: column; /* 垂直排列按钮 */
  366. gap: 10px;
  367. }
  368. .map-controls button {
  369. padding: 5px 10px;
  370. cursor: pointer;
  371. background: #fff;
  372. border: 1px solid #ccc;
  373. border-radius: 3px;
  374. }
  375. .status-info {
  376. position: absolute;
  377. top: 20px;
  378. left: 40%;
  379. z-index: 1000;
  380. display: flex;
  381. padding: 10px 15px;
  382. background: white;
  383. border-radius: 6px;
  384. box-shadow: 0 2px 10px rgb(0 0 0 / 10%);
  385. gap: 20px;
  386. }
  387. .status-item {
  388. display: flex;
  389. height: 50px;
  390. font-size: 14px;
  391. align-items: center;
  392. gap: 8px;
  393. }
  394. .online-icon {
  395. display: inline-block;
  396. width: 25px;
  397. height: 25px;
  398. background-image: url('https://aims.deepoil.cc/images/newding.svg');
  399. background-position: center;
  400. background-repeat: no-repeat;
  401. background-size: contain;
  402. }
  403. .offline-icon {
  404. display: inline-block;
  405. width: 25px;
  406. height: 25px;
  407. background-image: url('https://aims.deepoil.cc/images/newfail.svg');
  408. background-position: center;
  409. background-repeat: no-repeat;
  410. background-size: contain;
  411. }
  412. :deep(.el-select__selection) {
  413. height: 35px; /* 自定义高度 */
  414. }
  415. :deep(.el-select__placeholder.is-transparent) {
  416. color: orangered;
  417. }
  418. </style>