Browse Source

pms 设备属性模板代码初始化

zhangcl 4 months ago
parent
commit
9db0897882

+ 24 - 15
src/api/pms/devicetemplate/index.ts

@@ -24,27 +24,36 @@ export const getAttrTemplateByDeviceCategoryId = async (params: any): Promise<an
   return await request.get({ url: '/rq/iot-device-template/list-by-device-category-id?deviceCategoryId='+ params })
 }
 
-// 查询物料组列表
-export const getMaterialGroupPage = async (params: PageParam) => {
-  return await request.get({ url: '/rq/iot-material-group/list', params })
+// 查询 设备属性模板 列表
+export const getDeviceTemplatePage = async (params: PageParam) => {
+  return await request.get({ url: '/rq/iot-device-template/page', params })
 }
 
-// 查询物料组详情
-export const getMaterialGroup = async (id: number) => {
-  return await request.get({ url: '/rq/iot-material-group/get?id=' + id })
+// 查询 设备属性模板 详情
+export const getDeviceTemplate = async (id: number) => {
+  return await request.get({ url: '/rq/iot-device-template/get?id=' + id })
 }
 
-// 新增物料组
-export const createMaterialGroup = async (data: DeviceAttrTemplateVO) => {
-  return await request.post({ url: '/rq/iot-material-group/create', data: data })
+// 新增 设备属性模板
+export const createDeviceTemplate = async (data: DeviceAttrTemplateVO) => {
+  return await request.post({ url: '/rq/iot-device-template/create', data: data })
 }
 
-// 修改物料组
-export const updateMaterialGroup = async (params: DeviceAttrTemplateVO) => {
-  return await request.put({ url: '/rq/iot-material-group/update', data: params })
+// 修改 设备属性模板
+export const updateDeviceTemplate = async (params: DeviceAttrTemplateVO) => {
+  return await request.put({ url: '/rq/iot-device-template/update', data: params })
 }
 
-// 删除物料组
-export const deleteMaterialGroup = async (id: number) => {
-  return await request.delete({ url: '/rq/iot-material-group/delete?id=' + id })
+// 设备属性模板状态修改
+export const updateDeviceTemplateStatus = (id: number, status: number) => {
+  const data = {
+    id,
+    status
+  }
+  return request.put({ url: '/rq/iot-device-template/update-status', data: data })
+}
+
+// 删除 设备属性模板
+export const deleteDeviceTemplate = async (id: number) => {
+  return await request.delete({ url: '/rq/iot-device-template/delete?id=' + id })
 }

+ 123 - 0
src/views/pms/devicetemplate/DeviceCategoryTree.vue

@@ -0,0 +1,123 @@
+<template>
+  <div class="head-container">
+    <el-input v-model="deviceCategoryName" class="mb-20px" clearable placeholder="请输入设备分类名称">
+      <template #prefix>
+        <Icon icon="ep:search" />
+      </template>
+    </el-input>
+  </div>
+  <div class="head-container">
+    <el-tree
+      ref="treeRef"
+      :data="deviceCategoryList"
+      :expand-on-click-node="false"
+      :filter-node-method="filterNode"
+      :props="defaultProps"
+      default-expand-all
+      highlight-current
+      node-key="id"
+      @node-click="handleNodeClick"
+      @node-contextmenu="handleRightClick"
+      style="height: 35em"
+    />
+  </div>
+  <div
+    v-show="menuVisible"
+    class="custom-menu"
+    :style="{ left: menuX + 'px', top: menuY + 'px' }"
+  >
+    <ul>
+      <li @click="handleMenuClick('add')">新增子节点</li>
+      <li @click="handleMenuClick('edit')">重命名</li>
+      <li @click="handleMenuClick('delete')">删除</li>
+    </ul>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ElTree } from 'element-plus'
+import * as MaterialGroupApi from '@/api/pms/materialgroup'
+import * as DeviceCategoryApi from '@/api/pms/productclassify'
+import { defaultProps, handleTree } from '@/utils/tree'
+
+defineOptions({ name: 'DeviceCategoryTree' })
+
+const deviceCategoryName = ref('')
+const deviceCategoryList = ref<Tree[]>([]) // 树形结构
+const treeRef = ref<InstanceType<typeof ElTree>>()
+const menuVisible = ref(false);
+const menuX = ref(0);
+const menuY = ref(0);
+let selectedNode = null;
+const handleRightClick = (event, { node, data }) => {
+  event.preventDefault();
+  menuX.value = event.clientX;
+  menuY.value = event.clientY;
+  selectedNode = data; // 存储当前操作的节点数据 ‌:ml-citation{ref="7" data="citationList"}
+  menuVisible.value = true;
+};
+
+const handleMenuClick = (action) => {
+  switch(action) {
+    case 'add':
+      // 调用新增节点逻辑 ‌:ml-citation{ref="4" data="citationList"}
+      break;
+    case 'edit':
+      // 调用编辑节点逻辑 ‌:ml-citation{ref="7" data="citationList"}
+      break;
+    case 'delete':
+      // 调用删除节点逻辑 ‌:ml-citation{ref="4" data="citationList"}
+      break;
+  }
+  menuVisible.value = false;
+};
+/** 获得 设备分类 树 */
+const getTree = async () => {
+  const res = await DeviceCategoryApi.IotProductClassifyApi.getSimpleProductClassifyList()
+  deviceCategoryList.value = []
+  deviceCategoryList.value.push(...handleTree(res))
+}
+
+/** 基于名字过滤 */
+const filterNode = (name: string, data: Tree) => {
+  if (!name) return true
+  return data.name.includes(name)
+}
+
+/** 处理 设备分类树 被点击 */
+const handleNodeClick = async (row: { [key: string]: any }) => {
+  emits('node-click', row)
+}
+const emits = defineEmits(['node-click'])
+
+/** 监听 deviceCategoryName */
+watch(deviceCategoryName, (val) => {
+  treeRef.value!.filter(val)
+})
+
+/** 初始化 */
+onMounted(async () => {
+  await getTree()
+})
+</script>
+<style lang="scss" scoped>
+.custom-menu {
+  position: fixed;
+  background: white;
+  border: 1px solid #ccc;
+  box-shadow: 2px 2px 5px rgba(0,0,0,0.1);
+  z-index: 1000;
+}
+.custom-menu ul {
+  list-style: none;
+  padding: 0;
+  margin: 0;
+}
+.custom-menu li {
+  padding: 8px 20px;
+  cursor: pointer;
+}
+.custom-menu li:hover {
+  background: #f5f5f5;
+}
+</style>

+ 150 - 0
src/views/pms/devicetemplate/TemplateForm.vue

@@ -0,0 +1,150 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+    >
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="物料名称" prop="name">
+            <el-input v-model="formData.name" placeholder="请输入物料名称" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="所属物料组" prop="materialGroupId">
+            <el-tree-select
+              v-model="formData.materialGroupId"
+              :data="materialGroupList"
+              :props="defaultProps"
+              check-strictly
+              node-key="id"
+              placeholder="请选择所属物料组"
+            />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="物料编码" prop="code">
+            <el-input v-model="formData.code" maxlength="32" placeholder="请输入物料编码" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="规格型号" prop="model">
+            <el-input v-model="formData.model" maxlength="50" placeholder="请输入规格型号" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="24">
+          <el-form-item label="备注">
+            <el-input v-model="formData.remark" placeholder="请输入内容" type="textarea" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+    </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 { CommonStatusEnum } from '@/utils/constants'
+import { defaultProps, handleTree } from '@/utils/tree'
+import * as MaterialGroupApi from '@/api/pms/materialgroup'
+import * as MaterialApi from '@/api/pms/material'
+import { FormRules } from 'element-plus'
+
+defineOptions({ name: 'MaterialForm' })
+
+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({
+  name: '',
+  materialGroupId: '',
+  code: '',
+  model: '',
+  id: undefined,
+  unit: '',
+  remark: '',
+  status: CommonStatusEnum.ENABLE,
+})
+const formRules = reactive<FormRules>({
+  name: [{ required: true, message: '物料名称不能为空', trigger: 'blur' }],
+  code: [{ required: true, message: '物料编码不能为空', trigger: 'blur' }],
+  materialGroupId: [{ required: true, message: '所属物料组不能为空', trigger: 'blur' }],
+
+})
+const formRef = ref() // 表单 Ref
+const materialGroupList = ref<Tree[]>([]) // 树形结构
+
+/** 打开弹窗 */
+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 MaterialApi.getMaterial(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+  // 加载 物料组 树
+  materialGroupList.value = handleTree(await MaterialGroupApi.getSimpleMaterialGroupList())
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as MaterialApi.MaterialVO
+    if (formType.value === 'create') {
+      await MaterialApi.createMaterial(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await MaterialApi.updateMaterial(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    name: '',
+    materialGroupId: '',
+    code: '',
+    model: '',
+    id: undefined,
+    unit: '',
+    remark: '',
+    status: CommonStatusEnum.ENABLE,
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 215 - 0
src/views/pms/devicetemplate/detail/attrsModel/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>

+ 169 - 0
src/views/pms/devicetemplate/detail/attrsModel/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>

+ 213 - 0
src/views/pms/devicetemplate/detail/attrsModel/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()
+}

+ 180 - 0
src/views/pms/devicetemplate/detail/attrsModel/index.vue

@@ -0,0 +1,180 @@
+<!-- 产品的物模型列表 -->
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      ref="queryFormRef"
+      :inline="true"
+      :model="queryParams"
+      class="-mb-15px"
+      label-width="68px"
+    >
+      <el-form-item label="功能类型" prop="name">
+        <el-select
+          v-model="queryParams.type"
+          class="!w-240px"
+          clearable
+          placeholder="请选择功能类型"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_THING_MODEL_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery">
+          <Icon class="mr-5px" icon="ep:search" />
+          搜索
+        </el-button>
+        <el-button @click="resetQuery">
+          <Icon class="mr-5px" icon="ep:refresh" />
+          重置
+        </el-button>
+        <el-button
+          v-hasPermi="[`iot:thing-model:create`]"
+          plain
+          type="primary"
+          @click="openForm('create')"
+        >
+          <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" :show-overflow-tooltip="true" :stripe="true">
+        <el-table-column align="center" label="功能类型" prop="type">
+          <template #default="scope">
+            <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 align="center" label="操作">
+          <template #default="scope">
+            <el-button
+              v-hasPermi="[`iot:thing-model:update`]"
+              link
+              type="primary"
+              @click="openForm('update', scope.row.id)"
+            >
+              编辑
+            </el-button>
+            <el-button
+              v-hasPermi="['iot:thing-model: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"
+      />
+    </el-tabs>
+  </ContentWrap>
+  <!-- 表单弹窗:添加/修改 -->
+  <ThingModelForm ref="formRef" @success="getList" />
+</template>
+<script lang="ts" setup>
+import { ThingModelApi, ThingModelData } from '@/api/iot/thingmodel'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+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'
+
+defineOptions({ name: 'IoTThingModel' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const loading = ref(true) // 列表的加载中
+const list = ref<ThingModelData[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  type: undefined,
+  productId: -1
+})
+
+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 = 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
+  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 ThingModelApi.deleteThingModel(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 61 - 0
src/views/pms/devicetemplate/detail/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/pms/devicetemplate/detail/components/index.ts

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

+ 52 - 0
src/views/pms/devicetemplate/detail/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 '../attrsModel/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/pms/devicetemplate/detail/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 '../attrsModel/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/pms/devicetemplate/detail/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 '../attrsModel/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/pms/devicetemplate/detail/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 '../attrsModel/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/pms/devicetemplate/detail/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
+}

+ 82 - 0
src/views/pms/devicetemplate/detail/index.vue

@@ -0,0 +1,82 @@
+<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="功能定义" lazy name="thingModel">
+        <IoTProductThingModel ref="thingModelRef" />
+      </el-tab-pane>
+      <el-tab-pane label="消息解析" name="message" />
+      <el-tab-pane label="服务端订阅" name="subscription" />
+    </el-tabs>
+  </el-col>
+</template>
+<script lang="ts" setup>
+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' })
+
+const { delView } = useTagsViewStore() // 视图操作
+const { currentRoute } = useRouter()
+
+const route = useRoute()
+const message = useMessage()
+const id = route.params.id // 编号
+const loading = ref(true) // 加载中
+const product = ref<ProductVO>({} as ProductVO) // 详情
+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)
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 查询设备数量 */
+const getDeviceCount = async (productId: number) => {
+  try {
+    return await DeviceApi.getDeviceCount(productId)
+  } catch (error) {
+    console.error('Error fetching device count:', error, 'productId:', productId)
+    return 0
+  }
+}
+
+/** 初始化 */
+onMounted(async () => {
+  if (!id) {
+    message.warning('参数错误,产品不能为空!')
+    delView(unref(currentRoute))
+    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)
+  }
+})
+</script>

+ 274 - 0
src/views/pms/devicetemplate/index.vue

@@ -0,0 +1,274 @@
+<template>
+  <doc-alert title="用户体系" url="https://doc.iocoder.cn/user-center/" />
+  <doc-alert title="三方登陆" url="https://doc.iocoder.cn/social-user/" />
+  <doc-alert title="Excel 导入导出" url="https://doc.iocoder.cn/excel-import-and-export/" />
+
+  <el-row :gutter="20">
+    <!-- 左侧 设备分类 树 -->
+    <el-col :span="4" :xs="24">
+      <ContentWrap class="h-1/1">
+        <DeviceCategoryTree @node-click="handleDeviceCategoryTreeNodeClick" />
+      </ContentWrap>
+    </el-col>
+    <el-col :span="20" :xs="24">
+      <!-- 搜索 -->
+      <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="code">
+            <el-input
+              v-model="queryParams.code"
+              placeholder="请输入模板编码"
+              clearable
+              @keyup.enter="handleQuery"
+              class="!w-240px"
+            />
+          </el-form-item>
+          <el-form-item label="状态" prop="status">
+            <el-select
+              v-model="queryParams.status"
+              placeholder="属性模板状态"
+              clearable
+              class="!w-240px"
+            >
+              <el-option
+                v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value"
+              />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="创建时间" prop="createTime">
+            <el-date-picker
+              v-model="queryParams.createTime"
+              value-format="YYYY-MM-DD HH:mm:ss"
+              type="datetimerange"
+              start-placeholder="开始日期"
+              end-placeholder="结束日期"
+              class="!w-240px"
+            />
+          </el-form-item>
+          <el-form-item>
+            <el-button @click="handleQuery"><Icon icon="ep:search" />搜索</el-button>
+            <el-button @click="resetQuery"><Icon icon="ep:refresh" />重置</el-button>
+            <el-button
+              type="primary"
+              plain
+              @click="openForm('create')"
+              v-hasPermi="['rq:iot-device-template:create']"
+            >
+              <Icon icon="ep:plus" /> 新增
+            </el-button>
+          </el-form-item>
+        </el-form>
+      </ContentWrap>
+      <ContentWrap>
+        <el-table v-loading="loading" :data="list">
+          <el-table-column
+            label="模板编码"
+            align="center"
+            prop="code"
+            :show-overflow-tooltip="true"
+          />
+          <el-table-column
+            label="模板名称"
+            align="center"
+            prop="name"
+            :show-overflow-tooltip="true"
+          />
+          <el-table-column
+            label="设备分类名称"
+            align="center"
+            key="deviceCategoryName"
+            prop="deviceCategoryName"
+            :show-overflow-tooltip="true"
+          />
+          <el-table-column label="状态" key="status">
+            <template #default="scope">
+              <el-switch
+                v-model="scope.row.status"
+                :active-value="0"
+                :inactive-value="1"
+                @change="handleStatusChange(scope.row)"
+                :disabled="!checkPermi(['rq:iot-device-template:update'])"
+              />
+            </template>
+          </el-table-column>
+          <el-table-column
+            label="创建时间"
+            align="center"
+            prop="createTime"
+            :formatter="dateFormatter"
+            width="180"
+          />
+          <el-table-column label="操作" align="center" width="160">
+            <template #default="scope">
+              <div class="flex items-center justify-center">
+                <el-button
+                  type="primary"
+                  link
+                  @click="openForm('update', scope.row.id)"
+                  v-hasPermi="['rq:iot-device-template:update']"
+                >
+                  <Icon icon="ep:edit" />修改
+                </el-button>
+                <el-dropdown
+                  @command="(command) => handleCommand(command, scope.row)"
+                  v-hasPermi="[
+                    'rq:iot-device-template:delete'
+                  ]"
+                >
+                  <el-button type="primary" link><Icon icon="ep:d-arrow-right" /> 更多</el-button>
+                  <template #dropdown>
+                    <el-dropdown-menu>
+                      <el-dropdown-item
+                        command="handleDelete"
+                        v-if="checkPermi(['rq:iot-device-template:delete'])"
+                      >
+                        <Icon icon="ep:delete" />删除
+                      </el-dropdown-item>
+                    </el-dropdown-menu>
+                  </template>
+                </el-dropdown>
+              </div>
+            </template>
+          </el-table-column>
+        </el-table>
+        <Pagination
+          :total="total"
+          v-model:page="queryParams.pageNo"
+          v-model:limit="queryParams.pageSize"
+          @pagination="getList"
+        />
+      </ContentWrap>
+    </el-col>
+  </el-row>
+
+  <!-- 添加或修改 属性模板 对话框 -->
+  <TemplateForm ref="formRef" @success="getList" />
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { checkPermi } from '@/utils/permission'
+import { dateFormatter } from '@/utils/formatTime'
+import { CommonStatusEnum } from '@/utils/constants'
+import * as DeviceTemplateApi from '@/api/pms/devicetemplate'
+import TemplateForm from './TemplateForm.vue'
+import DeviceCategoryTree from './DeviceCategoryTree.vue'
+
+defineOptions({ name: 'DeviceAttrsTemplate' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined,
+  code: undefined,
+  status: undefined,
+  deviceCategoryId: undefined,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询 设备属性模板 列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await DeviceTemplateApi.getDeviceTemplatePage(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 handleDeviceCategoryTreeNodeClick = async (row) => {
+  queryParams.deviceCategoryId = row.id
+  await getList()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 修改 设备属性模板 状态 */
+const handleStatusChange = async (row: DeviceTemplateApi.DeviceAttrTemplateVO) => {
+  try {
+    // 修改状态的二次确认
+    const text = row.status === CommonStatusEnum.ENABLE ? '启用' : '停用'
+    await message.confirm('确认要"' + text + '""' + row.name + '"属性模板吗?')
+    // 发起修改状态
+    await DeviceTemplateApi.updateDeviceTemplateStatus(row.id, row.status)
+    // 刷新列表
+    await getList()
+  } catch {
+    // 取消后,进行恢复按钮
+    row.status =
+      row.status === CommonStatusEnum.ENABLE ? CommonStatusEnum.DISABLE : CommonStatusEnum.ENABLE
+  }
+}
+
+/** 操作分发 */
+const handleCommand = (command: string, row: DeviceTemplateApi.DeviceAttrTemplateVO) => {
+  switch (command) {
+    case 'handleDelete':
+      handleDelete(row.id)
+      break
+    default:
+      break
+  }
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await DeviceTemplateApi.deleteDeviceTemplate(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 */
+onMounted(() => {
+  getList()
+})
+</script>

+ 2 - 2
src/views/pms/material/index.vue

@@ -178,9 +178,9 @@
     </el-col>
   </el-row>
 
-  <!-- 添加或修改用户对话框 -->
+  <!-- 添加或修改 物料 对话框 -->
   <MaterialForm ref="formRef" @success="getList" />
-  <!-- 用户导入对话框 -->
+  <!-- 物料 导入对话框 -->
   <MaterialImportForm ref="importFormRef" @success="getList" />
 </template>
 <script lang="ts" setup>