浏览代码

!746 【功能新增】IoT: 规则场景联动
Merge pull request !746 from puhui999/feature/iot

芋道源码 1 年之前
父节点
当前提交
eaefbabea2

文件差异内容过多而无法显示
+ 190 - 187
pnpm-lock.yaml


+ 40 - 0
src/api/iot/rule/scene/index.ts

@@ -0,0 +1,40 @@
+import request from '@/config/axios'
+import { IotRuleSceneTriggerConfig } from '@/api/iot/rule/scene/scene.types'
+
+// IoT 规则场景(场景联动) VO
+export interface RuleSceneVO {
+  id?: number // 场景编号
+  name: string // 场景名称
+  description?: string // 场景描述
+  status: number // 场景状态
+  triggers: IotRuleSceneTriggerConfig[] // 触发器数组
+  actions?: any[] // 执行器数组
+}
+
+// IoT 规则场景(场景联动) API
+export const RuleSceneApi = {
+  // 查询规则场景(场景联动)分页
+  getRuleScenePage: async (params: any) => {
+    return await request.get({ url: `/iot/rule-scene/page`, params })
+  },
+
+  // 查询规则场景(场景联动)详情
+  getRuleScene: async (id: number) => {
+    return await request.get({ url: `/iot/rule-scene/get?id=` + id })
+  },
+
+  // 新增规则场景(场景联动)
+  createRuleScene: async (data: RuleSceneVO) => {
+    return await request.post({ url: `/iot/rule-scene/create`, data })
+  },
+
+  // 修改规则场景(场景联动)
+  updateRuleScene: async (data: RuleSceneVO) => {
+    return await request.put({ url: `/iot/rule-scene/update`, data })
+  },
+
+  // 删除规则场景(场景联动)
+  deleteRuleScene: async (id: number) => {
+    return await request.delete({ url: `/iot/rule-scene/delete?id=` + id })
+  }
+}

+ 222 - 0
src/api/iot/rule/scene/scene.types.ts

