Parcourir la source

feat(drainage): 设备实时监测改为实际数据展示并支持在线/报警筛选

null il y a 20 heures
Parent
commit
7cb17d2509
2 fichiers modifiés avec 145 ajouts et 140 suppressions
  1. 12 0
      src/api/drainage.js
  2. 133 140
      src/views/subSystem/drainage/jcsj/sbyx.vue

+ 12 - 0
src/api/drainage.js

@@ -264,6 +264,18 @@ export function publishWarning(data) {
   })
 }
 
+// 单个水位监测终端的历史数据(按设备编码倒序取最近 N 条,用于水位曲线)
+// 后端 /visualization/page 只支持 deviceCode 模糊查询,没有时间区间参数,
+// 时间窗口由调用方在拿到记录后自行裁剪。
+export function getWaterLevelHistory(deviceCode, pageSize = 500) {
+  return request({
+    url: '/water/monitor/visualization/page',
+    method: 'get',
+    params: { deviceCode, pageNum: 1, pageSize },
+    headers: { 'X-Silent-Error': 'true' }
+  })
+}
+
 // 各设备最新一条水位/液位监测数据(排水设备运行监测页使用)
 export function getLatestWaterLevelByDevice() {
   return request({

+ 133 - 140
src/views/subSystem/drainage/jcsj/sbyx.vue

@@ -5,7 +5,7 @@
     <div class="page-head">
       <div class="page-head-text">
         <div class="page-title">设备运行实时监测</div>
-        <div class="page-sub">实时掌握排水监测设备在线率、监测值与报警阈值,可直接派单或发布预警</div>
+        <div class="page-sub">实时掌握排水监测设备在线率与实际监测数据,可筛选报警设备并直接派单</div>
       </div>
       <el-button :icon="Refresh" :loading="loading" @click="refreshAll">刷新</el-button>
     </div>
@@ -70,13 +70,15 @@
           <el-input v-model="query.equipmentCode" placeholder="请输入" clearable class="filter-input"
             @keyup.enter="handleQuery" />
         </el-form-item>
-        <el-form-item label="运行状态">
-          <el-select v-model="query.runStatus" placeholder="全部" clearable class="filter-input">
-            <el-option label="在线" value="online" />
-            <el-option label="离线" value="offline" />
-            <el-option label="报警" value="alarm" />
+        <el-form-item label="在线状态">
+          <el-select v-model="query.onlineStatus" placeholder="全部" clearable class="filter-input">
+            <el-option label="在线" :value="1" />
+            <el-option label="离线" :value="0" />
           </el-select>
         </el-form-item>
+        <el-form-item label="报警">
+          <el-checkbox v-model="query.alarmOnly" @change="handleQuery" />
+        </el-form-item>
         <template v-if="filterExpanded">
           <el-form-item label="设备类型">
             <el-select v-model="query.equipmentTypeId" placeholder="全部" clearable class="filter-input">
@@ -92,12 +94,6 @@
             <el-input v-model="query.equipmentLocation" placeholder="请输入" clearable class="filter-input"
               @keyup.enter="handleQuery" />
           </el-form-item>
-          <el-form-item label="设备状态">
-            <el-select v-model="query.currentStatus" placeholder="全部" clearable class="filter-input">
-              <el-option v-for="item in currentStatusOptions" :key="item.value" :label="item.label"
-                :value="item.value" />
-            </el-select>
-          </el-form-item>
         </template>
         <el-form-item class="filter-actions">
           <el-button type="primary" :icon="Search" @click="handleQuery">搜索</el-button>
@@ -170,42 +166,25 @@
             </el-tag>
           </template>
         </el-table-column>
-        <el-table-column v-if="isColumnVisible('alarmStatus')" label="报警状态" min-width="90" align="center">
-          <template #default="{ row }">
-            <el-tag :type="row.alarmStatus === 1 ? 'danger' : 'success'">
-              {{ row.alarmStatus === 1 ? '报警' : '正常' }}
-            </el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column v-if="isColumnVisible('currentStatus')" label="设备状态" min-width="90" align="center">
+        <el-table-column v-if="isColumnVisible('actualData')" label="实际数据" min-width="240" show-overflow-tooltip>
           <template #default="{ row }">
-            <el-tag :type="statusTagType(row.currentStatus)">{{ statusText(row.currentStatus) }}</el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column v-if="isColumnVisible('waterLevel')" label="实时液位(m)" min-width="110" align="center">
-          <template #default="{ row }">
-            <span v-if="waterLevelOf(row) !== null" class="metric-value">{{ waterLevelOf(row) }}</span>
+            <span v-if="latestMetricsText(row) !== '—'" :class="{ 'cell-alarm': hasAlarmInLatest(row) }">
+              {{ latestMetricsText(row) }}
+            </span>
             <span v-else class="muted">—</span>
           </template>
         </el-table-column>
-        <el-table-column v-if="isColumnVisible('threshold')" label="报警阈值" min-width="150" align="center">
+        <el-table-column v-if="isColumnVisible('lastComm')" label="最后通讯" min-width="170" align="center">
           <template #default="{ row }">
-            <span v-if="thresholdText(row.equipmentCode)" class="threshold-text">{{ thresholdText(row.equipmentCode) }}</span>
-            <span v-else class="muted">未配置</span>
-          </template>
-        </el-table-column>
-        <el-table-column v-if="isColumnVisible('collectTime')" label="采集时间" min-width="165" align="center">
-          <template #default="{ row }">
-            <span v-if="collectTimeOf(row)">{{ collectTimeOf(row) }}</span>
+            <span v-if="lastCommOf(row)">{{ lastCommOf(row) }}</span>
             <span v-else class="muted">—</span>
           </template>
         </el-table-column>
-        <el-table-column label="操作" width="190" align="center" fixed="right">
+        <el-table-column label="操作" width="150" align="center" fixed="right">
           <template #default="{ row }">
             <el-button link type="primary" @click="handleView(row)">详情</el-button>
             <el-button link type="primary" @click="handleLocate(row)">定位</el-button>
             <el-button link type="warning" @click="openRepairDialog(row)">派单</el-button>
-            <el-button link type="danger" @click="openWarningDialog(row)">预警</el-button>
           </template>
         </el-table-column>
       </el-table>
@@ -232,22 +211,16 @@
         </el-descriptions>
 
         <div class="detail-section-title">实时监测</div>
-        <el-descriptions :column="3" border size="small">
+        <el-descriptions :column="2" border size="small">
           <el-descriptions-item label="在线状态">
             <el-tag :type="detailRow.onlineStatus === 1 ? 'success' : 'info'">
               {{ detailRow.onlineStatus === 1 ? '在线' : '离线' }}
             </el-tag>
           </el-descriptions-item>
-          <el-descriptions-item label="报警状态">
-            <el-tag :type="detailRow.alarmStatus === 1 ? 'danger' : 'success'">
-              {{ detailRow.alarmStatus === 1 ? '报警' : '正常' }}
-            </el-tag>
+          <el-descriptions-item label="最后通讯">{{ lastCommOf(detailRow) || '—' }}</el-descriptions-item>
+          <el-descriptions-item label="实际数据" :span="2">
+            <span :class="{ 'cell-alarm': hasAlarmInLatest(detailRow) }">{{ latestMetricsText(detailRow) }}</span>
           </el-descriptions-item>
-          <el-descriptions-item label="设备状态">
-            <el-tag :type="statusTagType(detailRow.currentStatus)">{{ statusText(detailRow.currentStatus) }}</el-tag>
-          </el-descriptions-item>
-          <el-descriptions-item label="实时液位(m)">{{ waterLevelOf(detailRow) ?? '—' }}</el-descriptions-item>
-          <el-descriptions-item label="采集时间" :span="2">{{ collectTimeOf(detailRow) || '—' }}</el-descriptions-item>
         </el-descriptions>
 
         <div class="detail-section-title">报警阈值</div>
@@ -437,10 +410,10 @@ import { saveAs } from 'file-saver'
 import locationIcon from '@/assets/images/location.png'
 import {
   getEquipmentTypeList,
-  getLatestWaterLevelByDevice,
   publishWarning
 } from '@/api/drainage'
 import { listEquipment } from '@/api/pipeNetwork/pdeviceInform'
+import { getEquipmentProfile } from '@/api/pipeNetwork/perception'
 import { pageWarningThresholds } from '@/api/warningThreshold'
 import { listWarningTypes } from '@/api/warning/list'
 import { addWorkOrder, dispatchWorkOrder, workOrderUsers } from '@/api/pipeNetwork/ops/workOrder'
@@ -451,17 +424,9 @@ import useUserStore from '@/store/modules/user'
 const ROOT_TYPE_ID = 2
 /** 统计与导出的一次性拉取上限,排水设备量小,够用即可 */
 const FULL_FETCH_SIZE = 500
-const COLUMN_STORAGE_KEY = 'drainage-sbyx-columns'
+const COLUMN_STORAGE_KEY = 'drainage-sbyx-columns-v2'
 
 // ==================== 字典 / 选项 ====================
-const currentStatusOptions = [
-  { label: '在用', value: 1 },
-  { label: '闲置', value: 2 },
-  { label: '维修', value: 3 },
-  { label: '报废', value: 4 },
-  { label: '待入库', value: 5 }
-]
-
 /** 所属片区,与 DistrictEnum、字典 drainage_district 同口径 */
 const districtOptions = [
   { label: '城南', value: 0 },
@@ -488,16 +453,6 @@ const orderLevelOptions = [
   { label: '低', value: 2 }
 ]
 
-function statusText(val) {
-  const hit = currentStatusOptions.find((item) => item.value === val)
-  return hit ? hit.label : '-'
-}
-
-function statusTagType(val) {
-  const map = { 1: 'success', 2: 'info', 3: 'warning', 4: 'danger', 5: '' }
-  return map[val] || 'info'
-}
-
 function districtLabel(val) {
   if (val === null || val === undefined || val === '') return '-'
   const hit = districtOptions.find((item) => String(item.value) === String(val))
@@ -509,11 +464,11 @@ function emptyQuery() {
   return {
     equipmentName: '',
     equipmentCode: '',
-    runStatus: '',
+    onlineStatus: '',
+    alarmOnly: false,
     equipmentTypeId: '',
     district: '',
     equipmentLocation: '',
-    currentStatus: '',
     pageNum: 1,
     pageSize: 10
   }
@@ -523,14 +478,6 @@ const query = ref(emptyQuery())
 const filterExpanded = ref(false)
 const typeOptions = ref([])
 
-/** 运行状态下拉 → 后端 onlineStatus / alarmStatus 两个参数,二者互斥 */
-function resolveStatusParams() {
-  if (query.value.runStatus === 'online') return { onlineStatus: 1 }
-  if (query.value.runStatus === 'offline') return { onlineStatus: 0 }
-  if (query.value.runStatus === 'alarm') return { alarmStatus: 1 }
-  return {}
-}
-
 function buildFilterParams() {
   return {
     rootTypeId: ROOT_TYPE_ID,
@@ -539,8 +486,9 @@ function buildFilterParams() {
     equipmentTypeId: query.value.equipmentTypeId || undefined,
     district: query.value.district === '' || query.value.district === null ? undefined : query.value.district,
     equipmentLocation: query.value.equipmentLocation || undefined,
-    currentStatus: query.value.currentStatus === '' ? undefined : query.value.currentStatus,
-    ...resolveStatusParams()
+    onlineStatus: query.value.onlineStatus === '' ? undefined : query.value.onlineStatus,
+    // 报警勾选框与「感知设备管理」同一口径:勾选即只看报警设备
+    alarmStatus: query.value.alarmOnly ? 1 : undefined
   }
 }
 
@@ -561,6 +509,8 @@ async function loadDevices() {
     const page = res?.data && Array.isArray(res.data.records) ? res.data : res
     tableData.value = page?.records || page?.rows || []
     total.value = page?.total || 0
+    // 实际数据与最后通讯取自设备档案,跟列表同一轮刷新,避免两批数据对不上
+    await loadProfilesFor(tableData.value, { force: true })
   } catch (error) {
     console.error('设备列表加载失败', error)
     tableData.value = []
@@ -616,27 +566,97 @@ async function loadStats() {
   }
 }
 
-// ==================== 实时监测值 / 报警阈值 ====================
-/** deviceCode -> { value, time } */
-const waterLevelMap = ref({})
-/** deviceCode -> 阈值配置列表 */
+// ==================== 实际数据 / 最后通讯(口径同远程测控) ====================
+/** equipmentId -> 设备档案(含 lastCommTime 与 recordView 采集记录) */
+const profileMap = ref({})
+/** 单次并发拉取的档案数:导出会跨页补档案,避免一次性打满后端 */
+const PROFILE_CONCURRENCY = 6
+/** deviceCode -> 阈值配置列表(仅详情抽屉与预警弹窗预填使用) */
 const thresholdMap = ref({})
 
-async function loadWaterLevels() {
-  try {
-    const res = await getLatestWaterLevelByDevice()
-    const list = Array.isArray(res) ? res : (res?.data || [])
-    const map = {}
-    list.forEach((item) => {
-      const code = item?.deviceCode
-      if (!code) return
-      map[code] = { value: item.waterLevel, time: item.createTime }
-    })
-    waterLevelMap.value = map
-  } catch (error) {
-    console.warn('液位实时数据加载失败', error)
-    waterLevelMap.value = {}
+function profileOf(row) {
+  if (!row?.equipmentId) return null
+  return profileMap.value[row.equipmentId] || null
+}
+
+/**
+ * 按设备档案补齐实时数据。已缓存的设备默认不再重复拉取,导出时可用 force 刷新。
+ */
+async function loadProfilesFor(rows, { force = false } = {}) {
+  const targets = (rows || []).filter(
+    (row) => row?.equipmentId && (force || !profileMap.value[row.equipmentId])
+  )
+  if (targets.length === 0) return
+  let cursor = 0
+  const worker = async () => {
+    while (cursor < targets.length) {
+      const row = targets[cursor]
+      cursor += 1
+      try {
+        const res = await getEquipmentProfile(row.equipmentId)
+        profileMap.value[row.equipmentId] = res?.data || null
+      } catch (error) {
+        console.warn('设备档案加载失败', row.equipmentId, error)
+        profileMap.value[row.equipmentId] = null
+      }
+    }
   }
+  await Promise.all(
+    Array.from({ length: Math.min(PROFILE_CONCURRENCY, targets.length) }, () => worker())
+  )
+}
+
+function latestRowOf(row) {
+  const rows = profileOf(row)?.recordView?.rows || []
+  return rows.length ? rows[0] : null
+}
+
+function columnLabelMap(profile) {
+  const map = {}
+  ;(profile?.recordView?.columns || []).forEach((col) => {
+    map[col.key] = col
+  })
+  return map
+}
+
+/** 实际数据:最新一条采集记录的指标拼成「指标:值+单位」,任一指标报警时整格标红 */
+function latestMetricsText(row) {
+  const first = latestRowOf(row)
+  if (!first) return '—'
+  const colMap = columnLabelMap(profileOf(row))
+  const text = (first.cells || [])
+    .filter((cell) => cell.key !== 'filepath')
+    .map((cell) => {
+      const col = colMap[cell.key]
+      const label = col?.label || cell.key
+      // value 为纯值,单位在列定义里;display 已含单位,仅在无有效纯值时兜底
+      const value = cell.value && cell.value !== '' && cell.value !== '—'
+        ? `${cell.value}${col?.unit || ''}`
+        : (cell.display || '—')
+      return `${label}:${value}`
+    })
+    .join('  ')
+  return text || '—'
+}
+
+function hasAlarmInLatest(row) {
+  const first = latestRowOf(row)
+  return (first?.cells || []).some((cell) => cell.alarm)
+}
+
+function lastCommOf(row) {
+  return profileOf(row)?.lastCommTime || ''
+}
+
+/** 预警弹窗「当前值」预填:取最新记录里第一个有效指标值 */
+function firstMetricValue(row) {
+  const first = latestRowOf(row)
+  const colMap = columnLabelMap(profileOf(row))
+  const cell = (first?.cells || []).find(
+    (item) => item.key !== 'filepath' && item.value && item.value !== '' && item.value !== '—'
+  )
+  if (!cell) return ''
+  return `${cell.value}${colMap[cell.key]?.unit || ''}`
 }
 
 async function loadThresholds() {
@@ -659,18 +679,6 @@ async function loadThresholds() {
   }
 }
 
-/** 液位值:空值返回 null,便于模板区分「没有数据」和「取了 0」 */
-function waterLevelOf(row) {
-  const hit = row?.equipmentCode ? waterLevelMap.value[row.equipmentCode] : null
-  if (!hit || hit.value === null || hit.value === undefined || hit.value === '') return null
-  return hit.value
-}
-
-function collectTimeOf(row) {
-  const hit = row?.equipmentCode ? waterLevelMap.value[row.equipmentCode] : null
-  return hit?.time || ''
-}
-
 /** 阈值文案:dataType 0 区间 / 1 仅下限 / 2 仅上限 */
 function formatThreshold(item) {
   if (!item) return '-'
@@ -683,13 +691,6 @@ function formatThreshold(item) {
   return `${min ?? '-'} ~ ${max ?? '-'}${unit}`
 }
 
-function thresholdText(code) {
-  const list = thresholdMap.value[code]
-  if (!list || list.length === 0) return ''
-  const first = formatThreshold(list[0])
-  return list.length > 1 ? `${first}(共 ${list.length} 项)` : first
-}
-
 // ==================== 列设置 ====================
 const columnDefs = [
   { key: 'equipmentName', label: '设备名称' },
@@ -698,11 +699,8 @@ const columnDefs = [
   { key: 'equipmentLocation', label: '安装位置' },
   { key: 'district', label: '所属片区' },
   { key: 'onlineStatus', label: '在线状态' },
-  { key: 'alarmStatus', label: '报警状态' },
-  { key: 'currentStatus', label: '设备状态' },
-  { key: 'waterLevel', label: '实时液位' },
-  { key: 'threshold', label: '报警阈值' },
-  { key: 'collectTime', label: '采集时间' }
+  { key: 'actualData', label: '实际数据' },
+  { key: 'lastComm', label: '最后通讯' }
 ]
 
 function defaultColumnKeys() {
@@ -1032,9 +1030,9 @@ function openWarningDialog(row) {
     userIds: []
   })
   warningFormRef.value?.clearValidate()
-  // 有实时液位就预填当前值,省一次手工输入
-  const level = waterLevelOf(rows[0])
-  if (level !== null) warningForm.currentValue = String(level)
+  // 有实时数据就预填当前值,省一次手工输入
+  const metric = firstMetricValue(rows[0])
+  if (metric) warningForm.currentValue = metric
   const threshold = thresholdMap.value[rows[0]?.equipmentCode]?.[0]
   if (threshold) warningForm.thresholdValue = formatThreshold(threshold)
   warningVisible.value = true
@@ -1185,11 +1183,12 @@ const EXPORT_PAGE_SIZE = 100
 
 const EXPORT_HEADER = [
   '设备名称', '设备编码', '设备类型', '安装位置', '所属片区',
-  '在线状态', '报警状态', '设备状态', '实时液位(m)', '报警阈值',
-  '采集时间', '权属单位', '维护人员'
+  '在线状态', '实际数据', '最后通讯',
+  '权属单位', '维护人员'
 ]
 
 function exportValues(row) {
+  const metrics = latestMetricsText(row)
   return [
     row.equipmentName || '',
     row.equipmentCode || '',
@@ -1197,11 +1196,8 @@ function exportValues(row) {
     row.equipmentLocation || '',
     districtLabel(row.district),
     row.onlineStatus === 1 ? '在线' : '离线',
-    row.alarmStatus === 1 ? '报警' : '正常',
-    statusText(row.currentStatus),
-    waterLevelOf(row) ?? '',
-    thresholdText(row.equipmentCode) || '未配置',
-    collectTimeOf(row) || '',
+    metrics === '—' ? '' : metrics,
+    lastCommOf(row),
     row.ownershipUnit || '',
     row.maintainer || ''
   ]
@@ -1247,11 +1243,13 @@ async function handleExport() {
       ElMessage.warning('当前条件下没有可导出的数据')
       return
     }
+    // 跨页导出时当前页之外的设备还没有档案,先补一次实际数据与最后通讯
+    await loadProfilesFor(rows)
     const sheet = XLSX.utils.aoa_to_sheet([EXPORT_HEADER, ...rows.map(exportValues)])
     sheet['!cols'] = [
       { wch: 22 }, { wch: 20 }, { wch: 20 }, { wch: 30 }, { wch: 10 },
-      { wch: 10 }, { wch: 10 }, { wch: 10 }, { wch: 12 }, { wch: 18 },
-      { wch: 20 }, { wch: 20 }, { wch: 12 }
+      { wch: 10 }, { wch: 40 }, { wch: 20 },
+      { wch: 20 }, { wch: 12 }
     ]
     const book = XLSX.utils.book_new()
     XLSX.utils.book_append_sheet(book, sheet, '设备运行实时监测')
@@ -1281,7 +1279,7 @@ async function loadTypeOptions() {
 }
 
 async function refreshAll() {
-  await Promise.all([loadDevices(), loadStats(), loadWaterLevels(), loadThresholds()])
+  await Promise.all([loadDevices(), loadStats(), loadThresholds()])
 }
 
 onMounted(() => {
@@ -1469,14 +1467,9 @@ onMounted(() => {
   font-weight: 400;
 }
 
-.metric-value {
+.cell-alarm {
+  color: #ef4444;
   font-weight: 600;
-  color: #0a1628;
-}
-
-.threshold-text {
-  color: #4e5969;
-  font-size: 13px;
 }
 
 .column-setting-head {