瀏覽代碼

!542 IOT client 同步
Merge pull request !542 from 芋道源码/feature/iot

芋道源码 10 月之前
父節點
當前提交
53d1cb8fff

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

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

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

@@ -0,0 +1,62 @@
+import request from '@/config/axios'
+
+// IoT 产品 VO
+export interface ProductVO {
+  id: number // 产品编号
+  name: string // 产品名称
+  productKey: string // 产品标识
+  protocolId: number // 协议编号
+  categoryId: number // 产品所属品类标识符
+  description: string // 产品描述
+  validateType: number // 数据校验级别
+  status: number // 产品状态
+  deviceType: number // 设备类型
+  netType: number // 联网方式
+  protocolType: number // 接入网关协议
+  dataFormat: number // 数据格式
+  deviceCount: number // 设备数量
+  createTime: Date // 创建时间
+}
+
+// IoT 产品 API
+export const ProductApi = {
+  // 查询产品分页
+  getProductPage: async (params: any) => {
+    return await request.get({ url: `/iot/product/page`, params })
+  },
+
+  // 查询产品详情
+  getProduct: async (id: number) => {
+    return await request.get({ url: `/iot/product/get?id=` + id })
+  },
+
+  // 新增产品
+  createProduct: async (data: ProductVO) => {
+    return await request.post({ url: `/iot/product/create`, data })
+  },
+
+  // 修改产品
+  updateProduct: async (data: ProductVO) => {
+    return await request.put({ url: `/iot/product/update`, data })
+  },
+
+  // 删除产品
+  deleteProduct: async (id: number) => {
+    return await request.delete({ url: `/iot/product/delete?id=` + id })
+  },
+
+  // 导出产品 Excel
+  exportProduct: async (params) => {
+    return await request.download({ url: `/iot/product/export-excel`, params })
+  },
+
+  // 更新产品状态
+  updateProductStatus: async (id: number, status: number) => {
+    return await request.put({ url: `/iot/product/update-status?id=` + id + `&status=` + status })
+  },
+
+  // 查询产品(精简)列表
+  getSimpleProductList() {
+    return request.get({ url: '/iot/product/list-all-simple' })
+  }
+}

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

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

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

@@ -603,6 +603,38 @@ const remainingRouter: AppRouteRecordRaw[] = [
       hidden: true,
       breadcrumb: false
     }
+  },
+  {
+    path: '/iot',
+    component: Layout,
+    name: 'IOT',
+    meta: {
+      hidden: true
+    },
+    children: [
+      {
+        path: 'product/detail/:id',
+        name: 'IoTProductDetail',
+        meta: {
+          title: '产品详情',
+          noCache: true,
+          hidden: true,
+          activeMenu: '/iot/product'
+        },
+        component: () => import('@/views/iot/product/detail/index.vue')
+      },
+      {
+        path: 'device/detail/:id',
+        name: 'IoTDeviceDetail',
+        meta: {
+          title: '设备详情',
+          noCache: true,
+          hidden: true,
+          activeMenu: '/iot/device'
+        },
+        component: () => import('@/views/iot/device/detail/index.vue')
+      }
+    ]
   }
 ]
 

+ 14 - 1
src/utils/dict.ts

@@ -225,5 +225,18 @@ export enum DICT_TYPE {
   AI_WRITE_LENGTH = 'ai_write_length', // AI 写作长度
   AI_WRITE_FORMAT = 'ai_write_format', // AI 写作格式
   AI_WRITE_TONE = 'ai_write_tone', // AI 写作语气
-  AI_WRITE_LANGUAGE = 'ai_write_language' // AI 写作语言
+  AI_WRITE_LANGUAGE = 'ai_write_language', // AI 写作语言
+
+  // ========== IOT - 物联网模块  ==========
+  IOT_NET_TYPE = 'iot_net_type', // IOT 联网方式
+  IOT_VALIDATE_TYPE = 'iot_validate_type', // IOT 数据校验级别
+  IOT_PRODUCT_STATUS = 'iot_product_status', // IOT 产品状态
+  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_DATA_TYPE = 'iot_data_type', // IOT 数据类型
+  IOT_UNIT_TYPE = 'iot_unit_type', // IOT 单位类型
+  IOT_RW_TYPE = 'iot_rw_type' // IOT 读写类型
 }

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

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

