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.

706 lines
18 KiB
Vue

3 months ago
<template>
<div v-loading="loading" class="app-container home-dashboard">
<section class="hero-panel">
<div class="hero-copy">
<p class="hero-eyebrow">Hawei Plus Overview</p>
<h1>海威PLUS 运营概览</h1>
<p class="hero-desc">
首页直接展示当前系统最新监测快照与报警概况所有卡片和图表都基于实时接口返回的真实数据生成方便值班人员进入系统后先看到整体状态再进入明细页面处理
3 months ago
</p>
<div class="hero-tags">
<el-tag effect="dark">实时快照</el-tag>
<el-tag effect="plain">报警概览</el-tag>
<el-tag effect="plain">分组分布</el-tag>
<el-tag effect="plain">重点设备</el-tag>
</div>
</div>
<div class="hero-side">
<div class="hero-side-card">
<span>最近刷新</span>
<strong>{{ lastUpdateTime || '--' }}</strong>
<p>按首页初始化时接口返回的最新时间戳展示</p>
</div>
<div class="hero-side-card">
<span>当前主看指标</span>
<strong>{{ primaryMetricLabel }}</strong>
<p>用于分组均值和设备快照排序的默认指标</p>
</div>
</div>
</section>
<div class="overview-grid">
<div v-for="item in overviewCards" :key="item.label" class="overview-card">
<span class="overview-label">{{ item.label }}</span>
<strong class="overview-value">{{ item.value }}</strong>
<p class="overview-desc">{{ item.desc }}</p>
</div>
</div>
<section class="glass-panel logic-panel">
<div class="section-head">
<div>
<p class="section-eyebrow">Logic Notes</p>
<h3>首页统计口径</h3>
</div>
</div>
<div class="logic-grid">
<div v-for="item in logicNotes" :key="item.title" class="logic-card">
<div class="logic-title">{{ item.title }}</div>
<p class="logic-desc">{{ item.desc }}</p>
</div>
</div>
</section>
<div class="chart-grid two-col">
<article class="chart-card">
<div class="chart-head">
<h3>指标快照对比</h3>
<p>以最新快照为样本对比活跃指标的平均值与峰值</p>
</div>
<Chart class="chart chart-lg" :chart-option="metricOverviewOption" />
</article>
<article class="chart-card">
<div class="chart-head">
<h3>报警处理概览</h3>
<p>直接展示当前未处理已处理和其余统计情况</p>
</div>
<Chart class="chart chart-lg" :chart-option="alarmOverviewOption" />
</article>
</div>
<div class="chart-grid two-col">
<article class="chart-card">
<div class="chart-head">
<h3>分组设备概览</h3>
<p>按设备树顶层分组统计设备数量并对主看指标求最新均值</p>
</div>
<Chart class="chart chart-md" :chart-option="groupOverviewOption" />
</article>
<article class="chart-card">
<div class="chart-head">
<h3>重点设备快照</h3>
<p>按主看指标的最新值降序展示重点设备</p>
</div>
<Chart class="chart chart-md" :chart-option="deviceSnapshotOption" />
</article>
</div>
<section class="glass-panel table-panel">
<div class="section-head">
<div>
<p class="section-eyebrow">Latest Snapshot</p>
<h3>最新设备清单</h3>
</div>
</div>
<div v-if="!latestRecords.length" class="empty-panel">
<el-empty description="当前没有获取到最新快照数据" />
</div>
<el-table v-else :data="latestTableData" stripe class="snapshot-table">
<el-table-column prop="monitorName" label="设备名称" min-width="180" />
<el-table-column prop="groupLabel" label="所属分组" min-width="140" />
<el-table-column prop="metricLabel" label="主看指标" min-width="120" />
<el-table-column prop="metricValue" label="最新值" min-width="120" />
<el-table-column prop="recodeTime" label="记录时间" min-width="180" />
</el-table>
</section>
3 months ago
</div>
</template>
<script setup name="Index" lang="ts">
import Chart from '@/components/Charts/Chart.vue';
import type { EChartsOption } from 'echarts';
import { getRecordAlarmDataSummary } from '@/api/ems/record/recordAlarmData';
import { getLatestRecords } from '@/api/ems/record/recordIotenvInstant';
import { getMonitorInfoTree } from '@/api/ems/base/baseMonitorInfo';
import { formatValue } from './ems/report/iotReportShared';
import { normalizeIotCurveRecord, readMetricValue, resolveIotCurveMetrics } from './ems/report/iotCurveShared';
interface GroupSnapshotItem {
label: string;
deviceCount: number;
avgValue: number;
}
const loading = ref(false);
const latestRecords = ref<any[]>([]);
const monitorTreeOptions = ref<any[]>([]);
const alarmSummary = ref<Record<string, number>>({});
const lastUpdateTime = computed(() => {
return latestRecords.value.length ? latestRecords.value.map((item) => item.__timeKey).filter(Boolean).sort().at(-1) || '' : '';
});
const getLeafNodes = (nodes, rootLabel = '') => {
let result = [];
(nodes || []).forEach((item) => {
const currentRootLabel = rootLabel || String(item.label || '未命名分组');
if (Array.isArray(item.children) && item.children.length > 0) {
result = result.concat(getLeafNodes(item.children, currentRootLabel));
return;
}
if (item.code) {
result.push({
...item,
__rootLabel: currentRootLabel
});
}
});
return result;
3 months ago
};
const leafNodeMap = computed(() => {
return getLeafNodes(monitorTreeOptions.value).reduce((accumulator, item) => {
accumulator[String(item.code)] = item;
return accumulator;
}, {});
});
const metricConfigs = computed(() => {
return resolveIotCurveMetrics(latestRecords.value, []);
});
const primaryMetric = computed(() => metricConfigs.value[0] || null);
const primaryMetricLabel = computed(() => (primaryMetric.value ? primaryMetric.value.label : '待识别'));
const metricStats = computed(() => {
return metricConfigs.value.map((metric) => {
const values = latestRecords.value.map((item) => readMetricValue(item, metric)).filter((item): item is number => item !== null);
const latestValue = values.at(-1) ?? null;
const avgValue = values.length ? values.reduce((sum, item) => sum + item, 0) / values.length : 0;
const maxValue = values.length ? Math.max(...values) : 0;
return {
...metric,
latestValue,
avgValue,
maxValue,
count: values.length
};
});
});
const groupSnapshot = computed<GroupSnapshotItem[]>(() => {
if (!primaryMetric.value) {
return [];
3 months ago
}
const bucket = {} as Record<string, number[]>;
latestRecords.value.forEach((item) => {
const monitorId = String(item.monitorId || item.monitorCode || '');
const rootLabel = leafNodeMap.value[monitorId]?.__rootLabel || '未归类';
const metricValue = readMetricValue(item, primaryMetric.value!);
if (!bucket[rootLabel]) {
bucket[rootLabel] = [];
}
if (metricValue !== null) {
bucket[rootLabel].push(metricValue);
}
});
return Object.keys(bucket).map((label) => ({
label,
deviceCount: getLeafNodes(monitorTreeOptions.value).filter((item) => item.__rootLabel === label).length,
avgValue: bucket[label].length ? bucket[label].reduce((sum, item) => sum + item, 0) / bucket[label].length : 0
}));
});
const latestTableData = computed(() => {
return latestRecords.value.slice(0, 12).map((item) => {
const monitorId = String(item.monitorId || item.monitorCode || '');
const metricValue = primaryMetric.value ? readMetricValue(item, primaryMetric.value) : null;
return {
monitorName: String(item.monitorName || leafNodeMap.value[monitorId]?.label || monitorId),
groupLabel: leafNodeMap.value[monitorId]?.__rootLabel || '未归类',
metricLabel: primaryMetric.value?.label || '--',
metricValue: primaryMetric.value?.unit ? `${formatValue(metricValue)} ${primaryMetric.value.unit}` : formatValue(metricValue),
recodeTime: item.__timeKey || '--'
};
});
});
const overviewCards = computed(() => [
{
label: '最新设备数',
value: latestRecords.value.length,
desc: '来自最新快照接口返回的设备条数。'
},
{
label: '活跃指标数',
value: metricConfigs.value.length,
desc: '根据最新快照里真实有值的字段自动识别。'
},
{
label: '报警总数',
value: Number(alarmSummary.value.totalCount || 0),
desc: '来自报警概览接口的累计统计。'
},
{
label: '未处理报警',
value: Number(alarmSummary.value.unhandledCount || 0),
desc: '优先关注待处理报警数量。'
3 months ago
}
]);
const logicNotes = computed(() => [
{
title: '设备数口径',
desc: '首页设备数直接使用最新快照接口返回条数,不做虚构补数。'
},
{
title: '指标口径',
desc: `活跃指标从最新快照里真实有值的字段识别得到,当前默认主看指标为 ${primaryMetricLabel.value}`
},
{
title: '分组口径',
desc: '分组统计按设备树顶层节点聚合,分组均值使用主看指标在该分组最新快照中的平均值。'
3 months ago
}
]);
3 months ago
const metricOverviewOption = computed<EChartsOption | null>(() => {
if (!metricStats.value.length) {
return null;
3 months ago
}
return {
tooltip: {
trigger: 'axis'
},
legend: {
top: 8,
data: ['平均值', '峰值']
},
grid: {
top: 54,
left: '10%',
right: '6%',
bottom: 36
},
xAxis: {
type: 'category',
data: metricStats.value.map((item) => item.label)
},
yAxis: {
type: 'value',
splitLine: {
lineStyle: {
type: 'dashed'
}
}
},
series: [
{
name: '平均值',
type: 'bar',
barWidth: 18,
data: metricStats.value.map((item) => Number(item.avgValue.toFixed(2))),
itemStyle: {
color: '#2563eb',
borderRadius: [6, 6, 0, 0]
}
},
{
name: '峰值',
type: 'bar',
barWidth: 18,
data: metricStats.value.map((item) => Number(item.maxValue.toFixed(2))),
itemStyle: {
color: '#14b8a6',
borderRadius: [6, 6, 0, 0]
}
}
]
};
});
3 months ago
const alarmOverviewOption = computed<EChartsOption>(() => {
const totalCount = Number(alarmSummary.value.totalCount || 0);
const unhandledCount = Number(alarmSummary.value.unhandledCount || 0);
const handledCount = Number(alarmSummary.value.handledCount || 0);
return {
tooltip: {
trigger: 'item'
},
legend: {
bottom: 0
},
series: [
{
type: 'pie',
radius: ['48%', '72%'],
center: ['50%', '48%'],
label: {
formatter: '{b}\n{c}'
},
data: [
{ name: '未处理', value: unhandledCount, itemStyle: { color: '#ef4444' } },
{ name: '已处理', value: handledCount, itemStyle: { color: '#22c55e' } },
{ name: '其余', value: Math.max(totalCount - unhandledCount - handledCount, 0), itemStyle: { color: '#94a3b8' } }
]
}
]
};
});
3 months ago
const groupOverviewOption = computed<EChartsOption | null>(() => {
if (!groupSnapshot.value.length) {
return null;
3 months ago
}
return {
tooltip: {
trigger: 'axis'
},
legend: {
top: 8,
data: ['设备数', `${primaryMetricLabel.value}均值`]
},
grid: {
top: 56,
left: '10%',
right: '8%',
bottom: 40
},
xAxis: {
type: 'category',
data: groupSnapshot.value.map((item) => item.label)
},
yAxis: [
{ type: 'value', name: '设备数' },
{ type: 'value', name: `${primaryMetricLabel.value}均值` }
],
series: [
{
name: '设备数',
type: 'bar',
barWidth: 18,
data: groupSnapshot.value.map((item) => item.deviceCount),
itemStyle: {
color: '#0f766e',
borderRadius: [6, 6, 0, 0]
}
},
{
name: `${primaryMetricLabel.value}均值`,
type: 'line',
yAxisIndex: 1,
smooth: true,
data: groupSnapshot.value.map((item) => Number(item.avgValue.toFixed(2))),
lineStyle: {
color: '#2563eb',
width: 3
},
itemStyle: {
color: '#2563eb'
}
}
]
};
});
3 months ago
const deviceSnapshotOption = computed<EChartsOption | null>(() => {
if (!primaryMetric.value) {
return null;
3 months ago
}
const topDevices = latestRecords.value
.map((item) => {
const monitorId = String(item.monitorId || item.monitorCode || '');
return {
monitorId,
monitorName: String(item.monitorName || leafNodeMap.value[monitorId]?.label || monitorId),
value: readMetricValue(item, primaryMetric.value!)
};
})
.filter((item) => item.value !== null)
.sort((left, right) => Number(right.value) - Number(left.value))
.slice(0, 10);
3 months ago
return {
tooltip: {
trigger: 'axis'
},
grid: {
top: 20,
left: '18%',
right: '6%',
bottom: 36
},
xAxis: {
type: 'value',
name: primaryMetric.value.unit ? `${primaryMetric.value.label}(${primaryMetric.value.unit})` : primaryMetric.value.label
},
yAxis: {
type: 'category',
data: topDevices.map((item) => item.monitorName.length > 12 ? `${item.monitorName.slice(0, 11)}...` : item.monitorName)
},
series: [
{
type: 'bar',
data: topDevices.map((item) => item.value),
itemStyle: {
color: primaryMetric.value.color,
borderRadius: [0, 6, 6, 0]
}
}
]
};
});
const loadDashboard = async () => {
loading.value = true;
try {
const [latestRes, alarmRes, treeRes] = await Promise.all([getLatestRecords(), getRecordAlarmDataSummary(), getMonitorInfoTree({})]);
latestRecords.value = (latestRes.data || []).map((item) => normalizeIotCurveRecord(item));
alarmSummary.value = (alarmRes.data || {}) as Record<string, number>;
monitorTreeOptions.value = treeRes.data || [];
} finally {
loading.value = false;
3 months ago
}
};
3 months ago
onMounted(() => {
void loadDashboard();
});
</script>
3 months ago
<style lang="scss" scoped>
.home-dashboard {
min-height: calc(100vh - 84px);
background:
radial-gradient(circle at top right, rgba(37, 99, 235, 0.14), transparent 24%),
radial-gradient(circle at top left, rgba(20, 184, 166, 0.1), transparent 28%),
linear-gradient(180deg, #f4f8ff 0%, #f8fafc 100%);
}
.hero-panel,
.glass-panel,
.chart-card,
.overview-card {
border-radius: 24px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
}
.hero-panel {
display: grid;
grid-template-columns: minmax(0, 1.2fr) 320px;
gap: 20px;
padding: 28px;
margin-bottom: 18px;
background:
linear-gradient(135deg, rgba(15, 118, 110, 0.96), rgba(37, 99, 235, 0.92)),
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0));
color: #ffffff;
}
.hero-eyebrow,
.section-eyebrow {
margin: 0 0 8px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.hero-eyebrow {
color: rgba(255, 255, 255, 0.72);
}
.hero-panel h1,
.section-head h3,
.chart-head h3 {
margin: 0;
}
.hero-panel h1 {
font-size: 34px;
line-height: 1.2;
}
.hero-desc {
margin: 16px 0 0;
max-width: 760px;
line-height: 1.8;
color: rgba(255, 255, 255, 0.84);
}
.hero-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 18px;
}
.hero-side {
display: grid;
gap: 16px;
}
.hero-side-card {
padding: 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.14);
border: 1px solid rgba(255, 255, 255, 0.18);
}
.hero-side-card span {
display: block;
font-size: 12px;
color: rgba(255, 255, 255, 0.72);
}
.hero-side-card strong {
display: block;
margin-top: 10px;
font-size: 22px;
font-weight: 700;
}
.hero-side-card p {
margin: 10px 0 0;
color: rgba(255, 255, 255, 0.78);
line-height: 1.6;
font-size: 12px;
}
.overview-grid,
.logic-grid,
.chart-grid {
display: grid;
gap: 16px;
}
.overview-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 18px;
}
.overview-card {
padding: 18px;
}
.overview-label {
display: block;
color: #64748b;
font-size: 12px;
}
.overview-value {
display: block;
margin-top: 12px;
font-size: 28px;
font-weight: 700;
color: #0f172a;
}
.overview-desc {
margin: 10px 0 0;
color: #64748b;
line-height: 1.6;
font-size: 13px;
}
.glass-panel,
.table-panel {
padding: 18px 20px;
margin-bottom: 18px;
}
.section-head,
.chart-head {
margin-bottom: 14px;
}
.section-eyebrow {
color: #2563eb;
}
.logic-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.logic-card {
padding: 16px;
border-radius: 18px;
border: 1px solid rgba(226, 232, 240, 0.92);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.94)),
linear-gradient(135deg, rgba(37, 99, 235, 0.05), rgba(20, 184, 166, 0.04));
}
.logic-title {
color: #0f172a;
font-size: 15px;
font-weight: 700;
}
.logic-desc {
margin: 10px 0 0;
color: #64748b;
line-height: 1.7;
font-size: 13px;
}
.two-col {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 18px;
}
.chart-card {
padding: 16px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.94)),
linear-gradient(135deg, rgba(37, 99, 235, 0.05), rgba(20, 184, 166, 0.04));
}
.chart-head p {
margin: 6px 0 0;
color: #64748b;
line-height: 1.6;
font-size: 12px;
}
.chart {
width: 100%;
}
.chart-lg {
height: 42vh;
min-height: 320px;
}
.chart-md {
height: 38vh;
min-height: 280px;
}
.empty-panel {
padding: 32px 0;
}
@media (max-width: 1200px) {
.hero-panel,
.overview-grid,
.logic-grid,
.two-col {
grid-template-columns: 1fr;
3 months ago
}
}
3 months ago
@media (max-width: 768px) {
.hero-panel,
.glass-panel,
.chart-card,
.overview-card {
padding: 16px;
border-radius: 20px;
}
.hero-panel h1 {
font-size: 26px;
3 months ago
}
}
</style>