@@ -0,0 +1,222 @@
+/**
+ * 场景规则触发器配置
+ */
+export interface IotRuleSceneTriggerConfig {
+  /**
+   * 触发类型
+   * - 1: 设备触发
+   * - 2: 定时触发
+   */
+  type: number
+  /** 产品标识 */
+  productKey: string
+  /** 设备名称数组 */
+  deviceNames: string[]
+  /** 触发条件数组。条件之间是"或"的关系 */
+  conditions: IotRuleSceneTriggerCondition[]
+  /** CRON 表达式。当 type = 2 时必填 */
+  cronExpression?: string
+}
+
+/**
+ * 触发条件
+ */
+export interface IotRuleSceneTriggerCondition {
+  /**
+   * 消息类型
+   * - property: 属性上报
+   * - event: 事件上报
+   */
+  type: string
+  /** 消息标识符 */
+  identifier?: string
+  /** 参数数组。参数之间是"或"的关系 */
+  parameters: IotRuleSceneTriggerConditionParameter[]
+}
+
+/**
+ * 触发条件参数
+ */
+export interface IotRuleSceneTriggerConditionParameter {
+  /** 标识符(属性、事件、服务) */
+  identifier: string
+  /**
+   * 操作符
+   */
+  operator: string
+  /**
+   * 比较值
+   * 如果有多个值,则使用 "," 分隔,类似 "1,2,3"
+   */
+  value: string
+}
+
+/**
+ * 执行器配置
+ */
+export interface IotRuleSceneActionConfig {
+  /**
+   * 执行类型
+   * - 1: 设备控制
+   * - 2: 数据桥接
+   */
+  type: number
+  /** 设备控制配置。当 type = 1 时必填 */
+  deviceControl?: IotRuleSceneActionDeviceControl
+  /** 数据桥接编号。当 type = 2 时必填 */
+  dataBridgeId?: number
+}
+
+/**
+ * 执行设备控制
+ */
+export interface IotRuleSceneActionDeviceControl {
+  /** 产品标识 */
+  productKey: string
+  /** 设备名称数组 */
+  deviceNames: string[]
+  /**
+   * 消息类型
+   * - property: 属性
+   * - service: 服务
+   */
+  type: string
+  /**
+   * 消息标识符
+   * - property_set: 属性设置
+   * - service_invoke: 服务调用
+   */
+  identifier: string
+  /** 具体数据 */
+  data: Record<string, any>
+}
+
+/**
+ * 场景规则创建/更新请求
+ */
+export interface IotRuleSceneSaveReqVO {
+  /** 场景规则编号 */
+  id?: number
+  /** 场景规则名称 */
+  name: string
+  /** 场景规则状态(0=禁用 1=启用) */
+  status: number
+  /** 触发器配置 */
+  triggerConfig: IotRuleSceneTriggerConfig
+  /** 执行动作配置数组 */
+  actionConfigs: IotRuleSceneActionConfig[]
+  /** 备注 */
+  remark?: string
+}
+
+/**
+ * 场景规则响应
+ */
+export interface IotRuleSceneRespVO {
+  /** 场景规则编号 */
+  id: number
+  /** 场景规则名称 */
+  name: string
+  /** 场景规则状态(0=禁用 1=启用) */
+  status: number
+  /** 触发器配置 */
+  triggerConfig: IotRuleSceneTriggerConfig
+  /** 执行动作配置数组 */
+  actionConfigs: IotRuleSceneActionConfig[]
+  /** 备注 */
+  remark?: string
+  /** 创建时间 */
+  createTime: Date
+}
+
+/**
+ * 场景规则分页项
+ */
+export interface IotRuleScenePageItemRespVO extends IotRuleSceneRespVO {
+  /** 触发次数 */
+  triggerCount: number
+  /** 最后触发时间 */
+  lastTriggerTime?: Date
+}
+
+/**
+ * 场景规则分页请求
+ */
+export interface IotRuleScenePageReqVO {
+  /** 场景规则名称 */
+  name?: string
+  /** 场景规则状态(0=禁用 1=启用) */
+  status?: number
+  /** 创建时间 */
+  createTime?: [Date, Date]
+  /** 页码 */
+  pageNo?: number
+  /** 每页条数 */
+  pageSize?: number
+}
+
+/**
+ * 场景规则类型枚举
+ */
+export enum IotRuleSceneTriggerTypeEnum {
+  /** 设备触发 */
+  DEVICE = 1,
+  /** 定时触发 */
+  TIMER = 2
+}
+
+/**
+ * 场景规则动作类型枚举
+ */
+export enum IotRuleSceneActionTypeEnum {
+  /** 设备控制 */
+  DEVICE_CONTROL = 1,
+  /** 数据桥接 */
+  DATA_BRIDGE = 2
+}
+
+/**
+ * 设备消息类型枚举
+ */
+export enum IotDeviceMessageTypeEnum {
+  /** 属性 */
+  PROPERTY = 'property',
+  /** 事件 */
+  EVENT = 'event',
+  /** 服务 */
+  SERVICE = 'service'
+}
+
+/**
+ * 设备消息标识符枚举
+ */
+export enum IotDeviceMessageIdentifierEnum {
+  /** 属性上报 */
+  PROPERTY_REPORT = 'property_report',
+  /** 属性设置 */
+  PROPERTY_SET = 'property_set',
+  /** 事件上报 */
+  EVENT_REPORT = 'event_report',
+  /** 服务调用 */
+  SERVICE_INVOKE = 'service_invoke'
+}
+
+/**
+ * 触发条件参数操作符枚举
+ */
+export enum IotRuleSceneTriggerConditionParameterOperatorEnum {
+  /** 等于 */
+  EQ = 'eq',
+  /** 大于 */
+  GT = 'gt',
+  /** 大于等于 */
+  GTE = 'gte',
+  /** 小于 */
+  LT = 'lt',
+  /** 小于等于 */
+  LTE = 'lte',
+  /** 范围 */
+  BETWEEN = 'between',
+  /** 在列表中 */
+  IN = 'in'
+}

+ 3 - 4
src/api/iot/thingmodel/index.ts

@@ -58,11 +58,10 @@ export const ThingModelApi = {
     return await request.get({ url: `/iot/thing-model/list`, params })
   },
 
