yanghao 6 dni temu
rodzic
commit
4dbe422d91

+ 2 - 0
package.json

@@ -33,6 +33,7 @@
     "@fullcalendar/interaction": "^6.1.17",
     "@fullcalendar/vue3": "^6.1.17",
     "@iconify/iconify": "^3.1.1",
+    "@kjgl77/datav-vue3": "^1.7.4",
     "@microsoft/fetch-event-source": "^2.0.1",
     "@number-flow/vue": "^0.4.8",
     "@types/echarts": "^5.0.0",
@@ -79,6 +80,7 @@
     "qs": "^6.12.0",
     "sortablejs": "1.15.0",
     "steady-xml": "^0.1.0",
+    "three": "^0.182.0",
     "url": "^0.11.3",
     "v3-jsoneditor": "^0.0.6",
     "video.js": "^7.21.5",

+ 61 - 0
pnpm-lock.yaml

@@ -32,6 +32,9 @@ importers:
       '@iconify/iconify':
         specifier: ^3.1.1
         version: 3.1.1
+      '@kjgl77/datav-vue3':
+        specifier: ^1.7.4
+        version: 1.7.4(vue@3.5.12(typescript@5.3.3))
       '@microsoft/fetch-event-source':
         specifier: ^2.0.1
         version: 2.0.1
@@ -170,6 +173,9 @@ importers:
       steady-xml:
         specifier: ^0.1.0
         version: 0.1.0
+      three:
+        specifier: ^0.182.0
+        version: 0.182.0
       url:
         specifier: ^0.11.3
         version: 0.11.4
@@ -1388,6 +1394,21 @@ packages:
     resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==}
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
 
+  '@jiaminghi/bezier-curve@0.0.9':
+    resolution: {integrity: sha512-u9xJPOEl6Dri2E9FfmJoGxYQY7vYJkURNX04Vj64tdi535tPrpkuf9Sm0lNr3QTKdHQh0DdNRsaa62FLQNQEEw==}
+
+  '@jiaminghi/c-render@0.4.3':
+    resolution: {integrity: sha512-FJfzj5hGj7MLqqqI2D7vEzHKbQ1Ynnn7PJKgzsjXaZpJzTqs2Yw5OSeZnm6l7Qj7jyPAP53lFvEQNH4o4j6s+Q==}
+
+  '@jiaminghi/charts@0.2.18':
+    resolution: {integrity: sha512-K+HXaOOeWG9OOY1VG6M4mBreeeIAPhb9X+khG651AbnwEwL6G2UtcAQ8GWCq6GzhczcLwwhIhuaHqRygwHC0sA==}
+
+  '@jiaminghi/color@1.1.3':
+    resolution: {integrity: sha512-ZY3hdorgODk4OSTbxyXBPxAxHPIVf9rPlKJyK1C1db46a50J0reFKpAvfZG8zMG3lvM60IR7Qawgcu4ZDO3+Hg==}
+
+  '@jiaminghi/transition@1.1.11':
+    resolution: {integrity: sha512-owBggipoHMikDHHDW5Gc7RZYlVuvxHADiU4bxfjBVkHDAmmck+fCkm46n2JzC3j33hWvP9nSCAeh37t6stgWeg==}
+
   '@jridgewell/gen-mapping@0.3.5':
     resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==}
     engines: {node: '>=6.0.0'}
@@ -1409,6 +1430,9 @@ packages:
   '@jridgewell/trace-mapping@0.3.25':
     resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
 
+  '@kjgl77/datav-vue3@1.7.4':
+    resolution: {integrity: sha512-zYVTVKkklUxwtiNKS1qPBilm4rTW+WItfp0zVpaRAI8wgXkLSPbDR9xPq2+UcU/Jft7/DVdMfBp709E2ResuPQ==}
+
   '@lezer/common@1.3.0':
     resolution: {integrity: sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ==}
 
@@ -4865,6 +4889,9 @@ packages:
   text-table@0.2.0:
     resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
 
+  three@0.182.0:
+    resolution: {integrity: sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==}
+
   through@2.3.8:
     resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
 
@@ -6579,6 +6606,28 @@ snapshots:
     dependencies:
       '@sinclair/typebox': 0.27.8
 
+  '@jiaminghi/bezier-curve@0.0.9':
+    dependencies:
+      '@babel/runtime': 7.26.0
+
+  '@jiaminghi/c-render@0.4.3':
+    dependencies:
+      '@babel/runtime': 7.26.0
+      '@jiaminghi/bezier-curve': 0.0.9
+      '@jiaminghi/color': 1.1.3
+      '@jiaminghi/transition': 1.1.11
+
+  '@jiaminghi/charts@0.2.18':
+    dependencies:
+      '@babel/runtime': 7.26.0
+      '@jiaminghi/c-render': 0.4.3
+
+  '@jiaminghi/color@1.1.3': {}
+
+  '@jiaminghi/transition@1.1.11':
+    dependencies:
+      '@babel/runtime': 7.26.0
+
   '@jridgewell/gen-mapping@0.3.5':
     dependencies:
       '@jridgewell/set-array': 1.2.1