+ 76 - 0
src/views/iot/device/detail/DeviceDetailsHeader.vue

@@ -0,0 +1,76 @@
+<template>
+  <div>
+    <div class="flex items-start justify-between">
+      <div>
+        <el-col>
+          <el-row>
+            <span class="text-xl font-bold">{{ device.deviceName }}</span>
+          </el-row>
+        </el-col>
+      </div>
+      <div>
+        <!-- 右上:按钮 -->
+        <el-button
+          @click="openForm('update', device.id)"
+          v-hasPermi="['iot:device:update']"
+          v-if="product.status === 0"
+        >
+          编辑
+        </el-button>
+      </div>
+    </div>
+  </div>
+  <ContentWrap class="mt-10px">
+    <el-descriptions :column="5" direction="horizontal">
+      <el-descriptions-item label="产品">
+        <el-link @click="goToProductDetail(product.id)">{{ product.name }}</el-link>
+      </el-descriptions-item>
+      <el-descriptions-item label="ProductKey">
+        {{ product.productKey }}
+        <el-button @click="copyToClipboard(product.productKey)">复制</el-button>
+      </el-descriptions-item>
+    </el-descriptions>
+  </ContentWrap>
+  <!-- 表单弹窗:添加/修改 -->
+  <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'
+
+const message = useMessage()
+const router = useRouter()
+
+// 操作修改
+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(() => {
+    message.success('复制成功')
+  })
+}
+
+/**
+ * 跳转到产品详情页面
+ *
+ * @param productId 产品 ID
+ */
+const goToProductDetail = (productId: number) => {
+  router.push({ name: 'IoTProductDetail', params: { id: productId } })
+}
+</script>

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

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

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

