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.
1030 lines
28 KiB
Vue
1030 lines
28 KiB
Vue
<template>
|
|
<div v-loading="loading" class="home-dashboard">
|
|
<!-- Header -->
|
|
<section class="db-header">
|
|
<div class="db-header-left">
|
|
<div class="db-badge-row">
|
|
<span class="db-badge">
|
|
<span class="pulse-dot"></span>
|
|
实时系统运行概览
|
|
</span>
|
|
</div>
|
|
<h1>能源设备运行驾驶舱</h1>
|
|
<p class="subtitle">设备台账统计 · 实时状态监控 · 告警规则联动</p>
|
|
</div>
|
|
<div class="db-header-right">
|
|
<div class="db-clock-pill">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="clock-icon"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
|
<span class="clock-time">{{ currentTime }}</span>
|
|
</div>
|
|
<el-button type="primary" class="refresh-btn" @click="loadAll(true)">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="refresh-icon"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/></svg>
|
|
刷新面板
|
|
</el-button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- KPI Cards -->
|
|
<div class="kpi-row">
|
|
<div
|
|
v-for="card in kpiCards"
|
|
:key="card.label"
|
|
class="kpi-item"
|
|
:style="{ '--accent': card.color, '--accent-grad': card.grad }"
|
|
>
|
|
<div class="kpi-icon" :style="{ background: card.grad }">
|
|
<span v-html="card.icon"></span>
|
|
</div>
|
|
<div class="kpi-body">
|
|
<span class="kpi-num">
|
|
{{ card.value }}<small v-if="card.unit">{{ card.unit }}</small>
|
|
</span>
|
|
<span class="kpi-title">{{ card.label }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Charts Row -->
|
|
<div class="chart-row">
|
|
<div class="chart-panel">
|
|
<div class="panel-header-simple">
|
|
<div class="panel-title-with-bar">设备类型分布</div>
|
|
<div class="chart-sub">按监测类型统计设备台数</div>
|
|
</div>
|
|
<div class="chart-container">
|
|
<Chart class="chart-box" :chart-option="typeBarOption" />
|
|
</div>
|
|
</div>
|
|
<div class="chart-panel">
|
|
<div class="panel-header-simple">
|
|
<div class="panel-title-with-bar2">告警处理状态</div>
|
|
<div class="chart-sub">已处理 / 待处理占比及处理率</div>
|
|
</div>
|
|
<div class="chart-container">
|
|
<Chart class="chart-box" :chart-option="alarmPieOption" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Interactive Dashboard Bottom Row
|
|
<div class="bottom-row">
|
|
<div class="bottom-card alarm-card">
|
|
<div class="panel-header">
|
|
<div class="panel-title-group">
|
|
<span class="panel-icon bg-grad-alarm-icon">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0zM12 9v4M12 17h.01"/></svg>
|
|
</span>
|
|
<div class="panel-titles">
|
|
<h3>最近异常告警日志</h3>
|
|
<p>实时推送最新的设备越界告警记录</p>
|
|
</div>
|
|
</div>
|
|
<el-button link type="primary" class="more-link" @click="goToPage('/ems/record/recordAlarmData')">
|
|
查看全部
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
class="arrow-icon"
|
|
>
|
|
<polyline points="9 18 15 12 9 6" />
|
|
</svg>
|
|
</el-button>
|
|
</div>
|
|
<div class="alarm-table-wrapper">
|
|
<el-table
|
|
:data="alarmList"
|
|
stripe
|
|
style="width: 100%"
|
|
size="small"
|
|
:header-cell-style="{ background: '#f8fafc', color: '#475569', fontWeight: 600 }"
|
|
>
|
|
<el-table-column label="处理状态" width="90" align="center">
|
|
<template #default="{ row }">
|
|
<el-tag :type="row.alarmStatus === 1 ? 'success' : 'danger'" effect="light" size="small" class="status-tag">
|
|
{{ row.alarmStatus === 1 ? '已处理' : '待处理' }}
|
|
</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="级别" width="80" align="center">
|
|
<template #default="{ row }">
|
|
<el-tag :type="getAlarmLevelType(row.alarmLevel)" effect="dark" size="small" class="level-tag">
|
|
{{ getAlarmLevelLabel(row.alarmLevel) }}
|
|
</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="告警详情" min-width="180" show-overflow-tooltip>
|
|
<template #default="{ row }">
|
|
<div class="alarm-title-text">{{ row.alarmTitle || row.alarmContent }}</div>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="monitorName" label="监测点" width="120" show-overflow-tooltip />
|
|
<el-table-column label="实际值 / 阈值" width="130" align="center">
|
|
<template #default="{ row }">
|
|
<span class="actual-val">{{ row.actualValue }}</span>
|
|
<span class="val-sep">/</span>
|
|
<span class="threshold-val">{{ row.thresholdValue }}</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="发生时间" width="140" align="center">
|
|
<template #default="{ row }">
|
|
<span class="time-text">{{ formatTime(row.collectTime) }}</span>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bottom-card shortcut-card-panel">
|
|
<div class="panel-header">
|
|
<div class="panel-title-group">
|
|
<span class="panel-icon bg-grad-shortcut-icon">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
|
|
</span>
|
|
<div class="panel-titles">
|
|
<h3>快捷功能通道</h3>
|
|
<p>高频业务功能一键直达</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="shortcut-grid">
|
|
<div v-for="sc in shortcuts" :key="sc.title" class="shortcut-item" @click="goToPage(sc.path)">
|
|
<div class="sc-icon-circle" :style="{ background: sc.color }">
|
|
<span class="sc-svg" v-html="sc.icon"></span>
|
|
</div>
|
|
<div class="sc-content">
|
|
<h4>{{ sc.title }}</h4>
|
|
<p>{{ sc.desc }}</p>
|
|
</div>
|
|
<div class="sc-arrow">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
-->
|
|
</div>
|
|
</template>
|
|
|
|
<script setup name="Index" lang="ts">
|
|
import Chart from '@/components/Charts/Chart.vue';
|
|
import type { EChartsOption } from 'echarts';
|
|
import { getMonitorInfoTree } from '@/api/ems/base/baseMonitorInfo';
|
|
import { getRecordAlarmDataSummary, listRecordAlarmData } from '@/api/ems/record/recordAlarmData';
|
|
|
|
// ---- type config ----
|
|
const TYPE_MAP: Record<number, string> = { 5: '温度', 6: '温湿度', 7: '噪声', 8: '照度', 9: '气体浓度', 10: '振动' };
|
|
const TYPE_COLOR: Record<number, string> = { 5: '#3b82f6', 6: '#10b981', 7: '#f59e0b', 8: '#8b5cf6', 9: '#ef4444', 10: '#6366f1' };
|
|
|
|
// ---- module cache ----
|
|
let cachedTypeCounts: Record<number, number> | null = null;
|
|
let cachedAlarm: { total: number; handled: number; unhandled: number } | null = null;
|
|
|
|
// ---- data helpers ----
|
|
const toNum = (v: unknown, fallback = 0) => { const n = Number(v); return Number.isFinite(n) ? n : fallback; };
|
|
|
|
const countLeavesByType = (nodes: any[]): Record<number, number> => {
|
|
const map: Record<number, number> = {};
|
|
const walk = (list: any[]) => {
|
|
for (const n of list || []) {
|
|
if (n.children?.length) { walk(n.children); continue; }
|
|
const t = Number(n.type ?? n.monitorType ?? 0);
|
|
if (t) map[t] = (map[t] || 0) + 1;
|
|
}
|
|
};
|
|
walk(nodes);
|
|
return map;
|
|
};
|
|
|
|
// ---- state ----
|
|
const loading = ref(false);
|
|
const typeCounts = ref<Record<number, number>>({});
|
|
const alarmHandled = ref(0);
|
|
const alarmUnhandled = ref(0);
|
|
const alarmList = ref<any[]>([]);
|
|
const currentTime = ref('');
|
|
|
|
// Router
|
|
const router = useRouter();
|
|
|
|
// ---- shortcuts ----
|
|
const shortcuts = [
|
|
{
|
|
title: '组态画面编辑器',
|
|
desc: '基于 Vue Flow 组态设计画面',
|
|
path: '/visualEditor',
|
|
color: 'rgba(59, 130, 246, 0.1)',
|
|
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>`
|
|
},
|
|
{
|
|
title: '设备监测点台账',
|
|
desc: '层级化设备台账及指标阈值',
|
|
path: '/ems/base/baseMonitorInfo',
|
|
color: 'rgba(99, 102, 241, 0.1)',
|
|
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="#6366f1" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>`
|
|
},
|
|
{
|
|
title: '异常告警中心',
|
|
desc: '越界异常处理与确认详情',
|
|
path: '/ems/record/recordAlarmData',
|
|
color: 'rgba(245, 158, 11, 0.1)',
|
|
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 0 1-3.46 0"/></svg>`
|
|
},
|
|
{
|
|
title: '告警规则配置',
|
|
desc: '定制采集点高低越界规则',
|
|
path: '/ems/record/recordAlarmRule',
|
|
color: 'rgba(16, 185, 129, 0.1)',
|
|
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`
|
|
}
|
|
];
|
|
|
|
// ---- KPI cards ----
|
|
const kpiCards = computed(() => {
|
|
const temp = typeCounts.value[5] || 0;
|
|
const vib = typeCounts.value[10] || 0;
|
|
const env = Object.entries(typeCounts.value).filter(([k]) => k !== '10').reduce((s, [, v]) => s + v, 0);
|
|
const totalAlarms = alarmHandled.value + alarmUnhandled.value;
|
|
const unhandled = alarmUnhandled.value;
|
|
|
|
return [
|
|
{
|
|
label: '温度测点',
|
|
value: temp,
|
|
unit: '个',
|
|
color: '#3b82f6',
|
|
grad: 'linear-gradient(135deg, #3b82f6, #60a5fa)',
|
|
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z"/></svg>`
|
|
},
|
|
{
|
|
label: '振动设备',
|
|
value: vib,
|
|
unit: '台',
|
|
color: '#6366f1',
|
|
grad: 'linear-gradient(135deg, #6366f1, #818cf8)',
|
|
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M17 5v14M22 9v6M7 5v14M2 9v6"/></svg>`
|
|
},
|
|
{
|
|
label: '温湿度',
|
|
value: env,
|
|
unit: '个',
|
|
color: '#10b981',
|
|
grad: 'linear-gradient(135deg, #10b981, #34d399)',
|
|
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 22c1.25-6.84 5.37-12 11-14.7L22 2l-5.3 9C14 16.63 8.84 20.75 2 22z"/><path d="M12 22V12"/></svg>`
|
|
},
|
|
{
|
|
label: '告警总数',
|
|
value: totalAlarms,
|
|
unit: '条',
|
|
color: '#f59e0b',
|
|
grad: 'linear-gradient(135deg, #f59e0b, #fbbf24)',
|
|
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 0 1-3.46 0"/></svg>`
|
|
},
|
|
{
|
|
label: '待处理',
|
|
value: unhandled,
|
|
unit: '条',
|
|
color: '#ef4444',
|
|
grad: 'linear-gradient(135deg, #ef4444, #f87171)',
|
|
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0zM12 9v4M12 17h.01"/></svg>`
|
|
}
|
|
];
|
|
});
|
|
|
|
// ---- charts ----
|
|
const typeBarOption = computed<EChartsOption | null>(() => {
|
|
const entries = Object.entries(typeCounts.value).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]);
|
|
if (!entries.length) return null;
|
|
return {
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
axisPointer: { type: 'shadow' },
|
|
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
|
borderColor: '#e2e8f0',
|
|
borderWidth: 1,
|
|
textStyle: { color: '#1e293b', fontSize: 12 },
|
|
shadowColor: 'rgba(0, 0, 0, 0.05)',
|
|
shadowBlur: 10
|
|
},
|
|
grid: { left: 80, right: 30, top: 15, bottom: 20 },
|
|
xAxis: {
|
|
type: 'value',
|
|
minInterval: 1,
|
|
axisLabel: { fontSize: 11, color: '#64748b' },
|
|
splitLine: { lineStyle: { type: 'dashed', color: '#f1f5f9' } }
|
|
},
|
|
yAxis: {
|
|
type: 'category',
|
|
data: entries.map(([k]) => TYPE_MAP[Number(k)] || k),
|
|
axisLabel: { fontSize: 12, color: '#334155', fontWeight: 500 },
|
|
axisLine: { show: false },
|
|
axisTick: { show: false }
|
|
},
|
|
series: [{
|
|
type: 'bar',
|
|
barWidth: 14,
|
|
data: entries.map(([k, v]) => {
|
|
const baseColor = TYPE_COLOR[Number(k)] || '#64748b';
|
|
return {
|
|
value: v,
|
|
itemStyle: {
|
|
color: {
|
|
type: 'linear',
|
|
x: 0,
|
|
y: 0,
|
|
x2: 1,
|
|
y2: 0,
|
|
colorStops: [
|
|
{ offset: 0, color: baseColor + '40' },
|
|
{ offset: 1, color: baseColor }
|
|
]
|
|
},
|
|
borderRadius: [0, 8, 8, 0]
|
|
}
|
|
};
|
|
})
|
|
}]
|
|
};
|
|
});
|
|
|
|
const alarmPieOption = computed<EChartsOption | null>(() => {
|
|
const h = alarmHandled.value;
|
|
const u = alarmUnhandled.value;
|
|
if (!h && !u) return null;
|
|
const total = h + u;
|
|
const handledRate = total > 0 ? Math.round((h / total) * 100) : 0;
|
|
|
|
return {
|
|
color: ['#10b981', '#ef4444'],
|
|
tooltip: {
|
|
trigger: 'item',
|
|
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
|
borderColor: '#e2e8f0',
|
|
borderWidth: 1,
|
|
textStyle: { color: '#1e293b', fontSize: 12 }
|
|
},
|
|
legend: {
|
|
bottom: '2%',
|
|
left: 'center',
|
|
icon: 'circle',
|
|
itemWidth: 8,
|
|
itemHeight: 8,
|
|
textStyle: { color: '#64748b', fontSize: 11 }
|
|
},
|
|
title: {
|
|
text: `${handledRate}%`,
|
|
subtext: '处理率',
|
|
left: 'center',
|
|
top: '36%',
|
|
textStyle: {
|
|
fontSize: 22,
|
|
fontWeight: 'bold',
|
|
color: '#0f172a'
|
|
},
|
|
subtextStyle: {
|
|
fontSize: 12,
|
|
color: '#64748b'
|
|
}
|
|
},
|
|
series: [{
|
|
type: 'pie',
|
|
radius: ['58%', '78%'],
|
|
center: ['50%', '44%'],
|
|
avoidLabelOverlap: false,
|
|
label: { show: false },
|
|
emphasis: {
|
|
scale: true,
|
|
scaleSize: 5,
|
|
label: { show: false }
|
|
},
|
|
data: [
|
|
{
|
|
name: '已处理',
|
|
value: h,
|
|
itemStyle: {
|
|
color: {
|
|
type: 'linear',
|
|
x: 0,
|
|
y: 0,
|
|
x2: 0,
|
|
y2: 1,
|
|
colorStops: [
|
|
{ offset: 0, color: '#10b981' },
|
|
{ offset: 1, color: '#059669' }
|
|
]
|
|
}
|
|
}
|
|
},
|
|
{
|
|
name: '待处理',
|
|
value: u,
|
|
itemStyle: {
|
|
color: {
|
|
type: 'linear',
|
|
x: 0,
|
|
y: 0,
|
|
x2: 0,
|
|
y2: 1,
|
|
colorStops: [
|
|
{ offset: 0, color: '#ef4444' },
|
|
{ offset: 1, color: '#dc2626' }
|
|
]
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}]
|
|
};
|
|
});
|
|
|
|
// ---- helpers for alarm records ----
|
|
const getAlarmLevelType = (level: string | number) => {
|
|
const lvl = String(level).toUpperCase();
|
|
if (lvl === '1' || lvl === 'CRITICAL' || lvl === 'H' || lvl === '3') return 'danger';
|
|
if (lvl === '2' || lvl === 'WARNING' || lvl === 'M' || lvl === '2') return 'warning';
|
|
return 'info';
|
|
};
|
|
|
|
const getAlarmLevelLabel = (level: string | number) => {
|
|
const lvl = String(level).toUpperCase();
|
|
if (lvl === '1' || lvl === 'CRITICAL' || lvl === 'H' || lvl === '3') return '紧急';
|
|
if (lvl === '2' || lvl === 'WARNING' || lvl === 'M' || lvl === '2') return '警告';
|
|
return '提醒';
|
|
};
|
|
|
|
const formatTime = (time: string | Date | undefined) => {
|
|
if (!time) return '--';
|
|
const date = new Date(time);
|
|
if (isNaN(date.getTime())) return String(time);
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
};
|
|
|
|
const goToPage = (path: string) => {
|
|
router.push(path);
|
|
};
|
|
|
|
// ---- clock timer ----
|
|
let timerId: number | null = null;
|
|
const updateClock = () => {
|
|
const now = new Date();
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
currentTime.value = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
|
};
|
|
|
|
// ---- load ----
|
|
const loadAll = async (force = false) => {
|
|
loading.value = true;
|
|
try {
|
|
if (force || !cachedTypeCounts) {
|
|
try {
|
|
const res = await getMonitorInfoTree({});
|
|
cachedTypeCounts = countLeavesByType(res.data || []);
|
|
} catch { cachedTypeCounts = {}; }
|
|
}
|
|
typeCounts.value = { ...cachedTypeCounts };
|
|
|
|
if (force || !cachedAlarm) {
|
|
try {
|
|
const r = await getRecordAlarmDataSummary();
|
|
const t = toNum(r.data?.totalCount);
|
|
const u = toNum(r.data?.unhandledCount);
|
|
cachedAlarm = { total: t, handled: t - u, unhandled: u };
|
|
} catch { cachedAlarm = { total: 0, handled: 0, unhandled: 0 }; }
|
|
}
|
|
alarmHandled.value = cachedAlarm.handled;
|
|
alarmUnhandled.value = cachedAlarm.unhandled;
|
|
|
|
// Load recent alarms
|
|
try {
|
|
const res = await listRecordAlarmData({ pageNum: 1, pageSize: 6 });
|
|
alarmList.value = res.rows || [];
|
|
} catch {
|
|
alarmList.value = [];
|
|
}
|
|
} finally { loading.value = false; }
|
|
};
|
|
|
|
onMounted(() => {
|
|
void loadAll();
|
|
updateClock();
|
|
timerId = window.setInterval(updateClock, 1000);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
if (timerId) {
|
|
clearInterval(timerId);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.home-dashboard {
|
|
min-height: calc(100vh - 84px);
|
|
padding: 24px;
|
|
background: #f1f5f9;
|
|
}
|
|
|
|
// Header
|
|
.db-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 24px;
|
|
padding: 24px 30px;
|
|
background: #ffffff;
|
|
border-radius: 16px;
|
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
|
|
border: 1px solid rgba(226, 232, 240, 0.8);
|
|
}
|
|
.db-header-left {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
|
|
h1 {
|
|
margin: 4px 0 2px;
|
|
font-size: 24px;
|
|
color: #0f172a;
|
|
font-weight: 800;
|
|
letter-spacing: -0.5px;
|
|
}
|
|
.subtitle {
|
|
margin: 0;
|
|
font-size: 14px;
|
|
color: #64748b;
|
|
}
|
|
}
|
|
.db-badge-row {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
.db-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 4px 12px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: #3b82f6;
|
|
background: rgba(59, 130, 246, 0.08);
|
|
border-radius: 99px;
|
|
letter-spacing: 0.5px;
|
|
|
|
.pulse-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
background-color: #10b981;
|
|
border-radius: 50%;
|
|
animation: pulse 2s infinite;
|
|
}
|
|
}
|
|
.db-header-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
}
|
|
.db-clock-pill {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px 16px;
|
|
border-radius: 99px;
|
|
background: #f8fafc;
|
|
border: 1px solid #e2e8f0;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: #475569;
|
|
|
|
.clock-icon {
|
|
width: 15px;
|
|
height: 15px;
|
|
color: #64748b;
|
|
}
|
|
}
|
|
.refresh-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
border-radius: 99px;
|
|
padding: 8px 20px;
|
|
font-weight: 500;
|
|
height: auto;
|
|
border: none;
|
|
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
|
color: #ffffff;
|
|
box-shadow: 0 4px 10px rgba(59, 130, 246, 0.3);
|
|
transition: all 0.3s ease;
|
|
cursor: pointer;
|
|
|
|
&:hover {
|
|
background: linear-gradient(135deg, #2563eb, #1d4ed8);
|
|
box-shadow: 0 6px 14px rgba(59, 130, 246, 0.4);
|
|
transform: translateY(-1px);
|
|
color: #ffffff;
|
|
}
|
|
|
|
.refresh-icon {
|
|
width: 14px;
|
|
height: 14px;
|
|
}
|
|
}
|
|
|
|
// KPI
|
|
.kpi-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(5, 1fr);
|
|
gap: 16px;
|
|
margin-bottom: 24px;
|
|
}
|
|
.kpi-item {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
padding: 24px 20px;
|
|
background: #ffffff;
|
|
border-radius: 16px;
|
|
border: 1px solid rgba(226, 232, 240, 0.8);
|
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
overflow: hidden;
|
|
|
|
&::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 4px;
|
|
height: 100%;
|
|
background: var(--accent-grad);
|
|
opacity: 0.8;
|
|
}
|
|
|
|
&:hover {
|
|
transform: translateY(-4px);
|
|
box-shadow: 0 12px 20px -8px rgba(0, 0, 0, 0.1), 0 4px 12px -2px rgba(0, 0, 0, 0.05);
|
|
border-color: var(--accent);
|
|
}
|
|
}
|
|
.kpi-icon {
|
|
width: 46px;
|
|
height: 46px;
|
|
border-radius: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #ffffff;
|
|
flex-shrink: 0;
|
|
|
|
span {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 22px;
|
|
height: 22px;
|
|
}
|
|
}
|
|
.kpi-body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
min-width: 0;
|
|
}
|
|
.kpi-num {
|
|
font-size: 26px;
|
|
font-weight: 800;
|
|
color: #0f172a;
|
|
line-height: 1;
|
|
letter-spacing: -0.5px;
|
|
|
|
small {
|
|
margin-left: 4px;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: #94a3b8;
|
|
}
|
|
}
|
|
.kpi-title {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: #64748b;
|
|
}
|
|
|
|
// Charts
|
|
.chart-row {
|
|
display: grid;
|
|
grid-template-columns: 1.2fr 0.8fr;
|
|
gap: 24px;
|
|
margin-bottom: 24px;
|
|
}
|
|
.chart-panel {
|
|
padding: 24px;
|
|
background: #ffffff;
|
|
border-radius: 16px;
|
|
border: 1px solid rgba(226, 232, 240, 0.8);
|
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0; // 防止子元素撑开容器导致布局挤压变形
|
|
}
|
|
.panel-header-simple {
|
|
margin-bottom: 16px;
|
|
}
|
|
.panel-title-with-bar, .panel-title-with-bar2 {
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
color: #1e293b;
|
|
position: relative;
|
|
padding-left: 12px;
|
|
|
|
&::before {
|
|
content: '';
|
|
position: absolute;
|
|
left: 0;
|
|
top: 3px;
|
|
width: 4px;
|
|
height: 14px;
|
|
border-radius: 2px;
|
|
}
|
|
}
|
|
.panel-title-with-bar::before {
|
|
background: #3b82f6;
|
|
}
|
|
.panel-title-with-bar2::before {
|
|
background: #10b981;
|
|
}
|
|
.chart-sub {
|
|
font-size: 12px;
|
|
color: #94a3b8;
|
|
margin-top: 4px;
|
|
font-weight: 400;
|
|
}
|
|
.chart-container {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 260px;
|
|
}
|
|
.chart-box {
|
|
width: 100%;
|
|
height: 260px;
|
|
}
|
|
|
|
// Bottom layout
|
|
.bottom-row {
|
|
display: grid;
|
|
grid-template-columns: 1.2fr 0.8fr;
|
|
gap: 24px;
|
|
}
|
|
.bottom-card {
|
|
background: #ffffff;
|
|
border-radius: 16px;
|
|
border: 1px solid rgba(226, 232, 240, 0.8);
|
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
|
|
padding: 24px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0; // 防止内部 el-table 撑开容器导致挤压右侧快捷通道
|
|
}
|
|
.panel-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 20px;
|
|
}
|
|
.panel-title-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
.panel-icon {
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 10px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #ffffff;
|
|
|
|
svg {
|
|
width: 18px;
|
|
height: 18px;
|
|
}
|
|
}
|
|
.bg-grad-alarm-icon {
|
|
background: linear-gradient(135deg, #ef4444, #f43f5e);
|
|
}
|
|
.bg-grad-shortcut-icon {
|
|
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
|
}
|
|
.panel-titles {
|
|
h3 {
|
|
margin: 0;
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
color: #1e293b;
|
|
}
|
|
p {
|
|
margin: 2px 0 0;
|
|
font-size: 12px;
|
|
color: #94a3b8;
|
|
}
|
|
}
|
|
.more-link {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
|
|
.arrow-icon {
|
|
width: 12px;
|
|
height: 12px;
|
|
transition: transform 0.2s ease;
|
|
}
|
|
|
|
&:hover .arrow-icon {
|
|
transform: translateX(2px);
|
|
}
|
|
}
|
|
|
|
// Alarm Table Custom
|
|
.alarm-table-wrapper {
|
|
flex: 1;
|
|
|
|
:deep(.el-table) {
|
|
--el-table-border-color: #f1f5f9;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
border: 1px solid #f1f5f9;
|
|
}
|
|
|
|
:deep(.el-table__header-wrapper) {
|
|
th {
|
|
font-size: 12px;
|
|
}
|
|
}
|
|
|
|
:deep(.el-table__row) {
|
|
font-size: 12px;
|
|
color: #334155;
|
|
transition: background-color 0.2s ease;
|
|
|
|
&:hover {
|
|
background-color: #f8fafc !important;
|
|
}
|
|
}
|
|
}
|
|
.alarm-title-text {
|
|
font-weight: 500;
|
|
color: #1e293b;
|
|
}
|
|
.status-tag, .level-tag {
|
|
font-weight: 600;
|
|
border-radius: 6px;
|
|
}
|
|
.actual-val {
|
|
color: #ef4444;
|
|
font-weight: 700;
|
|
}
|
|
.val-sep {
|
|
margin: 0 4px;
|
|
color: #cbd5e1;
|
|
}
|
|
.threshold-val {
|
|
color: #64748b;
|
|
font-weight: 500;
|
|
}
|
|
.time-text {
|
|
color: #64748b;
|
|
}
|
|
|
|
// Shortcut Card Custom
|
|
.shortcut-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
gap: 12px;
|
|
flex: 1;
|
|
}
|
|
.shortcut-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
padding: 14px 18px;
|
|
border-radius: 12px;
|
|
background: #f8fafc;
|
|
border: 1px solid #f1f5f9;
|
|
cursor: pointer;
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
|
|
&:hover {
|
|
background: #ffffff;
|
|
border-color: #e2e8f0;
|
|
box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.05);
|
|
transform: translateY(-2px);
|
|
|
|
.sc-arrow {
|
|
color: #3b82f6;
|
|
transform: translateX(2px);
|
|
}
|
|
}
|
|
}
|
|
.sc-icon-circle {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 10px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
|
|
.sc-svg {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 20px;
|
|
height: 20px;
|
|
}
|
|
}
|
|
.sc-content {
|
|
flex: 1;
|
|
min-width: 0;
|
|
|
|
h4 {
|
|
margin: 0;
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
color: #1e293b;
|
|
}
|
|
p {
|
|
margin: 4px 0 0;
|
|
font-size: 11px;
|
|
color: #94a3b8;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
}
|
|
.sc-arrow {
|
|
color: #cbd5e1;
|
|
display: flex;
|
|
align-items: center;
|
|
transition: all 0.2s ease;
|
|
|
|
svg {
|
|
width: 14px;
|
|
height: 14px;
|
|
}
|
|
}
|
|
|
|
// Animations & Keyframes
|
|
@keyframes pulse {
|
|
0% {
|
|
transform: scale(0.95);
|
|
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
|
|
}
|
|
70% {
|
|
transform: scale(1);
|
|
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0);
|
|
}
|
|
100% {
|
|
transform: scale(0.95);
|
|
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
|
}
|
|
}
|
|
|
|
// Responsive
|
|
@media (max-width: 1400px) {
|
|
.kpi-row {
|
|
grid-template-columns: repeat(3, 1fr);
|
|
}
|
|
}
|
|
@media (max-width: 1100px) {
|
|
.chart-row, .bottom-row {
|
|
grid-template-columns: 1fr;
|
|
gap: 20px;
|
|
}
|
|
}
|
|
@media (max-width: 768px) {
|
|
.kpi-row {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
.db-header {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
gap: 16px;
|
|
}
|
|
}
|
|
@media (max-width: 480px) {
|
|
.kpi-row {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|