@@ -6601,6 +6650,16 @@ snapshots:
       '@jridgewell/resolve-uri': 3.1.2
       '@jridgewell/sourcemap-codec': 1.5.0
 
+  '@kjgl77/datav-vue3@1.7.4(vue@3.5.12(typescript@5.3.3))':
+    dependencies:
+      '@jiaminghi/c-render': 0.4.3
+      '@jiaminghi/charts': 0.2.18
+      '@jiaminghi/color': 1.1.3
+      '@vueuse/core': 10.11.1(vue@3.5.12(typescript@5.3.3))
+    transitivePeerDependencies:
+      - '@vue/composition-api'
+      - vue
+
   '@lezer/common@1.3.0': {}
 
   '@lezer/highlight@1.2.3':
@@ -10338,6 +10397,8 @@ snapshots:
 
   text-table@0.2.0: {}
 
+  three@0.182.0: {}
+
   through@2.3.8: {}
 
   tiny-svg@3.1.3: {}

BIN
public/model/industrialEquipment.glb


BIN
public/model/test.glb


BIN
public/model/test2.glb


+ 3 - 0
src/main.ts

@@ -42,6 +42,8 @@ import Logger from '@/utils/Logger'
 
 import VueDOMPurifyHTML from 'vue-dompurify-html' // 解决v-html 的安全隐患
 
+import DataVVue3 from '@kjgl77/datav-vue3'
+
 // 创建实例
 const setupAll = async () => {
   const app = createApp(App)
@@ -63,6 +65,7 @@ const setupAll = async () => {
   setupMountedFocus(app)
 
   await router.isReady()
+  app.use(DataVVue3)
 
   app.use(VueDOMPurifyHTML)
 

+ 36 - 0
src/router/modules/remaining.ts

@@ -1780,6 +1780,42 @@ const remainingRouter: AppRouteRecordRaw[] = [
       }
     ]
   },
+  // {
+  //   path: '/device_monitor',
+  //   component: Layout,
+  //   name: 'kanban',
+  //   meta: { hidden: true },
+  //   children: [
+  //     {
+  //       path: 'kanban',
+  //       name: 'DeviceKanban',
+  //       meta: {
+  //         title: '设备看板',
+  //         noCache: true,
+  //         hidden: true
+  //       },
+  //       component: () => import('@/views/pms/monitor/kanban.vue')
+  //     }
+  //   ]
+  // },
+  {
+    path: '/kanban',
+    component: Layout,
+    name: 'kanban',
+    meta: { hidden: true },
+    children: [
+      {
+        path: 'monitor/kanban',
+        name: 'DeviceKanban',
+        meta: {
+          title: '设备看板',
+          noCache: true,
+          hidden: true
+        },
+        component: () => import('@/views/pms/monitor/kanban.vue')
+      }
+    ]
+  },
   {
     path: '/diy',
     name: 'DiyCenter',

+ 116 - 0
src/views/pms/monitor/ModelViewer.vue

@@ -0,0 +1,116 @@
+<template>
+  <div ref="container" class="w-full h-full z-999 cursor-pointer"></div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, onUnmounted } from 'vue'
+import * as THREE from 'three'
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
+
+const container = ref<HTMLDivElement>()
+
+let scene: THREE.Scene
+let camera: THREE.PerspectiveCamera
+let renderer: THREE.WebGLRenderer
+let controls: OrbitControls
+let model: THREE.Group
+
+const init = () => {
+  scene = new THREE.Scene()
+  // scene.background = new THREE.Color(0x0a0a0a) // 移除深色背景
+
+  camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000)
+  camera.position.set(3, 3, 3)
+  camera.lookAt(0, 0, 0)
+
+  renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
+  renderer.setSize(2000, 1000)
+  renderer.shadowMap.enabled = true
+  renderer.shadowMap.type = THREE.PCFSoftShadowMap
+  container.value?.appendChild(renderer.domElement)
+
+  controls = new OrbitControls(camera, renderer.domElement)
+  controls.enableDamping = true
+  controls.dampingFactor = 0.05
+  controls.enableZoom = true
+  controls.enablePan = false
+
+  const hemisphereLight = new THREE.HemisphereLight(0xffffbb, 0x080820, 2) // 增加强度从1到2
+  scene.add(hemisphereLight)
+
+  const pointLight = new THREE.PointLight(0xffffff, 2, 100) // 增加强度从1到2
+  pointLight.position.set(0, 5, 5)
+  scene.add(pointLight)
+
+  const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5) // 增加强度从0.8到1.5
+  directionalLight.position.set(10, 10, 5)
+  directionalLight.castShadow = true
+  scene.add(directionalLight)
+
+  // 添加额外的环境光
+  const ambientLight = new THREE.AmbientLight(0x404040, 0.6) // 添加环境光
+  scene.add(ambientLight)
+
+  const modelUrl = '/model/test2.glb'
+  console.log('模型URL:', modelUrl)
+  const loader = new GLTFLoader()
+  loader.load(
+    modelUrl,
+    (gltf) => {
+      console.log('模型加载成功:', gltf)
+      model = gltf.scene
+      model.scale.set(7, 7, 7) // 增加缩放
+      model.position.set(0, 0, 0)
+      scene.add(model)
+    },
+    (progress) => {
+      console.log('模型加载进度:', progress) // 加载进度
+    },
+    (error) => {
+      console.error('模型加载失败:', error)
+    }
+  )
+
+  const animate = () => {
+    requestAnimationFrame(animate)
+    controls.update()
+    if (model) {
+      model.rotation.y += 0.005
+    } else {
+      scene.children.forEach((child) => {
+        if (child instanceof THREE.Mesh && child.geometry instanceof THREE.BoxGeometry) {
+          child.rotation.y += 0.01
+        }
+      })
+    }
+    renderer.render(scene, camera)
+  }
+  animate()
+
+  const resize = () => {
+    if (container.value) {
+      const width = container.value.clientWidth
+      const height = container.value.clientHeight
+      camera.aspect = width / height
+      camera.updateProjectionMatrix()
+      renderer.setSize(width, height)
+    }
+  }
+  window.addEventListener('resize', resize)
+  resize()
+}
+
+onMounted(() => {
+  init()
+})
+
+onUnmounted(() => {
+  if (renderer) {
+    renderer.dispose()
+  }
+  if (controls) {
+    controls.dispose()
+  }
+})
+</script>

