Преглед на файлове

Merge branch 'master' of https://gitee.com/yudaocode/yudao-ui-admin-vue3 into feature/bpm

YunaiV преди 4 месеца
родител
ревизия
80ac4b0d7a
променени са 89 файла, в които са добавени 7578 реда и са изтрити 1343 реда
  1. 1 1
      .vscode/settings.json
  2. 2 0
      package.json
  3. 249 99
      pnpm-lock.yaml
  4. 169 0
      src/api/iot/device/device/index.ts
  5. 43 0
      src/api/iot/device/group/index.ts
  6. 0 74
      src/api/iot/device/index.ts
  7. 51 0
      src/api/iot/plugin/index.ts
  8. 43 0
      src/api/iot/product/category/index.ts
  9. 21 1
      src/api/iot/product/product/index.ts
  10. 127 0
      src/api/iot/rule/databridge/index.ts
  11. 41 0
      src/api/iot/statistics/index.ts
  12. 88 0
      src/api/iot/thingmodel/index.ts
  13. 0 55
      src/api/iot/thinkmodelfunction/index.ts
  14. 1 1
      src/api/mall/product/spu.ts
  15. BIN
      src/assets/imgs/iot/device.png
  16. 1 0
      src/assets/svgs/iot/card-fill.svg
  17. 1 0
      src/assets/svgs/iot/cube.svg
  18. 1 0
      src/components/Dialog/src/Dialog.vue
  19. 0 1
      src/components/SimpleProcessDesignerV2/src/consts.ts
  20. 1 1
      src/components/Table/src/Table.vue
  21. 16 5
      src/router/modules/remaining.ts
  22. 9 4
      src/utils/dict.ts
  23. 1 1
      src/utils/formCreate.ts
  24. 16 2
      src/utils/index.ts
  25. 3 0
      src/utils/routerHelper.ts
  26. 5 0
      src/views/ai/utils/constants.ts
  27. 0 156
      src/views/iot/device/DeviceForm.vue
  28. 0 123
      src/views/iot/device/detail/DeviceDetailsInfo.vue
  29. 0 66
      src/views/iot/device/detail/index.vue
  30. 263 0
      src/views/iot/device/device/DeviceForm.vue
  31. 90 0
      src/views/iot/device/device/DeviceGroupForm.vue
  32. 139 0
      src/views/iot/device/device/DeviceImportForm.vue
  33. 110 0
      src/views/iot/device/device/detail/DeviceDataDetail.vue
  34. 119 0
      src/views/iot/device/device/detail/DeviceDetailConfig.vue
  35. 16 23
      src/views/iot/device/device/detail/DeviceDetailsHeader.vue
  36. 144 0
      src/views/iot/device/device/detail/DeviceDetailsInfo.vue
  37. 166 0
      src/views/iot/device/device/detail/DeviceDetailsLog.vue
  38. 134 0
      src/views/iot/device/device/detail/DeviceDetailsModel.vue
  39. 331 0
      src/views/iot/device/device/detail/DeviceDetailsSimulator.vue
  40. 88 0
      src/views/iot/device/device/detail/index.vue
  41. 516 0
      src/views/iot/device/device/index.vue
  42. 112 0
      src/views/iot/device/group/DeviceGroupForm.vue
  43. 33 55
      src/views/iot/device/group/index.vue
  44. 0 267
      src/views/iot/device/index.vue
  45. 509 0
      src/views/iot/home/index.vue
  46. 106 0
      src/views/iot/plugin/PluginConfigForm.vue
  47. 99 0
      src/views/iot/plugin/detail/PluginImportForm.vue
  48. 120 0
      src/views/iot/plugin/detail/index.vue
  49. 329 0
      src/views/iot/plugin/index.vue
  50. 119 0
      src/views/iot/product/category/ProductCategoryForm.vue
  51. 170 0
      src/views/iot/product/category/index.vue
  52. 0 44
      src/views/iot/product/detail/ProductDetailsInfo.vue
  53. 0 229
      src/views/iot/product/detail/ThinkModelFunctionForm.vue
  54. 100 49
      src/views/iot/product/product/ProductForm.vue
  55. 18 11
      src/views/iot/product/product/detail/ProductDetailsHeader.vue
  56. 43 0
      src/views/iot/product/product/detail/ProductDetailsInfo.vue
  57. 22 18
      src/views/iot/product/product/detail/ProductTopic.vue
  58. 20 18
      src/views/iot/product/product/detail/index.vue
  59. 355 0
      src/views/iot/product/product/index.vue
  60. 207 0
      src/views/iot/rule/databridge/IoTDataBridgeForm.vue
  61. 84 0
      src/views/iot/rule/databridge/config/HttpConfigForm.vue
  62. 45 0
      src/views/iot/rule/databridge/config/KafkaMQConfigForm.vue
  63. 45 0
      src/views/iot/rule/databridge/config/MqttConfigForm.vue
  64. 63 0
      src/views/iot/rule/databridge/config/RabbitMQConfigForm.vue
  65. 58 0
      src/views/iot/rule/databridge/config/RedisStreamMQConfigForm.vue
  66. 57 0
      src/views/iot/rule/databridge/config/RocketMQConfigForm.vue
  67. 74 0
      src/views/iot/rule/databridge/config/components/KeyValueEditor.vue
  68. 15 0
      src/views/iot/rule/databridge/config/index.ts
  69. 234 0
      src/views/iot/rule/databridge/index.vue
  70. 56 0
      src/views/iot/thingmodel/ThingModelEvent.vue
  71. 215 0
      src/views/iot/thingmodel/ThingModelForm.vue
  72. 155 0
      src/views/iot/thingmodel/ThingModelInputOutputParam.vue
  73. 169 0
      src/views/iot/thingmodel/ThingModelProperty.vue
  74. 59 0
      src/views/iot/thingmodel/ThingModelService.vue
  75. 61 0
      src/views/iot/thingmodel/components/DataDefinition.vue
  76. 3 0
      src/views/iot/thingmodel/components/index.ts
  77. 213 0
      src/views/iot/thingmodel/config.ts
  78. 52 0
      src/views/iot/thingmodel/dataSpecs/ThingModelArrayDataSpecs.vue
  79. 159 0
      src/views/iot/thingmodel/dataSpecs/ThingModelEnumDataSpecs.vue
  80. 139 0
      src/views/iot/thingmodel/dataSpecs/ThingModelNumberDataSpecs.vue
  81. 170 0
      src/views/iot/thingmodel/dataSpecs/ThingModelStructDataSpecs.vue
  82. 11 0
      src/views/iot/thingmodel/dataSpecs/index.ts
  83. 56 30
      src/views/iot/thingmodel/index.vue
  84. 4 0
      src/views/iot/utils/constants.ts
  85. 2 2
      src/views/mall/product/spu/index.vue
  86. 6 2
      src/views/mall/trade/afterSale/detail/index.vue
  87. 1 1
      src/views/mall/trade/delivery/pickUpStore/DeliveryPickUpStoreBindForm.vue
  88. 2 2
      src/views/system/loginlog/index.vue
  89. 2 2
      src/views/system/operatelog/index.vue

+ 1 - 1
.vscode/settings.json

@@ -87,7 +87,7 @@
     "source.fixAll.stylelint": "explicit"
   },
   "[vue]": {
-    "editor.defaultFormatter": "esbenp.prettier-vscode"
+    "editor.defaultFormatter": "octref.vetur"
   },
   "i18n-ally.localesPaths": ["src/locales"],
   "i18n-ally.keystyle": "nested",

+ 2 - 0
package.json

@@ -67,6 +67,7 @@
     "sortablejs": "^1.15.3",
     "steady-xml": "^0.1.0",
     "url": "^0.11.3",
+    "v3-jsoneditor": "^0.0.6",
     "video.js": "^7.21.5",
     "vue": "3.5.12",
     "vue-dompurify-html": "^4.1.4",
@@ -92,6 +93,7 @@
     "@typescript-eslint/eslint-plugin": "^7.1.0",
     "@typescript-eslint/parser": "^7.1.0",
     "@unocss/eslint-config": "^0.57.4",
+    "@unocss/eslint-plugin": "66.1.0-beta.5",
     "@unocss/transformer-variant-group": "^0.58.5",
     "@vitejs/plugin-legacy": "^5.3.1",
     "@vitejs/plugin-vue": "^5.0.4",

Файловите разлики са ограничени, защото са твърде много
+ 249 - 99
pnpm-lock.yaml


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

@@ -0,0 +1,169 @@
+import request from '@/config/axios'
+
+// IoT 设备 VO
+export interface DeviceVO {
+  id: number // 设备 ID,主键,自增
+  deviceKey: string // 设备唯一标识符
+  deviceName: string // 设备名称
+  productId: number // 产品编号
+  productKey: string // 产品标识
+  deviceType: number // 设备类型
+  nickname: string // 设备备注名称
+  gatewayId: number // 网关设备 ID
+  state: number // 设备状态
+  onlineTime: Date // 最后上线时间
+  offlineTime: Date // 最后离线时间
+  activeTime: Date // 设备激活时间
+  createTime: Date // 创建时间
+  ip: string // 设备的 IP 地址
+  firmwareVersion: string // 设备的固件版本
+  deviceSecret: string // 设备密钥,用于设备认证,需安全存储
+  mqttClientId: string // MQTT 客户端 ID
+  mqttUsername: string // MQTT 用户名
+  mqttPassword: string // MQTT 密码
+  authType: string // 认证类型
+  latitude: number // 设备位置的纬度
+  longitude: number // 设备位置的经度
+  areaId: number // 地区编码
+  address: string // 设备详细地址
+  serialNumber: string // 设备序列号
+  config: string // 设备配置
+  groupIds?: number[] // 添加分组 ID
+}
+
+// IoT 设备数据 VO
+export interface DeviceDataVO {
+  deviceId: number // 设备编号
+  thinkModelFunctionId: number // 物模型编号
+  productKey: string // 产品标识
+  deviceName: string // 设备名称
+  identifier: string // 属性标识符
+  name: string // 属性名称
+  dataType: string // 数据类型
+  updateTime: Date // 更新时间
+  value: string // 最新值
+}
+
+// IoT 设备数据 VO
+export interface DeviceHistoryDataVO {
+  time: number // 时间
+  data: string // 数据
+}
+
+// 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 设备下行 Request VO
+export interface IotDeviceDownstreamReqVO {
+  id: number // 设备编号
+  type: string // 消息类型
+  identifier: string // 标识符
+  data: any // 请求参数
+}
+
+// MQTT 连接参数 VO
+export interface MqttConnectionParamsVO {
+  mqttClientId: string // MQTT 客户端 ID
+  mqttUsername: string // MQTT 用户名
+  mqttPassword: string // MQTT 密码
+}
+
+// 设备 API
+export const DeviceApi = {
+  // 查询设备分页
+  getDevicePage: async (params: any) => {
+    return await request.get({ url: `/iot/device/page`, params })
+  },
+
+  // 查询设备详情
+  getDevice: async (id: number) => {
+    return await request.get({ url: `/iot/device/get?id=` + id })
+  },
+
+  // 新增设备
+  createDevice: async (data: DeviceVO) => {
+    return await request.post({ url: `/iot/device/create`, data })
+  },
+
+  // 修改设备
+  updateDevice: async (data: DeviceVO) => {
+    return await request.put({ url: `/iot/device/update`, data })
+  },
+
+  // 修改设备分组
+  updateDeviceGroup: async (data: { ids: number[]; groupIds: number[] }) => {
+    return await request.put({ url: `/iot/device/update-group`, data })
+  },
+
+  // 删除单个设备
+  deleteDevice: async (id: number) => {
+    return await request.delete({ url: `/iot/device/delete?id=` + id })
+  },
+
+  // 删除多个设备
+  deleteDeviceList: async (ids: number[]) => {
+    return await request.delete({ url: `/iot/device/delete-list`, params: { ids: ids.join(',') } })
+  },
+
+  // 导出设备
+  exportDeviceExcel: async (params: any) => {
+    return await request.download({ url: `/iot/device/export-excel`, params })
+  },
+
+  // 获取设备数量
+  getDeviceCount: async (productId: number) => {
+    return await request.get({ url: `/iot/device/count?productId=` + productId })
+  },
+
+  // 获取设备的精简信息列表
+  getSimpleDeviceList: async (deviceType?: number) => {
+    return await request.get({ url: `/iot/device/simple-list?`, params: { deviceType } })
+  },
+
+  // 获取导入模板
+  importDeviceTemplate: async () => {
+    return await request.download({ url: `/iot/device/get-import-template` })
+  },
+
+  // 设备上行
+  upstreamDevice: async (data: IotDeviceUpstreamReqVO) => {
+    return await request.post({ url: `/iot/device/upstream`, data })
+  },
+
+  // 设备下行
+  downstreamDevice: async (data: IotDeviceDownstreamReqVO) => {
+    return await request.post({ url: `/iot/device/downstream`, data })
+  },
+
+  // 获取设备属性最新数据
+  getLatestDeviceProperties: async (params: any) => {
+    return await request.get({ url: `/iot/device/property/latest`, params })
+  },
+
+  // 获取设备属性历史数据
+  getHistoryDevicePropertyPage: async (params: any) => {
+    return await request.get({ url: `/iot/device/property/history-page`, params })
+  },
+
+  // 查询设备日志分页
+  getDeviceLogPage: async (params: any) => {
+    return await request.get({ url: `/iot/device/log/page`, params })
+  },
+
+  // 获取设备MQTT连接参数
+  getMqttConnectionParams: async (deviceId: number) => {
+    return await request.get({ url: `/iot/device/mqtt-connection-params`, params: { deviceId } })
+  }
+}

+ 43 - 0
src/api/iot/device/group/index.ts

@@ -0,0 +1,43 @@
+import request from '@/config/axios'
+
+// IoT 设备分组 VO
+export interface DeviceGroupVO {
+  id: number // 分组 ID
+  name: string // 分组名字
+  status: number // 分组状态
+  description: string // 分组描述
+  deviceCount?: number // 设备数量
+}
+
+// IoT 设备分组 API
+export const DeviceGroupApi = {
+  // 查询设备分组分页
+  getDeviceGroupPage: async (params: any) => {
+    return await request.get({ url: `/iot/device-group/page`, params })
+  },
+
+  // 查询设备分组详情
+  getDeviceGroup: async (id: number) => {
+    return await request.get({ url: `/iot/device-group/get?id=` + id })
+  },
+
+  // 新增设备分组
+  createDeviceGroup: async (data: DeviceGroupVO) => {
+    return await request.post({ url: `/iot/device-group/create`, data })
+  },
+
+  // 修改设备分组
+  updateDeviceGroup: async (data: DeviceGroupVO) => {
+    return await request.put({ url: `/iot/device-group/update`, data })
+  },
+
+  // 删除设备分组
+  deleteDeviceGroup: async (id: number) => {
+    return await request.delete({ url: `/iot/device-group/delete?id=` + id })
+  },
+
+  // 获取设备分组的精简信息列表
+  getSimpleDeviceGroupList: async () => {
+    return await request.get({ url: `/iot/device-group/simple-list` })
+  }
+}

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

@@ -1,74 +0,0 @@
-import request from '@/config/axios'
-
-// IoT 设备 VO
-export interface DeviceVO {
-  id: number // 设备 ID,主键,自增
-  deviceKey: string // 设备唯一标识符
-  deviceName: string // 设备名称
-  productId: number // 产品编号
-  productKey: string // 产品标识
-  deviceType: number // 设备类型
-  nickname: string // 设备备注名称
-  gatewayId: number // 网关设备 ID
-  status: number // 设备状态
-  statusLastUpdateTime: Date // 设备状态最后更新时间
-  lastOnlineTime: Date // 最后上线时间
-  lastOfflineTime: Date // 最后离线时间
-  activeTime: Date // 设备激活时间
-  createTime: Date // 创建时间
-  ip: string // 设备的 IP 地址
-  firmwareVersion: string // 设备的固件版本
-  deviceSecret: string // 设备密钥,用于设备认证,需安全存储
-  mqttClientId: string // MQTT 客户端 ID
-  mqttUsername: string // MQTT 用户名
-  mqttPassword: string // MQTT 密码
-  authType: string // 认证类型
-  latitude: number // 设备位置的纬度
-  longitude: number // 设备位置的经度
-  areaId: number // 地区编码
-  address: string // 设备详细地址
-  serialNumber: string // 设备序列号
-}
-
-export interface DeviceUpdateStatusVO {
-  id: number // 设备 ID,主键,自增
-  status: number // 设备状态
-}
-
-// 设备 API
-export const DeviceApi = {
-  // 查询设备分页
-  getDevicePage: async (params: any) => {
-    return await request.get({ url: `/iot/device/page`, params })
-  },
-
-  // 查询设备详情
-  getDevice: async (id: number) => {
-    return await request.get({ url: `/iot/device/get?id=` + id })
-  },
-
-  // 新增设备
-  createDevice: async (data: DeviceVO) => {
-    return await request.post({ url: `/iot/device/create`, data })
-  },
-
-  // 修改设备
-  updateDevice: async (data: DeviceVO) => {
-    return await request.put({ url: `/iot/device/update`, data })
-  },
-
-  // 修改设备状态
-  updateDeviceStatus: async (data: DeviceUpdateStatusVO) => {
-    return await request.put({ url: `/iot/device/update-status`, data })
-  },
-
-  // 删除设备
-  deleteDevice: async (id: number) => {
-    return await request.delete({ url: `/iot/device/delete?id=` + id })
-  },
-
-  // 获取设备数量
-  getDeviceCount: async (productId: number) => {
-    return await request.get({ url: `/iot/device/count?productId=` + productId })
-  }
-}

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

@@ -0,0 +1,51 @@
+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 })
+  }
+}

+ 43 - 0
src/api/iot/product/category/index.ts

@@ -0,0 +1,43 @@
+import request from '@/config/axios'
+
+// IoT 产品分类 VO
+export interface ProductCategoryVO {
+  id: number // 分类 ID
+  name: string // 分类名字
+  sort: number // 分类排序
+  status: number // 分类状态
+  description: string // 分类描述
+}
+
+// IoT 产品分类 API
+export const ProductCategoryApi = {
+  // 查询产品分类分页
+  getProductCategoryPage: async (params: any) => {
+    return await request.get({ url: `/iot/product-category/page`, params })
+  },
+
+  // 查询产品分类详情
+  getProductCategory: async (id: number) => {
+    return await request.get({ url: `/iot/product-category/get?id=` + id })
+  },
+
+  // 新增产品分类
+  createProductCategory: async (data: ProductCategoryVO) => {
+    return await request.post({ url: `/iot/product-category/create`, data })
+  },
+
+  // 修改产品分类
+  updateProductCategory: async (data: ProductCategoryVO) => {
+    return await request.put({ url: `/iot/product-category/update`, data })
+  },
+
+  // 删除产品分类
+  deleteProductCategory: async (id: number) => {
+    return await request.delete({ url: `/iot/product-category/delete?id=` + id })
+  },
+
+  /** 获取产品分类精简列表 */
+  getSimpleProductCategoryList: () => {
+    return request.get({ url: '/iot/product-category/simple-list' })
+  }
+}

+ 21 - 1
src/api/iot/product/index.ts → src/api/iot/product/product/index.ts

@@ -7,6 +7,9 @@ export interface ProductVO {
   productKey: string // 产品标识
   protocolId: number // 协议编号
   categoryId: number // 产品所属品类标识符
+  categoryName?: string // 产品所属品类名称
+  icon: string // 产品图标
+  picUrl: string // 产品图片
   description: string // 产品描述
   validateType: number // 数据校验级别
   status: number // 产品状态
@@ -18,6 +21,23 @@ export interface ProductVO {
   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 产品 API
 export const ProductApi = {
   // 查询产品分页
@@ -57,6 +77,6 @@ export const ProductApi = {
 
   // 查询产品(精简)列表
   getSimpleProductList() {
-    return request.get({ url: '/iot/product/list-all-simple' })
+    return request.get({ url: '/iot/product/simple-list' })
   }
 }

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

@@ -0,0 +1,127 @@
+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 })
+  }
+}

+ 41 - 0
src/api/iot/statistics/index.ts

