Explorar el Código

通用物模型

yanghao hace 1 semana
padre
commit
ea91f51f89

+ 1 - 1
.env.local

@@ -4,7 +4,7 @@ NODE_ENV=development
 VITE_DEV=true
 
 # 请求路径  http://192.168.188.200:48080  https://iot.deepoil.cc:5443  http://172.26.0.56:48080  http://192.168.188.198:48080
-VITE_BASE_URL='https://iot.deepoil.cc:5443'
+VITE_BASE_URL='http://172.26.0.56:48080'
 
 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持 S3 服务
 VITE_UPLOAD_TYPE=server

+ 0 - 310
src/views/iot/commonThing/CommonThingModelForm.vue

@@ -1,310 +0,0 @@
-<template>
-  <Dialog v-model="dialogVisible" :title="dialogTitle" width="900px">
-    <el-form
-      ref="formRef"
-      v-loading="formLoading"
-      :model="formData"
-      :rules="formRules"
-      label-width="100px">
-      <el-form-item label="功能标识" prop="identifier">
-        <el-input v-model="formData.identifier" placeholder="请输入功能标识" />
-      </el-form-item>
-      <el-form-item label="功能名称" prop="name">
-        <el-input v-model="formData.name" placeholder="请输入功能名称" />
-      </el-form-item>
-      <el-form-item label="功能描述" prop="description">
-        <el-input
-          v-model="formData.description"
-          type="textarea"
-          :rows="3"
-          :maxlength="200"
-          placeholder="请输入功能描述" />
-      </el-form-item>
-      <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 v-if="formData.type === 1" label="属性" prop="property">
-        <div class="json-editor-field">
-          <div class="json-editor-toolbar">
-            <span class="json-editor-toolbar__tip">请输入属性 JSON 数据</span>
-            <div class="json-editor-toolbar__actions">
-              <el-button text type="primary" @click="formatJsonField('property')">
-                美化 JSON
-              </el-button>
-              <el-button text @click="compressJsonField('property')">压缩 JSON</el-button>
-            </div>
-          </div>
-          <v-ace-editor
-            v-model:value="formData.property"
-            lang="json"
-            theme="monokai"
-            class="json-editor"
-            :options="editorOptions" />
-        </div>
-      </el-form-item>
-
-      <el-form-item v-if="formData.type === 3" label="事件" prop="event">
-        <div class="json-editor-field">
-          <div class="json-editor-toolbar">
-            <span class="json-editor-toolbar__tip">请输入事件 JSON 数据</span>
-            <div class="json-editor-toolbar__actions">
-              <el-button text type="primary" @click="formatJsonField('event')">美化 JSON</el-button>
-              <el-button text @click="compressJsonField('event')">压缩 JSON</el-button>
-            </div>
-          </div>
-          <v-ace-editor
-            v-model:value="formData.event"
-            lang="json"
-            theme="monokai"
-            class="json-editor"
-            :options="editorOptions" />
-        </div>
-      </el-form-item>
-
-      <el-form-item v-if="formData.type === 2" label="服务" prop="service">
-        <div class="json-editor-field">
-          <div class="json-editor-toolbar">
-            <span class="json-editor-toolbar__tip">请输入服务 JSON 数据</span>
-            <div class="json-editor-toolbar__actions">
-              <el-button text type="primary" @click="formatJsonField('service')">
-                美化 JSON
-              </el-button>
-              <el-button text @click="compressJsonField('service')">压缩 JSON</el-button>
-            </div>
-          </div>
-          <v-ace-editor
-            v-model:value="formData.service"
-            lang="json"
-            theme="monokai"
-            class="json-editor"
-            :options="editorOptions" />
-        </div>
-      </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 '@/components/mt-edit/ace-edit'
-import { VAceEditor } from 'vue3-ace-editor'
-import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
-import { ThingModelCommon, type CommonThingModelData } from '@/api/iot/thingmodel'
-
-defineOptions({ name: 'CommonThingModelForm' })
-
-const { t } = useI18n()
-const message = useMessage()
-
-const dialogVisible = ref(false)
-const dialogTitle = ref('')
-const formLoading = ref(false)
-const formType = ref('')
-const formRef = ref()
-const editorOptions = {
-  useWorker: true,
-  enableBasicAutocompletion: true,
-  enableSnippets: true,
-  enableLiveAutocompletion: true
-}
-
-const createDefaultFormData = (): CommonThingModelData => ({
-  identifier: '',
-  name: '',
-  description: '',
-  type: 1,
-  property: '',
-  event: '',
-  service: ''
-})
-
-const formData = ref<CommonThingModelData>(createDefaultFormData())
-
-const getJsonFieldByType = () => {
-  if (formData.value.type === 1) return 'property'
-  if (formData.value.type === 2) return 'service'
-  return 'event'
-}
-
-const tryFormatJson = (value?: string, indent = 2) => {
-  if (!value) return ''
-  const trimmed = value.trim()
-  if (!trimmed) return ''
-  return JSON.stringify(JSON.parse(trimmed), null, indent)
-}
-
-const formatJsonField = (field: 'property' | 'event' | 'service') => {
-  try {
-    formData.value[field] = tryFormatJson(formData.value[field], 2)
-    message.success('JSON 已美化')
-  } catch {
-    message.warning('当前内容不是合法 JSON,无法美化')
-  }
-}
-
-const compressJsonField = (field: 'property' | 'event' | 'service') => {
-  try {
-    formData.value[field] = tryFormatJson(formData.value[field], 0)
-    message.success('JSON 已压缩')
-  } catch {
-    message.warning('当前内容不是合法 JSON,无法压缩')
-  }
-}
-
-const validateJsonField = (field: 'property' | 'event' | 'service', label: string) => {
-  return (_rule: any, value: string, callback: (error?: Error) => void) => {
-    const currentType = formData.value.type
-    const fieldTypeMap = {
-      property: 1,
-      service: 2,
-      event: 3
-    }
-
-    if (currentType !== fieldTypeMap[field]) {
-      callback()
-      return
-    }
-
-    if (!value) {
-      callback(new Error(`${label}不能为空`))
-      return
-    }
-
-    try {
-      JSON.parse(value)
-      callback()
-    } catch {
-      callback(new Error(`${label}必须是合法的 JSON`))
-    }
-  }
-}
-
-const formRules = reactive({
-  identifier: [{ required: true, message: '功能标识不能为空', trigger: 'blur' }],
-  name: [{ required: true, message: '功能名称不能为空', trigger: 'blur' }],
-  type: [{ required: true, message: '功能类型不能为空', trigger: 'change' }]
-  // property: [{ validator: validateJsonField('property', '属性'), trigger: 'blur' }],
-  // event: [{ validator: validateJsonField('event', '事件'), trigger: 'blur' }],
-  // service: [{ validator: validateJsonField('service', '服务'), trigger: 'blur' }]
-})
-
-const open = async (type: string, id?: number) => {
-  dialogVisible.value = true
-  dialogTitle.value = t('action.' + type)
-  formType.value = type
-  resetForm()
-  if (!id) return
-
-  formLoading.value = true
-  try {
-    const data = await ThingModelCommon.getCommonThingModel(id)
-    formData.value = {
-      ...createDefaultFormData(),
-      ...data
-    }
-    const activeField = getJsonFieldByType()
-    if (formData.value[activeField]) {
-      formData.value[activeField] = tryFormatJson(formData.value[activeField], 2)
-    }
-  } 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 payload: CommonThingModelData = {
-      ...formData.value,
-      property: formData.value.type === 1 ? formData.value.property : '',
-      service: formData.value.type === 2 ? formData.value.service : '',
-      event: formData.value.type === 3 ? formData.value.event : ''
-    }
-
-    if (formType.value === 'create') {
-      await ThingModelCommon.createCommonThingModel(payload)
-      message.success(t('common.createSuccess'))
-    } else {
-      await ThingModelCommon.updateCommonThingModel(payload)
-      message.success(t('common.updateSuccess'))
-    }
-
-    dialogVisible.value = false
-    emit('success')
-  } finally {
-    formLoading.value = false
-  }
-}
-
-const resetForm = () => {
-  formData.value = createDefaultFormData()
-  formRef.value?.resetFields()
-}
-
-watch(
-  () => formData.value.type,
-  () => {
-    const activeField = getJsonFieldByType()
-    if (formData.value[activeField]) {
-      try {
-        formData.value[activeField] = tryFormatJson(formData.value[activeField], 2)
-      } catch {
-        // Keep original input when JSON is incomplete during editing.
-      }
-    }
-  }
-)
-</script>
-
-<style scoped>
-.json-editor-field {
-  width: 100%;
-  border: 1px solid var(--el-border-color);
-  border-radius: 8px;
-  overflow: hidden;
-  background: #111827;
-}
-
-.json-editor-toolbar {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 12px;
-  padding: 10px 12px;
-  background: #1f2937;
-  border-bottom: 1px solid rgb(255 255 255 / 0.08);
-}
-
-.json-editor-toolbar__tip {
-  font-size: 12px;
-  color: rgb(255 255 255 / 0.72);
-}
-
-.json-editor-toolbar__actions {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-}
-
-.json-editor {
-  width: 100%;
-  height: 320px;
-}
-</style>

