Browse Source

Merge remote-tracking branch 'origin/master'

Zimo 12 hours ago
parent
commit
82b6a57c38
1 changed files with 724 additions and 0 deletions
  1. 724 0
      src/views/oli-connection/monitoring-board/chart1.vue

+ 724 - 0
src/views/oli-connection/monitoring-board/chart1.vue

@@ -0,0 +1,724 @@
+<script lang="ts" setup>
+import { IotDeviceApi } from '@/api/pms/device'
+import { cancelAllRequests, IotStatApi } from '@/api/pms/stat'
+import { Dimensions, formatIotValue, useSocketBus } from '@/utils/useSocketBus'
+import dayjs from 'dayjs'
+import * as echarts from 'echarts'
+import { neonColors } from '@/utils/td-color'
+
+const props = defineProps({
+  id: {
+    type: Number,
+    required: true
+  },
+  deviceCode: {
+    type: String,
+    required: true
+  },
+  deviceName: {
+    type: String,
+    required: true
+  },
+  mqttUrl: {
+    type: String,
+    required: true
+  },
+  ifInline: {
+    type: String,
+    required: true
+  },
+  lastInlineTime: {
+    type: String,
+    required: true
+  },
+  deptName: {
+    type: String,
+    required: true
+  },
+  vehicleName: {
+    type: String,
+    required: true
+  },
+  carOnline: {
+    type: String,
+    required: true
+  },
+  // isRealTime: {
+  //   type: Boolean,
+  //   default: true
+  // },
+  date: {
+    type: Array as PropType<Array<string>>,
+    required: true
+  }
+})
+
+const dimensions = ref<Omit<Dimensions, 'color' | 'bgHover' | 'bgActive'>[]>([])
+const selectedDimension = ref<Record<string, boolean>>({})
+
+const { open: connect, onAny, close } = useSocketBus(props.deviceCode as string)
+
+onAny((msg) => {
+  if (!Array.isArray(msg) || msg.length === 0) return
+
+  const valueMap = new Map<string, number>()
+  for (const item of msg) {
+    const { identity, logValue, remark } = item
+
+    const value = logValue ? Number(logValue) : 0
+
+    if (identity) {
+      valueMap.set(identity, value)
+    }
+
+    const modelName = dimensions.value.find((item) => item.identifier === identity)?.name
+
+    if (modelName && chartData.value[modelName]) {
+      chartData.value[modelName].push({
+        ts: dayjs.unix(remark).valueOf(),
+        value
+      })
+
+      updateSingleSeries(modelName)
+    }
+  }
+})
+
+async function loadDimensions() {
+  if (!props.id) return
+  try {
+    dimensions.value = (((await IotDeviceApi.getIotDeviceTds(Number(props.id))) as any[]) ?? [])
+      .sort((a, b) => b.modelOrder - a.modelOrder)
+      .map((item) => {
+        const { value, suffix, isText } = formatIotValue(item.value)
+        return {
+          identifier: item.identifier,
+          name: item.modelName,
+          value: value,
+          suffix: suffix,
+          isText: isText,
+          response: false
+        }
+      })
+      .filter((item) => item.isText === false)
+
+    selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, true]))
+  } catch (error) {
+    console.error(error)
+  }
+}
+
+interface ChartData {
+  [key: Dimensions['name']]: { ts: number; value: number }[]
+}
+
+const chartData = ref<ChartData>({})
+
+let intervalArr = ref<number[]>([])
+let maxInterval = ref(0)
+let minInterval = ref(0)
+
+const chartRef = ref<HTMLDivElement | null>(null)
+let chart: echarts.ECharts | null = null
+
+function genderIntervalArr(init: boolean = false) {
+  // 1. 使用正负无穷大初始化,避免先把所有数存入数组
+  let maxVal = -Infinity
+  let minVal = Infinity
+  let hasData = false
+
+  // 2. 直接遍历数据查找最值 (不使用 spread ...)
+  for (const [key, value] of Object.entries(selectedDimension.value)) {
+    if (value) {
+      const dataset = chartData.value[key]
+      if (dataset && dataset.length > 0) {
+        hasData = true
+        // 使用循环代替 ...spread
+        for (const item of dataset) {
+          const val = item.value
+          if (val > maxVal) maxVal = val
+          if (val < minVal) minVal = val
+        }
+      }
+    }
+  }
+
+  // 3. 处理无数据的默认情况
+  if (!hasData) {
+    maxVal = 10000
+    minVal = 0
+  } else {
+    // 保持你原有的逻辑:如果最小值大于0,则归零
+    minVal = minVal > 0 ? 0 : minVal
+  }
+
+  // 4. 计算位数逻辑 (保持不变)
+  const maxDigits = (Math.floor(maxVal) + '').length
+  const minDigits = minVal === 0 ? 0 : (Math.floor(Math.abs(minVal)) + '').length
+
+  const interval = Math.max(maxDigits, minDigits)
+
+  maxInterval.value = interval
+  minInterval.value = minDigits
+
+  intervalArr.value = [0]
+  for (let i = 1; i <= interval; i++) {
+    intervalArr.value.push(Math.pow(10, i))
+  }
+
+  if (!init) {
+    chart?.setOption({
+      yAxis: {
+        min: -minInterval.value,
+        max: maxInterval.value
+      }
+    })
+  }
+}
+
+// function genderIntervalArr(init: boolean = false) {
+//   const values: number[] = []
+
+//   for (const [key, value] of Object.entries(selectedDimension.value)) {
+//     if (value) {
+//       values.push(...(chartData.value[key]?.map((item) => item.value) ?? []))
+//     }
+//   }
+
+//   const maxVal = values.length === 0 ? 10000 : Math.max(...values)
+//   const minVal = values.length === 0 ? 0 : Math.min(...values) > 0 ? 0 : Math.min(...values)
+
+//   const maxDigits = (Math.floor(maxVal) + '').length
+//   const minDigits = minVal === 0 ? 0 : (Math.floor(Math.abs(minVal)) + '').length
+
+//   const interval = Math.max(maxDigits, minDigits)
+
+//   maxInterval.value = interval
+//   minInterval.value = minDigits
+
+//   intervalArr.value = [0]
+//   for (let i = 1; i <= interval; i++) {
+//     intervalArr.value.push(Math.pow(10, i))
+//   }
+
+//   if (!init) {
+//     chart?.setOption({
+//       yAxis: {
+//         min: -minInterval.value,
+//         max: maxInterval.value
+//       }
+//     })
+//   }
+// }
+
+function chartInit() {
+  if (!chart) return
+
+  chart.on('legendselectchanged', (params: any) => {
+    // 1. 同步选中状态
+    selectedDimension.value = params.selected
+    const clickedModelName = params.name
+    const isSelected = params.selected[clickedModelName]
+
+    const oldMax = maxInterval.value
+    const oldMin = minInterval.value
+
+    genderIntervalArr()
+
+    const isScaleChanged = oldMax !== maxInterval.value || oldMin !== minInterval.value
+
+    if (isScaleChanged) {
+      Object.keys(selectedDimension.value).forEach((name) => {
+        if (selectedDimension.value[name]) {
+          updateSingleSeries(name)
+        }
+      })
+    } else {
+      if (isSelected) {
+        updateSingleSeries(clickedModelName)
+      }
+    }
+  })
+
+  window.addEventListener('resize', () => {
+    if (chart) chart.resize()
+  })
+}
+
+function render() {
+  if (!chartRef.value) return
+
+  if (!chart) chart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
+
+  chartInit()
+
+  genderIntervalArr(true)
+
+  chart.setOption({
+    color: neonColors,
+    animation: true,
+    animationDuration: 200,
+    animationEasing: 'linear',
+    animationDurationUpdate: 200,
+    animationEasingUpdate: 'linear',
+    grid: {
+      left: '3%',
+      top: '60px',
+      right: '6%',
+      bottom: '10%',
+      containLabel: true,
+      show: false
+    },
+    tooltip: {
+      trigger: 'axis',
+      confine: true,
+      enterable: true,
+      className: 'echarts-tooltip-scroll',
+      extraCssText:
+        'max-height: 300px; overflow-y: auto; pointer-events: auto; border-radius: 4px;',
+      backgroundColor: 'rgba(11, 17, 33, 0.95)',
+      borderColor: '#22d3ee',
+      borderWidth: 1,
+      textStyle: {
+        color: '#e2e8f0'
+      },
+      axisPointer: {
+        type: 'cross',
+        label: { backgroundColor: '#22d3ee', color: '#000' },
+        lineStyle: { color: 'rgba(255,255,255,0.3)', type: 'dashed' }
+      },
+      formatter: (params: any) => {
+        let d = `<div style="font-weight:bold; border-bottom:1px solid rgba(255,255,255,0.1); padding-bottom:5px; margin-bottom:5px;">${params[0].axisValueLabel}</div>`
+        const exist: string[] = []
+        params = params.filter((el: any) => {
+          if (exist.includes(el.seriesName)) return false
+          exist.push(el.seriesName)
+          return true
+        })
+
+        // 优化列表显示,圆点使用原本的颜色
+        let item = params.map(
+          (
+            el: any
+          ) => `<div class="flex items-center justify-between mt-1" style="font-size:12px; min-width: 180px;">
+            <span style="display:flex; align-items:center;">
+                <span style="display:inline-block;width:8px;height:8px;border-radius:50%;background-color:${el.color};margin-right:6px;"></span>
+                <span style="color:#cbd5e1">${el.seriesName}</span>
+            </span>
+            <span style="color:#fff; font-weight:bold; margin-left:10px;">${el.value[2]?.toFixed(2)}</span>
+          </div>`
+        )
+
+        return d + item.join('')
+      }
+    },
+    xAxis: {
+      type: 'time',
+      boundaryGap: ['0%', '25%'],
+      axisLabel: {
+        formatter: (v) => dayjs(v).format('YYYY-MM-DD\nHH:mm:ss'),
+        rotate: 0,
+        align: 'center',
+        color: '#94a3b8',
+        fontSize: 11
+      },
+      splitLine: {
+        show: false,
+        lineStyle: { color: 'rgba(255,255,255,0.5)', type: 'dashed' }
+      }
+    },
+    dataZoom: [
+      { type: 'inside', xAxisIndex: 0 },
+      {
+        type: 'slider',
+        xAxisIndex: 0,
+        height: 20,
+        bottom: 10,
+        borderColor: 'transparent',
+        backgroundColor: 'rgba(255,255,255,0.05)',
+        fillerColor: 'rgba(34,211,238,0.2)',
+        handleStyle: {
+          color: '#22d3ee',
+          borderColor: '#22d3ee'
+        },
+        labelFormatter: (value: any) => {
+          return dayjs(value).format('YYYY-MM-DD\nHH:mm:ss')
+        },
+        textStyle: {
+          color: '#94a3b8',
+          fontSize: 10,
+          lineHeight: 12
+        }
+      }
+    ],
+    yAxis: {
+      type: 'value',
+      min: -minInterval.value,
+      max: maxInterval.value,
+      interval: 1,
+      axisLabel: {
+        color: '#94a3b8',
+        formatter: (v) => {
+          const num = v === 0 ? 0 : v > 0 ? Math.pow(10, v) : -Math.pow(10, -v)
+          if (Math.abs(num) >= 10000) return (num / 10000).toFixed(0) + 'w'
+          if (Math.abs(num) >= 1000) return (num / 1000).toFixed(0) + 'k'
+          return num.toLocaleString()
+        }
+      },
+      show: true,
+      splitLine: {
+        show: true,
+        lineStyle: {
+          color: 'rgba(255,255,255,0.05)',
+          type: 'dashed'
+        }
+      },
+      axisPointer: {
+        show: true,
+        snap: false, // 必须设为 false,才能平滑显示小数部分的真实值
+        label: {
+          show: true,
+          backgroundColor: '#22d3ee', // 青色背景
+          color: '#000', // 黑色文字
+          fontWeight: 'bold',
+          precision: 2, // 保证精度
+
+          // --- 具体的实现逻辑 ---
+          formatter: (params: any) => {
+            const val = params.value // 这里拿到的是索引值,比如 4.21
+            if (val === 0) return '0.00'
+
+            // A. 处理正负号
+            const sign = val >= 0 ? 1 : -1
+            const absVal = Math.abs(val)
+
+            // B. 分离 整数部分(区间下标) 和 小数部分(区间内百分比)
+            const idx = Math.floor(absVal)
+
+            const percent = absVal - idx
+
+            // C. 安全检查:如果 intervalArr 还没生成,直接返回指数值
+            if (!intervalArr.value || intervalArr.value.length === 0) {
+              return (sign * Math.pow(10, absVal)).toFixed(2)
+            }
+
+            // D. 获取该区间的真实数值范围
+            // 例如 idx=2, 对应 intervalArr[2]=100, intervalArr[3]=1000
+            const min = intervalArr.value[idx]
+            // 如果到了最后一个区间,或者越界,就默认下一级是当前的10倍(防止报错)
+            const max =
+              intervalArr.value[idx + 1] !== undefined ? intervalArr.value[idx + 1] : min * 10
+
+            // E. 反向线性插值公式
+            // 真实值 = 下界 + (上下界之差 * 百分比)
+            const realVal = min + (max - min) * percent
+
+            // F. 加上符号并格式化
+            return (realVal * sign).toFixed(2)
+          }
+        }
+      }
+    },
+    legend: {
+      type: 'scroll', // 开启滚动,防止遮挡
+      top: 10,
+
+      left: 'center',
+      width: '90%',
+
+      textStyle: {
+        color: '#e2e8f0', // 亮白色
+        fontSize: 12
+      },
+      pageIconColor: '#22d3ee',
+      pageIconInactiveColor: '#475569',
+      pageTextStyle: { color: '#fff' },
+      data: dimensions.value.map((item) => item.name),
+      selected: selectedDimension.value,
+      show: true
+    },
+    // legend: {
+    //   data: dimensions.value.map((item) => item.name),
+    //   selected: selectedDimension.value,
+    //   show: true
+    // },
+    series: dimensions.value.map((item) => ({
+      name: item.name,
+      type: 'line',
+      smooth: 0.3,
+      showSymbol: false,
+
+      endLabel: {
+        show: true,
+        formatter: (params) => params.value[2]?.toFixed(2),
+        offset: [4, 0],
+        color: '#fff',
+        backgroundColor: 'auto',
+        padding: [2, 6],
+        borderRadius: 4,
+        fontSize: 11,
+        fontWeight: 'bold'
+      },
+
+      emphasis: {
+        focus: 'series',
+        lineStyle: { width: 4 }
+      },
+
+      lineStyle: {
+        width: 3,
+        shadowColor: 'rgba(0, 0, 0, 0.5)',
+        shadowBlur: 10,
+        shadowOffsetY: 5
+      },
+
+      data: [] // 占位数组
+    }))
+  })
+}
+
+function mapData({ value, ts }) {
+  if (value === null || value === undefined || value === 0) return [ts, 0, 0]
+
+  const isPositive = value > 0
+  const absItem = Math.abs(value)
+
+  if (!intervalArr.value.length) return [ts, 0, value]
+
+  const min_value = Math.max(...intervalArr.value.filter((v) => v <= absItem))
+  const min_index = intervalArr.value.findIndex((v) => v === min_value)
+
+  let denominator = 1
+  if (min_index < intervalArr.value.length - 1) {
+    denominator = intervalArr.value[min_index + 1] - intervalArr.value[min_index]
+  } else {
+    denominator = intervalArr.value[min_index] || 1
+  }
+
+  const new_value = (absItem - min_value) / denominator + min_index
+
+  return [ts, isPositive ? new_value : -new_value, value]
+}
+
+function updateSingleSeries(name: string) {
+  if (!chart) render()
+  if (!chart) return
+
+  const idx = dimensions.value.findIndex((item) => item.name === name)
+  if (idx === -1) return
+
+  const data = chartData.value[name].map((v) => mapData(v))
+
+  chart.setOption({
+    series: [{ name, data }]
+  })
+}
+
+const lastTsMap = ref<Record<Dimensions['name'], number>>({})
+
+const chartLoading = ref(false)
+
+async function initLoadChartData(real_time: boolean = true) {
+  if (!dimensions.value.length) return
+
+  chartData.value = Object.fromEntries(dimensions.value.map((item) => [item.name, []]))
+
+  chartLoading.value = true
+
+  dimensions.value = dimensions.value.map((item) => {
+    item.response = true
+    return item
+  })
+
+  for (const item of dimensions.value) {
+    const { identifier, name } = item
+    try {
+      const res = await IotStatApi.getDeviceInfoChart(
+        props.deviceCode,
+        identifier,
+        props.date[0],
+        props.date[1]
+      )
+
+      const sorted = res
+        .sort((a, b) => a.ts - b.ts)
+        .map((item) => ({ ts: item.ts, value: item.value }))
+
+      chartData.value[name] = sorted
+
+      lastTsMap.value[name] = sorted.at(-1)?.ts ?? 0
+
+      genderIntervalArr()
+
+      updateSingleSeries(name)
+
+      chartLoading.value = false
+    } finally {
+      item.response = false
+    }
+  }
+
+  if (real_time) {
+    connect()
+  }
+}
+
+async function initfn(load: boolean = true, real_time: boolean = true) {
+  if (load) await loadDimensions()
+  render()
+  initLoadChartData(real_time)
+}
+
+onMounted(() => {
+  initfn()
+})
+
+watch(
+  () => props.date,
+  async (newDate, oldDate) => {
+    if (!newDate || newDate.length !== 2) return
+
+    if (oldDate && newDate[0] === oldDate[0] && newDate[1] === oldDate[1]) return
+
+    await cancelAllRequests()
+
+    close()
+
+    const endTime = dayjs(newDate[1])
+    const now = dayjs()
+    const isRealTime = endTime.isAfter(now.subtract(1, 'minute'))
+
+    if (chart) chart.clear()
+
+    if (isRealTime) initfn(false)
+    else initfn(false, false)
+  }
+)
+
+onUnmounted(() => {
+  close()
+
+  window.removeEventListener('resize', () => {
+    if (chart) chart.resize()
+  })
+})
+
+const router = useRouter()
+
+function handleDetailClick() {
+  router.push({
+    name: 'MonitoringDetail',
+    query: {
+      id: props.id,
+      ifInline: props.ifInline,
+      carOnline: props.carOnline,
+      time: props.lastInlineTime,
+      name: props.deviceName,
+      code: props.deviceCode,
+      dept: props.deptName,
+      vehicle: props.vehicleName
+    }
+  })
+}
+</script>
+<template>
+  <div class="h-100 rounded-lg chart-container flex flex-col">
+    <header class="chart-header justify-between">
+      <div class="flex items-center">
+        <div class="title-icon"></div>
+        <div>{{ `${props.deviceCode}-${props.deviceName}` }}</div>
+      </div>
+      <el-button link type="primary" class="group" @click="handleDetailClick">
+        详情
+        <div
+          class="i-material-symbols:arrow-right-alt-rounded size-4 transition-transform group-hover:translate-x-1"
+        ></div>
+      </el-button>
+    </header>
+    <main
+      class="flex-1 chart-main"
+      ref="chartRef"
+      v-loading="chartLoading"
+      element-loading-background="transparent"
+    ></main>
+  </div>
+</template>
+<style scoped>
+.chart-container {
+  position: relative;
+  overflow: hidden;
+  background-color: rgb(11 17 33 / 90%);
+  border: 2px solid rgb(34 211 238 / 30%);
+  box-shadow:
+    0 0 20px rgb(0 0 0 / 80%),
+    inset 0 0 15px rgb(34 211 238 / 10%);
+  transition:
+    border-color 0.3s ease,
+    transform 0.3s ease;
+}
+
+.chart-container::before {
+  position: absolute;
+  pointer-events: none;
+  background: radial-gradient(
+    400px circle at var(--mouse-x, 50%) var(--mouse-y, 50%),
+    rgb(34 211 238 / 5%),
+    transparent 40%
+  );
+  content: '';
+  inset: 0;
+}
+
+.chart-container:hover {
+  border-color: rgb(34 211 238 / 60%);
+  transform: scale(1.005);
+}
+
+.chart-header {
+  display: flex;
+  padding: 12px 16px;
+  font-size: 18px;
+  font-weight: 600;
+  letter-spacing: 1px;
+  color: #e2e8f0;
+  background: rgb(255 255 255 / 3%);
+  border-bottom: 1px solid transparent;
+  border-image: linear-gradient(to right, rgb(34 211 238 / 50%), transparent) 1;
+  align-items: center;
+}
+
+.title-icon {
+  width: 4px;
+  height: 16px;
+  margin-right: 10px;
+  background: #22d3ee;
+  box-shadow: 0 0 8px #22d3ee;
+}
+
+.chart-main {
+  padding-top: 12px;
+  background-image: radial-gradient(circle at 50% 50%, rgb(34 211 238 / 10%) 0%, transparent 80%),
+    linear-gradient(to right, rgb(34 211 238 / 15%) 1px, transparent 1px),
+    linear-gradient(to bottom, rgb(34 211 238 / 15%) 1px, transparent 1px),
+    linear-gradient(135deg, rgb(11 17 33 / 90%) 0%, rgb(6 9 18 / 95%) 100%);
+  background-size:
+    100% 100%,
+    40px 40px,
+    40px 40px,
+    100% 100%;
+}
+
+/* 针对 ECharts tooltip 的滚动条美化 */
+.echarts-tooltip-scroll::-webkit-scrollbar {
+  width: 6px;
+}
+
+.echarts-tooltip-scroll::-webkit-scrollbar-thumb {
+  background: #22d3ee; /* 青色滑块 */
+  border-radius: 3px;
+}
+
+.echarts-tooltip-scroll::-webkit-scrollbar-track {
+  background: rgb(255 255 255 / 10%); /* 深色轨道 */
+}
+</style>