Ver código fonte

Merge branch 'monitoring-board'

Zimo 1 dia atrás
pai
commit
380e709f76

+ 4 - 0
src/api/pms/device/index.ts

@@ -183,6 +183,10 @@ export const IotDeviceApi = {
     return await request.get({ url: `/rq/iot-device/td/ly/page`, params })
   },
 
+  getBoardDevice: async (params: any) => {
+    return await request.get({ url: `/rq/iot-device/td/ly/screen`, params })
+  },
+
   getMonitoringQuery: async (params: any) => {
     return await request.get({ url: `rq/stat/td/ly/chart`, params })
   },

+ 123 - 0
src/utils/useMqtt.ts

@@ -0,0 +1,123 @@
+// useMqtt.ts
+import { ref, onUnmounted } from 'vue'
+import mqtt, { MqttClient, IClientOptions } from 'mqtt'
+import { ElMessage } from 'element-plus'
+
+type MessageCallback = (topic: string, payload: any) => void
+
+// 基础配置 (建议移至环境变量 import.meta.env.VITE_MQTT_HOST)
+const BASE_OPTIONS: IClientOptions = {
+  clean: true,
+  reconnectPeriod: 5000,
+  connectTimeout: 10000,
+  username: 'yanfan',
+  password:
+    'eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6IjY0YmM2NjJlLWZhMjQtNGY1Ny1hOTk1LWZiMGM2YjNhYzI4OCJ9.9nxoDUNGTk1szRlZHHG0AcWZctLrzJ16UA5rsBagHNcD10PC-LIMTgAr2CK1Ppafa6cW5XPdn7RqBF6iZjHtww'
+}
+
+export function useMqtt() {
+  const client = ref<MqttClient | null>(null)
+  const isConnected = ref(false)
+  const message = ref<any>(null) // 响应式消息数据
+
+  let customMessageCallback: MessageCallback | null = null
+
+  // 初始化连接
+  const connect = (host: string, options: IClientOptions = {}, onMessage?: MessageCallback) => {
+    if (client.value && client.value.connected) {
+      console.warn('当前实例已连接 MQTT,跳过初始化')
+      return
+    }
+
+    if (onMessage) {
+      customMessageCallback = onMessage
+    }
+
+    // 合并配置,生成唯一 ClientId
+    const finalOptions = {
+      ...BASE_OPTIONS,
+      clientId: `web-${Math.random().toString(16).substr(2)}`,
+      ...options
+    }
+
+    try {
+      client.value = mqtt.connect(host, finalOptions)
+
+      client.value.on('connect', () => {
+        isConnected.value = true
+        ElMessage.success('MQTT 连接成功')
+      })
+
+      client.value.on('error', (err) => {
+        console.error('MQTT Error:', err)
+        ElMessage.error(`MQTT 错误: ${err.message}`)
+        isConnected.value = false
+      })
+
+      client.value.on('close', () => {
+        isConnected.value = false
+      })
+
+      // 全局消息监听,更新响应式数据
+      client.value.on('message', (topic, payload) => {
+        let parsedData: any
+        try {
+          parsedData = JSON.parse(payload.toString())
+        } catch (e) {
+          parsedData = payload.toString()
+        }
+
+        message.value = { topic, payload: parsedData }
+
+        if (customMessageCallback) {
+          customMessageCallback(topic, parsedData)
+        }
+      })
+    } catch (err) {
+      ElMessage.error(`MQTT 初始化异常: ${(err as Error).message}`)
+      isConnected.value = false
+    }
+  }
+
+  // 订阅主题 (支持泛型)
+  const subscribe = (topic: string) => {
+    if (client.value && client.value.connected) {
+      client.value.subscribe(topic, { qos: 0 }, (err) => {
+        if (err) ElMessage.error(`订阅失败: ${err.message}`)
+        else console.log(`已订阅: ${topic}`)
+      })
+    }
+  }
+
+  // 发布消息
+  const publish = (topic: string, message: string | object) => {
+    if (client.value && client.value.connected) {
+      const payload = typeof message === 'string' ? message : JSON.stringify(message)
+      client.value.publish(topic, payload)
+    }
+  }
+
+  // 销毁连接
+  const destroy = () => {
+    if (client.value) {
+      client.value.end()
+      client.value = null
+      isConnected.value = false
+    }
+  }
+
+  // 组件卸载时自动断开 (防止内存泄漏)
+  onUnmounted(() => {
+    destroy()
+  })
+
+  return {
+    client,
+    isConnected,
+    message, // 组件内可以直接 watch 这个变量
+    connect,
+    subscribe,
+    publish,
+    destroy
+  }
+}