+ 58 - 0
src/views/iot/commonThing/ThingModelEvent.vue

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

+ 206 - 0
src/views/iot/commonThing/ThingModelForm.vue

@@ -0,0 +1,206 @@
+<!-- 产品的物模型表单 -->
+<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 === IoTThingModelTypeEnum.PROPERTY"
+        v-model="formData.property" />
+      <!-- 服务配置 -->
+      <ThingModelService
+        v-if="formData.type === IoTThingModelTypeEnum.SERVICE"
+        v-model="formData.service" />
+      <!-- 事件配置 -->
+      <ThingModelEvent
+        v-if="formData.type === IoTThingModelTypeEnum.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 ThingModelProperty from './ThingModelProperty.vue'
+import ThingModelService from './ThingModelService.vue'
+import ThingModelEvent from './ThingModelEvent.vue'
+import { ThingModelCommon, ThingModelData, ThingModelFormRules } from '@/api/iot/thingmodel'
+import { IoTDataSpecsDataTypeEnum, IoTThingModelTypeEnum } from '@/views/iot/utils/constants'
+import { cloneDeep } from 'lodash-es'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { isEmpty } from '@/utils/is'
+
+/** IoT 物模型数据表�?*/
+defineOptions({ name: 'IoTThingModelForm' })
+
+const { t } = useI18n() // 国际�?
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展�?
+const dialogTitle = ref('') // 弹窗的标�?
+const formLoading = ref(false) // 表单的加载中�?)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref<ThingModelData>({
+  type: IoTThingModelTypeEnum.PROPERTY,
+  dataType: IoTDataSpecsDataTypeEnum.INT,
+  property: {
+    dataType: IoTDataSpecsDataTypeEnum.INT,
+    dataSpecs: {
+      dataType: IoTDataSpecsDataTypeEnum.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 ThingModelCommon.getCommonThingModel(id)
+      // 情况一:属性初始化
+      if (isEmpty(formData.value.property)) {
+        formData.value.dataType = IoTDataSpecsDataTypeEnum.INT
+        formData.value.property = {
+          dataType: IoTDataSpecsDataTypeEnum.INT,
+          dataSpecs: {
+            dataType: IoTDataSpecsDataTypeEnum.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 () => {
+  await formRef.value.validate()
+  formLoading.value = true
+  try {
+    const data = cloneDeep(formData.value) as ThingModelData
+
+    fillExtraAttributes(data)
+    if (formType.value === 'create') {
+      await ThingModelCommon.createCommonThingModel(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ThingModelCommon.updateCommonThingModel(data)
+      message.success(t('common.updateSuccess'))
+    }
+    // 关闭弹窗
+    dialogVisible.value = false
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 填写额外的属性(处理不同类型的情况) */
+const fillExtraAttributes = (data: any) => {
+  // 属�?
+  if (data.type === IoTThingModelTypeEnum.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 === IoTThingModelTypeEnum.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 === IoTThingModelTypeEnum.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: IoTThingModelTypeEnum.PROPERTY,
+    dataType: IoTDataSpecsDataTypeEnum.INT,
+    property: {
+      dataType: IoTDataSpecsDataTypeEnum.INT,
+      dataSpecs: {
+        dataType: IoTDataSpecsDataTypeEnum.INT
+      }
+    },
+    service: {},
+    event: {}
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 151 - 0
src/views/iot/commonThing/ThingModelInputOutputParam.vue

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

+ 177 - 0
src/views/iot/commonThing/ThingModelProperty.vue

@@ -0,0 +1,177 @@
+<!-- 产品的物模型表单(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 getDataTypeOptions2"
+        :key="option.value"
+        :label="`${option.value}(${option.label})`"
+        :value="option.value"
+      />
+    </el-select>
+  </el-form-item>
+  <!-- 数值型配置 -->
+  <ThingModelNumberDataSpecs
+    v-if="
+      [
+        IoTDataSpecsDataTypeEnum.INT,
+        IoTDataSpecsDataTypeEnum.DOUBLE,
+        IoTDataSpecsDataTypeEnum.FLOAT
+      ].includes(property.dataType || '')
+    "
+    v-model="property.dataSpecs"
+  />
+  <!-- 枚举型配置 -->
+  <ThingModelEnumDataSpecs
+    v-if="property.dataType === IoTDataSpecsDataTypeEnum.ENUM"
+    v-model="property.dataSpecsList"
+  />
+  <!-- 布尔型配置 -->
+  <el-form-item v-if="property.dataType === IoTDataSpecsDataTypeEnum.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 === IoTDataSpecsDataTypeEnum.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 === IoTDataSpecsDataTypeEnum.DATE"
+    label="时间格式"
+    prop="date"
+  >
+    <el-input class="w-255px!" disabled placeholder="String 类型的 UTC 时间戳(毫秒)" />
+  </el-form-item>
+  <!-- 数组型配置-->
+  <ThingModelArrayDataSpecs
+    v-if="property.dataType === IoTDataSpecsDataTypeEnum.ARRAY"
+    v-model="property.dataSpecs"
+  />
+  <!-- Struct 型配置-->
+  <ThingModelStructDataSpecs
+    v-if="property.dataType === IoTDataSpecsDataTypeEnum.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
+        v-for="accessMode in Object.values(IoTThingModelAccessModeEnum)"
+        :key="accessMode.value"
+        :label="accessMode.value"
+      >
+        {{ accessMode.label }}
+      </el-radio>
+    </el-radio-group>
+  </el-form-item>
+</template>
+
+<script lang="ts" setup>
+import { useVModel } from '@vueuse/core'
+import {
+  ThingModelArrayDataSpecs,
+  ThingModelEnumDataSpecs,
+  ThingModelNumberDataSpecs,
+  ThingModelStructDataSpecs
+} from './dataSpecs'
+import { ThingModelProperty, validateBoolName } from '@/api/iot/thingmodel'
+import { isEmpty } from '@/utils/is'
+import {
+  getDataTypeOptions,
+  IoTDataSpecsDataTypeEnum,
+  IoTThingModelAccessModeEnum
+} from '@/views/iot/utils/constants'
+
+/** 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 getDataTypeOptions2 = computed(() => {
+  if (!props.isStructDataSpecs) {
+    return getDataTypeOptions()
+  }
+  const excludedTypes = [IoTDataSpecsDataTypeEnum.STRUCT, IoTDataSpecsDataTypeEnum.ARRAY]
+  return getDataTypeOptions().filter((item: any) => !excludedTypes.includes(item.value))
+}) // 获得数据类型列表
+
+/** 属性值的数据类型切换时初始化相关数据 */
+const handleChange = (dataType: any) => {
+  property.value.dataSpecs = {}
+  property.value.dataSpecsList = []
+  // 不是列表型数据才设置 dataSpecs.dataType
+  ![
+    IoTDataSpecsDataTypeEnum.ENUM,
+    IoTDataSpecsDataTypeEnum.BOOL,
+    IoTDataSpecsDataTypeEnum.STRUCT
+  ].includes(dataType) && (property.value.dataSpecs.dataType = dataType)
+  switch (dataType) {
+    case IoTDataSpecsDataTypeEnum.ENUM:
+      property.value.dataSpecsList.push({
+        dataType: IoTDataSpecsDataTypeEnum.ENUM,
+        name: '', // 枚举项的名称
+        value: undefined // 枚举值
+      })
+      break
+    case IoTDataSpecsDataTypeEnum.BOOL:
+      for (let i = 0; i < 2; i++) {
+        property.value.dataSpecsList.push({
+          dataType: IoTDataSpecsDataTypeEnum.BOOL,
+          name: '', // 布尔值的名称
+          value: i // 布尔值
+        })
+      }
+      break
+  }
+}
+
+/** 默认选中读写 */
+watch(
+  () => property.value.accessMode,
+  (val: string) => {
+    if (props.isStructDataSpecs || props.isParams) {
+      return
+    }
+    isEmpty(val) && (property.value.accessMode = IoTThingModelAccessModeEnum.READ_WRITE.value)
+  },
+  { immediate: true }
+)
+</script>
+
+<style lang="scss" scoped>
+:deep(.el-form-item) {
+  .el-form-item {
+    margin-bottom: 0;
+  }
+}
+</style>

+ 64 - 0
src/views/iot/commonThing/ThingModelService.vue

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

+ 49 - 0
src/views/iot/commonThing/ThingModelTSL.vue

@@ -0,0 +1,49 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle">
+    <JsonEditor
+      v-model="thingModelTSL"
+      :mode="viewMode === 'editor' ? 'code' : 'view'"
+      height="600px" />
+    <template #footer>
+      <el-radio-group v-model="viewMode" size="small">
+        <el-radio-button label="code">代码视图</el-radio-button>
+        <el-radio-button label="editor">编辑器视图</el-radio-button>
+      </el-radio-group>
+    </template>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import hljs from 'highlight.js' // 导入代码高亮文件
+import 'highlight.js/styles/github.css' // 导入代码高亮样式
+import json from 'highlight.js/lib/languages/json'
+import { ThingModelApi } from '@/api/iot/thingmodel'
+import { ProductVO } from '@/api/iot/product/product'
+import { IOT_PROVIDE_KEY } from '@/views/iot/utils/constants'
+
+defineOptions({ name: 'ThingModelTSL' })
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('物模型 TSL') // 弹窗的标题
+const product = inject<Ref<ProductVO>>(IOT_PROVIDE_KEY.PRODUCT) // 注入产品信息
+const viewMode = ref('code') // 查看模式:code-代码视图,editor-编辑器视图
+
+/** 打开弹窗 */
+const open = () => {
+  dialogVisible.value = true
+}
+defineExpose({ open })
+
+/** 获取 TSL */
+const thingModelTSL = ref({})
+const getTsl = async () => {
+  thingModelTSL.value = await ThingModelApi.getThingModelTSLByProductId(product?.value?.id || 0)
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  // 注册代码高亮的各种语言
+  hljs.registerLanguage('json', json)
+  await getTsl()
+})
+</script>

+ 74 - 0
src/views/iot/commonThing/components/DataDefinition.vue

@@ -0,0 +1,74 @@
+<template>
+  <!-- 属�?-->
+  <template v-if="data.type === IoTThingModelTypeEnum.PROPERTY && data.property">
+    <!-- 非列表型:数�?-->
+    <div
+      v-if="
+        [
+          IoTDataSpecsDataTypeEnum.INT,
+          IoTDataSpecsDataTypeEnum.DOUBLE,
+          IoTDataSpecsDataTypeEnum.FLOAT
+        ].includes(data.property.dataType)
+      ">
+      取值范围:{{
+        `${data.property.dataSpecs?.min ?? '-'}~${data.property.dataSpecs?.max ?? '-'}`
+      }}
+    </div>
+    <!-- 非列表型:文�?-->
+    <div v-if="IoTDataSpecsDataTypeEnum.TEXT === data.property.dataType">
+      数据长度:{{ data.property.dataSpecs?.length ?? '-' }}
+    </div>
+    <!-- 列表�? 数组、结构、时间(特殊�?-->
+    <div
+      v-if="
+        [
+          IoTDataSpecsDataTypeEnum.ARRAY,
+          IoTDataSpecsDataTypeEnum.STRUCT,
+          IoTDataSpecsDataTypeEnum.DATE
+        ].includes(data.property.dataType)
+      ">
+      -
+    </div>
+    <!-- 列表�? 布尔值、枚�?-->
+    <div
+      v-if="
+        [IoTDataSpecsDataTypeEnum.BOOL, IoTDataSpecsDataTypeEnum.ENUM].includes(
+          data.property.dataType
+        )
+      ">
+      <div>
+        {{ IoTDataSpecsDataTypeEnum.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 === IoTThingModelTypeEnum.SERVICE">
+    调用方式:{{
+      data.service?.callType ? getThingModelServiceCallTypeLabel(data.service.callType) : '-'
+    }}
+  </div>
+  <!-- 事件 -->
+  <div v-if="data.type === IoTThingModelTypeEnum.EVENT">
+    事件类型:{{ data.event?.type ? getEventTypeLabel(data.event.type) : '-' }}
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ThingModelData } from '@/api/iot/thingmodel'
+import {
+  getEventTypeLabel,
+  getThingModelServiceCallTypeLabel,
+  IoTDataSpecsDataTypeEnum,
+  IoTThingModelTypeEnum
+} from '@/views/iot/utils/constants'
+
+/** 数据定义展示组件 */
+defineOptions({ name: 'DataDefinition' })
+
+defineProps<{ data: ThingModelData }>()
+</script>
+
+<style lang="scss" scoped></style>

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

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

+ 56 - 0
src/views/iot/commonThing/dataSpecs/ThingModelArrayDataSpecs.vue

@@ -0,0 +1,56 @@
+<!-- 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 getDataTypeOptions()" :key="item.value">
+        <el-radio
+          v-if="
+            !(
+              [
+                IoTDataSpecsDataTypeEnum.ENUM,
+                IoTDataSpecsDataTypeEnum.ARRAY,
+                IoTDataSpecsDataTypeEnum.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 === IoTDataSpecsDataTypeEnum.STRUCT"
+    v-model="dataSpecs.dataSpecsList"
+  />
+</template>
+
+<script lang="ts" setup>
+import { useVModel } from '@vueuse/core'
+import ThingModelStructDataSpecs from './ThingModelStructDataSpecs.vue'
+import { getDataTypeOptions, IoTDataSpecsDataTypeEnum } from '@/views/iot/utils/constants'
+
+/** 数组型的 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 !== IoTDataSpecsDataTypeEnum.STRUCT) {
+    return
+  }
+
+  dataSpecs.value.dataSpecsList = []
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 160 - 0
src/views/iot/commonThing/dataSpecs/ThingModelEnumDataSpecs.vue

@@ -0,0 +1,160 @@
+<!-- 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 { isEmpty } from '@/utils/is'
+import { IoTDataSpecsDataTypeEnum } from '@/views/iot/utils/constants'
+import { DataSpecsEnumOrBoolData } from '@/api/iot/thingmodel'
+
+/** 枚举型的 dataSpecs 配置组件 */
+defineOptions({ name: 'ThingModelEnumDataSpecs' })
+
+const props = defineProps<{ modelValue: any }>()
+const emits = defineEmits(['update:modelValue'])
+const dataSpecsList = useVModel(props, 'modelValue', emits) as Ref<DataSpecsEnumOrBoolData[]>
+const message = useMessage()
+
+/** 添加枚举项 */
+const addEnum = () => {
+  dataSpecsList.value.push({
+    dataType: IoTDataSpecsDataTypeEnum.ENUM,
+    name: '', // 枚举项的名称
+    value: undefined // 枚举值
+  })
+}
+
+/** 删除枚举项 */
+const deleteEnum = (index: number) => {
+  if (dataSpecsList.value.length === 1) {
+    message.warning('至少需要一个枚举项')
+    return
+  }
+  dataSpecsList.value.splice(index, 1)
+}
+
+/** 校验枚举值 */
+const validateEnumValue = (_: any, value: any, callback: any) => {
+  if (isEmpty(value)) {
+    callback(new Error('枚举值不能为空'))
+    return
+  }
+  if (isNaN(Number(value))) {
+    callback(new Error('枚举值必须是数字'))
+    return
+  }
+  // 检查枚举值是否重复
+  const values = dataSpecsList.value.map((item) => item.value)
+  if (values.filter((v) => v === value).length > 1) {
+    callback(new Error('枚举值不能重复'))
+    return
+  }
+  callback()
+}
+
+/** 校验枚举描述 */
+const validateEnumName = (_: any, value: string, callback: any) => {
+  if (isEmpty(value)) {
+    callback(new Error('枚举描述不能为空'))
+    return
+  }
+  // 检查开头字符
+  if (!/^[\u4e00-\u9fa5a-zA-Z0-9]/.test(value)) {
+    callback(new Error('枚举描述必须以中文、英文字母或数字开头'))
+    return
+  }
+  // 检查整体格式
+  if (!/^[\u4e00-\u9fa5a-zA-Z0-9][a-zA-Z0-9\u4e00-\u9fa5_-]*$/.test(value)) {
+    callback(new Error('枚举描述只能包含中文、英文字母、数字、下划线和短划线'))
+    return
+  }
+  // 检查长度(一个中文算一个字符)
+  if (value.length > 20) {
+    callback(new Error('枚举描述长度不能超过20个字符'))
+    return
+  }
+  callback()
+}
+
+/** 校验整个枚举列表 */
+const validateEnumList = (_: any, __: any, callback: any) => {
+  if (isEmpty(dataSpecsList.value)) {
+    callback(new Error('请至少添加一个枚举项'))
+    return
+  }
+
+  // 检查是否存在空值
+  const hasEmptyValue = dataSpecsList.value.some(
+    (item) => isEmpty(item.value) || isEmpty(item.name)
+  )
+  if (hasEmptyValue) {
+    callback(new Error('存在未填写的枚举值或描述'))
+    return
+  }
+
+  // 检查枚举值是否都是数字
+  const hasInvalidNumber = dataSpecsList.value.some((item) => isNaN(Number(item.value)))
+  if (hasInvalidNumber) {
+    callback(new Error('存在非数字的枚举值'))
+    return
+  }
+
+  // 检查是否有重复的枚举值
+  const values = dataSpecsList.value.map((item) => item.value)
+  const uniqueValues = new Set(values)
+  if (values.length !== uniqueValues.size) {
+    callback(new Error('存在重复的枚举值'))
+    return
+  }
+  callback()
+}
+</script>
+
+<style lang="scss" scoped>
+:deep(.el-form-item) {
+  .el-form-item {
+    margin-bottom: 0;
+  }
+}
+</style>

+ 139 - 0
src/views/iot/commonThing/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 { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
+import { DataSpecsNumberData } from '@/api/iot/thingmodel'
+
+/** 数值型的 dataSpecs 配置组件 */
+defineOptions({ name: 'ThingModelNumberDataSpecs' })
+
+const props = defineProps<{ modelValue: any }>()
+const emits = defineEmits(['update:modelValue'])
+const dataSpecs = useVModel(props, 'modelValue', emits) as Ref<DataSpecsNumberData>
+
+/** 单位发生变化时触发 */
+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>

+ 167 - 0
src/views/iot/commonThing/dataSpecs/ThingModelStructDataSpecs.vue

@@ -0,0 +1,167 @@
+<!-- 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 { isEmpty } from '@/utils/is'
+import { IoTDataSpecsDataTypeEnum } from '@/views/iot/utils/constants'
+import { ThingModelFormRules } from '@/api/iot/thingmodel'
+
+/** 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: IoTDataSpecsDataTypeEnum.INT,
+    dataSpecs: {
+      dataType: IoTDataSpecsDataTypeEnum.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: IoTDataSpecsDataTypeEnum.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: IoTDataSpecsDataTypeEnum.INT,
+      dataSpecs: {
+        dataType: IoTDataSpecsDataTypeEnum.INT
+      }
+    }
+  }
+  structFormRef.value?.resetFields()
+}
+
+/** 校验 struct 不能为空 */
+const validateList = (_: any, __: any, callback: any) => {
+  if (isEmpty(dataSpecsList.value)) {
+    callback(new Error('struct 不能为空'))
+    return
+  }
+  callback()
+}
+
+/** 组件初始化 */
+onMounted(async () => {
+  await nextTick()
+  // 预防 dataSpecsList 空指针
+  isEmpty(dataSpecsList.value) && (dataSpecsList.value = [])
+})
+</script>
+
+<style lang="scss" scoped>
+.struct-item {
+  background-color: #e4f2fd;
+}
+</style>

+ 11 - 0
src/views/iot/commonThing/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
+}

+ 91 - 87
src/views/iot/commonThing/index.vue

@@ -1,15 +1,17 @@
+<!-- 产品的物模型列表 -->
 <template>
   <ContentWrap>
+    <!-- 搜索工作栏 -->
     <el-form
       ref="queryFormRef"
       :inline="true"
       :model="queryParams"
       class="-mb-15px"
       label-width="68px">
-      <el-form-item label="功能类型" prop="type">
+      <el-form-item label="功能类型" prop="name">
         <el-select
           v-model="queryParams.type"
-          class="!w-150px"
+          class="!w-240px"
           clearable
           placeholder="请选择功能类型"
           @change="handleQuery">
@@ -20,140 +22,142 @@
             :value="dict.value" />
         </el-select>
       </el-form-item>
-      <el-form-item label="功能名称" prop="name">
-        <el-input
-          v-model="queryParams.name"
-          class="!w-150px"
-          clearable
-          placeholder="请输入功能名称"
-          @keyup.enter="handleQuery" />
-      </el-form-item>
-      <el-form-item label="功能标识" prop="identifier">
-        <el-input
-          v-model="queryParams.identifier"
-          class="!w-150px"
-          clearable
-          placeholder="请输入功能标识"
-          @keyup.enter="handleQuery" />
-      </el-form-item>
       <el-form-item>
-        <el-button type="primary" @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 plain type="primary" @click="openForm('create')">
           <Icon class="mr-5px" icon="ep:plus" />
-          新增功能
+          添加功能
+        </el-button>
+        <el-button v-hasPermi="[`iot:thing-model:query`]" plain type="success" @click="openTSL">
+          TSL
         </el-button>
       </el-form-item>
     </el-form>
   </ContentWrap>
 
+  <!-- 列表 -->
   <ContentWrap>
-    <zm-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
-      <zm-table-column align="center" label="功能类型" prop="type" width="120">
-        <template #default="{ row }">
-          <dict-tag :type="DICT_TYPE.IOT_THING_MODEL_TYPE" :value="row.type" />
-        </template>
-      </zm-table-column>
-      <zm-table-column align="center" label="功能名称" prop="name" min-width="180" />
-      <zm-table-column align="center" label="功能标识" prop="identifier" min-width="180" />
-      <zm-table-column align="center" label="描述" prop="description" min-width="220" />
-      <zm-table-column align="left" label="属性" min-width="240" show-overflow-tooltip>
-        <template #default="{ row }">
-          {{ row.property || '-' }}
-        </template>
-      </zm-table-column>
-      <zm-table-column align="left" label="事件" min-width="240" show-overflow-tooltip>
-        <template #default="{ row }">
-          {{ row.event || '-' }}
-        </template>
-      </zm-table-column>
-      <zm-table-column align="left" label="服务" min-width="240" show-overflow-tooltip>
-        <template #default="{ row }">
-          {{ row.service || '-' }}
-        </template>
-      </zm-table-column>
-      <zm-table-column align="center" label="操作" action width="160" fixed="right">
-        <template #default="{ row }">
-          <el-button link type="primary" @click="openForm('update', row.id)"> 编辑 </el-button>
-          <el-button link type="danger" @click="handleDelete(row.id)"> 删除 </el-button>
-        </template>
-      </zm-table-column>
-    </zm-table>
-
-    <Pagination
-      v-model:limit="queryParams.pageSize"
-      v-model:page="queryParams.pageNo"
-      :total="total"
-      @pagination="getList" />
+    <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 }">
+            {{ getDataTypeOptionsLabel(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>
 
-  <CommonThingModelForm ref="formRef" @success="getList" />
+  <!-- 表单弹窗:添加/修改 -->
+  <ThingModelForm ref="formRef" @success="getList" />
+  <ThingModelTSL ref="tslRef" />
 </template>
-
 <script lang="ts" setup>
+import { ThingModelCommon, ThingModelData } from '@/api/iot/thingmodel'
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
-import { ThingModelCommon, type CommonThingModelData } from '@/api/iot/thingmodel'
-import CommonThingModelForm from './CommonThingModelForm.vue'
-
-defineOptions({ name: 'CommonThingModel' })
+import ThingModelForm from './ThingModelForm.vue'
+import ThingModelTSL from './ThingModelTSL.vue'
+import { ProductVO } from '@/api/iot/product/product'
+import { getDataTypeOptionsLabel, IOT_PROVIDE_KEY } from '@/views/iot/utils/constants'
+import { DataDefinition } from './components'
 
-const { t } = useI18n()
-const message = useMessage()
+defineOptions({ name: 'IoTThingModel' })
 
-const loading = ref(false)
-const list = ref<CommonThingModelData[]>([])
-const total = ref(0)
-const queryFormRef = ref()
-const formRef = ref()
+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 as number | undefined,
-  name: '',
-  identifier: ''
+  type: undefined,
+  productId: -1
 })
 
+const product = inject<Ref<ProductVO>>(IOT_PROVIDE_KEY.PRODUCT) // 注入产品信息
+
+/** 查询列表 */
 const getList = async () => {
   loading.value = true
   try {
+    queryParams.productId = product?.value?.id || -1
     const data = await ThingModelCommon.getCommonThingModelPage(queryParams)
-    list.value = data?.list || []
-    total.value = data?.total || 0
+    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 openForm = (type: string, id?: number) => {
-  formRef.value?.open(type, id)
+/** 展示物模型 TSL */
+const tslRef = ref()
+const openTSL = () => {
+  tslRef.value?.open()
 }
 
+/** 删除按钮操作 */
 const handleDelete = async (id: number) => {
   try {
+    // 删除的二次确认
     await message.delConfirm()
+    // 发起删除
     await ThingModelCommon.deleteCommonThingModel(id)
     message.success(t('common.delSuccess'))
+    // 刷新列表
     await getList()
   } catch {}
 }
 
+/** 初始化 **/
 onMounted(() => {
   getList()
 })