From a1d4069c8662ebb01dadbac1a180f5cfaebaafd0 Mon Sep 17 00:00:00 2001 From: "zangch@mesnac.com" Date: Wed, 1 Apr 2026 16:58:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(sys=5Foper=5Flog):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E6=97=A5=E5=BF=97=E5=A4=87=E6=B3=A8=E5=AD=97?= =?UTF-8?q?=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 @@ +