@@ -0,0 +1,66 @@
+<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 = Number(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>

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

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

+ 204 - 0
src/views/iot/product/ProductForm.vue

@@ -0,0 +1,204 @@
+<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="deviceType">
+        <el-select
+          v-model="formData.deviceType"
+          placeholder="请选择设备类型"
+          :disabled="formType === 'update'"
+        >
+          <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
+        v-if="formData.deviceType === 0 || formData.deviceType === 2"
+        label="联网方式"
+        prop="netType"
+      >
+        <el-select
+          v-model="formData.netType"
+          placeholder="请选择联网方式"
+          :disabled="formType === 'update'"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_NET_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item v-if="formData.deviceType === 1" label="接入网关协议" prop="protocolType">
+        <el-select
+          v-model="formData.protocolType"
+          placeholder="请选择接入网关协议"
+          :disabled="formType === 'update'"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PROTOCOL_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item label="数据格式" prop="dataFormat">
+        <el-select
+          v-model="formData.dataFormat"
+          placeholder="请选择接数据格式"
+          :disabled="formType === 'update'"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DATA_FORMAT)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item label="数据校验级别" prop="validateType">
+        <el-radio-group v-model="formData.validateType" :disabled="formType === 'update'">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_VALIDATE_TYPE)"
+            :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 { ProductApi, ProductVO } from '@/api/iot/product'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+
+defineOptions({ name: 'IoTProductForm' })
+
+const { t } = useI18n()
+const message = useMessage()
+
+const dialogVisible = ref(false)
+const dialogTitle = ref('')
+const formLoading = ref(false)
+const formType = ref('')
+const formData = ref({
+  name: undefined,
+  id: undefined,
+  productKey: undefined,
+  protocolId: undefined,
+  categoryId: undefined,
+  description: undefined,
+  validateType: undefined,
+  status: undefined,
+  deviceType: undefined,
+  netType: undefined,
+  protocolType: undefined,
+  dataFormat: undefined
+})
+const formRules = reactive({
+  name: [{ required: true, message: '产品名称不能为空', trigger: 'blur' }],
+  deviceType: [{ required: true, message: '设备类型不能为空', trigger: 'change' }],
+  netType: [
+    {
+      // TODO @haohao:0、1、/2 最好前端也枚举下;另外,这里的 required 可以直接设置为 true。然后表单那些 v-if。只要不存在,它自动就不校验了哈
+      required: formData.deviceType === 0 || formData.deviceType === 2,
+      message: '联网方式不能为空',
+      trigger: 'change'
+    }
+  ],
+  protocolType: [
+    { required: formData.deviceType === 1, message: '接入网关协议不能为空', trigger: 'change' }
+  ],
+  dataFormat: [{ required: true, message: '数据格式不能为空', trigger: 'change' }],
+  validateType: [{ required: true, message: '数据校验级别不能为空', trigger: 'change' }]
+})
+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 ProductApi.getProduct(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 ProductVO
+    if (formType.value === 'create') {
+      await ProductApi.createProduct(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ProductApi.updateProduct(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false // 确保关闭弹框
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    name: undefined,
+    id: undefined,
+    productKey: undefined,
+    protocolId: undefined,
+    categoryId: undefined,
+    description: undefined,
+    validateType: undefined,
+    status: undefined,
+    deviceType: undefined,
+    netType: undefined,
+    protocolType: undefined,
+    dataFormat: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 103 - 0
src/views/iot/product/detail/ProductDetailsHeader.vue

@@ -0,0 +1,103 @@
+<template>
+  <div>
+    <div class="flex items-start justify-between">
+      <div>
+        <el-col>
+          <el-row>
+            <span class="text-xl font-bold">{{ product.name }}</span>
+          </el-row>
+        </el-col>
+      </div>
+      <div>
+        <!-- 右上:按钮 -->
+        <el-button
+          @click="openForm('update', product.id)"
+          v-hasPermi="['iot:product:update']"
+          v-if="product.status === 0"
+        >
+          编辑
+        </el-button>
+        <el-button
+          type="primary"
+          @click="confirmPublish(product.id)"
+          v-hasPermi="['iot:product:update']"
+          v-if="product.status === 0"
+        >
+          发布
+        </el-button>
+        <el-button
+          type="danger"
+          @click="confirmUnpublish(product.id)"
+          v-hasPermi="['iot:product:update']"
+          v-if="product.status === 1"
+        >
+          撤销发布
+        </el-button>
+      </div>
+    </div>
+  </div>
+  <ContentWrap class="mt-10px">
+    <el-descriptions :column="5" direction="horizontal">
+      <el-descriptions-item label="ProductKey">
+        {{ product.productKey }}
+        <el-button @click="copyToClipboard(product.productKey)">复制</el-button>
+      </el-descriptions-item>
+    </el-descriptions>
+    <el-descriptions :column="5" direction="horizontal">
+      <el-descriptions-item label="设备数">
+        {{ product.deviceCount }}
+        <el-button @click="goToManagement(product.id)">前往管理</el-button>
+      </el-descriptions-item>
+    </el-descriptions>
+  </ContentWrap>
+  <!-- 表单弹窗:添加/修改 -->
+  <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'
+
+const message = useMessage()
+
+const { product } = defineProps<{ product: ProductVO }>() // 定义 Props
+
+/** 处理复制 */
+const copyToClipboard = (text: string) => {
+  navigator.clipboard.writeText(text).then(() => {
+    message.success('复制成功')
+  })
+}
+
+/** 路由跳转到设备管理 */
+const { push } = useRouter()
+const goToManagement = (productId: string) => {
+  push({ name: 'IoTDevice', query: { 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)
+    message.success('发布成功')
+    formRef.value.close() // 关闭弹框
+    emit('refresh')
+  } catch (error) {
+    message.error('发布失败')
+  }
+}
+const confirmUnpublish = async (id: number) => {
+  try {
+    await ProductApi.updateProductStatus(id, 0)
+    message.success('撤销发布成功')
+    formRef.value.close() // 关闭弹框
+    emit('refresh')
+  } catch (error) {
+    message.error('撤销发布失败')
+  }
+}
+</script>

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

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

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

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

+ 154 - 0
src/views/iot/product/detail/ThinkModelFunction.vue

@@ -0,0 +1,154 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="功能类型" prop="name">
+        <el-select
+          v-model="queryParams.type"
+          placeholder="请选择功能类型"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PRODUCT_FUNCTION_TYPE)"
+            :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:think-model-function:create']"
+        >
+          <Icon icon="ep:plus" 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="type">
+          <template #default="scope">
+            <dict-tag :type="DICT_TYPE.IOT_PRODUCT_FUNCTION_TYPE" :value="scope.row.type" />
+          </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">
+          <template #default="scope">
+            <el-button
+              link
+              type="primary"
+              @click="openForm('update', scope.row.id)"
+              v-hasPermi="[`iot:think-model-function:update`]"
+            >
+              编辑
+            </el-button>
+            <el-button
+              link
+              type="danger"
+              @click="handleDelete(scope.row.id)"
+              v-hasPermi="['iot:think-model-function:delete']"
+            >
+              删除
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <!-- 分页 -->
+      <Pagination
+        :total="total"
+        v-model:page="queryParams.pageNo"
+        v-model:limit="queryParams.pageSize"
+        @pagination="getList"
+      />
+    </el-tabs>
+  </ContentWrap>
+  <!-- 表单弹窗:添加/修改 -->
+  <ThinkModelFunctionForm ref="formRef" :product="product" @success="getList" />
+</template>
+<script setup lang="ts">
+import { ProductVO } from '@/api/iot/product'
+import { ThinkModelFunctionApi, ThinkModelFunctionVO } from '@/api/iot/thinkmodelfunction'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import ThinkModelFunctionForm from '@/views/iot/product/detail/ThinkModelFunctionForm.vue'
+
+const props = defineProps<{ product: ProductVO }>()
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const loading = ref(true) // 列表的加载中
+const list = ref<ThinkModelFunctionVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  type: undefined,
+  productId: -1
+})
+
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    queryParams.productId = props.product.id
+    const data = await ThinkModelFunctionApi.getThinkModelFunctionPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  queryParams.type = undefined
+  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 ThinkModelFunctionApi.deleteThinkModelFunction(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

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

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

+ 80 - 0
src/views/iot/product/detail/index.vue

@@ -0,0 +1,80 @@
+<template>
+  <ProductDetailsHeader :loading="loading" :product="product" @refresh="() => getProductData(id)" />
+  <el-col>
+    <el-tabs v-model="activeTab">
+      <el-tab-pane label="产品信息" name="info">
+        <ProductDetailsInfo v-if="activeTab === 'info'" :product="product" />
+      </el-tab-pane>
+      <el-tab-pane label="Topic 类列表" name="topic">
+        <ProductTopic v-if="activeTab === 'topic'" :product="product" />
+      </el-tab-pane>
+      <el-tab-pane label="功能定义" name="function">
+        <ThinkModelFunction v-if="activeTab === 'function'" :product="product" />
+      </el-tab-pane>
+      <el-tab-pane label="消息解析" name="message" />
+      <el-tab-pane label="服务端订阅" name="subscription" />
+    </el-tabs>
+  </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 { useTagsViewStore } from '@/store/modules/tagsView'
+import { useRouter } from 'vue-router'
+
+defineOptions({ name: 'IoTProductDetail' })
+
+const { delView } = useTagsViewStore() // 视图操作
+const { currentRoute } = useRouter()
+
+const route = useRoute()
+const message = useMessage()
+const id = Number(route.params.id) // 编号
+const loading = ref(true) // 加载中
+const product = ref<ProductVO>({} as ProductVO) // 详情
+const activeTab = ref('info') // 默认激活的标签页
+
+/** 获取详情 */
+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
+  } catch (error) {
+    console.error('Error fetching device count:', error)
+    return 0
+  }
+}
+
+/** 初始化 */
+onMounted(async () => {
+  if (!id) {
+    message.warning('参数错误,产品不能为空!')
+    delView(unref(currentRoute))
+    return
+  }
+  await getProductData(id)
+  // 查询设备数量
+  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>

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

@@ -0,0 +1,191 @@
+<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="ProductKey" prop="productKey">
+        <el-input
+          v-model="queryParams.productKey"
+          placeholder="请输入产品标识"
+          clearable
+          @keyup.enter="handleQuery"
+          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-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['iot:product: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="产品名称" 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">
+        <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="createTime"
+        :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">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openDetail(scope.row.id)"
+            v-hasPermi="['iot:product:query']"
+          >
+            查看
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['iot:product:delete']"
+            :disabled="scope.row.status === 1"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <ProductForm 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'
+
+/** iot 产品 列表 */
+defineOptions({ name: 'IoTProduct' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<ProductVO[]>([]) // 列表的数据
+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
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ProductApi.getProductPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+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)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>