mRStateMonitoring.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. <!--窨井盖安全实时监测一张图(参考 equipmentGIS.vue,仅展示 rootTypeId=4 窨井设备)-->
  2. <template>
  3. <div class="water-page gis-page">
  4. <div class="page-head">
  5. <div class="page-head-titles">
  6. <div class="page-title">
  7. <el-icon class="page-title-icon"><MapLocation /></el-icon>
  8. <span>窨井盖安全实时监测一张图</span>
  9. </div>
  10. </div>
  11. <el-button :icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
  12. </div>
  13. <!-- 设备统计 -->
  14. <div class="kpi-row">
  15. <el-card shadow="never" class="kpi">
  16. <div class="kpi-icon total"><el-icon><Monitor /></el-icon></div>
  17. <div class="kpi-data">
  18. <div class="kpi-label">设备总数(台)</div>
  19. <div class="kpi-value">{{ statistics.total }}</div>
  20. </div>
  21. </el-card>
  22. <el-card shadow="never" class="kpi">
  23. <div class="kpi-icon online"><el-icon><CircleCheck /></el-icon></div>
  24. <div class="kpi-data">
  25. <div class="kpi-label">在线设备(台)</div>
  26. <div class="kpi-value">{{ statistics.online }}</div>
  27. </div>
  28. </el-card>
  29. <el-card shadow="never" class="kpi">
  30. <div class="kpi-icon offline"><el-icon><CircleClose /></el-icon></div>
  31. <div class="kpi-data">
  32. <div class="kpi-label">离线设备(台)</div>
  33. <div class="kpi-value">{{ statistics.offline }}</div>
  34. </div>
  35. </el-card>
  36. <el-card shadow="never" class="kpi">
  37. <div class="kpi-icon alarm"><el-icon><Warning /></el-icon></div>
  38. <div class="kpi-data">
  39. <div class="kpi-label">报警设备(台)</div>
  40. <div class="kpi-value">{{ statistics.alarm }}</div>
  41. </div>
  42. </el-card>
  43. </div>
  44. <!-- 地图 -->
  45. <el-card shadow="never" class="map-card">
  46. <div class="map-wrap">
  47. <GisMapCanvas
  48. ref="gisMapRef"
  49. :points="mapPoints"
  50. cluster-label="设备集合"
  51. :center="mapCenter"
  52. :zoom="15"
  53. @point-click="openDetail($event.equipmentId)"
  54. @error="mapError = $event"
  55. />
  56. <div class="map-legend" @mousedown.stop @click.stop @dblclick.stop>
  57. <div class="legend-title">点击切换显示</div>
  58. <button type="button" class="legend-row" :class="{ 'is-off': !legendVisible.online }" @click="toggleLegend('online')">
  59. <i class="legend-dot online"></i>在线
  60. </button>
  61. <button type="button" class="legend-row" :class="{ 'is-off': !legendVisible.offline }" @click="toggleLegend('offline')">
  62. <i class="legend-dot offline"></i>离线
  63. </button>
  64. <button type="button" class="legend-row" :class="{ 'is-off': !legendVisible.alarm }" @click="toggleLegend('alarm')">
  65. <i class="legend-dot alarm"></i>报警(在线/离线时叠加)
  66. </button>
  67. </div>
  68. <el-alert
  69. v-if="mapError"
  70. class="map-error"
  71. type="warning"
  72. show-icon
  73. :closable="false"
  74. :title="mapError"
  75. >
  76. <el-button link type="primary" @click="loadData">重试</el-button>
  77. </el-alert>
  78. <el-empty
  79. v-if="!loading && !mapError && !mapPoints.length"
  80. class="map-empty"
  81. description="当前没有可显示的监测设备"
  82. />
  83. </div>
  84. </el-card>
  85. <!-- 设备采集记录抽屉 -->
  86. <el-drawer
  87. v-model="detailVisible"
  88. :title="(currentDevice?.equipmentName || currentDevice?.equipmentCode || '设备') + ' - 采集记录'"
  89. direction="rtl"
  90. size="900px"
  91. class="detail-drawer"
  92. >
  93. <div v-loading="detailLoading">
  94. <template v-if="currentDevice">
  95. <div class="dlg-summary">
  96. <div class="summary-name">{{ currentDevice.equipmentName || '未命名设备' }}</div>
  97. <div class="summary-code">{{ currentDevice.equipmentCode }}</div>
  98. <span class="summary-tag">{{ currentDevice.equipmentTypeName || '未分类' }}</span>
  99. <div class="status-group">
  100. <span class="badge" :class="Number(currentDevice.onlineStatus) === 1 ? 'online' : 'offline'">
  101. <span class="badge-dot"></span>
  102. <span class="badge-text">{{ Number(currentDevice.onlineStatus) === 1 ? '在线' : '离线' }}</span>
  103. </span>
  104. <span v-if="Number(currentDevice.alarmStatus) === 1" class="badge alarm">
  105. <el-icon class="badge-icon"><WarningFilled /></el-icon>
  106. <span class="badge-text">报警</span>
  107. </span>
  108. </div>
  109. </div>
  110. <div class="form-section-title detail-section-title">采集记录(最近 50 条)</div>
  111. <div class="hist-wrap">
  112. <el-table :data="pagedRecordRows" max-height="420" size="small" border>
  113. <el-table-column
  114. v-for="c in recordColumns"
  115. :key="c.key"
  116. :label="c.label"
  117. :min-width="c.minWidth"
  118. >
  119. <template #default="{ row }">
  120. <span v-if="isStatusKey(c.key)" :class="{ 'cell-alarm': row[c.key] === '1' }">
  121. {{ row[c.key] === '1' ? '报警' : '正常' }}
  122. </span>
  123. <span v-else>{{ row[c.key] != null && row[c.key] !== '' ? row[c.key] : '—' }}</span>
  124. </template>
  125. </el-table-column>
  126. <template #empty><div class="rec-empty">暂无采集记录</div></template>
  127. </el-table>
  128. <el-pagination
  129. v-if="recordRows.length"
  130. class="rec-pager"
  131. layout="prev, pager, next"
  132. :total="recordRows.length"
  133. :page-size="recordPageSize"
  134. small
  135. v-model:current-page="recordPage"
  136. />
  137. </div>
  138. </template>
  139. <el-empty v-else description="未选中设备" />
  140. </div>
  141. </el-drawer>
  142. </div>
  143. </template>
  144. <script setup>
  145. import { computed, nextTick, onMounted, reactive, ref } from 'vue'
  146. import { CircleCheck, CircleClose, MapLocation, Monitor, Refresh, Warning, WarningFilled } from '@element-plus/icons-vue'
  147. import GisMapCanvas from '@/components/GisMapCanvas.vue'
  148. import { getEquipmentGisPoints, getManholeDataPage } from '@/api/pipeNetwork/basic'
  149. import { gisPointStatusIcons } from '@/utils/gisMapColors'
  150. // 窨井设备固定为 rootTypeId=4
  151. const ROOT_TYPE_ID = 4
  152. const mapCenter = { lng: 110.3965, lat: 28.4638 }
  153. const allDevices = ref([])
  154. const loading = ref(false)
  155. const mapError = ref('')
  156. const gisMapRef = ref(null)
  157. const detailVisible = ref(false)
  158. const detailLoading = ref(false)
  159. const currentDevice = ref(null)
  160. const recordRows = ref([])
  161. const recordPage = ref(1)
  162. const recordPageSize = 10
  163. const statistics = computed(() => ({
  164. total: allDevices.value.length,
  165. online: allDevices.value.filter((device) => Number(device.onlineStatus) === 1).length,
  166. offline: allDevices.value.filter((device) => Number(device.onlineStatus) !== 1).length,
  167. alarm: allDevices.value.filter((device) => Number(device.alarmStatus) === 1).length
  168. }))
  169. // ============ 地图标记图标(在线=绿、离线=灰,报警叠加红点) ============
  170. const iconsReady = ref(false)
  171. const markerImages = {
  172. green: typeof Image !== 'undefined' ? new Image() : null,
  173. grey: typeof Image !== 'undefined' ? new Image() : null,
  174. red: typeof Image !== 'undefined' ? new Image() : null
  175. }
  176. const iconCache = new Map()
  177. function loadMarkerImages() {
  178. const entries = [
  179. ['green', gisPointStatusIcons.normal],
  180. ['grey', gisPointStatusIcons.offline],
  181. ['red', gisPointStatusIcons.alarm]
  182. ]
  183. let pending = entries.length
  184. if (!pending) {
  185. iconsReady.value = true
  186. return
  187. }
  188. const done = () => {
  189. pending -= 1
  190. if (pending <= 0) iconsReady.value = true
  191. }
  192. entries.forEach(([key, url]) => {
  193. const img = markerImages[key]
  194. if (!img) return done()
  195. img.onload = done
  196. img.onerror = done
  197. img.src = url
  198. })
  199. }
  200. function composeAlarmIcon(online) {
  201. const base = online ? markerImages.green : markerImages.grey
  202. const red = markerImages.red
  203. if (!base || !red || !base.complete || !red.complete) {
  204. return online ? gisPointStatusIcons.normal : gisPointStatusIcons.offline
  205. }
  206. const size = 32
  207. const canvas = document.createElement('canvas')
  208. canvas.width = size
  209. canvas.height = size
  210. const ctx = canvas.getContext('2d')
  211. ctx.drawImage(base, 0, 0, size, size)
  212. const badge = Math.round(size * 0.5)
  213. ctx.drawImage(red, size - badge + 2, 1, badge, badge)
  214. return canvas.toDataURL()
  215. }
  216. function getDeviceIcon(device) {
  217. const online = Number(device.onlineStatus) === 1
  218. const alarm = Number(device.alarmStatus) === 1
  219. if (!alarm) return online ? gisPointStatusIcons.normal : gisPointStatusIcons.offline
  220. if (!iconsReady.value) return online ? gisPointStatusIcons.normal : gisPointStatusIcons.offline
  221. const key = online ? 'online' : 'offline'
  222. if (!iconCache.has(key)) iconCache.set(key, composeAlarmIcon(online))
  223. return iconCache.get(key)
  224. }
  225. // ============ 图例状态筛选 ============
  226. const legendVisible = reactive({ online: true, offline: true, alarm: true })
  227. function toggleLegend(key) {
  228. legendVisible[key] = !legendVisible[key]
  229. }
  230. function isDeviceVisible(device) {
  231. const online = Number(device.onlineStatus) === 1
  232. const alarm = Number(device.alarmStatus) === 1
  233. if (online && !legendVisible.online) return false
  234. if (!online && !legendVisible.offline) return false
  235. if (alarm && !legendVisible.alarm) return false
  236. return true
  237. }
  238. const mapPoints = computed(() => {
  239. void iconsReady.value
  240. void legendVisible.online
  241. void legendVisible.offline
  242. void legendVisible.alarm
  243. return allDevices.value
  244. .filter(isDeviceVisible)
  245. .map((device) => ({
  246. ...device,
  247. lng: device._longitude,
  248. lat: device._latitude,
  249. name: device.equipmentName || device.equipmentCode,
  250. iconUrl: getDeviceIcon(device)
  251. }))
  252. })
  253. // ============ 采集记录列(样式对齐 remoteControl.vue 采集记录) ============
  254. const recordColumns = [
  255. { key: 'createTime', label: '采集时间', minWidth: 158 },
  256. { key: 'tiltAngle', label: '倾斜角度(°)', minWidth: 104 },
  257. { key: 'waterInfiltrationAlarmStatus', label: '水浸报警状态', minWidth: 104 },
  258. { key: 'waterLevelAlarmStatus', label: '水位报警状态', minWidth: 104 },
  259. { key: 'temperatureValue', label: '温度(℃)', minWidth: 104 },
  260. { key: 'batteryLevel', label: '电池电量(%)', minWidth: 104 },
  261. { key: 'signalStrength', label: '信号强度(%)', minWidth: 104 }
  262. ]
  263. const statusKeys = ['waterInfiltrationAlarmStatus', 'waterLevelAlarmStatus']
  264. function isStatusKey(key) {
  265. return statusKeys.includes(key)
  266. }
  267. const pagedRecordRows = computed(() => {
  268. const start = (recordPage.value - 1) * recordPageSize
  269. return recordRows.value.slice(start, start + recordPageSize)
  270. })
  271. // ============ 数据加载 ============
  272. function normalizeEquipment(row) {
  273. return {
  274. equipmentId: row.equipmentId,
  275. equipmentCode: row.equipmentCode,
  276. equipmentName: row.equipmentName,
  277. equipmentTypeId: row.equipmentTypeId,
  278. equipmentTypeName: row.equipmentTypeName,
  279. onlineStatus: row.onlineStatus,
  280. alarmStatus: row.alarmStatus,
  281. _longitude: row.longitude != null ? Number(row.longitude) : null,
  282. _latitude: row.latitude != null ? Number(row.latitude) : null
  283. }
  284. }
  285. async function loadData() {
  286. loading.value = true
  287. mapError.value = ''
  288. try {
  289. const allResponse = await getEquipmentGisPoints({ rootTypeId: ROOT_TYPE_ID })
  290. const list = allResponse?.data ?? allResponse
  291. allDevices.value = (Array.isArray(list) ? list : []).map(normalizeEquipment)
  292. await nextTick()
  293. gisMapRef.value?.fitBounds()
  294. } catch (error) {
  295. mapError.value = '监测设备数据加载失败,请点击右上角“刷新”重试'
  296. } finally {
  297. loading.value = false
  298. }
  299. }
  300. async function openDetail(equipmentId) {
  301. if (!equipmentId) return
  302. const device = allDevices.value.find((d) => d.equipmentId === equipmentId)
  303. if (!device) return
  304. currentDevice.value = device
  305. detailVisible.value = true
  306. detailLoading.value = true
  307. recordRows.value = []
  308. recordPage.value = 1
  309. try {
  310. const res = await getManholeDataPage({ imeiCardNumber: device.equipmentCode, pageNum: 1, pageSize: 50 })
  311. const page = res.data || {}
  312. recordRows.value = page.records || []
  313. } catch (e) {
  314. recordRows.value = []
  315. } finally {
  316. detailLoading.value = false
  317. }
  318. }
  319. onMounted(() => {
  320. loadMarkerImages()
  321. loadData()
  322. })
  323. </script>
  324. <style scoped>
  325. .gis-page {
  326. padding: 20px;
  327. background: #f9f9f9;
  328. min-height: calc(100vh - 84px);
  329. }
  330. /* 页头 */
  331. .page-head {
  332. display: flex;
  333. align-items: center;
  334. justify-content: space-between;
  335. margin-bottom: 20px;
  336. }
  337. .page-head-titles {
  338. display: flex;
  339. align-items: center;
  340. gap: 12px;
  341. min-width: 0;
  342. }
  343. .page-title {
  344. display: flex;
  345. align-items: center;
  346. gap: 8px;
  347. font-size: 20px;
  348. font-weight: 600;
  349. color: #0a1628;
  350. }
  351. .page-title-icon {
  352. color: #014db2;
  353. font-size: 22px;
  354. }
  355. /* KPI */
  356. .kpi-row {
  357. display: flex;
  358. gap: 16px;
  359. margin-bottom: 20px;
  360. }
  361. .kpi {
  362. flex: 1 1 0;
  363. min-width: 0;
  364. border-radius: 12px;
  365. }
  366. .kpi :deep(.el-card__body) {
  367. display: flex;
  368. align-items: center;
  369. gap: 16px;
  370. padding: 20px;
  371. }
  372. .kpi-icon {
  373. display: flex;
  374. align-items: center;
  375. justify-content: center;
  376. width: 44px;
  377. height: 44px;
  378. flex: 0 0 44px;
  379. border-radius: 10px;
  380. font-size: 22px;
  381. }
  382. .kpi-icon.total { background: #edf3fc; color: #014db2; }
  383. .kpi-icon.online { background: #e7f8f1; color: #10b981; }
  384. .kpi-icon.offline { background: #fef4e2; color: #f59e0b; }
  385. .kpi-icon.alarm { background: #fdecec; color: #ef4444; }
  386. .kpi-label { font-size: 13px; font-weight: 400; color: #6b7280; }
  387. .kpi-value { margin-top: 6px; font-size: 28px; font-weight: 700; line-height: 1; color: #0a1628; }
  388. /* 地图 */
  389. .map-card { border-radius: 12px; }
  390. .map-card :deep(.el-card__body) { padding: 12px; }
  391. .map-wrap {
  392. position: relative;
  393. width: 100%;
  394. height: calc(100vh - 340px);
  395. min-height: 480px;
  396. border-radius: 8px;
  397. overflow: hidden;
  398. }
  399. .map-legend {
  400. position: absolute;
  401. top: 12px;
  402. left: 12px;
  403. z-index: 9999;
  404. padding: 10px 12px;
  405. border-radius: 8px;
  406. background: rgba(255, 255, 255, 0.92);
  407. box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12);
  408. pointer-events: auto;
  409. }
  410. .legend-title { margin-bottom: 8px; font-size: 12px; font-weight: 600; color: #94a3b8; }
  411. .legend-row {
  412. display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
  413. padding: 0; border: none; background: transparent; font: inherit;
  414. font-size: 13px; color: #4d667d; cursor: pointer; transition: opacity 0.15s, color 0.15s;
  415. }
  416. .legend-row:last-child { margin-bottom: 0; }
  417. .legend-row:hover { color: #1f3b57; }
  418. .legend-row.is-off { opacity: 0.4; text-decoration: line-through; }
  419. .legend-dot { width: 10px; height: 10px; border-radius: 50%; }
  420. .legend-dot.online { background: #2ecc71; }
  421. .legend-dot.offline { background: #95a5a6; }
  422. .legend-dot.alarm { background: #e74c3c; }
  423. .map-error { position: absolute; left: 16px; bottom: 16px; width: 360px; z-index: 2; }
  424. .map-empty { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 2; }
  425. /* 详情抽屉摘要 */
  426. .dlg-summary {
  427. display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  428. padding: 14px; border-radius: 8px; background: #f5f9ff;
  429. }
  430. .summary-name { font-size: 15px; font-weight: 500; color: #014db2; }
  431. .summary-code { font-size: 12px; font-weight: 400; color: #6b7280; }
  432. .summary-tag {
  433. display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 4px;
  434. background: #fff; font-size: 11px; font-weight: 500; color: #014db2;
  435. }
  436. .form-section-title {
  437. margin: 8px 0 12px; padding-left: 10px; border-left: 3px solid #014db2;
  438. color: #1f2937; font-size: 14px; font-weight: 600;
  439. }
  440. .detail-section-title { margin-top: 18px; }
  441. .status-group { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
  442. .badge { display: flex; align-items: center; gap: 5px; }
  443. .badge-dot { width: 6px; height: 6px; border-radius: 3px; flex-shrink: 0; }
  444. .badge-text { font-size: 12px; font-weight: 500; }
  445. .badge.online .badge-dot { background: #10b981; }
  446. .badge.online .badge-text { color: #10b981; }
  447. .badge.offline .badge-dot { background: #f59e0b; }
  448. .badge.offline .badge-text { color: #f59e0b; }
  449. .badge.alarm .badge-text { color: #ef4444; }
  450. .badge-icon { font-size: 13px; color: #ef4444; }
  451. /* 采集记录表格(对齐 remoteControl.vue) */
  452. .hist-wrap { overflow: auto; border-radius: 8px; background: #fff; }
  453. .hist-wrap :deep(.el-table) { font-size: 12px; border: 1px solid #e3e9f2; border-radius: 8px; }
  454. .hist-wrap :deep(.el-table th.el-table__cell) { background: #f7f9fc; color: #5e6d82; font-weight: 600; }
  455. .hist-wrap :deep(.el-table td.el-table__cell) { padding: 6px 0; border-bottom: 1px solid #eef2f7; }
  456. .hist-wrap :deep(.el-table .cell) { padding: 0 10px; line-height: 1.5; }
  457. .hist-wrap :deep(.el-table td.el-table__cell .cell) { white-space: nowrap; }
  458. .hist-wrap :deep(.el-table__header th.el-table__cell) { border-bottom: 1px solid #e3e9f2; }
  459. .hist-wrap :deep(.el-table::before),
  460. .hist-wrap :deep(.el-table::after),
  461. .hist-wrap :deep(.el-table__inner-wrapper::before),
  462. .hist-wrap :deep(.el-table__inner-wrapper::after) { display: none; }
  463. .hist-wrap :deep(.el-table .el-table__row:last-child td.el-table__cell) { border-bottom: none; }
  464. .rec-empty { color: #9aa7b8; font-size: 12px; padding: 28px 0; }
  465. .cell-alarm { color: #ef4444; font-weight: 600; }
  466. .rec-pager { margin-top: 10px; justify-content: flex-end; display: flex; }
  467. @media (max-width: 1200px) {
  468. .kpi-row { flex-wrap: wrap; }
  469. .kpi { flex: 1 1 45%; }
  470. .map-wrap { height: 520px; }
  471. }
  472. @media (max-width: 768px) {
  473. .kpi { flex: 1 1 100%; }
  474. .page-head { flex-direction: column; align-items: flex-start; gap: 12px; }
  475. }
  476. </style>