+ 40 - 0
src/views/pms/monitor/data-row.vue

@@ -0,0 +1,40 @@
+<template>
+  <div
+    style="border-bottom: 0.5px solid rgba(255, 255, 255, 0.1); border-radius: 5px"
+    :class="[
+      'flex items-center justify-between border-b border-cyan-500/20 hover:bg-cyan-500/10 transition-colors px-2 -mx-2 rounded',
+      compact ? 'py-1' : 'py-2'
+    ]"
+  >
+    <span class="text-cyan-200">{{ label }}</span>
+    <div class="flex items-center gap-2">
+      <span class="font-mono text-white font-bold">{{ value }}</span>
+      <button
+        v-if="button"
+        style="border-radius: 5px"
+        :class="[
+          'px-3 py-1 rounded text-white text-sm transition-all',
+          buttonColor === 'green'
+            ? 'bg-green-600 hover:bg-green-500 shadow-[0_0_10px_rgba(22,163,74,0.5)]'
+            : 'bg-red-600 hover:bg-red-500 shadow-[0_0_10px_rgba(220,38,38,0.5)]'
+        ]"
+      >
+        {{ button }}
+      </button>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+interface Props {
+  label: string
+  value: string
+  button?: string
+  buttonColor?: 'green' | 'red'
+  compact?: boolean
+}
+
+withDefaults(defineProps<Props>(), {
+  compact: false
+})
+</script>

+ 492 - 0
src/views/pms/monitor/index.vue

