浏览代码

【功能完善】IoT:设备新增时,增加 deviceKey、serialNumber、picUrl 等字段

YunaiV 8 月之前
父节点
当前提交
dde5911b5f

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

@@ -91,6 +91,11 @@ export const DeviceApi = {
     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 } })
+  },
+
   // 获取设备属性最新数据
   getDevicePropertiesLatestData: async (params: any) => {
     return await request.get({ url: `/iot/device/data/latest`, params })

+ 96 - 14
src/views/iot/device/DeviceForm.vue → src/views/iot/device/device/DeviceForm.vue

@@ -13,6 +13,7 @@
           placeholder="请选择产品"
           :disabled="formType === 'update'"
           clearable
+          @change="handleProductChange"
         >
           <el-option
             v-for="product in products"
@@ -22,6 +23,19 @@
           />
         </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"
@@ -29,9 +43,30 @@
           :disabled="formType === 'update'"
         />
       </el-form-item>
-      <el-form-item label="备注名称" prop="nickname">
-        <el-input v-model="formData.nickname" placeholder="请输入备注名称" />
+      <el-form-item v-if="formData.deviceType === 1" 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="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>
@@ -41,13 +76,15 @@
 </template>
 <script setup lang="ts">
 import { DeviceApi, DeviceVO } from '@/api/iot/device'
-import { ProductApi } from '@/api/iot/product/product'
+import { DeviceTypeEnum, ProductApi, ProductVO } from '@/api/iot/product/product'
+import { UploadImg } from '@/components/UploadFile'
+import { generateRandomStr } from '@/utils'
 
-/** IoT 设备 表单 */
+/** IoT 设备表单 */
 defineOptions({ name: 'IoTDeviceForm' })
 
 const { t } = useI18n() // 国际化
-const message = useMessage() // 消息
+const message = useMessage() // 消息窗
 
 const dialogVisible = ref(false) // 弹窗的是否展示
 const dialogTitle = ref('') // 弹窗的标题
@@ -56,12 +93,26 @@ const formType = ref('') // 表单的类型:create - 新增;update - 修改
 const formData = ref({
   id: undefined,
   productId: undefined,
+  deviceKey: undefined as string | undefined,
   deviceName: undefined,
-  nickname: undefined
+  nickname: undefined,
+  picUrl: undefined,
+  gatewayId: undefined,
+  deviceType: undefined as number | undefined,
+  serialNumber: undefined
 })
 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:
@@ -87,9 +138,18 @@ const formRules = reactive({
       },
       trigger: 'blur'
     }
+  ],
+  serialNumber: [
+    {
+      pattern: /^[a-zA-Z0-9-_]+$/,
+      message: '序列号只能包含字母、数字、中划线和下划线',
+      trigger: 'blur'
+    }
   ]
 })
 const formRef = ref() // 表单 Ref
+const products = ref<ProductVO[]>([]) // 产品列表
+const gatewayDevices = ref<DeviceVO[]>([]) // 网关设备列表
 
 /** 打开弹窗 */
 const open = async (type: string, id?: number) => {
@@ -97,6 +157,7 @@ const open = async (type: string, id?: number) => {
   dialogTitle.value = t('action.' + type)
   formType.value = type
   resetForm()
+
   // 修改时,设置数据
   if (id) {
     formLoading.value = true
@@ -105,7 +166,18 @@ const open = async (type: string, id?: number) => {
     } finally {
       formLoading.value = false
     }
+  } else {
+    generateDeviceKey()
+  }
+
+  // 加载网关设备列表
+  try {
+    gatewayDevices.value = await DeviceApi.getSimpleDeviceList(DeviceTypeEnum.GATEWAY)
+  } catch (error) {
+    console.error('加载网关设备列表失败:', error)
   }
+  // 加载产品列表
+  products.value = await ProductApi.getSimpleProductList()
 }
 defineExpose({ open }) // 提供 open 方法,用于打开弹窗
 
@@ -138,19 +210,29 @@ const resetForm = () => {
   formData.value = {
     id: undefined,
     productId: undefined,
+    deviceKey: undefined,
     deviceName: undefined,
-    nickname: undefined
+    nickname: undefined,
+    picUrl: undefined,
+    gatewayId: undefined,
+    deviceType: undefined,
+    serialNumber: undefined
   }
   formRef.value?.resetFields()
 }
 
-/** 查询字典下拉列表 */
-const products = ref()
-const getProducts = async () => {
-  products.value = await ProductApi.getSimpleProductList()
+/** 产品选择变化 */
+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
 }
 
-onMounted(() => {
-  getProducts()
-})
+/** 生成 DeviceKey */
+const generateDeviceKey = () => {
+  formData.value.deviceKey = generateRandomStr(16)
+}
 </script>

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


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


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


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


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


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


+ 13 - 2
src/views/iot/product/product/ProductForm.vue

@@ -12,7 +12,13 @@
           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="请输入产品名称" />
@@ -184,7 +190,7 @@ const open = async (type: string, id?: number) => {
     }
   } else {
     // 新增时,生成随机 productKey
-    formData.value.productKey = generateRandomStr(16)
+    generateProductKey()
   }
   // 加载分类列表
   categoryList.value = await ProductCategoryApi.getSimpleProductCategoryList()
