Преглед на файлове

feat(views): 编写“环境监控”模块的历史数据曲线组件

wangshun преди 2 месеца
родител
ревизия
082adb5f7c
променени са 1 файла, в които са добавени 712 реда и са изтрити 0 реда
  1. 712 0
      src/views/env-monitor/HumitureCurve.vue

+ 712 - 0
src/views/env-monitor/HumitureCurve.vue

@@ -0,0 +1,712 @@
+<script setup lang="ts">
+import { computed, onMounted, ref, watch } from 'vue';
+import VChart from 'vue-echarts';
+import dayjs from 'dayjs';
+import { LineChart, PieChart } from 'echarts/charts';
+import {
+  AxisPointerComponent,
+  DatasetComponent,
+  GridComponent,
+  LegendComponent,
+  MarkLineComponent,
+  TitleComponent,
+  TooltipComponent,
+  TransformComponent,
+} from 'echarts/components';
+import { use } from 'echarts/core';
+import { CanvasRenderer } from 'echarts/renderers';
+
+import SvgIcon from '@/components/SvgIcon.vue';
+import { useRequest } from '@/hooks/request';
+import { t } from '@/i18n';
+import {
+  addBatchMonitorPointAlarm,
+  getMonitorPointAlarm,
+  getMonitorPointInfo,
+  getPointTimeSeries,
+  updateBatchMonitorPointAlarm,
+  updateLimits,
+} from '@/api';
+
+import type { FormInstance, Rule } from 'ant-design-vue/es/form';
+import type { SelectValue } from 'ant-design-vue/es/select';
+import type { Dayjs } from 'dayjs';
+import type {
+  FormatterData,
+  HistoricalDataSequence,
+  LimitForm,
+  MonitoringPointData,
+  TempHumidityControlSettings,
+  WarningItem,
+} from '@/types';
+
+use([
+  CanvasRenderer,
+  TitleComponent,
+  DatasetComponent,
+  TransformComponent,
+  TooltipComponent,
+  LegendComponent,
+  GridComponent,
+  LineChart,
+  PieChart,
+  MarkLineComponent,
+  AxisPointerComponent,
+]);
+
+const { handleRequest } = useRequest();
+
+interface Props {
+  monitoringId?: number;
+  monitoringData: MonitoringPointData[];
+}
+const props = defineProps<Props>();
+const formRef = ref<FormInstance>();
+const dateShortcut = ref<number>(0);
+const degreeValue = ref<string>('1');
+const limitOpen = ref<boolean>(false);
+const warningOpen = ref<boolean>(false);
+const warningShow = ref<boolean>(false);
+const curvedDataList = ref<TempHumidityControlSettings>();
+type RangeValue = [Dayjs, Dayjs];
+const historicalDataValue = ref<RangeValue>();
+const warningList = ref<WarningItem[]>([
+  {
+    pointId: props.monitoringId,
+    enabled: false,
+    type: 1,
+    val: 0,
+    duration: 0,
+  },
+  {
+    pointId: props.monitoringId,
+    enabled: false,
+    type: 2,
+    val: 0,
+    duration: 0,
+  },
+]);
+const limitForm = ref<LimitForm>({
+  id: undefined,
+  regionId: undefined,
+  tempUpper: 0,
+  tempLower: 0,
+  tempPreset: 0,
+  humidityUpper: 0,
+  humidityLower: 0,
+  humidityPreset: 0,
+  batch: false,
+});
+const degreeData = ref([
+  {
+    value: '1',
+    label: '温度',
+    icon: 'temperature',
+  },
+  {
+    value: '2',
+    label: '湿度',
+    icon: 'humidity',
+  },
+]);
+
+const rules: Record<string, Rule[]> = {
+  id: [{ required: true, message: t('common.cannotEmpty'), trigger: 'change' }],
+  tempUpper: [{ required: true, message: t('common.cannotEmpty'), trigger: 'change' }],
+  tempLower: [{ required: true, message: t('common.cannotEmpty'), trigger: 'change' }],
+  tempPreset: [{ required: true, message: t('common.cannotEmpty'), trigger: 'change' }],
+  humidityUpper: [{ required: true, message: t('common.cannotEmpty'), trigger: 'change' }],
+  humidityLower: [{ required: true, message: t('common.cannotEmpty'), trigger: 'change' }],
+  humidityPreset: [{ required: true, message: t('common.cannotEmpty'), trigger: 'change' }],
+};
+
+const addLimit = () => {
+  limitOpen.value = true;
+  limitForm.value.batch = false;
+};
+
+const addWarning = () => {
+  getMonitorPointList();
+  warningOpen.value = true;
+};
+
+const getWarning = () => {
+  handleRequest(async () => {
+    if (warningShow.value) {
+      await updateBatchMonitorPointAlarm(warningList.value);
+    } else {
+      await addBatchMonitorPointAlarm(warningList.value);
+    }
+  });
+
+  warningOpen.value = false;
+};
+
+const getMonitorPointList = () => {
+  handleRequest(async () => {
+    if (props.monitoringId) {
+      const data = await getMonitorPointAlarm(props.monitoringId);
+      if (data.length) {
+        warningShow.value = true;
+        data.forEach((item, i) => {
+          const { id, pointId, enabled, type, val, duration } = item;
+          Object.assign(warningList.value[i], {
+            id,
+            pointId,
+            enabled,
+            type,
+            val,
+            duration,
+          });
+        });
+      } else {
+        warningShow.value = false;
+        warningList.value = [
+          {
+            pointId: props.monitoringId,
+            enabled: false,
+            type: 1,
+            val: 0,
+            duration: 0,
+          },
+          {
+            pointId: props.monitoringId,
+            enabled: false,
+            type: 2,
+            val: 0,
+            duration: 0,
+          },
+        ];
+      }
+    }
+  });
+};
+
+const getLimit = () => {
+  formRef.value
+    ?.validate()
+    .then(() => {
+      handleRequest(async () => {
+        await updateLimits(limitForm.value);
+        getMonitorPoint();
+        limitOpen.value = false;
+      });
+    })
+    .catch(() => {});
+};
+
+const getMonitorPoint = () => {
+  handleRequest(async () => {
+    if (props.monitoringId) {
+      const { humidityLower, humidityPreset, humidityUpper, tempLower, tempPreset, tempUpper, id, regionId } =
+        await getMonitorPointInfo(props.monitoringId);
+
+      Object.assign(limitForm.value, {
+        humidityLower,
+        humidityPreset,
+        humidityUpper,
+        tempLower,
+        tempPreset,
+        tempUpper,
+        id,
+        regionId,
+      });
+    }
+  });
+};
+// 初始化默认值
+initDateRange();
+
+const disabledDate = (current: Dayjs): boolean => {
+  return current ? current.isAfter(dayjs().endOf('day')) : false;
+};
+
+// 添加格式转换
+const formatDate = (date: Dayjs) => date.format('YYYY-MM-DD HH:mm:ss');
+
+// 处理日期快捷选择
+function updateDateRange(type: number) {
+  const today = dayjs().endOf('day');
+  let startDate: Dayjs;
+
+  switch (type) {
+    case 0: // 最近1天
+      startDate = today.subtract(1, 'day').startOf('day');
+      break;
+    case 1: // 最近7天
+      startDate = today.subtract(6, 'days').startOf('day');
+      break;
+    case 2: // 最近30天
+      startDate = today.subtract(29, 'days').startOf('day');
+      break;
+    default:
+      return;
+  }
+
+  historicalDataValue.value = [startDate, today];
+}
+
+// 监听快捷选项变化
+watch(dateShortcut, (newVal) => {
+  if (newVal !== 3) {
+    updateDateRange(newVal);
+    getPointTimeSeriesList();
+  }
+});
+
+// 初始化日期范围
+function initDateRange() {
+  updateDateRange(dateShortcut.value);
+}
+
+// 处理自定义选择
+const handleShortcutChange = (value: SelectValue) => {
+  if (value !== 3) {
+    updateDateRange(value as number);
+  }
+};
+
+const getPointTimeSeriesList = () => {
+  handleRequest(async () => {
+    if (props.monitoringId) {
+      const data = historicalDataValue.value?.map(formatDate) || [];
+      curvedDataList.value = await getPointTimeSeries(
+        {
+          startTime: data[0],
+          endTime: data[1],
+        },
+        props.monitoringId,
+      );
+    }
+  });
+};
+
+const getCurvedData = (data: HistoricalDataSequence[], value: string) => {
+  const listNumber: number[] = [];
+  const listString: string[] = [];
+  if (data) {
+    data.forEach((item) => {
+      if (value === 'time') {
+        listString.push(item.time.slice(5));
+      }
+      if (value === 'value') {
+        listNumber.push(item.value);
+      }
+    });
+  }
+  if (value === 'time') {
+    return listString;
+  } else if (value === 'value') {
+    return listNumber;
+  } else {
+    return [];
+  }
+};
+
+const option = computed(() => {
+  if (!curvedDataList.value) return {};
+  return {
+    color: ['#1F8FFB', '#32BAC0'],
+    title: {
+      subtext: '单位: ' + (degreeValue.value === '1' ? '°C' : '%'),
+      padding: [0, 0, 0, 30], // 标题内边距
+      subtextStyle: {
+        color: '#999', // 设置副标题颜色
+        fontSize: 10, // 设置副标题字体大小
+      },
+    },
+    tooltip: {
+      trigger: 'axis',
+      formatter(params: FormatterData[]) {
+        let result = params[0].name + '<br/>';
+        params.forEach(function (item) {
+          // 创建一个圆形的 HTML 元素
+          const circle = `<span style="display: inline-block; width: 10px; height: 10px; border-radius: 50%; background-color: ${item.color}; margin-right: 5px;"></span>`;
+          result += circle + item.seriesName + ':&nbsp;&nbsp;' + item.value + '°C<br/>';
+        });
+        return result;
+      },
+      backgroundColor: 'rgba(255, 255, 255, 0.75)',
+    },
+    legend: {
+      bottom: 0,
+      data: [
+        {
+          name:
+            '室内' +
+            (degreeValue.value === '1' ? '温度' : '湿度') +
+            '设定值:' +
+            (degreeValue.value === '1' ? curvedDataList.value.tempPreset : curvedDataList.value.humidityPreset) +
+            '℃',
+          icon: 'path://M128 501.333v64H0v-64zm213.333 0v64h-128v-64zm213.334 0v64h-128v-64zm234.666 0v64h-128v-64zm234.667 0v64H896v-64z',
+          itemStyle: {
+            color: '#67C23A',
+          },
+          selectedMode: false,
+        },
+        {
+          name:
+            '室内' +
+            (degreeValue.value === '1' ? '温度' : '湿度') +
+            '上限值:' +
+            (degreeValue.value === '1' ? curvedDataList.value.tempUpper : curvedDataList.value.humidityUpper) +
+            '℃',
+          icon: 'path://M128 501.333v64H0v-64zm213.333 0v64h-128v-64zm213.334 0v64h-128v-64zm234.666 0v64h-128v-64zm234.667 0v64H896v-64z',
+          itemStyle: {
+            color: '#F56C6C',
+          },
+        },
+        {
+          name: '室内' + (degreeValue.value === '1' ? '温度' : '湿度'),
+          itemStyle: {
+            color: '#1F8FFB',
+          },
+        },
+        {
+          name: '送风' + (degreeValue.value === '1' ? '温度' : '湿度'),
+          itemStyle: {
+            color: '#32BAC0',
+          },
+        },
+      ],
+      itemGap: 20, // 图例间距
+      // 文本样式配置
+      textStyle: {
+        fontWeight: 400,
+        fontSize: 12,
+        color: '#000000',
+        lineHeight: 22, // 需要配合 itemHeight 使用
+      },
+    },
+    xAxis: {
+      type: 'category',
+      data:
+        degreeValue.value === '1'
+          ? getCurvedData(curvedDataList.value.tempData, 'time')
+          : getCurvedData(curvedDataList.value.humidityData, 'time'),
+      axisLine: {
+        lineStyle: {
+          type: 'dashed', // 虚线样式
+          color: '#EBEBEB', // 轴线颜色
+        },
+      },
+      // X轴文本颜色配置
+      axisLabel: {
+        color: '#999', // 文本颜色
+      },
+      axisTick: {
+        show: false, // 隐藏刻度线
+      },
+    },
+    yAxis: {
+      type: 'value',
+      min: 0,
+      max: 40,
+      interval: 10,
+      axisLine: {
+        lineStyle: {
+          color: '#999999', // Y轴线颜色
+        },
+      },
+      splitLine: {
+        lineStyle: {
+          type: 'dashed', // 设置为虚线
+          color: '#EBEBEB', // 指定颜色
+        },
+      },
+    },
+    series: [
+      // 室内温度设定值虚线
+      {
+        name:
+          '室内' +
+          (degreeValue.value === '1' ? '温度' : '湿度') +
+          '设定值:' +
+          (degreeValue.value === '1' ? curvedDataList.value.tempPreset : curvedDataList.value.humidityPreset) +
+          '℃',
+        type: 'line',
+        silent: true, // 设置该系列不响应事件,图例不可点击
+        lineStyle: {
+          color: '#67C23A',
+          width: 0, // 隐藏实际线条
+        },
+        markLine: {
+          symbol: 'none',
+          lineStyle: {
+            type: 'dashed',
+            color: '#67C23A',
+          },
+          data: [{ yAxis: 24 }],
+          label: { show: false },
+        },
+      },
+      // 室内温度上限值虚线
+      {
+        name:
+          '室内' +
+          (degreeValue.value === '1' ? '温度' : '湿度') +
+          '上限值:' +
+          (degreeValue.value === '1' ? curvedDataList.value.tempUpper : curvedDataList.value.humidityUpper) +
+          '℃',
+        type: 'line',
+        silent: true, // 设置该系列不响应事件,图例不可点击
+        lineStyle: {
+          color: '#F56C6C',
+          width: 0,
+        },
+        markLine: {
+          symbol: 'none',
+          lineStyle: {
+            type: 'dashed',
+            color: '#F56C6C',
+          },
+          data: [{ yAxis: 26 }],
+          label: { show: false },
+        },
+      },
+      // 室内温度折线
+      {
+        name: '室内' + (degreeValue.value === '1' ? '温度' : '湿度'),
+        type: 'line',
+        data:
+          degreeValue.value === '1'
+            ? getCurvedData(curvedDataList.value.tempData, 'value')
+            : getCurvedData(curvedDataList.value.humidityData, 'value'),
+        smooth: true,
+        lineStyle: {
+          color: '#1F8FFB',
+        },
+        showSymbol: true, // 启用数据点
+        symbolSize: 0, // 默认隐藏数据点(大小为0)
+        // 关键修复:emphasis 配置需独立声明
+        emphasis: {
+          symbolSize: 20, //  悬停时显示
+          itemStyle: {
+            color: '#fff',
+            borderColor: '#1F8FFB',
+            borderWidth: 8,
+          },
+        },
+      },
+      // 送风温度折线
+      {
+        name: '送风' + (degreeValue.value === '1' ? '温度' : '湿度'),
+        type: 'line',
+        data:
+          degreeValue.value === '1'
+            ? getCurvedData(curvedDataList.value.supplyTempData, 'value')
+            : getCurvedData(curvedDataList.value.supplyHumidityData, 'value'),
+        smooth: true,
+        lineStyle: {
+          color: '#32BAC0',
+        },
+        showSymbol: true, // 启用数据点
+        symbolSize: 0, // 默认隐藏数据点(大小为0)
+        // 关键修复:emphasis 配置需独立声明
+        emphasis: {
+          symbolSize: 20, //  悬停时显示
+          itemStyle: {
+            color: '#fff',
+            borderColor: '#32BAC0',
+            borderWidth: 8,
+          },
+        },
+      },
+    ],
+    grid: {
+      top: 30, // 上边距为0
+      right: 30, // 右边距为0
+      // bottom: 30, // 下边距为0
+      left: 30, // 左边距为0
+    },
+  };
+});
+
+watch(
+  () => props.monitoringId,
+  (count) => {
+    if (count) {
+      getMonitorPoint();
+      getMonitorPointList();
+      getPointTimeSeriesList();
+    }
+  },
+);
+
+onMounted(() => {
+  getMonitorPoint();
+  getPointTimeSeriesList();
+});
+</script>
+
+<template>
+  <div>
+    <AFlex justify="space-between" align="center">
+      <div style="width: 160px">
+        <ASegmented v-model:value="degreeValue" :options="degreeData" :block="true">
+          <template #label="{ payload }">
+            <SvgIcon :name="payload.icon" />
+            {{ payload.label }}
+          </template>
+        </ASegmented>
+      </div>
+      <div>
+        <AButton type="text" class="icon-button icon-style" @click="addLimit">
+          <SvgIcon name="setting" />
+          设置温湿度限值
+        </AButton>
+        <AButton type="text" class="icon-button icon-style" @click="addWarning">
+          <SvgIcon name="setting" />
+          设置温湿度预警
+        </AButton>
+      </div>
+    </AFlex>
+    <AFlex class="date-selection-div">
+      <ASelect v-model:value="dateShortcut" placeholder="请选择" class="date-shortcut" @change="handleShortcutChange">
+        <ASelectOption :value="0">最近1天</ASelectOption>
+        <ASelectOption :value="1">最近7天</ASelectOption>
+        <ASelectOption :value="2">最近30天</ASelectOption>
+        <ASelectOption :value="3">自定义</ASelectOption>
+      </ASelect>
+      <ARangePicker v-model:value="historicalDataValue" :disabled-date="disabledDate" :allow-clear="false" />
+    </AFlex>
+
+    <VChart class="chart" :option="option" />
+
+    <AModal
+      v-model:open="limitOpen"
+      title="设置温湿度限值"
+      :footer="null"
+      width="704px"
+      :mask-closable="false"
+      :keyboard="false"
+    >
+      <AForm ref="formRef" :model="limitForm" layout="vertical" :rules="rules">
+        <AFormItem label="请选择监控点名" name="id">
+          <ASelect
+            class="select-width"
+            v-model:value="limitForm.id"
+            :options="monitoringData"
+            :field-names="{ label: 'name', value: 'id' }"
+            placeholder="请选择"
+          />
+        </AFormItem>
+        <AFlex justify="space-between">
+          <AFormItem label="室内温度设定值" name="tempPreset">
+            <AInputNumber class="select-width" v-model:value="limitForm.tempPreset" :min="0" :max="9999" />
+          </AFormItem>
+          <AFormItem label="室内温度上限值" name="tempUpper">
+            <AInputNumber class="select-width" v-model:value="limitForm.tempUpper" :min="0" :max="9999" />
+          </AFormItem>
+          <AFormItem label="室内温度下限值" name="tempLower">
+            <AInputNumber class="select-width" v-model:value="limitForm.tempLower" :min="0" :max="9999" />
+          </AFormItem>
+        </AFlex>
+        <AFlex justify="space-between">
+          <AFormItem label="室内湿度设定值" name="humidityPreset">
+            <AInputNumber class="select-width" v-model:value="limitForm.humidityPreset" :min="0" :max="9999" />
+          </AFormItem>
+          <AFormItem label="室内湿度上限值" name="humidityUpper">
+            <AInputNumber class="select-width" v-model:value="limitForm.humidityUpper" :min="0" :max="9999" />
+          </AFormItem>
+          <AFormItem label="室内湿度下限值" name="humidityLower">
+            <AInputNumber class="select-width" v-model:value="limitForm.humidityLower" :min="0" :max="9999" />
+          </AFormItem>
+        </AFlex>
+        <ACheckbox v-model:checked="limitForm.batch">批量设置</ACheckbox>
+      </AForm>
+      <AFlex justify="flex-end" class="limit-top">
+        <AButton class="limit-button default-button" @click="limitOpen = false">{{ $t('common.cancel') }}</AButton>
+        <AButton type="primary" @click="getLimit">{{ $t('common.confirm') }}</AButton>
+      </AFlex>
+    </AModal>
+
+    <AModal v-model:open="warningOpen" :footer="null" width="704px" :mask-closable="false" :keyboard="false">
+      <template #title>
+        <div class="warning-title">设置温湿度预警</div>
+      </template>
+
+      <div v-for="item in warningList" :key="item.id" class="warning-div warning-color">
+        <ACheckbox v-model:checked="item.enabled" class="warning-color">{{
+          item.type === 1 ? '启用温度预警' : '启用湿度预警'
+        }}</ACheckbox>
+        <AFlex align="center" class="warning-flex">
+          <div>{{ item.type === 1 ? '室内温度大于' : '室内湿度大于' }}</div>
+
+          <AInputNumber class="input-width" v-model:value="item.val" :min="1" :max="9999" />
+          <div>{{ item.type === 1 ? '℃' : '%' }}</div>
+          <div>,且持续时间大于</div>
+          <AInputNumber class="input-width" v-model:value="item.duration" :min="1" :max="9999" />
+          <div>min时,触发</div>
+        </AFlex>
+      </div>
+
+      <AFlex justify="flex-end" class="limit-top">
+        <AButton class="default-button limit-button" @click="warningOpen = false">{{ $t('common.cancel') }}</AButton>
+        <AButton type="primary" @click="getWarning">{{ $t('common.confirm') }}</AButton>
+      </AFlex>
+    </AModal>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.chart {
+  width: 560px;
+  height: 700px;
+}
+
+.date-selection-div {
+  margin-top: 16px;
+}
+
+.date-shortcut {
+  width: 96px;
+  margin-right: 12px;
+}
+
+.warning-div {
+  padding-top: 8px;
+}
+
+.warning-color {
+  font-size: 14px;
+  font-style: normal;
+  font-weight: 400;
+  line-height: 22px;
+  color: rgb(0 0 0 / 65%);
+  text-align: left;
+}
+
+.check-box {
+  margin-top: 8px;
+}
+
+.warning-title {
+  margin-bottom: 16px;
+}
+
+.warning-flex {
+  margin: 16px 0 32px;
+}
+
+.input-width {
+  width: 120px;
+  margin: 0 12px;
+}
+
+.limit-top {
+  margin-top: 24px;
+}
+
+.limit-button {
+  margin: 0 16px;
+}
+
+.select-width {
+  width: 192px;
+}
+
+.icon-style {
+  color: var(--antd-color-primary);
+}
+</style>