alarmSanalysis.vue 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849
  1. <!--监测报警一张图-->
  2. <template>
  3. <div class="gis-alert-dashboard">
  4. <!-- 顶部状态栏 -->
  5. <div class="top-bar">
  6. <div class="alert-stats">
  7. <div class="stat" @click="focusLayer('critical')">
  8. <span class="stat-value critical">{{ criticalCount }}</span>
  9. <span class="stat-label">严重报警</span>
  10. </div>
  11. <div class="stat" @click="focusLayer('warning')">
  12. <span class="stat-value warning">{{ warningCount }}</span>
  13. <span class="stat-label">一般报警</span>
  14. </div>
  15. <div class="stat" @click="focusLayer('normal')">
  16. <span class="stat-value">{{ normalCount }}</span>
  17. <span class="stat-label">正常井盖</span>
  18. </div>
  19. <div class="stat" @click="focusLayer('offline')">
  20. <span class="stat-value offline">{{ offlineCount }}</span>
  21. <span class="stat-label">离线设备</span>
  22. </div>
  23. </div>
  24. </div>
  25. <div class="main-layout">
  26. <!-- 左侧图层控制面板 -->
  27. <div class="layer-control">
  28. <div class="layer-header">
  29. <el-icon><Monitor /></el-icon> 态势图层
  30. </div>
  31. <div class="layer-filter-panel">
  32. <el-input
  33. v-model="queryParams.keyword"
  34. clearable
  35. :prefix-icon="Search"
  36. placeholder="设备编号/名称/位置模糊查询"
  37. @keyup.enter="handleSearch"
  38. />
  39. <div class="filter-actions">
  40. <el-button type="primary" size="small" @click="handleSearch">查询</el-button>
  41. <el-button size="small" :icon="RefreshRight" @click="handleReset">重置</el-button>
  42. </div>
  43. <div class="filter-summary">
  44. <el-tag size="small" effect="plain">搜索结果 {{ totalDevices }} 台</el-tag>
  45. <el-tag size="small" type="success" effect="plain">当前显示 {{ displayDevices.length }} 台</el-tag>
  46. <el-tag v-if="activeQuickFilter" size="small" type="warning" effect="plain">态势筛选: {{ quickFilterLabel }}</el-tag>
  47. </div>
  48. </div>
  49. <el-tree
  50. ref="layerTreeRef"
  51. :data="layerTree"
  52. show-checkbox
  53. node-key="id"
  54. :default-checked-keys="defaultCheckedLayers"
  55. @check="onLayerChange"
  56. class="layer-tree"
  57. >
  58. <template #default="{ node, data }">
  59. <span class="layer-node">
  60. <el-icon><component :is="data.icon" /></el-icon>
  61. <span>{{ node.label }}</span>
  62. <el-badge v-if="data.alertCount" :value="data.alertCount" type="danger" class="layer-badge" />
  63. </span>
  64. </template>
  65. </el-tree>
  66. <div class="legend-panel">
  67. <div class="legend-title">图例说明</div>
  68. <div><span class="legend-marker critical-dot"></span> 严重报警</div>
  69. <div><span class="legend-marker warning-dot"></span> 一般报警</div>
  70. <div><span class="legend-marker normal-dot"></span> 正常在线</div>
  71. <div><span class="legend-marker offline-dot"></span> 离线/停用</div>
  72. <div><span class="legend-marker alarm-pulse"></span> 实时报警闪烁</div>
  73. </div>
  74. <div class="time-selector">
  75. <el-date-picker v-model="timeRange" type="datetimerange" range-separator="至" size="small" :shortcuts="shortcuts" start-placeholder="开始" end-placeholder="结束" />
  76. </div>
  77. <div class="panel-tip">已选图层会实时过滤地图点位,支持结合关键字一起查询。</div>
  78. </div>
  79. <!-- 右侧GIS地图核心区 -->
  80. <div class="gis-map-area">
  81. <div class="map-toolbar">
  82. <el-button-group>
  83. <el-button size="small" :icon="ZoomIn" @click="zoomIn">放大</el-button>
  84. <el-button size="small" :icon="ZoomOut" @click="zoomOut">缩小</el-button>
  85. <el-button size="small" :icon="FullScreen" @click="resetView">重置视图</el-button>
  86. </el-button-group>
  87. <div class="current-stat">
  88. <span><el-icon><Position /></el-icon> 设备总数: {{ totalDevices }}</span>
  89. <span><el-icon><Monitor /></el-icon> 当前显示: {{ displayDevices.length }}</span>
  90. <span class="alert-summary">报警中: {{ totalActiveAlerts }}</span>
  91. </div>
  92. </div>
  93. <div class="map-container" ref="mapContainer" v-loading="listLoading">
  94. <div ref="baiduMapRef" class="baidu-map"></div>
  95. <div v-if="!listLoading && !displayDevices.length" class="empty-state">
  96. 当前筛选条件下暂无设备
  97. </div>
  98. <div v-if="mapError" class="map-error">
  99. {{ mapError }}
  100. </div>
  101. </div>
  102. <!-- 右下角报警聚合弹窗/详情 -->
  103. <el-drawer v-model="drawerVisible" :title="selectedDevice?.name + ' - 监测报警详情'" direction="rtl" size="420px">
  104. <div v-if="selectedDevice" v-loading="detailLoading">
  105. <el-descriptions :column="1" border>
  106. <el-descriptions-item label="设备编号">{{ selectedDevice.id }}</el-descriptions-item>
  107. <el-descriptions-item label="地理位置">{{ selectedDevice.address }}</el-descriptions-item>
  108. <el-descriptions-item label="当前状态">
  109. <el-tag :type="getStatusTag(selectedDevice.status)">{{ selectedDevice.statusText }}</el-tag>
  110. </el-descriptions-item>
  111. <el-descriptions-item label="报警级别" v-if="selectedDevice.hasAlert">
  112. <el-tag :type="selectedDevice.level === 'critical' ? 'danger' : 'warning'">{{ selectedDevice.level === 'critical' ? '严重报警' : '一般报警' }}</el-tag>
  113. </el-descriptions-item>
  114. <el-descriptions-item label="报警类型">{{ selectedDevice.alertType || '无' }}</el-descriptions-item>
  115. <el-descriptions-item label="实时监测值">{{ selectedDevice.realTimeValue || '—' }}</el-descriptions-item>
  116. <el-descriptions-item label="报警阈值">{{ selectedDevice.threshold || '—' }}</el-descriptions-item>
  117. <el-descriptions-item label="最近通讯">{{ selectedDevice.lastComm }}</el-descriptions-item>
  118. </el-descriptions>
  119. <el-divider>处置建议</el-divider>
  120. <div class="advice-box">
  121. <p>{{ getAlertAdvice(selectedDevice) }}</p>
  122. <el-button type="primary" size="small" style="margin-top: 12px;" @click="dispatchWorkOrder">派发处置工单</el-button>
  123. </div>
  124. <el-divider v-if="selectedDevice.alertLogs?.length">报警记录</el-divider>
  125. <el-timeline>
  126. <el-timeline-item v-for="log in selectedDevice.alertLogs" :key="log.time" :timestamp="log.time" type="danger">
  127. {{ log.content }}
  128. </el-timeline-item>
  129. </el-timeline>
  130. </div>
  131. </el-drawer>
  132. </div>
  133. </div>
  134. </div>
  135. </template>
  136. <script setup>
  137. import {computed, nextTick, onMounted, onUnmounted, ref, watch} from 'vue'
  138. import {
  139. Connection,
  140. FullScreen,
  141. InfoFilled,
  142. Monitor,
  143. Position,
  144. RefreshRight,
  145. Search,
  146. Setting,
  147. WarningFilled,
  148. ZoomIn,
  149. ZoomOut
  150. } from '@element-plus/icons-vue'
  151. import {ElMessage} from 'element-plus'
  152. import {getManholeAlarmDeviceDetail, getManholeAlarmDeviceList} from '@/api/pipeNetwork/basic'
  153. const layerTree = ref([
  154. {
  155. id: 'current-status',
  156. label: '运维状态筛选',
  157. icon: Setting,
  158. alertCount: 0,
  159. children: [
  160. { id: 'current-1', label: '在用设备', icon: Monitor, alertCount: 0 },
  161. { id: 'current-3', label: '维修设备', icon: Setting, alertCount: 0 }
  162. ]
  163. },
  164. {
  165. id: 'alarm-status',
  166. label: '报警状态筛选',
  167. icon: WarningFilled,
  168. alertCount: 0,
  169. children: [
  170. { id: 'alarm-1', label: '报警设备', icon: WarningFilled, alertCount: 0 },
  171. { id: 'alarm-0', label: '正常设备', icon: InfoFilled, alertCount: 0 }
  172. ]
  173. },
  174. {
  175. id: 'online-status',
  176. label: '在线状态筛选',
  177. icon: Connection,
  178. alertCount: 0,
  179. children: [
  180. { id: 'online-1', label: '在线设备', icon: Connection, alertCount: 0 },
  181. { id: 'online-0', label: '离线设备', icon: Connection, alertCount: 0 }
  182. ]
  183. }
  184. ])
  185. const defaultCheckedLayers = ['current-1', 'current-3', 'alarm-1', 'alarm-0', 'online-1', 'online-0']
  186. const visibleLayers = ref(new Set(defaultCheckedLayers))
  187. const layerTreeRef = ref(null)
  188. const queryParams = ref({
  189. keyword: ''
  190. })
  191. const mockManholes = ref([])
  192. const listLoading = ref(false)
  193. const detailLoading = ref(false)
  194. const activeQuickFilter = ref('')
  195. const baiduMapRef = ref(null)
  196. const mapError = ref('')
  197. const BAIDU_MAP_AK = import.meta.env.VITE_BAIDU_MAP_AK || ''
  198. const DEFAULT_MAP_CENTER = { lng: 110.393, lat: 28.452 }
  199. let mapInstance = null
  200. let mapOverlays = []
  201. let baiduMapScriptPromise = null
  202. const quickFilterLabelMap = {
  203. critical: '严重报警',
  204. warning: '一般报警',
  205. normal: '正常井盖',
  206. offline: '离线设备'
  207. }
  208. const quickFilterLabel = computed(() => quickFilterLabelMap[activeQuickFilter.value] || '')
  209. function isBatteryLow(device) {
  210. const battery = toNumber(device.battery)
  211. return battery !== null && battery <= 20
  212. }
  213. function matchesQuickFilter(device) {
  214. if (!activeQuickFilter.value) return true
  215. return device.status === activeQuickFilter.value
  216. }
  217. function getSingleCheckedValue(checkedKeys, prefix) {
  218. const matched = [...checkedKeys].filter(key => key.startsWith(prefix))
  219. if (matched.length !== 1) return undefined
  220. const value = Number(matched[0].slice(prefix.length))
  221. return Number.isFinite(value) ? value : undefined
  222. }
  223. const displayDevices = computed(() => {
  224. return mockManholes.value.filter(matchesQuickFilter)
  225. })
  226. const criticalCount = computed(() => mockManholes.value.filter(m => m.status === 'critical').length)
  227. const warningCount = computed(() => mockManholes.value.filter(m => m.status === 'warning').length)
  228. const normalCount = computed(() => mockManholes.value.filter(m => m.status === 'normal').length)
  229. const offlineCount = computed(() => mockManholes.value.filter(m => m.status === 'offline').length)
  230. const totalDevices = computed(() => mockManholes.value.length)
  231. const totalActiveAlerts = computed(() => criticalCount.value + warningCount.value)
  232. const mapContainer = ref(null)
  233. const bounds = ref({ minLng: 121.45, maxLng: 121.55, minLat: 31.22, maxLat: 31.28 })
  234. function toNumber(value) {
  235. const num = Number(value)
  236. return Number.isFinite(num) ? num : null
  237. }
  238. function formatDateTime(value) {
  239. return value || '—'
  240. }
  241. function isValidCoordinate(lng, lat) {
  242. return lng !== null && lat !== null && lng >= 70 && lng <= 140 && lat >= 3 && lat <= 55
  243. }
  244. function getThresholdTextByCode(raw, warningCode) {
  245. const list = Array.isArray(raw?.warningThresholdList) ? raw.warningThresholdList : []
  246. const target = list.find(item => item.warningCode === warningCode)
  247. if (!target) return '—'
  248. const { minValue, maxValue } = target
  249. if (minValue !== null && minValue !== undefined && maxValue !== null && maxValue !== undefined) {
  250. return `${minValue} ~ ${maxValue}`
  251. }
  252. if (minValue !== null && minValue !== undefined) return `<= ${minValue}`
  253. if (maxValue !== null && maxValue !== undefined) return `>= ${maxValue}`
  254. return '—'
  255. }
  256. function buildAlertInfo(raw) {
  257. const status = raw?.equipmentStatus || {}
  258. const manholeData = raw?.manholeData || {}
  259. const battery = toNumber(manholeData.batteryLevel)
  260. const signal = toNumber(manholeData.signalStrength)
  261. const tiltAngle = manholeData?.tiltAngle
  262. if (Number(status.onlineStatus) !== 1) {
  263. return { status: 'offline', statusText: '离线', hasAlert: false, level: '', alertType: '', realTimeValue: '—', threshold: '—' }
  264. }
  265. if (`${manholeData.waterLevelAlarmStatus}` === '1') {
  266. return {
  267. status: 'critical',
  268. statusText: '严重报警',
  269. hasAlert: true,
  270. level: 'critical',
  271. alertType: '水位报警',
  272. realTimeValue: '报警',
  273. threshold: getThresholdTextByCode(raw, 'WARN-WATER-LEVEL')
  274. }
  275. }
  276. if (`${manholeData.waterInfiltrationAlarmStatus}` === '1') {
  277. return {
  278. status: 'critical',
  279. statusText: '严重报警',
  280. hasAlert: true,
  281. level: 'critical',
  282. alertType: '水浸报警',
  283. realTimeValue: '报警',
  284. threshold: getThresholdTextByCode(raw, 'WARN-WATER-INFILTRATION')
  285. }
  286. }
  287. if (Number(status.alarmStatus) === 1) {
  288. return {
  289. status: 'critical',
  290. statusText: '严重报警',
  291. hasAlert: true,
  292. level: 'critical',
  293. alertType: tiltAngle ? '倾斜报警' : '设备报警',
  294. realTimeValue: tiltAngle ? `${tiltAngle}°` : '报警',
  295. threshold: tiltAngle ? getThresholdTextByCode(raw, 'WARN-TILT') : getThresholdTextByCode(raw, 'WARN-DEVICE-ALARM')
  296. }
  297. }
  298. if ((battery !== null && battery <= 20) || (signal !== null && signal <= 20)) {
  299. return {
  300. status: 'warning',
  301. statusText: '一般报警',
  302. hasAlert: true,
  303. level: 'warning',
  304. alertType: battery !== null && battery <= 20 ? '低电量预警' : '弱信号预警',
  305. realTimeValue: battery !== null && battery <= 20 ? `${battery}%` : `${signal}%`,
  306. threshold: '20'
  307. }
  308. }
  309. return { status: 'normal', statusText: '正常', hasAlert: false, level: '', alertType: '', realTimeValue: '—', threshold: '—' }
  310. }
  311. function buildAlertLogs(raw, alertInfo) {
  312. if (!alertInfo?.hasAlert) return []
  313. const timestamp = raw?.equipmentStatus?.statusUpdateTime || raw?.updateTime || raw?.installTime || ''
  314. return [{
  315. time: formatDateTime(timestamp),
  316. content: `${alertInfo.alertType}${alertInfo.realTimeValue && alertInfo.realTimeValue !== '—' ? `:${alertInfo.realTimeValue}` : ''}${alertInfo.threshold && alertInfo.threshold !== '—' ? `,阈值 ${alertInfo.threshold}` : ''}`
  317. }]
  318. }
  319. function normalizeDevice(raw, _index = 0, baseDevice = {}) {
  320. const rawLng = toNumber(raw?.longitude)
  321. const rawLat = toNumber(raw?.latitude)
  322. const baseLng = toNumber(baseDevice.lng)
  323. const baseLat = toNumber(baseDevice.lat)
  324. const useRawCoordinate = isValidCoordinate(rawLng, rawLat)
  325. const useBaseCoordinate = isValidCoordinate(baseLng, baseLat)
  326. const lng = useRawCoordinate ? rawLng : (useBaseCoordinate ? baseLng : null)
  327. const lat = useRawCoordinate ? rawLat : (useBaseCoordinate ? baseLat : null)
  328. const alertInfo = buildAlertInfo(raw)
  329. const address = raw?.equipmentLocation || baseDevice.address || '暂无位置信息'
  330. return {
  331. equipmentId: raw?.equipmentId || baseDevice.equipmentId || '',
  332. id: raw?.equipmentCode || baseDevice.id || raw?.equipmentId || '',
  333. name: raw?.equipmentName || baseDevice.name || raw?.equipmentCode || '未命名设备',
  334. lng,
  335. lat,
  336. address,
  337. status: alertInfo.status,
  338. statusText: alertInfo.statusText,
  339. hasAlert: alertInfo.hasAlert,
  340. level: alertInfo.level,
  341. alertType: alertInfo.alertType,
  342. realTimeValue: alertInfo.realTimeValue,
  343. threshold: alertInfo.threshold,
  344. lastComm: formatDateTime(raw?.equipmentStatus?.statusUpdateTime || raw?.updateTime || raw?.installTime),
  345. locationShort: address.length > 8 ? `${address.slice(0, 8)}...` : address,
  346. battery: raw?.manholeData?.batteryLevel || '—',
  347. alertLogs: buildAlertLogs(raw, alertInfo),
  348. equipmentTypeName: raw?.equipmentTypeName || '—',
  349. equipmentModel: raw?.equipmentModel || '—',
  350. manufacturer: raw?.manufacturer || '—',
  351. installTime: formatDateTime(raw?.installTime),
  352. maintainer: raw?.maintainer || '—',
  353. raw
  354. }
  355. }
  356. function refreshLayerTree() {
  357. const sourceDevices = mockManholes.value
  358. const critical = criticalCount.value
  359. const warning = warningCount.value
  360. const normal = normalCount.value
  361. const offline = offlineCount.value
  362. const inUse = sourceDevices.filter(item => Number(item.raw?.equipmentStatus?.currentStatus) === 1).length
  363. const maintenance = sourceDevices.filter(item => Number(item.raw?.equipmentStatus?.currentStatus) === 3).length
  364. layerTree.value[0].alertCount = inUse + maintenance
  365. layerTree.value[0].children[0].alertCount = inUse
  366. layerTree.value[0].children[1].alertCount = maintenance
  367. layerTree.value[1].alertCount = critical + warning + normal
  368. layerTree.value[1].children[0].alertCount = critical + warning
  369. layerTree.value[1].children[1].alertCount = normal
  370. layerTree.value[2].alertCount = totalDevices.value
  371. layerTree.value[2].children[0].alertCount = totalDevices.value - offline
  372. layerTree.value[2].children[1].alertCount = offline
  373. }
  374. function refreshBounds() {
  375. const validList = displayDevices.value.filter(item => Number.isFinite(item.lng) && Number.isFinite(item.lat))
  376. if (!validList.length) {
  377. bounds.value = { minLng: 110.35, maxLng: 110.43, minLat: 28.42, maxLat: 28.5 }
  378. return
  379. }
  380. const lngList = validList.map(item => item.lng)
  381. const latList = validList.map(item => item.lat)
  382. const minLng = Math.min(...lngList)
  383. const maxLng = Math.max(...lngList)
  384. const minLat = Math.min(...latList)
  385. const maxLat = Math.max(...latList)
  386. bounds.value = {
  387. minLng: minLng - 0.01,
  388. maxLng: maxLng + 0.01,
  389. minLat: minLat - 0.01,
  390. maxLat: maxLat + 0.01
  391. }
  392. }
  393. function getStatusColor(status) {
  394. if (status === 'critical') return '#f56c6c'
  395. if (status === 'warning') return '#e6a23c'
  396. if (status === 'offline') return '#909399'
  397. return '#67c23a'
  398. }
  399. function getMarkerHtml(device, isActive = false) {
  400. const color = getStatusColor(device.status)
  401. const pulse = device.status === 'critical'
  402. ? 'box-shadow: 0 0 0 8px rgba(245,108,108,0.18);'
  403. : ''
  404. const activeRing = isActive
  405. ? 'border: 3px solid #ffffff; transform: scale(1.08);'
  406. : 'border: 2px solid #ffffff;'
  407. return `
  408. <div style="display:flex;flex-direction:column;align-items:center;transform:translateY(-6px);cursor:pointer;">
  409. <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;">
  410. ${device.name}
  411. </div>
  412. <div style="width:18px;height:18px;border-radius:50%;background:${color};${activeRing}${pulse}"></div>
  413. </div>
  414. `
  415. }
  416. function clearMapOverlays() {
  417. if (!mapInstance) return
  418. mapOverlays.forEach((overlay) => {
  419. try {
  420. mapInstance.removeOverlay(overlay)
  421. } catch (_) {}
  422. })
  423. mapOverlays = []
  424. }
  425. function fitMapViewport() {
  426. if (!mapInstance || !window.BMapGL) return
  427. if (!displayDevices.value.length) {
  428. mapInstance.centerAndZoom(new window.BMapGL.Point(DEFAULT_MAP_CENTER.lng, DEFAULT_MAP_CENTER.lat), 14)
  429. return
  430. }
  431. const points = displayDevices.value
  432. .filter(device => isValidCoordinate(device.lng, device.lat))
  433. .map(device => new window.BMapGL.Point(device.lng, device.lat))
  434. if (!points.length) {
  435. mapInstance.centerAndZoom(new window.BMapGL.Point(DEFAULT_MAP_CENTER.lng, DEFAULT_MAP_CENTER.lat), 14)
  436. return
  437. }
  438. mapInstance.setViewport(points)
  439. if (mapInstance.getZoom() > 16) {
  440. mapInstance.setZoom(16)
  441. }
  442. if (mapInstance.getZoom() < 12) {
  443. mapInstance.setZoom(12)
  444. }
  445. }
  446. function renderMapOverlays() {
  447. if (!mapInstance || !window.BMapGL) return
  448. clearMapOverlays()
  449. displayDevices.value
  450. .filter(device => isValidCoordinate(device.lng, device.lat))
  451. .forEach((device) => {
  452. const point = new window.BMapGL.Point(device.lng, device.lat)
  453. const isActive = selectedDevice.value?.equipmentId === device.equipmentId
  454. const label = new window.BMapGL.Label(getMarkerHtml(device, isActive), {
  455. position: point,
  456. offset: new window.BMapGL.Size(-30, -52)
  457. })
  458. label.setStyle({
  459. border: 'none',
  460. background: 'transparent',
  461. padding: '0'
  462. })
  463. label.addEventListener('click', async (event) => {
  464. event?.domEvent?.stopPropagation?.()
  465. await openDeviceDetail(device)
  466. })
  467. mapInstance.addOverlay(label)
  468. mapOverlays.push(label)
  469. })
  470. fitMapViewport()
  471. }
  472. function renderMap() {
  473. if (!mapInstance || !window.BMapGL) return
  474. renderMapOverlays()
  475. }
  476. function ensureBaiduMap() {
  477. if (window.BMapGL) {
  478. return Promise.resolve(window.BMapGL)
  479. }
  480. if (baiduMapScriptPromise) {
  481. return baiduMapScriptPromise
  482. }
  483. if (!BAIDU_MAP_AK) {
  484. return Promise.reject(new Error('未配置百度地图 AK,请在 .env.development 中配置 VITE_BAIDU_MAP_AK'))
  485. }
  486. baiduMapScriptPromise = new Promise((resolve, reject) => {
  487. const existing = document.querySelector('script[data-baidu-map="webgl"]')
  488. if (existing) {
  489. let attempts = 0
  490. const timer = window.setInterval(() => {
  491. attempts++
  492. if (window.BMapGL) {
  493. window.clearInterval(timer)
  494. resolve(window.BMapGL)
  495. } else if (attempts >= 50) {
  496. window.clearInterval(timer)
  497. reject(new Error('百度地图脚本加载超时'))
  498. }
  499. }, 200)
  500. return
  501. }
  502. const script = document.createElement('script')
  503. script.src = `https://api.map.baidu.com/api?type=webgl&v=1.0&ak=${BAIDU_MAP_AK}`
  504. script.async = true
  505. script.defer = true
  506. script.setAttribute('data-baidu-map', 'webgl')
  507. script.onload = () => {
  508. let attempts = 0
  509. const timer = window.setInterval(() => {
  510. attempts++
  511. if (window.BMapGL) {
  512. window.clearInterval(timer)
  513. resolve(window.BMapGL)
  514. } else if (attempts >= 50) {
  515. window.clearInterval(timer)
  516. reject(new Error('百度地图脚本初始化超时'))
  517. }
  518. }, 200)
  519. }
  520. script.onerror = () => reject(new Error('百度地图脚本加载失败'))
  521. document.head.appendChild(script)
  522. })
  523. return baiduMapScriptPromise
  524. }
  525. async function initMap() {
  526. if (!baiduMapRef.value) return
  527. try {
  528. await ensureBaiduMap()
  529. if (!mapInstance) {
  530. mapInstance = new window.BMapGL.Map(baiduMapRef.value, { enableMapClick: true })
  531. mapInstance.enableScrollWheelZoom(true)
  532. mapInstance.setMinZoom(11)
  533. mapInstance.setMaxZoom(18)
  534. mapInstance.centerAndZoom(new window.BMapGL.Point(DEFAULT_MAP_CENTER.lng, DEFAULT_MAP_CENTER.lat), 14)
  535. mapInstance.addEventListener('click', () => {
  536. drawerVisible.value = false
  537. })
  538. }
  539. mapError.value = ''
  540. renderMap()
  541. } catch (error) {
  542. mapError.value = error?.message || '百度地图加载失败'
  543. }
  544. }
  545. async function loadDeviceList() {
  546. listLoading.value = true
  547. try {
  548. const keyword = queryParams.value.keyword?.trim()
  549. const checkedKeys = visibleLayers.value
  550. const currentStatus = getSingleCheckedValue(checkedKeys, 'current-')
  551. const alarmStatus = getSingleCheckedValue(checkedKeys, 'alarm-')
  552. const onlineStatus = getSingleCheckedValue(checkedKeys, 'online-')
  553. const res = await getManholeAlarmDeviceList({
  554. keyword: keyword || undefined,
  555. currentStatus,
  556. alarmStatus,
  557. onlineStatus,
  558. isQueryManholeData: true
  559. })
  560. const list = Array.isArray(res?.data) ? res.data : []
  561. mockManholes.value = list.map((item, index) => normalizeDevice(item, index))
  562. refreshLayerTree()
  563. refreshBounds()
  564. await nextTick()
  565. renderMap()
  566. } catch (error) {
  567. mockManholes.value = []
  568. ElMessage.error('井盖设备数据加载失败')
  569. } finally {
  570. listLoading.value = false
  571. }
  572. }
  573. async function openDeviceDetail(device) {
  574. if (!device?.equipmentId) return
  575. selectedDevice.value = device
  576. drawerVisible.value = true
  577. detailLoading.value = true
  578. try {
  579. const res = await getManholeAlarmDeviceDetail(device.equipmentId)
  580. if (res?.data) {
  581. const index = mockManholes.value.findIndex(item => item.equipmentId === device.equipmentId)
  582. selectedDevice.value = normalizeDevice(res.data, index, device)
  583. if (mapInstance && window.BMapGL && isValidCoordinate(selectedDevice.value.lng, selectedDevice.value.lat)) {
  584. mapInstance.panTo(new window.BMapGL.Point(selectedDevice.value.lng, selectedDevice.value.lat))
  585. }
  586. renderMap()
  587. }
  588. } catch (error) {
  589. ElMessage.error('设备详情加载失败')
  590. } finally {
  591. detailLoading.value = false
  592. }
  593. }
  594. function zoomIn() {
  595. if (!mapInstance) return
  596. mapInstance.setZoom(Math.min(mapInstance.getZoom() + 1, 18))
  597. }
  598. function zoomOut() {
  599. if (!mapInstance) return
  600. mapInstance.setZoom(Math.max(mapInstance.getZoom() - 1, 11))
  601. }
  602. function resetView() {
  603. if (!mapInstance || !window.BMapGL) return
  604. fitMapViewport()
  605. }
  606. const onLayerChange = (_data, checkedInfo) => {
  607. visibleLayers.value = new Set(checkedInfo.checkedKeys)
  608. loadDeviceList()
  609. }
  610. const focusLayer = async (type) => {
  611. activeQuickFilter.value = activeQuickFilter.value === type ? '' : type
  612. const target = displayDevices.value.find(item => item.status === type)
  613. if (!target) {
  614. if (activeQuickFilter.value) {
  615. ElMessage.info('当前筛选下无对应设备')
  616. }
  617. return
  618. }
  619. if (!activeQuickFilter.value) {
  620. ElMessage.info('当前无对应设备')
  621. return
  622. }
  623. await nextTick()
  624. renderMap()
  625. }
  626. const selectedDevice = ref(null)
  627. const drawerVisible = ref(false)
  628. const timeRange = ref([])
  629. const shortcuts = [{ text: '最近24小时', value: () => [new Date(Date.now() - 86400000), new Date()] }]
  630. const getStatusTag = (status) => {
  631. if (status === 'critical') return 'danger'
  632. if (status === 'warning') return 'warning'
  633. if (status === 'offline') return 'info'
  634. return 'success'
  635. }
  636. const getAlertAdvice = (device) => {
  637. if (device.alertType?.includes('倾斜')) return '建议立即安排检修人员现场确认井盖固定情况,如损坏需更换。'
  638. if (device.alertType?.includes('水位')) return '井内水位过高,需紧急排水,检查管道是否堵塞。'
  639. if (device.alertType?.includes('水浸')) return '建议核查井内积水和周边排水情况,必要时安排现场排查。'
  640. if (device.alertType?.includes('低电量')) return '建议尽快安排电池更换,避免设备掉线。'
  641. if (device.alertType?.includes('弱信号')) return '建议核查设备天线、网络覆盖和安装环境。'
  642. return '请根据现场情况迅速核实并处置。'
  643. }
  644. const dispatchWorkOrder = () => { ElMessage.success('已派发工单至维护班组') }
  645. function handleSearch() {
  646. activeQuickFilter.value = ''
  647. loadDeviceList()
  648. }
  649. function handleReset() {
  650. queryParams.value.keyword = ''
  651. activeQuickFilter.value = ''
  652. visibleLayers.value = new Set(defaultCheckedLayers)
  653. layerTreeRef.value?.setCheckedKeys(defaultCheckedLayers)
  654. loadDeviceList()
  655. }
  656. watch(displayDevices, async () => {
  657. refreshBounds()
  658. await nextTick()
  659. renderMap()
  660. }, { deep: true })
  661. onMounted(async () => {
  662. await nextTick()
  663. await initMap()
  664. await loadDeviceList()
  665. nextTick(() => {
  666. renderMap()
  667. window.addEventListener('resize', renderMap)
  668. })
  669. })
  670. onUnmounted(() => {
  671. window.removeEventListener('resize', renderMap)
  672. clearMapOverlays()
  673. mapInstance = null
  674. })
  675. </script>
  676. <style scoped>
  677. .gis-alert-dashboard {
  678. background: #eef2f0;
  679. height: 100vh;
  680. display: flex;
  681. flex-direction: column;
  682. overflow: hidden;
  683. }
  684. .top-bar {
  685. background: white;
  686. padding: 10px 24px;
  687. display: flex;
  688. justify-content: space-between;
  689. align-items: center;
  690. box-shadow: 0 2px 10px rgba(0,0,0,0.05);
  691. }
  692. .logo-area { display: flex; align-items: center; gap: 14px; }
  693. .title { font-size: 1.5rem; font-weight: 700; background: linear-gradient(135deg, #1e5128, #409eff); -webkit-background-clip: text; background-clip: text; color: transparent; }
  694. .alert-stats { display: flex; gap: 24px; }
  695. .stat { text-align: center; cursor: pointer; padding: 4px 16px; border-radius: 40px; transition: 0.2s; }
  696. .stat:hover { background: #f0f2f5; }
  697. .stat-value { font-size: 26px; font-weight: bold; display: block; line-height: 1.2; }
  698. .stat-value.critical { color: #f56c6c; }
  699. .stat-value.warning { color: #e6a23c; }
  700. .stat-value.offline { color: #909399; }
  701. .stat-label { font-size: 12px; color: #606266; }
  702. .main-layout { display: flex; flex: 1; gap: 16px; padding: 16px; overflow: hidden; }
  703. .layer-control {
  704. width: 280px;
  705. background: white;
  706. border-radius: 20px;
  707. padding: 16px;
  708. display: flex;
  709. flex-direction: column;
  710. gap: 14px;
  711. overflow-y: auto;
  712. }
  713. .layer-header { font-weight: bold; display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
  714. .layer-filter-panel {
  715. display: flex;
  716. flex-direction: column;
  717. gap: 10px;
  718. }
  719. .filter-actions {
  720. display: flex;
  721. gap: 8px;
  722. }
  723. .filter-summary {
  724. display: flex;
  725. flex-wrap: wrap;
  726. gap: 6px;
  727. }
  728. .layer-tree { flex: 1; }
  729. .layer-badge { margin-left: auto; }
  730. .legend-panel { border-top: 1px solid #e9ecef; padding-top: 12px; margin-top: 8px; }
  731. .legend-title { margin-bottom: 8px; font-weight: 500; font-size: 13px; }
  732. .legend-marker { display: inline-block; width: 14px; height: 14px; border-radius: 14px; margin-right: 8px; vertical-align: middle; }
  733. .critical-dot { background: #f56c6c; box-shadow: 0 0 0 2px rgba(245,108,108,0.4); }
  734. .warning-dot { background: #e6a23c; }
  735. .normal-dot { background: #67c23a; }
  736. .offline-dot { background: #909399; }
  737. .alarm-pulse { width: 14px; height: 14px; background: red; border-radius: 50%; animation: pulse 1s infinite; display: inline-block; margin-right: 8px; }
  738. @keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.4; transform: scale(1.2); } 100% { opacity: 1; } }
  739. .gis-map-area {
  740. flex: 1;
  741. background: white;
  742. border-radius: 20px;
  743. display: flex;
  744. flex-direction: column;
  745. overflow: hidden;
  746. }
  747. .map-toolbar {
  748. padding: 12px 16px;
  749. border-bottom: 1px solid #edf2f0;
  750. display: flex;
  751. justify-content: space-between;
  752. }
  753. .current-stat { display: flex; gap: 20px; font-size: 13px; }
  754. .map-container {
  755. flex: 1;
  756. position: relative;
  757. overflow: hidden;
  758. }
  759. .baidu-map {
  760. width: 100%;
  761. height: 100%;
  762. }
  763. .empty-state {
  764. position: absolute;
  765. top: 50%;
  766. left: 50%;
  767. transform: translate(-50%, -50%);
  768. color: #909399;
  769. font-size: 14px;
  770. background: rgba(255, 255, 255, 0.92);
  771. padding: 10px 18px;
  772. border-radius: 12px;
  773. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
  774. z-index: 2;
  775. }
  776. .map-error {
  777. position: absolute;
  778. top: 16px;
  779. right: 16px;
  780. max-width: 320px;
  781. color: #f56c6c;
  782. background: rgba(255, 255, 255, 0.95);
  783. border: 1px solid rgba(245, 108, 108, 0.25);
  784. border-radius: 12px;
  785. padding: 10px 12px;
  786. font-size: 12px;
  787. line-height: 1.6;
  788. z-index: 3;
  789. }
  790. .advice-box { background: #f8f9fa; padding: 12px; border-radius: 12px; }
  791. .panel-tip {
  792. font-size: 12px;
  793. color: #909399;
  794. line-height: 1.5;
  795. }
  796. </style>