|
|
@@ -0,0 +1,315 @@
|
|
|
+<template>
|
|
|
+ <div class="realtime-curve-container">
|
|
|
+ <!-- ECharts 容器 -->
|
|
|
+ <div ref="chartRef" class="chart-box"></div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <script setup lang="ts">
|
|
|
+ // 补全所有必要导入,并严格指定类型
|
|
|
+ import { ref, reactive, onMounted, onUnmounted, watch } from 'vue';
|
|
|
+ import * as echarts from 'echarts';
|
|
|
+ import type { ECharts, EChartsOption, YAxisOption, SeriesOption } from 'echarts';
|
|
|
+ import BizVarDictAPI, { BizVarDictTable, BizVarDictPageQuery } from '@/api/module_business/vardict';
|
|
|
+ import MqttUtil, { MqttMessageCallback } from '@/utils/mqttUtil';
|
|
|
+
|
|
|
+ // ========== 严格 TS 类型定义 ==========
|
|
|
+ /** MQTT 消息 payload 数据项类型 */
|
|
|
+ interface MqttPayloadItem {
|
|
|
+ var_code: string;
|
|
|
+ value: number | string;
|
|
|
+ [key: string]: unknown; // 兼容其他字段
|
|
|
+ }
|
|
|
+
|
|
|
+ /** MQTT 消息 payload 类型 */
|
|
|
+ interface MqttPayload {
|
|
|
+ data: MqttPayloadItem[];
|
|
|
+ [key: string]: unknown;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 曲线数据结构类型 */
|
|
|
+ interface CurveDataItem {
|
|
|
+ xData: string[]; // X轴:时间戳字符串
|
|
|
+ yData: number[]; // Y轴:数值
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 曲线数据映射类型 */
|
|
|
+ type CurveDataMap = Record<string, CurveDataItem>;
|
|
|
+
|
|
|
+ // ========== 业务配置 ==========
|
|
|
+ const craneInfo = JSON.parse(localStorage.getItem('craneInfo') || '{}') as Record<string, string>;
|
|
|
+ const loading = ref<boolean>(true);
|
|
|
+ // 严格指定查询参数类型,避免 undefined 隐式类型
|
|
|
+ const queryFormVarDictData = reactive<BizVarDictPageQuery>({
|
|
|
+ page_no: 0,
|
|
|
+ page_size: 0,
|
|
|
+ crane_no: craneInfo.crane_no || '', // 兜底空字符串,避免 TS 报错
|
|
|
+ var_code: undefined,
|
|
|
+ var_name: undefined,
|
|
|
+ mec_type: undefined,
|
|
|
+ switch_type: undefined,
|
|
|
+ gateway_id: undefined,
|
|
|
+ var_group: undefined,
|
|
|
+ var_category: undefined,
|
|
|
+ is_top_show: undefined,
|
|
|
+ is_save: undefined,
|
|
|
+ is_overview_top_show: undefined,
|
|
|
+ is_home_page_show: undefined,
|
|
|
+ status: undefined,
|
|
|
+ created_time: undefined,
|
|
|
+ updated_time: undefined,
|
|
|
+ created_id: undefined,
|
|
|
+ updated_id: undefined,
|
|
|
+ });
|
|
|
+
|
|
|
+ // MQTT 配置(TS 类型约束)
|
|
|
+ const mqttConfig = {
|
|
|
+ wsUrl: import.meta.env.VITE_APP_WS_ENDPOINT || 'ws://127.0.0.1:9001',
|
|
|
+ topics: [`cdc/${craneInfo.crane_no || ''}/analog/batch/#`], // 兜底空字符串
|
|
|
+ };
|
|
|
+ const mqttUtil = new MqttUtil(mqttConfig);
|
|
|
+
|
|
|
+ // ========== 核心数据(严格 TS 类型) ==========
|
|
|
+ const allData = ref<BizVarDictTable[]>([]);
|
|
|
+ const chartRef = ref<HTMLDivElement | null>(null);
|
|
|
+ let chartInstance: ECharts | null = null;
|
|
|
+ const curveData = ref<CurveDataMap>({});
|
|
|
+ const MAX_DATA_LEN = 30; // 最大数据点数量
|
|
|
+
|
|
|
+ // ========== 解决 ECharts 报错核心:规范 Y 轴/系列配置 ==========
|
|
|
+ const initECharts = (): void => {
|
|
|
+ if (!chartRef.value) return;
|
|
|
+
|
|
|
+ // 销毁旧实例,避免重复创建
|
|
|
+ if (chartInstance) {
|
|
|
+ chartInstance.dispose();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 初始化 ECharts 实例(指定 DOM 类型)
|
|
|
+ chartInstance = echarts.init(chartRef.value);
|
|
|
+
|
|
|
+ // 基础配置:无标题、图例置顶(规避 getAxesOnZeroOf 报错的核心:Y轴先初始化单轴,后续动态更新)
|
|
|
+ const baseOption: EChartsOption = {
|
|
|
+ title: { show: false }, // 隐藏标题
|
|
|
+ legend: {
|
|
|
+ top: 0, // 图例置顶
|
|
|
+ left: 'center',
|
|
|
+ textStyle: { fontSize: 12 },
|
|
|
+ type: 'scroll', // 图例过多时滚动,避免溢出
|
|
|
+ },
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'axis',
|
|
|
+ axisPointer: { type: 'line' }, // 改用 line 类型,避免 shadow 兼容性问题
|
|
|
+ formatter: (params: echarts.EChartOption.TooltipFormatterParams) => {
|
|
|
+ if (!Array.isArray(params) || params.length === 0) return '';
|
|
|
+ const timeStr = new Date(params[0].name).toLocaleTimeString();
|
|
|
+ return params.reduce((res, param) => {
|
|
|
+ const value = param.value as number;
|
|
|
+ return `${res}${param.seriesName}: ${value.toFixed(2)}<br/>`;
|
|
|
+ }, `${timeStr}<br/>`);
|
|
|
+ },
|
|
|
+ },
|
|
|
+ grid: {
|
|
|
+ left: '10%',
|
|
|
+ right: '10%',
|
|
|
+ bottom: '15%',
|
|
|
+ top: '15%',
|
|
|
+ containLabel: true,
|
|
|
+ },
|
|
|
+ xAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: [],
|
|
|
+ axisLabel: {
|
|
|
+ formatter: (value: string) => new Date(value).toLocaleTimeString(),
|
|
|
+ },
|
|
|
+ boundaryGap: false, // 取消边界间隙,曲线更连续
|
|
|
+ },
|
|
|
+ // 初始单 Y 轴(解决 getAxesOnZeroOf 报错),后续按变量值域分组分配区间
|
|
|
+ yAxis: {
|
|
|
+ type: 'value',
|
|
|
+ axisLine: { show: true },
|
|
|
+ splitLine: { show: true },
|
|
|
+ },
|
|
|
+ series: [], // 动态生成系列
|
|
|
+ };
|
|
|
+
|
|
|
+ chartInstance.setOption(baseOption);
|
|
|
+
|
|
|
+ // 监听窗口大小变化
|
|
|
+ window.addEventListener('resize', handleResize);
|
|
|
+ };
|
|
|
+
|
|
|
+ /** 窗口大小变化处理 */
|
|
|
+ const handleResize = (): void => {
|
|
|
+ chartInstance?.resize();
|
|
|
+ };
|
|
|
+
|
|
|
+ /** 初始化曲线数据 */
|
|
|
+ const initCurveData = (): void => {
|
|
|
+ if (!allData.value.length) return;
|
|
|
+
|
|
|
+ // 初始化每个变量的曲线数据
|
|
|
+ curveData.value = allData.value.reduce((map, item) => {
|
|
|
+ map[item.var_code] = { xData: [], yData: [] };
|
|
|
+ return map;
|
|
|
+ }, {} as CurveDataMap);
|
|
|
+
|
|
|
+ // 初始化系列配置(曲线不交叉核心:按变量值域分配不同区间)
|
|
|
+ updateEChartsSeries();
|
|
|
+ };
|
|
|
+
|
|
|
+ /** 更新 ECharts 系列配置(规避曲线交叉:值域分段) */
|
|
|
+ const updateEChartsSeries = (): void => {
|
|
|
+ if (!chartInstance || !allData.value.length) return;
|
|
|
+
|
|
|
+ // 为每个变量分配独立值域区间(避免交叉)
|
|
|
+ const seriesList: SeriesOption[] = allData.value.map((item, index) => {
|
|
|
+ // 按索引分配值域区间(如第1个变量[0-100],第2个[100-200],以此类推)
|
|
|
+ const valueRange = index * 100;
|
|
|
+
|
|
|
+ return {
|
|
|
+ name: item.var_name || item.var_code,
|
|
|
+ type: 'line',
|
|
|
+ data: curveData.value[item.var_code]?.yData || [],
|
|
|
+ symbol: 'none',
|
|
|
+ smooth: true,
|
|
|
+ lineStyle: { width: 2 },
|
|
|
+ // 值域映射:将原始值映射到独立区间,避免交叉
|
|
|
+ encode: { y: 0 },
|
|
|
+ // 自定义数值转换(核心:曲线不交叉)
|
|
|
+ transform: [
|
|
|
+ {
|
|
|
+ type: 'ecSimpleTransform:custom',
|
|
|
+ config: {
|
|
|
+ callback: (params: { value: number }) => {
|
|
|
+ // 原始值 + 区间偏移量,保证每条曲线值域不重叠
|
|
|
+ return { value: params.value + valueRange };
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ // 更新系列配置(避免直接修改 Y 轴导致 getAxesOnZeroOf 报错)
|
|
|
+ chartInstance.setOption({
|
|
|
+ series: seriesList,
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ /** 更新曲线数据(严格 TS 类型) */
|
|
|
+ const updateCurveData = (varCode: string, value: number): void => {
|
|
|
+ if (!curveData.value[varCode]) return;
|
|
|
+
|
|
|
+ const now = new Date().toISOString();
|
|
|
+ const curveItem = curveData.value[varCode];
|
|
|
+
|
|
|
+ // 添加新数据
|
|
|
+ curveItem.xData.push(now);
|
|
|
+ curveItem.yData.push(Number(value));
|
|
|
+
|
|
|
+ // 限制数据长度
|
|
|
+ if (curveItem.xData.length > MAX_DATA_LEN) {
|
|
|
+ curveItem.xData.shift();
|
|
|
+ curveItem.yData.shift();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 更新 ECharts(仅更新数据,不修改轴配置,规避报错)
|
|
|
+ if (chartInstance) {
|
|
|
+ const seriesOption = chartInstance.getOption().series as SeriesOption[];
|
|
|
+ const targetSeriesIndex = allData.value.findIndex(item => item.var_code === varCode);
|
|
|
+
|
|
|
+ if (targetSeriesIndex > -1) {
|
|
|
+ chartInstance.setOption({
|
|
|
+ xAxis: { data: curveItem.xData },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ index: targetSeriesIndex,
|
|
|
+ data: curveItem.yData,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // ========== 数据获取 & MQTT 处理(严格 TS 类型) ==========
|
|
|
+ const getData = async (): Promise<void> => {
|
|
|
+ try {
|
|
|
+ const response = await BizVarDictAPI.listBizVarDictAnalog(queryFormVarDictData);
|
|
|
+ allData.value = response.data.data || [];
|
|
|
+ initCurveData();
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取变量数据失败:', error);
|
|
|
+ } finally {
|
|
|
+ loading.value = false;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ /** MQTT 消息处理(严格 TS 类型,兼容 analog/alarm 后缀) */
|
|
|
+ const handleMqttMessage: MqttMessageCallback = (topic: string, payload: MqttPayload): void => {
|
|
|
+ const topicLevels = topic.split('/');
|
|
|
+ if (topicLevels.length < 4 || !payload.data) return;
|
|
|
+
|
|
|
+ const suffix = topicLevels[2];
|
|
|
+ // 兼容 analog(实时数据)和 alarm(告警数据)
|
|
|
+ if (['analog', 'alarm'].includes(suffix)) {
|
|
|
+ payload.data.forEach((payloadItem) => {
|
|
|
+ const { var_code, value } = payloadItem;
|
|
|
+ if (!var_code || value === undefined) return;
|
|
|
+
|
|
|
+ // 更新 allData 中的值
|
|
|
+ const targetItem = allData.value.find(item => item.var_code === var_code);
|
|
|
+ if (targetItem) {
|
|
|
+ targetItem.value = Number(value);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 更新曲线数据(仅处理数值类型)
|
|
|
+ const numValue = Number(value);
|
|
|
+ if (!isNaN(numValue)) {
|
|
|
+ updateCurveData(var_code, numValue);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // ========== 生命周期(严格 TS 规范) ==========
|
|
|
+ onMounted(async (): Promise<void> => {
|
|
|
+ initECharts();
|
|
|
+ await getData();
|
|
|
+ mqttUtil.initConnect(handleMqttMessage);
|
|
|
+ });
|
|
|
+
|
|
|
+ onUnmounted((): void => {
|
|
|
+ mqttUtil.releaseResources();
|
|
|
+ window.removeEventListener('resize', handleResize);
|
|
|
+ // 销毁 ECharts 实例,避免内存泄漏
|
|
|
+ if (chartInstance) {
|
|
|
+ chartInstance.dispose();
|
|
|
+ chartInstance = null;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 深度监听 allData 变化
|
|
|
+ watch(
|
|
|
+ allData,
|
|
|
+ () => {
|
|
|
+ if (!loading.value) {
|
|
|
+ initCurveData();
|
|
|
+ }
|
|
|
+ },
|
|
|
+ { deep: true }
|
|
|
+ );
|
|
|
+ </script>
|
|
|
+
|
|
|
+ <style lang="less" scoped>
|
|
|
+ .realtime-curve-container {
|
|
|
+ width: 100%;
|
|
|
+ height: 100vh;
|
|
|
+
|
|
|
+ .chart-box {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ </style>
|