gaoluyang
2025-10-23 4d5e975ce21256b1ba64206c5deb79b94bceb0ba
设备监控前端页面
已添加1个文件
已修改1个文件
308 ■■■■■ 文件已修改
README.md 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/equipment/monitoring/equipment.vue 304 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
README.md
@@ -32,8 +32,8 @@
yarn dev
# æž„建测试环境 yarn build:stage
# æž„建生产环境 yarn build:prod
# æž„建生产环境 yarn build:prod -- --company="AAA"
# æž„建生产环境 yarn build
# æž„建生产环境 yarn build -- --company="AAA"
# å‰ç«¯è®¿é—®åœ°å€ http://localhost:80
```
src/views/equipment/monitoring/equipment.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,304 @@
<template>
  <div class="app-container">
    <el-row :gutter="12" class="mb12">
      <el-col :span="18">
        <el-card class="compact-card">
          <template #header>
            <div class="card-header">
              <span>设备实时监测</span>
              <div class="header-actions">
                <span class="mr8">告警通道:</span>
                <el-switch v-model="alarmChannels.platform" active-text="平台" @change="onSaveChannels" />
                <el-switch v-model="alarmChannels.sms" class="ml8" active-text="短信" @change="onSaveChannels" />
                <el-switch v-model="alarmChannels.voice" class="ml8" active-text="语音" @change="onSaveChannels" />
              </div>
            </div>
          </template>
          <el-table :data="equipmentRows" size="small" :border="true" :height="tableHeight">
            <el-table-column prop="name" label="设备" min-width="240" />
            <el-table-column prop="location" label="位置" min-width="200" />
            <el-table-column label="状态" width="110">
              <template #default="scope">
                <el-tag :type="statusTagType(scope.row.status)">{{ renderStatus(scope.row.status) }}</el-tag>
              </template>
            </el-table-column>
            <el-table-column label="温度(℃)" min-width="140">
              <template #default="scope">
                <span :class="overClass(scope.row, 'temperatureC')">{{ fmt(scope.row.metrics.temperatureC) }}</span>
              </template>
            </el-table-column>
            <el-table-column label="压力(bar)" min-width="140">
              <template #default="scope">
                <span :class="overClass(scope.row, 'pressureBar')">{{ fmt(scope.row.metrics.pressureBar) }}</span>
              </template>
            </el-table-column>
            <el-table-column label="电流(A)" min-width="140">
              <template #default="scope">
                <span :class="overClass(scope.row, 'currentA')">{{ fmt(scope.row.metrics.currentA) }}</span>
              </template>
            </el-table-column>
            <el-table-column label="电压(V)" min-width="140">
              <template #default="scope">
                <span :class="overClass(scope.row, 'voltageV')">{{ fmt(scope.row.metrics.voltageV) }}</span>
              </template>
            </el-table-column>
            <el-table-column label="功率因数" min-width="160">
              <template #default="scope">
                <span :class="overClass(scope.row, 'powerFactor', true)">{{ fmt(scope.row.metrics.powerFactor) }}</span>
              </template>
            </el-table-column>
            <el-table-column label="控制" width="220">
              <template #default="scope">
                <el-button
                  type="success"
                  size="small"
                  plain
                  icon="VideoPlay"
                  @click="onControl(scope.row, 'START')"
                  v-hasPermi="['monitor:equipment:control']"
                >启用</el-button>
                <el-button
                  type="danger"
                  size="small"
                  plain
                  icon="VideoPause"
                  @click="onControl(scope.row, 'STOP')"
                  v-hasPermi="['monitor:equipment:control']"
                >停机</el-button>
              </template>
            </el-table-column>
          </el-table>
        </el-card>
      </el-col>
      <el-col :span="6">
        <el-card class="mb12 compact-card">
          <template #header>
            <span>阈值设置</span>
          </template>
          <el-form label-width="100px" :inline="false" size="small">
            <el-form-item label="温度上限(℃)">
              <el-input-number v-model="thresholds.temperatureC" :min="20" :max="150" :step="1" />
            </el-form-item>
            <el-form-item label="压力上限(bar)">
              <el-input-number v-model="thresholds.pressureBar" :min="0" :max="40" :step="0.5" />
            </el-form-item>
            <el-form-item label="电流上限(A)">
              <el-input-number v-model="thresholds.currentA" :min="1" :max="500" :step="1" />
            </el-form-item>
            <el-form-item label="电压上限(V)">
              <el-input-number v-model="thresholds.voltageV" :min="360" :max="500" :step="1" />
            </el-form-item>
            <el-form-item label="功率因数下限">
              <el-input-number v-model="thresholds.powerFactor" :min="0.5" :max="1" :step="0.01" :precision="2" />
            </el-form-item>
            <el-form-item>
              <el-button type="primary" icon="Check" @click="onSaveThresholds">保存</el-button>
              <el-button type="warning" icon="Refresh" @click="onResetThresholds">重置</el-button>
            </el-form-item>
          </el-form>
        </el-card>
        <el-card class="compact-card">
          <template #header>
            <span>告警事件</span>
          </template>
          <el-scrollbar max-height="480px">
            <el-empty v-if="alarmEvents.length === 0" description="暂无告警" />
            <el-timeline v-else>
              <el-timeline-item
                v-for="(ev, idx) in alarmEvents"
                :key="idx"
                :timestamp="formatTs(ev.ts)"
                :type="ev.level === 'CRITICAL' ? 'danger' : 'warning'"
              >
                <div class="alarm-entry">
                  <div class="alarm-title">{{ ev.name }}({{ ev.location }})</div>
                  <div class="alarm-body">
                    <div>指标:{{ renderField(ev.field) }} = {{ ev.value }},阈值 {{ ev.comparator }} {{ ev.threshold }}</div>
                    <div>通道:{{ renderChannels(ev.channels) }}</div>
                  </div>
                </div>
              </el-timeline-item>
            </el-timeline>
          </el-scrollbar>
        </el-card>
      </el-col>
    </el-row>
  </div>
</template>
<script setup>
import { ElNotification } from 'element-plus'
import { onMounted, onBeforeUnmount, reactive, ref, computed } from 'vue'
import {
  subscribeEquipmentData,
  detectAlarms,
  getThresholds,
  saveThresholds,
  getAlarmChannels,
  saveAlarmChannels,
  listEquipments,
  sendControlCommand
} from '@/api/equipment/monitoring/equipment.js'
const thresholds = reactive(getThresholds())
const alarmChannels = reactive(getAlarmChannels())
const equipmentDataMap = reactive(new Map())
const equipmentRows = computed(() => Array.from(equipmentDataMap.values()))
const alarmEvents = ref([])
let unsubscribe = null
const tableHeight = ref(520)
function updateHeights() {
  // é¢„留顶部导航、卡片头、内边距等空间,避免出现外部滚动条
  const reserve = 260
  const h = window.innerHeight - reserve
  tableHeight.value = Math.max(420, h)
}
function pushAlarm(equipmentRow, overItem) {
  const channelUsed = { ...alarmChannels }
  const event = {
    ts: Date.now(),
    equipmentId: equipmentRow.id,
    name: equipmentRow.name,
    location: equipmentRow.location,
    field: overItem.field,
    value: +overItem.value.toFixed ? +overItem.value.toFixed(2) : overItem.value,
    threshold: overItem.threshold,
    comparator: overItem.field === 'powerFactor' ? '<' : '>',
    level: 'CRITICAL',
    channels: channelUsed
  }
  alarmEvents.value.unshift(event)
  if (alarmEvents.value.length > 100) alarmEvents.value.pop()
  if (channelUsed.platform) {
    ElNotification({ title: '设备告警', message: `${event.name} ${renderField(event.field)} è¶…限`, type: 'error', duration: 3000 })
  }
  if (channelUsed.sms) {
    /* eslint-disable no-console */
    console.log(`[SMS] å‘送至值班员:${event.name} ${renderField(event.field)} è¶…限`)
  }
  if (channelUsed.voice) {
    /* eslint-disable no-console */
    console.log(`[VOICE] IVR外呼:${event.name} ${renderField(event.field)} è¶…限`)
  }
}
function startStream() {
  const baseList = listEquipments()
  baseList.forEach(e => {
    equipmentDataMap.set(e.id, {
      id: e.id,
      name: e.name,
      location: e.location,
      status: 'RUNNING',
      metrics: { temperatureC: 0, pressureBar: 0, currentA: 0, voltageV: 0, powerFactor: 0 }
    })
  })
  unsubscribe = subscribeEquipmentData((msg) => {
    const row = equipmentDataMap.get(msg.equipmentId)
    if (!row) return
    row.status = msg.status
    row.metrics = msg.metrics
    // å‘Šè­¦æ£€æµ‹
    const overs = detectAlarms(row.metrics, thresholds)
    overs.forEach(item => pushAlarm(row, item))
  }, { streaming: false })
}
function onSaveThresholds() {
  saveThresholds({ ...thresholds })
  ElNotification({ title: '保存成功', message: '阈值已更新', type: 'success' })
}
function onResetThresholds() {
  const latest = getThresholds()
  Object.assign(thresholds, latest)
}
function onSaveChannels() {
  saveAlarmChannels({ ...alarmChannels })
}
async function onControl(row, action) {
  const res = await sendControlCommand(row.id, action)
  if (res && res.code === 200) {
    ElNotification({ title: '控制指令下发', message: `${row.name} ${action === 'START' ? '启用' : '停机'} æŒ‡ä»¤å·²å—理`, type: 'success' })
  }
}
function statusTagType(status) {
  if (status === 'RUNNING') return 'success'
  if (status === 'STOPPED') return 'info'
  return 'warning'
}
function renderStatus(status) {
  if (status === 'RUNNING') return '运行'
  if (status === 'STOPPED') return '停机'
  return '异常'
}
function fmt(v) {
  if (v === null || v === undefined) return '-'
  return typeof v === 'number' ? v.toFixed(2) : v
}
function overClass(row, field, reverse = false) {
  const v = row.metrics[field]
  const t = thresholds[field]
  if (v === undefined || t === undefined) return ''
  const over = reverse ? v < t : v > t
  return over ? 'text-danger' : ''
}
function renderField(field) {
  switch (field) {
    case 'temperatureC': return '温度'
    case 'pressureBar': return '压力'
    case 'currentA': return '电流'
    case 'voltageV': return '电压'
    case 'powerFactor': return '功率因数'
    default: return field
  }
}
function formatTs(ts) {
  const d = new Date(ts)
  const p2 = (n) => n.toString().padStart(2, '0')
  return `${d.getFullYear()}-${p2(d.getMonth()+1)}-${p2(d.getDate())} ${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`
}
onMounted(() => {
  startStream()
  updateHeights()
  window.addEventListener('resize', updateHeights)
})
onBeforeUnmount(() => {
  if (typeof unsubscribe === 'function') unsubscribe()
  window.removeEventListener('resize', updateHeights)
})
</script>
<style scoped>
.mb12 { margin-bottom: 12px; }
.mr8 { margin-right: 8px; }
.ml8 { margin-left: 8px; }
.card-header { display: flex; align-items: center; justify-content: space-between; }
.header-actions { display: flex; align-items: center; }
.alarm-entry { line-height: 1.6; }
.alarm-title { font-weight: 600; margin-bottom: 2px; }
.text-danger { color: #f56c6c; font-weight: 600; }
.compact-card :deep(.el-card__body) { padding: 12px; }
:deep(.el-table__cell) { padding: 6px 8px; }
:deep(.el-form-item) { margin-bottom: 8px; }
</style>