Răsfoiți Sursa

Merge branch 'qhse_cert' of shuzhihua/pms-iot-vue into master

yanghao 1 zi în urmă
părinte
comite
a7ea951f82
100 a modificat fișierele cu 12199 adăugiri și 2716 ștergeri
  1. 46 0
      src/api/iot/alert/config/index.ts
  2. 35 0
      src/api/iot/alert/record/index.ts
  3. 77 62
      src/api/iot/device/device/index.ts
  4. 31 0
      src/api/iot/device/modbus/config/index.ts
  5. 48 0
      src/api/iot/device/modbus/point/index.ts
  6. 44 0
      src/api/iot/ota/firmware/index.ts
  7. 38 0
      src/api/iot/ota/task/index.ts
  8. 38 0
      src/api/iot/ota/task/record/index.ts
  9. 0 51
      src/api/iot/plugin/index.ts
  10. 28 14
      src/api/iot/product/product/index.ts
  11. 39 0
      src/api/iot/rule/data/rule/index.ts
  12. 169 0
      src/api/iot/rule/data/sink/index.ts
  13. 0 127
      src/api/iot/rule/databridge/index.ts
  14. 87 0
      src/api/iot/rule/scene/index.ts
  15. 29 11
      src/api/iot/statistics/index.ts
  16. 225 12
      src/api/iot/thingmodel/index.ts
  17. 4 0
      src/components/Map/index.ts
  18. 259 0
      src/components/Map/src/MapDialog.vue
  19. 62 0
      src/components/Map/src/utils.ts
  20. 32 11
      src/router/modules/remaining.ts
  21. 471 0
      src/utils/cron.ts
  22. 2 0
      src/utils/dict.ts
  23. 201 0
      src/views/iot/alert/config/AlertConfigForm.vue
  24. 210 0
      src/views/iot/alert/config/index.vue
  25. 296 0
      src/views/iot/alert/record/index.vue
  26. 84 61
      src/views/iot/device/device/DeviceForm.vue
  27. 1 1
      src/views/iot/device/device/DeviceImportForm.vue
  28. 303 0
      src/views/iot/device/device/components/DeviceTableSelect.vue
  29. 0 110
      src/views/iot/device/device/detail/DeviceDataDetail.vue
  30. 42 27
      src/views/iot/device/device/detail/DeviceDetailConfig.vue
  31. 10 5
      src/views/iot/device/device/detail/DeviceDetailsHeader.vue
  32. 130 114
      src/views/iot/device/device/detail/DeviceDetailsInfo.vue
  33. 0 166
      src/views/iot/device/device/detail/DeviceDetailsLog.vue
  34. 201 0
      src/views/iot/device/device/detail/DeviceDetailsMessage.vue
  35. 0 134
      src/views/iot/device/device/detail/DeviceDetailsModel.vue
  36. 303 210
      src/views/iot/device/device/detail/DeviceDetailsSimulator.vue
  37. 264 0
      src/views/iot/device/device/detail/DeviceDetailsSubDevice.vue
  38. 35 0
      src/views/iot/device/device/detail/DeviceDetailsThingModel.vue
  39. 192 0
      src/views/iot/device/device/detail/DeviceDetailsThingModelEvent.vue
  40. 245 0
      src/views/iot/device/device/detail/DeviceDetailsThingModelProperty.vue
  41. 216 0
      src/views/iot/device/device/detail/DeviceDetailsThingModelPropertyHistory.vue
  42. 208 0
      src/views/iot/device/device/detail/DeviceDetailsThingModelService.vue
  43. 292 0
      src/views/iot/device/device/detail/DeviceModbusConfig.vue
  44. 205 0
      src/views/iot/device/device/detail/DeviceModbusConfigForm.vue
  45. 286 0
      src/views/iot/device/device/detail/DeviceModbusPointForm.vue
  46. 56 12
      src/views/iot/device/device/detail/index.vue
  47. 25 11
      src/views/iot/device/device/index.vue
  48. 50 0
      src/views/iot/home/components/ComparisonCard.vue
  49. 131 0
      src/views/iot/home/components/DeviceCountCard.vue
  50. 187 0
      src/views/iot/home/components/DeviceMapCard.vue
  51. 163 0
      src/views/iot/home/components/DeviceStateCountCard.vue
  52. 227 0
      src/views/iot/home/components/MessageTrendCard.vue
  53. 68 463
      src/views/iot/home/index.vue
  54. 169 0
      src/views/iot/ota/firmware/OtaFirmwareForm.vue
  55. 143 0
      src/views/iot/ota/firmware/detail/index.vue
  56. 232 0
      src/views/iot/ota/firmware/index.vue
  57. 285 0
      src/views/iot/ota/task/OtaTaskDetail.vue
  58. 132 0
      src/views/iot/ota/task/OtaTaskForm.vue
  59. 187 0
      src/views/iot/ota/task/OtaTaskList.vue
  60. 0 106
      src/views/iot/plugin/PluginConfigForm.vue
  61. 0 99
      src/views/iot/plugin/detail/PluginImportForm.vue
  62. 0 120
      src/views/iot/plugin/detail/index.vue
  63. 0 329
      src/views/iot/plugin/index.vue
  64. 0 1
      src/views/iot/product/category/index.vue
  65. 49 57
      src/views/iot/product/product/ProductForm.vue
  66. 65 0
      src/views/iot/product/product/components/ProductSelect.vue
  67. 220 0
      src/views/iot/product/product/components/ProductTableSelect.vue
  68. 15 12
      src/views/iot/product/product/detail/ProductDetailsHeader.vue
  69. 39 10
      src/views/iot/product/product/detail/ProductDetailsInfo.vue
  70. 0 247
      src/views/iot/product/product/detail/ProductTopic.vue
  71. 1 7
      src/views/iot/product/product/detail/index.vue
  72. 2 2
      src/views/iot/product/product/index.vue
  73. 20 0
      src/views/iot/rule/data/index.vue
  74. 158 0
      src/views/iot/rule/data/rule/DataRuleForm.vue
  75. 262 0
      src/views/iot/rule/data/rule/components/SourceConfigForm.vue
  76. 196 0
      src/views/iot/rule/data/rule/index.vue
  77. 67 62
      src/views/iot/rule/data/sink/DataSinkForm.vue
  78. 280 0
      src/views/iot/rule/data/sink/config/DatabaseConfigForm.vue
  79. 4 2
      src/views/iot/rule/data/sink/config/HttpConfigForm.vue
  80. 2 2
      src/views/iot/rule/data/sink/config/KafkaMQConfigForm.vue
  81. 2 2
      src/views/iot/rule/data/sink/config/MqttConfigForm.vue
  82. 2 2
      src/views/iot/rule/data/sink/config/RabbitMQConfigForm.vue
  83. 2 3
      src/views/iot/rule/data/sink/config/RedisStreamConfigForm.vue
  84. 2 2
      src/views/iot/rule/data/sink/config/RocketMQConfigForm.vue
  85. 103 0
      src/views/iot/rule/data/sink/config/TcpConfigForm.vue
  86. 117 0
      src/views/iot/rule/data/sink/config/WebSocketConfigForm.vue
  87. 0 1
      src/views/iot/rule/data/sink/config/components/KeyValueEditor.vue
  88. 8 2
      src/views/iot/rule/data/sink/config/index.ts
  89. 24 46
      src/views/iot/rule/data/sink/index.vue
  90. 336 0
      src/views/iot/rule/scene/form/RuleSceneForm.vue
  91. 81 0
      src/views/iot/rule/scene/form/configs/AlertConfig.vue
  92. 301 0
      src/views/iot/rule/scene/form/configs/ConditionConfig.vue
  93. 234 0
      src/views/iot/rule/scene/form/configs/CurrentTimeConditionConfig.vue
  94. 376 0
      src/views/iot/rule/scene/form/configs/DeviceControlConfig.vue
  95. 251 0
      src/views/iot/rule/scene/form/configs/DeviceTriggerConfig.vue
  96. 333 0
      src/views/iot/rule/scene/form/configs/MainConditionInnerConfig.vue
  97. 156 0
      src/views/iot/rule/scene/form/configs/SubConditionGroupConfig.vue
  98. 163 0
      src/views/iot/rule/scene/form/configs/TimerConditionGroupConfig.vue
  99. 519 0
      src/views/iot/rule/scene/form/inputs/JsonParamsInput.vue
  100. 266 0
      src/views/iot/rule/scene/form/inputs/ValueInput.vue

+ 46 - 0
src/api/iot/alert/config/index.ts

@@ -0,0 +1,46 @@
+import request from '@/config/axios'
+
+/** IoT 告警配置信息 */
+export interface AlertConfig {
+  id: number // 配置编号
+  name?: string // 配置名称
+  description: string // 配置描述
+  level?: number // 告警级别
+  status?: number // 配置状态
+  sceneRuleIds: string // 关联的场景联动规则编号数组
+  receiveUserIds: string // 接收的用户编号数组
+  receiveTypes: string // 接收的类型数组
+}
+
+// IoT 告警配置 API
+export const AlertConfigApi = {
+  // 查询告警配置分页
+  getAlertConfigPage: async (params: any) => {
+    return await request.get({ url: `/iot/alert-config/page`, params })
+  },
+
+  // 查询告警配置详情
+  getAlertConfig: async (id: number) => {
+    return await request.get({ url: `/iot/alert-config/get?id=` + id })
+  },
+
+  // 新增告警配置
+  createAlertConfig: async (data: AlertConfig) => {
+    return await request.post({ url: `/iot/alert-config/create`, data })
+  },
+
+  // 修改告警配置
+  updateAlertConfig: async (data: AlertConfig) => {
+    return await request.put({ url: `/iot/alert-config/update`, data })
+  },
+
+  // 删除告警配置
+  deleteAlertConfig: async (id: number) => {
+    return await request.delete({ url: `/iot/alert-config/delete?id=` + id })
+  },
+
+  // 获取告警配置简单列表
+  getSimpleAlertConfigList: async () => {
+    return await request.get({ url: `/iot/alert-config/simple-list` })
+  }
+}

+ 35 - 0
src/api/iot/alert/record/index.ts

@@ -0,0 +1,35 @@
+import request from '@/config/axios'
+
+/** IoT 告警记录信息 */
+export interface AlertRecord {
+  id: number // 记录编号
+  configId: number // 告警配置编号
+  configName: string // 告警名称
+  configLevel: number // 告警级别
+  productId: number // 产品编号
+  deviceId: number // 设备编号
+  deviceMessage: any // 触发的设备消息
+  processStatus?: boolean // 是否处理
+  processRemark: string // 处理结果(备注)
+}
+
+// IoT 告警记录 API
+export const AlertRecordApi = {
+  // 查询告警记录分页
+  getAlertRecordPage: async (params: any) => {
+    return await request.get({ url: `/iot/alert-record/page`, params })
+  },
+
+  // 查询告警记录详情
+  getAlertRecord: async (id: number) => {
+    return await request.get({ url: `/iot/alert-record/get?id=` + id })
+  },
+
+  // 处理告警记录
+  processAlertRecord: async (id: number, processRemark: string) => {
+    return await request.put({
+      url: `/iot/alert-record/process`,
+      data: { id, processRemark }
+    })
+  }
+}

+ 77 - 62
src/api/iot/device/device/index.ts

@@ -3,9 +3,9 @@ import request from '@/config/axios'
 // IoT 设备 VO
 export interface DeviceVO {
   id: number // 设备 ID,主键,自增
-  deviceKey: string // 设备唯一标识符
   deviceName: string // 设备名称
   productId: number // 产品编号
+  productName?: string // 产品名称(只有部分接口返回,例如 getDeviceLocationList)
   productKey: string // 产品标识
   deviceType: number // 设备类型
   nickname: string // 设备备注名称
@@ -21,9 +21,8 @@ export interface DeviceVO {
   mqttClientId: string // MQTT 客户端 ID
   mqttUsername: string // MQTT 用户名
   mqttPassword: string // MQTT 密码
-  authType: string // 认证类型
-  latitude: number // 设备位置的纬度
-  longitude: number // 设备位置的经度
+  latitude?: number // 设备位置的纬度
+  longitude?: number // 设备位置的经度
   areaId: number // 地区编码
   address: string // 设备详细地址
   serialNumber: string // 设备序列号
@@ -31,53 +30,36 @@ export interface DeviceVO {
   groupIds?: number[] // 添加分组 ID
 }
 
-// IoT 设备数据 VO
-export interface DeviceDataVO {
-  deviceId: number // 设备编号
-  thinkModelFunctionId: number // 物模型编号
-  productKey: string // 产品标识
-  deviceName: string // 设备名称
+// IoT 设备属性详细 VO
+export interface IotDevicePropertyDetailRespVO {
   identifier: string // 属性标识符
+  value: string // 最新值
+  updateTime: Date // 更新时间
   name: string // 属性名称
   dataType: string // 数据类型
-  updateTime: Date // 更新时间
-  value: string // 最新值
-}
-
-// IoT 设备数据 VO
-export interface DeviceHistoryDataVO {
-  time: number // 时间
-  data: string // 数据
+  dataSpecs: any // 数据定义
+  dataSpecsList: any[] // 数据定义列表
 }
 
-// IoT 设备状态枚举
-export enum DeviceStateEnum {
-  INACTIVE = 0, // 未激活
-  ONLINE = 1, // 在线
-  OFFLINE = 2 // 离线
-}
-
-// IoT 设备上行 Request VO
-export interface IotDeviceUpstreamReqVO {
-  id: number // 设备编号
-  type: string // 消息类型
-  identifier: string // 标识符
-  data: any // 请求参数
+// IoT 设备属性 VO
+export interface IotDevicePropertyRespVO {
+  identifier: string // 属性标识符
+  value: string // 最新值
+  updateTime: Date // 更新时间
 }
 
-// IoT 设备下行 Request VO
-export interface IotDeviceDownstreamReqVO {
-  id: number // 设备编号
-  type: string // 消息类型
-  identifier: string // 标识符
-  data: any // 请求参数
+// 设备认证参数 VO
+export interface IotDeviceAuthInfoVO {
+  clientId: string // 客户端 ID
+  username: string // 用户名
+  password: string // 密码
 }
 
-// MQTT 连接参数 VO
-export interface MqttConnectionParamsVO {
-  mqttClientId: string // MQTT 客户端 ID
-  mqttUsername: string // MQTT 用户名
-  mqttPassword: string // MQTT 密码
+// IoT 设备发送消息 Request VO
+export interface IotDeviceMessageSendReqVO {
+  deviceId: number // 设备编号
+  method: string // 请求方法
+  params?: any // 请求参数
 }
 
 // 设备 API
@@ -128,42 +110,75 @@ export const DeviceApi = {
   },
 
   // 获取设备的精简信息列表
-  getSimpleDeviceList: async (deviceType?: number) => {
-    return await request.get({ url: `/iot/device/simple-list?`, params: { deviceType } })
+  getSimpleDeviceList: async (deviceType?: number, productId?: number) => {
+    return await request.get({ url: `/iot/device/simple-list?`, params: { deviceType, productId } })
   },
 
-  // 获取导入模板
-  importDeviceTemplate: async () => {
-    return await request.download({ url: `/iot/device/get-import-template` })
+  // 获取设备位置列表(用于地图展示)
+  getDeviceLocationList: async () => {
+    return await request.get<DeviceVO[]>({ url: `/iot/device/location-list` })
   },
 
-  // 设备上行
-  upstreamDevice: async (data: IotDeviceUpstreamReqVO) => {
-    return await request.post({ url: `/iot/device/upstream`, data })
+  // 根据产品编号,获取设备的精简信息列表
+  getDeviceListByProductId: async (productId: number) => {
+    return await request.get({ url: `/iot/device/simple-list?`, params: { productId } })
   },
 
-  // 设备下行
-  downstreamDevice: async (data: IotDeviceDownstreamReqVO) => {
-    return await request.post({ url: `/iot/device/downstream`, data })
+  // 获取导入模板
+  importDeviceTemplate: async () => {
+    return await request.download({ url: `/iot/device/get-import-template` })
   },
 
   // 获取设备属性最新数据
   getLatestDeviceProperties: async (params: any) => {
-    return await request.get({ url: `/iot/device/property/latest`, params })
+    return await request.get({ url: `/iot/device/property/get-latest`, params })
   },
 
   // 获取设备属性历史数据
-  getHistoryDevicePropertyPage: async (params: any) => {
-    return await request.get({ url: `/iot/device/property/history-page`, params })
+  getHistoryDevicePropertyList: async (params: any) => {
+    return await request.get({ url: `/iot/device/property/history-list`, params })
+  },
+
+  // 获取设备认证信息
+  getDeviceAuthInfo: async (id: number) => {
+    return await request.get({ url: `/iot/device/get-auth-info`, params: { id } })
+  },
+
+  // 查询设备消息分页
+  getDeviceMessagePage: async (params: any) => {
+    return await request.get({ url: `/iot/device/message/page`, params })
+  },
+
+  // 查询设备消息配对分页
+  getDeviceMessagePairPage: async (params: any) => {
+    return await request.get({ url: `/iot/device/message/pair-page`, params })
+  },
+
+  // 发送设备消息
+  sendDeviceMessage: async (params: IotDeviceMessageSendReqVO) => {
+    return await request.post({ url: `/iot/device/message/send`, data: params })
+  },
+
+  // 绑定子设备到网关
+  bindDeviceGateway: async (data: { subIds: number[]; gatewayId: number }) => {
+    return await request.put({ url: `/iot/device/bind-gateway`, data })
+  },
+
+  // 解绑子设备与网关
+  unbindDeviceGateway: async (data: { subIds: number[]; gatewayId: number }) => {
+    return await request.put({ url: `/iot/device/unbind-gateway`, data })
   },
 
-  // 查询设备日志分页
-  getDeviceLogPage: async (params: any) => {
-    return await request.get({ url: `/iot/device/log/page`, params })
+  // 获取网关的子设备列表
+  getSubDeviceList: async (gatewayId: number) => {
+    return await request.get<DeviceVO[]>({
+      url: `/iot/device/sub-device-list`,
+      params: { gatewayId }
+    })
   },
 
-  // 获取设备MQTT连接参数
-  getMqttConnectionParams: async (deviceId: number) => {
-    return await request.get({ url: `/iot/device/mqtt-connection-params`, params: { deviceId } })
+  // 获取未绑定网关的子设备分页
+  getUnboundSubDevicePage: async (params: any) => {
+    return await request.get({ url: `/iot/device/unbound-sub-device-page`, params })
   }
 }

+ 31 - 0
src/api/iot/device/modbus/config/index.ts

@@ -0,0 +1,31 @@
+import request from '@/config/axios'
+
+/** Modbus 连接配置 VO */
+export interface DeviceModbusConfigVO {
+  id?: number // 主键
+  deviceId: number // 设备编号
+  ip: string // Modbus 服务器 IP 地址
+  port: number // Modbus 服务器端口
+  slaveId: number // 从站地址
+  timeout: number // 连接超时时间,单位:毫秒
+  retryInterval: number // 重试间隔,单位:毫秒
+  mode: number // 模式
+  frameFormat: number // 帧格式
+  status: number // 状态
+}
+
+/** Modbus 连接配置 API */
+export const DeviceModbusConfigApi = {
+  /** 获取设备的 Modbus 连接配置 */
+  getModbusConfig: async (deviceId: number) => {
+    return await request.get<DeviceModbusConfigVO>({
+      url: `/iot/device-modbus-config/get`,
+      params: { deviceId }
+    })
+  },
+
+  /** 保存 Modbus 连接配置 */
+  saveModbusConfig: async (data: DeviceModbusConfigVO) => {
+    return await request.post({ url: `/iot/device-modbus-config/save`, data })
+  }
+}

+ 48 - 0
src/api/iot/device/modbus/point/index.ts

@@ -0,0 +1,48 @@
+import request from '@/config/axios'
+
+/** Modbus 点位配置 VO */
+export interface DeviceModbusPointVO {
+  id?: number // 主键
+  deviceId: number // 设备编号
+  thingModelId?: number // 物模型属性编号
+  identifier: string // 属性标识符
+  name: string // 属性名称
+  functionCode?: number // Modbus 功能码
+  registerAddress?: number // 寄存器起始地址
+  registerCount?: number // 寄存器数量
+  byteOrder?: string // 字节序
+  rawDataType?: string // 原始数据类型
+  scale: number // 缩放因子
+  pollInterval: number // 轮询间隔,单位:毫秒
+  status: number // 状态
+}
+
+/** Modbus 点位配置 API */
+export const DeviceModbusPointApi = {
+  /** 获取设备的 Modbus 点位分页 */
+  getModbusPointPage: async (params: any) => {
+    return await request.get({ url: `/iot/device-modbus-point/page`, params })
+  },
+
+  /** 获取 Modbus 点位详情 */
+  getModbusPoint: async (id: number) => {
+    return await request.get<DeviceModbusPointVO>({
+      url: `/iot/device-modbus-point/get?id=${id}`
+    })
+  },
+
+  /** 创建 Modbus 点位配置 */
+  createModbusPoint: async (data: DeviceModbusPointVO) => {
+    return await request.post({ url: `/iot/device-modbus-point/create`, data })
+  },
+
+  /** 更新 Modbus 点位配置 */
+  updateModbusPoint: async (data: DeviceModbusPointVO) => {
+    return await request.put({ url: `/iot/device-modbus-point/update`, data })
+  },
+
+  /** 删除 Modbus 点位配置 */
+  deleteModbusPoint: async (id: number) => {
+    return await request.delete({ url: `/iot/device-modbus-point/delete?id=${id}` })
+  }
+}

+ 44 - 0
src/api/iot/ota/firmware/index.ts

@@ -0,0 +1,44 @@
+import request from '@/config/axios'
+
+/** IoT OTA 固件信息 */
+export interface IoTOtaFirmware {
+  id?: number // 固件编号
+  name?: string // 固件名称
+  description?: string // 固件描述
+  version?: string // 版本号
+  productId?: number // 产品编号
+  productName?: string // 产品名称
+  fileUrl?: string // 固件文件 URL
+  fileSize?: number // 固件文件大小
+  fileDigestAlgorithm?: string // 固件文件签名算法
+  fileDigestValue?: string // 固件文件签名结果
+  createTime?: Date // 创建时间
+}
+
+// IoT OTA 固件 API
+export const IoTOtaFirmwareApi = {
+  // 查询 OTA 固件分页
+  getOtaFirmwarePage: async (params: any) => {
+    return await request.get({ url: `/iot/ota/firmware/page`, params })
+  },
+
+  // 查询 OTA 固件详情
+  getOtaFirmware: async (id: number) => {
+    return await request.get({ url: `/iot/ota/firmware/get?id=` + id })
+  },
+
+  // 新增 OTA 固件
+  createOtaFirmware: async (data: IoTOtaFirmware) => {
+    return await request.post({ url: `/iot/ota/firmware/create`, data })
+  },
+
+  // 修改 OTA 固件
+  updateOtaFirmware: async (data: IoTOtaFirmware) => {
+    return await request.put({ url: `/iot/ota/firmware/update`, data })
+  },
+
+  // 删除 OTA 固件
+  deleteOtaFirmware: async (id: number) => {
+    return await request.delete({ url: `/iot/ota/firmware/delete?id=` + id })
+  }
+}

+ 38 - 0
src/api/iot/ota/task/index.ts

@@ -0,0 +1,38 @@
+import request from '@/config/axios'
+
+/** IoT OTA 任务信息 */
+export interface OtaTask {
+  id?: number // 任务编号
+  name: string // 任务名称
+  description?: string // 任务描述
+  firmwareId?: number // 固件编号
+  status: number // 任务状态
+  deviceScope?: number // 升级范围
+  deviceIds?: number[] // 指定设备ID列表(当升级范围为指定设备时使用)
+  deviceTotalCount?: number // 设备总共数量
+  deviceSuccessCount?: number // 设备成功数量
+  createTime?: Date // 创建时间
+}
+
+// IoT OTA 任务 API
+export const IoTOtaTaskApi = {
+  // 查询 OTA 升级任务分页
+  getOtaTaskPage: async (params: any) => {
+    return await request.get({ url: `/iot/ota/task/page`, params })
+  },
+
+  // 查询 OTA 升级任务详情
+  getOtaTask: async (id: number) => {
+    return await request.get({ url: `/iot/ota/task/get?id=` + id })
+  },
+
+  // 创建 OTA 升级任务
+  createOtaTask: async (data: OtaTask) => {
+    return await request.post({ url: `/iot/ota/task/create`, data })
+  },
+
+  // 取消 OTA 升级任务
+  cancelOtaTask: async (id: number) => {
+    return await request.post({ url: `/iot/ota/task/cancel?id=` + id })
+  }
+}

+ 38 - 0
src/api/iot/ota/task/record/index.ts

@@ -0,0 +1,38 @@
+import request from '@/config/axios'
+
+/** IoT OTA 任务记录信息 */
+export interface OtaTaskRecord {
+  id?: number // 升级记录编号
+  firmwareId?: number // 固件编号
+  firmwareVersion?: string // 固件版本
+  taskId?: number // 任务编号
+  deviceId?: string // 设备编号
+  deviceName?: string // 设备名称
+  currentVersion?: string // 当前版本
+  fromFirmwareId?: number // 来源的固件编号
+  fromFirmwareVersion?: string // 来源的固件版本
+  status?: number // 升级状态
+  progress?: number // 升级进度,百分比
+  description?: string // 升级进度描述
+  updateTime?: Date // 更新时间
+}
+
+// IoT OTA 任务记录 API
+export const IoTOtaTaskRecordApi = {
+  getOtaTaskRecordStatusStatistics: async (firmwareId?: number, taskId?: number) => {
+    const params: any = {}
+    if (firmwareId) params.firmwareId = firmwareId
+    if (taskId) params.taskId = taskId
+    return await request.get({ url: `/iot/ota/task/record/get-status-statistics`, params })
+  },
+
+  // 查询 OTA 任务记录分页
+  getOtaTaskRecordPage: async (params: any) => {
+    return await request.get({ url: `/iot/ota/task/record/page`, params })
+  },
+
+  // 取消 OTA 任务记录
+  cancelOtaTaskRecord: async (id: number) => {
+    return await request.put({ url: `/iot/ota/task/record/cancel?id=` + id })
+  }
+}

+ 0 - 51
src/api/iot/plugin/index.ts

@@ -1,51 +0,0 @@
-import request from '@/config/axios'
-
-// IoT 插件配置 VO
-export interface PluginConfigVO {
-  id: number // 主键ID
-  pluginKey: string // 插件标识
-  name: string // 插件名称
-  description: string // 描述
-  deployType: number // 部署方式
-  fileName: string // 插件包文件名
-  version: string // 插件版本
-  type: number // 插件类型
-  protocol: string // 设备插件协议类型
-  status: number // 状态
-  configSchema: string // 插件配置项描述信息
-  config: string // 插件配置信息
-  script: string // 插件脚本
-}
-
-// IoT 插件配置 API
-export const PluginConfigApi = {
-  // 查询插件配置分页
-  getPluginConfigPage: async (params: any) => {
-    return await request.get({ url: `/iot/plugin-config/page`, params })
-  },
-
-  // 查询插件配置详情
-  getPluginConfig: async (id: number) => {
-    return await request.get({ url: `/iot/plugin-config/get?id=` + id })
-  },
-
-  // 新增插件配置
-  createPluginConfig: async (data: PluginConfigVO) => {
-    return await request.post({ url: `/iot/plugin-config/create`, data })
-  },
-
-  // 修改插件配置
-  updatePluginConfig: async (data: PluginConfigVO) => {
-    return await request.put({ url: `/iot/plugin-config/update`, data })
-  },
-
-  // 删除插件配置
-  deletePluginConfig: async (id: number) => {
-    return await request.delete({ url: `/iot/plugin-config/delete?id=` + id })
-  },
-
-  // 修改插件状态
-  updatePluginStatus: async (data: any) => {
-    return await request.put({ url: `/iot/plugin-config/update-status`, data })
-  }
-}

+ 28 - 14
src/api/iot/product/product/index.ts

@@ -5,37 +5,46 @@ export interface ProductVO {
   id: number // 产品编号
   name: string // 产品名称
   productKey: string // 产品标识
+  productSecret?: string // 产品密钥
+  registerEnabled?: boolean // 动态注册
   protocolId: number // 协议编号
   categoryId: number // 产品所属品类标识符
   categoryName?: string // 产品所属品类名称
   icon: string // 产品图标
   picUrl: string // 产品图片
   description: string // 产品描述
-  validateType: number // 数据校验级别
   status: number // 产品状态
   deviceType: number // 设备类型
   netType: number // 联网方式
-  protocolType: number // 接入网关协议
-  dataFormat: number // 数据格式
+  protocolType: string // 协议类型
+  serializeType: string // 序列化类型
   deviceCount: number // 设备数量
   createTime: Date // 创建时间
 }
 
-// IOT 数据校验级别枚举类
-export enum ValidateTypeEnum {
-  WEAK = 0, // 弱校验
-  NONE = 1 // 免校验
-}
 // IOT 产品设备类型枚举类 0: 直连设备, 1: 网关子设备, 2: 网关设备
 export enum DeviceTypeEnum {
   DEVICE = 0, // 直连设备
   GATEWAY_SUB = 1, // 网关子设备
   GATEWAY = 2 // 网关设备
 }
-// IOT 数据格式枚举类
-export enum DataFormatEnum {
-  JSON = 0, // 标准数据格式(JSON)
-  CUSTOMIZE = 1 // 透传/自定义
+// IoT 协议类型枚举
+export enum ProtocolTypeEnum {
+  TCP = 'tcp',
+  UDP = 'udp',
+  WEBSOCKET = 'websocket',
+  HTTP = 'http',
+  MQTT = 'mqtt',
+  EMQX = 'emqx',
+  COAP = 'coap',
+  MODBUS_TCP_CLIENT = 'modbus_tcp_client',
+  MODBUS_TCP_SERVER = 'modbus_tcp_server'
+}
+
+// IoT 序列化类型枚举
+export enum SerializeTypeEnum {
+  JSON = 'json',
+  BINARY = 'binary'
 }
 
 // IoT 产品 API
@@ -76,7 +85,12 @@ export const ProductApi = {
   },
 
   // 查询产品(精简)列表
-  getSimpleProductList() {
-    return request.get({ url: '/iot/product/simple-list' })
+  getSimpleProductList(deviceType?: number) {
+    return request.get({ url: '/iot/product/simple-list', params: { deviceType } })
+  },
+
+  // 根据 ProductKey 获取产品信息
+  getProductByKey: async (productKey: string) => {
+    return await request.get({ url: `/iot/product/get-by-key`, params: { productKey } })
   }
 }

+ 39 - 0
src/api/iot/rule/data/rule/index.ts

@@ -0,0 +1,39 @@
+import request from '@/config/axios'
+
+/** IoT 数据流转规则信息 */
+export interface DataRule {
+  id: number // 场景编号
+  name?: string // 场景名称
+  description: string // 场景描述
+  status?: number // 场景状态
+  sourceConfigs?: any[] // 数据源配置数组
+  sinkIds?: number[] // 数据目的编号数组
+}
+
+// IoT 数据流转规则 API
+export const DataRuleApi = {
+  // 查询数据流转规则分页
+  getDataRulePage: async (params: any) => {
+    return await request.get({ url: `/iot/data-rule/page`, params })
+  },
+
+  // 查询数据流转规则详情
+  getDataRule: async (id: number) => {
+    return await request.get({ url: `/iot/data-rule/get?id=` + id })
+  },
+
+  // 新增数据流转规则
+  createDataRule: async (data: DataRule) => {
+    return await request.post({ url: `/iot/data-rule/create`, data })
+  },
+
+  // 修改数据流转规则
+  updateDataRule: async (data: DataRule) => {
+    return await request.put({ url: `/iot/data-rule/update`, data })
+  },
+
+  // 删除数据流转规则
+  deleteDataRule: async (id: number) => {
+    return await request.delete({ url: `/iot/data-rule/delete?id=` + id })
+  }
+}

+ 169 - 0
src/api/iot/rule/data/sink/index.ts

@@ -0,0 +1,169 @@
+import request from '@/config/axios'
+
+// IoT 数据流转目的 VO
+export interface DataSinkVO {
+  id?: number // 桥梁编号
+  name?: string // 桥梁名称
+  description?: string // 桥梁描述
+  status?: number // 桥梁状态
+  direction?: number // 桥梁方向
+  type?: number // 桥梁类型
+  config?:
+    | HttpConfig
+    | TcpConfig
+    | WebSocketConfig
+    | MqttConfig
+    | DatabaseConfig
+    | RocketMQConfig
+    | KafkaMQConfig
+    | RabbitMQConfig
+    | RedisStreamMQConfig // 桥梁配置
+}
+
+interface Config {
+  type: string
+}
+
+/** HTTP 配置 */
+export interface HttpConfig extends Config {
+  url: string
+  method: string
+  headers: Record<string, string>
+  query: Record<string, string>
+  body: string
+}
+
+/** TCP 配置 */
+export interface TcpConfig extends Config {
+  host: string
+  port: number
+  connectTimeoutMs: number
+  readTimeoutMs: number
+  ssl: boolean
+  sslCertPath: string
+  dataFormat: string
+  heartbeatIntervalMs: number
+  reconnectIntervalMs: number
+  maxReconnectAttempts: number
+}
+
+/** WebSocket 配置 */
+export interface WebSocketConfig extends Config {
+  serverUrl: string
+  connectTimeoutMs: number
+  sendTimeoutMs: number
+  heartbeatIntervalMs: number
+  heartbeatMessage: string
+  subprotocols: string
+  customHeaders: string
+  verifySslCert: boolean
+  dataFormat: string
+  reconnectIntervalMs: number
+  maxReconnectAttempts: number
+  enableCompression: boolean
+  sendRetryCount: number
+  sendRetryIntervalMs: number
+}
+
+/** MQTT 配置 */
+export interface MqttConfig extends Config {
+  url: string
+  username: string
+  password: string
+  clientId: string
+  topic: string
+}
+
+/** Database 配置 */
+export interface DatabaseConfig extends Config {
+  jdbcUrl: string
+  username: string
+  password: string
+  tableName: string
+}
+
+/** RocketMQ 配置 */
+export interface RocketMQConfig extends Config {
+  nameServer: string
+  accessKey: string
+  secretKey: string
+  group: string
+  topic: string
+  tags: string
+}
+
+/** Kafka 配置 */
+export interface KafkaMQConfig extends Config {
+  bootstrapServers: string
+  username: string
+  password: string
+  ssl: boolean
+  topic: string
+}
+
+/** RabbitMQ 配置 */
+export interface RabbitMQConfig extends Config {
+  host: string
+  port: number
+  virtualHost: string
+  username: string
+  password: string
+  exchange: string
+  routingKey: string
+  queue: string
+}
+
+/** Redis Stream MQ 配置 */
+export interface RedisStreamMQConfig extends Config {
+  host: string
+  port: number
+  password: string
+  database: number
+  topic: string
+}
+
+/** 数据流转目的类型 */
+export const IotDataSinkTypeEnum = {
+  HTTP: 1,
+  TCP: 2,
+  WEBSOCKET: 3,
+  MQTT: 10,
+  DATABASE: 20,
+  REDIS_STREAM: 21,
+  ROCKETMQ: 30,
+  RABBITMQ: 31,
+  KAFKA: 32
+} as const
+
+// 数据流转目的 API
+export const DataSinkApi = {
+  // 查询数据流转目的分页
+  getDataSinkPage: async (params: any) => {
+    return await request.get({ url: `/iot/data-sink/page`, params })
+  },
+
+  // 查询数据流转目的详情
+  getDataSink: async (id: number) => {
+    return await request.get({ url: `/iot/data-sink/get?id=` + id })
+  },
+
+  // 新增数据流转目的
+  createDataSink: async (data: DataSinkVO) => {
+    return await request.post({ url: `/iot/data-sink/create`, data })
+  },
+
+  // 修改数据流转目的
+  updateDataSink: async (data: DataSinkVO) => {
+    return await request.put({ url: `/iot/data-sink/update`, data })
+  },
+
+  // 删除数据流转目的
+  deleteDataSink: async (id: number) => {
+    return await request.delete({ url: `/iot/data-sink/delete?id=` + id })
+  },
+
+  // 查询数据流转目的(精简)列表
+  getDataSinkSimpleList() {
+    return request.get({ url: '/iot/data-sink/simple-list' })
+  }
+}

+ 0 - 127
src/api/iot/rule/databridge/index.ts

@@ -1,127 +0,0 @@
-import request from '@/config/axios'
-
-// IoT 数据桥梁 VO
-export interface DataBridgeVO {
-  id?: number // 桥梁编号
-  name?: string // 桥梁名称
-  description?: string // 桥梁描述
-  status?: number // 桥梁状态
-  direction?: number // 桥梁方向
-  type?: number // 桥梁类型
-  config?:
-    | HttpConfig
-    | MqttConfig
-    | RocketMQConfig
-    | KafkaMQConfig
-    | RabbitMQConfig
-    | RedisStreamMQConfig // 桥梁配置
-}
-
-interface Config {
-  type: string
-}
-
-/** HTTP 配置 */
-export interface HttpConfig extends Config {
-  url: string
-  method: string
-  headers: Record<string, string>
-  query: Record<string, string>
-  body: string
-}
-
-/** MQTT 配置 */
-export interface MqttConfig extends Config {
-  url: string
-  username: string
-  password: string
-  clientId: string
-  topic: string
-}
-
-/** RocketMQ 配置 */
-export interface RocketMQConfig extends Config {
-  nameServer: string
-  accessKey: string
-  secretKey: string
-  group: string
-  topic: string
-  tags: string
-}
-
-/** Kafka 配置 */
-export interface KafkaMQConfig extends Config {
-  bootstrapServers: string
-  username: string
-  password: string
-  ssl: boolean
-  topic: string
-}
-
-/** RabbitMQ 配置 */
-export interface RabbitMQConfig extends Config {
-  host: string
-  port: number
-  virtualHost: string
-  username: string
-  password: string
-  exchange: string
-  routingKey: string
-  queue: string
-}
-
-/** Redis Stream MQ 配置 */
-export interface RedisStreamMQConfig extends Config {
-  host: string
-  port: number
-  password: string
-  database: number
-  topic: string
-}
-
-/** 数据桥梁类型 */
-// TODO @puhui999:枚举用 number 可以么?
-export const IoTDataBridgeConfigType = {
-  HTTP: '1',
-  TCP: '2',
-  WEBSOCKET: '3',
-  MQTT: '10',
-  DATABASE: '20',
-  REDIS_STREAM: '21',
-  ROCKETMQ: '30',
-  RABBITMQ: '31',
-  KAFKA: '32'
-} as const
-
-// 数据桥梁 API
-export const DataBridgeApi = {
-  // 查询数据桥梁分页
-  getDataBridgePage: async (params: any) => {
-    return await request.get({ url: `/iot/data-bridge/page`, params })
-  },
-
-  // 查询数据桥梁详情
-  getDataBridge: async (id: number) => {
-    return await request.get({ url: `/iot/data-bridge/get?id=` + id })
-  },
-
-  // 新增数据桥梁
-  createDataBridge: async (data: DataBridgeVO) => {
-    return await request.post({ url: `/iot/data-bridge/create`, data })
-  },
-
-  // 修改数据桥梁
-  updateDataBridge: async (data: DataBridgeVO) => {
-    return await request.put({ url: `/iot/data-bridge/update`, data })
-  },
-
-  // 删除数据桥梁
-  deleteDataBridge: async (id: number) => {
-    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 })
-  }
-}

+ 87 - 0
src/api/iot/rule/scene/index.ts

@@ -0,0 +1,87 @@
+import request from '@/config/axios'
+
+// 场景联动
+export interface IotSceneRule {
+  id?: number // 场景编号
+  name: string // 场景名称
+  description?: string // 场景描述
+  status: number // 场景状态:0-开启,1-关闭
+  triggers: Trigger[] // 触发器数组
+  actions: Action[] // 执行器数组
+}
+
+// 触发器结构
+export interface Trigger {
+  type: number // 触发类型
+  productId?: number // 产品编号
+  deviceId?: number // 设备编号
+  identifier?: string // 物模型标识符
+  operator?: string // 操作符
+  value?: string // 参数值
+  cronExpression?: string // CRON 表达式
+  conditionGroups?: TriggerCondition[][] // 条件组(二维数组)
+}
+
+// 触发条件结构
+export interface TriggerCondition {
+  type: number // 条件类型:1-设备状态,2-设备属性,3-当前时间
+  productId?: number // 产品编号
+  deviceId?: number // 设备编号
+  identifier?: string // 标识符
+  operator: string // 操作符
+  param: string // 参数
+}
+
+// 执行器结构
+export interface Action {
+  type: number // 执行类型
+  productId?: number // 产品编号
+  deviceId?: number // 设备编号
+  identifier?: string // 物模型标识符(服务调用时使用)
+  params?: string // 请求参数
+  alertConfigId?: number // 告警配置编号
+}
+
+// IoT 场景联动 API
+export const RuleSceneApi = {
+  // 查询场景联动分页
+  getRuleScenePage: async (params: any) => {
+    return await request.get({ url: `/iot/scene-rule/page`, params })
+  },
+
+  // 查询场景联动详情
+  getRuleScene: async (id: number) => {
+    return await request.get({ url: `/iot/scene-rule/get?id=` + id })
+  },
+
+  // 新增场景联动
+  createRuleScene: async (data: IotSceneRule) => {
+    return await request.post({ url: `/iot/scene-rule/create`, data })
+  },
+
+  // 修改场景联动
+  updateRuleScene: async (data: IotSceneRule) => {
+    return await request.put({ url: `/iot/scene-rule/update`, data })
+  },
+
+  // 修改场景联动
+  updateRuleSceneStatus: async (id: number, status: number) => {
+    return await request.put({
+      url: `/iot/scene-rule/update-status`,
+      data: {
+        id,
+        status
+      }
+    })
+  },
+
+  // 删除场景联动
+  deleteRuleScene: async (id: number) => {
+    return await request.delete({ url: `/iot/scene-rule/delete?id=` + id })
+  },
+
+  // 获取场景联动简单列表
+  getSimpleRuleSceneList: async () => {
+    return await request.get({ url: `/iot/scene-rule/simple-list` })
+  }
+}

+ 29 - 11
src/api/iot/statistics/index.ts

@@ -16,25 +16,43 @@ export interface IotStatisticsSummaryRespVO {
   productCategoryDeviceCounts: Record<string, number>
 }
 
-/** IoT 消息统计数据类型 */
-export interface IotStatisticsDeviceMessageSummaryRespVO {
-  upstreamCounts: Record<number, number>
-  downstreamCounts: Record<number, number>
+/** 新的消息统计数据项 */
+export interface IotStatisticsDeviceMessageSummaryByDateRespVO {
+  time: string
+  upstreamCount: number
+  downstreamCount: number
+}
+
+/** 新的消息统计接口参数 */
+export interface IotStatisticsDeviceMessageReqVO {
+  interval: number
+  times?: string[]
+}
+
+/** 设备位置数据 VO */
+export interface DeviceLocationRespVO {
+  id: number
+  deviceName: string
+  nickname?: string
+  productName?: string
+  state: number
+  longitude: number
+  latitude: number
 }
 
 // IoT 数据统计 API
-export const ProductCategoryApi = {
-  // 查询基础的数据统计
-  getIotStatisticsSummary: async () => {
+export const StatisticsApi = {
+  // 查询全局的数据统计
+  getStatisticsSummary: async () => {
     return await request.get<IotStatisticsSummaryRespVO>({
       url: `/iot/statistics/get-summary`
     })
   },
 
-  // 查询设备上下行消息的数据统计
-  getIotStatisticsDeviceMessageSummary: async (params: { startTime: number; endTime: number }) => {
-    return await request.get<IotStatisticsDeviceMessageSummaryRespVO>({
-      url: `/iot/statistics/get-log-summary`,
+  // 获取设备消息的数据统计
+  getDeviceMessageSummaryByDate: async (params: IotStatisticsDeviceMessageReqVO) => {
+    return await request.get<IotStatisticsDeviceMessageSummaryByDateRespVO[]>({
+      url: `/iot/statistics/get-device-message-summary-by-date`,
       params
     })
   }

+ 225 - 12
src/api/iot/thingmodel/index.ts

@@ -1,4 +1,5 @@
 import request from '@/config/axios'
+import { isEmpty } from '@/utils/is'
 
 /**
  * IoT 产品物模型
@@ -17,14 +18,6 @@ export interface ThingModelData {
   service?: ThingModelService // 服务
 }
 
-/**
- * IoT 模拟设备
- */
-// TODO @super:和 ThingModelSimulatorData 会不会好点
-export interface SimulatorData extends ThingModelData {
-  simulateValue?: string | number // 用于存储模拟值 TODO @super:字段使用 value 会不会好点
-}
-
 /**
  * ThingModelProperty 类型
  */
@@ -46,6 +39,127 @@ export interface ThingModelService {
   [key: string]: any
 }
 
+/** dataSpecs 数值型数据结构 */
+export interface DataSpecsNumberData {
+  dataType: 'int' | 'float' | 'double' // 数据类型,取值为 INT、FLOAT 或 DOUBLE
+  max: string // 最大值,必须与 dataType 设置一致,且为 STRING 类型
+  min: string // 最小值,必须与 dataType 设置一致,且为 STRING 类型
+  step: string // 步长,必须与 dataType 设置一致,且为 STRING 类型
+  precise?: string // 精度,当 dataType 为 FLOAT 或 DOUBLE 时可选
+  defaultValue?: string // 默认值,可选
+  unit: string // 单位的符号
+  unitName: string // 单位的名称
+}
+
+/** dataSpecs 枚举型数据结构 */
+export interface DataSpecsEnumOrBoolData {
+  dataType: 'enum' | 'bool'
+  defaultValue?: string // 默认值,可选
+  name: string // 枚举项的名称
+  value: number | undefined // 枚举值
+}
+
+/** 物模型TSL响应数据结构 */
+export interface IotThingModelTSLResp {
+  productId: number
+  productKey: string
+  properties: ThingModelProperty[]
+  events: ThingModelEvent[]
+  services: ThingModelService[]
+}
+
+/** 物模型属性 */
+export interface ThingModelProperty {
+  identifier: string
+  name: string
+  accessMode: string
+  required?: boolean
+  dataType: string
+  description?: string
+  dataSpecs?: ThingModelProperty
+  dataSpecsList?: ThingModelProperty[]
+}
+
+/** 物模型事件 */
+export interface ThingModelEvent {
+  identifier: string
+  name: string
+  required?: boolean
+  type: string
+  description?: string
+  outputParams?: ThingModelParam[]
+  method?: string
+}
+
+/** 物模型服务 */
+export interface ThingModelService {
+  identifier: string
+  name: string
+  required?: boolean
+  callType: string
+  description?: string
+  inputParams?: ThingModelParam[]
+  outputParams?: ThingModelParam[]
+  method?: string
+}
+
+/** 物模型参数 */
+export interface ThingModelParam {
+  identifier: string
+  name: string
+  direction: string
+  paraOrder?: number
+  dataType: string
+  dataSpecs?: ThingModelProperty
+  dataSpecsList?: ThingModelProperty[]
+}
+
+/** 数值型数据规范 */
+export interface ThingModelNumericDataSpec {
+  dataType: 'int' | 'float' | 'double'
+  max: string
+  min: string
+  step: string
+  precise?: string
+  defaultValue?: string
+  unit?: string
+  unitName?: string
+}
+
+/** 布尔/枚举型数据规范 */
+export interface ThingModelBoolOrEnumDataSpecs {
+  dataType: 'bool' | 'enum'
+  name: string
+  value: number
+}
+
+/** 文本/时间型数据规范 */
+export interface ThingModelDateOrTextDataSpecs {
+  dataType: 'text' | 'date'
+  length?: number
+  defaultValue?: string
+}
+
+/** 数组型数据规范 */
+export interface ThingModelArrayDataSpecs {
+  dataType: 'array'
+  size: number
+  childDataType: string
+  dataSpecsList?: ThingModelProperty[]
+}
+
+/** 结构体型数据规范 */
+export interface ThingModelStructDataSpecs {
+  dataType: 'struct'
+  identifier: string
+  name: string
+  accessMode: string
+  required?: boolean
+  childDataType: string
+  dataSpecs?: ThingModelProperty
+  dataSpecsList?: ThingModelProperty[]
+}
+
 // IoT 产品物模型 API
 export const ThingModelApi = {
   // 查询产品物模型分页
@@ -58,11 +172,10 @@ export const ThingModelApi = {
     return await request.get({ url: `/iot/thing-model/list`, params })
   },
 
-  // 获得产品物模型
-  getThingModelListByProductId: async (params: any) => {
+  // 获得产品物模型 TSL
+  getThingModelTSLByProductId: async (productId: number) => {
     return await request.get({
-      url: `/iot/thing-model/list-by-product-id`,
-      params
+      url: `/iot/thing-model/get-tsl?productId=${productId}`
     })
   },
 
@@ -86,3 +199,103 @@ export const ThingModelApi = {
     return await request.delete({ url: `/iot/thing-model/delete?id=` + id })
   }
 }
+
+/** 公共校验规则 */
+export const ThingModelFormRules = {
+  name: [
+    { required: true, message: '功能名称不能为空', trigger: 'blur' },
+    {
+      pattern: /^[\u4e00-\u9fa5a-zA-Z0-9][\u4e00-\u9fa5a-zA-Z0-9\-_/\.]{0,29}$/,
+      message:
+        '支持中文、大小写字母、日文、数字、短划线、下划线、斜杠和小数点,必须以中文、英文或数字开头,不超过 30 个字符',
+      trigger: 'blur'
+    }
+  ],
+  type: [{ required: true, message: '功能类型不能为空', trigger: 'blur' }],
+  identifier: [
+    { required: true, message: '标识符不能为空', trigger: 'blur' },
+    {
+      pattern: /^[a-zA-Z0-9_]{1,50}$/,
+      message: '支持大小写字母、数字和下划线,不超过 50 个字符',
+      trigger: 'blur'
+    },
+    {
+      validator: (_: any, value: string, callback: any) => {
+        const reservedKeywords = ['set', 'get', 'post', 'property', 'event', 'time', 'value']
+        if (reservedKeywords.includes(value)) {
+          callback(
+            new Error(
+              'set, get, post, property, event, time, value 是系统保留字段,不能用于标识符定义'
+            )
+          )
+        } else if (/^\d+$/.test(value)) {
+          callback(new Error('标识符不能是纯数字'))
+        } else {
+          callback()
+        }
+      },
+      trigger: 'blur'
+    }
+  ],
+  'property.dataSpecs.childDataType': [{ required: true, message: '元素类型不能为空' }],
+  'property.dataSpecs.size': [
+    { required: true, message: '元素个数不能为空' },
+    {
+      validator: (_: any, value: any, callback: any) => {
+        if (isEmpty(value)) {
+          callback(new Error('元素个数不能为空'))
+          return
+        }
+        if (isNaN(Number(value))) {
+          callback(new Error('元素个数必须是数字'))
+          return
+        }
+        callback()
+      },
+      trigger: 'blur'
+    }
+  ],
+  'property.dataSpecs.length': [
+    { required: true, message: '请输入文本字节长度', trigger: 'blur' },
+    {
+      validator: (_: any, value: any, callback: any) => {
+        if (isEmpty(value)) {
+          callback(new Error('文本长度不能为空'))
+          return
+        }
+        if (isNaN(Number(value))) {
+          callback(new Error('文本长度必须是数字'))
+          return
+        }
+        callback()
+      },
+      trigger: 'blur'
+    }
+  ],
+  'property.accessMode': [{ required: true, message: '请选择读写类型', trigger: 'change' }]
+}
+
+/** 校验布尔值名称 */
+export const validateBoolName = (_: any, value: string, callback: any) => {
+  if (isEmpty(value)) {
+    callback(new Error('布尔值名称不能为空'))
+    return
+  }
+  // 检查开头字符
+  if (!/^[\u4e00-\u9fa5a-zA-Z0-9]/.test(value)) {
+    callback(new Error('布尔值名称必须以中文、英文字母或数字开头'))
+    return
+  }
+  // 检查整体格式
+  if (!/^[\u4e00-\u9fa5a-zA-Z0-9][a-zA-Z0-9\u4e00-\u9fa5_-]*$/.test(value)) {
+    callback(new Error('布尔值名称只能包含中文、英文字母、数字、下划线和短划线'))
+    return
+  }
+  // 检查长度(一个中文算一个字符)
+  if (value.length > 20) {
+    callback(new Error('布尔值名称长度不能超过 20 个字符'))
+    return
+  }
+
+  callback()
+}

+ 4 - 0
src/components/Map/index.ts

@@ -0,0 +1,4 @@
+import MapDialog from './src/MapDialog.vue'
+export { loadBaiduMapSdk } from './src/utils'
+
+export { MapDialog }

+ 259 - 0
src/components/Map/src/MapDialog.vue

@@ -0,0 +1,259 @@
+<!-- 地图选择弹窗组件:基于百度地图 GL 实现 -->
+<template>
+  <Dialog
+    title="百度地图"
+    v-model="dialogVisible"
+    @opened="handleDialogOpened"
+    @closed="handleDialogClosed"
+  >
+    <div class="w-full">
+      <!-- 第一行:位置搜索 -->
+      <el-form label-width="80px">
+        <el-form-item label="定位位置">
+          <el-select
+            class="w-full"
+            v-model="state.address"
+            clearable
+            filterable
+            remote
+            reserve-keyword
+            placeholder="可输入地址查询经纬度"
+            :remote-method="autoSearch"
+            @change="handleAddressSelect"
+            :loading="state.loading"
+          >
+            <el-option
+              v-for="item in state.mapAddressOptions"
+              :key="item.value"
+              :label="item.name"
+              :value="item.value"
+            />
+          </el-select>
+        </el-form-item>
+        <!-- 第二行:坐标显示 -->
+        <el-form-item label="当前坐标">
+          <div class="flex items-center gap-4">
+            <span>经度: {{ state.longitude || '-' }}</span>
+            <span>纬度: {{ state.latitude || '-' }}</span>
+          </div>
+        </el-form-item>
+      </el-form>
+      <!-- 第三行:地图 -->
+      <div
+        v-if="state.mapContainerReady"
+        ref="mapContainerRef"
+        class="w-full h-[400px] mt-[10px]"
+      ></div>
+      <div v-else class="w-full h-[400px] mt-[10px] flex items-center justify-center">
+        <span class="text-gray-400">地图加载中...</span>
+      </div>
+    </div>
+    <template #footer>
+      <el-button @click="handleConfirm" type="primary">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import { reactive, ref, nextTick } from 'vue'
+import { loadBaiduMapSdk } from './utils'
+
+const emits = defineEmits(['confirm'])
+
+const dialogVisible = ref(false)
+const mapContainerRef = ref<HTMLElement>()
+const state = reactive({
+  lonLat: '', // 经纬度字符串,格式为 "经度,纬度"
+  address: '', // 地址信息
+  loading: false, // 地址搜索加载状态
+  latitude: '', // 纬度
+  longitude: '', // 经度
+  map: null as any, // 百度地图实例
+  mapAddressOptions: [] as any[], // 地址搜索选项
+  mapMarker: null as any, // 地图标记点
+  geocoder: null as any, // 地理编码器实例
+  mapContainerReady: false // 地图容器是否准备好
+})
+
+// 初始经纬度(打开弹窗时传入)
+const initLongitude = ref<number | undefined>()
+const initLatitude = ref<number | undefined>()
+
+/** 打开弹窗 */
+const open = (longitude?: number, latitude?: number) => {
+  initLongitude.value = longitude
+  initLatitude.value = latitude
+  state.longitude = longitude ? String(longitude) : ''
+  state.latitude = latitude ? String(latitude) : ''
+  state.address = ''
+  state.mapAddressOptions = []
+  dialogVisible.value = true
+}
+
+defineExpose({ open })
+
+/** 弹窗打开动画完成后初始化地图 */
+const handleDialogOpened = async () => {
+  // 先显示地图容器
+  state.mapContainerReady = true
+
+  // 等待下一个 DOM 更新周期,确保地图容器已渲染
+  await nextTick()
+  // 加载百度地图 SDK
+  await loadBaiduMapSdk()
+  initMapInstance()
+}
+
+/** 弹窗关闭后清理地图 */
+const handleDialogClosed = () => {
+  // 销毁地图实例
+  if (state.map) {
+    state.map.destroy?.()
+    state.map = null
+  }
+  state.mapMarker = null
+  state.geocoder = null
+  state.mapContainerReady = false
+}
+
+/** 初始化地图实例 */
+const initMapInstance = () => {
+  if (!mapContainerRef.value) {
+    return
+  }
+
+  // 初始化地图和地理编码器
+  initMap()
+  initGeocoder()
+
+  // 监听地图点击事件
+  state.map.addEventListener('click', (e: any) => {
+    const point = e.latlng
+    state.lonLat = point.lng + ',' + point.lat
+    regeoCode(state.lonLat)
+  })
+
+  // 如果有初始经纬度,加载标记点
+  if (initLongitude.value && initLatitude.value) {
+    const lonLat = `${initLongitude.value},${initLatitude.value}`
+    regeoCode(lonLat)
+  }
+}
+
+/** 初始化地图 */
+const initMap = () => {
+  state.map = new window.BMapGL.Map(mapContainerRef.value)
+  state.map.centerAndZoom(new window.BMapGL.Point(116.404, 39.915), 11)
+  state.map.enableScrollWheelZoom()
+  state.map.disableDoubleClickZoom()
+
+  state.map.addControl(new window.BMapGL.NavigationControl())
+  state.map.addControl(new window.BMapGL.ScaleControl())
+  state.map.addControl(new window.BMapGL.ZoomControl())
+}
+
+/** 初始化地理编码器 */
+const initGeocoder = () => {
+  state.geocoder = new window.BMapGL.Geocoder()
+}
+
+/** 搜索地址 */
+const autoSearch = (queryValue: string) => {
+  if (!queryValue) {
+    state.mapAddressOptions = []
+    return
+  }
+
+  state.loading = true
+
+  // noinspection JSUnusedGlobalSymbols
+  const localSearch = new window.BMapGL.LocalSearch(state.map, {
+    onSearchComplete: (results: any) => {
+      state.loading = false
+      const temp: any[] = []
+
+      if (results && results._pois) {
+        results._pois.forEach((p: any) => {
+          const point = p.point
+          if (point && point.lng && point.lat) {
+            temp.push({
+              name: p.title,
+              value: point.lng + ',' + point.lat
+            })
+          }
+        })
+      }
+
+      state.mapAddressOptions = temp
+    }
+  })
+
+  localSearch.search(queryValue)
+}
+
+/** 处理地址选择 */
+const handleAddressSelect = (value: string) => {
+  if (value) {
+    regeoCode(value)
+  }
+}
+
+/** 添加标记点 */
+const setMarker = (lnglat: string[]) => {
+  if (!lnglat) {
+    return
+  }
+
+  if (state.mapMarker !== null) {
+    state.map.removeOverlay(state.mapMarker)
+  }
+
+  const point = new window.BMapGL.Point(lnglat[0], lnglat[1])
+  state.mapMarker = new window.BMapGL.Marker(point)
+
+  state.map.addOverlay(state.mapMarker)
+  state.map.centerAndZoom(point, 16)
+}
+
+/** 经纬度转地址、添加标记点 */
+const regeoCode = (lonLat: string) => {
+  if (!lonLat) {
+    return
+  }
+  const lnglat = lonLat.split(',')
+  if (lnglat.length !== 2) {
+    return
+  }
+
+  state.longitude = lnglat[0]
+  state.latitude = lnglat[1]
+  const point = new window.BMapGL.Point(lnglat[0], lnglat[1])
+  state.map.centerAndZoom(point, 16)
+
+  setMarker(lnglat)
+  getAddress(lnglat)
+}
+
+/** 根据经纬度获取地址信息 */
+const getAddress = (lnglat: string[]) => {
+  const point = new window.BMapGL.Point(lnglat[0], lnglat[1])
+  state.geocoder.getLocation(point, (result: any) => {
+    if (result && result.address) {
+      state.address = result.address
+    }
+  })
+}
+
+/** 确认选择 */
+const handleConfirm = () => {
+  if (state.longitude && state.latitude) {
+    emits('confirm', {
+      longitude: state.longitude,
+      latitude: state.latitude,
+      address: state.address
+    })
+  }
+  dialogVisible.value = false
+}
+</script>

+ 62 - 0
src/components/Map/src/utils.ts

@@ -0,0 +1,62 @@
+/**
+ * 百度地图 SDK 加载工具
+ */
+
+// 扩展 Window 接口以包含百度地图 GL API
+declare global {
+  interface Window {
+    BMapGL: any
+  }
+}
+
+// 全局回调名称
+const CALLBACK_NAME = '__BAIDU_MAP_LOAD_CALLBACK__'
+
+// SDK 加载状态
+let loadPromise: Promise<void> | null = null
+
+/**
+ * 加载百度地图 GL SDK
+ * @param timeout 超时时间(毫秒),默认 10000
+ * @returns Promise<void>
+ */
+export const loadBaiduMapSdk = (timeout = 10000): Promise<void> => {
+  // 已加载完成
+  if (window.BMapGL) {
+    return Promise.resolve()
+  }
+
+  // 正在加载中,返回同一个 Promise
+  if (loadPromise) {
+    return loadPromise
+  }
+
+  loadPromise = new Promise((resolve, reject) => {
+    const timeoutId = setTimeout(() => {
+      loadPromise = null
+      reject(new Error('百度地图 SDK 加载超时'))
+    }, timeout)
+
+    // 全局回调
+    ;(window as any)[CALLBACK_NAME] = () => {
+      clearTimeout(timeoutId)
+      delete (window as any)[CALLBACK_NAME]
+      resolve()
+    }
+
+    // 创建 script 标签
+    const script = document.createElement('script')
+    script.src = `https://api.map.baidu.com/api?v=1.0&type=webgl&ak=${
+      import.meta.env.VITE_BAIDU_MAP_KEY
+    }&callback=${CALLBACK_NAME}`
+    script.onerror = () => {
+      clearTimeout(timeoutId)
+      loadPromise = null
+      delete (window as any)[CALLBACK_NAME]
+      reject(new Error('百度地图 SDK 加载失败'))
+    }
+    document.body.appendChild(script)
+  })
+
+  return loadPromise
+}

+ 32 - 11
src/router/modules/remaining.ts

@@ -2120,18 +2120,18 @@ const remainingRouter: AppRouteRecordRaw[] = [
           activeMenu: '/iot/device/device'
         },
         component: () => import('@/views/iot/device/device/detail/index.vue')
-      },
-      {
-        path: 'plugin/detail/:id',
-        name: 'IoTPluginDetail',
-        meta: {
-          title: '插件详情',
-          noCache: true,
-          hidden: true,
-          activeMenu: '/iot/plugin'
-        },
-        component: () => import('@/views/iot/plugin/detail/index.vue')
       }
+      // {
+      //   path: 'plugin/detail/:id',
+      //   name: 'IoTPluginDetail',
+      //   meta: {
+      //     title: '插件详情',
+      //     noCache: true,
+      //     hidden: true,
+      //     activeMenu: '/iot/plugin'
+      //   },
+      //   component: () => import('@/views/iot/plugin/detail/index.vue')
+      // }
     ]
   },
 
@@ -2243,6 +2243,27 @@ const remainingRouter: AppRouteRecordRaw[] = [
       //   }
       // }
     ]
+  },
+
+  // qhse事故事件上报详情
+  {
+    path: '/qhse',
+    component: Layout,
+    name: 'Qhse',
+    meta: {
+      hidden: true
+    },
+    children: [
+      {
+        path: 'qhse_faultReport/detail',
+        component: () => import('@/views/pms/qhse/faultReport/QhseFaultReportInfo.vue'),
+        name: 'QhseFaultReportDetail',
+        meta: {
+          title: '事故事件详情',
+          hidden: true
+        }
+      }
+    ]
   }
 ]
 

+ 471 - 0
src/utils/cron.ts

@@ -0,0 +1,471 @@
+/**
+ * CRON 表达式工具类
+ * 提供 CRON 表达式的解析、格式化、验证等功能
+ */
+
+/** CRON 字段类型枚举 */
+export enum CronFieldType {
+  SECOND = 'second',
+  MINUTE = 'minute',
+  HOUR = 'hour',
+  DAY = 'day',
+  MONTH = 'month',
+  WEEK = 'week',
+  YEAR = 'year'
+}
+
+/** CRON 字段配置 */
+export interface CronFieldConfig {
+  key: CronFieldType
+  label: string
+  min: number
+  max: number
+  names?: Record<string, number> // 名称映射,如月份名称
+}
+
+/** CRON 字段配置常量 */
+export const CRON_FIELD_CONFIGS: Record<CronFieldType, CronFieldConfig> = {
+  [CronFieldType.SECOND]: { key: CronFieldType.SECOND, label: '秒', min: 0, max: 59 },
+  [CronFieldType.MINUTE]: { key: CronFieldType.MINUTE, label: '分', min: 0, max: 59 },
+  [CronFieldType.HOUR]: { key: CronFieldType.HOUR, label: '时', min: 0, max: 23 },
+  [CronFieldType.DAY]: { key: CronFieldType.DAY, label: '日', min: 1, max: 31 },
+  [CronFieldType.MONTH]: {
+    key: CronFieldType.MONTH,
+    label: '月',
+    min: 1,
+    max: 12,
+    names: {
+      JAN: 1,
+      FEB: 2,
+      MAR: 3,
+      APR: 4,
+      MAY: 5,
+      JUN: 6,
+      JUL: 7,
+      AUG: 8,
+      SEP: 9,
+      OCT: 10,
+      NOV: 11,
+      DEC: 12
+    }
+  },
+  [CronFieldType.WEEK]: {
+    key: CronFieldType.WEEK,
+    label: '周',
+    min: 0,
+    max: 7,
+    names: {
+      SUN: 0,
+      MON: 1,
+      TUE: 2,
+      WED: 3,
+      THU: 4,
+      FRI: 5,
+      SAT: 6
+    }
+  },
+  [CronFieldType.YEAR]: { key: CronFieldType.YEAR, label: '年', min: 1970, max: 2099 }
+}
+
+/** 解析后的 CRON 字段 */
+export interface ParsedCronField {
+  type: 'any' | 'specific' | 'range' | 'step' | 'list' | 'last' | 'weekday' | 'nth'
+  values: number[]
+  original: string
+  description: string
+}
+
+/** 解析后的 CRON 表达式 */
+export interface ParsedCronExpression {
+  second: ParsedCronField
+  minute: ParsedCronField
+  hour: ParsedCronField
+  day: ParsedCronField
+  month: ParsedCronField
+  week: ParsedCronField
+  year?: ParsedCronField
+  isValid: boolean
+  description: string
+  nextExecutionTime?: Date
+}
+
+/** 常用 CRON 表达式预设 */
+export const CRON_PRESETS = {
+  EVERY_SECOND: '* * * * * ?',
+  EVERY_MINUTE: '0 * * * * ?',
+  EVERY_HOUR: '0 0 * * * ?',
+  EVERY_DAY: '0 0 0 * * ?',
+  EVERY_WEEK: '0 0 0 ? * 1',
+  EVERY_MONTH: '0 0 0 1 * ?',
+  EVERY_YEAR: '0 0 0 1 1 ?',
+  WORKDAY_9AM: '0 0 9 ? * 2-6', // 工作日上午9点
+  WORKDAY_6PM: '0 0 18 ? * 2-6', // 工作日下午6点
+  WEEKEND_10AM: '0 0 10 ? * 1,7' // 周末上午10点
+} as const
+
+/** CRON 表达式工具类 */
+export class CronUtils {
+  /** 验证 CRON 表达式格式 */
+  static validate(cronExpression: string): boolean {
+    if (!cronExpression || typeof cronExpression !== 'string') {
+      return false
+    }
+
+    const parts = cronExpression.trim().split(/\s+/)
+
+    // 支持 5-7 个字段的 CRON 表达式
+    if (parts.length < 5 || parts.length > 7) {
+      return false
+    }
+
+    // 基本格式验证
+    const cronRegex = /^[0-9*\/\-,?LW#]+$/
+    return parts.every((part) => cronRegex.test(part))
+  }
+
+  /** 解析单个 CRON 字段 */
+  static parseField(
+    fieldValue: string,
+    fieldType: CronFieldType,
+    config: CronFieldConfig
+  ): ParsedCronField {
+    const field: ParsedCronField = {
+      type: 'any',
+      values: [],
+      original: fieldValue,
+      description: ''
+    }
+
+    // 处理特殊字符
+    if (fieldValue === '*' || fieldValue === '?') {
+      field.type = 'any'
+      field.description = `每${config.label}`
+      return field
+    }
+
+    // 处理最后一天 (L)
+    if (fieldValue === 'L' && fieldType === CronFieldType.DAY) {
+      field.type = 'last'
+      field.description = '每月最后一天'
+      return field
+    }
+
+    // 处理范围 (-)
+    if (fieldValue.includes('-')) {
+      const [start, end] = fieldValue.split('-').map(Number)
+      if (!isNaN(start) && !isNaN(end) && start >= config.min && end <= config.max) {
+        field.type = 'range'
+        field.values = Array.from({ length: end - start + 1 }, (_, i) => start + i)
+        field.description = `${config.label} ${start}-${end}`
+      }
+      return field
+    }
+
+    // 处理步长 (/)
+    if (fieldValue.includes('/')) {
+      const [base, step] = fieldValue.split('/')
+      const stepNum = Number(step)
+      if (!isNaN(stepNum) && stepNum > 0) {
+        field.type = 'step'
+        if (base === '*') {
+          field.description = `每${stepNum}${config.label}`
+        } else {
+          const startNum = Number(base)
+          field.description = `从${startNum}开始每${stepNum}${config.label}`
+        }
+      }
+      return field
+    }
+
+    // 处理列表 (,)
+    if (fieldValue.includes(',')) {
+      const values = fieldValue
+        .split(',')
+        .map(Number)
+        .filter((n) => !isNaN(n))
+      if (values.length > 0) {
+        field.type = 'list'
+        field.values = values
+        field.description = `${config.label} ${values.join(',')}`
+      }
+      return field
+    }
+
+    // 处理具体数值
+    const numValue = Number(fieldValue)
+    if (!isNaN(numValue) && numValue >= config.min && numValue <= config.max) {
+      field.type = 'specific'
+      field.values = [numValue]
+      field.description = `${config.label} ${numValue}`
+    }
+
+    return field
+  }
+
+  /** 解析完整的 CRON 表达式 */
+  static parse(cronExpression: string): ParsedCronExpression {
+    const result: ParsedCronExpression = {
+      second: { type: 'any', values: [], original: '*', description: '每秒' },
+      minute: { type: 'any', values: [], original: '*', description: '每分' },
+      hour: { type: 'any', values: [], original: '*', description: '每时' },
+      day: { type: 'any', values: [], original: '*', description: '每日' },
+      month: { type: 'any', values: [], original: '*', description: '每月' },
+      week: { type: 'any', values: [], original: '?', description: '任意周' },
+      isValid: false,
+      description: ''
+    }
+
+    if (!this.validate(cronExpression)) {
+      result.description = '无效的 CRON 表达式'
+      return result
+    }
+
+    const parts = cronExpression.trim().split(/\s+/)
+    const fieldTypes = [
+      CronFieldType.SECOND,
+      CronFieldType.MINUTE,
+      CronFieldType.HOUR,
+      CronFieldType.DAY,
+      CronFieldType.MONTH,
+      CronFieldType.WEEK
+    ]
+
+    // 如果只有5个字段,则第一个字段是分钟
+    const startIndex = parts.length === 5 ? 1 : 0
+
+    for (let i = 0; i < parts.length; i++) {
+      const fieldType = fieldTypes[i + startIndex]
+      if (fieldType && CRON_FIELD_CONFIGS[fieldType]) {
+        const config = CRON_FIELD_CONFIGS[fieldType]
+        result[fieldType] = this.parseField(parts[i], fieldType, config)
+      }
+    }
+
+    // 处理年份字段(如果存在)
+    if (parts.length === 7) {
+      const yearConfig = CRON_FIELD_CONFIGS[CronFieldType.YEAR]
+      result.year = this.parseField(parts[6], CronFieldType.YEAR, yearConfig)
+    }
+
+    result.isValid = true
+    result.description = this.generateDescription(result)
+
+    return result
+  }
+
+  /** 生成 CRON 表达式的可读描述 */
+  static generateDescription(parsed: ParsedCronExpression): string {
+    const parts: string[] = []
+
+    // 构建时间部分描述
+    if (parsed.hour.type === 'specific' && parsed.minute.type === 'specific') {
+      const hour = parsed.hour.values[0]
+      const minute = parsed.minute.values[0]
+      parts.push(`${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`)
+    } else if (parsed.hour.type === 'specific') {
+      parts.push(`每天${parsed.hour.values[0]}点`)
+    } else if (parsed.minute.type === 'specific' && parsed.minute.values[0] === 0) {
+      if (parsed.hour.type === 'any') {
+        parts.push('每小时整点')
+      }
+    } else if (parsed.minute.type === 'step') {
+      const step = parsed.minute.original.split('/')[1]
+      parts.push(`每${step}分钟`)
+    } else if (parsed.hour.type === 'step') {
+      const step = parsed.hour.original.split('/')[1]
+      parts.push(`每${step}小时`)
+    }
+
+    // 构建日期部分描述
+    if (parsed.day.type === 'specific') {
+      parts.push(`每月${parsed.day.values[0]}日`)
+    } else if (parsed.week.type === 'specific') {
+      const weekNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
+      const weekDay = parsed.week.values[0]
+      if (weekDay >= 0 && weekDay <= 6) {
+        parts.push(`每${weekNames[weekDay]}`)
+      }
+    } else if (parsed.week.type === 'range') {
+      parts.push('工作日')
+    }
+
+    // 构建月份部分描述
+    if (parsed.month.type === 'specific') {
+      parts.push(`${parsed.month.values[0]}月`)
+    }
+
+    return parts.length > 0 ? parts.join(' ') : '自定义时间规则'
+  }
+
+  /** 格式化 CRON 表达式为可读文本 */
+  static format(cronExpression: string): string {
+    if (!cronExpression) return ''
+
+    const parsed = this.parse(cronExpression)
+    return parsed.isValid ? parsed.description : cronExpression
+  }
+
+  /** 获取预设的 CRON 表达式列表 */
+  static getPresets() {
+    return Object.entries(CRON_PRESETS).map(([key, value]) => ({
+      label: this.format(value),
+      value,
+      key
+    }))
+  }
+
+  /** 计算 CRON 表达式的下次执行时间 */
+  static getNextExecutionTime(cronExpression: string, fromDate?: Date): Date | null {
+    const parsed = this.parse(cronExpression)
+    if (!parsed.isValid) {
+      return null
+    }
+
+    const now = fromDate || new Date()
+    // eslint-disable-next-line prefer-const
+    let nextTime = new Date(now.getTime() + 1000) // 从下一秒开始
+
+    // 简化版本:处理常见的 CRON 表达式模式
+    // 对于复杂的 CRON 表达式,建议使用专门的库如 node-cron 或 cron-parser
+
+    // 处理每分钟执行
+    if (parsed.second.type === 'specific' && parsed.minute.type === 'any') {
+      const targetSecond = parsed.second.values[0]
+      nextTime.setSeconds(targetSecond, 0)
+      if (nextTime <= now) {
+        nextTime.setMinutes(nextTime.getMinutes() + 1)
+      }
+      return nextTime
+    }
+
+    // 处理每小时执行
+    if (
+      parsed.second.type === 'specific' &&
+      parsed.minute.type === 'specific' &&
+      parsed.hour.type === 'any'
+    ) {
+      const targetSecond = parsed.second.values[0]
+      const targetMinute = parsed.minute.values[0]
+      nextTime.setMinutes(targetMinute, targetSecond, 0)
+      if (nextTime <= now) {
+        nextTime.setHours(nextTime.getHours() + 1)
+      }
+      return nextTime
+    }
+
+    // 处理每天执行
+    if (
+      parsed.second.type === 'specific' &&
+      parsed.minute.type === 'specific' &&
+      parsed.hour.type === 'specific'
+    ) {
+      const targetSecond = parsed.second.values[0]
+      const targetMinute = parsed.minute.values[0]
+      const targetHour = parsed.hour.values[0]
+
+      nextTime.setHours(targetHour, targetMinute, targetSecond, 0)
+      if (nextTime <= now) {
+        nextTime.setDate(nextTime.getDate() + 1)
+      }
+      return nextTime
+    }
+
+    // 处理步长执行
+    if (parsed.minute.type === 'step') {
+      const step = parseInt(parsed.minute.original.split('/')[1])
+      const currentMinute = nextTime.getMinutes()
+      const nextMinute = Math.ceil(currentMinute / step) * step
+
+      if (nextMinute >= 60) {
+        nextTime.setHours(nextTime.getHours() + 1, 0, 0, 0)
+      } else {
+        nextTime.setMinutes(nextMinute, 0, 0)
+      }
+      return nextTime
+    }
+
+    // 对于其他复杂情况,返回一个估算时间
+    return new Date(now.getTime() + 60000) // 1分钟后
+  }
+
+  /** 获取 CRON 表达式的执行频率描述 */
+  static getFrequencyDescription(cronExpression: string): string {
+    const parsed = this.parse(cronExpression)
+    if (!parsed.isValid) {
+      return '无效表达式'
+    }
+
+    // 计算大概的执行频率
+    if (parsed.second.type === 'any' && parsed.minute.type === 'any') {
+      return '每秒执行'
+    }
+
+    if (parsed.minute.type === 'any' && parsed.hour.type === 'any') {
+      return '每分钟执行'
+    }
+
+    if (parsed.hour.type === 'any' && parsed.day.type === 'any') {
+      return '每小时执行'
+    }
+
+    if (parsed.day.type === 'any' && parsed.month.type === 'any') {
+      return '每天执行'
+    }
+
+    if (parsed.month.type === 'any') {
+      return '每月执行'
+    }
+
+    return '按计划执行'
+  }
+
+  /** 检查 CRON 表达式是否会在指定时间执行 */
+  static willExecuteAt(cronExpression: string, targetDate: Date): boolean {
+    const parsed = this.parse(cronExpression)
+    if (!parsed.isValid) {
+      return false
+    }
+
+    // 检查各个字段是否匹配
+    const second = targetDate.getSeconds()
+    const minute = targetDate.getMinutes()
+    const hour = targetDate.getHours()
+    const day = targetDate.getDate()
+    const month = targetDate.getMonth() + 1
+    const weekDay = targetDate.getDay()
+
+    return (
+      this.fieldMatches(parsed.second, second) &&
+      this.fieldMatches(parsed.minute, minute) &&
+      this.fieldMatches(parsed.hour, hour) &&
+      this.fieldMatches(parsed.day, day) &&
+      this.fieldMatches(parsed.month, month) &&
+      (parsed.week.type === 'any' || this.fieldMatches(parsed.week, weekDay))
+    )
+  }
+
+  /** 检查字段值是否匹配 */
+  private static fieldMatches(field: ParsedCronField, value: number): boolean {
+    if (field.type === 'any') {
+      return true
+    }
+
+    if (field.type === 'specific' || field.type === 'list') {
+      return field.values.includes(value)
+    }
+
+    if (field.type === 'range') {
+      return value >= field.values[0] && value <= field.values[field.values.length - 1]
+    }
+
+    if (field.type === 'step') {
+      const [base, step] = field.original.split('/').map(Number)
+      if (base === 0 || field.original.startsWith('*')) {
+        return value % step === 0
+      }
+      return value >= base && (value - base) % step === 0
+    }
+
+    return false
+  }
+}

+ 2 - 0
src/utils/dict.ts

@@ -269,6 +269,8 @@ export enum DICT_TYPE {
   IOT_PLUGIN_TYPE = 'iot_plugin_type', // IOT 插件类型
   IOT_DATA_BRIDGE_DIRECTION_ENUM = 'iot_data_bridge_direction_enum', // 桥梁方向
   IOT_DATA_BRIDGE_TYPE_ENUM = 'iot_data_bridge_type_enum', // 桥梁类型
+  IOT_ALERT_LEVEL = 'iot_alert_level', // IOT 告警级别
+  IOT_ALERT_RECEIVE_TYPE = 'iot_alert_receive_type', // IOT 告警接收方式
 
   // ========== PMS模块  ==========
   PMS_INSPECT_WRITE = 'inspect_wirte_normal',

+ 201 - 0
src/views/iot/alert/config/AlertConfigForm.vue

@@ -0,0 +1,201 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="140px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="配置名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入配置名称" />
+      </el-form-item>
+      <el-form-item label="配置描述" prop="description">
+        <el-input v-model="formData.description" placeholder="请输入配置描述" />
+      </el-form-item>
+      <el-form-item label="告警级别" prop="level">
+        <el-select v-model="formData.level" placeholder="请选择告警级别">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_ALERT_LEVEL)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="配置状态" prop="status">
+        <el-select v-model="formData.status">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="关联场景联动规则" prop="sceneRuleIds">
+        <el-select
+          v-model="formData.sceneRuleIds"
+          multiple
+          placeholder="请选择关联的场景联动规则"
+          class="w-full"
+        >
+          <el-option
+            v-for="scene in sceneRuleOptions"
+            :key="scene.id"
+            :label="scene.name"
+            :value="scene.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="接收的用户" prop="receiveUserIds">
+        <el-select
+          v-model="formData.receiveUserIds"
+          multiple
+          placeholder="请选择接收的用户"
+          class="w-full"
+        >
+          <el-option
+            v-for="user in userOptions"
+            :key="user.id"
+            :label="user.nickname"
+            :value="user.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="接收类型" prop="receiveTypes">
+        <el-select
+          v-model="formData.receiveTypes"
+          multiple
+          placeholder="请选择接收类型"
+          class="w-full"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_ALERT_RECEIVE_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import { AlertConfigApi, AlertConfig } from '@/api/iot/alert/config'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { CommonStatusEnum } from '@/utils/constants'
+import { RuleSceneApi } from '@/api/iot/rule/scene'
+import * as UserApi from '@/api/system/user'
+
+/** IoT 告警配置 表单 */
+defineOptions({ name: 'AlertConfigForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref({
+  id: undefined,
+  name: undefined,
+  description: undefined,
+  level: undefined,
+  status: CommonStatusEnum.ENABLE,
+  sceneRuleIds: [],
+  receiveUserIds: [],
+  receiveTypes: []
+})
+const formRules = reactive({
+  name: [{ required: true, message: '配置名称不能为空', trigger: 'blur' }],
+  level: [{ required: true, message: '告警级别不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '配置状态不能为空', trigger: 'blur' }],
+  sceneRuleIds: [{ required: true, message: '关联场景联动规则不能为空', trigger: 'blur' }],
+  receiveUserIds: [{ required: true, message: '接收用户不能为空', trigger: 'blur' }],
+  receiveTypes: [{ required: true, message: '接收类型不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+// 选项数据
+const sceneRuleOptions = ref<any[]>([])
+const userOptions = ref<UserApi.UserVO[]>([])
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await AlertConfigApi.getAlertConfig(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+
+  // 加载选项数据
+  await loadOptions()
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 加载选项数据 */
+const loadOptions = async () => {
+  try {
+    // 加载场景联动规则选项
+    sceneRuleOptions.value = await RuleSceneApi.getSimpleRuleSceneList()
+    // 加载用户选项
+    userOptions.value = await UserApi.getSimpleUserList()
+  } catch (error) {
+    console.error('加载选项数据失败:', error)
+  }
+}
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  await formRef.value.validate()
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as AlertConfig
+    if (formType.value === 'create') {
+      await AlertConfigApi.createAlertConfig(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await AlertConfigApi.updateAlertConfig(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    description: undefined,
+    level: undefined,
+    status: CommonStatusEnum.ENABLE,
+    sceneRuleIds: [],
+    receiveUserIds: [],
+    receiveTypes: []
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 210 - 0
src/views/iot/alert/config/index.vue

@@ -0,0 +1,210 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <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-240px"
+        />
+      </el-form-item>
+      <el-form-item label="配置状态" prop="status">
+        <el-select
+          v-model="queryParams.status"
+          placeholder="请选择配置状态"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-220px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['iot:alert-config:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table
+      row-key="id"
+      v-loading="loading"
+      :data="list"
+      :stripe="true"
+      :show-overflow-tooltip="true"
+    >
+      <el-table-column label="配置编号" align="center" prop="id" />
+      <el-table-column label="配置名称" align="center" prop="name" />
+      <el-table-column label="配置描述" align="center" prop="description" />
+      <el-table-column label="告警级别" align="center" prop="level">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.IOT_ALERT_LEVEL" :value="scope.row.level" />
+        </template>
+      </el-table-column>
+      <el-table-column label="配置状态" align="center" prop="status">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column label="关联场景联动规则" align="center" prop="sceneRuleIds" min-width="100">
+        <template #default="scope"> {{ scope.row.sceneRuleIds?.length || 0 }} 条 </template>
+      </el-table-column>
+      <el-table-column label="接收人" align="center" prop="receiveUserNames" />
+      <el-table-column label="接收类型" align="center" prop="receiveTypes">
+        <template #default="scope">
+          <dict-tag
+            v-for="(receiveType, index) in scope.row.receiveTypes"
+            :key="index"
+            :type="DICT_TYPE.IOT_ALERT_RECEIVE_TYPE"
+            :value="receiveType"
+            class="mr-1"
+          />
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="操作" align="center" min-width="120px">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['iot:alert-config:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['iot:alert-config:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <AlertConfigForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import { AlertConfigApi, AlertConfig } from '@/api/iot/alert/config'
+import AlertConfigForm from './AlertConfigForm.vue'
+
+/** IoT 告警配置 列表 */
+defineOptions({ name: 'IotAlertConfig' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<AlertConfig[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined,
+  status: undefined,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await AlertConfigApi.getAlertConfigPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await AlertConfigApi.deleteAlertConfig(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 296 - 0
src/views/iot/alert/record/index.vue

@@ -0,0 +1,296 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="告警配置" prop="configId">
+        <el-select
+          v-model="queryParams.configId"
+          placeholder="请选择告警配置"
+          clearable
+          filterable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="config in alertConfigList"
+            :key="config.id"
+            :label="config.name"
+            :value="config.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="告警级别" prop="configLevel">
+        <el-select
+          v-model="queryParams.configLevel"
+          placeholder="请选择告警级别"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_ALERT_LEVEL)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="产品" prop="productId">
+        <el-select
+          v-model="queryParams.productId"
+          placeholder="请选择产品"
+          clearable
+          filterable
+          @change="handleProductChange"
+          class="!w-240px"
+        >
+          <el-option
+            v-for="product in productList"
+            :key="product.id"
+            :label="product.name"
+            :value="product.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="设备" prop="deviceId">
+        <el-select
+          v-model="queryParams.deviceId"
+          placeholder="请选择设备"
+          clearable
+          filterable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="device in filteredDeviceList"
+            :key="device.id"
+            :label="device.deviceName"
+            :value="device.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="是否处理" prop="processStatus">
+        <el-select
+          v-model="queryParams.processStatus"
+          placeholder="请选择是否处理"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
+            :key="String(dict.value)"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-220px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table
+      row-key="id"
+      v-loading="loading"
+      :data="list"
+      :stripe="true"
+      :show-overflow-tooltip="true"
+    >
+      <el-table-column label="记录编号" align="center" prop="id" />
+      <el-table-column label="告警名称" align="center" prop="configName" />
+      <el-table-column label="告警级别" align="center" prop="configLevel">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.IOT_ALERT_LEVEL" :value="scope.row.configLevel" />
+        </template>
+      </el-table-column>
+      <el-table-column label="产品名称" align="center" prop="productId">
+        <template #default="scope">
+          {{ getProductName(scope.row.productId) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="设备名称" align="center" prop="deviceId">
+        <template #default="scope">
+          {{ getDeviceName(scope.row.deviceId) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="触发的设备消息" align="center" prop="deviceMessage">
+        <template #default="scope">
+          <el-popover
+            placement="top-start"
+            :width="600"
+            trigger="hover"
+            v-if="scope.row.deviceMessage"
+          >
+            <template #reference>
+              <el-button link type="primary">
+                <Icon icon="ep:view" class="mr-5px" />
+                查看消息
+              </el-button>
+            </template>
+            <pre>{{ scope.row.deviceMessage }}</pre>
+          </el-popover>
+          <span v-else class="text-gray-400">-</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="是否处理" align="center" prop="processStatus">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.processStatus" />
+        </template>
+      </el-table-column>
+      <el-table-column label="处理结果" align="center" prop="processRemark" />
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="操作" align="center" min-width="120px">
+        <template #default="scope">
+          <el-button
+            v-if="!scope.row.processStatus"
+            link
+            type="primary"
+            @click="handleProcess(scope.row)"
+            v-hasPermi="['iot:alert-record:process']"
+          >
+            处理
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+</template>
+
+<script setup lang="ts">
+import { dateFormatter } from '@/utils/formatTime'
+import { AlertRecordApi, AlertRecord } from '@/api/iot/alert/record'
+import { AlertConfigApi, AlertConfig } from '@/api/iot/alert/config'
+import { ProductApi, ProductVO } from '@/api/iot/product/product'
+import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
+import { DICT_TYPE, getIntDictOptions, getBoolDictOptions } from '@/utils/dict'
+
+/** IoT 告警记录列表 */
+defineOptions({ name: 'IotAlertRecord' })
+
+const message = useMessage() // 消息弹窗
+
+const loading = ref(true) // 列表的加载中
+const list = ref<AlertRecord[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const alertConfigList = ref<AlertConfig[]>([]) // 告警配置列表
+const productList = ref<ProductVO[]>([]) // 产品列表
+const deviceList = ref<DeviceVO[]>([]) // 设备列表
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  configId: undefined as number | undefined,
+  configLevel: undefined as number | undefined,
+  productId: undefined as number | undefined,
+  deviceId: undefined as number | undefined,
+  processStatus: undefined as boolean | undefined,
+  createTime: [] as string[]
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 根据选择的产品 ID,筛选设备列表 */
+const filteredDeviceList = computed(() => {
+  if (!queryParams.productId) {
+    return deviceList.value
+  }
+  return deviceList.value.filter((device) => device.productId === queryParams.productId)
+})
+
+/** 根据产品 ID 获取产品名称 */
+const getProductName = (productId: number) => {
+  if (!productId) {
+    return `-`
+  }
+  const product = productList.value.find((p) => p.id === productId)
+  return product ? product.name : `加载中...`
+}
+
+/** 根据设备 ID 获取设备名称 */
+const getDeviceName = (deviceId: number) => {
+  if (!deviceId) {
+    return `-`
+  }
+  const device = deviceList.value.find((d) => d.id === deviceId)
+  return device ? device.deviceName : `加载中...`
+}
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await AlertRecordApi.getAlertRecordPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 产品变更处理 */
+const handleProductChange = () => {
+  queryParams.deviceId = undefined // 清空设备选择
+}
+
+/** 处理告警记录 */
+const handleProcess = async (row: AlertRecord) => {
+  try {
+    const { value: processRemark } = await ElMessageBox.prompt('请输入处理原因', '处理告警记录', {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消'
+    })
+    await AlertRecordApi.processAlertRecord(row.id, processRemark)
+    message.success('处理成功')
+    await getList()
+  } catch (error) {}
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  await getList()
+  alertConfigList.value = await AlertConfigApi.getSimpleAlertConfigList()
+  productList.value = await ProductApi.getSimpleProductList()
+  deviceList.value = await DeviceApi.getSimpleDeviceList()
+})
+</script>

+ 84 - 61
src/views/iot/device/device/DeviceForm.vue

@@ -23,19 +23,6 @@
           />
         </el-select>
       </el-form-item>
-      <el-form-item label="DeviceKey" prop="deviceKey">
-        <el-input
-          v-model="formData.deviceKey"
-          placeholder="请输入 DeviceKey"
-          :disabled="formType === 'update'"
-        >
-          <template #append>
-            <el-button @click="generateDeviceKey" :disabled="formType === 'update'">
-              重新生成
-            </el-button>
-          </template>
-        </el-input>
-      </el-form-item>
       <el-form-item label="DeviceName" prop="deviceName">
         <el-input
           v-model="formData.deviceName"
@@ -43,20 +30,6 @@
           :disabled="formType === 'update'"
         />
       </el-form-item>
-      <el-form-item
-        v-if="formData.deviceType === DeviceTypeEnum.GATEWAY_SUB"
-        label="网关设备"
-        prop="gatewayId"
-      >
-        <el-select v-model="formData.gatewayId" placeholder="子设备可选择父设备" clearable>
-          <el-option
-            v-for="gateway in gatewayDevices"
-            :key="gateway.id"
-            :label="gateway.nickname || gateway.deviceName"
-            :value="gateway.id"
-          />
-        </el-select>
-      </el-form-item>
 
       <el-collapse>
         <el-collapse-item title="更多配置">
@@ -79,6 +52,17 @@
           <el-form-item label="设备序列号" prop="serialNumber">
             <el-input v-model="formData.serialNumber" placeholder="请输入设备序列号" />
           </el-form-item>
+          <el-form-item label="设备位置" prop="longitude">
+            <div class="flex items-center gap-2 w-full">
+              <el-input v-model="formData.longitude" placeholder="经度" class="flex-1">
+                <template #prepend>经度</template>
+              </el-input>
+              <el-input v-model="formData.latitude" placeholder="纬度" class="flex-1">
+                <template #prepend>纬度</template>
+              </el-input>
+              <el-button type="primary" @click="openMapDialog">坐标拾取</el-button>
+            </div>
+          </el-form-item>
         </el-collapse-item>
       </el-collapse>
     </el-form>
@@ -87,13 +71,16 @@
       <el-button @click="dialogVisible = false">取 消</el-button>
     </template>
   </Dialog>
+  <!-- 地图选择弹窗 -->
+  <MapDialog ref="mapDialogRef" @confirm="handleMapConfirm" />
 </template>
 <script setup lang="ts">
 import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
 import { DeviceGroupApi } from '@/api/iot/device/group'
 import { DeviceTypeEnum, ProductApi, ProductVO } from '@/api/iot/product/product'
 import { UploadImg } from '@/components/UploadFile'
-import { generateRandomStr } from '@/utils'
+import { MapDialog } from '@/components/Map'
+import { ref } from 'vue'
 
 /** IoT 设备表单 */
 defineOptions({ name: 'IoTDeviceForm' })
@@ -105,28 +92,37 @@ const dialogVisible = ref(false) // 弹窗的是否展示
 const dialogTitle = ref('') // 弹窗的标题
 const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
 const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const mapDialogRef = ref() // 地图弹窗 Ref
+
 const formData = ref({
   id: undefined,
   productId: undefined,
-  deviceKey: undefined as string | undefined,
   deviceName: undefined,
   nickname: undefined,
   picUrl: undefined,
-  gatewayId: undefined,
   deviceType: undefined as number | undefined,
   serialNumber: undefined,
+  longitude: undefined as number | string | undefined,
+  latitude: undefined as number | string | undefined,
   groupIds: [] as number[]
 })
+
+/** 打开地图选择弹窗 */
+const openMapDialog = () => {
+  mapDialogRef.value?.open(
+    formData.value.longitude ? Number(formData.value.longitude) : undefined,
+    formData.value.latitude ? Number(formData.value.latitude) : undefined
+  )
+}
+
+/** 处理地图选择确认 */
+const handleMapConfirm = (data: { longitude: string; latitude: string; address: string }) => {
+  formData.value.longitude = data.longitude
+  formData.value.latitude = data.latitude
+}
+
 const formRules = reactive({
   productId: [{ required: true, message: '产品不能为空', trigger: 'blur' }],
-  deviceKey: [
-    { required: true, message: 'DeviceKey 不能为空', trigger: 'blur' },
-    {
-      pattern: /^[a-zA-Z0-9]+$/,
-      message: 'DeviceKey 只能包含字母和数字',
-      trigger: 'blur'
-    }
-  ],
   deviceName: [
     { required: true, message: 'DeviceName 不能为空', trigger: 'blur' },
     {
@@ -138,7 +134,7 @@ const formRules = reactive({
   ],
   nickname: [
     {
-      validator: (rule, value, callback) => {
+      validator: (_rule, value: any, callback) => {
         if (value === undefined || value === null) {
           callback()
           return
@@ -161,11 +157,56 @@ const formRules = reactive({
       message: '序列号只能包含字母、数字、中划线和下划线',
       trigger: 'blur'
     }
+  ],
+  longitude: [
+    {
+      validator: (_rule: any, value: any, callback: any) => {
+        if (value !== undefined && value !== null && value !== '') {
+          const num = Number(value)
+          if (isNaN(num)) {
+            callback(new Error('经度必须是有效数字'))
+            return
+          }
+          if (num < -180 || num > 180) {
+            callback(new Error('经度范围为 -180 到 180'))
+            return
+          }
+          if (!formData.value.latitude && formData.value.latitude !== 0) {
+            callback(new Error('请同时填写纬度'))
+            return
+          }
+        }
+        callback()
+      },
+      trigger: 'blur'
+    }
+  ],
+  latitude: [
+    {
+      validator: (_rule: any, value: any, callback: any) => {
+        if (value !== undefined && value !== null && value !== '') {
+          const num = Number(value)
+          if (isNaN(num)) {
+            callback(new Error('纬度必须是有效数字'))
+            return
+          }
+          if (num < -90 || num > 90) {
+            callback(new Error('纬度范围为 -90 到 90'))
+            return
+          }
+          if (!formData.value.longitude && formData.value.longitude !== 0) {
+            callback(new Error('请同时填写经度'))
+            return
+          }
+        }
+        callback()
+      },
+      trigger: 'blur'
+    }
   ]
 })
 const formRef = ref() // 表单 Ref
 const products = ref<ProductVO[]>([]) // 产品列表
-const gatewayDevices = ref<DeviceVO[]>([]) // 网关设备列表
 const deviceGroups = ref<any[]>([])
 
 /** 打开弹窗 */
@@ -183,25 +224,12 @@ const open = async (type: string, id?: number) => {
     } finally {
       formLoading.value = false
     }
-  } else {
-    generateDeviceKey()
   }
 
-  // 加载网关设备列表
-  try {
-    gatewayDevices.value = await DeviceApi.getSimpleDeviceList(DeviceTypeEnum.GATEWAY)
-  } catch (error) {
-    console.error('加载网关设备列表失败:', error)
-  }
   // 加载产品列表
   products.value = await ProductApi.getSimpleProductList()
-
   // 加载设备分组列表
-  try {
-    deviceGroups.value = await DeviceGroupApi.getSimpleDeviceGroupList()
-  } catch (error) {
-    console.error('加载设备分组列表失败:', error)
-  }
+  deviceGroups.value = await DeviceGroupApi.getSimpleDeviceGroupList()
 }
 defineExpose({ open }) // 提供 open 方法,用于打开弹窗
 
@@ -234,13 +262,13 @@ const resetForm = () => {
   formData.value = {
     id: undefined,
     productId: undefined,
-    deviceKey: undefined,
     deviceName: undefined,
     nickname: undefined,
     picUrl: undefined,
-    gatewayId: undefined,
     deviceType: undefined,
     serialNumber: undefined,
+    longitude: undefined,
+    latitude: undefined,
     groupIds: []
   }
   formRef.value?.resetFields()
@@ -255,9 +283,4 @@ const handleProductChange = (productId: number) => {
   const product = products.value?.find((item) => item.id === productId)
   formData.value.deviceType = product?.deviceType
 }
-
-/** 生成 DeviceKey */
-const generateDeviceKey = () => {
-  formData.value.deviceKey = generateRandomStr(16)
-}
 </script>

+ 1 - 1
src/views/iot/device/device/DeviceImportForm.vue

@@ -115,7 +115,7 @@ const submitFormSuccess = (response: any) => {
 /** 上传错误提示 */
 const submitFormError = (): void => {
   message.error('上传失败,请您重新上传!')
-  formLoading.value = false
+  resetForm()
 }
 
 /** 重置表单 */

+ 303 - 0
src/views/iot/device/device/components/DeviceTableSelect.vue

@@ -0,0 +1,303 @@
+<!-- IoT 设备选择,使用弹窗展示 -->
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible" :appendToBody="true" width="60%">
+    <ContentWrap>
+      <!-- 搜索工作栏 -->
+      <el-form
+        ref="queryFormRef"
+        :inline="true"
+        :model="queryParams"
+        class="-mb-15px"
+        label-width="100px"
+      >
+        <el-form-item v-if="!props.productId" label="产品" prop="productId">
+          <el-select
+            v-model="queryParams.productId"
+            placeholder="请选择产品"
+            clearable
+            class="!w-240px"
+          >
+            <el-option
+              v-for="product in products"
+              :key="product.id"
+              :label="product.name"
+              :value="product.id"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="DeviceName" prop="deviceName">
+          <el-input
+            v-model="queryParams.deviceName"
+            placeholder="请输入 DeviceName"
+            clearable
+            @keyup.enter="handleQuery"
+            class="!w-240px"
+          />
+        </el-form-item>
+        <el-form-item label="备注名称" prop="nickname">
+          <el-input
+            v-model="queryParams.nickname"
+            placeholder="请输入备注名称"
+            clearable
+            @keyup.enter="handleQuery"
+            class="!w-240px"
+          />
+        </el-form-item>
+        <el-form-item label="设备类型" prop="deviceType">
+          <el-select
+            v-model="queryParams.deviceType"
+            placeholder="请选择设备类型"
+            clearable
+            class="!w-240px"
+          >
+            <el-option
+              v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE)"
+              :key="dict.value"
+              :label="dict.label"
+              :value="dict.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="设备状态" prop="status">
+          <el-select
+            v-model="queryParams.status"
+            placeholder="请选择设备状态"
+            clearable
+            class="!w-240px"
+          >
+            <el-option
+              v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DEVICE_STATE)"
+              :key="dict.value"
+              :label="dict.label"
+              :value="dict.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="设备分组" prop="groupId">
+          <el-select
+            v-model="queryParams.groupId"
+            placeholder="请选择设备分组"
+            clearable
+            class="!w-240px"
+          >
+            <el-option
+              v-for="group in deviceGroups"
+              :key="group.id"
+              :label="group.name"
+              :value="group.id"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button @click="handleQuery">
+            <Icon class="mr-5px" icon="ep:search" />
+            搜索
+          </el-button>
+          <el-button @click="resetQuery">
+            <Icon class="mr-5px" icon="ep:refresh" />
+            重置
+          </el-button>
+        </el-form-item>
+      </el-form>
+    </ContentWrap>
+
+    <!-- 列表 -->
+    <ContentWrap>
+      <el-table
+        ref="tableRef"
+        v-loading="loading"
+        :data="list"
+        :show-overflow-tooltip="true"
+        :stripe="true"
+        @row-click="handleRowClick"
+        @selection-change="handleSelectionChange"
+      >
+        <el-table-column v-if="multiple" type="selection" width="55" />
+        <el-table-column v-else width="55">
+          <template #default="scope">
+            <el-radio
+              v-model="selectedId"
+              :value="scope.row.id"
+              @change="() => handleRadioChange(scope.row)"
+            >
+              &nbsp;
+            </el-radio>
+          </template>
+        </el-table-column>
+        <el-table-column label="DeviceName" align="center" prop="deviceName" />
+        <el-table-column label="备注名称" align="center" prop="nickname" />
+        <el-table-column label="所属产品" align="center" prop="productId">
+          <template #default="scope">
+            {{ products.find((p) => p.id === scope.row.productId)?.name || '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column label="设备类型" align="center" prop="deviceType">
+          <template #default="scope">
+            <dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="scope.row.deviceType" />
+          </template>
+        </el-table-column>
+        <el-table-column label="所属分组" align="center" prop="groupId">
+          <template #default="scope">
+            <template v-if="scope.row.groupIds?.length">
+              <el-tag v-for="id in scope.row.groupIds" :key="id" class="ml-5px" size="small">
+                {{ deviceGroups.find((g) => g.id === id)?.name }}
+              </el-tag>
+            </template>
+          </template>
+        </el-table-column>
+        <el-table-column label="设备状态" align="center" prop="status">
+          <template #default="scope">
+            <dict-tag :type="DICT_TYPE.IOT_DEVICE_STATE" :value="scope.row.status" />
+          </template>
+        </el-table-column>
+        <el-table-column
+          label="最后上线时间"
+          align="center"
+          prop="onlineTime"
+          :formatter="dateFormatter"
+          width="180px"
+        />
+      </el-table>
+
+      <!-- 分页 -->
+      <Pagination
+        v-model:limit="queryParams.pageSize"
+        v-model:page="queryParams.pageNo"
+        :total="total"
+        @pagination="getList"
+      />
+    </ContentWrap>
+
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
+import { ProductApi, ProductVO } from '@/api/iot/product/product'
+import { DeviceGroupApi, DeviceGroupVO } from '@/api/iot/device/group'
+
+defineOptions({ name: 'IoTDeviceTableSelect' })
+
+const props = defineProps({
+  multiple: {
+    type: Boolean,
+    default: false
+  },
+  productId: {
+    type: Number,
+    default: null
+  }
+})
+
+const message = useMessage()
+const dialogVisible = ref(false)
+const dialogTitle = ref('设备选择器')
+const formLoading = ref(false)
+const loading = ref(true) // 列表的加载中
+const list = ref<DeviceVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const selectedDevices = ref<DeviceVO[]>([]) // 选中的设备列表
+const selectedId = ref<number>() // 单选模式下选中的ID
+const products = ref<ProductVO[]>([]) // 产品列表
+const deviceGroups = ref<DeviceGroupVO[]>([]) // 设备分组列表
+
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  deviceName: undefined,
+  productId: undefined,
+  deviceType: undefined,
+  nickname: undefined,
+  status: undefined,
+  groupId: undefined
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    if (props.productId) {
+      queryParams.productId = props.productId as unknown as any
+    }
+    const data = await DeviceApi.getDevicePage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 打开弹窗 */
+const open = async () => {
+  dialogVisible.value = true
+  // 重置选择状态
+  selectedDevices.value = []
+  selectedId.value = undefined
+  if (!props.productId) {
+    // 获取产品列表
+    products.value = await ProductApi.getSimpleProductList()
+  }
+  // 获取设备列表
+  await getList()
+}
+defineExpose({ open })
+
+/** 处理行点击事件 */
+const tableRef = ref()
+const handleRowClick = (row: DeviceVO) => {
+  if (props.multiple) {
+    tableRef.value?.toggleRowSelection(row)
+  } else {
+    selectedId.value = row.id
+    selectedDevices.value = [row]
+  }
+}
+
+/** 处理单选变更事件 */
+const handleRadioChange = (row: DeviceVO) => {
+  selectedDevices.value = [row]
+}
+
+/** 处理选择变更事件 */
+const handleSelectionChange = (selection: DeviceVO[]) => {
+  if (props.multiple) {
+    selectedDevices.value = selection
+  }
+}
+
+/** 提交表单 */
+const emit = defineEmits(['success'])
+const submitForm = async () => {
+  if (selectedDevices.value.length === 0) {
+    message.warning(props.multiple ? '请至少选择一个设备' : '请选择一个设备')
+    return
+  }
+  emit('success', props.multiple ? selectedDevices.value : selectedDevices.value[0])
+  dialogVisible.value = false
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  // 获取分组列表
+  deviceGroups.value = await DeviceGroupApi.getSimpleDeviceGroupList()
+})
+</script>

+ 0 - 110
src/views/iot/device/device/detail/DeviceDataDetail.vue

@@ -1,110 +0,0 @@
-<!-- 设备物模型 -> 运行状态 -> 查看数据(设备的属性值历史)-->
-<template>
-  <Dialog title="查看数据" v-model="dialogVisible">
-    <ContentWrap>
-      <!-- 搜索工作栏 -->
-      <el-form
-        class="-mb-15px"
-        :model="queryParams"
-        ref="queryFormRef"
-        :inline="true"
-        label-width="68px"
-      >
-        <el-form-item label="时间" prop="createTime">
-          <el-date-picker
-            v-model="queryParams.times"
-            value-format="YYYY-MM-DD HH:mm:ss"
-            type="datetimerange"
-            start-placeholder="开始日期"
-            end-placeholder="结束日期"
-            class="!w-350px"
-          />
-        </el-form-item>
-        <el-form-item>
-          <el-button @click="handleQuery">
-            <Icon icon="ep:search" class="mr-5px" />
-            搜索
-          </el-button>
-        </el-form-item>
-      </el-form>
-    </ContentWrap>
-
-    <!-- TODO @haohao:可参考阿里云 IoT,改成“图标”、“表格”两个选项 -->
-    <!-- 列表 -->
-    <ContentWrap>
-      <el-table v-loading="detailLoading" :data="list" :stripe="true" :show-overflow-tooltip="true">
-        <el-table-column
-          label="时间"
-          align="center"
-          prop="updateTime"
-          :formatter="dateFormatter"
-          width="180px"
-        />
-        <el-table-column label="属性值" align="center" prop="value" />
-      </el-table>
-      <!-- 分页 -->
-      <Pagination
-        :total="total"
-        v-model:page="queryParams.pageNo"
-        v-model:limit="queryParams.pageSize"
-        @pagination="getList"
-      />
-    </ContentWrap>
-  </Dialog>
-</template>
-<script setup lang="ts">
-import { DeviceApi, DeviceHistoryDataVO, DeviceVO } from '@/api/iot/device/device'
-import { ProductVO } from '@/api/iot/product/product'
-import { beginOfDay, dateFormatter, endOfDay, formatDate } from '@/utils/formatTime'
-
-defineProps<{ product: ProductVO; device: DeviceVO }>()
-
-/** IoT 设备数据详情 */
-defineOptions({ name: 'IoTDeviceDataDetail' })
-
-const dialogVisible = ref(false) // 弹窗的是否展示
-const detailLoading = ref(false)
-
-const list = ref<DeviceHistoryDataVO[]>([]) // 列表的数据
-const total = ref(0) // 列表的总页数
-const queryParams = reactive({
-  pageNo: 1,
-  pageSize: 10,
-  deviceId: -1,
-  identifier: '',
-  times: [
-    // 默认显示最近一周的数据
-    formatDate(beginOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24 * 7))),
-    formatDate(endOfDay(new Date()))
-  ]
-})
-const queryFormRef = ref() // 搜索的表单
-
-/** 获得设备历史数据 */
-const getList = async () => {
-  detailLoading.value = true
-  try {
-    const data = await DeviceApi.getHistoryDevicePropertyPage(queryParams)
-    list.value = data.list
-    total.value = data.total
-  } finally {
-    detailLoading.value = false
-  }
-}
-
-/** 打开弹窗 */
-const open = (deviceId: number, identifier: string) => {
-  dialogVisible.value = true
-  queryParams.deviceId = deviceId
-  queryParams.identifier = identifier
-  getList()
-}
-
-/** 搜索按钮操作 */
-const handleQuery = () => {
-  queryParams.pageNo = 1
-  getList()
-}
-
-defineExpose({ open }) // 提供 open 方法,用于打开弹窗
-</script>

+ 42 - 27
src/views/iot/device/device/detail/DeviceDetailConfig.vue

@@ -8,24 +8,10 @@
       class="my-4"
       description="如需编辑文件,请点击下方编辑按钮"
     />
-
-    <!-- JSON 编辑器:读模式 -->
-    <Vue3Jsoneditor
-      v-if="isEditing"
+    <JsonEditor
       v-model="config"
-      :options="editorOptions"
-      height="500px"
-      currentMode="code"
-      @error="onError"
-    />
-    <!-- JSON 编辑器:写模式 -->
-    <Vue3Jsoneditor
-      v-else
-      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">
@@ -34,15 +20,20 @@
         保存
       </el-button>
       <el-button v-else @click="enableEdit">编辑</el-button>
-      <!-- TODO @芋艿:缺一个下发按钮 -->
+      <el-button v-if="!isEditing" type="success" @click="handleConfigPush" :loading="pushLoading">
+        配置推送
+      </el-button>
     </div>
   </div>
 </template>
 
 <script lang="ts" setup>
-import Vue3Jsoneditor from 'v3-jsoneditor/src/Vue3Jsoneditor.vue'
 import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
+import { IotDeviceMessageMethodEnum } from '@/views/iot/utils/constants'
 import { jsonParse } from '@/utils'
+import { isEmpty } from '@/utils/is'
+
+defineOptions({ name: 'DeviceDetailConfig' })
 
 const props = defineProps<{
   device: DeviceVO
@@ -54,6 +45,7 @@ const emit = defineEmits<{
 
 const message = useMessage()
 const loading = ref(false) // 加载中
+const pushLoading = ref(false) // 推送加载中
 const config = ref<any>({}) // 只存储 config 字段
 const hasJsonError = ref(false) // 是否有 JSON 格式错误
 
@@ -63,12 +55,6 @@ watchEffect(() => {
 })
 
 const isEditing = ref(false) // 编辑状态
-const editorOptions = computed(() => ({
-  mainMenuBar: false,
-  navigationBar: false,
-  statusBar: false
-})) // JSON 编辑器的选项
-
 /** 启用编辑模式的函数 */
 const enableEdit = () => {
   isEditing.value = true
@@ -92,6 +78,32 @@ const saveConfig = async () => {
   isEditing.value = false
 }
 
+/** 配置推送处理函数 */
+const handleConfigPush = async () => {
+  try {
+    // 二次确认
+    await message.confirm('确定要推送配置到设备吗?此操作将远程更新设备配置。', '配置推送确认')
+
+    pushLoading.value = true
+
+    // 调用配置推送接口
+    await DeviceApi.sendDeviceMessage({
+      deviceId: props.device.id,
+      method: IotDeviceMessageMethodEnum.CONFIG_PUSH.method,
+      params: config.value
+    })
+
+    message.success('配置推送成功!')
+  } catch (error) {
+    if (error !== 'cancel') {
+      message.error('配置推送失败!')
+      console.error('配置推送错误:', error)
+    }
+  } finally {
+    pushLoading.value = false
+  }
+}
+
 /** 更新设备配置 */
 const updateDeviceConfig = async () => {
   try {
@@ -112,8 +124,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>

+ 10 - 5
src/views/iot/device/device/detail/DeviceDetailsHeader.vue

@@ -39,8 +39,10 @@
 import DeviceForm from '@/views/iot/device/device/DeviceForm.vue'
 import { ProductVO } from '@/api/iot/product/product'
 import { DeviceVO } from '@/api/iot/device/device'
+import { useClipboard } from '@vueuse/core'
 
 const message = useMessage()
+const { t } = useI18n() // 国际化
 const router = useRouter()
 
 const { product, device } = defineProps<{ product: ProductVO; device: DeviceVO }>()
@@ -54,11 +56,14 @@ const openForm = (type: string, id?: number) => {
 
 /** 复制到剪贴板方法 */
 const copyToClipboard = async (text: string) => {
-  try {
-    await navigator.clipboard.writeText(text)
-    message.success('复制成功')
-  } catch (error) {
-    message.error('复制失败')
+  const { copy, copied, isSupported } = useClipboard({ legacy: true, source: text })
+  if (!isSupported) {
+    message.error(t('common.copyError'))
+    return
+  }
+  await copy()
+  if (unref(copied)) {
+    message.success(t('common.copySuccess'))
   }
 }
 

+ 130 - 114
src/views/iot/device/device/detail/DeviceDetailsInfo.vue

@@ -1,144 +1,160 @@
 <!-- 设备信息 -->
 <template>
-  <ContentWrap>
-    <el-descriptions :column="3" title="设备信息">
-      <el-descriptions-item label="产品名称">{{ product.name }}</el-descriptions-item>
-      <el-descriptions-item label="ProductKey">
-        {{ product.productKey }}
-        <el-button @click="copyToClipboard(product.productKey)">复制</el-button>
-      </el-descriptions-item>
-      <el-descriptions-item label="设备类型">
-        <dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="product.deviceType" />
-      </el-descriptions-item>
-      <el-descriptions-item label="DeviceName">
-        {{ device.deviceName }}
-        <el-button @click="copyToClipboard(device.deviceName)">复制</el-button>
-      </el-descriptions-item>
-      <el-descriptions-item label="备注名称">{{ device.nickname }}</el-descriptions-item>
-      <el-descriptions-item label="创建时间">
-        {{ formatDate(device.createTime) }}
-      </el-descriptions-item>
-      <el-descriptions-item label="当前状态">
-        <dict-tag :type="DICT_TYPE.IOT_DEVICE_STATE" :value="device.state" />
-      </el-descriptions-item>
-      <el-descriptions-item label="激活时间">
-        {{ formatDate(device.activeTime) }}
-      </el-descriptions-item>
-      <el-descriptions-item label="最后上线时间">
-        {{ formatDate(device.onlineTime) }}
-      </el-descriptions-item>
-      <el-descriptions-item label="最后离线时间" :span="3">
-        {{ formatDate(device.offlineTime) }}
-      </el-descriptions-item>
-      <el-descriptions-item label="MQTT 连接参数">
-        <el-button type="primary" @click="openMqttParams">查看</el-button>
-      </el-descriptions-item>
-    </el-descriptions>
-  </ContentWrap>
-
-  <!-- MQTT 连接参数弹框 -->
-  <Dialog
-    title="MQTT 连接参数"
-    v-model="mqttDialogVisible"
-    width="50%"
-    :before-close="handleCloseMqttDialog"
-  >
-    <el-form :model="mqttParams" label-width="120px">
-      <el-form-item label="clientId">
-        <el-input v-model="mqttParams.mqttClientId" readonly>
-          <template #append>
-            <el-button @click="copyToClipboard(mqttParams.mqttClientId)" type="primary">
-              <Icon icon="ph:copy" />
-            </el-button>
-          </template>
-        </el-input>
-      </el-form-item>
-      <el-form-item label="username">
-        <el-input v-model="mqttParams.mqttUsername" readonly>
-          <template #append>
-            <el-button @click="copyToClipboard(mqttParams.mqttUsername)" type="primary">
-              <Icon icon="ph:copy" />
-            </el-button>
-          </template>
-        </el-input>
-      </el-form-item>
-      <el-form-item label="passwd">
-        <el-input
-          v-model="mqttParams.mqttPassword"
-          readonly
-          :type="passwordVisible ? 'text' : 'password'"
-        >
-          <template #append>
-            <el-button @click="passwordVisible = !passwordVisible" type="primary">
-              <Icon :icon="passwordVisible ? 'ph:eye-slash' : 'ph:eye'" />
-            </el-button>
-            <el-button @click="copyToClipboard(mqttParams.mqttPassword)" type="primary">
-              <Icon icon="ph:copy" />
+  <div>
+    <ContentWrap>
+      <el-descriptions :column="3" border>
+          <el-descriptions-item label="产品名称">{{ product.name }}</el-descriptions-item>
+          <el-descriptions-item label="ProductKey">{{ product.productKey }}</el-descriptions-item>
+          <el-descriptions-item label="设备类型">
+            <dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="product.deviceType" />
+          </el-descriptions-item>
+          <el-descriptions-item label="DeviceName">{{ device.deviceName }}</el-descriptions-item>
+          <el-descriptions-item label="备注名称">{{ device.nickname }}</el-descriptions-item>
+          <el-descriptions-item label="当前状态">
+            <dict-tag :type="DICT_TYPE.IOT_DEVICE_STATE" :value="device.state" />
+          </el-descriptions-item>
+          <el-descriptions-item label="创建时间">
+            {{ formatDate(device.createTime) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="激活时间">
+            {{ formatDate(device.activeTime) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="最后上线时间">
+            {{ formatDate(device.onlineTime) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="最后离线时间">
+            {{ formatDate(device.offlineTime) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="设备位置">
+            <template v-if="hasLocation">
+              <span class="mr-2">{{ device.longitude }}, {{ device.latitude }}</span>
+              <el-button type="primary" link @click="openMapDialog">
+                <Icon icon="ep:location" class="mr-1" />
+                查看地图
+              </el-button>
+            </template>
+            <span v-else class="text-[var(--el-text-color-secondary)]">暂无位置信息</span>
+          </el-descriptions-item>
+          <el-descriptions-item label="认证信息">
+            <el-button type="primary" @click="handleAuthInfoDialogOpen" plain size="small">
+              查看
             </el-button>
-          </template>
-        </el-input>
-      </el-form-item>
-    </el-form>
-    <template #footer>
-      <el-button @click="mqttDialogVisible = false">关闭</el-button>
-    </template>
-  </Dialog>
+          </el-descriptions-item>
+        </el-descriptions>
+    </ContentWrap>
+
+    <!-- 认证信息弹框 -->
+    <Dialog
+      title="设备认证信息"
+      v-model="authDialogVisible"
+      width="640px"
+      :before-close="handleAuthInfoDialogClose"
+    >
+      <el-form :model="authInfo" label-width="120px">
+        <el-form-item label="clientId">
+          <el-input v-model="authInfo.clientId" readonly>
+            <template #append>
+              <el-button @click="copyToClipboard(authInfo.clientId)" type="primary">
+                <Icon icon="ph:copy" />
+              </el-button>
+            </template>
+          </el-input>
+        </el-form-item>
+        <el-form-item label="username">
+          <el-input v-model="authInfo.username" readonly>
+            <template #append>
+              <el-button @click="copyToClipboard(authInfo.username)" type="primary">
+                <Icon icon="ph:copy" />
+              </el-button>
+            </template>
+          </el-input>
+        </el-form-item>
+        <el-form-item label="password">
+          <el-input
+            v-model="authInfo.password"
+            readonly
+            :type="authPasswordVisible ? 'text' : 'password'"
+          >
+            <template #append>
+              <el-button @click="authPasswordVisible = !authPasswordVisible" type="primary">
+                <Icon :icon="authPasswordVisible ? 'ph:eye-slash' : 'ph:eye'" />
+              </el-button>
+              <el-button @click="copyToClipboard(authInfo.password)" type="primary">
+                <Icon icon="ph:copy" />
+              </el-button>
+            </template>
+          </el-input>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="handleAuthInfoDialogClose">关闭</el-button>
+      </template>
+    </Dialog>
+
+    <!-- 地图弹窗 -->
+    <MapDialog ref="mapDialogRef" />
+  </div>
 
   <!-- TODO 待开发:设备标签 -->
-  <!-- TODO 待开发:设备地图 -->
 </template>
 <script setup lang="ts">
 import { DICT_TYPE } from '@/utils/dict'
 import { ProductVO } from '@/api/iot/product/product'
 import { formatDate } from '@/utils/formatTime'
 import { DeviceVO } from '@/api/iot/device/device'
-import { DeviceApi, MqttConnectionParamsVO } from '@/api/iot/device/device/index'
+import { DeviceApi, IotDeviceAuthInfoVO } from '@/api/iot/device/device'
+import { MapDialog } from '@/components/Map'
+import { ref, computed } from 'vue'
+import { useClipboard } from '@vueuse/core'
 
 const message = useMessage() // 消息提示
+const { t } = useI18n() // 国际化
 
 const { product, device } = defineProps<{ product: ProductVO; device: DeviceVO }>() // 定义 Props
 const emit = defineEmits(['refresh']) // 定义 Emits
 
-const mqttDialogVisible = ref(false) // 定义 MQTT 弹框的可见性
-const passwordVisible = ref(false) // 定义密码可见性状态
-const mqttParams = ref({
-  mqttClientId: '',
-  mqttUsername: '',
-  mqttPassword: ''
-}) // 定义 MQTT 参数对象
+const authDialogVisible = ref(false) // 定义设备认证信息弹框的可见性
+const authPasswordVisible = ref(false) // 定义密码可见性状态
+const authInfo = ref<IotDeviceAuthInfoVO>({} as IotDeviceAuthInfoVO) // 定义设备认证信息对象
+const mapDialogRef = ref() // 地图弹窗 Ref
+
+/** 是否有位置信息 */
+const hasLocation = computed(() => {
+  return !!(device.longitude && device.latitude)
+})
+
+/** 打开地图弹窗 */
+const openMapDialog = () => {
+  mapDialogRef.value?.open(device.longitude, device.latitude)
+}
 
 /** 复制到剪贴板方法 */
 const copyToClipboard = async (text: string) => {
-  try {
-    await navigator.clipboard.writeText(text)
-    message.success('复制成功')
-  } catch (error) {
-    message.error('复制失败')
+  const { copy, copied, isSupported } = useClipboard({ legacy: true, source: text })
+  if (!isSupported) {
+    message.error(t('common.copyError'))
+    return
+  }
+  await copy()
+  if (unref(copied)) {
+    message.success(t('common.copySuccess'))
   }
 }
 
-/** 打开 MQTT 参数弹框的方法 */
-const openMqttParams = async () => {
+/** 打开设备认证信息弹框的方法 */
+const handleAuthInfoDialogOpen = async () => {
   try {
-    const data = await DeviceApi.getMqttConnectionParams(device.id)
-    // 根据 API 响应结构正确获取数据
-    // TODO @haohao:'N/A' 是不是在 ui 里处理哈
-    mqttParams.value = {
-      mqttClientId: data.mqttClientId || 'N/A',
-      mqttUsername: data.mqttUsername || 'N/A',
-      mqttPassword: data.mqttPassword || 'N/A'
-    }
-
-    // 显示 MQTT 弹框
-    mqttDialogVisible.value = true
+    authInfo.value = await DeviceApi.getDeviceAuthInfo(device.id)
+    // 显示设备认证信息弹框
+    authDialogVisible.value = true
   } catch (error) {
-    console.error('获取 MQTT 连接参数出错:', error)
-    message.error('获取MQTT连接参数失败,请检查网络连接或联系管理员')
+    console.error('获取设备认证信息出错:', error)
+    message.error('获取设备认证信息失败,请检查网络连接或联系管理员')
   }
 }
 
-/** 关闭 MQTT 弹框的方法 */
-const handleCloseMqttDialog = () => {
-  mqttDialogVisible.value = false
+/** 关闭设备认证信息弹框的方法 */
+const handleAuthInfoDialogClose = () => {
+  authDialogVisible.value = false
 }
 </script>

+ 0 - 166
src/views/iot/device/device/detail/DeviceDetailsLog.vue

@@ -1,166 +0,0 @@
-<!-- 设备日志 -->
-<template>
-  <ContentWrap>
-    <!-- 搜索区域 -->
-    <el-form :model="queryParams" inline>
-      <el-form-item>
-        <el-select v-model="queryParams.type" placeholder="所有" class="!w-160px">
-          <el-option label="所有" value="" />
-          <!-- TODO @super:搞成枚举 -->
-          <el-option label="状态" value="state" />
-          <el-option label="事件" value="event" />
-          <el-option label="属性" value="property" />
-          <el-option label="服务" value="service" />
-        </el-select>
-      </el-form-item>
-      <el-form-item>
-        <el-input v-model="queryParams.identifier" placeholder="日志识符" class="!w-200px" />
-      </el-form-item>
-      <el-form-item>
-        <el-button type="primary" @click="handleQuery">
-          <Icon icon="ep:search" class="mr-5px" /> 搜索
-        </el-button>
-        <el-switch
-          size="large"
-          width="80"
-          v-model="autoRefresh"
-          class="ml-20px"
-          inline-prompt
-          active-text="定时刷新"
-          inactive-text="定时刷新"
-          style="--el-switch-on-color: #13ce66"
-        />
-      </el-form-item>
-    </el-form>
-
-    <!-- 日志列表 -->
-    <el-table v-loading="loading" :data="list" :stripe="true" class="whitespace-nowrap">
-      <el-table-column label="时间" align="center" prop="ts" width="180">
-        <template #default="scope">
-          {{ formatDate(scope.row.ts) }}
-        </template>
-      </el-table-column>
-      <el-table-column label="类型" align="center" prop="type" width="120" />
-      <!-- TODO @super:标识符需要翻译 -->
-      <el-table-column label="标识符" align="center" prop="identifier" width="120" />
-      <el-table-column label="内容" align="center" prop="content" :show-overflow-tooltip="true" />
-    </el-table>
-
-    <!-- 分页 -->
-    <div class="mt-10px flex justify-end">
-      <Pagination
-        :total="total"
-        v-model:page="queryParams.pageNo"
-        v-model:limit="queryParams.pageSize"
-        @pagination="getLogList"
-      />
-    </div>
-  </ContentWrap>
-</template>
-
-<script setup lang="ts">
-import { DeviceApi } from '@/api/iot/device/device'
-import { formatDate } from '@/utils/formatTime'
-
-const props = defineProps<{
-  deviceKey: string
-}>()
-
-// 查询参数
-const queryParams = reactive({
-  deviceKey: props.deviceKey,
-  type: '',
-  identifier: '',
-  pageNo: 1,
-  pageSize: 10
-})
-
-// 列表数据
-const loading = ref(false)
-const total = ref(0)
-const list = ref([])
-const autoRefresh = ref(false)
-let timer: any = null // TODO @super:autoRefreshEnable,autoRefreshTimer;对应上
-
-// 类型映射 TODO @super:需要删除么?
-const typeMap = {
-  lifetime: '生命周期',
-  state: '设备状态',
-  property: '属性',
-  event: '事件',
-  service: '服务'
-}
-
-/** 查询日志列表 */
-const getLogList = async () => {
-  if (!props.deviceKey) return
-  loading.value = true
-  try {
-    const data = await DeviceApi.getDeviceLogPage(queryParams)
-    total.value = data.total
-    list.value = data.list
-  } finally {
-    loading.value = false
-  }
-}
-
-/** 获取日志名称 */
-const getLogName = (log: any) => {
-  const { type, identifier } = log
-  let name = '未知'
-
-  if (type === 'property') {
-    if (identifier === 'set_reply') name = '设置回复'
-    else if (identifier === 'report') name = '上报'
-    else if (identifier === 'set') name = '设置'
-  } else if (type === 'state') {
-    name = identifier === 'online' ? '上线' : '下线'
-  } else if (type === 'lifetime') {
-    name = identifier === 'register' ? '注册' : name
-  }
-
-  return `${name}(${identifier})`
-}
-
-/** 搜索操作 */
-const handleQuery = () => {
-  queryParams.pageNo = 1
-  getLogList()
-}
-
-/** 监听自动刷新 */
-watch(autoRefresh, (newValue) => {
-  if (newValue) {
-    timer = setInterval(() => {
-      getLogList()
-    }, 5000)
-  } else {
-    clearInterval(timer)
-    timer = null
-  }
-})
-
-/** 监听设备标识变化 */
-watch(
-  () => props.deviceKey,
-  (newValue) => {
-    if (newValue) {
-      handleQuery()
-    }
-  }
-)
-
-/** 组件卸载时清除定时器 */
-onBeforeUnmount(() => {
-  if (timer) {
-    clearInterval(timer)
-  }
-})
-
-/** 初始化 */
-onMounted(() => {
-  if (props.deviceKey) {
-    getLogList()
-  }
-})
-</script>

+ 201 - 0
src/views/iot/device/device/detail/DeviceDetailsMessage.vue

@@ -0,0 +1,201 @@
+<!-- 设备消息列表 -->
+<template>
+  <ContentWrap>
+    <!-- 搜索区域 -->
+    <el-form :model="queryParams" inline>
+      <el-form-item>
+        <el-select v-model="queryParams.method" placeholder="所有方法" class="!w-160px" clearable>
+          <el-option
+            v-for="item in methodOptions"
+            :key="item.value"
+            :label="item.label"
+            :value="item.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-select
+          v-model="queryParams.upstream"
+          placeholder="上行/下行"
+          class="!w-160px"
+          clearable
+        >
+          <el-option label="上行" value="true" />
+          <el-option label="下行" value="false" />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" @click="handleQuery">
+          <Icon icon="ep:search" class="mr-5px" /> 搜索
+        </el-button>
+        <el-switch
+          size="large"
+          width="80"
+          v-model="autoRefresh"
+          class="ml-20px"
+          inline-prompt
+          active-text="定时刷新"
+          inactive-text="定时刷新"
+          style="--el-switch-on-color: #13ce66"
+        />
+      </el-form-item>
+    </el-form>
+
+    <!-- 消息列表 -->
+    <el-table v-loading="loading" :data="list" :stripe="true" class="whitespace-nowrap">
+      <el-table-column label="时间" align="center" prop="ts" width="180">
+        <template #default="scope">
+          {{ formatDate(scope.row.ts) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="上行/下行" align="center" prop="upstream" width="140">
+        <template #default="scope">
+          <el-tag :type="scope.row.upstream ? 'primary' : 'success'">
+            {{ scope.row.upstream ? '上行' : '下行' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="是否回复" align="center" prop="reply" width="140">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.reply" />
+        </template>
+      </el-table-column>
+      <el-table-column label="请求编号" align="center" prop="requestId" width="300" />
+      <el-table-column label="请求方法" align="center" prop="method" width="140">
+        <template #default="scope">
+          {{ methodOptions.find((item) => item.value === scope.row.method)?.label }}
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="请求/响应数据"
+        align="center"
+        prop="params"
+        :show-overflow-tooltip="true"
+      >
+        <template #default="scope">
+          <span v-if="scope.row.reply">
+            {{ `{"code":${scope.row.code},"msg":"${scope.row.msg}","data":${scope.row.data}\}` }}
+          </span>
+          <span v-else>{{ scope.row.params }}</span>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 分页 -->
+    <div class="mt-10px flex justify-end">
+      <Pagination
+        :total="total"
+        v-model:page="queryParams.pageNo"
+        v-model:limit="queryParams.pageSize"
+        @pagination="getMessageList"
+      />
+    </div>
+  </ContentWrap>
+</template>
+
+<script setup lang="ts">
+import { DICT_TYPE } from '@/utils/dict'
+import { DeviceApi } from '@/api/iot/device/device'
+import { formatDate } from '@/utils/formatTime'
+import { IotDeviceMessageMethodEnum } from '@/views/iot/utils/constants'
+
+const props = defineProps<{
+  deviceId: number
+}>()
+
+// 查询参数
+const queryParams = reactive({
+  deviceId: props.deviceId,
+  method: undefined,
+  upstream: undefined,
+  pageNo: 1,
+  pageSize: 10
+})
+
+// 列表数据
+const loading = ref(false)
+const total = ref(0)
+const list = ref([])
+const autoRefresh = ref(false) // 自动刷新开关
+let autoRefreshTimer: any = null // 自动刷新定时器
+
+// 消息方法选项
+const methodOptions = computed(() => {
+  return Object.values(IotDeviceMessageMethodEnum).map((item) => ({
+    label: item.name,
+    value: item.method
+  }))
+})
+
+/** 查询消息列表 */
+const getMessageList = async () => {
+  if (!props.deviceId) return
+  loading.value = true
+  try {
+    const data = await DeviceApi.getDeviceMessagePage(queryParams)
+    total.value = data.total
+    list.value = data.list
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getMessageList()
+}
+
+/** 监听自动刷新 */
+watch(autoRefresh, (newValue) => {
+  if (newValue) {
+    autoRefreshTimer = setInterval(() => {
+      getMessageList()
+    }, 5000)
+  } else {
+    clearInterval(autoRefreshTimer)
+    autoRefreshTimer = null
+  }
+})
+
+/** 监听设备标识变化 */
+watch(
+  () => props.deviceId,
+  (newValue) => {
+    if (newValue) {
+      handleQuery()
+    }
+  }
+)
+
+/** 组件卸载时清除定时器 */
+onBeforeUnmount(() => {
+  if (autoRefreshTimer) {
+    clearInterval(autoRefreshTimer)
+    autoRefreshTimer = null
+  }
+})
+
+/** 初始化 */
+onMounted(() => {
+  if (props.deviceId) {
+    getMessageList()
+  }
+})
+
+/** 刷新消息列表 */
+const refresh = (delay = 0) => {
+  if (delay > 0) {
+    setTimeout(() => {
+      handleQuery()
+    }, delay)
+  } else {
+    handleQuery()
+  }
+}
+
+/** 暴露方法给父组件 */
+defineExpose({
+  refresh
+})
+</script>

+ 0 - 134
src/views/iot/device/device/detail/DeviceDetailsModel.vue

@@ -1,134 +0,0 @@
-<!-- 设备物模型:运行状态(属性)、事件管理、服务调用 -->
-<template>
-  <ContentWrap>
-    <el-tabs v-model="activeTab">
-      <el-tab-pane label="运行状态" name="status">
-        <ContentWrap>
-          <!-- 搜索工作栏 -->
-          <el-form
-            class="-mb-15px"
-            :model="queryParams"
-            ref="queryFormRef"
-            :inline="true"
-            label-width="68px"
-          >
-            <el-form-item label="标识符" prop="identifier">
-              <el-input
-                v-model="queryParams.identifier"
-                placeholder="请输入标识符"
-                clearable
-                class="!w-240px"
-              />
-            </el-form-item>
-            <el-form-item label="属性名称" prop="name">
-              <el-input
-                v-model="queryParams.name"
-                placeholder="请输入属性名称"
-                clearable
-                class="!w-240px"
-              />
-            </el-form-item>
-            <el-form-item>
-              <el-button @click="handleQuery"
-                ><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button
-              >
-              <el-button @click="resetQuery"
-                ><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button
-              >
-            </el-form-item>
-          </el-form>
-        </ContentWrap>
-        <ContentWrap>
-          <el-tabs>
-            <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
-              <el-table-column label="属性标识符" align="center" prop="property.identifier" />
-              <el-table-column label="属性名称" align="center" prop="property.name" />
-              <el-table-column label="数据类型" align="center" prop="property.dataType" />
-              <el-table-column label="属性值" align="center" prop="value" />
-              <el-table-column
-                label="更新时间"
-                align="center"
-                prop="updateTime"
-                :formatter="dateFormatter"
-                width="180px"
-              />
-              <el-table-column label="操作" align="center">
-                <template #default="scope">
-                  <el-button
-                    link
-                    type="primary"
-                    @click="openDetail(props.device.id, scope.row.property.identifier)"
-                  >
-                    查看数据
-                  </el-button>
-                </template>
-              </el-table-column>
-            </el-table>
-          </el-tabs>
-          <!-- 表单弹窗:添加/修改 -->
-          <DeviceDataDetail ref="detailRef" :device="device" :product="product" />
-        </ContentWrap>
-      </el-tab-pane>
-      <el-tab-pane label="事件管理" name="event">
-        <p>事件管理</p>
-      </el-tab-pane>
-      <el-tab-pane label="服务调用" name="service">
-        <p>服务调用</p>
-      </el-tab-pane>
-    </el-tabs>
-  </ContentWrap>
-</template>
-<script setup lang="ts">
-import { ProductVO } from '@/api/iot/product/product'
-import { DeviceApi, DeviceDataVO, DeviceVO } from '@/api/iot/device/device'
-import { dateFormatter } from '@/utils/formatTime'
-import DeviceDataDetail from './DeviceDataDetail.vue'
-
-const props = defineProps<{ product: ProductVO; device: DeviceVO }>()
-
-const loading = ref(true) // 列表的加载中
-const list = ref<DeviceDataVO[]>([]) // 列表的数据
-const queryParams = reactive({
-  deviceId: -1,
-  identifier: undefined as string | undefined,
-  name: undefined as string | undefined
-})
-
-const queryFormRef = ref() // 搜索的表单
-const activeTab = ref('status') // 默认选中的标签
-
-/** 查询列表 */
-const getList = async () => {
-  loading.value = true
-  try {
-    queryParams.deviceId = props.device.id
-    list.value = await DeviceApi.getLatestDeviceProperties(queryParams)
-  } finally {
-    loading.value = false
-  }
-}
-
-/** 搜索按钮操作 */
-const handleQuery = () => {
-  getList()
-}
-
-/** 重置按钮操作 */
-const resetQuery = () => {
-  queryFormRef.value.resetFields()
-  queryParams.identifier = undefined
-  queryParams.name = undefined
-  handleQuery()
-}
-
-/** 添加/修改操作 */
-const detailRef = ref()
-const openDetail = (deviceId: number, identifier: string) => {
-  detailRef.value.open(deviceId, identifier)
-}
-
-/** 初始化 **/
-onMounted(() => {
-  getList()
-})
-</script>

+ 303 - 210
src/views/iot/device/device/detail/DeviceDetailsSimulator.vue

@@ -6,75 +6,109 @@
       <el-col :span="12">
         <el-tabs v-model="activeTab" type="border-card">
           <!-- 上行指令调试 -->
-          <el-tab-pane label="上行指令调试" name="up">
-            <el-tabs v-if="activeTab === 'up'" v-model="subTab">
+          <el-tab-pane label="上行指令调试" name="upstream">
+            <el-tabs v-if="activeTab === 'upstream'" v-model="upstreamTab">
               <!-- 属性上报 -->
-              <el-tab-pane label="属性上报" name="property">
+              <el-tab-pane label="属性上报" :name="IotDeviceMessageMethodEnum.PROPERTY_POST.method">
                 <ContentWrap>
-                  <el-table
-                    v-loading="loading"
-                    :data="list"
-                    :show-overflow-tooltip="true"
-                    :stripe="true"
-                  >
-                    <!-- TODO @super:每个 colum 搞下宽度,避免 table 每一列最后有个 . -->
-                    <!-- TODO @super:可以左侧 fixed -->
-                    <el-table-column align="center" label="功能名称" prop="name" />
-                    <el-table-column align="center" label="标识符" prop="identifier" />
-                    <el-table-column align="center" label="数据类型" prop="identifier">
-                      <!-- TODO @super:不用翻译,可以减少宽度的占用 -->
+                  <el-table :data="propertyList" :show-overflow-tooltip="true" :stripe="true">
+                    <el-table-column
+                      fixed="left"
+                      align="center"
+                      label="功能名称"
+                      prop="name"
+                      width="120"
+                    />
+                    <el-table-column
+                      fixed="left"
+                      align="center"
+                      label="标识符"
+                      prop="identifier"
+                      width="120"
+                    />
+                    <el-table-column align="center" label="数据类型" width="100">
                       <template #default="{ row }">
-                        {{ dataTypeOptionsLabel(row.property?.dataType) ?? '-' }}
+                        {{ row.property?.dataType ?? '-' }}
                       </template>
                     </el-table-column>
-                    <el-table-column align="left" label="数据定义" prop="identifier">
+                    <el-table-column align="left" label="数据定义" min-width="200">
                       <template #default="{ row }">
                         <DataDefinition :data="row" />
                       </template>
                     </el-table-column>
-                    <!-- TODO @super:可以右侧 fixed -->
-                    <el-table-column align="center" label="值" width="80">
+                    <el-table-column fixed="right" align="center" label="值" width="150">
                       <template #default="scope">
-                        <el-input v-model="scope.row.simulateValue" class="!w-60px" />
+                        <el-input
+                          :model-value="getFormValue(scope.row.identifier)"
+                          @update:model-value="setFormValue(scope.row.identifier, $event)"
+                          placeholder="输入值"
+                          size="small"
+                        />
                       </template>
                     </el-table-column>
                   </el-table>
-                  <!-- TODO @super:发送按钮,可以放在右侧哈。因为我们的 simulateValue 就在最右侧 -->
-                  <div class="mt-10px">
-                    <el-button type="primary" @click="handlePropertyReport"> 发送</el-button>
+                  <div class="flex justify-between items-center mt-4">
+                    <span class="text-sm text-gray-600">
+                      设置属性值后,点击「发送属性上报」按钮
+                    </span>
+                    <el-button type="primary" @click="handlePropertyPost">发送属性上报</el-button>
                   </div>
                 </ContentWrap>
               </el-tab-pane>
 
               <!-- 事件上报 -->
-              <!-- TODO @super:待实现 -->
-              <el-tab-pane label="事件上报" name="event">
+              <el-tab-pane label="事件上报" :name="IotDeviceMessageMethodEnum.EVENT_POST.method">
                 <ContentWrap>
-                  <!-- TODO @super:因为事件是每个 event 去模拟,而不是类似属性的批量上传。所以,可以每一列后面有个“模拟”按钮。另外,“值”使用 textarea,高度 3 -->
-                  <!-- <el-table v-loading="loading" :data="eventList" :stripe="true">
-                    <el-table-column label="功能名称" align="center" prop="name" />
-                    <el-table-column label="标识符" align="center" prop="identifier" />
-                    <el-table-column label="数据类型" align="center" prop="dataType" />
+                  <el-table :data="eventList" :show-overflow-tooltip="true" :stripe="true">
                     <el-table-column
-                      label="数据定义"
+                      fixed="left"
                       align="center"
-                      prop="specs"
-                      :show-overflow-tooltip="true"
+                      label="功能名称"
+                      prop="name"
+                      width="120"
                     />
-                    <el-table-column label="值" align="center" width="80">
+                    <el-table-column
+                      fixed="left"
+                      align="center"
+                      label="标识符"
+                      prop="identifier"
+                      width="120"
+                    />
+                    <el-table-column align="center" label="数据类型" width="100">
+                      <template #default="{ row }">
+                        {{ row.event?.dataType ?? '-' }}
+                      </template>
+                    </el-table-column>
+                    <el-table-column align="left" label="数据定义" min-width="200">
+                      <template #default="{ row }">
+                        <DataDefinition :data="row" />
+                      </template>
+                    </el-table-column>
+                    <el-table-column align="center" label="值" width="200">
                       <template #default="scope">
-                        <el-input v-model="scope.row.simulateValue" class="!w-60px" />
+                        <el-input
+                          :model-value="getFormValue(scope.row.identifier)"
+                          @update:model-value="setFormValue(scope.row.identifier, $event)"
+                          type="textarea"
+                          :rows="3"
+                          placeholder="输入事件参数(JSON格式)"
+                          size="small"
+                        />
+                      </template>
+                    </el-table-column>
+                    <el-table-column fixed="right" align="center" label="操作" width="100">
+                      <template #default="scope">
+                        <el-button type="primary" size="small" @click="handleEventPost(scope.row)">
+                          上报事件
+                        </el-button>
                       </template>
                     </el-table-column>
                   </el-table>
-                  <div class="mt-10px">
-                    <el-button type="primary" @click="handleEventReport">发送</el-button>
-                  </div> -->
                 </ContentWrap>
               </el-tab-pane>
 
               <!-- 状态变更 -->
-              <el-tab-pane label="状态变更" name="status">
+              <el-tab-pane label="状态变更" :name="IotDeviceMessageMethodEnum.STATE_UPDATE.method">
                 <ContentWrap>
                   <div class="flex gap-4">
                     <el-button type="primary" @click="handleDeviceState(DeviceStateEnum.ONLINE)">
@@ -90,39 +124,106 @@
           </el-tab-pane>
 
           <!-- 下行指令调试 -->
-          <!-- TODO @super:待实现 -->
-          <el-tab-pane label="下行指令调试" name="down">
-            <el-tabs v-if="activeTab === 'down'" v-model="subTab">
+          <el-tab-pane label="下行指令调试" name="downstream">
+            <el-tabs v-if="activeTab === 'downstream'" v-model="downstreamTab">
               <!-- 属性调试 -->
-              <el-tab-pane label="属性调试" name="propertyDebug">
+              <el-tab-pane label="属性设置" :name="IotDeviceMessageMethodEnum.PROPERTY_SET.method">
                 <ContentWrap>
-                  <!-- <el-table v-loading="loading" :data="propertyList" :stripe="true">
-                    <el-table-column label="功能名称" align="center" prop="name" />
-                    <el-table-column label="标识符" align="center" prop="identifier" />
-                    <el-table-column label="数据类型" align="center" prop="dataType" />
+                  <el-table :data="propertyList" :show-overflow-tooltip="true" :stripe="true">
                     <el-table-column
-                      label="数据定义"
+                      fixed="left"
                       align="center"
-                      prop="specs"
-                      :show-overflow-tooltip="true"
+                      label="功能名称"
+                      prop="name"
+                      width="120"
                     />
-                    <el-table-column label="值" align="center" width="80">
+                    <el-table-column
+                      fixed="left"
+                      align="center"
+                      label="标识符"
+                      prop="identifier"
+                      width="120"
+                    />
+                    <el-table-column align="center" label="数据类型" width="100">
+                      <template #default="{ row }">
+                        {{ row.property?.dataType ?? '-' }}
+                      </template>
+                    </el-table-column>
+                    <el-table-column align="left" label="数据定义" min-width="200">
+                      <template #default="{ row }">
+                        <DataDefinition :data="row" />
+                      </template>
+                    </el-table-column>
+                    <el-table-column fixed="right" align="center" label="值" width="150">
                       <template #default="scope">
-                        <el-input v-model="scope.row.simulateValue" class="!w-60px" />
+                        <el-input
+                          :model-value="getFormValue(scope.row.identifier)"
+                          @update:model-value="setFormValue(scope.row.identifier, $event)"
+                          placeholder="输入值"
+                          size="small"
+                        />
                       </template>
                     </el-table-column>
                   </el-table>
-                  <div class="mt-10px">
-                    <el-button type="primary" @click="handlePropertyGet">获取</el-button>
-                  </div> -->
+                  <div class="flex justify-between items-center mt-4">
+                    <span class="text-sm text-gray-600">
+                      设置属性值后,点击「发送属性设置」按钮
+                    </span>
+                    <el-button type="primary" @click="handlePropertySet">发送属性设置</el-button>
+                  </div>
                 </ContentWrap>
               </el-tab-pane>
 
               <!-- 服务调用 -->
-              <!-- TODO @super:待实现 -->
-              <el-tab-pane label="服务调用" name="service">
+              <el-tab-pane
+                label="设备服务调用"
+                :name="IotDeviceMessageMethodEnum.SERVICE_INVOKE.method"
+              >
                 <ContentWrap>
-                  <!-- 服务调用相关内容 -->
+                  <el-table :data="serviceList" :show-overflow-tooltip="true" :stripe="true">
+                    <el-table-column
+                      fixed="left"
+                      align="center"
+                      label="服务名称"
+                      prop="name"
+                      width="120"
+                    />
+                    <el-table-column
+                      fixed="left"
+                      align="center"
+                      label="标识符"
+                      prop="identifier"
+                      width="120"
+                    />
+                    <el-table-column align="left" label="输入参数" min-width="200">
+                      <template #default="{ row }">
+                        <DataDefinition :data="row" />
+                      </template>
+                    </el-table-column>
+                    <el-table-column align="center" label="参数值" width="200">
+                      <template #default="scope">
+                        <el-input
+                          :model-value="getFormValue(scope.row.identifier)"
+                          @update:model-value="setFormValue(scope.row.identifier, $event)"
+                          type="textarea"
+                          :rows="3"
+                          placeholder="输入服务参数(JSON格式)"
+                          size="small"
+                        />
+                      </template>
+                    </el-table-column>
+                    <el-table-column fixed="right" align="center" label="操作" width="100">
+                      <template #default="scope">
+                        <el-button
+                          type="primary"
+                          size="small"
+                          @click="handleServiceInvoke(scope.row)"
+                        >
+                          服务调用
+                        </el-button>
+                      </template>
+                    </el-table-column>
+                  </el-table>
                 </ContentWrap>
               </el-tab-pane>
             </el-tabs>
@@ -132,11 +233,9 @@
 
       <!-- 右侧设备日志区域 -->
       <el-col :span="12">
-        <el-tabs type="border-card">
-          <el-tab-pane label="设备日志">
-            <DeviceDetailsLog :device-key="device.deviceKey" />
-          </el-tab-pane>
-        </el-tabs>
+        <ContentWrap title="设备消息">
+          <DeviceDetailsMessage ref="deviceMessageRef" :device-id="device.id" />
+        </ContentWrap>
       </el-col>
     </el-row>
   </ContentWrap>
@@ -144,188 +243,182 @@
 
 <script lang="ts" setup>
 import { ProductVO } from '@/api/iot/product/product'
-import { SimulatorData, ThingModelApi } from '@/api/iot/thingmodel'
-import { DeviceApi, DeviceStateEnum, DeviceVO } from '@/api/iot/device/device'
-import DeviceDetailsLog from './DeviceDetailsLog.vue'
-import { getDataTypeOptionsLabel } from '@/views/iot/thingmodel/config'
+import { ThingModelData } from '@/api/iot/thingmodel'
+import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
+import DeviceDetailsMessage from './DeviceDetailsMessage.vue'
 import { DataDefinition } from '@/views/iot/thingmodel/components'
+import {
+  DeviceStateEnum,
+  IotDeviceMessageMethodEnum,
+  IoTThingModelTypeEnum
+} from '@/views/iot/utils/constants'
 
 const props = defineProps<{
   product: ProductVO
   device: DeviceVO
+  thingModelList: ThingModelData[]
 }>()
 
 const message = useMessage() // 消息弹窗
-const activeTab = ref('up') // TODO @super:upstream 上行、downstream 下行
-const subTab = ref('property') // TODO @super:upstreamTab
+const activeTab = ref('upstream') // 上行upstream、下行downstream
+const upstreamTab = ref(IotDeviceMessageMethodEnum.PROPERTY_POST.method) // 上行子标签
+const downstreamTab = ref(IotDeviceMessageMethodEnum.PROPERTY_SET.method) // 下行子标签
+const deviceMessageRef = ref() // 设备消息组件引用
+const deviceMessageRefreshDelay = 2000 // 延迟 N 秒,保证模拟上行的消息被处理
 
-const loading = ref(false)
-const queryParams = reactive({
-  type: undefined, // TODO @super:type 默认给个第一个 tab 对应的,避免下面 watch 爆红
-  productId: -1
-})
-const list = ref<SimulatorData[]>([]) // 物模型列表的数据 TODO @super:thingModelList
-// TODO @super:dataTypeOptionsLabel 是不是不用定义,直接用 getDataTypeOptionsLabel 在 template 中使用即可?
-const dataTypeOptionsLabel = computed(() => (value: string) => getDataTypeOptionsLabel(value)) // 解析数据类型
+// 表单数据:存储用户输入的模拟值
+const formData = ref<Record<string, string>>({})
 
-/** 查询物模型列表 */
-// TODO @super:getThingModelList 更精准
-const getList = async () => {
-  loading.value = true
-  try {
-    queryParams.productId = props.product?.id || -1
-    const data = await ThingModelApi.getThingModelList(queryParams)
-    // 转换数据,添加 simulateValue 字段
-    // TODO @super:貌似下面的 simulateValue 不设置也可以?
-    list.value = data.map((item) => ({
-      ...item,
-      simulateValue: ''
-    }))
-  } finally {
-    loading.value = false
-  }
+// 根据类型过滤物模型数据
+const getFilteredThingModelList = (type: number) => {
+  return props.thingModelList.filter((item) => item.type === type)
 }
+const propertyList = computed(() => getFilteredThingModelList(IoTThingModelTypeEnum.PROPERTY))
+const eventList = computed(() => getFilteredThingModelList(IoTThingModelTypeEnum.EVENT))
+const serviceList = computed(() => getFilteredThingModelList(IoTThingModelTypeEnum.SERVICE))
 
-// // 功能列表数据结构定义
-// interface TableItem {
-//   name: string
-//   identifier: string
-//   value: string | number
-// }
-
-// // 添加计算属性来过滤物模型数据
-// const propertyList = computed(() => {
-//   return list.value
-//     .filter((item) => item.type === 'property')
-//     .map((item) => ({
-//       name: item.name,
-//       identifier: item.identifier,
-//       value: ''
-//     }))
-// })
-
-// const eventList = computed(() => {
-//   return list.value
-//     .filter((item) => item.type === 'event')
-//     .map((item) => ({
-//       name: item.name,
-//       identifier: item.identifier,
-//       value: ''
-//     }))
-// })
-
-/** 监听标签页变化 */
-// todo:后续改成查询字典
-watch(
-  [activeTab, subTab],
-  ([newActiveTab, newSubTab]) => {
-    // 根据标签页设置查询类型
-    if (newActiveTab === 'up') {
-      switch (newSubTab) {
-        case 'property':
-          queryParams.type = 1
-          break
-        case 'event':
-          queryParams.type = 3
-          break
-        // case 'status':
-        //   queryParams.type = 'status'
-        //   break
-      }
-    } else if (newActiveTab === 'down') {
-      switch (newSubTab) {
-        case 'propertyDebug':
-          queryParams.type = 1
-          break
-        case 'service':
-          queryParams.type = 2
-          break
-      }
-    }
-    getList() // 切换标签时重新获取数据
-  },
-  { immediate: true }
-)
+/** 获取表单值的辅助函数 */
+const getFormValue = (identifier: string | number | undefined) => {
+  if (!identifier) return ''
+  return formData.value[String(identifier)] || ''
+}
+/** 设置表单值的辅助函数 */
+const setFormValue = (identifier: string | number | undefined, value: string) => {
+  if (!identifier) return
+  formData.value[String(identifier)] = value
+}
 
-/** 处理属性上报 */
-const handlePropertyReport = async () => {
-  // TODO @super:数据类型效验
-  const data: Record<string, object> = {}
-  list.value.forEach((item) => {
-    // 只有当 simulateValue 有值时才添加到 content 中
-    // TODO @super:直接 if (item.simulateValue) 就可以哈,js 这块还是比较灵活的
-    if (item.simulateValue !== undefined && item.simulateValue !== '') {
-      // TODO @super:这里有个红色的 idea 告警,觉得去除下
-      data[item.identifier] = item.simulateValue
+/** 模拟属性上报 */
+const handlePropertyPost = async () => {
+  const data: Record<string, any> = {}
+  propertyList.value.forEach((item) => {
+    const value = getFormValue(item.identifier)
+    if (value && item.identifier) {
+      data[String(item.identifier)] = value
     }
   })
+  if (Object.keys(data).length === 0) {
+    message.warning('请至少设置一个属性值')
+    return
+  }
 
   try {
-    await DeviceApi.upstreamDevice({
-      id: props.device.id,
-      type: 'property',
-      identifier: 'report',
-      data: data
+    await DeviceApi.sendDeviceMessage({
+      deviceId: props.device.id,
+      method: IotDeviceMessageMethodEnum.PROPERTY_POST.method,
+      params: data
     })
     message.success('属性上报成功')
+    deviceMessageRef.value.refresh(deviceMessageRefreshDelay)
   } catch (error) {
     message.error('属性上报失败')
   }
 }
 
-// // 处理事件上报
-// const handleEventReport = async () => {
-//   const contentObj: Record<string, any> = {}
-//   list.value
-//     .filter(item => item.type === 'event')
-//     .forEach((item) => {
-//       if (item.simulateValue !== undefined && item.simulateValue !== '') {
-//         contentObj[item.identifier] = item.simulateValue
-//       }
-//     })
-
-//   const reportData: ReportData = {
-//     productKey: props.product.productKey,
-//     deviceKey: props.device.deviceKey,
-//     type: 'event',
-//     subType: list.value.find(item => item.type === 'event')?.identifier || '',
-//     reportTime: new Date().toISOString(),
-//     content: JSON.stringify(contentObj)  // 转换为 JSON 字符串
-//   }
+/** 模拟事件上报 */
+const handleEventPost = async (eventItem: ThingModelData) => {
+  const value = getFormValue(eventItem.identifier)
+  if (!value) {
+    message.warning('请输入事件参数')
+    return
+  }
+  let eventParams: any
+  try {
+    eventParams = JSON.parse(value)
+  } catch {
+    message.error('事件参数格式不正确,请输入有效的JSON格式')
+    return
+  }
 
-//   try {
-//     // TODO: 调用API发送数据
-//     console.log('上报数据:', reportData)
-//     message.success('事件上报成功')
-//   } catch (error) {
-//     message.error('事件上报失败')
-//   }
-// }
+  try {
+    await DeviceApi.sendDeviceMessage({
+      deviceId: props.device.id,
+      method: IotDeviceMessageMethodEnum.EVENT_POST.method,
+      params: {
+        identifier: String(eventItem.identifier),
+        value: eventParams,
+        time: Date.now()
+      }
+    })
+    message.success(`事件【${String(eventItem.name)}】上报成功`)
+    deviceMessageRef.value.refresh(deviceMessageRefreshDelay)
+  } catch (error) {
+    message.error(`事件【${String(eventItem.name)}】上报失败`)
+  }
+}
 
-/** 处理设备状态 */
+/** 模拟设备状态 */
 const handleDeviceState = async (state: number) => {
   try {
-    await DeviceApi.upstreamDevice({
-      id: props.device.id,
-      type: 'state',
-      identifier: 'report',
-      data: state
+    await DeviceApi.sendDeviceMessage({
+      deviceId: props.device.id,
+      method: IotDeviceMessageMethodEnum.STATE_UPDATE.method,
+      params: {
+        state: state
+      }
     })
     message.success(`设备${state === DeviceStateEnum.ONLINE ? '上线' : '下线'}成功`)
+    deviceMessageRef.value.refresh(deviceMessageRefreshDelay)
   } catch (error) {
     message.error(`设备${state === DeviceStateEnum.ONLINE ? '上线' : '下线'}失败`)
   }
 }
 
-// 处理属性获取
-const handlePropertyGet = async () => {
-  // TODO: 实现属性获取逻辑
-  message.success('属性获取成功')
+/** 模拟属性设置 */
+const handlePropertySet = async () => {
+  const data: Record<string, any> = {}
+  propertyList.value.forEach((item) => {
+    const value = getFormValue(item.identifier)
+    if (value && item.identifier) {
+      data[String(item.identifier)] = value
+    }
+  })
+  if (Object.keys(data).length === 0) {
+    message.warning('请至少设置一个属性值')
+    return
+  }
+
+  try {
+    await DeviceApi.sendDeviceMessage({
+      deviceId: props.device.id,
+      method: IotDeviceMessageMethodEnum.PROPERTY_SET.method,
+      params: data
+    })
+    message.success('属性设置成功')
+    deviceMessageRef.value.refresh(deviceMessageRefreshDelay)
+  } catch (error) {
+    message.error('属性设置失败')
+  }
 }
 
-// 初始化
-onMounted(() => {
-  getList()
-})
-// TODO @芋艿:后续再详细 review 下;
+/** 模拟服务调用 */
+const handleServiceInvoke = async (serviceItem: ThingModelData) => {
+  const value = getFormValue(serviceItem.identifier)
+  if (!value) {
+    message.warning('请输入服务参数')
+    return
+  }
+  let serviceParams: any
+  try {
+    serviceParams = JSON.parse(value)
+  } catch {
+    message.error('服务参数格式不正确,请输入有效的JSON格式')
+    return
+  }
+
+  try {
+    await DeviceApi.sendDeviceMessage({
+      deviceId: props.device.id,
+      method: IotDeviceMessageMethodEnum.SERVICE_INVOKE.method,
+      params: {
+        identifier: String(serviceItem.identifier),
+        inputParams: serviceParams
+      }
+    })
+    message.success(`服务【${String(serviceItem.name)}】调用成功`)
+    deviceMessageRef.value.refresh(deviceMessageRefreshDelay)
+  } catch (error) {
+    message.error(`服务【${String(serviceItem.name)}】调用失败`)
+  }
+}
 </script>

+ 264 - 0
src/views/iot/device/device/detail/DeviceDetailsSubDevice.vue

@@ -0,0 +1,264 @@
+<!-- 子设备管理 -->
+<template>
+  <ContentWrap>
+    <!-- 操作按钮 -->
+    <div class="mb-4">
+      <el-button type="primary" plain @click="openBindDialog" v-hasPermi="['iot:device:update']">
+        <Icon icon="ep:plus" class="mr-5px" /> 添加子设备
+      </el-button>
+      <el-button
+        type="danger"
+        plain
+        @click="handleUnbindBatch"
+        :disabled="selectedIds.length === 0"
+        v-hasPermi="['iot:device:update']"
+      >
+        <Icon icon="ep:delete" class="mr-5px" /> 批量解绑
+      </el-button>
+    </div>
+
+    <!-- 子设备列表 -->
+    <el-table
+      v-loading="loading"
+      :data="subDeviceList"
+      :stripe="true"
+      :show-overflow-tooltip="true"
+      @selection-change="handleSelectionChange"
+    >
+      <el-table-column type="selection" width="55" />
+      <el-table-column label="DeviceName" align="center" prop="deviceName">
+        <template #default="{ row }">
+          <el-link type="primary" @click="openDeviceDetail(row.id)">{{ row.deviceName }}</el-link>
+        </template>
+      </el-table-column>
+      <el-table-column label="备注名称" align="center" prop="nickname" />
+      <el-table-column label="产品名称" align="center" prop="productName" />
+      <el-table-column label="设备状态" align="center" prop="state">
+        <template #default="{ row }">
+          <dict-tag :type="DICT_TYPE.IOT_DEVICE_STATE" :value="row.state" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="最后上线时间"
+        align="center"
+        prop="onlineTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="操作" align="center" width="120px">
+        <template #default="{ row }">
+          <el-button link type="primary" @click="openDeviceDetail(row.id)"> 查看 </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleUnbind(row.id)"
+            v-hasPermi="['iot:device:update']"
+          >
+            解绑
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+  </ContentWrap>
+
+  <!-- 添加子设备弹窗 -->
+  <Dialog title="添加子设备" v-model="bindDialogVisible" width="900px">
+    <ContentWrap>
+      <!-- 搜索区域 -->
+      <el-form :model="bindQueryParams" ref="bindQueryFormRef" :inline="true" class="-mb-15px">
+        <el-form-item label="产品" prop="productId">
+          <ProductSelect
+            v-model="bindQueryParams.productId"
+            :device-type="DeviceTypeEnum.GATEWAY_SUB"
+            class="!w-200px"
+          />
+        </el-form-item>
+        <el-form-item label="设备名称" prop="deviceName">
+          <el-input
+            v-model="bindQueryParams.deviceName"
+            placeholder="请输入设备名称"
+            clearable
+            class="!w-200px"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="getBindableDevicePage">
+            <Icon icon="ep:search" class="mr-5px" /> 搜索
+          </el-button>
+          <el-button @click="resetBindQuery">
+            <Icon icon="ep:refresh" class="mr-5px" /> 重置
+          </el-button>
+        </el-form-item>
+      </el-form>
+    </ContentWrap>
+
+    <ContentWrap>
+      <!-- 分页表格 -->
+      <el-table
+        ref="bindTableRef"
+        v-loading="bindFormLoading"
+        :data="bindableDevices"
+        :stripe="true"
+        :show-overflow-tooltip="true"
+        @selection-change="handleBindSelectionChange"
+        max-height="400px"
+      >
+        <el-table-column type="selection" width="55" />
+        <el-table-column label="DeviceName" align="center" prop="deviceName" />
+        <el-table-column label="备注名称" align="center" prop="nickname" />
+        <el-table-column label="产品名称" align="center" prop="productName" />
+        <el-table-column label="设备状态" align="center" prop="state">
+          <template #default="{ row }">
+            <dict-tag :type="DICT_TYPE.IOT_DEVICE_STATE" :value="row.state" />
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <!-- 分页组件 -->
+      <Pagination
+        v-model:page="bindQueryParams.pageNo"
+        v-model:limit="bindQueryParams.pageSize"
+        :total="bindTotal"
+        @pagination="getBindableDevicePage"
+      />
+    </ContentWrap>
+
+    <template #footer>
+      <el-button type="primary" @click="handleBindSubmit" :loading="bindFormLoading">
+        确定(已选 {{ bindSelectedIds.length }} 个)
+      </el-button>
+      <el-button @click="bindDialogVisible = false">取消</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
+import { DeviceTypeEnum } from '@/api/iot/product/product'
+import { DICT_TYPE } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import ProductSelect from '@/views/iot/product/product/components/ProductSelect.vue'
+
+const props = defineProps<{
+  gatewayId: number
+}>()
+
+const message = useMessage()
+const { push } = useRouter()
+
+const loading = ref(false) // 列表加载状态
+const subDeviceList = ref<DeviceVO[]>([]) // 子设备列表
+const selectedIds = ref<number[]>([]) // 选中的设备ID
+
+const bindDialogVisible = ref(false) // 绑定弹窗可见性
+const bindFormLoading = ref(false) // 绑定弹窗加载状态
+const bindTableRef = ref()
+const bindQueryFormRef = ref()
+const bindableDevices = ref<DeviceVO[]>([]) // 可绑定设备列表
+const bindSelectedIds = ref<number[]>([]) // 绑定选中的设备ID
+const bindTotal = ref(0) // 可绑定设备总数
+const bindQueryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  productId: undefined as number | undefined,
+  deviceName: ''
+})
+
+/** 获取子设备列表 */
+const getSubDeviceList = async () => {
+  loading.value = true
+  try {
+    subDeviceList.value = await DeviceApi.getSubDeviceList(props.gatewayId)
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 打开设备详情 */
+const openDeviceDetail = (id: number) => {
+  push({ name: 'IoTDeviceDetail', params: { id } })
+}
+
+/** 多选框选中数据 */
+const handleSelectionChange = (selection: DeviceVO[]) => {
+  selectedIds.value = selection.map((item) => item.id)
+}
+
+/** 打开绑定弹窗 */
+const openBindDialog = async () => {
+  bindSelectedIds.value = []
+  bindDialogVisible.value = true
+  await getBindableDevicePage()
+}
+
+/** 获取可绑定设备分页 */
+const getBindableDevicePage = async () => {
+  bindFormLoading.value = true
+  try {
+    const result = await DeviceApi.getUnboundSubDevicePage(bindQueryParams)
+    bindableDevices.value = result.list
+    bindTotal.value = result.total
+  } finally {
+    bindFormLoading.value = false
+  }
+}
+
+/** 重置绑定弹窗搜索条件 */
+const resetBindQuery = () => {
+  bindQueryParams.pageNo = 1
+  bindQueryParams.productId = undefined
+  bindQueryParams.deviceName = ''
+  getBindableDevicePage()
+}
+
+/** 绑定弹窗多选框选中数据 */
+const handleBindSelectionChange = (selection: DeviceVO[]) => {
+  bindSelectedIds.value = selection.map((item) => item.id)
+}
+
+/** 提交绑定 */
+const handleBindSubmit = async () => {
+  if (bindSelectedIds.value.length === 0) {
+    message.warning('请选择要绑定的子设备')
+    return
+  }
+  bindFormLoading.value = true
+  try {
+    await DeviceApi.bindDeviceGateway({
+      subIds: bindSelectedIds.value,
+      gatewayId: props.gatewayId
+    })
+    message.success('绑定成功')
+    bindDialogVisible.value = false
+    await getSubDeviceList()
+  } finally {
+    bindFormLoading.value = false
+  }
+}
+
+/** 解绑单个设备 */
+const handleUnbind = async (id: number) => {
+  try {
+    await message.confirm('确定要解绑该子设备吗?')
+    await DeviceApi.unbindDeviceGateway({ subIds: [id], gatewayId: props.gatewayId })
+    message.success('解绑成功')
+    await getSubDeviceList()
+  } catch {}
+}
+
+/** 批量解绑 */
+const handleUnbindBatch = async () => {
+  try {
+    await message.confirm(`确定要解绑选中的 ${selectedIds.value.length} 个子设备吗?`)
+    await DeviceApi.unbindDeviceGateway({ subIds: selectedIds.value, gatewayId: props.gatewayId })
+    message.success('批量解绑成功')
+    selectedIds.value = []
+    await getSubDeviceList()
+  } catch {}
+}
+
+/** 初始化 */
+onMounted(async () => {
+  await getSubDeviceList()
+})
+</script>

+ 35 - 0
src/views/iot/device/device/detail/DeviceDetailsThingModel.vue

@@ -0,0 +1,35 @@
+<!-- 设备物模型:设备属性、事件管理、服务调用 -->
+<template>
+  <ContentWrap>
+    <el-tabs v-model="activeTab">
+      <el-tab-pane label="设备属性(运行状态)" name="property">
+        <DeviceDetailsThingModelProperty :device-id="deviceId" />
+      </el-tab-pane>
+      <el-tab-pane label="设备事件上报" name="event">
+        <DeviceDetailsThingModelEvent
+          :device-id="props.deviceId"
+          :thing-model-list="props.thingModelList"
+        />
+      </el-tab-pane>
+      <el-tab-pane label="设备服务调用" name="service">
+        <DeviceDetailsThingModelService
+          :device-id="deviceId"
+          :thing-model-list="props.thingModelList"
+        />
+      </el-tab-pane>
+    </el-tabs>
+  </ContentWrap>
+</template>
+<script setup lang="ts">
+import { ThingModelData } from '@/api/iot/thingmodel'
+import DeviceDetailsThingModelProperty from './DeviceDetailsThingModelProperty.vue'
+import DeviceDetailsThingModelEvent from './DeviceDetailsThingModelEvent.vue'
+import DeviceDetailsThingModelService from './DeviceDetailsThingModelService.vue'
+
+const props = defineProps<{
+  deviceId: number
+  thingModelList: ThingModelData[]
+}>()
+
+const activeTab = ref('property') // 默认选中设备属性
+</script>

+ 192 - 0
src/views/iot/device/device/detail/DeviceDetailsThingModelEvent.vue

@@ -0,0 +1,192 @@
+<!-- 设备事件管理 -->
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="80px"
+      @submit.prevent
+    >
+      <el-form-item label="标识符" prop="identifier">
+        <el-select
+          v-model="queryParams.identifier"
+          placeholder="请选择事件标识符"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="event in eventThingModels"
+            :key="event.identifier"
+            :label="`${event.name}(${event.identifier})`"
+            :value="event.identifier!"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="时间范围" prop="times">
+        <el-date-picker
+          v-model="queryParams.times"
+          type="datetimerange"
+          range-separator="至"
+          start-placeholder="开始时间"
+          end-placeholder="结束时间"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          :default-time="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 1, 1, 23, 59, 59)]"
+          class="!w-360px"
+          :shortcuts="defaultShortcuts"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery">
+          <Icon icon="ep:search" class="mr-5px" />
+          搜索
+        </el-button>
+        <el-button @click="resetQuery">
+          <Icon icon="ep:refresh" class="mr-5px" />
+          重置
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <ContentWrap>
+    <!-- 事件列表 -->
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="上报时间" align="center" prop="reportTime" width="180px">
+        <template #default="scope">
+          {{ scope.row.request?.reportTime ? formatDate(scope.row.request.reportTime) : '-' }}
+        </template>
+      </el-table-column>
+      <el-table-column label="标识符" align="center" prop="identifier" width="160px">
+        <template #default="scope">
+          <el-tag type="primary" size="small">
+            {{ scope.row.request?.identifier }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="事件名称" align="center" prop="eventName" width="160px">
+        <template #default="scope">
+          {{ getEventName(scope.row.request?.identifier) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="事件类型" align="center" prop="eventType" width="100px">
+        <template #default="scope">
+          {{ getEventType(scope.row.request?.identifier) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="输入参数" align="center" prop="params">
+        <template #default="scope"> {{ parseParams(scope.row.request.params) }} </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+</template>
+
+<script setup lang="ts">
+import { DeviceApi } from '@/api/iot/device/device'
+import { ThingModelData } from '@/api/iot/thingmodel'
+import { formatDate, defaultShortcuts } from '@/utils/formatTime'
+import {
+  getEventTypeLabel,
+  IotDeviceMessageMethodEnum,
+  IoTThingModelTypeEnum
+} from '@/views/iot/utils/constants'
+
+const props = defineProps<{
+  deviceId: number
+  thingModelList: ThingModelData[]
+}>()
+
+const loading = ref(false) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([] as any[]) // 列表的数据
+const queryParams = reactive({
+  deviceId: props.deviceId,
+  method: IotDeviceMessageMethodEnum.EVENT_POST.method, // 固定筛选事件消息
+  identifier: '',
+  times: [] as any[],
+  pageNo: 1,
+  pageSize: 10
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 事件类型的物模型数据 */
+const eventThingModels = computed(() => {
+  return props.thingModelList.filter(
+    (item: ThingModelData) => item.type === IoTThingModelTypeEnum.EVENT
+  )
+})
+
+/** 查询列表 */
+const getList = async () => {
+  if (!props.deviceId) return
+  loading.value = true
+  try {
+    const data = await DeviceApi.getDeviceMessagePairPage(queryParams)
+    list.value = data.list
+    total.value = data.length
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value?.resetFields()
+  queryParams.identifier = ''
+  queryParams.times = []
+  handleQuery()
+}
+
+/** 获取事件名称 */
+const getEventName = (identifier: string | undefined) => {
+  if (!identifier) return '-'
+  const event = eventThingModels.value.find(
+    (item: ThingModelData) => item.identifier === identifier
+  )
+  return event?.name || identifier
+}
+
+/** 获取事件类型 */
+const getEventType = (identifier: string | undefined) => {
+  if (!identifier) return '-'
+  const event = eventThingModels.value.find(
+    (item: ThingModelData) => item.identifier === identifier
+  )
+  if (!event?.event?.type) return '-'
+  return getEventTypeLabel(event.event.type) || '-'
+}
+
+/** 解析参数 */
+const parseParams = (params: string) => {
+  try {
+    const parsed = JSON.parse(params)
+    if (parsed.params) {
+      return parsed.params
+    }
+    return parsed
+  } catch (error) {
+    return {}
+  }
+}
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>

+ 245 - 0
src/views/iot/device/device/detail/DeviceDetailsThingModelProperty.vue

@@ -0,0 +1,245 @@
+<!-- 设备属性管理 -->
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+      @submit.prevent
+    >
+      <el-form-item label="" prop="keyword">
+        <el-input
+          v-model="queryParams.keyword"
+          placeholder="请输入属性名称、标志符"
+          clearable
+          class="!w-240px"
+          @keyup.enter="handleQuery"
+          @clear="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item class="float-right !mr-0 !mb-0">
+        <el-button-group>
+          <el-button :type="viewMode === 'card' ? 'primary' : 'default'" @click="viewMode = 'card'">
+            <Icon icon="ep:grid" />
+          </el-button>
+          <el-button :type="viewMode === 'list' ? 'primary' : 'default'" @click="viewMode = 'list'">
+            <Icon icon="ep:list" />
+          </el-button>
+        </el-button-group>
+      </el-form-item>
+      <!-- TODO @芋艿:参考阿里云,实时刷新! -->
+      <el-form-item>
+        <el-switch
+          size="large"
+          width="80"
+          v-model="autoRefresh"
+          class="-ml-15px"
+          inline-prompt
+          active-text="定时刷新"
+          inactive-text="定时刷新"
+          style="--el-switch-on-color: #13ce66"
+        />
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+  <ContentWrap>
+    <!-- 卡片视图 -->
+    <template v-if="viewMode === 'card'">
+      <el-row :gutter="16" v-loading="loading">
+        <el-col
+          v-for="item in list"
+          :key="item.identifier"
+          :xs="24"
+          :sm="12"
+          :md="12"
+          :lg="6"
+          class="mb-4"
+        >
+          <el-card
+            class="h-full transition-colors relative overflow-hidden"
+            :body-style="{ padding: '0' }"
+          >
+            <!-- 添加渐变背景层 -->
+            <div
+              class="absolute top-0 left-0 right-0 h-[50px] pointer-events-none bg-gradient-to-b from-[#eefaff] to-transparent"
+            >
+            </div>
+            <div class="p-4 relative">
+              <!-- 标题区域 -->
+              <div class="flex items-center mb-3">
+                <div class="mr-2.5 flex items-center">
+                  <Icon icon="ep:cpu" class="text-[18px] text-[#0070ff]" />
+                </div>
+                <div class="text-[16px] font-600 flex-1">{{ item.name }}</div>
+                <!-- 标识符 -->
+                <div class="inline-flex items-center mr-2">
+                  <el-tag size="small" type="primary">
+                    {{ item.identifier }}
+                  </el-tag>
+                </div>
+                <!-- 数据类型标签 -->
+                <div class="inline-flex items-center mr-2">
+                  <el-tag size="small" type="info">
+                    {{ item.dataType }}
+                  </el-tag>
+                </div>
+                <!-- 数据图标 - 可点击 -->
+                <div
+                  class="cursor-pointer flex items-center justify-center w-8 h-8 rounded-full hover:bg-blue-50 transition-colors"
+                  @click="openHistory(props.deviceId, item.identifier, item.dataType)"
+                >
+                  <Icon icon="ep:data-line" class="text-[18px] text-[#0070ff]" />
+                </div>
+              </div>
+
+              <!-- 信息区域 -->
+              <div class="text-[14px]">
+                <div class="mb-2.5 last:mb-0">
+                  <span class="text-[#717c8e] mr-2.5">属性值</span>
+                  <span class="text-[var(--el-text-color-primary)] font-600">
+                    {{ formatValueWithUnit(item) }}
+                  </span>
+                </div>
+                <div class="mb-2.5 last:mb-0">
+                  <span class="text-[#717c8e] mr-2.5">更新时间</span>
+                  <span class="text-[var(--el-text-color-primary)] text-[12px]">
+                    {{ item.updateTime ? formatDate(item.updateTime) : '-' }}
+                  </span>
+                </div>
+              </div>
+            </div>
+          </el-card>
+        </el-col>
+      </el-row>
+    </template>
+
+    <!-- 列表视图 -->
+    <el-table v-else v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="属性标识符" align="center" prop="identifier" />
+      <el-table-column label="属性名称" align="center" prop="name" />
+      <el-table-column label="数据类型" align="center" prop="dataType" />
+      <el-table-column label="属性值" align="center" prop="value">
+        <template #default="scope">
+          {{ formatValueWithUnit(scope.row) }}
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="更新时间"
+        align="center"
+        prop="updateTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="操作" align="center">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openHistory(props.deviceId, scope.row.identifier, scope.row.dataType)"
+          >
+            查看数据
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 表单弹窗:添加/修改 -->
+    <DeviceDetailsThingModelPropertyHistory ref="historyRef" :deviceId="props.deviceId" />
+  </ContentWrap>
+</template>
+<script setup lang="ts">
+import { DeviceApi, IotDevicePropertyDetailRespVO } from '@/api/iot/device/device'
+import { dateFormatter, formatDate } from '@/utils/formatTime'
+import DeviceDetailsThingModelPropertyHistory from './DeviceDetailsThingModelPropertyHistory.vue'
+
+const props = defineProps<{ deviceId: number }>()
+
+const loading = ref(true) // 列表的加载中
+const list = ref<IotDevicePropertyDetailRespVO[]>([]) // 显示的列表数据
+const filterList = ref<IotDevicePropertyDetailRespVO[]>([]) // 完整的数据列表
+const queryParams = reactive({
+  keyword: '' as string
+})
+const autoRefresh = ref(false) // 自动刷新开关
+let autoRefreshTimer: any = null // 定时器
+const viewMode = ref<'card' | 'list'>('card') // 视图模式状态
+
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const params = {
+      deviceId: props.deviceId,
+      identifier: undefined as string | undefined,
+      name: undefined as string | undefined
+    }
+    filterList.value = await DeviceApi.getLatestDeviceProperties(params)
+    handleFilter()
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 前端筛选数据 */
+const handleFilter = () => {
+  if (!queryParams.keyword.trim()) {
+    list.value = filterList.value
+  } else {
+    const keyword = queryParams.keyword.toLowerCase()
+    list.value = filterList.value.filter(
+      (item) =>
+        item.identifier?.toLowerCase().includes(keyword) ||
+        item.name?.toLowerCase().includes(keyword)
+    )
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  handleFilter()
+}
+
+/** 历史操作 */
+const historyRef = ref()
+const openHistory = (deviceId: number, identifier: string, dataType: string) => {
+  historyRef.value.open(deviceId, identifier, dataType)
+}
+
+/** 格式化属性值和单位 */
+const formatValueWithUnit = (item: IotDevicePropertyDetailRespVO) => {
+  if (item.value === null || item.value === undefined || item.value === '') {
+    return '-'
+  }
+  const unitName = item.dataSpecs?.unitName
+  return unitName ? `${item.value} ${unitName}` : item.value
+}
+
+/** 监听自动刷新 */
+watch(autoRefresh, (newValue) => {
+  if (newValue) {
+    autoRefreshTimer = setInterval(() => {
+      getList()
+    }, 5000) // 每 5 秒刷新一次
+  } else {
+    clearInterval(autoRefreshTimer)
+    autoRefreshTimer = null
+  }
+})
+
+/** 组件卸载时清除定时器 */
+onBeforeUnmount(() => {
+  if (autoRefreshTimer) {
+    clearInterval(autoRefreshTimer)
+  }
+})
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 216 - 0
src/views/iot/device/device/detail/DeviceDetailsThingModelPropertyHistory.vue

@@ -0,0 +1,216 @@
+<!-- 设备物模型 -> 运行状态 -> 查看数据(设备的属性值历史)-->
+<template>
+  <Dialog title="查看数据" v-model="dialogVisible" width="1024px" :appendToBody="true">
+    <ContentWrap>
+      <!-- 搜索工作栏 -->
+      <el-form
+        class="-mb-15px"
+        :model="queryParams"
+        ref="queryFormRef"
+        :inline="true"
+        label-width="68px"
+      >
+        <el-form-item label="" prop="createTime">
+          <el-date-picker
+            v-model="queryParams.times"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            type="datetimerange"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+            class="!w-360px"
+            @change="handleTimeChange"
+            :shortcuts="defaultShortcuts"
+          />
+        </el-form-item>
+        <el-form-item class="float-right !mr-0 !mb-0">
+          <el-button-group>
+            <el-button
+              :type="viewMode === 'chart' ? 'primary' : 'default'"
+              @click="viewMode = 'chart'"
+              :disabled="isComplexDataType"
+            >
+              <Icon icon="ep:histogram" />
+            </el-button>
+            <el-button
+              :type="viewMode === 'list' ? 'primary' : 'default'"
+              @click="viewMode = 'list'"
+            >
+              <Icon icon="ep:list" />
+            </el-button>
+          </el-button-group>
+        </el-form-item>
+      </el-form>
+    </ContentWrap>
+
+    <!-- 数据展示区域 -->
+    <ContentWrap>
+      <!-- 图表模式 -->
+      <div v-if="viewMode === 'chart'" class="chart-container">
+        <div v-if="list.length === 0" class="text-center text-gray-500 py-20"> 暂无数据 </div>
+        <Echart v-else :key="'erchart' + Date.now()" :options="echartsOption" height="400px" />
+      </div>
+
+      <!-- 表格模式 -->
+      <div v-else>
+        <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+          <el-table-column label="时间" align="center" prop="time" width="180px">
+            <template #default="scope">
+              {{ formatDate(new Date(scope.row.updateTime)) }}
+            </template>
+          </el-table-column>
+          <el-table-column label="属性值" align="center" prop="value">
+            <template #default="scope">
+              {{ scope.row.value }}
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+    </ContentWrap>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import { DeviceApi, IotDevicePropertyRespVO } from '@/api/iot/device/device'
+import { beginOfDay, defaultShortcuts, endOfDay, formatDate } from '@/utils/formatTime'
+import { Echart } from '@/components/Echart'
+import { IoTDataSpecsDataTypeEnum } from '@/views/iot/utils/constants'
+
+defineProps<{ deviceId: number }>()
+
+/** IoT 设备属性历史数据详情 */
+defineOptions({ name: 'DeviceDetailsThingModelPropertyHistory' })
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const loading = ref(false)
+const viewMode = ref<'chart' | 'list'>('chart') // 视图模式状态
+const list = ref<IotDevicePropertyRespVO[]>([]) // 列表的数据
+const chartKey = ref(0) // 图表重新渲染的key
+const thingModelDataType = ref<string>('') // 物模型数据类型
+const queryParams = reactive({
+  deviceId: -1,
+  identifier: '',
+  times: [
+    // 默认显示最近一周的数据
+    formatDate(beginOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24 * 7))),
+    formatDate(endOfDay(new Date()))
+  ]
+})
+const queryFormRef = ref() // 搜索的表单
+
+// 判断是否为复杂数据类型(struct 或 array)
+const isComplexDataType = computed(() => {
+  if (!thingModelDataType.value) return false
+  return [IoTDataSpecsDataTypeEnum.STRUCT, IoTDataSpecsDataTypeEnum.ARRAY].includes(
+    thingModelDataType.value as any
+  )
+})
+
+// Echarts 数据
+const echartsData = computed(() => {
+  if (!list.value || list.value.length === 0) return []
+  return list.value.map((item) => [item.updateTime, item.value])
+})
+// Echarts 配置
+const echartsOption = reactive<any>({
+  title: {
+    text: '设备属性值',
+    left: 'center'
+  },
+  grid: {
+    left: 60,
+    right: 40,
+    bottom: 80,
+    top: 80,
+    containLabel: true
+  },
+  tooltip: {
+    trigger: 'axis',
+    axisPointer: {
+      type: 'cross'
+    }
+  },
+  xAxis: {
+    type: 'time',
+    name: '时间',
+    axisLabel: {
+      formatter: (value: number) => formatDate(new Date(value), 'MM-DD HH:mm')
+    }
+  },
+  yAxis: {
+    type: 'value',
+    name: '属性值'
+  },
+  series: [
+    {
+      name: '属性值',
+      type: 'line',
+      smooth: true,
+      symbol: 'circle',
+      symbolSize: 6,
+      lineStyle: {
+        width: 2,
+        color: '#1890FF'
+      },
+      itemStyle: {
+        color: '#1890FF'
+      },
+      data: []
+    }
+  ],
+  dataZoom: [
+    {
+      type: 'inside'
+    },
+    {
+      type: 'slider',
+      height: 30
+    }
+  ]
+})
+
+/** 获得设备历史数据 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await DeviceApi.getHistoryDevicePropertyList(queryParams)
+    list.value = data || []
+    updateChartData()
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 打开弹窗 */
+const open = async (deviceId: number, identifier: string, dataType: string) => {
+  dialogVisible.value = true
+  queryParams.deviceId = deviceId
+  queryParams.identifier = identifier
+  thingModelDataType.value = dataType
+
+  // 如果物模型是 struct、array,需要默认使用 list 模式
+  if (isComplexDataType.value) {
+    viewMode.value = 'list'
+  } else {
+    viewMode.value = 'chart'
+  }
+  // 重置图表 key,确保每次打开都能正常渲染
+  chartKey.value = 0
+
+  // 等待弹窗完全渲染后再获取数据
+  await nextTick()
+  await getList()
+}
+
+/** 时间变化处理 */
+const handleTimeChange = () => {
+  getList()
+}
+
+/** 更新图表数据 */
+const updateChartData = () => {
+  if (echartsOption.series && echartsOption.series[0]) {
+    echartsOption.series[0].data = echartsData.value
+  }
+}
+
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+</script>

+ 208 - 0
src/views/iot/device/device/detail/DeviceDetailsThingModelService.vue

@@ -0,0 +1,208 @@
+<!-- 设备服务调用 -->
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="80px"
+      @submit.prevent
+    >
+      <el-form-item label="标识符" prop="identifier">
+        <el-select
+          v-model="queryParams.identifier"
+          placeholder="请选择服务标识符"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="service in serviceThingModels"
+            :key="service.identifier"
+            :label="`${service.name}(${service.identifier})`"
+            :value="service.identifier!"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="时间范围" prop="times">
+        <el-date-picker
+          v-model="queryParams.times"
+          type="datetimerange"
+          range-separator="至"
+          start-placeholder="开始时间"
+          end-placeholder="结束时间"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          :default-time="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 1, 1, 23, 59, 59)]"
+          class="!w-360px"
+          :shortcuts="defaultShortcuts"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery">
+          <Icon icon="ep:search" class="mr-5px" />
+          搜索
+        </el-button>
+        <el-button @click="resetQuery">
+          <Icon icon="ep:refresh" class="mr-5px" />
+          重置
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <ContentWrap>
+    <!-- 服务调用列表 -->
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="调用时间" align="center" prop="requestTime" width="180px">
+        <template #default="scope">
+          {{ scope.row.request?.reportTime ? formatDate(scope.row.request.reportTime) : '-' }}
+        </template>
+      </el-table-column>
+      <el-table-column label="响应时间" align="center" prop="responseTime" width="180px">
+        <template #default="scope">
+          {{ scope.row.reply?.reportTime ? formatDate(scope.row.reply.reportTime) : '-' }}
+        </template>
+      </el-table-column>
+      <el-table-column label="标识符" align="center" prop="identifier" width="160px">
+        <template #default="scope">
+          <el-tag type="primary" size="small">
+            {{ scope.row.request?.identifier }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="服务名称" align="center" prop="serviceName" width="160px">
+        <template #default="scope">
+          {{ getServiceName(scope.row.request?.identifier) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="调用方式" align="center" prop="callType" width="100px">
+        <template #default="scope">
+          {{ getCallType(scope.row.request?.identifier) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="输入参数" align="center" prop="inputParams">
+        <template #default="scope"> {{ parseParams(scope.row.request?.params) }} </template>
+      </el-table-column>
+      <el-table-column label="输出参数" align="center" prop="outputParams">
+        <template #default="scope">
+          <span v-if="scope.row.reply">
+            {{
+              `{"code":${scope.row.reply.code},"msg":"${scope.row.reply.msg}","data":${scope.row.reply.data}\}`
+            }}
+          </span>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+</template>
+
+<script setup lang="ts">
+import { DeviceApi } from '@/api/iot/device/device'
+import { ThingModelData } from '@/api/iot/thingmodel'
+import { formatDate, defaultShortcuts } from '@/utils/formatTime'
+import {
+  getThingModelServiceCallTypeLabel,
+  IotDeviceMessageMethodEnum,
+  IoTThingModelTypeEnum
+} from '@/views/iot/utils/constants'
+
+const props = defineProps<{
+  deviceId: number
+  thingModelList: ThingModelData[]
+}>()
+
+const loading = ref(false) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([] as any[]) // 列表的数据
+const queryParams = reactive({
+  deviceId: props.deviceId,
+  method: IotDeviceMessageMethodEnum.SERVICE_INVOKE.method, // 固定筛选服务调用消息
+  identifier: '',
+  times: [] as any[],
+  pageNo: 1,
+  pageSize: 10
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 服务类型的物模型数据 */
+const serviceThingModels = computed(() => {
+  return props.thingModelList.filter(
+    (item: ThingModelData) => item.type === IoTThingModelTypeEnum.SERVICE
+  )
+})
+
+/** 查询列表 */
+const getList = async () => {
+  if (!props.deviceId) return
+  loading.value = true
+  try {
+    const data = await DeviceApi.getDeviceMessagePairPage(queryParams)
+    list.value = data.list
+    total.value = data.length
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value?.resetFields()
+  queryParams.identifier = ''
+  queryParams.times = []
+  handleQuery()
+}
+
+/** 获取服务名称 */
+const getServiceName = (identifier: string | undefined) => {
+  if (!identifier) return '-'
+  const service = serviceThingModels.value.find(
+    (item: ThingModelData) => item.identifier === identifier
+  )
+  return service?.name || identifier
+}
+
+/** 获取调用方式 */
+const getCallType = (identifier: string | undefined) => {
+  if (!identifier) return '-'
+  const service = serviceThingModels.value.find(
+    (item: ThingModelData) => item.identifier === identifier
+  )
+  if (!service?.service?.callType) return '-'
+  return getThingModelServiceCallTypeLabel(service.service.callType) || '-'
+}
+
+/** 解析参数 */
+const parseParams = (params: string) => {
+  if (!params) return '-'
+  try {
+    const parsed = JSON.parse(params)
+    if (parsed.params) {
+      return JSON.stringify(parsed.params, null, 2)
+    }
+    return JSON.stringify(parsed, null, 2)
+  } catch (error) {
+    return params
+  }
+}
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>

+ 292 - 0
src/views/iot/device/device/detail/DeviceModbusConfig.vue

@@ -0,0 +1,292 @@
+<!-- Modbus 配置 -->
+<template>
+  <div>
+    <!-- 连接配置区域 -->
+    <ContentWrap>
+      <div class="flex items-center justify-between mb-4">
+        <span class="text-lg font-medium">连接配置</span>
+        <el-button type="primary" @click="handleEditConfig" v-hasPermi="['iot:device:create']">
+          编辑
+        </el-button>
+      </div>
+
+      <!-- 详情展示 -->
+      <el-descriptions :column="3" border direction="horizontal">
+        <!-- Client 模式专有字段 -->
+        <template v-if="isClient">
+          <el-descriptions-item label="IP 地址">
+            {{ modbusConfig.ip || '-' }}
+          </el-descriptions-item>
+          <el-descriptions-item label="端口">
+            {{ modbusConfig.port || '-' }}
+          </el-descriptions-item>
+        </template>
+        <!-- 公共字段 -->
+        <el-descriptions-item label="从站地址">
+          {{ modbusConfig.slaveId || '-' }}
+        </el-descriptions-item>
+        <!-- Client 模式专有字段 -->
+        <template v-if="isClient">
+          <el-descriptions-item label="连接超时">
+            {{ modbusConfig.timeout ? `${modbusConfig.timeout} ms` : '-' }}
+          </el-descriptions-item>
+          <el-descriptions-item label="重试间隔">
+            {{ modbusConfig.retryInterval ? `${modbusConfig.retryInterval} ms` : '-' }}
+          </el-descriptions-item>
+        </template>
+        <!-- Server 模式专有字段 -->
+        <template v-if="isServer">
+          <el-descriptions-item label="工作模式">
+            <dict-tag :type="DICT_TYPE.IOT_MODBUS_MODE" :value="modbusConfig.mode" />
+          </el-descriptions-item>
+          <el-descriptions-item label="帧格式">
+            <dict-tag :type="DICT_TYPE.IOT_MODBUS_FRAME_FORMAT" :value="modbusConfig.frameFormat" />
+          </el-descriptions-item>
+        </template>
+        <!-- 公共字段 -->
+        <el-descriptions-item label="状态">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="modbusConfig.status" />
+        </el-descriptions-item>
+      </el-descriptions>
+    </ContentWrap>
+
+    <!-- 点位配置区域 -->
+    <ContentWrap class="mt-4">
+      <div class="flex items-center justify-between mb-4">
+        <span class="text-lg font-medium">点位配置</span>
+        <el-button type="primary" @click="handleAddPoint" v-hasPermi="['iot:device:create']">
+          <Icon icon="ep:plus" class="mr-1" />
+          新增点位
+        </el-button>
+      </div>
+
+      <!-- 搜索栏 -->
+      <el-form :model="queryParams" :inline="true" class="-mb-15px">
+        <el-form-item label="属性名称" prop="name">
+          <el-input
+            v-model="queryParams.name"
+            placeholder="请输入属性名称"
+            clearable
+            class="!w-200px"
+            @keyup.enter="handleQuery"
+          />
+        </el-form-item>
+        <el-form-item label="标识符" prop="identifier">
+          <el-input
+            v-model="queryParams.identifier"
+            placeholder="请输入标识符"
+            clearable
+            class="!w-200px"
+            @keyup.enter="handleQuery"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button @click="handleQuery">
+            <Icon icon="ep:search" class="mr-5px" />
+            搜索
+          </el-button>
+          <el-button @click="resetQuery">
+            <Icon icon="ep:refresh" class="mr-5px" />
+            重置
+          </el-button>
+        </el-form-item>
+      </el-form>
+
+      <!-- 点位列表 -->
+      <el-table v-loading="pointLoading" :data="pointList" :stripe="true" class="mt-4">
+        <el-table-column label="属性名称" align="center" prop="name" min-width="100" />
+        <el-table-column label="标识符" align="center" prop="identifier" min-width="100">
+          <template #default="scope">
+            <el-tag size="small" type="primary">{{ scope.row.identifier }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="功能码" align="center" prop="functionCode" min-width="140">
+          <template #default="scope">
+            {{ formatFunctionCode(scope.row.functionCode) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="寄存器地址" align="center" prop="registerAddress" min-width="100">
+          <template #default="scope">
+            {{ formatRegisterAddress(scope.row.registerAddress) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="寄存器数量" align="center" prop="registerCount" min-width="90" />
+        <el-table-column label="数据类型" align="center" prop="rawDataType" min-width="90">
+          <template #default="scope">
+            <el-tag size="small" type="info">{{ scope.row.rawDataType }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="字节序" align="center" prop="byteOrder" min-width="80" />
+        <el-table-column label="缩放因子" align="center" prop="scale" min-width="80" />
+        <el-table-column label="轮询间隔" align="center" prop="pollInterval" min-width="90">
+          <template #default="scope"> {{ scope.row.pollInterval }} ms </template>
+        </el-table-column>
+        <el-table-column label="状态" align="center" prop="status" min-width="80">
+          <template #default="scope">
+            <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" align="center" fixed="right" width="120">
+          <template #default="scope">
+            <el-button
+              link
+              type="primary"
+              @click="handleEditPoint(scope.row)"
+              v-hasPermi="['iot:device:update']"
+            >
+              编辑
+            </el-button>
+            <el-button
+              link
+              type="danger"
+              @click="handleDeletePoint(scope.row.id, scope.row.name)"
+              v-hasPermi="['iot:device:delete']"
+            >
+              删除
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <!-- 分页 -->
+      <Pagination
+        :total="total"
+        v-model:page="queryParams.pageNo"
+        v-model:limit="queryParams.pageSize"
+        @pagination="getPointPage"
+      />
+    </ContentWrap>
+
+    <!-- 连接配置弹窗 -->
+    <DeviceModbusConfigForm
+      ref="configFormRef"
+      :device-id="device.id"
+      :protocol-type="product.protocolType"
+      @success="getModbusConfig"
+    />
+
+    <!-- 点位表单弹窗 -->
+    <DeviceModbusPointForm
+      ref="pointFormRef"
+      :device-id="device.id"
+      :thing-model-list="thingModelList"
+      @success="getPointPage"
+    />
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { DeviceVO } from '@/api/iot/device/device'
+import { ProductVO, ProtocolTypeEnum } from '@/api/iot/product/product'
+import { ThingModelData } from '@/api/iot/thingmodel'
+import { DeviceModbusConfigApi, DeviceModbusConfigVO } from '@/api/iot/device/modbus/config'
+import { DeviceModbusPointApi, DeviceModbusPointVO } from '@/api/iot/device/modbus/point'
+import { ModbusFunctionCodeOptions } from '@/views/iot/utils/constants'
+import { DICT_TYPE } from '@/utils/dict'
+import DeviceModbusConfigForm from './DeviceModbusConfigForm.vue'
+import DeviceModbusPointForm from './DeviceModbusPointForm.vue'
+
+defineOptions({ name: 'DeviceModbusConfig' })
+
+const props = defineProps<{
+  device: DeviceVO
+  product: ProductVO
+  thingModelList: ThingModelData[]
+}>()
+
+const message = useMessage()
+
+// ======================= 连接配置 =======================
+const isClient = computed(() => props.product.protocolType === ProtocolTypeEnum.MODBUS_TCP_CLIENT) // 是否为 Client 模式
+const isServer = computed(() => props.product.protocolType === ProtocolTypeEnum.MODBUS_TCP_SERVER) // 是否为 Server 模式
+const modbusConfig = ref<DeviceModbusConfigVO>({} as DeviceModbusConfigVO)
+
+/** 获取连接配置 */
+const getModbusConfig = async () => {
+  modbusConfig.value = await DeviceModbusConfigApi.getModbusConfig(props.device.id)
+}
+
+/** 编辑连接配置 */
+const configFormRef = ref()
+const handleEditConfig = () => {
+  configFormRef.value?.open(modbusConfig.value)
+}
+
+// ======================= 点位配置 =======================
+const pointLoading = ref(false)
+const pointList = ref<DeviceModbusPointVO[]>([])
+const total = ref(0)
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  deviceId: props.device.id,
+  name: undefined as string | undefined,
+  identifier: undefined as string | undefined
+})
+
+/** 获取点位分页 */
+const getPointPage = async () => {
+  pointLoading.value = true
+  try {
+    const data = await DeviceModbusPointApi.getModbusPointPage(queryParams)
+    pointList.value = data.list
+    total.value = data.total
+  } finally {
+    pointLoading.value = false
+  }
+}
+
+/** 搜索 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getPointPage()
+}
+
+/** 重置搜索 */
+const resetQuery = () => {
+  queryParams.name = undefined
+  queryParams.identifier = undefined
+  handleQuery()
+}
+
+/** 格式化功能码 */
+const formatFunctionCode = (code: number) => {
+  const option = ModbusFunctionCodeOptions.find((item) => item.value === code)
+  return option ? option.label : `${code}`
+}
+
+/** 格式化寄存器地址为十六进制 */
+const formatRegisterAddress = (address: number) => {
+  return '0x' + address.toString(16).toUpperCase().padStart(4, '0')
+}
+
+/** 新增点位 */
+const pointFormRef = ref()
+const handleAddPoint = () => {
+  pointFormRef.value?.open('create')
+}
+
+/** 编辑点位 */
+const handleEditPoint = (row: DeviceModbusPointVO) => {
+  pointFormRef.value?.open('update', row.id)
+}
+
+/** 删除点位 */
+const handleDeletePoint = async (id: number, name: string) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm('确定要删除点位【' + name + '】吗?')
+    // 发起删除
+    await DeviceModbusPointApi.deleteModbusPoint(id)
+    message.success('删除成功')
+    // 刷新列表
+    await getPointPage()
+  } catch {}
+}
+
+/** 初始化 */
+onMounted(async () => {
+  await getModbusConfig()
+  await getPointPage()
+})
+</script>

+ 205 - 0
src/views/iot/device/device/detail/DeviceModbusConfigForm.vue

@@ -0,0 +1,205 @@
+<!-- Modbus 连接配置弹窗 -->
+<template>
+  <Dialog title="编辑 Modbus 连接配置" v-model="dialogVisible" width="600px">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="120px"
+      v-loading="formLoading"
+    >
+      <!-- Client 模式专有字段:IP、端口、超时、重试 -->
+      <template v-if="isClient">
+        <el-form-item label="IP 地址" prop="ip">
+          <el-input v-model="formData.ip" placeholder="请输入 Modbus 服务器 IP 地址" />
+        </el-form-item>
+        <el-form-item label="端口" prop="port">
+          <el-input-number
+            v-model="formData.port"
+            placeholder="请输入端口"
+            :min="1"
+            :max="65535"
+            controls-position="right"
+            class="!w-full"
+          />
+        </el-form-item>
+      </template>
+      <!-- 公共字段:从站地址 -->
+      <el-form-item label="从站地址" prop="slaveId">
+        <el-input-number
+          v-model="formData.slaveId"
+          :min="1"
+          :max="247"
+          controls-position="right"
+          placeholder="请输入从站地址,范围 1-247"
+          class="!w-full"
+        />
+      </el-form-item>
+      <!-- Client 模式专有字段:超时、重试 -->
+      <template v-if="isClient">
+        <el-form-item label="连接超时(ms)" prop="timeout">
+          <el-input-number
+            v-model="formData.timeout"
+            :min="1000"
+            :step="1000"
+            controls-position="right"
+            placeholder="请输入连接超时时间"
+            class="!w-full"
+          />
+        </el-form-item>
+        <el-form-item label="重试间隔(ms)" prop="retryInterval">
+          <el-input-number
+            v-model="formData.retryInterval"
+            :min="1000"
+            :step="1000"
+            controls-position="right"
+            placeholder="请输入重试间隔"
+            class="!w-full"
+          />
+        </el-form-item>
+      </template>
+      <!-- Server 模式专有字段:模式、帧格式 -->
+      <template v-if="isServer">
+        <el-form-item label="工作模式" prop="mode">
+          <el-radio-group v-model="formData.mode">
+            <el-radio
+              v-for="dict in getIntDictOptions(DICT_TYPE.IOT_MODBUS_MODE)"
+              :key="dict.value"
+              :label="dict.value"
+            >
+              {{ dict.label }}
+            </el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="帧格式" prop="frameFormat">
+          <el-radio-group v-model="formData.frameFormat">
+            <el-radio
+              v-for="dict in getIntDictOptions(DICT_TYPE.IOT_MODBUS_FRAME_FORMAT)"
+              :key="dict.value"
+              :label="dict.value"
+            >
+              {{ dict.label }}
+            </el-radio>
+          </el-radio-group>
+        </el-form-item>
+      </template>
+      <!-- 公共字段:状态 -->
+      <el-form-item label="状态" prop="status">
+        <el-radio-group v-model="formData.status">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="dialogVisible = false">取消</el-button>
+      <el-button type="primary" @click="submitForm" :loading="formLoading">确定</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script lang="ts" setup>
+import { DeviceModbusConfigApi, DeviceModbusConfigVO } from '@/api/iot/device/modbus/config'
+import { ProtocolTypeEnum } from '@/api/iot/product/product'
+import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
+import { CommonStatusEnum } from '@/utils/constants'
+import { ModbusModeEnum, ModbusFrameFormatEnum } from '@/views/iot/utils/constants'
+
+defineOptions({ name: 'DeviceModbusConfigForm' })
+
+const props = defineProps<{
+  deviceId: number
+  protocolType: string
+}>()
+
+const emit = defineEmits<{
+  (e: 'success'): void
+}>()
+
+const message = useMessage()
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单提交 loading 状态
+const isClient = computed(() => props.protocolType === ProtocolTypeEnum.MODBUS_TCP_CLIENT) // 是否为 Client 模式
+const isServer = computed(() => props.protocolType === ProtocolTypeEnum.MODBUS_TCP_SERVER) // 是否为 Server 模式
+const formData = ref<DeviceModbusConfigVO>({
+  deviceId: props.deviceId,
+  ip: '',
+  port: 502,
+  slaveId: 1,
+  timeout: 3000,
+  retryInterval: 10000,
+  mode: ModbusModeEnum.POLLING,
+  frameFormat: ModbusFrameFormatEnum.MODBUS_TCP,
+  status: CommonStatusEnum.ENABLE
+})
+const formRules = computed(() => {
+  const rules: Record<string, any[]> = {
+    slaveId: [{ required: true, message: '请输入从站地址', trigger: 'blur' }]
+  }
+  if (isClient.value) {
+    rules.ip = [{ required: true, message: '请输入 IP 地址', trigger: 'blur' }]
+    rules.port = [{ required: true, message: '请输入端口', trigger: 'blur' }]
+    rules.timeout = [{ required: true, message: '请输入连接超时时间', trigger: 'blur' }]
+    rules.retryInterval = [{ required: true, message: '请输入重试间隔', trigger: 'blur' }]
+  }
+  if (isServer.value) {
+    rules.mode = [{ required: true, message: '请选择工作模式', trigger: 'change' }]
+    rules.frameFormat = [{ required: true, message: '请选择帧格式', trigger: 'change' }]
+  }
+  return rules
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (data?: DeviceModbusConfigVO) => {
+  dialogVisible.value = true
+  resetForm()
+  // 编辑模式
+  if (data && data.id) {
+    formData.value = { ...data }
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    deviceId: props.deviceId,
+    ip: '',
+    port: 502,
+    slaveId: 1,
+    timeout: 3000,
+    retryInterval: 10000,
+    mode: ModbusModeEnum.POLLING,
+    frameFormat: ModbusFrameFormatEnum.MODBUS_TCP,
+    status: CommonStatusEnum.ENABLE
+  }
+  formRef.value?.resetFields()
+}
+
+/** 提交表单 */
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef.value) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    formData.value.deviceId = props.deviceId
+    await DeviceModbusConfigApi.saveModbusConfig(formData.value)
+    message.success('保存成功')
+    dialogVisible.value = false
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 暴露方法 */
+defineExpose({ open })
+</script>

+ 286 - 0
src/views/iot/device/device/detail/DeviceModbusPointForm.vue

@@ -0,0 +1,286 @@
+<!-- Modbus 点位表单弹窗 -->
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible" width="600px">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="120px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="物模型属性" prop="thingModelId">
+        <el-select
+          v-model="formData.thingModelId"
+          placeholder="请选择物模型属性"
+          filterable
+          class="!w-full"
+          @change="handleThingModelChange"
+        >
+          <el-option
+            v-for="item in propertyList"
+            :key="item.id!"
+            :label="`${item.name} (${item.identifier})`"
+            :value="item.id!"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="功能码" prop="functionCode">
+        <el-select v-model="formData.functionCode" placeholder="请选择功能码" class="!w-full">
+          <el-option
+            v-for="item in ModbusFunctionCodeOptions"
+            :key="item.value"
+            :label="item.label"
+            :value="item.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="寄存器地址" prop="registerAddress">
+        <el-input
+          v-model.number="formData.registerAddress"
+          type="number"
+          :min="0"
+          :max="65535"
+          placeholder="请输入寄存器地址"
+          class="!w-full"
+        >
+          <template #suffix>
+            <span class="text-gray-400">{{ registerAddressHex }}</span>
+          </template>
+        </el-input>
+      </el-form-item>
+      <el-form-item label="寄存器数量" prop="registerCount">
+        <el-input-number
+          v-model="formData.registerCount"
+          :min="1"
+          :max="125"
+          controls-position="right"
+          placeholder="请输入寄存器数量"
+          class="!w-full"
+        />
+      </el-form-item>
+      <el-form-item label="原始数据类型" prop="rawDataType">
+        <el-select
+          v-model="formData.rawDataType"
+          placeholder="请选择数据类型"
+          class="!w-full"
+          @change="handleRawDataTypeChange"
+        >
+          <el-option
+            v-for="item in ModbusRawDataTypeOptions"
+            :key="item.value"
+            :label="`${item.label} - ${item.description}`"
+            :value="item.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="字节序" prop="byteOrder">
+        <el-select v-model="formData.byteOrder" placeholder="请选择字节序" class="!w-full">
+          <el-option
+            v-for="item in currentByteOrderOptions"
+            :key="item.value"
+            :label="`${item.label} - ${item.description}`"
+            :value="item.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="缩放因子" prop="scale">
+        <el-input-number
+          v-model="formData.scale"
+          :precision="6"
+          :step="0.1"
+          controls-position="right"
+          placeholder="请输入缩放因子"
+          class="!w-full"
+        />
+      </el-form-item>
+      <el-form-item label="轮询间隔(ms)" prop="pollInterval">
+        <el-input-number
+          v-model="formData.pollInterval"
+          :min="100"
+          :step="1000"
+          controls-position="right"
+          placeholder="请输入轮询间隔"
+          class="!w-full"
+        />
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-radio-group v-model="formData.status">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="dialogVisible = false">取消</el-button>
+      <el-button type="primary" @click="submitForm" :loading="formLoading">确定</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script lang="ts" setup>
+import { ThingModelData } from '@/api/iot/thingmodel'
+import { DeviceModbusPointApi, DeviceModbusPointVO } from '@/api/iot/device/modbus/point'
+import {
+  ModbusFunctionCodeOptions,
+  ModbusRawDataTypeOptions,
+  getByteOrderOptions,
+  IoTThingModelTypeEnum
+} from '@/views/iot/utils/constants'
+import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
+import { CommonStatusEnum } from '@/utils/constants'
+
+defineOptions({ name: 'DeviceModbusPointForm' })
+
+const props = defineProps<{
+  deviceId: number
+  thingModelList: ThingModelData[]
+}>()
+
+const emit = defineEmits<{
+  (e: 'success'): void
+}>()
+
+const { t } = useI18n()
+const message = useMessage()
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref<DeviceModbusPointVO>({
+  deviceId: props.deviceId,
+  thingModelId: undefined,
+  identifier: '',
+  name: '',
+  functionCode: undefined,
+  registerAddress: undefined,
+  registerCount: undefined,
+  byteOrder: undefined,
+  rawDataType: undefined,
+  scale: 1,
+  pollInterval: 5000,
+  status: CommonStatusEnum.ENABLE
+})
+
+const formRules = {
+  thingModelId: [{ required: true, message: '请选择物模型属性', trigger: 'change' }],
+  functionCode: [{ required: true, message: '请选择功能码', trigger: 'change' }],
+  registerAddress: [{ required: true, message: '请输入寄存器地址', trigger: 'blur' }],
+  registerCount: [{ required: true, message: '请输入寄存器数量', trigger: 'blur' }],
+  rawDataType: [{ required: true, message: '请选择数据类型', trigger: 'change' }],
+  pollInterval: [{ required: true, message: '请输入轮询间隔', trigger: 'blur' }]
+}
+const formRef = ref() // 表单 Ref
+
+/** 寄存器地址十六进制显示 */
+const registerAddressHex = computed(() => {
+  if (formData.value.registerAddress === undefined || formData.value.registerAddress === null) {
+    return ''
+  }
+  return '0x' + formData.value.registerAddress.toString(16).toUpperCase().padStart(4, '0')
+})
+
+/** 筛选属性类型的物模型 */
+const propertyList = computed(() => {
+  return props.thingModelList.filter((item) => item.type === IoTThingModelTypeEnum.PROPERTY)
+})
+
+/** 当前字节序选项(根据数据类型动态变化) */
+const currentByteOrderOptions = computed(() => {
+  if (!formData.value.rawDataType) {
+    return []
+  }
+  return getByteOrderOptions(formData.value.rawDataType)
+})
+
+/** 物模型属性变化 */
+const handleThingModelChange = (thingModelId: number) => {
+  const thingModel = props.thingModelList.find((item) => item.id === thingModelId)
+  if (thingModel) {
+    formData.value.identifier = thingModel.identifier!
+    formData.value.name = thingModel.name!
+  }
+}
+
+/** 数据类型变化 */
+const handleRawDataTypeChange = (rawDataType: string) => {
+  // 根据数据类型自动设置寄存器数量
+  const option = ModbusRawDataTypeOptions.find((item) => item.value === rawDataType)
+  if (option && option.registerCount > 0) {
+    formData.value.registerCount = option.registerCount
+  }
+
+  // 重置字节序为第一个选项
+  const byteOrderOptions = getByteOrderOptions(rawDataType)
+  if (byteOrderOptions.length > 0) {
+    formData.value.byteOrder = byteOrderOptions[0].value
+  }
+}
+
+/** 打开弹窗 */
+const open = async (type: 'create' | 'update', id?: number) => {
+  dialogVisible.value = true
+  formType.value = type
+  dialogTitle.value = t('action.' + type)
+  resetForm()
+  // 修改时,设置数据
+  if (type === 'update' && id) {
+    formLoading.value = true
+    try {
+      formData.value = await DeviceModbusPointApi.getModbusPoint(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+
+/** 提交表单 */
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef.value) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    if (formType.value === 'create') {
+      await DeviceModbusPointApi.createModbusPoint(formData.value)
+      message.success('创建成功')
+    } else {
+      await DeviceModbusPointApi.updateModbusPoint(formData.value)
+      message.success('更新成功')
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    deviceId: props.deviceId,
+    thingModelId: undefined,
+    identifier: '',
+    name: '',
+    functionCode: undefined,
+    registerAddress: undefined,
+    registerCount: undefined,
+    byteOrder: undefined,
+    rawDataType: undefined,
+    scale: 1,
+    pollInterval: 5000,
+    status: CommonStatusEnum.ENABLE
+  }
+  formRef.value?.resetFields()
+}
+
+/** 暴露方法 */
+defineExpose({ open })
+</script>

+ 56 - 12
src/views/iot/device/device/detail/index.vue

@@ -3,27 +3,36 @@
     :loading="loading"
     :product="product"
     :device="device"
-    @refresh="getDeviceData(id)"
+    @refresh="getDeviceData"
   />
   <el-col>
     <el-tabs v-model="activeTab">
       <el-tab-pane label="设备信息" name="info">
         <DeviceDetailsInfo v-if="activeTab === 'info'" :product="product" :device="device" />
       </el-tab-pane>
-      <el-tab-pane label="Topic 列表" />
       <el-tab-pane label="物模型数据" name="model">
-        <DeviceDetailsModel v-if="activeTab === 'model'" :product="product" :device="device" />
+        <DeviceDetailsThingModel
+          v-if="activeTab === 'model'"
+          :device-id="device.id"
+          :thing-model-list="thingModelList"
+        />
+      </el-tab-pane>
+      <el-tab-pane
+        label="子设备管理"
+        name="subDevice"
+        v-if="product.deviceType === DeviceTypeEnum.GATEWAY"
+      >
+        <DeviceDetailsSubDevice v-if="activeTab === 'subDevice'" :gateway-id="device.id" />
       </el-tab-pane>
-      <el-tab-pane label="子设备管理" v-if="product.deviceType === DeviceTypeEnum.GATEWAY" />
-      <el-tab-pane label="设备影子" />
-      <el-tab-pane label="设备日志" name="log">
-        <DeviceDetailsLog v-if="activeTab === 'log'" :device-key="device.deviceKey" />
+      <el-tab-pane label="设备消息" name="log">
+        <DeviceDetailsMessage v-if="activeTab === 'log'" :device-id="device.id" />
       </el-tab-pane>
       <el-tab-pane label="模拟设备" name="simulator">
         <DeviceDetailsSimulator
           v-if="activeTab === 'simulator'"
           :product="product"
           :device="device"
+          :thing-model-list="thingModelList"
         />
       </el-tab-pane>
       <el-tab-pane label="设备配置" name="config">
@@ -33,29 +42,49 @@
           @success="getDeviceData"
         />
       </el-tab-pane>
+      <el-tab-pane
+        label="Modbus 配置"
+        name="modbus"
+        v-if="
+          [ProtocolTypeEnum.MODBUS_TCP_CLIENT, ProtocolTypeEnum.MODBUS_TCP_SERVER].includes(
+            product.protocolType as ProtocolTypeEnum
+          )
+        "
+      >
+        <DeviceModbusConfig
+          v-if="activeTab === 'modbus'"
+          :device="device"
+          :product="product"
+          :thing-model-list="thingModelList"
+        />
+      </el-tab-pane>
     </el-tabs>
   </el-col>
 </template>
 <script lang="ts" setup>
 import { useTagsViewStore } from '@/store/modules/tagsView'
 import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
-import { DeviceTypeEnum, ProductApi, ProductVO } from '@/api/iot/product/product'
+import { DeviceTypeEnum, ProductApi, ProductVO, ProtocolTypeEnum } from '@/api/iot/product/product'
+import { ThingModelApi, ThingModelData } from '@/api/iot/thingmodel'
 import DeviceDetailsHeader from './DeviceDetailsHeader.vue'
 import DeviceDetailsInfo from './DeviceDetailsInfo.vue'
-import DeviceDetailsModel from './DeviceDetailsModel.vue'
-import DeviceDetailsLog from './DeviceDetailsLog.vue'
+import DeviceDetailsThingModel from './DeviceDetailsThingModel.vue'
+import DeviceDetailsMessage from './DeviceDetailsMessage.vue'
 import DeviceDetailsSimulator from './DeviceDetailsSimulator.vue'
 import DeviceDetailConfig from './DeviceDetailConfig.vue'
+import DeviceModbusConfig from './DeviceModbusConfig.vue'
+import DeviceDetailsSubDevice from './DeviceDetailsSubDevice.vue'
 
 defineOptions({ name: 'IoTDeviceDetail' })
 
 const route = useRoute()
 const message = useMessage()
-const id = route.params.id // 将字符串转换为数字
+const id = Number(route.params.id) // 将字符串转换为数字
 const loading = ref(true) // 加载中
 const product = ref<ProductVO>({} as ProductVO) // 产品详情
 const device = ref<DeviceVO>({} as DeviceVO) // 设备详情
 const activeTab = ref('info') // 默认激活的标签页
+const thingModelList = ref<ThingModelData[]>([]) // 物模型列表数据
 
 /** 获取设备详情 */
 const getDeviceData = async () => {
@@ -63,6 +92,7 @@ const getDeviceData = async () => {
   try {
     device.value = await DeviceApi.getDevice(id)
     await getProductData(device.value.productId)
+    await getThingModelList(device.value.productId)
   } finally {
     loading.value = false
   }
@@ -73,9 +103,23 @@ const getProductData = async (id: number) => {
   product.value = await ProductApi.getProduct(id)
 }
 
+/** 获取物模型列表 */
+const getThingModelList = async (productId: number) => {
+  try {
+    const data = await ThingModelApi.getThingModelList({
+      productId: productId
+    })
+    thingModelList.value = data || []
+  } catch (error) {
+    console.error('获取物模型列表失败:', error)
+    thingModelList.value = []
+  }
+}
+
 /** 初始化 */
 const { delView } = useTagsViewStore() // 视图操作
-const { currentRoute } = useRouter() // 路由
+const router = useRouter() // 路由
+const { currentRoute } = router
 onMounted(async () => {
   if (!id) {
     message.warning('参数错误,产品不能为空!')

+ 25 - 11
src/views/iot/device/device/index.vue

@@ -173,7 +173,7 @@
                 <div class="mr-2.5 flex items-center">
                   <el-image :src="defaultIconUrl" class="w-[18px] h-[18px]" />
                 </div>
-                <div class="text-[16px] font-600 flex-1">{{ item.deviceName }}</div>
+                <div class="text-[16px] font-600 flex-1 overflow-hidden text-ellipsis whitespace-nowrap">{{ item.deviceName }}</div>
                 <!-- 添加设备状态标签 -->
                 <div class="inline-flex items-center">
                   <div
@@ -199,20 +199,20 @@
                 <div class="flex-1">
                   <div class="mb-2.5 last:mb-0">
                     <span class="text-[#717c8e] mr-2.5">所属产品</span>
-                    <span class="text-[#0070ff]">
+                    <el-link class="text-[#0070ff]" @click="openProductDetail(item.productId)">
                       {{ products.find((p) => p.id === item.productId)?.name }}
-                    </span>
+                    </el-link>
                   </div>
                   <div class="mb-2.5 last:mb-0">
                     <span class="text-[#717c8e] mr-2.5">设备类型</span>
                     <dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="item.deviceType" />
                   </div>
                   <div class="mb-2.5 last:mb-0">
-                    <span class="text-[#717c8e] mr-2.5">DeviceKey</span>
+                    <span class="text-[#717c8e] mr-2.5">备注名称</span>
                     <span
-                      class="text-[#0b1d30] inline-block align-middle overflow-hidden text-ellipsis whitespace-nowrap max-w-[130px]"
+                      class="text-[var(--el-text-color-primary)] inline-block align-middle overflow-hidden text-ellipsis whitespace-nowrap max-w-[130px]"
                     >
-                      {{ item.deviceKey }}
+                      {{ item.nickname || item.deviceName }}
                     </span>
                   </div>
                 </div>
@@ -289,7 +289,9 @@
       <el-table-column label="备注名称" align="center" prop="nickname" />
       <el-table-column label="所属产品" align="center" prop="productId">
         <template #default="scope">
-          {{ products.find((p) => p.id === scope.row.productId)?.name || '-' }}
+          <el-link @click="openProductDetail(scope.row.productId)">
+            {{ products.find((p) => p.id === scope.row.productId)?.name || '-' }}
+          </el-link>
         </template>
       </el-table-column>
       <el-table-column label="设备类型" align="center" prop="deviceType">
@@ -369,7 +371,8 @@
 <script setup lang="ts">
 import { DICT_TYPE, getIntDictOptions, getDictLabel } from '@/utils/dict'
 import { dateFormatter } from '@/utils/formatTime'
-import { DeviceApi, DeviceVO, DeviceStateEnum } from '@/api/iot/device/device'
+import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
+import { DeviceStateEnum } from '@/views/iot/utils/constants'
 import DeviceForm from './DeviceForm.vue'
 import { ProductApi, ProductVO } from '@/api/iot/product/product'
 import { DeviceGroupApi, DeviceGroupVO } from '@/api/iot/device/group'
@@ -382,15 +385,16 @@ defineOptions({ name: 'IoTDevice' })
 
 const message = useMessage() // 消息弹窗
 const { t } = useI18n() // 国际化
+const route = useRoute() // 路由对象
 
 const loading = ref(true) // 列表加载中
 const list = ref<DeviceVO[]>([]) // 列表的数据
 const total = ref(0) // 列表的总页数
 const queryParams = reactive({
   pageNo: 1,
-  pageSize: 10,
+  pageSize: 12,
   deviceName: undefined,
-  productId: undefined,
+  productId: undefined as number | undefined,
   deviceType: undefined,
   nickname: undefined,
   status: undefined,
@@ -442,6 +446,11 @@ const openDetail = (id: number) => {
   push({ name: 'IoTDeviceDetail', params: { id } })
 }
 
+/** 跳转到产品详情页面 */
+const openProductDetail = (productId: number) => {
+  push({ name: 'IoTProductDetail', params: { id: productId } })
+}
+
 /** 删除按钮操作 */
 const handleDelete = async (id: number) => {
   try {
@@ -506,7 +515,12 @@ const handleImport = () => {
 
 /** 初始化 **/
 onMounted(async () => {
-  getList()
+  // 处理 productId 参数
+  const { productId } = route.query
+  if (productId) {
+    queryParams.productId = Number(productId)
+  }
+  await getList()
 
   // 获取产品列表
   products.value = await ProductApi.getSimpleProductList()

+ 50 - 0
src/views/iot/home/components/ComparisonCard.vue

@@ -0,0 +1,50 @@
+<template>
+  <el-card class="stat-card" shadow="never" :loading="loading">
+    <div class="flex flex-col">
+      <div class="flex justify-between items-center mb-1">
+        <span class="text-gray-500 text-base font-medium">{{ title }}</span>
+        <Icon :icon="icon" :class="`text-[32px] ${iconColor}`" />
+      </div>
+      <span class="text-3xl font-bold text-gray-700">
+        <span v-if="value === -1">--</span>
+        <span v-else>{{ value }}</span>
+      </span>
+      <el-divider class="my-2" />
+      <div class="flex justify-between items-center text-gray-400 text-sm">
+        <span>今日新增</span>
+        <span class="text-green-500" v-if="todayCount !== -1">+{{ todayCount }}</span>
+        <span v-else>--</span>
+      </div>
+    </div>
+  </el-card>
+</template>
+
+<script lang="ts" setup>
+import { propTypes } from '@/utils/propTypes'
+
+/** 【总数 + 新增数】统计卡片组件 */
+defineOptions({ name: 'IoTComparisonCard' })
+
+const props = defineProps({
+  title: propTypes.string.def('').isRequired,
+  value: propTypes.number.def(0).isRequired,
+  todayCount: propTypes.number.def(0).isRequired,
+  icon: propTypes.string.def('').isRequired,
+  iconColor: propTypes.string.def(''),
+  loading: {
+    type: Boolean,
+    default: false
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.stat-card {
+  transition: all 0.3s;
+
+  &:hover {
+    transform: translateY(-5px);
+    box-shadow: 0 5px 15px rgb(0 0 0 / 8%);
+  }
+}
+</style>

+ 131 - 0
src/views/iot/home/components/DeviceCountCard.vue

@@ -0,0 +1,131 @@
+<template>
+  <el-card class="chart-card" shadow="never" :loading="loading">
+    <template #header>
+      <div class="flex items-center">
+        <span class="text-base font-medium text-gray-600">设备数量统计</span>
+      </div>
+    </template>
+    <div v-if="loading && !hasData" class="h-[240px] flex justify-center items-center">
+      <el-empty description="加载中..." />
+    </div>
+    <div v-else-if="!hasData" class="h-[240px] flex justify-center items-center">
+      <el-empty description="暂无数据" />
+    </div>
+    <div v-else ref="deviceCountChartRef" class="h-[240px]"></div>
+  </el-card>
+</template>
+
+<script lang="ts" setup>
+import * as echarts from 'echarts/core'
+import { PieChart } from 'echarts/charts'
+import { CanvasRenderer } from 'echarts/renderers'
+import { TooltipComponent, LegendComponent } from 'echarts/components'
+import { LabelLayout } from 'echarts/features'
+import { IotStatisticsSummaryRespVO } from '@/api/iot/statistics'
+import type { PropType } from 'vue'
+
+/** 【设备数量】统计卡片 */
+defineOptions({ name: 'DeviceCountCard' })
+
+const props = defineProps({
+  statsData: {
+    type: Object as PropType<IotStatisticsSummaryRespVO>,
+    required: true
+  },
+  loading: {
+    type: Boolean,
+    default: false
+  }
+})
+
+const deviceCountChartRef = ref()
+
+/** 是否有数据 */
+const hasData = computed(() => {
+  if (!props.statsData) return false
+
+  const categories = Object.entries(props.statsData.productCategoryDeviceCounts || {})
+  return categories.length > 0 && props.statsData.deviceCount !== -1
+})
+
+/** 初始化图表 */
+const initChart = () => {
+  // 如果没有数据,则不初始化图表
+  if (!hasData.value) return
+  // 确保 DOM 元素存在且已渲染
+  if (!deviceCountChartRef.value) {
+    console.warn('图表DOM元素不存在')
+    return
+  }
+
+  echarts.use([TooltipComponent, LegendComponent, PieChart, CanvasRenderer, LabelLayout])
+  try {
+    const chart = echarts.init(deviceCountChartRef.value)
+    chart.setOption({
+      tooltip: {
+        trigger: 'item'
+      },
+      legend: {
+        top: '5%',
+        right: '10%',
+        align: 'left',
+        orient: 'vertical',
+        icon: 'circle'
+      },
+      series: [
+        {
+          name: 'Access From',
+          type: 'pie',
+          radius: ['50%', '80%'],
+          avoidLabelOverlap: false,
+          center: ['30%', '50%'],
+          label: {
+            show: false,
+            position: 'outside'
+          },
+          emphasis: {
+            label: {
+              show: true,
+              fontSize: 20,
+              fontWeight: 'bold'
+            }
+          },
+          labelLine: {
+            show: false
+          },
+          data: Object.entries(props.statsData.productCategoryDeviceCounts).map(
+            ([name, value]) => ({
+              name,
+              value
+            })
+          )
+        }
+      ]
+    })
+    return chart
+  } catch (error) {
+    console.error('初始化图表失败:', error)
+    return null
+  }
+}
+
+/** 监听数据变化 */
+watch(
+  () => props.statsData,
+  () => {
+    // 使用 nextTick 确保 DOM 已更新
+    nextTick(() => {
+      initChart()
+    })
+  },
+  { deep: true }
+)
+
+/** 组件挂载时初始化图表 */
+onMounted(async () => {
+  // 使用 nextTick 确保 DOM 已更新
+  await nextTick(() => {
+    initChart()
+  })
+})
+</script>

+ 187 - 0
src/views/iot/home/components/DeviceMapCard.vue

@@ -0,0 +1,187 @@
+<template>
+  <el-card class="chart-card" shadow="never">
+    <template #header>
+      <div class="flex items-center justify-between">
+        <span class="text-base font-medium text-gray-600">设备分布地图</span>
+        <div class="flex items-center gap-4 text-sm">
+          <span v-for="item in stateOptions" :key="item.value" class="flex items-center gap-1">
+            <span
+              class="inline-block w-3 h-3 rounded-full"
+              :style="{ backgroundColor: stateColorMap[item.value] }"
+            ></span>
+            <span class="text-gray-500">{{ item.label }}</span>
+          </span>
+        </div>
+      </div>
+    </template>
+    <div v-if="loading" class="h-[500px] flex justify-center items-center">
+      <el-empty description="加载中..." />
+    </div>
+    <div v-else-if="!hasData" class="h-[500px] flex justify-center items-center">
+      <el-empty description="暂无设备位置数据" />
+    </div>
+    <div v-show="hasData && !loading" ref="mapContainerRef" class="h-[500px] w-full"></div>
+  </el-card>
+</template>
+
+<script lang="ts" setup>
+import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
+import { useRouter } from 'vue-router'
+import { DICT_TYPE, getDictObj, getIntDictOptions } from '@/utils/dict'
+import { DeviceStateEnum } from '@/views/iot/utils/constants'
+import { loadBaiduMapSdk } from '@/components/Map/src/utils'
+
+defineOptions({ name: 'DeviceMapCard' })
+
+const router = useRouter()
+const mapContainerRef = ref<HTMLElement>()
+let mapInstance: any = null
+const loading = ref(true)
+const deviceList = ref<DeviceVO[]>([])
+
+/** 是否有数据 */
+const hasData = computed(() => deviceList.value.length > 0)
+
+/** 状态图例列表(从字典获取) */
+const stateOptions = computed(() => getIntDictOptions(DICT_TYPE.IOT_DEVICE_STATE))
+
+/** 设备状态颜色映射 */
+const stateColorMap: Record<number, string> = {
+  [DeviceStateEnum.INACTIVE]: '#EAB308', // 待激活 - 黄色
+  [DeviceStateEnum.ONLINE]: '#22C55E', // 在线 - 绿色
+  [DeviceStateEnum.OFFLINE]: '#9CA3AF' // 离线 - 灰色
+}
+
+/** 获取设备状态配置(从字典获取) */
+const getStateConfig = (state: number): { name: string; color: string } => {
+  const dict = getDictObj(DICT_TYPE.IOT_DEVICE_STATE, state)
+  return {
+    name: dict?.label || '未知',
+    color: stateColorMap[state] || '#909399'
+  }
+}
+
+/** 创建自定义标记点图标 */
+const createMarkerIcon = (color: string, isOnline: boolean) => {
+  const size = isOnline ? 24 : 20
+  const svg = `
+    <svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24">
+      <circle cx="12" cy="12" r="8" fill="${color}" stroke="white" stroke-width="2"/>
+      ${isOnline ? `<circle cx="12" cy="12" r="10" fill="none" stroke="${color}" stroke-width="2" opacity="0.5"/>` : ''}
+    </svg>
+  `
+  const blob = new Blob([svg], { type: 'image/svg+xml' })
+  const url = URL.createObjectURL(blob)
+  return new window.BMapGL.Icon(url, new window.BMapGL.Size(size, size), {
+    anchor: new window.BMapGL.Size(size / 2, size / 2)
+  })
+}
+
+/** 初始化地图 */
+const initMap = () => {
+  if (!mapContainerRef.value || !window.BMapGL) {
+    return
+  }
+
+  // 销毁旧实例
+  if (mapInstance) {
+    mapInstance.destroy?.()
+    mapInstance = null
+  }
+
+  // 创建地图实例,默认以中国为中心
+  mapInstance = new window.BMapGL.Map(mapContainerRef.value)
+  mapInstance.centerAndZoom(new window.BMapGL.Point(106, 37.5), 5)
+  mapInstance.enableScrollWheelZoom()
+
+  // 添加控件
+  mapInstance.addControl(new window.BMapGL.ScaleControl())
+  mapInstance.addControl(new window.BMapGL.ZoomControl())
+
+  // 添加设备标记点
+  deviceList.value.forEach((device) => {
+    const config = getStateConfig(device.state)
+    const isOnline = device.state === DeviceStateEnum.ONLINE
+    const point = new window.BMapGL.Point(device.longitude, device.latitude)
+
+    // 创建标记
+    const marker = new window.BMapGL.Marker(point, {
+      icon: createMarkerIcon(config.color, isOnline)
+    })
+
+    // 创建信息窗口内容
+    const infoContent = `
+      <div style="padding: 8px; min-width: 180px;">
+        <div style="font-weight: bold; margin-bottom: 8px; font-size: 14px;">${device.nickname || device.deviceName}</div>
+        <div style="color: #666; font-size: 12px; line-height: 1.8;">
+          <div>产品: ${device.productName || '-'}</div>
+          <div>状态: <span style="color: ${config.color}; font-weight: 500;">${config.name}</span></div>
+        </div>
+        <div style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #eee;">
+          <a href="javascript:void(0)" style="color: #409EFF; font-size: 12px; text-decoration: none;">点击查看详情 →</a>
+        </div>
+      </div>
+    `
+
+    // 点击标记显示信息窗口
+    marker.addEventListener('click', () => {
+      const infoWindow = new window.BMapGL.InfoWindow(infoContent, {
+        width: 220,
+        height: 140,
+        title: ''
+      })
+
+      // 信息窗口打开后绑定链接点击事件
+      infoWindow.addEventListener('open', () => {
+        setTimeout(() => {
+          const link = document.querySelector('.BMap_bubble_content a')
+          if (link) {
+            link.addEventListener('click', (e) => {
+              e.preventDefault()
+              router.push({ name: 'IoTDeviceDetail', params: { id: device.id } })
+            })
+          }
+        }, 100)
+      })
+
+      mapInstance.openInfoWindow(infoWindow, point)
+    })
+
+    mapInstance.addOverlay(marker)
+  })
+}
+
+/** 加载设备数据 */
+const loadDeviceData = async () => {
+  loading.value = true
+  try {
+    deviceList.value = await DeviceApi.getDeviceLocationList()
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 初始化 */
+const init = async () => {
+  await loadDeviceData()
+  if (!hasData.value) {
+    return
+  }
+  await loadBaiduMapSdk()
+  await nextTick()
+  initMap()
+}
+
+/** 组件挂载时初始化 */
+onMounted(() => {
+  init()
+})
+
+/** 组件卸载时销毁地图实例 */
+onUnmounted(() => {
+  if (mapInstance) {
+    mapInstance.destroy?.()
+    mapInstance = null
+  }
+})
+</script>

+ 163 - 0
src/views/iot/home/components/DeviceStateCountCard.vue

@@ -0,0 +1,163 @@
+<template>
+  <el-card class="chart-card" shadow="never" :loading="loading">
+    <template #header>
+      <div class="flex items-center">
+        <span class="text-base font-medium text-gray-600">设备状态统计</span>
+      </div>
+    </template>
+    <div v-if="loading && !hasData" class="h-[240px] flex justify-center items-center">
+      <el-empty description="加载中..." />
+    </div>
+    <div v-else-if="!hasData" class="h-[240px] flex justify-center items-center">
+      <el-empty description="暂无数据" />
+    </div>
+    <el-row v-else class="h-[240px]">
+      <el-col :span="8" class="flex flex-col items-center">
+        <div ref="deviceOnlineCountChartRef" class="h-[160px] w-full"></div>
+        <div class="text-center mt-2">
+          <span class="text-sm text-gray-600">在线设备</span>
+        </div>
+      </el-col>
+      <el-col :span="8" class="flex flex-col items-center">
+        <div ref="deviceOfflineChartRef" class="h-[160px] w-full"></div>
+        <div class="text-center mt-2">
+          <span class="text-sm text-gray-600">离线设备</span>
+        </div>
+      </el-col>
+      <el-col :span="8" class="flex flex-col items-center">
+        <div ref="deviceActiveChartRef" class="h-[160px] w-full"></div>
+        <div class="text-center mt-2">
+          <span class="text-sm text-gray-600">待激活设备</span>
+        </div>
+      </el-col>
+    </el-row>
+  </el-card>
+</template>
+
+<script lang="ts" setup>
+import * as echarts from 'echarts/core'
+import { GaugeChart } from 'echarts/charts'
+import { CanvasRenderer } from 'echarts/renderers'
+import { IotStatisticsSummaryRespVO } from '@/api/iot/statistics'
+import type { PropType } from 'vue'
+
+/** 【设备状态】统计卡片 */
+defineOptions({ name: 'DeviceStateCountCard' })
+
+const props = defineProps({
+  statsData: {
+    type: Object as PropType<IotStatisticsSummaryRespVO>,
+    required: true
+  },
+  loading: {
+    type: Boolean,
+    default: false
+  }
+})
+
+const deviceOnlineCountChartRef = ref()
+const deviceOfflineChartRef = ref()
+const deviceActiveChartRef = ref()
+
+/** 是否有数据 */
+const hasData = computed(() => {
+  if (!props.statsData) return false
+  return props.statsData.deviceCount !== -1
+})
+
+/** 初始化仪表盘图表 */
+const initGaugeChart = (el: any, value: number, color: string) => {
+  // 确保 DOM 元素存在且已渲染
+  if (!el) {
+    console.warn('图表DOM元素不存在')
+    return
+  }
+
+  echarts.use([GaugeChart, CanvasRenderer])
+  try {
+    const chart = echarts.init(el)
+    chart.setOption({
+      series: [
+        {
+          type: 'gauge',
+          startAngle: 360,
+          endAngle: 0,
+          min: 0,
+          max: props.statsData.deviceCount || 100, // 使用设备总数作为最大值
+          progress: {
+            show: true,
+            width: 12,
+            itemStyle: {
+              color: color
+            }
+          },
+          axisLine: {
+            lineStyle: {
+              width: 12,
+              color: [[1, '#E5E7EB']]
+            }
+          },
+          axisTick: { show: false },
+          splitLine: { show: false },
+          axisLabel: { show: false },
+          pointer: { show: false },
+          anchor: { show: false },
+          title: { show: false },
+          detail: {
+            valueAnimation: true,
+            fontSize: 24,
+            fontWeight: 'bold',
+            fontFamily: 'Inter, sans-serif',
+            color: color,
+            offsetCenter: [0, '0'],
+            formatter: (value: number) => {
+              return `${value} 个`
+            }
+          },
+          data: [{ value: value }]
+        }
+      ]
+    })
+    return chart
+  } catch (error) {
+    console.error('初始化图表失败:', error)
+    return null
+  }
+}
+
+/** 初始化所有图表 */
+const initCharts = () => {
+  // 如果没有数据,则不初始化图表
+  if (!hasData.value) return
+
+  // 使用 nextTick 确保 DOM 已更新
+  nextTick(() => {
+    // 在线设备统计
+    if (deviceOnlineCountChartRef.value) {
+      initGaugeChart(deviceOnlineCountChartRef.value, props.statsData.deviceOnlineCount, '#0d9')
+    }
+    // 离线设备统计
+    if (deviceOfflineChartRef.value) {
+      initGaugeChart(deviceOfflineChartRef.value, props.statsData.deviceOfflineCount, '#f50')
+    }
+    // 待激活设备统计
+    if (deviceActiveChartRef.value) {
+      initGaugeChart(deviceActiveChartRef.value, props.statsData.deviceInactiveCount, '#05b')
+    }
+  })
+}
+
+/** 监听数据变化 */
+watch(
+  () => props.statsData,
+  () => {
+    initCharts()
+  },
+  { deep: true }
+)
+
+/** 组件挂载时初始化图表 */
+onMounted(() => {
+  initCharts()
+})
+</script>

+ 227 - 0
src/views/iot/home/components/MessageTrendCard.vue

@@ -0,0 +1,227 @@
+<template>
+  <el-card class="chart-card" shadow="never" :loading="loading">
+    <template #header>
+      <div class="flex items-center justify-between">
+        <span class="text-base font-medium text-gray-600">消息量统计</span>
+        <div class="flex flex-wrap items-center gap-4">
+          <el-form-item label="时间范围" class="!mb-0">
+            <el-date-picker
+              v-model="queryParams.times"
+              :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+              :shortcuts="defaultShortcuts"
+              class="!w-240px"
+              end-placeholder="结束日期"
+              start-placeholder="开始日期"
+              type="daterange"
+              value-format="YYYY-MM-DD HH:mm:ss"
+              @change="handleQuery"
+            />
+          </el-form-item>
+          <el-form-item label="时间间隔" class="!mb-0">
+            <el-select
+              v-model="queryParams.interval"
+              class="!w-120px"
+              placeholder="间隔类型"
+              @change="handleQuery"
+            >
+              <el-option
+                v-for="dict in getIntDictOptions(DICT_TYPE.DATE_INTERVAL)"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value"
+              />
+            </el-select>
+          </el-form-item>
+        </div>
+      </div>
+    </template>
+    <div v-if="loading && !hasData" class="h-[300px] flex justify-center items-center">
+      <el-empty description="加载中..." />
+    </div>
+    <div v-else-if="!hasData" class="h-[300px] flex justify-center items-center">
+      <el-empty description="暂无数据" />
+    </div>
+    <div v-else ref="messageChartRef" class="h-[300px]"></div>
+  </el-card>
+</template>
+
+<script lang="ts" setup>
+import * as echarts from 'echarts/core'
+import { LineChart } from 'echarts/charts'
+import { CanvasRenderer } from 'echarts/renderers'
+import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
+import { UniversalTransition } from 'echarts/features'
+import {
+  StatisticsApi,
+  IotStatisticsDeviceMessageSummaryByDateRespVO,
+  IotStatisticsDeviceMessageReqVO
+} from '@/api/iot/statistics'
+import { formatDate, beginOfDay, endOfDay, defaultShortcuts } from '@/utils/formatTime'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+
+/** 消息趋势统计卡片 */
+defineOptions({ name: 'MessageTrendCard' })
+
+const messageChartRef = ref()
+const loading = ref(false)
+const messageData = ref<IotStatisticsDeviceMessageSummaryByDateRespVO[]>([])
+
+const queryParams = reactive<IotStatisticsDeviceMessageReqVO>({
+  interval: 1, // DAY, 日
+  times: [
+    // 默认显示最近一周的数据
+    formatDate(beginOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24 * 7))),
+    formatDate(endOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24)))
+  ]
+}) // 查询参数
+
+// 是否有数据
+const hasData = computed(() => {
+  return messageData.value && messageData.value.length > 0
+})
+
+// 处理查询操作
+const handleQuery = () => {
+  fetchMessageData()
+}
+
+// 获取消息统计数据
+const fetchMessageData = async () => {
+  loading.value = true
+  try {
+    messageData.value = await StatisticsApi.getDeviceMessageSummaryByDate(queryParams)
+
+    // 使用 nextTick 确保数据更新后重新渲染图表
+    await nextTick()
+    initChart()
+  } catch (error) {
+    console.error('获取消息统计数据失败:', error)
+    messageData.value = []
+  } finally {
+    loading.value = false
+  }
+}
+
+// 初始化图表
+const initChart = () => {
+  // 检查是否有数据可以绘制
+  if (!hasData.value) return
+  // 确保 DOM 元素存在且已渲染
+  if (!messageChartRef.value) {
+    console.warn('图表 DOM 元素不存在')
+    return
+  }
+
+  // 配置图表
+  echarts.use([
+    LineChart,
+    CanvasRenderer,
+    GridComponent,
+    LegendComponent,
+    TooltipComponent,
+    UniversalTransition
+  ])
+  try {
+    const chart = echarts.init(messageChartRef.value)
+    chart.setOption({
+      tooltip: {
+        trigger: 'axis',
+        backgroundColor: 'rgba(255, 255, 255, 0.9)',
+        borderColor: '#E5E7EB',
+        textStyle: {
+          color: '#374151'
+        }
+      },
+      legend: {
+        data: ['上行消息量', '下行消息量'],
+        textStyle: {
+          color: '#374151',
+          fontWeight: 500
+        }
+      },
+      grid: {
+        left: '3%',
+        right: '4%',
+        bottom: '3%',
+        containLabel: true
+      },
+      xAxis: {
+        type: 'category',
+        boundaryGap: false,
+        data: messageData.value.map((item) => item.time),
+        axisLine: {
+          lineStyle: {
+            color: '#E5E7EB'
+          }
+        },
+        axisLabel: {
+          color: '#6B7280'
+        }
+      },
+      yAxis: {
+        type: 'value',
+        axisLine: {
+          lineStyle: {
+            color: '#E5E7EB'
+          }
+        },
+        axisLabel: {
+          color: '#6B7280'
+        },
+        splitLine: {
+          lineStyle: {
+            color: '#F3F4F6'
+          }
+        }
+      },
+      series: [
+        {
+          name: '上行消息量',
+          type: 'line',
+          smooth: true,
+          data: messageData.value.map((item) => item.upstreamCount),
+          itemStyle: {
+            color: '#3B82F6'
+          },
+          lineStyle: {
+            width: 2
+          },
+          areaStyle: {
+            color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+              { offset: 0, color: 'rgba(59, 130, 246, 0.2)' },
+              { offset: 1, color: 'rgba(59, 130, 246, 0)' }
+            ])
+          }
+        },
+        {
+          name: '下行消息量',
+          type: 'line',
+          smooth: true,
+          data: messageData.value.map((item) => item.downstreamCount),
+          itemStyle: {
+            color: '#10B981'
+          },
+          lineStyle: {
+            width: 2
+          },
+          areaStyle: {
+            color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+              { offset: 0, color: 'rgba(16, 185, 129, 0.2)' },
+              { offset: 1, color: 'rgba(16, 185, 129, 0)' }
+            ])
+          }
+        }
+      ]
+    })
+    return chart
+  } catch (error) {
+    console.error('初始化图表失败:', error)
+    return null
+  }
+}
+
+/** 组件挂载时初始化 */
+onMounted(() => {
+  fetchMessageData()
+})
+</script>

+ 68 - 463
src/views/iot/home/index.vue

@@ -2,502 +2,109 @@
   <!-- 第一行:统计卡片行 -->
   <el-row :gutter="16" class="mb-4">
     <el-col :span="6">
-      <el-card class="stat-card" shadow="never">
-        <div class="flex flex-col">
-          <div class="flex justify-between items-center mb-1">
-            <span class="text-gray-500 text-base font-medium">分类数量</span>
-            <Icon icon="ep:menu" class="text-[32px] text-blue-400" />
-          </div>
-          <span class="text-3xl font-bold text-gray-700">
-            {{ statsData.productCategoryCount }}
-          </span>
-          <el-divider class="my-2" />
-          <div class="flex justify-between items-center text-gray-400 text-sm">
-            <span>今日新增</span>
-            <span class="text-green-500">+{{ statsData.productCategoryTodayCount }}</span>
-          </div>
-        </div>
-      </el-card>
+      <ComparisonCard
+        title="分类数量"
+        :value="statsData.productCategoryCount"
+        :todayCount="statsData.productCategoryTodayCount"
+        icon="ep:menu"
+        iconColor="text-blue-400"
+        :loading="loading"
+      />
     </el-col>
     <el-col :span="6">
-      <el-card class="stat-card" shadow="never">
-        <div class="flex flex-col">
-          <div class="flex justify-between items-center mb-1">
-            <span class="text-gray-500 text-base font-medium">产品数量</span>
-            <Icon icon="ep:box" class="text-[32px] text-orange-400" />
-          </div>
-          <span class="text-3xl font-bold text-gray-700">{{ statsData.productCount }}</span>
-          <el-divider class="my-2" />
-          <div class="flex justify-between items-center text-gray-400 text-sm">
-            <span>今日新增</span>
-            <span class="text-green-500">+{{ statsData.productTodayCount }}</span>
-          </div>
-        </div>
-      </el-card>
+      <ComparisonCard
+        title="产品数量"
+        :value="statsData.productCount"
+        :todayCount="statsData.productTodayCount"
+        icon="ep:box"
+        iconColor="text-orange-400"
+        :loading="loading"
+      />
     </el-col>
     <el-col :span="6">
-      <el-card class="stat-card" shadow="never">
-        <div class="flex flex-col">
-          <div class="flex justify-between items-center mb-1">
-            <span class="text-gray-500 text-base font-medium">设备数量</span>
-            <Icon icon="ep:cpu" class="text-[32px] text-purple-400" />
-          </div>
-          <span class="text-3xl font-bold text-gray-700">{{ statsData.deviceCount }}</span>
-          <el-divider class="my-2" />
-          <div class="flex justify-between items-center text-gray-400 text-sm">
-            <span>今日新增</span>
-            <span class="text-green-500">+{{ statsData.deviceTodayCount }}</span>
-          </div>
-        </div>
-      </el-card>
+      <ComparisonCard
+        title="设备数量"
+        :value="statsData.deviceCount"
+        :todayCount="statsData.deviceTodayCount"
+        icon="ep:cpu"
+        iconColor="text-purple-400"
+        :loading="loading"
+      />
     </el-col>
     <el-col :span="6">
-      <el-card class="stat-card" shadow="never">
-        <div class="flex flex-col">
-          <div class="flex justify-between items-center mb-1">
-            <span class="text-gray-500 text-base font-medium">设备消息数</span>
-            <Icon icon="ep:message" class="text-[32px] text-teal-400" />
-          </div>
-          <span class="text-3xl font-bold text-gray-700">
-            {{ statsData.deviceMessageCount }}
-          </span>
-          <el-divider class="my-2" />
-          <div class="flex justify-between items-center text-gray-400 text-sm">
-            <span>今日新增</span>
-            <span class="text-green-500">+{{ statsData.deviceMessageTodayCount }}</span>
-          </div>
-        </div>
-      </el-card>
+      <ComparisonCard
+        title="设备消息数"
+        :value="statsData.deviceMessageCount"
+        :todayCount="statsData.deviceMessageTodayCount"
+        icon="ep:message"
+        iconColor="text-teal-400"
+        :loading="loading"
+      />
     </el-col>
   </el-row>
 
   <!-- 第二行:图表行 -->
   <el-row :gutter="16" class="mb-4">
     <el-col :span="12">
-      <el-card class="chart-card" shadow="never">
-        <template #header>
-          <div class="flex items-center">
-            <span class="text-base font-medium text-gray-600">设备数量统计</span>
-          </div>
-        </template>
-        <div ref="deviceCountChartRef" class="h-[240px]"></div>
-      </el-card>
+      <DeviceCountCard :statsData="statsData" :loading="loading" />
     </el-col>
     <el-col :span="12">
-      <el-card class="chart-card" shadow="never">
-        <template #header>
-          <div class="flex items-center">
-            <span class="text-base font-medium text-gray-600">设备状态统计</span>
-          </div>
-        </template>
-        <el-row class="h-[240px]">
-          <el-col :span="8" class="flex flex-col items-center">
-            <div ref="deviceOnlineCountChartRef" class="h-[160px] w-full"></div>
-            <div class="text-center mt-2">
-              <span class="text-sm text-gray-600">在线设备</span>
-            </div>
-          </el-col>
-          <el-col :span="8" class="flex flex-col items-center">
-            <div ref="deviceOfflineChartRef" class="h-[160px] w-full"></div>
-            <div class="text-center mt-2">
-              <span class="text-sm text-gray-600">离线设备</span>
-            </div>
-          </el-col>
-          <el-col :span="8" class="flex flex-col items-center">
-            <div ref="deviceActiveChartRef" class="h-[160px] w-full"></div>
-            <div class="text-center mt-2">
-              <span class="text-sm text-gray-600">待激活设备</span>
-            </div>
-          </el-col>
-        </el-row>
-      </el-card>
+      <DeviceStateCountCard :statsData="statsData" :loading="loading" />
     </el-col>
   </el-row>
 
   <!-- 第三行:消息统计行 -->
-  <el-row>
+  <el-row class="mb-4">
     <el-col :span="24">
-      <el-card class="chart-card" shadow="never">
-        <template #header>
-          <div class="flex items-center justify-between">
-            <span class="text-base font-medium text-gray-600">上下行消息量统计</span>
-            <div class="flex items-center space-x-2">
-              <el-radio-group v-model="timeRange" @change="handleTimeRangeChange">
-                <el-radio-button label="1h">最近1小时</el-radio-button>
-                <el-radio-button label="24h">最近24小时</el-radio-button>
-                <el-radio-button label="7d">近一周</el-radio-button>
-              </el-radio-group>
-              <el-date-picker
-                v-model="dateRange"
-                type="datetimerange"
-                range-separator="至"
-                start-placeholder="开始时间"
-                end-placeholder="结束时间"
-                :default-time="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 1, 1, 23, 59, 59)]"
-                @change="handleDateRangeChange"
-              />
-            </div>
-          </div>
-        </template>
-        <div ref="deviceMessageCountChartRef" class="h-[300px]"></div>
-      </el-card>
+      <MessageTrendCard />
     </el-col>
   </el-row>
 
-  <!-- TODO 第四行:地图 -->
+  <!-- 第四行:设备分布地图 -->
+  <el-row>
+    <el-col :span="24">
+      <DeviceMapCard />
+    </el-col>
+  </el-row>
 </template>
 
 <script setup lang="ts" name="Index">
-import * as echarts from 'echarts/core'
-import {
-  GridComponent,
-  LegendComponent,
-  TitleComponent,
-  ToolboxComponent,
-  TooltipComponent
-} from 'echarts/components'
-import { GaugeChart, LineChart, PieChart } from 'echarts/charts'
-import { LabelLayout, UniversalTransition } from 'echarts/features'
-import { CanvasRenderer } from 'echarts/renderers'
-import {
-  IotStatisticsDeviceMessageSummaryRespVO,
-  IotStatisticsSummaryRespVO,
-  ProductCategoryApi
-} from '@/api/iot/statistics'
-import { formatDate } from '@/utils/formatTime'
-
-// TODO @super:参考下 /Users/yunai/Java/yudao-ui-admin-vue3/src/views/mall/home/index.vue,拆一拆组件
+import { IotStatisticsSummaryRespVO, StatisticsApi } from '@/api/iot/statistics'
+import ComparisonCard from './components/ComparisonCard.vue'
+import DeviceCountCard from './components/DeviceCountCard.vue'
+import DeviceStateCountCard from './components/DeviceStateCountCard.vue'
+import MessageTrendCard from './components/MessageTrendCard.vue'
+import DeviceMapCard from './components/DeviceMapCard.vue'
 
 /** IoT 首页 */
 defineOptions({ name: 'IoTHome' })
 
-// TODO @super:使用下 Echart 组件,参考 yudao-ui-admin-vue3/src/views/mall/home/components/TradeTrendCard.vue 等
-echarts.use([
-  TooltipComponent,
-  LegendComponent,
-  PieChart,
-  CanvasRenderer,
-  LabelLayout,
-  TitleComponent,
-  ToolboxComponent,
-  GridComponent,
-  LineChart,
-  UniversalTransition,
-  GaugeChart
-])
-
-const timeRange = ref('7d') // 修改默认选择为近一周
-const dateRange = ref<[Date, Date] | null>(null)
-
-const queryParams = reactive({
-  startTime: Date.now() - 7 * 24 * 60 * 60 * 1000, // 设置默认开始时间为 7 天前
-  endTime: Date.now() // 设置默认结束时间为当前时间
-})
-
-const deviceCountChartRef = ref() // 设备数量统计的图表
-const deviceOnlineCountChartRef = ref() // 在线设备统计的图表
-const deviceOfflineChartRef = ref() // 离线设备统计的图表
-const deviceActiveChartRef = ref() // 待激活设备统计的图表
-const deviceMessageCountChartRef = ref() // 上下行消息量统计的图表
-
-// 基础统计数据
-// TODO @super:初始为 -1,然后界面展示先是加载中?试试用 cursor 改哈
 const statsData = ref<IotStatisticsSummaryRespVO>({
-  productCategoryCount: 0,
-  productCount: 0,
-  deviceCount: 0,
-  deviceMessageCount: 0,
-  productCategoryTodayCount: 0,
-  productTodayCount: 0,
-  deviceTodayCount: 0,
-  deviceMessageTodayCount: 0,
-  deviceOnlineCount: 0,
-  deviceOfflineCount: 0,
-  deviceInactiveCount: 0,
+  productCategoryCount: -1,
+  productCount: -1,
+  deviceCount: -1,
+  deviceMessageCount: -1,
+  productCategoryTodayCount: -1,
+  productTodayCount: -1,
+  deviceTodayCount: -1,
+  deviceMessageTodayCount: -1,
+  deviceOnlineCount: -1,
+  deviceOfflineCount: -1,
+  deviceInactiveCount: -1,
   productCategoryDeviceCounts: {}
-})
-
-// 消息统计数据
-const messageStats = ref<IotStatisticsDeviceMessageSummaryRespVO>({
-  upstreamCounts: {},
-  downstreamCounts: {}
-})
-
-/** 处理快捷时间范围选择 */
-const handleTimeRangeChange = (timeRange: string) => {
-  const now = Date.now()
-  let startTime: number
-
-  // TODO @super:这个的计算,看看能不能结合 dayjs 简化。因为 1h、24h、7d 感觉是比较标准的。如果没有,抽到 utils/formatTime.ts 作为一个工具方法
-  switch (timeRange) {
-    case '1h':
-      startTime = now - 60 * 60 * 1000
-      break
-    case '24h':
-      startTime = now - 24 * 60 * 60 * 1000
-      break
-    case '7d':
-      startTime = now - 7 * 24 * 60 * 60 * 1000
-      break
-    default:
-      return
-  }
-
-  // 清空日期选择器
-  dateRange.value = null
-
-  // 更新查询参数
-  queryParams.startTime = startTime
-  queryParams.endTime = now
-
-  // 重新获取数据
-  getStats()
-}
-
-/** 处理自定义日期范围选择 */
-const handleDateRangeChange = (value: [Date, Date] | null) => {
-  if (value) {
-    // 清空快捷选项
-    timeRange.value = ''
+}) // 基础统计数据
 
-    // 更新查询参数
-    queryParams.startTime = value[0].getTime()
-    queryParams.endTime = value[1].getTime()
-
-    // 重新获取数据
-    getStats()
-  }
-}
+const loading = ref(true) // 加载状态
 
 /** 获取统计数据 */
 const getStats = async () => {
-  // 获取基础统计数据
-  statsData.value = await ProductCategoryApi.getIotStatisticsSummary()
-
-  // 获取消息统计数据
-  messageStats.value = await ProductCategoryApi.getIotStatisticsDeviceMessageSummary(queryParams)
-
-  // 初始化图表
-  initCharts()
-}
-
-/** 初始化图表 */
-const initCharts = () => {
-  // 设备数量统计
-  echarts.init(deviceCountChartRef.value).setOption({
-    tooltip: {
-      trigger: 'item'
-    },
-    legend: {
-      top: '5%',
-      right: '10%',
-      align: 'left',
-      orient: 'vertical',
-      icon: 'circle'
-    },
-    series: [
-      {
-        name: 'Access From',
-        type: 'pie',
-        radius: ['50%', '80%'],
-        avoidLabelOverlap: false,
-        center: ['30%', '50%'],
-        label: {
-          show: false,
-          position: 'outside'
-        },
-        emphasis: {
-          label: {
-            show: true,
-            fontSize: 20,
-            fontWeight: 'bold'
-          }
-        },
-        labelLine: {
-          show: false
-        },
-        data: Object.entries(statsData.value.productCategoryDeviceCounts).map(([name, value]) => ({
-          name,
-          value
-        }))
-      }
-    ]
-  })
-
-  // 在线设备统计
-  initGaugeChart(deviceOnlineCountChartRef.value, statsData.value.deviceOnlineCount, '#0d9')
-  // 离线设备统计
-  initGaugeChart(deviceOfflineChartRef.value, statsData.value.deviceOfflineCount, '#f50')
-  // 待激活设备统计
-  initGaugeChart(deviceActiveChartRef.value, statsData.value.deviceInactiveCount, '#05b')
-
-  // 消息量统计
-  initMessageChart()
-}
-
-/** 初始化仪表盘图表 */
-const initGaugeChart = (el: any, value: number, color: string) => {
-  echarts.init(el).setOption({
-    series: [
-      {
-        type: 'gauge',
-        startAngle: 360,
-        endAngle: 0,
-        min: 0,
-        max: statsData.value.deviceCount || 100, // 使用设备总数作为最大值
-        progress: {
-          show: true,
-          width: 12,
-          itemStyle: {
-            color: color
-          }
-        },
-        axisLine: {
-          lineStyle: {
-            width: 12,
-            color: [[1, '#E5E7EB']]
-          }
-        },
-        axisTick: { show: false },
-        splitLine: { show: false },
-        axisLabel: { show: false },
-        pointer: { show: false },
-        anchor: { show: false },
-        title: { show: false },
-        detail: {
-          valueAnimation: true,
-          fontSize: 24,
-          fontWeight: 'bold',
-          fontFamily: 'Inter, sans-serif',
-          color: color,
-          offsetCenter: [0, '0'],
-          formatter: (value: number) => {
-            return `${value} 个`
-          }
-        },
-        data: [{ value: value }]
-      }
-    ]
-  })
-}
-
-/** 初始化消息统计图表 */
-const initMessageChart = () => {
-  // 获取所有时间戳并排序
-  // TODO @super:一些 idea 里的红色报错,要去处理掉噢。
-  const timestamps = Array.from(
-    new Set([
-      ...messageStats.value.upstreamCounts.map((item) => Number(Object.keys(item)[0])),
-      ...messageStats.value.downstreamCounts.map((item) => Number(Object.keys(item)[0]))
-    ])
-  ).sort((a, b) => a - b) // 确保时间戳从小到大排序
-
-  // 准备数据
-  const xdata = timestamps.map((ts) => formatDate(ts, 'YYYY-MM-DD HH:mm'))
-  const upData = timestamps.map((ts) => {
-    const item = messageStats.value.upstreamCounts.find(
-      (count) => Number(Object.keys(count)[0]) === ts
-    )
-    return item ? Object.values(item)[0] : 0
-  })
-  const downData = timestamps.map((ts) => {
-    const item = messageStats.value.downstreamCounts.find(
-      (count) => Number(Object.keys(count)[0]) === ts
-    )
-    return item ? Object.values(item)[0] : 0
-  })
-
-  // 配置图表
-  echarts.init(deviceMessageCountChartRef.value).setOption({
-    tooltip: {
-      trigger: 'axis',
-      backgroundColor: 'rgba(255, 255, 255, 0.9)',
-      borderColor: '#E5E7EB',
-      textStyle: {
-        color: '#374151'
-      }
-    },
-    legend: {
-      data: ['上行消息量', '下行消息量'],
-      textStyle: {
-        color: '#374151',
-        fontWeight: 500
-      }
-    },
-    grid: {
-      left: '3%',
-      right: '4%',
-      bottom: '3%',
-      containLabel: true
-    },
-    xAxis: {
-      type: 'category',
-      boundaryGap: false,
-      data: xdata,
-      axisLine: {
-        lineStyle: {
-          color: '#E5E7EB'
-        }
-      },
-      axisLabel: {
-        color: '#6B7280'
-      }
-    },
-    yAxis: {
-      type: 'value',
-      axisLine: {
-        lineStyle: {
-          color: '#E5E7EB'
-        }
-      },
-      axisLabel: {
-        color: '#6B7280'
-      },
-      splitLine: {
-        lineStyle: {
-          color: '#F3F4F6'
-        }
-      }
-    },
-    series: [
-      {
-        name: '上行消息量',
-        type: 'line',
-        smooth: true, // 添加平滑曲线
-        data: upData,
-        itemStyle: {
-          color: '#3B82F6'
-        },
-        lineStyle: {
-          width: 2
-        },
-        areaStyle: {
-          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
-            { offset: 0, color: 'rgba(59, 130, 246, 0.2)' },
-            { offset: 1, color: 'rgba(59, 130, 246, 0)' }
-          ])
-        }
-      },
-      {
-        name: '下行消息量',
-        type: 'line',
-        smooth: true, // 添加平滑曲线
-        data: downData,
-        itemStyle: {
-          color: '#10B981'
-        },
-        lineStyle: {
-          width: 2
-        },
-        areaStyle: {
-          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
-            { offset: 0, color: 'rgba(16, 185, 129, 0.2)' },
-            { offset: 1, color: 'rgba(16, 185, 129, 0)' }
-          ])
-        }
-      }
-    ]
-  })
+  loading.value = true
+  try {
+    // 获取基础统计数据
+    statsData.value = await StatisticsApi.getStatisticsSummary()
+  } finally {
+    loading.value = false
+  }
 }
 
 /** 初始化 */
@@ -505,5 +112,3 @@ onMounted(() => {
   getStats()
 })
 </script>
-
-<style lang="scss" scoped></style>

+ 169 - 0
src/views/iot/ota/firmware/OtaFirmwareForm.vue

@@ -0,0 +1,169 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="固件名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入固件名称" />
+      </el-form-item>
+      <el-form-item label="固件描述" prop="description">
+        <el-input
+          v-model="formData.description"
+          type="textarea"
+          :rows="3"
+          placeholder="请输入固件描述"
+        />
+      </el-form-item>
+      <el-form-item label="所属产品" prop="productId">
+        <el-select
+          v-model="formData.productId"
+          placeholder="请选择产品"
+          clearable
+          class="!w-100%"
+          :disabled="formType === 'update'"
+        >
+          <el-option
+            v-for="product in productList"
+            :key="product.id"
+            :label="product.name"
+            :value="product.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="版本号" prop="version" v-if="formType === 'create'">
+        <el-input v-model="formData.version" placeholder="请输入版本号" />
+      </el-form-item>
+      <el-form-item label="固件文件" prop="fileUrl" v-if="formType === 'create'">
+        <UploadFile
+          v-model="formData.fileUrl"
+          :file-type="['bin', 'zip', 'pdf']"
+          :file-size="50"
+          :limit="1"
+        />
+      </el-form-item>
+      <!-- 更新时显示只读信息 -->
+      <template v-if="formType === 'update'">
+        <el-form-item label="版本号">
+          <el-input v-model="formData.version" readonly />
+        </el-form-item>
+        <el-form-item label="固件文件">
+          <el-link
+            type="primary"
+            :href="formData.fileUrl"
+            target="_blank"
+            download
+            v-if="formData.fileUrl"
+          >
+            <Icon icon="ep:download" class="mr-5px" />
+            下载固件文件
+          </el-link>
+          <span v-else>无文件</span>
+        </el-form-item>
+      </template>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import { IoTOtaFirmwareApi, IoTOtaFirmware } from '@/api/iot/ota/firmware'
+import { ProductApi, ProductVO } from '@/api/iot/product/product'
+import { UploadFile } from '@/components/UploadFile'
+
+/** IoT OTA 固件表单 */
+defineOptions({ name: 'IoTOtaFirmwareForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const productList = ref<ProductVO[]>([]) // 产品列表
+const formData = ref({
+  id: undefined,
+  name: undefined,
+  description: undefined,
+  version: undefined,
+  productId: undefined,
+  fileUrl: ''
+})
+const formRules = reactive({
+  name: [{ required: true, message: '固件名称不能为空', trigger: 'blur' }],
+  version: [{ required: true, message: '版本号不能为空', trigger: 'blur' }],
+  productId: [{ required: true, message: '产品不能为空', trigger: 'change' }],
+  fileUrl: [{ required: true, message: '固件文件不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await IoTOtaFirmwareApi.getOtaFirmware(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+
+  // 获取产品列表
+  productList.value = await ProductApi.getSimpleProductList()
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  await formRef.value.validate()
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as IoTOtaFirmware
+    if (formType.value === 'create') {
+      await IoTOtaFirmwareApi.createOtaFirmware(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      // 更新时只提交可编辑的字段
+      await IoTOtaFirmwareApi.updateOtaFirmware({
+        id: data.id,
+        name: data.name,
+        description: data.description
+      })
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    description: undefined,
+    version: undefined,
+    productId: undefined,
+    fileUrl: ''
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 143 - 0
src/views/iot/ota/firmware/detail/index.vue

@@ -0,0 +1,143 @@
+<template>
+  <div class="app-container">
+    <!-- 固件信息 -->
+    <ContentWrap title="固件信息" class="mb-20px">
+      <el-descriptions :column="3" v-loading="firmwareLoading" border>
+        <el-descriptions-item label="固件名称">
+          {{ firmware?.name }}
+        </el-descriptions-item>
+        <el-descriptions-item label="所属产品">
+          {{ firmware?.productName }}
+        </el-descriptions-item>
+        <el-descriptions-item label="固件版本">
+          {{ firmware?.version }}
+        </el-descriptions-item>
+        <el-descriptions-item label="创建时间">
+          {{ firmware?.createTime ? formatDate(firmware.createTime) : '-' }}
+        </el-descriptions-item>
+        <el-descriptions-item label="固件描述" :span="2">
+          {{ firmware?.description }}
+        </el-descriptions-item>
+      </el-descriptions>
+    </ContentWrap>
+
+    <!-- 升级设备统计 -->
+    <ContentWrap title="升级设备统计" class="mb-20px">
+      <el-row :gutter="20" class="py-20px" v-loading="firmwareStatisticsLoading">
+        <el-col :span="6">
+          <div class="text-center p-20px border border-solid border-gray-200 rounded bg-gray-50">
+            <div class="text-32px font-bold mb-8px text-blue-500">
+              {{
+                Object.values(firmwareStatistics).reduce((sum, count) => sum + (count || 0), 0) || 0
+              }}
+            </div>
+            <div class="text-14px text-gray-600">升级设备总数</div>
+          </div>
+        </el-col>
+        <el-col :span="3">
+          <div class="text-center p-20px border border-solid border-gray-200 rounded bg-gray-50">
+            <div class="text-32px font-bold mb-8px text-gray-400">
+              {{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.PENDING.value] || 0 }}
+            </div>
+            <div class="text-14px text-gray-600">待推送</div>
+          </div>
+        </el-col>
+        <el-col :span="3">
+          <div class="text-center p-20px border border-solid border-gray-200 rounded bg-gray-50">
+            <div class="text-32px font-bold mb-8px text-blue-400">
+              {{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.PUSHED.value] || 0 }}
+            </div>
+            <div class="text-14px text-gray-600">已推送</div>
+          </div>
+        </el-col>
+        <el-col :span="3">
+          <div class="text-center p-20px border border-solid border-gray-200 rounded bg-gray-50">
+            <div class="text-32px font-bold mb-8px text-yellow-500">
+              {{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.UPGRADING.value] || 0 }}
+            </div>
+            <div class="text-14px text-gray-600">正在升级</div>
+          </div>
+        </el-col>
+        <el-col :span="3">
+          <div class="text-center p-20px border border-solid border-gray-200 rounded bg-gray-50">
+            <div class="text-32px font-bold mb-8px text-green-500">
+              {{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.SUCCESS.value] || 0 }}
+            </div>
+            <div class="text-14px text-gray-600">升级成功</div>
+          </div>
+        </el-col>
+        <el-col :span="3">
+          <div class="text-center p-20px border border-solid border-gray-200 rounded bg-gray-50">
+            <div class="text-32px font-bold mb-8px text-red-500">
+              {{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.FAILURE.value] || 0 }}
+            </div>
+            <div class="text-14px text-gray-600">升级失败</div>
+          </div>
+        </el-col>
+        <el-col :span="3">
+          <div class="text-center p-20px border border-solid border-gray-200 rounded bg-gray-50">
+            <div class="text-32px font-bold mb-8px text-gray-400">
+              {{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.CANCELED.value] || 0 }}
+            </div>
+            <div class="text-14px text-gray-600">升级取消</div>
+          </div>
+        </el-col>
+      </el-row>
+    </ContentWrap>
+
+    <!-- 任务管理 -->
+    <OtaTaskList
+      :firmware-id="firmwareId"
+      :product-id="firmware?.productId"
+      @success="getStatistics"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { formatDate } from '@/utils/formatTime'
+import { IoTOtaFirmwareApi, IoTOtaFirmware } from '@/api/iot/ota/firmware'
+import { IoTOtaTaskRecordApi } from '@/api/iot/ota/task/record'
+import { IoTOtaTaskRecordStatusEnum } from '@/views/iot/utils/constants'
+import OtaTaskList from '../../task/OtaTaskList.vue'
+
+/** IoT OTA 固件详情 */
+defineOptions({ name: 'IoTOtaFirmwareDetail' })
+
+const route = useRoute() // 路由
+
+const firmwareId = ref(Number(route.params.id)) // 固件编号
+const firmwareLoading = ref(false) // 固件加载状态
+const firmware = ref<IoTOtaFirmware>({} as IoTOtaFirmware) // 固件信息
+
+const firmwareStatisticsLoading = ref(false) // 统计信息加载状态
+const firmwareStatistics = ref<Record<string, number>>({}) // 统计信息
+
+/** 获取固件信息 */
+const getFirmwareInfo = async () => {
+  firmwareLoading.value = true
+  try {
+    firmware.value = await IoTOtaFirmwareApi.getOtaFirmware(firmwareId.value)
+  } finally {
+    firmwareLoading.value = false
+  }
+}
+
+/** 获取升级统计 */
+const getStatistics = async () => {
+  firmwareStatisticsLoading.value = true
+  try {
+    firmwareStatistics.value = await IoTOtaTaskRecordApi.getOtaTaskRecordStatusStatistics(
+      firmwareId.value
+    )
+  } finally {
+    firmwareStatisticsLoading.value = false
+  }
+}
+
+/** 初始化 */
+onMounted(() => {
+  getFirmwareInfo()
+  getStatistics()
+})
+</script>

+ 232 - 0
src/views/iot/ota/firmware/index.vue

@@ -0,0 +1,232 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <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-240px"
+        />
+      </el-form-item>
+      <el-form-item label="产品" prop="productId">
+        <el-select
+          v-model="queryParams.productId"
+          placeholder="请选择产品"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        >
+          <el-option
+            v-for="product in productList"
+            :key="product.id"
+            :label="product.name"
+            :value="product.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-220px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['iot:ota-firmware:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table
+      row-key="id"
+      v-loading="loading"
+      :data="list"
+      :stripe="true"
+      :show-overflow-tooltip="true"
+    >
+      <el-table-column label="固件编号" align="center" prop="id" />
+      <el-table-column label="固件名称" align="center" prop="name" />
+      <el-table-column label="固件版本" align="center" prop="description" />
+      <el-table-column label="版本号" align="center" prop="version" />
+      <el-table-column label="所属产品" align="center" prop="productId">
+        <template #default="scope">
+          <el-link
+            @click="openProductDetail(scope.row.productId)"
+            v-if="getProductName(scope.row.productId)"
+          >
+            {{ getProductName(scope.row.productId) }}
+          </el-link>
+          <span v-else>加载中...</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="固件文件" align="center" prop="fileUrl">
+        <template #default="scope">
+          <el-link :href="scope.row.fileUrl" target="_blank" download>
+            <Icon icon="ep:download" class="mr-5px" />
+            下载固件
+          </el-link>
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="操作" align="center" min-width="180px">
+        <template #default="scope">
+          <el-button
+            link
+            @click="openFirmwareDetail(scope.row.id)"
+            v-hasPermi="['iot:ota-firmware:query']"
+          >
+            详情
+          </el-button>
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['iot:ota-firmware:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['iot:ota-firmware:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <OtaFirmwareForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { dateFormatter } from '@/utils/formatTime'
+import { IoTOtaFirmwareApi, IoTOtaFirmware } from '@/api/iot/ota/firmware'
+import { ProductApi, ProductVO } from '@/api/iot/product/product'
+import OtaFirmwareForm from './OtaFirmwareForm.vue'
+
+/** IoT OTA 固件列表 */
+defineOptions({ name: 'IoTOtaFirmware' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+const { push } = useRouter() // 路由
+
+const loading = ref(true) // 列表的加载中
+const list = ref<IoTOtaFirmware[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const productList = ref<ProductVO[]>([]) // 产品列表
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined,
+  productId: undefined,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await IoTOtaFirmwareApi.getOtaFirmwarePage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 根据产品编号,获取产品名称 */
+const getProductName = (productId: number) => {
+  const product = productList.value.find((p) => p.id === productId)
+  return product?.name || ''
+}
+
+/** 打开产品详情 */
+const openProductDetail = (productId: number) => {
+  push({ name: 'IoTProductDetail', params: { id: productId } })
+}
+
+/** 打开固件详情 */
+const openFirmwareDetail = (firmwareId: number) => {
+  push({ name: 'IoTOtaFirmwareDetail', params: { id: firmwareId } })
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await IoTOtaFirmwareApi.deleteOtaFirmware(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  productList.value = await ProductApi.getSimpleProductList()
+  getList()
+})
+</script>

+ 285 - 0
src/views/iot/ota/task/OtaTaskDetail.vue

@@ -0,0 +1,285 @@
+<template>
+  <Dialog v-model="dialogVisible" title="升级任务详情" width="1200px" append-to-body>
+    <!-- 任务信息 -->
+    <ContentWrap title="任务信息" class="mb-20px">
+      <el-descriptions :column="3" v-loading="taskLoading" border>
+        <el-descriptions-item label="任务编号">{{ task.id }}</el-descriptions-item>
+        <el-descriptions-item label="任务名称">{{ task.name }}</el-descriptions-item>
+        <el-descriptions-item label="升级范围">
+          <dict-tag :type="DICT_TYPE.IOT_OTA_TASK_DEVICE_SCOPE" :value="task.deviceScope" />
+        </el-descriptions-item>
+        <el-descriptions-item label="任务状态">
+          <dict-tag :type="DICT_TYPE.IOT_OTA_TASK_STATUS" :value="task.status" />
+        </el-descriptions-item>
+        <el-descriptions-item label="创建时间">
+          {{ task.createTime ? formatDate(task.createTime) : '-' }}
+        </el-descriptions-item>
+        <el-descriptions-item label="任务描述" :span="3">
+          {{ task.description }}
+        </el-descriptions-item>
+      </el-descriptions>
+    </ContentWrap>
+
+    <!-- 任务升级设备统计 -->
+    <ContentWrap title="升级设备统计" class="mb-20px">
+      <el-row :gutter="20" class="py-20px" v-loading="taskStatisticsLoading">
+        <el-col :span="6">
+          <div class="text-center p-20px border border-solid border-gray-200 rounded bg-gray-50">
+            <div class="text-32px font-bold mb-8px text-blue-500">
+              {{ Object.values(taskStatistics).reduce((sum, count) => sum + (count || 0), 0) || 0 }}
+            </div>
+            <div class="text-14px text-gray-600">升级设备总数</div>
+          </div>
+        </el-col>
+        <el-col :span="3">
+          <div class="text-center p-20px border border-solid border-gray-200 rounded bg-gray-50">
+            <div class="text-32px font-bold mb-8px text-gray-400">
+              {{ taskStatistics[IoTOtaTaskRecordStatusEnum.PENDING.value] || 0 }}
+            </div>
+            <div class="text-14px text-gray-600">待推送</div>
+          </div>
+        </el-col>
+        <el-col :span="3">
+          <div class="text-center p-20px border border-solid border-gray-200 rounded bg-gray-50">
+            <div class="text-32px font-bold mb-8px text-blue-400">
+              {{ taskStatistics[IoTOtaTaskRecordStatusEnum.PUSHED.value] || 0 }}
+            </div>
+            <div class="text-14px text-gray-600">已推送</div>
+          </div>
+        </el-col>
+        <el-col :span="3">
+          <div class="text-center p-20px border border-solid border-gray-200 rounded bg-gray-50">
+            <div class="text-32px font-bold mb-8px text-yellow-500">
+              {{ taskStatistics[IoTOtaTaskRecordStatusEnum.UPGRADING.value] || 0 }}
+            </div>
+            <div class="text-14px text-gray-600">正在升级</div>
+          </div>
+        </el-col>
+        <el-col :span="3">
+          <div class="text-center p-20px border border-solid border-gray-200 rounded bg-gray-50">
+            <div class="text-32px font-bold mb-8px text-green-500">
+              {{ taskStatistics[IoTOtaTaskRecordStatusEnum.SUCCESS.value] || 0 }}
+            </div>
+            <div class="text-14px text-gray-600">升级成功</div>
+          </div>
+        </el-col>
+        <el-col :span="3">
+          <div class="text-center p-20px border border-solid border-gray-200 rounded bg-gray-50">
+            <div class="text-32px font-bold mb-8px text-red-500">
+              {{ taskStatistics[IoTOtaTaskRecordStatusEnum.FAILURE.value] || 0 }}
+            </div>
+            <div class="text-14px text-gray-600">升级失败</div>
+          </div>
+        </el-col>
+        <el-col :span="3">
+          <div class="text-center p-20px border border-solid border-gray-200 rounded bg-gray-50">
+            <div class="text-32px font-bold mb-8px text-gray-400">
+              {{ taskStatistics[IoTOtaTaskRecordStatusEnum.CANCELED.value] || 0 }}
+            </div>
+            <div class="text-14px text-gray-600">升级取消</div>
+          </div>
+        </el-col>
+      </el-row>
+    </ContentWrap>
+
+    <!-- 设备管理 -->
+    <ContentWrap title="升级设备记录">
+      <!-- Tab 切换 -->
+      <el-tabs v-model="activeTab" @tab-click="handleTabClick" class="mb-15px">
+        <el-tab-pane v-for="tab in statusTabs" :key="tab.key" :label="tab.label" :name="tab.key" />
+      </el-tabs>
+      <!-- Tab 内容 -->
+      <div v-for="tab in statusTabs" :key="tab.key" v-show="activeTab === tab.key">
+        <!-- 设备列表 -->
+        <el-table
+          v-loading="recordLoading"
+          :data="recordList"
+          :stripe="true"
+          :show-overflow-tooltip="true"
+        >
+          <el-table-column label="设备名称" align="center" prop="deviceName" />
+          <el-table-column label="当前版本" align="center" prop="fromFirmwareVersion" />
+          <el-table-column label="升级状态" align="center" prop="status" width="120">
+            <template #default="scope">
+              <dict-tag :type="DICT_TYPE.IOT_OTA_TASK_RECORD_STATUS" :value="scope.row.status" />
+            </template>
+          </el-table-column>
+          <el-table-column label="升级进度" align="center" prop="progress" width="120">
+            <template #default="scope"> {{ scope.row.progress }}% </template>
+          </el-table-column>
+          <el-table-column label="状态描述" align="center" prop="description" />
+          <el-table-column label="更新时间" align="center" prop="updateTime" width="180">
+            <template #default="scope">
+              {{ formatDate(scope.row.updateTime) }}
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" align="center" width="80">
+            <template #default="scope">
+              <el-button
+                v-if="
+                  [
+                    IoTOtaTaskRecordStatusEnum.PENDING.value,
+                    IoTOtaTaskRecordStatusEnum.PUSHED.value,
+                    IoTOtaTaskRecordStatusEnum.UPGRADING.value
+                  ].includes(scope.row.status)
+                "
+                link
+                type="danger"
+                @click="handleCancelUpgrade(scope.row)"
+                v-hasPermi="['iot:ota-task-record:cancel']"
+              >
+                取消
+              </el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+        <!-- 分页 -->
+        <Pagination
+          :total="recordTotal"
+          v-model:page="queryParams.pageNo"
+          v-model:limit="queryParams.pageSize"
+          @pagination="getRecordList"
+        />
+      </div>
+    </ContentWrap>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, computed } from 'vue'
+import { TabsPaneContext } from 'element-plus'
+import { Dialog } from '@/components/Dialog'
+import { ContentWrap } from '@/components/ContentWrap'
+import Pagination from '@/components/Pagination/index.vue'
+import { IoTOtaTaskApi, OtaTask } from '@/api/iot/ota/task'
+import { IoTOtaTaskRecordApi, OtaTaskRecord } from '@/api/iot/ota/task/record'
+import { DICT_TYPE } from '@/utils/dict'
+import { IoTOtaTaskRecordStatusEnum } from '@/views/iot/utils/constants'
+import { formatDate } from '@/utils/formatTime'
+
+/** OTA 任务详情组件 */
+defineOptions({ name: 'OtaTaskDetail' })
+
+const message = useMessage() // 消息弹窗
+const dialogVisible = ref(false) // 弹窗的是否展示
+
+const taskId = ref<number>() // 任务编号
+const taskLoading = ref(false) // 任务加载状态
+const task = ref<OtaTask>({} as OtaTask) // 任务信息
+
+const taskStatisticsLoading = ref(false) // 任务统计加载状态
+const taskStatistics = ref<Record<string, number>>({}) // 任务统计数据
+
+const recordLoading = ref(false) // 记录列表加载状态
+const recordList = ref<OtaTaskRecord[]>([]) // 记录列表数据
+const recordTotal = ref(0) // 记录总数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  taskId: undefined as number | undefined,
+  status: undefined as number | undefined
+}) // 查询参数
+const activeTab = ref('') // 当前激活的标签页
+
+/** 状态标签配置 */
+const statusTabs = computed(() => {
+  const tabs = [{ key: '', label: '全部设备' }]
+  Object.values(IoTOtaTaskRecordStatusEnum).forEach((status) => {
+    tabs.push({
+      key: status.value.toString(),
+      label: status.label
+    })
+  })
+  return tabs
+})
+
+/** 获取任务详情 */
+const getTaskInfo = async () => {
+  if (!taskId.value) {
+    return
+  }
+  taskLoading.value = true
+  try {
+    task.value = await IoTOtaTaskApi.getOtaTask(taskId.value)
+  } finally {
+    taskLoading.value = false
+  }
+}
+
+/** 获取统计数据 */
+const getStatistics = async () => {
+  if (!taskId.value) {
+    return
+  }
+  taskStatisticsLoading.value = true
+  try {
+    taskStatistics.value = await IoTOtaTaskRecordApi.getOtaTaskRecordStatusStatistics(
+      undefined,
+      taskId.value
+    )
+  } finally {
+    taskStatisticsLoading.value = false
+  }
+}
+
+/** 获取升级记录列表 */
+const getRecordList = async () => {
+  if (!taskId.value) {
+    return
+  }
+  recordLoading.value = true
+  try {
+    queryParams.taskId = taskId.value
+    const data = await IoTOtaTaskRecordApi.getOtaTaskRecordPage(queryParams)
+    recordList.value = data.list || []
+    recordTotal.value = data.total || 0
+  } finally {
+    recordLoading.value = false
+  }
+}
+
+/** 切换标签 */
+const handleTabClick = (tab: TabsPaneContext) => {
+  const tabKey = tab.paneName as string
+  activeTab.value = tabKey
+  queryParams.pageNo = 1
+  queryParams.status = activeTab.value === '' ? undefined : parseInt(tabKey)
+  getRecordList()
+}
+
+/** 取消升级 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const handleCancelUpgrade = async (record: OtaTaskRecord) => {
+  try {
+    await message.confirm('确认要取消该设备的升级任务吗?')
+    await IoTOtaTaskRecordApi.cancelOtaTaskRecord(record.id!)
+    message.success('取消成功')
+    // 刷新数据
+    await getRecordList()
+    await getStatistics()
+    await getTaskInfo()
+    // 通知父组件刷新数据
+    emit('success')
+  } catch (error) {
+    console.error('取消升级失败', error)
+  }
+}
+
+/** 打开弹窗 */
+const open = (id: number) => {
+  taskId.value = id
+  dialogVisible.value = true
+  // 重置数据
+  activeTab.value = ''
+  queryParams.pageNo = 1
+  queryParams.status = undefined
+
+  // 加载数据
+  getTaskInfo()
+  getStatistics()
+  getRecordList()
+}
+
+/** 暴露方法 */
+defineExpose({ open })
+</script>

+ 132 - 0
src/views/iot/ota/task/OtaTaskForm.vue

@@ -0,0 +1,132 @@
+<template>
+  <el-dialog v-model="dialogVisible" title="新增升级任务" width="800px" append-to-body>
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="任务名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入任务名称" />
+      </el-form-item>
+      <el-form-item label="任务描述" prop="description">
+        <el-input
+          v-model="formData.description"
+          type="textarea"
+          :rows="3"
+          placeholder="请输入任务描述"
+        />
+      </el-form-item>
+      <el-form-item label="升级范围" prop="deviceScope">
+        <el-select v-model="formData.deviceScope" placeholder="请选择升级范围" class="w-full">
+          <el-option
+            v-for="item in Object.values(IoTOtaTaskDeviceScopeEnum)"
+            :key="item.value"
+            :label="item.label"
+            :value="item.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item
+        label="选择设备"
+        prop="deviceIds"
+        v-if="formData.deviceScope === IoTOtaTaskDeviceScopeEnum.SELECT.value"
+      >
+        <el-select
+          v-model="formData.deviceIds"
+          multiple
+          placeholder="请选择设备"
+          class="w-full"
+          filterable
+          reserve-keyword
+        >
+          <el-option
+            v-for="device in devices"
+            :key="device.id"
+            :label="
+              device.nickname ? `${device.deviceName} (${device.nickname})` : device.deviceName
+            "
+            :value="device.id"
+          />
+        </el-select>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { IoTOtaTaskApi, OtaTask } from '@/api/iot/ota/task'
+import { IoTOtaTaskDeviceScopeEnum } from '@/views/iot/utils/constants'
+import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
+
+/** IoT OTA 升级任务表单 */
+defineOptions({ name: 'OtaTaskForm' })
+
+const props = defineProps<{
+  firmwareId: number
+  productId: number
+}>()
+
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中:修改时的数据加载
+const formData = ref<OtaTask>({
+  name: '',
+  deviceScope: IoTOtaTaskDeviceScopeEnum.ALL.value,
+  firmwareId: props.firmwareId,
+  description: '',
+  deviceIds: []
+})
+const formRef = ref() // 表单 Ref
+const formRules = {
+  name: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
+  deviceScope: [{ required: true, message: '请选择升级范围', trigger: 'change' }],
+  deviceIds: [{ required: true, message: '请至少选择一个设备', trigger: 'change' }]
+}
+const devices = ref<DeviceVO[]>([]) // 设备选择相关
+
+/** 打开弹窗 */
+const open = async () => {
+  dialogVisible.value = true
+  resetForm()
+  // 加载设备列表
+  devices.value = (await DeviceApi.getDeviceListByProductId(props.productId)) || []
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  await formRef.value.validate()
+  // 提交请求
+  formLoading.value = true
+  try {
+    await IoTOtaTaskApi.createOtaTask(formData.value)
+    message.success('创建成功')
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    name: '',
+    deviceScope: IoTOtaTaskDeviceScopeEnum.ALL.value,
+    firmwareId: props.firmwareId,
+    description: '',
+    deviceIds: []
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 187 - 0
src/views/iot/ota/task/OtaTaskList.vue

@@ -0,0 +1,187 @@
+<template>
+  <ContentWrap title="升级任务管理" class="mb-20px">
+    <!-- 搜索栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+      @submit.prevent
+    >
+      <el-form-item>
+        <el-button type="primary" @click="openTaskForm" v-hasPermi="['iot:ota-task:create']">
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+      </el-form-item>
+      <el-form-item class="float-right">
+        <el-input
+          v-model="queryParams.name"
+          placeholder="请输入任务名称"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+    </el-form>
+
+    <!-- 任务列表 -->
+    <el-table
+      v-loading="taskLoading"
+      :data="taskList"
+      :stripe="true"
+      :show-overflow-tooltip="true"
+      class="mt-15px"
+    >
+      <el-table-column label="任务编号" align="center" prop="id" width="80" />
+      <el-table-column label="任务名称" align="center" prop="name" />
+      <el-table-column label="升级范围" align="center" prop="deviceScope">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.IOT_OTA_TASK_DEVICE_SCOPE" :value="scope.row.deviceScope" />
+        </template>
+      </el-table-column>
+      <el-table-column label="升级进度" align="center">
+        <template #default="scope">
+          {{ scope.row.deviceSuccessCount }}/{{ scope.row.deviceTotalCount }}
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+      />
+      <el-table-column label="任务描述" align="center" prop="description" show-overflow-tooltip />
+      <el-table-column label="任务状态" align="center" prop="status">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.IOT_OTA_TASK_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" width="120">
+        <template #default="scope">
+          <el-button link type="primary" @click="handleTaskDetail(scope.row.id)"> 详情 </el-button>
+          <el-button
+            v-if="scope.row.status === IoTOtaTaskStatusEnum.IN_PROGRESS.value"
+            link
+            type="danger"
+            @click="handleCancelTask(scope.row.id)"
+            v-hasPermi="['iot:ota-task:cancel']"
+          >
+            取消
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 分页 -->
+    <Pagination
+      :total="taskTotal"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getTaskList"
+    />
+
+    <!-- 新增任务弹窗 -->
+    <OtaTaskForm
+      ref="taskFormRef"
+      :firmware-id="firmwareId"
+      :product-id="productId"
+      @success="handleTaskCreateSuccess"
+    />
+
+    <!-- 任务详情弹窗 -->
+    <OtaTaskDetail ref="taskDetailRef" @success="refresh" />
+  </ContentWrap>
+</template>
+
+<script setup lang="ts">
+import { dateFormatter } from '@/utils/formatTime'
+import { IoTOtaTaskApi, OtaTask } from '@/api/iot/ota/task'
+import { DICT_TYPE } from '@/utils/dict'
+import { IoTOtaTaskStatusEnum } from '@/views/iot/utils/constants'
+import OtaTaskForm from './OtaTaskForm.vue'
+import OtaTaskDetail from './OtaTaskDetail.vue'
+
+/** IoT OTA 任务列表 */
+defineOptions({ name: 'OtaTaskList' })
+
+const props = defineProps<{
+  firmwareId: number
+  productId: number
+}>()
+
+const message = useMessage() // 消息弹窗
+
+// 任务列表
+const taskLoading = ref(false)
+const taskList = ref<OtaTask[]>([])
+const taskTotal = ref(0)
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: '',
+  firmwareId: props.firmwareId
+})
+const queryFormRef = ref() // 查询表单引用
+const taskFormRef = ref() // 任务表单引用
+const taskDetailRef = ref() // 任务详情引用
+
+/** 获取任务列表 */
+const getTaskList = async () => {
+  taskLoading.value = true
+  try {
+    const data = await IoTOtaTaskApi.getOtaTaskPage(queryParams)
+    taskList.value = data.list
+    taskTotal.value = data.total
+  } finally {
+    taskLoading.value = false
+  }
+}
+
+/** 搜索 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getTaskList()
+}
+
+/** 打开任务表单 */
+const openTaskForm = () => {
+  taskFormRef.value?.open()
+}
+
+/** 处理任务创建成功 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const handleTaskCreateSuccess = () => {
+  getTaskList()
+  emit('success')
+}
+
+/** 查看任务详情 */
+const handleTaskDetail = (id: number) => {
+  taskDetailRef.value?.open(id)
+}
+
+/** 取消任务 */
+const handleCancelTask = async (id: number) => {
+  try {
+    await message.confirm('确认要取消该升级任务吗?')
+    await IoTOtaTaskApi.cancelOtaTask(id)
+    message.success('取消成功')
+    // 刷新数据
+    await refresh()
+  } catch (error) {
+    console.error('取消任务失败', error)
+  }
+}
+
+/** 刷新数据 */
+const refresh = async () => {
+  await getTaskList()
+  emit('success')
+}
+
+/** 初始化 */
+onMounted(() => {
+  getTaskList()
+})
+</script>

+ 0 - 106
src/views/iot/plugin/PluginConfigForm.vue

@@ -1,106 +0,0 @@
-<template>
-  <Dialog :title="dialogTitle" v-model="dialogVisible">
-    <el-form
-      ref="formRef"
-      :model="formData"
-      :rules="formRules"
-      label-width="100px"
-      v-loading="formLoading"
-    >
-      <el-form-item label="插件名称" prop="name">
-        <el-input v-model="formData.name" placeholder="请输入插件名称" />
-      </el-form-item>
-      <el-form-item label="部署方式" prop="deployType">
-        <el-select v-model="formData.deployType" placeholder="请选择部署方式">
-          <el-option
-            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PLUGIN_DEPLOY_TYPE)"
-            :key="dict.value"
-            :label="dict.label"
-            :value="dict.value"
-          />
-        </el-select>
-      </el-form-item>
-    </el-form>
-    <template #footer>
-      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
-      <el-button @click="dialogVisible = false">取 消</el-button>
-    </template>
-  </Dialog>
-</template>
-<script setup lang="ts">
-import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
-import { PluginConfigApi, PluginConfigVO } from '@/api/iot/plugin'
-
-/** IoT 插件配置 表单 */
-defineOptions({ name: 'PluginConfigForm' })
-
-const { t } = useI18n() // 国际化
-const message = useMessage() // 消息弹窗
-
-const dialogVisible = ref(false) // 弹窗的是否展示
-const dialogTitle = ref('') // 弹窗的标题
-const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
-const formType = ref('') // 表单的类型:create - 新增;update - 修改
-const formData = ref({
-  id: undefined,
-  name: undefined,
-  deployType: undefined
-})
-const formRules = reactive({
-  name: [{ required: true, message: '插件名称不能为空', trigger: 'blur' }],
-  deployType: [{ required: true, message: '部署方式不能为空', trigger: 'change' }]
-})
-const formRef = ref() // 表单 Ref
-
-/** 打开弹窗 */
-const open = async (type: string, id?: number) => {
-  dialogVisible.value = true
-  dialogTitle.value = t('action.' + type)
-  formType.value = type
-  resetForm()
-  // 修改时,设置数据
-  if (id) {
-    formLoading.value = true
-    try {
-      formData.value = await PluginConfigApi.getPluginConfig(id)
-    } finally {
-      formLoading.value = false
-    }
-  }
-}
-defineExpose({ open }) // 提供 open 方法,用于打开弹窗
-
-/** 提交表单 */
-const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
-const submitForm = async () => {
-  // 校验表单
-  await formRef.value.validate()
-  // 提交请求
-  formLoading.value = true
-  try {
-    const data = formData.value as unknown as PluginConfigVO
-    if (formType.value === 'create') {
-      await PluginConfigApi.createPluginConfig(data)
-      message.success(t('common.createSuccess'))
-    } else {
-      await PluginConfigApi.updatePluginConfig(data)
-      message.success(t('common.updateSuccess'))
-    }
-    dialogVisible.value = false
-    // 发送操作成功的事件
-    emit('success')
-  } finally {
-    formLoading.value = false
-  }
-}
-
-/** 重置表单 */
-const resetForm = () => {
-  formData.value = {
-    id: undefined,
-    name: undefined,
-    deployType: undefined
-  }
-  formRef.value?.resetFields()
-}
-</script>

+ 0 - 99
src/views/iot/plugin/detail/PluginImportForm.vue

@@ -1,99 +0,0 @@
-<template>
-  <Dialog v-model="dialogVisible" title="插件导入" width="400">
-    <el-upload
-      ref="uploadRef"
-      v-model:file-list="fileList"
-      :action="importUrl + '?id=' + props.id"
-      :auto-upload="false"
-      :disabled="formLoading"
-      :headers="uploadHeaders"
-      :limit="1"
-      :on-error="submitFormError"
-      :on-exceed="handleExceed"
-      :on-success="submitFormSuccess"
-      accept=".jar"
-      drag
-    >
-      <Icon icon="ep:upload" />
-      <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
-    </el-upload>
-    <template #footer>
-      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
-      <el-button @click="dialogVisible = false">取 消</el-button>
-    </template>
-  </Dialog>
-</template>
-<script lang="ts" setup>
-import { getAccessToken, getTenantId } from '@/utils/auth'
-
-defineOptions({ name: 'PluginImportForm' })
-
-const props = defineProps<{ id: number }>() // 接收 id 作为 props
-
-const message = useMessage() // 消息弹窗
-
-const dialogVisible = ref(false) // 弹窗的是否展示
-const formLoading = ref(false) // 表单的加载中
-const uploadRef = ref()
-const importUrl =
-  import.meta.env.VITE_BASE_URL + import.meta.env.VITE_API_URL + '/iot/plugin-config/upload-file'
-const uploadHeaders = ref() // 上传 Header 头
-const fileList = ref([]) // 文件列表
-
-/** 打开弹窗 */
-const open = () => {
-  dialogVisible.value = true
-  fileList.value = []
-  resetForm()
-}
-defineExpose({ open }) // 提供 open 方法,用于打开弹窗
-
-/** 提交表单 */
-const submitForm = async () => {
-  if (fileList.value.length == 0) {
-    message.error('请上传文件')
-    return
-  }
-  // 提交请求
-  uploadHeaders.value = {
-    Authorization: 'Bearer ' + getAccessToken(),
-    'tenant-id': getTenantId()
-  }
-  formLoading.value = true
-  uploadRef.value!.submit()
-}
-
-/** 文件上传成功 */
-const emits = defineEmits(['success'])
-const submitFormSuccess = (response: any) => {
-  if (response.code !== 0) {
-    message.error(response.msg)
-    formLoading.value = false
-    return
-  }
-  message.alert('上传成功')
-  formLoading.value = false
-  dialogVisible.value = false
-  // 发送操作成功的事件
-  emits('success')
-}
-
-/** 上传错误提示 */
-const submitFormError = (): void => {
-  message.error('上传失败,请您重新上传!')
-  formLoading.value = false
-}
-
-/** 重置表单 */
-const resetForm = async (): Promise<void> => {
-  // 重置上传状态和文件
-  formLoading.value = false
-  await nextTick()
-  uploadRef.value?.clearFiles()
-}
-
-/** 文件数超出提示 */
-const handleExceed = (): void => {
-  message.error('最多只能上传一个文件!')
-}
-</script>

+ 0 - 120
src/views/iot/plugin/detail/index.vue

@@ -1,120 +0,0 @@
-<template>
-  <div>
-    <div class="flex items-start justify-between">
-      <div>
-        <el-col>
-          <el-row>
-            <span class="text-xl font-bold">插件配置</span>
-          </el-row>
-        </el-col>
-      </div>
-    </div>
-    <ContentWrap class="mt-10px">
-      <el-descriptions :column="2" direction="horizontal">
-        <el-descriptions-item label="插件名称">
-          {{ pluginConfig.name }}
-        </el-descriptions-item>
-        <el-descriptions-item label="插件标识">
-          {{ pluginConfig.pluginKey }}
-        </el-descriptions-item>
-        <el-descriptions-item label="版本号">
-          {{ pluginConfig.version }}
-        </el-descriptions-item>
-        <el-descriptions-item label="状态">
-          <el-switch
-            v-model="pluginConfig.status"
-            :active-value="1"
-            :inactive-value="0"
-            :disabled="pluginConfig.id <= 0"
-            @change="handleStatusChange"
-          />
-        </el-descriptions-item>
-        <el-descriptions-item label="插件描述">
-          {{ pluginConfig.description }}
-        </el-descriptions-item>
-      </el-descriptions>
-    </ContentWrap>
-    <!-- TODO @haohao:如果是独立部署,也是通过上传插件包哇? -->
-    <ContentWrap class="mt-10px">
-      <el-button type="warning" plain @click="handleImport" v-hasPermi="['system:user:import']">
-        <Icon icon="ep:upload" /> 上传插件包
-      </el-button>
-    </ContentWrap>
-  </div>
-  <!-- TODO @haohao:待完成:配置管理 -->
-  <!-- TODO @haohao:待完成:script 管理;可以最后搞 -->
-  <!-- TODO @haohao:插件实例的前端展示:底部要不要加个分页,展示运行中的实力?默认勾选,只展示 state 为在线的 -->
-
-  <!-- 插件导入对话框 -->
-  <PluginImportForm
-    ref="importFormRef"
-    :id="pluginConfig.id"
-    @success="getPluginConfig(pluginConfig.id)"
-  />
-</template>
-
-<script lang="ts" setup>
-import { PluginConfigApi, PluginConfigVO } from '@/api/iot/plugin'
-import { useRoute } from 'vue-router'
-import { onMounted, ref } from 'vue'
-import PluginImportForm from './PluginImportForm.vue'
-
-const message = useMessage()
-const route = useRoute()
-const pluginConfig = ref<PluginConfigVO>({
-  id: 0,
-  pluginKey: '',
-  name: '',
-  description: '',
-  version: '',
-  status: 0,
-  deployType: 0,
-  fileName: '',
-  type: 0,
-  protocol: '',
-  configSchema: '',
-  config: '',
-  script: ''
-})
-
-/** 获取插件配置 */
-const getPluginConfig = async (id: number) => {
-  pluginConfig.value = await PluginConfigApi.getPluginConfig(id)
-}
-
-/** 处理状态变更 */
-const handleStatusChange = async (status: number) => {
-  if (pluginConfig.value.id <= 0) {
-    return
-  }
-  try {
-    // 修改状态的二次确认
-    const text = status === 1 ? '启用' : '停用'
-    await message.confirm('确认要"' + text + '"插件吗?')
-    await PluginConfigApi.updatePluginStatus({
-      id: pluginConfig.value.id,
-      status
-    })
-    message.success('更新状态成功')
-    // 获取配置
-    await getPluginConfig(pluginConfig.value.id)
-  } catch (error) {
-    pluginConfig.value.status = status === 1 ? 0 : 1
-    message.error('更新状态失败')
-  }
-}
-
-/** 插件导入 */
-const importFormRef = ref()
-const handleImport = () => {
-  importFormRef.value.open()
-}
-
-/** 初始化插件配置 */
-onMounted(() => {
-  const id = Number(route.params.id)
-  if (id) {
-    getPluginConfig(id)
-  }
-})
-</script>

+ 0 - 329
src/views/iot/plugin/index.vue

@@ -1,329 +0,0 @@
-<!-- TODO @haohao:搞到 config 目录,会不会更好哈 -->
-<template>
-  <ContentWrap>
-    <!-- 搜索工作栏 -->
-    <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-240px"
-        />
-      </el-form-item>
-      <el-form-item label="状态" prop="status">
-        <el-select
-          v-model="queryParams.status"
-          placeholder="请选择状态"
-          clearable
-          @change="handleQuery"
-          class="!w-240px"
-        >
-          <el-option
-            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PLUGIN_STATUS)"
-            :key="dict.value"
-            :label="dict.label"
-            :value="dict.value"
-          />
-        </el-select>
-      </el-form-item>
-      <el-form-item class="float-right !mr-0 !mb-0">
-        <el-button-group>
-          <el-button :type="viewMode === 'card' ? 'primary' : 'default'" @click="viewMode = 'card'">
-            <Icon icon="ep:grid" />
-          </el-button>
-          <el-button :type="viewMode === 'list' ? 'primary' : 'default'" @click="viewMode = 'list'">
-            <Icon icon="ep:list" />
-          </el-button>
-        </el-button-group>
-      </el-form-item>
-      <el-form-item>
-        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
-        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
-        <el-button
-          type="primary"
-          plain
-          @click="openForm('create')"
-          v-hasPermi="['iot:plugin-config:create']"
-        >
-          <Icon icon="ep:plus" class="mr-5px" /> 新增
-        </el-button>
-      </el-form-item>
-    </el-form>
-  </ContentWrap>
-
-  <!-- 列表 -->
-  <ContentWrap>
-    <template v-if="viewMode === 'list'">
-      <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
-        <el-table-column label="插件名称" align="center" prop="name" />
-        <el-table-column label="插件标识" align="center" prop="pluginKey" />
-        <el-table-column label="jar 包" align="center" prop="fileName" />
-        <el-table-column label="版本号" align="center" prop="version" />
-        <el-table-column label="部署方式" align="center" prop="deployType">
-          <template #default="scope">
-            <dict-tag :type="DICT_TYPE.IOT_PLUGIN_DEPLOY_TYPE" :value="scope.row.deployType" />
-          </template>
-        </el-table-column>
-        <el-table-column label="状态" align="center" prop="status">
-          <template #default="scope">
-            <el-switch
-              v-model="scope.row.status"
-              :active-value="1"
-              :inactive-value="0"
-              @change="handleStatusChange(scope.row.id, Number($event))"
-            />
-          </template>
-        </el-table-column>
-        <el-table-column
-          label="创建时间"
-          align="center"
-          prop="createTime"
-          :formatter="dateFormatter"
-          width="180px"
-        />
-        <el-table-column label="操作" align="center" min-width="120px">
-          <template #default="scope">
-            <el-button
-              link
-              type="primary"
-              @click="openDetail(scope.row.id)"
-              v-hasPermi="['iot:product:query']"
-            >
-              查看
-            </el-button>
-            <el-button
-              link
-              type="primary"
-              @click="openForm('update', scope.row.id)"
-              v-hasPermi="['iot:plugin-config:update']"
-            >
-              编辑
-            </el-button>
-            <el-button
-              link
-              type="danger"
-              @click="handleDelete(scope.row.id)"
-              v-hasPermi="['iot:plugin-config:delete']"
-            >
-              删除
-            </el-button>
-          </template>
-        </el-table-column>
-      </el-table>
-    </template>
-    <template v-if="viewMode === 'card'">
-      <el-row :gutter="16">
-        <el-col v-for="item in list" :key="item.id" :xs="24" :sm="12" :md="12" :lg="6" class="mb-4">
-          <el-card
-            class="h-full transition-colors relative overflow-hidden"
-            :body-style="{ padding: '0' }"
-          >
-            <div class="p-4 relative">
-              <!-- 标题区域 -->
-              <div class="flex items-center mb-3">
-                <div class="mr-2.5 flex items-center">
-                  <el-image :src="defaultIconUrl" class="w-[18px] h-[18px]" />
-                </div>
-                <div class="text-[16px] font-600 flex-1">{{ item.name }}</div>
-                <!-- 添加插件状态标签 -->
-                <div class="inline-flex items-center">
-                  <div
-                    class="w-1 h-1 rounded-full mr-1.5"
-                    :class="
-                      item.status === 1
-                        ? 'bg-[var(--el-color-success)]'
-                        : 'bg-[var(--el-color-danger)]'
-                    "
-                  >
-                  </div>
-                  <el-text
-                    class="!text-xs font-bold"
-                    :type="item.status === 1 ? 'success' : 'danger'"
-                  >
-                    {{ item.status === 1 ? '开启' : '禁用' }}
-                  </el-text>
-                </div>
-              </div>
-
-              <!-- 信息区域 -->
-              <div class="flex items-center text-[14px]">
-                <div class="flex-1">
-                  <div class="mb-2.5 last:mb-0">
-                    <span class="text-[#717c8e] mr-2.5">插件标识</span>
-                    <span class="text-[#0b1d30] whitespace-normal break-all">
-                      {{ item.pluginKey }}
-                    </span>
-                  </div>
-                  <div class="mb-2.5 last:mb-0">
-                    <span class="text-[#717c8e] mr-2.5">jar 包</span>
-                    <span class="text-[#0b1d30]">{{ item.fileName }}</span>
-                  </div>
-                  <div class="mb-2.5 last:mb-0">
-                    <span class="text-[#717c8e] mr-2.5">版本号</span>
-                    <span class="text-[#0b1d30]">{{ item.version }}</span>
-                  </div>
-                  <div class="mb-2.5 last:mb-0">
-                    <span class="text-[#717c8e] mr-2.5">部署方式</span>
-                    <dict-tag :type="DICT_TYPE.IOT_PLUGIN_DEPLOY_TYPE" :value="item.deployType" />
-                  </div>
-                </div>
-              </div>
-
-              <!-- 分隔线 -->
-              <el-divider class="!my-3" />
-
-              <!-- 按钮 -->
-              <div class="flex items-center px-0">
-                <el-button
-                  class="flex-1 !px-2 !h-[32px] text-[13px]"
-                  type="primary"
-                  plain
-                  @click="openForm('update', item.id)"
-                  v-hasPermi="['iot:plugin-config:update']"
-                >
-                  <Icon icon="ep:edit-pen" class="mr-1" />
-                  编辑
-                </el-button>
-                <el-button
-                  class="flex-1 !px-2 !h-[32px] !ml-[10px] text-[13px]"
-                  type="warning"
-                  plain
-                  @click="openDetail(item.id)"
-                >
-                  <Icon icon="ep:view" class="mr-1" />
-                  详情
-                </el-button>
-                <div class="mx-[10px] h-[20px] w-[1px] bg-[#dcdfe6]"></div>
-                <el-button
-                  class="!px-2 !h-[32px] text-[13px]"
-                  type="danger"
-                  plain
-                  @click="handleDelete(item.id)"
-                  v-hasPermi="['iot:device:delete']"
-                >
-                  <Icon icon="ep:delete" />
-                </el-button>
-              </div>
-            </div>
-          </el-card>
-        </el-col>
-      </el-row>
-    </template>
-
-    <!-- 分页 -->
-    <Pagination
-      :total="total"
-      v-model:page="queryParams.pageNo"
-      v-model:limit="queryParams.pageSize"
-      @pagination="getList"
-    />
-  </ContentWrap>
-
-  <!-- 表单弹窗:添加/修改 -->
-  <PluginConfigForm ref="formRef" @success="getList" />
-</template>
-
-<script setup lang="ts">
-import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
-import { dateFormatter } from '@/utils/formatTime'
-import { PluginConfigApi, PluginConfigVO } from '@/api/iot/plugin'
-import PluginConfigForm from './PluginConfigForm.vue'
-
-/** IoT 插件配置 列表 */
-defineOptions({ name: 'IoTPlugin' })
-
-const message = useMessage() // 消息弹窗
-const { t } = useI18n() // 国际化
-
-const loading = ref(true) // 列表的加载中
-const list = ref<PluginConfigVO[]>([]) // 列表的数据
-const total = ref(0) // 列表的总页数
-const queryParams = reactive({
-  pageNo: 1,
-  pageSize: 10,
-  name: undefined,
-  status: undefined
-})
-const queryFormRef = ref() // 搜索的表单
-const defaultIconUrl = ref('/src/assets/svgs/iot/card-fill.svg') // 默认插件图标
-const viewMode = ref<'card' | 'list'>('card') // 视图模式状态
-
-/** 查询列表 */
-const getList = async () => {
-  loading.value = true
-  try {
-    const data = await PluginConfigApi.getPluginConfigPage(queryParams)
-    list.value = data.list
-    total.value = data.total
-  } finally {
-    loading.value = false
-  }
-}
-
-/** 搜索按钮操作 */
-const handleQuery = () => {
-  queryParams.pageNo = 1
-  getList()
-}
-
-/** 重置按钮操作 */
-const resetQuery = () => {
-  queryFormRef.value.resetFields()
-  handleQuery()
-}
-
-/** 添加/修改操作 */
-const formRef = ref()
-const openForm = (type: string, id?: number) => {
-  formRef.value.open(type, id)
-}
-
-/** 打开详情 */
-const { push } = useRouter()
-const openDetail = (id: number) => {
-  push({ name: 'IoTPluginDetail', params: { id } })
-}
-
-/** 删除按钮操作 */
-const handleDelete = async (id: number) => {
-  try {
-    // 删除的二次确认
-    await message.delConfirm()
-    // 发起删除
-    await PluginConfigApi.deletePluginConfig(id)
-    message.success(t('common.delSuccess'))
-    // 刷新列表
-    await getList()
-  } catch {}
-}
-
-/** 处理状态变更 */
-const handleStatusChange = async (id: number, status: number) => {
-  try {
-    // 修改状态的二次确认
-    const text = status === 1 ? '启用' : '停用'
-    await message.confirm('确认要"' + text + '"插件吗?')
-    await PluginConfigApi.updatePluginStatus({
-      id: id,
-      status
-    })
-    message.success('更新状态成功')
-    getList()
-  } catch (error) {
-    message.error('更新状态失败')
-  }
-}
-
-/** 初始化 **/
-onMounted(() => {
-  getList()
-})
-</script>

+ 0 - 1
src/views/iot/product/category/index.vue

@@ -118,7 +118,6 @@ const queryParams = reactive({
   createTime: []
 })
 const queryFormRef = ref() // 搜索的表单
-const exportLoading = ref(false) // 导出的加载中
 
 /** 查询列表 */
 const getList = async () => {

+ 49 - 57
src/views/iot/product/product/ProductForm.vue

@@ -4,7 +4,7 @@
       ref="formRef"
       :model="formData"
       :rules="formRules"
-      label-width="110px"
+      label-width="120px"
       v-loading="formLoading"
     >
       <el-form-item label="ProductKey" prop="productKey">
@@ -45,15 +45,11 @@
         </el-radio-group>
       </el-form-item>
       <el-form-item
-        v-if="[DeviceTypeEnum.DEVICE, DeviceTypeEnum.GATEWAY].includes(formData.deviceType)"
+        v-if="[DeviceTypeEnum.DEVICE, DeviceTypeEnum.GATEWAY].includes(formData.deviceType!)"
         label="联网方式"
         prop="netType"
       >
-        <el-select
-          v-model="formData.netType"
-          placeholder="请选择联网方式"
-          :disabled="formType === 'update'"
-        >
+        <el-select v-model="formData.netType" placeholder="请选择联网方式">
           <el-option
             v-for="dict in getIntDictOptions(DICT_TYPE.IOT_NET_TYPE)"
             :key="dict.value"
@@ -62,48 +58,53 @@
           />
         </el-select>
       </el-form-item>
-      <el-form-item
-        v-if="formData.deviceType === DeviceTypeEnum.GATEWAY_SUB"
-        label="接入网关协议"
-        prop="protocolType"
-      >
-        <el-select
-          v-model="formData.protocolType"
-          placeholder="请选择接入网关协议"
-          :disabled="formType === 'update'"
-        >
+      <el-form-item label="协议类型" prop="protocolType">
+        <el-select v-model="formData.protocolType" placeholder="请选择协议类型">
           <el-option
-            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PROTOCOL_TYPE)"
+            v-for="dict in getStrDictOptions(DICT_TYPE.IOT_PROTOCOL_TYPE)"
             :key="dict.value"
             :label="dict.label"
             :value="dict.value"
           />
         </el-select>
       </el-form-item>
-      <el-form-item label="数据格式" prop="dataFormat">
-        <el-radio-group v-model="formData.dataFormat" :disabled="formType === 'update'">
-          <el-radio
-            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DATA_FORMAT)"
-            :key="dict.value"
-            :label="dict.value"
+      <el-form-item prop="serializeType">
+        <template #label>
+          <el-tooltip
+            content="iot-gateway-server 默认根据接入的协议类型确定数据格式,仅 MQTT、EMQX 协议支持自定义序列化类型"
+            placement="top"
           >
-            {{ dict.label }}
-          </el-radio>
-        </el-radio-group>
-      </el-form-item>
-      <el-form-item label="数据校验级别" prop="validateType">
-        <el-radio-group v-model="formData.validateType" :disabled="formType === 'update'">
-          <el-radio
-            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_VALIDATE_TYPE)"
+            <span>
+              序列化类型
+              <Icon icon="ep:question-filled" class="ml-2px" />
+            </span>
+          </el-tooltip>
+        </template>
+        <el-select v-model="formData.serializeType" placeholder="请选择序列化类型">
+          <el-option
+            v-for="dict in getStrDictOptions(DICT_TYPE.IOT_SERIALIZE_TYPE)"
             :key="dict.value"
-            :label="dict.value"
-          >
-            {{ dict.label }}
-          </el-radio>
-        </el-radio-group>
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
       </el-form-item>
       <el-collapse>
         <el-collapse-item title="更多配置">
+          <el-form-item label="动态注册" prop="registerEnabled">
+            <template #label>
+              <el-tooltip
+                content="设备动态注册无需一一烧录设备证书(DeviceSecret),每台设备烧录相同的产品证书,即 ProductKey 和 ProductSecret ,云端鉴权通过后下发设备证书,您可以根据需要开启或关闭动态注册,保障安全性。"
+                placement="top"
+              >
+                <span>
+                  动态注册
+                  <Icon icon="ep:question-filled" class="ml-2px" />
+                </span>
+              </el-tooltip>
+            </template>
+            <el-switch v-model="formData.registerEnabled" />
+          </el-form-item>
           <el-form-item label="产品图标" prop="icon">
             <UploadImg v-model="formData.icon" :height="'80px'" :width="'80px'" />
           </el-form-item>
@@ -125,13 +126,13 @@
 
 <script setup lang="ts">
 import {
-  ValidateTypeEnum,
   ProductApi,
   ProductVO,
-  DataFormatEnum,
+  ProtocolTypeEnum,
+  SerializeTypeEnum,
   DeviceTypeEnum
 } from '@/api/iot/product/product'
-import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict'
 import { ProductCategoryApi, ProductCategoryVO } from '@/api/iot/product/category'
 import { UploadImg } from '@/components/UploadFile'
 import { generateRandomStr } from '@/utils'
@@ -155,10 +156,9 @@ const formData = ref({
   description: undefined,
   deviceType: undefined,
   netType: undefined,
-  protocolType: undefined,
-  protocolId: undefined,
-  dataFormat: DataFormatEnum.JSON,
-  validateType: ValidateTypeEnum.WEAK
+  protocolType: ProtocolTypeEnum.MQTT,
+  serializeType: SerializeTypeEnum.JSON,
+  registerEnabled: false
 })
 const formRules = reactive({
   productKey: [{ required: true, message: 'ProductKey 不能为空', trigger: 'blur' }],
@@ -172,15 +172,8 @@ const formRules = reactive({
       trigger: 'change'
     }
   ],
-  protocolType: [
-    {
-      required: true,
-      message: '接入网关协议不能为空',
-      trigger: 'change'
-    }
-  ],
-  dataFormat: [{ required: true, message: '数据格式不能为空', trigger: 'change' }],
-  validateType: [{ required: true, message: '数据校验级别不能为空', trigger: 'change' }]
+  protocolType: [{ required: true, message: '协议类型不能为空', trigger: 'change' }],
+  serializeType: [{ required: true, message: '序列化类型不能为空', trigger: 'change' }]
 })
 const formRef = ref()
 const categoryList = ref<ProductCategoryVO[]>([]) // 产品分类列表
@@ -240,10 +233,9 @@ const resetForm = () => {
     description: undefined,
     deviceType: undefined,
     netType: undefined,
-    protocolType: undefined,
-    protocolId: undefined,
-    dataFormat: DataFormatEnum.JSON,
-    validateType: ValidateTypeEnum.WEAK
+    protocolType: ProtocolTypeEnum.MQTT,
+    serializeType: SerializeTypeEnum.JSON,
+    registerEnabled: false
   }
   formRef.value?.resetFields()
 }

+ 65 - 0
src/views/iot/product/product/components/ProductSelect.vue

@@ -0,0 +1,65 @@
+<!-- 产品下拉选择器组件 -->
+<template>
+  <el-select
+    :model-value="modelValue"
+    @update:model-value="handleChange"
+    placeholder="请选择产品"
+    filterable
+    clearable
+    class="w-full"
+    :loading="loading"
+  >
+    <el-option
+      v-for="product in productList"
+      :key="product.id"
+      :label="product.name"
+      :value="product.id"
+    />
+  </el-select>
+</template>
+
+<script setup lang="ts">
+import { ProductApi } from '@/api/iot/product/product'
+
+/** 产品下拉选择器组件 */
+defineOptions({ name: 'ProductSelect' })
+
+const props = defineProps<{
+  modelValue?: number
+  deviceType?: number // 设备类型过滤
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', value?: number): void
+  (e: 'change', value?: number): void
+}>()
+
+const loading = ref(false) // 产品加载状态
+const productList = ref<any[]>([]) // 产品列表
+
+/**
+ * 处理选择变化事件
+ *
+ * @param value 选中的产品 ID
+ */
+const handleChange = (value?: number) => {
+  emit('update:modelValue', value)
+  emit('change', value)
+}
+
+/** 获取产品列表 */
+const getProductList = async () => {
+  try {
+    loading.value = true
+    const res = await ProductApi.getSimpleProductList(props.deviceType)
+    productList.value = res || []
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 组件挂载时获取产品列表 */
+onMounted(() => {
+  getProductList()
+})
+</script>

+ 220 - 0
src/views/iot/product/product/components/ProductTableSelect.vue

@@ -0,0 +1,220 @@
+<!-- IoT 产品选择,使用弹窗展示 -->
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible" :appendToBody="true" width="60%">
+    <ContentWrap>
+      <!-- 搜索工作栏 -->
+      <el-form
+        ref="queryFormRef"
+        :inline="true"
+        :model="queryParams"
+        class="-mb-15px"
+        label-width="68px"
+      >
+        <el-form-item label="产品名称" prop="name">
+          <el-input
+            v-model="queryParams.name"
+            class="!w-240px"
+            clearable
+            placeholder="请输入产品名称"
+            @keyup.enter="handleQuery"
+          />
+        </el-form-item>
+        <el-form-item label="ProductKey" prop="productKey">
+          <el-input
+            v-model="queryParams.productKey"
+            class="!w-240px"
+            clearable
+            placeholder="请输入产品标识"
+            @keyup.enter="handleQuery"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button @click="handleQuery">
+            <Icon class="mr-5px" icon="ep:search" />
+            搜索
+          </el-button>
+          <el-button @click="resetQuery">
+            <Icon class="mr-5px" icon="ep:refresh" />
+            重置
+          </el-button>
+        </el-form-item>
+      </el-form>
+    </ContentWrap>
+
+    <!-- 列表 -->
+    <ContentWrap>
+      <el-table
+        ref="tableRef"
+        v-loading="loading"
+        :data="list"
+        :show-overflow-tooltip="true"
+        :stripe="true"
+        @row-click="handleRowClick"
+        @selection-change="handleSelectionChange"
+      >
+        <el-table-column v-if="multiple" type="selection" width="55" />
+        <el-table-column v-else width="55">
+          <template #default="scope">
+            <el-radio
+              v-model="selectedId"
+              :value="scope.row.id"
+              @change="() => handleRadioChange(scope.row)"
+            >
+              &nbsp;
+            </el-radio>
+          </template>
+        </el-table-column>
+        <el-table-column align="center" label="名称" prop="name" />
+        <el-table-column align="center" label="ProductKey" prop="productKey" />
+        <el-table-column align="center" label="品类" prop="categoryName" />
+        <el-table-column align="center" label="设备类型" prop="deviceType">
+          <template #default="scope">
+            <dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="scope.row.deviceType" />
+          </template>
+        </el-table-column>
+        <el-table-column align="center" label="产品图标" prop="icon">
+          <template #default="scope">
+            <el-image
+              v-if="scope.row.icon"
+              :preview-src-list="[scope.row.icon]"
+              :src="scope.row.icon"
+              class="w-40px h-40px"
+            />
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+        <el-table-column align="center" label="产品图片" prop="picture">
+          <template #default="scope">
+            <el-image
+              v-if="scope.row.picUrl"
+              :preview-src-list="[scope.row.picture]"
+              :src="scope.row.picUrl"
+              class="w-40px h-40px"
+            />
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+        <el-table-column
+          :formatter="dateFormatter"
+          align="center"
+          label="创建时间"
+          prop="createTime"
+          width="180px"
+        />
+      </el-table>
+
+      <!-- 分页 -->
+      <Pagination
+        v-model:limit="queryParams.pageSize"
+        v-model:page="queryParams.pageNo"
+        :total="total"
+        @pagination="getList"
+      />
+    </ContentWrap>
+
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import { DICT_TYPE } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import { ProductApi, ProductVO } from '@/api/iot/product/product'
+
+defineOptions({ name: 'IoTProductTableSelect' })
+
+const props = defineProps({
+  multiple: {
+    type: Boolean,
+    default: false
+  }
+})
+
+const message = useMessage()
+const dialogVisible = ref(false)
+const dialogTitle = ref('产品选择器')
+const formLoading = ref(false)
+const loading = ref(true) // 列表的加载中
+const list = ref<ProductVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const selectedProducts = ref<ProductVO[]>([]) // 选中的产品列表
+const selectedId = ref<number>() // 单选模式下选中的ID
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined,
+  productKey: undefined
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ProductApi.getProductPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 打开弹窗 */
+const open = async () => {
+  dialogVisible.value = true
+  // 重置选择状态
+  selectedProducts.value = []
+  selectedId.value = undefined
+  await getList()
+}
+defineExpose({ open })
+
+/** 处理行点击事件 */
+const tableRef = ref()
+const handleRowClick = (row: ProductVO) => {
+  if (props.multiple) {
+    tableRef.value?.toggleRowSelection(row)
+  } else {
+    selectedId.value = row.id
+    selectedProducts.value = [row]
+  }
+}
+
+/** 处理单选变更事件 */
+const handleRadioChange = (row: ProductVO) => {
+  selectedProducts.value = [row]
+}
+
+/** 处理选择变更事件 */
+const handleSelectionChange = (selection: ProductVO[]) => {
+  if (props.multiple) {
+    selectedProducts.value = selection
+  }
+}
+
+/** 提交表单 */
+const emit = defineEmits(['success'])
+const submitForm = async () => {
+  if (selectedProducts.value.length === 0) {
+    message.warning(props.multiple ? '请至少选择一个产品' : '请选择一个产品')
+    return
+  }
+  emit('success', props.multiple ? selectedProducts.value : selectedProducts.value[0])
+  dialogVisible.value = false
+}
+</script>

+ 15 - 12
src/views/iot/product/product/detail/ProductDetailsHeader.vue

@@ -13,7 +13,7 @@
         <el-button
           @click="openForm('update', product.id)"
           v-hasPermi="['iot:product:update']"
-          v-if="product.status === 0"
+          :disabled="product.status === 1"
         >
           编辑
         </el-button>
@@ -37,15 +37,13 @@
     </div>
   </div>
   <ContentWrap class="mt-10px">
-    <el-descriptions :column="5" direction="horizontal">
+    <el-descriptions :column="1" direction="horizontal">
       <el-descriptions-item label="ProductKey">
         {{ product.productKey }}
         <el-button @click="copyToClipboard(product.productKey)">复制</el-button>
       </el-descriptions-item>
-    </el-descriptions>
-    <el-descriptions :column="5" direction="horizontal">
-      <el-descriptions-item label="设备数">
-        {{ product.deviceCount ?? '加载中...' }}
+      <el-descriptions-item label="设备总数">
+        <span class="ml-20px mr-10px">{{ product.deviceCount ?? '加载中...' }}</span>
         <el-button @click="goToDeviceList(product.id)">前往管理</el-button>
       </el-descriptions-item>
     </el-descriptions>
@@ -56,25 +54,30 @@
 <script setup lang="ts">
 import ProductForm from '@/views/iot/product/product/ProductForm.vue'
 import { ProductApi, ProductVO } from '@/api/iot/product/product'
+import { useClipboard } from '@vueuse/core'
 
 const message = useMessage()
+const { t } = useI18n() // 国际化
 
 const { product } = defineProps<{ product: ProductVO }>() // 定义 Props
 
 /** 复制到剪贴板方法 */
 const copyToClipboard = async (text: string) => {
-  try {
-    await navigator.clipboard.writeText(text)
-    message.success('复制成功')
-  } catch (error) {
-    message.error('复制失败')
+  const { copy, copied, isSupported } = useClipboard({ legacy: true, source: text })
+  if (!isSupported) {
+    message.error(t('common.copyError'))
+    return
+  }
+  await copy()
+  if (unref(copied)) {
+    message.success(t('common.copySuccess'))
   }
 }
 
 /** 路由跳转到设备管理 */
 const { push } = useRouter()
 const goToDeviceList = (productId: number) => {
-  push({ name: 'IoTDevice', params: { productId } })
+  push({ name: 'IoTDevice', query: { productId } })
 }
 
 /** 修改操作 */

+ 39 - 10
src/views/iot/product/product/detail/ProductDetailsInfo.vue

@@ -1,6 +1,6 @@
 <template>
   <ContentWrap>
-    <el-descriptions :column="3" title="产品信息">
+    <el-descriptions :column="3" title="产品信息" border>
       <el-descriptions-item label="产品名称">{{ product.name }}</el-descriptions-item>
       <el-descriptions-item label="所属分类">{{ product.categoryName }}</el-descriptions-item>
       <el-descriptions-item label="设备类型">
@@ -9,11 +9,11 @@
       <el-descriptions-item label="创建时间">
         {{ formatDate(product.createTime) }}
       </el-descriptions-item>
-      <el-descriptions-item label="数据格式">
-        <dict-tag :type="DICT_TYPE.IOT_DATA_FORMAT" :value="product.dataFormat" />
+      <el-descriptions-item label="协议类型">
+        <dict-tag :type="DICT_TYPE.IOT_PROTOCOL_TYPE" :value="product.protocolType" />
       </el-descriptions-item>
-      <el-descriptions-item label="数据校验级别">
-        <dict-tag :type="DICT_TYPE.IOT_VALIDATE_TYPE" :value="product.validateType" />
+      <el-descriptions-item label="序列化类型">
+        <dict-tag :type="DICT_TYPE.IOT_SERIALIZE_TYPE" :value="product.serializeType" />
       </el-descriptions-item>
       <el-descriptions-item label="产品状态">
         <dict-tag :type="DICT_TYPE.IOT_PRODUCT_STATUS" :value="product.status" />
@@ -24,11 +24,27 @@
       >
         <dict-tag :type="DICT_TYPE.IOT_NET_TYPE" :value="product.netType" />
       </el-descriptions-item>
-      <el-descriptions-item
-        label="接入网关协议"
-        v-if="product.deviceType === DeviceTypeEnum.GATEWAY_SUB"
-      >
-        <dict-tag :type="DICT_TYPE.IOT_PROTOCOL_TYPE" :value="product.protocolType" />
+      <el-descriptions-item label="动态注册">
+        <el-tag :type="product.registerEnabled ? 'success' : 'info'">
+          {{ product.registerEnabled ? '已开启' : '已关闭' }}
+        </el-tag>
+      </el-descriptions-item>
+      <el-descriptions-item label="产品密钥">
+        <div class="flex items-center">
+          <span>{{ secretVisible ? product.productSecret : '******' }}</span>
+          <el-button link type="primary" class="ml-2" @click="secretVisible = !secretVisible">
+            <Icon :icon="secretVisible ? 'ep:hide' : 'ep:view'" />
+          </el-button>
+          <el-button
+            v-if="secretVisible && product.productSecret"
+            link
+            type="primary"
+            class="ml-1"
+            @click="copySecret"
+          >
+            <Icon icon="ep:document-copy" />
+          </el-button>
+        </div>
       </el-descriptions-item>
       <el-descriptions-item label="产品描述">{{ product.description }}</el-descriptions-item>
     </el-descriptions>
@@ -38,6 +54,19 @@
 import { DICT_TYPE } from '@/utils/dict'
 import { DeviceTypeEnum, ProductVO } from '@/api/iot/product/product'
 import { formatDate } from '@/utils/formatTime'
+import { useClipboard } from '@vueuse/core'
 
 const { product } = defineProps<{ product: ProductVO }>()
+
+const message = useMessage()
+const secretVisible = ref(false)
+const { copy } = useClipboard()
+
+/** 复制产品密钥 */
+const copySecret = async () => {
+  if (product.productSecret) {
+    await copy(product.productSecret)
+    message.success('复制成功')
+  }
+}
 </script>

+ 0 - 247
src/views/iot/product/product/detail/ProductTopic.vue

@@ -1,247 +0,0 @@
-<template>
-  <ContentWrap>
-    <el-tabs>
-      <el-tab-pane label="基础通信 Topic">
-        <Table
-          :columns="basicColumn"
-          :data="basicData"
-          :span-method="createSpanMethod(basicData)"
-          align="left"
-          headerAlign="left"
-          border="true"
-        />
-      </el-tab-pane>
-      <el-tab-pane label="物模型通信 Topic">
-        <Table
-          :columns="functionColumn"
-          :data="functionData"
-          :span-method="createSpanMethod(functionData)"
-          align="left"
-          headerAlign="left"
-          border="true"
-        />
-      </el-tab-pane>
-    </el-tabs>
-  </ContentWrap>
-</template>
-<script setup lang="ts">
-import { ProductVO } from '@/api/iot/product/product'
-
-const props = defineProps<{ product: ProductVO }>()
-
-// TODO 芋艿:不确定未来会不会改,所以先写死
-
-// 基础通信 Topic 列
-const basicColumn = reactive([
-  { label: '功能', field: 'function', width: 150 },
-  { label: 'Topic 类', field: 'topicClass', width: 800 },
-  { label: '操作权限', field: 'operationPermission', width: 100 },
-  { label: '描述', field: 'description' }
-])
-
-// 基础通信 Topic 数据
-const basicData = computed(() => {
-  if (!props.product || !props.product.productKey) return []
-  return [
-    {
-      function: 'OTA 升级',
-      topicClass: `/ota/device/inform/${props.product.productKey}/\${deviceName}`,
-      operationPermission: '发布',
-      description: '设备上报固件升级信息'
-    },
-    {
-      function: 'OTA 升级',
-      topicClass: `/ota/device/upgrade/${props.product.productKey}/\${deviceName}`,
-      operationPermission: '订阅',
-      description: '固件升级信息下行'
-    },
-    {
-      function: 'OTA 升级',
-      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/ota/firmware/get`,
-      operationPermission: '发布',
-      description: '设备上报固件升级进度'
-    },
-    {
-      function: 'OTA 升级',
-      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/ota/firmware/get`,
-      operationPermission: '发布',
-      description: '设备主动拉取固件升级信息'
-    },
-    {
-      function: '设备标签',
-      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/deviceinfo/update`,
-      operationPermission: '发布',
-      description: '设备上报标签数据'
-    },
-    {
-      function: '设备标签',
-      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/deviceinfo/update_reply`,
-      operationPermission: '订阅',
-      description: '云端响应标签上报'
-    },
-    {
-      function: '设备标签',
-      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/deviceinfo/delete`,
-      operationPermission: '订阅',
-      description: '设备删除标签信息'
-    },
-    {
-      function: '设备标签',
-      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/deviceinfo/delete_reply`,
-      operationPermission: '订阅',
-      description: '云端响应标签删除'
-    },
-    {
-      function: '时钟同步',
-      topicClass: `/ext/ntp/${props.product.productKey}/\${deviceName}/request`,
-      operationPermission: '发布',
-      description: 'NTP 时钟同步请求'
-    },
-    {
-      function: '时钟同步',
-      topicClass: `/ext/ntp/${props.product.productKey}/\${deviceName}/response`,
-      operationPermission: '订阅',
-      description: 'NTP 时钟同步响应'
-    },
-    {
-      function: '设备影子',
-      topicClass: `/shadow/update/${props.product.productKey}/\${deviceName}`,
-      operationPermission: '发布',
-      description: '设备影子发布'
-    },
-    {
-      function: '设备影子',
-      topicClass: `/shadow/get/${props.product.productKey}/\${deviceName}`,
-      operationPermission: '订阅',
-      description: '设备接收影子变更'
-    },
-    {
-      function: '配置更新',
-      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/config/push`,
-      operationPermission: '订阅',
-      description: '云端主动下推配置信息'
-    },
-    {
-      function: '配置更新',
-      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/config/get`,
-      operationPermission: '发布',
-      description: '设备端查询配置信息'
-    },
-    {
-      function: '配置更新',
-      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/config/get_reply`,
-      operationPermission: '订阅',
-      description: '云端响应配置信息'
-    },
-    {
-      function: '广播',
-      topicClass: `/broadcast/${props.product.productKey}/\${identifier}`,
-      operationPermission: '订阅',
-      description: '广播 Topic,identifier 为用户自定义字符串'
-    }
-  ]
-})
-
-// 物模型通信 Topic 列
-const functionColumn = reactive([
-  { label: '功能', field: 'function', width: 150 },
-  { label: 'Topic 类', field: 'topicClass', width: 800 },
-  { label: '操作权限', field: 'operationPermission', width: 100 },
-  { label: '描述', field: 'description' }
-])
-
-// 物模型通信 Topic 数据
-const functionData = computed(() => {
-  if (!props.product || !props.product.productKey) return []
-  return [
-    {
-      function: '属性上报',
-      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/event/property/post`,
-      operationPermission: '发布',
-      description: '设备属性上报'
-    },
-    {
-      function: '属性上报',
-      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/event/property/post_reply`,
-      operationPermission: '订阅',
-      description: '云端响应属性上报'
-    },
-    {
-      function: '属性设置',
-      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/service/property/set`,
-      operationPermission: '订阅',
-      description: '设备属性设置'
-    },
-    {
-      function: '事件上报',
-      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/event/\${tsl.event.identifier}/post`,
-      operationPermission: '发布',
-      description: '设备事件上报'
-    },
-    {
-      function: '事件上报',
-      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/event/\${tsl.event.identifier}/post_reply`,
-      operationPermission: '订阅',
-      description: '云端响应事件上报'
-    },
-    {
-      function: '服务调用',
-      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/service/\${tsl.service.identifier}`,
-      operationPermission: '订阅',
-      description: '设备服务调用'
-    },
-    {
-      function: '服务调用',
-      topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/service/\${tsl.service.identifier}_reply`,
-      operationPermission: '发布',
-      description: '设备端响应服务调用'
-    }
-  ]
-})
-
-// 通用的单元格合并方法生成器
-const createSpanMethod = (data: any[]) => {
-  // 预处理,计算每个功能的合并行数
-  const rowspanMap: Record<number, number> = {}
-  let currentFunction = ''
-  let startIndex = 0
-  let count = 0
-
-  data.forEach((item, index) => {
-    if (item.function !== currentFunction) {
-      if (count > 0) {
-        rowspanMap[startIndex] = count
-      }
-      currentFunction = item.function
-      startIndex = index
-      count = 1
-    } else {
-      count++
-    }
-  })
-
-  // 处理最后一组
-  if (count > 0) {
-    rowspanMap[startIndex] = count
-  }
-
-  // 返回 span 方法
-  return ({ row, column, rowIndex, columnIndex }: SpanMethodProps) => {
-    if (columnIndex === 0) {
-      // 仅对“功能”列进行合并
-      const rowspan = rowspanMap[rowIndex] || 0
-      if (rowspan > 0) {
-        return {
-          rowspan,
-          colspan: 1
-        }
-      } else {
-        return {
-          rowspan: 0,
-          colspan: 0
-        }
-      }
-    }
-  }
-}
-</script>

+ 1 - 7
src/views/iot/product/product/detail/index.vue

@@ -5,14 +5,9 @@
       <el-tab-pane label="产品信息" name="info">
         <ProductDetailsInfo v-if="activeTab === 'info'" :product="product" />
       </el-tab-pane>
-      <el-tab-pane label="Topic 类列表" name="topic">
-        <ProductTopic v-if="activeTab === 'topic'" :product="product" />
-      </el-tab-pane>
-      <el-tab-pane label="功能定义" lazy name="thingModel">
+      <el-tab-pane label="物模型(功能定义)" lazy name="thingModel">
         <IoTProductThingModel ref="thingModelRef" />
       </el-tab-pane>
-      <el-tab-pane label="消息解析" name="message" />
-      <el-tab-pane label="服务端订阅" name="subscription" />
     </el-tabs>
   </el-col>
 </template>
@@ -21,7 +16,6 @@ import { ProductApi, ProductVO } from '@/api/iot/product/product'
 import { DeviceApi } from '@/api/iot/device/device'
 import ProductDetailsHeader from './ProductDetailsHeader.vue'
 import ProductDetailsInfo from './ProductDetailsInfo.vue'
-import ProductTopic from './ProductTopic.vue'
 import IoTProductThingModel from '@/views/iot/thingmodel/index.vue'
 import { useTagsViewStore } from '@/store/modules/tagsView'
 import { useRouter } from 'vue-router'

+ 2 - 2
src/views/iot/product/product/index.vue

@@ -97,7 +97,7 @@
                 </div>
                 <div class="mb-2.5 last:mb-0">
                   <span class="text-[#717c8e] mr-2.5">产品标识</span>
-                  <span class="text-[#0b1d30] inline-block align-middle overflow-hidden text-ellipsis whitespace-nowrap max-w-[140px]">
+                  <span class="text-[var(--el-text-color-primary)] inline-block align-middle overflow-hidden text-ellipsis whitespace-nowrap max-w-[140px]">
                     {{ item.productKey }}
                   </span>
                 </div>
@@ -263,7 +263,7 @@ const list = ref<ProductVO[]>([]) // 列表的数据
 const total = ref(0) // 列表的总页数
 const queryParams = reactive({
   pageNo: 1,
-  pageSize: 10,
+  pageSize: 12,
   name: undefined,
   productKey: undefined
 })

+ 20 - 0
src/views/iot/rule/data/index.vue

@@ -0,0 +1,20 @@
+<template>
+  <el-tabs v-model="activeTab" type="border-card">
+    <el-tab-pane label="规则" name="rule">
+      <RuleIndex />
+    </el-tab-pane>
+    <el-tab-pane label="目的" name="sink" lazy>
+      <SinkIndex />
+    </el-tab-pane>
+  </el-tabs>
+</template>
+
+<script setup lang="ts">
+import RuleIndex from './rule/index.vue'
+import SinkIndex from './sink/index.vue'
+
+/** IoT 数据流转 */
+defineOptions({ name: 'IoTDataRule' })
+
+const activeTab = ref('rule')
+</script>

+ 158 - 0
src/views/iot/rule/data/rule/DataRuleForm.vue

@@ -0,0 +1,158 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible" width="870">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="规则名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入规则名称" />
+      </el-form-item>
+      <el-form-item label="规则描述" prop="description">
+        <el-input v-model="formData.description" height="150px" type="textarea" />
+      </el-form-item>
+      <el-form-item label="规则状态" prop="status">
+        <el-radio-group v-model="formData.status">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="数据目的" prop="sinkIds">
+        <el-select
+          v-model="formData.sinkIds"
+          placeholder="请选择数据目的"
+          multiple
+          clearable
+          class="w-1/1"
+        >
+          <el-option
+            v-for="sink in dataSinkList"
+            :key="sink.id"
+            :label="sink.name"
+            :value="sink.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="数据源" prop="sourceConfigs">
+        <SourceConfigForm ref="sourceConfigRef" />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
+import { DataRuleApi, DataRule } from '@/api/iot/rule/data/rule'
+import { DataSinkApi } from '@/api/iot/rule/data/sink'
+import { CommonStatusEnum } from '@/utils/constants'
+import SourceConfigForm from './components/SourceConfigForm.vue'
+
+/** IoT 数据流转规则的表单 */
+defineOptions({ name: 'DataRuleForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref({
+  id: undefined,
+  name: undefined,
+  description: undefined,
+  status: CommonStatusEnum.ENABLE,
+  sourceConfigs: [],
+  sinkIds: []
+})
+const formRules = reactive({
+  name: [{ required: true, message: '规则名称不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '规则状态不能为空', trigger: 'blur' }],
+  sourceConfigs: [{ required: true, message: '数据源配置数组不能为空', trigger: 'blur' }],
+  sinkIds: [{ required: true, message: '数据目的编号数组不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+const dataSinkList = ref<any[]>([]) // 数据目的列表
+const sourceConfigRef = ref() // 数据源配置组件引用
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      const data = await DataRuleApi.getDataRule(id)
+      formData.value = data
+      // 设置数据源配置
+      nextTick(() => {
+        sourceConfigRef.value?.setData(data.sourceConfigs || [])
+      })
+    } finally {
+      formLoading.value = false
+    }
+  }
+
+  // 加载数据目的列表
+  dataSinkList.value = await DataSinkApi.getDataSinkSimpleList()
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验数据源配置
+  await sourceConfigRef.value?.validate()
+  formData.value.sourceConfigs = sourceConfigRef.value?.getData() || []
+  // 校验表单
+  await formRef.value.validate()
+
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = { ...formData.value } as unknown as DataRule
+    if (formType.value === 'create') {
+      await DataRuleApi.createDataRule(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await DataRuleApi.updateDataRule(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = async () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    description: undefined,
+    status: CommonStatusEnum.ENABLE,
+    sourceConfigs: [],
+    sinkIds: []
+  }
+  formRef.value?.resetFields()
+  // 重置数据源配置
+  await nextTick()
+  sourceConfigRef.value?.setData([])
+}
+</script>

+ 262 - 0
src/views/iot/rule/data/rule/components/SourceConfigForm.vue

@@ -0,0 +1,262 @@
+<template>
+  <el-form
+    ref="formRef"
+    :model="formData"
+    :rules="formRules"
+    label-width="0px"
+    :inline-message="true"
+  >
+    <el-table :data="formData" class="-mt-10px">
+      <el-table-column label="产品" min-width="150">
+        <template #default="{ row, $index }">
+          <el-form-item :prop="`${$index}.productId`" :rules="formRules.productId" class="mb-0px!">
+            <el-select
+              v-model="row.productId"
+              placeholder="请选择产品"
+              @change="handleProductChange(row, $index)"
+              clearable
+              filterable
+              style="width: 100%"
+            >
+              <el-option
+                v-for="product in productList"
+                :key="product.id"
+                :label="product.name"
+                :value="product.id"
+              />
+            </el-select>
+          </el-form-item>
+        </template>
+      </el-table-column>
+      <el-table-column label="设备" min-width="150">
+        <template #default="{ row, $index }">
+          <el-form-item :prop="`${$index}.deviceId`" :rules="formRules.deviceId" class="mb-0px!">
+            <el-select
+              v-model="row.deviceId"
+              placeholder="请选择设备"
+              clearable
+              filterable
+              style="width: 100%"
+            >
+              <el-option label="全部设备" :value="0" />
+              <el-option
+                v-for="device in getFilteredDevices(row.productId)"
+                :key="device.id"
+                :label="device.deviceName"
+                :value="device.id"
+              />
+            </el-select>
+          </el-form-item>
+        </template>
+      </el-table-column>
+      <el-table-column label="消息" min-width="150">
+        <template #default="{ row, $index }">
+          <el-form-item :prop="`${$index}.method`" :rules="formRules.method" class="mb-0px!">
+            <el-select
+              v-model="row.method"
+              placeholder="请选择消息"
+              @change="handleMethodChange(row, $index)"
+              clearable
+              filterable
+              style="width: 100%"
+            >
+              <el-option
+                v-for="method in upstreamMethods"
+                :key="method.method"
+                :label="method.name"
+                :value="method.method"
+              />
+            </el-select>
+          </el-form-item>
+        </template>
+      </el-table-column>
+      <el-table-column label="标识符" min-width="200">
+        <template #default="{ row, $index }">
+          <el-form-item :prop="`${$index}.identifier`" class="mb-0px!">
+            <el-select
+              v-if="shouldShowIdentifierSelect(row)"
+              v-model="row.identifier"
+              placeholder="请选择标识符"
+              clearable
+              filterable
+              style="width: 100%"
+              v-loading="row.identifierLoading"
+            >
+              <el-option
+                v-for="item in getThingModelOptions(row)"
+                :key="item.value"
+                :label="item.label"
+                :value="item.value"
+              />
+            </el-select>
+          </el-form-item>
+        </template>
+      </el-table-column>
+      <el-table-column align="center" fixed="right" label="操作" width="60">
+        <template #default="{ $index }">
+          <el-button @click="handleDelete($index)" link type="danger">—</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <el-row justify="center" class="mt-3">
+      <el-button @click="handleAdd" type="primary" plain round>+ 添加数据源</el-button>
+    </el-row>
+  </el-form>
+</template>
+
+<script setup lang="ts">
+import { ProductApi } from '@/api/iot/product/product'
+import { DeviceApi } from '@/api/iot/device/device'
+import { ThingModelApi } from '@/api/iot/thingmodel'
+import { IotDeviceMessageMethodEnum, IoTThingModelTypeEnum } from '@/views/iot/utils/constants'
+
+const formData = ref<any[]>([])
+const productList = ref<any[]>([]) // 产品列表
+const deviceList = ref<any[]>([]) // 设备列表
+const thingModelCache = ref<Map<number, any[]>>(new Map()) // 缓存物模型数据,key 为 productId
+
+const formRules = reactive({
+  productId: [{ required: true, message: '产品不能为空', trigger: 'change' }],
+  deviceId: [{ required: true, message: '设备不能为空', trigger: 'change' }],
+  method: [{ required: true, message: '消息方法不能为空', trigger: 'change' }]
+})
+const formRef = ref() // 表单 Ref
+
+// 获取上行消息方法列表
+const upstreamMethods = computed(() => {
+  return Object.values(IotDeviceMessageMethodEnum).filter((item) => item.upstream)
+})
+
+/** 根据产品 ID 过滤设备 */
+const getFilteredDevices = (productId: number) => {
+  if (!productId) return []
+  return deviceList.value.filter((device: any) => device.productId === productId)
+}
+
+/** 判断是否需要显示标识符选择器 */
+const shouldShowIdentifierSelect = (row: any) => {
+  return [
+    IotDeviceMessageMethodEnum.EVENT_POST.method,
+    IotDeviceMessageMethodEnum.PROPERTY_POST.method
+  ].includes(row.method)
+}
+
+/** 获取物模型选项 */
+const getThingModelOptions = (row: any) => {
+  if (!row.productId || !shouldShowIdentifierSelect(row)) {
+    return []
+  }
+  const thingModels: any[] = thingModelCache.value.get(row.productId) || []
+  let filteredModels: any[] = []
+  if (row.method === IotDeviceMessageMethodEnum.EVENT_POST.method) {
+    filteredModels = thingModels.filter((item: any) => item.type === IoTThingModelTypeEnum.EVENT)
+  } else if (row.method === IotDeviceMessageMethodEnum.PROPERTY_POST.method) {
+    filteredModels = thingModels.filter((item: any) => item.type === IoTThingModelTypeEnum.PROPERTY)
+  }
+  return filteredModels.map((item: any) => ({
+    label: `${item.name} (${item.identifier})`,
+    value: item.identifier
+  }))
+}
+
+/** 加载产品列表 */
+const loadProductList = async () => {
+  try {
+    productList.value = await ProductApi.getSimpleProductList()
+  } catch (error) {
+    console.error('加载产品列表失败:', error)
+  }
+}
+
+/** 加载设备列表 */
+const loadDeviceList = async () => {
+  try {
+    deviceList.value = await DeviceApi.getSimpleDeviceList()
+  } catch (error) {
+    console.error('加载设备列表失败:', error)
+  }
+}
+
+/** 加载物模型数据 */
+const loadThingModel = async (productId: number) => {
+  // 已缓存,无需重复加载
+  if (thingModelCache.value.has(productId)) {
+    return
+  }
+  try {
+    const thingModels = await ThingModelApi.getThingModelList({ productId })
+    thingModelCache.value.set(productId, thingModels)
+  } catch (error) {
+    console.error('加载物模型失败:', error)
+  }
+}
+
+/** 产品变化时处理 */
+const handleProductChange = async (row: any, _index: number) => {
+  row.deviceId = 0
+  row.method = undefined
+  row.identifier = undefined
+  row.identifierLoading = false
+}
+
+/** 消息方法变化时处理 */
+const handleMethodChange = async (row: any, _index: number) => {
+  // 清空标识符
+  row.identifier = undefined
+  // 如果需要加载物模型数据
+  if (shouldShowIdentifierSelect(row) && row.productId) {
+    row.identifierLoading = true
+    await loadThingModel(row.productId)
+    row.identifierLoading = false
+  }
+}
+
+/** 新增按钮操作 */
+const handleAdd = () => {
+  const row = {
+    productId: undefined,
+    deviceId: undefined,
+    method: undefined,
+    identifier: undefined,
+    identifierLoading: false
+  }
+  formData.value.push(row)
+}
+
+/** 删除按钮操作 */
+const handleDelete = (index: number) => {
+  formData.value.splice(index, 1)
+}
+
+/** 表单校验 */
+const validate = () => {
+  return formRef.value.validate()
+}
+
+/** 表单值 */
+const getData = () => {
+  return formData.value
+}
+
+/** 设置表单值 */
+const setData = (data: any[]) => {
+  // 确保每个项都有必要的字段
+  formData.value = (data || []).map((item) => ({
+    ...item,
+    identifierLoading: false
+  }))
+  // 为已有数据预加载物模型
+  data?.forEach(async (item) => {
+    if (item.productId && shouldShowIdentifierSelect(item)) {
+      await loadThingModel(item.productId)
+    }
+  })
+}
+
+/** 初始化 */
+onMounted(async () => {
+  await Promise.all([loadProductList(), loadDeviceList()])
+})
+
+defineExpose({ validate, getData, setData })
+</script>

+ 196 - 0
src/views/iot/rule/data/rule/index.vue

@@ -0,0 +1,196 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <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-240px"
+        />
+      </el-form-item>
+      <el-form-item label="规则状态" prop="status">
+        <el-select
+          v-model="queryParams.status"
+          placeholder="请选择规则状态"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-220px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['iot:data-rule:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table
+      row-key="id"
+      v-loading="loading"
+      :data="list"
+      :stripe="true"
+      :show-overflow-tooltip="true"
+    >
+      <el-table-column label="规则编号" align="center" prop="id" />
+      <el-table-column label="规则名称" align="center" prop="name" />
+      <el-table-column label="规则描述" align="center" prop="description" />
+      <el-table-column label="规则状态" align="center" prop="status">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column label="数据源" align="center" prop="sourceConfigs">
+        <template #default="scope"> {{ scope.row.sourceConfigs?.length || 0 }} 个 </template>
+      </el-table-column>
+      <el-table-column label="数据目的" align="center" prop="sinkIds">
+        <template #default="scope"> {{ scope.row.sinkIds?.length || 0 }} 个 </template>
+      </el-table-column>
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="操作" align="center" min-width="120px">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['iot:data-rule:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['iot:data-rule:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <DataRuleForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import { DataRuleApi, DataRule } from '@/api/iot/rule/data/rule'
+import DataRuleForm from './DataRuleForm.vue'
+
+/** IoT 数据流转规则列表 */
+defineOptions({ name: 'IotDataRule' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<DataRule[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined,
+  status: undefined,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await DataRuleApi.getDataRulePage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await DataRuleApi.deleteDataRule(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 67 - 62
src/views/iot/rule/databridge/IoTDataBridgeForm.vue → src/views/iot/rule/data/sink/DataSinkForm.vue

@@ -7,50 +7,50 @@
       :rules="formRules"
       label-width="120px"
     >
-      <el-form-item label="桥梁名称" prop="name">
-        <el-input v-model="formData.name" placeholder="请输入桥梁名称" />
+      <el-form-item label="目的名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入目的名称" />
       </el-form-item>
-      <el-form-item label="桥梁方向" prop="direction">
-        <el-radio-group v-model="formData.direction">
-          <el-radio
-            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DATA_BRIDGE_DIRECTION_ENUM)"
-            :key="dict.value"
-            :label="dict.value"
-          >
-            {{ dict.label }}
-          </el-radio>
-        </el-radio-group>
+      <el-form-item label="目的描述" prop="description">
+        <el-input v-model="formData.description" height="150px" type="textarea" />
       </el-form-item>
-      <el-form-item label="桥梁类型" prop="type">
-        <el-radio-group :model-value="formData.type" @change="handleTypeChange">
-          <el-radio
-            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DATA_BRIDGE_TYPE_ENUM)"
-            :key="dict.value"
-            :label="dict.value"
-          >
-            {{ dict.label }}
-          </el-radio>
-        </el-radio-group>
+      <el-form-item label="目的类型" prop="type">
+        <el-select v-model="formData.type" @change="handleTypeChange">
+          <el-option
+            v-for="item in getIntDictOptions(DICT_TYPE.IOT_DATA_SINK_TYPE_ENUM)"
+            :key="item.value"
+            :label="item.label"
+            :value="item.value"
+          />
+        </el-select>
       </el-form-item>
-      <HttpConfigForm v-if="showConfig(IoTDataBridgeConfigType.HTTP)" v-model="formData.config" />
-      <MqttConfigForm v-if="showConfig(IoTDataBridgeConfigType.MQTT)" v-model="formData.config" />
+      <HttpConfigForm v-if="IotDataSinkTypeEnum.HTTP === formData.type" v-model="formData.config" />
+      <TcpConfigForm v-if="IotDataSinkTypeEnum.TCP === formData.type" v-model="formData.config" />
+      <WebSocketConfigForm
+        v-if="IotDataSinkTypeEnum.WEBSOCKET === formData.type"
+        v-model="formData.config"
+      />
+      <MqttConfigForm v-if="IotDataSinkTypeEnum.MQTT === formData.type" v-model="formData.config" />
+      <DatabaseConfigForm
+        v-if="IotDataSinkTypeEnum.DATABASE === formData.type"
+        v-model="formData.config"
+      />
       <RocketMQConfigForm
-        v-if="showConfig(IoTDataBridgeConfigType.ROCKETMQ)"
+        v-if="IotDataSinkTypeEnum.ROCKETMQ === formData.type"
         v-model="formData.config"
       />
       <KafkaMQConfigForm
-        v-if="showConfig(IoTDataBridgeConfigType.KAFKA)"
+        v-if="IotDataSinkTypeEnum.KAFKA === formData.type"
         v-model="formData.config"
       />
       <RabbitMQConfigForm
-        v-if="showConfig(IoTDataBridgeConfigType.RABBITMQ)"
+        v-if="IotDataSinkTypeEnum.RABBITMQ === formData.type"
         v-model="formData.config"
       />
-      <RedisStreamMQConfigForm
-        v-if="showConfig(IoTDataBridgeConfigType.REDIS_STREAM)"
+      <RedisStreamConfigForm
+        v-if="IotDataSinkTypeEnum.REDIS_STREAM === formData.type"
         v-model="formData.config"
       />
-      <el-form-item label="桥梁状态" prop="status">
+      <el-form-item label="目的状态" prop="status">
         <el-radio-group v-model="formData.status">
           <el-radio
             v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
@@ -61,9 +61,6 @@
           </el-radio>
         </el-radio-group>
       </el-form-item>
-      <el-form-item label="桥梁描述" prop="description">
-        <el-input v-model="formData.description" height="150px" type="textarea" />
-      </el-form-item>
     </el-form>
     <template #footer>
       <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
@@ -72,19 +69,23 @@
   </Dialog>
 </template>
 <script lang="ts" setup>
-import { DICT_TYPE, getDictObj, getIntDictOptions } from '@/utils/dict'
-import { DataBridgeApi, DataBridgeVO, IoTDataBridgeConfigType } from '@/api/iot/rule/databridge'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { CommonStatusEnum } from '@/utils/constants'
+import { DataSinkApi, DataSinkVO, IotDataSinkTypeEnum } from '@/api/iot/rule/data/sink'
 import {
+  DatabaseConfigForm,
   HttpConfigForm,
   KafkaMQConfigForm,
   MqttConfigForm,
   RabbitMQConfigForm,
-  RedisStreamMQConfigForm,
-  RocketMQConfigForm
+  RedisStreamConfigForm,
+  RocketMQConfigForm,
+  TcpConfigForm,
+  WebSocketConfigForm
 } from './config'
 
-/** IoT 数据桥梁的表单 */
-defineOptions({ name: 'IoTDataBridgeForm' })
+/** IoT 数据流转目的的表单 */
+defineOptions({ name: 'IoTDataSinkForm' })
 
 const { t } = useI18n() // 国际化
 const message = useMessage() // 消息弹窗
@@ -93,26 +94,36 @@ const dialogVisible = ref(false) // 弹窗的是否展示
 const dialogTitle = ref('') // 弹窗的标题
 const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
 const formType = ref('') // 表单的类型:create - 新增;update - 修改
-const formData = ref<DataBridgeVO>({
-  status: 0,
-  direction: 1, // TODO @puhui999:枚举类
-  type: 1, // TODO @puhui999:枚举类
+const formData = ref<DataSinkVO>({
+  status: CommonStatusEnum.ENABLE,
+  type: IotDataSinkTypeEnum.HTTP,
   config: {} as any
 })
 const formRules = reactive({
   // 通用字段
-  name: [{ required: true, message: '桥梁名称不能为空', trigger: 'blur' }],
-  status: [{ required: true, message: '桥梁状态不能为空', trigger: 'blur' }],
-  direction: [{ required: true, message: '桥梁方向不能为空', trigger: 'blur' }],
-  type: [{ required: true, message: '桥梁类型不能为空', trigger: 'change' }],
+  name: [{ required: true, message: '目的名称不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '目的状态不能为空', trigger: 'blur' }],
+  type: [{ required: true, message: '目的类型不能为空', trigger: 'change' }],
   // HTTP 配置
   'config.url': [{ required: true, message: '请求地址不能为空', trigger: 'blur' }],
   'config.method': [{ required: true, message: '请求方法不能为空', trigger: 'blur' }],
+  // TCP 配置 (host 和 port 与 RabbitMQ/Redis 共用)
+  'config.connectTimeoutMs': [{ required: true, message: '连接超时时间不能为空', trigger: 'blur' }],
+  'config.readTimeoutMs': [{ required: true, message: '读取超时时间不能为空', trigger: 'blur' }],
+  'config.dataFormat': [{ required: true, message: '数据格式不能为空', trigger: 'change' }],
+  // WebSocket 配置
+  'config.serverUrl': [
+    { required: true, message: 'WebSocket 服务器地址不能为空', trigger: 'blur' }
+  ],
+  'config.sendTimeoutMs': [{ required: true, message: '发送超时时间不能为空', trigger: 'blur' }],
   // MQTT 配置
   'config.username': [{ required: true, message: '用户名不能为空', trigger: 'blur' }],
   'config.password': [{ required: true, message: '密码不能为空', trigger: 'blur' }],
-  'config.clientId': [{ required: true, message: '客户端ID不能为空', trigger: 'blur' }],
+  'config.clientId': [{ required: true, message: '客户端 ID 不能为空', trigger: 'blur' }],
   'config.topic': [{ required: true, message: '主题不能为空', trigger: 'blur' }],
+  // Database 配置
+  'config.jdbcUrl': [{ required: true, message: 'JDBC 连接地址不能为空', trigger: 'blur' }],
+  'config.tableName': [{ required: true, message: '目标表名不能为空', trigger: 'blur' }],
   // RocketMQ 配置
   'config.nameServer': [{ required: true, message: 'NameServer 地址不能为空', trigger: 'blur' }],
   'config.accessKey': [{ required: true, message: 'AccessKey 不能为空', trigger: 'blur' }],
@@ -139,10 +150,6 @@ const formRules = reactive({
 })
 
 const formRef = ref() // 表单 Ref
-const showConfig = computed(() => (val: string) => {
-  const dict = getDictObj(DICT_TYPE.IOT_DATA_BRIDGE_TYPE_ENUM, formData.value.type)
-  return dict && dict.value + '' === val
-}) // 显示对应的 Config 配置项
 
 /** 打开弹窗 */
 const open = async (type: string, id?: number) => {
@@ -154,7 +161,7 @@ const open = async (type: string, id?: number) => {
   if (id) {
     formLoading.value = true
     try {
-      formData.value = await DataBridgeApi.getDataBridge(id)
+      formData.value = await DataSinkApi.getDataSink(id)
     } finally {
       formLoading.value = false
     }
@@ -170,12 +177,12 @@ const submitForm = async () => {
   // 提交请求
   formLoading.value = true
   try {
-    const data = formData.value as unknown as DataBridgeVO
+    const data = formData.value as unknown as DataSinkVO
     if (formType.value === 'create') {
-      await DataBridgeApi.createDataBridge(data)
+      await DataSinkApi.createDataSink(data)
       message.success(t('common.createSuccess'))
     } else {
-      await DataBridgeApi.updateDataBridge(data)
+      await DataSinkApi.updateDataSink(data)
       message.success(t('common.updateSuccess'))
     }
     dialogVisible.value = false
@@ -187,8 +194,8 @@ const submitForm = async () => {
 }
 
 /** 处理类型切换事件 */
-const handleTypeChange = (val: number) => {
-  formData.value.type = val
+const handleTypeChange = (type: number) => {
+  formData.value.type = type
   // 切换类型时重置配置
   formData.value.config = {} as any
 }
@@ -196,10 +203,8 @@ const handleTypeChange = (val: number) => {
 /** 重置表单 */
 const resetForm = () => {
   formData.value = {
-    // TODO @puhui999:换成枚举值哈
-    status: 0,
-    direction: 1,
-    type: 1,
+    status: CommonStatusEnum.ENABLE,
+    type: IotDataSinkTypeEnum.HTTP,
     config: {} as any
   }
   formRef.value?.resetFields()

+ 280 - 0
src/views/iot/rule/data/sink/config/DatabaseConfigForm.vue

@@ -0,0 +1,280 @@
+<template>
+  <el-form-item label="JDBC 地址" prop="config.jdbcUrl">
+    <el-input
+      v-model="config.jdbcUrl"
+      placeholder="请输入JDBC连接地址,如:jdbc:mysql://localhost:3306/iot_data"
+    />
+  </el-form-item>
+  <el-form-item label="用户名" prop="config.username">
+    <el-input v-model="config.username" placeholder="请输入数据库用户名" />
+  </el-form-item>
+  <el-form-item label="密码" prop="config.password">
+    <el-input
+      v-model="config.password"
+      placeholder="请输入数据库密码"
+      show-password
+      type="password"
+    />
+  </el-form-item>
+  <el-form-item label="目标表名" prop="config.tableName">
+    <div style="display: flex; align-items: center; gap: 12px; width: 100%">
+      <el-input v-model="config.tableName" placeholder="目标表名" style="width: 240px" />
+      <el-button type="primary" link @click="toggleSqlTip">
+        <el-icon class="mr-1"><component :is="showSqlTip ? 'ArrowUp' : 'Document'" /></el-icon>
+        {{ showSqlTip ? '收起表结构提示' : '查看表结构提示' }}
+      </el-button>
+    </div>
+  </el-form-item>
+
+  <!-- Redesigned Terminal-style SQL snippet -->
+  <el-collapse-transition>
+    <div v-show="showSqlTip" class="terminal-card">
+      <div class="terminal-header">
+        <div class="terminal-dots">
+          <div class="dot red"></div>
+          <div class="dot yellow"></div>
+          <div class="dot green"></div>
+        </div>
+        <div class="terminal-title">Initialization Required</div>
+        <button class="terminal-copy-btn" type="button" @click="handleCopySQL">
+          <el-icon class="copy-icon"><Check v-if="isCopied" /><DocumentCopy v-else /></el-icon>
+          {{ isCopied ? '已复制' : 'Copy SQL' }}
+        </button>
+      </div>
+      <div class="terminal-body">
+        <div class="terminal-desc">
+          ✨ 目标数据库必须包含以下结构的表,才能正常接收数据流转的消息:
+        </div>
+        <div class="terminal-code-wrapper">
+          <pre
+            class="terminal-code"
+          ><code><span class="kw">CREATE</span> <span class="kw">TABLE</span> <span class="identifier">iot_device_message_sink</span> (
+    <span class="identifier">id</span> <span class="type">VARCHAR</span>(64) <span class="kw">NOT NULL COMMENT</span> <span class="string">'消息ID'</span>,
+    <span class="identifier">device_id</span> <span class="type">BIGINT</span> <span class="kw">NOT NULL COMMENT</span> <span class="string">'设备编号'</span>,
+    <span class="identifier">tenant_id</span> <span class="type">BIGINT</span> <span class="kw">NOT NULL DEFAULT</span> <span class="num">0</span> <span class="kw">COMMENT</span> <span class="string">'租户编号'</span>,
+    <span class="identifier">method</span> <span class="type">VARCHAR</span>(128) <span class="kw">COMMENT</span> <span class="string">'请求方法'</span>,
+    <span class="identifier">report_time</span> <span class="type">DATETIME</span> <span class="kw">COMMENT</span> <span class="string">'上报时间'</span>,
+    <span class="identifier">data</span> <span class="type">TEXT</span> <span class="kw">COMMENT</span> <span class="string">'完整消息JSON'</span>,
+    <span class="identifier">create_time</span> <span class="type">DATETIME</span> <span class="kw">NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT</span> <span class="string">'创建时间'</span>,
+    <span class="kw">PRIMARY KEY</span> (<span class="identifier">id</span>) <span class="kw">USING BTREE</span>,
+    <span class="kw">INDEX</span> <span class="identifier">idx_create_time</span> (<span class="identifier">create_time</span> <span class="kw">ASC</span>) <span class="kw">USING BTREE</span>
+) <span class="kw">ENGINE</span> = <span class="identifier">InnoDB</span> <span class="kw">CHARACTER SET</span> = <span class="identifier">utf8mb4</span> <span class="kw">COLLATE</span> = <span class="identifier">utf8mb4_unicode_ci</span> <span class="kw">COMMENT</span> = <span class="string">'IoT 设备消息流转目标表'</span>;</code></pre>
+        </div>
+      </div>
+    </div>
+  </el-collapse-transition>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, ref } from 'vue'
+import { Check, DocumentCopy } from '@element-plus/icons-vue'
+import { DatabaseConfig, IotDataSinkTypeEnum } from '@/api/iot/rule/data/sink'
+import { useClipboard, useVModel } from '@vueuse/core'
+import { isEmpty } from '@/utils/is'
+import { useMessage } from '@/hooks/web/useMessage'
+
+defineOptions({ name: 'DatabaseConfigForm' })
+
+const props = defineProps<{
+  modelValue: any
+}>()
+const emit = defineEmits(['update:modelValue'])
+const config = useVModel(props, 'modelValue', emit) as Ref<DatabaseConfig>
+const message = useMessage()
+
+const rawSQL = `CREATE TABLE iot_device_message_sink (
+    id VARCHAR(64) NOT NULL COMMENT '消息ID',
+    device_id BIGINT NOT NULL COMMENT '设备编号',
+    tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户编号',
+    method VARCHAR(128) COMMENT '请求方法',
+    report_time DATETIME COMMENT '上报时间',
+    data TEXT COMMENT '完整消息JSON',
+    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    PRIMARY KEY (id) USING BTREE,
+    INDEX idx_create_time (create_time ASC) USING BTREE
+) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'IoT 设备消息流转目标表';`
+
+const isCopied = ref(false)
+const showSqlTip = ref(false)
+const { copy } = useClipboard()
+
+const toggleSqlTip = () => {
+  showSqlTip.value = !showSqlTip.value
+}
+
+const handleCopySQL = async () => {
+  await copy(rawSQL)
+  isCopied.value = true
+  message.success('建表 SQL 已复制到剪贴板')
+  setTimeout(() => {
+    isCopied.value = false
+  }, 2000)
+}
+
+/** 组件初始化 */
+onMounted(() => {
+  if (!isEmpty(config.value)) {
+    return
+  }
+  config.value = {
+    type: IotDataSinkTypeEnum.DATABASE + '', // 序列化成对应类型时使用
+    jdbcUrl: '',
+    username: '',
+    password: '',
+    tableName: 'iot_device_message_sink'
+  }
+})
+</script>
+
+<style scoped>
+/* 终端风卡片设计 (Tokyo Night 极客美学) */
+.terminal-card {
+  margin-top: 32px;
+  margin-bottom: 8px;
+  border-radius: 12px;
+  background-color: #1a1b26;
+  box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.4);
+  border: 1px solid #24283b;
+  overflow: hidden;
+  font-family: 'Fira Code', 'JetBrains Mono', Consolas, Monaco, monospace;
+}
+
+.terminal-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 12px 16px;
+  background-color: #24283b;
+  border-bottom: 1px solid #16161e;
+  position: relative;
+}
+
+.terminal-dots {
+  display: flex;
+  gap: 8px;
+}
+
+.dot {
+  width: 12px;
+  height: 12px;
+  border-radius: 50%;
+  transition: transform 0.2s ease;
+}
+.dot:hover {
+  transform: scale(1.2);
+}
+.dot.red {
+  background-color: #f7768e;
+  box-shadow: 0 0 5px rgba(247, 118, 142, 0.4);
+}
+.dot.yellow {
+  background-color: #e0af68;
+  box-shadow: 0 0 5px rgba(224, 175, 104, 0.4);
+}
+.dot.green {
+  background-color: #9ece6a;
+  box-shadow: 0 0 5px rgba(158, 206, 106, 0.4);
+}
+
+.terminal-title {
+  color: #a9b1d6;
+  font-size: 13px;
+  font-weight: 600;
+  letter-spacing: 0.8px;
+  position: absolute;
+  left: 50%;
+  transform: translateX(-50%);
+}
+
+.terminal-copy-btn {
+  background: transparent;
+  border: 1px solid #414868;
+  color: #a9b1d6;
+  border-radius: 6px;
+  padding: 6px 12px;
+  font-size: 12px;
+  cursor: pointer;
+  transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-family: inherit;
+}
+.terminal-copy-btn:hover {
+  background: #bb9af7;
+  border-color: #bb9af7;
+  color: #1a1b26;
+  transform: translateY(-2px);
+  box-shadow: 0 4px 12px rgba(187, 154, 247, 0.3);
+}
+.terminal-copy-btn:active {
+  transform: translateY(0);
+}
+
+.copy-icon {
+  font-size: 14px;
+}
+
+.terminal-body {
+  padding: 20px;
+  color: #c0caf5;
+  font-size: 13px;
+  line-height: 1.6;
+}
+
+.terminal-desc {
+  color: #7dcfff;
+  margin-bottom: 16px;
+  font-family: var(--el-font-family);
+  font-size: 13px;
+  padding-bottom: 16px;
+  border-bottom: 1px dashed #292e42;
+}
+
+.terminal-code-wrapper {
+  overflow-x: auto;
+  border-radius: 8px;
+}
+
+.terminal-code {
+  margin: 0;
+  white-space: pre;
+  min-width: max-content;
+}
+
+.terminal-code code {
+  font-family: inherit;
+}
+
+/* 手工实现的轻量级 SQL 语法高亮 (Tokyo Night Color Palette) */
+.kw {
+  color: #bb9af7;
+} /* 紫色 - 关键字 */
+.type {
+  color: #2ac3de;
+} /* 青色 - 数据类型 */
+.string {
+  color: #9ece6a;
+} /* 绿色 - 字符串/注释 */
+.identifier {
+  color: #c0caf5;
+} /* 浅蓝 - 变量名/默认字色 */
+.num {
+  color: #ff9e64;
+} /* 橙色 - 数字 */
+
+/* 定制代码块的滚动条 */
+.terminal-code-wrapper::-webkit-scrollbar {
+  height: 8px;
+}
+.terminal-code-wrapper::-webkit-scrollbar-thumb {
+  background: #414868;
+  border-radius: 4px;
+}
+.terminal-code-wrapper::-webkit-scrollbar-thumb:hover {
+  background: #565f89;
+}
+.terminal-code-wrapper::-webkit-scrollbar-track {
+  background: transparent;
+}
+</style>

+ 4 - 2
src/views/iot/rule/databridge/config/HttpConfigForm.vue → src/views/iot/rule/data/sink/config/HttpConfigForm.vue

@@ -3,6 +3,7 @@
     <el-input v-model="urlPath" placeholder="请输入请求地址">
       <template #prepend>
         <el-select v-model="urlPrefix" placeholder="Select" style="width: 115px">
+          <!--suppress HttpUrlsUsage -->
           <el-option label="http://" value="http://" />
           <el-option label="https://" value="https://" />
         </el-select>
@@ -29,7 +30,7 @@
 </template>
 
 <script lang="ts" setup>
-import { HttpConfig, IoTDataBridgeConfigType } from '@/api/iot/rule/databridge'
+import { HttpConfig, IotDataSinkTypeEnum } from '@/api/iot/rule/data/sink'
 import { useVModel } from '@vueuse/core'
 import { isEmpty } from '@/utils/is'
 import KeyValueEditor from './components/KeyValueEditor.vue'
@@ -42,6 +43,7 @@ const props = defineProps<{
 const emit = defineEmits(['update:modelValue'])
 const config = useVModel(props, 'modelValue', emit) as Ref<HttpConfig>
 
+// noinspection HttpUrlsUsage
 /** URL处理 */
 const urlPrefix = ref('http://')
 const urlPath = ref('')
@@ -73,7 +75,7 @@ onMounted(() => {
   }
 
   config.value = {
-    type: IoTDataBridgeConfigType.HTTP,
+    type: IotDataSinkTypeEnum.HTTP + '', // 序列化成对应类型时使用
     url: '',
     method: 'POST',
     headers: {},

+ 2 - 2
src/views/iot/rule/databridge/config/KafkaMQConfigForm.vue → src/views/iot/rule/data/sink/config/KafkaMQConfigForm.vue

@@ -16,7 +16,7 @@
   </el-form-item>
 </template>
 <script lang="ts" setup>
-import { IoTDataBridgeConfigType, KafkaMQConfig } from '@/api/iot/rule/databridge'
+import { IotDataSinkTypeEnum, KafkaMQConfig } from '@/api/iot/rule/data/sink'
 import { useVModel } from '@vueuse/core'
 import { isEmpty } from '@/utils/is'
 
@@ -34,7 +34,7 @@ onMounted(() => {
     return
   }
   config.value = {
-    type: IoTDataBridgeConfigType.KAFKA,
+    type: IotDataSinkTypeEnum.KAFKA + '', // 序列化成对应类型时使用
     bootstrapServers: '',
     username: '',
     password: '',

+ 2 - 2
src/views/iot/rule/databridge/config/MqttConfigForm.vue → src/views/iot/rule/data/sink/config/MqttConfigForm.vue

@@ -16,7 +16,7 @@
   </el-form-item>
 </template>
 <script lang="ts" setup>
-import { IoTDataBridgeConfigType, MqttConfig } from '@/api/iot/rule/databridge'
+import { IotDataSinkTypeEnum, MqttConfig } from '@/api/iot/rule/data/sink'
 import { useVModel } from '@vueuse/core'
 import { isEmpty } from '@/utils/is'
 
@@ -34,7 +34,7 @@ onMounted(() => {
     return
   }
   config.value = {
-    type: IoTDataBridgeConfigType.MQTT,
+    type: IotDataSinkTypeEnum.MQTT + '', // 序列化成对应类型时使用
     url: '',
     username: '',
     password: '',

+ 2 - 2
src/views/iot/rule/databridge/config/RabbitMQConfigForm.vue → src/views/iot/rule/data/sink/config/RabbitMQConfigForm.vue

@@ -31,7 +31,7 @@
   </el-form-item>
 </template>
 <script lang="ts" setup>
-import { IoTDataBridgeConfigType, RabbitMQConfig } from '@/api/iot/rule/databridge'
+import { IotDataSinkTypeEnum, RabbitMQConfig } from '@/api/iot/rule/data/sink'
 import { useVModel } from '@vueuse/core'
 import { isEmpty } from '@/utils/is'
 
@@ -49,7 +49,7 @@ onMounted(() => {
     return
   }
   config.value = {
-    type: IoTDataBridgeConfigType.RABBITMQ,
+    type: IotDataSinkTypeEnum.RABBITMQ + '', // 序列化成对应类型时使用
     host: '',
     port: 5672,
     virtualHost: '/',

+ 2 - 3
src/views/iot/rule/databridge/config/RedisStreamMQConfigForm.vue → src/views/iot/rule/data/sink/config/RedisStreamConfigForm.vue

@@ -1,4 +1,3 @@
-<!-- TODO @puhui999:去掉 MQ 关键字哈 -->
 <template>
   <el-form-item label="主机地址" prop="config.host">
     <el-input v-model="config.host" placeholder="请输入主机地址,如:localhost" />
@@ -29,7 +28,7 @@
   </el-form-item>
 </template>
 <script lang="ts" setup>
-import { IoTDataBridgeConfigType, RedisStreamMQConfig } from '@/api/iot/rule/databridge'
+import { IotDataSinkTypeEnum, RedisStreamMQConfig } from '@/api/iot/rule/data/sink'
 import { useVModel } from '@vueuse/core'
 import { isEmpty } from '@/utils/is'
 
@@ -47,7 +46,7 @@ onMounted(() => {
     return
   }
   config.value = {
-    type: IoTDataBridgeConfigType.REDIS_STREAM,
+    type: IotDataSinkTypeEnum.REDIS_STREAM + '', // 序列化成对应类型时使用
     host: '',
     port: 6379,
     password: '',

+ 2 - 2
src/views/iot/rule/databridge/config/RocketMQConfigForm.vue → src/views/iot/rule/data/sink/config/RocketMQConfigForm.vue

@@ -27,7 +27,7 @@
   </el-form-item>
 </template>
 <script lang="ts" setup>
-import { IoTDataBridgeConfigType, RocketMQConfig } from '@/api/iot/rule/databridge'
+import { IotDataSinkTypeEnum, RocketMQConfig } from '@/api/iot/rule/data/sink'
 import { useVModel } from '@vueuse/core'
 import { isEmpty } from '@/utils/is'
 
@@ -45,7 +45,7 @@ onMounted(() => {
     return
   }
   config.value = {
-    type: IoTDataBridgeConfigType.ROCKETMQ,
+    type: IotDataSinkTypeEnum.ROCKETMQ + '', // 序列化成对应类型时使用
     nameServer: '',
     accessKey: '',
     secretKey: '',

+ 103 - 0
src/views/iot/rule/data/sink/config/TcpConfigForm.vue

@@ -0,0 +1,103 @@
+<template>
+  <el-form-item label="服务器地址" prop="config.host">
+    <el-input v-model="config.host" placeholder="请输入 TCP 服务器地址,如:localhost" />
+  </el-form-item>
+  <el-form-item label="端口" prop="config.port">
+    <el-input-number
+      v-model="config.port"
+      :max="65535"
+      :min="1"
+      controls-position="right"
+      placeholder="请输入端口"
+    />
+  </el-form-item>
+  <el-form-item label="连接超时(ms)" prop="config.connectTimeoutMs">
+    <el-input-number
+      v-model="config.connectTimeoutMs"
+      :min="1000"
+      :step="1000"
+      controls-position="right"
+      placeholder="请输入连接超时时间"
+    />
+  </el-form-item>
+  <el-form-item label="读取超时(ms)" prop="config.readTimeoutMs">
+    <el-input-number
+      v-model="config.readTimeoutMs"
+      :min="1000"
+      :step="1000"
+      controls-position="right"
+      placeholder="请输入读取超时时间"
+    />
+  </el-form-item>
+  <el-form-item label="启用 SSL" prop="config.ssl">
+    <el-switch v-model="config.ssl" />
+  </el-form-item>
+  <el-form-item v-if="config.ssl" label="SSL 证书路径" prop="config.sslCertPath">
+    <el-input v-model="config.sslCertPath" placeholder="请输入 SSL 证书路径" />
+  </el-form-item>
+  <el-form-item label="数据格式" prop="config.dataFormat">
+    <el-select v-model="config.dataFormat" placeholder="请选择数据格式">
+      <el-option label="JSON" value="JSON" />
+      <el-option label="BINARY" value="BINARY" />
+    </el-select>
+  </el-form-item>
+  <el-form-item label="心跳间隔(ms)" prop="config.heartbeatIntervalMs">
+    <el-input-number
+      v-model="config.heartbeatIntervalMs"
+      :min="0"
+      :step="1000"
+      controls-position="right"
+      placeholder="0 表示不启用心跳"
+    />
+  </el-form-item>
+  <el-form-item label="重连间隔(ms)" prop="config.reconnectIntervalMs">
+    <el-input-number
+      v-model="config.reconnectIntervalMs"
+      :min="1000"
+      :step="1000"
+      controls-position="right"
+      placeholder="请输入重连间隔时间"
+    />
+  </el-form-item>
+  <el-form-item label="最大重连次数" prop="config.maxReconnectAttempts">
+    <el-input-number
+      v-model="config.maxReconnectAttempts"
+      :min="0"
+      controls-position="right"
+      placeholder="请输入最大重连次数"
+    />
+  </el-form-item>
+</template>
+<script lang="ts" setup>
+import { IotDataSinkTypeEnum, TcpConfig } from '@/api/iot/rule/data/sink'
+import { useVModel } from '@vueuse/core'
+import { isEmpty } from '@/utils/is'
+
+defineOptions({ name: 'TcpConfigForm' })
+
+const props = defineProps<{
+  modelValue: any
+}>()
+const emit = defineEmits(['update:modelValue'])
+const config = useVModel(props, 'modelValue', emit) as Ref<TcpConfig>
+
+/** 组件初始化 */
+onMounted(() => {
+  if (!isEmpty(config.value)) {
+    return
+  }
+  config.value = {
+    type: IotDataSinkTypeEnum.TCP + '',
+    host: '',
+    port: 8080,
+    connectTimeoutMs: 5000,
+    readTimeoutMs: 10000,
+    ssl: false,
+    sslCertPath: '',
+    dataFormat: 'JSON',
+    heartbeatIntervalMs: 30000,
+    reconnectIntervalMs: 5000,
+    maxReconnectAttempts: 3
+  }
+})
+</script>

+ 117 - 0
src/views/iot/rule/data/sink/config/WebSocketConfigForm.vue

@@ -0,0 +1,117 @@
+<template>
+  <el-form-item label="服务器地址" prop="config.serverUrl">
+    <el-input
+      v-model="config.serverUrl"
+      placeholder="请输入 WebSocket 地址,如:ws://localhost:8080/ws"
+    />
+  </el-form-item>
+  <el-form-item label="连接超时(ms)" prop="config.connectTimeoutMs">
+    <el-input-number
+      v-model="config.connectTimeoutMs"
+      :min="1000"
+      :step="1000"
+      controls-position="right"
+    />
+  </el-form-item>
+  <el-form-item label="发送超时(ms)" prop="config.sendTimeoutMs">
+    <el-input-number
+      v-model="config.sendTimeoutMs"
+      :min="1000"
+      :step="1000"
+      controls-position="right"
+    />
+  </el-form-item>
+  <el-form-item label="心跳间隔(ms)" prop="config.heartbeatIntervalMs">
+    <el-input-number
+      v-model="config.heartbeatIntervalMs"
+      :min="0"
+      :step="1000"
+      controls-position="right"
+      placeholder="0 表示不启用心跳"
+    />
+  </el-form-item>
+  <el-form-item label="心跳消息" prop="config.heartbeatMessage">
+    <el-input v-model="config.heartbeatMessage" placeholder="请输入心跳消息内容(JSON 格式)" />
+  </el-form-item>
+  <el-form-item label="子协议" prop="config.subprotocols">
+    <el-input v-model="config.subprotocols" placeholder="请输入子协议列表,多个用逗号分隔" />
+  </el-form-item>
+  <el-form-item label="自定义请求头" prop="config.customHeaders">
+    <el-input
+      v-model="config.customHeaders"
+      type="textarea"
+      placeholder="请输入自定义请求头(JSON 格式)"
+    />
+  </el-form-item>
+  <el-form-item label="验证 SSL 证书" prop="config.verifySslCert">
+    <el-switch v-model="config.verifySslCert" />
+  </el-form-item>
+  <el-form-item label="数据格式" prop="config.dataFormat">
+    <el-select v-model="config.dataFormat" placeholder="请选择数据格式">
+      <el-option label="JSON" value="JSON" />
+      <el-option label="TEXT" value="TEXT" />
+    </el-select>
+  </el-form-item>
+  <el-form-item label="重连间隔(ms)" prop="config.reconnectIntervalMs">
+    <el-input-number
+      v-model="config.reconnectIntervalMs"
+      :min="1000"
+      :step="1000"
+      controls-position="right"
+    />
+  </el-form-item>
+  <el-form-item label="最大重连次数" prop="config.maxReconnectAttempts">
+    <el-input-number v-model="config.maxReconnectAttempts" :min="0" controls-position="right" />
+  </el-form-item>
+  <el-form-item label="启用压缩" prop="config.enableCompression">
+    <el-switch v-model="config.enableCompression" />
+  </el-form-item>
+  <el-form-item label="发送重试次数" prop="config.sendRetryCount">
+    <el-input-number v-model="config.sendRetryCount" :min="0" controls-position="right" />
+  </el-form-item>
+  <el-form-item label="重试间隔(ms)" prop="config.sendRetryIntervalMs">
+    <el-input-number
+      v-model="config.sendRetryIntervalMs"
+      :min="100"
+      :step="500"
+      controls-position="right"
+    />
+  </el-form-item>
+</template>
+<script lang="ts" setup>
+import { IotDataSinkTypeEnum, WebSocketConfig } from '@/api/iot/rule/data/sink'
+import { useVModel } from '@vueuse/core'
+import { isEmpty } from '@/utils/is'
+
+defineOptions({ name: 'WebSocketConfigForm' })
+
+const props = defineProps<{
+  modelValue: any
+}>()
+const emit = defineEmits(['update:modelValue'])
+const config = useVModel(props, 'modelValue', emit) as Ref<WebSocketConfig>
+
+/** 组件初始化 */
+onMounted(() => {
+  if (!isEmpty(config.value)) {
+    return
+  }
+  config.value = {
+    type: IotDataSinkTypeEnum.WEBSOCKET + '',
+    serverUrl: '',
+    connectTimeoutMs: 5000,
+    sendTimeoutMs: 10000,
+    heartbeatIntervalMs: 30000,
+    heartbeatMessage: '{"type":"heartbeat"}',
+    subprotocols: '',
+    customHeaders: '',
+    verifySslCert: true,
+    dataFormat: 'JSON',
+    reconnectIntervalMs: 5000,
+    maxReconnectAttempts: 3,
+    enableCompression: false,
+    sendRetryCount: 1,
+    sendRetryIntervalMs: 1000
+  }
+})
+</script>

+ 0 - 1
src/views/iot/rule/databridge/config/components/KeyValueEditor.vue → src/views/iot/rule/data/sink/config/components/KeyValueEditor.vue

@@ -58,7 +58,6 @@ const updateModelValue = () => {
   emit('update:modelValue', result)
 }
 
-// TODO @puhui999:有告警的地方,尽量用 cursor 处理下
 /** 监听项目变化 */
 watch(items, updateModelValue, { deep: true })
 watch(

+ 8 - 2
src/views/iot/rule/databridge/config/index.ts → src/views/iot/rule/data/sink/config/index.ts

@@ -1,15 +1,21 @@
 import HttpConfigForm from './HttpConfigForm.vue'
+import TcpConfigForm from './TcpConfigForm.vue'
+import WebSocketConfigForm from './WebSocketConfigForm.vue'
 import MqttConfigForm from './MqttConfigForm.vue'
+import DatabaseConfigForm from './DatabaseConfigForm.vue'
 import RocketMQConfigForm from './RocketMQConfigForm.vue'
 import KafkaMQConfigForm from './KafkaMQConfigForm.vue'
 import RabbitMQConfigForm from './RabbitMQConfigForm.vue'
-import RedisStreamMQConfigForm from './RedisStreamMQConfigForm.vue'
+import RedisStreamConfigForm from './RedisStreamConfigForm.vue'
 
 export {
   HttpConfigForm,
+  TcpConfigForm,
+  WebSocketConfigForm,
   MqttConfigForm,
+  DatabaseConfigForm,
   RocketMQConfigForm,
   KafkaMQConfigForm,
   RabbitMQConfigForm,
-  RedisStreamMQConfigForm
+  RedisStreamConfigForm
 }

+ 24 - 46
src/views/iot/rule/databridge/index.vue → src/views/iot/rule/data/sink/index.vue

@@ -8,21 +8,21 @@
       class="-mb-15px"
       label-width="68px"
     >
-      <el-form-item label="桥梁名称" prop="name">
+      <el-form-item label="目的名称" prop="name">
         <el-input
           v-model="queryParams.name"
           class="!w-240px"
           clearable
-          placeholder="请输入桥梁名称"
+          placeholder="请输入目的名称"
           @keyup.enter="handleQuery"
         />
       </el-form-item>
-      <el-form-item label="桥梁状态" prop="status">
+      <el-form-item label="目的状态" prop="status">
         <el-select
           v-model="queryParams.status"
           class="!w-240px"
           clearable
-          placeholder="请选择桥梁状态"
+          placeholder="请选择目的状态"
         >
           <el-option
             v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
@@ -32,30 +32,15 @@
           />
         </el-select>
       </el-form-item>
-      <el-form-item label="桥梁方向" prop="direction">
-        <el-select
-          v-model="queryParams.direction"
-          class="!w-240px"
-          clearable
-          placeholder="请选择桥梁方向"
-        >
-          <el-option
-            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DATA_BRIDGE_DIRECTION_ENUM)"
-            :key="dict.value"
-            :label="dict.label"
-            :value="dict.value"
-          />
-        </el-select>
-      </el-form-item>
-      <el-form-item label="桥梁类型" prop="type">
+      <el-form-item label="目的类型" prop="type">
         <el-select
           v-model="queryParams.type"
           class="!w-240px"
           clearable
-          placeholder="请选择桥梁类型"
+          placeholder="请选择目的类型"
         >
           <el-option
-            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DATA_BRIDGE_TYPE_ENUM)"
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DATA_SINK_TYPE_ENUM)"
             :key="dict.value"
             :label="dict.label"
             :value="dict.value"
@@ -83,7 +68,7 @@
           重置
         </el-button>
         <el-button
-          v-hasPermi="['iot:data-bridge:create']"
+          v-hasPermi="['iot:data-sink:create']"
           plain
           type="primary"
           @click="openForm('create')"
@@ -98,22 +83,17 @@
   <!-- 列表 -->
   <ContentWrap>
     <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
-      <el-table-column align="center" label="桥梁编号" prop="id" />
-      <el-table-column align="center" label="桥梁名称" prop="name" />
-      <el-table-column align="center" label="桥梁描述" prop="description" />
-      <el-table-column align="center" label="桥梁状态" prop="status">
+      <el-table-column align="center" label="目的编号" prop="id" />
+      <el-table-column align="center" label="目的名称" prop="name" />
+      <el-table-column align="center" label="目的描述" prop="description" />
+      <el-table-column align="center" label="目的状态" prop="status">
         <template #default="scope">
           <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
         </template>
       </el-table-column>
-      <el-table-column align="center" label="桥梁方向" prop="direction">
-        <template #default="scope">
-          <dict-tag :type="DICT_TYPE.IOT_DATA_BRIDGE_DIRECTION_ENUM" :value="scope.row.direction" />
-        </template>
-      </el-table-column>
-      <el-table-column align="center" label="桥梁类型" prop="type">
+      <el-table-column align="center" label="目的类型" prop="type">
         <template #default="scope">
-          <dict-tag :type="DICT_TYPE.IOT_DATA_BRIDGE_TYPE_ENUM" :value="scope.row.type" />
+          <dict-tag :type="DICT_TYPE.IOT_DATA_SINK_TYPE_ENUM" :value="scope.row.type" />
         </template>
       </el-table-column>
       <el-table-column
@@ -126,7 +106,7 @@
       <el-table-column align="center" fixed="right" label="操作" width="120px">
         <template #default="scope">
           <el-button
-            v-hasPermi="['iot:data-bridge:update']"
+            v-hasPermi="['iot:data-sink:update']"
             link
             type="primary"
             @click="openForm('update', scope.row.id)"
@@ -134,7 +114,7 @@
             编辑
           </el-button>
           <el-button
-            v-hasPermi="['iot:data-bridge:delete']"
+            v-hasPermi="['iot:data-sink:delete']"
             link
             type="danger"
             @click="handleDelete(scope.row.id)"
@@ -154,31 +134,29 @@
   </ContentWrap>
 
   <!-- 表单弹窗:添加/修改 -->
-  <DataBridgeForm ref="formRef" @success="getList" />
+  <DataSinkForm ref="formRef" @success="getList" />
 </template>
 
 <script lang="ts" setup>
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import { dateFormatter } from '@/utils/formatTime'
-import { DataBridgeApi, DataBridgeVO } from '@/api/iot/rule/databridge'
-import DataBridgeForm from './IoTDataBridgeForm.vue'
+import { DataSinkApi, DataSinkVO } from '@/api/iot/rule/data/sink'
+import DataSinkForm from './DataSinkForm.vue'
 
-/** IoT 数据桥梁 列表 */
-defineOptions({ name: 'IotDataBridge' })
+/** IoT 数据流转目的 列表 */
+defineOptions({ name: 'IotDataSink' })
 
 const message = useMessage() // 消息弹窗
 const { t } = useI18n() // 国际化
 
 const loading = ref(true) // 列表的加载中
-const list = ref<DataBridgeVO[]>([]) // 列表的数据
+const list = ref<DataSinkVO[]>([]) // 列表的数据
 const total = ref(0) // 列表的总页数
 const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
   name: undefined,
-  description: undefined,
   status: undefined,
-  direction: undefined,
   type: undefined,
   createTime: []
 })
@@ -188,7 +166,7 @@ const queryFormRef = ref() // 搜索的表单
 const getList = async () => {
   loading.value = true
   try {
-    const data = await DataBridgeApi.getDataBridgePage(queryParams)
+    const data = await DataSinkApi.getDataSinkPage(queryParams)
     list.value = data.list
     total.value = data.total
   } finally {
@@ -220,7 +198,7 @@ const handleDelete = async (id: number) => {
     // 删除的二次确认
     await message.delConfirm()
     // 发起删除
-    await DataBridgeApi.deleteDataBridge(id)
+    await DataSinkApi.deleteDataSink(id)
     message.success(t('common.delSuccess'))
     // 刷新列表
     await getList()

+ 336 - 0
src/views/iot/rule/scene/form/RuleSceneForm.vue

@@ -0,0 +1,336 @@
+<template>
+  <el-drawer
+    v-model="drawerVisible"
+    :title="drawerTitle"
+    size="80%"
+    direction="rtl"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    @close="handleClose"
+  >
+    <el-form ref="formRef" :model="formData" :rules="formRules" label-width="110px">
+      <!-- 基础信息配置 -->
+      <BasicInfoSection v-model="formData" :rules="formRules" />
+      <!-- 触发器配置 -->
+      <TriggerSection v-model:triggers="formData.triggers" />
+      <!-- 执行器配置 -->
+      <ActionSection v-model:actions="formData.actions" />
+    </el-form>
+    <template #footer>
+      <div class="drawer-footer">
+        <el-button :disabled="submitLoading" type="primary" @click="handleSubmit">
+          <Icon icon="ep:check" />
+          确 定
+        </el-button>
+        <el-button @click="handleClose">
+          <Icon icon="ep:close" />
+          取 消
+        </el-button>
+      </div>
+    </template>
+  </el-drawer>
+</template>
+
+<script setup lang="ts">
+import { useVModel } from '@vueuse/core'
+import BasicInfoSection from './sections/BasicInfoSection.vue'
+import TriggerSection from './sections/TriggerSection.vue'
+import ActionSection from './sections/ActionSection.vue'
+import { IotSceneRule } from '@/api/iot/rule/scene'
+import { RuleSceneApi } from '@/api/iot/rule/scene'
+import {
+  IotRuleSceneTriggerTypeEnum,
+  IotRuleSceneActionTypeEnum,
+  isDeviceTrigger
+} from '@/views/iot/utils/constants'
+import { ElMessage } from 'element-plus'
+import { CommonStatusEnum } from '@/utils/constants'
+
+/** IoT 场景联动规则表单 - 主表单组件 */
+defineOptions({ name: 'RuleSceneForm' })
+
+/** 组件属性定义 */
+const props = defineProps<{
+  /** 抽屉显示状态 */
+  modelValue: boolean
+  /** 编辑的场景联动规则数据 */
+  ruleScene?: IotSceneRule
+}>()
+
+/** 组件事件定义 */
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: boolean): void
+  (e: 'success'): void
+}>()
+
+const drawerVisible = useVModel(props, 'modelValue', emit) // 抽屉显示状态
+
+/**
+ * 创建默认的表单数据
+ * @returns 默认表单数据对象
+ */
+const createDefaultFormData = (): IotSceneRule => {
+  return {
+    name: '',
+    description: '',
+    status: CommonStatusEnum.ENABLE, // 默认启用状态
+    triggers: [
+      {
+        type: IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST,
+        productId: undefined,
+        deviceId: undefined,
+        identifier: undefined,
+        operator: undefined,
+        value: undefined,
+        cronExpression: undefined,
+        conditionGroups: [] // 空的条件组数组
+      }
+    ],
+    actions: []
+  }
+}
+
+const formRef = ref() // 表单引用
+const formData = ref<IotSceneRule>(createDefaultFormData()) // 表单数据
+
+/**
+ * 触发器校验器
+ * @param _rule 校验规则(未使用)
+ * @param value 校验值
+ * @param callback 回调函数
+ */
+const validateTriggers = (_rule: any, value: any, callback: any) => {
+  if (!value || !Array.isArray(value) || value.length === 0) {
+    callback(new Error('至少需要一个触发器'))
+    return
+  }
+
+  for (let i = 0; i < value.length; i++) {
+    const trigger = value[i]
+
+    // 校验触发器类型
+    if (!trigger.type) {
+      callback(new Error(`触发器 ${i + 1}: 触发器类型不能为空`))
+      return
+    }
+
+    // 校验设备触发器
+    if (isDeviceTrigger(trigger.type)) {
+      if (!trigger.productId) {
+        callback(new Error(`触发器 ${i + 1}: 产品不能为空`))
+        return
+      }
+      if (!trigger.deviceId) {
+        callback(new Error(`触发器 ${i + 1}: 设备不能为空`))
+        return
+      }
+      if (!trigger.identifier) {
+        callback(new Error(`触发器 ${i + 1}: 物模型标识符不能为空`))
+        return
+      }
+      // 事件上报 / 服务调用:operator 由前端自动设为 '=',参数值留空表示"事件 / 调用发生即匹配"
+      const isEventOrService =
+        trigger.type === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST ||
+        trigger.type === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE
+      if (!isEventOrService) {
+        if (!trigger.operator) {
+          callback(new Error(`触发器 ${i + 1}: 操作符不能为空`))
+          return
+        }
+        if (trigger.value === undefined || trigger.value === null || trigger.value === '') {
+          callback(new Error(`触发器 ${i + 1}: 参数值不能为空`))
+          return
+        }
+      }
+    }
+
+    // 校验定时触发器
+    if (trigger.type === IotRuleSceneTriggerTypeEnum.TIMER) {
+      if (!trigger.cronExpression) {
+        callback(new Error(`触发器 ${i + 1}: CRON表达式不能为空`))
+        return
+      }
+    }
+  }
+
+  callback()
+}
+
+/**
+ * 执行器校验器
+ * @param _rule 校验规则(未使用)
+ * @param value 校验值
+ * @param callback 回调函数
+ */
+const validateActions = (_rule: any, value: any, callback: any) => {
+  if (!value || !Array.isArray(value) || value.length === 0) {
+    callback(new Error('至少需要一个执行器'))
+    return
+  }
+
+  for (let i = 0; i < value.length; i++) {
+    const action = value[i]
+
+    // 校验执行器类型
+    if (!action.type) {
+      callback(new Error(`执行器 ${i + 1}: 执行器类型不能为空`))
+      return
+    }
+
+    // 校验设备控制执行器
+    if (
+      action.type === IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET ||
+      action.type === IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE
+    ) {
+      if (!action.productId) {
+        callback(new Error(`执行器 ${i + 1}: 产品不能为空`))
+        return
+      }
+      if (!action.deviceId) {
+        callback(new Error(`执行器 ${i + 1}: 设备不能为空`))
+        return
+      }
+
+      // 服务调用需要验证服务标识符
+      if (action.type === IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE) {
+        if (!action.identifier) {
+          callback(new Error(`执行器 ${i + 1}: 服务不能为空`))
+          return
+        }
+      }
+
+      if (!action.params || Object.keys(action.params).length === 0) {
+        callback(new Error(`执行器 ${i + 1}: 参数配置不能为空`))
+        return
+      }
+    }
+
+    // 校验告警执行器
+    if (
+      action.type === IotRuleSceneActionTypeEnum.ALERT_TRIGGER ||
+      action.type === IotRuleSceneActionTypeEnum.ALERT_RECOVER
+    ) {
+      if (!action.alertConfigId) {
+        callback(new Error(`执行器 ${i + 1}: 告警配置不能为空`))
+        return
+      }
+    }
+  }
+
+  callback()
+}
+
+const formRules = reactive({
+  name: [
+    { required: true, message: '场景名称不能为空', trigger: 'blur' },
+    { type: 'string', min: 1, max: 50, message: '场景名称长度应在1-50个字符之间', trigger: 'blur' }
+  ],
+  status: [
+    { required: true, message: '场景状态不能为空', trigger: 'change' },
+    {
+      type: 'enum',
+      enum: [CommonStatusEnum.ENABLE, CommonStatusEnum.DISABLE],
+      message: '状态值必须为启用或禁用',
+      trigger: 'change'
+    }
+  ],
+  description: [
+    { type: 'string', max: 200, message: '场景描述不能超过200个字符', trigger: 'blur' }
+  ],
+  triggers: [{ required: true, validator: validateTriggers, trigger: 'change' }],
+  actions: [{ required: true, validator: validateActions, trigger: 'change' }]
+}) // 表单校验规则
+
+const submitLoading = ref(false) // 提交加载状态
+const isEdit = ref(false) // 是否为编辑模式
+const drawerTitle = computed(() => (isEdit.value ? '编辑场景联动规则' : '新增场景联动规则')) // 抽屉标题
+
+/** 提交表单 */
+const handleSubmit = async () => {
+  // 校验表单
+  if (!formRef.value) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+
+  // 提交请求
+  submitLoading.value = true
+  try {
+    if (isEdit.value) {
+      // 更新场景联动规则
+      await RuleSceneApi.updateRuleScene(formData.value)
+      ElMessage.success('更新成功')
+    } else {
+      // 创建场景联动规则
+      await RuleSceneApi.createRuleScene(formData.value)
+      ElMessage.success('创建成功')
+    }
+
+    // 关闭抽屉并触发成功事件
+    drawerVisible.value = false
+    emit('success')
+  } catch (error) {
+    console.error('保存失败:', error)
+    ElMessage.error(isEdit.value ? '更新失败' : '创建失败')
+  } finally {
+    submitLoading.value = false
+  }
+}
+
+/** 处理抽屉关闭事件 */
+const handleClose = () => {
+  drawerVisible.value = false
+}
+
+/** 初始化表单数据 */
+const initFormData = () => {
+  if (props.ruleScene) {
+    // 编辑模式:数据结构已对齐,直接使用后端数据
+    isEdit.value = true
+    formData.value = {
+      ...props.ruleScene,
+      // 确保触发器数组不为空
+      triggers: props.ruleScene.triggers?.length
+        ? props.ruleScene.triggers
+        : [
+            {
+              type: IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST,
+              productId: undefined,
+              deviceId: undefined,
+              identifier: undefined,
+              operator: undefined,
+              value: undefined,
+              cronExpression: undefined,
+              conditionGroups: []
+            }
+          ],
+      // 确保执行器数组不为空
+      actions: props.ruleScene.actions || []
+    }
+  } else {
+    // 新增模式:使用默认数据
+    isEdit.value = false
+    formData.value = createDefaultFormData()
+  }
+}
+
+/** 监听抽屉显示 */
+watch(drawerVisible, async (visible) => {
+  if (visible) {
+    initFormData()
+    // 重置表单验证状态
+    await nextTick()
+    formRef.value?.clearValidate()
+  }
+})
+
+/** 监听编辑数据变化 */
+watch(
+  () => props.ruleScene,
+  () => {
+    if (drawerVisible.value) {
+      initFormData()
+    }
+  },
+  { deep: true }
+)
+</script>

+ 81 - 0
src/views/iot/rule/scene/form/configs/AlertConfig.vue

@@ -0,0 +1,81 @@
+<!-- 告警配置组件 -->
+<template>
+  <div class="w-full">
+    <el-form-item label="告警配置" required>
+      <el-select
+        v-model="localValue"
+        placeholder="请选择告警配置"
+        filterable
+        clearable
+        @change="handleChange"
+        class="w-full"
+        :loading="loading"
+      >
+        <el-option
+          v-for="config in alertConfigs"
+          :key="config.id"
+          :label="config.name"
+          :value="config.id"
+        >
+          <div class="flex items-center justify-between">
+            <span>{{ config.name }}</span>
+            <el-tag :type="config.enabled ? 'success' : 'danger'" size="small">
+              {{ config.enabled ? '启用' : '禁用' }}
+            </el-tag>
+          </div>
+        </el-option>
+      </el-select>
+    </el-form-item>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useVModel } from '@vueuse/core'
+import { AlertConfigApi } from '@/api/iot/alert/config'
+
+/** 告警配置组件 */
+defineOptions({ name: 'AlertConfig' })
+
+const props = defineProps<{
+  modelValue?: number
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', value?: number): void
+}>()
+
+const localValue = useVModel(props, 'modelValue', emit)
+
+const loading = ref(false) // 加载状态
+const alertConfigs = ref<any[]>([]) // 告警配置列表
+
+/**
+ * 处理选择变化事件
+ * @param value 选中的值
+ */
+const handleChange = (value?: number) => {
+  emit('update:modelValue', value)
+}
+
+/**
+ * 加载告警配置列表
+ */
+const loadAlertConfigs = async () => {
+  loading.value = true
+  try {
+    const data = await AlertConfigApi.getAlertConfigPage({
+      pageNo: 1,
+      pageSize: 100,
+      enabled: true // 只加载启用的配置
+    })
+    alertConfigs.value = data.list || []
+  } finally {
+    loading.value = false
+  }
+}
+
+// 组件挂载时加载数据
+onMounted(() => {
+  loadAlertConfigs()
+})
+</script>

+ 301 - 0
src/views/iot/rule/scene/form/configs/ConditionConfig.vue

@@ -0,0 +1,301 @@
+<!-- 单个条件配置组件 -->
+<template>
+  <div class="flex flex-col gap-16px">
+    <!-- 条件类型选择 -->
+    <el-row :gutter="16">
+      <el-col :span="8">
+        <el-form-item label="条件类型" required>
+          <el-select
+            :model-value="condition.type"
+            @update:model-value="(value) => updateConditionField('type', value)"
+            @change="handleConditionTypeChange"
+            placeholder="请选择条件类型"
+            class="w-full"
+          >
+            <el-option
+              v-for="option in getConditionTypeOptions()"
+              :key="option.value"
+              :label="option.label"
+              :value="option.value"
+            />
+          </el-select>
+        </el-form-item>
+      </el-col>
+    </el-row>
+
+    <!-- 产品设备选择 - 设备相关条件的公共部分 -->
+    <el-row v-if="isDeviceCondition" :gutter="16">
+      <el-col :span="12">
+        <el-form-item label="产品" required>
+          <ProductSelector
+            :model-value="condition.productId"
+            @update:model-value="(value) => updateConditionField('productId', value)"
+            @change="handleProductChange"
+          />
+        </el-form-item>
+      </el-col>
+      <el-col :span="12">
+        <el-form-item label="设备" required>
+          <DeviceSelector
+            :model-value="condition.deviceId"
+            @update:model-value="(value) => updateConditionField('deviceId', value)"
+            :product-id="condition.productId"
+            @change="handleDeviceChange"
+          />
+        </el-form-item>
+      </el-col>
+    </el-row>
+
+    <!-- 设备状态条件配置 -->
+    <div
+      v-if="condition.type === IotRuleSceneTriggerConditionTypeEnum.DEVICE_STATUS"
+      class="flex flex-col gap-16px"
+    >
+      <!-- 状态和操作符选择 -->
+      <el-row :gutter="16">
+        <!-- 操作符选择 -->
+        <el-col :span="12">
+          <el-form-item label="操作符" required>
+            <el-select
+              :model-value="condition.operator"
+              @update:model-value="(value) => updateConditionField('operator', value)"
+              placeholder="请选择操作符"
+              class="w-full"
+            >
+              <el-option
+                v-for="option in statusOperatorOptions"
+                :key="option.value"
+                :label="option.label"
+                :value="option.value"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+
+        <!-- 状态选择 -->
+        <el-col :span="12">
+          <el-form-item label="设备状态" required>
+            <el-select
+              :model-value="condition.param"
+              @update:model-value="(value) => updateConditionField('param', value)"
+              placeholder="请选择设备状态"
+              class="w-full"
+            >
+              <el-option
+                v-for="option in deviceStatusOptions"
+                :key="option.value"
+                :label="option.label"
+                :value="option.value"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+      </el-row>
+    </div>
+
+    <!-- 设备属性条件配置 -->
+    <div
+      v-else-if="condition.type === IotRuleSceneTriggerConditionTypeEnum.DEVICE_PROPERTY"
+      class="space-y-16px"
+    >
+      <!-- 属性配置 -->
+      <el-row :gutter="16">
+        <!-- 属性/事件/服务选择 -->
+        <el-col :span="6">
+          <el-form-item label="监控项" required>
+            <PropertySelector
+              :model-value="condition.identifier"
+              @update:model-value="(value) => updateConditionField('identifier', value)"
+              :trigger-type="triggerType"
+              :product-id="condition.productId"
+              :device-id="condition.deviceId"
+              @change="handlePropertyChange"
+            />
+          </el-form-item>
+        </el-col>
+
+        <!-- 操作符选择 -->
+        <el-col :span="6">
+          <el-form-item label="操作符" required>
+            <OperatorSelector
+              :model-value="condition.operator"
+              @update:model-value="(value) => updateConditionField('operator', value)"
+              :property-type="propertyType"
+              @change="handleOperatorChange"
+            />
+          </el-form-item>
+        </el-col>
+
+        <!-- 值输入 -->
+        <el-col :span="12">
+          <el-form-item label="比较值" required>
+            <ValueInput
+              :model-value="condition.param"
+              @update:model-value="(value) => updateConditionField('param', value)"
+              :property-type="propertyType"
+              :operator="condition.operator"
+              :property-config="propertyConfig"
+            />
+          </el-form-item>
+        </el-col>
+      </el-row>
+    </div>
+
+    <!-- 当前时间条件配置 -->
+    <CurrentTimeConditionConfig
+      v-else-if="condition.type === IotRuleSceneTriggerConditionTypeEnum.CURRENT_TIME"
+      :model-value="condition"
+      @update:model-value="updateCondition"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useVModel } from '@vueuse/core'
+import CurrentTimeConditionConfig from './CurrentTimeConditionConfig.vue'
+import ProductSelector from '../selectors/ProductSelector.vue'
+import DeviceSelector from '../selectors/DeviceSelector.vue'
+import PropertySelector from '../selectors/PropertySelector.vue'
+import OperatorSelector from '../selectors/OperatorSelector.vue'
+import ValueInput from '../inputs/ValueInput.vue'
+import type { TriggerCondition } from '@/api/iot/rule/scene'
+import {
+  IotRuleSceneTriggerConditionTypeEnum,
+  IotRuleSceneTriggerConditionParameterOperatorEnum,
+  getConditionTypeOptions,
+  IoTDeviceStatusEnum
+} from '@/views/iot/utils/constants'
+
+/** 单个条件配置组件 */
+defineOptions({ name: 'ConditionConfig' })
+
+const props = defineProps<{
+  modelValue: TriggerCondition
+  triggerType: number
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: TriggerCondition): void
+}>()
+
+/** 获取设备状态选项 */
+const deviceStatusOptions = [
+  {
+    value: IoTDeviceStatusEnum.ONLINE.value,
+    label: IoTDeviceStatusEnum.ONLINE.label
+  },
+  {
+    value: IoTDeviceStatusEnum.OFFLINE.value,
+    label: IoTDeviceStatusEnum.OFFLINE.label
+  }
+]
+
+/** 获取状态操作符选项 */
+const statusOperatorOptions = [
+  {
+    value: IotRuleSceneTriggerConditionParameterOperatorEnum.EQUALS.value,
+    label: IotRuleSceneTriggerConditionParameterOperatorEnum.EQUALS.name
+  },
+  {
+    value: IotRuleSceneTriggerConditionParameterOperatorEnum.NOT_EQUALS.value,
+    label: IotRuleSceneTriggerConditionParameterOperatorEnum.NOT_EQUALS.name
+  }
+]
+
+const condition = useVModel(props, 'modelValue', emit)
+
+const propertyType = ref<string>('string') // 属性类型
+const propertyConfig = ref<any>(null) // 属性配置
+const isDeviceCondition = computed(() => {
+  return (
+    condition.value.type === IotRuleSceneTriggerConditionTypeEnum.DEVICE_STATUS ||
+    condition.value.type === IotRuleSceneTriggerConditionTypeEnum.DEVICE_PROPERTY
+  )
+}) // 计算属性:判断是否为设备相关条件
+
+/**
+ * 更新条件字段
+ * @param field 字段名
+ * @param value 字段值
+ */
+const updateConditionField = (field: any, value: any) => {
+  ;(condition.value as any)[field] = value
+  emit('update:modelValue', condition.value)
+}
+
+/**
+ * 更新整个条件对象
+ * @param newCondition 新的条件对象
+ */
+const updateCondition = (newCondition: TriggerCondition) => {
+  condition.value = newCondition
+  emit('update:modelValue', condition.value)
+}
+
+/**
+ * 处理条件类型变化事件
+ * @param type 条件类型
+ */
+const handleConditionTypeChange = (type: number) => {
+  // 根据条件类型清理字段
+  const isCurrentTime = type === IotRuleSceneTriggerConditionTypeEnum.CURRENT_TIME
+  const isDeviceStatus = type === IotRuleSceneTriggerConditionTypeEnum.DEVICE_STATUS
+
+  // 清理标识符字段(时间条件和设备状态条件都不需要)
+  if (isCurrentTime || isDeviceStatus) {
+    condition.value.identifier = undefined
+  }
+
+  // 清理设备相关字段(仅时间条件需要)
+  if (isCurrentTime) {
+    condition.value.productId = undefined
+    condition.value.deviceId = undefined
+  }
+
+  // 设置默认操作符
+  condition.value.operator = isCurrentTime
+    ? 'at_time'
+    : IotRuleSceneTriggerConditionParameterOperatorEnum.EQUALS.value
+
+  // 清空参数值
+  condition.value.param = ''
+}
+
+/** 处理产品变化事件 */
+const handleProductChange = (_: number) => {
+  // 产品变化时清空设备和属性
+  condition.value.deviceId = undefined
+  condition.value.identifier = ''
+}
+
+/** 处理设备变化事件 */
+const handleDeviceChange = (_: number) => {
+  // 设备变化时清空属性
+  condition.value.identifier = ''
+}
+
+/**
+ * 处理属性变化事件
+ * @param propertyInfo 属性信息对象
+ */
+const handlePropertyChange = (propertyInfo: { type: string; config: any }) => {
+  propertyType.value = propertyInfo.type
+  propertyConfig.value = propertyInfo.config
+
+  // 重置操作符和值
+  condition.value.operator = IotRuleSceneTriggerConditionParameterOperatorEnum.EQUALS.value
+  condition.value.param = ''
+}
+
+/** 处理操作符变化事件 */
+const handleOperatorChange = () => {
+  // 重置值
+  condition.value.param = ''
+}
+</script>
+
+<style scoped>
+:deep(.el-form-item) {
+  margin-bottom: 0;
+}
+</style>

+ 234 - 0
src/views/iot/rule/scene/form/configs/CurrentTimeConditionConfig.vue

@@ -0,0 +1,234 @@
+<!-- 当前时间条件配置组件 -->
+<template>
+  <div class="flex flex-col gap-16px">
+    <el-row :gutter="16">
+      <!-- 时间操作符选择 -->
+      <el-col :span="8">
+        <el-form-item label="时间条件" required>
+          <el-select
+            :model-value="condition.operator"
+            @update:model-value="(value) => updateConditionField('operator', value)"
+            placeholder="请选择时间条件"
+            class="w-full"
+          >
+            <el-option
+              v-for="option in timeOperatorOptions"
+              :key="option.value"
+              :label="option.label"
+              :value="option.value"
+            >
+              <div class="flex items-center justify-between w-full">
+                <div class="flex items-center gap-8px">
+                  <Icon :icon="option.icon" :class="option.iconClass" />
+                  <span>{{ option.label }}</span>
+                </div>
+                <el-tag :type="option.tag as any" size="small">{{ option.category }}</el-tag>
+              </div>
+            </el-option>
+          </el-select>
+        </el-form-item>
+      </el-col>
+
+      <!-- 时间值输入 -->
+      <el-col :span="8">
+        <el-form-item label="时间值" required>
+          <el-time-picker
+            v-if="needsTimeInput"
+            :model-value="timeValue"
+            @update:model-value="handleTimeValueChange"
+            placeholder="请选择时间"
+            format="HH:mm:ss"
+            value-format="HH:mm:ss"
+            class="w-full"
+          />
+          <el-date-picker
+            v-else-if="needsDateInput"
+            :model-value="timeValue"
+            @update:model-value="handleTimeValueChange"
+            type="datetime"
+            placeholder="请选择日期时间"
+            format="YYYY-MM-DD HH:mm:ss"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            class="w-full"
+          />
+          <div v-else class="text-[var(--el-text-color-placeholder)] text-14px">
+            无需设置时间值
+          </div>
+        </el-form-item>
+      </el-col>
+
+      <!-- 第二个时间值(范围条件) -->
+      <el-col :span="8" v-if="needsSecondTimeInput">
+        <el-form-item label="结束时间" required>
+          <el-time-picker
+            v-if="needsTimeInput"
+            :model-value="timeValue2"
+            @update:model-value="handleTimeValue2Change"
+            placeholder="请选择结束时间"
+            format="HH:mm:ss"
+            value-format="HH:mm:ss"
+            class="w-full"
+          />
+          <el-date-picker
+            v-else
+            :model-value="timeValue2"
+            @update:model-value="handleTimeValue2Change"
+            type="datetime"
+            placeholder="请选择结束日期时间"
+            format="YYYY-MM-DD HH:mm:ss"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            class="w-full"
+          />
+        </el-form-item>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useVModel } from '@vueuse/core'
+import { IotRuleSceneTriggerTimeOperatorEnum } from '@/views/iot/utils/constants'
+import type { TriggerCondition } from '@/api/iot/rule/scene'
+
+/** 当前时间条件配置组件 */
+defineOptions({ name: 'CurrentTimeConditionConfig' })
+
+const props = defineProps<{
+  modelValue: TriggerCondition
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: TriggerCondition): void
+}>()
+
+const condition = useVModel(props, 'modelValue', emit)
+
+// 时间操作符选项
+const timeOperatorOptions = [
+  {
+    value: IotRuleSceneTriggerTimeOperatorEnum.BEFORE_TIME.value,
+    label: IotRuleSceneTriggerTimeOperatorEnum.BEFORE_TIME.name,
+    icon: 'ep:arrow-left',
+    iconClass: 'text-blue-500',
+    tag: 'primary',
+    category: '时间点'
+  },
+  {
+    value: IotRuleSceneTriggerTimeOperatorEnum.AFTER_TIME.value,
+    label: IotRuleSceneTriggerTimeOperatorEnum.AFTER_TIME.name,
+    icon: 'ep:arrow-right',
+    iconClass: 'text-green-500',
+    tag: 'success',
+    category: '时间点'
+  },
+  {
+    value: IotRuleSceneTriggerTimeOperatorEnum.BETWEEN_TIME.value,
+    label: IotRuleSceneTriggerTimeOperatorEnum.BETWEEN_TIME.name,
+    icon: 'ep:sort',
+    iconClass: 'text-orange-500',
+    tag: 'warning',
+    category: '时间段'
+  },
+  {
+    value: IotRuleSceneTriggerTimeOperatorEnum.AT_TIME.value,
+    label: IotRuleSceneTriggerTimeOperatorEnum.AT_TIME.name,
+    icon: 'ep:position',
+    iconClass: 'text-purple-500',
+    tag: 'info',
+    category: '时间点'
+  },
+  {
+    value: IotRuleSceneTriggerTimeOperatorEnum.TODAY.value,
+    label: IotRuleSceneTriggerTimeOperatorEnum.TODAY.name,
+    icon: 'ep:calendar',
+    iconClass: 'text-red-500',
+    tag: 'danger',
+    category: '日期'
+  }
+]
+
+// 计算属性:是否需要时间输入
+const needsTimeInput = computed(() => {
+  const timeOnlyOperators = [
+    IotRuleSceneTriggerTimeOperatorEnum.BEFORE_TIME.value,
+    IotRuleSceneTriggerTimeOperatorEnum.AFTER_TIME.value,
+    IotRuleSceneTriggerTimeOperatorEnum.BETWEEN_TIME.value,
+    IotRuleSceneTriggerTimeOperatorEnum.AT_TIME.value
+  ]
+  return timeOnlyOperators.includes(condition.value.operator as any)
+})
+
+// 计算属性:是否需要日期输入
+const needsDateInput = computed(() => {
+  return false // 暂时不支持日期输入,只支持时间
+})
+
+// 计算属性:是否需要第二个时间输入
+const needsSecondTimeInput = computed(() => {
+  return condition.value.operator === IotRuleSceneTriggerTimeOperatorEnum.BETWEEN_TIME.value
+})
+
+// 计算属性:从 param 中解析时间值
+const timeValue = computed(() => {
+  if (!condition.value.param) return ''
+  const params = condition.value.param.split(',')
+  return params[0] || ''
+})
+
+// 计算属性:从 param 中解析第二个时间值
+const timeValue2 = computed(() => {
+  if (!condition.value.param) return ''
+  const params = condition.value.param.split(',')
+  return params[1] || ''
+})
+
+/**
+ * 更新条件字段
+ * @param field 字段名
+ * @param value 字段值
+ */
+const updateConditionField = (field: any, value: any) => {
+  condition.value[field] = value
+}
+
+/**
+ * 处理第一个时间值变化
+ * @param value 时间值
+ */
+const handleTimeValueChange = (value: string) => {
+  const currentParams = condition.value.param ? condition.value.param.split(',') : []
+  currentParams[0] = value || ''
+
+  // 如果是范围条件,保留第二个值;否则只保留第一个值
+  if (needsSecondTimeInput.value) {
+    condition.value.param = currentParams.slice(0, 2).join(',')
+  } else {
+    condition.value.param = currentParams[0]
+  }
+}
+
+/**
+ * 处理第二个时间值变化
+ * @param value 时间值
+ */
+const handleTimeValue2Change = (value: string) => {
+  const currentParams = condition.value.param ? condition.value.param.split(',') : ['']
+  currentParams[1] = value || ''
+  condition.value.param = currentParams.slice(0, 2).join(',')
+}
+
+/** 监听操作符变化,清理不相关的时间值 */
+watch(
+  () => condition.value.operator,
+  (newOperator) => {
+    if (newOperator === IotRuleSceneTriggerTimeOperatorEnum.TODAY.value) {
+      // 今日条件不需要时间参数
+      condition.value.param = ''
+    } else if (!needsSecondTimeInput.value) {
+      // 非范围条件只保留第一个时间值
+      const currentParams = condition.value.param ? condition.value.param.split(',') : []
+      condition.value.param = currentParams[0] || ''
+    }
+  }
+)
+</script>

+ 376 - 0
src/views/iot/rule/scene/form/configs/DeviceControlConfig.vue

@@ -0,0 +1,376 @@
+<!-- 设备控制配置组件 -->
+<template>
+  <div class="flex flex-col gap-16px">
+    <!-- 产品和设备选择 - 与触发器保持一致的分离式选择器 -->
+    <el-row :gutter="16">
+      <el-col :span="12">
+        <el-form-item label="产品" required>
+          <ProductSelector v-model="action.productId" @change="handleProductChange" />
+        </el-form-item>
+      </el-col>
+      <el-col :span="12">
+        <el-form-item label="设备" required>
+          <DeviceSelector
+            v-model="action.deviceId"
+            :product-id="action.productId"
+            @change="handleDeviceChange"
+          />
+        </el-form-item>
+      </el-col>
+    </el-row>
+
+    <!-- 服务选择 - 服务调用类型时显示 -->
+    <div v-if="action.productId && isServiceInvokeAction" class="space-y-16px">
+      <el-form-item label="服务" required>
+        <el-select
+          v-model="action.identifier"
+          placeholder="请选择服务"
+          filterable
+          clearable
+          class="w-full"
+          :loading="loadingServices"
+          @change="handleServiceChange"
+        >
+          <el-option
+            v-for="service in serviceList"
+            :key="service.identifier"
+            :label="service.name"
+            :value="service.identifier"
+          >
+            <div class="flex items-center justify-between">
+              <span>{{ service.name }}</span>
+              <el-tag :type="service.callType === 'sync' ? 'primary' : 'success'" size="small">
+                {{ service.callType === 'sync' ? '同步' : '异步' }}
+              </el-tag>
+            </div>
+          </el-option>
+        </el-select>
+      </el-form-item>
+
+      <!-- 服务参数配置 -->
+      <div v-if="action.identifier" class="space-y-16px">
+        <el-form-item label="服务参数" required>
+          <JsonParamsInput
+            v-model="paramsValue"
+            type="service"
+            :config="{ service: selectedService } as any"
+            placeholder="请输入 JSON 格式的服务参数"
+          />
+        </el-form-item>
+      </div>
+    </div>
+
+    <!-- 控制参数配置 - 属性设置类型时显示 -->
+    <div v-if="action.productId && isPropertySetAction" class="space-y-16px">
+      <!-- 参数配置 -->
+      <el-form-item label="参数" required>
+        <JsonParamsInput
+          v-model="paramsValue"
+          type="property"
+          :config="{ properties: thingModelProperties }"
+          placeholder="请输入 JSON 格式的控制参数"
+        />
+      </el-form-item>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useVModel } from '@vueuse/core'
+import ProductSelector from '../selectors/ProductSelector.vue'
+import DeviceSelector from '../selectors/DeviceSelector.vue'
+import JsonParamsInput from '../inputs/JsonParamsInput.vue'
+import type { Action } from '@/api/iot/rule/scene'
+import type { ThingModelProperty, ThingModelService } from '@/api/iot/thingmodel'
+import {
+  IotRuleSceneActionTypeEnum,
+  IoTThingModelAccessModeEnum,
+  IoTDataSpecsDataTypeEnum
+} from '@/views/iot/utils/constants'
+import { ThingModelApi } from '@/api/iot/thingmodel'
+
+/** 设备控制配置组件 */
+defineOptions({ name: 'DeviceControlConfig' })
+
+const props = defineProps<{
+  modelValue: Action
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: Action): void
+}>()
+
+const action = useVModel(props, 'modelValue', emit)
+
+const thingModelProperties = ref<ThingModelProperty[]>([]) // 物模型属性列表
+const loadingThingModel = ref(false) // 物模型加载状态
+const selectedService = ref<ThingModelService | null>(null) // 选中的服务对象
+const serviceList = ref<ThingModelService[]>([]) // 服务列表
+const loadingServices = ref(false) // 服务加载状态
+
+// 参数值的计算属性,用于双向绑定
+const paramsValue = computed({
+  get: () => {
+    // 如果 params 是对象,转换为 JSON 字符串(兼容旧数据)
+    if (action.value.params && typeof action.value.params === 'object') {
+      return JSON.stringify(action.value.params, null, 2)
+    }
+    // 如果 params 已经是字符串,直接返回
+    return action.value.params || ''
+  },
+  set: (value: string) => {
+    // 直接保存为 JSON 字符串,不进行解析转换
+    action.value.params = value.trim() || ''
+  }
+})
+
+// 计算属性:是否为属性设置类型
+const isPropertySetAction = computed(() => {
+  return action.value.type === IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET
+})
+
+// 计算属性:是否为服务调用类型
+const isServiceInvokeAction = computed(() => {
+  return action.value.type === IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE
+})
+
+/**
+ * 处理产品变化事件
+ * @param productId 产品 ID
+ */
+const handleProductChange = (productId?: number) => {
+  // 当产品变化时,清空设备选择和参数配置
+  if (action.value.productId !== productId) {
+    action.value.deviceId = undefined
+    action.value.identifier = undefined // 清空服务标识符
+    action.value.params = '' // 清空参数,保存为空字符串
+    selectedService.value = null // 清空选中的服务
+    serviceList.value = [] // 清空服务列表
+  }
+
+  // 加载新产品的物模型属性或服务列表
+  if (productId) {
+    if (isPropertySetAction.value) {
+      loadThingModelProperties(productId)
+    } else if (isServiceInvokeAction.value) {
+      loadServiceList(productId)
+    }
+  }
+}
+
+/**
+ * 处理设备变化事件
+ * @param deviceId 设备 ID
+ */
+const handleDeviceChange = (deviceId?: number) => {
+  // 当设备变化时,清空参数配置
+  if (action.value.deviceId !== deviceId) {
+    action.value.params = '' // 清空参数,保存为空字符串
+  }
+}
+
+/**
+ * 处理服务变化事件
+ * @param serviceIdentifier 服务标识符
+ */
+const handleServiceChange = (serviceIdentifier?: string) => {
+  // 根据服务标识符找到对应的服务对象
+  const service = serviceList.value.find((s) => s.identifier === serviceIdentifier) || null
+  selectedService.value = service
+
+  // 当服务变化时,清空参数配置
+  action.value.params = ''
+
+  // 如果选择了服务且有输入参数,生成默认参数结构
+  if (service && service.inputParams && service.inputParams.length > 0) {
+    const defaultParams = {}
+    service.inputParams.forEach((param) => {
+      defaultParams[param.identifier] = getDefaultValueForParam(param)
+    })
+    // 将默认参数转换为 JSON 字符串保存
+    action.value.params = JSON.stringify(defaultParams, null, 2)
+  }
+}
+
+/**
+ * 获取物模型TSL数据
+ * @param productId 产品ID
+ * @returns 物模型TSL数据
+ */
+const getThingModelTSL = async (productId: number) => {
+  if (!productId) return null
+
+  try {
+    return await ThingModelApi.getThingModelTSLByProductId(productId)
+  } catch (error) {
+    console.error('获取物模型TSL数据失败:', error)
+    return null
+  }
+}
+
+/**
+ * 加载物模型属性(可写属性)
+ * @param productId 产品ID
+ */
+const loadThingModelProperties = async (productId: number) => {
+  if (!productId) {
+    thingModelProperties.value = []
+    return
+  }
+
+  try {
+    loadingThingModel.value = true
+    const tslData = await getThingModelTSL(productId)
+
+    if (!tslData?.properties) {
+      thingModelProperties.value = []
+      return
+    }
+
+    // 过滤出可写的属性(accessMode 包含 'w')
+    thingModelProperties.value = tslData.properties.filter(
+      (property: ThingModelProperty) =>
+        property.accessMode &&
+        (property.accessMode === IoTThingModelAccessModeEnum.READ_WRITE.value ||
+          property.accessMode === IoTThingModelAccessModeEnum.WRITE_ONLY.value)
+    )
+  } catch (error) {
+    console.error('加载物模型属性失败:', error)
+    thingModelProperties.value = []
+  } finally {
+    loadingThingModel.value = false
+  }
+}
+
+/**
+ * 加载服务列表
+ * @param productId 产品ID
+ */
+const loadServiceList = async (productId: number) => {
+  if (!productId) {
+    serviceList.value = []
+    return
+  }
+
+  try {
+    loadingServices.value = true
+    const tslData = await getThingModelTSL(productId)
+
+    if (!tslData?.services) {
+      serviceList.value = []
+      return
+    }
+
+    serviceList.value = tslData.services
+  } catch (error) {
+    console.error('加载服务列表失败:', error)
+    serviceList.value = []
+  } finally {
+    loadingServices.value = false
+  }
+}
+
+/**
+ * 从TSL加载服务信息(用于编辑模式回显)
+ * @param productId 产品ID
+ * @param serviceIdentifier 服务标识符
+ */
+const loadServiceFromTSL = async (productId: number, serviceIdentifier: string) => {
+  // 先加载服务列表
+  await loadServiceList(productId)
+
+  // 然后设置选中的服务
+  const service = serviceList.value.find((s: any) => s.identifier === serviceIdentifier)
+  if (service) {
+    selectedService.value = service
+  }
+}
+
+/**
+ * 根据参数类型获取默认值
+ * @param param 参数对象
+ * @returns 默认值
+ */
+const getDefaultValueForParam = (param: any) => {
+  switch (param.dataType) {
+    case IoTDataSpecsDataTypeEnum.INT:
+      return 0
+    case IoTDataSpecsDataTypeEnum.FLOAT:
+    case IoTDataSpecsDataTypeEnum.DOUBLE:
+      return 0.0
+    case IoTDataSpecsDataTypeEnum.BOOL:
+      return false
+    case IoTDataSpecsDataTypeEnum.TEXT:
+      return ''
+    case IoTDataSpecsDataTypeEnum.ENUM:
+      // 如果有枚举值,使用第一个
+      if (param.dataSpecs?.dataSpecsList && param.dataSpecs.dataSpecsList.length > 0) {
+        return param.dataSpecs.dataSpecsList[0].value
+      }
+      return ''
+    default:
+      return ''
+  }
+}
+
+const isInitialized = ref(false) // 防止重复初始化的标志
+
+/**
+ * 初始化组件数据
+ */
+const initializeComponent = async () => {
+  if (isInitialized.value) return
+
+  const currentAction = action.value
+  if (!currentAction) return
+
+  // 如果已经选择了产品且是属性设置类型,加载物模型
+  if (currentAction.productId && isPropertySetAction.value) {
+    await loadThingModelProperties(currentAction.productId)
+  }
+
+  // 如果是服务调用类型且已有标识符,初始化服务选择
+  if (currentAction.productId && isServiceInvokeAction.value && currentAction.identifier) {
+    // 加载物模型TSL以获取服务信息
+    await loadServiceFromTSL(currentAction.productId, currentAction.identifier)
+  }
+
+  isInitialized.value = true
+}
+
+/** 组件初始化 */
+onMounted(() => {
+  initializeComponent()
+})
+
+/** 监听关键字段的变化,避免深度监听导致的性能问题 */
+watch(
+  () => [action.value.productId, action.value.type, action.value.identifier],
+  async ([newProductId, , newIdentifier], [oldProductId, , oldIdentifier]) => {
+    // 避免初始化时的重复调用
+    if (!isInitialized.value) return
+
+    // 产品变化时重新加载数据
+    if (newProductId !== oldProductId) {
+      if (newProductId && isPropertySetAction.value) {
+        await loadThingModelProperties(newProductId as number)
+      } else if (newProductId && isServiceInvokeAction.value) {
+        await loadServiceList(newProductId as number)
+      }
+    }
+
+    // 服务标识符变化时更新选中的服务
+    if (
+      newIdentifier !== oldIdentifier &&
+      newProductId &&
+      isServiceInvokeAction.value &&
+      newIdentifier
+    ) {
+      const service = serviceList.value.find((s: any) => s.identifier === newIdentifier)
+      if (service) {
+        selectedService.value = service
+      }
+    }
+  }
+)
+</script>

+ 251 - 0
src/views/iot/rule/scene/form/configs/DeviceTriggerConfig.vue

@@ -0,0 +1,251 @@
+<!-- 设备触发配置组件 -->
+<template>
+  <div class="flex flex-col gap-16px">
+    <!-- 主条件配置 - 默认直接展示 -->
+    <div class="space-y-16px">
+      <!-- 主条件配置 -->
+      <div class="flex flex-col gap-16px">
+        <!-- 主条件配置 -->
+        <div class="space-y-16px">
+          <!-- 主条件头部 - 与附加条件组保持一致的绿色风格 -->
+          <div
+            class="flex items-center justify-between p-16px bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-8px"
+          >
+            <div class="flex items-center gap-12px">
+              <div class="flex items-center gap-8px text-16px font-600 text-green-700">
+                <div
+                  class="w-24px h-24px bg-green-500 text-white rounded-full flex items-center justify-center text-12px font-bold"
+                >
+                  主
+                </div>
+                <span>主条件</span>
+              </div>
+              <el-tag size="small" type="success">必须满足</el-tag>
+            </div>
+          </div>
+
+          <!-- 主条件内容配置 -->
+          <MainConditionInnerConfig
+            :model-value="trigger"
+            @update:model-value="updateCondition"
+            :trigger-type="trigger.type"
+            @trigger-type-change="handleTriggerTypeChange"
+          />
+        </div>
+      </div>
+    </div>
+
+    <!-- 条件组配置 -->
+    <div class="space-y-16px">
+      <!-- 条件组配置 -->
+      <div class="flex flex-col gap-16px">
+        <!-- 条件组容器头部 -->
+        <div
+          class="flex items-center justify-between p-16px bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-8px"
+        >
+          <div class="flex items-center gap-12px">
+            <div class="flex items-center gap-8px text-16px font-600 text-green-700">
+              <div
+                class="w-24px h-24px bg-green-500 text-white rounded-full flex items-center justify-center text-12px font-bold"
+              >
+                组
+              </div>
+              <span>附加条件组</span>
+            </div>
+            <el-tag size="small" type="success">与"主条件"为且关系</el-tag>
+            <el-tag size="small" type="info">
+              {{ trigger.conditionGroups?.length || 0 }} 个子条件组
+            </el-tag>
+          </div>
+          <div class="flex items-center gap-8px">
+            <el-button
+              type="primary"
+              size="small"
+              @click="addSubGroup"
+              :disabled="(trigger.conditionGroups?.length || 0) >= maxSubGroups"
+            >
+              <Icon icon="ep:plus" />
+              添加子条件组
+            </el-button>
+            <el-button type="danger" size="small" text @click="removeConditionGroup">
+              <Icon icon="ep:delete" />
+              删除条件组
+            </el-button>
+          </div>
+        </div>
+
+        <!-- 子条件组列表 -->
+        <div
+          v-if="trigger.conditionGroups && trigger.conditionGroups.length > 0"
+          class="space-y-16px"
+        >
+          <!-- 逻辑关系说明 -->
+          <div class="relative">
+            <div
+              v-for="(subGroup, subGroupIndex) in trigger.conditionGroups"
+              :key="`sub-group-${subGroupIndex}`"
+              class="relative"
+            >
+              <!-- 子条件组容器 -->
+              <div
+                class="border-2 border-orange-200 rounded-8px bg-orange-50 shadow-sm hover:shadow-md transition-shadow"
+              >
+                <div
+                  class="flex items-center justify-between p-16px bg-gradient-to-r from-orange-50 to-yellow-50 border-b border-orange-200 rounded-t-6px"
+                >
+                  <div class="flex items-center gap-12px">
+                    <div class="flex items-center gap-8px text-16px font-600 text-orange-700">
+                      <div
+                        class="w-24px h-24px bg-orange-500 text-white rounded-full flex items-center justify-center text-12px font-bold"
+                      >
+                        {{ subGroupIndex + 1 }}
+                      </div>
+                      <span>子条件组 {{ subGroupIndex + 1 }}</span>
+                    </div>
+                    <el-tag size="small" type="warning" class="font-500">组内条件为"且"关系</el-tag>
+                    <el-tag size="small" type="info"> {{ subGroup?.length || 0 }}个条件 </el-tag>
+                  </div>
+                  <el-button
+                    type="danger"
+                    size="small"
+                    text
+                    @click="removeSubGroup(subGroupIndex)"
+                    class="hover:bg-red-50"
+                  >
+                    <Icon icon="ep:delete" />
+                    删除组
+                  </el-button>
+                </div>
+
+                <SubConditionGroupConfig
+                  :model-value="subGroup"
+                  @update:model-value="(value) => updateSubGroup(subGroupIndex, value)"
+                  :trigger-type="trigger.type"
+                  :max-conditions="maxConditionsPerGroup"
+                />
+              </div>
+
+              <!-- 子条件组间的"或"连接符 -->
+              <div
+                v-if="subGroupIndex < trigger.conditionGroups!.length - 1"
+                class="flex items-center justify-center py-12px"
+              >
+                <div class="flex items-center gap-8px">
+                  <!-- 连接线 -->
+                  <div class="w-32px h-1px bg-orange-300"></div>
+                  <!-- 或标签 -->
+                  <div class="px-16px py-6px bg-orange-100 border-2 border-orange-300 rounded-full">
+                    <span class="text-14px font-600 text-orange-600">或</span>
+                  </div>
+                  <!-- 连接线 -->
+                  <div class="w-32px h-1px bg-orange-300"></div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <!-- 空状态 -->
+        <div
+          v-else
+          class="p-24px border-2 border-dashed border-orange-200 rounded-8px text-center bg-orange-50"
+        >
+          <div class="flex flex-col items-center gap-12px">
+            <Icon icon="ep:plus" class="text-32px text-orange-400" />
+            <div class="text-orange-600">
+              <p class="text-14px font-500 mb-4px">暂无子条件组</p>
+              <p class="text-12px">点击上方"添加子条件组"按钮开始配置</p>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useVModel } from '@vueuse/core'
+
+import MainConditionInnerConfig from './MainConditionInnerConfig.vue'
+import SubConditionGroupConfig from './SubConditionGroupConfig.vue'
+import type { Trigger } from '@/api/iot/rule/scene'
+
+/** 设备触发配置组件 */
+defineOptions({ name: 'DeviceTriggerConfig' })
+
+const props = defineProps<{
+  modelValue: Trigger
+  index: number
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: Trigger): void
+  (e: 'trigger-type-change', type: number): void
+}>()
+
+const trigger = useVModel(props, 'modelValue', emit)
+
+const maxSubGroups = 3 // 最多 3 个子条件组
+const maxConditionsPerGroup = 3 // 每组最多 3 个条件
+
+/**
+ * 更新条件
+ * @param condition 条件对象
+ */
+const updateCondition = (condition: Trigger) => {
+  trigger.value = condition
+}
+
+/**
+ * 处理触发器类型变化事件
+ * @param type 触发器类型
+ */
+const handleTriggerTypeChange = (type: number) => {
+  trigger.value.type = type
+  emit('trigger-type-change', type)
+}
+
+/** 添加子条件组 */
+const addSubGroup = async () => {
+  if (!trigger.value.conditionGroups) {
+    trigger.value.conditionGroups = []
+  }
+
+  // 检查是否达到最大子组数量限制
+  if (trigger.value.conditionGroups?.length >= maxSubGroups) {
+    return
+  }
+
+  // 使用 nextTick 确保响应式更新完成后再添加新的子组
+  await nextTick()
+  if (trigger.value.conditionGroups) {
+    trigger.value.conditionGroups.push([])
+  }
+}
+
+/**
+ * 移除子条件组
+ * @param index 子条件组索引
+ */
+const removeSubGroup = (index: number) => {
+  if (trigger.value.conditionGroups) {
+    trigger.value.conditionGroups.splice(index, 1)
+  }
+}
+
+/**
+ * 更新子条件组
+ * @param index 子条件组索引
+ * @param subGroup 子条件组数据
+ */
+const updateSubGroup = (index: number, subGroup: any) => {
+  if (trigger.value.conditionGroups) {
+    trigger.value.conditionGroups[index] = subGroup
+  }
+}
+
+/** 移除整个条件组 */
+const removeConditionGroup = () => {
+  trigger.value.conditionGroups = undefined
+}
+</script>

+ 333 - 0
src/views/iot/rule/scene/form/configs/MainConditionInnerConfig.vue

@@ -0,0 +1,333 @@
+<template>
+  <div class="space-y-16px">
+    <!-- 触发事件类型选择 -->
+    <el-form-item label="触发事件类型" required>
+      <el-select
+        :model-value="triggerType"
+        @update:model-value="handleTriggerTypeChange"
+        placeholder="请选择触发事件类型"
+        class="w-full"
+      >
+        <el-option
+          v-for="option in triggerTypeOptions"
+          :key="option.value"
+          :label="option.label"
+          :value="option.value"
+        />
+      </el-select>
+    </el-form-item>
+
+    <!-- 设备属性条件配置 -->
+    <div v-if="isDevicePropertyTrigger" class="space-y-16px">
+      <!-- 产品设备选择 -->
+      <el-row :gutter="16">
+        <el-col :span="12">
+          <el-form-item label="产品" required>
+            <ProductSelector
+              :model-value="condition.productId"
+              @update:model-value="(value) => updateConditionField('productId', value)"
+              @change="handleProductChange"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="设备" required>
+            <DeviceSelector
+              :model-value="condition.deviceId"
+              @update:model-value="(value) => updateConditionField('deviceId', value)"
+              :product-id="condition.productId"
+              @change="handleDeviceChange"
+            />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <!-- 属性配置 -->
+      <el-row :gutter="16">
+        <!-- 属性/事件/服务选择 -->
+        <el-col :span="6">
+          <el-form-item label="监控项" required>
+            <PropertySelector
+              :model-value="condition.identifier"
+              @update:model-value="(value) => updateConditionField('identifier', value)"
+              :trigger-type="triggerType"
+              :product-id="condition.productId"
+              :device-id="condition.deviceId"
+              @change="handlePropertyChange"
+            />
+          </el-form-item>
+        </el-col>
+
+        <!-- 操作符选择 - 服务调用和事件上报不需要操作符 -->
+        <el-col v-if="needsOperatorSelector" :span="6">
+          <el-form-item label="操作符" required>
+            <OperatorSelector
+              :model-value="condition.operator"
+              @update:model-value="(value) => updateConditionField('operator', value)"
+              :property-type="propertyType"
+            />
+          </el-form-item>
+        </el-col>
+
+        <!-- 值输入 -->
+        <el-col :span="isWideValueColumn ? 18 : 12">
+          <el-form-item :label="valueInputLabel" required>
+            <!-- 服务调用参数配置 -->
+            <JsonParamsInput
+              v-if="triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE"
+              v-model="condition.value"
+              type="service"
+              :config="serviceConfig"
+              placeholder="请输入 JSON 格式的服务参数"
+            />
+            <!-- 事件上报比较值:标量填裸值;结构体/数组填 JSON 整体相等;留空则事件发生即匹配 -->
+            <template v-else-if="triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST">
+              <el-input
+                :model-value="condition.value"
+                @update:model-value="(value) => updateConditionField('value', value)"
+                placeholder="留空则事件发生即匹配"
+              />
+              <div class="text-12px text-[var(--el-text-color-secondary)] mt-4px leading-relaxed">
+                标量事件值填裸值(如
+                <code class="px-2px">normal</code>);结构体/数组事件值填合法
+                JSON(如
+                <code class="px-2px">{"level":"high"}</code>)
+              </div>
+            </template>
+            <!-- 普通值输入 -->
+            <ValueInput
+              v-else
+              :model-value="condition.value"
+              @update:model-value="(value) => updateConditionField('value', value)"
+              :property-type="propertyType"
+              :operator="condition.operator"
+              :property-config="propertyConfig"
+            />
+          </el-form-item>
+        </el-col>
+      </el-row>
+    </div>
+
+    <!-- 设备状态条件配置 -->
+    <div v-else-if="isDeviceStatusTrigger" class="space-y-16px">
+      <!-- 设备状态触发器使用简化的配置 -->
+      <el-row :gutter="16">
+        <el-col :span="12">
+          <el-form-item label="产品" required>
+            <ProductSelector
+              :model-value="condition.productId"
+              @update:model-value="(value) => updateConditionField('productId', value)"
+              @change="handleProductChange"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="设备" required>
+            <DeviceSelector
+              :model-value="condition.deviceId"
+              @update:model-value="(value) => updateConditionField('deviceId', value)"
+              :product-id="condition.productId"
+              @change="handleDeviceChange"
+            />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row :gutter="16">
+        <el-col :span="6">
+          <el-form-item label="操作符" required>
+            <el-select
+              :model-value="condition.operator"
+              @update:model-value="(value) => updateConditionField('operator', value)"
+              placeholder="请选择操作符"
+              class="w-full"
+            >
+              <el-option
+                :label="IotRuleSceneTriggerConditionParameterOperatorEnum.EQUALS.name"
+                :value="IotRuleSceneTriggerConditionParameterOperatorEnum.EQUALS.value"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+        <el-col :span="6">
+          <el-form-item label="参数" required>
+            <el-select
+              :model-value="condition.value"
+              @update:model-value="(value) => updateConditionField('value', value)"
+              placeholder="请选择操作符"
+              class="w-full"
+            >
+              <el-option
+                v-for="option in deviceStatusChangeOptions"
+                :key="option.value"
+                :label="option.label"
+                :value="option.value"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+      </el-row>
+    </div>
+
+    <!-- 其他触发类型的提示 -->
+    <div v-else class="text-center py-20px">
+      <p class="text-14px text-[var(--el-text-color-secondary)] mb-4px">
+        当前触发事件类型:{{ getTriggerTypeLabel(triggerType) }}
+      </p>
+      <p class="text-12px text-[var(--el-text-color-placeholder)]">
+        此触发类型暂不需要配置额外条件
+      </p>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import ProductSelector from '../selectors/ProductSelector.vue'
+import DeviceSelector from '../selectors/DeviceSelector.vue'
+import PropertySelector from '../selectors/PropertySelector.vue'
+import OperatorSelector from '../selectors/OperatorSelector.vue'
+import ValueInput from '../inputs/ValueInput.vue'
+import JsonParamsInput from '../inputs/JsonParamsInput.vue'
+
+import type { Trigger } from '@/api/iot/rule/scene'
+import {
+  IotRuleSceneTriggerTypeEnum,
+  triggerTypeOptions,
+  getTriggerTypeLabel,
+  IotRuleSceneTriggerConditionParameterOperatorEnum,
+  IoTDeviceStatusEnum
+} from '@/views/iot/utils/constants'
+import { useVModel } from '@vueuse/core'
+
+/** 主条件内部配置组件 */
+defineOptions({ name: 'MainConditionInnerConfig' })
+
+const props = defineProps<{
+  modelValue: Trigger
+  triggerType: number
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: Trigger): void
+  (e: 'trigger-type-change', value: number): void
+}>()
+
+/** 获取设备状态变更选项(用于触发器配置) */
+const deviceStatusChangeOptions = [
+  {
+    label: IoTDeviceStatusEnum.ONLINE.label,
+    value: IoTDeviceStatusEnum.ONLINE.value
+  },
+  {
+    label: IoTDeviceStatusEnum.OFFLINE.label,
+    value: IoTDeviceStatusEnum.OFFLINE.value
+  }
+]
+
+const condition = useVModel(props, 'modelValue', emit)
+const propertyType = ref('') // 属性类型
+const propertyConfig = ref<any>(null) // 属性配置
+
+// 计算属性:是否为设备属性触发器
+const isDevicePropertyTrigger = computed(() => {
+  return (
+    props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST ||
+    props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST ||
+    props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE
+  )
+})
+
+// 计算属性:是否为设备状态触发器
+const isDeviceStatusTrigger = computed(() => {
+  return props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_STATE_UPDATE
+})
+
+// 计算属性:是否需要操作符选择(服务调用和事件上报不需要操作符)
+const needsOperatorSelector = computed(() => {
+  const noOperatorTriggerTypes = [
+    IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE,
+    IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST
+  ] as number[]
+  return !noOperatorTriggerTypes.includes(props.triggerType)
+})
+
+// 计算属性:是否需要宽列布局(服务调用和事件上报不需要操作符列,所以值输入列更宽)
+const isWideValueColumn = computed(() => {
+  const wideColumnTriggerTypes = [
+    IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE,
+    IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST
+  ] as number[]
+  return wideColumnTriggerTypes.includes(props.triggerType)
+})
+
+// 计算属性:值输入字段的标签文本
+const valueInputLabel = computed(() => {
+  return props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE
+    ? '服务参数'
+    : '比较值'
+})
+
+// 计算属性:服务配置 - 用于 JsonParamsInput
+const serviceConfig = computed(() => {
+  if (
+    propertyConfig.value &&
+    props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE
+  ) {
+    return {
+      service: {
+        name: propertyConfig.value.name || '服务',
+        inputParams: propertyConfig.value.inputParams || []
+      }
+    }
+  }
+  return undefined
+})
+
+/**
+ * 更新条件字段
+ * @param field 字段名
+ * @param value 字段值
+ */
+const updateConditionField = (field: any, value: any) => {
+  condition.value[field] = value
+}
+
+/**
+ * 处理触发器类型变化事件
+ * @param type 触发器类型
+ */
+const handleTriggerTypeChange = (type: number) => {
+  emit('trigger-type-change', type)
+}
+
+/** 处理产品变化事件 */
+const handleProductChange = () => {
+  // 产品变化时清空设备和属性
+  condition.value.deviceId = undefined
+  condition.value.identifier = ''
+}
+
+/** 处理设备变化事件 */
+const handleDeviceChange = () => {
+  // 设备变化时清空属性
+  condition.value.identifier = ''
+}
+
+/**
+ * 处理属性变化事件
+ * @param propertyInfo 属性信息对象
+ */
+const handlePropertyChange = (propertyInfo: any) => {
+  if (propertyInfo) {
+    propertyType.value = propertyInfo.type
+    propertyConfig.value = propertyInfo.config
+
+    // 对于事件上报和服务调用,自动设置操作符为 '='
+    if (
+      props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST ||
+      props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE
+    ) {
+      condition.value.operator = IotRuleSceneTriggerConditionParameterOperatorEnum.EQUALS.value
+    }
+  }
+}
+</script>

+ 156 - 0
src/views/iot/rule/scene/form/configs/SubConditionGroupConfig.vue

@@ -0,0 +1,156 @@
+<template>
+  <div class="p-16px">
+    <!-- 空状态 -->
+    <div v-if="!subGroup || subGroup.length === 0" class="text-center py-24px">
+      <div class="flex flex-col items-center gap-12px">
+        <Icon icon="ep:plus" class="text-32px text-[var(--el-text-color-placeholder)]" />
+        <div class="text-[var(--el-text-color-secondary)]">
+          <p class="text-14px font-500 mb-4px">暂无条件</p>
+          <p class="text-12px">点击下方按钮添加第一个条件</p>
+        </div>
+        <el-button type="primary" @click="addCondition">
+          <Icon icon="ep:plus" />
+          添加条件
+        </el-button>
+      </div>
+    </div>
+
+    <!-- 条件列表 -->
+    <div v-else class="space-y-16px">
+      <div
+        v-for="(condition, conditionIndex) in subGroup"
+        :key="`condition-${conditionIndex}`"
+        class="relative"
+      >
+        <!-- 条件配置 -->
+        <div
+          class="border border-[var(--el-border-color-lighter)] rounded-6px bg-[var(--el-fill-color-blank)] shadow-sm"
+        >
+          <div
+            class="flex items-center justify-between p-12px bg-[var(--el-fill-color-light)] border-b border-[var(--el-border-color-lighter)] rounded-t-4px"
+          >
+            <div class="flex items-center gap-8px">
+              <div
+                class="w-20px h-20px bg-blue-500 text-white rounded-full flex items-center justify-center text-10px font-bold"
+              >
+                {{ conditionIndex + 1 }}
+              </div>
+              <span class="text-12px font-500 text-[var(--el-text-color-primary)]"
+                >条件 {{ conditionIndex + 1 }}</span
+              >
+            </div>
+            <el-button
+              type="danger"
+              size="small"
+              text
+              @click="removeCondition(conditionIndex)"
+              v-if="subGroup!.length > 1"
+              class="hover:bg-red-50"
+            >
+              <Icon icon="ep:delete" />
+            </el-button>
+          </div>
+
+          <div class="p-12px">
+            <ConditionConfig
+              :model-value="condition"
+              @update:model-value="(value) => updateCondition(conditionIndex, value)"
+              :trigger-type="triggerType"
+            />
+          </div>
+        </div>
+      </div>
+
+      <!-- 添加条件按钮 -->
+      <div
+        v-if="subGroup && subGroup.length > 0 && subGroup.length < maxConditions"
+        class="text-center py-16px"
+      >
+        <el-button type="primary" plain @click="addCondition">
+          <Icon icon="ep:plus" />
+          继续添加条件
+        </el-button>
+        <span class="block mt-8px text-12px text-[var(--el-text-color-secondary)]">
+          最多可添加 {{ maxConditions }} 个条件
+        </span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { nextTick } from 'vue'
+import { useVModel } from '@vueuse/core'
+import ConditionConfig from './ConditionConfig.vue'
+import type { TriggerCondition } from '@/api/iot/rule/scene'
+import {
+  IotRuleSceneTriggerConditionTypeEnum,
+  IotRuleSceneTriggerConditionParameterOperatorEnum
+} from '@/views/iot/utils/constants'
+
+/** 子条件组配置组件 */
+defineOptions({ name: 'SubConditionGroupConfig' })
+
+const props = defineProps<{
+  modelValue: TriggerCondition[]
+  triggerType: number
+  maxConditions?: number
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: TriggerCondition[]): void
+}>()
+
+const subGroup = useVModel(props, 'modelValue', emit)
+
+const maxConditions = computed(() => props.maxConditions || 3) // 最大条件数量
+
+/** 添加条件 */
+const addCondition = async () => {
+  // 确保 subGroup.value 是一个数组
+  if (!subGroup.value) {
+    subGroup.value = []
+  }
+
+  // 检查是否达到最大条件数量限制
+  if (subGroup.value?.length >= maxConditions.value) {
+    return
+  }
+
+  const newCondition: TriggerCondition = {
+    type: IotRuleSceneTriggerConditionTypeEnum.DEVICE_PROPERTY, // 默认为设备属性
+    productId: undefined,
+    deviceId: undefined,
+    identifier: '',
+    operator: IotRuleSceneTriggerConditionParameterOperatorEnum.EQUALS.value, // 使用枚举默认值
+    param: ''
+  }
+
+  // 使用 nextTick 确保响应式更新完成后再添加新条件
+  await nextTick()
+  if (subGroup.value) {
+    subGroup.value.push(newCondition)
+  }
+}
+
+/**
+ * 移除条件
+ * @param index 条件索引
+ */
+const removeCondition = (index: number) => {
+  if (subGroup.value) {
+    subGroup.value.splice(index, 1)
+  }
+}
+
+/**
+ * 更新条件
+ * @param index 条件索引
+ * @param condition 条件对象
+ */
+const updateCondition = (index: number, condition: TriggerCondition) => {
+  if (subGroup.value) {
+    subGroup.value[index] = condition
+  }
+}
+</script>

+ 163 - 0
src/views/iot/rule/scene/form/configs/TimerConditionGroupConfig.vue

@@ -0,0 +1,163 @@
+<!-- 定时触发器条件组配置组件 -->
+<template>
+  <div class="space-y-16px">
+    <!-- 条件组容器头部 -->
+    <div
+      class="flex items-center justify-between p-16px bg-gradient-to-r from-blue-50 to-cyan-50 border border-blue-200 rounded-8px"
+    >
+      <div class="flex items-center gap-12px">
+        <div class="flex items-center gap-8px text-16px font-600 text-blue-700">
+          <div
+            class="w-24px h-24px bg-blue-500 text-white rounded-full flex items-center justify-center text-12px font-bold"
+          >
+            组
+          </div>
+          <span>附加条件组</span>
+        </div>
+        <el-tag size="small" type="info">定时触发时需满足以下条件</el-tag>
+        <el-tag size="small" type="warning"> {{ conditionGroups?.length || 0 }} 个子条件组 </el-tag>
+      </div>
+      <el-button
+        type="primary"
+        size="small"
+        @click="addConditionGroup"
+        :disabled="(conditionGroups?.length || 0) >= maxGroups"
+      >
+        <Icon icon="ep:plus" />
+        添加条件组
+      </el-button>
+    </div>
+
+    <!-- 条件组列表 -->
+    <div v-if="conditionGroups && conditionGroups.length > 0" class="space-y-16px">
+      <div
+        v-for="(group, groupIndex) in conditionGroups"
+        :key="`group-${groupIndex}`"
+        class="relative"
+      >
+        <!-- 条件组容器 -->
+        <div
+          class="border-2 border-orange-200 rounded-8px bg-orange-50 shadow-sm hover:shadow-md transition-shadow"
+        >
+          <div
+            class="flex items-center justify-between p-16px bg-gradient-to-r from-orange-50 to-yellow-50 border-b border-orange-200 rounded-t-6px"
+          >
+            <div class="flex items-center gap-12px">
+              <div class="flex items-center gap-8px text-16px font-600 text-orange-700">
+                <div
+                  class="w-24px h-24px bg-orange-500 text-white rounded-full flex items-center justify-center text-12px font-bold"
+                >
+                  {{ groupIndex + 1 }}
+                </div>
+                <span>子条件组 {{ groupIndex + 1 }}</span>
+              </div>
+              <el-tag size="small" type="warning" class="font-500">组内条件为"且"关系</el-tag>
+              <el-tag size="small" type="info"> {{ group?.length || 0 }}个条件 </el-tag>
+            </div>
+            <el-button
+              type="danger"
+              size="small"
+              text
+              @click="removeConditionGroup(groupIndex)"
+              class="hover:bg-red-50"
+            >
+              <Icon icon="ep:delete" />
+              删除组
+            </el-button>
+          </div>
+
+          <SubConditionGroupConfig
+            :model-value="group"
+            @update:model-value="(value) => updateConditionGroup(groupIndex, value)"
+            :trigger-type="IotRuleSceneTriggerTypeEnum.TIMER"
+            :max-conditions="maxConditionsPerGroup"
+          />
+        </div>
+
+        <!-- 条件组间的"或"连接符 -->
+        <div
+          v-if="groupIndex < conditionGroups.length - 1"
+          class="flex items-center justify-center py-12px"
+        >
+          <div class="flex items-center gap-8px">
+            <!-- 连接线 -->
+            <div class="w-32px h-1px bg-orange-300"></div>
+            <!-- 或标签 -->
+            <div class="px-16px py-6px bg-orange-100 border-2 border-orange-300 rounded-full">
+              <span class="text-14px font-600 text-orange-600">或</span>
+            </div>
+            <!-- 连接线 -->
+            <div class="w-32px h-1px bg-orange-300"></div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 空状态 -->
+    <div
+      v-else
+      class="p-24px border-2 border-dashed border-blue-200 rounded-8px text-center bg-blue-50"
+    >
+      <div class="flex flex-col items-center gap-12px">
+        <Icon icon="ep:plus" class="text-32px text-blue-400" />
+        <div class="text-blue-600">
+          <p class="text-14px font-500 mb-4px">暂无附加条件</p>
+          <p class="text-12px">定时触发时将直接执行动作</p>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useVModel } from '@vueuse/core'
+import SubConditionGroupConfig from './SubConditionGroupConfig.vue'
+import type { TriggerCondition } from '@/api/iot/rule/scene'
+import { IotRuleSceneTriggerTypeEnum } from '@/views/iot/utils/constants'
+
+/** 定时触发器条件组配置组件 */
+defineOptions({ name: 'TimerConditionGroupConfig' })
+
+const props = defineProps<{
+  modelValue?: TriggerCondition[][]
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: TriggerCondition[][]): void
+}>()
+
+const conditionGroups = useVModel(props, 'modelValue', emit)
+
+const maxGroups = 3 // 最多 3 个条件组
+const maxConditionsPerGroup = 3 // 每组最多 3 个条件
+
+/** 添加条件组 */
+const addConditionGroup = async () => {
+  if (!conditionGroups.value) {
+    conditionGroups.value = []
+  }
+  // 检查是否达到最大条件组数量限制
+  if (conditionGroups.value.length >= maxGroups) {
+    return
+  }
+  // 使用 nextTick 确保响应式更新完成后再添加新的条件组
+  await nextTick()
+  if (conditionGroups.value) {
+    conditionGroups.value.push([])
+  }
+}
+
+/** 移除条件组 */
+const removeConditionGroup = (index: number) => {
+  if (conditionGroups.value) {
+    conditionGroups.value.splice(index, 1)
+  }
+}
+
+/** 更新条件组 */
+const updateConditionGroup = (index: number, group: TriggerCondition[]) => {
+  if (conditionGroups.value) {
+    conditionGroups.value[index] = group
+  }
+}
+</script>

+ 519 - 0
src/views/iot/rule/scene/form/inputs/JsonParamsInput.vue

@@ -0,0 +1,519 @@
+<!-- JSON参数输入组件 - 通用版本 -->
+<template>
+  <!-- 参数配置 -->
+  <div class="w-full space-y-12px">
+    <!-- JSON 输入框 -->
+    <div class="relative">
+      <el-input
+        v-model="paramsJson"
+        type="textarea"
+        :rows="4"
+        :placeholder="placeholder"
+        @input="handleParamsChange"
+        :class="{ 'is-error': jsonError }"
+      />
+      <!-- 查看详细示例弹出层 -->
+      <div class="absolute top-8px right-8px">
+        <el-popover
+          placement="left-start"
+          :width="450"
+          trigger="click"
+          :show-arrow="true"
+          :offset="8"
+          popper-class="json-params-detail-popover"
+        >
+          <template #reference>
+            <el-button
+              type="info"
+              :icon="InfoFilled"
+              circle
+              size="small"
+              :title="JSON_PARAMS_INPUT_CONSTANTS.VIEW_EXAMPLE_TITLE"
+            />
+          </template>
+
+          <!-- 弹出层内容 -->
+          <div class="json-params-detail-content">
+            <div class="flex items-center gap-8px mb-16px">
+              <Icon :icon="titleIcon" class="text-[var(--el-color-primary)] text-18px" />
+              <span class="text-16px font-600 text-[var(--el-text-color-primary)]">
+                {{ title }}
+              </span>
+            </div>
+
+            <div class="space-y-16px">
+              <!-- 参数列表 -->
+              <div v-if="paramsList.length > 0">
+                <div class="flex items-center gap-8px mb-8px">
+                  <Icon :icon="paramsIcon" class="text-[var(--el-color-primary)] text-14px" />
+                  <span class="text-14px font-500 text-[var(--el-text-color-primary)]">
+                    {{ paramsLabel }}
+                  </span>
+                </div>
+                <div class="ml-22px space-y-8px">
+                  <div
+                    v-for="param in paramsList"
+                    :key="param.identifier"
+                    class="flex items-center justify-between p-8px bg-[var(--el-fill-color-lighter)] rounded-4px"
+                  >
+                    <div class="flex-1">
+                      <div class="text-12px font-500 text-[var(--el-text-color-primary)]">
+                        {{ param.name }}
+                        <el-tag v-if="param.required" size="small" type="danger" class="ml-4px">
+                          {{ JSON_PARAMS_INPUT_CONSTANTS.REQUIRED_TAG }}
+                        </el-tag>
+                      </div>
+                      <div class="text-11px text-[var(--el-text-color-secondary)]">
+                        {{ param.identifier }}
+                      </div>
+                    </div>
+                    <div class="flex items-center gap-8px">
+                      <el-tag :type="getParamTypeTag(param.dataType)" size="small">
+                        {{ getParamTypeName(param.dataType) }}
+                      </el-tag>
+                      <span class="text-11px text-[var(--el-text-color-secondary)]">
+                        {{ getExampleValue(param) }}
+                      </span>
+                    </div>
+                  </div>
+                </div>
+
+                <div class="mt-12px ml-22px">
+                  <div class="text-12px text-[var(--el-text-color-secondary)] mb-6px">
+                    {{ JSON_PARAMS_INPUT_CONSTANTS.COMPLETE_JSON_FORMAT }}
+                  </div>
+                  <pre
+                    class="p-12px bg-[var(--el-fill-color-light)] rounded-4px text-11px text-[var(--el-text-color-primary)] overflow-x-auto border-l-3px border-[var(--el-color-primary)]"
+                  >
+                      <code>{{ generateExampleJson() }}</code>
+                    </pre>
+                </div>
+              </div>
+
+              <!-- 无参数提示 -->
+              <div v-else>
+                <div class="text-center py-16px">
+                  <p class="text-14px text-[var(--el-text-color-secondary)]">{{ emptyMessage }}</p>
+                </div>
+              </div>
+            </div>
+          </div>
+        </el-popover>
+      </div>
+    </div>
+
+    <!-- 验证状态和错误提示 -->
+    <div class="flex items-center justify-between">
+      <div class="flex items-center gap-8px">
+        <Icon
+          :icon="
+            jsonError
+              ? JSON_PARAMS_INPUT_ICONS.STATUS_ICONS.ERROR
+              : JSON_PARAMS_INPUT_ICONS.STATUS_ICONS.SUCCESS
+          "
+          :class="jsonError ? 'text-[var(--el-color-danger)]' : 'text-[var(--el-color-success)]'"
+          class="text-14px"
+        />
+        <span
+          :class="jsonError ? 'text-[var(--el-color-danger)]' : 'text-[var(--el-color-success)]'"
+          class="text-12px"
+        >
+          {{ jsonError || JSON_PARAMS_INPUT_CONSTANTS.JSON_FORMAT_CORRECT }}
+        </span>
+      </div>
+
+      <!-- 快速填充按钮 -->
+      <div v-if="paramsList.length > 0" class="flex items-center gap-8px">
+        <span class="text-12px text-[var(--el-text-color-secondary)]">{{
+          JSON_PARAMS_INPUT_CONSTANTS.QUICK_FILL_LABEL
+        }}</span>
+        <el-button size="small" type="primary" plain @click="fillExampleJson">
+          {{ JSON_PARAMS_INPUT_CONSTANTS.EXAMPLE_DATA_BUTTON }}
+        </el-button>
+        <el-button size="small" type="danger" plain @click="clearParams">{{
+          JSON_PARAMS_INPUT_CONSTANTS.CLEAR_BUTTON
+        }}</el-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useVModel } from '@vueuse/core'
+import { InfoFilled } from '@element-plus/icons-vue'
+import {
+  IoTDataSpecsDataTypeEnum,
+  JSON_PARAMS_INPUT_CONSTANTS,
+  JSON_PARAMS_INPUT_ICONS,
+  JSON_PARAMS_EXAMPLE_VALUES,
+  JsonParamsInputTypeEnum,
+  type JsonParamsInputType
+} from '@/views/iot/utils/constants'
+
+/** JSON参数输入组件 - 通用版本 */
+defineOptions({ name: 'JsonParamsInput' })
+
+interface JsonParamsConfig {
+  // 服务配置
+  service?: {
+    name: string
+    inputParams?: any[]
+  }
+  // 事件配置
+  event?: {
+    name: string
+    outputParams?: any[]
+  }
+  // 属性配置
+  properties?: any[]
+  // 自定义配置
+  custom?: {
+    name: string
+    params: any[]
+  }
+}
+
+interface Props {
+  modelValue?: string
+  config?: JsonParamsConfig
+  type?: JsonParamsInputType
+  placeholder?: string
+}
+
+interface Emits {
+  (e: 'update:modelValue', value: string): void
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  type: JsonParamsInputTypeEnum.SERVICE,
+  placeholder: JSON_PARAMS_INPUT_CONSTANTS.PLACEHOLDER
+})
+
+const emit = defineEmits<Emits>()
+
+const localValue = useVModel(props, 'modelValue', emit, {
+  defaultValue: ''
+})
+
+const paramsJson = ref('') // JSON参数字符串
+const jsonError = ref('') // JSON验证错误信息
+
+// 计算属性:参数列表
+const paramsList = computed(() => {
+  switch (props.type) {
+    case JsonParamsInputTypeEnum.SERVICE:
+      return props.config?.service?.inputParams || []
+    case JsonParamsInputTypeEnum.EVENT:
+      return props.config?.event?.outputParams || []
+    case JsonParamsInputTypeEnum.PROPERTY:
+      return props.config?.properties || []
+    case JsonParamsInputTypeEnum.CUSTOM:
+      return props.config?.custom?.params || []
+    default:
+      return []
+  }
+})
+
+// 计算属性:标题
+const title = computed(() => {
+  switch (props.type) {
+    case JsonParamsInputTypeEnum.SERVICE:
+      return JSON_PARAMS_INPUT_CONSTANTS.TITLES.SERVICE(props.config?.service?.name)
+    case JsonParamsInputTypeEnum.EVENT:
+      return JSON_PARAMS_INPUT_CONSTANTS.TITLES.EVENT(props.config?.event?.name)
+    case JsonParamsInputTypeEnum.PROPERTY:
+      return JSON_PARAMS_INPUT_CONSTANTS.TITLES.PROPERTY
+    case JsonParamsInputTypeEnum.CUSTOM:
+      return JSON_PARAMS_INPUT_CONSTANTS.TITLES.CUSTOM(props.config?.custom?.name)
+    default:
+      return JSON_PARAMS_INPUT_CONSTANTS.TITLES.DEFAULT
+  }
+})
+
+// 计算属性:标题图标
+const titleIcon = computed(() => {
+  switch (props.type) {
+    case JsonParamsInputTypeEnum.SERVICE:
+      return JSON_PARAMS_INPUT_ICONS.TITLE_ICONS.SERVICE
+    case JsonParamsInputTypeEnum.EVENT:
+      return JSON_PARAMS_INPUT_ICONS.TITLE_ICONS.EVENT
+    case JsonParamsInputTypeEnum.PROPERTY:
+      return JSON_PARAMS_INPUT_ICONS.TITLE_ICONS.PROPERTY
+    case JsonParamsInputTypeEnum.CUSTOM:
+      return JSON_PARAMS_INPUT_ICONS.TITLE_ICONS.CUSTOM
+    default:
+      return JSON_PARAMS_INPUT_ICONS.TITLE_ICONS.DEFAULT
+  }
+})
+
+// 计算属性:参数图标
+const paramsIcon = computed(() => {
+  switch (props.type) {
+    case JsonParamsInputTypeEnum.SERVICE:
+      return JSON_PARAMS_INPUT_ICONS.PARAMS_ICONS.SERVICE
+    case JsonParamsInputTypeEnum.EVENT:
+      return JSON_PARAMS_INPUT_ICONS.PARAMS_ICONS.EVENT
+    case JsonParamsInputTypeEnum.PROPERTY:
+      return JSON_PARAMS_INPUT_ICONS.PARAMS_ICONS.PROPERTY
+    case JsonParamsInputTypeEnum.CUSTOM:
+      return JSON_PARAMS_INPUT_ICONS.PARAMS_ICONS.CUSTOM
+    default:
+      return JSON_PARAMS_INPUT_ICONS.PARAMS_ICONS.DEFAULT
+  }
+})
+
+// 计算属性:参数标签
+const paramsLabel = computed(() => {
+  switch (props.type) {
+    case JsonParamsInputTypeEnum.SERVICE:
+      return JSON_PARAMS_INPUT_CONSTANTS.PARAMS_LABELS.SERVICE
+    case JsonParamsInputTypeEnum.EVENT:
+      return JSON_PARAMS_INPUT_CONSTANTS.PARAMS_LABELS.EVENT
+    case JsonParamsInputTypeEnum.PROPERTY:
+      return JSON_PARAMS_INPUT_CONSTANTS.PARAMS_LABELS.PROPERTY
+    case JsonParamsInputTypeEnum.CUSTOM:
+      return JSON_PARAMS_INPUT_CONSTANTS.PARAMS_LABELS.CUSTOM
+    default:
+      return JSON_PARAMS_INPUT_CONSTANTS.PARAMS_LABELS.DEFAULT
+  }
+})
+
+// 计算属性:空状态消息
+const emptyMessage = computed(() => {
+  switch (props.type) {
+    case JsonParamsInputTypeEnum.SERVICE:
+      return JSON_PARAMS_INPUT_CONSTANTS.EMPTY_MESSAGES.SERVICE
+    case JsonParamsInputTypeEnum.EVENT:
+      return JSON_PARAMS_INPUT_CONSTANTS.EMPTY_MESSAGES.EVENT
+    case JsonParamsInputTypeEnum.PROPERTY:
+      return JSON_PARAMS_INPUT_CONSTANTS.EMPTY_MESSAGES.PROPERTY
+    case JsonParamsInputTypeEnum.CUSTOM:
+      return JSON_PARAMS_INPUT_CONSTANTS.EMPTY_MESSAGES.CUSTOM
+    default:
+      return JSON_PARAMS_INPUT_CONSTANTS.EMPTY_MESSAGES.DEFAULT
+  }
+})
+
+// 计算属性:无配置消息
+const noConfigMessage = computed(() => {
+  switch (props.type) {
+    case JsonParamsInputTypeEnum.SERVICE:
+      return JSON_PARAMS_INPUT_CONSTANTS.NO_CONFIG_MESSAGES.SERVICE
+    case JsonParamsInputTypeEnum.EVENT:
+      return JSON_PARAMS_INPUT_CONSTANTS.NO_CONFIG_MESSAGES.EVENT
+    case JsonParamsInputTypeEnum.PROPERTY:
+      return JSON_PARAMS_INPUT_CONSTANTS.NO_CONFIG_MESSAGES.PROPERTY
+    case JsonParamsInputTypeEnum.CUSTOM:
+      return JSON_PARAMS_INPUT_CONSTANTS.NO_CONFIG_MESSAGES.CUSTOM
+    default:
+      return JSON_PARAMS_INPUT_CONSTANTS.NO_CONFIG_MESSAGES.DEFAULT
+  }
+})
+
+/**
+ * 处理参数变化事件
+ */
+const handleParamsChange = () => {
+  try {
+    jsonError.value = '' // 清除之前的错误
+
+    if (paramsJson.value.trim()) {
+      const parsed = JSON.parse(paramsJson.value)
+      localValue.value = paramsJson.value
+
+      // 额外的参数验证
+      if (typeof parsed !== 'object' || parsed === null) {
+        jsonError.value = JSON_PARAMS_INPUT_CONSTANTS.PARAMS_MUST_BE_OBJECT
+        return
+      }
+
+      // 验证必填参数
+      for (const param of paramsList.value) {
+        if (param.required && (!parsed[param.identifier] || parsed[param.identifier] === '')) {
+          jsonError.value = JSON_PARAMS_INPUT_CONSTANTS.PARAM_REQUIRED_ERROR(param.name)
+          return
+        }
+      }
+    } else {
+      localValue.value = ''
+    }
+
+    // 验证通过
+    jsonError.value = ''
+  } catch (error) {
+    jsonError.value = JSON_PARAMS_INPUT_CONSTANTS.JSON_FORMAT_ERROR(
+      error instanceof Error ? error.message : JSON_PARAMS_INPUT_CONSTANTS.UNKNOWN_ERROR
+    )
+  }
+}
+
+/**
+ * 快速填充示例数据
+ */
+const fillExampleJson = () => {
+  paramsJson.value = generateExampleJson()
+  handleParamsChange()
+}
+
+/**
+ * 清空参数
+ */
+const clearParams = () => {
+  paramsJson.value = ''
+  localValue.value = ''
+  jsonError.value = ''
+}
+
+/**
+ * 获取参数类型名称
+ * @param dataType 数据类型
+ * @returns 类型名称
+ */
+const getParamTypeName = (dataType: string) => {
+  // 使用 constants.ts 中已有的 getDataTypeName 函数逻辑
+  const typeMap = {
+    [IoTDataSpecsDataTypeEnum.INT]: '整数',
+    [IoTDataSpecsDataTypeEnum.FLOAT]: '浮点数',
+    [IoTDataSpecsDataTypeEnum.DOUBLE]: '双精度',
+    [IoTDataSpecsDataTypeEnum.TEXT]: '字符串',
+    [IoTDataSpecsDataTypeEnum.BOOL]: '布尔值',
+    [IoTDataSpecsDataTypeEnum.ENUM]: '枚举',
+    [IoTDataSpecsDataTypeEnum.DATE]: '日期',
+    [IoTDataSpecsDataTypeEnum.STRUCT]: '结构体',
+    [IoTDataSpecsDataTypeEnum.ARRAY]: '数组'
+  }
+  return typeMap[dataType] || dataType
+}
+
+/**
+ * 获取参数类型标签样式
+ * @param dataType 数据类型
+ * @returns 标签样式
+ */
+const getParamTypeTag = (dataType: string) => {
+  const tagMap = {
+    [IoTDataSpecsDataTypeEnum.INT]: 'primary',
+    [IoTDataSpecsDataTypeEnum.FLOAT]: 'success',
+    [IoTDataSpecsDataTypeEnum.DOUBLE]: 'success',
+    [IoTDataSpecsDataTypeEnum.TEXT]: 'info',
+    [IoTDataSpecsDataTypeEnum.BOOL]: 'warning',
+    [IoTDataSpecsDataTypeEnum.ENUM]: 'danger',
+    [IoTDataSpecsDataTypeEnum.DATE]: 'primary',
+    [IoTDataSpecsDataTypeEnum.STRUCT]: 'info',
+    [IoTDataSpecsDataTypeEnum.ARRAY]: 'warning'
+  }
+  return tagMap[dataType] || 'info'
+}
+
+/**
+ * 获取示例值
+ * @param param 参数对象
+ * @returns 示例值
+ */
+const getExampleValue = (param: any) => {
+  const exampleConfig =
+    JSON_PARAMS_EXAMPLE_VALUES[param.dataType] || JSON_PARAMS_EXAMPLE_VALUES.DEFAULT
+  return exampleConfig.display
+}
+
+/**
+ * 生成示例JSON
+ * @returns JSON字符串
+ */
+const generateExampleJson = () => {
+  if (paramsList.value.length === 0) {
+    return '{}'
+  }
+
+  const example = {}
+  paramsList.value.forEach((param) => {
+    const exampleConfig =
+      JSON_PARAMS_EXAMPLE_VALUES[param.dataType] || JSON_PARAMS_EXAMPLE_VALUES.DEFAULT
+    example[param.identifier] = exampleConfig.value
+  })
+
+  return JSON.stringify(example, null, 2)
+}
+
+/**
+ * 处理数据回显
+ * @param value 值字符串
+ */
+const handleDataDisplay = (value: string) => {
+  if (!value || !value.trim()) {
+    paramsJson.value = ''
+    jsonError.value = ''
+    return
+  }
+
+  try {
+    // 尝试解析JSON,如果成功则格式化
+    const parsed = JSON.parse(value)
+    paramsJson.value = JSON.stringify(parsed, null, 2)
+    jsonError.value = ''
+  } catch {
+    // 如果不是有效的JSON,直接使用原字符串
+    paramsJson.value = value
+    jsonError.value = ''
+  }
+}
+
+// 监听外部值变化(编辑模式数据回显)
+watch(
+  () => localValue.value,
+  async (newValue, oldValue) => {
+    // 避免循环更新
+    if (newValue === oldValue) return
+
+    // 使用 nextTick 确保在下一个 tick 中处理数据
+    await nextTick()
+    handleDataDisplay(newValue || '')
+  },
+  { immediate: true }
+)
+
+// 组件挂载后也尝试处理一次数据回显
+onMounted(async () => {
+  await nextTick()
+  if (localValue.value) {
+    handleDataDisplay(localValue.value)
+  }
+})
+
+// 监听配置变化
+watch(
+  () => props.config,
+  (newConfig, oldConfig) => {
+    // 只有在配置真正变化时才清空数据
+    if (JSON.stringify(newConfig) !== JSON.stringify(oldConfig)) {
+      // 如果没有外部传入的值,才清空数据
+      if (!localValue.value) {
+        paramsJson.value = ''
+        jsonError.value = ''
+      }
+    }
+  }
+)
+</script>
+
+<style scoped>
+/* 弹出层内容样式 */
+.json-params-detail-content {
+  padding: 4px 0;
+}
+
+/* 弹出层自定义样式 */
+:global(.json-params-detail-popover) {
+  max-width: 500px !important;
+}
+
+:global(.json-params-detail-popover .el-popover__content) {
+  padding: 16px !important;
+}
+
+/* JSON 代码块样式 */
+.json-params-detail-content pre {
+  max-height: 200px;
+  overflow-y: auto;
+}
+</style>

+ 266 - 0
src/views/iot/rule/scene/form/inputs/ValueInput.vue

@@ -0,0 +1,266 @@
+<!-- 值输入组件 -->
+<template>
+  <div class="w-full min-w-0">
+    <!-- 布尔值选择 -->
+    <el-select
+      v-if="propertyType === IoTDataSpecsDataTypeEnum.BOOL"
+      v-model="localValue"
+      placeholder="请选择布尔值"
+      class="w-full!"
+    >
+      <el-option label="真 (true)" value="true" />
+      <el-option label="假 (false)" value="false" />
+    </el-select>
+
+    <!-- 枚举值选择 -->
+    <el-select
+      v-else-if="propertyType === IoTDataSpecsDataTypeEnum.ENUM && enumOptions.length > 0"
+      v-model="localValue"
+      placeholder="请选择枚举值"
+      class="w-full!"
+    >
+      <el-option
+        v-for="option in enumOptions"
+        :key="option.value"
+        :label="option.label"
+        :value="option.value"
+      />
+    </el-select>
+
+    <!-- 范围输入 (between 操作符) -->
+    <div
+      v-else-if="operator === IotRuleSceneTriggerConditionParameterOperatorEnum.BETWEEN.value"
+      class="w-full! flex items-center gap-8px"
+    >
+      <el-input
+        v-model="rangeStart"
+        :type="getInputType()"
+        placeholder="最小值"
+        @input="handleRangeChange"
+        class="flex-1 min-w-0"
+        style="width: auto !important"
+      />
+      <span class="text-12px text-[var(--el-text-color-secondary)] whitespace-nowrap">至</span>
+      <el-input
+        v-model="rangeEnd"
+        :type="getInputType()"
+        placeholder="最大值"
+        @input="handleRangeChange"
+        class="flex-1 min-w-0"
+      />
+    </div>
+
+    <!-- 列表输入 (in 操作符) -->
+    <div
+      v-else-if="operator === IotRuleSceneTriggerConditionParameterOperatorEnum.IN.value"
+      class="w-full!"
+    >
+      <el-input v-model="localValue" placeholder="请输入值列表,用逗号分隔" class="w-full!">
+        <template #suffix>
+          <el-tooltip content="多个值用逗号分隔,如:1,2,3" placement="top">
+            <Icon
+              icon="ep:question-filled"
+              class="text-[var(--el-text-color-placeholder)] cursor-help"
+            />
+          </el-tooltip>
+        </template>
+      </el-input>
+      <div v-if="listPreview.length > 0" class="mt-8px flex items-center gap-6px flex-wrap">
+        <span class="text-12px text-[var(--el-text-color-secondary)]">解析结果:</span>
+        <el-tag v-for="(item, index) in listPreview" :key="index" size="small" class="m-0">
+          {{ item }}
+        </el-tag>
+      </div>
+    </div>
+
+    <!-- 日期时间输入 -->
+    <el-date-picker
+      v-else-if="propertyType === IoTDataSpecsDataTypeEnum.DATE"
+      v-model="dateValue"
+      type="datetime"
+      placeholder="请选择日期时间"
+      format="YYYY-MM-DD HH:mm:ss"
+      value-format="YYYY-MM-DD HH:mm:ss"
+      @change="handleDateChange"
+      class="w-full!"
+    />
+
+    <!-- 数字输入 -->
+    <el-input-number
+      v-else-if="isNumericType()"
+      v-model="numberValue"
+      :precision="getPrecision()"
+      :step="getStep()"
+      :min="getMin()"
+      :max="getMax()"
+      placeholder="请输入数值"
+      @change="handleNumberChange"
+      class="w-full!"
+    />
+
+    <!-- 文本输入 -->
+    <el-input
+      v-else
+      v-model="localValue"
+      :type="getInputType()"
+      :placeholder="getPlaceholder()"
+      class="w-full!"
+    >
+      <template #suffix>
+        <el-tooltip
+          v-if="propertyConfig?.unit"
+          :content="`单位:${propertyConfig.unit}`"
+          placement="top"
+        >
+          <span class="text-12px text-[var(--el-text-color-secondary)] px-4px">
+            {{ propertyConfig.unit }}
+          </span>
+        </el-tooltip>
+      </template>
+    </el-input>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useVModel } from '@vueuse/core'
+import {
+  IoTDataSpecsDataTypeEnum,
+  IotRuleSceneTriggerConditionParameterOperatorEnum
+} from '@/views/iot/utils/constants'
+
+/** 值输入组件 */
+defineOptions({ name: 'ValueInput' })
+
+interface Props {
+  modelValue?: string
+  propertyType?: string
+  operator?: string
+  propertyConfig?: any
+}
+
+interface Emits {
+  (e: 'update:modelValue', value: string): void
+}
+
+const props = defineProps<Props>()
+const emit = defineEmits<Emits>()
+
+const localValue = useVModel(props, 'modelValue', emit, {
+  defaultValue: ''
+})
+
+const rangeStart = ref('') // 范围开始值
+const rangeEnd = ref('') // 范围结束值
+const dateValue = ref('') // 日期值
+const numberValue = ref<number>() // 数字值
+
+/** 计算属性:枚举选项 */
+const enumOptions = computed(() => {
+  if (props.propertyConfig?.enum) {
+    return props.propertyConfig.enum.map((item: any) => ({
+      label: item.name || item.label || item.value,
+      value: item.value
+    }))
+  }
+  return []
+})
+
+/** 计算属性:列表预览 */
+const listPreview = computed(() => {
+  if (
+    props.operator === IotRuleSceneTriggerConditionParameterOperatorEnum.IN.value &&
+    localValue.value
+  ) {
+    return localValue.value
+      .split(',')
+      .map((item) => item.trim())
+      .filter((item) => item)
+  }
+  return []
+})
+
+/** 判断是否为数字类型 */
+const isNumericType = () => {
+  return [
+    IoTDataSpecsDataTypeEnum.INT,
+    IoTDataSpecsDataTypeEnum.FLOAT,
+    IoTDataSpecsDataTypeEnum.DOUBLE
+  ].includes((props.propertyType || '') as any)
+}
+
+/** 获取输入框类型 */
+const getInputType = () => {
+  switch (props.propertyType) {
+    case IoTDataSpecsDataTypeEnum.INT:
+    case IoTDataSpecsDataTypeEnum.FLOAT:
+    case IoTDataSpecsDataTypeEnum.DOUBLE:
+      return 'number'
+    default:
+      return 'text'
+  }
+}
+
+/** 获取占位符文本 */
+const getPlaceholder = () => {
+  const typeMap = {
+    [IoTDataSpecsDataTypeEnum.TEXT]: '请输入字符串',
+    [IoTDataSpecsDataTypeEnum.INT]: '请输入整数',
+    [IoTDataSpecsDataTypeEnum.FLOAT]: '请输入浮点数',
+    [IoTDataSpecsDataTypeEnum.DOUBLE]: '请输入双精度数',
+    [IoTDataSpecsDataTypeEnum.STRUCT]: '请输入 JSON 格式数据',
+    [IoTDataSpecsDataTypeEnum.ARRAY]: '请输入数组格式数据'
+  }
+  return typeMap[props.propertyType || ''] || '请输入值'
+}
+
+/** 获取数字精度 */
+const getPrecision = () => {
+  return props.propertyType === IoTDataSpecsDataTypeEnum.INT ? 0 : 2
+}
+
+/** 获取数字步长 */
+const getStep = () => {
+  return props.propertyType === IoTDataSpecsDataTypeEnum.INT ? 1 : 0.1
+}
+
+/** 获取最小值 */
+const getMin = () => {
+  return props.propertyConfig?.min || undefined
+}
+
+/** 获取最大值 */
+const getMax = () => {
+  return props.propertyConfig?.max || undefined
+}
+
+/** 处理范围变化事件 */
+const handleRangeChange = () => {
+  if (rangeStart.value && rangeEnd.value) {
+    localValue.value = `${rangeStart.value},${rangeEnd.value}`
+  } else {
+    localValue.value = ''
+  }
+}
+
+/** 处理日期变化事件 */
+const handleDateChange = (value: string) => {
+  localValue.value = value || ''
+}
+
+/** 处理数字变化事件 */
+const handleNumberChange = (value: number | undefined) => {
+  localValue.value = value?.toString() || ''
+}
+
+/** 监听操作符变化 */
+watch(
+  () => props.operator,
+  () => {
+    localValue.value = ''
+    rangeStart.value = ''
+    rangeEnd.value = ''
+    dateValue.value = ''
+    numberValue.value = undefined
+  }
+)
+</script>

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff