<template>
|
<div class="app-container iot-monitor">
|
<div class="header">
|
<div class="title">实时工况监控(IoT)</div>
|
<div class="actions">
|
<el-button type="primary" @click="toggleCollecting">{{ collecting ? '暂停采集' : '启动采集' }}</el-button>
|
<el-button @click="resetAll">重置</el-button>
|
<span class="ts">上次更新时间:{{ lastUpdatedDisplay }}</span>
|
</div>
|
</div>
|
|
<!-- <el-alert-->
|
<!-- title="边缘预警规则:轴承磨损-振动值偏离基线±5%触发告警;温度/压力越界触发提醒"-->
|
<!-- type="info"-->
|
<!-- :closable="false"-->
|
<!-- show-icon-->
|
<!-- class="rule-alert"-->
|
<!-- />-->
|
|
<el-row :gutter="16">
|
<el-col v-for="dev in devices" :key="dev.id" :span="12">
|
<el-card :class="['device-card', dev.hasAlert ? 'is-alert' : '']">
|
<template #header>
|
<div class="card-header">
|
<div class="card-title">
|
<span class="device-name">{{ dev.name }}</span>
|
<el-tag :type="dev.hasAlert ? 'danger' : 'success'" size="small">{{ dev.hasAlert ? '告警' : '正常' }}</el-tag>
|
</div>
|
<div class="meta">类型:{{ dev.type }}|基线振动:{{ dev.baseline.vibration.toFixed(2) }} mm/s</div>
|
</div>
|
</template>
|
|
<div class="metrics">
|
<div class="metric" :class="{ 'metric-alert': dev.alerts.vibration }">
|
<div class="metric-head">
|
<span>振动(mm/s)</span>
|
<el-tag :type="dev.alerts.vibration ? 'danger' : 'info'" size="small">{{ dev.alerts.vibration ? '±5%越界' : '基线±5%' }}</el-tag>
|
</div>
|
<div class="metric-value">{{ currentValue(dev.series.vibration).toFixed(2) }}</div>
|
<Echarts
|
:xAxis="[{ type: 'category', data: xAxisLabels }]"
|
:yAxis="[{ type: 'value', name: 'mm/s' }]"
|
:series="[{ type: 'line', smooth: true, showSymbol: false, data: dev.series.vibration }]"
|
:tooltip="{ trigger: 'axis' }"
|
:grid="{ left: 40, right: 10, top: 10, bottom: 20 }"
|
:chartStyle="{ height: '160px', width: '100%' }"
|
:lineColors="['#409EFF']"
|
/>
|
</div>
|
|
<div class="metric" :class="{ 'metric-alert': dev.alerts.temperature }">
|
<div class="metric-head">
|
<span>温度(°C)</span>
|
<el-tag :type="dev.alerts.temperature ? 'warning' : 'info'" size="small">{{ dev.alerts.temperature ? '越界' : '20~80' }}</el-tag>
|
</div>
|
<div class="metric-value">{{ currentValue(dev.series.temperature).toFixed(1) }}</div>
|
<Echarts
|
:xAxis="[{ type: 'category', data: xAxisLabels }]"
|
:yAxis="[{ type: 'value', name: '°C' }]"
|
:series="[{ type: 'line', smooth: true, showSymbol: false, data: dev.series.temperature }]"
|
:tooltip="{ trigger: 'axis' }"
|
:grid="{ left: 40, right: 10, top: 10, bottom: 20 }"
|
:chartStyle="{ height: '160px', width: '100%' }"
|
:lineColors="['#E6A23C']"
|
/>
|
</div>
|
|
<div class="metric" :class="{ 'metric-alert': dev.alerts.pressure }">
|
<div class="metric-head">
|
<span>压力(MPa)</span>
|
<el-tag :type="dev.alerts.pressure ? 'warning' : 'info'" size="small">{{ dev.alerts.pressure ? '越界' : '0.2~1.5' }}</el-tag>
|
</div>
|
<div class="metric-value">{{ currentValue(dev.series.pressure).toFixed(2) }}</div>
|
<Echarts
|
:xAxis="[{ type: 'category', data: xAxisLabels }]"
|
:yAxis="[{ type: 'value', name: 'MPa' }]"
|
:series="[{ type: 'line', smooth: true, showSymbol: false, data: dev.series.pressure }]"
|
:tooltip="{ trigger: 'axis' }"
|
:grid="{ left: 40, right: 10, top: 10, bottom: 20 }"
|
:chartStyle="{ height: '160px', width: '100%' }"
|
:lineColors="['#67C23A']"
|
/>
|
</div>
|
</div>
|
</el-card>
|
</el-col>
|
</el-row>
|
</div>
|
|
</template>
|
|
<script setup>
|
import { ref, reactive, onMounted, onBeforeUnmount, computed } from 'vue'
|
import { ElNotification } from 'element-plus'
|
import Echarts from '@/components/Echarts/echarts.vue'
|
|
defineOptions({ name: 'IoTMonitor' })
|
|
const windowSize = 30
|
const collecting = ref(true)
|
const lastUpdated = ref(Date.now())
|
const lastUpdatedDisplay = computed(() => new Date(lastUpdated.value).toLocaleTimeString())
|
|
const xAxisLabels = ref(Array.from({ length: windowSize }, (_, i) => i - (windowSize - 1)).map(n => `${n}s`))
|
|
function makeSeries(fill, decimals = 2) {
|
return Array.from({ length: windowSize }, () => Number(fill.toFixed(decimals)))
|
}
|
|
const devices = reactive([
|
{
|
id: 'water-pump',
|
name: '注水泵1',
|
type: '移动装备',
|
baseline: { vibration: 9 },
|
initial: { temperature: 40, pressure: 0.70 },
|
alerts: { vibration: false, temperature: false, pressure: false },
|
hasAlert: false,
|
series: {
|
vibration: makeSeries(9),
|
temperature: makeSeries(40, 1),
|
pressure: makeSeries(0.7, 2),
|
},
|
},
|
{
|
id: 'fluid-supply-truck',
|
name: '注水泵2',
|
type: '移动装备',
|
baseline: { vibration: 7 },
|
initial: { temperature: 30, pressure: 0.60 },
|
alerts: { vibration: false, temperature: false, pressure: false },
|
hasAlert: false,
|
series: {
|
vibration: makeSeries(7),
|
temperature: makeSeries(30, 1),
|
pressure: makeSeries(0.6, 2),
|
},
|
},
|
{
|
id: 'fracturing-truck',
|
name: '注水泵3',
|
type: '移动装备',
|
baseline: { vibration: 12 },
|
initial: { temperature: 65, pressure: 1.40 },
|
alerts: { vibration: false, temperature: false, pressure: false },
|
hasAlert: false,
|
series: {
|
vibration: makeSeries(12),
|
temperature: makeSeries(65, 1),
|
pressure: makeSeries(1.4, 2),
|
},
|
},
|
{
|
id: 'oil-tank-truck',
|
name: '注水泵4',
|
type: '移动装备',
|
baseline: { vibration: 6 },
|
initial: { temperature: 28, pressure: 0.50 },
|
alerts: { vibration: false, temperature: false, pressure: false },
|
hasAlert: false,
|
series: {
|
vibration: makeSeries(6),
|
temperature: makeSeries(28, 1),
|
pressure: makeSeries(0.5, 2),
|
},
|
},
|
])
|
|
function currentValue(arr) {
|
return arr[arr.length - 1] ?? 0
|
}
|
|
function pushWindow(arr, val) {
|
if (arr.length >= windowSize) arr.shift()
|
arr.push(val)
|
}
|
|
function clamp(val, min, max) { return Math.max(min, Math.min(max, val)) }
|
|
function tickDevice(dev) {
|
const vibBase = dev.baseline.vibration
|
// 振动:基线±2%随机波动;5%概率触发8%~12%尖峰模拟告警
|
const spike = Math.random() < 0.05
|
const vibNoise = vibBase * (spike ? (1 + (Math.random() * 0.08 + 0.04) * (Math.random() < 0.5 ? -1 : 1)) : (1 + (Math.random() - 0.5) * 0.04))
|
const vibVal = Number(vibNoise.toFixed(2))
|
pushWindow(dev.series.vibration, vibVal)
|
|
// 温度:缓慢随机游走,并添加偶发高温偏移
|
const tPrev = currentValue(dev.series.temperature)
|
const tDrift = tPrev + (Math.random() - 0.5) * 0.8 + (Math.random() < 0.02 ? 6 : 0)
|
const tVal = Number(clamp(tDrift, 15, 95).toFixed(1))
|
pushWindow(dev.series.temperature, tVal)
|
|
// 压力:小幅波动,偶发低压/高压
|
const pPrev = currentValue(dev.series.pressure)
|
const pDrift = pPrev + (Math.random() - 0.5) * 0.05 + (Math.random() < 0.02 ? (Math.random() < 0.5 ? -0.3 : 0.3) : 0)
|
const pVal = Number(clamp(pDrift, 0.05, 2.0).toFixed(2))
|
pushWindow(dev.series.pressure, pVal)
|
|
// 边缘计算阈值判断
|
const vibDelta = Math.abs(vibVal - vibBase) / vibBase
|
const vibAlert = vibDelta > 0.05
|
const tAlert = tVal < 20 || tVal > 80
|
const pAlert = pVal < 0.2 || pVal > 1.5
|
|
const prevHasAlert = dev.hasAlert
|
dev.alerts.vibration = vibAlert
|
dev.alerts.temperature = tAlert
|
dev.alerts.pressure = pAlert
|
dev.hasAlert = vibAlert || tAlert || pAlert
|
|
if (dev.hasAlert && !prevHasAlert) {
|
const reasons = []
|
if (vibAlert) reasons.push(`振动偏离±5% (当前 ${vibVal} / 基线 ${vibBase})`)
|
if (tAlert) reasons.push(`温度越界 (当前 ${tVal}°C, 期望 20~80°C) `)
|
if (pAlert) reasons.push(`压力越界 (当前 ${pVal}MPa, 期望 0.2~1.5MPa) `)
|
ElNotification({
|
title: `${dev.name} 告警`,
|
message: reasons.join(';'),
|
type: vibAlert ? 'error' : 'warning',
|
duration: 5000,
|
})
|
}
|
}
|
|
let timer = null
|
function start() {
|
if (timer) return
|
timer = setInterval(() => {
|
if (!collecting.value) return
|
devices.forEach(tickDevice)
|
lastUpdated.value = Date.now()
|
}, 10000)
|
}
|
|
function stop() {
|
if (timer) {
|
clearInterval(timer)
|
timer = null
|
}
|
}
|
|
function toggleCollecting() { collecting.value = !collecting.value }
|
|
function resetAll() {
|
devices.forEach(dev => {
|
dev.series.vibration = makeSeries(dev.baseline.vibration)
|
const t0 = dev.initial?.temperature ?? 45
|
const p0 = dev.initial?.pressure ?? 0.8
|
dev.series.temperature = makeSeries(t0, 1)
|
dev.series.pressure = makeSeries(p0, 2)
|
dev.alerts.vibration = false
|
dev.alerts.temperature = false
|
dev.alerts.pressure = false
|
dev.hasAlert = false
|
})
|
lastUpdated.value = Date.now()
|
}
|
|
onMounted(() => {
|
start()
|
})
|
|
onBeforeUnmount(() => {
|
stop()
|
})
|
</script>
|
|
<style lang="scss" scoped>
|
.iot-monitor {
|
.header {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
margin-bottom: 12px;
|
.title { font-size: 18px; font-weight: 600; }
|
.actions { display: flex; align-items: center; gap: 8px; }
|
.ts { color: #909399; font-size: 12px; }
|
}
|
.rule-alert { margin-bottom: 12px; }
|
}
|
|
.device-card {
|
margin-bottom: 16px;
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
&.is-alert { border-color: #F56C6C; box-shadow: 0 0 0 2px rgba(245,108,108,0.2) inset; }
|
.card-header {
|
display: flex; flex-direction: column; gap: 4px;
|
.card-title { display: flex; align-items: center; gap: 8px; font-weight: 600; }
|
.meta { color: #909399; font-size: 12px; }
|
}
|
.metrics {
|
display: grid;
|
grid-template-columns: 1fr;
|
gap: 12px;
|
}
|
}
|
|
.metric {
|
border: 1px solid #ebeef5;
|
border-radius: 6px;
|
padding: 8px 8px 0 8px;
|
&-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; font-size: 13px; color: #606266; }
|
&-value { font-size: 20px; font-weight: 600; margin: 2px 0 6px 0; }
|
}
|
|
.metric-alert {
|
border-color: #F56C6C;
|
background: #FFF6F6;
|
}
|
|
@media (min-width: 1200px) {
|
.device-card .metrics { grid-template-columns: 1fr 1fr 1fr; }
|
}
|
</style>
|