+ 701 - 0
src/views/oli-connection/monitoring-board/chart.vue

@@ -0,0 +1,701 @@
+<script lang="ts" setup>
+import { IotDeviceApi } from '@/api/pms/device'
+import { cancelAllRequests, IotStatApi } from '@/api/pms/stat'
+import { useMqtt } from '@/utils/useMqtt'
+import { Dimensions, formatIotValue } 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
+  },
+  // 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 { connect, destroy, isConnected, subscribe } = useMqtt()
+
+const handleMessageUpdate = (_topic: string, data: any) => {
+  const valueMap = new Map<string, number>()
+
+  for (const item of data) {
+    const { id: identity, value: 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)
+    }
+  }
+}
+
+watch(isConnected, (newVal) => {
+  if (newVal) {
+    // subscribe(`/636/${props.deviceCode}/property/post`)
+
+    switch (props.deviceCode) {
+      case 'YF1539':
+        subscribe(`/656/${props.deviceCode}/property/post`)
+      case 'YF325':
+      case 'YF288':
+      case 'YF671':
+      case 'YF459':
+        subscribe(`/635/${props.deviceCode}/property/post`)
+      case 'YF649':
+        subscribe(`/636/${props.deviceCode}/property/post`)
+      default:
+        subscribe(`/636/${props.deviceCode}/property/post`)
+    }
+
+    // subscribe(props.mqttUrl)
+    // subscribe('/636/YF649/property/post')
+  }
+})
+
+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(false)
+
+      updateSingleSeries(name)
+
+      chartLoading.value = false
+    } finally {
+      item.response = false
+    }
+  }
+
+  if (real_time) {
+    connect('ws://172.21.10.65:8083/mqtt', {}, handleMessageUpdate)
+  }
+}
+
+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()
+
+    destroy()
+
+    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(() => {
+  destroy()
+
+  window.removeEventListener('resize', () => {
+    if (chart) chart.resize()
+  })
+})
+</script>
+<template>
+  <div class="h-100 rounded-lg chart-container flex flex-col">
+    <header class="chart-header">
+      <div class="title-icon"></div>
+      <div>{{ `${props.deviceCode}-${props.deviceName}` }}</div>
+    </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>

+ 482 - 0
src/views/oli-connection/monitoring-board/index.vue