@@ -231,4 +237,9 @@ const resetForm = () => {
   }
   formRef.value?.resetFields()
 }
+
+/** 生成 ProductKey */
+const generateProductKey = () => {
+  formData.value.productKey = generateRandomStr(16)
+}
 </script>

+ 21 - 17
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"
@@ -29,23 +29,18 @@ 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 [
     {

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

@@ -36,7 +36,7 @@ const message = useMessage()
 const id = Number(route.params.id) // 编号
 const loading = ref(true) // 加载中
 const product = ref<ProductVO>({} as ProductVO) // 详情
-const activeTab = ref('info') // 默认激活的标签页
+const activeTab = ref('info') // 默认为 info 标签页
 
 /** 获取详情 */
 const getProductData = async (id: number) => {
@@ -66,6 +66,11 @@ 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)

+ 92 - 83
src/views/iot/product/product/index.vue

@@ -63,96 +63,89 @@
 
   <!-- 卡片视图 -->
   <ContentWrap>
-    <div v-if="viewMode === 'card'" class="flex flex-wrap gap-4">
-      <el-card
-        v-for="item in list"
-        :key="item.id"
-        class="w-[calc(25%-12px)] transition-colors"
-        :body-style="{ padding: '0' }"
-      >
-        <!-- 内容区域 -->
-        <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]"
-                fit="contain"
-              />
+    <el-row v-if="viewMode === 'card'" :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" :body-style="{ padding: '0' }">
+          <!-- 内容区域 -->
+          <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="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 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]">{{ item.productKey }}</span>
+                </div>
               </div>
-              <div class="mb-2.5 last:mb-0">
-                <span class="text-[#717c8e] mr-2.5">产品标识</span>
-                <span class="text-[#0b1d30]">{{ item.productKey }}</span>
+              <div class="w-[100px] h-[100px]">
+                <el-image :src="item.picUrl || defaultPicUrl" class="w-full h-full" />
               </div>
             </div>
-            <div class="w-[100px] h-[100px]">
-              <el-image :src="item.picUrl || defaultPicUrl" class="w-full h-full" fit="cover" />
-            </div>
-          </div>
 
-          <!-- 分隔线 -->
-          <el-divider class="!my-3" />
+            <!-- 分隔线 -->
+            <el-divider class="!my-3" />
 
-          <!-- 按钮组 -->
-          <div class="flex items-center">
-            <el-button
-              class="flex-1 !px-2 !h-[32px]"
-              type="primary"
-              plain
-              @click="openForm('update', item.id)"
-              v-hasPermi="['iot:product:update']"
-            >
-              <Icon icon="ep:edit-pen" class="mr-1" />
-              编辑
-            </el-button>
-            <el-button
-              class="flex-1 !px-2 !h-[32px] !ml-[12px]"
-              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-[12px]"
-              type="success"
-              plain
-              @click="openObjectModel(item)"
-            >
-              <Icon icon="ep:scale-to-original" class="mr-1" />
-              物模型
-            </el-button>
-            <div class="mx-[12px] h-[20px] w-[1px] bg-[#dcdfe6]"></div>
-            <el-button
-              class="!px-2 !h-[32px]"
-              type="danger"
-              plain
-              @click="handleDelete(item.id)"
-              v-hasPermi="['iot:product:delete']"
-              :disabled="item.status === 1"
-            >
-              <Icon icon="ep:delete" />
-            </el-button>
+            <!-- 按钮组 -->
+            <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:product: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="success"
+                plain
+                @click="openObjectModel(item)"
+              >
+                <Icon icon="ep:scale-to-original" 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:product:delete']"
+                :disabled="item.status === 1"
+              >
+                <Icon icon="ep:delete" />
+              </el-button>
+            </div>
           </div>
-        </div>
-      </el-card>
-    </div>
+        </el-card>
+      </el-col>
+    </el-row>
 
     <!-- 列表视图 -->
     <el-table v-else v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
@@ -251,8 +244,11 @@ 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({
@@ -296,11 +292,19 @@ const openForm = (type: string, id?: number) => {
 }
 
 /** 打开详情 */
-const { push } = useRouter()
 const openDetail = (id: number) => {
   push({ name: 'IoTProductDetail', params: { id } })
 }
 
+/** 打开物模型 */
+const openObjectModel = (item: ProductVO) => {
+  push({
+    name: 'IoTProductDetail',
+    params: { id: item.id },
+    query: { tab: 'function' }
+  })
+}
+
 /** 删除按钮操作 */
 const handleDelete = async (id: number) => {
   try {
@@ -332,5 +336,10 @@ const handleExport = async () => {
 /** 初始化 **/
 onMounted(() => {
   getList()
+  // 处理 tab 参数
+  const { tab } = route.query
+  if (tab) {
+    activeName.value = tab as string
+  }
 })
 </script>