|
@@ -1,1055 +1,455 @@
|
|
|
<script setup lang="ts">
|
|
<script setup lang="ts">
|
|
|
-import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
|
|
|
|
|
+import { LineLayer, PointLayer, Scene } from '@antv/l7'
|
|
|
|
|
+import type { ILayer } from '@antv/l7'
|
|
|
|
|
+import { Mapbox } from '@antv/l7-maps'
|
|
|
|
|
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
|
import { IotDeviceApi } from '@/api/pms/device'
|
|
import { IotDeviceApi } from '@/api/pms/device'
|
|
|
-import * as DeptApi from '@/api/system/dept'
|
|
|
|
|
-import DeviceMonitorDrawer from '@/views/pms/map/DeviceMonitorDrawer.vue'
|
|
|
|
|
-import { DICT_TYPE, getDictLabel } from '@/utils/dict'
|
|
|
|
|
-import { defaultProps, handleTree } from '@/utils/tree'
|
|
|
|
|
|
|
|
|
|
-type MapMode = 'TMAP_NORMAL_MAP' | 'TMAP_SATELLITE_MAP'
|
|
|
|
|
-type StatusFilter = 'all' | 'online' | 'offline'
|
|
|
|
|
|
|
+type TianDiTuLayer = 'img_w' | 'cia_w'
|
|
|
|
|
|
|
|
type MapDevice = {
|
|
type MapDevice = {
|
|
|
id: number
|
|
id: number
|
|
|
deviceCode: string
|
|
deviceCode: string
|
|
|
deviceName: string
|
|
deviceName: string
|
|
|
- lat: number
|
|
|
|
|
- lng: number
|
|
|
|
|
- location: string
|
|
|
|
|
- deptId: number
|
|
|
|
|
|
|
+ lat?: number | string | null
|
|
|
|
|
+ lng?: number | string | null
|
|
|
ifInline: 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
|
|
|
|
|
|
|
+type RenderDevice = Omit<MapDevice, 'lat' | 'lng'> & {
|
|
|
|
|
+ lat: number
|
|
|
|
|
+ lng: number
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-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<number | undefined>(undefined)
|
|
|
|
|
-const dept = ref('')
|
|
|
|
|
-const deviceCode = ref('')
|
|
|
|
|
|
|
+type ArcData = {
|
|
|
|
|
+ lng1: number
|
|
|
|
|
+ lat1: number
|
|
|
|
|
+ lng2: number
|
|
|
|
|
+ lat2: number
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
-const keyword = ref('')
|
|
|
|
|
-const statusFilter = ref<StatusFilter>('all')
|
|
|
|
|
-const mapMode = ref<MapMode>('TMAP_NORMAL_MAP')
|
|
|
|
|
|
|
+type DeviceLayerSet = {
|
|
|
|
|
+ arcBase: ILayer
|
|
|
|
|
+ arcAnimated: ILayer
|
|
|
|
|
+ halo: ILayer
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
-const RUIHENG_XINGYU_DEPT_ID = 157
|
|
|
|
|
|
|
+const INITIAL_CENTER: [number, number] = [104.114129, 36.550339]
|
|
|
|
|
+const ARC_ORIGIN: [number, number] = [118.4939, 37.40613]
|
|
|
|
|
+const MAP_ZOOM = 5
|
|
|
|
|
+const MAP_PITCH = 45
|
|
|
const DEFAULT_DEPT_ID = 156
|
|
const DEFAULT_DEPT_ID = 156
|
|
|
-const initialCenter: [number, number] = [104.114129, 36.550339]
|
|
|
|
|
-const initialZoom = 5
|
|
|
|
|
-const mapControls = ['Scale']
|
|
|
|
|
-const isZhzqSource = () => sessionStorage.getItem('LOGIN_SOURCE') === 'zhzq'
|
|
|
|
|
-const isZhlySource = () => sessionStorage.getItem('LOGIN_SOURCE') === 'zhly'
|
|
|
|
|
-
|
|
|
|
|
-const queryParams = reactive({
|
|
|
|
|
- deptId: isZhzqSource() ? RUIHENG_XINGYU_DEPT_ID : DEFAULT_DEPT_ID
|
|
|
|
|
-})
|
|
|
|
|
-
|
|
|
|
|
-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 RUIHENG_XINGYU_DEPT_ID = 157
|
|
|
|
|
+// 荧光洋红在绿色卫星影像和深色底图上都有较高对比度。
|
|
|
|
|
+const DEVICE_COLOR = '#ff3df2'
|
|
|
|
|
+
|
|
|
|
|
+// 相同数据结构的图层共享解析配置,避免在每次渲染时重复创建配置对象。
|
|
|
|
|
+const POINT_SOURCE_OPTIONS = {
|
|
|
|
|
+ parser: {
|
|
|
|
|
+ type: 'json' as const,
|
|
|
|
|
+ x: 'lng',
|
|
|
|
|
+ y: 'lat'
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-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 ARC_SOURCE_OPTIONS = {
|
|
|
|
|
+ parser: {
|
|
|
|
|
+ type: 'json' as const,
|
|
|
|
|
+ x: 'lng1',
|
|
|
|
|
+ y: 'lat1',
|
|
|
|
|
+ x1: 'lng2',
|
|
|
|
|
+ y1: 'lat2'
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-const statusOptions = [
|
|
|
|
|
- { label: '全部', value: 'all' },
|
|
|
|
|
- { label: '在线', value: 'online' },
|
|
|
|
|
- { label: '离线', value: 'offline' }
|
|
|
|
|
-] as const
|
|
|
|
|
-
|
|
|
|
|
-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))
|
|
|
|
|
|
|
+const mapContainerRef = ref<HTMLDivElement>()
|
|
|
|
|
+const mapLoading = ref(true)
|
|
|
|
|
+const mapError = ref('')
|
|
|
|
|
+const deviceLoading = ref(false)
|
|
|
|
|
+const deviceError = ref('')
|
|
|
|
|
+const devices = ref<RenderDevice[]>([])
|
|
|
|
|
|
|
|
- return matchKeyword && matchStatus
|
|
|
|
|
- })
|
|
|
|
|
-})
|
|
|
|
|
-
|
|
|
|
|
-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 deviceCount = computed(() => devices.value.length)
|
|
|
|
|
+const deviceSummary = computed(() =>
|
|
|
|
|
+ deviceLoading.value ? '设备加载中' : `${deviceCount.value} 个设备`
|
|
|
)
|
|
)
|
|
|
-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()])
|
|
|
|
|
-})
|
|
|
|
|
|
|
+let scene: Scene | null = null
|
|
|
|
|
+let sceneReady = false
|
|
|
|
|
+let disposed = false
|
|
|
|
|
|
|
|
-const loadDeptTree = async () => {
|
|
|
|
|
- const res = isZhzqSource()
|
|
|
|
|
- ? await DeptApi.specifiedSimpleDepts(RUIHENG_XINGYU_DEPT_ID)
|
|
|
|
|
- : await DeptApi.getSimpleDeptList()
|
|
|
|
|
- deptList.value = res || []
|
|
|
|
|
- treeList.value = handleTree(res || [])
|
|
|
|
|
-}
|
|
|
|
|
|
|
+// 所有设备共用三个图层。后续设备刷新通过 setData 更新,避免反复分配 WebGL 资源。
|
|
|
|
|
+let deviceLayerSet: DeviceLayerSet | null = null
|
|
|
|
|
|
|
|
-const loadDevices = async () => {
|
|
|
|
|
- loading.value = true
|
|
|
|
|
- try {
|
|
|
|
|
- const res = await IotDeviceApi.getMapDevice({
|
|
|
|
|
- ...queryParams,
|
|
|
|
|
- ...(isZhlySource() ? { source: 'ly' } : {})
|
|
|
|
|
- })
|
|
|
|
|
- devices.value = res || []
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- message.error('地图设备加载失败,请稍后重试')
|
|
|
|
|
- } finally {
|
|
|
|
|
- loading.value = false
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
|
|
+const getLoginSource = () => sessionStorage.getItem('LOGIN_SOURCE')
|
|
|
|
|
|
|
|
-const handleDeptChange = () => {
|
|
|
|
|
- return loadDevices()
|
|
|
|
|
-}
|
|
|
|
|
|
|
+const getDeviceQuery = () => {
|
|
|
|
|
+ const loginSource = getLoginSource()
|
|
|
|
|
|
|
|
-const handleMapInit = (map: any) => {
|
|
|
|
|
- mapRef.value = map
|
|
|
|
|
- applyMapMode()
|
|
|
|
|
- syncCurrentZoom()
|
|
|
|
|
|
|
+ return {
|
|
|
|
|
+ deptId: loginSource === 'zhzq' ? RUIHENG_XINGYU_DEPT_ID : DEFAULT_DEPT_ID,
|
|
|
|
|
+ ...(loginSource === 'zhly' ? { source: 'ly' } : {})
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-const syncCurrentZoom = () => {
|
|
|
|
|
- currentZoom.value = mapRef.value?.getZoom?.() ?? initialZoom
|
|
|
|
|
-}
|
|
|
|
|
|
|
+const normalizeDevices = (rawDevices: MapDevice[]): RenderDevice[] => {
|
|
|
|
|
+ const result: RenderDevice[] = []
|
|
|
|
|
+
|
|
|
|
|
+ // 清洗和坐标转换在一次遍历中完成。
|
|
|
|
|
+ rawDevices.forEach((device) => {
|
|
|
|
|
+ const lng = Number(device.lng)
|
|
|
|
|
+ const lat = Number(device.lat)
|
|
|
|
|
+ const positionValid =
|
|
|
|
|
+ Number.isFinite(lng) &&
|
|
|
|
|
+ Number.isFinite(lat) &&
|
|
|
|
|
+ lng !== 0 &&
|
|
|
|
|
+ lat !== 0 &&
|
|
|
|
|
+ lng >= -180 &&
|
|
|
|
|
+ lng <= 180 &&
|
|
|
|
|
+ lat >= -90 &&
|
|
|
|
|
+ lat <= 90
|
|
|
|
|
+
|
|
|
|
|
+ if (!positionValid) return
|
|
|
|
|
+
|
|
|
|
|
+ result.push({
|
|
|
|
|
+ ...device,
|
|
|
|
|
+ lng,
|
|
|
|
|
+ lat
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
|
|
|
-const zoomIn = () => {
|
|
|
|
|
- mapRef.value?.zoomIn?.()
|
|
|
|
|
|
|
+ return result
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-const zoomOut = () => {
|
|
|
|
|
- mapRef.value?.zoomOut?.()
|
|
|
|
|
-}
|
|
|
|
|
|
|
+const createArcData = (list: RenderDevice[]): ArcData[] =>
|
|
|
|
|
+ list.map((device) => ({
|
|
|
|
|
+ lng1: ARC_ORIGIN[0],
|
|
|
|
|
+ lat1: ARC_ORIGIN[1],
|
|
|
|
|
+ lng2: device.lng,
|
|
|
|
|
+ lat2: device.lat
|
|
|
|
|
+ }))
|
|
|
|
|
|
|
|
-const switchMapMode = (mode: MapMode) => {
|
|
|
|
|
- mapMode.value = mode
|
|
|
|
|
- applyMapMode()
|
|
|
|
|
-}
|
|
|
|
|
|
|
+const createDeviceLayerSet = (
|
|
|
|
|
+ currentScene: Scene,
|
|
|
|
|
+ renderDevices: RenderDevice[],
|
|
|
|
|
+ arcs: ArcData[]
|
|
|
|
|
+): DeviceLayerSet => {
|
|
|
|
|
+ const arcBase = new LineLayer({
|
|
|
|
|
+ name: 'device-arc-base',
|
|
|
|
|
+ zIndex: 4,
|
|
|
|
|
+ blend: 'normal'
|
|
|
|
|
+ })
|
|
|
|
|
+ .source(arcs, ARC_SOURCE_OPTIONS)
|
|
|
|
|
+ .shape('arc')
|
|
|
|
|
+ .size(4)
|
|
|
|
|
+ .color(DEVICE_COLOR)
|
|
|
|
|
+ .style({ opacity: 0.62 })
|
|
|
|
|
+
|
|
|
|
|
+ const arcAnimated = new LineLayer({
|
|
|
|
|
+ name: 'device-arc-animated',
|
|
|
|
|
+ zIndex: 5,
|
|
|
|
|
+ blend: 'normal'
|
|
|
|
|
+ })
|
|
|
|
|
+ .source(arcs, ARC_SOURCE_OPTIONS)
|
|
|
|
|
+ .shape('arc')
|
|
|
|
|
+ .size(7)
|
|
|
|
|
+ .color(DEVICE_COLOR)
|
|
|
|
|
+ .style({ opacity: 1 })
|
|
|
|
|
+ .animate({
|
|
|
|
|
+ enable: true,
|
|
|
|
|
+ duration: 2.5,
|
|
|
|
|
+ interval: 0.08,
|
|
|
|
|
+ trailLength: 0.8
|
|
|
|
|
+ })
|
|
|
|
|
|
|
|
-const applyMapMode = () => {
|
|
|
|
|
- if (!mapRef.value) return
|
|
|
|
|
- ;(mapRef.value as any).setMapType?.((window as any)[mapMode.value])
|
|
|
|
|
-}
|
|
|
|
|
|
|
+ const halo = new PointLayer({
|
|
|
|
|
+ name: 'device-halo',
|
|
|
|
|
+ zIndex: 10,
|
|
|
|
|
+ blend: 'additive'
|
|
|
|
|
+ })
|
|
|
|
|
+ .source(renderDevices, POINT_SOURCE_OPTIONS)
|
|
|
|
|
+ .shape('circle')
|
|
|
|
|
+ .size(28)
|
|
|
|
|
+ .color(DEVICE_COLOR)
|
|
|
|
|
+ .style({
|
|
|
|
|
+ opacity: 0.46,
|
|
|
|
|
+ stroke: DEVICE_COLOR,
|
|
|
|
|
+ strokeWidth: 2.5
|
|
|
|
|
+ })
|
|
|
|
|
+ .animate(true)
|
|
|
|
|
|
|
|
-const isOnline = (device?: MapDevice | null) => {
|
|
|
|
|
- return Number(device?.ifInline) === 3
|
|
|
|
|
|
|
+ const layerSet = { arcBase, arcAnimated, halo }
|
|
|
|
|
+ Object.values(layerSet).forEach((layer) => currentScene.addLayer(layer))
|
|
|
|
|
+ return layerSet
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-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 updateDeviceLayerSet = (
|
|
|
|
|
+ layerSet: DeviceLayerSet,
|
|
|
|
|
+ renderDevices: RenderDevice[],
|
|
|
|
|
+ arcs: ArcData[]
|
|
|
|
|
+) => {
|
|
|
|
|
+ layerSet.arcBase.setData(arcs)
|
|
|
|
|
+ layerSet.arcAnimated.setData(arcs)
|
|
|
|
|
+ layerSet.halo.setData(renderDevices)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-const getMarkerIcon = (device?: MapDevice | null) => {
|
|
|
|
|
- const theme = isOnline(device) ? markerThemeMap.online : markerThemeMap.offline
|
|
|
|
|
- const cachedIcon = markerIconCache.get(theme.key)
|
|
|
|
|
|
|
+const renderDevices = () => {
|
|
|
|
|
+ const currentScene = scene
|
|
|
|
|
+ if (!currentScene || !sceneReady) return
|
|
|
|
|
|
|
|
- if (cachedIcon) return cachedIcon
|
|
|
|
|
|
|
+ const arcs = createArcData(devices.value)
|
|
|
|
|
|
|
|
- const markerIcon = {
|
|
|
|
|
- iconUrl: svgToDataUri(buildMarkerSvg(theme)),
|
|
|
|
|
- iconSize: [46, 58] as [number, number],
|
|
|
|
|
- iconAnchor: [23, 49] as [number, number]
|
|
|
|
|
|
|
+ if (deviceLayerSet) {
|
|
|
|
|
+ updateDeviceLayerSet(deviceLayerSet, devices.value, arcs)
|
|
|
|
|
+ return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- markerIconCache.set(theme.key, markerIcon)
|
|
|
|
|
- return markerIcon
|
|
|
|
|
|
|
+ if (!devices.value.length) return
|
|
|
|
|
+ deviceLayerSet = createDeviceLayerSet(currentScene, devices.value, arcs)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-const buildMarkerSvg = (theme: MarkerTheme) => {
|
|
|
|
|
- const glyph = renderMarkerGlyph(theme.iconName, 18)
|
|
|
|
|
|
|
+const loadDevices = async () => {
|
|
|
|
|
+ deviceLoading.value = true
|
|
|
|
|
+ deviceError.value = ''
|
|
|
|
|
|
|
|
- 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()
|
|
|
|
|
-}
|
|
|
|
|
|
|
+ try {
|
|
|
|
|
+ const response = (await IotDeviceApi.getMapDevice(getDeviceQuery())) as MapDevice[]
|
|
|
|
|
+ if (disposed) return
|
|
|
|
|
|
|
|
-const renderMarkerGlyph = (iconName: MarkerGlyphName, boxSize = 18) => {
|
|
|
|
|
- const iconBody = markerGlyphBodyMap[iconName]
|
|
|
|
|
- const iconScale = boxSize / 24
|
|
|
|
|
|
|
+ devices.value = normalizeDevices(response || [])
|
|
|
|
|
+ renderDevices()
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ if (disposed) return
|
|
|
|
|
|
|
|
- return `
|
|
|
|
|
- <g transform="scale(${iconScale})">
|
|
|
|
|
- ${iconBody}
|
|
|
|
|
- </g>
|
|
|
|
|
- `.trim()
|
|
|
|
|
|
|
+ deviceError.value = '设备位置加载失败'
|
|
|
|
|
+ console.error('Failed to load map devices:', error)
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (!disposed) deviceLoading.value = false
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-const svgToDataUri = (svg: string) => {
|
|
|
|
|
- return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`
|
|
|
|
|
-}
|
|
|
|
|
|
|
+const createTianDiTuTileUrls = (type: TianDiTuLayer, token: string) =>
|
|
|
|
|
+ Array.from(
|
|
|
|
|
+ { length: 8 },
|
|
|
|
|
+ (_, index) =>
|
|
|
|
|
+ `https://t${index}.tianditu.gov.cn/DataServer?T=${type}&x={x}&y={y}&l={z}&tk=${token}`
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
-const clusterStyles = [
|
|
|
|
|
- {
|
|
|
|
|
- size: 80,
|
|
|
|
|
- textColor: '#ffffff',
|
|
|
|
|
- textSize: 14,
|
|
|
|
|
- range: [0, 20]
|
|
|
|
|
- },
|
|
|
|
|
- {
|
|
|
|
|
- size: 80,
|
|
|
|
|
- textColor: '#ffffff',
|
|
|
|
|
- textSize: 14,
|
|
|
|
|
- range: [20, 100]
|
|
|
|
|
|
|
+const createSatelliteStyle = (token: string) => ({
|
|
|
|
|
+ version: 8,
|
|
|
|
|
+ sources: {
|
|
|
|
|
+ 'tianditu-satellite': {
|
|
|
|
|
+ type: 'raster',
|
|
|
|
|
+ tiles: createTianDiTuTileUrls('img_w', token),
|
|
|
|
|
+ tileSize: 256,
|
|
|
|
|
+ minzoom: 0,
|
|
|
|
|
+ maxzoom: 18
|
|
|
|
|
+ },
|
|
|
|
|
+ 'tianditu-labels': {
|
|
|
|
|
+ type: 'raster',
|
|
|
|
|
+ tiles: createTianDiTuTileUrls('cia_w', token),
|
|
|
|
|
+ tileSize: 256,
|
|
|
|
|
+ minzoom: 0,
|
|
|
|
|
+ maxzoom: 18
|
|
|
|
|
+ }
|
|
|
},
|
|
},
|
|
|
- {
|
|
|
|
|
- size: 80,
|
|
|
|
|
- textColor: '#ffffff',
|
|
|
|
|
- textSize: 14,
|
|
|
|
|
- range: [100, 99999]
|
|
|
|
|
- }
|
|
|
|
|
-]
|
|
|
|
|
-
|
|
|
|
|
-const handleMarkerClick = (e: any) => {
|
|
|
|
|
- const device = e?.layer?.extData as MapDevice | undefined
|
|
|
|
|
-
|
|
|
|
|
- if (!device) return
|
|
|
|
|
-
|
|
|
|
|
- openInfoWindow(device, resolveLngLat(e?.lnglat))
|
|
|
|
|
-}
|
|
|
|
|
|
|
+ layers: [
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'tianditu-satellite',
|
|
|
|
|
+ type: 'raster',
|
|
|
|
|
+ source: 'tianditu-satellite'
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'tianditu-labels',
|
|
|
|
|
+ type: 'raster',
|
|
|
|
|
+ source: 'tianditu-labels'
|
|
|
|
|
+ }
|
|
|
|
|
+ ]
|
|
|
|
|
+})
|
|
|
|
|
|
|
|
-const handleInfoWindowTargetChange = (target: [number, number] | null) => {
|
|
|
|
|
- infoWindowTarget.value = target
|
|
|
|
|
|
|
+const initMap = () => {
|
|
|
|
|
+ const container = mapContainerRef.value
|
|
|
|
|
+ const rawToken = import.meta.env.VITE_TK?.trim()
|
|
|
|
|
|
|
|
- if (!target) {
|
|
|
|
|
- activeDevice.value = null
|
|
|
|
|
|
|
+ if (!container) return
|
|
|
|
|
+ if (!rawToken) {
|
|
|
|
|
+ mapLoading.value = false
|
|
|
|
|
+ mapError.value = '地图加载失败,请配置天地图密钥'
|
|
|
|
|
+ return
|
|
|
}
|
|
}
|
|
|
-}
|
|
|
|
|
|
|
|
|
|
-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
|
|
|
|
|
-}
|
|
|
|
|
|
|
+ try {
|
|
|
|
|
+ const currentScene = new Scene({
|
|
|
|
|
+ id: container,
|
|
|
|
|
+ map: new Mapbox({
|
|
|
|
|
+ style: createSatelliteStyle(encodeURIComponent(rawToken)),
|
|
|
|
|
+ center: INITIAL_CENTER,
|
|
|
|
|
+ zoom: MAP_ZOOM,
|
|
|
|
|
+ pitch: MAP_PITCH,
|
|
|
|
|
+ rotation: 0,
|
|
|
|
|
+ minZoom: 3,
|
|
|
|
|
+ maxZoom: 18
|
|
|
|
|
+ }),
|
|
|
|
|
+ logoVisible: false
|
|
|
|
|
+ })
|
|
|
|
|
|
|
|
-const resolveLngLat = (lnglat: any): [number, number] | null => {
|
|
|
|
|
- const lng = Number(lnglat?.lng ?? lnglat?.getLng?.())
|
|
|
|
|
- const lat = Number(lnglat?.lat ?? lnglat?.getLat?.())
|
|
|
|
|
|
|
+ scene = currentScene
|
|
|
|
|
+ currentScene.on('loaded', () => {
|
|
|
|
|
+ // loaded 可能晚于组件卸载,必须确认场景仍是当前实例。
|
|
|
|
|
+ if (disposed || scene !== currentScene) return
|
|
|
|
|
|
|
|
- if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
|
|
|
|
|
- return null
|
|
|
|
|
|
|
+ sceneReady = true
|
|
|
|
|
+ mapLoading.value = false
|
|
|
|
|
+ renderDevices()
|
|
|
|
|
+ })
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ mapLoading.value = false
|
|
|
|
|
+ mapError.value = '地图加载失败,请检查天地图密钥或网络连接'
|
|
|
|
|
+ console.error('Failed to initialize the L7 TianDiTu map:', error)
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- return [lng, lat]
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-const getDeptName = (device?: MapDevice | null) => {
|
|
|
|
|
- return deptNameMap.value.get(Number(device?.deptId)) || '-'
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-const getDeviceStatusText = (device?: MapDevice | null) => {
|
|
|
|
|
- return getDictLabel(DICT_TYPE.PMS_DEVICE_STATUS, device?.deviceStatus) || '-'
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-const getDeviceOnlineText = (device?: MapDevice | null) => {
|
|
|
|
|
- return (
|
|
|
|
|
- getDictLabel(DICT_TYPE.IOT_DEVICE_STATUS, device?.ifInline) ||
|
|
|
|
|
- (isOnline(device) ? '在线' : '离线')
|
|
|
|
|
- )
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-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)]
|
|
|
|
|
|
|
+onMounted(() => {
|
|
|
|
|
+ // 地图初始化和设备请求互不依赖,并行启动可缩短首屏等待时间。
|
|
|
|
|
+ initMap()
|
|
|
|
|
+ loadDevices()
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
-const handleClusterInit = (clusterer: any) => {
|
|
|
|
|
- clusterer?.setMaxZoom?.(16)
|
|
|
|
|
-}
|
|
|
|
|
|
|
+onBeforeUnmount(() => {
|
|
|
|
|
+ disposed = true
|
|
|
|
|
+ sceneReady = false
|
|
|
|
|
+ deviceLayerSet = null
|
|
|
|
|
+ scene?.destroy()
|
|
|
|
|
+ scene = null
|
|
|
|
|
+})
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
<template>
|
|
<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="map-page">
|
|
|
|
|
+ <div ref="mapContainerRef" class="map-container"></div>
|
|
|
|
|
|
|
|
- <div class="toolbar-panel__footer">
|
|
|
|
|
- <div class="toolbar-badges">
|
|
|
|
|
- <span class="toolbar-badge">缩放 {{ currentZoom }}</span>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ <div class="map-mode-badge">
|
|
|
|
|
+ <span class="map-mode-badge__dot"></span>
|
|
|
|
|
+ <span>天地图 · 3D 卫星 · {{ deviceSummary }}</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>
|
|
|
|
|
|
|
+ <div v-if="!deviceLoading && deviceCount" class="device-legend">
|
|
|
|
|
+ <span><i class="device-legend__dot"></i>设备</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
- <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 v-if="mapLoading" class="map-state">地图加载中...</div>
|
|
|
|
|
+ <div v-else-if="mapError" class="map-state map-state--error">{{ mapError }}</div>
|
|
|
|
|
+ <div v-if="deviceError" class="device-error">{{ deviceError }}</div>
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
|
<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;
|
|
|
|
|
|
|
+@mixin glass-panel($radius) {
|
|
|
|
|
+ color: #f8fafc;
|
|
|
|
|
+ pointer-events: none;
|
|
|
|
|
+ background: rgb(15 23 42 / 78%);
|
|
|
|
|
+ border: 1px solid rgb(255 255 255 / 18%);
|
|
|
|
|
+ border-radius: $radius;
|
|
|
|
|
+ box-shadow: 0 8px 24px rgb(15 23 42 / 24%);
|
|
|
|
|
+ backdrop-filter: blur(10px);
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
|
|
+.map-page {
|
|
|
position: relative;
|
|
position: relative;
|
|
|
- height: var(--page-height);
|
|
|
|
|
|
|
+ height: calc(
|
|
|
|
|
+ 100vh - 20px - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height)
|
|
|
|
|
+ );
|
|
|
|
|
+ min-height: 480px;
|
|
|
overflow: hidden;
|
|
overflow: hidden;
|
|
|
- border: 1px solid rgb(203 213 225 / 70%);
|
|
|
|
|
- border-radius: 20px;
|
|
|
|
|
- box-shadow: 0 24px 60px rgb(15 23 42 / 12%);
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-.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);
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-.hero-panel__head {
|
|
|
|
|
- display: flex;
|
|
|
|
|
- gap: 20px;
|
|
|
|
|
- justify-content: space-between;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-.hero-panel__kicker {
|
|
|
|
|
- margin-bottom: 6px;
|
|
|
|
|
- font-size: 12px;
|
|
|
|
|
- font-weight: 700;
|
|
|
|
|
- letter-spacing: 0.16em;
|
|
|
|
|
- color: var(--teal);
|
|
|
|
|
- text-transform: uppercase;
|
|
|
|
|
|
|
+ background: #eef2f6;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.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);
|
|
|
|
|
|
|
+.map-container {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: 100%;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.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%);
|
|
|
|
|
-}
|
|
|
|
|
|
|
+.map-mode-badge {
|
|
|
|
|
+ @include glass-panel(999px);
|
|
|
|
|
|
|
|
-.hero-panel__map-mode::before {
|
|
|
|
|
position: absolute;
|
|
position: absolute;
|
|
|
- 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;
|
|
|
|
|
|
|
+ top: 16px;
|
|
|
|
|
+ right: 16px;
|
|
|
|
|
+ z-index: 10;
|
|
|
display: inline-flex;
|
|
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;
|
|
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: 14px;
|
|
|
|
|
- justify-content: space-between;
|
|
|
|
|
align-items: center;
|
|
align-items: center;
|
|
|
- margin-top: 16px;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-.toolbar-badges {
|
|
|
|
|
- display: flex;
|
|
|
|
|
- flex: 1;
|
|
|
|
|
- gap: 8px;
|
|
|
|
|
- flex-wrap: wrap;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-.toolbar-badge {
|
|
|
|
|
- display: inline-flex;
|
|
|
|
|
- padding: 7px 10px;
|
|
|
|
|
- font-size: 12px;
|
|
|
|
|
|
|
+ padding: 9px 13px;
|
|
|
|
|
+ font-size: 13px;
|
|
|
font-weight: 600;
|
|
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;
|
|
|
|
|
|
|
+.map-mode-badge__dot {
|
|
|
|
|
+ width: 7px;
|
|
|
|
|
+ height: 7px;
|
|
|
|
|
+ background: #38bdf8;
|
|
|
|
|
+ border-radius: 50%;
|
|
|
|
|
+ box-shadow: 0 0 10px #38bdf8;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.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;
|
|
|
|
|
-}
|
|
|
|
|
|
|
+.device-legend {
|
|
|
|
|
+ @include glass-panel(8px);
|
|
|
|
|
|
|
|
-.icon-action:hover {
|
|
|
|
|
- transform: translateY(-1px);
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-.legend-panel__title {
|
|
|
|
|
- margin-bottom: 10px;
|
|
|
|
|
- font-size: 14px;
|
|
|
|
|
- font-weight: 700;
|
|
|
|
|
- color: var(--text-main);
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-.legend-list {
|
|
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ right: 16px;
|
|
|
|
|
+ bottom: 16px;
|
|
|
|
|
+ z-index: 10;
|
|
|
display: flex;
|
|
display: flex;
|
|
|
gap: 14px;
|
|
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;
|
|
align-items: center;
|
|
|
- justify-content: center;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-.legend-chip--online {
|
|
|
|
|
- background: linear-gradient(135deg, #0f766e, #2dd4bf);
|
|
|
|
|
-}
|
|
|
|
|
|
|
+ padding: 9px 13px;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
|
|
|
-.legend-chip--offline {
|
|
|
|
|
- background: linear-gradient(135deg, #ea580c, #fb7185);
|
|
|
|
|
|
|
+ span {
|
|
|
|
|
+ display: inline-flex;
|
|
|
|
|
+ gap: 6px;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.legend-chip--cluster {
|
|
|
|
|
- background: linear-gradient(135deg, #0369a1, #1d4ed8);
|
|
|
|
|
|
|
+.device-legend__dot {
|
|
|
|
|
+ width: 8px;
|
|
|
|
|
+ height: 8px;
|
|
|
|
|
+ background: #ff3df2;
|
|
|
|
|
+ border: 1px solid #fff;
|
|
|
|
|
+ border-radius: 50%;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.legend-chip__icon {
|
|
|
|
|
|
|
+.map-state {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ top: 50%;
|
|
|
|
|
+ left: 50%;
|
|
|
|
|
+ z-index: 10;
|
|
|
|
|
+ padding: 10px 16px;
|
|
|
font-size: 14px;
|
|
font-size: 14px;
|
|
|
|
|
+ color: #475569;
|
|
|
|
|
+ pointer-events: none;
|
|
|
|
|
+ background: rgb(255 255 255 / 92%);
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
|
|
+ box-shadow: 0 8px 24px rgb(15 23 42 / 12%);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.device-infowindow {
|
|
|
|
|
- min-width: 280px;
|
|
|
|
|
- color: var(--text-main);
|
|
|
|
|
|
|
+.map-state--error {
|
|
|
|
|
+ color: #dc2626;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.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 {
|
|
|
|
|
|
|
+.device-error {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ top: 68px;
|
|
|
|
|
+ right: 16px;
|
|
|
|
|
+ z-index: 10;
|
|
|
|
|
+ padding: 8px 12px;
|
|
|
font-size: 12px;
|
|
font-size: 12px;
|
|
|
- font-weight: 600;
|
|
|
|
|
- line-height: 1.6;
|
|
|
|
|
- color: var(--text-sub);
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-.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;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-.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;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-.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;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ color: #b91c1c;
|
|
|
|
|
+ background: rgb(254 242 242 / 94%);
|
|
|
|
|
+ border: 1px solid #fecaca;
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+ box-shadow: 0 8px 24px rgb(15 23 42 / 12%);
|
|
|
}
|
|
}
|
|
|
</style>
|
|
</style>
|