feat(mes/eqp): 新增设备监控模拟页面

该页面实现了设备状态统计看板、多条件筛选搜索、自动刷新、设备卡片展示、详情弹窗查看功能,使用mock数据模拟真实设备运行状态和参数变化
This commit is contained in:
2026-06-13 13:30:52 +08:00
parent 948e62daae
commit ee1cb31321

View File

@@ -0,0 +1,529 @@
<template>
<div class="app-container eqp-monitor">
<div class="stat-panel mb16">
<div class="stat-item">
<div class="stat-icon-wrap total-icon"><i class="el-icon-cpu" /></div>
<div class="stat-body">
<div class="stat-num">{{ stats.total }}</div>
<div class="stat-label">设备总数</div>
</div>
<div class="stat-divider" />
</div>
<div class="stat-item">
<div class="stat-icon-wrap running-icon"><i class="el-icon-video-play" /></div>
<div class="stat-body">
<div class="stat-num running-num">{{ stats.running }}</div>
<div class="stat-label">运行中</div>
</div>
<div class="stat-divider" />
</div>
<div class="stat-item">
<div class="stat-icon-wrap standby-icon"><i class="el-icon-video-pause" /></div>
<div class="stat-body">
<div class="stat-num standby-num">{{ stats.stopped }}</div>
<div class="stat-label">已停机</div>
</div>
<div class="stat-divider" />
</div>
<div class="stat-item">
<div class="stat-icon-wrap fault-icon"><i class="el-icon-warning-outline" /></div>
<div class="stat-body">
<div class="stat-num fault-num">{{ stats.fault }}</div>
<div class="stat-label"> </div>
</div>
<div class="stat-divider" />
</div>
<div class="stat-item">
<div class="stat-icon-wrap offline-icon"><i class="el-icon-remove-outline" /></div>
<div class="stat-body">
<div class="stat-num offline-num">{{ stats.offline }}</div>
<div class="stat-label"> 线</div>
</div>
</div>
</div>
<el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true" label-width="68px">
<el-form-item label="设备类型" prop="type">
<el-select v-model="queryParams.type" placeholder="全部" clearable style="width:140px" @change="handleQuery">
<el-option label="CNC加工中心" value="CNC" />
<el-option label="工业机器人" value="Robot" />
<el-option label="传送带" value="Conveyor" />
<el-option label="液压机" value="Hydraulic" />
<el-option label="焊机" value="Welding" />
</el-select>
</el-form-item>
<el-form-item label="设备状态" prop="status">
<el-select v-model="queryParams.status" placeholder="全部" clearable style="width:120px" @change="handleQuery">
<el-option label="运行中" value="running" />
<el-option label="已停机" value="stopped" />
<el-option label="故障" value="fault" />
<el-option label="离线" value="offline" />
</el-select>
</el-form-item>
<el-form-item label="关键词" prop="keyword">
<el-input v-model="queryParams.keyword" placeholder="设备编号/名称" clearable style="width:180px" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button :type="autoRefresh ? 'danger' : 'primary'" plain :icon="autoRefresh ? 'el-icon-close' : 'el-icon-refresh'" size="mini" @click="toggleAutoRefresh">
{{ autoRefresh ? '停止刷新' : '自动刷新' }}
</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="warning" plain icon="el-icon-download" size="mini" @click="handleExport">导出</el-button>
</el-col>
<el-col v-if="autoRefresh" :span="1.5">
<span class="refresh-tip">数据自动刷新中...</span>
</el-col>
<right-toolbar :show-search.sync="showSearch" @queryTable="handleQuery" />
</el-row>
<!-- 设备卡片网格 -->
<div v-loading="loading" class="device-grid">
<div
v-for="dev in filteredDevices"
:key="dev.id"
class="device-card"
:class="['card-' + dev.status]"
@click="handleDetail(dev)"
>
<!-- 卡片头部 -->
<div class="card-header">
<div class="card-left">
<span class="card-code">{{ dev.code }}</span>
<span :class="['type-tag', 'type-' + dev.type]">{{ dev.typeName }}</span>
</div>
<div class="card-status">
<div class="status-dot-wrap" :class="'status-' + dev.status">
<span class="status-dot" /><span>{{ dev.status | statusFilter }}</span>
</div>
</div>
</div>
<div class="card-name">{{ dev.name }}</div>
<div class="card-workshop">{{ dev.workshop }}</div>
<!-- 参数区域 -->
<div class="card-params">
<div v-for="item in dev.params" :key="item.key" class="param-row">
<div class="param-label">{{ item.label }}</div>
<div class="param-gauge">
<div class="param-gauge-track">
<div
class="param-gauge-fill"
:class="{ 'gauge-alarm': item.alarm }"
:style="{ width: item.percent + '%' }"
/>
</div>
</div>
<div class="param-value" :class="{ 'param-alarm': item.alarm }">
{{ item.value }} <small>{{ item.unit }}</small>
</div>
</div>
</div>
<!-- 底部 -->
<div class="card-footer">
<span class="card-time">{{ dev.runningTime }}</span>
<span class="card-update">{{ dev.updateTime }}</span>
</div>
</div>
</div>
<!-- 空状态 -->
<el-empty v-if="!loading && filteredDevices.length === 0" description="暂无匹配设备" />
<!-- 详情弹窗 -->
<el-dialog :title="detailTitle" :visible.sync="detailOpen" width="800px" append-to-body>
<div v-if="detailDev" class="detail-body">
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="设备编号">{{ detailDev.code }}</el-descriptions-item>
<el-descriptions-item label="设备名称">{{ detailDev.name }}</el-descriptions-item>
<el-descriptions-item label="设备类型">{{ detailDev.typeName }}</el-descriptions-item>
<el-descriptions-item label="所属车间">{{ detailDev.workshop }}</el-descriptions-item>
<el-descriptions-item label="当前状态">
<div class="status-dot-wrap" :class="'status-' + detailDev.status">
<span class="status-dot" /><span>{{ detailDev.status | statusFilter }}</span>
</div>
</el-descriptions-item>
<el-descriptions-item label="累计运行">{{ detailDev.runningTime }}</el-descriptions-item>
</el-descriptions>
<div class="detail-params mt16">
<div class="detail-param-title">实时参数详情</div>
<el-row :gutter="16">
<el-col v-for="item in detailDev.params" :key="item.key" :span="8">
<div class="detail-param-card" :class="{ 'detail-alarm': item.alarm }">
<div class="d-param-label">{{ item.label }}</div>
<div class="d-param-value">{{ item.value }} <small>{{ item.unit }}</small></div>
<div class="d-param-range">范围: {{ item.min }} ~ {{ item.max }} {{ item.unit }}</div>
<div class="d-param-bar">
<div class="d-param-bar-inner" :class="{ 'bar-alarm': item.alarm }" :style="{ width: item.percent + '%' }" />
</div>
</div>
</el-col>
</el-row>
</div>
</div>
</el-dialog>
</div>
</template>
<script>
function randomVal(base, range) {
return base + (Math.random() - 0.5) * 2 * range
}
const DEVICES = [
{ id: 'EQP-0001', code: 'EQP-0001', name: '立式加工中心', type: 'CNC', typeName: 'CNC加工中心', workshop: '一车间', hours: 3200 },
{ id: 'EQP-0002', code: 'EQP-0002', name: '五轴加工中心', type: 'CNC', typeName: 'CNC加工中心', workshop: '一车间', hours: 4800 },
{ id: 'EQP-0003', code: 'EQP-0003', name: '卧式加工中心', type: 'CNC', typeName: 'CNC加工中心', workshop: '一车间', hours: 2100 },
{ id: 'EQP-0004', code: 'EQP-0004', name: '龙门加工中心', type: 'CNC', typeName: 'CNC加工中心', workshop: '一车间', hours: 5600 },
{ id: 'EQP-0005', code: 'EQP-0005', name: '钻攻加工中心', type: 'CNC', typeName: 'CNC加工中心', workshop: '一车间', hours: 1500 },
{ id: 'EQP-0006', code: 'EQP-0006', name: '六轴机器人', type: 'Robot', typeName: '工业机器人', workshop: '二车间', hours: 2800 },
{ id: 'EQP-0007', code: 'EQP-0007', name: '协作机器人', type: 'Robot', typeName: '工业机器人', workshop: '二车间', hours: 3100 },
{ id: 'EQP-0008', code: 'EQP-0008', name: 'SCARA机器人', type: 'Robot', typeName: '工业机器人', workshop: '二车间', hours: 1900 },
{ id: 'EQP-0009', code: 'EQP-0009', name: '辊筒传送带', type: 'Conveyor', typeName: '传送带', workshop: '三车间', hours: 4000 },
{ id: 'EQP-0010', code: 'EQP-0010', name: '皮带传送带', type: 'Conveyor', typeName: '传送带', workshop: '三车间', hours: 3500 },
{ id: 'EQP-0011', code: 'EQP-0011', name: '链板传送带', type: 'Conveyor', typeName: '传送带', workshop: '三车间', hours: 2700 },
{ id: 'EQP-0012', code: 'EQP-0012', name: '四柱液压机', type: 'Hydraulic', typeName: '液压机', workshop: '一车间', hours: 5200 },
{ id: 'EQP-0013', code: 'EQP-0013', name: '框架式液压机', type: 'Hydraulic', typeName: '液压机', workshop: '一车间', hours: 4400 },
{ id: 'EQP-0014', code: 'EQP-0014', name: '弧焊机器人工作站', type: 'Welding', typeName: '焊机', workshop: '二车间', hours: 2300 },
{ id: 'EQP-0015', code: 'EQP-0015', name: '激光焊接机', type: 'Welding', typeName: '焊机', workshop: '二车间', hours: 3700 },
{ id: 'EQP-0016', code: 'EQP-0016', name: '电阻点焊机', type: 'Welding', typeName: '焊机', workshop: '二车间', hours: 4100 }
]
const TYPE_PARAMS = {
CNC: [
{ key: 'temp', label: '主轴温度', unit: '°C', min: 20, max: 75, setpoint: 42, range: 8, decimals: 1 },
{ key: 'speed', label: '主轴转速', unit: 'rpm', min: 0, max: 12000, setpoint: 6000, range: 1500, decimals: 0 },
{ key: 'current', label: '工作电流', unit: 'A', min: 0, max: 80, setpoint: 35, range: 15, decimals: 1 },
{ key: 'vibration', label: '振动幅度', unit: 'mm/s', min: 0, max: 10, setpoint: 2.5, range: 2, decimals: 1 },
{ key: 'pressure', label: '液压压力', unit: 'MPa', min: 0, max: 25, setpoint: 12, range: 4, decimals: 1 },
{ key: 'voltage', label: '输入电压', unit: 'V', min: 340, max: 420, setpoint: 380, range: 5, decimals: 1 }
],
Robot: [
{ key: 'temp', label: '电机温度', unit: '°C', min: 10, max: 90, setpoint: 50, range: 10, decimals: 1 },
{ key: 'speed', label: '关节转速', unit: '°/s', min: 0, max: 250, setpoint: 120, range: 40, decimals: 0 },
{ key: 'current', label: '驱动电流', unit: 'A', min: 0, max: 30, setpoint: 12, range: 6, decimals: 1 },
{ key: 'vibration', label: '末端振动', unit: 'mm/s', min: 0, max: 5, setpoint: 1, range: 0.8, decimals: 1 },
{ key: 'pressure', label: '气源压力', unit: 'MPa', min: 0, max: 1, setpoint: 0.6, range: 0.15, decimals: 2 },
{ key: 'voltage', label: '供电电压', unit: 'V', min: 200, max: 240, setpoint: 220, range: 3, decimals: 1 }
],
Conveyor: [
{ key: 'temp', label: '电机温度', unit: '°C', min: 10, max: 80, setpoint: 45, range: 8, decimals: 1 },
{ key: 'speed', label: '输送速度', unit: 'm/min', min: 0, max: 60, setpoint: 30, range: 8, decimals: 1 },
{ key: 'current', label: '电机电流', unit: 'A', min: 0, max: 25, setpoint: 10, range: 4, decimals: 1 },
{ key: 'vibration', label: '运行振动', unit: 'mm/s', min: 0, max: 8, setpoint: 2, range: 1.5, decimals: 1 },
{ key: 'pressure', label: '张紧压力', unit: 'N', min: 0, max: 500, setpoint: 250, range: 50, decimals: 0 },
{ key: 'voltage', label: '供电电压', unit: 'V', min: 360, max: 400, setpoint: 380, range: 3, decimals: 1 }
],
Hydraulic: [
{ key: 'temp', label: '油液温度', unit: '°C', min: 15, max: 65, setpoint: 40, range: 6, decimals: 1 },
{ key: 'speed', label: '滑块速度', unit: 'mm/s', min: 0, max: 200, setpoint: 80, range: 25, decimals: 0 },
{ key: 'current', label: '电机电流', unit: 'A', min: 0, max: 120, setpoint: 55, range: 20, decimals: 1 },
{ key: 'vibration', label: '机身振动', unit: 'mm/s', min: 0, max: 12, setpoint: 4, range: 2.5, decimals: 1 },
{ key: 'pressure', label: '工作压力', unit: 'MPa', min: 0, max: 35, setpoint: 20, range: 5, decimals: 1 },
{ key: 'voltage', label: '供电电压', unit: 'V', min: 360, max: 400, setpoint: 380, range: 5, decimals: 1 }
],
Welding: [
{ key: 'temp', label: '焊枪温度', unit: '°C', min: 20, max: 120, setpoint: 65, range: 15, decimals: 1 },
{ key: 'speed', label: '送丝速度', unit: 'm/min', min: 0, max: 20, setpoint: 10, range: 3, decimals: 1 },
{ key: 'current', label: '焊接电流', unit: 'A', min: 0, max: 350, setpoint: 180, range: 50, decimals: 0 },
{ key: 'vibration', label: '机头振动', unit: 'mm/s', min: 0, max: 6, setpoint: 1.8, range: 1.2, decimals: 1 },
{ key: 'pressure', label: '保护气压', unit: 'MPa', min: 0, max: 1.5, setpoint: 0.8, range: 0.2, decimals: 2 },
{ key: 'voltage', label: '焊接电压', unit: 'V', min: 15, max: 40, setpoint: 26, range: 5, decimals: 1 }
]
}
function buildParamItem(cfg, val, isStopped) {
const alarm = !isStopped && (val > cfg.max * 0.92 || val < cfg.min * 1.08)
return {
key: cfg.key,
label: cfg.label,
value: val.toFixed(cfg.decimals),
unit: cfg.unit,
min: cfg.min,
max: cfg.max,
alarm: alarm,
percent: Math.min(100, Math.max(0, ((val - cfg.min) / (cfg.max - cfg.min)) * 100))
}
}
export default {
name: 'EqpMonitor',
filters: {
statusFilter(val) {
const map = { running: '运行中', stopped: '已停机', fault: '故障', offline: '离线' }
return map[val] || val
}
},
data() {
return {
loading: false,
showSearch: true,
autoRefresh: true,
sourceData: [],
detailOpen: false,
detailDev: null,
queryParams: { type: '', status: '', keyword: '' },
timer: null
}
},
computed: {
stats() {
const arr = this.sourceData
if (!arr || !arr.length) return { total: 0, running: 0, stopped: 0, fault: 0, offline: 0 }
let total = 0; let running = 0; let stopped = 0; let fault = 0; let offline = 0
arr.forEach(d => {
total++
if (d.status === 'running') running++
else if (d.status === 'stopped') stopped++
else if (d.status === 'fault') fault++
else if (d.status === 'offline') offline++
})
return { total, running, stopped, fault, offline }
},
filteredDevices() {
const q = this.queryParams
return this.sourceData.filter(d => {
if (q.type && d.type !== q.type) return false
if (q.status && d.status !== q.status) return false
if (q.keyword) {
const kw = q.keyword.toLowerCase()
if (d.code.toLowerCase().indexOf(kw) < 0 && d.name.toLowerCase().indexOf(kw) < 0) return false
}
return true
})
},
detailTitle() {
return this.detailDev ? '设备详情 - ' + this.detailDev.name : '设备详情'
}
},
created() {
this.initSource()
this.tickAll()
this.startTimer()
},
beforeDestroy() {
this.stopTimer()
},
methods: {
initSource() {
this.sourceData = DEVICES.map(d => {
const statusRand = Math.random()
let status = 'running'
if (statusRand < 0.03) status = 'offline'
else if (statusRand < 0.13) status = 'fault'
else if (statusRand < 0.28) status = 'stopped'
const cfg = TYPE_PARAMS[d.type]
const params = cfg.map(c => {
const stopped = status === 'stopped' || status === 'offline'
const val = stopped ? 0 : randomVal(c.setpoint, c.range)
return buildParamItem(c, val, stopped)
})
const now = new Date()
const ts = ('0' + now.getHours()).slice(-2) + ':' + ('0' + now.getMinutes()).slice(-2) + ':' + ('0' + now.getSeconds()).slice(-2)
return {
id: d.id,
code: d.code,
name: d.name,
type: d.type,
typeName: d.typeName,
workshop: d.workshop,
status: status,
runningTime: d.hours + 'h',
updateTime: ts,
params: params
}
})
},
tickAll() {
const now = new Date()
const ts = ('0' + now.getHours()).slice(-2) + ':' + ('0' + now.getMinutes()).slice(-2) + ':' + ('0' + now.getSeconds()).slice(-2)
this.sourceData.forEach(dev => {
dev.updateTime = ts
if (dev.status === 'offline') return
const cfg = TYPE_PARAMS[dev.type]
if (!cfg) return
const stopped = dev.status === 'stopped'
let hasAlarm = false
const newParams = cfg.map(c => {
const val = stopped ? randomVal(0, c.range * 0.3) : randomVal(c.setpoint, c.range)
const item = buildParamItem(c, val, stopped)
if (item.alarm) hasAlarm = true
return item
})
dev.params = newParams
if (stopped && Math.random() < 0.03) dev.status = 'running'
else if (!stopped && dev.status === 'running') {
if (hasAlarm && Math.random() < 0.3) dev.status = 'fault'
else if (Math.random() < 0.015) dev.status = 'stopped'
}
if (dev.status === 'fault' && Math.random() < 0.08) dev.status = 'running'
})
},
handleQuery() {
// filtering is computed, no action needed
},
resetQuery() {
if (this.$refs.queryForm) {
this.$refs.queryForm.resetFields()
}
this.queryParams = { type: '', status: '', keyword: '' }
},
toggleAutoRefresh() {
this.autoRefresh = !this.autoRefresh
this.autoRefresh ? this.startTimer() : this.stopTimer()
},
startTimer() {
if (this.timer) return
this.timer = setInterval(() => {
this.tickAll()
}, 2000)
},
stopTimer() {
if (this.timer) {
clearInterval(this.timer)
this.timer = null
}
},
handleExport() {
this.$modal.msgSuccess('导出功能(演示)')
},
handleDetail(dev) {
this.detailDev = dev
this.detailOpen = true
},
handleStart(dev) {
const d = this.sourceData.find(item => item.id === dev.id)
if (!d) return
d.status = 'running'
const cfg = TYPE_PARAMS[d.type]
d.params = cfg.map(c => buildParamItem(c, randomVal(c.setpoint, c.range), false))
this.$modal.msgSuccess(d.name + ' 已启动')
}
}
}
</script>
<style lang="scss" scoped>
.eqp-monitor { background: #f4f5f7; min-height: 100vh; }
.mb16 { margin-bottom: 16px; }
.mt16 { margin-top: 16px; }
/* ─── 统计看板 ─── */
.stat-panel {
display: flex; background: #fff; border: 1px solid #dcdee0; border-radius: 4px; padding: 0; overflow: hidden;
}
.stat-item { flex: 1; display: flex; align-items: center; padding: 18px 20px; gap: 14px; position: relative; }
.stat-divider { position: absolute; right: 0; top: 16px; bottom: 16px; width: 1px; background: #e4e6ea; }
.stat-icon-wrap { width: 42px; height: 42px; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0; }
.total-icon { background: #edf0f3; color: #3d4b5c; }
.running-icon { background: #e8f5ef; color: #0a7c42; }
.standby-icon { background: #fdf3e3; color: #8b5c00; }
.fault-icon { background: #fdecea; color: #a61c00; }
.offline-icon { background: #f0f1f2; color: #5f6368; }
.stat-body { display: flex; flex-direction: column; }
.stat-num { font-size: 26px; font-weight: 700; line-height: 1; color: #1f2329; font-variant-numeric: tabular-nums; letter-spacing: -0.5px; }
.running-num { color: #0a7c42; }
.standby-num { color: #8b5c00; }
.fault-num { color: #a61c00; }
.offline-num { color: #5f6368; }
.stat-label { font-size: 12px; color: #8f9099; margin-top: 4px; letter-spacing: 1px; }
/* ─── 刷新提示 ─── */
.refresh-tip { display: inline-block; padding: 6px 0; font-size: 12px; color: #0a7c42; animation: blink 1.2s infinite; }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
/* ─── 设备卡片网格 ─── */
.device-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
.device-card {
background: #fff;
border: 1px solid #dcdee0;
border-radius: 4px;
padding: 16px;
cursor: pointer;
transition: box-shadow .2s, border-color .2s;
&:hover { box-shadow: 0 2px 12px rgba(0,0,0,.06); border-color: #bbb; }
&.card-fault { border-color: #f5c6c4; background: #fffbfb; }
&.card-offline { border-color: #e0e2e4; background: #f7f8f9; }
}
.card-header {
display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;
}
.card-left { display: flex; align-items: center; gap: 8px; }
.card-code { font-family: 'Consolas','Courier New',monospace; font-weight: 600; font-size: 13px; color: #1f2329; }
.card-name { font-size: 14px; font-weight: 500; color: #1f2329; margin-bottom: 2px; }
.card-workshop { font-size: 11px; color: #8f9099; margin-bottom: 10px; }
/* 类型标签 */
.type-tag { display: inline-block; padding: 0 6px; border-radius: 2px; font-size: 10px; font-weight: 600; }
.type-CNC { background: #e8edf5; color: #2b4c8c; border: 1px solid #c5d0e8; }
.type-Robot { background: #e8f5ef; color: #0a7c42; border: 1px solid #b7d9c5; }
.type-Conveyor { background: #fdf3e3; color: #8b5c00; border: 1px solid #f0d9a8; }
.type-Hydraulic { background: #eef0f3; color: #4a5568; border: 1px solid #cbd5e0; }
.type-Welding { background: #fdecea; color: #a61c00; border: 1px solid #f5c6c4; }
/* 状态指示 */
.status-dot-wrap { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 500; }
.status-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.status-running { color: #0a7c42; }
.status-running .status-dot { background: #0a7c42; box-shadow: 0 0 0 2px #c8ead8; }
.status-stopped { color: #8b5c00; }
.status-stopped .status-dot { background: #d4860a; box-shadow: 0 0 0 2px #f5e2b8; }
.status-fault { color: #a61c00; }
.status-fault .status-dot { background: #c5221f; box-shadow: 0 0 0 2px #f5c6c4; animation: pulse 0.8s infinite; }
.status-offline { color: #5f6368; }
.status-offline .status-dot { background: #9aa0a6; box-shadow: 0 0 0 2px #e0e2e4; }
@keyframes pulse { 0%, 100% { box-shadow: 0 0 0 2px #f5c6c4; } 50% { box-shadow: 0 0 0 5px rgba(197,34,31,.25); } }
/* ─── 卡片参数行 ─── */
.card-params {
border-top: 1px solid #f0f1f2;
padding-top: 10px;
}
.param-row {
display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
&:last-child { margin-bottom: 0; }
}
.param-label { width: 52px; font-size: 10px; color: #8f9099; text-align: right; flex-shrink: 0; }
.param-gauge { flex: 1; }
.param-gauge-track { height: 4px; background: #e8eaed; border-radius: 2px; overflow: hidden; }
.param-gauge-fill { height: 100%; background: #409eff; border-radius: 2px; transition: width .4s ease; min-width: 2px; }
.param-gauge-fill.gauge-alarm { background: #c5221f; }
.param-value { width: 60px; font-size: 12px; font-weight: 600; color: #1f2329; text-align: right; font-variant-numeric: tabular-nums; flex-shrink: 0; }
.param-value small { font-size: 9px; color: #8f9099; font-weight: 400; }
.param-value.param-alarm { color: #a61c00; }
/* ─── 卡片底部 ─── */
.card-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; padding-top: 8px; border-top: 1px solid #f0f1f2; }
.card-time { font-size: 11px; color: #b0b3bb; }
.card-update { font-size: 10px; color: #b0b3bb; }
/* ─── 详情弹窗 ─── */
.detail-param-title { font-size: 14px; font-weight: 600; color: #1f2329; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #ebeef5; }
.detail-param-card { background: #fafbfc; border: 1px solid #e4e6ea; border-radius: 4px; padding: 14px 16px; margin-bottom: 10px; text-align: center; }
.detail-param-card.detail-alarm { border-color: #f5c6c4; background: #fffbfb; }
.d-param-label { font-size: 12px; color: #8f9099; margin-bottom: 6px; }
.d-param-value { font-size: 24px; font-weight: 700; color: #1f2329; font-variant-numeric: tabular-nums; }
.d-param-value small { font-size: 12px; color: #8f9099; font-weight: 400; }
.detail-alarm .d-param-value { color: #a61c00; }
.d-param-range { font-size: 11px; color: #b0b3bb; margin: 4px 0 8px; }
.d-param-bar { height: 4px; background: #e8eaed; border-radius: 2px; overflow: hidden; }
.d-param-bar-inner { height: 100%; background: #409eff; border-radius: 2px; transition: width .4s ease; }
.d-param-bar-inner.bar-alarm { background: #c5221f; }
</style>