From 7636a0028c8eb1c2a614346e1a9797ed57e2e750 Mon Sep 17 00:00:00 2001 From: "zangch@mesnac.com" Date: Wed, 1 Apr 2026 16:15:11 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(ems\report):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=B8=A9=E5=BA=A6=E4=B8=93=E5=B1=9E=E6=8A=A5=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/ems/report/tempBoard.ts | 334 ++++++++++++++++++ .../ems/report/tempBoard/advanced/index.vue | 175 +++++++++ .../ems/report/tempBoard/anomaly/index.vue | 158 +++++++++ .../ems/report/tempBoard/comparison/index.vue | 135 +++++++ .../tempBoard/components/TempBoardFilter.vue | 208 +++++++++++ .../report/tempBoard/distribution/index.vue | 139 ++++++++ .../ems/report/tempBoard/overview/index.vue | 176 +++++++++ .../ems/report/tempBoard/quality/index.vue | 149 ++++++++ .../ems/report/tempBoard/realtime/index.vue | 160 +++++++++ .../ems/report/tempBoard/trend/index.vue | 113 ++++++ 10 files changed, 1747 insertions(+) create mode 100644 src/api/ems/report/tempBoard.ts create mode 100644 src/views/ems/report/tempBoard/advanced/index.vue create mode 100644 src/views/ems/report/tempBoard/anomaly/index.vue create mode 100644 src/views/ems/report/tempBoard/comparison/index.vue create mode 100644 src/views/ems/report/tempBoard/components/TempBoardFilter.vue create mode 100644 src/views/ems/report/tempBoard/distribution/index.vue create mode 100644 src/views/ems/report/tempBoard/overview/index.vue create mode 100644 src/views/ems/report/tempBoard/quality/index.vue create mode 100644 src/views/ems/report/tempBoard/realtime/index.vue create mode 100644 src/views/ems/report/tempBoard/trend/index.vue diff --git a/src/api/ems/report/tempBoard.ts b/src/api/ems/report/tempBoard.ts new file mode 100644 index 0000000..e94cf00 --- /dev/null +++ b/src/api/ems/report/tempBoard.ts @@ -0,0 +1,334 @@ +import request from '@/utils/request' + +import type { AxiosPromise } from 'axios' + +// ==================== TS 类型定义 ==================== + +/** 温度看板通用查询参数 */ +export interface TempBoardQuery { + /** 开始时间 yyyy-MM-dd HH:mm:ss */ + startTime?: string + /** 结束时间 yyyy-MM-dd HH:mm:ss */ + endTime?: string + /** 测点ID列表 */ + monitorIds?: string[] + /** 高温阈值(默认35) */ + highTempThreshold?: number + /** 低温阈值(默认10) */ + lowTempThreshold?: number + /** 温升过快阈值 ℃/分钟(默认1.0) */ + rapidRiseThreshold?: number + /** 温度抖动标准差阈值(默认2.0) */ + stddevThreshold?: number + /** 采样间隔异常阈值 秒(默认300) */ + gapThresholdSeconds?: number + /** 长时间未更新阈值 秒(默认600) */ + staleThresholdSeconds?: number + /** TopN 数量(默认10) */ + topN?: number + /** 单测点趋势查询 */ + monitorId?: string + /** 预期采样数(完整率计算用) */ + expectedSampleCount?: number +} + +/** 测点温度排行项 */ +export interface MonitorTempRank { + monitorId: string + monitorName: string + temperature: number + collectTime: string +} + +/** 数据新鲜度项 */ +export interface FreshnessItem { + monitorId: string + monitorName: string + temperature: number + collectTime: string + ageSeconds: number +} + +/** 温度总览 VO */ +export interface TempBoardOverviewVO { + monitorCount: number + avgLatestTemp: number + maxLatestTemp: number + maxTempMonitorId: string + minLatestTemp: number + minTempMonitorId: string + highTempTopN: MonitorTempRank[] + lowTempTopN: MonitorTempRank[] + freshnessList: FreshnessItem[] +} + +/** 实时监控 VO */ +export interface TempBoardRealtimeVO { + monitorId: string + monitorName: string + temperature: number + collectTime: string + recodeTime: string + delaySeconds: number + staleSeconds: number +} + +/** 趋势分析 VO */ +export interface TempBoardTrendVO { + statTime: string + monitorId: string + monitorName: string + avgTemp: number + maxTemp: number + minTemp: number + changeRate: number + sampleCount: number + peakTemp: number + peakTime: string + valleyTemp: number + valleyTime: string + prevTemp: number + prevTime: string +} + +/** 分布分析 VO */ +export interface TempBoardDistributionVO { + tempBucket: string + tempBin: number + sampleCount: number + statDate: string + statHour: number + avgTemp: number + monitorId: string + monitorName: string + temperature: number +} + +/** 异常预警 VO */ +export interface TempBoardAnomalyVO { + monitorId: string + monitorName: string + temperature: number + collectTime: string + anomalyType: string + risePerMin: number + tempStddev: number + startTime: string + endTime: string + maxTemp: number + sampleCount: number + prevTemp: number + prevTime: string + statTime: string +} + +/** 对比分析 VO */ +export interface TempBoardComparisonVO { + monitorId: string + monitorName: string + avgTemp: number + maxTemp: number + minTemp: number + tempRange: number + tempStddev: number + statDate: string + todayAvg: number + yesterdayAvg: number + diffValue: number +} + +/** 数据质量 VO */ +export interface TempBoardQualityVO { + monitorId: string + monitorName: string + delayBucket: string + sampleCount: number + collectTime: string + recodeTime: string + delaySeconds: number + prevTime: string + gapSeconds: number + actualCount: number + expectedCount: number + completenessRate: number + firstTime: string + lastTime: string + temperature: number +} + +/** 高级分析 VO */ +export interface TempBoardAdvancedVO { + fromNode: string + toNode: string + flowCount: number + statTime: string + monitorId: string + monitorName: string + avgTemp: number + sampleCount: number + tempBucket: string + maxTemp: number + minTemp: number + tempStddev: number + avgDelay: number +} + +// ==================== API 函数 ==================== + +const BASE_URL = '/ems/report/tempBoard' + +// --- A. 温度总览 --- +/** 温度总览 */ +export const getTempOverview = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/overview`, method: 'get', params: query }) + +// --- B. 实时监控 --- +/** 实时温度明细 */ +export const getRealtimeDetail = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/realtime/detail`, method: 'get', params: query }) + +/** 高温测点 */ +export const getHighTempMonitors = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/realtime/highTemp`, method: 'get', params: query }) + +/** 低温测点 */ +export const getLowTempMonitors = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/realtime/lowTemp`, method: 'get', params: query }) + +/** 长时间未更新测点 */ +export const getStaleMonitors = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/realtime/stale`, method: 'get', params: query }) + +/** 入库延迟排行 */ +export const getDelayRanking = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/realtime/delay`, method: 'get', params: query }) + +// --- C. 趋势分析 --- +/** 单测点分钟趋势 */ +export const getMinuteTrend = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/trend/minute`, method: 'get', params: query }) + +/** 单测点小时趋势 */ +export const getHourlyTrend = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/trend/hourly`, method: 'get', params: query }) + +/** 多测点对比趋势 */ +export const getMultiCompareTrend = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/trend/multiCompare`, method: 'get', params: query }) + +/** 日均温趋势 */ +export const getDailyTrend = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/trend/daily`, method: 'get', params: query }) + +/** 温度变化率趋势 */ +export const getChangeRateTrend = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/trend/changeRate`, method: 'get', params: query }) + +/** 峰谷时刻表 */ +export const getPeakValley = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/trend/peakValley`, method: 'get', params: query }) + +// --- D. 分布分析 --- +/** 温度区间分布 */ +export const getIntervalDistribution = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/distribution/interval`, method: 'get', params: query }) + +/** 温度直方图 */ +export const getHistogram = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/distribution/histogram`, method: 'get', params: query }) + +/** 温度箱线图数据 */ +export const getBoxplotData = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/distribution/boxplot`, method: 'get', params: query }) + +/** 日历热力图 */ +export const getCalendarHeatmap = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/distribution/calendarHeatmap`, method: 'get', params: query }) + +/** 小时热力图 */ +export const getHourlyHeatmap = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/distribution/hourlyHeatmap`, method: 'get', params: query }) + +// --- E. 异常预警 --- +/** 高温事件 */ +export const getHighTempEvents = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/anomaly/highTemp`, method: 'get', params: query }) + +/** 低温事件 */ +export const getLowTempEvents = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/anomaly/lowTemp`, method: 'get', params: query }) + +/** 连续高温时段 */ +export const getContinuousHighTemp = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/anomaly/continuousHighTemp`, method: 'get', params: query }) + +/** 温升过快事件 */ +export const getRapidRiseEvents = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/anomaly/rapidRise`, method: 'get', params: query }) + +/** 温度抖动异常 */ +export const getJitterAnomalies = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/anomaly/jitter`, method: 'get', params: query }) + +// --- F. 对比分析 --- +/** 测点平均温度排行 */ +export const getAvgTempRanking = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/comparison/avgRanking`, method: 'get', params: query }) + +/** 测点稳定性排行 */ +export const getStabilityRanking = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/comparison/stabilityRanking`, method: 'get', params: query }) + +/** 今日vs昨日对比 */ +export const getDailyDiff = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/comparison/dailyDiff`, method: 'get', params: query }) + +/** 峰值对比 */ +export const getPeakCompare = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/comparison/peak`, method: 'get', params: query }) + +/** 波动幅度对比 */ +export const getFluctuationCompare = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/comparison/fluctuation`, method: 'get', params: query }) + +// --- G. 数据质量 --- +/** 入库延迟分布 */ +export const getDelayDistribution = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/quality/delayDistribution`, method: 'get', params: query }) + +/** 时间逆序可疑数据 */ +export const getTimeReversal = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/quality/timeReversal`, method: 'get', params: query }) + +/** 采样间隔异常 */ +export const getSamplingGapAnomalies = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/quality/samplingGap`, method: 'get', params: query }) + +/** 数据完整率 */ +export const getCompletenessRate = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/quality/completeness`, method: 'get', params: query }) + +/** 测点活跃度 */ +export const getMonitorActivity = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/quality/activity`, method: 'get', params: query }) + +// --- H. 高级分析 --- +/** 桑基图数据 */ +export const getSankeyData = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/advanced/sankey`, method: 'get', params: query }) + +/** 主题河流图数据 */ +export const getThemeRiverData = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/advanced/themeRiver`, method: 'get', params: query }) + +/** 矩形树图数据 */ +export const getTreemapData = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/advanced/treemap`, method: 'get', params: query }) + +/** 旭日图数据 */ +export const getSunburstData = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/advanced/sunburst`, method: 'get', params: query }) + +/** 平行坐标图数据 */ +export const getParallelData = (query: TempBoardQuery): AxiosPromise => + request({ url: `${BASE_URL}/advanced/parallel`, method: 'get', params: query }) diff --git a/src/views/ems/report/tempBoard/advanced/index.vue b/src/views/ems/report/tempBoard/advanced/index.vue new file mode 100644 index 0000000..85fc881 --- /dev/null +++ b/src/views/ems/report/tempBoard/advanced/index.vue @@ -0,0 +1,175 @@ + + + diff --git a/src/views/ems/report/tempBoard/anomaly/index.vue b/src/views/ems/report/tempBoard/anomaly/index.vue new file mode 100644 index 0000000..c799b49 --- /dev/null +++ b/src/views/ems/report/tempBoard/anomaly/index.vue @@ -0,0 +1,158 @@ + + + diff --git a/src/views/ems/report/tempBoard/comparison/index.vue b/src/views/ems/report/tempBoard/comparison/index.vue new file mode 100644 index 0000000..cfc1f93 --- /dev/null +++ b/src/views/ems/report/tempBoard/comparison/index.vue @@ -0,0 +1,135 @@ + + + diff --git a/src/views/ems/report/tempBoard/components/TempBoardFilter.vue b/src/views/ems/report/tempBoard/components/TempBoardFilter.vue new file mode 100644 index 0000000..fea9a34 --- /dev/null +++ b/src/views/ems/report/tempBoard/components/TempBoardFilter.vue @@ -0,0 +1,208 @@ + + + + + diff --git a/src/views/ems/report/tempBoard/distribution/index.vue b/src/views/ems/report/tempBoard/distribution/index.vue new file mode 100644 index 0000000..220140a --- /dev/null +++ b/src/views/ems/report/tempBoard/distribution/index.vue @@ -0,0 +1,139 @@ + + + diff --git a/src/views/ems/report/tempBoard/overview/index.vue b/src/views/ems/report/tempBoard/overview/index.vue new file mode 100644 index 0000000..d841d9a --- /dev/null +++ b/src/views/ems/report/tempBoard/overview/index.vue @@ -0,0 +1,176 @@ + + + + + diff --git a/src/views/ems/report/tempBoard/quality/index.vue b/src/views/ems/report/tempBoard/quality/index.vue new file mode 100644 index 0000000..3c07712 --- /dev/null +++ b/src/views/ems/report/tempBoard/quality/index.vue @@ -0,0 +1,149 @@ + + + diff --git a/src/views/ems/report/tempBoard/realtime/index.vue b/src/views/ems/report/tempBoard/realtime/index.vue new file mode 100644 index 0000000..b07540a --- /dev/null +++ b/src/views/ems/report/tempBoard/realtime/index.vue @@ -0,0 +1,160 @@ + + + diff --git a/src/views/ems/report/tempBoard/trend/index.vue b/src/views/ems/report/tempBoard/trend/index.vue new file mode 100644 index 0000000..6033ae5 --- /dev/null +++ b/src/views/ems/report/tempBoard/trend/index.vue @@ -0,0 +1,113 @@ + + + From cb4f23b59cf3039f2805ca93ec2fec94e17d66c8 Mon Sep 17 00:00:00 2001 From: "zangch@mesnac.com" Date: Wed, 1 Apr 2026 16:48:44 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(=E6=B8=A9=E5=BA=A6=E6=8A=A5=E8=A1=A8):?= =?UTF-8?q?=20=E6=96=B0=E5=A2=9E=E5=B8=AE=E5=8A=A9=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E5=B9=B6=E4=BC=98=E5=8C=96=E5=9B=BE=E8=A1=A8?= =?UTF-8?q?=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增HelpButton组件用于显示各模块帮助说明内容 添加useChartResize工具函数实现图表自适应 优化各页面图表样式和交互效果 重构卡片头部布局统一使用card-header样式 --- .../ems/report/tempBoard/advanced/index.vue | 189 +++++++++++--- .../ems/report/tempBoard/anomaly/index.vue | 84 ++++-- .../ems/report/tempBoard/comparison/index.vue | 145 ++++++++--- .../tempBoard/components/HelpButton.vue | 42 +++ .../tempBoard/components/helpContent.ts | 243 ++++++++++++++++++ .../tempBoard/components/useChartResize.ts | 63 +++++ .../report/tempBoard/distribution/index.vue | 181 ++++++++++--- .../ems/report/tempBoard/overview/index.vue | 115 +++++++-- .../ems/report/tempBoard/quality/index.vue | 93 +++++-- .../ems/report/tempBoard/realtime/index.vue | 44 +++- .../ems/report/tempBoard/trend/index.vue | 120 +++++++-- 11 files changed, 1126 insertions(+), 193 deletions(-) create mode 100644 src/views/ems/report/tempBoard/components/HelpButton.vue create mode 100644 src/views/ems/report/tempBoard/components/helpContent.ts create mode 100644 src/views/ems/report/tempBoard/components/useChartResize.ts diff --git a/src/views/ems/report/tempBoard/advanced/index.vue b/src/views/ems/report/tempBoard/advanced/index.vue index 85fc881..44be458 100644 --- a/src/views/ems/report/tempBoard/advanced/index.vue +++ b/src/views/ems/report/tempBoard/advanced/index.vue @@ -14,36 +14,61 @@ - -
+ +
- -
+ +
- + - -
+ +
- + - -
+ +
- -
+ +
@@ -53,6 +78,9 @@ import { ref, reactive, onMounted, nextTick } from 'vue' import * as echarts from 'echarts' import { getSankeyData, getThemeRiverData, getTreemapData, getSunburstData, getParallelData } from '@/api/ems/report/tempBoard' import type { TempBoardQuery, TempBoardAdvancedVO } from '@/api/ems/report/tempBoard' +import HelpButton from '../components/HelpButton.vue' +import { ADVANCED_HELP } from '../components/helpContent' +import { useChartResize } from '../components/useChartResize' defineOptions({ name: 'TempBoardAdvanced' }) @@ -65,6 +93,8 @@ const treemapChartRef = ref() const sunburstChartRef = ref() const parallelChartRef = ref() +const { getChart } = useChartResize(sankeyChartRef, themeRiverChartRef, treemapChartRef, sunburstChartRef, parallelChartRef) + function initTimeRange() { const end = new Date(); const start = new Date(end.getTime() - 7 * 24 * 3600 * 1000) const p = (n: number) => n.toString().padStart(2, '0') @@ -89,87 +119,168 @@ async function handleQuery() { } finally { loading.value = false } } +/** 桑基图 */ function renderSankey(data: TempBoardAdvancedVO[]) { - if (!sankeyChartRef.value || !data.length) return - const chart = echarts.init(sankeyChartRef.value) + const chart = getChart(sankeyChartRef) + if (!chart || !data.length) return const nodes = new Set() data.forEach(d => { nodes.add(d.fromNode!); nodes.add(d.toNode!) }) chart.setOption({ - tooltip: { trigger: 'item' }, + tooltip: { + trigger: 'item', + formatter: (p: any) => p.dataType === 'edge' + ? `${p.data.source} → ${p.data.target}
流量: ${p.data.value}` + : `${p.name}` + }, series: [{ - type: 'sankey', layout: 'none', emphasis: { focus: 'adjacency' }, + type: 'sankey', + layout: 'none', + emphasis: { focus: 'adjacency' }, data: [...nodes].map(n => ({ name: n })), links: data.map(d => ({ source: d.fromNode, target: d.toNode, value: d.flowCount })), - lineStyle: { color: 'gradient', curveness: 0.5 } + lineStyle: { color: 'gradient', curveness: 0.5 }, + label: { color: '#333', fontSize: 12 }, + itemStyle: { borderWidth: 0 } }] }) } +/** 主题河流图 */ function renderThemeRiver(data: TempBoardAdvancedVO[]) { - if (!themeRiverChartRef.value || !data.length) return - const chart = echarts.init(themeRiverChartRef.value) + const chart = getChart(themeRiverChartRef) + if (!chart || !data.length) return chart.setOption({ - tooltip: { trigger: 'axis' }, + tooltip: { + trigger: 'axis', + formatter: (params: any) => { + const p = params[0] + return `${p.data[2]}
时间: ${p.data[0]}
温度: ${p.data[1]}℃` + } + }, + legend: { top: 0, textStyle: { color: '#666' } }, + singleAxis: { type: 'time', axisLabel: { color: '#666' } }, series: [{ type: 'themeRiver', data: data.map(d => [d.statTime, d.avgTemp, d.monitorName || d.monitorId]), - emphasis: { itemStyle: { shadowBlur: 20, shadowColor: 'rgba(0,0,0,0.8)' } } + emphasis: { itemStyle: { shadowBlur: 20, shadowColor: 'rgba(0,0,0,0.8)' } }, + label: { show: false } }] }) } +/** 矩形树图 */ function renderTreemap(data: TempBoardAdvancedVO[]) { - if (!treemapChartRef.value || !data.length) return - const chart = echarts.init(treemapChartRef.value) + const chart = getChart(treemapChartRef) + if (!chart || !data.length) return chart.setOption({ - tooltip: { formatter: (p: any) => `${p.name}: ${p.value?.toFixed(2)}℃ (${data.find(d => d.monitorName === p.name || d.monitorId === p.name)?.sampleCount}样本)` }, + tooltip: { + formatter: (p: any) => { + const item = data.find(d => d.monitorName === p.name || d.monitorId === p.name) + return `${p.name}
平均温度: ${p.value?.toFixed(2)}℃${item ? `
样本数: ${item.sampleCount}` : ''}` + } + }, series: [{ type: 'treemap', - data: data.map(d => ({ name: d.monitorName || d.monitorId, value: d.avgTemp })), - leafDepth: 1, roam: false, - label: { show: true, formatter: '{b}\n{c}℃' } + data: data.map(d => ({ + name: d.monitorName || d.monitorId, + value: d.avgTemp, + itemStyle: { + color: d.avgTemp >= 30 ? '#f56c6c' : d.avgTemp >= 20 ? '#e6a23c' : d.avgTemp >= 10 ? '#67c23a' : '#409eff' + } + })), + leafDepth: 1, + roam: false, + label: { show: true, formatter: '{b}\n{c}℃', fontSize: 12, color: '#fff' }, + upperLabel: { show: true, height: 24, color: '#fff' }, + itemStyle: { borderColor: '#fff', borderWidth: 2, gapWidth: 2 }, + levels: [ + { itemStyle: { borderColor: '#333', borderWidth: 2, gapWidth: 2 } }, + { itemStyle: { borderColor: '#fff', borderWidth: 1, gapWidth: 1 } } + ] }] }) } +/** 旭日图 */ function renderSunburst(data: TempBoardAdvancedVO[]) { - if (!sunburstChartRef.value || !data.length) return - const chart = echarts.init(sunburstChartRef.value) + const chart = getChart(sunburstChartRef) + if (!chart || !data.length) return // 构建层级数据:温区 → 测点 const bucketMap = new Map() + const colorMap: Record = { + '低温': '#409eff', '偏低': '#67c23a', '正常': '#95de64', + '偏高': '#e6a23c', '高温': '#f56c6c' + } data.forEach(d => { if (!bucketMap.has(d.tempBucket!)) { bucketMap.set(d.tempBucket!, { name: d.tempBucket!, value: 0, children: [] }) } const bucket = bucketMap.get(d.tempBucket!)! - bucket.children.push({ name: d.monitorName || d.monitorId, value: d.sampleCount }) + bucket.children.push({ + name: d.monitorName || d.monitorId, + value: d.sampleCount, + itemStyle: { color: colorMap[d.tempBucket!] || undefined } + }) bucket.value += d.sampleCount ?? 0 }) chart.setOption({ tooltip: { formatter: '{b}: {c}样本' }, - series: [{ type: 'sunburst', data: [...bucketMap.values()], radius: ['15%', '90%'], label: { rotate: 'radial' } }] + series: [{ + type: 'sunburst', + data: [...bucketMap.values()].map(b => ({ + ...b, + itemStyle: { color: colorMap[b.name] || undefined } + })), + radius: ['15%', '90%'], + label: { rotate: 'radial', fontSize: 11, color: '#333' }, + itemStyle: { borderWidth: 2, borderColor: '#fff' }, + emphasis: { focus: 'ancestor' } + }] }) } +/** 平行坐标图 */ function renderParallel(data: TempBoardAdvancedVO[]) { - if (!parallelChartRef.value || !data.length) return - const chart = echarts.init(parallelChartRef.value) + const chart = getChart(parallelChartRef) + if (!chart || !data.length) return const dimNames = ['测点', '平均温度', '最高温度', '最低温度', '标准差', '平均延迟(s)'] const fields = ['avgTemp', 'maxTemp', 'minTemp', 'tempStddev', 'avgDelay'] as const + // 为每条线生成不同颜色 + const colors = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#9b59b6', '#1abc9c', '#e74c3c', '#2ecc71'] chart.setOption({ tooltip: { trigger: 'item' }, + legend: { + data: data.map(d => d.monitorName || d.monitorId), + top: 0, + type: 'scroll', + textStyle: { color: '#666', fontSize: 11 } + }, parallelAxis: [ - { type: 'category', data: data.map(d => d.monitorName || d.monitorId), dim: 0, name: dimNames[0], axisLabel: { rotate: 30 } }, - ...fields.map((f, i) => ({ dim: i + 1, name: dimNames[i + 1] })) + { type: 'category', data: data.map(d => d.monitorName || d.monitorId), dim: 0, name: dimNames[0], axisLabel: { rotate: 30, color: '#666' } }, + ...fields.map((f, i) => ({ dim: i + 1, name: dimNames[i + 1], axisLabel: { color: '#666' } })) ], parallel: { left: 100, right: 50, top: 50, bottom: 30 }, - series: [{ + series: data.map((d, idx) => ({ + name: d.monitorName || d.monitorId, type: 'parallel', - data: data.map(d => [d.monitorName || d.monitorId, d.avgTemp, d.maxTemp, d.minTemp, d.tempStddev, d.avgDelay]), - lineStyle: { width: 2, opacity: 0.5 } - }] + data: [[d.monitorName || d.monitorId, d.avgTemp, d.maxTemp, d.minTemp, d.tempStddev, d.avgDelay]], + lineStyle: { width: 2, opacity: 0.7, color: colors[idx % colors.length] }, + smooth: true + })) }) } onMounted(() => { initTimeRange(); handleQuery() }) + + diff --git a/src/views/ems/report/tempBoard/anomaly/index.vue b/src/views/ems/report/tempBoard/anomaly/index.vue index c799b49..33c4917 100644 --- a/src/views/ems/report/tempBoard/anomaly/index.vue +++ b/src/views/ems/report/tempBoard/anomaly/index.vue @@ -30,25 +30,38 @@ - - + + + + - - + + + + - - + + + + - - + + + + - + - + @@ -58,9 +71,14 @@ - + - + @@ -74,7 +92,12 @@ - + @@ -89,9 +112,14 @@ - + - + @@ -101,9 +129,14 @@ - + - + @@ -121,6 +154,8 @@ import { ref, reactive, onMounted } from 'vue' import { getHighTempEvents, getLowTempEvents, getContinuousHighTemp, getRapidRiseEvents, getJitterAnomalies } from '@/api/ems/report/tempBoard' import type { TempBoardQuery, TempBoardAnomalyVO } from '@/api/ems/report/tempBoard' +import HelpButton from '../components/HelpButton.vue' +import { ANOMALY_HELP } from '../components/helpContent' defineOptions({ name: 'TempBoardAnomaly' }) @@ -156,3 +191,18 @@ async function handleQuery() { onMounted(() => { initTimeRange(); handleQuery() }) + + diff --git a/src/views/ems/report/tempBoard/comparison/index.vue b/src/views/ems/report/tempBoard/comparison/index.vue index cfc1f93..588f4a7 100644 --- a/src/views/ems/report/tempBoard/comparison/index.vue +++ b/src/views/ems/report/tempBoard/comparison/index.vue @@ -14,39 +14,64 @@ - + - -
+ +
- + - -
+ +
- + - -
+ +
- + - -
+ +
- + @@ -65,10 +90,13 @@ + + diff --git a/src/views/ems/report/tempBoard/components/HelpButton.vue b/src/views/ems/report/tempBoard/components/HelpButton.vue new file mode 100644 index 0000000..05da92c --- /dev/null +++ b/src/views/ems/report/tempBoard/components/HelpButton.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/src/views/ems/report/tempBoard/components/helpContent.ts b/src/views/ems/report/tempBoard/components/helpContent.ts new file mode 100644 index 0000000..da093a4 --- /dev/null +++ b/src/views/ems/report/tempBoard/components/helpContent.ts @@ -0,0 +1,243 @@ +/** + * 温度报表各模块帮助说明内容 + */ + +/** 总览模块 */ +export const OVERVIEW_HELP = ` +

功能概述

+

总览页面是温度监控的"驾驶舱",在一个页面内集中展示所有温度测点的宏观状态,帮助运维人员快速掌握整体温度态势。

+ +

核心指标说明

+
    +
  • 活跃测点数:当前在采集周期内有数据上报的温度传感器数量,反映数据采集覆盖率。
  • +
  • 平均温度:所有活跃测点最新温度值的算术平均,用于把握整体温度水平。
  • +
  • 最高温度:当前所有测点中的温度最大值,标注对应测点名称,方便定位热源。
  • +
  • 最低温度:当前所有测点中的温度最小值,标注对应测点名称,可发现异常低温。
  • +
+ +

TopN 排行榜

+

通过横向条形图分别展示温度最高和最低的 TopN 测点,红色表示高温、蓝色表示低温。可根据需要调整 TopN 数量和温度阈值来筛选关注的测点范围。

+ +

数据新鲜度概览

+

表格展示每个测点的数据延迟情况,用颜色标签标识延迟程度:

+
    +
  • 绿色:延迟 < 60秒,数据正常
  • +
  • 橙色:延迟 60~300秒,需关注
  • +
  • 红色:延迟 > 300秒,可能存在采集故障
  • +
+ +

使用建议

+

建议将高温阈值设定为设备告警温度的 80% 作为预警线,低温阈值设定为防冻温度。定期检查数据新鲜度,及时发现传感器离线或通讯中断问题。

+` + +/** 实时温度模块 */ +export const REALTIME_HELP = ` +

功能概述

+

实时温度监控页面展示各测点的最新采集数据,是日常巡检和异常快速定位的核心页面。通过表格和分类视图,帮助运维人员第一时间发现高温、低温和数据采集异常。

+ +

温度阈值过滤

+
    +
  • 高温阈值:温度超过此值的测点将被标记为高温测点(红色高亮显示)。
  • +
  • 低温阈值:温度低于此值的测点将被标记为低温测点(蓝色高亮显示)。
  • +
  • 未更新阈值:超过此秒数未上报数据的测点将被标记为长时间未更新。
  • +
+ +

实时温度明细

+

完整的温度数据表格,包含温度值、采集时间、入库时间和各种延迟指标。温度值根据阈值自动着色,便于一眼识别异常测点。

+ +

分类视图

+
    +
  • 高温测点:仅显示温度超过高温阈值的测点列表。
  • +
  • 低温测点:仅显示温度低于低温阈值的测点列表。
  • +
  • 长时间未更新:超过指定时间未上报数据的测点,可能存在传感器故障或通讯问题。
  • +
+ +

入库延迟与数据新鲜度

+

入库延迟指数据从采集到存入数据库的时间差,反映系统处理能力。数据新鲜度指从最后一次采集到现在的时间差,反映传感器工作状态。

+` + +/** 趋势分析模块 */ +export const TREND_HELP = ` +

功能概述

+

趋势分析模块通过多条时间维度的折线图展示温度随时间的变化趋势,是温度数据深度分析的核心工具。支持分钟级、小时级和日级三种粒度,帮助发现温度变化规律和异常波动。

+ +

分钟趋势

+

以分钟为粒度展示温度变化曲线,适用于查看短期内的温度波动细节,如设备启停导致的温度突变、环境温度的周期性变化等。适合排查具体时间点发生的温度异常。

+ +

小时趋势(含最高/最低温带)

+

以小时为粒度展示平均温度曲线,同时叠加最高温和最低温的虚线。最高温与最低温之间的区域形成了"温带",直观反映每小时的温度波动范围。温带越宽,说明该时段温度波动越大。

+ +

日均温趋势

+

以天为粒度的日均温度趋势,适用于中长期分析,可观察温度的季节性变化、设备运行周期对温度的影响等宏观趋势。

+ +

峰谷时刻表

+

统计选定时间段内每个测点的温度最高点(峰值)和最低点(谷值)及其对应时间。通过峰谷时刻表可以:

+
    +
  • 判断设备高温时段是否与生产高峰相关
  • +
  • 识别温度波动的周期性规律
  • +
  • 为空调/制冷设备的调度策略提供数据依据
  • +
+ +

使用建议

+

建议先用日趋势定位异常日期,再用小时趋势缩小范围,最后用分钟趋势精确到具体时间点,实现"从宏观到微观"的逐步聚焦分析。

+` + +/** 分布分析模块 */ +export const DISTRIBUTION_HELP = ` +

功能概述

+

分布分析模块从统计学角度展示温度数据的分布特征,帮助理解温度数据的整体分布态势、集中趋势和离散程度。

+ +

温度区间分布(环形图)

+

以饼图/环形图的形式展示各温度区间内的样本数量占比。每个扇区代表一个预设的温度区间(如 0~10℃、10~20℃ 等),扇区大小反映该区间内的数据样本数。

+

通过此图可以快速回答:"大部分时间温度处于什么范围?极端温度出现的频率有多高?"

+ +

温度直方图

+

以 1℃ 为分箱单位绘制温度直方图,比环形图更精细地展示温度分布。直方图的横轴为温度值(精确到 1℃),纵轴为该温度值对应的样本数。

+

理想的温度分布应呈正态分布(钟形曲线),如果分布偏斜或出现多个峰值,说明可能存在温度控制异常或不同区域温度差异显著。

+ +

日历热力图

+

以日历形式展示每天的平均温度,颜色深浅表示温度高低。类似 GitHub 的贡献热力图,可以直观看到:

+
    +
  • 哪些日期温度偏高或偏低
  • +
  • 温度的周/月变化规律
  • +
  • 异常温度日的分布
  • +
+ +

小时热力图

+

以小时为横轴、日期为纵轴的二维热力图,每个格子的颜色代表该小时段的平均温度。可以清晰看到:

+
    +
  • 每天的温度高峰时段和低谷时段
  • +
  • 工作日与休息日的温度差异
  • +
  • 特定时间段是否存在系统性温度异常
  • +
+` + +/** 异常分析模块 */ +export const ANOMALY_HELP = ` +

功能概述

+

异常分析模块是温度监控体系中最重要的预警工具。它从多个维度识别温度异常事件,包括阈值越限、温升速率异常、温度抖动等,帮助运维人员及时发现和处置潜在风险。

+ +

异常类型说明

+
    +
  • 高温事件:温度超过高温阈值的记录。持续高温可能导致设备过热损坏。
  • +
  • 低温事件:温度低于低温阈值的记录。异常低温可能导致管道冻裂或设备性能下降。
  • +
  • 温升过快:单位时间内温度上升速率超过阈值。可能是设备故障、短路等紧急情况的早期信号。
  • +
  • 温度抖动:一定时间窗口内温度标准差超过阈值,表示温度不稳定。可能是传感器故障、接触不良或环境干扰。
  • +
+ +

连续高温时段

+

识别温度持续超过高温阈值的时段,展示起始时间、持续时长和最高温度。连续高温比瞬时高温更需要关注,因为:

+
    +
  • 长时间高温会加速设备老化
  • +
  • 持续高温可能意味着制冷系统失效
  • +
  • 可据此评估设备的热负荷承受能力
  • +
+ +

参数调整建议

+
    +
  • 温升阈值:建议设置为 1~2℃/min,过小会产生过多误报,过大会遗漏真实异常。
  • +
  • 抖动阈值(标准差):建议设置为 1~3σ,具体取决于环境温度的正常波动范围。
  • +
+` + +/** 对比分析模块 */ +export const COMPARISON_HELP = ` +

功能概述

+

对比分析模块从横向(不同测点间)和纵向(不同时间段)两个维度对温度数据进行比较,帮助发现测点间的差异和温度变化趋势。

+ +

测点平均温度排行

+

以横向条形图展示各测点的平均温度,按温度从高到低排列。可以直观看到哪些区域/设备温度较高,辅助定位热源和评估空调制冷效果。

+ +

测点稳定性排行

+

按温度标准差升序排列各测点。标准差越小表示温度越稳定,标准差大的测点需要关注是否存在温度控制问题或环境影响。

+ +

今日 vs 昨日 温度对比

+

以分组柱状图的形式将每个测点的今日平均温度与昨日进行对比。便于快速发现温度的日间变化:

+
    +
  • 今日明显高于昨日:可能存在设备热负荷增加或制冷效率下降
  • +
  • 今日明显低于昨日:可能存在设备停机或环境温度变化
  • +
+ +

波动幅度对比

+

展示各测点在统计时段内的温度波动幅度(最高温 - 最低温)。波动幅度大的测点温度控制能力较差,需要重点关注。

+ +

峰值温度排行

+

表格展示各测点的峰值温度、平均温度和波动幅度,按峰值从高到低排列。峰值温度是评估设备热安全余量的关键指标。

+` + +/** 数据质量模块 */ +export const QUALITY_HELP = ` +

功能概述

+

数据质量模块从数据完整性和准确性两个角度评估温度采集系统的运行状态。高质量的数据是一切分析和决策的基础,该模块帮助发现和定位数据采集链路中的问题。

+ +

入库延迟分布

+

以柱状图展示数据入库延迟的分布情况。延迟是指从数据采集(collectTime)到数据入库(recodeTime)的时间差。理想情况下,大多数数据的延迟应集中在低延迟区间。如果高延迟区间数据量较大,说明:

+
    +
  • 数据传输链路存在瓶颈
  • +
  • 数据处理服务存在积压
  • +
  • 网络带宽不足或通讯不稳定
  • +
+ +

数据完整率

+

对比每个测点的实际采样数与预期采样数,计算数据完整率。完整率低于 90% 的测点需要排查原因:

+
    +
  • 传感器故障导致数据丢失
  • +
  • 采集网关掉线或重启
  • +
  • 通讯链路不稳定
  • +
+ +

时间逆序可疑数据

+

检测入库时间早于采集时间的异常记录。正常情况下数据先采集后入库,出现时间逆序说明数据可能经过了补录、回填或时间戳修正等操作,这类数据的可靠性需要额外确认。

+ +

采样间隔异常

+

检测两次连续采集之间的时间间隔超过阈值的记录。采样间隔异常通常意味着:

+
    +
  • 传感器暂时失联
  • +
  • 采集网关负载过高导致丢包
  • +
  • 通讯链路瞬时中断
  • +
+ +

测点活跃度

+

展示每个测点在统计时段内的样本数量、首次和最后一次采集时间。可快速识别完全不活跃的测点。

+` + +/** 高级分析模块 */ +export const ADVANCED_HELP = ` +

桑基图(Sankey Diagram)

+

桑基图是一种特殊的流量图,用于展示能量、物质或信息在不同状态之间的流动与转化。在温度分析中,桑基图展示测点在不同温区之间的流转关系。

+

如何看桑基图:

+
    +
  • 左侧节点代表起始温区,右侧节点代表目标温区
  • +
  • 连接线的粗细代表流转的样本数量,越粗表示流转越频繁
  • +
  • 颜色渐变帮助区分不同的流转路径
  • +
+

实际意义:通过桑基图可以发现哪些温区之间的转化最频繁,识别温度变化的"主要路径"。例如,大量数据从"正常温区"流向"高温区",说明温度上升趋势明显,需要加强监控。

+ +

主题河流图(ThemeRiver)

+

主题河流图是一种展示多维时序数据的变化可视化方法。不同颜色的"河流"代表不同的测点,河流的宽度代表温度值的大小。

+

如何看主题河流图:

+
    +
  • 每条河流代表一个测点的温度变化
  • +
  • 河流越宽,表示该测点温度越高
  • +
  • 河流的起伏反映温度随时间的波动
  • +
  • 多条河流叠加在一起,可以比较不同测点的温度变化趋势
  • +
+ +

矩形树图(Treemap)

+

矩形树图通过嵌套的矩形块展示层次结构数据。每个矩形块代表一个测点,面积大小与平均温度成正比,颜色深浅也反映温度高低。

+

实际意义:一目了然地看到哪些测点温度占比最大,快速定位"温度热点"。

+ +

旭日图(Sunburst)

+

旭日图是饼图的扩展,支持多层级数据展示。内圈代表温区分类,外圈代表该温区下的具体测点。每段弧的长度反映样本数量。

+

如何看旭日图:从中心向外看,先看温区分布,再看每个温区下有哪些测点。弧段越长,表示该分类的样本越多。

+ +

平行坐标图(Parallel Coordinates)

+

平行坐标图是多元数据分析的经典可视化方法。每条垂直轴代表一个指标(平均温度、最高温度、最低温度、标准差、平均延迟),每条折线代表一个测点。

+

如何看平行坐标图:

+
    +
  • 折线的走向反映测点在各指标上的表现
  • +
  • 多条折线聚集在一起,说明这些测点的温度特征相似
  • +
  • 折线与其他折线明显不同的测点需要重点关注
  • +
  • 可以识别出"高温高波动"或"低温高稳定"等典型模式
  • +
+` diff --git a/src/views/ems/report/tempBoard/components/useChartResize.ts b/src/views/ems/report/tempBoard/components/useChartResize.ts new file mode 100644 index 0000000..f2bb2c6 --- /dev/null +++ b/src/views/ems/report/tempBoard/components/useChartResize.ts @@ -0,0 +1,63 @@ +import { onBeforeUnmount, onMounted, type Ref } from 'vue' +import * as echarts from 'echarts' + +/** + * ECharts 图表实例管理 + 自动 resize + * @param chartRefs 图表容器 ref 数组 + * @returns chartInstances 用于外部 setOption + */ +export function useChartResize(...chartRefs: Ref[]) { + const chartInstances = new Map() + + /** 获取或创建图表实例 */ + function getChart(ref: Ref): echarts.ECharts | undefined { + const el = ref.value + if (!el) return undefined + if (!chartInstances.has(el)) { + chartInstances.set(el, echarts.init(el)) + } + return chartInstances.get(el) + } + + /** 释放所有图表实例 */ + function disposeAll() { + chartInstances.forEach(chart => chart.dispose()) + chartInstances.clear() + } + + let resizeObserver: ResizeObserver | null = null + + onMounted(() => { + resizeObserver = new ResizeObserver(() => { + chartInstances.forEach(chart => { + if (!chart.isDisposed()) { + chart.resize() + } + }) + }) + // 观察所有图表容器 + chartRefs.forEach(r => { + if (r.value) { + resizeObserver!.observe(r.value) + } + }) + // 同时监听窗口 resize + window.addEventListener('resize', handleResize) + }) + + function handleResize() { + chartInstances.forEach(chart => { + if (!chart.isDisposed()) { + chart.resize() + } + }) + } + + onBeforeUnmount(() => { + window.removeEventListener('resize', handleResize) + resizeObserver?.disconnect() + disposeAll() + }) + + return { getChart, disposeAll } +} diff --git a/src/views/ems/report/tempBoard/distribution/index.vue b/src/views/ems/report/tempBoard/distribution/index.vue index 220140a..001c63c 100644 --- a/src/views/ems/report/tempBoard/distribution/index.vue +++ b/src/views/ems/report/tempBoard/distribution/index.vue @@ -14,30 +14,50 @@ - + - -
+ +
- + - -
+ +
- -
+ +
- -
+ +
@@ -47,6 +67,9 @@ import { ref, reactive, onMounted, nextTick } from 'vue' import * as echarts from 'echarts' import { getIntervalDistribution, getHistogram, getCalendarHeatmap, getHourlyHeatmap } from '@/api/ems/report/tempBoard' import type { TempBoardQuery, TempBoardDistributionVO } from '@/api/ems/report/tempBoard' +import HelpButton from '../components/HelpButton.vue' +import { DISTRIBUTION_HELP } from '../components/helpContent' +import { useChartResize } from '../components/useChartResize' defineOptions({ name: 'TempBoardDistribution' }) @@ -58,6 +81,8 @@ const histogramChartRef = ref() const calendarHeatmapRef = ref() const hourlyHeatmapRef = ref() +const { getChart } = useChartResize(intervalChartRef, histogramChartRef, calendarHeatmapRef, hourlyHeatmapRef) + function initTimeRange() { const end = new Date() const start = new Date(end.getTime() - 7 * 24 * 3600 * 1000) @@ -85,55 +110,143 @@ async function handleQuery() { } finally { loading.value = false } } +/** 温度区间分布环形图 */ function renderIntervalChart(data: TempBoardDistributionVO[]) { - if (!intervalChartRef.value || !data.length) return - const chart = echarts.init(intervalChartRef.value) + const chart = getChart(intervalChartRef) + if (!chart || !data.length) return + const colorPalette = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399', '#9b59b6', '#1abc9c'] chart.setOption({ - tooltip: { trigger: 'item' }, - legend: { orient: 'vertical', left: 'left' }, - series: [{ type: 'pie', radius: ['40%', '70%'], data: data.map(d => ({ name: d.tempBucket, value: d.sampleCount })), emphasis: { itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0,0,0,0.5)' } } }] + tooltip: { + trigger: 'item', + formatter: (p: any) => `${p.name}
样本数: ${p.value}
占比: ${p.percent}%` + }, + legend: { + orient: 'vertical', left: 'left', top: 'middle', + textStyle: { color: '#666' }, + formatter: (name: string) => name + }, + color: colorPalette, + series: [{ + type: 'pie', + radius: ['40%', '70%'], + center: ['60%', '50%'], + data: data.map(d => ({ name: d.tempBucket, value: d.sampleCount })), + emphasis: { itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0,0,0,0.5)' } }, + label: { formatter: '{b}\n{d}%', fontSize: 12 }, + itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 } + }] }) } +/** 温度直方图 */ function renderHistogramChart(data: TempBoardDistributionVO[]) { - if (!histogramChartRef.value || !data.length) return - const chart = echarts.init(histogramChartRef.value) + const chart = getChart(histogramChartRef) + if (!chart || !data.length) return chart.setOption({ - tooltip: { trigger: 'axis' }, + tooltip: { + trigger: 'axis', + axisPointer: { type: 'shadow' }, + formatter: (params: any) => `${params[0].name}
样本数: ${params[0].value}` + }, grid: { left: 60, right: 30, top: 30, bottom: 40 }, - xAxis: { type: 'category', data: data.map(d => `${d.tempBin}℃`), name: '温度' }, - yAxis: { type: 'value', name: '样本数' }, - series: [{ type: 'bar', data: data.map(d => d.sampleCount), itemStyle: { color: '#67c23a' } }] + xAxis: { + type: 'category', data: data.map(d => `${d.tempBin}℃`), name: '温度', + axisLabel: { color: '#666', rotate: 30 } + }, + yAxis: { type: 'value', name: '样本数', axisLabel: { color: '#666' }, splitLine: { lineStyle: { type: 'dashed' } } }, + series: [{ + type: 'bar', + data: data.map(d => d.sampleCount), + itemStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: '#67c23a' }, + { offset: 1, color: '#b3e19d' } + ]), + borderRadius: [4, 4, 0, 0] + }, + label: { show: true, position: 'top', fontSize: 10, color: '#666' }, + barMaxWidth: 30 + }] }) } +/** 日历热力图 */ function renderCalendarHeatmap(data: TempBoardDistributionVO[]) { - if (!calendarHeatmapRef.value || !data.length) return - const chart = echarts.init(calendarHeatmapRef.value) + const chart = getChart(calendarHeatmapRef) + if (!chart || !data.length) return const dates = data.map(d => d.statDate!) const range = dates.length ? [dates[0], dates[dates.length - 1]] : [] chart.setOption({ - tooltip: { formatter: (p: any) => `${p.value[0]}: ${p.value[1]?.toFixed(2)}℃` }, - visualMap: { min: 10, max: 30, calculable: true, orient: 'horizontal', left: 'center', bottom: 0, inRange: { color: ['#313695', '#4575b4', '#74add1', '#abd9e9', '#fee090', '#fdae61', '#f46d43', '#d73027'] } }, - calendar: { range, cellSize: ['auto', 30] }, - series: [{ type: 'heatmap', coordinateSystem: 'calendar', data: data.map(d => [d.statDate, d.avgTemp]) }] + tooltip: { + formatter: (p: any) => `${p.value[0]}
平均温度: ${p.value[1]?.toFixed(2)}℃` + }, + visualMap: { + min: 10, max: 30, calculable: true, + orient: 'horizontal', left: 'center', bottom: 0, + inRange: { color: ['#313695', '#4575b4', '#74add1', '#abd9e9', '#fee090', '#fdae61', '#f46d43', '#d73027'] }, + textStyle: { color: '#666' } + }, + calendar: { + range, + cellSize: ['auto', 30], + itemStyle: { borderWidth: 3, borderColor: '#fff' }, + yearLabel: { show: false }, + dayLabel: { color: '#666' }, + monthLabel: { color: '#333' } + }, + series: [{ + type: 'heatmap', + coordinateSystem: 'calendar', + data: data.map(d => [d.statDate, d.avgTemp]), + itemStyle: { borderRadius: 2 } + }] }) } +/** 小时热力图 */ function renderHourlyHeatmap(data: TempBoardDistributionVO[]) { - if (!hourlyHeatmapRef.value || !data.length) return - const chart = echarts.init(hourlyHeatmapRef.value) + const chart = getChart(hourlyHeatmapRef) + if (!chart || !data.length) return const hours = Array.from({ length: 24 }, (_, i) => `${i}:00`) const dates = [...new Set(data.map(d => d.statDate!))] chart.setOption({ - tooltip: { formatter: (p: any) => `${p.value[0]} ${hours[p.value[1]]}: ${p.value[2]?.toFixed(2)}℃` }, + tooltip: { + formatter: (p: any) => `${p.value[0]} ${hours[p.value[1]]}
平均温度: ${p.value[2]?.toFixed(2)}℃` + }, grid: { left: 100, right: 60, top: 10, bottom: 60 }, - xAxis: { type: 'category', data: hours, splitArea: { show: true } }, - yAxis: { type: 'category', data: dates, splitArea: { show: true } }, - visualMap: { min: 10, max: 30, calculable: true, orient: 'horizontal', left: 'center', bottom: 0 }, - series: [{ type: 'heatmap', data: data.map(d => [d.statHour, d.statDate, d.avgTemp]), label: { show: false }, emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.5)' } } }] + xAxis: { type: 'category', data: hours, splitArea: { show: true }, axisLabel: { color: '#666' } }, + yAxis: { type: 'category', data: dates, splitArea: { show: true }, axisLabel: { color: '#666' } }, + visualMap: { + min: 10, max: 30, calculable: true, + orient: 'horizontal', left: 'center', bottom: 0, + inRange: { color: ['#313695', '#4575b4', '#74add1', '#abd9e9', '#fee090', '#fdae61', '#f46d43', '#d73027'] }, + textStyle: { color: '#666' } + }, + series: [{ + type: 'heatmap', + data: data.map(d => [d.statHour, d.statDate, d.avgTemp]), + label: { show: false }, + emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.5)' } }, + itemStyle: { borderRadius: 2, borderColor: '#fff', borderWidth: 1 } + }] }) } onMounted(() => { initTimeRange(); handleQuery() }) + + diff --git a/src/views/ems/report/tempBoard/overview/index.vue b/src/views/ems/report/tempBoard/overview/index.vue index d841d9a..35e77d7 100644 --- a/src/views/ems/report/tempBoard/overview/index.vue +++ b/src/views/ems/report/tempBoard/overview/index.vue @@ -1,7 +1,7 @@ + + From a1d4069c8662ebb01dadbac1a180f5cfaebaafd0 Mon Sep 17 00:00:00 2001 From: "zangch@mesnac.com" Date: Wed, 1 Apr 2026 16:58:56 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat(sys=5Foper=5Flog):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E6=93=8D=E4=BD=9C=E6=97=A5=E5=BF=97=E5=A4=87=E6=B3=A8?= =?UTF-8?q?=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/ems/report/iotAdvancedReport.ts | 60 ++ src/api/monitor/operlog/types.ts | 3 + .../ems/base/emsBaseLocationJson/index.vue | 2 +- src/views/ems/report/iotCurveShared.ts | 502 ++++++++++++++ src/views/ems/report/iotReportShared.ts | 638 ++++++++++++++++++ src/views/monitor/operlog/index.vue | 5 + .../monitor/operlog/oper-info-dialog.vue | 5 + 7 files changed, 1214 insertions(+), 1 deletion(-) create mode 100644 src/api/ems/report/iotAdvancedReport.ts create mode 100644 src/views/ems/report/iotCurveShared.ts create mode 100644 src/views/ems/report/iotReportShared.ts diff --git a/src/api/ems/report/iotAdvancedReport.ts b/src/api/ems/report/iotAdvancedReport.ts new file mode 100644 index 0000000..ceddb4c --- /dev/null +++ b/src/api/ems/report/iotAdvancedReport.ts @@ -0,0 +1,60 @@ +import request from '@/utils/request'; +import type { + EfficiencyAnalysisVO, + EnergyAbnormalAlertReportVO, + EmsActionResponse, + InstrumentConditionReportVO, + MeterBalanceReportVO, + RecordIotenvInstantReportQuery +} from '../types'; + +export interface MeterBalanceReportQuery extends RecordIotenvInstantReportQuery { + rootMonitorId?: string; + aggregationType?: 'LAST' | 'AVG' | 'SUM' | string; + imbalanceThreshold?: number | string; +} + +export interface EfficiencyAnalysisQuery extends RecordIotenvInstantReportQuery { + metricCodes?: string[]; +} + +export interface EnergyAbnormalAlertReportQuery extends RecordIotenvInstantReportQuery { + alarmStatus?: number; + pushStatus?: string; +} + +// 查询仪表工况分析 +export function getInstrumentConditionReport(data: RecordIotenvInstantReportQuery): Promise> { + return request({ + url: '/ems/report/iotAdvanced/instrumentCondition', + method: 'post', + data + }); +} + +// 查询计量平衡报表 +export function getMeterBalanceReport(data: MeterBalanceReportQuery): Promise> { + return request({ + url: '/ems/report/iotAdvanced/meterBalance', + method: 'post', + data + }); +} + +// 查询综合运行能效分析 +export function getEfficiencyAnalysisReport(data: EfficiencyAnalysisQuery): Promise> { + return request({ + url: '/ems/report/iotAdvanced/efficiencyAnalysis', + method: 'post', + data + }); +} + +// 查询用能异常报警报表 +export function getEnergyAbnormalAlertReport(data: EnergyAbnormalAlertReportQuery): Promise> { + return request({ + url: '/ems/report/iotAdvanced/energyAbnormalAlert', + method: 'post', + data + }); +} diff --git a/src/api/monitor/operlog/types.ts b/src/api/monitor/operlog/types.ts index 10f65c7..ab3f375 100644 --- a/src/api/monitor/operlog/types.ts +++ b/src/api/monitor/operlog/types.ts @@ -4,6 +4,7 @@ export interface OperLogQuery extends PageQuery { operName: string; businessType: string; status: string; + operRemark: string; orderByColumn: string; isAsc: string; } @@ -28,6 +29,7 @@ export interface OperLogVO extends BaseEntity { errorMsg: string; operTime: string; costTime: number; + operRemark: string; } export interface OperLogForm { @@ -50,4 +52,5 @@ export interface OperLogForm { errorMsg: string; operTime: string; costTime: number; + operRemark: string; } diff --git a/src/views/ems/base/emsBaseLocationJson/index.vue b/src/views/ems/base/emsBaseLocationJson/index.vue index 0541b8b..9b16dbc 100644 --- a/src/views/ems/base/emsBaseLocationJson/index.vue +++ b/src/views/ems/base/emsBaseLocationJson/index.vue @@ -108,7 +108,7 @@ const initFormData: EmsBaseLocationJsonForm = { id: undefined, json: undefined, remark: undefined, - deptId: undefined + deptId: undefined, } const data = reactive>({ form: {...initFormData}, diff --git a/src/views/ems/report/iotCurveShared.ts b/src/views/ems/report/iotCurveShared.ts new file mode 100644 index 0000000..cda5515 --- /dev/null +++ b/src/views/ems/report/iotCurveShared.ts @@ -0,0 +1,502 @@ +import type { RecordIotenvInstantVO } from '@/api/ems/types'; +import * as echarts from 'echarts'; + +export interface CurveChartExpose { + chart?: unknown; + setData?: (option: echarts.EChartsOption) => void; +} + +export interface IotCurveMetricHint { + key?: string; + label?: string; + unit?: string; + aliases?: string[]; +} + +export interface IotCurveMetricDefinition { + key: string; + label: string; + unit: string; + aliases: string[]; + color: string; + areaStartColor: string; + areaEndColor: string; +} + +export interface NormalizedIotCurveRecord extends RecordIotenvInstantVO { + __timeKey: string; + __sortValue: number; +} + +const BASE_METRIC_CATALOG: IotCurveMetricDefinition[] = [ + { + key: 'temperature', + label: '温度', + unit: '℃', + aliases: ['temperature', 'tempreture'], + color: '#ff7a45', + areaStartColor: 'rgba(255, 122, 69, 0.28)', + areaEndColor: 'rgba(255, 122, 69, 0.02)' + }, + { + key: 'humidity', + label: '湿度', + unit: '%', + aliases: ['humidity'], + color: '#14b8a6', + areaStartColor: 'rgba(20, 184, 166, 0.26)', + areaEndColor: 'rgba(20, 184, 166, 0.02)' + }, + { + key: 'noise', + label: '噪声', + unit: 'dB', + aliases: ['noise'], + color: '#8b5cf6', + areaStartColor: 'rgba(139, 92, 246, 0.24)', + areaEndColor: 'rgba(139, 92, 246, 0.02)' + }, + { + key: 'illuminance', + label: '照度', + unit: 'Lux', + aliases: ['illuminance'], + color: '#f59e0b', + areaStartColor: 'rgba(245, 158, 11, 0.24)', + areaEndColor: 'rgba(245, 158, 11, 0.02)' + }, + { + key: 'concentration', + label: '浓度', + unit: 'ppm', + aliases: ['concentration'], + color: '#ef4444', + areaStartColor: 'rgba(239, 68, 68, 0.24)', + areaEndColor: 'rgba(239, 68, 68, 0.02)' + }, + { + key: 'vibrationSpeed', + label: '振动速度', + unit: 'mm/s', + aliases: ['vibrationSpeed', 'speed'], + color: '#2563eb', + areaStartColor: 'rgba(37, 99, 235, 0.24)', + areaEndColor: 'rgba(37, 99, 235, 0.02)' + }, + { + key: 'vibrationDisplacement', + label: '振动位移', + unit: 'um', + aliases: ['vibrationDisplacement', 'displacement'], + color: '#0f766e', + areaStartColor: 'rgba(15, 118, 110, 0.24)', + areaEndColor: 'rgba(15, 118, 110, 0.02)' + }, + { + key: 'vibrationAcceleration', + label: '振动加速度', + unit: 'g', + aliases: ['vibrationAcceleration', 'acceleration'], + color: '#db2777', + areaStartColor: 'rgba(219, 39, 119, 0.24)', + areaEndColor: 'rgba(219, 39, 119, 0.02)' + }, + { + key: 'vibrationTemp', + label: '振动温度', + unit: '℃', + aliases: ['vibrationTemp'], + color: '#f97316', + areaStartColor: 'rgba(249, 115, 22, 0.24)', + areaEndColor: 'rgba(249, 115, 22, 0.02)' + } +]; + +const toNumberOrNull = (value: unknown) => { + if (value === null || value === undefined || value === '') { + return null; + } + const nextValue = Number(value); + return Number.isFinite(nextValue) ? nextValue : null; +}; + +const uniqueStrings = (values: Array) => { + return Array.from( + new Set( + values + .map((item) => String(item || '').trim()) + .filter((item) => item.length > 0) + ) + ); +}; + +const createFallbackMetricDefinition = (hint: IotCurveMetricHint, index: number): IotCurveMetricDefinition => { + const colorPalette = [ + ['#2563eb', 'rgba(37, 99, 235, 0.24)', 'rgba(37, 99, 235, 0.02)'], + ['#14b8a6', 'rgba(20, 184, 166, 0.24)', 'rgba(20, 184, 166, 0.02)'], + ['#f97316', 'rgba(249, 115, 22, 0.24)', 'rgba(249, 115, 22, 0.02)'], + ['#8b5cf6', 'rgba(139, 92, 246, 0.24)', 'rgba(139, 92, 246, 0.02)'], + ['#ef4444', 'rgba(239, 68, 68, 0.24)', 'rgba(239, 68, 68, 0.02)'] + ]; + const [color, areaStartColor, areaEndColor] = colorPalette[index % colorPalette.length]; + const fallbackKey = String(hint.key || hint.aliases?.[0] || `metric_${index + 1}`); + + return { + key: fallbackKey, + label: String(hint.label || fallbackKey), + unit: String(hint.unit || ''), + aliases: uniqueStrings([fallbackKey, ...(hint.aliases || [])]), + color, + areaStartColor, + areaEndColor + }; +}; + +const mergeMetricCatalog = (metricHints: IotCurveMetricHint[] = []) => { + const catalogMap = new Map(); + + BASE_METRIC_CATALOG.forEach((item) => { + catalogMap.set(item.key, item); + }); + + metricHints.forEach((hint, index) => { + const key = String(hint.key || hint.aliases?.[0] || '').trim(); + if (!key) { + return; + } + const previous = catalogMap.get(key); + if (previous) { + catalogMap.set(key, { + ...previous, + label: hint.label || previous.label, + unit: hint.unit || previous.unit, + aliases: uniqueStrings([key, ...(previous.aliases || []), ...(hint.aliases || [])]) + }); + return; + } + catalogMap.set(key, createFallbackMetricDefinition(hint, index)); + }); + + return Array.from(catalogMap.values()); +}; + +const resolveTimeKey = (record: RecordIotenvInstantVO) => { + return String(record?.recodeTime || record?.collectTime || ''); +}; + +const resolveSortValue = (timeKey: string) => { + const timestamp = new Date(timeKey).getTime(); + return Number.isFinite(timestamp) ? timestamp : 0; +}; + +export const normalizeIotCurveRecord = (record: RecordIotenvInstantVO): NormalizedIotCurveRecord => { + const timeKey = resolveTimeKey(record); + return { + ...record, + __timeKey: timeKey, + __sortValue: resolveSortValue(timeKey) + }; +}; + +export const readMetricValue = (record: Record, metric: IotCurveMetricDefinition) => { + for (const alias of metric.aliases) { + const metricValue = toNumberOrNull(record[alias]); + if (metricValue !== null) { + return metricValue; + } + } + return null; +}; + +export const buildMetricHintsByNode = (node?: Record | null) => { + if (!node) { + return [] as IotCurveMetricHint[]; + } + + const metricCode = String(node.metricCode || '').trim(); + const metricName = String(node.metricName || node.pointType || '').trim(); + const unitName = String(node.unitName || '').trim(); + if (!metricCode) { + return []; + } + + // 这里优先把树节点元数据也转成候选指标,后续即使后端新增类型,只要字段名与 metricCode 对上就能直接出图。 + return [ + { + key: metricCode, + label: metricName || String(node.label || metricCode), + unit: unitName, + aliases: [metricCode] + } + ]; +}; + +export const resolveIotCurveMetrics = ( + records: Array>, + metricHints: IotCurveMetricHint[] = [] +) => { + const catalog = mergeMetricCatalog(metricHints); + const preferredKeys = uniqueStrings(metricHints.map((item) => item.key)); + const detectedMetrics = catalog.filter((item) => records.some((record) => readMetricValue(record, item) !== null)); + + if (!records.length) { + return catalog.filter((item) => preferredKeys.includes(item.key)); + } + + const resultMap = new Map(); + preferredKeys.forEach((key) => { + const metric = catalog.find((item) => item.key === key); + if (metric && detectedMetrics.some((item) => item.key === key)) { + resultMap.set(key, metric); + } + }); + detectedMetrics.forEach((item) => { + resultMap.set(item.key, item); + }); + + return Array.from(resultMap.values()); +}; + +export const extractRealtimeIotRecords = (payload: unknown): RecordIotenvInstantVO[] => { + if (!payload) { + return []; + } + if (Array.isArray(payload)) { + return payload as RecordIotenvInstantVO[]; + } + if (typeof payload !== 'object') { + return []; + } + + const source = payload as Record; + const candidateKeys = ['data', 'rows', 'records', 'list', 'payload']; + for (const key of candidateKeys) { + if (Array.isArray(source[key])) { + return source[key] as RecordIotenvInstantVO[]; + } + } + if (resolveTimeKey(source as RecordIotenvInstantVO)) { + return [source as RecordIotenvInstantVO]; + } + return []; +}; + +export const mergeRealtimeWindow = ( + currentRecords: NormalizedIotCurveRecord[], + incomingRecords: RecordIotenvInstantVO[], + windowSize: number +) => { + const mergedMap = new Map(); + [...currentRecords, ...incomingRecords.map(normalizeIotCurveRecord)].forEach((item) => { + if (!item.__timeKey) { + return; + } + const uniqueKey = `${item.monitorId || item.monitorCode || ''}_${item.__timeKey}`; + const previous = mergedMap.get(uniqueKey); + mergedMap.set(uniqueKey, { + ...previous, + ...item + }); + }); + + const normalizedWindowSize = Math.max(1, Number(windowSize) || 1); + return Array.from(mergedMap.values()) + .sort((left, right) => { + if (left.__sortValue !== right.__sortValue) { + return left.__sortValue - right.__sortValue; + } + return left.__timeKey.localeCompare(right.__timeKey); + }) + .slice(-normalizedWindowSize); +}; + +export const buildIotCurveOption = ({ + metric, + monitorName, + records, + premium = false, + subtitle = '' +}: { + metric: IotCurveMetricDefinition; + monitorName: string; + records: Array>; + premium?: boolean; + subtitle?: string; +}): echarts.EChartsOption => { + const titleText = `${monitorName || '设备'} ${metric.label}曲线`; + const xData = records.map((item) => String(item.__timeKey || item.recodeTime || item.collectTime || '')); + const seriesData = records.map((item) => readMetricValue(item, metric)); + + return { + backgroundColor: 'transparent', + animation: premium, + animationDuration: premium ? 420 : 0, + animationDurationUpdate: premium ? 320 : 0, + title: { + text: titleText, + subtext: subtitle, + left: 'center', + top: 10, + textStyle: { + color: premium ? '#0f172a' : '#0f172a', + fontSize: premium ? 18 : 16, + fontWeight: 600 + }, + subtextStyle: { + color: premium ? '#64748b' : '#64748b', + fontSize: 12 + } + }, + grid: { + top: premium ? '18%' : '16%', + bottom: 56, + left: 68, + right: 28 + }, + tooltip: { + trigger: 'axis', + backgroundColor: premium ? 'rgba(15, 23, 42, 0.92)' : 'rgba(15, 23, 42, 0.88)', + borderWidth: 0, + textStyle: { + color: '#ffffff' + }, + axisPointer: { + type: 'line', + label: { + show: true + } + } + }, + legend: { + right: 20, + top: 16, + itemWidth: 14, + itemHeight: 8, + textStyle: { + color: '#475569' + } + }, + dataZoom: [ + { + type: 'inside', + filterMode: 'none' + }, + { + type: 'slider', + height: 22, + bottom: 14, + borderColor: 'transparent', + backgroundColor: premium ? 'rgba(148, 163, 184, 0.16)' : 'rgba(148, 163, 184, 0.14)', + fillerColor: premium ? 'rgba(59, 130, 246, 0.18)' : 'rgba(59, 130, 246, 0.14)', + handleStyle: { + color: metric.color + } + } + ], + xAxis: { + type: 'category', + boundaryGap: false, + data: xData, + axisLine: { + show: true, + lineStyle: { + color: '#cbd5e1' + } + }, + axisTick: { + show: false + }, + axisLabel: { + color: '#64748b', + hideOverlap: true + } + }, + yAxis: { + type: 'value', + name: metric.unit ? `${metric.label}(${metric.unit})` : metric.label, + nameTextStyle: { + color: '#475569' + }, + splitLine: { + lineStyle: { + color: 'rgba(148, 163, 184, 0.18)' + } + }, + axisTick: { + show: false + }, + axisLine: { + show: true, + lineStyle: { + color: '#cbd5e1' + } + }, + axisLabel: { + color: '#64748b' + } + }, + series: [ + { + name: metric.unit ? `${metric.label}(${metric.unit})` : metric.label, + type: 'line', + smooth: true, + connectNulls: true, + showSymbol: records.length <= 80, + symbol: 'circle', + symbolSize: premium ? 7 : 6, + lineStyle: { + width: premium ? 3 : 2, + color: metric.color + }, + itemStyle: { + color: metric.color, + borderColor: '#ffffff', + borderWidth: premium ? 2 : 1 + }, + areaStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: metric.areaStartColor }, + { offset: 1, color: metric.areaEndColor } + ]) + }, + emphasis: { + focus: 'series' + }, + data: seriesData + } + ] + }; +}; + +export const connectCurveCharts = ( + charts: Array< + | { + chart?: unknown; + } + | null + | undefined + >, + groupId: string +) => { + const chartInstances = charts + .map((item) => item?.chart) + .filter((item): item is echarts.ECharts => Boolean(item) && typeof (item as echarts.ECharts).setOption === 'function'); + + if (chartInstances.length < 2) { + return () => undefined; + } + + chartInstances.forEach((item) => { + item.group = groupId; + }); + echarts.connect(groupId); + + return () => { + echarts.disconnect(groupId); + chartInstances.forEach((item) => { + if (item.group === groupId) { + item.group = ''; + } + }); + }; +}; diff --git a/src/views/ems/report/iotReportShared.ts b/src/views/ems/report/iotReportShared.ts new file mode 100644 index 0000000..968d79f --- /dev/null +++ b/src/views/ems/report/iotReportShared.ts @@ -0,0 +1,638 @@ +import { computed, nextTick, reactive, ref, watch } from 'vue'; +import { useRoute } from 'vue-router'; +import { ElMessage } from 'element-plus'; +import { parseTime } from '@/utils/ruoyi'; +import { getMonitorInfoTree } from '@/api/ems/base/baseMonitorInfo'; +import { getRecordIotenvInstantReportData } from '@/api/ems/record/recordIotenvInstant'; +import type { RecordIotenvInstantVO } from '@/api/ems/types'; +import { + buildMetricHintsByNode, + normalizeIotCurveRecord, + readMetricValue, + resolveIotCurveMetrics, + type IotCurveMetricDefinition, + type IotCurveMetricHint, + type NormalizedIotCurveRecord +} from './iotCurveShared'; + +export interface IotReportMetricStats { + latest: number | null; + avg: number | null; + max: number | null; + p75: number | null; + p90: number | null; +} + +export interface IotReportDeviceStat { + monitorId: string; + monitorName: string; + latest: number; + avg: number; + max: number; + count: number; +} + +export interface IotReportScopeOption { + key: string; + label: string; + deviceCount: number; +} + +interface IotTreeLeafNode extends Record { + code?: string; + label?: string; + type?: unknown; + __rootKey?: string; +} + +interface IotReportSelection { + compareScope: string; + monitorId: string; + monitorIds: string[]; + monitorName: string; + selectionLabel: string; + nodeId: string; +} + +const createDefaultTimeRange = () => { + const now = new Date(); + const start = new Date(now.getTime() - 2 * 60 * 60 * 1000); + return [parseTime(start, '{y}-{m}-{d} {h}:{i}:{s}'), parseTime(now, '{y}-{m}-{d} {h}:{i}:{s}')]; +}; + +const resolveNodeKey = (node: Record) => { + return String(node.id || node.code || node.label || Math.random()); +}; + +const cloneTreeByRootKeys = (nodes: Array>, selectedKeys: string[]) => { + return (nodes || []).filter((item) => selectedKeys.includes(resolveNodeKey(item))); +}; + +const getLeafNodes = (nodes: Array>, rootKey?: string): IotTreeLeafNode[] => { + let result: IotTreeLeafNode[] = []; + (nodes || []).forEach((item) => { + const currentRootKey = rootKey || resolveNodeKey(item); + if (Array.isArray(item.children) && item.children.length > 0) { + result = result.concat(getLeafNodes(item.children as Array>, currentRootKey)); + return; + } + if (item.code) { + result.push({ + ...item, + __rootKey: currentRootKey + }); + } + }); + return result; +}; + +const buildDeviceDisplayMap = (nodes: Array>) => { + return getLeafNodes(nodes).reduce((accumulator, item) => { + const key = String(item.code || ''); + if (!key) { + return accumulator; + } + accumulator[key] = { + label: String(item.label || key), + type: item.type + }; + return accumulator; + }, {} as Record); +}; + +const findRootKeyByNodeId = (nodes: Array>, targetId: string, rootKey = ''): string => { + for (const item of nodes || []) { + const currentRootKey = rootKey || resolveNodeKey(item); + if (String(item.id || '') === String(targetId || '')) { + return currentRootKey; + } + if (Array.isArray(item.children) && item.children.length > 0) { + const matched = findRootKeyByNodeId(item.children as Array>, targetId, currentRootKey); + if (matched) { + return matched; + } + } + } + return ''; +}; + +const buildSelectionByLeafNodes = (leafNodes: IotTreeLeafNode[], selectionLabel: string, compareScope = ''): IotReportSelection => { + const monitorIds = leafNodes.map((item) => String(item.code || '')).filter((item) => item); + const firstNode = leafNodes[0]; + return { + compareScope: compareScope || (monitorIds.length > 1 ? 'group' : 'single'), + monitorId: monitorIds.length === 1 ? monitorIds[0] : '', + monitorIds, + monitorName: monitorIds.length === 1 ? String(firstNode?.label || selectionLabel) : selectionLabel, + selectionLabel, + nodeId: String(firstNode?.id || '') + }; +}; + +export const isValidNumber = (value: unknown) => value !== null && value !== undefined && !Number.isNaN(Number(value)); + +export const average = (values: number[]) => { + if (!values.length) { + return 0; + } + return values.reduce((sum, item) => sum + item, 0) / values.length; +}; + +export const percentile = (sortedValues: number[], ratio: number) => { + if (!sortedValues.length) { + return 0; + } + const index = Math.min(sortedValues.length - 1, Math.max(0, Math.ceil(sortedValues.length * ratio) - 1)); + return sortedValues[index]; +}; + +export const getBands = (values: number[]) => { + const sortedValues = [...values].sort((left, right) => left - right); + return { + low: percentile(sortedValues, 0.33), + high: percentile(sortedValues, 0.66), + avg: average(sortedValues), + p75: percentile(sortedValues, 0.75), + p90: percentile(sortedValues, 0.9) + }; +}; + +export const formatValue = (value: unknown) => { + if (!isValidNumber(value)) { + return '--'; + } + return Number(value).toFixed(2); +}; + +export const formatPercent = (value: unknown) => { + return `${(Number(value || 0) * 100).toFixed(1)}%`; +}; + +export const normalizePercent = (value: number | null, max: number | null) => { + if (!isValidNumber(value) || !isValidNumber(max) || Number(max) <= 0) { + return 0; + } + return Number((((value || 0) / (max || 1)) * 100).toFixed(2)); +}; + +export const ceilNumber = (value: number) => Math.ceil(value * 10) / 10; + +export const useIotReportWorkbench = () => { + const route = useRoute(); + + const loading = ref(false); + const treeLoading = ref(false); + const reportReady = ref(false); + const monitorTreeOptions = ref>>([]); + const deviceDisplayMap = ref>({}); + const selectedScopeKeys = ref([]); + const currentTreeNodeId = ref(''); + const rawRecords = ref([]); + const daterangeRecordTime = ref(createDefaultTimeRange()); + + const queryForm = reactive({ + compareScope: 'single', + monitorId: '', + monitorIds: [] as string[], + monitorName: '', + selectionLabel: '当前分析范围', + samplingInterval: 5, + primaryMetricField: '' + }); + + const leafNodeMap = computed(() => { + return getLeafNodes(monitorTreeOptions.value).reduce((accumulator, item) => { + const key = String(item.code || ''); + if (key) { + accumulator[key] = item; + } + return accumulator; + }, {} as Record); + }); + + const selectedLeafNodes = computed(() => { + const currentIds = queryForm.monitorIds.length ? queryForm.monitorIds : queryForm.monitorId ? [queryForm.monitorId] : []; + return currentIds.map((item) => leafNodeMap.value[item]).filter(Boolean); + }); + + const metricHints = computed(() => { + return selectedLeafNodes.value.flatMap((item) => buildMetricHintsByNode(item)); + }); + + const metricConfigs = computed(() => { + return resolveIotCurveMetrics(rawRecords.value, metricHints.value); + }); + + const primaryMetric = computed(() => { + if (!metricConfigs.value.length) { + return null; + } + const matched = metricConfigs.value.find((item) => item.key === queryForm.primaryMetricField); + return matched || metricConfigs.value[0]; + }); + + const availablePrimaryMetricOptions = computed(() => metricConfigs.value); + + const deviceCount = computed(() => { + if (queryForm.monitorIds.length > 0) { + return queryForm.monitorIds.length; + } + return queryForm.monitorId ? 1 : 0; + }); + + const hasMultiDevice = computed(() => { + return queryForm.monitorIds.length > 1 || ['group', 'all'].includes(queryForm.compareScope); + }); + + const availableScopeOptions = computed(() => { + return (monitorTreeOptions.value || []).map((item) => ({ + key: resolveNodeKey(item), + label: String(item.label || '未命名分组'), + deviceCount: getLeafNodes([item]).length + })); + }); + + const filteredMonitorTreeOptions = computed(() => { + if (!selectedScopeKeys.value.length) { + return monitorTreeOptions.value; + } + return cloneTreeByRootKeys(monitorTreeOptions.value, selectedScopeKeys.value); + }); + + const monitorTypeLabel = computed(() => { + return metricConfigs.value.length ? metricConfigs.value.map((item) => item.label).join(' / ') : '待识别'; + }); + + const selectionSummary = computed(() => { + const summaryMap = { + all: '多分组联合分析', + group: '设备组分析', + single: '单设备分析' + }; + return summaryMap[queryForm.compareScope] || '分析中'; + }); + + const energySelectionSummary = computed(() => { + if (!selectedScopeKeys.value.length) { + return '当前未限定分组,默认展示全部范围'; + } + const labels = availableScopeOptions.value.filter((item) => selectedScopeKeys.value.includes(item.key)).map((item) => item.label); + return `当前分组:${labels.join(' / ')}`; + }); + + const deviceSelectionSummary = computed(() => { + return queryForm.selectionLabel ? `分析范围:${queryForm.selectionLabel}` : '分析范围待确认'; + }); + + const analysisObjectValue = computed(() => { + if (hasMultiDevice.value) { + return `${deviceCount.value} 台设备`; + } + return queryForm.monitorName || queryForm.selectionLabel || '--'; + }); + + const analysisObjectFoot = computed(() => { + return hasMultiDevice.value ? '当前报表按设备组/联合范围聚合分析' : '当前报表按单设备时间轨迹分析'; + }); + + const metricDescription = computed(() => { + return metricConfigs.value.length ? metricConfigs.value.map((item) => item.label).join('、') : '等待识别有效指标'; + }); + + const primaryMetricFoot = computed(() => { + return primaryMetric.value ? `当前主看指标用于排名、筛选与小时均值分析` : '请选择有效主看指标'; + }); + + const getMonitorDisplayName = (monitorId: string) => { + const cachedItem = deviceDisplayMap.value[monitorId]; + return cachedItem?.label || monitorId || '--'; + }; + + const getShortDisplayName = (monitorId: string) => { + const displayName = getMonitorDisplayName(monitorId); + return displayName.length <= 10 ? displayName : `${displayName.slice(0, 9)}...`; + }; + + const buildSeriesTimeAxis = () => { + return Array.from(new Set(rawRecords.value.map((item) => item.__timeKey).filter((item) => item))); + }; + + const getMetricValues = (metric: IotCurveMetricDefinition, monitorId = '') => { + return rawRecords.value + .filter((item) => !monitorId || String(item.monitorId || item.monitorCode || '') === monitorId) + .map((item) => readMetricValue(item, metric)) + .filter((item): item is number => item !== null); + }; + + const getMetricStats = (metric: IotCurveMetricDefinition, monitorId = ''): IotReportMetricStats => { + const values = getMetricValues(metric, monitorId); + if (!values.length) { + return { + latest: null, + avg: null, + max: null, + p75: null, + p90: null + }; + } + const sortedValues = [...values].sort((left, right) => left - right); + return { + latest: values[values.length - 1], + avg: average(values), + max: sortedValues[sortedValues.length - 1], + p75: percentile(sortedValues, 0.75), + p90: percentile(sortedValues, 0.9) + }; + }; + + const metricCards = computed(() => { + return metricConfigs.value.slice(0, 4).map((metric) => { + const stats = getMetricStats(metric); + return { + ...metric, + latest: stats.latest, + avg: stats.avg, + max: stats.max + }; + }); + }); + + const deviceStats = computed(() => { + if (!primaryMetric.value) { + return []; + } + const deviceMap = {} as Record; + rawRecords.value.forEach((item) => { + const monitorId = String(item.monitorId || item.monitorCode || ''); + const metricValue = readMetricValue(item, primaryMetric.value as IotCurveMetricDefinition); + if (!monitorId || metricValue === null) { + return; + } + if (!deviceMap[monitorId]) { + deviceMap[monitorId] = []; + } + deviceMap[monitorId].push(metricValue); + }); + + return Object.keys(deviceMap) + .map((monitorId) => { + const values = deviceMap[monitorId]; + const sortedValues = [...values].sort((left, right) => left - right); + return { + monitorId, + monitorName: getMonitorDisplayName(monitorId), + latest: values[values.length - 1], + avg: average(values), + max: sortedValues[sortedValues.length - 1], + count: values.length + }; + }) + .sort((left, right) => right.avg - left.avg); + }); + + const coverageRatio = computed(() => { + if (!rawRecords.value.length || !metricConfigs.value.length) { + return 0; + } + const totalPoints = rawRecords.value.length * metricConfigs.value.length; + const validPoints = metricConfigs.value.reduce((sum, metric) => sum + getMetricValues(metric).length, 0); + return totalPoints ? validPoints / totalPoints : 0; + }); + + const loadMonitorTree = async () => { + treeLoading.value = true; + try { + // 这里不再硬编码能源类型编号,直接读取当前项目可用树结构,后续新增/删减类型不需要改页面。 + const response = await getMonitorInfoTree({}); + monitorTreeOptions.value = response.data || []; + deviceDisplayMap.value = buildDeviceDisplayMap(monitorTreeOptions.value); + } finally { + treeLoading.value = false; + } + }; + + const buildSelectionByMonitorIds = (monitorIds: string[]) => { + const leafNodes = monitorIds.map((item) => leafNodeMap.value[item]).filter(Boolean); + if (!leafNodes.length) { + return { + compareScope: 'all', + monitorId: '', + monitorIds: [], + monitorName: '', + selectionLabel: '当前分析范围', + nodeId: '' + } as IotReportSelection; + } + const compareScope = leafNodes.length > 1 ? 'group' : 'single'; + const selectionLabel = leafNodes.length > 1 ? `选中设备组(${leafNodes.length}台)` : String(leafNodes[0].label || '当前设备'); + return buildSelectionByLeafNodes(leafNodes, selectionLabel, compareScope); + }; + + const applySelection = (selection: IotReportSelection, shouldQuery = true) => { + queryForm.compareScope = selection.compareScope; + queryForm.monitorId = selection.monitorId; + queryForm.monitorIds = selection.monitorIds; + queryForm.monitorName = selection.monitorName; + queryForm.selectionLabel = selection.selectionLabel; + currentTreeNodeId.value = selection.nodeId; + if (shouldQuery) { + void handleQuery(); + } + }; + + const applySelectionByScopeKeys = (shouldQuery = true) => { + const selectedRoots = cloneTreeByRootKeys(monitorTreeOptions.value, selectedScopeKeys.value); + if (!selectedRoots.length) { + return; + } + const leafNodes = getLeafNodes(selectedRoots); + const selectionLabel = + selectedRoots.length === 1 ? `${String(selectedRoots[0].label || '当前分组')}设备组` : `${selectedRoots.map((item) => item.label).join(' / ')}联合分析`; + const compareScope = selectedRoots.length > 1 ? 'all' : ''; + applySelection(buildSelectionByLeafNodes(leafNodes, selectionLabel, compareScope), shouldQuery); + }; + + const initQueryFromRoute = () => { + const { query } = route; + const selectionCacheKey = String(query.selectionKey || 'recordIotenvInstantDashboardSelection'); + const selectionCacheText = window.sessionStorage.getItem(selectionCacheKey); + let selectionCache = {} as Record; + try { + selectionCache = selectionCacheText ? JSON.parse(selectionCacheText) : {}; + } catch (error) { + selectionCache = {}; + } + + const monitorIds = String(query.monitorIds || '') + .split(',') + .map((item) => item.trim()) + .filter((item) => item); + + queryForm.compareScope = String(query.compareScope || (monitorIds.length > 1 ? 'group' : 'single')); + queryForm.monitorId = String(query.monitorId || ''); + queryForm.monitorIds = monitorIds; + queryForm.monitorName = String(query.monitorName || query.monitorId || ''); + queryForm.selectionLabel = String(query.selectionLabel || selectionCache.selectionLabel || query.monitorName || '当前分析范围'); + queryForm.samplingInterval = Number(query.samplingInterval || 5); + queryForm.primaryMetricField = String(query.primaryMetricField || ''); + + const beginRecordTime = String(query.beginRecordTime || ''); + const endRecordTime = String(query.endRecordTime || ''); + if (beginRecordTime && endRecordTime) { + daterangeRecordTime.value = [beginRecordTime, endRecordTime]; + } + }; + + const applyInitialSelection = () => { + if (!monitorTreeOptions.value.length) { + return; + } + const routeIds = queryForm.monitorIds.length ? queryForm.monitorIds : queryForm.monitorId ? [queryForm.monitorId] : []; + if (routeIds.length) { + const selection = buildSelectionByMonitorIds(routeIds); + if (selection.monitorIds.length) { + selectedScopeKeys.value = Array.from(new Set(routeIds.map((item) => leafNodeMap.value[item]?.__rootKey).filter(Boolean) as string[])); + applySelection(selection, false); + return; + } + } + selectedScopeKeys.value = [resolveNodeKey(monitorTreeOptions.value[0])]; + applySelectionByScopeKeys(false); + }; + + const buildRequestQuery = () => { + const query: Record = { + samplingInterval: queryForm.samplingInterval, + params: { + beginRecordTime: daterangeRecordTime.value[0], + endRecordTime: daterangeRecordTime.value[1] + } + }; + if (queryForm.monitorIds.length > 1) { + query.monitorIds = queryForm.monitorIds; + } else { + query.monitorId = queryForm.monitorIds[0] || queryForm.monitorId; + } + return query; + }; + + const handleQuery = async () => { + if (!deviceCount.value) { + ElMessage.warning('当前没有可分析的设备范围'); + return; + } + if (!daterangeRecordTime.value || daterangeRecordTime.value.length !== 2) { + ElMessage.warning('请选择完整的记录时间范围'); + return; + } + + loading.value = true; + reportReady.value = false; + try { + const { data = [] } = await getRecordIotenvInstantReportData(buildRequestQuery()); + rawRecords.value = (data as RecordIotenvInstantVO[]).map((item) => normalizeIotCurveRecord(item)); + if (metricConfigs.value.length && !metricConfigs.value.some((item) => item.key === queryForm.primaryMetricField)) { + queryForm.primaryMetricField = metricConfigs.value[0].key; + } + reportReady.value = rawRecords.value.length > 0 && metricConfigs.value.length > 0; + } catch (error) { + ElMessage.error('报表加载失败,请稍后重试'); + } finally { + loading.value = false; + } + }; + + const handleScopeChange = (value: string[]) => { + if (!value || !value.length) { + nextTick(() => { + selectedScopeKeys.value = availableScopeOptions.value.length ? [availableScopeOptions.value[0].key] : []; + }); + ElMessage.warning('至少保留一个分组用于分析'); + return; + } + applySelectionByScopeKeys(); + }; + + const handleTreeNodeClick = (data: Record) => { + if (!data) { + return; + } + const rootKey = findRootKeyByNodeId(monitorTreeOptions.value, String(data.id || '')); + if (rootKey) { + selectedScopeKeys.value = [rootKey]; + } + if (Array.isArray(data.children) && data.children.length > 0) { + applySelection(buildSelectionByLeafNodes(getLeafNodes([data]), `${String(data.label || '当前节点')}设备组`, 'group')); + return; + } + const monitorId = String(data.code || ''); + applySelection(buildSelectionByMonitorIds([monitorId])); + }; + + watch( + metricConfigs, + (value) => { + if (!value.length) { + queryForm.primaryMetricField = ''; + return; + } + if (!value.some((item) => item.key === queryForm.primaryMetricField)) { + queryForm.primaryMetricField = value[0].key; + } + }, + { + immediate: true + } + ); + + const initialize = async () => { + initQueryFromRoute(); + await loadMonitorTree(); + applyInitialSelection(); + await handleQuery(); + }; + + return { + availablePrimaryMetricOptions, + availableScopeOptions, + analysisObjectFoot, + analysisObjectValue, + buildSeriesTimeAxis, + coverageRatio, + currentTreeNodeId, + daterangeRecordTime, + deviceCount, + deviceDisplayMap, + deviceSelectionSummary, + deviceStats, + energySelectionSummary, + filteredMonitorTreeOptions, + formatPercent, + formatValue, + getBands, + getMetricStats, + getMetricValues, + getMonitorDisplayName, + getShortDisplayName, + handleQuery, + handleScopeChange, + handleTreeNodeClick, + hasMultiDevice, + initialize, + loading, + metricCards, + metricConfigs, + metricDescription, + monitorTreeOptions, + monitorTypeLabel, + primaryMetric, + primaryMetricFoot, + queryForm, + rawRecords, + reportReady, + selectedScopeKeys, + selectionSummary, + treeLoading, + availableMetricHints: metricHints + }; +}; diff --git a/src/views/monitor/operlog/index.vue b/src/views/monitor/operlog/index.vue index dc66371..952b00f 100644 --- a/src/views/monitor/operlog/index.vue +++ b/src/views/monitor/operlog/index.vue @@ -7,6 +7,9 @@ + + + @@ -73,6 +76,7 @@ +