@@ -0,0 +1,482 @@
+<script setup lang="ts">
+import { useUserStore } from '@/store/modules/user'
+import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
+import { rangeShortcuts } from '@/utils/formatTime'
+import * as DeptApi from '@/api/system/dept'
+import { handleTree } from '@/utils/tree'
+import { IotDeviceApi } from '@/api/pms/device'
+import { EChartsOption } from 'echarts'
+import chart from './chart.vue'
+import dayjs from 'dayjs'
+import { useFullscreen } from '@vueuse/core'
+import { Crop, FullScreen } from '@element-plus/icons-vue'
+
+const userStore = useUserStore()
+const userDeptId = userStore.getUser.deptId
+
+interface DeviceData {
+  id: number
+  deviceCode: string
+  deviceName: string
+  mqttUrl: string
+}
+
+interface DeviceQuery {
+  deptId?: number
+  ifInline?: string
+}
+
+const originalDeviceQuery: DeviceQuery = {
+  deptId: userDeptId,
+  ifInline: '0'
+}
+
+const deviceQuery = ref<DeviceQuery>({
+  ...originalDeviceQuery
+})
+
+interface Query {
+  deviceCodes: number[]
+  time: string[]
+}
+
+const originalQuery: Query = {
+  deviceCodes: [],
+  time: [
+    dayjs().subtract(5, 'minute').format('YYYY-MM-DD HH:mm:ss'),
+    dayjs().format('YYYY-MM-DD HH:mm:ss')
+  ]
+  // time: [...rangeShortcuts[2].value().map((item) => dayjs(item).format('YYYY-MM-DD HH:mm:ss'))]
+}
+
+const query = ref<Query>({ ...originalQuery })
+
+const deptLoading = ref(false)
+const deptOptions = ref<any[]>([])
+
+async function loadDeptOptions() {
+  deptLoading.value = true
+  try {
+    function sortTreeBySort(treeNodes: Tree[]) {
+      if (!treeNodes || !Array.isArray(treeNodes)) return treeNodes
+      const sortedNodes = [...treeNodes].sort((a, b) => {
+        const sortA = a.sort != null ? a.sort : 999999
+        const sortB = b.sort != null ? b.sort : 999999
+        return sortA - sortB
+      })
+
+      sortedNodes.forEach((node) => {
+        if (node.children && Array.isArray(node.children)) {
+          node.children = sortTreeBySort(node.children)
+        }
+      })
+      return sortedNodes
+    }
+
+    const depts = await DeptApi.specifiedSimpleDepts(userDeptId)
+    deptOptions.value = sortTreeBySort(handleTree(depts))
+  } catch (error) {
+  } finally {
+    deptLoading.value = false
+  }
+}
+
+const deviceLoading = ref(false)
+const deviceOptions = ref<any[]>([])
+
+async function loadDeviceOptions() {
+  if (!deviceQuery.value.deptId) return
+
+  deviceLoading.value = true
+  try {
+    // const data = await IotDeviceApi.getBoardDevice({
+    //   deptId: deviceQuery.value.deptId,
+    //   ifInline: deviceQuery.value.ifInline,
+    //   pageSize: 100
+    // })
+    const data = await IotDeviceApi.getIotDeviceTdPage({
+      deptId: deviceQuery.value.deptId,
+      ifInline: deviceQuery.value.ifInline,
+      pageSize: 100
+    })
+    deviceOptions.value = data.list.map((item: any) => ({
+      label: item.deviceCode + '-' + item.deviceName,
+      value: item.id,
+      raw: item
+    }))
+    // query.value.deviceIds = deviceOptions.value.map(i => i.value)
+  } catch (error) {
+    deviceOptions.value = []
+  } finally {
+    deviceLoading.value = false
+  }
+}
+
+const deviceList = ref<DeviceData[]>([])
+const chartOption = ref<EChartsOption>({})
+
+async function handleDeviceChange(selectedIds: number[]) {
+  deviceList.value = deviceList.value.filter((d) => selectedIds.includes(d.id))
+
+  const currentIds = deviceList.value.map((d) => d.id)
+  const newIds = selectedIds.filter((id) => !currentIds.includes(id))
+
+  for (const id of newIds) {
+    const option = deviceOptions.value.find((op) => op.value === id)
+    if (option) {
+      deviceList.value.push({
+        id: id,
+        deviceCode: option.raw.deviceCode,
+        deviceName: option.raw.deviceName,
+        mqttUrl: option.raw.mqttUrl
+      })
+    }
+  }
+}
+
+onMounted(() => {
+  loadDeptOptions()
+  loadDeviceOptions()
+})
+
+function handleDeptChange() {
+  query.value = { ...originalQuery }
+  deviceList.value = []
+  chartOption.value = {}
+  loadDeviceOptions()
+}
+
+const targetArea = ref(null)
+
+const { toggle, isFullscreen } = useFullscreen(targetArea)
+
+function handleRest() {
+  deviceQuery.value = { ...originalDeviceQuery }
+  query.value = { ...originalQuery }
+  loadDeptOptions()
+  loadDeviceOptions()
+}
+</script>
+
+<template>
+  <div
+    ref="targetArea"
+    class="relative w-full rounded-lg bg-[#020408] overflow-hidden min-h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
+  >
+    <header
+      class="relative w-full h-14 flex items-center justify-center select-none bg-[#0b1121] border-b border-white/5 shadow-lg"
+    >
+      <div
+        class="absolute inset-0 opacity-20"
+        style="background-image: radial-gradient(circle at 50% 50%, #083344 0%, transparent 50%)"
+      >
+      </div>
+
+      <div class="absolute bottom-0 left-0 w-full h-[2px] bg-slate-800/50 overflow-hidden z-20">
+        <div
+          class="absolute top-0 bottom-0 w-[40%] bg-gradient-to-r from-transparent via-[#22d3ee] to-transparent shadow-[0_0_20px_#22d3ee] animate-scan-line"
+        ></div>
+      </div>
+
+      <div class="absolute left-6 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-80">
+        <div
+          class="w-1 h-4 bg-cyan-400 skew-x-[-12deg] shadow-[0_0_5px_rgba(34,211,238,0.8)]"
+        ></div>
+        <div class="w-1 h-3 bg-cyan-700 skew-x-[-12deg]"></div>
+        <div class="w-1 h-2 bg-cyan-900 skew-x-[-12deg]"></div>
+      </div>
+
+      <div
+        class="absolute right-6 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-80 flex-row-reverse"
+      >
+        <div class="w-1 h-4 bg-cyan-400 skew-x-[12deg] shadow-[0_0_5px_rgba(34,211,238,0.8)]"></div>
+        <div class="w-1 h-3 bg-cyan-700 skew-x-[12deg]"></div>
+        <div class="w-1 h-2 bg-cyan-900 skew-x-[12deg]"></div>
+      </div>
+
+      <h1 class="z-10 text-2xl font-bold tracking-[0.5em] uppercase">
+        <span
+          class="text-transparent bg-clip-text bg-gradient-to-b from-white via-cyan-100 to-cyan-500 drop-shadow-[0_0_10px_rgba(34,211,238,0.8)]"
+        >
+          监控看板
+        </span>
+      </h1>
+    </header>
+    <div class="p-4">
+      <el-form size="default" class="search-container grid grid-cols-6 gap-6">
+        <div
+          class="absolute left-0 top-0 w-[2px] h-full bg-gradient-to-b from-transparent via-cyan-500 to-transparent"
+        ></div>
+        <el-form-item class="col-span-1" label="部门">
+          <el-cascader
+            v-model="deviceQuery.deptId"
+            :options="deptOptions"
+            @change="handleDeptChange"
+            popper-class="poper"
+            :teleported="false"
+            :show-all-levels="false"
+            :props="{ checkStrictly: true, label: 'name', value: 'id' }"
+            class="w-full"
+            placeholder="请选择部门"
+          />
+        </el-form-item>
+        <el-form-item class="col-span-1" label="在线状态">
+          <el-select
+            v-model="deviceQuery.ifInline"
+            placeholder="请选择状态"
+            clearable
+            :teleported="false"
+            popper-class="poper"
+            class="w-full"
+            :class="{ selected: Boolean(deviceQuery.ifInline) }"
+          >
+            <el-option
+              v-for="dict in getStrDictOptions(DICT_TYPE.IOT_DEVICE_STATUS)"
+              :key="dict.value"
+              :label="dict.label"
+              :value="dict.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item class="col-span-3" label="时间范围">
+          <el-date-picker
+            v-model="query.time"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            type="datetimerange"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+            :shortcuts="rangeShortcuts"
+            :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+            :teleported="false"
+            popper-class="poper"
+            class="w-full"
+          />
+        </el-form-item>
+        <el-form-item class="col-span-5 col-start-1" label="设备">
+          <el-select
+            v-model="query.deviceCodes"
+            :options="deviceOptions"
+            multiple
+            placeholder="请选择设备"
+            :teleported="false"
+            popper-class="poper"
+            class="w-full"
+            tag-type="primary"
+            filterable
+            @change="handleDeviceChange"
+          />
+        </el-form-item>
+        <el-form-item class="col-span-1 flex justify-end">
+          <div class="flex gap-3 w-full justify-end">
+            <el-button
+              class="custom-btn primary-btn"
+              :type="isFullscreen ? 'info' : 'primary'"
+              :icon="isFullscreen ? Crop : FullScreen"
+              @click="toggle"
+            >
+              {{ isFullscreen ? '退出全屏' : '全屏' }}
+            </el-button>
+            <el-button @click="handleRest" class="custom-btn reset-btn">重置</el-button>
+          </div>
+        </el-form-item>
+      </el-form>
+    </div>
+
+    <div class="p-4 grid grid-cols-3 gap-6">
+      <template v-for="item in deviceList" :key="item.id">
+        <chart v-bind="item" :date="query.time" />
+      </template>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+@keyframes scan-line {
+  0% {
+    left: -40%;
+    opacity: 0;
+  }
+
+  10% {
+    opacity: 1;
+  }
+
+  90% {
+    opacity: 1;
+  }
+
+  100% {
+    left: 100%;
+    opacity: 0;
+  }
+}
+
+/* 5. 扫描线动画细节 */
+@keyframes scan-line {
+  0% {
+    left: -40%;
+    opacity: 0;
+  }
+
+  50% {
+    opacity: 1;
+  }
+
+  100% {
+    left: 100%;
+    opacity: 0;
+  }
+}
+
+.animate-scan-line {
+  animation: scan-line 3s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate;
+}
+
+.search-container {
+  position: relative;
+  padding: 24px;
+  background: linear-gradient(135deg, rgb(11 17 33 / 90%) 0%, rgb(6 9 18 / 95%) 100%);
+  border: 1px solid rgb(34 211 238 / 60%);
+  border-radius: 4px;
+  box-shadow: 0 10px 40px rgb(0 0 0 / 60%);
+}
+
+:deep(.el-form-item) {
+  --background-color: rgb(2 4 8 / 80%) !important;
+  --box-shadow: 0 0 0 1px rgb(34 211 238 / 20%) inset !important;
+  --transition: all 0.3s ease;
+  --active-box-shadow: 0 0 0 1px #22d3ee inset, 0 0 12px rgb(34 211 238 / 30%) !important;
+  --bg-color-overlay: rgb(16 24 45) !important;
+  --border-color: rgb(34 211 238 / 20%) !important;
+  --text-color-regular: rgb(240 249 255 / 90%) !important;
+  --text-color-primary: rgb(92 231 247 / 90%) !important;
+
+  margin-bottom: 0;
+
+  .el-form-item__label {
+    padding-bottom: 4px;
+    font-size: 14px;
+    font-weight: 500;
+    color: rgb(165 243 252 / 90%) !important;
+  }
+
+  .el-input__wrapper,
+  .el-select__wrapper {
+    background-color: var(--background-color);
+    box-shadow: var(--box-shadow);
+    transition: var(--transition);
+  }
+
+  .el-input__wrapper.is-focus,
+  .is-focused,
+  .is-active {
+    box-shadow: var(--active-box-shadow);
+  }
+
+  .poper {
+    --el-bg-color-overlay: var(--bg-color-overlay);
+    --el-border-color-light: var(--border-color);
+    --el-fill-color-light: var(--border-color);
+    --el-text-color-regular: var(--text-color-regular);
+    --el-text-color-primary: var(--text-color-primary);
+    --el-border-color-extra-light: var(--border-color);
+  }
+
+  .el-cascader-node {
+    &.is-active {
+      box-shadow: none;
+    }
+  }
+
+  .el-time-spinner__item.is-active {
+    box-shadow: none;
+  }
+
+  .cancel {
+    color: var(--el-color-danger);
+  }
+
+  .el-picker-panel__footer {
+    .is-text {
+      color: var(--el-color-danger);
+    }
+
+    .is-plain {
+      color: var(--text-color-primary);
+      background-color: var(--border-color);
+      border-color: var(--border-color);
+    }
+  }
+
+  .el-picker__popper {
+    border-color: var(--border-color);
+
+    .el-time-panel {
+      border-color: var(--border-color);
+    }
+
+    .el-picker-panel__footer {
+      .is-text {
+        color: var(--el-color-danger);
+      }
+
+      .is-plain {
+        color: var(--text-color-primary);
+        background-color: var(--border-color);
+        border-color: var(--border-color);
+      }
+    }
+  }
+
+  .el-input {
+    --el-input-hover-border-color: var(--border-color);
+  }
+
+  .el-input__inner,
+  .el-date-editor .el-range-input {
+    color: var(--text-color-primary);
+  }
+
+  .selected {
+    .el-select__placeholder {
+      color: var(--text-color-primary);
+    }
+  }
+
+  .el-tag {
+    --el-tag-bg-color: var(--border-color);
+    --el-tag-border-color: var(--border-color);
+    --el-tag-text-color: var(--text-color-primary);
+  }
+}
+
+.custom-btn {
+  border: none;
+  border-radius: 2px;
+  transition: all 0.3s;
+}
+
+.primary-btn {
+  color: #020617;
+  background: linear-gradient(90deg, #0891b2 0%, #22d3ee 100%);
+  box-shadow: 0 0 10px rgb(34 211 238 / 30%);
+}
+
+.primary-btn:hover {
+  background: linear-gradient(90deg, #22d3ee 0%, #67e8f9 100%);
+  transform: translateY(-1px);
+  box-shadow: 0 0 20px rgb(34 211 238 / 50%);
+}
+
+.reset-btn {
+  color: #94a3b8;
+  background: rgb(255 255 255 / 5%);
+  border: 1px solid rgb(148 163 184 / 30%);
+}
+
+.reset-btn:hover {
+  color: #fff;
+  background: rgb(255 255 255 / 10%);
+  border-color: rgb(255 255 255 / 50%);
+}
+</style>

+ 1 - 1
src/views/oli-connection/monitoring/index.vue

@@ -17,7 +17,7 @@ interface Query {
   deptId?: number
   deviceName?: string
   deviceCode?: string
-  ifInline?: number
+  ifInline?: string
   pageNo: number
   pageSize: number
 }