You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1055 lines
30 KiB
Vue

2 years ago
<template>
<div class="app-container">
<el-row :gutter="20">
<!-- 左侧设备树 -->
<el-col :span="5" :xs="24">
<div class="head-container">
<el-input
v-model="deviceTreeFilter"
placeholder="请输入设备名称"
clearable
size="small"
prefix-icon="el-icon-search"
style="margin-bottom: 20px"
/>
</div>
<div class="head-container">
<el-tree
:data="deviceTreeOptions"
:props="deviceTreeProps"
:expand-on-click-node="false"
:filter-node-method="filterDeviceNode"
ref="deviceTree"
node-key="id"
default-expand-all
highlight-current
@node-click="handleDeviceNodeClick">
</el-tree>
</div>
</el-col>
<!-- 右侧内容区 -->
<el-col :span="19" :xs="24">
<!-- 统计卡片区域 -->
<div class="stats-section">
<div class="stats-grid">
<div class="stat-card total">
<div class="stat-icon">
<i class="el-icon-monitor"></i>
</div>
<div class="stat-content">
<div class="stat-number">{{ totalDeviceCount }}</div>
<div class="stat-label">设备总数</div>
</div>
</div>
<div class="stat-card alarm-rule">
<div class="stat-icon">
<i class="el-icon-warning"></i>
</div>
<div class="stat-content">
<div class="stat-number">{{ alarmRuleTotalCount }}</div>
<div class="stat-label">异常规则数量</div>
</div>
</div>
<div class="stat-card alarm-data">
<div class="stat-icon">
<i class="el-icon-bell"></i>
</div>
<div class="stat-content">
<div class="stat-number">{{ alarmDataTotalCount }}</div>
<div class="stat-label">异常数据数量</div>
</div>
</div>
</div>
</div>
<!-- 设备监控数据区域 -->
<div class="monitoring-section">
<div class="section-header">
<h2 class="section-title">设备监控数据</h2>
<div class="section-actions">
<span v-if="selectedNodeName" class="selected-node">
当前节点{{ selectedNodeName }}
</span>
<el-button
type="primary"
icon="el-icon-refresh"
size="small"
@click="refreshData"
:loading="loading">
刷新数据
</el-button>
</div>
</div>
<!-- 设备数据卡片网格 -->
<div class="device-grid" v-loading="loading">
<div
v-for="device in filteredDeviceList"
:key="device.monitorId"
class="device-card"
:class="getDeviceStatus(device)">
<!-- 设备头部信息 -->
<div class="device-header">
<div class="device-info">
<h3 class="device-name">{{ device.monitorName || '未知设备' }}</h3>
<span class="device-id">{{ device.monitorId }}</span>
</div>
<div class="device-status">
<span class="status-indicator" :class="getDeviceStatus(device)"></span>
<span class="status-text">{{ getStatusText(device) }}</span>
</div>
</div>
<!-- 设备数据展示 -->
<div class="device-data">
<div class="data-row" v-if="device.temperature !== null && device.temperature !== undefined">
<div class="data-item">
<i class="data-icon el-icon-thermometer"></i>
<span class="data-label">温度</span>
<span class="data-value">{{ formatValue(device.temperature, '°C') }}</span>
</div>
</div>
<div class="data-row" v-if="device.humidity !== null && device.humidity !== undefined">
<div class="data-item">
<i class="data-icon el-icon-cloudy"></i>
<span class="data-label">湿度</span>
<span class="data-value">{{ formatValue(device.humidity, '%') }}</span>
</div>
</div>
<div class="data-row" v-if="device.noise !== null && device.noise !== undefined">
<div class="data-item">
<i class="data-icon el-icon-microphone"></i>
<span class="data-label">噪声</span>
<span class="data-value">{{ formatValue(device.noise, 'dB') }}</span>
</div>
</div>
<div class="data-row" v-if="device.illuminance !== null && device.illuminance !== undefined">
<div class="data-item">
<i class="data-icon el-icon-sunny"></i>
<span class="data-label">照度</span>
<span class="data-value">{{ formatValue(device.illuminance, 'lx') }}</span>
</div>
</div>
<div class="data-row" v-if="device.concentration !== null && device.concentration !== undefined">
<div class="data-item">
<i class="data-icon el-icon-warning-outline"></i>
<span class="data-label">硫化氢</span>
<span class="data-value">{{ formatValue(device.concentration, 'ppm') }}</span>
</div>
</div>
<div class="data-row" v-if="device.vibrationSpeed !== null && device.vibrationSpeed !== undefined">
<div class="data-item">
<i class="data-icon el-icon-s-operation"></i>
<span class="data-label">振动速度</span>
<span class="data-value">{{ formatValue(device.vibrationSpeed, 'mm/s') }}</span>
</div>
</div>
<!-- 无数据提示 -->
<div v-if="!hasData(device)" class="no-data">
<i class="el-icon-warning-outline"></i>
<span>当天无最新数据</span>
</div>
</div>
<!-- 设备底部信息 -->
<div class="device-footer" v-if="device.recodeTime">
<span class="update-time">
<i class="el-icon-time"></i>
记录时间{{ formatTime(device.recodeTime) }}
</span>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="!loading && filteredDeviceList.length === 0 && selectedNodeName" class="empty-state">
<i class="el-icon-box"></i>
<p>该节点下暂无设备数据</p>
</div>
<!-- 未选择提示 -->
<div v-if="!selectedNodeName && !loading" class="empty-state">
<i class="el-icon-s-grid"></i>
<p>请在设备树中选择节点查看设备数据</p>
</div>
</div>
</el-col>
</el-row>
2 years ago
</div>
</template>
<script>
import { getLatestRecords, getLatestRecordsByParentId } from '@/api/ems/record/recordIotenvInstant'
import {getAlarmDataTotalCount, saveWebSocketAlarmData} from "@/api/ems/record/recordAlarmData";
import {getEmsRecordAlarmRuleTotalCount} from "@/api/ems/record/recordAlarmRule";
import { getMonitorInfoTree } from '@/api/ems/base/baseMonitorInfo';
2 years ago
export default {
name: 'Dashboard',
2 years ago
data() {
return {
loading: false,
deviceList: [],
// refreshTimer: null, // 已删除:不再使用定时刷新
// WebSocket相关
websocket: null,
websocketUrl: 'ws://119.45.202.115:7181/ws',
isWebSocketConnected: false,
reconnectTimer: null,
reconnectAttempts: 0,
maxReconnectAttempts: 5,
alarmRuleTotalCount: 0,
alarmDataTotalCount: 0,
totalDeviceCount: 0, // 设备总数
// 设备树相关
deviceTreeOptions: [],
deviceTreeFilter: '',
selectedNodeId: null, // 当前选中的节点ID
selectedNodeName: null, // 当前选中的节点名称
deviceTreeProps: {
children: 'children',
label: 'label'
}
}
},
computed: {
// 根据选择的设备树节点过滤设备列表
filteredDeviceList() {
return this.deviceList
}
},
watch: {
// 根据名称筛选设备树
deviceTreeFilter(val) {
this.$refs.deviceTree.filter(val)
}
},
mounted() {
this.loadDeviceTree()
this.loadStatistics() // 初始加载统计数据
this.initWebSocket() // 初始化WebSocket连接
},
beforeDestroy() {
// 清理WebSocket相关定时器
if (this.reconnectTimer) {
clearInterval(this.reconnectTimer)
}
// 关闭WebSocket连接
this.closeWebSocket()
2 years ago
},
methods: {
// 初始化WebSocket连接
initWebSocket() {
console.log('正在建立WebSocket连接...')
try {
this.websocket = new WebSocket(this.websocketUrl)
this.websocket.onopen = this.onWebSocketOpen
this.websocket.onmessage = this.onWebSocketMessage
this.websocket.onclose = this.onWebSocketClose
this.websocket.onerror = this.onWebSocketError
} catch (error) {
console.error('WebSocket连接失败:', error)
this.handleReconnect()
}
},
// WebSocket连接成功
onWebSocketOpen(event) {
console.log('WebSocket连接已建立')
this.isWebSocketConnected = true
this.reconnectAttempts = 0
// 清除重连定时器
if (this.reconnectTimer) {
clearInterval(this.reconnectTimer)
this.reconnectTimer = null
}
this.$message.success('实时数据连接已建立')
},
// WebSocket接收消息
onWebSocketMessage(event) {
try {
const data = JSON.parse(event.data)
console.log('收到WebSocket数据:', data)
if (data.deviceParam) {
// 处理实时传感器数据
this.handleDeviceData(data)
}
if (data.isFlag === 1 && data.alarmRules && data.alarmContents) {
// 处理告警信息 - 通过事件总线通知Navbar组件
this.$bus.$emit('websocket-alarm', data)
// 实时更新告警数据统计 - 根据告警规则数量增加统计
this.alarmDataTotalCount += data.alarmRules.length
console.log('实时更新告警统计,新增告警数量:', data.alarmRules.length, '当前总数:', this.alarmDataTotalCount)
}
} catch (error) {
console.error('解析WebSocket数据失败:', error)
}
},
// WebSocket连接关闭
onWebSocketClose(event) {
console.log('WebSocket连接已关闭', event)
this.isWebSocketConnected = false
if (event.code !== 1000) { // 非正常关闭
this.$message.warning('实时数据连接已断开,正在尝试重连...')
this.handleReconnect()
}
},
// WebSocket连接错误
onWebSocketError(event) {
console.error('WebSocket连接错误:', event)
this.isWebSocketConnected = false
this.handleReconnect()
},
// 处理重连
handleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.log('达到最大重连次数,停止重连')
this.$message.error('实时数据连接失败,请刷新页面重试')
return
}
this.reconnectAttempts++
console.log(`${this.reconnectAttempts}次重连尝试...`)
this.reconnectTimer = setTimeout(() => {
this.initWebSocket()
}, 3000 * this.reconnectAttempts) // 递增延迟重连
},
// 关闭WebSocket连接
closeWebSocket() {
if (this.websocket) {
this.websocket.close(1000, '页面关闭')
this.websocket = null
}
this.isWebSocketConnected = false
},
// 处理实时设备数据
handleDeviceData(data) {
const deviceParam = data.deviceParam
if (!deviceParam || !deviceParam.monitorId) {
return
}
// 如果当前选中了节点,只更新该节点下的设备数据
if (this.selectedNodeId !== null) {
this.updateDeviceDataInList(deviceParam)
} else {
// 如果没有选中节点,可以选择是否更新全局设备列表
console.log('收到设备数据但未选中节点:', deviceParam.monitorId)
}
},
// 更新设备列表中的数据
updateDeviceDataInList(deviceParam) {
const index = this.deviceList.findIndex(device => device.monitorId === deviceParam.monitorId)
if (index !== -1) {
// 更新现有设备数据
const updatedDevice = {
...this.deviceList[index],
temperature: deviceParam.temperature,
humidity: deviceParam.humidity,
illuminance: deviceParam.illuminance,
noise: deviceParam.noise,
concentration: deviceParam.concentration,
vibrationSpeed: deviceParam.vibrationSpeed || deviceParam.VibrationSpeed,
vibrationDisplacement: deviceParam.vibrationDisplacement || deviceParam.VibrationDisplacement,
vibrationAcceleration: deviceParam.vibrationAcceleration || deviceParam.VibrationAcceleration,
vibrationTemp: deviceParam.vibrationTemp || deviceParam.VibrationTemp,
collectTime: deviceParam.collectTime,
recodeTime: deviceParam.recordTime
}
// 使用Vue.set确保响应式更新
this.$set(this.deviceList, index, updatedDevice)
console.log('更新设备数据:', deviceParam.monitorId)
} else {
console.log('设备不在当前节点列表中:', deviceParam.monitorId)
}
},
async loadStatistics() {
try {
this.alarmDataTotalCount = await getAlarmDataTotalCount();
this.alarmRuleTotalCount = await getEmsRecordAlarmRuleTotalCount();
// 获取所有设备数据用于统计设备总数
const response = await getLatestRecords()
if (response.code === 200) {
const rawData = response.data || []
const allDevices = rawData.filter(device => {
return device.monitorName !== '胶东机场' &&
device.monitorId &&
device.monitorName
})
// 更新设备总数
this.totalDeviceCount = allDevices.length
}
} catch (error) {
console.error('获取统计数据失败:', error)
}
},
async loadDeviceDataByNode(parentId) {
this.loading = true
try {
const response = await getLatestRecordsByParentId(parentId)
if (response.code === 200) {
// 过滤掉异常数据
const rawData = response.data || []
this.deviceList = rawData.filter(device => {
// 过滤条件:
// 1. 排除monitorName为"胶东机场"的记录
// 2. 确保设备有基本信息monitorId和monitorName不为空
return device.monitorName !== '胶东机场' &&
device.monitorId &&
device.monitorName
})
console.log('选中节点设备数据:', this.deviceList)
} else {
this.$message.error(response.msg || '获取设备数据失败')
this.deviceList = []
}
} catch (error) {
console.error('获取设备数据失败:', error)
this.$message.error('获取设备数据失败')
this.deviceList = []
} finally {
this.loading = false
}
},
refreshData() {
if (this.selectedNodeId !== null) {
this.loadDeviceDataByNode(this.selectedNodeId)
}
// 删除统计数据的手动刷新统计数据现在通过WebSocket实时更新
// this.loadStatistics()
},
// 加载设备树
async loadDeviceTree() {
const response = await getMonitorInfoTree({})
this.deviceTreeOptions = response.data || []
console.log('设备树数据:', this.deviceTreeOptions)
},
// 设备树节点筛选
filterDeviceNode(value, data) {
if (!value) return true
return data.label.indexOf(value) !== -1
},
// 设备树节点点击事件
handleDeviceNodeClick(data) {
console.log('点击设备树节点:', data)
// 设置选中的节点信息
this.selectedNodeId = data.id
this.selectedNodeName = data.label
console.log('选中节点ID:', this.selectedNodeId, '节点名称:', this.selectedNodeName)
// 根据选中的节点ID加载该节点下的设备数据
this.loadDeviceDataByNode(this.selectedNodeId)
},
hasData(device) {
// 修正判断逻辑检查是否有任何传感器数据包括0值
return (device.temperature !== null && device.temperature !== undefined) ||
(device.humidity !== null && device.humidity !== undefined) ||
(device.noise !== null && device.noise !== undefined) ||
(device.illuminance !== null && device.illuminance !== undefined) ||
(device.concentration !== null && device.concentration !== undefined) ||
(device.vibrationSpeed !== null && device.vibrationSpeed !== undefined) ||
(device.recodeTime !== null && device.recodeTime !== undefined)
},
getDeviceStatus(device) {
if (!this.hasData(device)) {
return 'offline'
}
// 根据数据值判断设备状态
// if (device.temperature && (device.temperature > 40 || device.temperature < -10)) {
// return 'error'
// }
// if (device.humidity && (device.humidity > 90 || device.humidity < 10)) {
// return 'warning'
// }
// if (device.noise && device.noise > 80) {
// return 'warning'
// }
// if (device.concentration && device.concentration > 10) {
// return 'error'
// }
return 'normal'
},
getStatusText(device) {
const status = this.getDeviceStatus(device)
const statusMap = {
'normal': '正常',
// 'warning': '警告',
// 'error': '异常',
'offline': '离线'
}
return statusMap[status] || '未知'
},
formatValue(value, unit) {
if (value === null || value === undefined) {
return '--'
}
return `${Number(value).toFixed(1)} ${unit}`
},
formatTime(time) {
if (!time) return '--'
// 使用原生Date对象格式化时间避免moment.js依赖问题
try {
const date = new Date(time)
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${month}-${day} ${hours}:${minutes}:${seconds}`
} catch (error) {
console.error('时间格式化失败:', error)
return '--'
}
},
// 保存WebSocket告警数据到数据库
async saveRealtimeAlarmData(alarmData) {
try {
// 构建符合EmsRecordAlarmData实体的数据列表
// 每个触发的告警规则对应一条EmsRecordAlarmData记录
const alarmDataList = []
if (!alarmData.alarmRules || alarmData.alarmRules.length === 0) {
console.warn('告警数据中没有告警规则')
return
}
// 获取当前时间并格式化为后端期望的格式
const getCurrentTimeForBackend = () => {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
const hours = String(now.getHours()).padStart(2, '0')
const minutes = String(now.getMinutes()).padStart(2, '0')
const seconds = String(now.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
// 为每个触发的告警规则创建一条记录
for (const rule of alarmData.alarmRules) {
// 获取对应字段的实际数值
const actualValue = this.getActualValueFromDeviceParam(alarmData.deviceParam, rule.monitorField)
const alarmRecord = {
// 设备相关信息
monitorId: alarmData.monitorId,
collectTime: getCurrentTimeForBackend(), // 使用当前时间并格式化为后端期望格式
// 告警类型根据规则的triggerRule设置0=大于阈值1=小于阈值)
alarmType: rule.triggerRule || 0,
// 告警状态1=未处理
alarmStatus: 1,
// 告警数据:实际触发告警的数值
alarmData: actualValue ? String(actualValue) : '',
// 告警原因:使用字段名称
cause: this.getFieldName(rule.monitorField),
// 其他字段可以为空,后端会设置默认值
operationName: null,
operationTime: null,
notifyUser: null
}
alarmDataList.push(alarmRecord)
console.log('构建告警记录:', {
设备ID: alarmRecord.monitorId,
告警字段: alarmRecord.cause,
实际数值: alarmRecord.alarmData,
告警类型: alarmRecord.alarmType === 0 ? '大于阈值' : '小于阈值',
规则阈值: rule.triggerValue,
记录时间: alarmRecord.collectTime
})
}
if (alarmDataList.length === 0) {
console.warn('没有有效的告警记录可保存')
return
}
// 发送到后端保存
const response = await saveWebSocketAlarmData(alarmDataList)
if (response.code === 200) {
console.log('告警数据保存成功:', response.msg)
// 告警数据保存成功新的告警记录会通过WebSocket实时推送无需手动刷新
// this.getAlarmDataList()
} else {
console.error('告警数据保存失败:', response.msg)
this.$message.error('告警数据保存失败: ' + response.msg)
}
} catch (error) {
console.error('保存告警数据异常:', error)
this.$message.error('保存告警数据异常')
}
},
// 从设备参数中获取指定字段的实际数值
getActualValueFromDeviceParam(deviceParam, monitorField) {
if (!deviceParam || monitorField === null || monitorField === undefined) {
return null
}
switch (monitorField) {
case 0: // 温度
return deviceParam.temperature
case 1: // 湿度
return deviceParam.humidity
case 2: // 振动-速度(mm/s)
return deviceParam.vibrationSpeed || deviceParam.VibrationSpeed
case 3: // 振动-位移(um)
return deviceParam.vibrationDisplacement || deviceParam.VibrationDisplacement
case 4: // 振动-加速度(g)
return deviceParam.vibrationAcceleration || deviceParam.VibrationAcceleration
case 5: // 振动-温度(℃)
return deviceParam.vibrationTemp || deviceParam.VibrationTemp
case 6: // 噪音
return deviceParam.noise
case 7: // 照度
return deviceParam.illuminance
case 8: // 气体浓度
return deviceParam.concentration
default:
console.warn('未知的监测字段:', monitorField)
return null
}
},
// 获取监测字段名称
getFieldName(fieldCode) {
const fieldMap = {
0: '温度',
1: '湿度',
2: '振动-速度(mm/s)',
3: '振动-位移(um)',
4: '振动-加速度(g)',
5: '振动-温度(℃)',
6: '噪音',
7: '照度',
8: '气体浓度'
}
return fieldMap[fieldCode] || '未知字段'
}
2 years ago
}
}
2 years ago
</script>
<style lang="scss" scoped>
.app-container {
padding: 20px;
background-color: #f5f7fa;
min-height: 100vh;
}
// 左侧设备树样式
.head-container {
background: white;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.el-tree {
background: transparent;
}
.el-tree-node__content {
height: 32px;
line-height: 32px;
border-radius: 6px;
margin-bottom: 2px;
&:hover {
background-color: #f5f7fa;
}
}
.el-tree-node.is-current > .el-tree-node__content {
background-color: #ecf5ff;
color: #409eff;
}
.el-tree-node__expand-icon {
color: #c0c4cc;
&.is-leaf {
color: transparent;
}
}
}
// 统计卡片区域
.stats-section {
margin-bottom: 20px;
.stats-grid {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.stat-card {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
transition: transform 0.3s ease, box-shadow 0.3s ease;
min-width: 200px;
flex: 1;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.stat-icon {
width: 50px;
height: 50px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 14px;
i {
font-size: 20px;
color: white;
}
}
.stat-content {
flex: 1;
.stat-number {
font-size: 24px;
font-weight: 700;
line-height: 1;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: #7f8c8d;
}
}
&.total {
.stat-icon {
background: linear-gradient(135deg, #409eff, #66b1ff);
}
.stat-number {
color: #409eff;
}
}
&.alarm-rule {
.stat-icon {
background: linear-gradient(135deg, #e6a23c, #f0c040);
}
.stat-number {
color: #e6a23c;
}
}
&.alarm-data {
.stat-icon {
background: linear-gradient(135deg, #f56c6c, #ff8080);
}
.stat-number {
color: #f56c6c;
}
}
}
}
// 设备监控数据区域
.monitoring-section {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid #e9ecef;
.section-title {
font-size: 18px;
font-weight: 600;
color: #2c3e50;
margin: 0;
}
.section-actions {
display: flex;
align-items: center;
gap: 12px;
.selected-node {
font-size: 14px;
color: #409eff;
background: #ecf5ff;
padding: 4px 12px;
border-radius: 4px;
border: 1px solid #d9ecff;
}
}
}
2 years ago
.device-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.device-card {
background: #f8f9fa;
border-radius: 10px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
border-left: 4px solid #e9ecef;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
&.normal {
border-left-color: #67c23a;
}
&.warning {
border-left-color: #e6a23c;
}
&.error {
border-left-color: #f56c6c;
}
&.offline {
border-left-color: #909399;
opacity: 0.8;
}
}
.device-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
.device-info {
flex: 1;
.device-name {
font-size: 16px;
font-weight: 600;
color: #2c3e50;
margin: 0 0 4px 0;
}
.device-id {
font-size: 12px;
color: #7f8c8d;
background: white;
padding: 2px 8px;
border-radius: 4px;
}
}
.device-status {
display: flex;
align-items: center;
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
&.normal {
background-color: #67c23a;
}
&.warning {
background-color: #e6a23c;
}
&.error {
background-color: #f56c6c;
}
&.offline {
background-color: #909399;
}
}
.status-text {
font-size: 12px;
color: #7f8c8d;
}
}
}
.device-data {
.data-row {
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
}
.data-item {
display: flex;
align-items: center;
padding: 6px 0;
.data-icon {
width: 20px;
color: #409eff;
margin-right: 8px;
}
.data-label {
flex: 1;
font-size: 14px;
color: #606266;
}
.data-value {
font-size: 14px;
font-weight: 600;
color: #2c3e50;
}
}
.no-data {
text-align: center;
padding: 20px;
color: #909399;
i {
font-size: 24px;
margin-bottom: 8px;
display: block;
}
}
}
.device-footer {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e9ecef;
.update-time {
font-size: 11px;
color: #7f8c8d;
i {
margin-right: 4px;
}
}
}
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #909399;
i {
font-size: 48px;
margin-bottom: 16px;
display: block;
}
p {
font-size: 16px;
margin: 0;
2 years ago
}
}
2 years ago
// 响应式设计
@media (max-width: 768px) {
.app-container {
padding: 16px;
}
.stats-grid {
flex-direction: column;
}
.device-grid {
grid-template-columns: 1fr;
}
.section-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
.section-actions {
width: 100%;
justify-content: space-between;
}
2 years ago
}
}
</style>