<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>
|