Просмотр исходного кода

!757 【功能完善】IoT: 场景联动
Merge pull request !757 from puhui999/feature/iot

芋道源码 1 год назад
Родитель
Сommit
30c9f0b872
28 измененных файлов с 1562 добавлено и 413 удалено
  1. 2 1
      package.json
  2. 31 28
      pnpm-lock.yaml
  3. 11 0
      src/api/iot/device/device/index.ts
  4. 5 0
      src/api/iot/product/product/index.ts
  5. 3 3
      src/api/iot/rule/databridge/index.ts
  6. 4 1
      src/api/iot/rule/scene/scene.types.ts
  7. 3 0
      src/components/JsonEditor/index.ts
  8. 126 0
      src/components/JsonEditor/src/JsonEditor.vue
  9. 80 0
      src/components/JsonEditor/types/index.ts
  10. 2 1
      src/utils/dict.ts
  11. 1 1
      src/views/iot/device/device/components/DeviceTableSelect.vue
  12. 11 26
      src/views/iot/device/device/detail/DeviceDetailConfig.vue
  13. 1 1
      src/views/iot/product/product/components/ProductTableSelect.vue
  14. 45 13
      src/views/iot/rule/scene/RuleSceneForm.vue
  15. 0 242
      src/views/iot/rule/scene/components/DeviceListener.vue
  16. 0 80
      src/views/iot/rule/scene/components/DeviceListenerCondition.vue
  17. 81 0
      src/views/iot/rule/scene/components/ThingModelDualView.vue
  18. 138 0
      src/views/iot/rule/scene/components/ThingModelParamInput.vue
  19. 253 0
      src/views/iot/rule/scene/components/action/ActionExecutor.vue
  20. 83 0
      src/views/iot/rule/scene/components/action/AlertAction.vue
  21. 38 0
      src/views/iot/rule/scene/components/action/DataBridgeAction.vue
  22. 230 0
      src/views/iot/rule/scene/components/action/DeviceControlAction.vue
  23. 0 0
      src/views/iot/rule/scene/components/listener/ConditionSelector.vue
  24. 306 0
      src/views/iot/rule/scene/components/listener/DeviceListener.vue
  25. 87 0
      src/views/iot/rule/scene/components/listener/DeviceListenerCondition.vue
  26. 6 3
      src/views/iot/rule/scene/index.vue
  27. 14 13
      src/views/iot/thingmodel/ThingModelTSL.vue
  28. 1 0
      src/views/iot/thingmodel/index.vue

+ 2 - 1
package.json

@@ -51,6 +51,7 @@
     "fast-xml-parser": "^4.3.2",
     "highlight.js": "^11.9.0",
     "jsencrypt": "^3.3.2",
+    "jsoneditor": "^10.1.3",
     "lodash-es": "^4.17.21",
     "markdown-it": "^14.1.0",
     "markmap-common": "^0.16.0",
@@ -67,7 +68,6 @@
     "sortablejs": "^1.15.3",
     "steady-xml": "^0.1.0",
     "url": "^0.11.3",
-    "v3-jsoneditor": "^0.0.6",
     "video.js": "^7.21.5",
     "vue": "3.5.12",
     "vue-dompurify-html": "^4.1.4",
@@ -85,6 +85,7 @@
     "@iconify/json": "^2.2.187",
     "@intlify/unplugin-vue-i18n": "^2.0.0",
     "@purge-icons/generated": "^0.9.0",
+    "@types/jsoneditor": "^9.9.5",
     "@types/lodash-es": "^4.17.12",
     "@types/node": "^20.11.21",
     "@types/nprogress": "^0.2.3",

+ 31 - 28
pnpm-lock.yaml

@@ -86,6 +86,9 @@ importers:
       jsencrypt:
         specifier: ^3.3.2
         version: 3.3.2
+      jsoneditor:
+        specifier: ^10.1.3
+        version: 10.1.3
       lodash-es:
         specifier: ^4.17.21
         version: 4.17.21
@@ -134,9 +137,6 @@ importers:
       url:
         specifier: ^0.11.3
         version: 0.11.4
-      v3-jsoneditor:
-        specifier: ^0.0.6
-        version: 0.0.6
       video.js:
         specifier: ^7.21.5
         version: 7.21.6
@@ -183,6 +183,9 @@ importers:
       '@purge-icons/generated':
         specifier: ^0.9.0
         version: 0.9.0
+      '@types/jsoneditor':
+        specifier: ^9.9.5
+        version: 9.9.5
       '@types/lodash-es':
         specifier: ^4.17.12
         version: 4.17.12
@@ -1693,6 +1696,9 @@ packages:
     resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
     engines: {node: '>=10.13.0'}
 
+  '@types/ace@0.0.52':
+    resolution: {integrity: sha512-YPF9S7fzpuyrxru+sG/rrTpZkC6gpHBPF14W3x70kqVOD+ks6jkYLapk4yceh36xej7K4HYxcyz9ZDQ2lTvwgQ==}
+
   '@types/conventional-commits-parser@5.0.1':
     resolution: {integrity: sha512-7uz5EHdzz2TqoMfV7ee61Egf5y6NkcO4FB/1iCCQnbeiI1F3xzv3vK5dBCXUCLQgGYS+mUeigK1iKQzvED+QnQ==}
 
@@ -1804,6 +1810,9 @@ packages:
   '@types/json-schema@7.0.15':
     resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
 
+  '@types/jsoneditor@9.9.5':
+    resolution: {integrity: sha512-+Wex7QCirPcG90WA8/CmvDO21KUjz63/G7Yk52Yx/NhWHw5DyeET/L+wjZHAeNeNCCnMOTEtVX5gc3F4UXwXMQ==}
+
   '@types/lodash-es@4.17.12':
     resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==}
 
@@ -2336,8 +2345,8 @@ packages:
     resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==}
     hasBin: true
 
-  ace-builds@1.39.0:
-    resolution: {integrity: sha512-MqoZojv4gpc5QyTMor/dS6kmruDV9db9LVZbCiT4qYz6WsDiv4qyG5f7ZPc+wjUl6oLMqgCAsBjo1whdSVyMlQ==}
+  ace-builds@1.39.1:
+    resolution: {integrity: sha512-HcJbBzx8qY66t9gZo/sQu7pi0wO/CFLdYn1LxQO1WQTfIkMfyc7LRnBpsp/oNCSSU/LL83jXHN1fqyOTuIhUjg==}
 
   acorn-jsx@5.3.2:
     resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
@@ -4069,8 +4078,8 @@ packages:
     resolution: {integrity: sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==}
     engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
 