@@ -0,0 +1,492 @@
+<template>
+  <el-row :gutter="20">
+    <!-- 左侧部门树 -->
+    <el-col :span="4" :xs="24">
+      <ContentWrap class="h-1/1" style="border: 0" v-if="treeShow">
+        <DeptTree @node-click="handleDeptNodeClick" />
+      </ContentWrap>
+    </el-col>
+    <el-col :span="contentSpan" :xs="24">
+      <ContentWrap style="border: 0">
+        <!-- 搜索工作栏 -->
+        <el-form
+          class="-mb-15px"
+          :model="queryParams"
+          ref="queryFormRef"
+          :inline="true"
+          label-width="68px"
+        >
+          <el-form-item label="成套名称" prop="name">
+            <el-input
+              v-model="queryParams.name"
+              placeholder="请输入成套名称"
+              clearable
+              @keyup.enter="handleQuery"
+              class="!w-200px"
+            />
+          </el-form-item>
+          <el-form-item>
+            <el-button @click="handleQuery"
+              ><Icon icon="ep:search" class="mr-5px" /> {{ t('devicePerson.search') }}</el-button
+            >
+            <el-button @click="resetQuery"
+              ><Icon icon="ep:refresh" class="mr-5px" /> {{ t('devicePerson.reset') }}</el-button
+            >
+          </el-form-item>
+        </el-form>
+      </ContentWrap>
+
+      <!-- 列表 -->
+      <ContentWrap style="border: 0">
+        <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+          <el-table-column :label="t('monitor.serial')" width="70" align="center">
+            <template #default="scope">
+              {{ scope.$index + 1 }}
+            </template>
+          </el-table-column>
+          <el-table-column label="部门名称" align="center" prop="deptName" />
+          <el-table-column label="成套名称" align="center" prop="name" />
+
+          <el-table-column label="描述" align="center" prop="remark" />
+          <el-table-column label="设备数量" align="center" prop="deviceCount">
+            <template #default="scope">
+              {{ (scope.row.details && scope.row.details.length) || 0 }}
+            </template>
+          </el-table-column>
+          <el-table-column label="主设备" align="center" prop="mainDeviceName">
+            <template #default="scope">
+              {{
+                scope.row.details.filter((item) => item.ifMaster)[0]?.deviceName +
+                  ' ' +
+                  scope.row.details.filter((item) => item.ifMaster)[0]?.deviceCode || '无'
+              }}
+            </template>
+          </el-table-column>
+
+          <el-table-column :label="t('devicePerson.operation')" align="center" min-width="120px">
+            <template #default="scope">
+              <el-button link type="primary" @click="handleEdit(scope.row)"> 查看详情 </el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+        <!-- 分页 -->
+        <Pagination
+          :total="total"
+          v-model:page="queryParams.pageNo"
+          v-model:limit="queryParams.pageSize"
+          @pagination="getList"
+        />
+      </ContentWrap>
+    </el-col>
+  </el-row>
+
+  <!-- 新增/编辑成套设备对话框 -->
+  <el-dialog :title="dialogTitle" v-model="dialogVisible" width="800px" @close="cancel">
+    <template #header>
+      <div class="my-header" style="padding-bottom: 20px">
+        <span>{{ dialogTitle }}</span>
+      </div>
+    </template>
+    <el-form ref="formRef" :model="formData" :rules="formRules" label-width="80px">
+      <el-row :gutter="20">
+        <el-col :span="12">
+          <el-form-item label="成套名称" prop="name">
+            <el-input v-model="formData.name" placeholder="请输入成套名称" />
+          </el-form-item>
+        </el-col>
+
+        <el-col :span="12">
+          <el-form-item :label="t('iotDevice.dept')" prop="deptId">
+            <el-tree-select
+              v-model="formData.deptId"
+              :data="deptList"
+              :props="defaultProps"
+              check-strictly
+              node-key="id"
+              filterable
+              placeholder="请选择所在部门"
+              @change="handleDeptChange"
+            />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="20">
+        <el-col :span="24">
+          <el-form-item label="描述" prop="remark">
+            <el-input
+              v-model="formData.remark"
+              type="textarea"
+              placeholder="请输入描述"
+              :rows="2"
+            />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="20">
+        <el-col :span="24">
+          <el-form-item label="选择设备" prop="devices">
+            <div class="transfer-container">
+              <el-transfer
+                v-model="selectedDeviceIds"
+                :data="deviceOptions"
+                :titles="['设备列表', '已选择设备']"
+                :button-texts="['移除', '添加']"
+                filterable
+                :filter-method="filterDeviceMethod"
+                filter-placeholder="请输入设备名称"
+                @change="rightDeviceChange"
+              >
+                <template #left-empty>
+                  <el-empty :image-size="60" :description="isEdit ? '加载中...' : '请选择设备'" />
+                </template>
+                <template #right-empty>
+                  <el-empty :image-size="60" :description="isEdit ? '加载中...' : '请选择设备'" />
+                </template>
+              </el-transfer>
+            </div>
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="20" v-if="selectedDevices.length > 0">
+        <el-col :span="12">
+          <el-form-item label="设置主设备" prop="mainDevice" label-width="100">
+            <el-select
+              v-model="mainDeviceId"
+              placeholder="请选择主设备"
+              clearable
+              filterable
+              @change="setMainDevice"
+            >
+              <el-option
+                v-for="device in selectedDevices"
+                :key="device.id"
+                :label="device.label"
+                :value="device.id"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+      </el-row>
+    </el-form>
+
+    <template #footer>
+      <el-button @click="cancel">取 消</el-button>
+      <el-button type="primary" @click="submit">确 定</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { IotDeviceApi } from '@/api/pms/device'
+import DeptTree from '@/views/system/user/DeptTree.vue'
+import { defaultProps, handleTree } from '@/utils/tree'
+import * as DeptApi from '@/api/system/dept'
+import { ElMessageBox } from 'element-plus'
+const deptList = ref<Tree[]>([]) // 树形结构
+import { useRouter } from 'vue-router'
+const router = useRouter()
+
+defineOptions({ name: 'IotDeviceComplete' })
+
+const loading = ref(true) // 列表的加载中
+
+const { t } = useI18n()
+
+const list = ref([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  deptName: undefined,
+  name: undefined,
+  deptId: undefined
+})
+const queryFormRef = ref(null) // 搜索的表单
+
+const contentSpan = ref(20)
+const treeShow = ref(true)
+
+// 对话框相关
+const dialogVisible = ref(false)
+const dialogTitle = ref('')
+const isEdit = ref(false)
+
+// 表单相关
+const formRef = ref()
+const formData = ref({
+  name: '',
+  details: [],
+  deptId: '',
+  remark: ''
+})
+
+// 表单验证规则
+const formRules = {
+  name: [{ required: true, message: '成套名称不能为空', trigger: 'blur' }],
+  code: [{ required: true, message: '成套编码不能为空', trigger: 'blur' }],
+  deptId: [{ required: true, message: '请选择部门', trigger: 'change' }],
+  devices: [
+    {
+      required: true,
+      validator: (rule: any, value: any, callback: any) => {
+        if (selectedDeviceIds.value.length === 0) {
+          callback(new Error('请至少选择一个设备'))
+        } else {
+          callback()
+        }
+      },
+      trigger: 'change'
+    }
+  ],
+  mainDevice: [
+    {
+      required: true,
+      validator: (rule: any, value: any, callback: any) => {
+        if (!mainDeviceId.value) {
+          callback(new Error('请选择主设备'))
+        } else {
+          callback()
+        }
+      },
+      trigger: 'change'
+    }
+  ]
+}
+
+// 部门树数据
+const selectedDeptId = ref<number | string>('')
+
+// 新增时部门改变时获取设备列表
+const handleDeptChange = async (deptId) => {
+  if (deptId) {
+    selectedDeptId.value = deptId
+    getDeviceList(deptId)
+  }
+
+  // 清空主设备she 设备列表
+  selectedDevices.value = []
+  mainDeviceId.value = ''
+  selectedDeviceIds.value = []
+}
+// 获取设备列表
+const getDeviceList = async (deptId) => {
+  try {
+    const res = await IotDeviceApi.getIotDeviceSetOptions(deptId)
+    deviceOptions.value = res.map((item) => ({
+      key: item.id, // 始终使用id作为key
+      label: `${item.deviceName} (${item.deviceCode})`,
+      ...item
+    }))
+  } catch (err) {
+    console.error(err)
+  }
+}
+
+// 设备选择相关
+const deviceOptions = ref<any[]>([])
+const selectedDeviceIds = ref([])
+const selectedDevices = ref([])
+const mainDeviceId = ref('')
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await IotDeviceApi.getIotDeviceSetList(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 首页处理部门被点击 */
+const handleDeptNodeClick = async (row) => {
+  queryParams.deptId = row.id
+  await getList()
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+// 设备过滤方法
+const filterDeviceMethod = (query, item) => {
+  return item.label.toLowerCase().includes(query.toLowerCase())
+}
+
+// 更新已选择的设备列表
+const updateSelectedDevices = () => {
+  // 根据 selectedDeviceIds 从 deviceOptions 中找到对应的设备
+  selectedDevices.value = deviceOptions.value.filter((item) =>
+    selectedDeviceIds.value.includes(item.key)
+  )
+
+  console.log('selectedDevices>>>>>>>>>>>>>>>>', selectedDevices.value)
+
+  // 构建 details 数据
+  formData.value.details = selectedDevices.value.map((item) => ({
+    deviceId: item.id,
+    deviceName: item.deviceName,
+    deviceCode: item.deviceCode,
+    deptId: item.deptId,
+    ifMaster: item.id === mainDeviceId.value ? true : false
+  }))
+
+  // 如果主设备不在当前选择中,则清空主设备
+  if (
+    mainDeviceId.value &&
+    !selectedDevices.value.some((device) => device.id === mainDeviceId.value)
+  ) {
+    mainDeviceId.value = ''
+  }
+}
+
+const rightDeviceChange = (val) => {
+  selectedDeviceIds.value = val
+  updateSelectedDevices()
+
+  // 手动触发验证
+  if (formRef.value) {
+    formRef.value.validateField('devices')
+    if (val.length > 0) {
+      formRef.value.validateField('mainDevice')
+    }
+  }
+}
+
+// 设置主设备
+const setMainDevice = (val) => {
+  mainDeviceId.value = val
+  // 更新 details 中的 ifMaster 字段
+  console.log('selectedDevices.value>>>>>>>>>>>>>>>>', selectedDevices.value)
+
+  formData.value.details = selectedDevices.value.map((item) => ({
+    deviceId: item.id,
+    deviceName: item.deviceName,
+    deviceCode: item.deviceCode,
+    deptId: item.deptId,
+    ifMaster: item.id === mainDeviceId.value ? true : false
+  }))
+
+  console.log('formData.value.details>>>>>>>>>>>>>>>>', formData.value.details)
+
+  // 手动触发验证
+  if (formRef.value) {
+    formRef.value.validateField('mainDevice')
+  }
+}
+
+// 显示新增对话框
+const handleAdd = () => {
+  isEdit.value = false
+  dialogTitle.value = '新增成套设备'
+  resetForm()
+  dialogVisible.value = true
+}
+
+// 显示编辑对话框
+const handleEdit = (row) => {
+  router.push({ path: '/kanban/monitor/kanban' })
+}
+
+// 重置表单
+const resetForm = () => {
+  formData.value = {
+    name: '',
+    details: [],
+    deptId: '',
+    remark: ''
+  }
+  selectedDeviceIds.value = []
+  selectedDevices.value = []
+  deviceOptions.value = []
+  mainDeviceId.value = ''
+}
+
+// 取消操作
+const cancel = () => {
+  dialogVisible.value = false
+  resetForm()
+}
+
+// 提交表单
+const submit = async () => {
+  if (!formRef.value) return
+
+  await formRef.value.validate(async (valid) => {
+    if (!valid) return
+    try {
+      const data = {
+        ...formData.value
+      }
+
+      console.log('提交数据:', data)
+
+      if (isEdit.value) {
+        await IotDeviceApi.updateIotDeviceSet(data)
+        ElMessage.success('编辑成功')
+      } else {
+        await IotDeviceApi.createIotDeviceSet(data)
+        ElMessage.success('新增成功')
+      }
+
+      dialogVisible.value = false
+      getList()
+    } catch (error) {
+      console.error(error)
+    }
+  })
+}
+
+onMounted(async () => {
+  getList()
+
+  deptList.value = handleTree(await DeptApi.getSimpleDeptList())
+})
+</script>
+
+<style scoped>
+.transfer-container {
+  display: flex;
+  flex-direction: column;
+}
+
+.transfer-footer {
+  margin-top: 8px;
+  text-align: right;
+}
+
+::deep(.el-transfer-panel) {
+  width: 300px;
+}
+
+::deep(.el-tree--highlight-current) {
+  height: 200px !important;
+}
+
+/* 调整穿梭框面板主体高度 */
+::deep(.el-transfer-panel__body) {
+  height: 850px !important;
+}
+
+/* 如果需要进一步调整,可以分别设置左右面板的高度 */
+::deep(.el-transfer-panel .el-transfer-panel__list) {
+  height: 850px !important; /* 调整列表区域高度 */
+}
+
+.el-transfer {
+  --el-transfer-panel-body-height: 500px;
+}
+</style>

+ 344 - 0
src/views/pms/monitor/kanban.vue

@@ -0,0 +1,344 @@
+<template>
+  <div
+    class="min-h-screen bg-gradient-to-br from-slate-950 via-blue-950 to-slate-900 text-cyan-100 p-6 relative overflow-hidden"
+  >
+    <!-- Animated background grid -->
+    <div class="absolute inset-0 opacity-20">
+      <div class="absolute inset-0 grid-pattern"></div>
+    </div>
+
+    <!-- Scanning line effect -->
+    <div class="absolute inset-0 pointer-events-none">
+      <div class="scan-line"></div>
+    </div>
+
+    <!-- Header -->
+    <div class="relative z-10 mb-6">
+      <div class="flex items-center justify-between border-b-2 border-cyan-500/30 pb-4">
+        <div class="flex items-center gap-8 text-sm">
+          <div
+            style="border: 0.5px solid #085b77"
+            v-for="(item, i) in leftNavItems"
+            :key="i"
+            class="px-4 py-2 bg-cyan-500/10 border border-[#085b77] skew-x-[-12deg] hover:bg-cyan-500/20 transition-all cursor-pointer"
+          >
+            <span class="inline-block skew-x-[12deg]">{{ item }}</span>
+          </div>
+        </div>
+        <h1 class="text-2xl font-bold text-center flex-1 text-cyan-300 tracking-wider">
+          PSA3000万70MPa制氮装置监控系统
+        </h1>
+
+        <div class="flex items-center gap-8 text-sm">
+          <div
+            v-for="(item, i) in rightNavItems"
+            :key="i"
+            style="border: 0.5px solid #085b77"
+            class="px-4 py-2 bg-cyan-500/10 border border-cyan-500/30 skew-x-[-12deg] hover:bg-cyan-500/20 transition-all cursor-pointer"
+          >
+            <span class="inline-block skew-x-[12deg]">{{ item }}</span>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <dv-border-box1 class="relative z-10">
+      <div class="relative z-10 grid grid-cols-12 gap-4 mt-5 p-6">
+        <!-- Left panels -->
+        <div class="col-span-3 space-y-4">
+          <!-- PSA核心参数 -->
+          <div class="panel">
+            <div class="flex justify-between items-center panel-title">
+              <h4>PSA核心参数</h4>
+              <dv-decoration3 style="width: 120px; height: 30px" />
+            </div>
+
+            <div class="space-y-3">
+              <!-- <DataRow label="氧气纯度" value="99.66 %" button="启停按钮" button-color="green" /> -->
+              <DataRow label="氧气压力" value="0.54 MPa" />
+              <DataRow label="氧气瞬时流量" value="3002.10 Nm³" />
+              <DataRow label="氧气累计流量" value="219.6217 万Nm³" />
+              <div class="flex items-center justify-between mt-4">
+                <span class="text-cyan-200">运行状态</span>
+                <div class="status-indicator status-active"></div>
+              </div>
+            </div>
+          </div>
+
+          <!-- 空气处理参数 -->
+          <div class="panel">
+            <div class="flex justify-between items-center panel-title">
+              <h4>空气处理参数</h4>
+              <dv-decoration3 style="width: 120px; height: 30px" />
+            </div>
+            <div class="space-y-3">
+              <!-- <DataRow label="出口温度" value="6.7 ℃" button="启停按钮" button-color="green" /> -->
+              <DataRow label="出口压力" value="0.6 MPa" />
+              <div class="flex items-center justify-between mt-4">
+                <span class="text-cyan-200">冷干机运行状态</span>
+                <div class="status-indicator status-inactive"></div>
+              </div>
+
+              <div class="flex items-center justify-between mt-4">
+                <span class="text-cyan-200">风机运行状态</span>
+                <div class="status-indicator status-active"></div>
+              </div>
+            </div>
+          </div>
+
+          <!-- 1800中压机参数 -->
+          <div class="panel">
+            <div class="flex justify-between items-center panel-title">
+              <h4>1800中压机参数</h4>
+              <dv-decoration3 style="width: 120px; height: 30px" />
+            </div>
+
+            <div class="space-y-3">
+              <!-- <DataRow label="排气压力" value="3.47 MPa" button="启停按钮" button-color="green" /> -->
+              <DataRow label="排气温度" value="107.0 ℃" />
+              <DataRow label="总用电能" value="247406.4 kWh" />
+              <div class="flex items-center justify-between mt-4">
+                <span class="text-cyan-200">运行状态</span>
+                <div class="status-indicator status-active"></div>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <div class="col-span-6 space-y-4">
+          <div class="panel relative overflow-hidden">
+            <div class="relative h-[450px] flex items-center justify-center">
+              <ModelViewer />
+              <!-- <div class="absolute inset-0 bg-gradient-to-t from-slate-900/80 to-transparent"></div> -->
+            </div>
+          </div>
+
+          <!-- 1050空压机参数 -->
+          <div class="panel">
+            <div class="mb-2">
+              <dv-decoration7>
+                <h3 class="text-cyan-300 text-lg font-bold px-1"> 1050空压机参数 </h3>
+              </dv-decoration7>
+            </div>
+            <!-- <h3 class="panel-title">1050空压机参数</h3> -->
+            <div class="grid grid-cols-5 gap-4">
+              <div v-for="(unit, i) in compressorUnits" :key="i" class="space-y-2 text-center">
+                <div class="text-cyan-300 font-bold mb-2">{{ unit.name }}</div>
+                <div class="text-sm">
+                  <div class="text-cyan-200">排气压力</div>
+                  <div class="text-white font-mono">{{ unit.pressure }}</div>
+                </div>
+                <div class="text-sm">
+                  <div class="text-cyan-200">排气温度</div>
+                  <div class="text-white font-mono">{{ unit.temp }}</div>
+                </div>
+                <div class="text-sm">
+                  <div class="text-cyan-200">总用电能</div>
+                  <div class="text-white font-mono text-xs">{{ unit.energy }}</div>
+                </div>
+                <div
+                  :class="[
+                    'w-8 h-8 mx-auto rounded-full animate-pulse',
+                    unit.status === 'active' ? 'status-active' : 'status-inactive'
+                  ]"
+                ></div>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <!-- Right panel -->
+        <div class="col-span-3">
+          <div class="panel h-full">
+            <div class="flex items-center justify-between mb-4 border-b border-cyan-500/30 pb-2">
+              <h3 class="text-cyan-300 text-lg font-bold">液驱压缩机参数</h3>
+              <dv-decoration8 :reverse="true" style="width: 110px; height: 20px" />
+            </div>
+            <div class="space-y-2 text-sm overflow-y-auto overflow-x-hidden">
+              <DataRow label="入口压力" value="3.30 MPa" :compact="true" />
+              <DataRow label="一级入口压力" value="3.32 MPa" :compact="true" />
+              <DataRow label="A级一级出口温度" value="104.5 ℃" :compact="true" />
+              <DataRow label="A级一级冷却后温度" value="16.6 ℃" :compact="true" />
+              <DataRow label="A级二级入口压力" value="8.40 MPa" :compact="true" />
+              <DataRow label="A级二级出口温度" value="134.3 ℃" :compact="true" />
+              <DataRow label="A级二级冷却后温度" value="27.6 ℃" :compact="true" />
+              <DataRow label="A级三级入口压力" value="22.57 MPa" :compact="true" />
+              <DataRow label="B级一级出口温度" value="109.6 ℃" :compact="true" />
+              <DataRow label="B级一级冷却后温度" value="16.5 ℃" :compact="true" />
+              <DataRow label="B级二级入口压力" value="8.23 MPa" :compact="true" />
+              <DataRow label="B级二级出口温度" value="130.0 ℃" :compact="true" />
+              <DataRow label="B级二级冷却后温度" value="21.5 ℃" :compact="true" />
+              <DataRow label="B级三级入口压力" value="22.46 MPa" :compact="true" />
+              <DataRow label="三级出口温度" value="83.6 ℃" :compact="true" />
+              <DataRow label="总出口温度" value="26.1 ℃" :compact="true" />
+              <DataRow label="总出口压力" value="40.82 MPa" :compact="true" />
+            </div>
+          </div>
+        </div>
+      </div>
+    </dv-border-box1>
+
+    <!-- Main content -->
+  </div>
+</template>
+
+<script setup lang="ts">
+import DataRow from './data-row.vue'
+import ModelViewer from './ModelViewer.vue'
+const leftNavItems = ['箱变', '1050空压机', 'PSA数据', '空气处理镜']
+const rightNavItems = ['主界面', '液驱及中压', '报警监控']
+
+const compressorUnits = [
+  {
+    name: '空压机1',
+    pressure: '0.68 MPa',
+    temp: '77.0 ℃',
+    energy: '124361.6 kWh',
+    status: 'active'
+  },
+  {
+    name: '空压机2',
+    pressure: '0.69 MPa',
+    temp: '78.0 ℃',
+    energy: '136750.4 kWh',
+    status: 'active'
+  },
+  {
+    name: '空压机3',
+    pressure: '0.69 MPa',
+    temp: '77.0 ℃',
+    energy: '134804.8 kWh',
+    status: 'active'
+  },
+  {
+    name: '空压机4',
+    pressure: '0.67 MPa',
+    temp: '84.0 ℃',
+    energy: '133656.0 kWh',
+    status: 'active'
+  },
+  {
+    name: '空压机5',
+    pressure: '0.00 MPa',
+    temp: '0.0 ℃',
+    energy: '12456.0 kWh',
+    status: 'inactive'
+  }
+]
+</script>
+
+<style scoped>
+/* @keyframes scan {
+  0% {
+    top: 0;
+  }
+  100% {
+    top: 100%;
+  }
+} */
+
+@keyframes gridMove {
+  0% {
+    transform: translate(0, 0);
+  }
+  100% {
+    transform: translate(50px, 50px);
+  }
+}
+
+.grid-pattern {
+  background-image: linear-gradient(rgba(6, 182, 212, 0.3) 1px, transparent 1px),
+    linear-gradient(90deg, rgba(6, 182, 212, 0.3) 1px, transparent 1px);
+  background-size: 50px 50px;
+  animation: gridMove 20s linear infinite;
+}
+
+.scan-line {
+  position: absolute;
+  width: 100%;
+  height: 2px;
+  background: linear-gradient(to right, transparent, rgb(6, 182, 212), transparent);
+  opacity: 0.3;
+  animation: scan 4s linear infinite;
+}
+
+.scan-line-fast {
+  position: absolute;
+  width: 100%;
+  height: 4px;
+  background: linear-gradient(to right, transparent, rgba(6, 182, 212, 0.6), transparent);
+  animation: scan 3s linear infinite;
+}
+
+.panel {
+  background: linear-gradient(to bottom right, rgba(15, 23, 42, 0.8), rgba(30, 58, 138, 0.5));
+  backdrop-filter: blur(4px);
+  border: 2px solid rgba(6, 182, 212, 0.4);
+  padding: 1rem;
+  border-radius: 0.5rem;
+  box-shadow: 0 0 20px rgba(6, 182, 212, 0.3);
+  transition: all 0.3s;
+}
+
+.panel:hover {
+  box-shadow: 0 0 30px rgba(6, 182, 212, 0.5);
+}
+
+.panel-title {
+  color: rgb(103, 232, 249);
+  font-size: 1.125rem;
+  margin-bottom: 1rem;
+  font-weight: bold;
+  border-bottom: 1px solid rgba(6, 182, 212, 0.3);
+  padding-bottom: 0.5rem;
+}
+
+.status-indicator {
+  width: 2rem;
+  height: 2rem;
+  border-radius: 50%;
+  animation: pulse 1.5s ease-in-out infinite;
+}
+
+.status-active {
+  background: rgb(34, 197, 94);
+  box-shadow: 0 0 15px rgba(34, 197, 94, 0.8);
+}
+
+.status-inactive {
+  background: rgb(239, 68, 68);
+  box-shadow: 0 0 15px rgba(239, 68, 68, 0.8);
+}
+
+.btn {
+  padding: 0.25rem 0.75rem;
+  border-radius: 0.25rem;
+  color: white;
+  transition: all 0.3s;
+  border: none;
+  cursor: pointer;
+  font-weight: 500;
+}
+
+.btn-green {
+  background: rgb(22, 163, 74);
+  box-shadow: 0 0 10px rgba(22, 163, 74, 0.5);
+}
+
+.btn-green:hover {
+  background: rgb(34, 197, 94);
+  box-shadow: 0 0 20px rgba(22, 163, 74, 0.8);
+  transform: translateY(-1px);
+}
+
+.btn-red {
+  background: rgb(220, 38, 38);
+  box-shadow: 0 0 10px rgba(220, 38, 38, 0.5);
+}
+
+.btn-red:hover {
+  background: rgb(239, 68, 68);
+  box-shadow: 0 0 20px rgba(220, 38, 38, 0.8);
+  transform: translateY(-1px);
+}
+</style>