-  // 获得产品物模型
-  getThingModelListByProductId: async (params: any) => {
+  // 获得产品物模型 TSL
+  getThingModelTSLByProductId: async (productId: number) => {
     return await request.get({
-      url: `/iot/thing-model/list-by-product-id`,
-      params
+      url: `/iot/thing-model/tsl-by-product-id?productId=${productId}`
     })
   },
 

+ 1 - 1
src/store/modules/app.ts

@@ -61,7 +61,7 @@ export const useAppStore = defineStore('app', {
       tagsView: true, // 标签页
       tagsViewImmerse: false, // 标签页沉浸
       tagsViewIcon: true, // 是否显示标签图标
-      logo: true, // logo
+      logo: false, // logo
       fixedHeader: true, // 固定toolheader
       footer: true, // 显示页脚
       greyMode: false, // 是否开始灰色模式,用于特殊悼念日

+ 3 - 1
src/utils/dict.ts

@@ -245,5 +245,7 @@ export enum DICT_TYPE {
   IOT_PLUGIN_STATUS = 'iot_plugin_status', // IOT 插件状态
   IOT_PLUGIN_TYPE = 'iot_plugin_type', // IOT 插件类型
   IOT_DATA_BRIDGE_DIRECTION_ENUM = 'iot_data_bridge_direction_enum', // 桥梁方向
-  IOT_DATA_BRIDGE_TYPE_ENUM = 'iot_data_bridge_type_enum' // 桥梁类型
+  IOT_DATA_BRIDGE_TYPE_ENUM = 'iot_data_bridge_type_enum', // 桥梁类型
+  IOT_DEVICE_MESSAGE_TYPE_ENUM = 'iot_device_message_type_enum', // IoT 设备消息类型枚举
+  IOT_RULE_SCENE_TRIGGER_TYPE_ENUM = 'iot_rule_scene_trigger_type_enum' // IoT 场景流转的触发类型枚举
 }

+ 300 - 0
src/views/iot/device/device/components/IoTDeviceTableSelect.vue

@@ -0,0 +1,300 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible" :appendToBody="true" width="60%">
+    <ContentWrap>
+      <!-- 搜索工作栏 -->
+      <el-form
+        ref="queryFormRef"
+        :inline="true"
+        :model="queryParams"
+        class="-mb-15px"
+        label-width="100px"
+      >
+        <el-form-item v-if="!props.productId" label="产品" prop="productId">
+          <el-select
+            v-model="queryParams.productId"
+            placeholder="请选择产品"
+            clearable
+            class="!w-240px"
+          >
+            <el-option
+              v-for="product in products"
+              :key="product.id"
+              :label="product.name"
+              :value="product.id"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="DeviceName" prop="deviceName">
+          <el-input
+            v-model="queryParams.deviceName"
+            placeholder="请输入 DeviceName"
+            clearable
+            @keyup.enter="handleQuery"
+            class="!w-240px"
+          />
+        </el-form-item>
+        <el-form-item label="备注名称" prop="nickname">
+          <el-input
+            v-model="queryParams.nickname"
+            placeholder="请输入备注名称"
+            clearable
+            @keyup.enter="handleQuery"
+            class="!w-240px"
+          />
+        </el-form-item>
+        <el-form-item label="设备类型" prop="deviceType">
+          <el-select
+            v-model="queryParams.deviceType"
+            placeholder="请选择设备类型"
+            clearable
+            class="!w-240px"
+          >
+            <el-option
+              v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE)"
+              :key="dict.value"
+              :label="dict.label"
+              :value="dict.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="设备状态" prop="status">
+          <el-select
+            v-model="queryParams.status"
+            placeholder="请选择设备状态"
+            clearable
+            class="!w-240px"
+          >
+            <el-option
+              v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DEVICE_STATE)"
+              :key="dict.value"
+              :label="dict.label"
+              :value="dict.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="设备分组" prop="groupId">
+          <el-select
+            v-model="queryParams.groupId"
+            placeholder="请选择设备分组"
+            clearable
+            class="!w-240px"
+          >
+            <el-option
+              v-for="group in deviceGroups"
+              :key="group.id"
+              :label="group.name"
+              :value="group.id"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <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-form-item>
+      </el-form>
+    </ContentWrap>
+    <ContentWrap>
+      <el-table
+        ref="tableRef"
+        v-loading="loading"
+        :data="list"
+        :show-overflow-tooltip="true"
+        :stripe="true"
+        @row-click="handleRowClick"
+        @selection-change="handleSelectionChange"
+      >
+        <el-table-column v-if="multiple" type="selection" width="55" />
+        <el-table-column v-else width="55">
+          <template #default="scope">
+            <el-radio
+              v-model="selectedId"
+              :label="scope.row.id"
+              @change="() => handleRadioChange(scope.row)"
+            >
+              &nbsp;
+            </el-radio>
+          </template>
+        </el-table-column>
+        <el-table-column label="DeviceName" align="center" prop="deviceName" />
+        <el-table-column label="备注名称" align="center" prop="nickname" />
+        <el-table-column label="所属产品" align="center" prop="productId">
+          <template #default="scope">
+            {{ products.find((p) => p.id === scope.row.productId)?.name || '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column label="设备类型" align="center" prop="deviceType">
+          <template #default="scope">
+            <dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="scope.row.deviceType" />
+          </template>
+        </el-table-column>
+        <el-table-column label="所属分组" align="center" prop="groupId">
+          <template #default="scope">
+            <template v-if="scope.row.groupIds?.length">
+              <el-tag v-for="id in scope.row.groupIds" :key="id" class="ml-5px" size="small">
+                {{ deviceGroups.find((g) => g.id === id)?.name }}
+              </el-tag>
+            </template>
+          </template>
+        </el-table-column>
+        <el-table-column label="设备状态" align="center" prop="status">
+          <template #default="scope">
+            <dict-tag :type="DICT_TYPE.IOT_DEVICE_STATE" :value="scope.row.status" />
+          </template>
+        </el-table-column>
+        <el-table-column
+          label="最后上线时间"
+          align="center"
+          prop="onlineTime"
+          :formatter="dateFormatter"
+          width="180px"
+        />
+      </el-table>
+
+      <!-- 分页 -->
+      <Pagination
+        v-model:limit="queryParams.pageSize"
+        v-model:page="queryParams.pageNo"
+        :total="total"
+        @pagination="getList"
+      />
+    </ContentWrap>
+
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
+import { ProductApi, ProductVO } from '@/api/iot/product/product'
+import { DeviceGroupApi, DeviceGroupVO } from '@/api/iot/device/group'
+
+defineOptions({ name: 'IoTDeviceTableSelect' })
+
+const props = defineProps({
+  multiple: {
+    type: Boolean,
+    default: false
+  },
+  productId: {
+    type: Number,
+    default: null
+  }
+})
+
+const message = useMessage()
+const dialogVisible = ref(false)
+const dialogTitle = ref('设备选择器')
+const formLoading = ref(false)
+const loading = ref(true) // 列表的加载中
+const list = ref<DeviceVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const selectedDevices = ref<DeviceVO[]>([]) // 选中的设备列表
+const selectedId = ref<number>() // 单选模式下选中的ID
+const products = ref<ProductVO[]>([]) // 产品列表
+const deviceGroups = ref<DeviceGroupVO[]>([]) // 设备分组列表
+
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  deviceName: undefined,
+  productId: undefined,
+  deviceType: undefined,
+  nickname: undefined,
+  status: undefined,
+  groupId: undefined
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    if (props.productId) {
+      queryParams.productId = props.productId as unknown as any
+    }
+    const data = await DeviceApi.getDevicePage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 打开弹窗 */
+const open = async () => {
+  dialogVisible.value = true
+  // 重置选择状态
+  selectedDevices.value = []
+  selectedId.value = undefined
+  if (!props.productId) {
+    // 获取产品列表
+    products.value = await ProductApi.getSimpleProductList()
+  }
+  // 获取设备列表
+  await getList()
+}
+defineExpose({ open })
+
+/** 处理行点击事件 */
+const tableRef = ref()
+const handleRowClick = (row: DeviceVO) => {
+  if (props.multiple) {
+    tableRef.value?.toggleRowSelection(row)
+  } else {
+    selectedId.value = row.id
+    selectedDevices.value = [row]
+  }
+}
+
+/** 处理单选变更事件 */
+const handleRadioChange = (row: DeviceVO) => {
+  selectedDevices.value = [row]
+}
+
+/** 处理选择变更事件 */
+const handleSelectionChange = (selection: DeviceVO[]) => {
+  if (props.multiple) {
+    selectedDevices.value = selection
+  }
+}
+
+/** 提交表单 */
+const emit = defineEmits(['success'])
+const submitForm = async () => {
+  if (selectedDevices.value.length === 0) {
+    message.warning(props.multiple ? '请至少选择一个设备' : '请选择一个设备')
+    return
+  }
+  emit('success', props.multiple ? selectedDevices.value : selectedDevices.value[0])
+  dialogVisible.value = false
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  // 获取分组列表
+  deviceGroups.value = await DeviceGroupApi.getSimpleDeviceGroupList()
+})
+</script>

+ 217 - 0
src/views/iot/product/product/components/IoTProductTableSelect.vue

@@ -0,0 +1,217 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible" :appendToBody="true" width="60%">
+    <ContentWrap>
+      <!-- 搜索工作栏 -->
+      <el-form
+        ref="queryFormRef"
+        :inline="true"
+        :model="queryParams"
+        class="-mb-15px"
+        label-width="68px"
+      >
+        <el-form-item label="产品名称" prop="name">
+          <el-input
+            v-model="queryParams.name"
+            class="!w-240px"
+            clearable
+            placeholder="请输入产品名称"
+            @keyup.enter="handleQuery"
+          />
+        </el-form-item>
+        <el-form-item label="ProductKey" prop="productKey">
+          <el-input
+            v-model="queryParams.productKey"
+            class="!w-240px"
+            clearable
+            placeholder="请输入产品标识"
+            @keyup.enter="handleQuery"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button @click="handleQuery">
+            <Icon class="mr-5px" icon="ep:search" />
+            搜索
+          </el-button>
+          <el-button @click="resetQuery">
+            <Icon class="mr-5px" icon="ep:refresh" />
+            重置
+          </el-button>
+        </el-form-item>
+      </el-form>
+    </ContentWrap>
+    <ContentWrap>
+      <el-table
+        ref="tableRef"
+        v-loading="loading"
+        :data="list"
+        :show-overflow-tooltip="true"
+        :stripe="true"
+        @row-click="handleRowClick"
+        @selection-change="handleSelectionChange"
+      >
+        <el-table-column v-if="multiple" type="selection" width="55" />
+        <el-table-column v-else width="55">
+          <template #default="scope">
+            <el-radio
+              v-model="selectedId"
+              :label="scope.row.id"
+              @change="() => handleRadioChange(scope.row)"
+            >
+              &nbsp;
+            </el-radio>
+          </template>
+        </el-table-column>
+        <el-table-column align="center" label="名称" prop="name" />
+        <el-table-column align="center" label="ProductKey" prop="productKey" />
+        <el-table-column align="center" label="品类" prop="categoryName" />
+        <el-table-column align="center" label="设备类型" prop="deviceType">
+          <template #default="scope">
+            <dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="scope.row.deviceType" />
+          </template>
+        </el-table-column>
+        <el-table-column align="center" label="产品图标" prop="icon">
+          <template #default="scope">
+            <el-image
+              v-if="scope.row.icon"
+              :preview-src-list="[scope.row.icon]"
+              :src="scope.row.icon"
+              class="w-40px h-40px"
+            />
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+        <el-table-column align="center" label="产品图片" prop="picture">
+          <template #default="scope">
+            <el-image
+              v-if="scope.row.picUrl"
+              :preview-src-list="[scope.row.picture]"
+              :src="scope.row.picUrl"
+              class="w-40px h-40px"
+            />
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+        <el-table-column
+          :formatter="dateFormatter"
+          align="center"
+          label="创建时间"
+          prop="createTime"
+          width="180px"
+        />
+      </el-table>
+
+      <!-- 分页 -->
+      <Pagination
+        v-model:limit="queryParams.pageSize"
+        v-model:page="queryParams.pageNo"
+        :total="total"
+        @pagination="getList"
+      />
+    </ContentWrap>
+
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import { DICT_TYPE } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import { ProductApi, ProductVO } from '@/api/iot/product/product'
+
+defineOptions({ name: 'IoTProductTableSelect' })
+
+const props = defineProps({
+  multiple: {
+    type: Boolean,
+    default: false
+  }
+})
+
+const message = useMessage()
+const dialogVisible = ref(false)
+const dialogTitle = ref('产品选择器')
+const formLoading = ref(false)
+const loading = ref(true) // 列表的加载中
+const list = ref<ProductVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const selectedProducts = ref<ProductVO[]>([]) // 选中的产品列表
+const selectedId = ref<number>() // 单选模式下选中的ID
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined,
+  productKey: undefined
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ProductApi.getProductPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 打开弹窗 */
+const open = async () => {
+  dialogVisible.value = true
+  // 重置选择状态
+  selectedProducts.value = []
+  selectedId.value = undefined
+  await getList()
+}
+defineExpose({ open })
+
+/** 处理行点击事件 */
+const tableRef = ref()
+const handleRowClick = (row: ProductVO) => {
+  if (props.multiple) {
+    tableRef.value?.toggleRowSelection(row)
+  } else {
+    selectedId.value = row.id
+    selectedProducts.value = [row]
+  }
+}
+
+/** 处理单选变更事件 */
+const handleRadioChange = (row: ProductVO) => {
+  selectedProducts.value = [row]
+}
+
+/** 处理选择变更事件 */
+const handleSelectionChange = (selection: ProductVO[]) => {
+  if (props.multiple) {
+    selectedProducts.value = selection
+  }
+}
+
+/** 提交表单 */
+const emit = defineEmits(['success'])
+const submitForm = async () => {
+  if (selectedProducts.value.length === 0) {
+    message.warning(props.multiple ? '请至少选择一个产品' : '请选择一个产品')
+    return
+  }
+  emit('success', props.multiple ? selectedProducts.value : selectedProducts.value[0])
+  dialogVisible.value = false
+}
+</script>

+ 166 - 0
src/views/iot/rule/scene/IoTRuleSceneForm.vue

@@ -0,0 +1,166 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible" width="1080px">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+    >
+      <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="status">
+            <el-radio-group v-model="formData.status">
+              <el-radio
+                v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+                :key="dict.value"
+                :label="dict.value"
+              >
+                {{ dict.label }}
+              </el-radio>
+            </el-radio-group>
+          </el-form-item>
+        </el-col>
+        <el-col :span="24">
+          <el-form-item label="场景描述" prop="description">
+            <el-input v-model="formData.description" type="textarea" placeholder="请输入场景描述" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="24">
+          <el-divider content-position="left">触发器配置</el-divider>
+          <device-listener
+            v-for="(trigger, index) in formData.triggers"
+            :key="index"
+            :model-value="trigger"
+            @update:model-value="(val) => (formData.triggers[index] = val)"
+            class="mb-10px"
+          >
+            <el-button
+              type="danger"
+              round
+              :icon="Delete"
+              size="small"
+              @click="removeTrigger(index)"
+            />
+          </device-listener>
+          <el-text class="ml-10px!" type="primary" @click="addTrigger">添加触发器</el-text>
+        </el-col>
+        <el-col :span="24">
+          <el-divider content-position="left">执行动作配置</el-divider>
+          <el-form-item label="执行器数组" prop="actionConfigs">
+            <!--            <el-input v-model="formData.actions" placeholder="请输入执行器数组" />-->
+          </el-form-item>
+        </el-col>
+      </el-row>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { RuleSceneApi, RuleSceneVO } from '@/api/iot/rule/scene'
+import DeviceListener from './components/DeviceListener.vue'
+import { Delete } from '@element-plus/icons-vue'
+import { IotRuleSceneTriggerConfig } from '@/api/iot/rule/scene/scene.types'
+
+/** IoT 规则场景(场景联动) 表单 */
+defineOptions({ name: 'RuleSceneForm' })
+
+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<RuleSceneVO>({
+  status: 0,
+  triggers: [] as IotRuleSceneTriggerConfig[]
+} as RuleSceneVO)
+const formRules = reactive({
+  name: [{ required: true, message: '场景名称不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '场景状态不能为空', trigger: 'blur' }],
+  triggers: [{ required: true, message: '触发器数组不能为空', trigger: 'blur' }],
+  actions: [{ required: true, message: '执行器数组不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 添加触发器 */
+const addTrigger = () => {
+  formData.value.triggers.push({
+    type: 1,
+    productKey: '',
+    deviceNames: [],
+    conditions: [
+      {
+        type: 'property',
+        parameters: []
+      }
+    ]
+  })
+}
+/** 移除触发器 */
+const removeTrigger = (index: number) => {
+  const newTriggers = [...formData.value.triggers]
+  newTriggers.splice(index, 1)
+  formData.value.triggers = newTriggers
+}
+/** 打开弹窗 */
+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 RuleSceneApi.getRuleScene(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  await formRef.value.validate()
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as RuleSceneVO
+    if (formType.value === 'create') {
+      await RuleSceneApi.createRuleScene(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await RuleSceneApi.updateRuleScene(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    status: 0,
+    triggers: [] as IotRuleSceneTriggerConfig[]
+  } as RuleSceneVO
+  formRef.value?.resetFields()
+}
+</script>

+ 52 - 0
src/views/iot/rule/scene/components/ConditionSelector.vue

@@ -0,0 +1,52 @@
+<template>
+  <el-select
+    v-model="selectedOperator"
+    class="condition-selector"
+    clearable
+    :placeholder="placeholder"
+  >
+    <!-- TODO puhui999: 考虑根据属性类型不同展示不同的可选条件 -->
+    <el-option label="等于" value="=" />
+    <el-option label="不等于" value="!=" />
+    <el-option label="大于" value=">" />
+    <el-option label="大于等于" value=">=" />
+    <el-option label="小于" value="<" />
+    <el-option label="小于等于" value="<=" />
+    <el-option label="在列表中" value="in" />
+    <el-option label="不在列表中" value="not in" />
+    <el-option label="在范围内" value="between" />
+    <el-option label="不在范围内" value="not between" />
+    <el-option label="包含" value="like" />
+    <el-option label="非空" value="not null" />
+  </el-select>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+/** 条件选择器 */
+defineOptions({ name: 'ConditionSelector' })
+const props = defineProps({
+  placeholder: {
+    type: String,
+    default: '请选择条件'
+  },
+  modelValue: {
+    type: String,
+    default: ''
+  }
+})
+
+const emit = defineEmits(['update:modelValue'])
+
+const selectedOperator = computed({
+  get: () => props.modelValue,
+  set: (value) => emit('update:modelValue', value)
+})
+</script>
+
+<style scoped>
+.condition-selector {
+  width: 100%;
+}
+</style>

+ 236 - 0
src/views/iot/rule/scene/components/DeviceListener.vue

@@ -0,0 +1,236 @@
+<template>
+  <div class="device-listener m-10px">
+    <div class="device-listener-header h-50px flex items-center px-10px">
+      <div class="flex items-center mr-60px">
+        <span class="mr-10px">触发条件</span>
+        <el-select
+          v-model="triggerConfig.type"
+          class="!w-240px"
+          clearable
+          placeholder="请选择触发条件"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.IOT_RULE_SCENE_TRIGGER_TYPE_ENUM)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </div>
+      <div class="flex items-center mr-60px">
+        <span class="mr-10px">产品</span>
+        <el-button type="primary" @click="productTableSelectRef?.open()">
+          {{ !product ? '选择产品' : product.name }}
+        </el-button>
+      </div>
+      <div class="flex items-center mr-60px">
+        <span class="mr-10px">设备</span>
+        <el-button type="primary" @click="openDeviceSelect">
+          {{ isEmpty(deviceList) ? '选择设备' : triggerConfig.deviceNames.join(',') }}
+        </el-button>
+      </div>
+      <!-- 删除触发器 -->
+      <div class="device-listener-delete">
+        <el-tooltip content="删除触发器" placement="top">
+          <slot></slot>
+        </el-tooltip>
+      </div>
+    </div>
+    <!-- 触发器条件 -->
+    <div
+      class="device-listener-condition flex p-10px"
+      v-for="(condition, index) in triggerConfig.conditions"
+      :key="index"
+    >
+      <div class="flex flex-col items-center justify-center mr-10px h-a">
+        <el-select
+          v-model="condition.type"
+          @change="condition.parameters = []"
+          class="!w-160px"
+          clearable
+          placeholder=""
+        >
+          <!--          <el-option-->
+          <!--            v-for="dict in getStrDictOptions(DICT_TYPE.IOT_DEVICE_MESSAGE_TYPE_ENUM)"-->
+          <!--            :key="dict.value"-->
+          <!--            :label="dict.label"-->
+          <!--            :value="dict.value"-->
+          <!--          />-->
+          <el-option label="属性" value="property" />
+          <el-option label="服务" value="service" />
+          <el-option label="事件" value="event" />
+        </el-select>
+      </div>
+      <div class="">
+        <DeviceListenerCondition
+          v-for="(parameter, index2) in condition.parameters"
+          :key="index2"
+          :model-value="parameter"
+          :thingModels="thingModels(condition)"
+          @update:model-value="(val) => (condition.parameters[index2] = val)"
+          class="mb-10px last:mb-0"
+        >
+          <el-tooltip content="删除参数" placement="top">
+            <el-button
+              class="device-listener-delete"
+              type="danger"
+              circle
+              :icon="Delete"
+              size="small"
+              @click="removeConditionParameter(condition.parameters, index2)"
+            />
+          </el-tooltip>
+        </DeviceListenerCondition>
+      </div>
+      <!-- 添加参数 -->
+      <div class="flex flex-1 flex-col items-center justify-center w-60px h-a">
+        <el-tooltip content="添加参数" placement="top">
+          <el-button
+            type="primary"
+            circle
+            :icon="Plus"
+            size="small"
+            @click="addConditionParameter(condition.parameters)"
+          />
+        </el-tooltip>
+      </div>
+      <!-- 删除条件 -->
+      <div
+        class="device-listener-condition flex flex-1 flex-col items-center justify-center w-a h-a"
+      >
+        <el-tooltip content="删除条件" placement="top">
+          <el-button type="danger" :icon="Delete" size="small" @click="removeCondition(index)" />
+        </el-tooltip>
+      </div>
+    </div>
+    <el-text class="ml-10px!" type="primary" @click="addCondition">添加触发条件</el-text>
+  </div>
+
+  <IoTProductTableSelect ref="productTableSelectRef" @success="handleProductSelect" />
+  <IoTDeviceTableSelect
+    ref="deviceTableSelectRef"
+    multiple
+    :product-id="product?.id"
+    @success="handleDeviceSelect"
+  />
+</template>
+
+<script setup lang="ts">
+import { Delete, Plus } from '@element-plus/icons-vue'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import DeviceListenerCondition from './DeviceListenerCondition.vue'
+import IoTProductTableSelect from '@/views/iot/product/product/components/IoTProductTableSelect.vue'
+import IoTDeviceTableSelect from '@/views/iot/device/device/components/IoTDeviceTableSelect.vue'
+import {
+  IotRuleSceneTriggerCondition,
+  IotRuleSceneTriggerConditionParameter,
+  IotRuleSceneTriggerConfig
+} from '@/api/iot/rule/scene/scene.types'
+import { ProductVO } from '@/api/iot/product/product'
+import { DeviceVO } from '@/api/iot/device/device'
+import { useVModel } from '@vueuse/core'
+import { isEmpty } from '@/utils/is'
+import { ThingModelApi } from '@/api/iot/thingmodel'
+
+/** 场景联动之监听器组件 */
+defineOptions({ name: 'DeviceListener' })
+
+const props = defineProps<{ modelValue: any }>()
+const emits = defineEmits(['update:modelValue'])
+const triggerConfig = useVModel(props, 'modelValue', emits) as Ref<IotRuleSceneTriggerConfig>
+
+const message = useMessage()
+
+/** 添加触发条件 */
+const addCondition = () => {
+  triggerConfig.value.conditions.push({
+    type: 'property',
+    parameters: []
+  })
+}
+/** 移除触发条件 */
+const removeCondition = (index: number) => {
+  triggerConfig.value.conditions.splice(index, 1)
+}
+/** 添加参数 */
+const addConditionParameter = (conditionParameters: IotRuleSceneTriggerConditionParameter[]) => {
+  if (!product.value) {
+    message.warning('请先选择一个产品')
+    return
+  }
+  conditionParameters.push({} as IotRuleSceneTriggerConditionParameter)
+}
+/** 移除参数 */
+const removeConditionParameter = (
+  conditionParameters: IotRuleSceneTriggerConditionParameter[],
+  index: number
+) => {
+  conditionParameters.splice(index, 1)
+}
+
+const productTableSelectRef = ref<InstanceType<typeof IoTProductTableSelect>>()
+const deviceTableSelectRef = ref<InstanceType<typeof IoTDeviceTableSelect>>()
+const product = ref<ProductVO>()
+const deviceList = ref<DeviceVO[]>([])
+/** 处理产品选择 */
+const handleProductSelect = (val: ProductVO) => {
+  product.value = val
+  triggerConfig.value.productKey = val.productKey
+  deviceList.value = []
+  getThingModelTSL()
+}
+/** 处理设备选择 */
+const handleDeviceSelect = (val: DeviceVO[]) => {
+  deviceList.value = val
+  triggerConfig.value.deviceNames = val.map((item) => item.deviceName)
+}
+/** 打开设备选择器 */
+const openDeviceSelect = () => {
+  if (!product.value) {
+    message.warning('请先选择一个产品')
+    return
+  }
+  deviceTableSelectRef.value?.open()
+}
+
+/** 获取产品物模型 */
+const thingModelTSL = ref<any>()
+const thingModels = computed(() => (condition: IotRuleSceneTriggerCondition) => {
+  switch (condition.type) {
+    case 'property':
+      return thingModelTSL.value.properties
+    // TODO puhui999: 服务和事件后续考虑
+    case 'service':
+      return thingModelTSL.value.services
+    case 'event':
+      return thingModelTSL.value.events
+  }
+  return []
+})
+const getThingModelTSL = async () => {
+  if (!product.value) {
+    return
+  }
+  thingModelTSL.value = await ThingModelApi.getThingModelTSLByProductId(product.value.id)
+}
+</script>
+
+<style lang="scss" scoped>
+.device-listener {
+  .device-listener-header {
+    position: relative;
+    background-color: #eff3f7;
+
+    .device-listener-delete {
+      position: absolute;
+      top: auto;
+      right: 16px;
+      bottom: auto;
+    }
+  }
+
+  .device-listener-condition {
+    background-color: #dbe5f6;
+  }
+}
+</style>

+ 57 - 0
src/views/iot/rule/scene/components/DeviceListenerCondition.vue

@@ -0,0 +1,57 @@
+<template>
+  <div class="device-listener-condition">
+    <el-select
+      v-model="conditionParameter.identifier"
+      class="!w-240px mr-10px"
+      clearable
+      placeholder="请选择物模型"
+    >
+      <el-option
+        v-for="thingModel in thingModels"
+        :key="thingModel.identifier"
+        :label="thingModel.name"
+        :value="thingModel.identifier"
+      />
+    </el-select>
+    <ConditionSelector v-model="conditionParameter.operator" class="!w-180px mr-10px" />
+    <!-- TODO puhui999: 输入值范围校验? -->
+    <el-input v-model="conditionParameter.value" class="!w-240px mr-10px" placeholder="请输入值">
+      <template #append> {{ getUnitName }} </template>
+    </el-input>
+    <!-- 按钮插槽 -->
+    <slot></slot>
+  </div>
+</template>
+
+<script setup lang="ts">
+import ConditionSelector from './ConditionSelector.vue'
+import { IotRuleSceneTriggerConditionParameter } from '@/api/iot/rule/scene/scene.types'
+import { useVModel } from '@vueuse/core'
+
+defineOptions({ name: 'DeviceListenerCondition' })
+const props = defineProps<{ modelValue: any; thingModels: any }>()
+const emits = defineEmits(['update:modelValue'])
+const conditionParameter = useVModel(
+  props,
+  'modelValue',
+  emits
+) as Ref<IotRuleSceneTriggerConditionParameter>
+
+/** 获得属性单位 */
+const getUnitName = computed(() => {
+  const model = props.thingModels?.find(
+    (item: any) => item.identifier === conditionParameter.value.identifier
+  )
+  // 属性
+  if (model?.dataSpecs) {
+    return model.dataSpecs.unitName
+  }
+  // 服务和事件
+  // if (model?.outputParams) {
+  //   return model.dataSpecs.unitName
+  // }
+  return '单位'
+})
+</script>
+
+<style scoped lang="scss"></style>

+ 187 - 0
src/views/iot/rule/scene/index.vue

@@ -0,0 +1,187 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="场景名称" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          placeholder="请输入场景名称"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="场景状态" prop="status">
+        <el-select
+          v-model="queryParams.status"
+          placeholder="请选择场景状态"
+          clearable
+          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="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-220px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['iot:rule-scene:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="场景编号" align="center" prop="id" />
+      <el-table-column label="场景名称" align="center" prop="name" />
+      <el-table-column label="场景描述" align="center" prop="description" />
+      <el-table-column label="场景状态" align="center" prop="status">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column label="触发器数组" align="center" prop="triggers" />
+      <el-table-column label="执行器数组" align="center" prop="actions" />
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="操作" align="center" min-width="120px">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['iot:rule-scene:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['iot:rule-scene:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <IoTRuleSceneForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import { RuleSceneApi, RuleSceneVO } from '@/api/iot/rule/scene'
+import IoTRuleSceneForm from './IoTRuleSceneForm.vue'
+
+/** IoT 规则场景(场景联动) 列表 */
+defineOptions({ name: 'IotRuleScene' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<RuleSceneVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined,
+  description: undefined,
+  status: undefined,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await RuleSceneApi.getRuleScenePage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await RuleSceneApi.deleteRuleScene(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

部分文件因为文件数量过多而无法显示