|
|
|
|
@ -1,165 +1,705 @@
|
|
|
|
|
<template>
|
|
|
|
|
<div class="app-container home">
|
|
|
|
|
<el-row :gutter="20">
|
|
|
|
|
<el-col :sm="24" :lg="12" style="padding-left: 20px">
|
|
|
|
|
<h2>RuoYi-Vue-Plus多租户管理系统</h2>
|
|
|
|
|
<p>
|
|
|
|
|
RuoYi-Vue-Plus 是基于 RuoYi-Vue 针对 分布式集群 场景升级(不兼容原框架)
|
|
|
|
|
<br />
|
|
|
|
|
* 前端开发框架 Vue3、TS、Element Plus<br />
|
|
|
|
|
* 后端开发框架 Spring Boot<br />
|
|
|
|
|
* 容器框架 Undertow 基于 Netty 的高性能容器<br />
|
|
|
|
|
* 权限认证框架 Sa-Token 支持多终端认证系统<br />
|
|
|
|
|
* 关系数据库 MySQL 适配 8.X 最低 5.7<br />
|
|
|
|
|
* 缓存数据库 Redis 适配 6.X 最低 4.X<br />
|
|
|
|
|
* 数据库框架 Mybatis-Plus 快速 CRUD 增加开发效率<br />
|
|
|
|
|
* 数据库框架 p6spy 更强劲的 SQL 分析<br />
|
|
|
|
|
* 多数据源框架 dynamic-datasource 支持主从与多种类数据库异构<br />
|
|
|
|
|
* 序列化框架 Jackson 统一使用 jackson 高效可靠<br />
|
|
|
|
|
* Redis客户端 Redisson 性能强劲、API丰富<br />
|
|
|
|
|
* 分布式限流 Redisson 全局、请求IP、集群ID 多种限流<br />
|
|
|
|
|
* 分布式锁 Lock4j 注解锁、工具锁 多种多样<br />
|
|
|
|
|
* 分布式幂等 Lock4j 基于分布式锁实现<br />
|
|
|
|
|
* 分布式链路追踪 SkyWalking 支持链路追踪、网格分析、度量聚合、可视化<br />
|
|
|
|
|
* 分布式任务调度 SnailJob 高性能 高可靠 易扩展<br />
|
|
|
|
|
* 文件存储 Minio 本地存储<br />
|
|
|
|
|
* 文件存储 七牛、阿里、腾讯 云存储<br />
|
|
|
|
|
* 监控框架 SpringBoot-Admin 全方位服务监控<br />
|
|
|
|
|
* 校验框架 Validation 增强接口安全性 严谨性<br />
|
|
|
|
|
* Excel框架 FastExcel(原Alibaba EasyExcel) 性能优异 扩展性强<br />
|
|
|
|
|
* 文档框架 SpringDoc、javadoc 无注解零入侵基于java注释<br />
|
|
|
|
|
* 工具类框架 Hutool、Lombok 减少代码冗余 增加安全性<br />
|
|
|
|
|
* 代码生成器 适配MP、SpringDoc规范化代码 一键生成前后端代码<br />
|
|
|
|
|
* 部署方式 Docker 容器编排 一键部署业务集群<br />
|
|
|
|
|
* 国际化 SpringMessage Spring标准国际化方案<br />
|
|
|
|
|
<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">
|
|
|
|
|
首页直接展示当前系统最新监测快照与报警概况。所有卡片和图表都基于实时接口返回的真实数据生成,方便值班人员进入系统后先看到整体状态,再进入明细页面处理。
|
|
|
|
|
</p>
|
|
|
|
|
<p><b>当前版本:</b> <span>v5.6.0</span></p>
|
|
|
|
|
<p>
|
|
|
|
|
<el-tag type="danger">¥免费开源</el-tag>
|
|
|
|
|
</p>
|
|
|
|
|
<p>
|
|
|
|
|
<el-button type="primary" icon="Cloudy" plain @click="goTarget('https://gitee.com/dromara/RuoYi-Vue-Plus')">访问码云</el-button>
|
|
|
|
|
<el-button type="primary" icon="Cloudy" plain @click="goTarget('https://github.com/dromara/RuoYi-Vue-Plus')">访问GitHub</el-button>
|
|
|
|
|
<el-button type="primary" icon="Cloudy" plain @click="goTarget('https://plus-doc.dromara.org/#/ruoyi-vue-plus/changlog')"
|
|
|
|
|
>更新日志</el-button
|
|
|
|
|
>
|
|
|
|
|
</p>
|
|
|
|
|
</el-col>
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
<el-col :sm="24" :lg="12" style="padding-left: 20px">
|
|
|
|
|
<h2>RuoYi-Cloud-Plus多租户微服务管理系统</h2>
|
|
|
|
|
<p>
|
|
|
|
|
RuoYi-Cloud-Plus 微服务通用权限管理系统 重写 RuoYi-Cloud 全方位升级(不兼容原框架)
|
|
|
|
|
<br />
|
|
|
|
|
* 前端开发框架 Vue3、TS、Element UI<br />
|
|
|
|
|
* 后端开发框架 Spring Boot<br />
|
|
|
|
|
* 微服务开发框架 Spring Cloud、Spring Cloud Alibaba<br />
|
|
|
|
|
* 容器框架 Undertow 基于 XNIO 的高性能容器<br />
|
|
|
|
|
* 权限认证框架 Sa-Token、Jwt 支持多终端认证系统<br />
|
|
|
|
|
* 关系数据库 MySQL 适配 8.X 最低 5.7<br />
|
|
|
|
|
* 关系数据库 Oracle 适配 11g 12c<br />
|
|
|
|
|
* 关系数据库 PostgreSQL 适配 13 14<br />
|
|
|
|
|
* 关系数据库 SQLServer 适配 2017 2019<br />
|
|
|
|
|
* 缓存数据库 Redis 适配 6.X 最低 5.X<br />
|
|
|
|
|
* 分布式注册中心 Alibaba Nacos 采用2.X 基于GRPC通信高性能<br />
|
|
|
|
|
* 分布式配置中心 Alibaba Nacos 采用2.X 基于GRPC通信高性能<br />
|
|
|
|
|
* 服务网关 Spring Cloud Gateway 响应式高性能网关<br />
|
|
|
|
|
* 负载均衡 Spring Cloud Loadbalancer 负载均衡处理<br />
|
|
|
|
|
* RPC远程调用 Apache Dubbo 原生态使用体验、高性能<br />
|
|
|
|
|
* 分布式限流熔断 Alibaba Sentinel 无侵入、高扩展<br />
|
|
|
|
|
* 分布式事务 Alibaba Seata 无侵入、高扩展 支持 四种模式<br />
|
|
|
|
|
* 分布式消息队列 Apache Kafka 高性能高速度<br />
|
|
|
|
|
* 分布式消息队列 Apache RocketMQ 高可用功能多样<br />
|
|
|
|
|
* 分布式消息队列 RabbitMQ 支持各种扩展插件功能多样性<br />
|
|
|
|
|
* 分布式搜索引擎 ElasticSearch 业界知名<br />
|
|
|
|
|
* 分布式链路追踪 Apache SkyWalking 链路追踪、网格分析、度量聚合、可视化<br />
|
|
|
|
|
* 分布式日志中心 ELK 业界成熟解决方案<br />
|
|
|
|
|
* 分布式监控 Prometheus、Grafana 全方位性能监控<br />
|
|
|
|
|
* 其余与 Vue 版本一致<br />
|
|
|
|
|
</p>
|
|
|
|
|
<p><b>当前版本:</b> <span>v2.6.0</span></p>
|
|
|
|
|
<p>
|
|
|
|
|
<el-tag type="danger">¥免费开源</el-tag>
|
|
|
|
|
</p>
|
|
|
|
|
<p>
|
|
|
|
|
<el-button type="primary" icon="Cloudy" plain @click="goTarget('https://gitee.com/dromara/RuoYi-Cloud-Plus')">访问码云</el-button>
|
|
|
|
|
<el-button type="primary" icon="Cloudy" plain @click="goTarget('https://github.com/dromara/RuoYi-Cloud-Plus')">访问GitHub</el-button>
|
|
|
|
|
<el-button type="primary" icon="Cloudy" plain @click="goTarget('https://plus-doc.dromara.org/#/ruoyi-cloud-plus/changlog')"
|
|
|
|
|
>更新日志</el-button
|
|
|
|
|
>
|
|
|
|
|
</p>
|
|
|
|
|
</el-col>
|
|
|
|
|
</el-row>
|
|
|
|
|
<el-divider />
|
|
|
|
|
<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>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup name="Index" lang="ts">
|
|
|
|
|
const goTarget = (url: string) => {
|
|
|
|
|
window.open(url, '__blank');
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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 [];
|
|
|
|
|
}
|
|
|
|
|
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: '优先关注待处理报警数量。'
|
|
|
|
|
}
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const logicNotes = computed(() => [
|
|
|
|
|
{
|
|
|
|
|
title: '设备数口径',
|
|
|
|
|
desc: '首页设备数直接使用最新快照接口返回条数,不做虚构补数。'
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: '指标口径',
|
|
|
|
|
desc: `活跃指标从最新快照里真实有值的字段识别得到,当前默认主看指标为 ${primaryMetricLabel.value}。`
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: '分组口径',
|
|
|
|
|
desc: '分组统计按设备树顶层节点聚合,分组均值使用主看指标在该分组最新快照中的平均值。'
|
|
|
|
|
}
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const metricOverviewOption = computed<EChartsOption | null>(() => {
|
|
|
|
|
if (!metricStats.value.length) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
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]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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' } }
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const groupOverviewOption = computed<EChartsOption | null>(() => {
|
|
|
|
|
if (!groupSnapshot.value.length) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
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'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const deviceSnapshotOption = computed<EChartsOption | null>(() => {
|
|
|
|
|
if (!primaryMetric.value) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
void loadDashboard();
|
|
|
|
|
});
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
|
|
.home {
|
|
|
|
|
blockquote {
|
|
|
|
|
padding: 10px 20px;
|
|
|
|
|
margin: 0 0 20px;
|
|
|
|
|
font-size: 17.5px;
|
|
|
|
|
border-left: 5px solid #eee;
|
|
|
|
|
}
|
|
|
|
|
hr {
|
|
|
|
|
margin-top: 20px;
|
|
|
|
|
margin-bottom: 20px;
|
|
|
|
|
border: 0;
|
|
|
|
|
border-top: 1px solid #eee;
|
|
|
|
|
}
|
|
|
|
|
.col-item {
|
|
|
|
|
margin-bottom: 20px;
|
|
|
|
|
}
|
|
|
|
|
.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%);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ul {
|
|
|
|
|
padding: 0;
|
|
|
|
|
margin: 0;
|
|
|
|
|
}
|
|
|
|
|
.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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
font-family: 'open sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
|
|
|
.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;
|
|
|
|
|
color: #676a6c;
|
|
|
|
|
overflow-x: hidden;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ul {
|
|
|
|
|
list-style-type: none;
|
|
|
|
|
.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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
.hero-panel,
|
|
|
|
|
.glass-panel,
|
|
|
|
|
.chart-card,
|
|
|
|
|
.overview-card {
|
|
|
|
|
padding: 16px;
|
|
|
|
|
border-radius: 20px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h4 {
|
|
|
|
|
margin-top: 0px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h2 {
|
|
|
|
|
margin-top: 10px;
|
|
|
|
|
.hero-panel h1 {
|
|
|
|
|
font-size: 26px;
|
|
|
|
|
font-weight: 100;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
p {
|
|
|
|
|
margin-top: 10px;
|
|
|
|
|
|
|
|
|
|
b {
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.update-log {
|
|
|
|
|
ol {
|
|
|
|
|
display: block;
|
|
|
|
|
list-style-type: decimal;
|
|
|
|
|
margin-block-start: 1em;
|
|
|
|
|
margin-block-end: 1em;
|
|
|
|
|
margin-inline-start: 0;
|
|
|
|
|
margin-inline-end: 0;
|
|
|
|
|
padding-inline-start: 40px;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
|