Zimo vor 2 Tagen
Ursprung
Commit
d155333ca5

+ 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
+  }
+}

+ 0 - 477
src/views/oli-connection/monitoring-board/1.vue

@@ -1,477 +0,0 @@
-<script setup lang="ts">
-import { reactive, ref } from 'vue'
-import { Search, RefreshLeft, Cpu, Aim } from '@element-plus/icons-vue'
-
-// --- 表单数据模型 ---
-const queryForm = reactive({
-  orgNode: [], // 级联选择结果
-  deviceCode: '',
-  deviceName: '',
-  deviceStatus: '',
-  selectedDevices: [], // 设备多选结果
-  timeRange: []
-})
-
-// --- 模拟数据 (与之前相同) ---
-const orgOptions = [
-  { value: 'factory-1', label: '第一工厂', children: [{ value: 'workshop-a', label: '总装车间' }] },
-  { value: 'factory-2', label: '第二工厂' }
-]
-
-const statusOptions = [
-  { label: '运行中 (RUNNING)', value: 'running', color: '#00f2ea' }, // 青色
-  { label: '故障 (ERROR)', value: 'error', color: '#ff0055' }, // 玫红
-  { label: '待机 (IDLE)', value: 'stopped', color: '#f59e0b' } // 橙色
-]
-
-const deviceOptions = ref([
-  { label: '[CNC-001] 数控机床核心单元', value: 'cnc-001' },
-  { label: '[ROBOT-A] 焊接机械臂一号', value: 'robot-a' },
-  { label: '[AGV-102] 智能物流载具', value: 'agv-102' }
-])
-
-// --- 级联配置 ---
-const cascaderProps = { checkStrictly: true, expandTrigger: 'hover' as const }
-
-// --- 事件处理 ---
-const handleQueryConditionChange = () => {
-  console.log('>> 系统提示: 前置条件变更,正在重新计算目标设备列表...')
-  // 模拟 API 调用延迟和数据更新动画效果
-  const originalOptions = [...deviceOptions.value]
-  deviceOptions.value = []
-  setTimeout(() => {
-    deviceOptions.value = originalOptions
-    console.log('>> 系统提示: 设备列表更新完毕。')
-  }, 500)
-}
-
-const handleSearch = () => {
-  console.log('执行指令: 检索数据', queryForm)
-}
-const handleReset = () => {
-  queryForm.orgNode = []
-  queryForm.deviceCode = ''
-  queryForm.deviceName = ''
-  queryForm.deviceStatus = ''
-  queryForm.selectedDevices = []
-  queryForm.timeRange = []
-}
-</script>
-
-<template>
-  <div class="cyber-bg min-h-[400px] w-full p-6 flex flex-col gap-6 relative overflow-hidden">
-    <div
-      class="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgdmlld0JveD0iMCAwIDQwIDQwIiBvcGFjaXR5PSIwLjEiPjxwYXRoIGQ9Ik0wIDBoNDB2NDBIMHoiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzAwZjJlYSIgc3Ryb2tlLXdpZHRoPSIwLjUiLz48L3N2Zz4=')] opacity-20 pointer-events-none"
-    ></div>
-
-    <div class="cyber-panel relative p-5 rounded-sm backdrop-blur-xl border border-[#00f2ea]/30">
-      <div class="tech-corner top-left"></div>
-      <div class="tech-corner top-right"></div>
-      <div class="tech-corner bottom-left"></div>
-      <div class="tech-corner bottom-right"></div>
-
-      <div class="flex items-center justify-between mb-6 border-b border-[#00f2ea]/20 pb-3">
-        <div class="flex items-center gap-3">
-          <div class="p-2 bg-[#00f2ea]/10 rounded-sm border border-[#00f2ea]/50">
-            <el-icon class="text-[#00f2ea] text-xl animate-pulse"><Cpu /></el-icon>
-          </div>
-          <h2
-            class="text-xl font-oxanium text-transparent bg-clip-text bg-gradient-to-r from-[#00f2ea] to-[#bd00ff] tracking-wider font-bold uppercase u-text-glow"
-          >
-            设备数据检索终端 // DATA RETRIEVAL TERMINAL
-          </h2>
-        </div>
-        <div class="flex items-center gap-2 text-xs text-[#00f2ea]/70 font-mono">
-          <span class="relative flex h-3 w-3">
-            <span
-              class="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#00f2ea] opacity-75"
-            ></span>
-            <span class="relative inline-flex rounded-full h-3 w-3 bg-[#00f2ea]"></span>
-          </span>
-          SYSTEM STATUS: ONLINE
-        </div>
-      </div>
-
-      <el-form :inline="true" :model="queryForm" class="cyber-form flex flex-wrap gap-x-6 gap-y-4">
-        <div
-          class="w-full flex flex-wrap gap-4 items-end border-l-2 border-[#00f2ea]/30 pl-4 bg-gradient-to-r from-[#00f2ea]/5 to-transparent py-2"
-        >
-          <el-form-item label="节点层级 / NODE LEVEL">
-            <el-cascader
-              v-model="queryForm.orgNode"
-              :options="orgOptions"
-              :props="cascaderProps"
-              placeholder="SELECT LEVEL >"
-              clearable
-              class="cyber-input w-48"
-              :show-all-levels="false"
-              popper-class="cyber-popper"
-            />
-          </el-form-item>
-
-          <el-form-item label="设备编码 / CODE">
-            <el-input
-              v-model="queryForm.deviceCode"
-              placeholder="INPUT CODE >"
-              @change="handleQueryConditionChange"
-              class="cyber-input w-40"
-            >
-              <template #prefix
-                ><el-icon><Aim /></el-icon
-              ></template>
-            </el-input>
-          </el-form-item>
-
-          <el-form-item label="设备名称 / NAME">
-            <el-input
-              v-model="queryForm.deviceName"
-              placeholder="INPUT NAME >"
-              @change="handleQueryConditionChange"
-              class="cyber-input w-40"
-            />
-          </el-form-item>
-
-          <el-form-item label="运行状态 / STATUS">
-            <el-select
-              v-model="queryForm.deviceStatus"
-              placeholder="SELECT STATUS >"
-              clearable
-              @change="handleQueryConditionChange"
-              class="cyber-input w-44"
-              popper-class="cyber-popper"
-            >
-              <el-option
-                v-for="item in statusOptions"
-                :key="item.value"
-                :label="item.label"
-                :value="item.value"
-              >
-                <span class="flex items-center gap-2 font-mono text-sm">
-                  <span
-                    :style="{ backgroundColor: item.color, boxShadow: `0 0 8px ${item.color}` }"
-                    class="h-1.5 w-1.5 rounded-full inline-block"
-                  ></span>
-                  {{ item.label }}
-                </span>
-              </el-option>
-            </el-select>
-          </el-form-item>
-        </div>
-
-        <div class="w-full flex flex-wrap gap-4 items-end mt-2 pl-4">
-          <el-form-item label="目标设备锁定 / TARGET DEVICES" class="flex-grow">
-            <el-select
-              v-model="queryForm.selectedDevices"
-              multiple
-              collapse-tags
-              collapse-tags-tooltip
-              placeholder="MULTI-SELECT DEVICES >"
-              class="cyber-input w-full md:w-[400px]"
-              popper-class="cyber-popper"
-            >
-              <el-option
-                v-for="item in deviceOptions"
-                :key="item.value"
-                :label="item.label"
-                :value="item.value"
-              >
-                <span class="font-mono">{{ item.label }}</span>
-              </el-option>
-            </el-select>
-          </el-form-item>
-
-          <el-form-item label="时间跨度 / TIME SPAN">
-            <el-date-picker
-              v-model="queryForm.timeRange"
-              type="datetimerange"
-              range-separator="TO"
-              start-placeholder="START TIME"
-              end-placeholder="END TIME"
-              class="cyber-date-picker !w-[380px]"
-              popper-class="cyber-popper"
-            />
-          </el-form-item>
-
-          <div class="ml-auto flex gap-3">
-            <el-button class="cyber-btn-primary" @click="handleSearch">
-              <el-icon class="mr-2"><Search /></el-icon> 初始化查询
-            </el-button>
-            <el-button class="cyber-btn-secondary" @click="handleReset">
-              <el-icon class="mr-2"><RefreshLeft /></el-icon> 重置条件
-            </el-button>
-          </div>
-        </div>
-      </el-form>
-    </div>
-
-    <div
-      class="flex-grow cyber-panel relative p-6 rounded-sm backdrop-blur-md border border-[#00f2ea]/20 min-h-[300px] flex items-center justify-center flex-col gap-4"
-    >
-      <div class="scan-line absolute inset-0 pointer-events-none z-0"></div>
-      <el-icon class="text-6xl text-[#00f2ea]/30"><Cpu /></el-icon>
-      <p class="text-[#00f2ea]/70 font-mono tracking-[0.2em] z-10"
-        >等待数据接入 // AWAITING DATA STREAM...</p
-      >
-    </div>
-  </div>
-</template>
-
-<style scoped>
-/* --- 引入科幻感字体 (可选,如果你的环境支持联网) --- */
-@import url('https://fonts.googleapis.com/css2?family=Oxanium:wght@400;700&family=Roboto+Mono:wght@400;500&display=swap');
-
-/* --- 自定义工具类 --- */
-.font-oxanium {
-  font-family: Oxanium, cursive, sans-serif;
-}
-
-.font-mono {
-  font-family: 'Roboto Mono', monospace;
-}
-
-.u-text-glow {
-  text-shadow: 0 0 10px rgb(0 242 234 / 50%);
-}
-
-/* --- 核心背景 --- */
-.cyber-bg {
-  color: #e0f2fe;
-  background: radial-gradient(circle at center top, #1a1a2e 0%, #050505 100%);
-}
-
-/* --- 面板样式 (HUD Container) --- */
-.cyber-panel {
-  background: rgb(10 10 20 / 70%);
-  box-shadow:
-    inset 0 0 30px rgb(0 242 234 / 5%),
-    0 0 10px rgb(0 0 0 / 50%);
-}
-
-/* --- 装饰性角落 (Tech Corners) --- */
-.tech-corner {
-  position: absolute;
-  z-index: 1;
-  width: 15px;
-  height: 15px;
-  border-color: rgb(0 242 234 / 60%);
-}
-
-.tech-corner.top-left {
-  top: -1px;
-  left: -1px;
-  border-top-width: 2px;
-  border-left-width: 2px;
-}
-
-.tech-corner.top-right {
-  top: -1px;
-  right: -1px;
-  border-top-width: 2px;
-  border-right-width: 2px;
-}
-
-.tech-corner.bottom-left {
-  bottom: -1px;
-  left: -1px;
-  border-bottom-width: 2px;
-  border-left-width: 2px;
-}
-
-.tech-corner.bottom-right {
-  right: -1px;
-  bottom: -1px;
-  border-right-width: 2px;
-  border-bottom-width: 2px;
-}
-
-/* =========================================
-   Element Plus 深度定制 (The Magic Happens Here)
-   ========================================= */
-
-/* 1. Form Label 定制 */
-:deep(.el-form-item__label) {
-  padding-bottom: 4px;
-  font-family: 'Roboto Mono', monospace;
-  font-size: 0.75rem; /* text-xs */
-  font-weight: bold;
-  letter-spacing: 0.05em;
-  color: rgb(0 242 234 / 70%) !important; /* 青色文字 */
-}
-
-/* 2. 输入框与选择器容器 (Wrapper) 定制 */
-:deep(.cyber-input .el-input__wrapper),
-:deep(.cyber-input .el-select__wrapper),
-:deep(.cyber-date-picker.el-input__wrapper) {
-  padding-top: 4px;
-  padding-bottom: 4px;
-  background-color: rgb(0 242 234 / 5%) !important; /* 极淡的青色背景 */
-  border: 1px solid rgb(0 242 234 / 20%); /* 基础边框 */
-  border-radius: 2px; /* 硬朗的圆角 */
-  box-shadow: none !important; /* 移除默认阴影 */
-  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
-}
-
-/* 悬停与聚焦状态:发光增强 */
-:deep(.cyber-input .el-input__wrapper:hover),
-:deep(.cyber-input .el-select__wrapper:hover),
-:deep(.cyber-input .el-input__wrapper.is-focus),
-:deep(.cyber-input .el-select__wrapper.is-focused) {
-  border-color: rgb(0 242 234 / 80%);
-  box-shadow:
-    0 0 15px rgb(0 242 234 / 30%),
-    inset 0 0 10px rgb(0 242 234 / 10%) !important;
-}
-
-/* 输入框内部文字 */
-:deep(.el-input__inner) {
-  font-family: 'Roboto Mono', monospace;
-  font-size: 0.9rem;
-  color: #fff !important;
-}
-
-:deep(.el-input__inner::placeholder) {
-  color: rgb(0 242 234 / 30%);
-}
-
-/* 图标颜色 */
-:deep(.el-input__prefix-inner .el-icon),
-:deep(.el-select__caret),
-:deep(.el-date-editor .el-range-separator) {
-  color: rgb(0 242 234 / 60%) !important;
-}
-
-/* 日期选择器文字 */
-:deep(.el-date-editor .el-range-input) {
-  font-family: 'Roboto Mono', monospace;
-  color: #fff !important;
-}
-
-/* Tag 标签定制 (用于多选和级联) */
-:deep(.el-tag.el-tag--info) {
-  color: #e0c0ff;
-  background-color: rgb(189 0 255 / 20%); /* 紫色背景 */
-  border-color: rgb(189 0 255 / 50%);
-  border-radius: 2px;
-}
-
-:deep(.el-tag .el-tag__close) {
-  color: #e0c0ff;
-}
-
-/* =========================================
-   按钮定制 (Cyber Buttons)
-   ========================================= */
-.cyber-btn-primary {
-  font-family: Oxanium, sans-serif;
-  font-weight: bold;
-  letter-spacing: 1px;
-  color: #000 !important;
-  background: linear-gradient(135deg, #00f2ea 0%, #00a8c6 100%) !important;
-  border: none !important;
-  transition: all 0.3s;
-  clip-path: polygon(10% 0, 100% 0, 100% 70%, 90% 100%, 0 100%, 0 30%); /* 斜切角造型 */
-}
-
-.cyber-btn-primary:hover {
-  filter: brightness(1.2);
-  transform: translateY(-1px);
-  box-shadow: 0 0 20px rgb(0 242 234 / 60%);
-}
-
-.cyber-btn-secondary {
-  font-family: Oxanium, sans-serif;
-  color: rgb(0 242 234 / 80%) !important;
-  background: transparent !important;
-  border: 1px solid rgb(0 242 234 / 50%) !important;
-  clip-path: polygon(0 0, 90% 0, 100% 30%, 100% 100%, 10% 100%, 0 70%);
-}
-
-.cyber-btn-secondary:hover {
-  color: #fff !important;
-  background: rgb(0 242 234 / 10%) !important;
-  border-color: #00f2ea !important;
-  box-shadow: inset 0 0 10px rgb(0 242 234 / 30%);
-}
-
-/* =========================================
-   下拉弹出层定制 (Global Styles needed for Poppers)
-   ========================================= */
-
-/* 注意:这部分通常需要放在全局样式文件中,或者去掉 scoped,
-   为了方便演示,我这里使用了特殊的处理方式。
-   在实际项目中,请确保这些样式能作用于 body 下的弹出层。
-*/
-</style>
-
-<style>
-@keyframes scan {
-  0% {
-    transform: translateY(-100%);
-  }
-
-  100% {
-    transform: translateY(100%);
-  }
-}
-
-.cyber-popper.el-popper {
-  background: rgb(10 10 25 / 95%) !important;
-  border: 1px solid rgb(0 242 234 / 30%) !important;
-  backdrop-filter: blur(10px);
-}
-
-.cyber-popper .el-popper__arrow::before {
-  background: rgb(10 10 25 / 95%) !important;
-  border: 1px solid rgb(0 242 234 / 30%) !important;
-}
-
-/* 下拉选项 Item */
-.cyber-popper .el-select-dropdown__item,
-.cyber-popper .el-cascader-node {
-  font-family: 'Roboto Mono', monospace;
-  color: #a0aec0 !important;
-}
-
-/* 选项悬停/选中状态 */
-.cyber-popper .el-select-dropdown__item.hover,
-.cyber-popper .el-select-dropdown__item.selected,
-.cyber-popper .el-cascader-node.is-active,
-.cyber-popper .el-cascader-node:hover {
-  font-weight: bold;
-  color: #00f2ea !important;
-  background: rgb(0 242 234 / 15%) !important;
-}
-
-/* 日期选择器面板特殊处理 */
-.cyber-popper .el-picker-panel {
-  color: #e0f2fe;
-  background: transparent !important;
-}
-
-.cyber-popper .el-date-table td.available:hover {
-  color: #00f2ea;
-}
-
-.cyber-popper .el-date-table td.today span {
-  font-weight: bold;
-  color: #00f2ea;
-}
-
-.cyber-popper .el-date-table td.current:not(.disabled) span {
-  color: #000;
-  background-color: #00f2ea;
-}
-
-.cyber-popper .el-date-range-picker__content.is-left {
-  border-right: 1px solid rgb(0 242 234 / 20%);
-}
-
-/* 扫描线动画 */
-.scan-line {
-  width: 100%;
-  height: 100%;
-  background: linear-gradient(to bottom, transparent, rgb(0 242 234 / 20%), transparent);
-  opacity: 0.3;
-  animation: scan 3s linear infinite;
-}
-</style>

+ 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>

+ 150 - 299
src/views/oli-connection/monitoring-board/index.vue

@@ -1,6 +1,25 @@
 <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
@@ -8,8 +27,8 @@ interface DeviceQuery {
 }
 
 const originalDeviceQuery: DeviceQuery = {
-  deptId: 0,
-  ifInline: '3'
+  deptId: userDeptId,
+  ifInline: '0'
 }
 
 const deviceQuery = ref<DeviceQuery>({
@@ -17,314 +36,131 @@ const deviceQuery = ref<DeviceQuery>({
 })
 
 interface Query {
-  deviceCodes?: string[]
-  time?: string[]
+  deviceCodes: number[]
+  time: string[]
 }
 
 const originalQuery: Query = {
   deviceCodes: [],
-  time: []
+  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 value = ref([])
+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 handleChange = (value) => {
-  console.log(value)
+    const depts = await DeptApi.specifiedSimpleDepts(userDeptId)
+    deptOptions.value = sortTreeBySort(handleTree(depts))
+  } catch (error) {
+  } finally {
+    deptLoading.value = false
+  }
 }
 
-const options = [
-  {
-    value: 'guide',
-    label: 'Guide',
-    children: [
-      {
-        value: 'disciplines',
-        label: 'Disciplines',
-        children: [
-          {
-            value: 'consistency',
-            label: 'Consistency'
-          },
-          {
-            value: 'feedback',
-            label: 'Feedback'
-          },
-          {
-            value: 'efficiency',
-            label: 'Efficiency'
-          },
-          {
-            value: 'controllability',
-            label: 'Controllability'
-          }
-        ]
-      },
-      {
-        value: 'navigation',
-        label: 'Navigation',
-        children: [
-          {
-            value: 'side nav',
-            label: 'Side Navigation'
-          },
-          {
-            value: 'top nav',
-            label: 'Top Navigation'
-          }
-        ]
-      }
-    ]
-  },
-  {
-    value: 'component',
-    label: 'Component',
-    children: [
-      {
-        value: 'basic',
-        label: 'Basic',
-        children: [
-          {
-            value: 'layout',
-            label: 'Layout'
-          },
-          {
-            value: 'color',
-            label: 'Color'
-          },
-          {
-            value: 'typography',
-            label: 'Typography'
-          },
-          {
-            value: 'icon',
-            label: 'Icon'
-          },
-          {
-            value: 'button',
-            label: 'Button'
-          }
-        ]
-      },
-      {
-        value: 'form',
-        label: 'Form',
-        children: [
-          {
-            value: 'radio',
-            label: 'Radio'
-          },
-          {
-            value: 'checkbox',
-            label: 'Checkbox'
-          },
-          {
-            value: 'input',
-            label: 'Input'
-          },
-          {
-            value: 'input-number',
-            label: 'InputNumber'
-          },
-          {
-            value: 'select',
-            label: 'Select'
-          },
-          {
-            value: 'cascader',
-            label: 'Cascader'
-          },
-          {
-            value: 'switch',
-            label: 'Switch'
-          },
-          {
-            value: 'slider',
-            label: 'Slider'
-          },
-          {
-            value: 'time-picker',
-            label: 'TimePicker'
-          },
-          {
-            value: 'date-picker',
-            label: 'DatePicker'
-          },
-          {
-            value: 'datetime-picker',
-            label: 'DateTimePicker'
-          },
-          {
-            value: 'upload',
-            label: 'Upload'
-          },
-          {
-            value: 'rate',
-            label: 'Rate'
-          },
-          {
-            value: 'form',
-            label: 'Form'
-          }
-        ]
-      },
-      {
-        value: 'data',
-        label: 'Data',
-        children: [
-          {
-            value: 'table',
-            label: 'Table'
-          },
-          {
-            value: 'tag',
-            label: 'Tag'
-          },
-          {
-            value: 'progress',
-            label: 'Progress'
-          },
-          {
-            value: 'tree',
-            label: 'Tree'
-          },
-          {
-            value: 'pagination',
-            label: 'Pagination'
-          },
-          {
-            value: 'badge',
-            label: 'Badge'
-          }
-        ]
-      },
-      {
-        value: 'notice',
-        label: 'Notice',
-        children: [
-          {
-            value: 'alert',
-            label: 'Alert'
-          },
-          {
-            value: 'loading',
-            label: 'Loading'
-          },
-          {
-            value: 'message',
-            label: 'Message'
-          },
-          {
-            value: 'message-box',
-            label: 'MessageBox'
-          },
-          {
-            value: 'notification',
-            label: 'Notification'
-          }
-        ]
-      },
-      {
-        value: 'navigation',
-        label: 'Navigation',
-        children: [
-          {
-            value: 'menu',
-            label: 'Menu'
-          },
-          {
-            value: 'tabs',
-            label: 'Tabs'
-          },
-          {
-            value: 'breadcrumb',
-            label: 'Breadcrumb'
-          },
-          {
-            value: 'dropdown',
-            label: 'Dropdown'
-          },
-          {
-            value: 'steps',
-            label: 'Steps'
-          }
-        ]
-      },
-      {
-        value: 'others',
-        label: 'Others',
-        children: [
-          {
-            value: 'dialog',
-            label: 'Dialog'
-          },
-          {
-            value: 'tooltip',
-            label: 'Tooltip'
-          },
-          {
-            value: 'popover',
-            label: 'Popover'
-          },
-          {
-            value: 'card',
-            label: 'Card'
-          },
-          {
-            value: 'carousel',
-            label: 'Carousel'
-          },
-          {
-            value: 'collapse',
-            label: 'Collapse'
-          }
-        ]
-      }
-    ]
-  },
-  {
-    value: 'resource',
-    label: 'Resource',
-    children: [
-      {
-        value: 'axure',
-        label: 'Axure Components'
-      },
-      {
-        value: 'sketch',
-        label: 'Sketch Templates'
-      },
-      {
-        value: 'docs',
-        label: 'Design Documentation'
-      }
-    ]
+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 deviceOptions = ref([
-  {
-    value: 'guide',
-    label: 'Guide'
-  },
-  {
-    value: 'monitoring',
-    label: 'Monitoring'
-  },
-  {
-    value: 'control',
-    label: 'Control'
-  },
-  {
-    value: 'config',
-    label: 'Config'
+}
+
+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
@@ -373,13 +209,13 @@ const deviceOptions = ref([
         ></div>
         <el-form-item class="col-span-1" label="部门">
           <el-cascader
-            v-model="value"
-            :options="options"
-            @change="handleChange"
+            v-model="deviceQuery.deptId"
+            :options="deptOptions"
+            @change="handleDeptChange"
             popper-class="poper"
             :teleported="false"
             :show-all-levels="false"
-            :props="{ checkStrictly: true }"
+            :props="{ checkStrictly: true, label: 'name', value: 'id' }"
             class="w-full"
             placeholder="请选择部门"
           />
@@ -426,16 +262,31 @@ const deviceOptions = ref([
             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">查询数据</el-button>
-            <el-button class="custom-btn reset-btn">重置</el-button>
+            <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>
 

+ 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
 }