-  jsoneditor@9.10.5:
-    resolution: {integrity: sha512-fVZ0NMt+zm4rqTKBv2x7zPdLeaRyKo1EjJkaR1QjK4gEM1rMwICILYSW1OPxSc1qqyAoDaA/eeNrluKoxOocCA==}
+  jsoneditor@10.1.3:
+    resolution: {integrity: sha512-zvbkiduFR19vLMJN1sSvBs9baGDdQRJGmKy6+/vQzDFhx//oEd6WAkrmmTeU4NNk9MAo+ZirENuwbtJXvS9M5g==}
 
   jsonfile@6.1.0:
     resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
@@ -4079,8 +4088,8 @@ packages:
     resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
     engines: {'0': node >= 0.2.0}
 
-  jsonrepair@3.1.0:
-    resolution: {integrity: sha512-idqReg23J0PVRAADmZMc5xQM3xeOX5bTB6OTyMnzq33IXJXmn9iJuWIEvGmrN80rQf4d7uLTMEDwpzujNcI0Rg==}
+  jsonrepair@3.12.0:
+    resolution: {integrity: sha512-SWfjz8SuQ0wZjwsxtSJ3Zy8vvLg6aO/kxcp9TWNPGwJKgTZVfhNEQBMk/vPOpYCDFWRxD6QWuI6IHR1t615f0w==}
     hasBin: true
 
   katex@0.16.11:
@@ -4402,9 +4411,6 @@ packages:
   mlly@1.7.3:
     resolution: {integrity: sha512-xUsx5n/mN0uQf4V548PKQ+YShA4/IW0KI1dZhrNrPCLG+xizETbHTkOa1f8/xut9JRPp8kQuMnz0oqwkTiLo/A==}
 
-  mobius1-selectr@2.4.13:
-    resolution: {integrity: sha512-Mk9qDrvU44UUL0EBhbAA1phfQZ7aMZPjwtL7wkpiBzGh8dETGqfsh50mWoX9EkjDlkONlErWXArHCKfoxVg0Bw==}
-
   moddle-xml@10.1.0:
     resolution: {integrity: sha512-erWckwLt+dYskewKXJso9u+aAZ5172lOiYxSOqKCPTy7L/xmqH1PoeoA7eVC7oJTt3PqF5TkZzUmbjGH6soQBg==}
 
@@ -5561,9 +5567,6 @@ packages:
     resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
     hasBin: true
 
-  v3-jsoneditor@0.0.6:
-    resolution: {integrity: sha512-9G0sXWXUn67SBkn46ycWfwPwjuJu/lcsQaNzMtXAR2/95hMV21WfcRNsqJ+vVVrSHQehohB/9fVLwYEXz0u/KA==}
-
   vanilla-picker@2.12.3:
     resolution: {integrity: sha512-qVkT1E7yMbUsB2mmJNFmaXMWE2hF8ffqzMMwe9zdAikd8u2VfnsVY2HQcOUi2F38bgbxzlJBEdS1UUhOXdF9GQ==}
 
@@ -7334,6 +7337,8 @@ snapshots:
 
   '@trysound/sax@0.2.0': {}
 
+  '@types/ace@0.0.52': {}
+
   '@types/conventional-commits-parser@5.0.1':
     dependencies:
       '@types/node': 20.17.9
@@ -7468,6 +7473,11 @@ snapshots:
 
   '@types/json-schema@7.0.15': {}
 
+  '@types/jsoneditor@9.9.5':
+    dependencies:
+      '@types/ace': 0.0.52
+      ajv: 6.12.6
+
   '@types/lodash-es@4.17.12':
     dependencies:
       '@types/lodash': 4.17.13
@@ -7830,7 +7840,7 @@ snapshots:
   '@unocss/rule-utils@0.58.9':
     dependencies:
       '@unocss/core': 0.58.9
-      magic-string: 0.30.14
+      magic-string: 0.30.17
 
   '@unocss/rule-utils@66.1.0-beta.5':
     dependencies:
@@ -8272,7 +8282,7 @@ snapshots:
       jsonparse: 1.3.1
       through: 2.3.8
 
-  ace-builds@1.39.0: {}
+  ace-builds@1.39.1: {}
 
   acorn-jsx@5.3.2(acorn@8.14.0):
     dependencies:
@@ -10179,15 +10189,14 @@ snapshots:
       espree: 9.6.1
       semver: 7.6.3
 
-  jsoneditor@9.10.5:
+  jsoneditor@10.1.3:
     dependencies:
-      ace-builds: 1.39.0
+      ace-builds: 1.39.1
       ajv: 6.12.6
       javascript-natural-sort: 0.7.1
       jmespath: 0.16.0
       json-source-map: 0.6.1
-      jsonrepair: 3.1.0
-      mobius1-selectr: 2.4.13
+      jsonrepair: 3.12.0
       picomodal: 3.0.0
       vanilla-picker: 2.12.3
 
@@ -10199,7 +10208,7 @@ snapshots:
 
   jsonparse@1.3.1: {}
 
-  jsonrepair@3.1.0: {}
+  jsonrepair@3.12.0: {}
 
   katex@0.16.11:
     dependencies:
@@ -10550,8 +10559,6 @@ snapshots:
       pkg-types: 1.2.1
       ufo: 1.5.4
 
-  mobius1-selectr@2.4.13: {}
-
   moddle-xml@10.1.0:
     dependencies:
       min-dash: 4.2.2
@@ -11811,10 +11818,6 @@ snapshots:
 
   uuid@10.0.0: {}
 
-  v3-jsoneditor@0.0.6:
-    dependencies:
-      jsoneditor: 9.10.5
-
   vanilla-picker@2.12.3:
     dependencies:
       '@sphinxxxx/color-conversion': 2.2.2

+ 11 - 0
src/api/iot/device/device/index.ts

@@ -165,5 +165,16 @@ export const DeviceApi = {
   // 获取设备MQTT连接参数
   getMqttConnectionParams: async (deviceId: number) => {
     return await request.get({ url: `/iot/device/mqtt-connection-params`, params: { deviceId } })
+  },
+
+  // 根据ProductKey和DeviceNames获取设备列表
+  getDevicesByProductKeyAndNames: async (productKey: string, deviceNames: string[]) => {
+    return await request.get({
+      url: `/iot/device/list-by-product-key-and-names`,
+      params: {
+        productKey,
+        deviceNames: deviceNames.join(',')
+      }
+    })
   }
 }

+ 5 - 0
src/api/iot/product/product/index.ts

@@ -78,5 +78,10 @@ export const ProductApi = {
   // 查询产品(精简)列表
   getSimpleProductList() {
     return request.get({ url: '/iot/product/simple-list' })
+  },
+  
+  // 根据ProductKey获取产品信息
+  getProductByKey: async (productKey: string) => {
+    return await request.get({ url: `/iot/product/get-by-key`, params: { productKey } })
   }
 }

+ 3 - 3
src/api/iot/rule/databridge/index.ts

@@ -124,8 +124,8 @@ export const DataBridgeApi = {
     return await request.delete({ url: `/iot/data-bridge/delete?id=` + id })
   },
 
-  // 导出数据桥梁 Excel
-  exportDataBridge: async (params) => {
-    return await request.download({ url: `/iot/data-bridge/export-excel`, params })
+  // 查询数据桥梁(精简)列表
+  getSimpleDataBridgeList() {
+    return request.get({ url: '/iot/data-bridge/simple-list' })
   }
 }

+ 4 - 1
src/api/iot/rule/scene/scene.types.ts

@@ -58,7 +58,8 @@ interface TenantBaseDO {
 
 // 触发条件参数
 interface TriggerConditionParameter {
-  identifier: string // 标识符(属性、事件、服务)
+  identifier0: string // 标识符(事件、服务)
+  identifier: string // 标识符(属性)
   operator: string // 操作符
   value: string // 比较值
 }
@@ -72,6 +73,7 @@ interface TriggerCondition {
 
 // 触发器配置
 interface TriggerConfig {
+  key: any // 解决组件索引重用
   type: number // 触发类型
   productKey: string // 产品标识
   deviceNames: string[] // 设备名称数组
@@ -98,6 +100,7 @@ interface ActionAlert {
 
 // 执行器配置
 interface ActionConfig {
+  key: any // 解决组件索引重用
   type: number // 执行类型
   deviceControl?: ActionDeviceControl // 设备控制
   alert?: ActionAlert // 告警执行

+ 3 - 0
src/components/JsonEditor/index.ts

@@ -0,0 +1,3 @@
+import JsonEditor from './src/JsonEditor.vue'
+
+export { JsonEditor }

+ 126 - 0
src/components/JsonEditor/src/JsonEditor.vue

@@ -0,0 +1,126 @@
+<template>
+  <div ref="jsonEditorContainer" class="json-editor" :style="{ height }"></div>
+</template>
+<script setup lang="ts">
+import { useVModel } from '@vueuse/core'
+import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import JSONEditor, { JSONEditorMode, JSONEditorOptions } from 'jsoneditor'
+import 'jsoneditor/dist/jsoneditor.min.css'
+import { JsonEditorEmits, JsonEditorExpose, JsonEditorProps } from '../types'
+
+/** 基于 https://github.com/josdejong/jsoneditor 二次封装组件,提供 JSON 编辑器功能。 */
+defineOptions({ name: 'JsonEditor' })
+
+const props = withDefaults(defineProps<JsonEditorProps>(), {
+  mode: 'view' as JSONEditorMode,
+  height: '400px',
+  showModeSelection: false,
+  showNavigationBar: false,
+  showStatusBar: false,
+  showMainMenuBar: true
+})
+
+const emits = defineEmits<JsonEditorEmits>()
+const jsonObj = useVModel(props, 'modelValue', emits) as Ref<any>
+const jsonEditorContainer = ref<HTMLElement | null>(null)
+let jsonEditor: JSONEditor | null = null
+
+// 设置默认值
+const height = props.height
+
+// 初始化JSONEditor
+const initJsonEditor = () => {
+  if (!jsonEditorContainer.value) return
+
+  // 合并默认配置和用户自定义配置
+  const options: JSONEditorOptions = {
+    mode: props.mode,
+    modes: props.showModeSelection
+      ? (['tree', 'code', 'form', 'text', 'view', 'preview'] as JSONEditorMode[])
+      : undefined,
+    navigationBar: props.showNavigationBar,
+    statusBar: props.showStatusBar,
+    mainMenuBar: props.showMainMenuBar,
+    onChange: () => {
+      jsonObj.value = jsonEditor?.get()
+      emits('change', jsonEditor?.get())
+    },
+    onValidationError: (errors: any) => {
+      emits('error', errors)
+    },
+    ...props.options
+  } as JSONEditorOptions
+
+  // 创建JSONEditor实例
+  jsonEditor = new JSONEditor(jsonEditorContainer.value, options)
+
+  // 设置初始值
+  if (jsonObj.value) {
+    jsonEditor.set(jsonObj.value)
+  }
+
+  if (props.mode === 'view') {
+    jsonEditor?.expandAll() // 默认展开全部
+  }
+}
+
+// 监听数据变化
+watch(
+  () => jsonObj.value,
+  (newValue) => {
+    if (!jsonEditor) return
+
+    try {
+      // 防止无限循环更新
+      const currentJson = jsonEditor.get()
+      if (JSON.stringify(currentJson) !== JSON.stringify(newValue)) {
+        jsonEditor.update(newValue)
+      }
+    } catch (error) {
+      console.error('JSON更新失败:', error)
+    }
+  },
+  { deep: true }
+)
+
+// 监听模式变化
+watch(
+  () => props.mode,
+  (newMode) => {
+    if (!jsonEditor) return
+    try {
+      jsonEditor.setMode(newMode)
+    } catch (error) {
+      console.error('切换模式失败:', error)
+    }
+  }
+)
+
+// 生命周期钩子
+onMounted(() => {
+  initJsonEditor()
+})
+
+onBeforeUnmount(() => {
+  if (jsonEditor) {
+    jsonEditor.destroy()
+    jsonEditor = null
+  }
+})
+
+// 暴露方法
+defineExpose<JsonEditorExpose>({
+  // 获取编辑器实例,以便可以调用更多JSONEditor的原生方法
+  getEditor: () => jsonEditor
+})
+</script>
+
+<style lang="scss" scoped>
+/* 隐藏 Ace 编辑器的 powered by ace 标记 */
+:deep(.jsoneditor-menu) {
+  /* 隐藏 powered by ace 标记 */
+  .jsoneditor-poweredBy {
+    display: none !important;
+  }
+}
+</style>

+ 80 - 0
src/components/JsonEditor/types/index.ts

@@ -0,0 +1,80 @@
+import { JSONEditorOptions, JSONEditorMode } from 'jsoneditor'
+
+export interface JsonEditorProps {
+  /**
+   * JSON数据,支持双向绑定
+   */
+  modelValue: any
+  
+  /**
+   * 编辑器模式
+   * @default 'tree'
+   */
+  mode?: JSONEditorMode
+  
+  /**
+   * 编辑器高度
+   * @default '400px'
+   */
+  height?: string
+  
+  /**
+   * 是否显示模式选择下拉菜单
+   * @default false
+   */
+  showModeSelection?: boolean
+  
+  /**
+   * 是否显示导航栏
+   * @default false
+   */
+  showNavigationBar?: boolean
+  
+  /**
+   * 是否显示状态栏
+   * @default true
+   */
+  showStatusBar?: boolean
+  
+  /**
+   * 是否显示主菜单栏
+   * @default true
+   */
+  showMainMenuBar?: boolean
+  
+  /**
+   * JSONEditor配置选项
+   * @see https://github.com/josdejong/jsoneditor/blob/develop/docs/api.md
+   */
+  options?: Partial<JSONEditorOptions>
+}
+
+/**
+ * JsonEditor组件触发的事件
+ */
+export interface JsonEditorEmits {
+  /**
+   * 数据更新时触发
+   */
+  (e: 'update:modelValue', value: any): void
+  
+  /**
+   * 数据变化时触发
+   */
+  (e: 'change', value: any): void
+  
+  /**
+   * 验证错误时触发
+   */
+  (e: 'error', errors: any): void
+}
+
+/**
+ * JsonEditor组件暴露的方法
+ */
+export interface JsonEditorExpose {
+  /**
+   * 获取原始的JSONEditor实例
+   */
+  getEditor: () => any
+} 

+ 2 - 1
src/utils/dict.ts

@@ -247,5 +247,6 @@ export enum DICT_TYPE {
   IOT_DATA_BRIDGE_DIRECTION_ENUM = 'iot_data_bridge_direction_enum', // 桥梁方向
   IOT_DATA_BRIDGE_TYPE_ENUM = 'iot_data_bridge_type_enum', // 桥梁类型
   IOT_DEVICE_MESSAGE_TYPE_ENUM = 'iot_device_message_type_enum', // IoT 设备消息类型枚举
-  IOT_RULE_SCENE_TRIGGER_TYPE_ENUM = 'iot_rule_scene_trigger_type_enum' // IoT 场景流转的触发类型枚举
+  IOT_RULE_SCENE_TRIGGER_TYPE_ENUM = 'iot_rule_scene_trigger_type_enum', // IoT 场景流转的触发类型枚举
+  IOT_RULE_SCENE_ACTION_TYPE_ENUM = 'iot_rule_scene_action_type_enum' // IoT 规则场景的触发类型枚举
 }

+ 1 - 1
src/views/iot/device/device/components/DeviceTableSelect.vue

@@ -117,7 +117,7 @@
           <template #default="scope">
             <el-radio
               v-model="selectedId"
-              :label="scope.row.id"
+              :value="scope.row.id"
               @change="() => handleRadioChange(scope.row)"
             >
               &nbsp;

+ 11 - 26
src/views/iot/device/device/detail/DeviceDetailConfig.vue

@@ -8,24 +8,10 @@
       class="my-4"
       description="如需编辑文件,请点击下方编辑按钮"
     />
-
-    <!-- JSON 编辑器:读模式 -->
-    <Vue3Jsoneditor
-      v-if="isEditing"
-      v-model="config"
-      :options="editorOptions"
-      height="500px"
-      currentMode="code"
-      @error="onError"
-    />
-    <!-- JSON 编辑器:写模式 -->
-    <Vue3Jsoneditor
-      v-else
+    <JsonEditor
       v-model="config"
-      :options="editorOptions"
-      height="500px"
-      currentMode="view"
-      v-loading.fullscreen.lock="loading"
+      :mode="isEditing ? 'code' : 'view'"
+      height="600px"
       @error="onError"
     />
     <div class="mt-5 text-center">
@@ -40,9 +26,11 @@
 </template>
 
 <script lang="ts" setup>
-import Vue3Jsoneditor from 'v3-jsoneditor/src/Vue3Jsoneditor.vue'
 import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
 import { jsonParse } from '@/utils'
+import { isEmpty } from '@/utils/is'
+
+defineOptions({ name: 'DeviceDetailConfig' })
 
 const props = defineProps<{
   device: DeviceVO
@@ -63,12 +51,6 @@ watchEffect(() => {
 })
 
 const isEditing = ref(false) // 编辑状态
-const editorOptions = computed(() => ({
-  mainMenuBar: false,
-  navigationBar: false,
-  statusBar: false
-})) // JSON 编辑器的选项
-
 /** 启用编辑模式的函数 */
 const enableEdit = () => {
   isEditing.value = true
@@ -112,8 +94,11 @@ const updateDeviceConfig = async () => {
 }
 
 /** 处理 JSON 编辑器错误的函数 */
-const onError = (e: any) => {
-  console.log('onError', e)
+const onError = (errors: any) => {
+  if (isEmpty(errors)) {
+    hasJsonError.value = false
+    return
+  }
   hasJsonError.value = true
 }
 </script>

+ 1 - 1
src/views/iot/product/product/components/ProductTableSelect.vue

@@ -57,7 +57,7 @@
           <template #default="scope">
             <el-radio
               v-model="selectedId"
-              :label="scope.row.id"
+              :value="scope.row.id"
               @change="() => handleRadioChange(scope.row)"
             >
               &nbsp;

+ 45 - 13
src/views/iot/rule/scene/RuleSceneForm.vue

@@ -19,7 +19,7 @@
               <el-radio
                 v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
                 :key="dict.value"
-                :label="dict.value"
+                :value="dict.value"
               >
                 {{ dict.label }}
               </el-radio>
@@ -35,7 +35,7 @@
           <el-divider content-position="left">触发器配置</el-divider>
           <device-listener
             v-for="(trigger, index) in formData.triggers"
-            :key="index"
+            :key="trigger.key"
             :model-value="trigger"
             @update:model-value="(val) => (formData.triggers[index] = val)"
             class="mb-10px"
@@ -49,10 +49,21 @@
           </el-button>
         </el-col>
         <el-col :span="24">
-          <el-divider content-position="left">执行动作配置</el-divider>
-          <el-form-item label="执行器数组" prop="actionConfigs">
-            <!--            <el-input v-model="formData.actions" placeholder="请输入执行器数组" />-->
-          </el-form-item>
+          <el-divider content-position="left">执行器配置</el-divider>
+          <action-executor
+            v-for="(action, index) in formData.actions"
+            :key="action.key"
+            :model-value="action"
+            @update:model-value="(val) => (formData.actions[index] = val)"
+            class="mb-10px"
+          >
+            <el-button type="danger" round size="small" @click="removeAction(index)">
+              <Icon icon="ep:delete" />
+            </el-button>
+          </action-executor>
+          <el-button class="ml-10px!" type="primary" size="small" @click="addAction">
+            添加执行器
+          </el-button>
         </el-col>
       </el-row>
     </el-form>
@@ -65,15 +76,19 @@
 <script setup lang="ts">
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import { RuleSceneApi } from '@/api/iot/rule/scene'
-import DeviceListener from './components/DeviceListener.vue'
+import DeviceListener from './components/listener/DeviceListener.vue'
 import { CommonStatusEnum } from '@/utils/constants'
 import {
+  ActionConfig,
   IotDeviceMessageIdentifierEnum,
   IotDeviceMessageTypeEnum,
   IotRuleScene,
+  IotRuleSceneActionTypeEnum,
   IotRuleSceneTriggerTypeEnum,
   TriggerConfig
 } from '@/api/iot/rule/scene/scene.types'
+import ActionExecutor from './components/action/ActionExecutor.vue'
+import { generateUUID } from '@/utils'
 
 /** IoT 场景联动表单 */
 defineOptions({ name: 'IotRuleSceneForm' })
@@ -87,7 +102,8 @@ const formLoading = ref(false) // 表单的加载中:1)修改时的数据加
 const formType = ref('') // 表单的类型:create - 新增;update - 修改
 const formData = ref<IotRuleScene>({
   status: CommonStatusEnum.ENABLE,
-  triggers: [] as TriggerConfig[]
+  triggers: [] as TriggerConfig[],
+  actions: [] as ActionConfig[]
 } as IotRuleScene)
 const formRules = reactive({
   name: [{ required: true, message: '场景名称不能为空', trigger: 'blur' }],
@@ -100,6 +116,7 @@ const formRef = ref() // 表单 Ref
 /** 添加触发器 */
 const addTrigger = () => {
   formData.value.triggers.push({
+    key: generateUUID(), // 解决组件索引重用
     type: IotRuleSceneTriggerTypeEnum.DEVICE,
     productKey: '',
     deviceNames: [],
@@ -114,9 +131,19 @@ const addTrigger = () => {
 }
 /** 移除触发器 */
 const removeTrigger = (index: number) => {
-  const newTriggers = [...formData.value.triggers]
-  newTriggers.splice(index, 1)
-  formData.value.triggers = newTriggers
+  formData.value.triggers.splice(index, 1)
+}
+
+/** 添加执行器 */
+const addAction = () => {
+  formData.value.actions.push({
+    key: generateUUID(), // 解决组件索引重用
+    type: IotRuleSceneActionTypeEnum.DEVICE_CONTROL
+  } as ActionConfig)
+}
+/** 移除执行器 */
+const removeAction = (index: number) => {
+  formData.value.actions.splice(index, 1)
 }
 
 /** 打开弹窗 */
@@ -129,7 +156,11 @@ const open = async (type: string, id?: number) => {
   if (id) {
     formLoading.value = true
     try {
-      formData.value = await RuleSceneApi.getRuleScene(id)
+      const data = (await RuleSceneApi.getRuleScene(id)) as IotRuleScene
+      // 解决组件索引重用
+      data.triggers?.forEach((item) => (item.key = generateUUID()))
+      data.actions?.forEach((item) => (item.key = generateUUID()))
+      formData.value = data
     } finally {
       formLoading.value = false
     }
@@ -165,7 +196,8 @@ const submitForm = async () => {
 const resetForm = () => {
   formData.value = {
     status: CommonStatusEnum.ENABLE,
-    triggers: [] as TriggerConfig[]
+    triggers: [] as TriggerConfig[],
+    actions: [] as ActionConfig[]
   } as IotRuleScene
   formRef.value?.resetFields()
 }

+ 0 - 242
src/views/iot/rule/scene/components/DeviceListener.vue

@@ -1,242 +0,0 @@
-<template>
-  <div class="m-10px">
-    <div class="relative bg-[#eff3f7] h-50px flex items-center px-10px">
-      <div class="flex items-center mr-60px">
-        <span class="mr-10px">触发条件</span>
-        <el-select
-          v-model="triggerConfig.type"
-          class="!w-240px"
-          clearable
-          placeholder="请选择触发条件"
-        >
-          <el-option
-            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_RULE_SCENE_TRIGGER_TYPE_ENUM)"
-            :key="dict.value"
-            :label="dict.label"
-            :value="dict.value"
-          />
-        </el-select>
-      </div>
-      <div
-        v-if="triggerConfig.type === IotRuleSceneTriggerTypeEnum.DEVICE"
-        class="flex items-center mr-60px"
-      >
-        <span class="mr-10px">产品</span>
-        <el-button type="primary" @click="productTableSelectRef?.open()" size="small" plain>
-          {{ product ? product.name : '选择产品' }}
-        </el-button>
-      </div>
-      <div
-        v-if="triggerConfig.type === IotRuleSceneTriggerTypeEnum.DEVICE"
-        class="flex items-center mr-60px"
-      >
-        <span class="mr-10px">设备</span>
-        <el-button type="primary" @click="openDeviceSelect" size="small" plain>
-          {{ isEmpty(deviceList) ? '选择设备' : triggerConfig.deviceNames.join(',') }}
-        </el-button>
-      </div>
-      <!-- 删除触发器 -->
-      <div class="absolute top-auto right-16px bottom-auto">
-        <el-tooltip content="删除触发器" placement="top">
-          <slot></slot>
-        </el-tooltip>
-      </div>
-    </div>
-    <!-- 设备触发器条件 -->
-    <template v-if="triggerConfig.type === IotRuleSceneTriggerTypeEnum.DEVICE">
-      <div
-        class="bg-[#dbe5f6] flex p-10px"
-        v-for="(condition, index) in triggerConfig.conditions"
-        :key="index"
-      >
-        <div class="flex flex-col items-center justify-center mr-10px h-a">
-          <el-select
-            v-model="condition.type"
-            @change="condition.parameters = []"
-            class="!w-160px"
-            clearable
-            placeholder=""
-          >
-            <el-option label="属性" :value="IotDeviceMessageTypeEnum.PROPERTY" />
-            <el-option label="服务" :value="IotDeviceMessageTypeEnum.SERVICE" />
-            <el-option label="事件" :value="IotDeviceMessageTypeEnum.EVENT" />
-          </el-select>
-        </div>
-        <div class="">
-          <DeviceListenerCondition
-            v-for="(parameter, index2) in condition.parameters"
-            :key="index2"
-            :model-value="parameter"
-            :thingModels="thingModels(condition)"
-            @update:model-value="(val) => (condition.parameters[index2] = val)"
-            class="mb-10px last:mb-0"
-          >
-            <el-tooltip content="删除参数" placement="top">
-              <el-button
-                type="danger"
-                circle
-                size="small"
-                @click="removeConditionParameter(condition.parameters, index2)"
-              >
-                <Icon icon="ep:delete" />
-              </el-button>
-            </el-tooltip>
-          </DeviceListenerCondition>
-        </div>
-        <!-- 添加参数 -->
-        <div class="flex flex-1 flex-col items-center justify-center w-60px h-a">
-          <el-tooltip content="添加参数" placement="top">
-            <el-button
-              type="primary"
-              circle
-              size="small"
-              @click="addConditionParameter(condition.parameters)"
-            >
-              <Icon icon="ep:plus" />
-            </el-button>
-          </el-tooltip>
-        </div>
-        <!-- 删除条件 -->
-        <div
-          class="device-listener-condition flex flex-1 flex-col items-center justify-center w-a h-a"
-        >
-          <el-tooltip content="删除条件" placement="top">
-            <el-button type="danger" size="small" @click="removeCondition(index)">
-              <Icon icon="ep:delete" />
-            </el-button>
-          </el-tooltip>
-        </div>
-      </div>
-    </template>
-    <!-- 定时触发 -->
-    <div
-      v-if="triggerConfig.type === IotRuleSceneTriggerTypeEnum.TIMER"
-      class="bg-[#dbe5f6] flex items-center justify-between p-10px"
-    >
-      <span class="w-120px">CRON 表达式</span>
-      <crontab v-model="triggerConfig.cronExpression" />
-    </div>
-    <!-- 设备触发才可以设置多个触发条件 -->
-    <el-text
-      v-if="triggerConfig.type === IotRuleSceneTriggerTypeEnum.DEVICE"
-      class="ml-10px!"
-      type="primary"
-      @click="addCondition"
-    >
-      添加触发条件
-    </el-text>
-  </div>
-
-  <!-- 产品、设备的选择 -->
-  <ProductTableSelect ref="productTableSelectRef" @success="handleProductSelect" />
-  <DeviceTableSelect
-    ref="deviceTableSelectRef"
-    multiple
-    :product-id="product?.id"
-    @success="handleDeviceSelect"
-  />
-</template>
-
-<script setup lang="ts">
-import { useVModel } from '@vueuse/core'
-import { isEmpty } from '@/utils/is'
-import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
-import DeviceListenerCondition from './DeviceListenerCondition.vue'
-import ProductTableSelect from '@/views/iot/product/product/components/ProductTableSelect.vue'
-import DeviceTableSelect from '@/views/iot/device/device/components/DeviceTableSelect.vue'
-import { ProductVO } from '@/api/iot/product/product'
-import { DeviceVO } from '@/api/iot/device/device'
-import { ThingModelApi } from '@/api/iot/thingmodel'
-import {
-  IotDeviceMessageIdentifierEnum,
-  IotDeviceMessageTypeEnum,
-  IotRuleSceneTriggerTypeEnum,
-  TriggerCondition,
-  TriggerConditionParameter,
-  TriggerConfig
-} from '@/api/iot/rule/scene/scene.types'
-
-/** 场景联动之监听器组件 */
-defineOptions({ name: 'DeviceListener' })
-
-const props = defineProps<{ modelValue: any }>()
-const emits = defineEmits(['update:modelValue'])
-const triggerConfig = useVModel(props, 'modelValue', emits) as Ref<TriggerConfig>
-
-const message = useMessage()
-
-/** 添加触发条件 */
-const addCondition = () => {
-  triggerConfig.value.conditions?.push({
-    type: IotDeviceMessageTypeEnum.PROPERTY,
-    identifier: IotDeviceMessageIdentifierEnum.PROPERTY_SET,
-    parameters: []
-  })
-}
-/** 移除触发条件 */
-const removeCondition = (index: number) => {
-  triggerConfig.value.conditions?.splice(index, 1)
-}
-
-/** 添加参数 */
-const addConditionParameter = (conditionParameters: TriggerConditionParameter[]) => {
-  if (!product.value) {
-    message.warning('请先选择一个产品')
-    return
-  }
-  conditionParameters.push({} as TriggerConditionParameter)
-}
-/** 移除参数 */
-const removeConditionParameter = (
-  conditionParameters: TriggerConditionParameter[],
-  index: number
-) => {
-  conditionParameters.splice(index, 1)
-}
-
-const productTableSelectRef = ref<InstanceType<typeof ProductTableSelect>>()
-const deviceTableSelectRef = ref<InstanceType<typeof DeviceTableSelect>>()
-const product = ref<ProductVO>()
-const deviceList = ref<DeviceVO[]>([])
-/** 处理产品选择 */
-const handleProductSelect = (val: ProductVO) => {
-  product.value = val
-  triggerConfig.value.productKey = val.productKey
-  deviceList.value = []
-  getThingModelTSL()
-}
-/** 处理设备选择 */
-const handleDeviceSelect = (val: DeviceVO[]) => {
-  deviceList.value = val
-  triggerConfig.value.deviceNames = val.map((item) => item.deviceName)
-}
-/** 打开设备选择器 */
-const openDeviceSelect = () => {
-  if (!product.value) {
-    message.warning('请先选择一个产品')
-    return
-  }
-  deviceTableSelectRef.value?.open()
-}
-
-/** 获取产品物模型 */
-const thingModelTSL = ref<any>()
-const thingModels = computed(() => (condition: TriggerCondition) => {
-  switch (condition.type) {
-    case IotDeviceMessageTypeEnum.PROPERTY:
-      return thingModelTSL.value.properties
-    // TODO puhui999: 服务和事件后续考虑
-    case IotDeviceMessageTypeEnum.SERVICE:
-      return thingModelTSL.value.services
-    case IotDeviceMessageTypeEnum.EVENT:
-      return thingModelTSL.value.events
-  }
-  return []
-})
-const getThingModelTSL = async () => {
-  if (!product.value) {
-    return
-  }
-  thingModelTSL.value = await ThingModelApi.getThingModelTSLByProductId(product.value.id)
-}
-</script>

+ 0 - 80
src/views/iot/rule/scene/components/DeviceListenerCondition.vue

@@ -1,80 +0,0 @@
-<template>
-  <div class="device-listener-condition">
-    <el-select
-      v-model="conditionParameter.identifier"
-      class="!w-240px mr-10px"
-      clearable
-      placeholder="请选择物模型"
-    >
-      <el-option
-        v-for="thingModel in thingModels"
-        :key="thingModel.identifier"
-        :label="thingModel.name"
-        :value="thingModel.identifier"
-      />
-    </el-select>
-    <ConditionSelector
-      v-model="conditionParameter.operator"
-      :data-type="getDataType"
-      class="!w-180px mr-10px"
-    />
-    <!-- TODO puhui999: 输入值范围校验? -->
-    <el-input
-      v-if="
-        conditionParameter.operator !==
-        IotRuleSceneTriggerConditionParameterOperatorEnum.NOT_NULL.value
-      "
-      v-model="conditionParameter.value"
-      class="!w-240px mr-10px"
-      placeholder="请输入值"
-    >
-      <template v-if="getUnitName" #append> {{ getUnitName }} </template>
-    </el-input>
-    <!-- 按钮插槽 -->
-    <slot></slot>
-  </div>
-</template>
-
-<script setup lang="ts">
-import ConditionSelector from './ConditionSelector.vue'
-import {
-  IotRuleSceneTriggerConditionParameterOperatorEnum,
-  TriggerConditionParameter
-} from '@/api/iot/rule/scene/scene.types'
-import { useVModel } from '@vueuse/core'
-
-defineOptions({ name: 'DeviceListenerCondition' })
-const props = defineProps<{ modelValue: any; thingModels: any }>()
-const emits = defineEmits(['update:modelValue'])
-const conditionParameter = useVModel(props, 'modelValue', emits) as Ref<TriggerConditionParameter>
-
-/** 获得物模型属性类型 */
-const getDataType = computed(() => {
-  const model = props.thingModels?.find(
-    (item: any) => item.identifier === conditionParameter.value.identifier
-  )
-  // 属性
-  if (model?.dataSpecs) {
-    return model.dataSpecs.dataType
-  }
-  return ''
-})
-/** 获得属性单位 */
-const getUnitName = computed(() => {
-  const model = props.thingModels?.find(
-    (item: any) => item.identifier === conditionParameter.value.identifier
-  )
-  // 属性
-  if (model?.dataSpecs) {
-    return model.dataSpecs.unitName
-  }
-  // TODO puhui999: 先不考虑服务和事件的情况
-  // 服务和事件
-  // if (model?.outputParams) {
-  //   return model.dataSpecs.unitName
-  // }
-  return ''
-})
-</script>
-
-<style scoped lang="scss"></style>

+ 81 - 0
src/views/iot/rule/scene/components/ThingModelDualView.vue

@@ -0,0 +1,81 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle" :appendToBody="true" v-loading="loading">
+    <div class="flex h-600px">
+      <!-- 左侧物模型属性(view模式) -->
+      <div class="w-1/2 border-r border-gray-200 pr-2 overflow-auto">
+        <JsonEditor :model-value="thingModel" mode="view" height="600px" />
+      </div>
+
+      <!-- 右侧JSON编辑器(code模式) -->
+      <div class="w-1/2 pl-2 overflow-auto">
+        <JsonEditor v-model="editableModelTSL" mode="code" height="600px" @error="handleError" />
+      </div>
+    </div>
+
+    <template #footer>
+      <el-button @click="dialogVisible = false">取消</el-button>
+      <el-button type="primary" @click="handleSave" :disabled="hasJsonError">保存</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import { isEmpty } from '@/utils/is'
+
+defineOptions({ name: 'ThingModelDualView' })
+
+const props = defineProps<{
+  modelValue: any // 物模型的值
+  thingModel: any[] // 物模型
+}>()
+const emits = defineEmits(['update:modelValue', 'change'])
+
+const message = useMessage()
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('物模型编辑器') // 弹窗的标题
+const editableModelTSL = ref([
+  {
+    identifier: '对应左侧 identifier 属性值',
+    value: '如果 identifier 是 int 类型则输入数字,具体查看产品物模型定义'
+  }
+]) // 物模型数据
+const hasJsonError = ref(false) // 是否有 JSON 格式错误
+const loading = ref(false) // 加载状态
+
+/** 打开弹窗 */
+const open = () => {
+  try {
+    // 数据回显
+    if (props.modelValue) {
+      editableModelTSL.value = JSON.parse(props.modelValue)
+    }
+  } catch (e) {
+    message.error('物模型编辑器参数')
+    console.error(e)
+  } finally {
+    dialogVisible.value = true
+    // 重置状态
+    hasJsonError.value = false
+  }
+}
+defineExpose({ open }) // 暴露方法供父组件调用
+
+/** 保存修改 */
+const handleSave = async () => {
+  try {
+    await message.confirm('确定要保存物模型参数吗?')
+    emits('update:modelValue', JSON.stringify(editableModelTSL.value))
+    message.success('保存成功')
+    dialogVisible.value = false
+  } catch {}
+}
+
+/** 处理 JSON 编辑器错误的函数 */
+const handleError = (errors: any) => {
+  if (isEmpty(errors)) {
+    hasJsonError.value = false
+    return
+  }
+  hasJsonError.value = true
+}
+</script>

+ 138 - 0
src/views/iot/rule/scene/components/ThingModelParamInput.vue

@@ -0,0 +1,138 @@
+<template>
+  <div class="flex items-center">
+    <!-- 数值类型输入框 -->
+    <template v-if="isNumeric">
+      <el-input
+        v-model="value"
+        class="w-1/1!"
+        :placeholder="`请输入${dataSpecs.unitName ? dataSpecs.unitName : '数值'}`"
+      >
+        <template #append> {{ dataSpecs.unit }} </template>
+      </el-input>
+    </template>
+
+    <!-- 布尔类型使用开关 -->
+    <template v-else-if="isBool">
+      <el-switch
+        v-model="value"
+        size="large"
+        :active-text="dataSpecsList[1].name"
+        :active-value="dataSpecsList[1].value"
+        :inactive-text="dataSpecsList[0].name"
+        :inactive-value="dataSpecsList[0].value"
+      />
+    </template>
+
+    <!-- 枚举类型使用下拉选择 -->
+    <template v-else-if="isEnum">
+      <el-select class="w-1/1!" v-model="value">
+        <el-option
+          v-for="(item, index) in dataSpecsList"
+          :key="index"
+          :label="item.name"
+          :value="item.value"
+        />
+      </el-select>
+    </template>
+
+    <!-- 时间类型使用时间选择器 -->
+    <template v-else-if="isDate">
+      <el-date-picker
+        class="w-1/1!"
+        v-model="value"
+        type="datetime"
+        value-format="YYYY-MM-DD HH:mm:ss"
+        placeholder="选择日期时间"
+      />
+    </template>
+
+    <!-- 文本类型使用文本输入框 -->
+    <template v-else-if="isText">
+      <el-input
+        class="w-1/1!"
+        v-model="value"
+        :maxlength="dataSpecs?.length"
+        :show-word-limit="true"
+        placeholder="请输入文本"
+      />
+    </template>
+
+    <!-- array、struct 直接输入 -->
+    <template v-else>
+      <el-input class="w-1/1!" :model-value="value" disabled placeholder="请输入值">
+        <template #append>
+          <el-button type="primary" @click="openJsonEditor">编辑</el-button>
+        </template>
+      </el-input>
+      <!-- array、struct 类型数据编辑 -->
+      <ThingModelDualView
+        ref="thingModelDualViewRef"
+        v-model="value"
+        :thing-model="dataSpecsList"
+      />
+    </template>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue'
+import { useVModel } from '@vueuse/core'
+import { DataSpecsDataType } from '@/views/iot/thingmodel/config'
+import ThingModelDualView from './ThingModelDualView.vue'
+
+/** 物模型属性参数输入组件 */
+defineOptions({ name: 'ThingModelParamInput' })
+
+const props = defineProps<{
+  modelValue: any // 物模型的值
+  thingModel: any // 物模型
+}>()
+
+const emits = defineEmits(['update:modelValue', 'change'])
+const value = useVModel(props, 'modelValue', emits)
+
+const thingModelDualViewRef = ref<InstanceType<typeof ThingModelDualView>>()
+const openJsonEditor = () => {
+  thingModelDualViewRef.value?.open()
+}
+
+/** 计算属性:判断数据类型 */
+const isNumeric = computed(() =>
+  [DataSpecsDataType.INT, DataSpecsDataType.FLOAT, DataSpecsDataType.DOUBLE].includes(
+    props.thingModel?.dataType as any
+  )
+)
+const isBool = computed(() => props.thingModel?.dataType === DataSpecsDataType.BOOL)
+const isEnum = computed(() => props.thingModel?.dataType === DataSpecsDataType.ENUM)
+const isDate = computed(() => props.thingModel?.dataType === DataSpecsDataType.DATE)
+const isText = computed(() => props.thingModel?.dataType === DataSpecsDataType.TEXT)
+/** 获取数据规格 */
+const dataSpecs = computed(() => {
+  if (isNumeric.value || isDate.value || isText.value) {
+    return props.thingModel?.dataSpecs || {}
+  }
+  return {}
+})
+const dataSpecsList = computed(() => {
+  if (
+    isBool.value ||
+    isEnum.value ||
+    [DataSpecsDataType.ARRAY, DataSpecsDataType.STRUCT].includes(props.thingModel?.dataType)
+  ) {
+    return props.thingModel?.dataSpecsList || []
+  }
+  return []
+})
+
+/** 物模型切换重置值 */
+watch(
+  () => props.thingModel?.dataType,
+  (_, oldValue) => {
+    if (!oldValue) {
+      return
+    }
+    value.value = undefined
+  },
+  { deep: true }
+)
+</script>

+ 253 - 0
src/views/iot/rule/scene/components/action/ActionExecutor.vue

@@ -0,0 +1,253 @@
+<template>
+  <div>
+    <div class="m-10px">
+      <!-- 产品设备回显区域 -->
+      <div class="relative bg-[#eff3f7] h-50px flex items-center px-10px">
+        <div class="flex items-center mr-60px">
+          <span class="mr-10px">执行动作</span>
+          <el-select
+            v-model="actionConfig.type"
+            class="!w-240px"
+            clearable
+            placeholder="请选择执行类型"
+          >
+            <el-option
+              v-for="dict in getIntDictOptions(DICT_TYPE.IOT_RULE_SCENE_ACTION_TYPE_ENUM)"
+              :key="dict.value"
+              :label="dict.label"
+              :value="dict.value"
+            />
+          </el-select>
+        </div>
+        <div
+          v-if="actionConfig.type === IotRuleSceneActionTypeEnum.DEVICE_CONTROL"
+          class="flex items-center mr-60px"
+        >
+          <span class="mr-10px">产品</span>
+          <el-button type="primary" @click="handleSelectProduct" size="small" plain>
+            {{ product ? product.name : '选择产品' }}
+          </el-button>
+        </div>
+        <div
+          v-if="actionConfig.type === IotRuleSceneActionTypeEnum.DEVICE_CONTROL"
+          class="flex items-center mr-60px"
+        >
+          <span class="mr-10px">设备</span>
+          <el-button type="primary" @click="handleSelectDevice" size="small" plain>
+            {{ isEmpty(deviceList) ? '选择设备' : deviceList.map((d) => d.deviceName).join(',') }}
+          </el-button>
+        </div>
+        <!-- 删除执行器 -->
+        <div class="absolute top-auto right-16px bottom-auto">
+          <el-tooltip content="删除执行器" placement="top">
+            <slot></slot>
+          </el-tooltip>
+        </div>
+      </div>
+
+      <!-- 设备控制执行器 -->
+      <DeviceControlAction
+        v-if="actionConfig.type === IotRuleSceneActionTypeEnum.DEVICE_CONTROL"
+        :model-value="actionConfig.deviceControl"
+        :product-id="product?.id"
+        :product-key="product?.productKey"
+        @update:model-value="(val) => (actionConfig.deviceControl = val)"
+      />
+
+      <!-- 告警执行器 -->
+      <AlertAction
+        v-else-if="actionConfig.type === IotRuleSceneActionTypeEnum.ALERT"
+        :model-value="actionConfig.alert"
+        @update:model-value="(val) => (actionConfig.alert = val)"
+      />
+
+      <!-- 数据桥接执行器 -->
+      <DataBridgeAction
+        v-else-if="actionConfig.type === IotRuleSceneActionTypeEnum.DATA_BRIDGE"
+        :model-value="actionConfig.dataBridgeId"
+        @update:model-value="(val) => (actionConfig.dataBridgeId = val)"
+      />
+    </div>
+
+    <!-- 产品、设备的选择 -->
+    <ProductTableSelect ref="productTableSelectRef" @success="handleProductSelect" />
+    <DeviceTableSelect
+      ref="deviceTableSelectRef"
+      multiple
+      :product-id="product?.id"
+      @success="handleDeviceSelect"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useVModel } from '@vueuse/core'
+import { isEmpty } from '@/utils/is'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import ProductTableSelect from '@/views/iot/product/product/components/ProductTableSelect.vue'
+import DeviceTableSelect from '@/views/iot/device/device/components/DeviceTableSelect.vue'
+import DeviceControlAction from './DeviceControlAction.vue'
+import AlertAction from './AlertAction.vue'
+import DataBridgeAction from './DataBridgeAction.vue'
+import { ProductApi, ProductVO } from '@/api/iot/product/product'
+import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
+import {
+  ActionAlert,
+  ActionConfig,
+  ActionDeviceControl,
+  IotDeviceMessageIdentifierEnum,
+  IotDeviceMessageTypeEnum,
+  IotRuleSceneActionTypeEnum
+} from '@/api/iot/rule/scene/scene.types'
+
+/** 场景联动之执行器组件 */
+defineOptions({ name: 'ActionExecutor' })
+
+const props = defineProps<{ modelValue: any }>()
+const emits = defineEmits(['update:modelValue'])
+const actionConfig = useVModel(props, 'modelValue', emits) as Ref<ActionConfig>
+
+const message = useMessage()
+
+/** 初始化执行器结构 */
+const initActionConfig = () => {
+  if (!actionConfig.value) {
+    actionConfig.value = { type: IotRuleSceneActionTypeEnum.DEVICE_CONTROL } as ActionConfig
+  }
+
+  // 设备控制执行器初始化
+  if (
+    actionConfig.value.type === IotRuleSceneActionTypeEnum.DEVICE_CONTROL &&
+    !actionConfig.value.deviceControl
+  ) {
+    actionConfig.value.deviceControl = {
+      productKey: '',
+      deviceNames: [],
+      type: IotDeviceMessageTypeEnum.PROPERTY,
+      identifier: IotDeviceMessageIdentifierEnum.PROPERTY_SET,
+      data: {}
+    } as ActionDeviceControl
+  }
+
+  // 告警执行器初始化
+  if (actionConfig.value.type === IotRuleSceneActionTypeEnum.ALERT && !actionConfig.value.alert) {
+    actionConfig.value.alert = {} as ActionAlert
+  }
+
+  // 数据桥接执行器初始化
+  if (
+    actionConfig.value.type === IotRuleSceneActionTypeEnum.DATA_BRIDGE &&
+    !actionConfig.value.dataBridgeId
+  ) {
+    actionConfig.value.dataBridgeId = undefined
+  }
+}
+
+/** 产品和设备选择 */
+const productTableSelectRef = ref<InstanceType<typeof ProductTableSelect>>()
+const deviceTableSelectRef = ref<InstanceType<typeof DeviceTableSelect>>()
+const product = ref<ProductVO>()
+const deviceList = ref<DeviceVO[]>([])
+
+/** 处理选择产品 */
+const handleSelectProduct = () => {
+  productTableSelectRef.value?.open()
+}
+
+/** 处理选择设备 */
+const handleSelectDevice = () => {
+  if (!product.value) {
+    message.warning('请先选择一个产品')
+    return
+  }
+  deviceTableSelectRef.value?.open()
+}
+
+/** 处理产品选择成功 */
+const handleProductSelect = (val: ProductVO) => {
+  product.value = val
+  if (actionConfig.value.deviceControl) {
+    actionConfig.value.deviceControl.productKey = val.productKey
+  }
+  // 重置设备选择
+  deviceList.value = []
+  if (actionConfig.value.deviceControl) {
+    actionConfig.value.deviceControl.deviceNames = []
+  }
+}
+
+/** 处理设备选择成功 */
+const handleDeviceSelect = (val: DeviceVO[]) => {
+  deviceList.value = val
+  if (actionConfig.value.deviceControl) {
+    actionConfig.value.deviceControl.deviceNames = val.map((item) => item.deviceName)
+  }
+}
+
+/** 监听执行类型变化,初始化对应配置 */
+watch(
+  () => actionConfig.value.type,
+  () => {
+    initActionConfig()
+  },
+  { immediate: true }
+)
+
+/**
+ * 初始化产品回显信息
+ */
+const initProductInfo = async () => {
+  if (!actionConfig.value.deviceControl?.productKey) {
+    return
+  }
+
+  try {
+    // 使用新的API直接通过productKey获取产品信息
+    const productData = await ProductApi.getProductByKey(
+      actionConfig.value.deviceControl.productKey
+    )
+    if (productData) {
+      product.value = productData
+    }
+  } catch (error) {
+    console.error('获取产品信息失败:', error)
+  }
+}
+
+/**
+ * 初始化设备回显信息
+ */
+const initDeviceInfo = async () => {
+  if (
+    !actionConfig.value.deviceControl?.productKey ||
+    !actionConfig.value.deviceControl?.deviceNames?.length
+  ) {
+    return
+  }
+
+  try {
+    // 使用新的API直接通过productKey和deviceNames获取设备列表
+    const deviceData = await DeviceApi.getDevicesByProductKeyAndNames(
+      actionConfig.value.deviceControl.productKey,
+      actionConfig.value.deviceControl.deviceNames
+    )
+
+    if (deviceData && deviceData.length > 0) {
+      deviceList.value = deviceData
+    }
+  } catch (error) {
+    console.error('获取设备信息失败:', error)
+  }
+}
+
+/** 初始化 */
+onMounted(async () => {
+  initActionConfig()
+
+  // 初始化产品和设备回显
+  if (actionConfig.value.deviceControl) {
+    await initProductInfo()
+    await initDeviceInfo()
+  }
+})
+</script>

+ 83 - 0
src/views/iot/rule/scene/components/action/AlertAction.vue

@@ -0,0 +1,83 @@
+<template>
+  <div class="bg-[#dbe5f6] p-10px">
+    <div class="flex items-center mb-10px">
+      <span class="mr-10px w-80px">接收方式</span>
+      <el-select
+        v-model="alertConfig.receiveType"
+        class="!w-160px"
+        clearable
+        placeholder="选择接收方式"
+      >
+        <el-option 
+          v-for="(value, key) in IotAlertConfigReceiveTypeEnum"
+          :key="value"
+          :label="key === 'SMS' ? '短信' : key === 'MAIL' ? '邮箱' : '通知'"
+          :value="value"
+        />
+      </el-select>
+    </div>
+    <div v-if="alertConfig.receiveType === IotAlertConfigReceiveTypeEnum.SMS" class="flex items-center mb-10px">
+      <span class="mr-10px w-80px">手机号码</span>
+      <el-select
+        v-model="alertConfig.phoneNumbers"
+        class="!w-360px"
+        multiple
+        filterable
+        allow-create
+        default-first-option
+        placeholder="请输入手机号码"
+      />
+    </div>
+    <div v-if="alertConfig.receiveType === IotAlertConfigReceiveTypeEnum.MAIL" class="flex items-center mb-10px">
+      <span class="mr-10px w-80px">邮箱地址</span>
+      <el-select
+        v-model="alertConfig.emails"
+        class="!w-360px"
+        multiple
+        filterable
+        allow-create
+        default-first-option
+        placeholder="请输入邮箱地址"
+      />
+    </div>
+    <div class="flex items-center">
+      <span class="mr-10px w-80px align-self-start">通知内容</span>
+      <el-input
+        v-model="alertConfig.content"
+        type="textarea"
+        :rows="4"
+        class="!w-360px"
+        placeholder="请输入通知内容"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useVModel } from '@vueuse/core'
+import { ActionAlert, IotAlertConfigReceiveTypeEnum } from '@/api/iot/rule/scene/scene.types'
+
+/** 告警执行器组件 */
+defineOptions({ name: 'AlertAction' })
+
+const props = defineProps<{ modelValue: any }>()
+const emits = defineEmits(['update:modelValue'])
+const alertConfig = useVModel(props, 'modelValue', emits) as Ref<ActionAlert>
+
+/** 初始化告警执行器结构 */
+const initAlertConfig = () => {
+  if (!alertConfig.value) {
+    alertConfig.value = {
+      receiveType: IotAlertConfigReceiveTypeEnum.NOTIFY,
+      phoneNumbers: [],
+      emails: [],
+      content: ''
+    } as ActionAlert
+  }
+}
+
+/** 初始化 */
+onMounted(() => {
+  initAlertConfig()
+})
+</script> 

+ 38 - 0
src/views/iot/rule/scene/components/action/DataBridgeAction.vue

@@ -0,0 +1,38 @@
+<template>
+  <div class="bg-[#dbe5f6] p-10px">
+    <div class="flex items-center">
+      <span class="mr-10px w-80px">数据桥梁</span>
+      <el-select v-model="dataBridgeId" class="!w-240px" clearable placeholder="选择数据桥接">
+        <el-option
+          v-for="bridge in dataBridgeList"
+          :key="bridge.id"
+          :label="bridge.name"
+          :value="bridge.id"
+        />
+      </el-select>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useVModel } from '@vueuse/core'
+import { DataBridgeApi } from '@/api/iot/rule/databridge'
+
+/** 数据桥接执行器组件 */
+defineOptions({ name: 'DataBridgeAction' })
+
+const props = defineProps<{ modelValue: any }>()
+const emits = defineEmits(['update:modelValue'])
+const dataBridgeId = useVModel(props, 'modelValue', emits)
+
+const dataBridgeList = ref<any[]>([]) /** 数据桥接列表 */
+
+/** 获取数据桥接列表 */
+const getDataBridgeList = async () => {
+  dataBridgeList.value = await DataBridgeApi.getSimpleDataBridgeList()
+}
+
+onMounted(() => {
+  getDataBridgeList()
+})
+</script>

+ 230 - 0
src/views/iot/rule/scene/components/action/DeviceControlAction.vue

@@ -0,0 +1,230 @@
+<template>
+  <div class="bg-[#dbe5f6] flex p-10px">
+    <div class="flex flex-col items-center justify-center mr-10px h-a">
+      <el-select v-model="deviceControlConfig.type" class="!w-160px" clearable placeholder="">
+        <el-option label="属性" :value="IotDeviceMessageTypeEnum.PROPERTY" />
+        <el-option label="服务" :value="IotDeviceMessageTypeEnum.SERVICE" />
+      </el-select>
+    </div>
+    <div class="">
+      <div
+        class="flex items-center justify-around mb-10px last:mb-0"
+        v-for="(parameter, index) in parameters"
+        :key="index"
+      >
+        <!-- 选择服务 -->
+        <el-select
+          v-if="IotDeviceMessageTypeEnum.SERVICE === deviceControlConfig.type"
+          v-model="parameter.identifier0"
+          class="!w-240px mr-10px"
+          clearable
+          placeholder="请选择服务"
+        >
+          <el-option
+            v-for="thingModel in getThingModelTSLServices"
+            :key="thingModel.identifier"
+            :label="thingModel.name"
+            :value="thingModel.identifier"
+          />
+        </el-select>
+        <el-select
+          v-model="parameter.identifier"
+          class="!w-240px mr-10px"
+          clearable
+          placeholder="请选择物模型"
+        >
+          <el-option
+            v-for="thingModel in thingModels(parameter?.identifier0)"
+            :key="thingModel.identifier"
+            :label="thingModel.name"
+            :value="thingModel.identifier"
+          />
+        </el-select>
+        <ThingModelParamInput
+          class="!w-240px mr-10px"
+          v-model="parameter.value"
+          :thing-model="
+            thingModels(parameter?.identifier0)?.find(
+              (item) => item.identifier === parameter.identifier
+            )
+          "
+        />
+        <el-tooltip content="删除参数" placement="top">
+          <el-button type="danger" circle size="small" @click="removeParameter(index)">
+            <Icon icon="ep:delete" />
+          </el-button>
+        </el-tooltip>
+      </div>
+    </div>
+    <!-- 添加参数 -->
+    <div class="flex flex-1 flex-col items-center justify-center w-60px h-a">
+      <el-tooltip content="添加参数" placement="top">
+        <el-button type="primary" circle size="small" @click="addParameter">
+          <Icon icon="ep:plus" />
+        </el-button>
+      </el-tooltip>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useVModel } from '@vueuse/core'
+import { isEmpty } from '@/utils/is'
+import { ThingModelApi } from '@/api/iot/thingmodel'
+import {
+  ActionDeviceControl,
+  IotDeviceMessageIdentifierEnum,
+  IotDeviceMessageTypeEnum
+} from '@/api/iot/rule/scene/scene.types'
+import ThingModelParamInput from '../ThingModelParamInput.vue'
+
+/** 设备控制执行器组件 */
+defineOptions({ name: 'DeviceControlAction' })
+
+const props = defineProps<{
+  modelValue: any
+  productId?: number
+  productKey?: string
+}>()
+const emits = defineEmits(['update:modelValue'])
+const deviceControlConfig = useVModel(props, 'modelValue', emits) as Ref<ActionDeviceControl>
+
+const message = useMessage()
+
+/** 执行器参数 */
+const parameters = ref<{ identifier: string; value: any; identifier0?: string }[]>([])
+const addParameter = () => {
+  if (!props.productId) {
+    message.warning('请先选择一个产品')
+    return
+  }
+  if (parameters.value.length >= thingModels.value().length) {
+    message.warning(`该产品只有${thingModels.value().length}个物模型!!!`)
+    return
+  }
+  parameters.value.push({ identifier: '', value: undefined })
+}
+const removeParameter = (index: number) => {
+  parameters.value.splice(index, 1)
+}
+watch(
+  () => parameters.value,
+  (newVal) => {
+    if (isEmpty(newVal)) {
+      return
+    }
+    for (const parameter of newVal) {
+      if (isEmpty(parameter.identifier)) {
+        break
+      }
+      // 单独处理服务的情况
+      if (IotDeviceMessageTypeEnum.SERVICE === deviceControlConfig.value.type) {
+        if (!parameter.identifier0) {
+          continue
+        }
+        deviceControlConfig.value.data[parameter.identifier0] = {
+          identifier: parameter.identifier,
+          value: parameter.value
+        }
+        continue
+      }
+      deviceControlConfig.value.data[parameter.identifier] = parameter.value
+    }
+  },
+  { deep: true }
+)
+
+/** 初始化设备控制执行器结构 */
+const initDeviceControlConfig = () => {
+  if (!deviceControlConfig.value) {
+    deviceControlConfig.value = {
+      productKey: '',
+      deviceNames: [],
+      type: IotDeviceMessageTypeEnum.PROPERTY,
+      identifier: IotDeviceMessageIdentifierEnum.PROPERTY_SET,
+      data: {}
+    } as ActionDeviceControl
+  } else {
+    // 单独处理服务的情况
+    if (IotDeviceMessageTypeEnum.SERVICE === deviceControlConfig.value.type) {
+      // 参数回显
+      parameters.value = Object.entries(deviceControlConfig.value.data).map(([key, value]) => ({
+        identifier0: key,
+        identifier: value.identifier,
+        value: value.value
+      }))
+      return
+    }
+    // 参数回显
+    parameters.value = Object.entries(deviceControlConfig.value.data).map(([key, value]) => ({
+      identifier: key,
+      value: value
+    }))
+  }
+
+  // 确保data对象存在
+  if (!deviceControlConfig.value.data) {
+    deviceControlConfig.value.data = {}
+  }
+}
+
+/** 获取产品物模型 */
+const thingModelTSL = ref<any>()
+const getThingModelTSL = async () => {
+  if (!props.productId) {
+    return
+  }
+  thingModelTSL.value = await ThingModelApi.getThingModelTSLByProductId(props.productId)
+}
+const thingModels = computed(() => (identifier?: string): any[] => {
+  if (isEmpty(thingModelTSL.value)) {
+    return []
+  }
+  switch (deviceControlConfig.value.type) {
+    case IotDeviceMessageTypeEnum.PROPERTY:
+      return thingModelTSL.value?.properties || []
+    case IotDeviceMessageTypeEnum.SERVICE:
+      const service = thingModelTSL.value.services?.find(
+        (item: any) => item.identifier === identifier
+      )
+      return service?.inputParams || []
+  }
+  return []
+})
+/** 获取物模型服务 */
+const getThingModelTSLServices = computed(() => thingModelTSL.value?.services || [])
+
+/** 监听 productId 变化 */
+watch(
+  () => props.productId,
+  () => {
+    getThingModelTSL()
+    if (deviceControlConfig.value && deviceControlConfig.value.productKey === props.productKey) {
+      return
+    }
+    // 当产品ID变化时,清空原有数据
+    deviceControlConfig.value.data = {}
+    parameters.value = []
+  }
+)
+
+/** 监听消息类型变化 */
+watch(
+  () => deviceControlConfig.value.type,
+  () => {
+    // 切换消息类型时清空参数
+    deviceControlConfig.value.data = {}
+    parameters.value = []
+    if (deviceControlConfig.value.type === IotDeviceMessageTypeEnum.PROPERTY) {
+      deviceControlConfig.value.identifier = IotDeviceMessageIdentifierEnum.PROPERTY_SET
+    } else if (deviceControlConfig.value.type === IotDeviceMessageTypeEnum.SERVICE) {
+      deviceControlConfig.value.identifier = IotDeviceMessageIdentifierEnum.SERVICE_INVOKE
+    }
+  }
+)
+
+// 初始化
+onMounted(() => {
+  initDeviceControlConfig()
+})
+</script>

+ 0 - 0
src/views/iot/rule/scene/components/ConditionSelector.vue → src/views/iot/rule/scene/components/listener/ConditionSelector.vue


+ 306 - 0
src/views/iot/rule/scene/components/listener/DeviceListener.vue

@@ -0,0 +1,306 @@
+<template>
+  <div>
+    <div class="m-10px">
+      <div class="relative bg-[#eff3f7] h-50px flex items-center px-10px">
+        <div class="flex items-center mr-60px">
+          <span class="mr-10px">触发条件</span>
+          <el-select
+            v-model="triggerConfig.type"
+            class="!w-240px"
+            clearable
+            placeholder="请选择触发条件"
+          >
+            <el-option
+              v-for="dict in getIntDictOptions(DICT_TYPE.IOT_RULE_SCENE_TRIGGER_TYPE_ENUM)"
+              :key="dict.value"
+              :label="dict.label"
+              :value="dict.value"
+            />
+          </el-select>
+        </div>
+        <div
+          v-if="triggerConfig.type === IotRuleSceneTriggerTypeEnum.DEVICE"
+          class="flex items-center mr-60px"
+        >
+          <span class="mr-10px">产品</span>
+          <el-button type="primary" @click="productTableSelectRef?.open()" size="small" plain>
+            {{ product ? product.name : '选择产品' }}
+          </el-button>
+        </div>
+        <div
+          v-if="triggerConfig.type === IotRuleSceneTriggerTypeEnum.DEVICE"
+          class="flex items-center mr-60px"
+        >
+          <span class="mr-10px">设备</span>
+          <el-button type="primary" @click="openDeviceSelect" size="small" plain>
+            {{ isEmpty(deviceList) ? '选择设备' : triggerConfig.deviceNames.join(',') }}
+          </el-button>
+        </div>
+        <!-- 删除触发器 -->
+        <div class="absolute top-auto right-16px bottom-auto">
+          <el-tooltip content="删除触发器" placement="top">
+            <slot></slot>
+          </el-tooltip>
+        </div>
+      </div>
+      <!-- 设备触发器条件 -->
+      <template v-if="triggerConfig.type === IotRuleSceneTriggerTypeEnum.DEVICE">
+        <div
+          class="bg-[#dbe5f6] flex p-10px"
+          v-for="(condition, index) in triggerConfig.conditions"
+          :key="index"
+        >
+          <div class="flex flex-col items-center justify-center mr-10px h-a">
+            <el-select
+              v-model="condition.type"
+              @change="condition.parameters = []"
+              class="!w-160px"
+              clearable
+              placeholder=""
+            >
+              <el-option label="属性" :value="IotDeviceMessageTypeEnum.PROPERTY" />
+              <el-option label="服务" :value="IotDeviceMessageTypeEnum.SERVICE" />
+              <el-option label="事件" :value="IotDeviceMessageTypeEnum.EVENT" />
+            </el-select>
+          </div>
+          <div class="w-70%">
+            <DeviceListenerCondition
+              v-for="(parameter, index2) in condition.parameters"
+              :key="index2"
+              :model-value="parameter"
+              :condition-type="condition.type"
+              :thingModels="thingModels(condition)"
+              @update:model-value="(val) => (condition.parameters[index2] = val)"
+              class="mb-10px last:mb-0"
+            >
+              <el-tooltip content="删除参数" placement="top">
+                <el-button
+                  type="danger"
+                  circle
+                  size="small"
+                  @click="removeConditionParameter(condition.parameters, index2)"
+                >
+                  <Icon icon="ep:delete" />
+                </el-button>
+              </el-tooltip>
+            </DeviceListenerCondition>
+          </div>
+          <!-- 添加参数 -->
+          <div class="flex flex-1 flex-col items-center justify-center w-60px h-a">
+            <el-tooltip content="添加参数" placement="top">
+              <el-button
+                type="primary"
+                circle
+                size="small"
+                @click="addConditionParameter(condition.parameters)"
+              >
+                <Icon icon="ep:plus" />
+              </el-button>
+            </el-tooltip>
+          </div>
+          <!-- 删除条件 -->
+          <div
+            class="device-listener-condition flex flex-1 flex-col items-center justify-center w-a h-a"
+          >
+            <el-tooltip content="删除条件" placement="top">
+              <el-button type="danger" size="small" @click="removeCondition(index)">
+                <Icon icon="ep:delete" />
+              </el-button>
+            </el-tooltip>
+          </div>
+        </div>
+      </template>
+      <!-- 定时触发 -->
+      <div
+        v-if="triggerConfig.type === IotRuleSceneTriggerTypeEnum.TIMER"
+        class="bg-[#dbe5f6] flex items-center justify-between p-10px"
+      >
+        <span class="w-120px">CRON 表达式</span>
+        <crontab v-model="triggerConfig.cronExpression" />
+      </div>
+      <!-- 设备触发才可以设置多个触发条件 -->
+      <el-text
+        v-if="triggerConfig.type === IotRuleSceneTriggerTypeEnum.DEVICE"
+        class="ml-10px!"
+        type="primary"
+        @click="addCondition"
+      >
+        添加触发条件
+      </el-text>
+    </div>
+
+    <!-- 产品、设备的选择 -->
+    <ProductTableSelect ref="productTableSelectRef" @success="handleProductSelect" />
+    <DeviceTableSelect
+      ref="deviceTableSelectRef"
+      multiple
+      :product-id="product?.id"
+      @success="handleDeviceSelect"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useVModel } from '@vueuse/core'
+import { isEmpty } from '@/utils/is'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import DeviceListenerCondition from './DeviceListenerCondition.vue'
+import ProductTableSelect from '@/views/iot/product/product/components/ProductTableSelect.vue'
+import DeviceTableSelect from '@/views/iot/device/device/components/DeviceTableSelect.vue'
+import { ProductApi, ProductVO } from '@/api/iot/product/product'
+import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
+import { ThingModelApi } from '@/api/iot/thingmodel'
+import {
+  IotDeviceMessageIdentifierEnum,
+  IotDeviceMessageTypeEnum,
+  IotRuleSceneTriggerTypeEnum,
+  TriggerCondition,
+  TriggerConditionParameter,
+  TriggerConfig
+} from '@/api/iot/rule/scene/scene.types'
+
+/** 场景联动之监听器组件 */
+defineOptions({ name: 'DeviceListener' })
+
+const props = defineProps<{ modelValue: any }>()
+const emits = defineEmits(['update:modelValue'])
+const triggerConfig = useVModel(props, 'modelValue', emits) as Ref<TriggerConfig>
+
+const message = useMessage()
+
+/** 添加触发条件 */
+const addCondition = () => {
+  triggerConfig.value.conditions?.push({
+    type: IotDeviceMessageTypeEnum.PROPERTY,
+    identifier: IotDeviceMessageIdentifierEnum.PROPERTY_SET,
+    parameters: []
+  })
+}
+/** 移除触发条件 */
+const removeCondition = (index: number) => {
+  triggerConfig.value.conditions?.splice(index, 1)
+}
+
+/** 添加参数 */
+const addConditionParameter = (conditionParameters: TriggerConditionParameter[]) => {
+  if (!product.value) {
+    message.warning('请先选择一个产品')
+    return
+  }
+  conditionParameters.push({} as TriggerConditionParameter)
+}
+/** 移除参数 */
+const removeConditionParameter = (
+  conditionParameters: TriggerConditionParameter[],
+  index: number
+) => {
+  conditionParameters.splice(index, 1)
+}
+
+/** 产品和设备选择引用 */
+const productTableSelectRef = ref<InstanceType<typeof ProductTableSelect>>()
+const deviceTableSelectRef = ref<InstanceType<typeof DeviceTableSelect>>()
+const product = ref<ProductVO>()
+const deviceList = ref<DeviceVO[]>([])
+/** 处理产品选择 */
+const handleProductSelect = (val: ProductVO) => {
+  product.value = val
+  triggerConfig.value.productKey = val.productKey
+  deviceList.value = []
+  getThingModelTSL()
+}
+/** 处理设备选择 */
+const handleDeviceSelect = (val: DeviceVO[]) => {
+  deviceList.value = val
+  triggerConfig.value.deviceNames = val.map((item) => item.deviceName)
+}
+/** 打开设备选择器 */
+const openDeviceSelect = () => {
+  if (!product.value) {
+    message.warning('请先选择一个产品')
+    return
+  }
+  deviceTableSelectRef.value?.open()
+}
+
+/**
+ * 初始化产品回显信息
+ */
+const initProductInfo = async () => {
+  if (!triggerConfig.value.productKey) {
+    return
+  }
+
+  try {
+    // 使用新的API直接通过productKey获取产品信息
+    const productData = await ProductApi.getProductByKey(triggerConfig.value.productKey)
+    if (productData) {
+      product.value = productData
+      // 加载物模型数据
+      await getThingModelTSL()
+    }
+  } catch (error) {
+    console.error('获取产品信息失败:', error)
+  }
+}
+
+/**
+ * 初始化设备回显信息
+ */
+const initDeviceInfo = async () => {
+  if (!triggerConfig.value.productKey || !triggerConfig.value.deviceNames?.length) {
+    return
+  }
+
+  try {
+    // 使用新的API直接通过productKey和deviceNames获取设备列表
+    const deviceData = await DeviceApi.getDevicesByProductKeyAndNames(
+      triggerConfig.value.productKey,
+      triggerConfig.value.deviceNames
+    )
+
+    if (deviceData && deviceData.length > 0) {
+      deviceList.value = deviceData
+    }
+  } catch (error) {
+    console.error('获取设备信息失败:', error)
+  }
+}
+
+/** 获取产品物模型 */
+const thingModelTSL = ref<any>()
+const thingModels = computed(() => (condition: TriggerCondition) => {
+  if (isEmpty(thingModelTSL.value)) {
+    return []
+  }
+  switch (condition.type) {
+    case IotDeviceMessageTypeEnum.PROPERTY:
+      return thingModelTSL.value?.properties || []
+    case IotDeviceMessageTypeEnum.SERVICE:
+      return thingModelTSL.value?.services || []
+    case IotDeviceMessageTypeEnum.EVENT:
+      return thingModelTSL.value?.events || []
+  }
+  return []
+})
+const getThingModelTSL = async () => {
+  if (!product.value) {
+    return
+  }
+  thingModelTSL.value = await ThingModelApi.getThingModelTSLByProductId(product.value.id)
+}
+
+/** 初始化 */
+onMounted(async () => {
+  // 初始化产品和设备回显
+  if (triggerConfig.value) {
+    // 初始化conditions数组,如果不存在
+    if (!triggerConfig.value.conditions) {
+      triggerConfig.value.conditions = []
+    }
+
+    await initProductInfo()
+    await initDeviceInfo()
+  }
+})
+</script>

+ 87 - 0
src/views/iot/rule/scene/components/listener/DeviceListenerCondition.vue

@@ -0,0 +1,87 @@
+<template>
+  <div class="flex items-center w-1/1">
+    <!-- 选择服务 -->
+    <el-select
+      v-if="
+        [IotDeviceMessageTypeEnum.SERVICE, IotDeviceMessageTypeEnum.EVENT].includes(conditionType)
+      "
+      v-model="conditionParameter.identifier0"
+      class="!w-150px mr-10px"
+      clearable
+      placeholder="请选择服务"
+    >
+      <el-option
+        v-for="thingModel in thingModels"
+        :key="thingModel.identifier"
+        :label="thingModel.name"
+        :value="thingModel.identifier"
+      />
+    </el-select>
+    <el-select
+      v-model="conditionParameter.identifier"
+      class="!w-150px mr-10px"
+      clearable
+      placeholder="请选择物模型"
+    >
+      <el-option
+        v-for="thingModel in getThingModels"
+        :key="thingModel.identifier"
+        :label="thingModel.name"
+        :value="thingModel.identifier"
+      />
+    </el-select>
+    <ConditionSelector
+      v-model="conditionParameter.operator"
+      :data-type="model?.dataType"
+      class="!w-150px mr-10px"
+    />
+    <ThingModelParamInput
+      v-if="
+        conditionParameter.operator !==
+        IotRuleSceneTriggerConditionParameterOperatorEnum.NOT_NULL.value
+      "
+      class="!w-200px mr-10px"
+      v-model="conditionParameter.value"
+      :thing-model="model"
+    />
+    <!-- 按钮插槽 -->
+    <slot></slot>
+  </div>
+</template>
+
+<script setup lang="ts">
+import ConditionSelector from './ConditionSelector.vue'
+import {
+  IotDeviceMessageTypeEnum,
+  IotRuleSceneTriggerConditionParameterOperatorEnum,
+  TriggerConditionParameter
+} from '@/api/iot/rule/scene/scene.types'
+import { useVModel } from '@vueuse/core'
+import ThingModelParamInput from '@/views/iot/rule/scene/components/ThingModelParamInput.vue'
+
+/** 设备触发条件 */
+defineOptions({ name: 'DeviceListenerCondition' })
+const props = defineProps<{ modelValue: any; conditionType: any; thingModels: any }>()
+const emits = defineEmits(['update:modelValue'])
+const conditionParameter = useVModel(props, 'modelValue', emits) as Ref<TriggerConditionParameter>
+
+/** 属性就是 thingModels,服务和事件取对应的 outputParams */
+const getThingModels = computed(() => {
+  switch (props.conditionType) {
+    case IotDeviceMessageTypeEnum.PROPERTY:
+      return props.thingModels || []
+    case IotDeviceMessageTypeEnum.SERVICE:
+    case IotDeviceMessageTypeEnum.EVENT:
+      return (
+        props.thingModels.find(
+          (item: any) => item.identifier === conditionParameter.value.identifier0
+        )?.outputParams || []
+      )
+  }
+})
+
+/** 获得物模型属性、类型 */
+const model = computed(() =>
+  getThingModels.value.find((item: any) => item.identifier === conditionParameter.value.identifier)
+)
+</script>

+ 6 - 3
src/views/iot/rule/scene/index.vue

@@ -69,9 +69,12 @@
           <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
         </template>
       </el-table-column>
-      <!-- TODO @puhui999:触发器数组 => 触发器,然后展示 x 个? ps:执行器数组也类似哈。-->
-      <el-table-column label="触发器数组" align="center" prop="triggers" />
-      <el-table-column label="执行器数组" align="center" prop="actions" />
+      <el-table-column label="触发器" align="center" prop="triggers">
+        <template #default="{ row }"> {{ row.triggers?.length }}个 </template>
+      </el-table-column>
+      <el-table-column label="执行器" align="center" prop="actions">
+        <template #default="{ row }"> {{ row.actions?.length }}个 </template>
+      </el-table-column>
       <el-table-column
         label="创建时间"
         align="center"

+ 14 - 13
src/views/iot/thingmodel/ThingModelTSL.vue

@@ -1,8 +1,16 @@
 <template>
   <Dialog v-model="dialogVisible" :title="dialogTitle">
-    <el-scrollbar height="600px">
-      <pre><code v-dompurify-html="highlightedCode()" class="hljs"></code></pre>
-    </el-scrollbar>
+    <JsonEditor
+      v-model="thingModelTSL"
+      :mode="viewMode === 'editor' ? 'code' : 'view'"
+      height="600px"
+    />
+    <template #footer>
+      <el-radio-group v-model="viewMode" size="small">
+        <el-radio-button label="code">代码视图</el-radio-button>
+        <el-radio-button label="editor">编辑器视图</el-radio-button>
+      </el-radio-group>
+    </template>
   </Dialog>
 </template>
 
@@ -19,6 +27,7 @@ defineOptions({ name: 'ThingModelTSL' })
 const dialogVisible = ref(false) // 弹窗的是否展示
 const dialogTitle = ref('物模型 TSL') // 弹窗的标题
 const product = inject<Ref<ProductVO>>(IOT_PROVIDE_KEY.PRODUCT) // 注入产品信息
+const viewMode = ref('code') // 查看模式:code-代码视图,editor-编辑器视图
 
 /** 打开弹窗 */
 const open = () => {
@@ -27,17 +36,9 @@ const open = () => {
 defineExpose({ open })
 
 /** 获取 TSL */
-const thingModelTSL = ref('')
+const thingModelTSL = ref({})
 const getTsl = async () => {
-  const res = await ThingModelApi.getThingModelTSLByProductId(product?.value?.id || 0)
-  thingModelTSL.value = JSON.stringify(res, null, 2)
-}
-
-/** 代码高亮 */
-const highlightedCode = () => {
-  // TODO @puhui999:可以考虑 highlight 的告警解决下;另外,可以考虑配置设置里面,有个 json editor 预览
-  const result = hljs.highlight('json', thingModelTSL.value, true)
-  return result.value || '&nbsp;'
+  thingModelTSL.value = await ThingModelApi.getThingModelTSLByProductId(product?.value?.id || 0)
 }
 
 /** 初始化 **/

+ 1 - 0
src/views/iot/thingmodel/index.vue

@@ -108,6 +108,7 @@
 import { ThingModelApi, ThingModelData } from '@/api/iot/thingmodel'
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import ThingModelForm from './ThingModelForm.vue'
+import ThingModelTSL from './ThingModelTSL.vue'
 import { ProductVO } from '@/api/iot/product/product'
 import { IOT_PROVIDE_KEY } from '@/views/iot/utils/constants'
 import { getDataTypeOptionsLabel } from './config'