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

<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>