You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

639 lines
21 KiB
TypeScript

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<string, unknown> {
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<string, unknown>) => {
return String(node.id || node.code || node.label || Math.random());
};
const cloneTreeByRootKeys = (nodes: Array<Record<string, unknown>>, selectedKeys: string[]) => {
return (nodes || []).filter((item) => selectedKeys.includes(resolveNodeKey(item)));
};
const getLeafNodes = (nodes: Array<Record<string, unknown>>, 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<Record<string, unknown>>, currentRootKey));
return;
}
if (item.code) {
result.push({
...item,
__rootKey: currentRootKey
});
}
});
return result;
};
const buildDeviceDisplayMap = (nodes: Array<Record<string, unknown>>) => {
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<string, { label: string; type: unknown }>);
};
const findRootKeyByNodeId = (nodes: Array<Record<string, unknown>>, 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<Record<string, unknown>>, 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<Array<Record<string, unknown>>>([]);
const deviceDisplayMap = ref<Record<string, { label: string; type: unknown }>>({});
const selectedScopeKeys = ref<string[]>([]);
const currentTreeNodeId = ref('');
const rawRecords = ref<NormalizedIotCurveRecord[]>([]);
const daterangeRecordTime = ref<string[]>(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<string, IotTreeLeafNode>);
});
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<IotCurveMetricHint[]>(() => {
return selectedLeafNodes.value.flatMap((item) => buildMetricHintsByNode(item));
});
const metricConfigs = computed<IotCurveMetricDefinition[]>(() => {
return resolveIotCurveMetrics(rawRecords.value, metricHints.value);
});
const primaryMetric = computed<IotCurveMetricDefinition | null>(() => {
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<IotReportScopeOption[]>(() => {
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<IotReportDeviceStat[]>(() => {
if (!primaryMetric.value) {
return [];
}
const deviceMap = {} as Record<string, number[]>;
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<string, unknown>;
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<string, unknown> = {
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<string, unknown>) => {
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
};
};