|
|
@@ -28,9 +28,28 @@
|
|
|
<!-- 左侧图层控制面板 -->
|
|
|
<div class="layer-control">
|
|
|
<div class="layer-header">
|
|
|
- <el-icon>x</el-icon> 态势图层
|
|
|
+ <el-icon><Monitor /></el-icon> 态势图层
|
|
|
+ </div>
|
|
|
+ <div class="layer-filter-panel">
|
|
|
+ <el-input
|
|
|
+ v-model="queryParams.keyword"
|
|
|
+ clearable
|
|
|
+ :prefix-icon="Search"
|
|
|
+ placeholder="设备编号/名称/位置模糊查询"
|
|
|
+ @keyup.enter="handleSearch"
|
|
|
+ />
|
|
|
+ <div class="filter-actions">
|
|
|
+ <el-button type="primary" size="small" @click="handleSearch">查询</el-button>
|
|
|
+ <el-button size="small" :icon="RefreshRight" @click="handleReset">重置</el-button>
|
|
|
+ </div>
|
|
|
+ <div class="filter-summary">
|
|
|
+ <el-tag size="small" effect="plain">搜索结果 {{ totalDevices }} 台</el-tag>
|
|
|
+ <el-tag size="small" type="success" effect="plain">当前显示 {{ displayDevices.length }} 台</el-tag>
|
|
|
+ <el-tag v-if="activeQuickFilter" size="small" type="warning" effect="plain">态势筛选: {{ quickFilterLabel }}</el-tag>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
<el-tree
|
|
|
+ ref="layerTreeRef"
|
|
|
:data="layerTree"
|
|
|
show-checkbox
|
|
|
node-key="id"
|
|
|
@@ -57,6 +76,7 @@
|
|
|
<div class="time-selector">
|
|
|
<el-date-picker v-model="timeRange" type="datetimerange" range-separator="至" size="small" :shortcuts="shortcuts" start-placeholder="开始" end-placeholder="结束" />
|
|
|
</div>
|
|
|
+ <div class="panel-tip">已选图层会实时过滤地图点位,支持结合关键字一起查询。</div>
|
|
|
</div>
|
|
|
|
|
|
<!-- 右侧GIS地图核心区 -->
|
|
|
@@ -68,23 +88,23 @@
|
|
|
<el-button size="small" :icon="FullScreen" @click="resetView">重置视图</el-button>
|
|
|
</el-button-group>
|
|
|
<div class="current-stat">
|
|
|
- <span><el-icon><Position /></el-icon> 监测点总数: {{ totalDevices }}</span>
|
|
|
+ <span><el-icon><Position /></el-icon> 设备总数: {{ totalDevices }}</span>
|
|
|
+ <span><el-icon><Monitor /></el-icon> 当前显示: {{ displayDevices.length }}</span>
|
|
|
<span class="alert-summary">报警中: {{ totalActiveAlerts }}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
- <div class="map-container" ref="mapContainer">
|
|
|
- <canvas id="alertMapCanvas" ref="canvasRef" @click="onMapClick" @mousemove="onCanvasMouseMove"></canvas>
|
|
|
- <!-- 悬浮信息卡 -->
|
|
|
- <div v-if="hoverDevice" class="hover-card" :style="{ top: hoverY + 'px', left: hoverX + 'px' }">
|
|
|
- <div class="hover-title">{{ hoverDevice.name }}</div>
|
|
|
- <div>状态: {{ hoverDevice.statusText }}</div>
|
|
|
- <div>报警: {{ hoverDevice.hasAlert ? hoverDevice.alertType : '无' }}</div>
|
|
|
- <div>位置: {{ hoverDevice.locationShort }}</div>
|
|
|
+ <div class="map-container" ref="mapContainer" v-loading="listLoading">
|
|
|
+ <div ref="baiduMapRef" class="baidu-map"></div>
|
|
|
+ <div v-if="!listLoading && !displayDevices.length" class="empty-state">
|
|
|
+ 当前筛选条件下暂无设备
|
|
|
+ </div>
|
|
|
+ <div v-if="mapError" class="map-error">
|
|
|
+ {{ mapError }}
|
|
|
</div>
|
|
|
</div>
|
|
|
<!-- 右下角报警聚合弹窗/详情 -->
|
|
|
<el-drawer v-model="drawerVisible" :title="selectedDevice?.name + ' - 监测报警详情'" direction="rtl" size="420px">
|
|
|
- <div v-if="selectedDevice">
|
|
|
+ <div v-if="selectedDevice" v-loading="detailLoading">
|
|
|
<el-descriptions :column="1" border>
|
|
|
<el-descriptions-item label="设备编号">{{ selectedDevice.id }}</el-descriptions-item>
|
|
|
<el-descriptions-item label="地理位置">{{ selectedDevice.address }}</el-descriptions-item>
|
|
|
@@ -118,62 +138,105 @@
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
-import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
|
|
-import { MapLocation, ZoomIn, ZoomOut, FullScreen, Position, WarningFilled, Location, Monitor, Opportunity } from '@element-plus/icons-vue'
|
|
|
-import { ElMessage } from 'element-plus'
|
|
|
+import {computed, nextTick, onMounted, onUnmounted, ref, watch} from 'vue'
|
|
|
+import {
|
|
|
+ Connection,
|
|
|
+ FullScreen,
|
|
|
+ InfoFilled,
|
|
|
+ Monitor,
|
|
|
+ Position,
|
|
|
+ RefreshRight,
|
|
|
+ Search,
|
|
|
+ Setting,
|
|
|
+ WarningFilled,
|
|
|
+ ZoomIn,
|
|
|
+ ZoomOut
|
|
|
+} from '@element-plus/icons-vue'
|
|
|
+import {ElMessage} from 'element-plus'
|
|
|
+import {getManholeAlarmDeviceDetail, getManholeAlarmDeviceList} from '@/api/pipeNetwork/basic'
|
|
|
|
|
|
-// 图层树结构(分图层汇聚展示)
|
|
|
const layerTree = ref([
|
|
|
{
|
|
|
- id: 'layer-status',
|
|
|
- label: '井盖本体状态图层',
|
|
|
- icon: 'Monitor',
|
|
|
- alertCount: 3,
|
|
|
+ id: 'current-status',
|
|
|
+ label: '运维状态筛选',
|
|
|
+ icon: Setting,
|
|
|
+ alertCount: 0,
|
|
|
children: [
|
|
|
- { id: 'tilt', label: '位移/倾斜监测', icon: 'Position', alertCount: 2 },
|
|
|
- { id: 'open', label: '开盖状态监测', icon: 'Lock', alertCount: 1 },
|
|
|
- { id: 'vibration', label: '震动异常监测', icon: 'Opportunity', alertCount: 0 }
|
|
|
+ { id: 'current-1', label: '在用设备', icon: Monitor, alertCount: 0 },
|
|
|
+ { id: 'current-3', label: '维修设备', icon: Setting, alertCount: 0 }
|
|
|
]
|
|
|
},
|
|
|
{
|
|
|
- id: 'layer-alert',
|
|
|
- label: '实时报警图层',
|
|
|
- icon: 'WarningFilled',
|
|
|
- alertCount: 3,
|
|
|
+ id: 'alarm-status',
|
|
|
+ label: '报警状态筛选',
|
|
|
+ icon: WarningFilled,
|
|
|
+ alertCount: 0,
|
|
|
children: [
|
|
|
- { id: 'critical-alert', label: '严重报警点', icon: 'WarningFilled', alertCount: 2 },
|
|
|
- { id: 'warning-alert', label: '一般报警点', icon: 'InfoFilled', alertCount: 1 }
|
|
|
+ { id: 'alarm-1', label: '报警设备', icon: WarningFilled, alertCount: 0 },
|
|
|
+ { id: 'alarm-0', label: '正常设备', icon: InfoFilled, alertCount: 0 }
|
|
|
]
|
|
|
},
|
|
|
{
|
|
|
- id: 'layer-device',
|
|
|
- label: '设备运维图层',
|
|
|
- icon: 'Setting',
|
|
|
- alertCount: 1,
|
|
|
+ id: 'online-status',
|
|
|
+ label: '在线状态筛选',
|
|
|
+ icon: Connection,
|
|
|
+ alertCount: 0,
|
|
|
children: [
|
|
|
- { id: 'battery-low', label: '低电量设备', icon: 'Battery', alertCount: 1 },
|
|
|
- { id: 'offline', label: '离线设备', icon: 'Connection', alertCount: 1 }
|
|
|
+ { id: 'online-1', label: '在线设备', icon: Connection, alertCount: 0 },
|
|
|
+ { id: 'online-0', label: '离线设备', icon: Connection, alertCount: 0 }
|
|
|
]
|
|
|
}
|
|
|
])
|
|
|
-const defaultCheckedLayers = ['layer-status', 'layer-alert', 'tilt', 'open', 'critical-alert', 'warning-alert']
|
|
|
+const defaultCheckedLayers = ['current-1', 'current-3', 'alarm-1', 'alarm-0', 'online-1', 'online-0']
|
|
|
const visibleLayers = ref(new Set(defaultCheckedLayers))
|
|
|
+const layerTreeRef = ref(null)
|
|
|
+const queryParams = ref({
|
|
|
+ keyword: ''
|
|
|
+})
|
|
|
+const mockManholes = ref([])
|
|
|
+const listLoading = ref(false)
|
|
|
+const detailLoading = ref(false)
|
|
|
+const activeQuickFilter = ref('')
|
|
|
+const baiduMapRef = ref(null)
|
|
|
+const mapError = ref('')
|
|
|
|
|
|
-// 模拟窨井盖设备数据 (含报警态势)
|
|
|
-const mockManholes = ref([
|
|
|
- { id: 'MN-001', name: '人民路1号', lng: 121.487, lat: 31.249, address: '人民路与解放路口', status: 'critical', statusText: '严重报警', hasAlert: true, level: 'critical', alertType: '倾斜超标', realTimeValue: '14.2°', threshold: '8°', lastComm: '2025-04-02 14:20', locationShort: '人民路口', battery: 67,
|
|
|
- alertLogs: [{ time: '2025-04-02 14:18', content: '倾斜角14.2°超过阈值8°' }] },
|
|
|
- { id: 'MN-002', name: '滨江路防爆井', lng: 121.502, lat: 31.235, address: '滨江路化工园区', status: 'critical', statusText: '严重报警', hasAlert: true, level: 'critical', alertType: '水位超标', realTimeValue: '62cm', threshold: '40cm', lastComm: '2025-04-02 14:05', locationShort: '滨江园区', battery: 34,
|
|
|
- alertLogs: [{ time: '2025-04-02 13:58', content: '水位62cm超阈值' }] },
|
|
|
- { id: 'MN-003', name: '古城街井盖', lng: 121.478, lat: 31.258, address: '古城街北段', status: 'warning', statusText: '一般报警', hasAlert: true, level: 'warning', alertType: '异常震动', realTimeValue: '112mg', threshold: '80mg', lastComm: '2025-04-02 13:30', locationShort: '古城街', battery: 55,
|
|
|
- alertLogs: [{ time: '2025-04-02 13:25', content: '震动112mg异常' }] },
|
|
|
- { id: 'MN-004', name: '高新区智能井', lng: 121.512, lat: 31.242, address: '高新大道云计算中心', status: 'normal', statusText: '正常', hasAlert: false, lastComm: '2025-04-02 14:15', locationShort: '高新区', battery: 92 },
|
|
|
- { id: 'MN-005', name: '南港路井盖', lng: 121.495, lat: 31.228, address: '南港路旧车站', status: 'warning', statusText: '一般报警', hasAlert: true, level: 'warning', alertType: '井盖开启异常', realTimeValue: '开启', threshold: '关闭', lastComm: '2025-04-02 12:50', locationShort: '南港路', battery: 41,
|
|
|
- alertLogs: [{ time: '2025-04-02 12:48', content: '井盖非正常开启' }] },
|
|
|
- { id: 'MN-006', name: '石化区防爆井', lng: 121.524, lat: 31.239, address: '石化大道', status: 'offline', statusText: '离线', hasAlert: false, lastComm: '2025-04-01 20:00', locationShort: '石化区', battery: 12 }
|
|
|
-])
|
|
|
+const BAIDU_MAP_AK = import.meta.env.VITE_BAIDU_MAP_AK || ''
|
|
|
+const DEFAULT_MAP_CENTER = { lng: 110.393, lat: 28.452 }
|
|
|
+
|
|
|
+let mapInstance = null
|
|
|
+let mapOverlays = []
|
|
|
+let baiduMapScriptPromise = null
|
|
|
+
|
|
|
+const quickFilterLabelMap = {
|
|
|
+ critical: '严重报警',
|
|
|
+ warning: '一般报警',
|
|
|
+ normal: '正常井盖',
|
|
|
+ offline: '离线设备'
|
|
|
+}
|
|
|
+
|
|
|
+const quickFilterLabel = computed(() => quickFilterLabelMap[activeQuickFilter.value] || '')
|
|
|
+
|
|
|
+function isBatteryLow(device) {
|
|
|
+ const battery = toNumber(device.battery)
|
|
|
+ return battery !== null && battery <= 20
|
|
|
+}
|
|
|
+
|
|
|
+function matchesQuickFilter(device) {
|
|
|
+ if (!activeQuickFilter.value) return true
|
|
|
+ return device.status === activeQuickFilter.value
|
|
|
+}
|
|
|
+
|
|
|
+function getSingleCheckedValue(checkedKeys, prefix) {
|
|
|
+ const matched = [...checkedKeys].filter(key => key.startsWith(prefix))
|
|
|
+ if (matched.length !== 1) return undefined
|
|
|
+ const value = Number(matched[0].slice(prefix.length))
|
|
|
+ return Number.isFinite(value) ? value : undefined
|
|
|
+}
|
|
|
+
|
|
|
+const displayDevices = computed(() => {
|
|
|
+ return mockManholes.value.filter(matchesQuickFilter)
|
|
|
+})
|
|
|
|
|
|
-// 统计
|
|
|
const criticalCount = computed(() => mockManholes.value.filter(m => m.status === 'critical').length)
|
|
|
const warningCount = computed(() => mockManholes.value.filter(m => m.status === 'warning').length)
|
|
|
const normalCount = computed(() => mockManholes.value.filter(m => m.status === 'normal').length)
|
|
|
@@ -181,143 +244,437 @@ const offlineCount = computed(() => mockManholes.value.filter(m => m.status ===
|
|
|
const totalDevices = computed(() => mockManholes.value.length)
|
|
|
const totalActiveAlerts = computed(() => criticalCount.value + warningCount.value)
|
|
|
|
|
|
-// GIS Canvas 绘图部分
|
|
|
-const canvasRef = ref(null)
|
|
|
const mapContainer = ref(null)
|
|
|
-let ctx = null
|
|
|
-let mapWidth = 0, mapHeight = 0
|
|
|
-let mapZoom = 1
|
|
|
-let offsetX = 0, offsetY = 0
|
|
|
-const bounds = { minLng: 121.45, maxLng: 121.55, minLat: 31.22, maxLat: 31.28 }
|
|
|
-
|
|
|
-function worldToCanvas(lng, lat) {
|
|
|
- const px = (lng - bounds.minLng) / (bounds.maxLng - bounds.minLng) * mapWidth
|
|
|
- const py = (1 - (lat - bounds.minLat) / (bounds.maxLat - bounds.minLat)) * mapHeight
|
|
|
- const cx = mapWidth / 2, cy = mapHeight / 2
|
|
|
- return { x: (px - cx) * mapZoom + cx + offsetX, y: (py - cy) * mapZoom + cy + offsetY }
|
|
|
-}
|
|
|
-
|
|
|
-function drawBaseMap() {
|
|
|
- if (!ctx) return
|
|
|
- ctx.fillStyle = '#eaf2e6'
|
|
|
- ctx.fillRect(0, 0, mapWidth, mapHeight)
|
|
|
- ctx.strokeStyle = '#c8ddc4'
|
|
|
- ctx.lineWidth = 1
|
|
|
- for (let i = 0; i < 12; i++) {
|
|
|
- ctx.beginPath()
|
|
|
- ctx.moveTo((i / 12) * mapWidth, 0)
|
|
|
- ctx.lineTo((i / 12) * mapWidth, mapHeight)
|
|
|
- ctx.stroke()
|
|
|
- ctx.beginPath()
|
|
|
- ctx.moveTo(0, (i / 12) * mapHeight)
|
|
|
- ctx.lineTo(mapWidth, (i / 12) * mapHeight)
|
|
|
- ctx.stroke()
|
|
|
+const bounds = ref({ minLng: 121.45, maxLng: 121.55, minLat: 31.22, maxLat: 31.28 })
|
|
|
+
|
|
|
+function toNumber(value) {
|
|
|
+ const num = Number(value)
|
|
|
+ return Number.isFinite(num) ? num : null
|
|
|
+}
|
|
|
+
|
|
|
+function formatDateTime(value) {
|
|
|
+ return value || '—'
|
|
|
+}
|
|
|
+
|
|
|
+function isValidCoordinate(lng, lat) {
|
|
|
+ return lng !== null && lat !== null && lng >= 70 && lng <= 140 && lat >= 3 && lat <= 55
|
|
|
+}
|
|
|
+
|
|
|
+function getThresholdTextByCode(raw, warningCode) {
|
|
|
+ const list = Array.isArray(raw?.warningThresholdList) ? raw.warningThresholdList : []
|
|
|
+ const target = list.find(item => item.warningCode === warningCode)
|
|
|
+ if (!target) return '—'
|
|
|
+ const { minValue, maxValue } = target
|
|
|
+ if (minValue !== null && minValue !== undefined && maxValue !== null && maxValue !== undefined) {
|
|
|
+ return `${minValue} ~ ${maxValue}`
|
|
|
+ }
|
|
|
+ if (minValue !== null && minValue !== undefined) return `<= ${minValue}`
|
|
|
+ if (maxValue !== null && maxValue !== undefined) return `>= ${maxValue}`
|
|
|
+ return '—'
|
|
|
+}
|
|
|
+
|
|
|
+function buildAlertInfo(raw) {
|
|
|
+ const status = raw?.equipmentStatus || {}
|
|
|
+ const manholeData = raw?.manholeData || {}
|
|
|
+ const battery = toNumber(manholeData.batteryLevel)
|
|
|
+ const signal = toNumber(manholeData.signalStrength)
|
|
|
+ const tiltAngle = manholeData?.tiltAngle
|
|
|
+
|
|
|
+ if (Number(status.onlineStatus) !== 1) {
|
|
|
+ return { status: 'offline', statusText: '离线', hasAlert: false, level: '', alertType: '', realTimeValue: '—', threshold: '—' }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (`${manholeData.waterLevelAlarmStatus}` === '1') {
|
|
|
+ return {
|
|
|
+ status: 'critical',
|
|
|
+ statusText: '严重报警',
|
|
|
+ hasAlert: true,
|
|
|
+ level: 'critical',
|
|
|
+ alertType: '水位报警',
|
|
|
+ realTimeValue: '报警',
|
|
|
+ threshold: getThresholdTextByCode(raw, 'WARN-WATER-LEVEL')
|
|
|
+ }
|
|
|
}
|
|
|
- ctx.fillStyle = '#a8d0a6'
|
|
|
- ctx.font = '12px "Microsoft YaHei"'
|
|
|
- ctx.fillText('人民路', 180, 110)
|
|
|
- ctx.fillText('滨江路', 380, 280)
|
|
|
- ctx.fillText('高新区', 580, 170)
|
|
|
-}
|
|
|
-
|
|
|
-function drawManholes() {
|
|
|
- if (!ctx) return
|
|
|
- mockManholes.value.forEach(device => {
|
|
|
- const { x, y } = worldToCanvas(device.lng, device.lat)
|
|
|
- if (x < -40 || x > mapWidth + 40 || y < -40 || y > mapHeight + 40) return
|
|
|
- // 根据状态确定图标样式
|
|
|
- let color = '#67C23A'
|
|
|
- let size = 14
|
|
|
- let isAlertBlink = false
|
|
|
- if (device.status === 'critical') { color = '#F56C6C'; size = 18; isAlertBlink = true }
|
|
|
- else if (device.status === 'warning') { color = '#E6A23C'; size = 16 }
|
|
|
- else if (device.status === 'offline') { color = '#909399'; size = 12 }
|
|
|
- // 闪烁效果: 简单模拟根据时间正弦变化,但在重绘中直接绘制不闪烁,但可增加外圈
|
|
|
- ctx.beginPath()
|
|
|
- ctx.arc(x, y, size, 0, 2 * Math.PI)
|
|
|
- ctx.fillStyle = color
|
|
|
- ctx.fill()
|
|
|
- ctx.strokeStyle = '#fff'
|
|
|
- ctx.lineWidth = 2
|
|
|
- ctx.stroke()
|
|
|
- if (isAlertBlink) {
|
|
|
- ctx.beginPath()
|
|
|
- ctx.arc(x, y, size + 4, 0, 2 * Math.PI)
|
|
|
- ctx.fillStyle = 'rgba(245,108,108,0.3)'
|
|
|
- ctx.fill()
|
|
|
+
|
|
|
+ if (`${manholeData.waterInfiltrationAlarmStatus}` === '1') {
|
|
|
+ return {
|
|
|
+ status: 'critical',
|
|
|
+ statusText: '严重报警',
|
|
|
+ hasAlert: true,
|
|
|
+ level: 'critical',
|
|
|
+ alertType: '水浸报警',
|
|
|
+ realTimeValue: '报警',
|
|
|
+ threshold: getThresholdTextByCode(raw, 'WARN-WATER-INFILTRATION')
|
|
|
}
|
|
|
- ctx.fillStyle = '#fff'
|
|
|
- ctx.font = 'bold 12px sans-serif'
|
|
|
- ctx.fillText(device.name.slice(0, 4), x-10, y-8)
|
|
|
- // 图层过滤额外绘制报警标识
|
|
|
- if (visibleLayers.value.has('critical-alert') && device.status === 'critical') {
|
|
|
- ctx.fillStyle = '#ff4d4f'
|
|
|
- ctx.beginPath()
|
|
|
- ctx.moveTo(x+12, y-12)
|
|
|
- ctx.lineTo(x+18, y-5)
|
|
|
- ctx.fill()
|
|
|
+ }
|
|
|
+
|
|
|
+ if (Number(status.alarmStatus) === 1) {
|
|
|
+ return {
|
|
|
+ status: 'critical',
|
|
|
+ statusText: '严重报警',
|
|
|
+ hasAlert: true,
|
|
|
+ level: 'critical',
|
|
|
+ alertType: tiltAngle ? '倾斜报警' : '设备报警',
|
|
|
+ realTimeValue: tiltAngle ? `${tiltAngle}°` : '报警',
|
|
|
+ threshold: tiltAngle ? getThresholdTextByCode(raw, 'WARN-TILT') : getThresholdTextByCode(raw, 'WARN-DEVICE-ALARM')
|
|
|
}
|
|
|
- if (visibleLayers.value.has('tilt') && device.alertType?.includes('倾斜')) {
|
|
|
- ctx.fillStyle = '#f56c6c'
|
|
|
- ctx.fillRect(x+8, y-14, 6, 6)
|
|
|
+ }
|
|
|
+
|
|
|
+ if ((battery !== null && battery <= 20) || (signal !== null && signal <= 20)) {
|
|
|
+ return {
|
|
|
+ status: 'warning',
|
|
|
+ statusText: '一般报警',
|
|
|
+ hasAlert: true,
|
|
|
+ level: 'warning',
|
|
|
+ alertType: battery !== null && battery <= 20 ? '低电量预警' : '弱信号预警',
|
|
|
+ realTimeValue: battery !== null && battery <= 20 ? `${battery}%` : `${signal}%`,
|
|
|
+ threshold: '20'
|
|
|
}
|
|
|
+ }
|
|
|
+
|
|
|
+ return { status: 'normal', statusText: '正常', hasAlert: false, level: '', alertType: '', realTimeValue: '—', threshold: '—' }
|
|
|
+}
|
|
|
+
|
|
|
+function buildAlertLogs(raw, alertInfo) {
|
|
|
+ if (!alertInfo?.hasAlert) return []
|
|
|
+ const timestamp = raw?.equipmentStatus?.statusUpdateTime || raw?.updateTime || raw?.installTime || ''
|
|
|
+ return [{
|
|
|
+ time: formatDateTime(timestamp),
|
|
|
+ content: `${alertInfo.alertType}${alertInfo.realTimeValue && alertInfo.realTimeValue !== '—' ? `:${alertInfo.realTimeValue}` : ''}${alertInfo.threshold && alertInfo.threshold !== '—' ? `,阈值 ${alertInfo.threshold}` : ''}`
|
|
|
+ }]
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeDevice(raw, _index = 0, baseDevice = {}) {
|
|
|
+ const rawLng = toNumber(raw?.longitude)
|
|
|
+ const rawLat = toNumber(raw?.latitude)
|
|
|
+ const baseLng = toNumber(baseDevice.lng)
|
|
|
+ const baseLat = toNumber(baseDevice.lat)
|
|
|
+ const useRawCoordinate = isValidCoordinate(rawLng, rawLat)
|
|
|
+ const useBaseCoordinate = isValidCoordinate(baseLng, baseLat)
|
|
|
+ const lng = useRawCoordinate ? rawLng : (useBaseCoordinate ? baseLng : null)
|
|
|
+ const lat = useRawCoordinate ? rawLat : (useBaseCoordinate ? baseLat : null)
|
|
|
+ const alertInfo = buildAlertInfo(raw)
|
|
|
+ const address = raw?.equipmentLocation || baseDevice.address || '暂无位置信息'
|
|
|
+ return {
|
|
|
+ equipmentId: raw?.equipmentId || baseDevice.equipmentId || '',
|
|
|
+ id: raw?.equipmentCode || baseDevice.id || raw?.equipmentId || '',
|
|
|
+ name: raw?.equipmentName || baseDevice.name || raw?.equipmentCode || '未命名设备',
|
|
|
+ lng,
|
|
|
+ lat,
|
|
|
+ address,
|
|
|
+ status: alertInfo.status,
|
|
|
+ statusText: alertInfo.statusText,
|
|
|
+ hasAlert: alertInfo.hasAlert,
|
|
|
+ level: alertInfo.level,
|
|
|
+ alertType: alertInfo.alertType,
|
|
|
+ realTimeValue: alertInfo.realTimeValue,
|
|
|
+ threshold: alertInfo.threshold,
|
|
|
+ lastComm: formatDateTime(raw?.equipmentStatus?.statusUpdateTime || raw?.updateTime || raw?.installTime),
|
|
|
+ locationShort: address.length > 8 ? `${address.slice(0, 8)}...` : address,
|
|
|
+ battery: raw?.manholeData?.batteryLevel || '—',
|
|
|
+ alertLogs: buildAlertLogs(raw, alertInfo),
|
|
|
+ equipmentTypeName: raw?.equipmentTypeName || '—',
|
|
|
+ equipmentModel: raw?.equipmentModel || '—',
|
|
|
+ manufacturer: raw?.manufacturer || '—',
|
|
|
+ installTime: formatDateTime(raw?.installTime),
|
|
|
+ maintainer: raw?.maintainer || '—',
|
|
|
+ raw
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function refreshLayerTree() {
|
|
|
+ const sourceDevices = mockManholes.value
|
|
|
+ const critical = criticalCount.value
|
|
|
+ const warning = warningCount.value
|
|
|
+ const normal = normalCount.value
|
|
|
+ const offline = offlineCount.value
|
|
|
+ const inUse = sourceDevices.filter(item => Number(item.raw?.equipmentStatus?.currentStatus) === 1).length
|
|
|
+ const maintenance = sourceDevices.filter(item => Number(item.raw?.equipmentStatus?.currentStatus) === 3).length
|
|
|
+
|
|
|
+ layerTree.value[0].alertCount = inUse + maintenance
|
|
|
+ layerTree.value[0].children[0].alertCount = inUse
|
|
|
+ layerTree.value[0].children[1].alertCount = maintenance
|
|
|
+ layerTree.value[1].alertCount = critical + warning + normal
|
|
|
+ layerTree.value[1].children[0].alertCount = critical + warning
|
|
|
+ layerTree.value[1].children[1].alertCount = normal
|
|
|
+ layerTree.value[2].alertCount = totalDevices.value
|
|
|
+ layerTree.value[2].children[0].alertCount = totalDevices.value - offline
|
|
|
+ layerTree.value[2].children[1].alertCount = offline
|
|
|
+}
|
|
|
+
|
|
|
+function refreshBounds() {
|
|
|
+ const validList = displayDevices.value.filter(item => Number.isFinite(item.lng) && Number.isFinite(item.lat))
|
|
|
+ if (!validList.length) {
|
|
|
+ bounds.value = { minLng: 110.35, maxLng: 110.43, minLat: 28.42, maxLat: 28.5 }
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const lngList = validList.map(item => item.lng)
|
|
|
+ const latList = validList.map(item => item.lat)
|
|
|
+ const minLng = Math.min(...lngList)
|
|
|
+ const maxLng = Math.max(...lngList)
|
|
|
+ const minLat = Math.min(...latList)
|
|
|
+ const maxLat = Math.max(...latList)
|
|
|
+ bounds.value = {
|
|
|
+ minLng: minLng - 0.01,
|
|
|
+ maxLng: maxLng + 0.01,
|
|
|
+ minLat: minLat - 0.01,
|
|
|
+ maxLat: maxLat + 0.01
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function getStatusColor(status) {
|
|
|
+ if (status === 'critical') return '#f56c6c'
|
|
|
+ if (status === 'warning') return '#e6a23c'
|
|
|
+ if (status === 'offline') return '#909399'
|
|
|
+ return '#67c23a'
|
|
|
+}
|
|
|
+
|
|
|
+function getMarkerHtml(device, isActive = false) {
|
|
|
+ const color = getStatusColor(device.status)
|
|
|
+ const pulse = device.status === 'critical'
|
|
|
+ ? 'box-shadow: 0 0 0 8px rgba(245,108,108,0.18);'
|
|
|
+ : ''
|
|
|
+ const activeRing = isActive
|
|
|
+ ? 'border: 3px solid #ffffff; transform: scale(1.08);'
|
|
|
+ : 'border: 2px solid #ffffff;'
|
|
|
+ return `
|
|
|
+ <div style="display:flex;flex-direction:column;align-items:center;transform:translateY(-6px);cursor:pointer;">
|
|
|
+ <div style="max-width:160px;padding:4px 8px;border-radius:999px;background:rgba(20,33,61,0.82);color:#fff;font-size:12px;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:6px;">
|
|
|
+ ${device.name}
|
|
|
+ </div>
|
|
|
+ <div style="width:18px;height:18px;border-radius:50%;background:${color};${activeRing}${pulse}"></div>
|
|
|
+ </div>
|
|
|
+ `
|
|
|
+}
|
|
|
+
|
|
|
+function clearMapOverlays() {
|
|
|
+ if (!mapInstance) return
|
|
|
+ mapOverlays.forEach((overlay) => {
|
|
|
+ try {
|
|
|
+ mapInstance.removeOverlay(overlay)
|
|
|
+ } catch (_) {}
|
|
|
})
|
|
|
+ mapOverlays = []
|
|
|
+}
|
|
|
+
|
|
|
+function fitMapViewport() {
|
|
|
+ if (!mapInstance || !window.BMapGL) return
|
|
|
+ if (!displayDevices.value.length) {
|
|
|
+ mapInstance.centerAndZoom(new window.BMapGL.Point(DEFAULT_MAP_CENTER.lng, DEFAULT_MAP_CENTER.lat), 14)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const points = displayDevices.value
|
|
|
+ .filter(device => isValidCoordinate(device.lng, device.lat))
|
|
|
+ .map(device => new window.BMapGL.Point(device.lng, device.lat))
|
|
|
+ if (!points.length) {
|
|
|
+ mapInstance.centerAndZoom(new window.BMapGL.Point(DEFAULT_MAP_CENTER.lng, DEFAULT_MAP_CENTER.lat), 14)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ mapInstance.setViewport(points)
|
|
|
+ if (mapInstance.getZoom() > 16) {
|
|
|
+ mapInstance.setZoom(16)
|
|
|
+ }
|
|
|
+ if (mapInstance.getZoom() < 12) {
|
|
|
+ mapInstance.setZoom(12)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function renderMapOverlays() {
|
|
|
+ if (!mapInstance || !window.BMapGL) return
|
|
|
+ clearMapOverlays()
|
|
|
+ displayDevices.value
|
|
|
+ .filter(device => isValidCoordinate(device.lng, device.lat))
|
|
|
+ .forEach((device) => {
|
|
|
+ const point = new window.BMapGL.Point(device.lng, device.lat)
|
|
|
+ const isActive = selectedDevice.value?.equipmentId === device.equipmentId
|
|
|
+ const label = new window.BMapGL.Label(getMarkerHtml(device, isActive), {
|
|
|
+ position: point,
|
|
|
+ offset: new window.BMapGL.Size(-30, -52)
|
|
|
+ })
|
|
|
+ label.setStyle({
|
|
|
+ border: 'none',
|
|
|
+ background: 'transparent',
|
|
|
+ padding: '0'
|
|
|
+ })
|
|
|
+ label.addEventListener('click', async (event) => {
|
|
|
+ event?.domEvent?.stopPropagation?.()
|
|
|
+ await openDeviceDetail(device)
|
|
|
+ })
|
|
|
+ mapInstance.addOverlay(label)
|
|
|
+ mapOverlays.push(label)
|
|
|
+ })
|
|
|
+ fitMapViewport()
|
|
|
}
|
|
|
|
|
|
function renderMap() {
|
|
|
- if (!canvasRef.value || !mapContainer.value) return
|
|
|
- const canvas = canvasRef.value
|
|
|
- mapWidth = mapContainer.value.clientWidth
|
|
|
- mapHeight = mapContainer.value.clientHeight
|
|
|
- canvas.width = mapWidth
|
|
|
- canvas.height = mapHeight
|
|
|
- ctx = canvas.getContext('2d')
|
|
|
- drawBaseMap()
|
|
|
- drawManholes()
|
|
|
+ if (!mapInstance || !window.BMapGL) return
|
|
|
+ renderMapOverlays()
|
|
|
}
|
|
|
|
|
|
-function zoomIn() { mapZoom = Math.min(mapZoom + 0.15, 2.5); renderMap() }
|
|
|
-function zoomOut() { mapZoom = Math.max(mapZoom - 0.15, 0.6); renderMap() }
|
|
|
-function resetView() { mapZoom = 1; offsetX = 0; offsetY = 0; renderMap() }
|
|
|
+function ensureBaiduMap() {
|
|
|
+ if (window.BMapGL) {
|
|
|
+ return Promise.resolve(window.BMapGL)
|
|
|
+ }
|
|
|
+ if (baiduMapScriptPromise) {
|
|
|
+ return baiduMapScriptPromise
|
|
|
+ }
|
|
|
+ if (!BAIDU_MAP_AK) {
|
|
|
+ return Promise.reject(new Error('未配置百度地图 AK,请在 .env.development 中配置 VITE_BAIDU_MAP_AK'))
|
|
|
+ }
|
|
|
+
|
|
|
+ baiduMapScriptPromise = new Promise((resolve, reject) => {
|
|
|
+ const existing = document.querySelector('script[data-baidu-map="webgl"]')
|
|
|
+ if (existing) {
|
|
|
+ let attempts = 0
|
|
|
+ const timer = window.setInterval(() => {
|
|
|
+ attempts++
|
|
|
+ if (window.BMapGL) {
|
|
|
+ window.clearInterval(timer)
|
|
|
+ resolve(window.BMapGL)
|
|
|
+ } else if (attempts >= 50) {
|
|
|
+ window.clearInterval(timer)
|
|
|
+ reject(new Error('百度地图脚本加载超时'))
|
|
|
+ }
|
|
|
+ }, 200)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const script = document.createElement('script')
|
|
|
+ script.src = `https://api.map.baidu.com/api?type=webgl&v=1.0&ak=${BAIDU_MAP_AK}`
|
|
|
+ script.async = true
|
|
|
+ script.defer = true
|
|
|
+ script.setAttribute('data-baidu-map', 'webgl')
|
|
|
+ script.onload = () => {
|
|
|
+ let attempts = 0
|
|
|
+ const timer = window.setInterval(() => {
|
|
|
+ attempts++
|
|
|
+ if (window.BMapGL) {
|
|
|
+ window.clearInterval(timer)
|
|
|
+ resolve(window.BMapGL)
|
|
|
+ } else if (attempts >= 50) {
|
|
|
+ window.clearInterval(timer)
|
|
|
+ reject(new Error('百度地图脚本初始化超时'))
|
|
|
+ }
|
|
|
+ }, 200)
|
|
|
+ }
|
|
|
+ script.onerror = () => reject(new Error('百度地图脚本加载失败'))
|
|
|
+ document.head.appendChild(script)
|
|
|
+ })
|
|
|
+
|
|
|
+ return baiduMapScriptPromise
|
|
|
+}
|
|
|
|
|
|
-const onLayerChange = () => { renderMap() }
|
|
|
-const focusLayer = (type) => { /* 可缩放至对应类型设备,简化处理 */ ElMessage.info(`聚焦${type}类设备`) }
|
|
|
+async function initMap() {
|
|
|
+ if (!baiduMapRef.value) return
|
|
|
+ try {
|
|
|
+ await ensureBaiduMap()
|
|
|
+ if (!mapInstance) {
|
|
|
+ mapInstance = new window.BMapGL.Map(baiduMapRef.value, { enableMapClick: true })
|
|
|
+ mapInstance.enableScrollWheelZoom(true)
|
|
|
+ mapInstance.setMinZoom(11)
|
|
|
+ mapInstance.setMaxZoom(18)
|
|
|
+ mapInstance.centerAndZoom(new window.BMapGL.Point(DEFAULT_MAP_CENTER.lng, DEFAULT_MAP_CENTER.lat), 14)
|
|
|
+ mapInstance.addEventListener('click', () => {
|
|
|
+ drawerVisible.value = false
|
|
|
+ })
|
|
|
+ }
|
|
|
+ mapError.value = ''
|
|
|
+ renderMap()
|
|
|
+ } catch (error) {
|
|
|
+ mapError.value = error?.message || '百度地图加载失败'
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function loadDeviceList() {
|
|
|
+ listLoading.value = true
|
|
|
+ try {
|
|
|
+ const keyword = queryParams.value.keyword?.trim()
|
|
|
+ const checkedKeys = visibleLayers.value
|
|
|
+ const currentStatus = getSingleCheckedValue(checkedKeys, 'current-')
|
|
|
+ const alarmStatus = getSingleCheckedValue(checkedKeys, 'alarm-')
|
|
|
+ const onlineStatus = getSingleCheckedValue(checkedKeys, 'online-')
|
|
|
+ const res = await getManholeAlarmDeviceList({
|
|
|
+ keyword: keyword || undefined,
|
|
|
+ currentStatus,
|
|
|
+ alarmStatus,
|
|
|
+ onlineStatus,
|
|
|
+ isQueryManholeData: true
|
|
|
+ })
|
|
|
+ const list = Array.isArray(res?.data) ? res.data : []
|
|
|
+ mockManholes.value = list.map((item, index) => normalizeDevice(item, index))
|
|
|
+ refreshLayerTree()
|
|
|
+ refreshBounds()
|
|
|
+ await nextTick()
|
|
|
+ renderMap()
|
|
|
+ } catch (error) {
|
|
|
+ mockManholes.value = []
|
|
|
+ ElMessage.error('井盖设备数据加载失败')
|
|
|
+ } finally {
|
|
|
+ listLoading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function openDeviceDetail(device) {
|
|
|
+ if (!device?.equipmentId) return
|
|
|
+ selectedDevice.value = device
|
|
|
+ drawerVisible.value = true
|
|
|
+ detailLoading.value = true
|
|
|
+ try {
|
|
|
+ const res = await getManholeAlarmDeviceDetail(device.equipmentId)
|
|
|
+ if (res?.data) {
|
|
|
+ const index = mockManholes.value.findIndex(item => item.equipmentId === device.equipmentId)
|
|
|
+ selectedDevice.value = normalizeDevice(res.data, index, device)
|
|
|
+ if (mapInstance && window.BMapGL && isValidCoordinate(selectedDevice.value.lng, selectedDevice.value.lat)) {
|
|
|
+ mapInstance.panTo(new window.BMapGL.Point(selectedDevice.value.lng, selectedDevice.value.lat))
|
|
|
+ }
|
|
|
+ renderMap()
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ ElMessage.error('设备详情加载失败')
|
|
|
+ } finally {
|
|
|
+ detailLoading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function zoomIn() {
|
|
|
+ if (!mapInstance) return
|
|
|
+ mapInstance.setZoom(Math.min(mapInstance.getZoom() + 1, 18))
|
|
|
+}
|
|
|
+function zoomOut() {
|
|
|
+ if (!mapInstance) return
|
|
|
+ mapInstance.setZoom(Math.max(mapInstance.getZoom() - 1, 11))
|
|
|
+}
|
|
|
+function resetView() {
|
|
|
+ if (!mapInstance || !window.BMapGL) return
|
|
|
+ fitMapViewport()
|
|
|
+}
|
|
|
+
|
|
|
+const onLayerChange = (_data, checkedInfo) => {
|
|
|
+ visibleLayers.value = new Set(checkedInfo.checkedKeys)
|
|
|
+ loadDeviceList()
|
|
|
+}
|
|
|
+const focusLayer = async (type) => {
|
|
|
+ activeQuickFilter.value = activeQuickFilter.value === type ? '' : type
|
|
|
+ const target = displayDevices.value.find(item => item.status === type)
|
|
|
+ if (!target) {
|
|
|
+ if (activeQuickFilter.value) {
|
|
|
+ ElMessage.info('当前筛选下无对应设备')
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!activeQuickFilter.value) {
|
|
|
+ ElMessage.info('当前无对应设备')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ await nextTick()
|
|
|
+ renderMap()
|
|
|
+}
|
|
|
|
|
|
-// 交互变量
|
|
|
const selectedDevice = ref(null)
|
|
|
const drawerVisible = ref(false)
|
|
|
-const hoverDevice = ref(null)
|
|
|
-const hoverX = ref(0), hoverY = ref(0)
|
|
|
const timeRange = ref([])
|
|
|
const shortcuts = [{ text: '最近24小时', value: () => [new Date(Date.now() - 86400000), new Date()] }]
|
|
|
|
|
|
-function onMapClick(e) {
|
|
|
- const rect = canvasRef.value.getBoundingClientRect()
|
|
|
- const mouseX = (e.clientX - rect.left) * (mapWidth / rect.width)
|
|
|
- const mouseY = (e.clientY - rect.top) * (mapHeight / rect.height)
|
|
|
- let minDist = 20, hit = null
|
|
|
- mockManholes.value.forEach(device => {
|
|
|
- const { x, y } = worldToCanvas(device.lng, device.lat)
|
|
|
- const dist = Math.hypot(mouseX - x, mouseY - y)
|
|
|
- if (dist < minDist) { minDist = dist; hit = device }
|
|
|
- })
|
|
|
- if (hit) { selectedDevice.value = hit; drawerVisible.value = true }
|
|
|
-}
|
|
|
-
|
|
|
-function onCanvasMouseMove(e) {
|
|
|
- const rect = canvasRef.value.getBoundingClientRect()
|
|
|
- const mouseX = (e.clientX - rect.left) * (mapWidth / rect.width)
|
|
|
- const mouseY = (e.clientY - rect.top) * (mapHeight / rect.height)
|
|
|
- let minDist = 18, hit = null
|
|
|
- mockManholes.value.forEach(device => {
|
|
|
- const { x, y } = worldToCanvas(device.lng, device.lat)
|
|
|
- const dist = Math.hypot(mouseX - x, mouseY - y)
|
|
|
- if (dist < minDist) { minDist = dist; hit = device }
|
|
|
- })
|
|
|
- if (hit) { hoverDevice.value = hit; hoverX.value = e.clientX + 12; hoverY.value = e.clientY - 40 }
|
|
|
- else hoverDevice.value = null
|
|
|
-}
|
|
|
-
|
|
|
const getStatusTag = (status) => {
|
|
|
if (status === 'critical') return 'danger'
|
|
|
if (status === 'warning') return 'warning'
|
|
|
@@ -327,18 +684,46 @@ const getStatusTag = (status) => {
|
|
|
const getAlertAdvice = (device) => {
|
|
|
if (device.alertType?.includes('倾斜')) return '建议立即安排检修人员现场确认井盖固定情况,如损坏需更换。'
|
|
|
if (device.alertType?.includes('水位')) return '井内水位过高,需紧急排水,检查管道是否堵塞。'
|
|
|
- if (device.alertType?.includes('震动')) return '持续震动可能为外力破坏,建议巡检周边施工情况。'
|
|
|
+ if (device.alertType?.includes('水浸')) return '建议核查井内积水和周边排水情况,必要时安排现场排查。'
|
|
|
+ if (device.alertType?.includes('低电量')) return '建议尽快安排电池更换,避免设备掉线。'
|
|
|
+ if (device.alertType?.includes('弱信号')) return '建议核查设备天线、网络覆盖和安装环境。'
|
|
|
return '请根据现场情况迅速核实并处置。'
|
|
|
}
|
|
|
const dispatchWorkOrder = () => { ElMessage.success('已派发工单至维护班组') }
|
|
|
|
|
|
-onMounted(() => {
|
|
|
+function handleSearch() {
|
|
|
+ activeQuickFilter.value = ''
|
|
|
+ loadDeviceList()
|
|
|
+}
|
|
|
+
|
|
|
+function handleReset() {
|
|
|
+ queryParams.value.keyword = ''
|
|
|
+ activeQuickFilter.value = ''
|
|
|
+ visibleLayers.value = new Set(defaultCheckedLayers)
|
|
|
+ layerTreeRef.value?.setCheckedKeys(defaultCheckedLayers)
|
|
|
+ loadDeviceList()
|
|
|
+}
|
|
|
+
|
|
|
+watch(displayDevices, async () => {
|
|
|
+ refreshBounds()
|
|
|
+ await nextTick()
|
|
|
+ renderMap()
|
|
|
+}, { deep: true })
|
|
|
+
|
|
|
+onMounted(async () => {
|
|
|
+ await nextTick()
|
|
|
+ await initMap()
|
|
|
+ await loadDeviceList()
|
|
|
nextTick(() => {
|
|
|
renderMap()
|
|
|
window.addEventListener('resize', renderMap)
|
|
|
})
|
|
|
})
|
|
|
-onUnmounted(() => window.removeEventListener('resize', renderMap))
|
|
|
+onUnmounted(() => {
|
|
|
+ window.removeEventListener('resize', renderMap)
|
|
|
+ clearMapOverlays()
|
|
|
+ mapInstance = null
|
|
|
+})
|
|
|
</script>
|
|
|
|
|
|
<style scoped>
|
|
|
@@ -379,6 +764,20 @@ onUnmounted(() => window.removeEventListener('resize', renderMap))
|
|
|
overflow-y: auto;
|
|
|
}
|
|
|
.layer-header { font-weight: bold; display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
|
|
+.layer-filter-panel {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+.filter-actions {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+.filter-summary {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 6px;
|
|
|
+}
|
|
|
.layer-tree { flex: 1; }
|
|
|
.layer-badge { margin-left: auto; }
|
|
|
.legend-panel { border-top: 1px solid #e9ecef; padding-top: 12px; margin-top: 8px; }
|
|
|
@@ -408,19 +807,43 @@ onUnmounted(() => window.removeEventListener('resize', renderMap))
|
|
|
.map-container {
|
|
|
flex: 1;
|
|
|
position: relative;
|
|
|
- cursor: crosshair;
|
|
|
-}
|
|
|
-canvas { width: 100%; height: 100%; display: block; }
|
|
|
-.hover-card {
|
|
|
- position: fixed;
|
|
|
- background: rgba(0,0,0,0.8);
|
|
|
- color: white;
|
|
|
- padding: 8px 12px;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.baidu-map {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+}
|
|
|
+.empty-state {
|
|
|
+ position: absolute;
|
|
|
+ top: 50%;
|
|
|
+ left: 50%;
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
+ color: #909399;
|
|
|
+ font-size: 14px;
|
|
|
+ background: rgba(255, 255, 255, 0.92);
|
|
|
+ padding: 10px 18px;
|
|
|
border-radius: 12px;
|
|
|
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
|
|
+ z-index: 2;
|
|
|
+}
|
|
|
+.map-error {
|
|
|
+ position: absolute;
|
|
|
+ top: 16px;
|
|
|
+ right: 16px;
|
|
|
+ max-width: 320px;
|
|
|
+ color: #f56c6c;
|
|
|
+ background: rgba(255, 255, 255, 0.95);
|
|
|
+ border: 1px solid rgba(245, 108, 108, 0.25);
|
|
|
+ border-radius: 12px;
|
|
|
+ padding: 10px 12px;
|
|
|
font-size: 12px;
|
|
|
- pointer-events: none;
|
|
|
- z-index: 200;
|
|
|
- backdrop-filter: blur(6px);
|
|
|
+ line-height: 1.6;
|
|
|
+ z-index: 3;
|
|
|
}
|
|
|
.advice-box { background: #f8f9fa; padding: 12px; border-radius: 12px; }
|
|
|
-</style>
|
|
|
+.panel-tip {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+ line-height: 1.5;
|
|
|
+}
|
|
|
+</style>
|