| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505 |
- <!--窨井盖安全实时监测一张图(参考 equipmentGIS.vue,仅展示 rootTypeId=4 窨井设备)-->
- <template>
- <div class="water-page gis-page">
- <div class="page-head">
- <div class="page-head-titles">
- <div class="page-title">
- <el-icon class="page-title-icon"><MapLocation /></el-icon>
- <span>窨井盖安全实时监测一张图</span>
- </div>
- </div>
- <el-button :icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
- </div>
- <!-- 设备统计 -->
- <div class="kpi-row">
- <el-card shadow="never" class="kpi">
- <div class="kpi-icon total"><el-icon><Monitor /></el-icon></div>
- <div class="kpi-data">
- <div class="kpi-label">设备总数(台)</div>
- <div class="kpi-value">{{ statistics.total }}</div>
- </div>
- </el-card>
- <el-card shadow="never" class="kpi">
- <div class="kpi-icon online"><el-icon><CircleCheck /></el-icon></div>
- <div class="kpi-data">
- <div class="kpi-label">在线设备(台)</div>
- <div class="kpi-value">{{ statistics.online }}</div>
- </div>
- </el-card>
- <el-card shadow="never" class="kpi">
- <div class="kpi-icon offline"><el-icon><CircleClose /></el-icon></div>
- <div class="kpi-data">
- <div class="kpi-label">离线设备(台)</div>
- <div class="kpi-value">{{ statistics.offline }}</div>
- </div>
- </el-card>
- <el-card shadow="never" class="kpi">
- <div class="kpi-icon alarm"><el-icon><Warning /></el-icon></div>
- <div class="kpi-data">
- <div class="kpi-label">报警设备(台)</div>
- <div class="kpi-value">{{ statistics.alarm }}</div>
- </div>
- </el-card>
- </div>
- <!-- 地图 -->
- <el-card shadow="never" class="map-card">
- <div class="map-wrap">
- <GisMapCanvas
- ref="gisMapRef"
- :points="mapPoints"
- cluster-label="设备集合"
- :center="mapCenter"
- :zoom="15"
- @point-click="openDetail($event.equipmentId)"
- @error="mapError = $event"
- />
- <div class="map-legend" @mousedown.stop @click.stop @dblclick.stop>
- <div class="legend-title">点击切换显示</div>
- <button type="button" class="legend-row" :class="{ 'is-off': !legendVisible.online }" @click="toggleLegend('online')">
- <i class="legend-dot online"></i>在线
- </button>
- <button type="button" class="legend-row" :class="{ 'is-off': !legendVisible.offline }" @click="toggleLegend('offline')">
- <i class="legend-dot offline"></i>离线
- </button>
- <button type="button" class="legend-row" :class="{ 'is-off': !legendVisible.alarm }" @click="toggleLegend('alarm')">
- <i class="legend-dot alarm"></i>报警(在线/离线时叠加)
- </button>
- </div>
- <el-alert
- v-if="mapError"
- class="map-error"
- type="warning"
- show-icon
- :closable="false"
- :title="mapError"
- >
- <el-button link type="primary" @click="loadData">重试</el-button>
- </el-alert>
- <el-empty
- v-if="!loading && !mapError && !mapPoints.length"
- class="map-empty"
- description="当前没有可显示的监测设备"
- />
- </div>
- </el-card>
- <!-- 设备采集记录抽屉 -->
- <el-drawer
- v-model="detailVisible"
- :title="(currentDevice?.equipmentName || currentDevice?.equipmentCode || '设备') + ' - 采集记录'"
- direction="rtl"
- size="900px"
- class="detail-drawer"
- >
- <div v-loading="detailLoading">
- <template v-if="currentDevice">
- <div class="dlg-summary">
- <div class="summary-name">{{ currentDevice.equipmentName || '未命名设备' }}</div>
- <div class="summary-code">{{ currentDevice.equipmentCode }}</div>
- <span class="summary-tag">{{ currentDevice.equipmentTypeName || '未分类' }}</span>
- <div class="status-group">
- <span class="badge" :class="Number(currentDevice.onlineStatus) === 1 ? 'online' : 'offline'">
- <span class="badge-dot"></span>
- <span class="badge-text">{{ Number(currentDevice.onlineStatus) === 1 ? '在线' : '离线' }}</span>
- </span>
- <span v-if="Number(currentDevice.alarmStatus) === 1" class="badge alarm">
- <el-icon class="badge-icon"><WarningFilled /></el-icon>
- <span class="badge-text">报警</span>
- </span>
- </div>
- </div>
- <div class="form-section-title detail-section-title">采集记录(最近 50 条)</div>
- <div class="hist-wrap">
- <el-table :data="pagedRecordRows" max-height="420" size="small" border>
- <el-table-column
- v-for="c in recordColumns"
- :key="c.key"
- :label="c.label"
- :min-width="c.minWidth"
- >
- <template #default="{ row }">
- <span v-if="isStatusKey(c.key)" :class="{ 'cell-alarm': row[c.key] === '1' }">
- {{ row[c.key] === '1' ? '报警' : '正常' }}
- </span>
- <span v-else>{{ row[c.key] != null && row[c.key] !== '' ? row[c.key] : '—' }}</span>
- </template>
- </el-table-column>
- <template #empty><div class="rec-empty">暂无采集记录</div></template>
- </el-table>
- <el-pagination
- v-if="recordRows.length"
- class="rec-pager"
- layout="prev, pager, next"
- :total="recordRows.length"
- :page-size="recordPageSize"
- small
- v-model:current-page="recordPage"
- />
- </div>
- </template>
- <el-empty v-else description="未选中设备" />
- </div>
- </el-drawer>
- </div>
- </template>
- <script setup>
- import { computed, nextTick, onMounted, reactive, ref } from 'vue'
- import { CircleCheck, CircleClose, MapLocation, Monitor, Refresh, Warning, WarningFilled } from '@element-plus/icons-vue'
- import GisMapCanvas from '@/components/GisMapCanvas.vue'
- import { getEquipmentGisPoints, getManholeDataPage } from '@/api/pipeNetwork/basic'
- import { gisPointStatusIcons } from '@/utils/gisMapColors'
- // 窨井设备固定为 rootTypeId=4
- const ROOT_TYPE_ID = 4
- const mapCenter = { lng: 110.3965, lat: 28.4638 }
- const allDevices = ref([])
- const loading = ref(false)
- const mapError = ref('')
- const gisMapRef = ref(null)
- const detailVisible = ref(false)
- const detailLoading = ref(false)
- const currentDevice = ref(null)
- const recordRows = ref([])
- const recordPage = ref(1)
- const recordPageSize = 10
- const statistics = computed(() => ({
- total: allDevices.value.length,
- online: allDevices.value.filter((device) => Number(device.onlineStatus) === 1).length,
- offline: allDevices.value.filter((device) => Number(device.onlineStatus) !== 1).length,
- alarm: allDevices.value.filter((device) => Number(device.alarmStatus) === 1).length
- }))
- // ============ 地图标记图标(在线=绿、离线=灰,报警叠加红点) ============
- const iconsReady = ref(false)
- const markerImages = {
- green: typeof Image !== 'undefined' ? new Image() : null,
- grey: typeof Image !== 'undefined' ? new Image() : null,
- red: typeof Image !== 'undefined' ? new Image() : null
- }
- const iconCache = new Map()
- function loadMarkerImages() {
- const entries = [
- ['green', gisPointStatusIcons.normal],
- ['grey', gisPointStatusIcons.offline],
- ['red', gisPointStatusIcons.alarm]
- ]
- let pending = entries.length
- if (!pending) {
- iconsReady.value = true
- return
- }
- const done = () => {
- pending -= 1
- if (pending <= 0) iconsReady.value = true
- }
- entries.forEach(([key, url]) => {
- const img = markerImages[key]
- if (!img) return done()
- img.onload = done
- img.onerror = done
- img.src = url
- })
- }
- function composeAlarmIcon(online) {
- const base = online ? markerImages.green : markerImages.grey
- const red = markerImages.red
- if (!base || !red || !base.complete || !red.complete) {
- return online ? gisPointStatusIcons.normal : gisPointStatusIcons.offline
- }
- const size = 32
- const canvas = document.createElement('canvas')
- canvas.width = size
- canvas.height = size
- const ctx = canvas.getContext('2d')
- ctx.drawImage(base, 0, 0, size, size)
- const badge = Math.round(size * 0.5)
- ctx.drawImage(red, size - badge + 2, 1, badge, badge)
- return canvas.toDataURL()
- }
- function getDeviceIcon(device) {
- const online = Number(device.onlineStatus) === 1
- const alarm = Number(device.alarmStatus) === 1
- if (!alarm) return online ? gisPointStatusIcons.normal : gisPointStatusIcons.offline
- if (!iconsReady.value) return online ? gisPointStatusIcons.normal : gisPointStatusIcons.offline
- const key = online ? 'online' : 'offline'
- if (!iconCache.has(key)) iconCache.set(key, composeAlarmIcon(online))
- return iconCache.get(key)
- }
- // ============ 图例状态筛选 ============
- const legendVisible = reactive({ online: true, offline: true, alarm: true })
- function toggleLegend(key) {
- legendVisible[key] = !legendVisible[key]
- }
- function isDeviceVisible(device) {
- const online = Number(device.onlineStatus) === 1
- const alarm = Number(device.alarmStatus) === 1
- if (online && !legendVisible.online) return false
- if (!online && !legendVisible.offline) return false
- if (alarm && !legendVisible.alarm) return false
- return true
- }
- const mapPoints = computed(() => {
- void iconsReady.value
- void legendVisible.online
- void legendVisible.offline
- void legendVisible.alarm
- return allDevices.value
- .filter(isDeviceVisible)
- .map((device) => ({
- ...device,
- lng: device._longitude,
- lat: device._latitude,
- name: device.equipmentName || device.equipmentCode,
- iconUrl: getDeviceIcon(device)
- }))
- })
- // ============ 采集记录列(样式对齐 remoteControl.vue 采集记录) ============
- const recordColumns = [
- { key: 'createTime', label: '采集时间', minWidth: 158 },
- { key: 'tiltAngle', label: '倾斜角度(°)', minWidth: 104 },
- { key: 'waterInfiltrationAlarmStatus', label: '水浸报警状态', minWidth: 104 },
- { key: 'waterLevelAlarmStatus', label: '水位报警状态', minWidth: 104 },
- { key: 'temperatureValue', label: '温度(℃)', minWidth: 104 },
- { key: 'batteryLevel', label: '电池电量(%)', minWidth: 104 },
- { key: 'signalStrength', label: '信号强度(%)', minWidth: 104 }
- ]
- const statusKeys = ['waterInfiltrationAlarmStatus', 'waterLevelAlarmStatus']
- function isStatusKey(key) {
- return statusKeys.includes(key)
- }
- const pagedRecordRows = computed(() => {
- const start = (recordPage.value - 1) * recordPageSize
- return recordRows.value.slice(start, start + recordPageSize)
- })
- // ============ 数据加载 ============
- function normalizeEquipment(row) {
- return {
- equipmentId: row.equipmentId,
- equipmentCode: row.equipmentCode,
- equipmentName: row.equipmentName,
- equipmentTypeId: row.equipmentTypeId,
- equipmentTypeName: row.equipmentTypeName,
- onlineStatus: row.onlineStatus,
- alarmStatus: row.alarmStatus,
- _longitude: row.longitude != null ? Number(row.longitude) : null,
- _latitude: row.latitude != null ? Number(row.latitude) : null
- }
- }
- async function loadData() {
- loading.value = true
- mapError.value = ''
- try {
- const allResponse = await getEquipmentGisPoints({ rootTypeId: ROOT_TYPE_ID })
- const list = allResponse?.data ?? allResponse
- allDevices.value = (Array.isArray(list) ? list : []).map(normalizeEquipment)
- await nextTick()
- gisMapRef.value?.fitBounds()
- } catch (error) {
- mapError.value = '监测设备数据加载失败,请点击右上角“刷新”重试'
- } finally {
- loading.value = false
- }
- }
- async function openDetail(equipmentId) {
- if (!equipmentId) return
- const device = allDevices.value.find((d) => d.equipmentId === equipmentId)
- if (!device) return
- currentDevice.value = device
- detailVisible.value = true
- detailLoading.value = true
- recordRows.value = []
- recordPage.value = 1
- try {
- const res = await getManholeDataPage({ imeiCardNumber: device.equipmentCode, pageNum: 1, pageSize: 50 })
- const page = res.data || {}
- recordRows.value = page.records || []
- } catch (e) {
- recordRows.value = []
- } finally {
- detailLoading.value = false
- }
- }
- onMounted(() => {
- loadMarkerImages()
- loadData()
- })
- </script>
- <style scoped>
- .gis-page {
- padding: 20px;
- background: #f9f9f9;
- min-height: calc(100vh - 84px);
- }
- /* 页头 */
- .page-head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 20px;
- }
- .page-head-titles {
- display: flex;
- align-items: center;
- gap: 12px;
- min-width: 0;
- }
- .page-title {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 20px;
- font-weight: 600;
- color: #0a1628;
- }
- .page-title-icon {
- color: #014db2;
- font-size: 22px;
- }
- /* KPI */
- .kpi-row {
- display: flex;
- gap: 16px;
- margin-bottom: 20px;
- }
- .kpi {
- flex: 1 1 0;
- min-width: 0;
- border-radius: 12px;
- }
- .kpi :deep(.el-card__body) {
- display: flex;
- align-items: center;
- gap: 16px;
- padding: 20px;
- }
- .kpi-icon {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 44px;
- height: 44px;
- flex: 0 0 44px;
- border-radius: 10px;
- font-size: 22px;
- }
- .kpi-icon.total { background: #edf3fc; color: #014db2; }
- .kpi-icon.online { background: #e7f8f1; color: #10b981; }
- .kpi-icon.offline { background: #fef4e2; color: #f59e0b; }
- .kpi-icon.alarm { background: #fdecec; color: #ef4444; }
- .kpi-label { font-size: 13px; font-weight: 400; color: #6b7280; }
- .kpi-value { margin-top: 6px; font-size: 28px; font-weight: 700; line-height: 1; color: #0a1628; }
- /* 地图 */
- .map-card { border-radius: 12px; }
- .map-card :deep(.el-card__body) { padding: 12px; }
- .map-wrap {
- position: relative;
- width: 100%;
- height: calc(100vh - 340px);
- min-height: 480px;
- border-radius: 8px;
- overflow: hidden;
- }
- .map-legend {
- position: absolute;
- top: 12px;
- left: 12px;
- z-index: 9999;
- padding: 10px 12px;
- border-radius: 8px;
- background: rgba(255, 255, 255, 0.92);
- box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12);
- pointer-events: auto;
- }
- .legend-title { margin-bottom: 8px; font-size: 12px; font-weight: 600; color: #94a3b8; }
- .legend-row {
- display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
- padding: 0; border: none; background: transparent; font: inherit;
- font-size: 13px; color: #4d667d; cursor: pointer; transition: opacity 0.15s, color 0.15s;
- }
- .legend-row:last-child { margin-bottom: 0; }
- .legend-row:hover { color: #1f3b57; }
- .legend-row.is-off { opacity: 0.4; text-decoration: line-through; }
- .legend-dot { width: 10px; height: 10px; border-radius: 50%; }
- .legend-dot.online { background: #2ecc71; }
- .legend-dot.offline { background: #95a5a6; }
- .legend-dot.alarm { background: #e74c3c; }
- .map-error { position: absolute; left: 16px; bottom: 16px; width: 360px; z-index: 2; }
- .map-empty { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 2; }
- /* 详情抽屉摘要 */
- .dlg-summary {
- display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
- padding: 14px; border-radius: 8px; background: #f5f9ff;
- }
- .summary-name { font-size: 15px; font-weight: 500; color: #014db2; }
- .summary-code { font-size: 12px; font-weight: 400; color: #6b7280; }
- .summary-tag {
- display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 4px;
- background: #fff; font-size: 11px; font-weight: 500; color: #014db2;
- }
- .form-section-title {
- margin: 8px 0 12px; padding-left: 10px; border-left: 3px solid #014db2;
- color: #1f2937; font-size: 14px; font-weight: 600;
- }
- .detail-section-title { margin-top: 18px; }
- .status-group { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
- .badge { display: flex; align-items: center; gap: 5px; }
- .badge-dot { width: 6px; height: 6px; border-radius: 3px; flex-shrink: 0; }
- .badge-text { font-size: 12px; font-weight: 500; }
- .badge.online .badge-dot { background: #10b981; }
- .badge.online .badge-text { color: #10b981; }
- .badge.offline .badge-dot { background: #f59e0b; }
- .badge.offline .badge-text { color: #f59e0b; }
- .badge.alarm .badge-text { color: #ef4444; }
- .badge-icon { font-size: 13px; color: #ef4444; }
- /* 采集记录表格(对齐 remoteControl.vue) */
- .hist-wrap { overflow: auto; border-radius: 8px; background: #fff; }
- .hist-wrap :deep(.el-table) { font-size: 12px; border: 1px solid #e3e9f2; border-radius: 8px; }
- .hist-wrap :deep(.el-table th.el-table__cell) { background: #f7f9fc; color: #5e6d82; font-weight: 600; }
- .hist-wrap :deep(.el-table td.el-table__cell) { padding: 6px 0; border-bottom: 1px solid #eef2f7; }
- .hist-wrap :deep(.el-table .cell) { padding: 0 10px; line-height: 1.5; }
- .hist-wrap :deep(.el-table td.el-table__cell .cell) { white-space: nowrap; }
- .hist-wrap :deep(.el-table__header th.el-table__cell) { border-bottom: 1px solid #e3e9f2; }
- .hist-wrap :deep(.el-table::before),
- .hist-wrap :deep(.el-table::after),
- .hist-wrap :deep(.el-table__inner-wrapper::before),
- .hist-wrap :deep(.el-table__inner-wrapper::after) { display: none; }
- .hist-wrap :deep(.el-table .el-table__row:last-child td.el-table__cell) { border-bottom: none; }
- .rec-empty { color: #9aa7b8; font-size: 12px; padding: 28px 0; }
- .cell-alarm { color: #ef4444; font-weight: 600; }
- .rec-pager { margin-top: 10px; justify-content: flex-end; display: flex; }
- @media (max-width: 1200px) {
- .kpi-row { flex-wrap: wrap; }
- .kpi { flex: 1 1 45%; }
- .map-wrap { height: 520px; }
- }
- @media (max-width: 768px) {
- .kpi { flex: 1 1 100%; }
- .page-head { flex-direction: column; align-items: flex-start; gap: 12px; }
- }
- </style>
|