feat: 初始化告警功能

main
zangch@mesnac.com 1 month ago
parent 97ee812fe6
commit 0ad92caa81

@ -1,8 +1,8 @@
{
"$schema": "https://json.schemastore.org/package",
"name": "ruoyi-vue-plus",
"name": "HaiWei-Plus",
"version": "5.6.0-2.6.0",
"description": "RuoYi-Vue-Plus多租户管理系统",
"description": "HaiWei-Plus能源管理系统",
"author": "LionLi",
"license": "MIT",
"type": "module",

@ -1,10 +1,12 @@
<template>
<el-config-provider :locale="appStore.locale" :size="appStore.size">
<router-view />
<RealtimeAlarmModal />
</el-config-provider>
</template>
<script setup lang="ts">
import RealtimeAlarmModal from '@/components/alarm/RealtimeAlarmModal.vue';
import { useSettingsStore } from '@/store/modules/settings';
import { handleThemeStyle } from '@/utils/theme';
import { useAppStore } from '@/store/modules/app';

@ -1,5 +1,6 @@
import request from '@/utils/request';
import type {
AlarmHandleResultVO,
AlarmOverviewSummaryVO,
EmsActionResponse,
EmsDetailResponse,
@ -7,7 +8,8 @@ import type {
EmsId,
EmsIdParam,
EmsListResponse,
EmsQuery
EmsQuery,
RealtimeAlarmBatchResultVO
} from '../types';
// 查询异常数据记录列表
@ -62,8 +64,8 @@ export function delRecordAlarmData(objId: EmsIdParam): Promise<EmsActionResponse
});
}
// 新增异常数据记录
export function handleExceptions(objId: EmsId): Promise<EmsActionResponse> {
// 确认处理异常数据记录
export function handleExceptions(objId: EmsId): Promise<EmsActionResponse<AlarmHandleResultVO>> {
return request({
url: '/ems/record/recordAlarmData/handleExceptions/' + objId,
method: 'post'
@ -81,7 +83,7 @@ export function getAlarmDataTotalCount(): Promise<EmsDetailResponse<number>> {
// 保存WebSocket告警数据批量保存
// 参数alarmDataList应为EmsRecordAlarmData实体数组
// 每个告警规则对应一条EmsRecordAlarmData记录
export function saveWebSocketAlarmData(alarmDataList: EmsRecordAlarmDataVO[]): Promise<EmsActionResponse> {
export function saveWebSocketAlarmData(alarmDataList: EmsRecordAlarmDataVO[]): Promise<EmsActionResponse<RealtimeAlarmBatchResultVO>> {
return request({
url: '/ems/record/recordAlarmData/saveWebSocketAlarmData',
method: 'post',

@ -103,10 +103,22 @@ export interface RecordIotenvInstantReportQuery extends EmsQuery {
samplingInterval?: EmsId | string;
vibrationParam?: string;
primaryMetricField?: string;
samplingGranularity?: 'SECOND' | 'MINUTE' | 'HOUR' | string;
beginRecordTime?: EmsDateValue;
endRecordTime?: EmsDateValue;
}
/**
*
* 使 OPC/ EmsQuery
*/
export interface RecordIotenvLatestQuery extends EmsQuery {
parentId?: EmsId | string;
monitorName?: string;
staleMinutes?: EmsId | string;
staleOnly?: boolean;
}
/**
* `tempreture`
*/
@ -451,6 +463,7 @@ export interface EmsAlarmActionStepVO extends EmsEntity {
ruleObjId?: EmsId;
stepSequence?: EmsId;
description?: string;
remark?: string;
stepImages?: EmsAlarmActionStepImageVO[];
}
@ -565,6 +578,26 @@ export interface EmsRecordAlarmDataVO extends EmsEntity {
confirmRemark?: string;
}
export interface AlarmHandleResultVO extends EmsEntity {
requestCount?: EmsId;
updatedCount?: EmsId;
alreadyHandledCount?: EmsId;
missingCount?: EmsId;
successIds?: EmsId[];
alreadyHandledIds?: EmsId[];
missingIds?: EmsId[];
}
export interface AlarmRealtimeSaveResultVO extends EmsEntity {
requestCount?: EmsId;
insertedCount?: EmsId;
duplicateCount?: EmsId;
failedCount?: EmsId;
insertedAlarms?: EmsRecordAlarmDataVO[];
duplicateKeys?: string[];
failureMessages?: string[];
}
export interface AlarmOverviewSummaryVO extends EmsEntity {
totalCount?: EmsId;
unhandledCount?: EmsId;
@ -575,6 +608,23 @@ export interface AlarmOverviewSummaryVO extends EmsEntity {
pushFailCount?: EmsId;
}
export interface RealtimeAlarmBatchResultVO extends EmsEntity {
totalCount?: EmsId;
insertedCount?: EmsId;
duplicateCount?: EmsId;
failedCount?: EmsId;
records?: EmsRecordAlarmDataVO[];
}
export interface RealtimeAlarmEventVO extends EmsEntity {
eventType?: string;
eventTime?: EmsDateValue;
generatedAt?: EmsDateValue;
source?: string;
alarms?: EmsRecordAlarmDataVO[];
payload?: RealtimeAlarmBatchResultVO;
}
export interface EmsRecordAlarmRuleVO extends EmsEntity {
objId?: EmsId;
ruleId?: string;
@ -808,9 +858,130 @@ export interface RecordIotenvInstantVO extends EmsEntity {
monitorType?: EmsId;
energyName?: string;
samplingInterval?: EmsId;
samplingGranularity?: 'SECOND' | 'MINUTE' | 'HOUR' | string;
vibrationParam?: string;
}
export interface IotMetricOptionVO extends EmsEntity {
metricCode?: string;
metricName?: string;
unit?: string;
}
export interface InstrumentConditionPointVO extends EmsEntity {
time?: string;
value?: EmsDecimalValue;
}
export interface InstrumentConditionSeriesVO extends EmsEntity {
monitorId?: string;
monitorName?: string;
metricCode?: string;
metricName?: string;
unit?: string;
points?: InstrumentConditionPointVO[];
}
export interface InstrumentConditionStatVO extends EmsEntity {
metricCode?: string;
metricName?: string;
unit?: string;
latest?: EmsDecimalValue;
avg?: EmsDecimalValue;
max?: EmsDecimalValue;
min?: EmsDecimalValue;
sampleCount?: EmsId;
}
export interface AdvancedReportBaseVO extends EmsEntity {
hasData?: boolean;
supported?: boolean;
emptyReason?: string;
}
export interface InstrumentConditionReportVO extends AdvancedReportBaseVO {
samplingGranularity?: string;
samplingInterval?: EmsId;
availableMetrics?: IotMetricOptionVO[];
summaryCards?: InstrumentConditionStatVO[];
seriesList?: InstrumentConditionSeriesVO[];
}
export interface MeterBalanceCircuitVO extends EmsEntity {
monitorId?: string;
monitorName?: string;
monitorIds?: string[];
upstreamValue?: EmsDecimalValue;
downstreamTotal?: EmsDecimalValue;
diffValue?: EmsDecimalValue;
diffRate?: EmsDecimalValue;
abnormal?: boolean;
}
export interface MeterBalanceReportVO extends AdvancedReportBaseVO {
rootMonitorId?: string;
rootMonitorName?: string;
metricCode?: string;
metricName?: string;
unit?: string;
aggregationType?: string;
helpText?: string;
upstreamValue?: EmsDecimalValue;
downstreamTotal?: EmsDecimalValue;
diffValue?: EmsDecimalValue;
diffRate?: EmsDecimalValue;
circuits?: MeterBalanceCircuitVO[];
}
export interface EfficiencyMetricScoreVO extends EmsEntity {
metricCode?: string;
metricName?: string;
unit?: string;
weight?: EmsDecimalValue;
averageValue?: EmsDecimalValue;
peakValue?: EmsDecimalValue;
stabilityScore?: EmsDecimalValue;
fluctuationScore?: EmsDecimalValue;
thresholdScore?: EmsDecimalValue;
score?: EmsDecimalValue;
}
export interface EfficiencyDeviceScoreVO extends EmsEntity {
monitorId?: string;
monitorName?: string;
totalScore?: EmsDecimalValue;
sampleCount?: EmsId;
validMetricCount?: EmsId;
}
export interface EfficiencyAnalysisVO extends AdvancedReportBaseVO {
templateName?: string;
analysisConclusion?: string;
overallScore?: EmsDecimalValue;
metricScores?: EfficiencyMetricScoreVO[];
deviceScores?: EfficiencyDeviceScoreVO[];
}
export interface AlertTrendPointVO extends EmsEntity {
timeBucket?: string;
alarmCount?: EmsId;
}
export interface AlertRankingVO extends EmsEntity {
monitorId?: string;
monitorName?: string;
alarmCount?: EmsId;
unhandledCount?: EmsId;
pushFailCount?: EmsId;
}
export interface EnergyAbnormalAlertReportVO extends AdvancedReportBaseVO {
summary?: AlarmOverviewSummaryVO;
trend?: AlertTrendPointVO[];
ranking?: AlertRankingVO[];
records?: EmsRecordAlarmDataVO[];
}
export interface EmsReportPointDnbVO extends EmsEntity {
objId?: EmsId;
monitorCode?: string;

@ -2,9 +2,11 @@ import { defineStore } from 'pinia';
import { reactive } from 'vue';
interface NoticeItem {
type?: string;
title?: string;
read: boolean;
message: any;
message: string;
rawMessage?: unknown;
time: string;
}

@ -1,6 +1,7 @@
import { getToken } from '@/utils/auth';
import { ElNotification } from 'element-plus';
import { useNoticeStore } from '@/store/modules/notice';
import { parseAlarmRealtimeMessage, useAlarmRealtimeBus } from '@/utils/alarmRealtime';
// 初始化
export const initSSE = (url: any) => {
@ -26,15 +27,24 @@ export const initSSE = (url: any) => {
watch(data, () => {
if (!data.value) return;
const { envelope, displayMessage } = parseAlarmRealtimeMessage(data.value);
if (envelope) {
useAlarmRealtimeBus().emit({
...envelope,
channel: envelope.channel ?? 'SSE'
});
}
useNoticeStore().addNotice({
message: data.value,
type: envelope?.eventType,
message: displayMessage,
rawMessage: data.value,
read: false,
time: new Date().toLocaleString()
});
ElNotification({
title: '消息',
message: data.value,
type: 'success',
title: envelope ? '实时告警' : '消息',
message: displayMessage,
type: envelope ? 'warning' : 'success',
duration: 3000
});
data.value = null;

@ -1,6 +1,7 @@
import { getToken } from '@/utils/auth';
import { ElNotification } from 'element-plus';
import { useNoticeStore } from '@/store/modules/notice';
import { parseAlarmRealtimeMessage, useAlarmRealtimeBus } from '@/utils/alarmRealtime';
// 初始化socket
export const initWebSocket = (url: any) => {
@ -35,15 +36,24 @@ export const initWebSocket = (url: any) => {
if (e.data.indexOf('ping') > 0) {
return;
}
const { envelope, displayMessage } = parseAlarmRealtimeMessage(e.data);
if (envelope) {
useAlarmRealtimeBus().emit({
...envelope,
channel: envelope.channel ?? 'WEBSOCKET'
});
}
useNoticeStore().addNotice({
message: e.data,
type: envelope?.eventType,
message: displayMessage,
rawMessage: e.data,
read: false,
time: new Date().toLocaleString()
});
ElNotification({
title: '消息',
message: e.data,
type: 'success',
title: envelope ? '实时告警' : '消息',
message: displayMessage,
type: envelope ? 'warning' : 'success',
duration: 3000
});
}

@ -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 />
* 前端开发框架 Vue3TSElement 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 />
* 文档框架 SpringDocjavadoc 无注解零入侵基于java注释<br />
* 工具类框架 HutoolLombok 减少代码冗余 增加安全性<br />
* 代码生成器 适配MPSpringDoc规范化代码 一键生成前后端代码<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">&yen;免费开源</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 />
* 前端开发框架 Vue3TSElement UI<br />
* 后端开发框架 Spring Boot<br />
* 微服务开发框架 Spring CloudSpring Cloud Alibaba<br />
* 容器框架 Undertow 基于 XNIO 的高性能容器<br />
* 权限认证框架 Sa-TokenJwt 支持多终端认证系统<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 />
* 分布式监控 PrometheusGrafana 全方位性能监控<br />
* 其余与 Vue 版本一致<br />
</p>
<p><b>当前版本:</b> <span>v2.6.0</span></p>
<p>
<el-tag type="danger">&yen;免费开源</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>

Loading…
Cancel
Save