@@ -0,0 +1,41 @@
+import request from '@/config/axios'
+
+/** IoT 统计数据类型 */
+export interface IotStatisticsSummaryRespVO {
+  productCategoryCount: number
+  productCount: number
+  deviceCount: number
+  deviceMessageCount: number
+  productCategoryTodayCount: number
+  productTodayCount: number
+  deviceTodayCount: number
+  deviceMessageTodayCount: number
+  deviceOnlineCount: number
+  deviceOfflineCount: number
+  deviceInactiveCount: number
+  productCategoryDeviceCounts: Record<string, number>
+}
+
+/** IoT 消息统计数据类型 */
+export interface IotStatisticsDeviceMessageSummaryRespVO {
+  upstreamCounts: Record<number, number>
+  downstreamCounts: Record<number, number>
+}
+
+// IoT 数据统计 API
+export const ProductCategoryApi = {
+  // 查询基础的数据统计
+  getIotStatisticsSummary: 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`,
+      params
+    })
+  }
+}

+ 88 - 0
src/api/iot/thingmodel/index.ts

@@ -0,0 +1,88 @@
+import request from '@/config/axios'
+
+/**
+ * IoT 产品物模型
+ */
+export interface ThingModelData {
+  id?: number // 物模型功能编号
+  identifier?: string // 功能标识
+  name?: string // 功能名称
+  description?: string // 功能描述
+  productId?: number // 产品编号
+  productKey?: string // 产品标识
+  dataType: string // 数据类型,与 dataSpecs 的 dataType 保持一致
+  type: number // 功能类型
+  property: ThingModelProperty // 属性
+  event?: ThingModelEvent // 事件
+  service?: ThingModelService // 服务
+}
+
+/**
+ * IoT 模拟设备
+ */
+// TODO @super:和 ThingModelSimulatorData 会不会好点
+export interface SimulatorData extends ThingModelData {
+  simulateValue?: string | number // 用于存储模拟值 TODO @super:字段使用 value 会不会好点
+}
+
+/**
+ * ThingModelProperty 类型
+ */
+export interface ThingModelProperty {
+  [key: string]: any
+}
+
+/**
+ * ThingModelEvent 类型
+ */
+export interface ThingModelEvent {
+  [key: string]: any
+}
+
+/**
+ * ThingModelService 类型
+ */
+export interface ThingModelService {
+  [key: string]: any
+}
+
+// IoT 产品物模型 API
+export const ThingModelApi = {
+  // 查询产品物模型分页
+  getThingModelPage: async (params: any) => {
+    return await request.get({ url: `/iot/thing-model/page`, params })
+  },
+
+  // 获得产品物模型列表
+  getThingModelList: async (params: any) => {
+    return await request.get({ url: `/iot/thing-model/list`, params })
+  },
+
+  // 获得产品物模型
+  getThingModelListByProductId: async (params: any) => {
+    return await request.get({
+      url: `/iot/thing-model/list-by-product-id`,
+      params
+    })
+  },
+
+  // 查询产品物模型详情
+  getThingModel: async (id: number) => {
+    return await request.get({ url: `/iot/thing-model/get?id=` + id })
+  },
+
+  // 新增产品物模型
+  createThingModel: async (data: ThingModelData) => {
+    return await request.post({ url: `/iot/thing-model/create`, data })
+  },
+
+  // 修改产品物模型
+  updateThingModel: async (data: ThingModelData) => {
+    return await request.put({ url: `/iot/thing-model/update`, data })
+  },
+
+  // 删除产品物模型
+  deleteThingModel: async (id: number) => {
+    return await request.delete({ url: `/iot/thing-model/delete?id=` + id })
+  }
+}

+ 0 - 55
src/api/iot/thinkmodelfunction/index.ts

@@ -1,55 +0,0 @@
-import request from '@/config/axios'
-
-// IoT 产品物模型 VO
-export interface ThinkModelFunctionVO {
-  id: number // 物模型功能编号
-  identifier: string // 功能标识
-  name: string // 功能名称
-  description: string // 功能描述
-  productId: number // 产品编号
-  productKey: string // 产品标识
-  type: number // 功能类型
-  property: string // 属性
-  event: string // 事件
-  service: string // 服务
-}
-
-// IoT 产品物模型 API
-export const ThinkModelFunctionApi = {
-  // 查询产品物模型分页
-  getThinkModelFunctionPage: async (params: any) => {
-    return await request.get({ url: `/iot/think-model-function/page`, params })
-  },
-  // 获得产品物模型
-  getThinkModelFunctionListByProductId: async (params: any) => {
-    return await request.get({
-      url: `/iot/think-model-function/list-by-product-id`,
-      params
-    })
-  },
-
-  // 查询产品物模型详情
-  getThinkModelFunction: async (id: number) => {
-    return await request.get({ url: `/iot/think-model-function/get?id=` + id })
-  },
-
-  // 新增产品物模型
-  createThinkModelFunction: async (data: ThinkModelFunctionVO) => {
-    return await request.post({ url: `/iot/think-model-function/create`, data })
-  },
-
-  // 修改产品物模型
-  updateThinkModelFunction: async (data: ThinkModelFunctionVO) => {
-    return await request.put({ url: `/iot/think-model-function/update`, data })
-  },
-
-  // 删除产品物模型
-  deleteThinkModelFunction: async (id: number) => {
-    return await request.delete({ url: `/iot/think-model-function/delete?id=` + id })
-  },
-
-  // 导出产品物模型 Excel
-  exportThinkModelFunction: async (params) => {
-    return await request.download({ url: `/iot/think-model-function/export-excel`, params })
-  }
-}

+ 1 - 1
src/api/mall/product/spu.ts

@@ -101,7 +101,7 @@ export const deleteSpu = (id: number) => {
 }
 
 // 导出商品 Spu Excel
-export const exportSpu = async (params) => {
+export const exportSpu = async (params: any) => {
   return await request.download({ url: '/product/spu/export', params })
 }
 

BIN
src/assets/imgs/iot/device.png


+ 1 - 0
src/assets/svgs/iot/card-fill.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" class="design-iconfont" viewBox="0 0 12 12"><path fill="url(#a)" fill-rule="evenodd" d="M1 0a1 1 0 0 0-1 1v3.538a1 1 0 0 0 1 1h3.538a1 1 0 0 0 1-1V1a1 1 0 0 0-1-1H1Zm0 6.462a1 1 0 0 0-1 1V11a1 1 0 0 0 1 1h3.538a1 1 0 0 0 1-1V7.462a1 1 0 0 0-1-1H1ZM6.462 1a1 1 0 0 1 1-1H11a1 1 0 0 1 1 1v3.538a1 1 0 0 1-1 1H7.462a1 1 0 0 1-1-1V1Zm1 5.462a1 1 0 0 0-1 1V11a1 1 0 0 0 1 1H11a1 1 0 0 0 1-1V7.462a1 1 0 0 0-1-1H7.462Z" clip-rule="evenodd"/><defs><linearGradient id="a" x1="0" x2="12" y1="0" y2="12" gradientUnits="userSpaceOnUse"><stop stop-color="#1B3149"/><stop offset="1" stop-color="#717D8A"/></linearGradient></defs></svg>

+ 1 - 0
src/assets/svgs/iot/cube.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 12 12"><g clip-path="url(#a)"><path fill="url(#b)" fill-rule="evenodd" d="M6.958.42C6.444.216 5.61.216 5.098.42L1.15 1.975c-.77.304-.77.797 0 1.1l3.947 1.558c.514.202 1.347.202 1.86 0l3.948-1.557c.77-.304.77-.797 0-1.1L6.958.418ZM4.715 11.788a.857.857 0 0 0 .3.056c.383 0 .671-.295.671-.7V6.404c0-.49-.364-1.007-.817-1.177L1.09 3.805a.808.808 0 0 0-.284-.056c-.353 0-.581.275-.581.7V9.19c0 .508.33 1.014.763 1.177l3.726 1.422Zm2.229-.024h-.02l.073.003c.074.004.154.009.227-.019L11 10.367c.45-.168.83-.686.83-1.177V4.45c0-.413-.29-.7-.673-.7a.965.965 0 0 0-.317.055l-3.72 1.422c-.44.165-.75.67-.75 1.177v4.74c0 .42.218.621.575.621Z" clip-rule="evenodd"/></g><defs><linearGradient id="b" x1=".226" x2="11.803" y1=".267" y2="11.871" gradientUnits="userSpaceOnUse"><stop stop-color="#1B3149"/><stop offset="1" stop-color="#717D8A"/></linearGradient><clipPath id="a"><path fill="#fff" d="M0 0h12v12H0z"/></clipPath></defs></svg>

+ 1 - 0
src/components/Dialog/src/Dialog.vue

@@ -68,6 +68,7 @@ const dialogStyle = computed(() => {
     draggable
     class="com-dialog"
     :show-close="false"
+    @close="$emit('update:modelValue', false)"
   >
     <template #header="{ close }">
       <div class="relative h-54px flex items-center justify-between pl-15px pr-15px">

+ 0 - 1
src/components/SimpleProcessDesignerV2/src/consts.ts

@@ -544,7 +544,6 @@ export const CANDIDATE_STRATEGY: DictDataVO[] = [
   { label: '部门成员', value: CandidateStrategy.DEPT_MEMBER },
   { label: '部门负责人', value: CandidateStrategy.DEPT_LEADER },
   { label: '连续多级部门负责人', value: CandidateStrategy.MULTI_LEVEL_DEPT_LEADER },
-  { label: '指定岗位', value: CandidateStrategy.MULTI_LEVEL_DEPT_LEADER },
   { label: '发起人自选', value: CandidateStrategy.START_USER_SELECT },
   { label: '审批人自选', value: CandidateStrategy.APPROVE_USER_SELECT },
   { label: '发起人本人', value: CandidateStrategy.START_USER },

+ 1 - 1
src/components/Table/src/Table.vue

@@ -56,7 +56,7 @@ export default defineComponent({
     // 注册
     onMounted(() => {
       const tableRef = unref(elTableRef)
-      emit('register', tableRef?.$parent, elTableRef)
+      emit('register', tableRef?.$parent, elTableRef.value)
     })
 
     const pageSizeRef = ref(props.pageSize)

+ 16 - 5
src/router/modules/remaining.ts

@@ -689,15 +689,15 @@ const remainingRouter: AppRouteRecordRaw[] = [
     },
     children: [
       {
-        path: 'product/detail/:id',
+        path: 'product/product/detail/:id',
         name: 'IoTProductDetail',
         meta: {
           title: '产品详情',
           noCache: true,
           hidden: true,
-          activeMenu: '/iot/product'
+          activeMenu: '/iot/device/product'
         },
-        component: () => import('@/views/iot/product/detail/index.vue')
+        component: () => import('@/views/iot/product/product/detail/index.vue')
       },
       {
         path: 'device/detail/:id',
@@ -706,9 +706,20 @@ const remainingRouter: AppRouteRecordRaw[] = [
           title: '设备详情',
           noCache: true,
           hidden: true,
-          activeMenu: '/iot/device'
+          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/device/detail/index.vue')
+        component: () => import('@/views/iot/plugin/detail/index.vue')
       }
     ]
   }

+ 9 - 4
src/utils/dict.ts

@@ -236,9 +236,14 @@ export enum DICT_TYPE {
   IOT_PRODUCT_DEVICE_TYPE = 'iot_product_device_type', // IOT 产品设备类型
   IOT_DATA_FORMAT = 'iot_data_format', // IOT 数据格式
   IOT_PROTOCOL_TYPE = 'iot_protocol_type', // IOT 接入网关协议
-  IOT_DEVICE_STATUS = 'iot_device_status', // IOT 设备状态
-  IOT_PRODUCT_FUNCTION_TYPE = 'iot_product_function_type', // IOT 产品功能类型
+  IOT_DEVICE_STATE = 'iot_device_state', // IOT 设备状态
+  IOT_THING_MODEL_TYPE = 'iot_thing_model_type', // IOT 产品功能类型
   IOT_DATA_TYPE = 'iot_data_type', // IOT 数据类型
-  IOT_UNIT_TYPE = 'iot_unit_type', // IOT 单位类型
-  IOT_RW_TYPE = 'iot_rw_type' // IOT 读写类型
+  IOT_THING_MODEL_UNIT = 'iot_thing_model_unit', // IOT 物模型单位
+  IOT_RW_TYPE = 'iot_rw_type', // IOT 读写类型
+  IOT_PLUGIN_DEPLOY_TYPE = 'iot_plugin_deploy_type', // IOT 插件部署类型
+  IOT_PLUGIN_STATUS = 'iot_plugin_status', // IOT 插件状态
+  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' // 桥梁类型
 }

+ 1 - 1
src/utils/formCreate.ts

@@ -11,7 +11,7 @@ export const encodeConf = (designerRef: object) => {
 // 编码表单 Fields
 export const encodeFields = (designerRef: object) => {
   // @ts-ignore
-  const rule = designerRef.value.getRule()
+  const rule = JSON.parse(designerRef.value.getJson())
   const fields: string[] = []
   rule.forEach((item) => {
     fields.push(JSON.stringify(item))

+ 16 - 2
src/utils/index.ts

@@ -116,9 +116,23 @@ export function toAnyString() {
   return str
 }
 
+/**
+ * 生成指定长度的随机字符串
+ *
+ * @param length 字符串长度
+ */
+export function generateRandomStr(length: number): string {
+  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
+  let result = ''
+  for (let i = 0; i < length; i++) {
+    result += chars.charAt(Math.floor(Math.random() * chars.length))
+  }
+  return result
+}
+
 /**
  * 根据支持的文件类型生成 accept 属性值
- * 
+ *
  * @param supportedFileTypes 支持的文件类型数组,如 ['PDF', 'DOC', 'DOCX']
  * @returns 用于文件上传组件 accept 属性的字符串
  */
@@ -503,7 +517,7 @@ export function jsonParse(str: string) {
   try {
     return JSON.parse(str)
   } catch (e) {
-    console.error(`str[${str}] 不是一个 JSON 字符串`)
+    console.log(`str[${str}] 不是一个 JSON 字符串`)
     return ''
   }
 }

+ 3 - 0
src/utils/routerHelper.ts

@@ -100,6 +100,9 @@ export const generateRoute = (routes: AppCustomRouteRecordRaw[]): AppRouteRecord
     //处理顶级非目录路由
     if (!route.children && route.parentId == 0 && route.component) {
       data.component = Layout
+      data.meta = {
+        hidden: meta.hidden,
+      }
       data.name = toCamelCase(route.path, true) + 'Parent'
       data.redirect = ''
       meta.alwaysShow = true

+ 5 - 0
src/views/ai/utils/constants.ts

@@ -16,6 +16,7 @@ export const AiPlatformEnum = {
   DEEP_SEEK: 'DeepSeek', // DeepSeek
   ZHI_PU: 'ZhiPu', // 智谱 AI
   XING_HUO: 'XingHuo', // 讯飞
+  SiliconFlow: 'SiliconFlow', // 硅基流动
   OPENAI: 'OpenAI',
   Ollama: 'Ollama',
   STABLE_DIFFUSION: 'StableDiffusion', // Stability AI
@@ -44,6 +45,10 @@ export const OtherPlatformEnum: ImageModelVO[] = [
   {
     key: AiPlatformEnum.ZHI_PU,
     name: '智谱 AI'
+  },
+  {
+    key: AiPlatformEnum.SiliconFlow,
+    name: '硅基流动'
   }
 ]
 

+ 0 - 156
src/views/iot/device/DeviceForm.vue

@@ -1,156 +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="productId">
-        <el-select
-          v-model="formData.productId"
-          placeholder="请选择产品"
-          :disabled="formType === 'update'"
-          clearable
-        >
-          <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="formData.deviceName"
-          placeholder="请输入 DeviceName"
-          :disabled="formType === 'update'"
-        />
-      </el-form-item>
-      <el-form-item label="备注名称" prop="nickname">
-        <el-input v-model="formData.nickname" placeholder="请输入备注名称" />
-      </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 { DeviceApi, DeviceVO } from '@/api/iot/device'
-import { ProductApi } from '@/api/iot/product'
-
-/** IoT 设备 表单 */
-defineOptions({ name: 'IoTDeviceForm' })
-
-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,
-  productId: undefined,
-  deviceName: undefined,
-  nickname: undefined
-})
-const formRules = reactive({
-  productId: [{ required: true, message: '产品不能为空', trigger: 'blur' }],
-  deviceName: [
-    {
-      pattern: /^[a-zA-Z0-9_.\-:@]{4,32}$/,
-      message:
-        '支持英文字母、数字、下划线(_)、中划线(-)、点号(.)、半角冒号(:)和特殊字符@,长度限制为 4~32 个字符',
-      trigger: 'blur'
-    }
-  ],
-  nickname: [
-    {
-      validator: (rule, value, callback) => {
-        if (value === undefined || value === null) {
-          callback()
-          return
-        }
-        const length = value.replace(/[\u4e00-\u9fa5\u3040-\u30ff]/g, 'aa').length
-        if (length < 4 || length > 64) {
-          callback(new Error('备注名称长度限制为 4~64 个字符,中文及日文算 2 个字符'))
-        } else if (!/^[\u4e00-\u9fa5\u3040-\u30ff_a-zA-Z0-9]+$/.test(value)) {
-          callback(new Error('备注名称只能包含中文、英文字母、日文、数字和下划线(_)'))
-        } else {
-          callback()
-        }
-      },
-      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 DeviceApi.getDevice(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 DeviceVO
-    if (formType.value === 'create') {
-      await DeviceApi.createDevice(data)
-      message.success(t('common.createSuccess'))
-    } else {
-      await DeviceApi.updateDevice(data)
-      message.success(t('common.updateSuccess'))
-    }
-    dialogVisible.value = false
-    // 发送操作成功的事件
-    emit('success')
-  } finally {
-    formLoading.value = false
-  }
-}
-
-/** 重置表单 */
-const resetForm = () => {
-  formData.value = {
-    id: undefined,
-    productId: undefined,
-    deviceName: undefined,
-    nickname: undefined
-  }
-  formRef.value?.resetFields()
-}
-
-/** 查询字典下拉列表 */
-const products = ref()
-const getProducts = async () => {
-  products.value = await ProductApi.getSimpleProductList()
-}
-
-onMounted(() => {
-  getProducts()
-})
-</script>

+ 0 - 123
src/views/iot/device/detail/DeviceDetailsInfo.vue

@@ -1,123 +0,0 @@
-<template>
-  <ContentWrap>
-    <el-collapse v-model="activeNames">
-      <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="激活时间">
-          {{ formatDate(device.activeTime) }}
-        </el-descriptions-item>
-        <el-descriptions-item label="最后上线时间">
-          {{ formatDate(device.lastOnlineTime) }}
-        </el-descriptions-item>
-        <el-descriptions-item label="当前状态">
-          <dict-tag :type="DICT_TYPE.IOT_DEVICE_STATUS" :value="device.status" />
-        </el-descriptions-item>
-        <el-descriptions-item label="最后离线时间" :span="3">
-          {{ formatDate(device.lastOfflineTime) }}
-        </el-descriptions-item>
-        <el-descriptions-item label="MQTT 连接参数">
-          <el-button type="primary" @click="openMqttParams">查看</el-button>
-        </el-descriptions-item>
-      </el-descriptions>
-    </el-collapse>
-
-    <!-- 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="password">
-            <template #append>
-              <el-button @click="copyToClipboard(mqttParams.mqttPassword)" type="primary">
-                <Icon icon="ph:copy" />
-              </el-button>
-            </template>
-          </el-input>
-        </el-form-item>
-      </el-form>
-      <template #footer>
-        <el-button @click="mqttDialogVisible = false">关闭</el-button>
-      </template>
-    </Dialog>
-  </ContentWrap>
-</template>
-<script setup lang="ts">
-import { ref } from 'vue'
-import { DICT_TYPE } from '@/utils/dict'
-import { ProductVO } from '@/api/iot/product'
-import { formatDate } from '@/utils/formatTime'
-import { DeviceVO } from '@/api/iot/device'
-
-const message = useMessage() // 消息提示
-
-const { product, device } = defineProps<{ product: ProductVO; device: DeviceVO }>() // 定义 Props
-
-const emit = defineEmits(['refresh']) // 定义 Emits
-
-const activeNames = ref(['basicInfo']) // 展示的折叠面板
-const mqttDialogVisible = ref(false) // 定义 MQTT 弹框的可见性
-const mqttParams = ref({
-  mqttClientId: '',
-  mqttUsername: '',
-  mqttPassword: ''
-}) // 定义 MQTT 参数对象
-
-/** 复制到剪贴板方法 */
-const copyToClipboard = (text: string) => {
-  navigator.clipboard.writeText(text).then(() => {
-    message.success('复制成功')
-  })
-}
-
-/** 打开 MQTT 参数弹框的方法 */
-const openMqttParams = () => {
-  mqttParams.value = {
-    mqttClientId: device.mqttClientId || 'N/A',
-    mqttUsername: device.mqttUsername || 'N/A',
-    mqttPassword: device.mqttPassword || 'N/A'
-  }
-  mqttDialogVisible.value = true
-}
-
-/** 关闭 MQTT 弹框的方法 */
-const handleCloseMqttDialog = () => {
-  mqttDialogVisible.value = false
-}
-</script>

+ 0 - 66
src/views/iot/device/detail/index.vue

@@ -1,66 +0,0 @@
-<template>
-  <DeviceDetailsHeader
-    :loading="loading"
-    :product="product"
-    :device="device"
-    @refresh="getDeviceData(id)"
-  />
-  <el-col>
-    <el-tabs>
-      <el-tab-pane label="设备信息">
-        <DeviceDetailsInfo :product="product" :device="device" />
-      </el-tab-pane>
-      <el-tab-pane label="Topic 列表" />
-      <el-tab-pane label="物模型数据" />
-      <el-tab-pane label="子设备管理" />
-    </el-tabs>
-  </el-col>
-</template>
-<script lang="ts" setup>
-import { useTagsViewStore } from '@/store/modules/tagsView'
-import { DeviceApi, DeviceVO } from '@/api/iot/device'
-import { ProductApi, ProductVO } from '@/api/iot/product'
-import DeviceDetailsHeader from '@/views/iot/device/detail/DeviceDetailsHeader.vue'
-import DeviceDetailsInfo from '@/views/iot/device/detail/DeviceDetailsInfo.vue'
-
-defineOptions({ name: 'IoTDeviceDetail' })
-
-const route = useRoute()
-const message = useMessage()
-const id = route.params.id // 编号
-const loading = ref(true) // 加载中
-const product = ref<ProductVO>({} as ProductVO) // 产品详情
-const device = ref<DeviceVO>({} as DeviceVO) // 设备详情
-
-/** 获取设备详情 */
-const getDeviceData = async (id: number) => {
-  loading.value = true
-  try {
-    device.value = await DeviceApi.getDevice(id)
-    console.log(product.value)
-    await getProductData(device.value.productId)
-  } finally {
-    loading.value = false
-  }
-}
-
-/** 获取产品详情 */
-const getProductData = async (id: number) => {
-  product.value = await ProductApi.getProduct(id)
-  console.log(product.value)
-}
-
-/** 获取物模型 */
-
-/** 初始化 */
-const { delView } = useTagsViewStore() // 视图操作
-const { currentRoute } = useRouter() // 路由
-onMounted(async () => {
-  if (!id) {
-    message.warning('参数错误,产品不能为空!')
-    delView(unref(currentRoute))
-    return
-  }
-  await getDeviceData(id)
-})
-</script>

+ 263 - 0
src/views/iot/device/device/DeviceForm.vue

@@ -0,0 +1,263 @@
+<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="productId">
+        <el-select
+          v-model="formData.productId"
+          placeholder="请选择产品"
+          :disabled="formType === 'update'"
+          clearable
+          @change="handleProductChange"
+        >
+          <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="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"
+          placeholder="请输入 DeviceName"
+          :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="更多配置">
+          <el-form-item label="备注名称" prop="nickname">
+            <el-input v-model="formData.nickname" placeholder="请输入备注名称" />
+          </el-form-item>
+          <el-form-item label="设备图片" prop="picUrl">
+            <UploadImg v-model="formData.picUrl" :height="'120px'" :width="'120px'" />
+          </el-form-item>
+          <el-form-item label="设备分组" prop="groupIds">
+            <el-select v-model="formData.groupIds" placeholder="请选择设备分组" multiple clearable>
+              <el-option
+                v-for="group in deviceGroups"
+                :key="group.id"
+                :label="group.name"
+                :value="group.id"
+              />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="设备序列号" prop="serialNumber">
+            <el-input v-model="formData.serialNumber" placeholder="请输入设备序列号" />
+          </el-form-item>
+        </el-collapse-item>
+      </el-collapse>
+    </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 { 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'
+
+/** IoT 设备表单 */
+defineOptions({ name: 'IoTDeviceForm' })
+
+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,
+  productId: undefined,
+  deviceKey: undefined as string | undefined,
+  deviceName: undefined,
+  nickname: undefined,
+  picUrl: undefined,
+  gatewayId: undefined,
+  deviceType: undefined as number | undefined,
+  serialNumber: undefined,
+  groupIds: [] as number[]
+})
+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' },
+    {
+      pattern: /^[a-zA-Z0-9_.\-:@]{4,32}$/,
+      message:
+        '支持英文字母、数字、下划线(_)、中划线(-)、点号(.)、半角冒号(:)和特殊字符@,长度限制为 4~32 个字符',
+      trigger: 'blur'
+    }
+  ],
+  nickname: [
+    {
+      validator: (rule, value, callback) => {
+        if (value === undefined || value === null) {
+          callback()
+          return
+        }
+        const length = value.replace(/[\u4e00-\u9fa5\u3040-\u30ff]/g, 'aa').length
+        if (length < 4 || length > 64) {
+          callback(new Error('备注名称长度限制为 4~64 个字符,中文及日文算 2 个字符'))
+        } else if (!/^[\u4e00-\u9fa5\u3040-\u30ff_a-zA-Z0-9]+$/.test(value)) {
+          callback(new Error('备注名称只能包含中文、英文字母、日文、数字和下划线(_)'))
+        } else {
+          callback()
+        }
+      },
+      trigger: 'blur'
+    }
+  ],
+  serialNumber: [
+    {
+      pattern: /^[a-zA-Z0-9-_]+$/,
+      message: '序列号只能包含字母、数字、中划线和下划线',
+      trigger: 'blur'
+    }
+  ]
+})
+const formRef = ref() // 表单 Ref
+const products = ref<ProductVO[]>([]) // 产品列表
+const gatewayDevices = ref<DeviceVO[]>([]) // 网关设备列表
+const deviceGroups = ref<any[]>([])
+
+/** 打开弹窗 */
+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 DeviceApi.getDevice(id)
+    } 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)
+  }
+}
+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 DeviceVO
+    if (formType.value === 'create') {
+      await DeviceApi.createDevice(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await DeviceApi.updateDevice(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    productId: undefined,
+    deviceKey: undefined,
+    deviceName: undefined,
+    nickname: undefined,
+    picUrl: undefined,
+    gatewayId: undefined,
+    deviceType: undefined,
+    serialNumber: undefined,
+    groupIds: []
+  }
+  formRef.value?.resetFields()
+}
+
+/** 产品选择变化 */
+const handleProductChange = (productId: number) => {
+  if (!productId) {
+    formData.value.deviceType = undefined
+    return
+  }
+  const product = products.value?.find((item) => item.id === productId)
+  formData.value.deviceType = product?.deviceType
+}
+
+/** 生成 DeviceKey */
+const generateDeviceKey = () => {
+  formData.value.deviceKey = generateRandomStr(16)
+}
+</script>

+ 90 - 0
src/views/iot/device/device/DeviceGroupForm.vue

@@ -0,0 +1,90 @@
+<template>
+  <Dialog :title="'添加设备到分组'" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="设备分组" prop="groupIds">
+        <el-select v-model="formData.groupIds" placeholder="请选择设备分组" multiple clearable>
+          <el-option
+            v-for="group in deviceGroups"
+            :key="group.id"
+            :label="group.name"
+            :value="group.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>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import { DeviceApi } from '@/api/iot/device/device'
+import { DeviceGroupApi } from '@/api/iot/device/group'
+
+defineOptions({ name: 'IoTDeviceGroupForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中
+const formData = ref({
+  ids: [] as number[],
+  groupIds: [] as number[]
+})
+const formRules = reactive({
+  groupIds: [{ required: true, message: '设备分组不能为空', trigger: 'change' }]
+})
+const formRef = ref() // 表单 Ref
+const deviceGroups = ref<any[]>([]) // 设备分组列表
+
+/** 打开弹窗 */
+const open = async (ids: number[]) => {
+  dialogVisible.value = true
+  resetForm()
+  formData.value.ids = ids
+
+  // 加载设备分组列表
+  try {
+    deviceGroups.value = await DeviceGroupApi.getSimpleDeviceGroupList()
+  } catch (error) {
+    console.error('加载设备分组列表失败:', error)
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  await formRef.value.validate()
+  // 提交请求
+  formLoading.value = true
+  try {
+    await DeviceApi.updateDeviceGroup(formData.value)
+    message.success(t('common.updateSuccess'))
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    ids: [],
+    groupIds: []
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 139 - 0
src/views/iot/device/device/DeviceImportForm.vue

@@ -0,0 +1,139 @@
+<template>
+  <Dialog v-model="dialogVisible" title="设备导入" width="400">
+    <el-upload
+      ref="uploadRef"
+      v-model:file-list="fileList"
+      :action="importUrl + '?updateSupport=' + updateSupport"
+      :auto-upload="false"
+      :disabled="formLoading"
+      :headers="uploadHeaders"
+      :limit="1"
+      :on-error="submitFormError"
+      :on-exceed="handleExceed"
+      :on-success="submitFormSuccess"
+      accept=".xlsx, .xls"
+      drag
+    >
+      <Icon icon="ep:upload" />
+      <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
+      <template #tip>
+        <div class="el-upload__tip text-center">
+          <div class="el-upload__tip">
+            <el-checkbox v-model="updateSupport" />
+            是否更新已经存在的设备数据
+          </div>
+          <span>仅允许导入 xls、xlsx 格式文件。</span>
+          <el-link
+            :underline="false"
+            style="font-size: 12px; vertical-align: baseline"
+            type="primary"
+            @click="importTemplate"
+          >
+            下载模板
+          </el-link>
+        </div>
+      </template>
+    </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 { DeviceApi } from '@/api/iot/device/device'
+import { getAccessToken, getTenantId } from '@/utils/auth'
+import download from '@/utils/download'
+
+defineOptions({ name: 'IoTDeviceImportForm' })
+
+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/device/import'
+const uploadHeaders = ref() // 上传 Header 头
+const fileList = ref([]) // 文件列表
+const updateSupport = ref(0) // 是否更新已经存在的设备数据
+
+/** 打开弹窗 */
+const open = () => {
+  dialogVisible.value = true
+  updateSupport.value = 0
+  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
+  }
+  // 拼接提示语
+  const data = response.data
+  let text = '上传成功数量:' + data.createDeviceNames.length + ';'
+  for (let deviceName of data.createDeviceNames) {
+    text += '< ' + deviceName + ' >'
+  }
+  text += '更新成功数量:' + data.updateDeviceNames.length + ';'
+  for (const deviceName of data.updateDeviceNames) {
+    text += '< ' + deviceName + ' >'
+  }
+  text += '更新失败数量:' + Object.keys(data.failureDeviceNames).length + ';'
+  for (const deviceName in data.failureDeviceNames) {
+    text += '< ' + deviceName + ': ' + data.failureDeviceNames[deviceName] + ' >'
+  }
+  message.alert(text)
+  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('最多只能上传一个文件!')
+}
+
+/** 下载模板操作 */
+const importTemplate = async () => {
+  const res = await DeviceApi.importDeviceTemplate()
+  download.excel(res, '设备导入模版.xls')
+}
+</script>

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

@@ -0,0 +1,110 @@
+<!-- 设备物模型 -> 运行状态 -> 查看数据(设备的属性值历史)-->
+<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>

+ 119 - 0
src/views/iot/device/device/detail/DeviceDetailConfig.vue

@@ -0,0 +1,119 @@
+<!-- 设备配置 -->
+<template>
+  <div>
+    <el-alert
+      title="支持远程更新设备的配置文件(JSON 格式),可以在下方编辑配置模板,对设备的系统参数、网络参数等进行远程配置。配置完成后,需点击「下发」按钮,设备即可进行远程配置。"
+      type="info"
+      show-icon
+      class="my-4"
+      description="如需编辑文件,请点击下方编辑按钮"
+    />
+
+    <!-- JSON 编辑器:读模式 -->
+    <Vue3Jsoneditor
+      v-if="isEditing"
+      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"
+      @error="onError"
+    />
+    <div class="mt-5 text-center">
+      <el-button v-if="isEditing" @click="cancelEdit">取消</el-button>
+      <el-button v-if="isEditing" type="primary" @click="saveConfig" :disabled="hasJsonError">
+        保存
+      </el-button>
+      <el-button v-else @click="enableEdit">编辑</el-button>
+      <!-- TODO @芋艿:缺一个下发按钮 -->
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import Vue3Jsoneditor from 'v3-jsoneditor/src/Vue3Jsoneditor.vue'
+import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
+import { jsonParse } from '@/utils'
+
+const props = defineProps<{
+  device: DeviceVO
+}>()
+
+const emit = defineEmits<{
+  (e: 'success'): void // 定义 success 事件,不需要参数
+}>()
+
+const message = useMessage()
+const loading = ref(false) // 加载中
+const config = ref<any>({}) // 只存储 config 字段
+const hasJsonError = ref(false) // 是否有 JSON 格式错误
+
+/** 监听 props.device 的变化,只更新 config 字段 */
+watchEffect(() => {
+  config.value = jsonParse(props.device.config)
+})
+
+const isEditing = ref(false) // 编辑状态
+const editorOptions = computed(() => ({
+  mainMenuBar: false,
+  navigationBar: false,
+  statusBar: false
+})) // JSON 编辑器的选项
+
+/** 启用编辑模式的函数 */
+const enableEdit = () => {
+  isEditing.value = true
+  hasJsonError.value = false // 重置错误状态
+}
+
+/** 取消编辑的函数 */
+const cancelEdit = () => {
+  config.value = jsonParse(props.device.config)
+  isEditing.value = false
+  hasJsonError.value = false // 重置错误状态
+}
+
+/** 保存配置的函数 */
+const saveConfig = async () => {
+  if (hasJsonError.value) {
+    message.error('JSON格式错误,请修正后再提交!')
+    return
+  }
+  await updateDeviceConfig()
+  isEditing.value = false
+}
+
+/** 更新设备配置 */
+const updateDeviceConfig = async () => {
+  try {
+    // 提交请求
+    loading.value = true
+    await DeviceApi.updateDevice({
+      id: props.device.id,
+      config: JSON.stringify(config.value)
+    } as DeviceVO)
+    message.success('更新成功!')
+    // 触发 success 事件
+    emit('success')
+  } catch (error) {
+    console.error(error)
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 处理 JSON 编辑器错误的函数 */
+const onError = (e: any) => {
+  console.log('onError', e)
+  hasJsonError.value = true
+}
+</script>

+ 16 - 23
src/views/iot/device/detail/DeviceDetailsHeader.vue → src/views/iot/device/device/detail/DeviceDetailsHeader.vue

@@ -1,3 +1,4 @@
+<!-- 设备信息(头部) -->
 <template>
   <div>
     <div class="flex items-start justify-between">
@@ -35,41 +36,33 @@
   <DeviceForm ref="formRef" @success="emit('refresh')" />
 </template>
 <script setup lang="ts">
-import { ref } from 'vue'
-import DeviceForm from '@/views/iot/device/DeviceForm.vue'
-import { ProductVO } from '@/api/iot/product'
-import { DeviceVO } from '@/api/iot/device'
-import { useRouter } from 'vue-router'
+import DeviceForm from '@/views/iot/device/device/DeviceForm.vue'
+import { ProductVO } from '@/api/iot/product/product'
+import { DeviceVO } from '@/api/iot/device/device'
 
 const message = useMessage()
 const router = useRouter()
 
-// 操作修改
+const { product, device } = defineProps<{ product: ProductVO; device: DeviceVO }>()
+const emit = defineEmits(['refresh'])
+
+/** 操作修改 */
 const formRef = ref()
 const openForm = (type: string, id?: number) => {
   formRef.value.open(type, id)
 }
 
-const { product, device } = defineProps<{ product: ProductVO; device: DeviceVO }>()
-const emit = defineEmits(['refresh'])
-
-/**
- * 将文本复制到剪贴板
- *
- * @param text 需要复制的文本
- */
-const copyToClipboard = (text: string) => {
-  // TODO @haohao:可以考虑用 await 异步转同步哈
-  navigator.clipboard.writeText(text).then(() => {
+/** 复制到剪贴板方法 */
+const copyToClipboard = async (text: string) => {
+  try {
+    await navigator.clipboard.writeText(text)
     message.success('复制成功')
-  })
+  } catch (error) {
+    message.error('复制失败')
+  }
 }
 
-/**
- * 跳转到产品详情页面
- *
- * @param productId 产品 ID
- */
+/** 跳转到产品详情页面 */
 const goToProductDetail = (productId: number) => {
   router.push({ name: 'IoTProductDetail', params: { id: productId } })
 }

+ 144 - 0
src/views/iot/device/device/detail/DeviceDetailsInfo.vue

@@ -0,0 +1,144 @@
+<!-- 设备信息 -->
+<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" />
+            </el-button>
+          </template>
+        </el-input>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="mqttDialogVisible = false">关闭</el-button>
+    </template>
+  </Dialog>
+
+  <!-- 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'
+
+const message = useMessage() // 消息提示
+
+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 copyToClipboard = async (text: string) => {
+  try {
+    await navigator.clipboard.writeText(text)
+    message.success('复制成功')
+  } catch (error) {
+    message.error('复制失败')
+  }
+}
+
+/** 打开 MQTT 参数弹框的方法 */
+const openMqttParams = 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
+  } catch (error) {
+    console.error('获取 MQTT 连接参数出错:', error)
+    message.error('获取MQTT连接参数失败,请检查网络连接或联系管理员')
+  }
+}
+
+/** 关闭 MQTT 弹框的方法 */
+const handleCloseMqttDialog = () => {
+  mqttDialogVisible.value = false
+}
+</script>

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

@@ -0,0 +1,166 @@
+<!-- 设备日志 -->
+<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>

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

@@ -0,0 +1,134 @@
+<!-- 设备物模型:运行状态(属性)、事件管理、服务调用 -->
+<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>

+ 331 - 0
src/views/iot/device/device/detail/DeviceDetailsSimulator.vue

@@ -0,0 +1,331 @@
+<!-- 模拟设备 -->
+<template>
+  <ContentWrap>
+    <el-row :gutter="20">
+      <!-- 左侧指令调试区域 -->
+      <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="property">
+                <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:不用翻译,可以减少宽度的占用 -->
+                      <template #default="{ row }">
+                        {{ dataTypeOptionsLabel(row.property?.dataType) ?? '-' }}
+                      </template>
+                    </el-table-column>
+                    <el-table-column align="left" label="数据定义" prop="identifier">
+                      <template #default="{ row }">
+                        <DataDefinition :data="row" />
+                      </template>
+                    </el-table-column>
+                    <!-- TODO @super:可以右侧 fixed -->
+                    <el-table-column align="center" label="值" width="80">
+                      <template #default="scope">
+                        <el-input v-model="scope.row.simulateValue" class="!w-60px" />
+                      </template>
+                    </el-table-column>
+                  </el-table>
+                  <!-- TODO @super:发送按钮,可以放在右侧哈。因为我们的 simulateValue 就在最右侧 -->
+                  <div class="mt-10px">
+                    <el-button type="primary" @click="handlePropertyReport"> 发送</el-button>
+                  </div>
+                </ContentWrap>
+              </el-tab-pane>
+
+              <!-- 事件上报 -->
+              <!-- TODO @super:待实现 -->
+              <el-tab-pane label="事件上报" name="event">
+                <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-column
+                      label="数据定义"
+                      align="center"
+                      prop="specs"
+                      :show-overflow-tooltip="true"
+                    />
+                    <el-table-column label="值" align="center" width="80">
+                      <template #default="scope">
+                        <el-input v-model="scope.row.simulateValue" class="!w-60px" />
+                      </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">
+                <ContentWrap>
+                  <div class="flex gap-4">
+                    <el-button type="primary" @click="handleDeviceState(DeviceStateEnum.ONLINE)">
+                      设备上线
+                    </el-button>
+                    <el-button type="danger" @click="handleDeviceState(DeviceStateEnum.OFFLINE)">
+                      设备下线
+                    </el-button>
+                  </div>
+                </ContentWrap>
+              </el-tab-pane>
+            </el-tabs>
+          </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="propertyDebug">
+                <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-column
+                      label="数据定义"
+                      align="center"
+                      prop="specs"
+                      :show-overflow-tooltip="true"
+                    />
+                    <el-table-column label="值" align="center" width="80">
+                      <template #default="scope">
+                        <el-input v-model="scope.row.simulateValue" class="!w-60px" />
+                      </template>
+                    </el-table-column>
+                  </el-table>
+                  <div class="mt-10px">
+                    <el-button type="primary" @click="handlePropertyGet">获取</el-button>
+                  </div> -->
+                </ContentWrap>
+              </el-tab-pane>
+
+              <!-- 服务调用 -->
+              <!-- TODO @super:待实现 -->
+              <el-tab-pane label="服务调用" name="service">
+                <ContentWrap>
+                  <!-- 服务调用相关内容 -->
+                </ContentWrap>
+              </el-tab-pane>
+            </el-tabs>
+          </el-tab-pane>
+        </el-tabs>
+      </el-col>
+
+      <!-- 右侧设备日志区域 -->
+      <el-col :span="12">
+        <el-tabs type="border-card">
+          <el-tab-pane label="设备日志">
+            <DeviceDetailsLog :device-key="device.deviceKey" />
+          </el-tab-pane>
+        </el-tabs>
+      </el-col>
+    </el-row>
+  </ContentWrap>
+</template>
+
+<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 { DataDefinition } from '@/views/iot/thingmodel/components'
+
+const props = defineProps<{
+  product: ProductVO
+  device: DeviceVO
+}>()
+
+const message = useMessage() // 消息弹窗
+const activeTab = ref('up') // TODO @super:upstream 上行、downstream 下行
+const subTab = ref('property') // TODO @super:upstreamTab
+
+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)) // 解析数据类型
+
+/** 查询物模型列表 */
+// 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
+  }
+}
+
+// // 功能列表数据结构定义
+// 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 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
+    }
+  })
+
+  try {
+    await DeviceApi.upstreamDevice({
+      id: props.device.id,
+      type: 'property',
+      identifier: 'report',
+      data: data
+    })
+    message.success('属性上报成功')
+  } 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 字符串
+//   }
+
+//   try {
+//     // TODO: 调用API发送数据
+//     console.log('上报数据:', reportData)
+//     message.success('事件上报成功')
+//   } catch (error) {
+//     message.error('事件上报失败')
+//   }
+// }
+
+/** 处理设备状态 */
+const handleDeviceState = async (state: number) => {
+  try {
+    await DeviceApi.upstreamDevice({
+      id: props.device.id,
+      type: 'state',
+      identifier: 'report',
+      data: state
+    })
+    message.success(`设备${state === DeviceStateEnum.ONLINE ? '上线' : '下线'}成功`)
+  } catch (error) {
+    message.error(`设备${state === DeviceStateEnum.ONLINE ? '上线' : '下线'}失败`)
+  }
+}
+
+// 处理属性获取
+const handlePropertyGet = async () => {
+  // TODO: 实现属性获取逻辑
+  message.success('属性获取成功')
+}
+
+// 初始化
+onMounted(() => {
+  getList()
+})
+// TODO @芋艿:后续再详细 review 下;
+</script>

+ 88 - 0
src/views/iot/device/device/detail/index.vue

@@ -0,0 +1,88 @@
+<template>
+  <DeviceDetailsHeader
+    :loading="loading"
+    :product="product"
+    :device="device"
+    @refresh="getDeviceData(id)"
+  />
+  <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" />
+      </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>
+      <el-tab-pane label="模拟设备" name="simulator">
+        <DeviceDetailsSimulator
+          v-if="activeTab === 'simulator'"
+          :product="product"
+          :device="device"
+        />
+      </el-tab-pane>
+      <el-tab-pane label="设备配置" name="config">
+        <DeviceDetailConfig
+          v-if="activeTab === 'config'"
+          :device="device"
+          @success="getDeviceData"
+        />
+      </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 DeviceDetailsHeader from './DeviceDetailsHeader.vue'
+import DeviceDetailsInfo from './DeviceDetailsInfo.vue'
+import DeviceDetailsModel from './DeviceDetailsModel.vue'
+import DeviceDetailsLog from './DeviceDetailsLog.vue'
+import DeviceDetailsSimulator from './DeviceDetailsSimulator.vue'
+import DeviceDetailConfig from './DeviceDetailConfig.vue'
+
+defineOptions({ name: 'IoTDeviceDetail' })
+
+const route = useRoute()
+const message = useMessage()
+const id = route.params.id // 将字符串转换为数字
+const loading = ref(true) // 加载中
+const product = ref<ProductVO>({} as ProductVO) // 产品详情
+const device = ref<DeviceVO>({} as DeviceVO) // 设备详情
+const activeTab = ref('info') // 默认激活的标签页
+
+/** 获取设备详情 */
+const getDeviceData = async () => {
+  loading.value = true
+  try {
+    device.value = await DeviceApi.getDevice(id)
+    await getProductData(device.value.productId)
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 获取产品详情 */
+const getProductData = async (id: number) => {
+  product.value = await ProductApi.getProduct(id)
+}
+
+/** 初始化 */
+const { delView } = useTagsViewStore() // 视图操作
+const { currentRoute } = useRouter() // 路由
+onMounted(async () => {
+  if (!id) {
+    message.warning('参数错误,产品不能为空!')
+    delView(unref(currentRoute))
+    return
+  }
+  await getDeviceData()
+  activeTab.value = (route.query.tab as string) || 'info'
+})
+</script>

+ 516 - 0
src/views/iot/device/device/index.vue

@@ -0,0 +1,516 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item 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 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:device:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" />
+          新增
+        </el-button>
+        <el-button
+          type="success"
+          plain
+          @click="handleExport"
+          :loading="exportLoading"
+          v-hasPermi="['iot:device:export']"
+        >
+          <Icon icon="ep:download" class="mr-5px" /> 导出
+        </el-button>
+        <el-button type="warning" plain @click="handleImport" v-hasPermi="['iot:device:import']">
+          <Icon icon="ep:upload" /> 导入
+        </el-button>
+        <el-button
+          type="primary"
+          plain
+          @click="openGroupForm"
+          :disabled="selectedIds.length === 0"
+          v-hasPermi="['iot:device:update']"
+        >
+          <Icon icon="ep:folder-add" class="mr-5px" /> 添加到分组
+        </el-button>
+        <el-button
+          type="danger"
+          plain
+          @click="handleDeleteList"
+          :disabled="selectedIds.length === 0"
+          v-hasPermi="['iot:device:delete']"
+        >
+          <Icon icon="ep:delete" class="mr-5px" /> 批量删除
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <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="absolute top-0 left-0 right-0 h-[50px] pointer-events-none"
+              :class="[
+                item.state === DeviceStateEnum.ONLINE
+                  ? 'bg-gradient-to-b from-[#eefaff] to-transparent'
+                  : 'bg-gradient-to-b from-[#fff1f1] to-transparent'
+              ]"
+            >
+            </div>
+            <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.deviceName }}</div>
+                <!-- 添加设备状态标签 -->
+                <div class="inline-flex items-center">
+                  <div
+                    class="w-1 h-1 rounded-full mr-1.5"
+                    :class="
+                      item.state === DeviceStateEnum.ONLINE
+                        ? 'bg-[var(--el-color-success)]'
+                        : 'bg-[var(--el-color-danger)]'
+                    "
+                  >
+                  </div>
+                  <el-text
+                    class="!text-xs font-bold"
+                    :type="item.state === DeviceStateEnum.ONLINE ? 'success' : 'danger'"
+                  >
+                    {{ getDictLabel(DICT_TYPE.IOT_DEVICE_STATE, item.state) }}
+                  </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-[#0070ff]">
+                      {{ products.find((p) => p.id === item.productId)?.name }}
+                    </span>
+                  </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-[#0b1d30] inline-block align-middle overflow-hidden text-ellipsis whitespace-nowrap max-w-[130px]"
+                    >
+                      {{ item.deviceKey }}
+                    </span>
+                  </div>
+                </div>
+                <div class="w-[100px] h-[100px]">
+                  <el-image :src="defaultPicUrl" class="w-full h-full" />
+                </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:device: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>
+                <el-button
+                  class="flex-1 !px-2 !h-[32px] !ml-[10px] text-[13px]"
+                  type="info"
+                  plain
+                  @click="openModel(item.id)"
+                >
+                  <Icon icon="ep:tickets" 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>
+
+    <!-- 列表视图 -->
+    <el-table
+      v-else
+      v-loading="loading"
+      :data="list"
+      :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="scope">
+          <el-link @click="openDetail(scope.row.id)">{{ scope.row.deviceName }}</el-link>
+        </template>
+      </el-table-column>
+      <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-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="openModel(scope.row.id)"> 日志 </el-button>
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['iot:device:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            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="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <DeviceForm ref="formRef" @success="getList" />
+  <!-- 分组表单组件 -->
+  <DeviceGroupForm ref="groupFormRef" @success="getList" />
+  <!-- 导入表单组件 -->
+  <DeviceImportForm ref="importFormRef" @success="getList" />
+</template>
+
+<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 DeviceForm from './DeviceForm.vue'
+import { ProductApi, ProductVO } from '@/api/iot/product/product'
+import { DeviceGroupApi, DeviceGroupVO } from '@/api/iot/device/group'
+import download from '@/utils/download'
+import DeviceGroupForm from './DeviceGroupForm.vue'
+import DeviceImportForm from './DeviceImportForm.vue'
+
+/** IoT 设备列表 */
+defineOptions({ name: 'IoTDevice' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表加载中
+const list = ref<DeviceVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  deviceName: undefined,
+  productId: undefined,
+  deviceType: undefined,
+  nickname: undefined,
+  status: undefined,
+  groupId: undefined
+})
+const queryFormRef = ref() // 搜索的表单
+const exportLoading = ref(false) // 导出加载状态
+const products = ref<ProductVO[]>([]) // 产品列表
+const deviceGroups = ref<DeviceGroupVO[]>([]) // 设备分组列表
+const selectedIds = ref<number[]>([]) // 选中的设备编号数组
+const viewMode = ref<'card' | 'list'>('card') // 视图模式状态
+const defaultPicUrl = ref('/src/assets/imgs/iot/device.png') // 默认设备图片
+const defaultIconUrl = ref('/src/assets/svgs/iot/card-fill.svg') // 默认设备图标
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    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()
+  selectedIds.value = [] // 清空选择
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 打开详情 */
+const { push } = useRouter()
+const openDetail = (id: number) => {
+  push({ name: 'IoTDeviceDetail', params: { id } })
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 起删除
+    await DeviceApi.deleteDevice(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 导出方法 */
+const handleExport = async () => {
+  try {
+    // 导出的二次确认
+    await message.exportConfirm()
+    // 发起导出
+    exportLoading.value = true
+    const data = await DeviceApi.exportDeviceExcel(queryParams)
+    download.excel(data, '物联网设备.xls')
+  } catch {
+  } finally {
+    exportLoading.value = false
+  }
+}
+
+/** 多选框选中数据 */
+const handleSelectionChange = (selection: DeviceVO[]) => {
+  selectedIds.value = selection.map((item) => item.id)
+}
+
+/** 批量删除按钮操作 */
+const handleDeleteList = async () => {
+  try {
+    await message.delConfirm()
+    // 执行批量删除
+    await DeviceApi.deleteDeviceList(selectedIds.value)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 添加到分组操作 */
+const groupFormRef = ref()
+const openGroupForm = () => {
+  groupFormRef.value.open(selectedIds.value)
+}
+
+/** 打开物模型数据 */
+const openModel = (id: number) => {
+  push({ name: 'IoTDeviceDetail', params: { id }, query: { tab: 'model' } })
+}
+
+/** 设备导入 */
+const importFormRef = ref()
+const handleImport = () => {
+  importFormRef.value.open()
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  getList()
+
+  // 获取产品列表
+  products.value = await ProductApi.getSimpleProductList()
+  // 获取分组列表
+  deviceGroups.value = await DeviceGroupApi.getSimpleDeviceGroupList()
+})
+</script>

+ 112 - 0
src/views/iot/device/group/DeviceGroupForm.vue

@@ -0,0 +1,112 @@
+<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="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="description">
+        <el-input type="textarea" v-model="formData.description" placeholder="请输入分组描述" />
+      </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 { DeviceGroupApi, DeviceGroupVO } from '@/api/iot/device/group'
+
+/** IoT 设备分组 表单 */
+defineOptions({ name: 'IoTDeviceGroupForm' })
+
+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,
+  status: undefined,
+  description: undefined
+})
+const formRules = reactive({
+  name: [{ required: true, message: '分组名字不能为空', trigger: 'blur' }],
+  status: [{ 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 DeviceGroupApi.getDeviceGroup(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 DeviceGroupVO
+    if (formType.value === 'create') {
+      await DeviceGroupApi.createDeviceGroup(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await DeviceGroupApi.updateDeviceGroup(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    status: undefined,
+    description: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 33 - 55
src/views/iot/product/index.vue → src/views/iot/device/group/index.vue

@@ -8,22 +8,24 @@
       :inline="true"
       label-width="68px"
     >
-      <el-form-item label="产品名称" prop="name">
+      <el-form-item label="分组名字" prop="name">
         <el-input
           v-model="queryParams.name"
-          placeholder="请输入产品名称"
+          placeholder="请输入分组名字"
           clearable
           @keyup.enter="handleQuery"
           class="!w-240px"
         />
       </el-form-item>
-      <el-form-item label="ProductKey" prop="productKey">
-        <el-input
-          v-model="queryParams.productKey"
-          placeholder="请输入产品标识"
-          clearable
-          @keyup.enter="handleQuery"
-          class="!w-240px"
+      <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>
@@ -33,7 +35,7 @@
           type="primary"
           plain
           @click="openForm('create')"
-          v-hasPermi="['iot:product:create']"
+          v-hasPermi="['iot:device-group:create']"
         >
           <Icon icon="ep:plus" class="mr-5px" /> 新增
         </el-button>
@@ -44,17 +46,14 @@
   <!-- 列表 -->
   <ContentWrap>
     <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
-      <el-table-column label="产品名称" align="center" prop="name">
-        <template #default="scope">
-          <el-link @click="openDetail(scope.row.id)">{{ scope.row.name }}</el-link>
-        </template>
-      </el-table-column>
-      <el-table-column label="ProductKey" align="center" prop="productKey" />
-      <el-table-column label="设备类型" align="center" prop="deviceType">
+      <el-table-column label="分组 ID" align="center" prop="id" />
+      <el-table-column label="分组名字" align="center" prop="name" />
+      <el-table-column label="分组状态" align="center" prop="status">
         <template #default="scope">
-          <dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="scope.row.deviceType" />
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
         </template>
       </el-table-column>
+      <el-table-column label="分组描述" align="center" prop="description" />
       <el-table-column
         label="创建时间"
         align="center"
@@ -62,27 +61,22 @@
         :formatter="dateFormatter"
         width="180px"
       />
-      <el-table-column label="产品状态" align="center" prop="status">
-        <template #default="scope">
-          <dict-tag :type="DICT_TYPE.IOT_PRODUCT_STATUS" :value="scope.row.status" />
-        </template>
-      </el-table-column>
-      <el-table-column label="操作" align="center">
+      <el-table-column label="设备数量" align="center" prop="deviceCount" />
+      <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']"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['iot:device-group:update']"
           >
-            查看
+            编辑
           </el-button>
           <el-button
             link
             type="danger"
             @click="handleDelete(scope.row.id)"
-            v-hasPermi="['iot:product:delete']"
-            :disabled="scope.row.status === 1"
+            v-hasPermi="['iot:device-group:delete']"
           >
             删除
           </el-button>
@@ -99,39 +93,29 @@
   </ContentWrap>
 
   <!-- 表单弹窗:添加/修改 -->
-  <ProductForm ref="formRef" @success="getList" />
+  <DeviceGroupForm ref="formRef" @success="getList" />
 </template>
 
 <script setup lang="ts">
-import { dateFormatter } from '@/utils/formatTime'
-import { ProductApi, ProductVO } from '@/api/iot/product'
-import ProductForm from './ProductForm.vue'
 import { DICT_TYPE } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import { DeviceGroupApi, DeviceGroupVO } from '@/api/iot/device/group'
+import DeviceGroupForm from './DeviceGroupForm.vue'
 
-/** iot 产品 列表 */
-defineOptions({ name: 'IoTProduct' })
+/** IoT 设备分组列表 */
+defineOptions({ name: 'IoTDeviceGroup' })
 
 const message = useMessage() // 消息弹窗
 const { t } = useI18n() // 国际化
 
 const loading = ref(true) // 列表的加载中
-const list = ref<ProductVO[]>([]) // 列表的数据
+const list = ref<DeviceGroupVO[]>([]) // 列表的数据
 const total = ref(0) // 列表的总页数
 const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
   name: undefined,
-  createTime: [],
-  productKey: undefined,
-  protocolId: undefined,
-  categoryId: undefined,
-  description: undefined,
-  validateType: undefined,
-  status: undefined,
-  deviceType: undefined,
-  netType: undefined,
-  protocolType: undefined,
-  dataFormat: undefined
+  createTime: []
 })
 const queryFormRef = ref() // 搜索的表单
 
@@ -139,7 +123,7 @@ const queryFormRef = ref() // 搜索的表单
 const getList = async () => {
   loading.value = true
   try {
-    const data = await ProductApi.getProductPage(queryParams)
+    const data = await DeviceGroupApi.getDeviceGroupPage(queryParams)
     list.value = data.list
     total.value = data.total
   } finally {
@@ -165,19 +149,13 @@ const openForm = (type: string, id?: number) => {
   formRef.value.open(type, id)
 }
 
-/** 打开详情 */
-const { push } = useRouter()
-const openDetail = (id: number) => {
-  push({ name: 'IoTProductDetail', params: { id } })
-}
-
 /** 删除按钮操作 */
 const handleDelete = async (id: number) => {
   try {
     // 删除的二次确认
     await message.delConfirm()
     // 发起删除
-    await ProductApi.deleteProduct(id)
+    await DeviceGroupApi.deleteDeviceGroup(id)
     message.success(t('common.delSuccess'))
     // 刷新列表
     await getList()

+ 0 - 267
src/views/iot/device/index.vue

@@ -1,267 +0,0 @@
-<template>
-  <ContentWrap>
-    <!-- 搜索工作栏 -->
-    <el-form
-      class="-mb-15px"
-      :model="queryParams"
-      ref="queryFormRef"
-      :inline="true"
-      label-width="68px"
-    >
-      <el-form-item 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_STATUS)"
-            :key="dict.value"
-            :label="dict.label"
-            :value="dict.value"
-          />
-        </el-select>
-      </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:device:create']"
-        >
-          <Icon icon="ep:plus" 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="DeviceName" align="center" prop="deviceName">
-        <template #default="scope">
-          <el-link @click="openDetail(scope.row.id)">{{ scope.row.deviceName }}</el-link>
-        </template>
-      </el-table-column>
-      <el-table-column label="备注名称" align="center" prop="nickname" />
-      <el-table-column label="设备所属产品" align="center" prop="productId">
-        <template #default="scope">
-          {{ productMap[scope.row.productId] }}
-        </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="status">
-        <template #default="scope">
-          <dict-tag :type="DICT_TYPE.IOT_DEVICE_STATUS" :value="scope.row.status" />
-        </template>
-      </el-table-column>
-      <el-table-column
-        label="最后上线时间"
-        align="center"
-        prop="lastOnlineTime"
-        :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:device:update']"
-          >
-            编辑
-          </el-button>
-          <el-button
-            link
-            type="danger"
-            @click="handleDelete(scope.row.id)"
-            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="getList"
-    />
-  </ContentWrap>
-
-  <!-- 表单弹窗:添加/修改 -->
-  <DeviceForm ref="formRef" @success="getList" />
-</template>
-
-<script setup lang="ts">
-import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
-import { dateFormatter } from '@/utils/formatTime'
-import { DeviceApi, DeviceVO } from '@/api/iot/device'
-import DeviceForm from './DeviceForm.vue'
-import { ProductApi } from '@/api/iot/product'
-
-/** IoT 设备 列表 */
-defineOptions({ name: 'IoTDevice' })
-
-const message = useMessage() // 消息弹窗
-const { t } = useI18n() // 国际化
-
-const loading = ref(true) // 列表的加载中
-const list = ref<DeviceVO[]>([]) // 列表的数据
-const total = ref(0) // 列表的总页数
-const queryParams = reactive({
-  pageNo: 1,
-  pageSize: 10,
-  deviceName: undefined,
-  productId: undefined,
-  deviceType: undefined,
-  nickname: undefined,
-  status: undefined
-})
-const queryFormRef = ref() // 搜索的表单
-
-/** 产品标号和名称的映射 */
-const productMap = reactive({})
-
-/** 查询列表 */
-const getList = async () => {
-  loading.value = true
-  try {
-    const data = await DeviceApi.getDevicePage(queryParams)
-    list.value = data.list
-    total.value = data.total
-    // 获取产品ID列表
-    const productIds = [...new Set(data.list.map((device) => device.productId))]
-    // 获取产品名称
-    // TODO @haohao:最好后端拼接哈
-    const products = await Promise.all(productIds.map((id) => ProductApi.getProduct(id)))
-    products.forEach((product) => {
-      productMap[product.id] = product.name
-    })
-  } 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: 'IoTDeviceDetail', params: { id } })
-}
-
-/** 删除按钮操作 */
-const handleDelete = async (id: number) => {
-  try {
-    // 删除的二次确认
-    await message.delConfirm()
-    // 发起删除
-    await DeviceApi.deleteDevice(id)
-    message.success(t('common.delSuccess'))
-    // 刷新列表
-    await getList()
-  } catch {}
-}
-
-/** 查询字典下拉列表 */
-const products = ref()
-const getProducts = async () => {
-  products.value = await ProductApi.getSimpleProductList()
-}
-
-/** 初始化 **/
-onMounted(() => {
-  getList()
-  getProducts()
-})
-</script>

+ 509 - 0
src/views/iot/home/index.vue

@@ -0,0 +1,509 @@
+<template>
+  <!-- 第一行:统计卡片行 -->
+  <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>
+    </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>
+    </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>
+    </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>
+    </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>
+    </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>
+    </el-col>
+  </el-row>
+
+  <!-- 第三行:消息统计行 -->
+  <el-row>
+    <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>
+    </el-col>
+  </el-row>
+
+  <!-- TODO 第四行:地图 -->
+</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,拆一拆组件
+
+/** 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,
+  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 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)' }
+          ])
+        }
+      }
+    ]
+  })
+}
+
+/** 初始化 */
+onMounted(() => {
+  getStats()
+})
+</script>
+
+<style lang="scss" scoped></style>

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

@@ -0,0 +1,106 @@
+<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>

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

@@ -0,0 +1,99 @@
+<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>

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

@@ -0,0 +1,120 @@
+<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>

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

@@ -0,0 +1,329 @@
+<!-- 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>

+ 119 - 0
src/views/iot/product/category/ProductCategoryForm.vue

@@ -0,0 +1,119 @@
+<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="sort">
+        <el-input v-model="formData.sort" placeholder="请输入分类排序" />
+      </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="description">
+        <el-input type="textarea" v-model="formData.description" placeholder="请输入分类描述" />
+      </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 { ProductCategoryApi, ProductCategoryVO } from '@/api/iot/product/category'
+import { CommonStatusEnum } from '@/utils/constants'
+
+/** IoT 产品分类 表单 */
+defineOptions({ name: 'ProductCategoryForm' })
+
+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,
+  sort: 0,
+  status: CommonStatusEnum.ENABLE,
+  description: undefined
+})
+const formRules = reactive({
+  name: [{ required: true, message: '分类名字不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '分类状态不能为空', trigger: 'blur' }],
+  sort: [{ 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 ProductCategoryApi.getProductCategory(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 ProductCategoryVO
+    if (formType.value === 'create') {
+      await ProductCategoryApi.createProductCategory(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ProductCategoryApi.updateProductCategory(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    sort: 0,
+    status: CommonStatusEnum.ENABLE,
+    description: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

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

@@ -0,0 +1,170 @@
+<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="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:product-category:create']"
+        >
+          <Icon icon="ep:plus" 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="ID" align="center" prop="id" />
+      <el-table-column label="名字" align="center" prop="name" />
+      <el-table-column label="排序" align="center" prop="sort" />
+      <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="description" />
+      <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:product-category:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['iot:product-category: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>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <ProductCategoryForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { DICT_TYPE } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import { ProductCategoryApi, ProductCategoryVO } from '@/api/iot/product/category'
+import ProductCategoryForm from './ProductCategoryForm.vue'
+
+/** IoT 产品分类列表 */
+defineOptions({ name: 'IotProductCategory' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<ProductCategoryVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+const exportLoading = ref(false) // 导出的加载中
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ProductCategoryApi.getProductCategoryPage(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 ProductCategoryApi.deleteProductCategory(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 0 - 44
src/views/iot/product/detail/ProductDetailsInfo.vue

@@ -1,44 +0,0 @@
-<template>
-  <ContentWrap>
-    <el-collapse v-model="activeNames">
-      <el-descriptions :column="3" title="产品信息">
-        <el-descriptions-item label="产品名称">{{ product.name }}</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="创建时间">
-          {{ formatDate(product.createTime) }}
-        </el-descriptions-item>
-        <el-descriptions-item label="数据格式">
-          <dict-tag :type="DICT_TYPE.IOT_DATA_FORMAT" :value="product.dataFormat" />
-        </el-descriptions-item>
-        <el-descriptions-item label="数据校验级别">
-          <dict-tag :type="DICT_TYPE.IOT_VALIDATE_TYPE" :value="product.validateType" />
-        </el-descriptions-item>
-        <el-descriptions-item label="产品状态">
-          <dict-tag :type="DICT_TYPE.IOT_PRODUCT_STATUS" :value="product.status" />
-        </el-descriptions-item>
-        <el-descriptions-item
-          label="联网方式"
-          v-if="product.deviceType === 0 || product.deviceType === 2"
-        >
-          <dict-tag :type="DICT_TYPE.IOT_NET_TYPE" :value="product.netType" />
-        </el-descriptions-item>
-        <el-descriptions-item label="接入网关协议" v-if="product.deviceType === 1">
-          <dict-tag :type="DICT_TYPE.IOT_PROTOCOL_TYPE" :value="product.protocolType" />
-        </el-descriptions-item>
-        <el-descriptions-item label="产品描述">{{ product.description }}</el-descriptions-item>
-      </el-descriptions>
-    </el-collapse>
-  </ContentWrap>
-</template>
-<script setup lang="ts">
-import { DICT_TYPE } from '@/utils/dict'
-import { ProductVO } from '@/api/iot/product'
-import { formatDate } from '@/utils/formatTime'
-
-const { product } = defineProps<{ product: ProductVO }>()
-
-// 展示的折叠面板
-const activeNames = ref(['basicInfo'])
-</script>

+ 0 - 229
src/views/iot/product/detail/ThinkModelFunctionForm.vue

@@ -1,229 +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="type">
-        <el-radio-group v-model="formData.type">
-          <el-radio-button value="1"> 属性 </el-radio-button>
-          <el-radio-button value="2"> 服务 </el-radio-button>
-          <el-radio-button value="3"> 事件 </el-radio-button>
-        </el-radio-group>
-      </el-form-item>
-      <el-form-item label="功能名称" prop="name">
-        <el-input v-model="formData.name" placeholder="请输入功能名称" />
-      </el-form-item>
-      <el-form-item label="标识符" prop="identifier">
-        <el-input
-          v-model="formData.identifier"
-          placeholder="请输入标识符"
-          :disabled="formType === 'update'"
-        />
-      </el-form-item>
-      <el-form-item label="数据类型" prop="type">
-        <el-select
-          v-model="formData.property.dataType.type"
-          placeholder="请选择数据类型"
-          :disabled="formType === 'update'"
-        >
-          <el-option key="int" label="int32 (整数型)" value="int" />
-          <el-option key="float" label="float (单精度浮点型)" value="float" />
-          <el-option key="double" label="double (双精度浮点型)" value="double" />
-          <!--          <el-option key="text" label="text (文本型)" value="text" />-->
-          <!--          <el-option key="date" label="date (日期型)" value="date" />-->
-          <!--          <el-option key="bool" label="bool (布尔型)" value="bool" />-->
-          <!--          <el-option key="enum" label="enum (枚举型)" value="enum" />-->
-          <!--          <el-option key="struct" label="struct (结构体)" value="struct" />-->
-          <!--          <el-option key="array" label="array (数组)" value="array" />-->
-        </el-select>
-      </el-form-item>
-      <el-form-item label="取值范围" prop="max">
-        <el-input v-model="formData.property.dataType.specs.min" placeholder="请输入最小值" />
-        <span class="mx-2">~</span>
-        <el-input v-model="formData.property.dataType.specs.max" placeholder="请输入最大值" />
-      </el-form-item>
-      <el-form-item label="步长" prop="step">
-        <el-input v-model="formData.property.dataType.specs.step" placeholder="请输入步长" />
-      </el-form-item>
-      <el-form-item label="单位" prop="unit">
-        <el-input v-model="formData.property.dataType.specs.unit" placeholder="请输入单位" />
-      </el-form-item>
-      <el-form-item label="读写类型" prop="accessMode">
-        <el-radio-group v-model="formData.property.accessMode">
-          <el-radio label="rw">读写</el-radio>
-          <el-radio label="r">只读</el-radio>
-        </el-radio-group>
-      </el-form-item>
-      <el-form-item label="属性描述" prop="property.description">
-        <el-input
-          type="textarea"
-          v-model="formData.property.description"
-          placeholder="请输入属性描述"
-        />
-      </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 { ProductVO } from '@/api/iot/product'
-import { ThinkModelFunctionApi, ThinkModelFunctionVO } from '@/api/iot/thinkmodelfunction'
-
-const props = defineProps<{ product: ProductVO }>()
-
-defineOptions({ name: 'ThinkModelFunctionForm' })
-
-const { t } = useI18n()
-const message = useMessage()
-
-const dialogVisible = ref(false)
-const dialogTitle = ref('')
-const formLoading = ref(false)
-const formType = ref('')
-const formData = ref({
-  id: undefined,
-  productId: undefined,
-  productKey: undefined,
-  identifier: undefined,
-  name: undefined,
-  description: undefined,
-  type: '1',
-  property: {
-    identifier: undefined,
-    name: undefined,
-    accessMode: 'rw',
-    required: true,
-    dataType: {
-      type: undefined,
-      specs: {
-        min: undefined,
-        max: undefined,
-        step: undefined,
-        unit: undefined
-      }
-    },
-    description: undefined // 添加这一行
-  }
-})
-const formRules = reactive({
-  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: (rule, value, callback) => {
-        const reservedKeywords = ['set', 'get', 'post', 'property', 'event', 'time', 'value']
-        if (reservedKeywords.includes(value)) {
-          callback(
-            new Error(
-              'set, get, post, property, event, time, value 是系统保留字段,不能用于标识符定义'
-            )
-          )
-        } else {
-          callback()
-        }
-      },
-      trigger: 'blur'
-    }
-  ],
-  property: {
-    dataType: {
-      type: [{ required: true, message: '数据类型不能为空', trigger: 'blur' }]
-    },
-    accessMode: [{ required: true, message: '读写类型不能为空', trigger: 'blur' }]
-  }
-})
-const formRef = 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 ThinkModelFunctionApi.getThinkModelFunction(id)
-    } finally {
-      formLoading.value = false
-    }
-  }
-}
-defineExpose({ open, close: () => (dialogVisible.value = false) })
-
-/** 提交表单 */
-const emit = defineEmits(['success'])
-const submitForm = async () => {
-  await formRef.value.validate()
-  formLoading.value = true
-  try {
-    const data = formData.value as unknown as ThinkModelFunctionVO
-    data.productId = props.product.id
-    data.productKey = props.product.productKey
-    if (formType.value === 'create') {
-      await ThinkModelFunctionApi.createThinkModelFunction(data)
-      message.success(t('common.createSuccess'))
-    } else {
-      await ThinkModelFunctionApi.updateThinkModelFunction(data)
-      message.success(t('common.updateSuccess'))
-    }
-    dialogVisible.value = false // 确保关闭弹框
-    emit('success')
-  } finally {
-    formLoading.value = false
-  }
-}
-
-/** 重置表单 */
-const resetForm = () => {
-  formData.value = {
-    id: undefined,
-    productId: undefined,
-    productKey: undefined,
-    identifier: undefined,
-    name: undefined,
-    description: undefined,
-    type: '1', // todo @HAOHAO:看看枚举下
-    property: {
-      identifier: undefined,
-      name: undefined,
-      accessMode: 'rw',
-      required: true,
-      dataType: {
-        type: undefined,
-        specs: {
-          min: undefined,
-          max: undefined,
-          step: undefined,
-          unit: undefined
-        }
-      },
-      description: undefined // 确保重置 description 字段
-    }
-  }
-  formRef.value?.resetFields()
-}
-</script>

+ 100 - 49
src/views/iot/product/ProductForm.vue → src/views/iot/product/product/ProductForm.vue

@@ -4,30 +4,48 @@
       ref="formRef"
       :model="formData"
       :rules="formRules"
-      label-width="100px"
+      label-width="110px"
       v-loading="formLoading"
     >
+      <el-form-item label="ProductKey" prop="productKey">
+        <el-input
+          v-model="formData.productKey"
+          placeholder="请输入 ProductKey"
+          :readonly="formType === 'update'"
+        >
+          <template #append>
+            <el-button @click="generateProductKey" :disabled="formType === 'update'">
+              重新生成
+            </el-button>
+          </template>
+        </el-input>
+      </el-form-item>
       <el-form-item label="产品名称" prop="name">
         <el-input v-model="formData.name" placeholder="请输入产品名称" />
       </el-form-item>
-
-      <el-form-item label="设备类型" prop="deviceType">
-        <el-select
-          v-model="formData.deviceType"
-          placeholder="请选择设备类型"
-          :disabled="formType === 'update'"
-        >
+      <el-form-item label="产品分类" prop="categoryId">
+        <el-select v-model="formData.categoryId" placeholder="请选择产品分类" clearable>
           <el-option
-            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE)"
-            :key="dict.value"
-            :label="dict.label"
-            :value="dict.value"
+            v-for="category in categoryList"
+            :key="category.id"
+            :label="category.name"
+            :value="category.id"
           />
         </el-select>
       </el-form-item>
-
+      <el-form-item label="设备类型" prop="deviceType">
+        <el-radio-group v-model="formData.deviceType" :disabled="formType === 'update'">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
       <el-form-item
-        v-if="formData.deviceType === 0 || formData.deviceType === 2"
+        v-if="[DeviceTypeEnum.DEVICE, DeviceTypeEnum.GATEWAY].includes(formData.deviceType)"
         label="联网方式"
         prop="netType"
       >
@@ -44,8 +62,11 @@
           />
         </el-select>
       </el-form-item>
-
-      <el-form-item v-if="formData.deviceType === 1" label="接入网关协议" prop="protocolType">
+      <el-form-item
+        v-if="formData.deviceType === DeviceTypeEnum.GATEWAY_SUB"
+        label="接入网关协议"
+        prop="protocolType"
+      >
         <el-select
           v-model="formData.protocolType"
           placeholder="请选择接入网关协议"
@@ -59,22 +80,17 @@
           />
         </el-select>
       </el-form-item>
-
       <el-form-item label="数据格式" prop="dataFormat">
-        <el-select
-          v-model="formData.dataFormat"
-          placeholder="请选择接数据格式"
-          :disabled="formType === 'update'"
-        >
-          <el-option
+        <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.label"
-            :value="dict.value"
-          />
-        </el-select>
+            :label="dict.value"
+          >
+            {{ 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
@@ -86,12 +102,20 @@
           </el-radio>
         </el-radio-group>
       </el-form-item>
-
-      <el-form-item label="产品描述" prop="description">
-        <el-input type="textarea" v-model="formData.description" placeholder="请输入产品描述" />
-      </el-form-item>
+      <el-collapse>
+        <el-collapse-item title="更多配置">
+          <el-form-item label="产品图标" prop="icon">
+            <UploadImg v-model="formData.icon" :height="'80px'" :width="'80px'" />
+          </el-form-item>
+          <el-form-item label="产品图片" prop="picUrl">
+            <UploadImg v-model="formData.picUrl" :height="'120px'" :width="'120px'" />
+          </el-form-item>
+          <el-form-item label="产品描述" prop="description">
+            <el-input type="textarea" v-model="formData.description" placeholder="请输入产品描述" />
+          </el-form-item>
+        </el-collapse-item>
+      </el-collapse>
     </el-form>
-
     <template #footer>
       <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
       <el-button @click="dialogVisible = false">取 消</el-button>
@@ -100,8 +124,17 @@
 </template>
 
 <script setup lang="ts">
-import { ProductApi, ProductVO } from '@/api/iot/product'
+import {
+  ValidateTypeEnum,
+  ProductApi,
+  ProductVO,
+  DataFormatEnum,
+  DeviceTypeEnum
+} from '@/api/iot/product/product'
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { ProductCategoryApi, ProductCategoryVO } from '@/api/iot/product/category'
+import { UploadImg } from '@/components/UploadFile'
+import { generateRandomStr } from '@/utils'
 
 defineOptions({ name: 'IoTProductForm' })
 
@@ -113,37 +146,44 @@ const dialogTitle = ref('')
 const formLoading = ref(false)
 const formType = ref('')
 const formData = ref({
-  name: undefined,
   id: undefined,
-  productKey: undefined,
-  protocolId: undefined,
+  name: undefined,
+  productKey: '',
   categoryId: undefined,
+  icon: undefined,
+  picUrl: undefined,
   description: undefined,
-  validateType: undefined,
-  status: undefined,
   deviceType: undefined,
   netType: undefined,
   protocolType: undefined,
-  dataFormat: undefined
+  protocolId: undefined,
+  dataFormat: DataFormatEnum.JSON,
+  validateType: ValidateTypeEnum.WEAK
 })
 const formRules = reactive({
+  productKey: [{ required: true, message: 'ProductKey 不能为空', trigger: 'blur' }],
   name: [{ required: true, message: '产品名称不能为空', trigger: 'blur' }],
+  categoryId: [{ required: true, message: '产品分类不能为空', trigger: 'change' }],
   deviceType: [{ required: true, message: '设备类型不能为空', trigger: 'change' }],
   netType: [
     {
-      // TODO @haohao:0、1、/2 最好前端也枚举下;另外,这里的 required 可以直接设置为 true。然后表单那些 v-if。只要不存在,它自动就不校验了哈
-      required: formData.deviceType === 0 || formData.deviceType === 2,
+      required: true,
       message: '联网方式不能为空',
       trigger: 'change'
     }
   ],
   protocolType: [
-    { required: formData.deviceType === 1, message: '接入网关协议不能为空', trigger: 'change' }
+    {
+      required: true,
+      message: '接入网关协议不能为空',
+      trigger: 'change'
+    }
   ],
   dataFormat: [{ required: true, message: '数据格式不能为空', trigger: 'change' }],
   validateType: [{ required: true, message: '数据校验级别不能为空', trigger: 'change' }]
 })
 const formRef = ref()
+const categoryList = ref<ProductCategoryVO[]>([]) // 产品分类列表
 
 /** 打开弹窗 */
 const open = async (type: string, id?: number) => {
@@ -158,7 +198,12 @@ const open = async (type: string, id?: number) => {
     } finally {
       formLoading.value = false
     }
+  } else {
+    // 新增时,生成随机 productKey
+    generateProductKey()
   }
+  // 加载分类列表
+  categoryList.value = await ProductCategoryApi.getSimpleProductCategoryList()
 }
 defineExpose({ open, close: () => (dialogVisible.value = false) })
 
@@ -186,19 +231,25 @@ const submitForm = async () => {
 /** 重置表单 */
 const resetForm = () => {
   formData.value = {
-    name: undefined,
     id: undefined,
-    productKey: undefined,
-    protocolId: undefined,
+    name: undefined,
+    productKey: '',
     categoryId: undefined,
+    icon: undefined,
+    picUrl: undefined,
     description: undefined,
-    validateType: undefined,
-    status: undefined,
     deviceType: undefined,
     netType: undefined,
     protocolType: undefined,
-    dataFormat: undefined
+    protocolId: undefined,
+    dataFormat: DataFormatEnum.JSON,
+    validateType: ValidateTypeEnum.WEAK
   }
   formRef.value?.resetFields()
 }
+
+/** 生成 ProductKey */
+const generateProductKey = () => {
+  formData.value.productKey = generateRandomStr(16)
+}
 </script>

+ 18 - 11
src/views/iot/product/detail/ProductDetailsHeader.vue → src/views/iot/product/product/detail/ProductDetailsHeader.vue

@@ -45,8 +45,8 @@
     </el-descriptions>
     <el-descriptions :column="5" direction="horizontal">
       <el-descriptions-item label="设备数">
-        {{ product.deviceCount }}
-        <el-button @click="goToManagement(product.id)">前往管理</el-button>
+        {{ product.deviceCount ?? '加载中...' }}
+        <el-button @click="goToDeviceList(product.id)">前往管理</el-button>
       </el-descriptions-item>
     </el-descriptions>
   </ContentWrap>
@@ -54,32 +54,37 @@
   <ProductForm ref="formRef" @success="emit('refresh')" />
 </template>
 <script setup lang="ts">
-import ProductForm from '@/views/iot/product/ProductForm.vue'
-import { ProductApi, ProductVO } from '@/api/iot/product'
+import ProductForm from '@/views/iot/product/product/ProductForm.vue'
+import { ProductApi, ProductVO } from '@/api/iot/product/product'
 
 const message = useMessage()
 
 const { product } = defineProps<{ product: ProductVO }>() // 定义 Props
 
-/** 处理复制 */
-const copyToClipboard = (text: string) => {
-  navigator.clipboard.writeText(text).then(() => {
+/** 复制到剪贴板方法 */
+const copyToClipboard = async (text: string) => {
+  try {
+    await navigator.clipboard.writeText(text)
     message.success('复制成功')
-  })
+  } catch (error) {
+    message.error('复制失败')
+  }
 }
 
 /** 路由跳转到设备管理 */
 const { push } = useRouter()
-const goToManagement = (productId: string) => {
-  push({ name: 'IoTDevice', query: { productId } })
+const goToDeviceList = (productId: number) => {
+  push({ name: 'IoTDevice', params: { productId } })
 }
 
-/** 操作修改 */
+/** 修改操作 */
 const emit = defineEmits(['refresh']) // 定义 Emits
 const formRef = ref()
 const openForm = (type: string, id?: number) => {
   formRef.value.open(type, id)
 }
+
+/** 发布操作 */
 const confirmPublish = async (id: number) => {
   try {
     await ProductApi.updateProductStatus(id, 1)
@@ -90,6 +95,8 @@ const confirmPublish = async (id: number) => {
     message.error('发布失败')
   }
 }
+
+/** 撤销发布操作 */
 const confirmUnpublish = async (id: number) => {
   try {
     await ProductApi.updateProductStatus(id, 0)

+ 43 - 0
src/views/iot/product/product/detail/ProductDetailsInfo.vue

@@ -0,0 +1,43 @@
+<template>
+  <ContentWrap>
+    <el-descriptions :column="3" title="产品信息">
+      <el-descriptions-item label="产品名称">{{ product.name }}</el-descriptions-item>
+      <el-descriptions-item label="所属分类">{{ product.categoryName }}</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="创建时间">
+        {{ formatDate(product.createTime) }}
+      </el-descriptions-item>
+      <el-descriptions-item label="数据格式">
+        <dict-tag :type="DICT_TYPE.IOT_DATA_FORMAT" :value="product.dataFormat" />
+      </el-descriptions-item>
+      <el-descriptions-item label="数据校验级别">
+        <dict-tag :type="DICT_TYPE.IOT_VALIDATE_TYPE" :value="product.validateType" />
+      </el-descriptions-item>
+      <el-descriptions-item label="产品状态">
+        <dict-tag :type="DICT_TYPE.IOT_PRODUCT_STATUS" :value="product.status" />
+      </el-descriptions-item>
+      <el-descriptions-item
+        label="联网方式"
+        v-if="[DeviceTypeEnum.DEVICE, DeviceTypeEnum.GATEWAY].includes(product.deviceType)"
+      >
+        <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>
+      <el-descriptions-item label="产品描述">{{ product.description }}</el-descriptions-item>
+    </el-descriptions>
+  </ContentWrap>
+</template>
+<script setup lang="ts">
+import { DICT_TYPE } from '@/utils/dict'
+import { DeviceTypeEnum, ProductVO } from '@/api/iot/product/product'
+import { formatDate } from '@/utils/formatTime'
+
+const { product } = defineProps<{ product: ProductVO }>()
+</script>

+ 22 - 18
src/views/iot/product/detail/ProductTopic.vue → src/views/iot/product/product/detail/ProductTopic.vue

@@ -3,9 +3,9 @@
     <el-tabs>
       <el-tab-pane label="基础通信 Topic">
         <Table
-          :columns="columns1"
-          :data="data1"
-          :span-method="createSpanMethod(data1)"
+          :columns="basicColumn"
+          :data="basicData"
+          :span-method="createSpanMethod(basicData)"
           align="left"
           headerAlign="left"
           border="true"
@@ -13,9 +13,9 @@
       </el-tab-pane>
       <el-tab-pane label="物模型通信 Topic">
         <Table
-          :columns="columns2"
-          :data="data2"
-          :span-method="createSpanMethod(data2)"
+          :columns="functionColumn"
+          :data="functionData"
+          :span-method="createSpanMethod(functionData)"
           align="left"
           headerAlign="left"
           border="true"
@@ -25,27 +25,22 @@
   </ContentWrap>
 </template>
 <script setup lang="ts">
-import { ProductVO } from '@/api/iot/product'
+import { ProductVO } from '@/api/iot/product/product'
 
 const props = defineProps<{ product: ProductVO }>()
 
-// 定义列
-const columns1 = reactive([
-  { label: '功能', field: 'function', width: 150 },
-  { label: 'Topic 类', field: 'topicClass', width: 800 },
-  { label: '操作权限', field: 'operationPermission', width: 100 },
-  { label: '描述', field: 'description' }
-])
+// TODO 芋艿:不确定未来会不会改,所以先写死
 
-const columns2 = reactive([
+// 基础通信 Topic 列
+const basicColumn = reactive([
   { label: '功能', field: 'function', width: 150 },
   { label: 'Topic 类', field: 'topicClass', width: 800 },
   { label: '操作权限', field: 'operationPermission', width: 100 },
   { label: '描述', field: 'description' }
 ])
 
-// TODO @haohao:这个,有没可能写到一个枚举里,方便后续维护? /Users/yunai/Java/yudao-ui-admin-vue3/src/views/ai/utils/constants.ts
-const data1 = computed(() => {
+// 基础通信 Topic 数据
+const basicData = computed(() => {
   if (!props.product || !props.product.productKey) return []
   return [
     {
@@ -147,7 +142,16 @@ const data1 = computed(() => {
   ]
 })
 
-const data2 = computed(() => {
+// 物模型通信 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 [
     {

+ 20 - 18
src/views/iot/product/detail/index.vue → src/views/iot/product/product/detail/index.vue

@@ -8,8 +8,8 @@
       <el-tab-pane label="Topic 类列表" name="topic">
         <ProductTopic v-if="activeTab === 'topic'" :product="product" />
       </el-tab-pane>
-      <el-tab-pane label="功能定义" name="function">
-        <ThinkModelFunction v-if="activeTab === 'function'" :product="product" />
+      <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" />
@@ -17,14 +17,15 @@
   </el-col>
 </template>
 <script lang="ts" setup>
-import { ProductApi, ProductVO } from '@/api/iot/product'
-import { DeviceApi } from '@/api/iot/device'
-import ProductDetailsHeader from '@/views/iot/product/detail/ProductDetailsHeader.vue'
-import ProductDetailsInfo from '@/views/iot/product/detail/ProductDetailsInfo.vue'
-import ProductTopic from '@/views/iot/product/detail/ProductTopic.vue'
-import ThinkModelFunction from '@/views/iot/product/detail/ThinkModelFunction.vue'
+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'
+import { IOT_PROVIDE_KEY } from '@/views/iot/utils/constants'
 
 defineOptions({ name: 'IoTProductDetail' })
 
@@ -36,27 +37,26 @@ const message = useMessage()
 const id = route.params.id // 编号
 const loading = ref(true) // 加载中
 const product = ref<ProductVO>({} as ProductVO) // 详情
-const activeTab = ref('info') // 默认激活的标签页
+const activeTab = ref('info') // 默认为 info 标签页
+
+provide(IOT_PROVIDE_KEY.PRODUCT, product) // 提供产品信息给产品信息详情页的所有子组件
 
 /** 获取详情 */
 const getProductData = async (id: number) => {
   loading.value = true
   try {
     product.value = await ProductApi.getProduct(id)
-    console.log('Product data:', product.value)
   } finally {
     loading.value = false
   }
 }
 
-// 查询设备数量
+/** 查询设备数量 */
 const getDeviceCount = async (productId: number) => {
   try {
-    const count = await DeviceApi.getDeviceCount(productId)
-    console.log('Device count response:', count)
-    return count
+    return await DeviceApi.getDeviceCount(productId)
   } catch (error) {
-    console.error('Error fetching device count:', error)
+    console.error('Error fetching device count:', error, 'productId:', productId)
     return 0
   }
 }
@@ -69,12 +69,14 @@ onMounted(async () => {
     return
   }
   await getProductData(id)
+  // 处理 tab 参数
+  const { tab } = route.query
+  if (tab) {
+    activeTab.value = tab as string
+  }
   // 查询设备数量
   if (product.value.id) {
     product.value.deviceCount = await getDeviceCount(product.value.id)
-    console.log('Device count:', product.value.deviceCount)
-  } else {
-    console.error('Product ID is undefined')
   }
 })
 </script>

+ 355 - 0
src/views/iot/product/product/index.vue

@@ -0,0 +1,355 @@
+<template>
+  <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-button
+          v-hasPermi="['iot:product:create']"
+          plain
+          type="primary"
+          @click="openForm('create')"
+        >
+          <Icon class="mr-5px" icon="ep:plus" />
+          新增
+        </el-button>
+        <el-button
+          v-hasPermi="['iot:product:export']"
+          :loading="exportLoading"
+          plain
+          type="success"
+          @click="handleExport"
+        >
+          <Icon class="mr-5px" icon="ep:download" />
+          导出
+        </el-button>
+      </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>
+  </ContentWrap>
+
+  <!-- 卡片视图 -->
+  <ContentWrap>
+    <el-row v-if="viewMode === 'card'" :gutter="16">
+      <el-col v-for="item in list" :key="item.id" :lg="6" :md="12" :sm="12" :xs="24" class="mb-4">
+        <el-card :body-style="{ padding: '0' }" class="h-full transition-colors">
+          <!-- 内容区域 -->
+          <div class="p-4">
+            <!-- 标题区域 -->
+            <div class="flex items-center mb-3">
+              <div class="mr-2.5 flex items-center">
+                <el-image :src="item.icon || defaultIconUrl" class="w-[35px] h-[35px]" />
+              </div>
+              <div class="text-[16px] font-600">{{ item.name }}</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-[#0070ff]">{{ item.categoryName }}</span>
+                </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">产品标识</span>
+                  <span class="text-[#0b1d30] inline-block align-middle overflow-hidden text-ellipsis whitespace-nowrap max-w-[140px]">
+                    {{ item.productKey }}
+                  </span>
+                </div>
+              </div>
+              <div class="w-[100px] h-[100px]">
+                <el-image :src="item.picUrl || defaultPicUrl" class="w-full h-full" />
+              </div>
+            </div>
+
+            <!-- 分隔线 -->
+            <el-divider class="!my-3" />
+
+            <!-- 按钮组 -->
+            <div class="flex items-center px-0">
+              <el-button
+                v-hasPermi="['iot:product:update']"
+                class="flex-1 !px-2 !h-[32px] text-[13px]"
+                plain
+                type="primary"
+                @click="openForm('update', item.id)"
+              >
+                <Icon class="mr-1" icon="ep:edit-pen" />
+                编辑
+              </el-button>
+              <el-button
+                class="flex-1 !px-2 !h-[32px] !ml-[10px] text-[13px]"
+                plain
+                type="warning"
+                @click="openDetail(item.id)"
+              >
+                <Icon class="mr-1" icon="ep:view" />
+                详情
+              </el-button>
+              <el-button
+                class="flex-1 !px-2 !h-[32px] !ml-[10px] text-[13px]"
+                plain
+                type="success"
+                @click="openObjectModel(item)"
+              >
+                <Icon class="mr-1" icon="ep:scale-to-original" />
+                物模型
+              </el-button>
+              <div class="mx-[10px] h-[20px] w-[1px] bg-[#dcdfe6]"></div>
+              <el-button
+                v-hasPermi="['iot:product:delete']"
+                :disabled="item.status === 1"
+                class="!px-2 !h-[32px] text-[13px]"
+                plain
+                type="danger"
+                @click="handleDelete(item.id)"
+              >
+                <Icon icon="ep:delete" />
+              </el-button>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 列表视图 -->
+    <el-table v-else v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
+      <el-table-column align="center" label="ID" prop="id" />
+      <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-column align="center" label="操作">
+        <template #default="scope">
+          <el-button
+            v-hasPermi="['iot:product:query']"
+            link
+            type="primary"
+            @click="openDetail(scope.row.id)"
+          >
+            查看
+          </el-button>
+          <el-button
+            v-hasPermi="['iot:product:update']"
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+          >
+            编辑
+          </el-button>
+          <el-button
+            v-hasPermi="['iot:product:delete']"
+            :disabled="scope.row.status === 1"
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <ProductForm ref="formRef" @success="getList" />
+</template>
+
+<script lang="ts" setup>
+import { dateFormatter } from '@/utils/formatTime'
+import { ProductApi, ProductVO } from '@/api/iot/product/product'
+import ProductForm from './ProductForm.vue'
+import { DICT_TYPE } from '@/utils/dict'
+import download from '@/utils/download'
+import defaultPicUrl from '@/assets/imgs/iot/device.png'
+import defaultIconUrl from '@/assets/svgs/iot/cube.svg'
+
+/** iot 产品列表 */
+defineOptions({ name: 'IoTProduct' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+const { push } = useRouter()
+const route = useRoute()
+
+const loading = ref(true) // 列表的加载中
+const activeName = ref('info') // 当前激活的标签页
+const list = ref<ProductVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined,
+  productKey: undefined
+})
+const queryFormRef = ref() // 搜索的表单
+const exportLoading = ref(false) // 导出加载中
+const viewMode = ref<'card' | 'list'>('card') // 视图模式状态
+
+/** 查询列表 */
+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 formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 打开详情 */
+const openDetail = (id: number) => {
+  push({ name: 'IoTProductDetail', params: { id } })
+}
+
+/** 打开物模型 */
+const openObjectModel = (item: ProductVO) => {
+  push({
+    name: 'IoTProductDetail',
+    params: { id: item.id },
+    query: { tab: 'thingModel' }
+  })
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await ProductApi.deleteProduct(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 导出按钮操作 */
+const handleExport = async () => {
+  try {
+    // 导出的二次确认
+    await message.exportConfirm()
+    // 发起导出
+    exportLoading.value = true
+    const data = await ProductApi.exportProduct(queryParams)
+    download.excel(data, '物联网产品.xls')
+  } catch {
+  } finally {
+    exportLoading.value = false
+  }
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+  // 处理 tab 参数
+  const { tab } = route.query
+  if (tab) {
+    activeName.value = tab as string
+  }
+})
+</script>

+ 207 - 0
src/views/iot/rule/databridge/IoTDataBridgeForm.vue

@@ -0,0 +1,207 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="120px"
+    >
+      <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>
+      <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>
+      <HttpConfigForm v-if="showConfig(IoTDataBridgeConfigType.HTTP)" v-model="formData.config" />
+      <MqttConfigForm v-if="showConfig(IoTDataBridgeConfigType.MQTT)" v-model="formData.config" />
+      <RocketMQConfigForm
+        v-if="showConfig(IoTDataBridgeConfigType.ROCKETMQ)"
+        v-model="formData.config"
+      />
+      <KafkaMQConfigForm
+        v-if="showConfig(IoTDataBridgeConfigType.KAFKA)"
+        v-model="formData.config"
+      />
+      <RabbitMQConfigForm
+        v-if="showConfig(IoTDataBridgeConfigType.RABBITMQ)"
+        v-model="formData.config"
+      />
+      <RedisStreamMQConfigForm
+        v-if="showConfig(IoTDataBridgeConfigType.REDIS_STREAM)"
+        v-model="formData.config"
+      />
+      <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="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>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE, getDictObj, getIntDictOptions } from '@/utils/dict'
+import { DataBridgeApi, DataBridgeVO, IoTDataBridgeConfigType } from '@/api/iot/rule/databridge'
+import {
+  HttpConfigForm,
+  KafkaMQConfigForm,
+  MqttConfigForm,
+  RabbitMQConfigForm,
+  RedisStreamMQConfigForm,
+  RocketMQConfigForm
+} from './config'
+
+/** IoT 数据桥梁的表单 */
+defineOptions({ name: 'IoTDataBridgeForm' })
+
+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<DataBridgeVO>({
+  status: 0,
+  direction: 1, // TODO @puhui999:枚举类
+  type: 1, // TODO @puhui999:枚举类
+  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' }],
+  // HTTP 配置
+  'config.url': [{ required: true, message: '请求地址不能为空', trigger: 'blur' }],
+  'config.method': [{ 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.topic': [{ required: true, message: '主题不能为空', trigger: 'blur' }],
+  // RocketMQ 配置
+  'config.nameServer': [{ required: true, message: 'NameServer 地址不能为空', trigger: 'blur' }],
+  'config.accessKey': [{ required: true, message: 'AccessKey 不能为空', trigger: 'blur' }],
+  'config.secretKey': [{ required: true, message: 'SecretKey 不能为空', trigger: 'blur' }],
+  'config.group': [{ required: true, message: '消费组不能为空', trigger: 'blur' }],
+  // Kafka 配置
+  'config.bootstrapServers': [{ required: true, message: '服务地址不能为空', trigger: 'blur' }],
+  'config.ssl': [{ required: true, message: 'SSL 配置不能为空', trigger: 'change' }],
+  // RabbitMQ 配置
+  'config.host': [{ required: true, message: '主机地址不能为空', trigger: 'blur' }],
+  'config.port': [
+    { required: true, message: '端口不能为空', trigger: 'blur' },
+    { type: 'number', min: 1, max: 65535, message: '端口号范围 1-65535', trigger: 'blur' }
+  ],
+  'config.virtualHost': [{ required: true, message: '虚拟主机不能为空', trigger: 'blur' }],
+  'config.exchange': [{ required: true, message: '交换机不能为空', trigger: 'blur' }],
+  'config.routingKey': [{ required: true, message: '路由键不能为空', trigger: 'blur' }],
+  'config.queue': [{ required: true, message: '队列不能为空', trigger: 'blur' }],
+  // Redis Stream 配置
+  'config.database': [
+    { required: true, message: '数据库索引不能为空', trigger: 'blur' },
+    { type: 'number', min: 0, message: '数据库索引必须是非负整数', trigger: 'blur' }
+  ]
+})
+
+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) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await DataBridgeApi.getDataBridge(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 DataBridgeVO
+    if (formType.value === 'create') {
+      await DataBridgeApi.createDataBridge(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await DataBridgeApi.updateDataBridge(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 处理类型切换事件 */
+const handleTypeChange = (val: number) => {
+  formData.value.type = val
+  // 切换类型时重置配置
+  formData.value.config = {} as any
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    // TODO @puhui999:换成枚举值哈
+    status: 0,
+    direction: 1,
+    type: 1,
+    config: {} as any
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 84 - 0
src/views/iot/rule/databridge/config/HttpConfigForm.vue

@@ -0,0 +1,84 @@
+<template>
+  <el-form-item label="请求地址" prop="config.url">
+    <el-input v-model="urlPath" placeholder="请输入请求地址">
+      <template #prepend>
+        <el-select v-model="urlPrefix" placeholder="Select" style="width: 115px">
+          <el-option label="http://" value="http://" />
+          <el-option label="https://" value="https://" />
+        </el-select>
+      </template>
+    </el-input>
+  </el-form-item>
+  <el-form-item label="请求方法" prop="config.method">
+    <el-select v-model="config.method" placeholder="请选择请求方法">
+      <el-option label="GET" value="GET" />
+      <el-option label="POST" value="POST" />
+      <el-option label="PUT" value="PUT" />
+      <el-option label="DELETE" value="DELETE" />
+    </el-select>
+  </el-form-item>
+  <el-form-item label="请求头" prop="config.headers">
+    <key-value-editor v-model="config.headers" add-button-text="添加请求头" />
+  </el-form-item>
+  <el-form-item label="请求参数" prop="config.query">
+    <key-value-editor v-model="config.query" add-button-text="添加参数" />
+  </el-form-item>
+  <el-form-item label="请求体" prop="config.body">
+    <el-input v-model="config.body" placeholder="请输入内容" type="textarea" />
+  </el-form-item>
+</template>
+
+<script lang="ts" setup>
+import { HttpConfig, IoTDataBridgeConfigType } from '@/api/iot/rule/databridge'
+import { useVModel } from '@vueuse/core'
+import { isEmpty } from '@/utils/is'
+import KeyValueEditor from './components/KeyValueEditor.vue'
+
+defineOptions({ name: 'HttpConfigForm' })
+
+const props = defineProps<{
+  modelValue: any
+}>()
+const emit = defineEmits(['update:modelValue'])
+const config = useVModel(props, 'modelValue', emit) as Ref<HttpConfig>
+
+/** URL处理 */
+const urlPrefix = ref('http://')
+const urlPath = ref('')
+const fullUrl = computed(() => {
+  return urlPath.value ? urlPrefix.value + urlPath.value : ''
+})
+
+/** 监听 URL 变化 */
+watch([urlPrefix, urlPath], () => {
+  config.value.url = fullUrl.value
+})
+
+/** 组件初始化 */
+onMounted(() => {
+  if (!isEmpty(config.value)) {
+    // 初始化 URL
+    if (config.value.url) {
+      if (config.value.url.startsWith('https://')) {
+        urlPrefix.value = 'https://'
+        urlPath.value = config.value.url.substring(8)
+      } else if (config.value.url.startsWith('http://')) {
+        urlPrefix.value = 'http://'
+        urlPath.value = config.value.url.substring(7)
+      } else {
+        urlPath.value = config.value.url
+      }
+    }
+    return
+  }
+
+  config.value = {
+    type: IoTDataBridgeConfigType.HTTP,
+    url: '',
+    method: 'POST',
+    headers: {},
+    query: {},
+    body: ''
+  }
+})
+</script>

+ 45 - 0
src/views/iot/rule/databridge/config/KafkaMQConfigForm.vue

@@ -0,0 +1,45 @@
+<template>
+  <el-form-item label="服务地址" prop="config.bootstrapServers">
+    <el-input v-model="config.bootstrapServers" placeholder="请输入服务地址,如:localhost:9092" />
+  </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="启用 SSL" prop="config.ssl">
+    <el-switch v-model="config.ssl" />
+  </el-form-item>
+  <el-form-item label="主题" prop="config.topic">
+    <el-input v-model="config.topic" placeholder="请输入主题" />
+  </el-form-item>
+</template>
+<script lang="ts" setup>
+import { IoTDataBridgeConfigType, KafkaMQConfig } from '@/api/iot/rule/databridge'
+import { useVModel } from '@vueuse/core'
+import { isEmpty } from '@/utils/is'
+
+defineOptions({ name: 'KafkaMQConfigForm' })
+
+const props = defineProps<{
+  modelValue: any
+}>()
+const emit = defineEmits(['update:modelValue'])
+const config = useVModel(props, 'modelValue', emit) as Ref<KafkaMQConfig>
+
+/** 组件初始化 */
+onMounted(() => {
+  if (!isEmpty(config.value)) {
+    return
+  }
+  config.value = {
+    type: IoTDataBridgeConfigType.KAFKA,
+    bootstrapServers: '',
+    username: '',
+    password: '',
+    ssl: false,
+    topic: ''
+  }
+})
+</script>

+ 45 - 0
src/views/iot/rule/databridge/config/MqttConfigForm.vue

@@ -0,0 +1,45 @@
+<template>
+  <el-form-item label="服务地址" prop="config.url">
+    <el-input v-model="config.url" placeholder="请输入MQTT服务地址,如:mqtt://localhost:1883" />
+  </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="客户端ID" prop="config.clientId">
+    <el-input v-model="config.clientId" placeholder="请输入客户端ID" />
+  </el-form-item>
+  <el-form-item label="主题" prop="config.topic">
+    <el-input v-model="config.topic" placeholder="请输入主题" />
+  </el-form-item>
+</template>
+<script lang="ts" setup>
+import { IoTDataBridgeConfigType, MqttConfig } from '@/api/iot/rule/databridge'
+import { useVModel } from '@vueuse/core'
+import { isEmpty } from '@/utils/is'
+
+defineOptions({ name: 'MqttConfigForm' })
+
+const props = defineProps<{
+  modelValue: any
+}>()
+const emit = defineEmits(['update:modelValue'])
+const config = useVModel(props, 'modelValue', emit) as Ref<MqttConfig>
+
+/** 组件初始化 */
+onMounted(() => {
+  if (!isEmpty(config.value)) {
+    return
+  }
+  config.value = {
+    type: IoTDataBridgeConfigType.MQTT,
+    url: '',
+    username: '',
+    password: '',
+    clientId: '',
+    topic: ''
+  }
+})
+</script>

+ 63 - 0
src/views/iot/rule/databridge/config/RabbitMQConfigForm.vue

@@ -0,0 +1,63 @@
+<template>
+  <el-form-item label="主机地址" prop="config.host">
+    <el-input v-model="config.host" placeholder="请输入主机地址,如: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="虚拟主机" prop="config.virtualHost">
+    <el-input v-model="config.virtualHost" placeholder="请输入虚拟主机" />
+  </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.exchange">
+    <el-input v-model="config.exchange" placeholder="请输入交换机" />
+  </el-form-item>
+  <el-form-item label="路由键" prop="config.routingKey">
+    <el-input v-model="config.routingKey" placeholder="请输入路由键" />
+  </el-form-item>
+  <el-form-item label="队列" prop="config.queue">
+    <el-input v-model="config.queue" placeholder="请输入队列" />
+  </el-form-item>
+</template>
+<script lang="ts" setup>
+import { IoTDataBridgeConfigType, RabbitMQConfig } from '@/api/iot/rule/databridge'
+import { useVModel } from '@vueuse/core'
+import { isEmpty } from '@/utils/is'
+
+defineOptions({ name: 'RabbitMQConfigForm' })
+
+const props = defineProps<{
+  modelValue: any
+}>()
+const emit = defineEmits(['update:modelValue'])
+const config = useVModel(props, 'modelValue', emit) as Ref<RabbitMQConfig>
+
+/** 组件初始化 */
+onMounted(() => {
+  if (!isEmpty(config.value)) {
+    return
+  }
+  config.value = {
+    type: IoTDataBridgeConfigType.RABBITMQ,
+    host: '',
+    port: 5672,
+    virtualHost: '/',
+    username: '',
+    password: '',
+    exchange: '',
+    routingKey: '',
+    queue: ''
+  }
+})
+</script>

+ 58 - 0
src/views/iot/rule/databridge/config/RedisStreamMQConfigForm.vue

@@ -0,0 +1,58 @@
+<!-- TODO @puhui999:去掉 MQ 关键字哈 -->
+<template>
+  <el-form-item label="主机地址" prop="config.host">
+    <el-input v-model="config.host" placeholder="请输入主机地址,如: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="密码" prop="config.password">
+    <el-input v-model="config.password" placeholder="请输入密码" show-password type="password" />
+  </el-form-item>
+  <el-form-item label="数据库" prop="config.database">
+    <el-input-number
+      v-model="config.database"
+      :max="15"
+      :min="0"
+      controls-position="right"
+      placeholder="请输入数据库索引"
+    />
+  </el-form-item>
+  <el-form-item label="主题" prop="config.topic">
+    <el-input v-model="config.topic" placeholder="请输入主题" />
+  </el-form-item>
+</template>
+<script lang="ts" setup>
+import { IoTDataBridgeConfigType, RedisStreamMQConfig } from '@/api/iot/rule/databridge'
+import { useVModel } from '@vueuse/core'
+import { isEmpty } from '@/utils/is'
+
+defineOptions({ name: 'RedisStreamMQConfigForm' })
+
+const props = defineProps<{
+  modelValue: any
+}>()
+const emit = defineEmits(['update:modelValue'])
+const config = useVModel(props, 'modelValue', emit) as Ref<RedisStreamMQConfig>
+
+/** 组件初始化 */
+onMounted(() => {
+  if (!isEmpty(config.value)) {
+    return
+  }
+  config.value = {
+    type: IoTDataBridgeConfigType.REDIS_STREAM,
+    host: '',
+    port: 6379,
+    password: '',
+    database: 0,
+    topic: ''
+  }
+})
+</script>

+ 57 - 0
src/views/iot/rule/databridge/config/RocketMQConfigForm.vue

@@ -0,0 +1,57 @@
+<template>
+  <el-form-item label="NameServer" prop="config.nameServer">
+    <el-input
+      v-model="config.nameServer"
+      placeholder="请输入 NameServer 地址,如:127.0.0.1:9876"
+    />
+  </el-form-item>
+  <el-form-item label="AccessKey" prop="config.accessKey">
+    <el-input v-model="config.accessKey" placeholder="请输入 AccessKey" />
+  </el-form-item>
+  <el-form-item label="SecretKey" prop="config.secretKey">
+    <el-input
+      v-model="config.secretKey"
+      placeholder="请输入 SecretKey"
+      show-password
+      type="password"
+    />
+  </el-form-item>
+  <el-form-item label="消费组" prop="config.group">
+    <el-input v-model="config.group" placeholder="请输入消费组" />
+  </el-form-item>
+  <el-form-item label="主题" prop="config.topic">
+    <el-input v-model="config.topic" placeholder="请输入主题" />
+  </el-form-item>
+  <el-form-item label="标签" prop="config.tags">
+    <el-input v-model="config.tags" placeholder="请输入标签" />
+  </el-form-item>
+</template>
+<script lang="ts" setup>
+import { IoTDataBridgeConfigType, RocketMQConfig } from '@/api/iot/rule/databridge'
+import { useVModel } from '@vueuse/core'
+import { isEmpty } from '@/utils/is'
+
+defineOptions({ name: 'RocketMQConfigForm' })
+
+const props = defineProps<{
+  modelValue: any
+}>()
+const emit = defineEmits(['update:modelValue'])
+const config = useVModel(props, 'modelValue', emit) as Ref<RocketMQConfig>
+
+/** 组件初始化 */
+onMounted(() => {
+  if (!isEmpty(config.value)) {
+    return
+  }
+  config.value = {
+    type: IoTDataBridgeConfigType.ROCKETMQ,
+    nameServer: '',
+    accessKey: '',
+    secretKey: '',
+    group: '',
+    topic: '',
+    tags: ''
+  }
+})
+</script>

+ 74 - 0
src/views/iot/rule/databridge/config/components/KeyValueEditor.vue

@@ -0,0 +1,74 @@
+<template>
+  <div v-for="(item, index) in items" :key="index" class="flex mb-2 w-full">
+    <el-input v-model="item.key" class="mr-2" placeholder="键" />
+    <el-input v-model="item.value" placeholder="值" />
+    <el-button class="ml-2" text type="danger" @click="removeItem(index)">
+      <el-icon>
+        <Delete />
+      </el-icon>
+      删除
+    </el-button>
+  </div>
+  <el-button text type="primary" @click="addItem">
+    <el-icon>
+      <Plus />
+    </el-icon>
+    {{ addButtonText }}
+  </el-button>
+</template>
+
+<script lang="ts" setup>
+import { Delete, Plus } from '@element-plus/icons-vue'
+import { isEmpty } from '@/utils/is'
+
+defineOptions({ name: 'KeyValueEditor' })
+
+interface KeyValueItem {
+  key: string
+  value: string
+}
+
+const props = defineProps<{
+  modelValue: Record<string, string>
+  addButtonText: string
+}>()
+const emit = defineEmits(['update:modelValue'])
+const items = ref<KeyValueItem[]>([]) // 内部 key-value 项列表
+
+/** 添加项目 */
+const addItem = () => {
+  items.value.push({ key: '', value: '' })
+  updateModelValue()
+}
+
+/** 移除项目 */
+const removeItem = (index: number) => {
+  items.value.splice(index, 1)
+  updateModelValue()
+}
+
+/** 更新 modelValue */
+const updateModelValue = () => {
+  const result: Record<string, string> = {}
+  items.value.forEach((item) => {
+    if (item.key) {
+      result[item.key] = item.value
+    }
+  })
+  emit('update:modelValue', result)
+}
+
+// TODO @puhui999:有告警的地方,尽量用 cursor 处理下
+/** 监听项目变化 */
+watch(items, updateModelValue, { deep: true })
+watch(
+  () => props.modelValue,
+  (val) => {
+    // 列表有值后以列表中的值为准
+    if (isEmpty(val) || !isEmpty(items.value)) {
+      return
+    }
+    items.value = Object.entries(props.modelValue).map(([key, value]) => ({ key, value }))
+  }
+)
+</script>

+ 15 - 0
src/views/iot/rule/databridge/config/index.ts

@@ -0,0 +1,15 @@
+import HttpConfigForm from './HttpConfigForm.vue'
+import MqttConfigForm from './MqttConfigForm.vue'
+import RocketMQConfigForm from './RocketMQConfigForm.vue'
+import KafkaMQConfigForm from './KafkaMQConfigForm.vue'
+import RabbitMQConfigForm from './RabbitMQConfigForm.vue'
+import RedisStreamMQConfigForm from './RedisStreamMQConfigForm.vue'
+
+export {
+  HttpConfigForm,
+  MqttConfigForm,
+  RocketMQConfigForm,
+  KafkaMQConfigForm,
+  RabbitMQConfigForm,
+  RedisStreamMQConfigForm
+}

+ 234 - 0
src/views/iot/rule/databridge/index.vue

@@ -0,0 +1,234 @@
+<template>
+  <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="桥梁状态" prop="status">
+        <el-select
+          v-model="queryParams.status"
+          class="!w-240px"
+          clearable
+          placeholder="请选择桥梁状态"
+        >
+          <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="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-select
+          v-model="queryParams.type"
+          class="!w-240px"
+          clearable
+          placeholder="请选择桥梁类型"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DATA_BRIDGE_TYPE_ENUM)"
+            :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"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-220px"
+          end-placeholder="结束日期"
+          start-placeholder="开始日期"
+          type="daterange"
+          value-format="YYYY-MM-DD HH:mm:ss"
+        />
+      </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-button
+          v-hasPermi="['iot:data-bridge:create']"
+          plain
+          type="primary"
+          @click="openForm('create')"
+        >
+          <Icon class="mr-5px" icon="ep:plus" />
+          新增
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <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">
+        <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">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.IOT_DATA_BRIDGE_TYPE_ENUM" :value="scope.row.type" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="创建时间"
+        prop="createTime"
+        width="180px"
+      />
+      <el-table-column align="center" fixed="right" label="操作" width="120px">
+        <template #default="scope">
+          <el-button
+            v-hasPermi="['iot:data-bridge:update']"
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+          >
+            编辑
+          </el-button>
+          <el-button
+            v-hasPermi="['iot:data-bridge:delete']"
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <DataBridgeForm 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'
+
+/** IoT 数据桥梁 列表 */
+defineOptions({ name: 'IotDataBridge' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<DataBridgeVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined,
+  description: undefined,
+  status: undefined,
+  direction: undefined,
+  type: undefined,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await DataBridgeApi.getDataBridgePage(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 DataBridgeApi.deleteDataBridge(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 56 - 0
src/views/iot/thingmodel/ThingModelEvent.vue

@@ -0,0 +1,56 @@
+<!-- 产品的物模型表单(event 项) -->
+<template>
+  <el-form-item
+    :rules="[{ required: true, message: '请选择事件类型', trigger: 'change' }]"
+    label="事件类型"
+    prop="event.type"
+  >
+    <el-radio-group v-model="thingModelEvent.type">
+      <el-radio :value="ThingModelEventType.INFO.value">
+        {{ ThingModelEventType.INFO.label }}
+      </el-radio>
+      <el-radio :value="ThingModelEventType.ALERT.value">
+        {{ ThingModelEventType.ALERT.label }}
+      </el-radio>
+      <el-radio :value="ThingModelEventType.ERROR.value">
+        {{ ThingModelEventType.ERROR.label }}
+      </el-radio>
+    </el-radio-group>
+  </el-form-item>
+  <el-form-item label="输出参数">
+    <ThingModelInputOutputParam
+      v-model="thingModelEvent.outputParams"
+      :direction="ThingModelParamDirection.OUTPUT"
+    />
+  </el-form-item>
+</template>
+
+<script lang="ts" setup>
+import ThingModelInputOutputParam from './ThingModelInputOutputParam.vue'
+import { useVModel } from '@vueuse/core'
+import { ThingModelEvent } from '@/api/iot/thingmodel'
+import { ThingModelEventType, ThingModelParamDirection } from './config'
+import { isEmpty } from '@/utils/is'
+
+/** IoT 物模型事件 */
+defineOptions({ name: 'ThingModelEvent' })
+
+const props = defineProps<{ modelValue: any; isStructDataSpecs?: boolean }>()
+const emits = defineEmits(['update:modelValue'])
+const thingModelEvent = useVModel(props, 'modelValue', emits) as Ref<ThingModelEvent>
+
+// 默认选中,INFO 信息
+watch(
+  () => thingModelEvent.value.type,
+  (val: string) => isEmpty(val) && (thingModelEvent.value.type = ThingModelEventType.INFO.value),
+  { immediate: true }
+)
+</script>
+
+<style lang="scss" scoped>
+:deep(.el-form-item) {
+  .el-form-item {
+    margin-bottom: 0;
+  }
+}
+</style>

+ 215 - 0
src/views/iot/thingmodel/ThingModelForm.vue

@@ -0,0 +1,215 @@
+<!-- 产品的物模型表单 -->
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="ThingModelFormRules"
+      label-width="100px"
+    >
+      <el-form-item label="功能类型" prop="type">
+        <el-radio-group v-model="formData.type">
+          <el-radio-button
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_THING_MODEL_TYPE)"
+            :key="dict.value"
+            :value="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio-button>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="功能名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入功能名称" />
+      </el-form-item>
+      <el-form-item label="标识符" prop="identifier">
+        <el-input v-model="formData.identifier" placeholder="请输入标识符" />
+      </el-form-item>
+      <!-- 属性配置 -->
+      <ThingModelProperty
+        v-if="formData.type === ThingModelType.PROPERTY"
+        v-model="formData.property"
+      />
+      <!-- 服务配置 -->
+      <ThingModelService
+        v-if="formData.type === ThingModelType.SERVICE"
+        v-model="formData.service"
+      />
+      <!-- 事件配置 -->
+      <ThingModelEvent v-if="formData.type === ThingModelType.EVENT" v-model="formData.event" />
+      <el-form-item label="描述" prop="description">
+        <el-input
+          v-model="formData.description"
+          :maxlength="200"
+          :rows="3"
+          placeholder="请输入属性描述"
+          type="textarea"
+        />
+      </el-form-item>
+    </el-form>
+
+    <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 { ProductVO } from '@/api/iot/product/product'
+import ThingModelProperty from './ThingModelProperty.vue'
+import ThingModelService from './ThingModelService.vue'
+import ThingModelEvent from './ThingModelEvent.vue'
+import { ThingModelApi, ThingModelData } from '@/api/iot/thingmodel'
+import { IOT_PROVIDE_KEY } from '@/views/iot/utils/constants'
+import { DataSpecsDataType, ThingModelFormRules, ThingModelType } from './config'
+import { cloneDeep } from 'lodash-es'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { isEmpty } from '@/utils/is'
+
+/** IoT 物模型数据表单 */
+defineOptions({ name: 'IoTThingModelForm' })
+
+const product = inject<Ref<ProductVO>>(IOT_PROVIDE_KEY.PRODUCT) // 注入产品信息
+
+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<ThingModelData>({
+  type: ThingModelType.PROPERTY,
+  dataType: DataSpecsDataType.INT,
+  property: {
+    dataType: DataSpecsDataType.INT,
+    dataSpecs: {
+      dataType: DataSpecsDataType.INT
+    }
+  },
+  service: {},
+  event: {}
+})
+
+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 ThingModelApi.getThingModel(id)
+      // 情况一:属性初始化
+      if (isEmpty(formData.value.property)) {
+        formData.value.dataType = DataSpecsDataType.INT
+        formData.value.property = {
+          dataType: DataSpecsDataType.INT,
+          dataSpecs: {
+            dataType: DataSpecsDataType.INT
+          }
+        }
+      }
+      // 情况二:服务初始化
+      if (isEmpty(formData.value.service)) {
+        formData.value.service = {}
+      }
+      // 情况三:事件初始化
+      if (isEmpty(formData.value.event)) {
+        formData.value.event = {}
+      }
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open, close: () => (dialogVisible.value = false) })
+
+/** 提交表单 */
+const emit = defineEmits(['success'])
+const submitForm = async () => {
+  debugger
+  await formRef.value.validate()
+  formLoading.value = true
+  try {
+    const data = cloneDeep(formData.value) as ThingModelData
+    // 信息补全
+    data.productId = product!.value.id
+    data.productKey = product!.value.productKey
+    fillExtraAttributes(data)
+    if (formType.value === 'create') {
+      await ThingModelApi.createThingModel(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ThingModelApi.updateThingModel(data)
+      message.success(t('common.updateSuccess'))
+    }
+  } finally {
+    dialogVisible.value = false // 确保关闭弹框
+    emit('success')
+    formLoading.value = false
+  }
+}
+
+/** 填写额外的属性 */
+const fillExtraAttributes = (data: any) => {
+  // 处理不同类型的情况
+  // 属性
+  if (data.type === ThingModelType.PROPERTY) {
+    removeDataSpecs(data.property)
+    data.dataType = data.property.dataType
+    data.property.identifier = data.identifier
+    data.property.name = data.name
+    delete data.service
+    delete data.event
+  }
+  // 服务
+  if (data.type === ThingModelType.SERVICE) {
+    removeDataSpecs(data.service)
+    data.dataType = data.service.dataType
+    data.service.identifier = data.identifier
+    data.service.name = data.name
+    delete data.property
+    delete data.event
+  }
+  // 事件
+  if (data.type === ThingModelType.EVENT) {
+    removeDataSpecs(data.event)
+    data.dataType = data.event.dataType
+    data.event.identifier = data.identifier
+    data.event.name = data.name
+    delete data.property
+    delete data.service
+  }
+}
+/** 处理 dataSpecs 为空的情况 */
+const removeDataSpecs = (val: any) => {
+  if (isEmpty(val.dataSpecs)) {
+    delete val.dataSpecs
+  }
+  if (isEmpty(val.dataSpecsList)) {
+    delete val.dataSpecsList
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    type: ThingModelType.PROPERTY,
+    dataType: DataSpecsDataType.INT,
+    property: {
+      dataType: DataSpecsDataType.INT,
+      dataSpecs: {
+        dataType: DataSpecsDataType.INT
+      }
+    },
+    service: {},
+    event: {}
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 155 - 0
src/views/iot/thingmodel/ThingModelInputOutputParam.vue

@@ -0,0 +1,155 @@
+<!-- 产品的物模型表单(event、service 项里的参数) -->
+<template>
+  <div
+    v-for="(item, index) in thingModelParams"
+    :key="index"
+    class="w-1/1 param-item flex justify-between px-10px mb-10px"
+  >
+    <span>参数名称:{{ item.name }}</span>
+    <div class="btn">
+      <el-button link type="primary" @click="openParamForm(item)">编辑</el-button>
+      <el-divider direction="vertical" />
+      <el-button link type="danger" @click="deleteParamItem(index)">删除</el-button>
+    </div>
+  </div>
+  <el-button link type="primary" @click="openParamForm(null)">+新增参数</el-button>
+
+  <!-- param 表单 -->
+  <Dialog v-model="dialogVisible" :title="dialogTitle" append-to-body>
+    <el-form
+      ref="paramFormRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="ThingModelFormRules"
+      label-width="100px"
+    >
+      <el-form-item label="参数名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入功能名称" />
+      </el-form-item>
+      <el-form-item label="标识符" prop="identifier">
+        <el-input v-model="formData.identifier" placeholder="请输入标识符" />
+      </el-form-item>
+      <!-- 属性配置 -->
+      <ThingModelProperty v-model="formData.property" is-params />
+    </el-form>
+
+    <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 { useVModel } from '@vueuse/core'
+import ThingModelProperty from './ThingModelProperty.vue'
+import { DataSpecsDataType, ThingModelFormRules } from './config'
+import { isEmpty } from '@/utils/is'
+
+/** 输入输出参数配置组件 */
+defineOptions({ name: 'ThingModelInputOutputParam' })
+
+const props = defineProps<{ modelValue: any; direction: string }>()
+const emits = defineEmits(['update:modelValue'])
+const thingModelParams = useVModel(props, 'modelValue', emits) as Ref<any[]>
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('新增参数') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const paramFormRef = ref() // 表单 ref
+const formData = ref<any>({
+  dataType: DataSpecsDataType.INT,
+  property: {
+    dataType: DataSpecsDataType.INT,
+    dataSpecs: {
+      dataType: DataSpecsDataType.INT
+    }
+  }
+})
+
+/** 打开 param 表单 */
+const openParamForm = (val: any) => {
+  dialogVisible.value = true
+  resetForm()
+  if (isEmpty(val)) {
+    return
+  }
+  // 编辑时回显数据
+  formData.value = {
+    identifier: val.identifier,
+    name: val.name,
+    description: val.description,
+    property: {
+      dataType: val.dataType,
+      dataSpecs: val.dataSpecs,
+      dataSpecsList: val.dataSpecsList
+    }
+  }
+}
+
+/** 删除 param 项 */
+const deleteParamItem = (index: number) => {
+  thingModelParams.value.splice(index, 1)
+}
+
+/** 添加参数 */
+const submitForm = async () => {
+  // 初始化参数列表
+  if (isEmpty(thingModelParams.value)) {
+    thingModelParams.value = []
+  }
+  // 校验参数
+  await paramFormRef.value.validate()
+  try {
+    const data = unref(formData)
+    // 构建数据对象
+    const item = {
+      identifier: data.identifier,
+      name: data.name,
+      description: data.description,
+      dataType: data.property.dataType,
+      paraOrder: 0, // TODO @puhui999: 先写死默认看看后续
+      direction: props.direction,
+      dataSpecs:
+        !!data.property.dataSpecs && Object.keys(data.property.dataSpecs).length > 1
+          ? data.property.dataSpecs
+          : undefined,
+      dataSpecsList: isEmpty(data.property.dataSpecsList) ? undefined : data.property.dataSpecsList
+    }
+
+    // 查找是否已有相同 identifier 的项
+    const existingIndex = thingModelParams.value.findIndex(
+      (spec) => spec.identifier === data.identifier
+    )
+    if (existingIndex > -1) {
+      // 更新已有项
+      thingModelParams.value[existingIndex] = item
+    } else {
+      // 添加新项
+      thingModelParams.value.push(item)
+    }
+  } finally {
+    // 隐藏对话框
+    dialogVisible.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    dataType: DataSpecsDataType.INT,
+    property: {
+      dataType: DataSpecsDataType.INT,
+      dataSpecs: {
+        dataType: DataSpecsDataType.INT
+      }
+    }
+  }
+  paramFormRef.value?.resetFields()
+}
+</script>
+
+<style lang="scss" scoped>
+.param-item {
+  background-color: #e4f2fd;
+}
+</style>

+ 169 - 0
src/views/iot/thingmodel/ThingModelProperty.vue

@@ -0,0 +1,169 @@
+<!-- 产品的物模型表单(property 项) -->
+<template>
+  <el-form-item
+    :rules="[{ required: true, message: '请选择数据类型', trigger: 'change' }]"
+    label="数据类型"
+    prop="property.dataType"
+  >
+    <el-select v-model="property.dataType" placeholder="请选择数据类型" @change="handleChange">
+      <!-- ARRAY 和 STRUCT 类型数据相互嵌套时,最多支持递归嵌套 2 层(父和子) -->
+      <el-option
+        v-for="option in getDataTypeOptions"
+        :key="option.value"
+        :label="`${option.value}(${option.label})`"
+        :value="option.value"
+      />
+    </el-select>
+  </el-form-item>
+  <!-- 数值型配置 -->
+  <ThingModelNumberDataSpecs
+    v-if="
+      [DataSpecsDataType.INT, DataSpecsDataType.DOUBLE, DataSpecsDataType.FLOAT].includes(
+        property.dataType || ''
+      )
+    "
+    v-model="property.dataSpecs"
+  />
+  <!-- 枚举型配置 -->
+  <ThingModelEnumDataSpecs
+    v-if="property.dataType === DataSpecsDataType.ENUM"
+    v-model="property.dataSpecsList"
+  />
+  <!-- 布尔型配置 -->
+  <el-form-item v-if="property.dataType === DataSpecsDataType.BOOL" label="布尔值">
+    <template v-for="(item, index) in property.dataSpecsList" :key="item.value">
+      <div class="flex items-center justify-start w-1/1 mb-5px">
+        <span>{{ item.value }}</span>
+        <span class="mx-2">-</span>
+        <el-form-item
+          :prop="`property.dataSpecsList[${index}].name`"
+          :rules="[
+            { required: true, message: '枚举描述不能为空' },
+            { validator: validateBoolName, trigger: 'blur' }
+          ]"
+          class="flex-1 mb-0"
+        >
+          <el-input
+            v-model="item.name"
+            :placeholder="`如:${item.value === 0 ? '关' : '开'}`"
+            class="w-255px!"
+          />
+        </el-form-item>
+      </div>
+    </template>
+  </el-form-item>
+  <!-- 文本型配置 -->
+  <el-form-item
+    v-if="property.dataType === DataSpecsDataType.TEXT"
+    label="数据长度"
+    prop="property.dataSpecs.length"
+  >
+    <el-input v-model="property.dataSpecs.length" class="w-255px!" placeholder="请输入文本字节长度">
+      <template #append>字节</template>
+    </el-input>
+  </el-form-item>
+  <!-- 时间型配置 -->
+  <el-form-item v-if="property.dataType === DataSpecsDataType.DATE" label="时间格式" prop="date">
+    <el-input class="w-255px!" disabled placeholder="String 类型的 UTC 时间戳(毫秒)" />
+  </el-form-item>
+  <!-- 数组型配置-->
+  <ThingModelArrayDataSpecs
+    v-if="property.dataType === DataSpecsDataType.ARRAY"
+    v-model="property.dataSpecs"
+  />
+  <!-- Struct 型配置-->
+  <ThingModelStructDataSpecs
+    v-if="property.dataType === DataSpecsDataType.STRUCT"
+    v-model="property.dataSpecsList"
+  />
+  <el-form-item v-if="!isStructDataSpecs && !isParams" label="读写类型" prop="property.accessMode">
+    <el-radio-group v-model="property.accessMode">
+      <el-radio :label="ThingModelAccessMode.READ_WRITE.value">
+        {{ ThingModelAccessMode.READ_WRITE.label }}
+      </el-radio>
+      <el-radio :label="ThingModelAccessMode.READ_ONLY.value">
+        {{ ThingModelAccessMode.READ_ONLY.label }}
+      </el-radio>
+    </el-radio-group>
+  </el-form-item>
+</template>
+
+<script lang="ts" setup>
+import { useVModel } from '@vueuse/core'
+import {
+  DataSpecsDataType,
+  dataTypeOptions,
+  ThingModelAccessMode,
+  validateBoolName
+} from './config'
+import {
+  ThingModelArrayDataSpecs,
+  ThingModelEnumDataSpecs,
+  ThingModelNumberDataSpecs,
+  ThingModelStructDataSpecs
+} from './dataSpecs'
+import { ThingModelProperty } from '@/api/iot/thingmodel'
+import { isEmpty } from '@/utils/is'
+
+/** IoT 物模型属性 */
+defineOptions({ name: 'ThingModelProperty' })
+
+const props = defineProps<{ modelValue: any; isStructDataSpecs?: boolean; isParams?: boolean }>()
+const emits = defineEmits(['update:modelValue'])
+const property = useVModel(props, 'modelValue', emits) as Ref<ThingModelProperty>
+const getDataTypeOptions = computed(() => {
+  return !props.isStructDataSpecs
+    ? dataTypeOptions
+    : dataTypeOptions.filter(
+        (item) =>
+          !([DataSpecsDataType.STRUCT, DataSpecsDataType.ARRAY] as any[]).includes(item.value)
+      )
+}) // 获得数据类型列表
+
+/** 属性值的数据类型切换时初始化相关数据 */
+const handleChange = (dataType: any) => {
+  property.value.dataSpecs = {}
+  property.value.dataSpecsList = []
+  // 不是列表型数据才设置 dataSpecs.dataType
+  ![DataSpecsDataType.ENUM, DataSpecsDataType.BOOL, DataSpecsDataType.STRUCT].includes(dataType) &&
+    (property.value.dataSpecs.dataType = dataType)
+  switch (dataType) {
+    case DataSpecsDataType.ENUM:
+      property.value.dataSpecsList.push({
+        dataType: DataSpecsDataType.ENUM,
+        name: '', // 枚举项的名称
+        value: undefined // 枚举值
+      })
+      break
+    case DataSpecsDataType.BOOL:
+      for (let i = 0; i < 2; i++) {
+        property.value.dataSpecsList.push({
+          dataType: DataSpecsDataType.BOOL,
+          name: '', // 布尔值的名称
+          value: i // 布尔值
+        })
+      }
+      break
+  }
+}
+
+// 默认选中读写
+watch(
+  () => property.value.accessMode,
+  (val: string) => {
+    if (props.isStructDataSpecs || props.isParams) {
+      return
+    }
+    isEmpty(val) && (property.value.accessMode = ThingModelAccessMode.READ_WRITE.value)
+  },
+  { immediate: true }
+)
+</script>
+
+<style lang="scss" scoped>
+:deep(.el-form-item) {
+  .el-form-item {
+    margin-bottom: 0;
+  }
+}
+</style>

+ 59 - 0
src/views/iot/thingmodel/ThingModelService.vue

@@ -0,0 +1,59 @@
+<!-- 产品的物模型表单(service 项) -->
+<template>
+  <el-form-item
+    :rules="[{ required: true, message: '请选择调用方式', trigger: 'change' }]"
+    label="调用方式"
+    prop="service.callType"
+  >
+    <el-radio-group v-model="service.callType">
+      <el-radio :value="ThingModelServiceCallType.ASYNC.value">
+        {{ ThingModelServiceCallType.ASYNC.label }}
+      </el-radio>
+      <el-radio :value="ThingModelServiceCallType.SYNC.value">
+        {{ ThingModelServiceCallType.SYNC.label }}
+      </el-radio>
+    </el-radio-group>
+  </el-form-item>
+  <el-form-item label="输入参数">
+    <ThingModelInputOutputParam
+      v-model="service.inputParams"
+      :direction="ThingModelParamDirection.INPUT"
+    />
+  </el-form-item>
+  <el-form-item label="输出参数">
+    <ThingModelInputOutputParam
+      v-model="service.outputParams"
+      :direction="ThingModelParamDirection.OUTPUT"
+    />
+  </el-form-item>
+</template>
+
+<script lang="ts" setup>
+import ThingModelInputOutputParam from './ThingModelInputOutputParam.vue'
+import { useVModel } from '@vueuse/core'
+import { ThingModelService } from '@/api/iot/thingmodel'
+import { ThingModelParamDirection, ThingModelServiceCallType } from './config'
+import { isEmpty } from '@/utils/is'
+
+/** IoT 物模型服务 */
+defineOptions({ name: 'ThingModelService' })
+
+const props = defineProps<{ modelValue: any; isStructDataSpecs?: boolean }>()
+const emits = defineEmits(['update:modelValue'])
+const service = useVModel(props, 'modelValue', emits) as Ref<ThingModelService>
+
+// 默认选中,ASYNC 异步
+watch(
+  () => service.value.callType,
+  (val: string) => isEmpty(val) && (service.value.callType = ThingModelServiceCallType.ASYNC.value),
+  { immediate: true }
+)
+</script>
+
+<style lang="scss" scoped>
+:deep(.el-form-item) {
+  .el-form-item {
+    margin-bottom: 0;
+  }
+}
+</style>

+ 61 - 0
src/views/iot/thingmodel/components/DataDefinition.vue

@@ -0,0 +1,61 @@
+<template>
+  <!-- 属性 -->
+  <template v-if="data.type === ThingModelType.PROPERTY">
+    <!-- 非列表型:数值 -->
+    <div
+      v-if="
+        [DataSpecsDataType.INT, DataSpecsDataType.DOUBLE, DataSpecsDataType.FLOAT].includes(
+          data.property.dataType
+        )
+      "
+    >
+      取值范围:{{ `${data.property.dataSpecs.min}~${data.property.dataSpecs.max}` }}
+    </div>
+    <!-- 非列表型:文本 -->
+    <div v-if="DataSpecsDataType.TEXT === data.property.dataType">
+      数据长度:{{ data.property.dataSpecs.length }}
+    </div>
+    <!-- 列表型: 数组、结构、时间(特殊) -->
+    <div
+      v-if="
+        [DataSpecsDataType.ARRAY, DataSpecsDataType.STRUCT, DataSpecsDataType.DATE].includes(
+          data.property.dataType
+        )
+      "
+    >
+      -
+    </div>
+    <!-- 列表型: 布尔值、枚举 -->
+    <div v-if="[DataSpecsDataType.BOOL, DataSpecsDataType.ENUM].includes(data.property.dataType)">
+      <div> {{ DataSpecsDataType.BOOL === data.property.dataType ? '布尔值' : '枚举值' }}:</div>
+      <div v-for="item in data.property.dataSpecsList" :key="item.value">
+        {{ `${item.name}-${item.value}` }}
+      </div>
+    </div>
+  </template>
+  <!-- 服务 -->
+  <div v-if="data.type === ThingModelType.SERVICE">
+    调用方式:{{ getCallTypeByValue(data.service!.callType) }}
+  </div>
+  <!-- 事件 -->
+  <div v-if="data.type === ThingModelType.EVENT">
+    事件类型:{{ getEventTypeByValue(data.event!.type) }}
+  </div>
+</template>
+
+<script lang="ts" setup>
+import {
+  DataSpecsDataType,
+  getCallTypeByValue,
+  getEventTypeByValue,
+  ThingModelType
+} from '@/views/iot/thingmodel/config'
+import { ThingModelData } from '@/api/iot/thingmodel'
+
+/** 数据定义展示组件 */
+defineOptions({ name: 'DataDefinition' })
+
+defineProps<{ data: ThingModelData }>()
+</script>
+
+<style lang="scss" scoped></style>

+ 3 - 0
src/views/iot/thingmodel/components/index.ts

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

+ 213 - 0
src/views/iot/thingmodel/config.ts

@@ -0,0 +1,213 @@
+import { isEmpty } from '@/utils/is'
+
+/** dataSpecs 数值型数据结构 */
+export interface DataSpecsNumberDataVO {
+  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 DataSpecsEnumOrBoolDataVO {
+  dataType: 'enum' | 'bool'
+  defaultValue?: string // 默认值,可选
+  name: string // 枚举项的名称
+  value: number | undefined // 枚举值
+}
+
+/** 属性值的数据类型 */
+export const DataSpecsDataType = {
+  INT: 'int',
+  FLOAT: 'float',
+  DOUBLE: 'double',
+  ENUM: 'enum',
+  BOOL: 'bool',
+  TEXT: 'text',
+  DATE: 'date',
+  STRUCT: 'struct',
+  ARRAY: 'array'
+} as const
+
+/** 物体模型数据类型配置项 */
+export const dataTypeOptions = [
+  { value: DataSpecsDataType.INT, label: '整数型' },
+  { value: DataSpecsDataType.FLOAT, label: '单精度浮点型' },
+  { value: DataSpecsDataType.DOUBLE, label: '双精度浮点型' },
+  { value: DataSpecsDataType.ENUM, label: '枚举型' },
+  { value: DataSpecsDataType.BOOL, label: '布尔型' },
+  { value: DataSpecsDataType.TEXT, label: '文本型' },
+  { value: DataSpecsDataType.DATE, label: '时间型' },
+  { value: DataSpecsDataType.STRUCT, label: '结构体' },
+  { value: DataSpecsDataType.ARRAY, label: '数组' }
+]
+
+/** 获得物体模型数据类型配置项名称 */
+export const getDataTypeOptionsLabel = (value: string) => {
+  if (isEmpty(value)) {
+    return value
+  }
+  const dataType = dataTypeOptions.find((option) => option.value === value)
+  return dataType && `${dataType.value}(${dataType.label})`
+}
+
+// IOT 产品物模型类型枚举类
+export const ThingModelType = {
+  PROPERTY: 1, // 属性
+  SERVICE: 2, // 服务
+  EVENT: 3 // 事件
+} as const
+
+// IOT 产品物模型访问模式枚举类
+export const ThingModelAccessMode = {
+  READ_WRITE: {
+    label: '读写',
+    value: 'rw'
+  },
+  READ_ONLY: {
+    label: '只读',
+    value: 'r'
+  }
+} as const
+
+// IOT 产品物模型服务调用方式枚举
+export const ThingModelServiceCallType = {
+  ASYNC: {
+    label: '异步调用',
+    value: 'async'
+  },
+  SYNC: {
+    label: '同步调用',
+    value: 'sync'
+  }
+} as const
+export const getCallTypeByValue = (value: string): string | undefined =>
+  Object.values(ThingModelServiceCallType).find((type) => type.value === value)?.label
+
+// IOT 产品物模型事件类型枚举
+export const ThingModelEventType = {
+  INFO: {
+    label: '信息',
+    value: 'info'
+  },
+  ALERT: {
+    label: '告警',
+    value: 'alert'
+  },
+  ERROR: {
+    label: '故障',
+    value: 'error'
+  }
+} as const
+export const getEventTypeByValue = (value: string): string | undefined =>
+  Object.values(ThingModelEventType).find((type) => type.value === value)?.label
+
+// IOT 产品物模型参数是输入参数还是输出参数
+export const ThingModelParamDirection = {
+  INPUT: 'input', // 输入参数
+  OUTPUT: 'output' // 输出参数
+} as const
+
+/** 公共校验规则 */
+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()
+}

+ 52 - 0
src/views/iot/thingmodel/dataSpecs/ThingModelArrayDataSpecs.vue

@@ -0,0 +1,52 @@
+<!-- dataType:array 数组类型 -->
+<template>
+  <el-form-item label="元素类型" prop="property.dataSpecs.childDataType">
+    <el-radio-group v-model="dataSpecs.childDataType" @change="handleChange">
+      <template v-for="item in dataTypeOptions" :key="item.value">
+        <el-radio
+          v-if="
+            !(
+              [DataSpecsDataType.ENUM, DataSpecsDataType.ARRAY, DataSpecsDataType.DATE] as any[]
+            ).includes(item.value)
+          "
+          :value="item.value"
+          class="w-1/3"
+        >
+          {{ `${item.value}(${item.label})` }}
+        </el-radio>
+      </template>
+    </el-radio-group>
+  </el-form-item>
+  <el-form-item label="元素个数" prop="property.dataSpecs.size">
+    <el-input v-model="dataSpecs.size" placeholder="请输入数组中的元素个数" />
+  </el-form-item>
+  <!-- Struct 型配置-->
+  <ThingModelStructDataSpecs
+    v-if="dataSpecs.childDataType === DataSpecsDataType.STRUCT"
+    v-model="dataSpecs.dataSpecsList"
+  />
+</template>
+
+<script lang="ts" setup>
+import { useVModel } from '@vueuse/core'
+import { DataSpecsDataType, dataTypeOptions } from '../config'
+import ThingModelStructDataSpecs from './ThingModelStructDataSpecs.vue'
+
+/** 数组型的 dataSpecs 配置组件 */
+defineOptions({ name: 'ThingModelArrayDataSpecs' })
+
+const props = defineProps<{ modelValue: any }>()
+const emits = defineEmits(['update:modelValue'])
+const dataSpecs = useVModel(props, 'modelValue', emits) as Ref<any>
+
+/** 元素类型改变时间。当值为 struct 时,对 dataSpecs 中的 dataSpecsList 进行初始化 */
+const handleChange = (val: string) => {
+  if (val !== DataSpecsDataType.STRUCT) {
+    return
+  }
+
+  dataSpecs.value.dataSpecsList = []
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 159 - 0
src/views/iot/thingmodel/dataSpecs/ThingModelEnumDataSpecs.vue

@@ -0,0 +1,159 @@
+<!-- dataType:enum 数组类型 -->
+<template>
+  <el-form-item
+    :rules="[{ required: true, validator: validateEnumList, trigger: 'change' }]"
+    label="枚举项"
+  >
+    <div class="flex flex-col">
+      <div class="flex items-center">
+        <span class="flex-1"> 参数值 </span>
+        <span class="flex-1"> 参数描述 </span>
+      </div>
+      <div
+        v-for="(item, index) in dataSpecsList"
+        :key="index"
+        class="flex items-center justify-between mb-5px"
+      >
+        <el-form-item
+          :prop="`property.dataSpecsList[${index}].value`"
+          :rules="[
+            { required: true, message: '枚举值不能为空' },
+            { validator: validateEnumValue, trigger: 'blur' }
+          ]"
+          class="flex-1 mb-0"
+        >
+          <el-input v-model="item.value" placeholder="请输入枚举值,如'0'" />
+        </el-form-item>
+        <span class="mx-2">~</span>
+        <el-form-item
+          :prop="`property.dataSpecsList[${index}].name`"
+          :rules="[
+            { required: true, message: '枚举描述不能为空' },
+            { validator: validateEnumName, trigger: 'blur' }
+          ]"
+          class="flex-1 mb-0"
+        >
+          <el-input v-model="item.name" placeholder="对该枚举项的描述" />
+        </el-form-item>
+        <el-button class="ml-10px" link type="primary" @click="deleteEnum(index)">删除</el-button>
+      </div>
+      <el-button link type="primary" @click="addEnum">+添加枚举项</el-button>
+    </div>
+  </el-form-item>
+</template>
+
+<script lang="ts" setup>
+import { useVModel } from '@vueuse/core'
+import { DataSpecsDataType, DataSpecsEnumOrBoolDataVO } from '../config'
+import { isEmpty } from '@/utils/is'
+
+/** 枚举型的 dataSpecs 配置组件 */
+defineOptions({ name: 'ThingModelEnumDataSpecs' })
+
+const props = defineProps<{ modelValue: any }>()
+const emits = defineEmits(['update:modelValue'])
+const dataSpecsList = useVModel(props, 'modelValue', emits) as Ref<DataSpecsEnumOrBoolDataVO[]>
+const message = useMessage()
+
+/** 添加枚举项 */
+const addEnum = () => {
+  dataSpecsList.value.push({
+    dataType: DataSpecsDataType.ENUM,
+    name: '', // 枚举项的名称
+    value: undefined // 枚举值
+  })
+}
+
+/** 删除枚举项 */
+const deleteEnum = (index: number) => {
+  if (dataSpecsList.value.length === 1) {
+    message.warning('至少需要一个枚举项')
+    return
+  }
+  dataSpecsList.value.splice(index, 1)
+}
+
+/** 校验枚举值 */
+const validateEnumValue = (_: any, value: any, callback: any) => {
+  if (isEmpty(value)) {
+    callback(new Error('枚举值不能为空'))
+    return
+  }
+  if (isNaN(Number(value))) {
+    callback(new Error('枚举值必须是数字'))
+    return
+  }
+  // 检查枚举值是否重复
+  const values = dataSpecsList.value.map((item) => item.value)
+  if (values.filter((v) => v === value).length > 1) {
+    callback(new Error('枚举值不能重复'))
+    return
+  }
+  callback()
+}
+
+/** 校验枚举描述 */
+const validateEnumName = (_: 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()
+}
+
+/** 校验整个枚举列表 */
+const validateEnumList = (_: any, __: any, callback: any) => {
+  if (isEmpty(dataSpecsList.value)) {
+    callback(new Error('请至少添加一个枚举项'))
+    return
+  }
+
+  // 检查是否存在空值
+  const hasEmptyValue = dataSpecsList.value.some(
+    (item) => isEmpty(item.value) || isEmpty(item.name)
+  )
+  if (hasEmptyValue) {
+    callback(new Error('存在未填写的枚举值或描述'))
+    return
+  }
+
+  // 检查枚举值是否都是数字
+  const hasInvalidNumber = dataSpecsList.value.some((item) => isNaN(Number(item.value)))
+  if (hasInvalidNumber) {
+    callback(new Error('存在非数字的枚举值'))
+    return
+  }
+
+  // 检查是否有重复的枚举值
+  const values = dataSpecsList.value.map((item) => item.value)
+  const uniqueValues = new Set(values)
+  if (values.length !== uniqueValues.size) {
+    callback(new Error('存在重复的枚举值'))
+    return
+  }
+  callback()
+}
+</script>
+
+<style lang="scss" scoped>
+:deep(.el-form-item) {
+  .el-form-item {
+    margin-bottom: 0;
+  }
+}
+</style>

+ 139 - 0
src/views/iot/thingmodel/dataSpecs/ThingModelNumberDataSpecs.vue

@@ -0,0 +1,139 @@
+<!-- dataType:number 数组类型 -->
+<template>
+  <el-form-item label="取值范围">
+    <div class="flex items-center justify-between">
+      <el-form-item
+        :rules="[
+          { required: true, message: '最小值不能为空' },
+          { validator: validateMin, trigger: 'blur' }
+        ]"
+        class="mb-0"
+        prop="property.dataSpecs.min"
+      >
+        <el-input v-model="dataSpecs.min" placeholder="请输入最小值" />
+      </el-form-item>
+      <span class="mx-2">~</span>
+      <el-form-item
+        :rules="[
+          { required: true, message: '最大值不能为空' },
+          { validator: validateMax, trigger: 'blur' }
+        ]"
+        class="mb-0"
+        prop="property.dataSpecs.max"
+      >
+        <el-input v-model="dataSpecs.max" placeholder="请输入最大值" />
+      </el-form-item>
+    </div>
+  </el-form-item>
+  <el-form-item
+    :rules="[
+      { required: true, message: '步长不能为空' },
+      { validator: validateStep, trigger: 'blur' }
+    ]"
+    label="步长"
+    prop="property.dataSpecs.step"
+  >
+    <el-input v-model="dataSpecs.step" placeholder="请输入步长" />
+  </el-form-item>
+  <el-form-item
+    :rules="[{ required: true, message: '请选择单位' }]"
+    label="单位"
+    prop="property.dataSpecs.unit"
+  >
+    <el-select
+      :model-value="dataSpecs.unit ? dataSpecs.unitName + '-' + dataSpecs.unit : ''"
+      filterable
+      placeholder="请选择单位"
+      class="w-1/1"
+      @change="unitChange"
+    >
+      <el-option
+        v-for="(item, index) in getStrDictOptions(DICT_TYPE.IOT_THING_MODEL_UNIT)"
+        :key="index"
+        :label="item.label + '-' + item.value"
+        :value="item.label + '-' + item.value"
+      />
+    </el-select>
+  </el-form-item>
+</template>
+
+<script lang="ts" setup>
+import { useVModel } from '@vueuse/core'
+import { DataSpecsNumberDataVO } from '../config'
+import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
+
+/** 数值型的 dataSpecs 配置组件 */
+defineOptions({ name: 'ThingModelNumberDataSpecs' })
+
+const props = defineProps<{ modelValue: any }>()
+const emits = defineEmits(['update:modelValue'])
+const dataSpecs = useVModel(props, 'modelValue', emits) as Ref<DataSpecsNumberDataVO>
+
+/** 单位发生变化时触发 */
+const unitChange = (UnitSpecs: string) => {
+  const [unitName, unit] = UnitSpecs.split('-')
+  dataSpecs.value.unitName = unitName
+  dataSpecs.value.unit = unit
+}
+
+/** 校验最小值 */
+const validateMin = (_: any, __: any, callback: any) => {
+  const min = Number(dataSpecs.value.min)
+  const max = Number(dataSpecs.value.max)
+  if (isNaN(min)) {
+    callback(new Error('请输入有效的数值'))
+    return
+  }
+  if (max !== undefined && !isNaN(max) && min >= max) {
+    callback(new Error('最小值必须小于最大值'))
+    return
+  }
+
+  callback()
+}
+
+/** 校验最大值 */
+const validateMax = (_: any, __: any, callback: any) => {
+  const min = Number(dataSpecs.value.min)
+  const max = Number(dataSpecs.value.max)
+  if (isNaN(max)) {
+    callback(new Error('请输入有效的数值'))
+    return
+  }
+  if (min !== undefined && !isNaN(min) && max <= min) {
+    callback(new Error('最大值必须大于最小值'))
+    return
+  }
+
+  callback()
+}
+
+/** 校验步长 */
+const validateStep = (_: any, __: any, callback: any) => {
+  const step = Number(dataSpecs.value.step)
+  if (isNaN(step)) {
+    callback(new Error('请输入有效的数值'))
+    return
+  }
+  if (step <= 0) {
+    callback(new Error('步长必须大于0'))
+    return
+  }
+  const min = Number(dataSpecs.value.min)
+  const max = Number(dataSpecs.value.max)
+  if (!isNaN(min) && !isNaN(max) && step > max - min) {
+    callback(new Error('步长不能大于最大值和最小值的差值'))
+    return
+  }
+
+  callback()
+}
+</script>
+
+<style lang="scss" scoped>
+:deep(.el-form-item) {
+  .el-form-item {
+    margin-bottom: 0;
+  }
+}
+</style>

+ 170 - 0
src/views/iot/thingmodel/dataSpecs/ThingModelStructDataSpecs.vue

@@ -0,0 +1,170 @@
+<!-- dataType:struct 数组类型 -->
+<template>
+  <!-- struct 数据展示 -->
+  <el-form-item
+    :rules="[{ required: true, validator: validateList, trigger: 'change' }]"
+    label="JSON 对象"
+  >
+    <div
+      v-for="(item, index) in dataSpecsList"
+      :key="index"
+      class="w-1/1 struct-item flex justify-between px-10px mb-10px"
+    >
+      <span>参数名称:{{ item.name }}</span>
+      <div class="btn">
+        <el-button link type="primary" @click="openStructForm(item)">编辑</el-button>
+        <el-divider direction="vertical" />
+        <el-button link type="danger" @click="deleteStructItem(index)">删除</el-button>
+      </div>
+    </div>
+    <el-button link type="primary" @click="openStructForm(null)">+新增参数</el-button>
+  </el-form-item>
+
+  <!-- struct 表单 -->
+  <Dialog v-model="dialogVisible" :title="dialogTitle" append-to-body>
+    <el-form
+      ref="structFormRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="ThingModelFormRules"
+      label-width="100px"
+    >
+      <el-form-item label="参数名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入功能名称" />
+      </el-form-item>
+      <el-form-item label="标识符" prop="identifier">
+        <el-input v-model="formData.identifier" placeholder="请输入标识符" />
+      </el-form-item>
+      <!-- 属性配置 -->
+      <ThingModelProperty v-model="formData.property" is-struct-data-specs />
+    </el-form>
+
+    <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 { useVModel } from '@vueuse/core'
+import ThingModelProperty from '../ThingModelProperty.vue'
+import { DataSpecsDataType, ThingModelFormRules } from '../config'
+import { isEmpty } from '@/utils/is'
+
+/** Struct 型的 dataSpecs 配置组件 */
+defineOptions({ name: 'ThingModelStructDataSpecs' })
+
+const props = defineProps<{ modelValue: any }>()
+const emits = defineEmits(['update:modelValue'])
+const dataSpecsList = useVModel(props, 'modelValue', emits) as Ref<any[]>
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('新增参数') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const structFormRef = ref() // 表单 ref
+const formData = ref<any>({
+  property: {
+    dataType: DataSpecsDataType.INT,
+    dataSpecs: {
+      dataType: DataSpecsDataType.INT
+    }
+  }
+})
+
+/** 打开 struct 表单 */
+const openStructForm = (val: any) => {
+  dialogVisible.value = true
+  resetForm()
+  if (isEmpty(val)) {
+    return
+  }
+  // 编辑时回显数据
+  formData.value = {
+    identifier: val.identifier,
+    name: val.name,
+    description: val.description,
+    property: {
+      dataType: val.childDataType,
+      dataSpecs: val.dataSpecs,
+      dataSpecsList: val.dataSpecsList
+    }
+  }
+}
+
+/** 删除 struct 项 */
+const deleteStructItem = (index: number) => {
+  dataSpecsList.value.splice(index, 1)
+}
+
+/** 添加参数 */
+const submitForm = async () => {
+  await structFormRef.value.validate()
+
+  try {
+    const data = unref(formData)
+    // 构建数据对象
+    const item = {
+      identifier: data.identifier,
+      name: data.name,
+      description: data.description,
+      dataType: DataSpecsDataType.STRUCT,
+      childDataType: data.property.dataType,
+      dataSpecs:
+        !!data.property.dataSpecs && Object.keys(data.property.dataSpecs).length > 1
+          ? data.property.dataSpecs
+          : undefined,
+      dataSpecsList: isEmpty(data.property.dataSpecsList) ? undefined : data.property.dataSpecsList
+    }
+
+    // 查找是否已有相同 identifier 的项
+    const existingIndex = dataSpecsList.value.findIndex(
+      (spec) => spec.identifier === data.identifier
+    )
+    if (existingIndex > -1) {
+      // 更新已有项
+      dataSpecsList.value[existingIndex] = item
+    } else {
+      // 添加新项
+      dataSpecsList.value.push(item)
+    }
+  } finally {
+    // 隐藏对话框
+    dialogVisible.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    property: {
+      dataType: DataSpecsDataType.INT,
+      dataSpecs: {
+        dataType: DataSpecsDataType.INT
+      }
+    }
+  }
+  structFormRef.value?.resetFields()
+}
+
+/** 校验 struct 不能为空 */
+const validateList = (_: any, __: any, callback: any) => {
+  if (isEmpty(dataSpecsList.value)) {
+    callback(new Error('struct 不能为空'))
+    return
+  }
+  callback()
+}
+
+/** 组件初始化 */
+onMounted(async () => {
+  await nextTick()
+  // 预防 dataSpecsList 空指针
+  isEmpty(dataSpecsList.value) && (dataSpecsList.value = [])
+})
+</script>
+
+<style lang="scss" scoped>
+.struct-item {
+  background-color: #e4f2fd;
+}
+</style>

+ 11 - 0
src/views/iot/thingmodel/dataSpecs/index.ts

@@ -0,0 +1,11 @@
+import ThingModelEnumDataSpecs from './ThingModelEnumDataSpecs.vue'
+import ThingModelNumberDataSpecs from './ThingModelNumberDataSpecs.vue'
+import ThingModelArrayDataSpecs from './ThingModelArrayDataSpecs.vue'
+import ThingModelStructDataSpecs from './ThingModelStructDataSpecs.vue'
+
+export {
+  ThingModelEnumDataSpecs,
+  ThingModelNumberDataSpecs,
+  ThingModelArrayDataSpecs,
+  ThingModelStructDataSpecs
+}

+ 56 - 30
src/views/iot/product/detail/ThinkModelFunction.vue → src/views/iot/thingmodel/index.vue

@@ -1,22 +1,23 @@
+<!-- 产品的物模型列表 -->
 <template>
   <ContentWrap>
     <!-- 搜索工作栏 -->
     <el-form
-      class="-mb-15px"
-      :model="queryParams"
       ref="queryFormRef"
       :inline="true"
+      :model="queryParams"
+      class="-mb-15px"
       label-width="68px"
     >
       <el-form-item label="功能类型" prop="name">
         <el-select
           v-model="queryParams.type"
-          placeholder="请选择功能类型"
-          clearable
           class="!w-240px"
+          clearable
+          placeholder="请选择功能类型"
         >
           <el-option
-            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PRODUCT_FUNCTION_TYPE)"
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_THING_MODEL_TYPE)"
             :key="dict.value"
             :label="dict.label"
             :value="dict.value"
@@ -24,44 +25,63 @@
         </el-select>
       </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 @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-button
-          type="primary"
+          v-hasPermi="[`iot:thing-model:create`]"
           plain
+          type="primary"
           @click="openForm('create')"
-          v-hasPermi="['iot:think-model-function:create']"
         >
-          <Icon icon="ep:plus" class="mr-5px" /> 添加功能
+          <Icon class="mr-5px" icon="ep:plus" />
+          添加功能
         </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="type">
+      <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
+        <el-table-column align="center" label="功能类型" prop="type">
           <template #default="scope">
-            <dict-tag :type="DICT_TYPE.IOT_PRODUCT_FUNCTION_TYPE" :value="scope.row.type" />
+            <dict-tag :type="DICT_TYPE.IOT_THING_MODEL_TYPE" :value="scope.row.type" />
+          </template>
+        </el-table-column>
+        <el-table-column align="center" label="功能名称" prop="name" />
+        <el-table-column align="center" label="标识符" prop="identifier" />
+        <el-table-column align="center" label="数据类型" prop="identifier">
+          <template #default="{ row }">
+            {{ dataTypeOptionsLabel(row.property?.dataType) ?? '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column align="left" label="数据定义" prop="identifier">
+          <template #default="{ row }">
+            <DataDefinition :data="row" />
           </template>
         </el-table-column>
-        <el-table-column label="功能名称" align="center" prop="name" />
-        <el-table-column label="标识符" align="center" prop="identifier" />
-        <el-table-column label="操作" align="center">
+        <el-table-column align="center" label="操作">
           <template #default="scope">
             <el-button
+              v-hasPermi="[`iot:thing-model:update`]"
               link
               type="primary"
               @click="openForm('update', scope.row.id)"
-              v-hasPermi="[`iot:think-model-function:update`]"
             >
               编辑
             </el-button>
             <el-button
+              v-hasPermi="['iot:thing-model:delete']"
               link
               type="danger"
               @click="handleDelete(scope.row.id)"
-              v-hasPermi="['iot:think-model-function:delete']"
             >
               删除
             </el-button>
@@ -70,29 +90,32 @@
       </el-table>
       <!-- 分页 -->
       <Pagination
-        :total="total"
-        v-model:page="queryParams.pageNo"
         v-model:limit="queryParams.pageSize"
+        v-model:page="queryParams.pageNo"
+        :total="total"
         @pagination="getList"
       />
     </el-tabs>
   </ContentWrap>
   <!-- 表单弹窗:添加/修改 -->
-  <ThinkModelFunctionForm ref="formRef" :product="product" @success="getList" />
+  <ThingModelForm ref="formRef" @success="getList" />
 </template>
-<script setup lang="ts">
-import { ProductVO } from '@/api/iot/product'
-import { ThinkModelFunctionApi, ThinkModelFunctionVO } from '@/api/iot/thinkmodelfunction'
+<script lang="ts" setup>
+import { ThingModelApi, ThingModelData } from '@/api/iot/thingmodel'
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
-import ThinkModelFunctionForm from '@/views/iot/product/detail/ThinkModelFunctionForm.vue'
+import ThingModelForm from './ThingModelForm.vue'
+import { ProductVO } from '@/api/iot/product/product'
+import { IOT_PROVIDE_KEY } from '@/views/iot/utils/constants'
+import { getDataTypeOptionsLabel } from './config'
+import { DataDefinition } from './components'
 
-const props = defineProps<{ product: ProductVO }>()
+defineOptions({ name: 'IoTThingModel' })
 
 const { t } = useI18n() // 国际化
 const message = useMessage() // 消息弹窗
 
 const loading = ref(true) // 列表的加载中
-const list = ref<ThinkModelFunctionVO[]>([]) // 列表的数据
+const list = ref<ThingModelData[]>([]) // 列表的数据
 const total = ref(0) // 列表的总页数
 const queryParams = reactive({
   pageNo: 1,
@@ -102,19 +125,22 @@ const queryParams = reactive({
 })
 
 const queryFormRef = ref() // 搜索的表单
+const product = inject<Ref<ProductVO>>(IOT_PROVIDE_KEY.PRODUCT) // 注入产品信息
+const dataTypeOptionsLabel = computed(() => (value: string) => getDataTypeOptionsLabel(value)) // 解析数据类型
 
 /** 查询列表 */
 const getList = async () => {
   loading.value = true
   try {
-    queryParams.productId = props.product.id
-    const data = await ThinkModelFunctionApi.getThinkModelFunctionPage(queryParams)
+    queryParams.productId = product?.value?.id || -1
+    const data = await ThingModelApi.getThingModelPage(queryParams)
     list.value = data.list
     total.value = data.total
   } finally {
     loading.value = false
   }
 }
+
 /** 搜索按钮操作 */
 const handleQuery = () => {
   queryParams.pageNo = 1
@@ -140,7 +166,7 @@ const handleDelete = async (id: number) => {
     // 删除的二次确认
     await message.delConfirm()
     // 发起删除
-    await ThinkModelFunctionApi.deleteThinkModelFunction(id)
+    await ThingModelApi.deleteThingModel(id)
     message.success(t('common.delSuccess'))
     // 刷新列表
     await getList()

+ 4 - 0
src/views/iot/utils/constants.ts

@@ -0,0 +1,4 @@
+/** iot 依赖注入 KEY */
+export const IOT_PROVIDE_KEY = {
+  PRODUCT: 'IOT_PRODUCT'
+}

+ 2 - 2
src/views/mall/product/spu/index.vue

@@ -411,7 +411,7 @@ const handleExport = async () => {
     await message.exportConfirm()
     // 发起导出
     exportLoading.value = true
-    const data = await ProductSpuApi.exportSpu(queryParams)
+    const data = await ProductSpuApi.exportSpu(queryParams.value)
     download.excel(data, '商品列表.xls')
   } catch {
   } finally {
@@ -434,7 +434,7 @@ onActivated(() => {
 onMounted(async () => {
   // 解析路由的 categoryId
   if (route.query.categoryId) {
-    queryParams.value.categoryId = Number(route.query.categoryId)
+    queryParams.value.categoryId = route.query.categoryId
   }
   // 获得商品信息
   await getTabsCount()

+ 6 - 2
src/views/mall/trade/afterSale/detail/index.vue

@@ -90,11 +90,15 @@
       <el-descriptions-item labelClassName="no-colon">
         <el-row :gutter="20">
           <el-col :span="15">
-            <el-table :data="[formData.orderItem]" border>
+            <el-table v-if="formData.orderItem" :data="[formData.orderItem]" border>
               <el-table-column label="商品" prop="spuName" width="auto">
                 <template #default="{ row }">
                   {{ row.spuName }}
-                  <el-tag v-for="property in row.properties" :key="property.propertyId">
+                  <el-tag
+                    v-for="property in row.properties"
+                    :key="property.propertyId"
+                    class="mr-10px"
+                  >
                     {{ property.propertyName }}: {{ property.valueName }}
                   </el-tag>
                 </template>

+ 1 - 1
src/views/mall/trade/delivery/pickUpStore/DeliveryPickUpStoreBindForm.vue

@@ -20,7 +20,7 @@
             <el-button type="primary" @click="storeStaffTableSelect.open()">选择店员</el-button>
           </el-form-item>
           <!-- 店员列表 -->
-          <ContentWrap v-if="formData.verifyUsers.length > 0">
+          <ContentWrap v-if="formData.verifyUsers?.length > 0">
             <el-table :data="formData.verifyUsers">
               <el-table-column label="编号" align="center" prop="id" />
               <el-table-column

+ 2 - 2
src/views/system/loginlog/index.vue

@@ -47,7 +47,7 @@
           plain
           @click="handleExport"
           :loading="exportLoading"
-          v-hasPermi="['infra:login-log:export']"
+          v-hasPermi="['system:login-log:export']"
         >
           <Icon icon="ep:download" class="mr-5px" /> 导出
         </el-button>
@@ -85,7 +85,7 @@
             link
             type="primary"
             @click="openDetail(scope.row)"
-            v-hasPermi="['infra:login-log:query']"
+            v-hasPermi="['system:login-log:query']"
           >
             详情
           </el-button>

+ 2 - 2
src/views/system/operatelog/index.vue

@@ -81,7 +81,7 @@
           plain
           @click="handleExport"
           :loading="exportLoading"
-          v-hasPermi="['infra:operate-log:export']"
+          v-hasPermi="['system:operate-log:export']"
         >
           <Icon icon="ep:download" class="mr-5px" /> 导出
         </el-button>
@@ -112,7 +112,7 @@
             link
             type="primary"
             @click="openDetail(scope.row)"
-            v-hasPermi="['infra:operate-log:query']"
+            v-hasPermi="['system:operate-log:query']"
           >
             详情
           </el-button>

Някои файлове не бяха показани, защото твърде много файлове са промени