|
|
@@ -1,459 +1,1046 @@
|
|
|
-<template>
|
|
|
- <div class="map-container">
|
|
|
- <div class="status-info">
|
|
|
- <div class="status-item">
|
|
|
- <span class="online-icon"></span>
|
|
|
- <span style="font-weight: bold;font-size: 17px">在线数量 {{ inlineCount }}</span>
|
|
|
- </div>
|
|
|
- <div class="status-item">
|
|
|
- <span class="offline-icon"></span>
|
|
|
- <span style="font-weight: bold;font-size: 17px">离线数量 {{ outlineCount }}</span>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <div id="baidu-map" ref="mapContainer"></div>
|
|
|
- <div class="map-controls">
|
|
|
- <button @click="zoomIn">{{t('map.amplify')}}</button>
|
|
|
- <button @click="zoomOut">{{t('map.reduce')}}</button>
|
|
|
- <button @click="toggleMapType"
|
|
|
- >{{t('map.SwitchMapType')}}({{ mapType === 'BMAP_NORMAL_MAP' ? '地图' : '卫星' }})</button>
|
|
|
- <el-tree-select
|
|
|
- class="my-el-select"
|
|
|
- v-model="queryParams.deptId"
|
|
|
- :data="treeList"
|
|
|
- :props="defaultProps"
|
|
|
- check-strictly
|
|
|
- node-key="id"
|
|
|
- :placeholder="t('deviceForm.deptHolder')"
|
|
|
- filterable
|
|
|
- @change="getData"
|
|
|
- />
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <DeviceMonitorDrawer :model-value="drawerVisible" @update:model-value="(val) => (drawerVisible = val)" :id="deviceId" :deviceName="deviceName" :lastLineTime="lastLineTime"
|
|
|
- :ifLine="ifLine" :dept="dept" :deviceCode="deviceCode"
|
|
|
- ref="showDrawer" />
|
|
|
-</template>
|
|
|
-
|
|
|
<script setup lang="ts">
|
|
|
-import { onBeforeUnmount, onMounted, ref } from 'vue'
|
|
|
-import { DICT_TYPE, getDictLabel } from '@/utils/dict'
|
|
|
-import { IotDeviceApi, IotDeviceVO } from '@/api/pms/device'
|
|
|
-import DeviceMonitorDrawer from '@/views/pms/map/DeviceMonitorDrawer.vue'
|
|
|
+import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
|
+import { IotDeviceApi } from '@/api/pms/device'
|
|
|
import * as DeptApi from '@/api/system/dept'
|
|
|
-import {defaultProps, handleTree} from "@/utils/tree";
|
|
|
+import DeviceMonitorDrawer from '@/views/pms/map/DeviceMonitorDrawer.vue'
|
|
|
+import { DICT_TYPE, getDictLabel } from '@/utils/dict'
|
|
|
+import { defaultProps, handleTree } from '@/utils/tree'
|
|
|
|
|
|
-interface Cluster {
|
|
|
- lng: number
|
|
|
+type MapMode = 'TMAP_NORMAL_MAP' | 'TMAP_SATELLITE_MAP'
|
|
|
+type StatusFilter = 'all' | 'online' | 'offline'
|
|
|
+
|
|
|
+type MapDevice = {
|
|
|
+ id: number
|
|
|
+ deviceCode: string
|
|
|
+ deviceName: string
|
|
|
lat: number
|
|
|
- count: number
|
|
|
- devices?: IotDeviceVO[]
|
|
|
-}
|
|
|
-defineOptions({ name: 'DeviceMap' })
|
|
|
-const showDrawer = ref()
|
|
|
-const drawerVisible = ref<boolean>(false)
|
|
|
-const mapContainer = ref<HTMLElement | null>(null)
|
|
|
-const map = ref<any>(null)
|
|
|
-const mapType = ref<'BMAP_NORMAL_MAP' | 'BMAP_SATELLITE_MAP'>('BMAP_NORMAL_MAP')
|
|
|
-const clusters = ref<Cluster[]>([])
|
|
|
-const deviceId = ref()
|
|
|
-const deviceName = ref('')
|
|
|
+ lng: number
|
|
|
+ location: string
|
|
|
+ deptId: number
|
|
|
+ ifInline: number
|
|
|
+ lastInlineTime: string
|
|
|
+ deviceStatus: string
|
|
|
+}
|
|
|
+
|
|
|
+type MarkerGlyphName = 'mdi:access-point' | 'mdi:access-point-off'
|
|
|
+
|
|
|
+type MarkerTheme = {
|
|
|
+ key: 'online' | 'offline'
|
|
|
+ iconName: MarkerGlyphName
|
|
|
+ startColor: string
|
|
|
+ endColor: string
|
|
|
+ ringColor: string
|
|
|
+ badgeColor: string
|
|
|
+ statusColor: string
|
|
|
+ shadowColor: string
|
|
|
+ iconColor: string
|
|
|
+}
|
|
|
+
|
|
|
+const { t } = useI18n()
|
|
|
+const message = useMessage()
|
|
|
+
|
|
|
+const loading = ref(false)
|
|
|
+const deptList = ref<any[]>([])
|
|
|
+const treeList = ref<any[]>([])
|
|
|
+const mapRef = ref<any>(null)
|
|
|
+const activeDevice = ref<MapDevice | null>(null)
|
|
|
+const infoWindowTarget = ref<[number, number] | null>(null)
|
|
|
+const showDrawer = ref<any>(null)
|
|
|
+const drawerVisible = ref(false)
|
|
|
+const deviceId = ref<number | undefined>(undefined)
|
|
|
+const drawerDeviceName = ref('')
|
|
|
const lastLineTime = ref('')
|
|
|
-const ifLine = ref()
|
|
|
+const ifLine = ref<number | undefined>(undefined)
|
|
|
const dept = ref('')
|
|
|
const deviceCode = ref('')
|
|
|
-const { t } = useI18n() // 国际化
|
|
|
-const inlineCount = ref(0)
|
|
|
-const outlineCount = ref(0)
|
|
|
-// 设备数据示例
|
|
|
-const devices = ref<IotDeviceVO[]>()
|
|
|
-const treeList = ref<Tree[]>([]) // 树形结构
|
|
|
-// 初始化地图
|
|
|
-const initMap = () => {
|
|
|
- if (!mapContainer.value) return
|
|
|
-
|
|
|
- const script = document.createElement('script')
|
|
|
- script.src = `https://api.map.baidu.com/api?v=3.0&ak=c0crhdxQ5H7WcqbcazGr7mnHrLa4GmO0&type=webgl`
|
|
|
- script.async = true
|
|
|
- script.onload = () => {
|
|
|
- if ((window as any).BMap) {
|
|
|
- map.value = new (window as any).BMap.Map(mapContainer.value)
|
|
|
- const point = new (window as any).BMap.Point(104.114129, 37.550339)
|
|
|
- map.value.centerAndZoom(point, 5)
|
|
|
-
|
|
|
- map.value.enableScrollWheelZoom(true)
|
|
|
- map.value.setMapType((window as any)[mapType.value])
|
|
|
-
|
|
|
- map.value.addControl(new (window as any).BMap.NavigationControl())
|
|
|
- map.value.addControl(new (window as any).BMap.ScaleControl())
|
|
|
-
|
|
|
- initDeviceMarkers()
|
|
|
- map.value.addEventListener('zoomend', () => {
|
|
|
- initDeviceMarkers()
|
|
|
- })
|
|
|
- } else {
|
|
|
- console.error('百度地图API加载失败')
|
|
|
- }
|
|
|
- }
|
|
|
- script.onerror = () => {
|
|
|
- console.error('百度地图API加载失败')
|
|
|
- }
|
|
|
- document.head.appendChild(script)
|
|
|
-}
|
|
|
-
|
|
|
-const initDeviceMarkers = () => {
|
|
|
- if (!map.value) return
|
|
|
-
|
|
|
- map.value.clearOverlays()
|
|
|
-
|
|
|
- const zoomLevel = map.value.getZoom()
|
|
|
- if (zoomLevel > 8) {
|
|
|
- debugger
|
|
|
- // 高缩放级别下显示单个设备标记
|
|
|
- devices.value.forEach((device) => {
|
|
|
- const point = new (window as any).BMap.Point(device.lng, device.lat)
|
|
|
- const marker = createDeviceMarker(device, point)
|
|
|
- map.value.addOverlay(marker)
|
|
|
- })
|
|
|
- } else {
|
|
|
- debugger
|
|
|
- // 低缩放级别下进行聚合
|
|
|
- clusters.value = clusterDevices(devices.value, map.value)
|
|
|
- clusters.value.forEach((cluster) => {
|
|
|
- if (cluster.count === 1) {
|
|
|
- // 只有一个设备时显示设备图标
|
|
|
- const device = cluster.devices?.[0]
|
|
|
- if (device) {
|
|
|
- const point = new (window as any).BMap.Point(device.lng, device.lat)
|
|
|
- const marker = createDeviceMarker(device, point)
|
|
|
- map.value.addOverlay(marker)
|
|
|
- }
|
|
|
- } else if (cluster.count > 1) {
|
|
|
- // 多个设备时显示聚合标签
|
|
|
- const point = new (window as any).BMap.Point(cluster.lng, cluster.lat)
|
|
|
- const label = createClusterLabel(cluster, point)
|
|
|
- map.value.addOverlay(label)
|
|
|
- }
|
|
|
- })
|
|
|
+
|
|
|
+const keyword = ref('')
|
|
|
+const statusFilter = ref<StatusFilter>('all')
|
|
|
+const mapMode = ref<MapMode>('TMAP_NORMAL_MAP')
|
|
|
+
|
|
|
+const initialCenter: [number, number] = [104.114129, 36.550339]
|
|
|
+const initialZoom = 5
|
|
|
+const mapControls = ['Scale']
|
|
|
+
|
|
|
+const queryParams = reactive({
|
|
|
+ deptId: 156
|
|
|
+})
|
|
|
+
|
|
|
+const devices = ref<MapDevice[]>([])
|
|
|
+const currentZoom = ref(initialZoom)
|
|
|
+const markerIconCache = new Map<
|
|
|
+ string,
|
|
|
+ { iconUrl: string; iconSize: [number, number]; iconAnchor: [number, number] }
|
|
|
+>()
|
|
|
+
|
|
|
+const markerGlyphBodyMap: Record<MarkerGlyphName, string> = {
|
|
|
+ 'mdi:access-point':
|
|
|
+ '<path fill="currentColor" d="M4.93 4.93A9.97 9.97 0 0 0 2 12c0 2.76 1.12 5.26 2.93 7.07l1.41-1.41A7.94 7.94 0 0 1 4 12c0-2.21.89-4.22 2.34-5.66zm14.14 0l-1.41 1.41A7.96 7.96 0 0 1 20 12c0 2.22-.89 4.22-2.34 5.66l1.41 1.41A9.97 9.97 0 0 0 22 12c0-2.76-1.12-5.26-2.93-7.07M7.76 7.76A5.98 5.98 0 0 0 6 12c0 1.65.67 3.15 1.76 4.24l1.41-1.41A4 4 0 0 1 8 12c0-1.11.45-2.11 1.17-2.83zm8.48 0l-1.41 1.41A4 4 0 0 1 16 12c0 1.11-.45 2.11-1.17 2.83l1.41 1.41A5.98 5.98 0 0 0 18 12c0-1.65-.67-3.15-1.76-4.24M12 10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2"/>',
|
|
|
+ 'mdi:access-point-off':
|
|
|
+ '<path fill="currentColor" d="M20.84 22.73L12.1 14H12a2 2 0 0 1-2-2v-.1l-1.6-1.61c-.25.52-.4 1.09-.4 1.71c0 1.11.45 2.11 1.17 2.83l-1.41 1.41A5.98 5.98 0 0 1 6 12c0-1.17.34-2.26.93-3.18L5.5 7.37C4.55 8.67 4 10.27 4 12c0 2.22.89 4.22 2.34 5.66l-1.41 1.41A9.97 9.97 0 0 1 2 12c0-2.28.77-4.37 2.06-6.05L1.11 3l1.28-1.27l19.72 19.73zm-4.91-10l1.6 1.6c.3-.72.47-1.5.47-2.33c0-1.65-.67-3.15-1.76-4.24l-1.41 1.41a3.99 3.99 0 0 1 1.1 3.56m3.1 3.1l1.47 1.45c.94-1.53 1.5-3.34 1.5-5.28c0-2.76-1.12-5.26-2.93-7.07l-1.41 1.41A7.96 7.96 0 0 1 20 12c0 1.39-.35 2.7-.97 3.83"/>'
|
|
|
+}
|
|
|
+
|
|
|
+const markerThemeMap: Record<'online' | 'offline', MarkerTheme> = {
|
|
|
+ online: {
|
|
|
+ key: 'online',
|
|
|
+ iconName: 'mdi:access-point',
|
|
|
+ startColor: '#0f766e',
|
|
|
+ endColor: '#2dd4bf',
|
|
|
+ ringColor: '#ccfbf1',
|
|
|
+ badgeColor: '#f8fffd',
|
|
|
+ statusColor: '#10b981',
|
|
|
+ shadowColor: 'rgba(15, 118, 110, 0.3)',
|
|
|
+ iconColor: '#0f766e'
|
|
|
+ },
|
|
|
+ offline: {
|
|
|
+ key: 'offline',
|
|
|
+ iconName: 'mdi:access-point-off',
|
|
|
+ startColor: '#ea580c',
|
|
|
+ endColor: '#fb7185',
|
|
|
+ ringColor: '#ffe4e6',
|
|
|
+ badgeColor: '#fffaf8',
|
|
|
+ statusColor: '#f97316',
|
|
|
+ shadowColor: 'rgba(234, 88, 12, 0.28)',
|
|
|
+ iconColor: '#c2410c'
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-const createDeviceMarker = (device: IotDeviceVO, point: any) => {
|
|
|
- // 根据设备是否在线选择不同的图标
|
|
|
- const iconUrl = device.ifInline === 3
|
|
|
- ? import.meta.env.VITE_BASE_URL + '/images/newding.svg'
|
|
|
- : import.meta.env.VITE_BASE_URL + '/images/newfail.svg';
|
|
|
-debugger
|
|
|
- const marker = new (window as any).BMap.Marker(point, {
|
|
|
- icon: new (window as any).BMap.Icon(
|
|
|
- iconUrl,
|
|
|
- new (window as any).BMap.Size(40, 47),
|
|
|
- {
|
|
|
- anchor: new (window as any).BMap.Size(25, 40)
|
|
|
- // imageOffset: new (window as any).BMap.Size(0, -5)
|
|
|
- }
|
|
|
- )
|
|
|
- });
|
|
|
-
|
|
|
- // 添加点击事件
|
|
|
- marker.addEventListener('click', () => {
|
|
|
- showDeviceInfoWindow(device, point);
|
|
|
- });
|
|
|
-
|
|
|
- return marker;
|
|
|
-};
|
|
|
-
|
|
|
-const createClusterLabel = (cluster: Cluster, point: any) => {
|
|
|
- const label = new (window as any).BMap.Label(cluster.count.toString(), {
|
|
|
- position: point,
|
|
|
- offset: new (window as any).BMap.Size(-10, -10)
|
|
|
- })
|
|
|
+const statusOptions = [
|
|
|
+ { label: '全部', value: 'all' },
|
|
|
+ { label: '在线', value: 'online' },
|
|
|
+ { label: '离线', value: 'offline' }
|
|
|
+] as const
|
|
|
|
|
|
- // 创建一个 style 标签并添加到 head 中,定义呼吸动画
|
|
|
- const style = document.createElement('style')
|
|
|
- style.textContent = `
|
|
|
- @keyframes breathing {
|
|
|
- 0% {
|
|
|
- border-color: rgba(255, 255, 255, 0.3);
|
|
|
- }
|
|
|
- 50% {
|
|
|
- border-color: rgba(255, 255, 255, 0.8);
|
|
|
- }
|
|
|
- 100% {
|
|
|
- border-color: rgba(255, 255, 255, 0.3);
|
|
|
- }
|
|
|
- }
|
|
|
- `
|
|
|
- document.head.appendChild(style)
|
|
|
-
|
|
|
- // 根据 cluster.count 的值设置不同的背景颜色
|
|
|
- const backgroundColor = cluster.count > 10 ? '#1d4eed' : '#f67d1a';
|
|
|
-
|
|
|
- // 初始样式
|
|
|
- label.setStyle({
|
|
|
- color: '#fff',
|
|
|
- backgroundColor: backgroundColor,
|
|
|
- borderRadius: '50%',
|
|
|
- width: '50px',
|
|
|
- height: '50px',
|
|
|
- textAlign: 'center',
|
|
|
- lineHeight: '40px',
|
|
|
- cursor: 'pointer',
|
|
|
- fontSize: '18px',
|
|
|
- fontWeight: 'bold',
|
|
|
- boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)',
|
|
|
- transition: 'all 0.3s ease',
|
|
|
- border: '6px solid rgba(255, 255, 255, 0.5)', // 添加带有不透明度的边框
|
|
|
- animation: 'breathing 1.5s infinite' // 添加呼吸动画
|
|
|
+const totalCount = computed(() => devices.value.length)
|
|
|
+const onlineCount = computed(() => devices.value.filter((item) => isOnline(item)).length)
|
|
|
+const offlineCount = computed(() => devices.value.filter((item) => !isOnline(item)).length)
|
|
|
+
|
|
|
+const filteredDevices = computed(() => {
|
|
|
+ const text = keyword.value.trim().toLowerCase()
|
|
|
+ return devices.value.filter((item) => {
|
|
|
+ const matchKeyword =
|
|
|
+ !text ||
|
|
|
+ [item.deviceName, item.deviceCode, item.location]
|
|
|
+ .filter(Boolean)
|
|
|
+ .some((value) => String(value).toLowerCase().includes(text))
|
|
|
+
|
|
|
+ const matchStatus =
|
|
|
+ statusFilter.value === 'all' ||
|
|
|
+ (statusFilter.value === 'online' && isOnline(item)) ||
|
|
|
+ (statusFilter.value === 'offline' && !isOnline(item))
|
|
|
+
|
|
|
+ return matchKeyword && matchStatus
|
|
|
})
|
|
|
+})
|
|
|
|
|
|
- // 鼠标悬停样式
|
|
|
- const hoverStyle = {
|
|
|
- backgroundColor: '#2196df',
|
|
|
- transform: 'scale(1.1)'
|
|
|
+const filteredCount = computed(() => filteredDevices.value.length)
|
|
|
+const markerDevices = computed(() =>
|
|
|
+ filteredDevices.value
|
|
|
+ .filter((item) => hasValidPosition(item))
|
|
|
+ .map((item) => ({
|
|
|
+ ...item,
|
|
|
+ position: [Number(item.lng), Number(item.lat)] as [number, number],
|
|
|
+ icon: getMarkerIcon(item)
|
|
|
+ }))
|
|
|
+)
|
|
|
+const clusterMarkers = computed(() =>
|
|
|
+ markerDevices.value.map((item) => ({
|
|
|
+ position: item.position,
|
|
|
+ icon: item.icon,
|
|
|
+ title: item.deviceName || item.deviceCode || '',
|
|
|
+ zIndexOffset: isOnline(item) ? 240 : 180,
|
|
|
+ extData: item
|
|
|
+ }))
|
|
|
+)
|
|
|
+const deptNameMap = computed(() => {
|
|
|
+ return deptList.value.reduce((acc, cur) => {
|
|
|
+ acc.set(Number(cur.id), String(cur.name).trim())
|
|
|
+ return acc
|
|
|
+ }, new Map<number, string>())
|
|
|
+})
|
|
|
+
|
|
|
+onMounted(async () => {
|
|
|
+ await Promise.all([loadDeptTree(), loadDevices()])
|
|
|
+})
|
|
|
+
|
|
|
+const loadDeptTree = async () => {
|
|
|
+ const res = await DeptApi.getSimpleDeptList()
|
|
|
+ deptList.value = res || []
|
|
|
+ treeList.value = handleTree(res || [])
|
|
|
+}
|
|
|
+
|
|
|
+const loadDevices = async () => {
|
|
|
+ loading.value = true
|
|
|
+ try {
|
|
|
+ const res = await IotDeviceApi.getMapDevice(queryParams)
|
|
|
+ devices.value = res || []
|
|
|
+ } catch (error) {
|
|
|
+ message.error('地图设备加载失败,请稍后重试')
|
|
|
+ } finally {
|
|
|
+ loading.value = false
|
|
|
}
|
|
|
+}
|
|
|
+
|
|
|
+const handleDeptChange = () => {
|
|
|
+ return loadDevices()
|
|
|
+}
|
|
|
+
|
|
|
+const handleMapInit = (map: any) => {
|
|
|
+ mapRef.value = map
|
|
|
+ applyMapMode()
|
|
|
+ syncCurrentZoom()
|
|
|
+}
|
|
|
+
|
|
|
+const syncCurrentZoom = () => {
|
|
|
+ currentZoom.value = mapRef.value?.getZoom?.() ?? initialZoom
|
|
|
+}
|
|
|
+
|
|
|
+const zoomIn = () => {
|
|
|
+ mapRef.value?.zoomIn?.()
|
|
|
+}
|
|
|
+
|
|
|
+const zoomOut = () => {
|
|
|
+ mapRef.value?.zoomOut?.()
|
|
|
+}
|
|
|
+
|
|
|
+const switchMapMode = (mode: MapMode) => {
|
|
|
+ mapMode.value = mode
|
|
|
+ applyMapMode()
|
|
|
+}
|
|
|
+
|
|
|
+const applyMapMode = () => {
|
|
|
+ if (!mapRef.value) return
|
|
|
+ ;(mapRef.value as any).setMapType?.((window as any)[mapMode.value])
|
|
|
+}
|
|
|
+
|
|
|
+const isOnline = (device?: MapDevice | null) => {
|
|
|
+ return Number(device?.ifInline) === 3
|
|
|
+}
|
|
|
+
|
|
|
+const hasValidPosition = (device?: MapDevice | null) => {
|
|
|
+ const lng = Number(device?.lng)
|
|
|
+ const lat = Number(device?.lat)
|
|
|
+
|
|
|
+ return Number.isFinite(lng) && Number.isFinite(lat) && lng !== 0 && lat !== 0
|
|
|
+}
|
|
|
|
|
|
- // 鼠标移出样式
|
|
|
- const normalStyle = {
|
|
|
- backgroundColor: backgroundColor,
|
|
|
- transform: 'scale(1)'
|
|
|
+const getMarkerIcon = (device?: MapDevice | null) => {
|
|
|
+ const theme = isOnline(device) ? markerThemeMap.online : markerThemeMap.offline
|
|
|
+ const cachedIcon = markerIconCache.get(theme.key)
|
|
|
+
|
|
|
+ if (cachedIcon) return cachedIcon
|
|
|
+
|
|
|
+ const markerIcon = {
|
|
|
+ iconUrl: svgToDataUri(buildMarkerSvg(theme)),
|
|
|
+ iconSize: [46, 58] as [number, number],
|
|
|
+ iconAnchor: [23, 49] as [number, number]
|
|
|
}
|
|
|
|
|
|
- // 添加点击事件
|
|
|
- label.addEventListener('click', () => {
|
|
|
- if (map.value) {
|
|
|
- const currentZoom = map.value.getZoom();
|
|
|
- const MAX_ZOOM = 12; // 手动设定最大缩放级别
|
|
|
- const newZoom = Math.min(currentZoom + 2, MAX_ZOOM);
|
|
|
- map.value.setZoom(newZoom);
|
|
|
- map.value.panTo(point);
|
|
|
- initDeviceMarkers(); // 重新计算并显示标记和聚合标签
|
|
|
- }
|
|
|
- })
|
|
|
+ markerIconCache.set(theme.key, markerIcon)
|
|
|
+ return markerIcon
|
|
|
+}
|
|
|
|
|
|
- return label
|
|
|
+const buildMarkerSvg = (theme: MarkerTheme) => {
|
|
|
+ const glyph = renderMarkerGlyph(theme.iconName, 18)
|
|
|
+
|
|
|
+ return `
|
|
|
+ <svg xmlns="http://www.w3.org/2000/svg" width="46" height="58" viewBox="0 0 46 58" fill="none">
|
|
|
+ <defs>
|
|
|
+ <linearGradient id="marker-gradient-${theme.key}" x1="10" y1="6" x2="35" y2="45" gradientUnits="userSpaceOnUse">
|
|
|
+ <stop stop-color="${theme.startColor}" />
|
|
|
+ <stop offset="1" stop-color="${theme.endColor}" />
|
|
|
+ </linearGradient>
|
|
|
+ <filter id="marker-shadow-${theme.key}" x="2" y="1" width="42" height="54" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
|
|
+ <feDropShadow dx="0" dy="6" stdDeviation="4" flood-color="${theme.shadowColor}" />
|
|
|
+ </filter>
|
|
|
+ </defs>
|
|
|
+ <ellipse cx="23" cy="54.5" rx="8.5" ry="2.5" fill="rgba(15, 23, 42, 0.12)" />
|
|
|
+ <g filter="url(#marker-shadow-${theme.key})">
|
|
|
+ <path
|
|
|
+ d="M23 3C14.72 3 8 9.72 8 18c0 11.39 11.46 23.89 14.02 30.68a1.06 1.06 0 0 0 1.96 0C26.54 41.89 38 29.39 38 18C38 9.72 31.28 3 23 3Z"
|
|
|
+ fill="url(#marker-gradient-${theme.key})"
|
|
|
+ />
|
|
|
+ <path
|
|
|
+ d="M23 3C14.72 3 8 9.72 8 18c0 11.39 11.46 23.89 14.02 30.68a1.06 1.06 0 0 0 1.96 0C26.54 41.89 38 29.39 38 18C38 9.72 31.28 3 23 3Z"
|
|
|
+ stroke="rgba(255, 255, 255, 0.74)"
|
|
|
+ stroke-width="1.2"
|
|
|
+ />
|
|
|
+ <path
|
|
|
+ d="M14 10.5C16.72 7.56 19.82 6.09 23.31 6.09c2.98 0 5.81 1.02 8.48 3.06c2.66 2.04 4 4.44 4 7.18c0 1.09-.2 2.18-.6 3.28c-.4-4.09-1.85-7.16-4.35-9.23c-2.49-2.06-5.18-3.1-8.08-3.1c-3.09 0-6 1.08-8.76 3.22Z"
|
|
|
+ fill="rgba(255, 255, 255, 0.22)"
|
|
|
+ />
|
|
|
+ <circle cx="23" cy="18" r="11.4" fill="${theme.badgeColor}" />
|
|
|
+ <circle cx="23" cy="18" r="11.4" stroke="${theme.ringColor}" stroke-width="1.8" />
|
|
|
+ <g transform="translate(14 9)" color="${theme.iconColor}" fill="${theme.iconColor}">
|
|
|
+ ${glyph}
|
|
|
+ </g>
|
|
|
+ <circle cx="31.2" cy="26.6" r="3.7" fill="${theme.statusColor}" stroke="white" stroke-width="1.4" />
|
|
|
+ </g>
|
|
|
+ </svg>
|
|
|
+ `.trim()
|
|
|
}
|
|
|
|
|
|
-const clusterDevices = (devices: IotDeviceVO[], map: any): Cluster[] => {
|
|
|
- const clusters: Cluster[] = []
|
|
|
- const gridSize = getGridSize(map.getZoom())
|
|
|
+const renderMarkerGlyph = (iconName: MarkerGlyphName, boxSize = 18) => {
|
|
|
+ const iconBody = markerGlyphBodyMap[iconName]
|
|
|
+ const iconScale = boxSize / 24
|
|
|
|
|
|
- const gridMap = new Map<string, Cluster>()
|
|
|
+ return `
|
|
|
+ <g transform="scale(${iconScale})">
|
|
|
+ ${iconBody}
|
|
|
+ </g>
|
|
|
+ `.trim()
|
|
|
+}
|
|
|
|
|
|
- devices.forEach((device) => {
|
|
|
- const gridKey = `${Math.floor(device.lng / gridSize)}_${Math.floor(device.lat / gridSize)}`
|
|
|
+const svgToDataUri = (svg: string) => {
|
|
|
+ return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`
|
|
|
+}
|
|
|
|
|
|
- if (!gridMap.has(gridKey)) {
|
|
|
- gridMap.set(gridKey, {
|
|
|
- lng: Math.floor(device.lng / gridSize) * gridSize + gridSize / 2,
|
|
|
- lat: Math.floor(device.lat / gridSize) * gridSize + gridSize / 2,
|
|
|
- count: 0,
|
|
|
- devices: []
|
|
|
- })
|
|
|
- }
|
|
|
+const clusterStyles = [
|
|
|
+ {
|
|
|
+ size: 80,
|
|
|
+ textColor: '#ffffff',
|
|
|
+ textSize: 14,
|
|
|
+ range: [0, 20]
|
|
|
+ },
|
|
|
+ {
|
|
|
+ size: 80,
|
|
|
+ textColor: '#ffffff',
|
|
|
+ textSize: 14,
|
|
|
+ range: [20, 100]
|
|
|
+ },
|
|
|
+ {
|
|
|
+ size: 80,
|
|
|
+ textColor: '#ffffff',
|
|
|
+ textSize: 14,
|
|
|
+ range: [100, 99999]
|
|
|
+ }
|
|
|
+]
|
|
|
|
|
|
- const cluster = gridMap.get(gridKey)!
|
|
|
- cluster.count++
|
|
|
- if (!cluster.devices) cluster.devices = []
|
|
|
- cluster.devices.push(device)
|
|
|
- })
|
|
|
+const handleMarkerClick = (e: any) => {
|
|
|
+ const device = e?.layer?.extData as MapDevice | undefined
|
|
|
|
|
|
- return Array.from(gridMap.values())
|
|
|
-}
|
|
|
-
|
|
|
-const getGridSize = (zoom: number): number => {
|
|
|
- debugger
|
|
|
- if (zoom <= 5) return 5
|
|
|
- if (zoom <= 8) return 3
|
|
|
- if (zoom < 10) return 0.1
|
|
|
- return 0.1
|
|
|
-}
|
|
|
-
|
|
|
-const showDeviceInfoWindow = (device: IotDeviceVO, point: any) => {
|
|
|
- DeptApi.getDept(device.deptId).then(res=>{
|
|
|
- dept.value = res.name
|
|
|
- const content = `
|
|
|
-<div style="display: flex;flex-direction: column;justify-content: center;border: 1px solid #ccc;">
|
|
|
- <div style="margin-top: 1px;padding: 8px">
|
|
|
- <p><strong>${t('iotDevice.code')}:</strong> ${device.deviceCode}</p>
|
|
|
- <p><strong>${t('iotDevice.name')}:</strong> ${device.deviceName}</p>
|
|
|
- <p><strong>${t('iotDevice.dept')}:</strong> ${res.name}</p>
|
|
|
- <p><strong>${t('form.position')}:</strong> ${device.location?device.location.replaceAll('"',''):''}</p>
|
|
|
- <p><strong>${t('dict.status')}:</strong> ${getDictLabel(DICT_TYPE.PMS_DEVICE_STATUS, device.deviceStatus)}</p>
|
|
|
- <p><strong>${t('monitor.online')}:</strong> ${getDictLabel(DICT_TYPE.IOT_DEVICE_STATUS, device.ifInline)}</p>
|
|
|
- <p><strong>${t('monitor.latestDataTime')}:</strong> ${device.lastInlineTime}</p>
|
|
|
- </div>
|
|
|
- <div style="margin-bottom: 5px;padding: 8px">
|
|
|
- <button id="device-detail-btn" style=" background-color: #2196f3;
|
|
|
- color: white;
|
|
|
- border: none;
|
|
|
- padding: 8px 16px;
|
|
|
- border-radius: 4px;
|
|
|
-
|
|
|
- cursor: pointer;">${t('fault.view')}</button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- `
|
|
|
- const infoWindow = new (window as any).BMap.InfoWindow(content, {
|
|
|
- width: 350,
|
|
|
- height: 270
|
|
|
- })
|
|
|
- map.value.openInfoWindow(infoWindow, point)
|
|
|
-
|
|
|
- // 事件绑定(需延迟确保DOM加载)
|
|
|
- setTimeout(function () {
|
|
|
- document.getElementById('device-detail-btn').addEventListener('click', function () {
|
|
|
- drawerVisible.value = true
|
|
|
- deviceId.value = device.id;
|
|
|
- deviceCode.value = device.deviceCode
|
|
|
- deviceName.value = device.deviceName
|
|
|
- ifLine.value = device.ifInline
|
|
|
- lastLineTime.value = device.lastInlineTime
|
|
|
- showDrawer.value.openDrawer()
|
|
|
-
|
|
|
- })
|
|
|
- }, 200)
|
|
|
- })
|
|
|
+ if (!device) return
|
|
|
|
|
|
+ openInfoWindow(device, resolveLngLat(e?.lnglat))
|
|
|
}
|
|
|
|
|
|
-const zoomIn = () => {
|
|
|
- if (map.value) {
|
|
|
- map.value.zoomIn()
|
|
|
+const handleInfoWindowTargetChange = (target: [number, number] | null) => {
|
|
|
+ infoWindowTarget.value = target
|
|
|
+
|
|
|
+ if (!target) {
|
|
|
+ activeDevice.value = null
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-const zoomOut = () => {
|
|
|
- if (map.value) {
|
|
|
- map.value.zoomOut()
|
|
|
+const openInfoWindow = (device: MapDevice, target?: [number, number] | null) => {
|
|
|
+ if (!target && !hasValidPosition(device)) return
|
|
|
+
|
|
|
+ activeDevice.value = device
|
|
|
+ infoWindowTarget.value = target ?? [Number(device.lng), Number(device.lat)]
|
|
|
+}
|
|
|
+
|
|
|
+const closeInfoWindow = () => {
|
|
|
+ activeDevice.value = null
|
|
|
+ infoWindowTarget.value = null
|
|
|
+}
|
|
|
+
|
|
|
+const resolveLngLat = (lnglat: any): [number, number] | null => {
|
|
|
+ const lng = Number(lnglat?.lng ?? lnglat?.getLng?.())
|
|
|
+ const lat = Number(lnglat?.lat ?? lnglat?.getLat?.())
|
|
|
+
|
|
|
+ if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
|
|
|
+ return null
|
|
|
}
|
|
|
+
|
|
|
+ return [lng, lat]
|
|
|
}
|
|
|
|
|
|
-const toggleMapType = () => {
|
|
|
- if (!map.value) return
|
|
|
+const getDeptName = (device?: MapDevice | null) => {
|
|
|
+ return deptNameMap.value.get(Number(device?.deptId)) || '-'
|
|
|
+}
|
|
|
|
|
|
- mapType.value = mapType.value === 'BMAP_NORMAL_MAP' ? 'BMAP_SATELLITE_MAP' : 'BMAP_NORMAL_MAP'
|
|
|
- map.value.setMapType((window as any)[mapType.value])
|
|
|
+const getDeviceStatusText = (device?: MapDevice | null) => {
|
|
|
+ return getDictLabel(DICT_TYPE.PMS_DEVICE_STATUS, device?.deviceStatus) || '-'
|
|
|
}
|
|
|
-const queryParams = reactive({
|
|
|
- deptId: undefined
|
|
|
-})
|
|
|
-const getData = async () => {
|
|
|
- debugger
|
|
|
- await IotDeviceApi.getMapDevice(queryParams).then((res) => {
|
|
|
- devices.value = res.filter((item)=> item.lat!=0&&item.lng!=0)
|
|
|
- outlineCount.value = devices.value.filter((item)=> item.ifInline===4).length
|
|
|
- inlineCount.value = devices.value.filter((item)=> item.ifInline===3).length
|
|
|
- initMap()
|
|
|
- })
|
|
|
+
|
|
|
+const getDeviceOnlineText = (device?: MapDevice | null) => {
|
|
|
+ return (
|
|
|
+ getDictLabel(DICT_TYPE.IOT_DEVICE_STATUS, device?.ifInline) ||
|
|
|
+ (isOnline(device) ? '在线' : '离线')
|
|
|
+ )
|
|
|
}
|
|
|
-onMounted(async () => {
|
|
|
- await getData()
|
|
|
- const res = await DeptApi.getSimpleDeptList()
|
|
|
- treeList.value = []
|
|
|
- treeList.value.push(...handleTree(res))
|
|
|
- debugger
|
|
|
-})
|
|
|
|
|
|
-onBeforeUnmount(() => {
|
|
|
- if (map.value) {
|
|
|
- // 清除地图上的覆盖物
|
|
|
- map.value.clearOverlays();
|
|
|
- // 移除地图容器中的内容
|
|
|
- if (mapContainer.value) {
|
|
|
- mapContainer.value.innerHTML = '';
|
|
|
- }
|
|
|
- // 解除地图的事件绑定
|
|
|
- map.value.removeEventListener('zoomend', initDeviceMarkers);
|
|
|
+const getDeviceLocationText = (device?: MapDevice | null) => {
|
|
|
+ const location = String(device?.location || '')
|
|
|
+ .replaceAll('"', '')
|
|
|
+ .trim()
|
|
|
+
|
|
|
+ return location || '-'
|
|
|
+}
|
|
|
+
|
|
|
+const openDeviceDrawer = () => {
|
|
|
+ if (!activeDevice.value) return
|
|
|
+
|
|
|
+ drawerVisible.value = true
|
|
|
+ deviceId.value = activeDevice.value.id
|
|
|
+ deviceCode.value = activeDevice.value.deviceCode || ''
|
|
|
+ drawerDeviceName.value = activeDevice.value.deviceName || ''
|
|
|
+ ifLine.value = activeDevice.value.ifInline
|
|
|
+ lastLineTime.value = activeDevice.value.lastInlineTime || ''
|
|
|
+ dept.value = getDeptName(activeDevice.value)
|
|
|
+ showDrawer.value?.openDrawer?.()
|
|
|
+}
|
|
|
+
|
|
|
+watch(filteredDevices, (list) => {
|
|
|
+ if (!activeDevice.value) return
|
|
|
+
|
|
|
+ const nextDevice = list.find(
|
|
|
+ (item) => item.id === activeDevice.value?.id && hasValidPosition(item)
|
|
|
+ )
|
|
|
+
|
|
|
+ if (!nextDevice) {
|
|
|
+ closeInfoWindow()
|
|
|
+ return
|
|
|
}
|
|
|
+
|
|
|
+ activeDevice.value = nextDevice
|
|
|
+ infoWindowTarget.value = [Number(nextDevice.lng), Number(nextDevice.lat)]
|
|
|
})
|
|
|
|
|
|
-// return {
|
|
|
-// mapContainer,
|
|
|
-// selectedDevice,
|
|
|
-// hoverDevice,
|
|
|
-// // deviceStatusMap,
|
|
|
-// zoomIn,
|
|
|
-// zoomOut,
|
|
|
-// toggleMapType
|
|
|
-// }
|
|
|
-// })
|
|
|
+const handleClusterInit = (clusterer: any) => {
|
|
|
+ clusterer?.setMaxZoom?.(16)
|
|
|
+}
|
|
|
</script>
|
|
|
|
|
|
-<style scoped>
|
|
|
-.map-container {
|
|
|
+<template>
|
|
|
+ <div v-loading="loading" class="device-map-page">
|
|
|
+ <tdt-map
|
|
|
+ :center="initialCenter"
|
|
|
+ :zoom="initialZoom"
|
|
|
+ :controls="mapControls"
|
|
|
+ :min-zoom="3"
|
|
|
+ @init="handleMapInit"
|
|
|
+ @zoomend="syncCurrentZoom"
|
|
|
+ >
|
|
|
+ <tdt-marker-clusterer
|
|
|
+ :markers="clusterMarkers"
|
|
|
+ :styles="clusterStyles"
|
|
|
+ @click="handleMarkerClick"
|
|
|
+ @init="handleClusterInit"
|
|
|
+ />
|
|
|
+
|
|
|
+ <tdt-infowindow
|
|
|
+ :target="infoWindowTarget"
|
|
|
+ :min-width="280"
|
|
|
+ :max-width="360"
|
|
|
+ :offset="[0, -30]"
|
|
|
+ :auto-pan="true"
|
|
|
+ :close-on-click="true"
|
|
|
+ @update:target="handleInfoWindowTargetChange"
|
|
|
+ >
|
|
|
+ <div v-if="activeDevice" class="device-infowindow">
|
|
|
+ <div class="device-infowindow__list">
|
|
|
+ <div class="device-infowindow__row">
|
|
|
+ <span>历史编码</span>
|
|
|
+ <strong>{{ activeDevice.deviceCode || '-' }}</strong>
|
|
|
+ </div>
|
|
|
+ <div class="device-infowindow__row">
|
|
|
+ <span>设备名称</span>
|
|
|
+ <strong>{{ activeDevice.deviceName || '-' }}</strong>
|
|
|
+ </div>
|
|
|
+ <div class="device-infowindow__row">
|
|
|
+ <span>所在部门</span>
|
|
|
+ <strong>{{ getDeptName(activeDevice) }}</strong>
|
|
|
+ </div>
|
|
|
+ <div class="device-infowindow__row">
|
|
|
+ <span>位置</span>
|
|
|
+ <strong>{{ getDeviceLocationText(activeDevice) }}</strong>
|
|
|
+ </div>
|
|
|
+ <div class="device-infowindow__row">
|
|
|
+ <span>状态</span>
|
|
|
+ <strong>{{ getDeviceStatusText(activeDevice) }}</strong>
|
|
|
+ </div>
|
|
|
+ <div class="device-infowindow__row">
|
|
|
+ <span>在线状态</span>
|
|
|
+ <strong>{{ getDeviceOnlineText(activeDevice) }}</strong>
|
|
|
+ </div>
|
|
|
+ <div class="device-infowindow__row">
|
|
|
+ <span>最后在线时间</span>
|
|
|
+ <strong>{{ activeDevice.lastInlineTime || '暂无数据' }}</strong>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="device-infowindow__footer">
|
|
|
+ <button type="button" class="device-infowindow__button" @click="openDeviceDrawer">
|
|
|
+ 查看监测详情
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </tdt-infowindow>
|
|
|
+
|
|
|
+ <tdt-control position="topleft" visible>
|
|
|
+ <div
|
|
|
+ class="floating-panel hero-panel"
|
|
|
+ @click.stop
|
|
|
+ @dblclick.stop
|
|
|
+ @mousedown.stop
|
|
|
+ @mouseup.stop
|
|
|
+ @mousemove.stop
|
|
|
+ @pointerdown.stop
|
|
|
+ @pointerup.stop
|
|
|
+ @pointermove.stop
|
|
|
+ @touchstart.stop
|
|
|
+ @touchmove.stop
|
|
|
+ @touchend.stop
|
|
|
+ @wheel.stop
|
|
|
+ >
|
|
|
+ <div class="hero-panel__head">
|
|
|
+ <div>
|
|
|
+ <div class="hero-panel__kicker">PMS · TianDiTu</div>
|
|
|
+ <div class="hero-panel__title">设备智览地图</div>
|
|
|
+ <div class="hero-panel__desc">
|
|
|
+ 设备位置、在线状态与监测入口,支持部门筛选、聚合放大和一键追踪。
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ class="hero-panel__map-mode -translate-y-1"
|
|
|
+ :class="mapMode === 'TMAP_SATELLITE_MAP' ? 'is-satellite' : 'is-map'"
|
|
|
+ >
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="mode-chip text-gray-6"
|
|
|
+ :class="{ 'is-active': mapMode === 'TMAP_NORMAL_MAP' }"
|
|
|
+ @click="switchMapMode('TMAP_NORMAL_MAP')"
|
|
|
+ >
|
|
|
+ 地图
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="mode-chip text-gray-6"
|
|
|
+ :class="{ 'is-active': mapMode === 'TMAP_SATELLITE_MAP' }"
|
|
|
+ @click="switchMapMode('TMAP_SATELLITE_MAP')"
|
|
|
+ >
|
|
|
+ 卫星
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="hero-stats">
|
|
|
+ <div class="hero-stat hero-stat--primary">
|
|
|
+ <span>设备总数</span>
|
|
|
+ <strong>{{ totalCount }}</strong>
|
|
|
+ </div>
|
|
|
+ <div class="hero-stat hero-stat--success">
|
|
|
+ <span>在线设备</span>
|
|
|
+ <strong>{{ onlineCount }}</strong>
|
|
|
+ </div>
|
|
|
+ <div class="hero-stat hero-stat--danger">
|
|
|
+ <span>离线设备</span>
|
|
|
+ <strong>{{ offlineCount }}</strong>
|
|
|
+ </div>
|
|
|
+ <div class="hero-stat hero-stat--neutral">
|
|
|
+ <span>筛选结果</span>
|
|
|
+ <strong>{{ filteredCount }}</strong>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </tdt-control>
|
|
|
+
|
|
|
+ <tdt-control position="topright" visible>
|
|
|
+ <div
|
|
|
+ class="floating-panel toolbar-panel p-4!"
|
|
|
+ @click.stop
|
|
|
+ @dblclick.stop
|
|
|
+ @mousedown.stop
|
|
|
+ @mouseup.stop
|
|
|
+ @mousemove.stop
|
|
|
+ @pointerdown.stop
|
|
|
+ @pointerup.stop
|
|
|
+ @pointermove.stop
|
|
|
+ @touchstart.stop
|
|
|
+ @touchmove.stop
|
|
|
+ @touchend.stop
|
|
|
+ @wheel.stop
|
|
|
+ >
|
|
|
+ <el-form size="default" label-position="top">
|
|
|
+ <el-form-item label="关键词">
|
|
|
+ <el-input v-model="keyword" clearable placeholder="设备名称 / 编码 / 位置">
|
|
|
+ <template #prefix>
|
|
|
+ <Icon icon="ep:search" />
|
|
|
+ </template>
|
|
|
+ </el-input>
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item label="部门筛选">
|
|
|
+ <el-tree-select
|
|
|
+ v-model="queryParams.deptId"
|
|
|
+ :data="treeList"
|
|
|
+ :props="defaultProps"
|
|
|
+ check-strictly
|
|
|
+ node-key="id"
|
|
|
+ filterable
|
|
|
+ clearable
|
|
|
+ :placeholder="t('deviceForm.deptHolder')"
|
|
|
+ @change="handleDeptChange"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item label="设备状态">
|
|
|
+ <div class="status-tabs">
|
|
|
+ <button
|
|
|
+ v-for="item in statusOptions"
|
|
|
+ :key="item.value"
|
|
|
+ type="button"
|
|
|
+ class="status-tab"
|
|
|
+ :class="{ 'is-active': statusFilter === item.value }"
|
|
|
+ @click="statusFilter = item.value"
|
|
|
+ >
|
|
|
+ {{ item.label }}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+
|
|
|
+ <div class="toolbar-panel__footer">
|
|
|
+ <div class="toolbar-badges">
|
|
|
+ <span class="toolbar-badge">缩放 {{ currentZoom }}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="toolbar-actions">
|
|
|
+ <button type="button" class="icon-action" @click="zoomOut">
|
|
|
+ <Icon icon="ep:minus" />
|
|
|
+ </button>
|
|
|
+ <button type="button" class="icon-action" @click="zoomIn">
|
|
|
+ <Icon icon="ep:plus" />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </tdt-control>
|
|
|
+
|
|
|
+ <tdt-control position="bottomright" visible>
|
|
|
+ <div class="floating-panel p-4!">
|
|
|
+ <div class="legend-panel__title">图例说明</div>
|
|
|
+ <div class="legend-list">
|
|
|
+ <div class="legend-item">
|
|
|
+ <span class="legend-chip legend-chip--online">
|
|
|
+ <span class="i-mdi:access-point legend-chip__icon"></span>
|
|
|
+ </span>
|
|
|
+ <span>在线设备</span>
|
|
|
+ </div>
|
|
|
+ <div class="legend-item">
|
|
|
+ <span class="legend-chip legend-chip--offline">
|
|
|
+ <span class="i-mdi:access-point-off legend-chip__icon"></span>
|
|
|
+ </span>
|
|
|
+ <span>离线设备</span>
|
|
|
+ </div>
|
|
|
+ <div class="legend-item">
|
|
|
+ <span class="legend-chip legend-chip--cluster">
|
|
|
+ <span class="i-mdi:google-circles-communities legend-chip__icon"></span>
|
|
|
+ </span>
|
|
|
+ <span>聚合点位</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </tdt-control>
|
|
|
+ </tdt-map>
|
|
|
+
|
|
|
+ <DeviceMonitorDrawer
|
|
|
+ :model-value="drawerVisible"
|
|
|
+ @update:model-value="(val) => (drawerVisible = val)"
|
|
|
+ :id="deviceId"
|
|
|
+ :deviceName="drawerDeviceName"
|
|
|
+ :lastLineTime="lastLineTime"
|
|
|
+ :ifLine="ifLine"
|
|
|
+ :dept="dept"
|
|
|
+ :deviceCode="deviceCode"
|
|
|
+ ref="showDrawer"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+.device-map-page {
|
|
|
+ --page-height: calc(
|
|
|
+ 100vh - 20px - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height)
|
|
|
+ );
|
|
|
+ --panel-bg: rgb(255 255 255 / 78%);
|
|
|
+ --panel-border: rgb(255 255 255 / 58%);
|
|
|
+ --panel-shadow: 0 22px 50px rgb(15 23 42 / 14%);
|
|
|
+ --text-main: #0f172a;
|
|
|
+ --text-sub: #526072;
|
|
|
+ --teal: #0f766e;
|
|
|
+ --orange: #f97316;
|
|
|
+ --slate: #334155;
|
|
|
+
|
|
|
position: relative;
|
|
|
- width: 100%;
|
|
|
- height: 90vh;
|
|
|
+ height: var(--page-height);
|
|
|
+ overflow: hidden;
|
|
|
+ border: 1px solid rgb(203 213 225 / 70%);
|
|
|
+ border-radius: 20px;
|
|
|
+ box-shadow: 0 24px 60px rgb(15 23 42 / 12%);
|
|
|
}
|
|
|
|
|
|
-#baidu-map {
|
|
|
- width: 100%;
|
|
|
- height: 100%;
|
|
|
+.floating-panel {
|
|
|
+ padding: 24px;
|
|
|
+ background: var(--panel-bg);
|
|
|
+ border: 1px solid var(--panel-border);
|
|
|
+ border-radius: 16px;
|
|
|
+ box-shadow: var(--panel-shadow);
|
|
|
+ backdrop-filter: blur(18px);
|
|
|
}
|
|
|
|
|
|
-.map-controls {
|
|
|
- position: absolute;
|
|
|
- top: 20px;
|
|
|
- right: 20px; /* 修改为right定位 */
|
|
|
- z-index: 1000;
|
|
|
+.hero-panel__head {
|
|
|
display: flex;
|
|
|
- flex-direction: column; /* 垂直排列按钮 */
|
|
|
- gap: 10px;
|
|
|
+ gap: 20px;
|
|
|
+ justify-content: space-between;
|
|
|
}
|
|
|
|
|
|
-.map-controls button {
|
|
|
- padding: 5px 10px;
|
|
|
- background: #fff;
|
|
|
- border: 1px solid #ccc;
|
|
|
- border-radius: 3px;
|
|
|
- cursor: pointer;
|
|
|
+.hero-panel__kicker {
|
|
|
+ margin-bottom: 6px;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 700;
|
|
|
+ letter-spacing: 0.16em;
|
|
|
+ color: var(--teal);
|
|
|
+ text-transform: uppercase;
|
|
|
+}
|
|
|
+
|
|
|
+.hero-panel__title {
|
|
|
+ font-size: 28px;
|
|
|
+ font-weight: 700;
|
|
|
+ line-height: 1.15;
|
|
|
+ color: var(--text-main);
|
|
|
+}
|
|
|
+
|
|
|
+.hero-panel__desc {
|
|
|
+ max-width: 460px;
|
|
|
+ margin-top: 8px;
|
|
|
+ font-size: 14px;
|
|
|
+ line-height: 1.6;
|
|
|
+ color: var(--text-sub);
|
|
|
+}
|
|
|
+
|
|
|
+.hero-panel__map-mode {
|
|
|
+ position: relative;
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
|
+ height: fit-content;
|
|
|
+ min-width: 152px;
|
|
|
+ padding: 4px;
|
|
|
+ gap: 0;
|
|
|
+ align-items: center;
|
|
|
+ background: linear-gradient(180deg, rgb(255 255 255 / 92%), rgb(241 245 249 / 90%));
|
|
|
+ border: 1px solid rgb(255 255 255 / 90%);
|
|
|
+ border-radius: 999px;
|
|
|
+ box-shadow:
|
|
|
+ inset 0 1px 0 rgb(255 255 255 / 92%),
|
|
|
+ 0 8px 20px rgb(15 23 42 / 8%);
|
|
|
}
|
|
|
-.status-info {
|
|
|
+
|
|
|
+.hero-panel__map-mode::before {
|
|
|
position: absolute;
|
|
|
- top: 20px;
|
|
|
- left: 40%;
|
|
|
- z-index: 1000;
|
|
|
- background: white;
|
|
|
- padding: 10px 15px;
|
|
|
- border-radius: 6px;
|
|
|
- box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
|
|
+ top: 4px;
|
|
|
+ left: 4px;
|
|
|
+ width: calc((100% - 8px) / 2);
|
|
|
+ height: calc(100% - 8px);
|
|
|
+ background: linear-gradient(135deg, #0f766e 0%, #14b8a6 100%);
|
|
|
+ border-radius: 999px;
|
|
|
+ content: '';
|
|
|
+ transform: translateX(0);
|
|
|
+ box-shadow:
|
|
|
+ 0 6px 14px rgb(15 118 110 / 16%),
|
|
|
+ inset 0 1px 0 rgb(255 255 255 / 22%);
|
|
|
+ transition: transform 0.22s ease;
|
|
|
+}
|
|
|
+
|
|
|
+.hero-panel__map-mode.is-satellite::before {
|
|
|
+ transform: translateX(100%);
|
|
|
+}
|
|
|
+
|
|
|
+.mode-chip {
|
|
|
+ position: relative;
|
|
|
+ z-index: 1;
|
|
|
+ display: inline-flex;
|
|
|
+ height: 36px;
|
|
|
+ min-width: 0;
|
|
|
+ padding: 0 18px;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 600;
|
|
|
+ line-height: 1;
|
|
|
+ text-align: center;
|
|
|
+ white-space: nowrap;
|
|
|
+ cursor: pointer;
|
|
|
+ background: transparent;
|
|
|
+ border: none;
|
|
|
+ border-radius: 999px;
|
|
|
+ transition:
|
|
|
+ color 0.18s ease,
|
|
|
+ opacity 0.18s ease;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+
|
|
|
+.mode-chip:not(.is-active) {
|
|
|
+ opacity: 0.82;
|
|
|
+}
|
|
|
+
|
|
|
+.mode-chip.is-active {
|
|
|
+ color: #fff;
|
|
|
+ opacity: 1;
|
|
|
+}
|
|
|
+
|
|
|
+.hero-stats {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
|
+ gap: 12px;
|
|
|
+ margin-top: 18px;
|
|
|
+}
|
|
|
+
|
|
|
+.hero-stat {
|
|
|
+ padding: 14px 16px;
|
|
|
+ background: rgb(255 255 255 / 72%);
|
|
|
+ border-radius: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.hero-stat span {
|
|
|
+ display: block;
|
|
|
+ font-size: 12px;
|
|
|
+ color: var(--text-sub);
|
|
|
+}
|
|
|
+
|
|
|
+.hero-stat strong {
|
|
|
+ display: block;
|
|
|
+ margin-top: 8px;
|
|
|
+ font-size: 28px;
|
|
|
+ line-height: 1;
|
|
|
+ color: var(--text-main);
|
|
|
+}
|
|
|
+
|
|
|
+.hero-stat--primary {
|
|
|
+ background: linear-gradient(135deg, rgb(15 118 110 / 12%), rgb(20 184 166 / 8%));
|
|
|
+}
|
|
|
+
|
|
|
+.hero-stat--success {
|
|
|
+ background: linear-gradient(135deg, rgb(34 197 94 / 12%), rgb(45 212 191 / 8%));
|
|
|
+}
|
|
|
+
|
|
|
+.hero-stat--danger {
|
|
|
+ background: linear-gradient(135deg, rgb(249 115 22 / 12%), rgb(239 68 68 / 8%));
|
|
|
+}
|
|
|
+
|
|
|
+.hero-stat--neutral {
|
|
|
+ background: linear-gradient(135deg, rgb(148 163 184 / 12%), rgb(203 213 225 / 8%));
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar-panel {
|
|
|
+ width: min(320px, calc(100vw - 64px));
|
|
|
+}
|
|
|
+
|
|
|
+.status-tabs {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
|
+ gap: 8px;
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.status-tab {
|
|
|
+ padding: 10px 12px;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: var(--text-sub);
|
|
|
+ cursor: pointer;
|
|
|
+ background: rgb(255 255 255 / 72%);
|
|
|
+ border: 1px solid rgb(148 163 184 / 18%);
|
|
|
+ border-radius: 14px;
|
|
|
+ transition: all 0.22s ease;
|
|
|
+}
|
|
|
+
|
|
|
+.status-tab.is-active {
|
|
|
+ color: var(--teal);
|
|
|
+ background: linear-gradient(135deg, rgb(15 118 110 / 12%), rgb(20 184 166 / 8%));
|
|
|
+ border-color: rgb(15 118 110 / 18%);
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar-panel__footer {
|
|
|
display: flex;
|
|
|
- gap: 20px;
|
|
|
+ gap: 14px;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ margin-top: 16px;
|
|
|
}
|
|
|
|
|
|
-.status-item {
|
|
|
+.toolbar-badges {
|
|
|
display: flex;
|
|
|
+ flex: 1;
|
|
|
+ gap: 8px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar-badge {
|
|
|
+ display: inline-flex;
|
|
|
+ padding: 7px 10px;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: var(--slate);
|
|
|
+ background: rgb(255 255 255 / 86%);
|
|
|
+ border-radius: 999px;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar-actions {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ justify-content: flex-end;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+
|
|
|
+.icon-action {
|
|
|
+ display: inline-flex;
|
|
|
+ width: 40px;
|
|
|
+ height: 40px;
|
|
|
+ font-size: 16px;
|
|
|
+ color: var(--text-main);
|
|
|
+ cursor: pointer;
|
|
|
+ background: linear-gradient(135deg, rgb(255 255 255 / 96%), rgb(248 250 252 / 86%));
|
|
|
+ border: none;
|
|
|
+ border-radius: 14px;
|
|
|
+ box-shadow: 0 12px 24px rgb(15 23 42 / 8%);
|
|
|
+ transition: transform 0.2s ease;
|
|
|
+ justify-content: center;
|
|
|
align-items: center;
|
|
|
+}
|
|
|
+
|
|
|
+.icon-action:hover {
|
|
|
+ transform: translateY(-1px);
|
|
|
+}
|
|
|
+
|
|
|
+.legend-panel__title {
|
|
|
+ margin-bottom: 10px;
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: var(--text-main);
|
|
|
+}
|
|
|
+
|
|
|
+.legend-list {
|
|
|
+ display: flex;
|
|
|
+ gap: 14px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+}
|
|
|
+
|
|
|
+.legend-item {
|
|
|
+ display: inline-flex;
|
|
|
+ font-size: 13px;
|
|
|
+ color: var(--text-sub);
|
|
|
gap: 8px;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+
|
|
|
+.legend-chip {
|
|
|
+ display: inline-flex;
|
|
|
+ width: 26px;
|
|
|
+ height: 26px;
|
|
|
+ color: #fff;
|
|
|
+ border-radius: 999px;
|
|
|
+ box-shadow: 0 10px 22px rgb(15 23 42 / 12%);
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+
|
|
|
+.legend-chip--online {
|
|
|
+ background: linear-gradient(135deg, #0f766e, #2dd4bf);
|
|
|
+}
|
|
|
+
|
|
|
+.legend-chip--offline {
|
|
|
+ background: linear-gradient(135deg, #ea580c, #fb7185);
|
|
|
+}
|
|
|
+
|
|
|
+.legend-chip--cluster {
|
|
|
+ background: linear-gradient(135deg, #0369a1, #1d4ed8);
|
|
|
+}
|
|
|
+
|
|
|
+.legend-chip__icon {
|
|
|
font-size: 14px;
|
|
|
- height: 50px;
|
|
|
}
|
|
|
|
|
|
-.online-icon {
|
|
|
- display: inline-block;
|
|
|
- width: 25px;
|
|
|
- height: 25px;
|
|
|
- background-image: url('https://aims.deepoil.cc/images/newding.svg');
|
|
|
- background-size: contain;
|
|
|
- background-repeat: no-repeat;
|
|
|
- background-position: center;
|
|
|
+.device-infowindow {
|
|
|
+ min-width: 280px;
|
|
|
+ color: var(--text-main);
|
|
|
+}
|
|
|
+
|
|
|
+.device-infowindow__list {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.device-infowindow__row {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: 92px minmax(0, 1fr);
|
|
|
+ gap: 12px;
|
|
|
+ padding: 10px 12px;
|
|
|
+ background: linear-gradient(180deg, rgb(248 250 252 / 96%), rgb(241 245 249 / 90%));
|
|
|
+ border: 1px solid rgb(226 232 240 / 90%);
|
|
|
+ border-radius: 14px;
|
|
|
+ align-items: start;
|
|
|
+}
|
|
|
+
|
|
|
+.device-infowindow__row span {
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 600;
|
|
|
+ line-height: 1.6;
|
|
|
+ color: var(--text-sub);
|
|
|
}
|
|
|
|
|
|
-.offline-icon {
|
|
|
- display: inline-block;
|
|
|
- width: 25px;
|
|
|
- height: 25px;
|
|
|
- background-image: url('https://aims.deepoil.cc/images/newfail.svg');
|
|
|
- background-size: contain;
|
|
|
- background-repeat: no-repeat;
|
|
|
- background-position: center;
|
|
|
+.device-infowindow__row strong {
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 600;
|
|
|
+ line-height: 1.5;
|
|
|
+ color: var(--text-main);
|
|
|
+ word-break: break-word;
|
|
|
}
|
|
|
|
|
|
+.device-infowindow__footer {
|
|
|
+ margin-top: 14px;
|
|
|
+}
|
|
|
|
|
|
-:deep(.el-select__selection) {
|
|
|
- height: 35px; /* 自定义高度 */
|
|
|
+.device-infowindow__button {
|
|
|
+ display: inline-flex;
|
|
|
+ width: 100%;
|
|
|
+ padding: 11px 16px;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #fff;
|
|
|
+ cursor: pointer;
|
|
|
+ background: linear-gradient(135deg, #0f766e, #14b8a6);
|
|
|
+ border: none;
|
|
|
+ border-radius: 14px;
|
|
|
+ box-shadow: 0 12px 22px rgb(15 118 110 / 20%);
|
|
|
+ transition:
|
|
|
+ transform 0.18s ease,
|
|
|
+ box-shadow 0.18s ease,
|
|
|
+ opacity 0.18s ease;
|
|
|
+ justify-content: center;
|
|
|
+ align-items: center;
|
|
|
}
|
|
|
-:deep(.el-select__placeholder.is-transparent){
|
|
|
- color: orangered;
|
|
|
+
|
|
|
+.device-infowindow__button:hover {
|
|
|
+ transform: translateY(-1px);
|
|
|
+ box-shadow: 0 14px 26px rgb(15 118 110 / 24%);
|
|
|
+}
|
|
|
+
|
|
|
+.device-infowindow__button:active {
|
|
|
+ opacity: 0.92;
|
|
|
+}
|
|
|
+
|
|
|
+@media (width <= 640px) {
|
|
|
+ .device-infowindow {
|
|
|
+ min-width: 236px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .device-infowindow__row {
|
|
|
+ grid-template-columns: 1fr;
|
|
|
+ gap: 6px;
|
|
|
+ }
|
|
|
}
|
|
|
</style>
|