| | |
| | | <span>设备实时数据</span> |
| | | </div> |
| | | </template> |
| | | <el-row :gutter="20"> |
| | | <el-col :span="8"> |
| | | <el-card class="realtime-data" shadow="hover"> |
| | | <template #header> |
| | | <span>实时分钟数据</span> |
| | | </template> |
| | | <el-descriptions :column="1" v-if="currentDevice"> |
| | | <el-descriptions-item label="设备编号">{{ |
| | | currentDevice.deviceId |
| | | }}</el-descriptions-item> |
| | | <el-descriptions-item label="设备供应商">{{ |
| | | currentDevice.supplier |
| | | }}</el-descriptions-item> |
| | | <el-descriptions-item label="油烟浓度" |
| | | >{{ currentDevice.油烟浓度 }} mg/m³</el-descriptions-item |
| | | <el-space v-if="devices && devices.length > 0" justify="center" wrap> |
| | | <el-card |
| | | v-for="(device, index) in devices" |
| | | :key="device.deviceId" |
| | | class="device-card" |
| | | shadow="hover" |
| | | :class="{ 'abnormal-device': isDeviceAbnormal(device) }" |
| | | > |
| | | <template #header> |
| | | <div class="device-header"> |
| | | <span class="device-id">{{ device.deviceId }}</span> |
| | | <div class="device-status"> |
| | | <el-icon v-if="device.status === '正常'" class="status-icon normal-icon" |
| | | ><iconify-icon icon="mdi:check-circle" |
| | | /></el-icon> |
| | | <el-icon v-else class="status-icon abnormal-icon" |
| | | ><iconify-icon icon="mdi:alert-circle" |
| | | /></el-icon> |
| | | <span>{{ device.status }}</span> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <!-- 实时分钟数据 --> |
| | | <div class="realtime-data"> |
| | | <div class="basic-info"> |
| | | <span class="supplier">{{ device.supplier }}</span> |
| | | <span class="monitor-time">{{ |
| | | device.monitorTime || new Date().toLocaleString() |
| | | }}</span> |
| | | </div> |
| | | <div class="monitor-values"> |
| | | <div |
| | | class="value-item" |
| | | :class="{ 'abnormal-value': isAbnormal('smokeDensity', device.smokeDensity) }" |
| | | > |
| | | <el-descriptions-item label="风机电流" |
| | | >{{ currentDevice.风机电流 }} A</el-descriptions-item |
| | | <span class="value-label">油烟浓度</span> |
| | | <span class="value">{{ device.smokeDensity }} <span class="unit">mg/m³</span></span> |
| | | </div> |
| | | <div |
| | | class="value-item" |
| | | :class="{ 'abnormal-value': isAbnormal('fanCurrent', device.fanCurrent) }" |
| | | > |
| | | <el-descriptions-item label="净化器电流" |
| | | >{{ currentDevice.净化器电流 }} A</el-descriptions-item |
| | | <span class="value-label">风机电流</span> |
| | | <span class="value">{{ device.fanCurrent }} <span class="unit">A</span></span> |
| | | </div> |
| | | <div |
| | | class="value-item" |
| | | :class="{ 'abnormal-value': isAbnormal('purifierCurrent', device.purifierCurrent) }" |
| | | > |
| | | </el-descriptions> |
| | | </el-card> |
| | | </el-col> |
| | | <el-col :span="16"> |
| | | <el-card class="realtime-chart" shadow="hover"> |
| | | <template #header> |
| | | <span>近一小时数据</span> |
| | | </template> |
| | | <div ref="hourlyChart" class="chart-container"></div> |
| | | </el-card> |
| | | </el-col> |
| | | </el-row> |
| | | <span class="value-label">净化器电流</span> |
| | | <span class="value">{{ device.purifierCurrent }} <span class="unit">A</span></span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 近一小时数据 --> |
| | | <div class="hourly-charts"> |
| | | <el-popover |
| | | placement="left" |
| | | :width="600" |
| | | trigger="click" |
| | | @show="() => initDeviceCharts(device)" |
| | | > |
| | | <template #reference> |
| | | <div class="chart-header"> |
| | | <span class="date">{{ new Date().toLocaleDateString() }}</span> |
| | | <el-button size="small" type="primary" link> 查看近一小时数据 </el-button> |
| | | </div> |
| | | </template> |
| | | <div class="popover-content"> |
| | | <div class="popover-header"> |
| | | <h3>{{ device.deviceId }} 近一小时数据</h3> |
| | | </div> |
| | | <div ref="charts" :key="device.deviceId" class="charts-container"> |
| | | <div class="chart-item"> |
| | | <div class="chart-title">油烟浓度(mg/m³)</div> |
| | | <div ref="smokeChart" :data-device-id="device.deviceId" class="small-chart"></div> |
| | | </div> |
| | | <div class="chart-item"> |
| | | <div class="chart-title">风机电流(A)</div> |
| | | <div ref="fanChart" :data-device-id="device.deviceId" class="small-chart"></div> |
| | | </div> |
| | | <div class="chart-item"> |
| | | <div class="chart-title">净化器电流(A)</div> |
| | | <div |
| | | ref="purifierChart" |
| | | :data-device-id="device.deviceId" |
| | | class="small-chart" |
| | | ></div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </el-popover> |
| | | </div> |
| | | </el-card> |
| | | </el-space> |
| | | <div v-else class="no-data"> |
| | | <el-empty description="暂无设备数据" /> |
| | | </div> |
| | | </el-card> |
| | | </template> |
| | | |
| | | <script> |
| | | import * as echarts from 'echarts' |
| | | import { Icon } from '@iconify/vue' |
| | | |
| | | export default { |
| | | name: 'RealTimeData', |
| | | components: { |
| | | IconifyIcon: Icon, |
| | | }, |
| | | props: { |
| | | currentDevice: { |
| | | type: Object, |
| | | default: null, |
| | | }, |
| | | hourlyData: { |
| | | devices: { |
| | | type: Array, |
| | | default: () => [], |
| | | }, |
| | | }, |
| | | data() { |
| | | return { |
| | | hourlyChart: null, |
| | | charts: {}, |
| | | } |
| | | }, |
| | | mounted() { |
| | | this.initChart() |
| | | // 不需要自动初始化图表,只在弹出框显示时初始化 |
| | | }, |
| | | beforeUnmount() { |
| | | if (this.hourlyChart) { |
| | | this.hourlyChart.dispose() |
| | | } |
| | | Object.values(this.charts).forEach((chart) => { |
| | | if (chart) { |
| | | chart.dispose() |
| | | } |
| | | }) |
| | | }, |
| | | watch: { |
| | | hourlyData() { |
| | | this.updateChart() |
| | | devices: { |
| | | handler(newDevices) { |
| | | // 清除旧图表 |
| | | Object.values(this.charts).forEach((chart) => { |
| | | if (chart) { |
| | | chart.dispose() |
| | | } |
| | | }) |
| | | this.charts = {} |
| | | }, |
| | | deep: true, |
| | | }, |
| | | }, |
| | | methods: { |
| | | initChart() { |
| | | this.hourlyChart = echarts.init(this.$refs.hourlyChart) |
| | | this.updateChart() |
| | | initDeviceCharts(device) { |
| | | const deviceId = device.deviceId |
| | | |
| | | window.addEventListener('resize', () => { |
| | | this.hourlyChart.resize() |
| | | // 清除该设备的旧图表 |
| | | Object.keys(this.charts).forEach((key) => { |
| | | if (key.startsWith(deviceId)) { |
| | | this.charts[key].dispose() |
| | | delete this.charts[key] |
| | | } |
| | | }) |
| | | |
| | | this.$nextTick(() => { |
| | | // 油烟浓度图表 |
| | | const smokeChartEl = document.querySelector(`[data-device-id="${deviceId}"]`) |
| | | if (smokeChartEl) { |
| | | this.charts[`${deviceId}_smoke`] = echarts.init(smokeChartEl) |
| | | } |
| | | |
| | | // 风机电流图表 |
| | | const fanChartEl = document.querySelectorAll(`[data-device-id="${deviceId}"]`)[1] |
| | | if (fanChartEl) { |
| | | this.charts[`${deviceId}_fan`] = echarts.init(fanChartEl) |
| | | } |
| | | |
| | | // 净化器电流图表 |
| | | const purifierChartEl = document.querySelectorAll(`[data-device-id="${deviceId}"]`)[2] |
| | | if (purifierChartEl) { |
| | | this.charts[`${deviceId}_purifier`] = echarts.init(purifierChartEl) |
| | | } |
| | | |
| | | this.updateDeviceCharts(device) |
| | | }) |
| | | }, |
| | | updateChart() { |
| | | if (!this.hourlyChart) return |
| | | updateCharts() { |
| | | this.devices.forEach((device) => { |
| | | this.updateDeviceCharts(device) |
| | | }) |
| | | }, |
| | | updateDeviceCharts(device) { |
| | | const deviceId = device.deviceId |
| | | const deviceHourlyData = device.hourlyData || [] |
| | | |
| | | const option = { |
| | | const _baseOption = { |
| | | tooltip: { |
| | | trigger: 'axis', |
| | | axisPointer: { |
| | | type: 'cross', |
| | | label: { |
| | | backgroundColor: '#6a7985', |
| | | }, |
| | | }, |
| | | }, |
| | | legend: { |
| | | data: ['油烟浓度', '风机电流', '净化器电流'], |
| | | }, |
| | | grid: { |
| | | left: '3%', |
| | | right: '4%', |
| | | bottom: '3%', |
| | | left: '0%', |
| | | right: '0%', |
| | | top: '10%', |
| | | bottom: '0%', |
| | | containLabel: true, |
| | | }, |
| | | xAxis: { |
| | | type: 'category', |
| | | boundaryGap: false, |
| | | data: this.hourlyData.map((item) => item.time), |
| | | data: deviceHourlyData.map((item) => item.time), |
| | | axisLabel: { |
| | | fontSize: 10, |
| | | }, |
| | | }, |
| | | yAxis: [ |
| | | { |
| | | type: 'value', |
| | | name: '油烟浓度 (mg/m³)', |
| | | position: 'left', |
| | | }, |
| | | { |
| | | type: 'value', |
| | | name: '电流 (A)', |
| | | position: 'right', |
| | | }, |
| | | ], |
| | | series: [ |
| | | { |
| | | name: '油烟浓度', |
| | | type: 'line', |
| | | data: this.hourlyData.map((item) => parseFloat(item.油烟浓度)), |
| | | yAxisIndex: 0, |
| | | }, |
| | | { |
| | | name: '风机电流', |
| | | type: 'line', |
| | | data: this.hourlyData.map((item) => parseFloat(item.风机电流)), |
| | | yAxisIndex: 1, |
| | | }, |
| | | { |
| | | name: '净化器电流', |
| | | type: 'line', |
| | | data: this.hourlyData.map((item) => parseFloat(item.净化器电流)), |
| | | yAxisIndex: 1, |
| | | }, |
| | | ], |
| | | } |
| | | |
| | | this.hourlyChart.setOption(option) |
| | | // 更新油烟浓度图表 |
| | | if (this.charts[`${deviceId}_smoke`]) { |
| | | const smokeOption = { |
| | | ..._baseOption, |
| | | yAxis: { |
| | | type: 'value', |
| | | name: 'mg/m³', |
| | | // nameLocation: 'middle', |
| | | // nameGap: 30, |
| | | axisLabel: { |
| | | fontSize: 10, |
| | | }, |
| | | }, |
| | | series: [ |
| | | { |
| | | name: '油烟浓度', |
| | | type: 'line', |
| | | data: deviceHourlyData.map((item) => parseFloat(item.smokeDensity)), |
| | | smooth: true, |
| | | }, |
| | | ], |
| | | } |
| | | this.charts[`${deviceId}_smoke`].setOption(smokeOption) |
| | | } |
| | | |
| | | // 更新风机电流图表 |
| | | if (this.charts[`${deviceId}_fan`]) { |
| | | const fanOption = { |
| | | ..._baseOption, |
| | | yAxis: { |
| | | type: 'value', |
| | | name: 'A', |
| | | // nameLocation: 'middle', |
| | | // nameGap: 30, |
| | | axisLabel: { |
| | | fontSize: 10, |
| | | }, |
| | | }, |
| | | series: [ |
| | | { |
| | | name: '风机电流', |
| | | type: 'line', |
| | | data: deviceHourlyData.map((item) => parseFloat(item.fanCurrent)), |
| | | smooth: true, |
| | | }, |
| | | ], |
| | | } |
| | | this.charts[`${deviceId}_fan`].setOption(fanOption) |
| | | } |
| | | |
| | | // 更新净化器电流图表 |
| | | if (this.charts[`${deviceId}_purifier`]) { |
| | | const purifierOption = { |
| | | ..._baseOption, |
| | | yAxis: { |
| | | type: 'value', |
| | | name: 'A', |
| | | // nameLocation: 'middle', |
| | | // nameGap: 30, |
| | | axisLabel: { |
| | | fontSize: 10, |
| | | }, |
| | | }, |
| | | series: [ |
| | | { |
| | | name: '净化器电流', |
| | | type: 'line', |
| | | data: deviceHourlyData.map((item) => parseFloat(item.purifierCurrent)), |
| | | smooth: true, |
| | | }, |
| | | ], |
| | | } |
| | | this.charts[`${deviceId}_purifier`].setOption(purifierOption) |
| | | } |
| | | }, |
| | | isDeviceAbnormal(device) { |
| | | return ( |
| | | this.isAbnormal('smokeDensity', device.smokeDensity) || |
| | | this.isAbnormal('fanCurrent', device.fanCurrent) || |
| | | this.isAbnormal('purifierCurrent', device.purifierCurrent) |
| | | ) |
| | | }, |
| | | isAbnormal(type, value) { |
| | | // 这里可以根据实际情况定义异常值判断逻辑 |
| | | const thresholds = { |
| | | smokeDensity: 10, |
| | | fanCurrent: 5, |
| | | purifierCurrent: 3, |
| | | } |
| | | return parseFloat(value) > (thresholds[type] || 0) |
| | | }, |
| | | }, |
| | | } |
| | | </script> |
| | | |
| | | <style scoped> |
| | | <style scoped lang="scss"> |
| | | .section { |
| | | margin-bottom: 20px; |
| | | } |
| | |
| | | align-items: center; |
| | | } |
| | | |
| | | .chart-container { |
| | | height: 300px; |
| | | .device-card { |
| | | // width: 100%; |
| | | margin-bottom: 20px; |
| | | transition: all 0.3s ease; |
| | | } |
| | | |
| | | .device-card:hover { |
| | | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); |
| | | } |
| | | |
| | | .abnormal-device { |
| | | border-bottom: 4px solid #f56c6c; |
| | | } |
| | | |
| | | .device-header { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | } |
| | | |
| | | .device-id { |
| | | font-size: 16px; |
| | | font-weight: bold; |
| | | color: #303133; |
| | | } |
| | | |
| | | .device-status { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 5px; |
| | | } |
| | | |
| | | .status-icon { |
| | | font-size: 16px; |
| | | } |
| | | |
| | | .normal-icon { |
| | | color: #67c23a; |
| | | } |
| | | |
| | | .abnormal-icon { |
| | | color: #f56c6c; |
| | | } |
| | | |
| | | .realtime-data { |
| | | margin-bottom: 20px; |
| | | } |
| | | |
| | | .basic-info { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | margin-bottom: 15px; |
| | | font-size: 12px; |
| | | color: #909399; |
| | | } |
| | | |
| | | .supplier { |
| | | font-style: italic; |
| | | } |
| | | |
| | | .monitor-time { |
| | | font-family: monospace; |
| | | } |
| | | |
| | | .monitor-values { |
| | | display: flex; |
| | | justify-content: space-around; |
| | | gap: 10px; |
| | | } |
| | | |
| | | .value-item { |
| | | flex: 1; |
| | | width: 60px; |
| | | text-align: center; |
| | | padding: 10px; |
| | | border-radius: 4px; |
| | | background-color: #f9f9f9; |
| | | border: 1px solid transparent; |
| | | } |
| | | |
| | | .value-label { |
| | | display: block; |
| | | font-size: 12px; |
| | | color: #606266; |
| | | margin-bottom: 5px; |
| | | } |
| | | |
| | | .value { |
| | | display: block; |
| | | font-size: 20px; |
| | | font-weight: bold; |
| | | color: #303133; |
| | | } |
| | | |
| | | .unit { |
| | | font-size: 12px; |
| | | font-weight: normal; |
| | | color: #909399; |
| | | } |
| | | |
| | | .abnormal-value { |
| | | background-color: #fef0f0; |
| | | border: 1px solid #fbc4c4; |
| | | } |
| | | |
| | | .abnormal-value .value { |
| | | color: #f56c6c; |
| | | } |
| | | |
| | | .hourly-charts { |
| | | margin-top: 20px; |
| | | } |
| | | |
| | | .chart-header { |
| | | margin-top: 10px; |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | padding: 5px 0; |
| | | } |
| | | |
| | | .popover-content { |
| | | padding: 10px; |
| | | } |
| | | |
| | | .popover-header { |
| | | margin-bottom: 15px; |
| | | border-bottom: 1px solid #f0f0f0; |
| | | padding-bottom: 10px; |
| | | } |
| | | |
| | | .popover-header h3 { |
| | | margin: 0; |
| | | font-size: 14px; |
| | | font-weight: bold; |
| | | color: #303133; |
| | | } |
| | | |
| | | .date { |
| | | font-size: 12px; |
| | | color: #909399; |
| | | font-family: monospace; |
| | | } |
| | | |
| | | .charts-container { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 2px; |
| | | } |
| | | |
| | | .chart-item { |
| | | background-color: #f9f9f9; |
| | | border-radius: 4px; |
| | | padding: 10px; |
| | | } |
| | | |
| | | .chart-title { |
| | | font-size: 12px; |
| | | color: #606266; |
| | | margin-bottom: 5px; |
| | | text-align: center; |
| | | } |
| | | |
| | | .small-chart { |
| | | height: 120px; |
| | | width: 100%; |
| | | } |
| | | |
| | | .no-data { |
| | | padding: 40px 0; |
| | | text-align: center; |
| | | } |
| | | |
| | | /* 响应式设计 */ |
| | | @media (max-width: 768px) { |
| | | .monitor-values { |
| | | flex-direction: column; |
| | | } |
| | | |
| | | .value-item { |
| | | width: 100%; |
| | | } |
| | | |
| | | .small-chart { |
| | | height: 100px; |
| | | } |
| | | } |
| | | </style> |