Pre Merge pull request !793 from puhui999/feature/iot

pull/793/MERGE
puhui999 2025-07-17 15:34:06 +00:00 committed by Gitee
commit e84e2c0840
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
46 changed files with 10129 additions and 1906 deletions

View File

@ -50,6 +50,19 @@ const IotAlertConfigReceiveTypeEnum = {
NOTIFY: 3 // 通知
} as const
// 设备状态枚举
const DeviceStateEnum = {
INACTIVE: 0, // 未激活
ONLINE: 1, // 在线
OFFLINE: 2 // 离线
} as const
// 通用状态枚举
const CommonStatusEnum = {
ENABLE: 0, // 开启
DISABLE: 1 // 关闭
} as const
// 基础接口
interface TenantBaseDO {
createTime?: Date // 创建时间
@ -62,10 +75,10 @@ interface TenantBaseDO {
// 触发条件参数
interface TriggerConditionParameter {
identifier0: string // 标识符(事件、服务)
identifier: string // 标识符(属性)
operator: string // 操作符
value: string // 比较值
identifier0?: string // 标识符(事件、服务)
identifier?: string // 标识符(属性)
operator: string // 操作符(必填)
value: string // 比较值(必填,多值用逗号分隔)
}
// 触发条件
@ -77,39 +90,104 @@ interface TriggerCondition {
// 触发器配置
interface TriggerConfig {
key: any // 解决组件索引重用
type: number // 触发类型
productKey: string // 产品标识
deviceNames: string[] // 设备名称数组
conditions?: TriggerCondition[] // 触发条件数组
cronExpression?: string // CRON 表达式
key?: string // 组件唯一标识符,用于解决索引重用问题
type: number // 触发类型(必填)
productKey?: string // 产品标识(设备触发时必填)
deviceNames?: string[] // 设备名称数组(设备触发时必填)
conditions?: TriggerCondition[] // 触发条件数组(设备触发时必填)
cronExpression?: string // CRON表达式(定时触发时必填)
}
// 执行设备控制
interface ActionDeviceControl {
productKey: string // 产品标识
deviceNames: string[] // 设备名称数组
type: string // 消息类型
identifier: string // 消息标识符
data: Record<string, any> // 具体数据
productKey: string // 产品标识(必填)
deviceNames: string[] // 设备名称数组(必填)
type: string // 消息类型(必填)
identifier: string // 消息标识符(必填)
params: Record<string, any> // 参数对象(必填)- 统一使用 params 字段
}
// 执行器配置
interface ActionConfig {
key: any // 解决组件索引重用 TODO @puhui999看看有没更好的解决方案呢。
type: number // 执行类型
deviceControl?: ActionDeviceControl // 设备控制
alertConfigId?: number // 告警配置ID告警恢复时需要
key?: string // 组件唯一标识符,用于解决索引重用问题
type: number // 执行类型(必填)
deviceControl?: ActionDeviceControl // 设备控制(设备控制时必填)
alertConfigId?: number // 告警配置ID告警恢复时必填
}
// 表单数据接口
interface RuleSceneFormData {
id?: number
name: string
description?: string
status: number
triggers: TriggerFormData[]
actions: ActionFormData[]
}
interface TriggerFormData {
type: number
productId?: number
deviceId?: number
identifier?: string
operator?: string
value?: string
cronExpression?: string
conditionGroups?: ConditionGroupFormData[]
}
interface ActionFormData {
type: number
productId?: number
deviceId?: number
params?: Record<string, any>
alertConfigId?: number
}
interface ConditionGroupFormData {
conditions: ConditionFormData[]
logicOperator: 'AND' | 'OR'
}
interface ConditionFormData {
type: number
productId: number
deviceId: number
identifier: string
operator: string
param: string
}
// 主接口
interface IotRuleScene extends TenantBaseDO {
id: number // 场景编号
name: string // 场景名称
description: string // 场景描述
status: number // 场景状态
triggers: TriggerConfig[] // 触发器数组
actions: ActionConfig[] // 执行器数组
id?: number // 场景编号(新增时为空)
name: string // 场景名称(必填)
description?: string // 场景描述(可选)
status: number // 场景状态0-开启1-关闭
triggers: TriggerConfig[] // 触发器数组(必填,至少一个)
actions: ActionConfig[] // 执行器数组(必填,至少一个)
}
// 工具类型
type TriggerType = (typeof IotRuleSceneTriggerTypeEnum)[keyof typeof IotRuleSceneTriggerTypeEnum]
type ActionType = (typeof IotRuleSceneActionTypeEnum)[keyof typeof IotRuleSceneActionTypeEnum]
type MessageType = (typeof IotDeviceMessageTypeEnum)[keyof typeof IotDeviceMessageTypeEnum]
type OperatorType =
(typeof IotRuleSceneTriggerConditionParameterOperatorEnum)[keyof typeof IotRuleSceneTriggerConditionParameterOperatorEnum]['value']
// 表单验证规则类型
interface ValidationRule {
required?: boolean
message?: string
trigger?: string | string[]
type?: string
min?: number
max?: number
enum?: any[]
}
interface FormValidationRules {
[key: string]: ValidationRule[]
}
export {
@ -119,10 +197,23 @@ export {
TriggerConditionParameter,
ActionConfig,
ActionDeviceControl,
RuleSceneFormData,
TriggerFormData,
ActionFormData,
ConditionGroupFormData,
ConditionFormData,
IotRuleSceneTriggerTypeEnum,
IotRuleSceneActionTypeEnum,
IotDeviceMessageTypeEnum,
IotDeviceMessageIdentifierEnum,
IotRuleSceneTriggerConditionParameterOperatorEnum,
IotAlertConfigReceiveTypeEnum
IotAlertConfigReceiveTypeEnum,
DeviceStateEnum,
CommonStatusEnum,
TriggerType,
ActionType,
MessageType,
OperatorType,
ValidationRule,
FormValidationRules
}

View File

@ -116,8 +116,8 @@ const queryParams = reactive({
const loading = ref(false)
const total = ref(0)
const list = ref([])
const autoRefresh = ref(false)
let autoRefreshTimer: any = null // TODO @superautoRefreshEnableautoRefreshTimer
const autoRefresh = ref(false) //
let autoRefreshTimer: any = null //
//
const methodOptions = computed(() => {
@ -172,6 +172,7 @@ watch(
onBeforeUnmount(() => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer)
autoRefreshTimer = null
}
})

View File

@ -7,15 +7,14 @@
ref="queryFormRef"
:inline="true"
label-width="68px"
@submit.prevent
>
<el-form-item>
<el-button type="primary" @click="openTaskForm" v-hasPermi="['iot:ota-task:create']">
<Icon icon="ep:plus" class="mr-5px" /> 新增
</el-button>
</el-form-item>
<!-- TODO @AIunocss -->
<el-form-item style="float: right">
<!--TODO @AI:有个 bug回车后会刷新修复下 -->
<el-form-item class="float-right">
<el-input
v-model="queryParams.name"
placeholder="请输入任务名称"

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,468 @@
# IotThingModelTSLRespVO 数据结构文档
## 概述
`IotThingModelTSLRespVO` 是IoT产品物模型TSLThing Specification Language的响应数据结构用于返回完整的产品物模型定义包括属性、事件和服务的详细信息。TSL是阿里云IoT平台定义的一套物模型描述规范。
## 主体数据结构
### IotThingModelTSLRespVO
```typescript
interface IotThingModelTSLRespVO {
productId: number; // 产品编号(必填)
productKey: string; // 产品标识(必填)
properties: ThingModelProperty[]; // 属性列表(必填)
events: ThingModelEvent[]; // 事件列表(必填)
services: ThingModelService[]; // 服务列表(必填)
}
```
**字段说明:**
- `productId`: 产品编号唯一标识一个IoT产品
- `productKey`: 产品标识符,用于设备连接和识别
- `properties`: 设备属性列表,描述设备的状态信息
- `events`: 设备事件列表,描述设备主动上报的事件
- `services`: 设备服务列表,描述可以调用的设备功能
## 属性数据结构 (ThingModelProperty)
### 基本结构
```typescript
interface ThingModelProperty {
identifier: string; // 属性标识符(必填)
name: string; // 属性名称(必填)
accessMode: string; // 访问模式(必填)
required?: boolean; // 是否必选
dataType: string; // 数据类型(必填)
dataSpecs?: ThingModelDataSpecs; // 数据规范(非列表型)
dataSpecsList?: ThingModelDataSpecs[]; // 数据规范(列表型)
}
```
### 字段详细说明
#### identifier属性标识符
- **类型**: `string`
- **必填**: 是
- **格式**: 正则表达式 `^[a-zA-Z][a-zA-Z0-9_]{0,31}$`
- **说明**: 只能由字母、数字和下划线组成必须以字母开头长度不超过32个字符
- **示例**: `"temperature"`, `"humidity"`, `"power_status"`
#### name属性名称
- **类型**: `string`
- **必填**: 是
- **说明**: 属性的显示名称,用于界面展示
- **示例**: `"温度"`, `"湿度"`, `"电源状态"`
#### accessMode访问模式
- **类型**: `string`
- **必填**: 是
- **枚举值**:
- `"r"`: 只读,设备只能上报,平台不能下发
- `"rw"`: 读写,设备可以上报,平台也可以下发
- **示例**: `"r"`, `"rw"`
#### dataType数据类型
- **类型**: `string`
- **必填**: 是
- **枚举值**:
- `"int"`: 整数型
- `"float"`: 单精度浮点型
- `"double"`: 双精度浮点型
- `"enum"`: 枚举型
- `"bool"`: 布尔型
- `"text"`: 文本型
- `"date"`: 时间型
- `"struct"`: 结构体型
- `"array"`: 数组型
## 事件数据结构 (ThingModelEvent)
### 基本结构
```typescript
interface ThingModelEvent {
identifier: string; // 事件标识符(必填)
name: string; // 事件名称(必填)
required?: boolean; // 是否必选
type: string; // 事件类型(必填)
outputParams?: ThingModelParam[]; // 输出参数
method?: string; // 执行方法
}
```
### 字段详细说明
#### type事件类型
- **类型**: `string`
- **必填**: 是
- **枚举值**:
- `"info"`: 信息事件
- `"alert"`: 告警事件
- `"error"`: 故障事件
#### outputParams输出参数
- **类型**: `ThingModelParam[]`
- **必填**: 否
- **说明**: 事件触发时返回的参数信息
## 服务数据结构 (ThingModelService)
### 基本结构
```typescript
interface ThingModelService {
identifier: string; // 服务标识符(必填)
name: string; // 服务名称(必填)
required?: boolean; // 是否必选
callType: string; // 调用类型(必填)
inputParams?: ThingModelParam[]; // 输入参数
outputParams?: ThingModelParam[]; // 输出参数
method?: string; // 执行方法
}
```
### 字段详细说明
#### callType调用类型
- **类型**: `string`
- **必填**: 是
- **枚举值**:
- `"async"`: 异步调用
- `"sync"`: 同步调用
## 参数数据结构 (ThingModelParam)
### 基本结构
```typescript
interface ThingModelParam {
identifier: string; // 参数标识符(必填)
name: string; // 参数名称(必填)
direction: string; // 参数方向(必填)
paraOrder?: number; // 参数序号
dataType: string; // 数据类型(必填)
dataSpecs?: ThingModelDataSpecs; // 数据规范(非列表型)
dataSpecsList?: ThingModelDataSpecs[]; // 数据规范(列表型)
}
```
### 字段详细说明
#### direction参数方向
- **类型**: `string`
- **必填**: 是
- **枚举值**:
- `"input"`: 输入参数
- `"output"`: 输出参数
## 数据规范结构 (ThingModelDataSpecs)
数据规范是一个抽象基类,根据不同的数据类型有不同的具体实现:
### 1. 数值型数据规范 (ThingModelNumericDataSpec)
适用于 `int`、`float`、`double` 类型:
```typescript
interface ThingModelNumericDataSpec {
dataType: "int" | "float" | "double";
max: string; // 最大值(必填)
min: string; // 最小值(必填)
step: string; // 步长(必填)
precise?: string; // 精度float/double可选
defaultValue?: string; // 默认值
unit?: string; // 单位符号
unitName?: string; // 单位名称
}
```
### 2. 布尔/枚举型数据规范 (ThingModelBoolOrEnumDataSpecs)
适用于 `bool`、`enum` 类型:
```typescript
interface ThingModelBoolOrEnumDataSpecs {
dataType: "bool" | "enum";
name: string; // 枚举项名称(必填)
value: number; // 枚举值(必填)
}
```
### 3. 文本/时间型数据规范 (ThingModelDateOrTextDataSpecs)
适用于 `text`、`date` 类型:
```typescript
interface ThingModelDateOrTextDataSpecs {
dataType: "text" | "date";
length?: number; // 数据长度text类型需要最大2048
defaultValue?: string; // 默认值
}
```
### 4. 数组型数据规范 (ThingModelArrayDataSpecs)
适用于 `array` 类型:
```typescript
interface ThingModelArrayDataSpecs {
dataType: "array";
size: number; // 数组元素个数(必填)
childDataType: string; // 数组元素数据类型(必填)
dataSpecsList?: ThingModelDataSpecs[]; // 子元素数据规范struct类型时
}
```
**childDataType 枚举值**:
- `"struct"`: 结构体
- `"int"`: 整数
- `"float"`: 单精度浮点
- `"double"`: 双精度浮点
- `"text"`: 文本
### 5. 结构体型数据规范 (ThingModelStructDataSpecs)
适用于 `struct` 类型:
```typescript
interface ThingModelStructDataSpecs {
dataType: "struct";
identifier: string; // 属性标识符(必填)
name: string; // 属性名称(必填)
accessMode: string; // 操作类型(必填)
required?: boolean; // 是否必选
childDataType: string; // 子数据类型(必填)
dataSpecs?: ThingModelDataSpecs; // 数据规范(非列表型)
dataSpecsList?: ThingModelDataSpecs[]; // 数据规范(列表型)
}
```
**childDataType 枚举值**:
- `"int"`: 整数
- `"float"`: 单精度浮点
- `"double"`: 双精度浮点
- `"text"`: 文本
- `"date"`: 时间
- `"enum"`: 枚举
- `"bool"`: 布尔
## 数据类型映射关系
### dataSpecs vs dataSpecsList
- **dataSpecs**: 用于非列表型数据类型(`int`、`float`、`double`、`text`、`date`、`array`
- **dataSpecsList**: 用于列表型数据类型(`enum`、`bool`、`struct`
### JSON多态序列化
数据规范使用Jackson的`@JsonTypeInfo`和`@JsonSubTypes`注解实现多态序列化:
```json
{
"dataType": "int",
"max": "100",
"min": "0",
"step": "1",
"unit": "°C",
"unitName": "摄氏度"
}
```
## 完整示例
### 温度传感器物模型示例
```json
{
"productId": 1024,
"productKey": "temperature_sensor",
"properties": [
{
"identifier": "temperature",
"name": "温度",
"accessMode": "r",
"required": true,
"dataType": "float",
"dataSpecs": {
"dataType": "float",
"max": "100.0",
"min": "-40.0",
"step": "0.1",
"precise": "1",
"unit": "°C",
"unitName": "摄氏度"
}
},
{
"identifier": "power_switch",
"name": "电源开关",
"accessMode": "rw",
"required": false,
"dataType": "bool",
"dataSpecsList": [
{
"dataType": "bool",
"name": "关闭",
"value": 0
},
{
"dataType": "bool",
"name": "开启",
"value": 1
}
]
}
],
"events": [
{
"identifier": "high_temperature_alert",
"name": "高温告警",
"required": false,
"type": "alert",
"outputParams": [
{
"identifier": "current_temp",
"name": "当前温度",
"direction": "output",
"dataType": "float",
"dataSpecs": {
"dataType": "float",
"max": "100.0",
"min": "-40.0",
"step": "0.1"
}
}
]
}
],
"services": [
{
"identifier": "reset_device",
"name": "重置设备",
"required": false,
"callType": "async",
"inputParams": [
{
"identifier": "reset_type",
"name": "重置类型",
"direction": "input",
"dataType": "enum",
"dataSpecsList": [
{
"dataType": "enum",
"name": "软重置",
"value": 1
},
{
"dataType": "enum",
"name": "硬重置",
"value": 2
}
]
}
],
"outputParams": [
{
"identifier": "result",
"name": "执行结果",
"direction": "output",
"dataType": "bool",
"dataSpecsList": [
{
"dataType": "bool",
"name": "失败",
"value": 0
},
{
"dataType": "bool",
"name": "成功",
"value": 1
}
]
}
]
}
]
}
```
## 前端使用建议
### 1. TypeScript类型定义
建议在前端项目中定义完整的TypeScript接口确保类型安全
```typescript
// 定义完整的类型接口
export interface IotThingModelTSLRespVO {
productId: number;
productKey: string;
properties: ThingModelProperty[];
events: ThingModelEvent[];
services: ThingModelService[];
}
// 使用联合类型处理数据规范的多态性
export type ThingModelDataSpecs =
| ThingModelNumericDataSpec
| ThingModelBoolOrEnumDataSpecs
| ThingModelDateOrTextDataSpecs
| ThingModelArrayDataSpecs
| ThingModelStructDataSpecs;
```
### 2. 数据验证
```typescript
// 验证数据类型和数据规范的一致性
function validateDataSpecs(dataType: string, dataSpecs: any): boolean {
switch (dataType) {
case 'int':
case 'float':
case 'double':
return dataSpecs.dataType === dataType &&
dataSpecs.max !== undefined &&
dataSpecs.min !== undefined;
case 'bool':
case 'enum':
return Array.isArray(dataSpecs) &&
dataSpecs.every(spec => spec.name && spec.value !== undefined);
// ... 其他类型验证
default:
return false;
}
}
```
### 3. 数据转换工具
```typescript
// 将后端数据转换为前端展示格式
function formatPropertyValue(property: ThingModelProperty, value: any): string {
if (property.dataType === 'enum' || property.dataType === 'bool') {
const spec = property.dataSpecsList?.find(s => s.value === value);
return spec?.name || String(value);
}
if (property.dataType === 'float' || property.dataType === 'double') {
const unit = property.dataSpecs?.unit || '';
return `${value}${unit}`;
}
return String(value);
}
```
## 注意事项
1. **数据规范选择**: 根据`dataType`选择使用`dataSpecs`还是`dataSpecsList`
2. **标识符唯一性**: 在同一产品下,所有功能的`identifier`必须唯一
3. **数据类型一致性**: 参数的`dataType`必须与其`dataSpecs`的`dataType`保持一致
4. **枚举值处理**: 布尔型和枚举型数据使用`dataSpecsList`数组存储可选值
5. **嵌套结构**: 结构体和数组类型可能包含嵌套的数据规范定义
6. **版本兼容**: 物模型结构可能随版本演进,前端需要做好兼容性处理
这个数据结构为IoT设备的完整功能描述提供了标准化的格式支持复杂的数据类型和嵌套结构能够满足各种IoT设备的建模需求。

View File

@ -1,224 +0,0 @@
<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"
:value="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>
<!-- 根据触发类型选择不同的监听器组件 -->
<template v-for="(trigger, index) in formData.triggers" :key="trigger.key">
<!-- 设备状态变更和定时触发使用简化的监听器 -->
<device-state-listener
v-if="
trigger.type === IotRuleSceneTriggerTypeEnum.DEVICE_STATE_UPDATE ||
trigger.type === IotRuleSceneTriggerTypeEnum.TIMER
"
:model-value="trigger"
@update:model-value="(val) => (formData.triggers[index] = val)"
class="mb-10px"
>
<el-button type="danger" round size="small" @click="removeTrigger(index)">
<Icon icon="ep:delete" />
</el-button>
</device-state-listener>
<!-- 其他设备触发类型使用完整的监听器 -->
<device-listener
v-else
:model-value="trigger"
@update:model-value="(val) => (formData.triggers[index] = val)"
class="mb-10px"
>
<el-button type="danger" round size="small" @click="removeTrigger(index)">
<Icon icon="ep:delete" />
</el-button>
</device-listener>
</template>
<el-button class="ml-10px!" type="primary" size="small" @click="addTrigger">
添加触发器
</el-button>
</el-col>
<el-col :span="24">
<el-divider content-position="left">执行器配置</el-divider>
<action-executor
v-for="(action, index) in formData.actions"
:key="action.key"
:model-value="action"
@update:model-value="(val) => (formData.actions[index] = val)"
class="mb-10px"
>
<el-button type="danger" round size="small" @click="removeAction(index)">
<Icon icon="ep:delete" />
</el-button>
</action-executor>
<el-button class="ml-10px!" type="primary" size="small" @click="addAction">
添加执行器
</el-button>
</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 } from '@/api/iot/rule/scene'
import DeviceListener from './components/listener/DeviceListener.vue'
import DeviceStateListener from './components/listener/DeviceStateListener.vue'
import { CommonStatusEnum } from '@/utils/constants'
import {
ActionConfig,
IotDeviceMessageIdentifierEnum,
IotDeviceMessageTypeEnum,
IotRuleScene,
IotRuleSceneActionTypeEnum,
IotRuleSceneTriggerTypeEnum,
TriggerConfig
} from '@/api/iot/rule/scene/scene.types'
import ActionExecutor from './components/action/ActionExecutor.vue'
import { generateUUID } from '@/utils'
/** IoT 场景联动表单 */
defineOptions({ name: 'IotRuleSceneForm' })
const { t } = useI18n() //
const message = useMessage() //
const dialogVisible = ref(false) //
const dialogTitle = ref('') //
const formLoading = ref(false) // 12
const formType = ref('') // create - update -
const formData = ref<IotRuleScene>({
status: CommonStatusEnum.ENABLE,
triggers: [] as TriggerConfig[],
actions: [] as ActionConfig[]
} as IotRuleScene)
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({
key: generateUUID(), //
type: IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST, //
productKey: '',
deviceNames: [],
conditions: [
{
type: IotDeviceMessageTypeEnum.PROPERTY,
identifier: IotDeviceMessageIdentifierEnum.PROPERTY_SET,
parameters: []
}
]
})
}
/** 移除触发器 */
const removeTrigger = (index: number) => {
formData.value.triggers.splice(index, 1)
}
/** 添加执行器 */
const addAction = () => {
formData.value.actions.push({
key: generateUUID(), //
type: IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET
} as ActionConfig)
}
/** 移除执行器 */
const removeAction = (index: number) => {
formData.value.actions.splice(index, 1)
}
/** 打开弹窗 */
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 {
const data = (await RuleSceneApi.getRuleScene(id)) as IotRuleScene
//
data.triggers?.forEach((item) => (item.key = generateUUID()))
data.actions?.forEach((item) => (item.key = generateUUID()))
formData.value = data
} 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 IotRuleScene
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: CommonStatusEnum.ENABLE,
triggers: [] as TriggerConfig[],
actions: [] as ActionConfig[]
} as IotRuleScene
formRef.value?.resetFields()
}
</script>

View File

@ -0,0 +1,302 @@
<!-- IoT场景联动规则表单 - 主表单组件 -->
<template>
<el-drawer
v-model="drawerVisible"
:title="drawerTitle"
size="80%"
direction="rtl"
:close-on-click-modal="false"
:close-on-press-escape="false"
@close="handleClose"
class="rule-scene-drawer"
>
<div class="rule-scene-form">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
class="form-container"
>
<!-- 基础信息配置 -->
<BasicInfoSection v-model="formData" :rules="formRules" />
<!-- 触发器配置 -->
<TriggerSection v-model:triggers="formData.triggers" @validate="handleTriggerValidate" />
<!-- 执行器配置 -->
<ActionSection v-model:actions="formData.actions" @validate="handleActionValidate" />
<!-- 预览区域 -->
<PreviewSection
:form-data="formData"
:validation-result="validationResult"
@validate="handleValidate"
/>
</el-form>
</div>
<!-- 抽屉底部操作栏 -->
<template #footer>
<div class="drawer-footer">
<el-button @click="handleClose" size="large">取消</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="submitLoading"
:disabled="!canSubmit"
size="large"
>
{{ isEdit ? '更新' : '创建' }}
</el-button>
</div>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import BasicInfoSection from './sections/BasicInfoSection.vue'
import TriggerSection from './sections/TriggerSection.vue'
import ActionSection from './sections/ActionSection.vue'
import PreviewSection from './sections/PreviewSection.vue'
import { RuleSceneFormData, IotRuleScene } from '@/api/iot/rule/scene/scene.types'
import { getBaseValidationRules } from '../utils/validation'
import { transformFormToApi, transformApiToForm, createDefaultFormData } from '../utils/transform'
import { handleValidationError, showSuccess, withErrorHandling } from '../utils/errorHandler'
/** IoT场景联动规则表单 - 主表单组件 */
defineOptions({ name: 'RuleSceneForm' })
interface Props {
modelValue: boolean
ruleScene?: IotRuleScene
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const drawerVisible = useVModel(props, 'modelValue', emit)
//
const formRef = ref()
const formData = ref<RuleSceneFormData>(createDefaultFormData())
const formRules = getBaseValidationRules()
const submitLoading = ref(false)
const validationResult = ref<{ valid: boolean; message?: string } | null>(null)
//
const triggerValidation = ref({ valid: true, message: '' })
const actionValidation = ref({ valid: true, message: '' })
//
const isEdit = computed(() => !!props.ruleScene?.id)
const drawerTitle = computed(() => (isEdit.value ? '编辑场景联动规则' : '新增场景联动规则'))
const canSubmit = computed(() => {
return (
formData.value.name &&
formData.value.triggers.length > 0 &&
formData.value.actions.length > 0 &&
triggerValidation.value.valid &&
actionValidation.value.valid
)
})
//
const handleTriggerValidate = (result: { valid: boolean; message: string }) => {
triggerValidation.value = result
}
const handleActionValidate = (result: { valid: boolean; message: string }) => {
actionValidation.value = result
}
const handleValidate = async () => {
try {
await formRef.value?.validate()
if (!triggerValidation.value.valid) {
throw new Error(triggerValidation.value.message)
}
if (!actionValidation.value.valid) {
throw new Error(actionValidation.value.message)
}
validationResult.value = { valid: true, message: '验证通过' }
showSuccess('规则验证通过')
return true
} catch (error: any) {
const message = error.message || '表单验证失败'
validationResult.value = { valid: false, message }
await handleValidationError(message, 'rule-scene-form')
return false
}
}
const handleSubmit = async () => {
const result = await withErrorHandling(
async () => {
//
const isValid = await handleValidate()
if (!isValid) {
throw new Error('表单验证失败')
}
//
const apiData = transformFormToApi(formData.value)
// API
console.log('提交数据:', apiData)
// API
await new Promise((resolve) => setTimeout(resolve, 1000))
return apiData
},
{
loadingKey: 'rule-scene-submit',
loadingText: isEdit.value ? '更新中...' : '创建中...',
context: 'rule-scene-form',
showSuccess: true,
successMessage: isEdit.value ? '更新成功' : '创建成功'
}
)
if (result) {
emit('success')
handleClose()
}
}
const handleClose = () => {
drawerVisible.value = false
validationResult.value = null
}
//
const initFormData = () => {
if (props.ruleScene) {
formData.value = transformApiToForm(props.ruleScene)
} else {
formData.value = createDefaultFormData()
}
}
//
watch(drawerVisible, (visible) => {
if (visible) {
initFormData()
nextTick(() => {
formRef.value?.clearValidate()
})
}
})
// props
watch(
() => props.ruleScene,
() => {
if (drawerVisible.value) {
initFormData()
}
}
)
</script>
<style scoped>
.rule-scene-drawer {
--el-drawer-padding-primary: 20px;
}
.rule-scene-form {
height: calc(100vh - 120px);
overflow-y: auto;
padding: 20px;
padding-bottom: 80px; /* 为底部操作栏留出空间 */
}
.form-container {
display: flex;
flex-direction: column;
gap: 24px;
}
.drawer-footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: flex-end;
gap: 16px;
padding: 16px 20px;
background: var(--el-bg-color);
border-top: 1px solid var(--el-border-color-light);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
}
/* 滚动条样式 */
.rule-scene-form::-webkit-scrollbar {
width: 6px;
}
.rule-scene-form::-webkit-scrollbar-track {
background: var(--el-fill-color-light);
border-radius: 3px;
}
.rule-scene-form::-webkit-scrollbar-thumb {
background: var(--el-border-color);
border-radius: 3px;
}
.rule-scene-form::-webkit-scrollbar-thumb:hover {
background: var(--el-border-color-dark);
}
/* 抽屉内容区域优化 */
:deep(.el-drawer__body) {
padding: 0;
position: relative;
}
:deep(.el-drawer__header) {
padding: 20px 20px 16px 20px;
border-bottom: 1px solid var(--el-border-color-light);
margin-bottom: 0;
}
:deep(.el-drawer__title) {
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
/* 响应式设计 */
@media (max-width: 768px) {
.rule-scene-drawer {
--el-drawer-size: 100% !important;
}
.rule-scene-form {
padding: 16px;
padding-bottom: 80px;
}
.form-container {
gap: 20px;
}
.drawer-footer {
padding: 12px 16px;
gap: 12px;
}
}
</style>

View File

@ -1,81 +0,0 @@
<template>
<Dialog v-model="dialogVisible" :title="dialogTitle" :appendToBody="true" v-loading="loading">
<div class="flex h-600px">
<!-- 左侧物模型属性view 模式 -->
<div class="w-1/2 border-r border-gray-200 pr-2 overflow-auto">
<JsonEditor :model-value="thingModel" mode="view" height="600px" />
</div>
<!-- 右侧 JSON 编辑器code 模式 -->
<div class="w-1/2 pl-2 overflow-auto">
<JsonEditor v-model="editableModelTSL" mode="code" height="600px" @error="handleError" />
</div>
</div>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave" :disabled="hasJsonError">保存</el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { isEmpty } from '@/utils/is'
defineOptions({ name: 'ThingModelDualView' })
const props = defineProps<{
modelValue: any //
thingModel: any[] //
}>()
const emits = defineEmits(['update:modelValue', 'change'])
const message = useMessage()
const dialogVisible = ref(false) //
const dialogTitle = ref('物模型编辑器') //
const editableModelTSL = ref([
{
identifier: '对应左侧 identifier 属性值',
value: '如果 identifier 是 int 类型则输入数字,具体查看产品物模型定义'
}
]) //
const hasJsonError = ref(false) // JSON
const loading = ref(false) //
/** 打开弹窗 */
const open = () => {
try {
//
if (props.modelValue) {
editableModelTSL.value = JSON.parse(props.modelValue)
}
} catch (e) {
message.error('物模型编辑器参数')
console.error(e)
} finally {
dialogVisible.value = true
//
hasJsonError.value = false
}
}
defineExpose({ open }) //
/** 保存修改 */
const handleSave = async () => {
try {
await message.confirm('确定要保存物模型参数吗?')
emits('update:modelValue', JSON.stringify(editableModelTSL.value))
message.success('保存成功')
dialogVisible.value = false
} catch {}
}
/** 处理 JSON 编辑器错误的函数 */
const handleError = (errors: any) => {
if (isEmpty(errors)) {
hasJsonError.value = false
return
}
hasJsonError.value = true
}
</script>

View File

@ -1,142 +0,0 @@
<template>
<div class="flex items-center">
<!-- 数值类型输入框 -->
<template v-if="isNumeric">
<el-input
v-model="value"
class="w-1/1!"
:placeholder="`请输入${dataSpecs.unitName ? dataSpecs.unitName : '数值'}`"
>
<template #append> {{ dataSpecs.unit }} </template>
</el-input>
</template>
<!-- 布尔类型使用开关 -->
<template v-else-if="isBool">
<el-switch
v-model="value"
size="large"
:active-text="dataSpecsList[1].name"
:active-value="dataSpecsList[1].value"
:inactive-text="dataSpecsList[0].name"
:inactive-value="dataSpecsList[0].value"
/>
</template>
<!-- 枚举类型使用下拉选择 -->
<template v-else-if="isEnum">
<el-select class="w-1/1!" v-model="value">
<el-option
v-for="(item, index) in dataSpecsList"
:key="index"
:label="item.name"
:value="item.value"
/>
</el-select>
</template>
<!-- 时间类型使用时间选择器 -->
<template v-else-if="isDate">
<el-date-picker
class="w-1/1!"
v-model="value"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择日期时间"
/>
</template>
<!-- 文本类型使用文本输入框 -->
<template v-else-if="isText">
<el-input
class="w-1/1!"
v-model="value"
:maxlength="dataSpecs?.length"
:show-word-limit="true"
placeholder="请输入文本"
/>
</template>
<!-- arraystruct 直接输入 -->
<template v-else>
<el-input class="w-1/1!" :model-value="value" disabled placeholder="请输入值">
<template #append>
<el-button type="primary" @click="openJsonEditor"></el-button>
</template>
</el-input>
<!-- arraystruct 类型数据编辑 -->
<ThingModelDualView
ref="thingModelDualViewRef"
v-model="value"
:thing-model="dataSpecsList"
/>
</template>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { useVModel } from '@vueuse/core'
import ThingModelDualView from './ThingModelDualView.vue'
import { IoTDataSpecsDataTypeEnum } from '@/views/iot/utils/constants'
/** 物模型属性参数输入组件 */
defineOptions({ name: 'ThingModelParamInput' })
const props = defineProps<{
modelValue: any //
thingModel: any //
}>()
const emits = defineEmits(['update:modelValue', 'change'])
const value = useVModel(props, 'modelValue', emits)
const thingModelDualViewRef = ref<InstanceType<typeof ThingModelDualView>>()
const openJsonEditor = () => {
thingModelDualViewRef.value?.open()
}
/** 计算属性:判断数据类型 */
const isNumeric = computed(() =>
[
IoTDataSpecsDataTypeEnum.INT,
IoTDataSpecsDataTypeEnum.FLOAT,
IoTDataSpecsDataTypeEnum.DOUBLE
].includes(props.thingModel?.dataType as any)
)
const isBool = computed(() => props.thingModel?.dataType === IoTDataSpecsDataTypeEnum.BOOL)
const isEnum = computed(() => props.thingModel?.dataType === IoTDataSpecsDataTypeEnum.ENUM)
const isDate = computed(() => props.thingModel?.dataType === IoTDataSpecsDataTypeEnum.DATE)
const isText = computed(() => props.thingModel?.dataType === IoTDataSpecsDataTypeEnum.TEXT)
/** 获取数据规格 */
const dataSpecs = computed(() => {
if (isNumeric.value || isDate.value || isText.value) {
return props.thingModel?.dataSpecs || {}
}
return {}
})
const dataSpecsList = computed(() => {
if (
isBool.value ||
isEnum.value ||
[IoTDataSpecsDataTypeEnum.ARRAY, IoTDataSpecsDataTypeEnum.STRUCT].includes(
props.thingModel?.dataType
)
) {
return props.thingModel?.dataSpecsList || []
}
return []
})
/** 物模型切换重置值 */
watch(
() => props.thingModel?.dataType,
(_, oldValue) => {
if (!oldValue) {
return
}
value.value = undefined
},
{ deep: true }
)
</script>

View File

@ -1,307 +0,0 @@
<template>
<div>
<div class="m-10px">
<!-- 产品设备回显区域 -->
<div class="relative bg-[#eff3f7] h-50px flex items-center px-10px">
<div class="flex items-center mr-60px">
<span class="mr-10px">执行动作</span>
<el-select
v-model="actionConfig.type"
class="!w-240px"
clearable
placeholder="请选择执行类型"
>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_RULE_SCENE_ACTION_TYPE_ENUM)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</div>
<div v-if="isDeviceAction" class="flex items-center mr-60px">
<span class="mr-10px">产品</span>
<el-button type="primary" @click="handleSelectProduct" size="small" plain>
{{ product ? product.name : '选择产品' }}
</el-button>
</div>
<!-- TODO @puhui999单选设备 -->
<div v-if="isDeviceAction" class="flex items-center mr-60px">
<span class="mr-10px">设备</span>
<el-button type="primary" @click="handleSelectDevice" size="small" plain>
{{ isEmpty(deviceList) ? '选择设备' : deviceList.map((d) => d.deviceName).join(',') }}
</el-button>
</div>
<!-- 删除执行器 -->
<div class="absolute top-auto right-16px bottom-auto">
<el-tooltip content="删除执行器" placement="top">
<slot></slot>
</el-tooltip>
</div>
</div>
<!-- 设备控制执行器 -->
<!-- TODO @puhui999服务调用时选择好某个物模型剩余直接填写一个 JSON 不用逐个添加~可以试试设备详情-设备调试有服务调用的模拟 -->
<DeviceControlAction
v-if="isDeviceAction"
:action-type="actionConfig.type"
:model-value="actionConfig.deviceControl"
:product-id="product?.id"
:product-key="product?.productKey"
@update:model-value="(val) => (actionConfig.deviceControl = val)"
/>
<!-- 告警执行器 -->
<div v-else-if="isAlertAction">
<!-- 告警触发 - 无需额外配置 -->
<div
v-if="actionConfig.type === IotRuleSceneActionTypeEnum.ALERT_TRIGGER"
class="bg-[#dbe5f6] flex items-center justify-center p-10px"
>
<el-icon class="mr-5px text-blue-500"><Icon icon="ep:info-filled" /></el-icon>
<span class="text-gray-600">触发告警通知系统将自动发送告警信息</span>
</div>
<!-- 告警恢复 - 需要选择告警配置 -->
<div v-else-if="actionConfig.type === IotRuleSceneActionTypeEnum.ALERT_RECOVER">
<div class="bg-[#dbe5f6] flex items-center justify-center p-10px mb-10px">
<el-icon class="mr-5px text-blue-500"><Icon icon="ep:info-filled" /></el-icon>
<!-- TODO @puhui999这种类型的提示感觉放在 SELECT 后面有个所有的提示会不会更好呀因为可以少占用行呢 -->
<span class="text-gray-600">恢复指定的告警配置状态</span>
</div>
<div class="p-10px">
<el-form-item label="选择告警配置" required label-width="110">
<el-select
v-model="actionConfig.alertConfigId"
class="!w-240px"
clearable
placeholder="请选择要恢复的告警配置"
:loading="alertConfigLoading"
>
<el-option
v-for="config in alertConfigList"
:key="config.id"
:label="config.name"
:value="config.id"
/>
</el-select>
</el-form-item>
</div>
</div>
</div>
</div>
<!-- 产品设备的选择 -->
<ProductTableSelect ref="productTableSelectRef" @success="handleProductSelect" />
<DeviceTableSelect
ref="deviceTableSelectRef"
multiple
:product-id="product?.id"
@success="handleDeviceSelect"
/>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import { isEmpty } from '@/utils/is'
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import ProductTableSelect from '@/views/iot/product/product/components/ProductTableSelect.vue'
import DeviceTableSelect from '@/views/iot/device/device/components/DeviceTableSelect.vue'
import DeviceControlAction from './DeviceControlAction.vue'
import { ProductApi, ProductVO } from '@/api/iot/product/product'
import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
import { AlertConfigApi, AlertConfig } from '@/api/iot/alert/config'
import {
ActionConfig,
ActionDeviceControl,
IotDeviceMessageIdentifierEnum,
IotDeviceMessageTypeEnum,
IotRuleSceneActionTypeEnum
} from '@/api/iot/rule/scene/scene.types'
/** 场景联动之执行器组件 */
defineOptions({ name: 'ActionExecutor' })
const props = defineProps<{ modelValue: any }>()
const emits = defineEmits(['update:modelValue'])
const actionConfig = useVModel(props, 'modelValue', emits) as Ref<ActionConfig>
const message = useMessage()
/** 计算属性:判断是否为设备相关执行类型 */
const isDeviceAction = computed(() => {
return [
IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET,
IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE
].includes(actionConfig.value.type as any)
})
/** 计算属性:判断是否为告警相关执行类型 */
const isAlertAction = computed(() => {
return [
IotRuleSceneActionTypeEnum.ALERT_TRIGGER,
IotRuleSceneActionTypeEnum.ALERT_RECOVER
].includes(actionConfig.value.type as any)
})
/** 初始化执行器结构 */
const initActionConfig = () => {
if (!actionConfig.value) {
actionConfig.value = { type: IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET } as ActionConfig
}
//
if (isDeviceAction.value && !actionConfig.value.deviceControl) {
actionConfig.value.deviceControl = {
productKey: '',
deviceNames: [],
type:
actionConfig.value.type === IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET
? IotDeviceMessageTypeEnum.PROPERTY
: IotDeviceMessageTypeEnum.SERVICE,
identifier:
actionConfig.value.type === IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET
? IotDeviceMessageIdentifierEnum.PROPERTY_SET
: IotDeviceMessageIdentifierEnum.SERVICE_INVOKE,
data: {}
} as ActionDeviceControl
}
//
if (isAlertAction.value) {
if (actionConfig.value.type === IotRuleSceneActionTypeEnum.ALERT_TRIGGER) {
// -
actionConfig.value.alertConfigId = undefined
} else if (actionConfig.value.type === IotRuleSceneActionTypeEnum.ALERT_RECOVER) {
// -
if (!actionConfig.value.alertConfigId) {
actionConfig.value.alertConfigId = undefined
}
}
}
}
/** 产品和设备选择 */
const productTableSelectRef = ref<InstanceType<typeof ProductTableSelect>>()
const deviceTableSelectRef = ref<InstanceType<typeof DeviceTableSelect>>()
const product = ref<ProductVO>()
const deviceList = ref<DeviceVO[]>([])
/** 告警配置相关 */
const alertConfigList = ref<AlertConfig[]>([])
const alertConfigLoading = ref(false)
/** 处理选择产品 */
const handleSelectProduct = () => {
productTableSelectRef.value?.open()
}
/** 处理选择设备 */
const handleSelectDevice = () => {
if (!product.value) {
message.warning('请先选择一个产品')
return
}
deviceTableSelectRef.value?.open()
}
/** 处理产品选择成功 */
const handleProductSelect = (val: ProductVO) => {
product.value = val
if (actionConfig.value.deviceControl) {
actionConfig.value.deviceControl.productKey = val.productKey
}
//
deviceList.value = []
if (actionConfig.value.deviceControl) {
actionConfig.value.deviceControl.deviceNames = []
}
}
/** 处理设备选择成功 */
const handleDeviceSelect = (val: DeviceVO[]) => {
deviceList.value = val
if (actionConfig.value.deviceControl) {
actionConfig.value.deviceControl.deviceNames = val.map((item) => item.deviceName)
}
}
/** 获取告警配置列表 */
const getAlertConfigList = async () => {
try {
alertConfigLoading.value = true
alertConfigList.value = await AlertConfigApi.getSimpleAlertConfigList()
} catch (error) {
console.error('获取告警配置列表失败:', error)
} finally {
alertConfigLoading.value = false
}
}
/** 监听执行类型变化,初始化对应配置 */
watch(
() => actionConfig.value.type,
(newType) => {
initActionConfig()
//
if (newType === IotRuleSceneActionTypeEnum.ALERT_RECOVER) {
getAlertConfigList()
}
},
{ immediate: true }
)
/** 初始化产品回显信息 */
const initProductInfo = async () => {
if (!actionConfig.value.deviceControl?.productKey) {
return
}
try {
const productData = await ProductApi.getProductByKey(
actionConfig.value.deviceControl.productKey
)
if (productData) {
product.value = productData
}
} catch (error) {
console.error('获取产品信息失败:', error)
}
}
/**
* 初始化设备回显信息
*/
const initDeviceInfo = async () => {
if (
!actionConfig.value.deviceControl?.productKey ||
!actionConfig.value.deviceControl?.deviceNames?.length
) {
return
}
try {
const deviceData = await DeviceApi.getDevicesByProductKeyAndNames(
actionConfig.value.deviceControl.productKey,
actionConfig.value.deviceControl.deviceNames
)
if (deviceData && deviceData.length > 0) {
deviceList.value = deviceData
}
} catch (error) {
console.error('获取设备信息失败:', error)
}
}
/** 初始化 */
onMounted(async () => {
initActionConfig()
//
if (actionConfig.value.deviceControl) {
await initProductInfo()
await initDeviceInfo()
}
})
</script>

View File

@ -1,248 +0,0 @@
<template>
<div class="bg-[#dbe5f6] flex p-10px">
<div class="">
<div
class="flex items-center justify-around mb-10px last:mb-0"
v-for="(parameter, index) in parameters"
:key="index"
>
<!-- 选择服务 -->
<el-select
v-if="IotDeviceMessageTypeEnum.SERVICE === deviceControlConfig.type"
v-model="parameter.identifier0"
class="!w-240px mr-10px"
clearable
placeholder="请选择服务"
>
<el-option
v-for="thingModel in getThingModelTSLServices"
:key="thingModel.identifier"
:label="thingModel.name"
:value="thingModel.identifier"
/>
</el-select>
<el-select
v-model="parameter.identifier"
class="!w-240px mr-10px"
clearable
placeholder="请选择物模型"
>
<el-option
v-for="thingModel in thingModels(parameter?.identifier0)"
:key="thingModel.identifier"
:label="thingModel.name"
:value="thingModel.identifier"
/>
</el-select>
<ThingModelParamInput
class="!w-240px mr-10px"
v-model="parameter.value"
:thing-model="
thingModels(parameter?.identifier0)?.find(
(item) => item.identifier === parameter.identifier
)
"
/>
<el-tooltip content="删除参数" placement="top">
<el-button type="danger" circle size="small" @click="removeParameter(index)">
<Icon icon="ep:delete" />
</el-button>
</el-tooltip>
</div>
</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 size="small" @click="addParameter">
<Icon icon="ep:plus" />
</el-button>
</el-tooltip>
</div>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import { isEmpty } from '@/utils/is'
import { ThingModelApi } from '@/api/iot/thingmodel'
import {
ActionDeviceControl,
IotDeviceMessageIdentifierEnum,
IotDeviceMessageTypeEnum,
IotRuleSceneActionTypeEnum
} from '@/api/iot/rule/scene/scene.types'
import ThingModelParamInput from '../ThingModelParamInput.vue'
/** 设备控制执行器组件 */
defineOptions({ name: 'DeviceControlAction' })
const props = defineProps<{
modelValue: any
actionType: number
productId?: number
productKey?: string
}>()
const emits = defineEmits(['update:modelValue'])
const deviceControlConfig = useVModel(props, 'modelValue', emits) as Ref<ActionDeviceControl>
const message = useMessage()
/** 执行器参数 */
const parameters = ref<{ identifier: string; value: any; identifier0?: string }[]>([])
const addParameter = () => {
if (!props.productId) {
message.warning('请先选择一个产品')
return
}
parameters.value.push({ identifier: '', value: undefined })
}
const removeParameter = (index: number) => {
parameters.value.splice(index, 1)
}
watch(
() => parameters.value,
(newVal) => {
if (isEmpty(newVal)) {
return
}
for (const parameter of newVal) {
if (isEmpty(parameter.identifier)) {
break
}
//
if (IotDeviceMessageTypeEnum.SERVICE === deviceControlConfig.value.type) {
if (!parameter.identifier0) {
continue
}
deviceControlConfig.value.data[parameter.identifier0] = {
identifier: parameter.identifier,
value: parameter.value
}
continue
}
deviceControlConfig.value.data[parameter.identifier] = parameter.value
}
},
{ deep: true }
)
/** 初始化设备控制执行器结构 */
const initDeviceControlConfig = () => {
if (!deviceControlConfig.value) {
deviceControlConfig.value = {
productKey: '',
deviceNames: [],
type:
props.actionType === IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET
? IotDeviceMessageTypeEnum.PROPERTY
: IotDeviceMessageTypeEnum.SERVICE,
identifier:
props.actionType === IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET
? IotDeviceMessageIdentifierEnum.PROPERTY_SET
: IotDeviceMessageIdentifierEnum.SERVICE_INVOKE,
data: {}
} as ActionDeviceControl
} else {
//
if (IotDeviceMessageTypeEnum.SERVICE === deviceControlConfig.value.type) {
//
parameters.value = Object.entries(deviceControlConfig.value.data).map(([key, value]) => ({
identifier0: key,
identifier: value.identifier,
value: value.value
}))
return
}
//
parameters.value = Object.entries(deviceControlConfig.value.data).map(([key, value]) => ({
identifier: key,
value: value
}))
}
// data
if (!deviceControlConfig.value.data) {
deviceControlConfig.value.data = {}
}
}
/** 获取产品物模型 */
const thingModelTSL = ref<any>()
const getThingModelTSL = async () => {
if (!props.productId) {
return
}
thingModelTSL.value = await ThingModelApi.getThingModelTSLByProductId(props.productId)
}
const thingModels = computed(() => (identifier?: string): any[] => {
if (isEmpty(thingModelTSL.value)) {
return []
}
switch (deviceControlConfig.value.type) {
case IotDeviceMessageTypeEnum.PROPERTY:
return thingModelTSL.value?.properties || []
case IotDeviceMessageTypeEnum.SERVICE:
const service = thingModelTSL.value.services?.find(
(item: any) => item.identifier === identifier
)
return service?.inputParams || []
}
return []
})
/** 获取物模型服务 */
const getThingModelTSLServices = computed(() => thingModelTSL.value?.services || [])
/** 监听 productId 变化 */
watch(
() => props.productId,
() => {
getThingModelTSL()
if (deviceControlConfig.value && deviceControlConfig.value.productKey === props.productKey) {
return
}
// ID
deviceControlConfig.value.data = {}
parameters.value = []
}
)
/** 监听执行类型变化 */
watch(
() => props.actionType,
(val: any) => {
if (!val) {
return
}
//
deviceControlConfig.value.data = {}
parameters.value = []
if (val === IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET) {
deviceControlConfig.value.type = IotDeviceMessageTypeEnum.PROPERTY
deviceControlConfig.value.identifier = IotDeviceMessageIdentifierEnum.PROPERTY_SET
} else if (val === IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE) {
deviceControlConfig.value.type = IotDeviceMessageTypeEnum.SERVICE
deviceControlConfig.value.identifier = IotDeviceMessageIdentifierEnum.SERVICE_INVOKE
}
}
)
/** 监听消息类型变化 */
watch(
() => deviceControlConfig.value.type,
() => {
//
deviceControlConfig.value.data = {}
parameters.value = []
if (deviceControlConfig.value.type === IotDeviceMessageTypeEnum.PROPERTY) {
deviceControlConfig.value.identifier = IotDeviceMessageIdentifierEnum.PROPERTY_SET
} else if (deviceControlConfig.value.type === IotDeviceMessageTypeEnum.SERVICE) {
deviceControlConfig.value.identifier = IotDeviceMessageIdentifierEnum.SERVICE_INVOKE
}
}
)
//
onMounted(() => {
initDeviceControlConfig()
})
</script>

View File

@ -0,0 +1,288 @@
<!-- 告警配置组件 -->
<template>
<div class="alert-config">
<el-form-item label="告警配置" required>
<el-select
v-model="localValue"
placeholder="请选择告警配置"
filterable
clearable
@change="handleChange"
class="w-full"
:loading="loading"
>
<el-option
v-for="config in alertConfigs"
:key="config.id"
:label="config.name"
:value="config.id"
>
<div class="alert-option">
<div class="option-content">
<div class="option-name">{{ config.name }}</div>
<div class="option-desc">{{ config.description }}</div>
</div>
<el-tag :type="config.enabled ? 'success' : 'danger'" size="small">
{{ config.enabled ? '启用' : '禁用' }}
</el-tag>
</div>
</el-option>
</el-select>
</el-form-item>
<!-- 告警配置详情 -->
<div v-if="selectedConfig" class="alert-details">
<div class="details-header">
<Icon icon="ep:bell" class="details-icon" />
<span class="details-title">{{ selectedConfig.name }}</span>
<el-tag :type="selectedConfig.enabled ? 'success' : 'danger'" size="small">
{{ selectedConfig.enabled ? '启用' : '禁用' }}
</el-tag>
</div>
<div class="details-content">
<div class="detail-item">
<span class="detail-label">描述</span>
<span class="detail-value">{{ selectedConfig.description }}</span>
</div>
<div class="detail-item">
<span class="detail-label">通知方式</span>
<span class="detail-value">{{ getNotifyTypeName(selectedConfig.notifyType) }}</span>
</div>
<div v-if="selectedConfig.receivers" class="detail-item">
<span class="detail-label">接收人</span>
<span class="detail-value">{{ selectedConfig.receivers.join(', ') }}</span>
</div>
</div>
</div>
<!-- 验证结果 -->
<div v-if="validationMessage" class="validation-result">
<el-alert
:title="validationMessage"
:type="isValid ? 'success' : 'error'"
:closable="false"
show-icon
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
/** 告警配置组件 */
defineOptions({ name: 'AlertConfig' })
interface Props {
modelValue?: number
}
interface Emits {
(e: 'update:modelValue', value?: number): void
(e: 'validate', result: { valid: boolean; message: string }): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const localValue = useVModel(props, 'modelValue', emit)
//
const loading = ref(false)
const alertConfigs = ref<any[]>([])
const validationMessage = ref('')
const isValid = ref(true)
//
const selectedConfig = computed(() => {
return alertConfigs.value.find(config => config.id === localValue.value)
})
//
const getNotifyTypeName = (type: number) => {
const typeMap = {
1: '邮件通知',
2: '短信通知',
3: '微信通知',
4: '钉钉通知'
}
return typeMap[type] || '未知'
}
//
const handleChange = () => {
updateValidationResult()
}
const updateValidationResult = () => {
if (!localValue.value) {
isValid.value = false
validationMessage.value = '请选择告警配置'
emit('validate', { valid: false, message: validationMessage.value })
return
}
const config = selectedConfig.value
if (!config) {
isValid.value = false
validationMessage.value = '告警配置不存在'
emit('validate', { valid: false, message: validationMessage.value })
return
}
if (!config.enabled) {
isValid.value = false
validationMessage.value = '选择的告警配置已禁用'
emit('validate', { valid: false, message: validationMessage.value })
return
}
//
isValid.value = true
validationMessage.value = '告警配置验证通过'
emit('validate', { valid: true, message: validationMessage.value })
}
// API
const getAlertConfigs = async () => {
loading.value = true
try {
// API
// 使
alertConfigs.value = [
{
id: 1,
name: '设备异常告警',
description: '设备状态异常时发送告警',
enabled: true,
notifyType: 1,
receivers: ['admin@example.com', 'operator@example.com']
},
{
id: 2,
name: '温度超限告警',
description: '温度超过阈值时发送告警',
enabled: true,
notifyType: 2,
receivers: ['13800138000', '13900139000']
},
{
id: 3,
name: '系统故障告警',
description: '系统发生故障时发送告警',
enabled: false,
notifyType: 3,
receivers: ['技术支持群']
}
]
} catch (error) {
console.error('获取告警配置失败:', error)
} finally {
loading.value = false
}
}
//
watch(() => localValue.value, () => {
updateValidationResult()
})
//
onMounted(() => {
getAlertConfigs()
if (localValue.value) {
updateValidationResult()
}
})
</script>
<style scoped>
.alert-config {
width: 100%;
}
.alert-option {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 4px 0;
}
.option-content {
flex: 1;
}
.option-name {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 2px;
}
.option-desc {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.alert-details {
margin-top: 12px;
padding: 12px;
background: var(--el-fill-color-light);
border-radius: 6px;
border: 1px solid var(--el-border-color-lighter);
}
.details-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.details-icon {
color: var(--el-color-warning);
font-size: 14px;
}
.details-title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.details-content {
display: flex;
flex-direction: column;
gap: 4px;
margin-left: 22px;
}
.detail-item {
display: flex;
align-items: flex-start;
gap: 8px;
}
.detail-label {
font-size: 12px;
color: var(--el-text-color-secondary);
min-width: 60px;
flex-shrink: 0;
}
.detail-value {
font-size: 12px;
color: var(--el-text-color-primary);
flex: 1;
}
.validation-result {
margin-top: 8px;
}
:deep(.el-select-dropdown__item) {
height: auto;
padding: 8px 20px;
}
</style>

View File

@ -0,0 +1,260 @@
<!-- 单个条件配置组件 -->
<template>
<div class="condition-config">
<el-row :gutter="16">
<!-- 属性/事件/服务选择 -->
<el-col :span="8">
<el-form-item label="监控项" required>
<PropertySelector
:model-value="condition.identifier"
@update:model-value="(value) => updateConditionField('identifier', value)"
:trigger-type="triggerType"
:product-id="productId"
:device-id="deviceId"
@change="handlePropertyChange"
/>
</el-form-item>
</el-col>
<!-- 操作符选择 -->
<el-col :span="6">
<el-form-item label="操作符" required>
<OperatorSelector
:model-value="condition.operator"
@update:model-value="(value) => updateConditionField('operator', value)"
:property-type="propertyType"
@change="handleOperatorChange"
/>
</el-form-item>
</el-col>
<!-- 值输入 -->
<el-col :span="10">
<el-form-item label="比较值" required>
<ValueInput
:model-value="condition.param"
@update:model-value="(value) => updateConditionField('param', value)"
:property-type="propertyType"
:operator="condition.operator"
:property-config="propertyConfig"
@validate="handleValueValidate"
/>
</el-form-item>
</el-col>
</el-row>
<!-- 条件预览 -->
<div v-if="conditionPreview" class="condition-preview">
<div class="preview-header">
<Icon icon="ep:view" class="preview-icon" />
<span class="preview-title">条件预览</span>
</div>
<div class="preview-content">
<code class="preview-text">{{ conditionPreview }}</code>
</div>
</div>
<!-- 验证结果 -->
<div v-if="validationMessage" class="validation-result">
<el-alert
:title="validationMessage"
:type="isValid ? 'success' : 'error'"
:closable="false"
show-icon
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import PropertySelector from '../selectors/PropertySelector.vue'
import OperatorSelector from '../selectors/OperatorSelector.vue'
import ValueInput from '../inputs/ValueInput.vue'
import { ConditionFormData } from '@/api/iot/rule/scene/scene.types'
/** 单个条件配置组件 */
defineOptions({ name: 'ConditionConfig' })
interface Props {
modelValue: ConditionFormData
triggerType: number
productId?: number
deviceId?: number
}
interface Emits {
(e: 'update:modelValue', value: ConditionFormData): void
(e: 'validate', result: { valid: boolean; message: string }): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const condition = useVModel(props, 'modelValue', emit)
//
const propertyType = ref<string>('string')
const propertyConfig = ref<any>(null)
const validationMessage = ref('')
const isValid = ref(true)
const valueValidation = ref({ valid: true, message: '' })
//
const conditionPreview = computed(() => {
if (!condition.value.identifier || !condition.value.operator || !condition.value.param) {
return ''
}
const propertyName = propertyConfig.value?.name || condition.value.identifier
const operatorText = getOperatorText(condition.value.operator)
const value = condition.value.param
return `${propertyName} ${operatorText} ${value} 时触发`
})
//
const getOperatorText = (operator: string) => {
const operatorMap = {
'=': '等于',
'!=': '不等于',
'>': '大于',
'>=': '大于等于',
'<': '小于',
'<=': '小于等于',
'in': '包含于',
'between': '介于'
}
return operatorMap[operator] || operator
}
//
const updateConditionField = (field: keyof ConditionFormData, value: any) => {
condition.value[field] = value
emit('update:modelValue', condition.value)
}
const handlePropertyChange = (propertyInfo: { type: string; config: any }) => {
propertyType.value = propertyInfo.type
propertyConfig.value = propertyInfo.config
//
condition.value.operator = '='
condition.value.param = ''
updateValidationResult()
}
const handleOperatorChange = () => {
//
condition.value.param = ''
updateValidationResult()
}
const handleValueValidate = (result: { valid: boolean; message: string }) => {
valueValidation.value = result
updateValidationResult()
}
const updateValidationResult = () => {
//
if (!condition.value.identifier) {
isValid.value = false
validationMessage.value = '请选择监控项'
emit('validate', { valid: false, message: validationMessage.value })
return
}
if (!condition.value.operator) {
isValid.value = false
validationMessage.value = '请选择操作符'
emit('validate', { valid: false, message: validationMessage.value })
return
}
if (!condition.value.param) {
isValid.value = false
validationMessage.value = '请输入比较值'
emit('validate', { valid: false, message: validationMessage.value })
return
}
//
if (!valueValidation.value.valid) {
isValid.value = false
validationMessage.value = valueValidation.value.message
emit('validate', { valid: false, message: validationMessage.value })
return
}
//
isValid.value = true
validationMessage.value = '条件配置验证通过'
emit('validate', { valid: true, message: validationMessage.value })
}
//
watch(() => [condition.value.identifier, condition.value.operator, condition.value.param], () => {
updateValidationResult()
}, { deep: true })
//
onMounted(() => {
updateValidationResult()
})
</script>
<style scoped>
.condition-config {
display: flex;
flex-direction: column;
gap: 16px;
}
.condition-preview {
padding: 12px;
background: var(--el-fill-color-light);
border-radius: 6px;
border: 1px solid var(--el-border-color-lighter);
}
.preview-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.preview-icon {
color: var(--el-color-primary);
font-size: 14px;
}
.preview-title {
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.preview-content {
margin-left: 20px;
}
.preview-text {
font-size: 14px;
color: var(--el-text-color-primary);
background: var(--el-fill-color-blank);
padding: 8px 12px;
border-radius: 4px;
display: block;
font-family: inherit;
}
.validation-result {
margin-top: 8px;
}
:deep(.el-form-item) {
margin-bottom: 0;
}
</style>

View File

@ -0,0 +1,331 @@
<!-- 条件组配置组件 -->
<template>
<div class="condition-group-config">
<div class="group-content">
<!-- 条件列表 -->
<div v-if="group.conditions && group.conditions.length > 0" class="conditions-list">
<div
v-for="(condition, index) in group.conditions"
:key="`condition-${index}`"
class="condition-item"
>
<div class="condition-header">
<div class="condition-title">
<span>条件 {{ index + 1 }}</span>
<el-tag size="small" type="primary">
{{ getConditionTypeName(condition.type) }}
</el-tag>
</div>
<el-button
type="danger"
size="small"
text
@click="removeCondition(index)"
v-if="group.conditions!.length > 1"
>
<Icon icon="ep:delete" />
删除
</el-button>
</div>
<div class="condition-content">
<ConditionConfig
:model-value="condition"
@update:model-value="(value) => updateCondition(index, value)"
:trigger-type="triggerType"
:product-id="productId"
:device-id="deviceId"
@validate="(result) => handleConditionValidate(index, result)"
/>
</div>
<!-- 逻辑连接符 -->
<div
v-if="index < group.conditions!.length - 1"
class="logic-connector"
>
<el-select
v-model="group.logicOperator"
size="small"
style="width: 80px;"
>
<el-option label="且" value="AND" />
<el-option label="或" value="OR" />
</el-select>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-else class="empty-conditions">
<el-empty description="暂无条件配置" :image-size="80">
<el-button type="primary" @click="addCondition">
<Icon icon="ep:plus" />
添加第一个条件
</el-button>
</el-empty>
</div>
<!-- 添加条件按钮 -->
<div v-if="group.conditions && group.conditions.length > 0 && group.conditions.length < maxConditions" class="add-condition">
<el-button
type="primary"
plain
@click="addCondition"
class="add-condition-btn"
>
<Icon icon="ep:plus" />
继续添加条件
</el-button>
<span class="add-condition-text">
最多可添加 {{ maxConditions }} 个条件
</span>
</div>
</div>
<!-- 验证结果 -->
<div v-if="validationMessage" class="validation-result">
<el-alert
:title="validationMessage"
:type="isValid ? 'success' : 'error'"
:closable="false"
show-icon
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import ConditionConfig from './ConditionConfig.vue'
import {
ConditionGroupFormData,
ConditionFormData,
IotRuleSceneTriggerTypeEnum
} from '@/api/iot/rule/scene/scene.types'
/** 条件组配置组件 */
defineOptions({ name: 'ConditionGroupConfig' })
interface Props {
modelValue: ConditionGroupFormData
triggerType: number
productId?: number
deviceId?: number
}
interface Emits {
(e: 'update:modelValue', value: ConditionGroupFormData): void
(e: 'validate', result: { valid: boolean; message: string }): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const group = useVModel(props, 'modelValue', emit)
//
const maxConditions = 5
//
const conditionValidations = ref<{ [key: number]: { valid: boolean; message: string } }>({})
const validationMessage = ref('')
const isValid = ref(true)
//
const conditionTypeNames = {
[IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST]: '属性条件',
[IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST]: '事件条件',
[IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE]: '服务条件'
}
//
const getConditionTypeName = (type: number) => {
return conditionTypeNames[type] || '未知条件'
}
//
const updateCondition = (index: number, condition: ConditionFormData) => {
if (group.value.conditions) {
group.value.conditions[index] = condition
}
}
const addCondition = () => {
if (!group.value.conditions) {
group.value.conditions = []
}
if (group.value.conditions.length >= maxConditions) {
return
}
const newCondition: ConditionFormData = {
type: props.triggerType,
productId: props.productId || 0,
deviceId: props.deviceId || 0,
identifier: '',
operator: '=',
param: ''
}
group.value.conditions.push(newCondition)
}
const removeCondition = (index: number) => {
if (group.value.conditions) {
group.value.conditions.splice(index, 1)
delete conditionValidations.value[index]
//
const newValidations: { [key: number]: { valid: boolean; message: string } } = {}
Object.keys(conditionValidations.value).forEach(key => {
const numKey = parseInt(key)
if (numKey > index) {
newValidations[numKey - 1] = conditionValidations.value[numKey]
} else if (numKey < index) {
newValidations[numKey] = conditionValidations.value[numKey]
}
})
conditionValidations.value = newValidations
updateValidationResult()
}
}
const handleConditionValidate = (index: number, result: { valid: boolean; message: string }) => {
conditionValidations.value[index] = result
updateValidationResult()
}
const updateValidationResult = () => {
if (!group.value.conditions || group.value.conditions.length === 0) {
isValid.value = false
validationMessage.value = '请至少添加一个条件'
emit('validate', { valid: false, message: validationMessage.value })
return
}
const validations = Object.values(conditionValidations.value)
const allValid = validations.every(v => v.valid)
if (allValid) {
isValid.value = true
validationMessage.value = '条件组配置验证通过'
} else {
isValid.value = false
const errorMessages = validations
.filter(v => !v.valid)
.map(v => v.message)
validationMessage.value = `条件配置错误: ${errorMessages.join('; ')}`
}
emit('validate', { valid: isValid.value, message: validationMessage.value })
}
//
watch(() => group.value.conditions?.length, () => {
updateValidationResult()
})
//
onMounted(() => {
if (!group.value.conditions || group.value.conditions.length === 0) {
addCondition()
}
})
</script>
<style scoped>
.condition-group-config {
padding: 16px;
}
.group-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.conditions-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.condition-item {
position: relative;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
background: var(--el-fill-color-blank);
}
.condition-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--el-fill-color-light);
border-bottom: 1px solid var(--el-border-color-lighter);
}
.condition-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.condition-content {
padding: 16px;
}
.logic-connector {
display: flex;
justify-content: center;
align-items: center;
padding: 8px 0;
position: relative;
}
.logic-connector::before {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 1px;
height: 100%;
background: var(--el-border-color);
}
.empty-conditions {
padding: 40px 0;
text-align: center;
}
.add-condition {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: 1px dashed var(--el-border-color);
border-radius: 6px;
background: var(--el-fill-color-lighter);
}
.add-condition-btn {
flex-shrink: 0;
}
.add-condition-text {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.validation-result {
margin-top: 16px;
}
</style>

View File

@ -0,0 +1,173 @@
<!-- 设备控制配置组件 -->
<template>
<div class="device-control-config">
<!-- 产品和设备选择 -->
<ProductDeviceSelector
v-model:product-id="action.productId"
v-model:device-id="action.deviceId"
@change="handleDeviceChange"
/>
<!-- 控制参数配置 -->
<div v-if="action.productId && action.deviceId" class="control-params">
<el-form-item label="控制参数" required>
<el-input
v-model="paramsJson"
type="textarea"
:rows="4"
placeholder="请输入JSON格式的控制参数"
@input="handleParamsChange"
/>
</el-form-item>
<!-- 参数示例 -->
<div class="params-example">
<el-alert
title="参数格式示例"
type="info"
:closable="false"
show-icon
>
<template #default>
<div class="example-content">
<p>属性设置示例</p>
<pre><code>{ "temperature": 25, "power": true }</code></pre>
<p>服务调用示例</p>
<pre><code>{ "method": "restart", "params": { "delay": 5 } }</code></pre>
</div>
</template>
</el-alert>
</div>
</div>
<!-- 验证结果 -->
<div v-if="validationMessage" class="validation-result">
<el-alert
:title="validationMessage"
:type="isValid ? 'success' : 'error'"
:closable="false"
show-icon
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import ProductDeviceSelector from '../selectors/ProductDeviceSelector.vue'
import { ActionFormData } from '@/api/iot/rule/scene/scene.types'
/** 设备控制配置组件 */
defineOptions({ name: 'DeviceControlConfig' })
interface Props {
modelValue: ActionFormData
}
interface Emits {
(e: 'update:modelValue', value: ActionFormData): void
(e: 'validate', result: { valid: boolean; message: string }): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const action = useVModel(props, 'modelValue', emit)
//
const paramsJson = ref('')
const validationMessage = ref('')
const isValid = ref(true)
//
const handleDeviceChange = ({ productId, deviceId }: { productId?: number; deviceId?: number }) => {
action.value.productId = productId
action.value.deviceId = deviceId
updateValidationResult()
}
const handleParamsChange = () => {
try {
if (paramsJson.value.trim()) {
action.value.params = JSON.parse(paramsJson.value)
} else {
action.value.params = {}
}
updateValidationResult()
} catch (error) {
isValid.value = false
validationMessage.value = 'JSON格式错误'
emit('validate', { valid: false, message: validationMessage.value })
}
}
const updateValidationResult = () => {
//
if (!action.value.productId || !action.value.deviceId) {
isValid.value = false
validationMessage.value = '请选择产品和设备'
emit('validate', { valid: false, message: validationMessage.value })
return
}
if (!action.value.params || Object.keys(action.value.params).length === 0) {
isValid.value = false
validationMessage.value = '请配置控制参数'
emit('validate', { valid: false, message: validationMessage.value })
return
}
//
isValid.value = true
validationMessage.value = '设备控制配置验证通过'
emit('validate', { valid: true, message: validationMessage.value })
}
//
onMounted(() => {
if (action.value.params) {
paramsJson.value = JSON.stringify(action.value.params, null, 2)
}
updateValidationResult()
})
//
watch(() => action.value.params, (newParams) => {
if (newParams && typeof newParams === 'object') {
paramsJson.value = JSON.stringify(newParams, null, 2)
}
}, { deep: true })
</script>
<style scoped>
.device-control-config {
display: flex;
flex-direction: column;
gap: 16px;
}
.control-params {
margin-top: 16px;
}
.params-example {
margin-top: 8px;
}
.example-content pre {
margin: 4px 0;
padding: 8px;
background: var(--el-fill-color-light);
border-radius: 4px;
font-size: 12px;
}
.example-content code {
font-family: 'Courier New', monospace;
color: var(--el-color-primary);
}
.validation-result {
margin-top: 8px;
}
</style>

View File

@ -0,0 +1,347 @@
<!-- 设备触发配置组件 -->
<template>
<div class="device-trigger-config">
<!-- 产品和设备选择 -->
<ProductDeviceSelector
v-model:product-id="trigger.productId"
v-model:device-id="trigger.deviceId"
@change="handleDeviceChange"
/>
<!-- 设备状态变更提示 -->
<div
v-if="trigger.type === TriggerTypeEnum.DEVICE_STATE_UPDATE"
class="state-update-notice"
>
<el-alert
title="设备状态变更触发"
type="info"
:closable="false"
show-icon
>
<template #default>
<p>当选中的设备上线或离线时将自动触发场景规则</p>
<p class="notice-tip">无需配置额外的触发条件</p>
</template>
</el-alert>
</div>
<!-- 条件组配置 -->
<div
v-else-if="needsConditions"
class="condition-groups"
>
<div class="condition-groups-header">
<div class="header-left">
<span class="header-title">触发条件</span>
<el-tag size="small" type="info">
{{ trigger.conditionGroups?.length || 0 }}/{{ maxConditionGroups }}
</el-tag>
</div>
<div class="header-right">
<el-button
type="primary"
size="small"
@click="addConditionGroup"
:disabled="(trigger.conditionGroups?.length || 0) >= maxConditionGroups"
>
<Icon icon="ep:plus" />
添加条件组
</el-button>
</div>
</div>
<!-- 条件组列表 -->
<div v-if="trigger.conditionGroups && trigger.conditionGroups.length > 0" class="condition-groups-list">
<div
v-for="(group, groupIndex) in trigger.conditionGroups"
:key="`group-${groupIndex}`"
class="condition-group"
>
<div class="group-header">
<div class="group-title">
<span>条件组 {{ groupIndex + 1 }}</span>
<el-select
v-model="group.logicOperator"
size="small"
style="width: 80px; margin-left: 12px;"
>
<el-option label="且" value="AND" />
<el-option label="或" value="OR" />
</el-select>
</div>
<el-button
type="danger"
size="small"
text
@click="removeConditionGroup(groupIndex)"
v-if="trigger.conditionGroups!.length > 1"
>
<Icon icon="ep:delete" />
删除组
</el-button>
</div>
<ConditionGroupConfig
:model-value="group"
@update:model-value="(value) => updateConditionGroup(groupIndex, value)"
:trigger-type="trigger.type"
:product-id="trigger.productId"
:device-id="trigger.deviceId"
@validate="(result) => handleGroupValidate(groupIndex, result)"
/>
</div>
</div>
<!-- 空状态 -->
<div v-else class="empty-conditions">
<el-empty description="暂无触发条件">
<el-button type="primary" @click="addConditionGroup">
<Icon icon="ep:plus" />
添加第一个条件组
</el-button>
</el-empty>
</div>
</div>
<!-- 验证结果 -->
<div v-if="validationMessage" class="validation-result">
<el-alert
:title="validationMessage"
:type="isValid ? 'success' : 'error'"
:closable="false"
show-icon
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import ProductDeviceSelector from '../selectors/ProductDeviceSelector.vue'
import ConditionGroupConfig from './ConditionGroupConfig.vue'
import {
TriggerFormData,
ConditionGroupFormData,
IotRuleSceneTriggerTypeEnum as TriggerTypeEnum
} from '@/api/iot/rule/scene/scene.types'
/** 设备触发配置组件 */
defineOptions({ name: 'DeviceTriggerConfig' })
interface Props {
modelValue: TriggerFormData
}
interface Emits {
(e: 'update:modelValue', value: TriggerFormData): void
(e: 'validate', result: { valid: boolean; message: string }): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const trigger = useVModel(props, 'modelValue', emit)
//
const maxConditionGroups = 3
//
const groupValidations = ref<{ [key: number]: { valid: boolean; message: string } }>({})
const validationMessage = ref('')
const isValid = ref(true)
//
const needsConditions = computed(() => {
return trigger.value.type !== TriggerTypeEnum.DEVICE_STATE_UPDATE
})
//
const updateConditionGroup = (index: number, group: ConditionGroupFormData) => {
if (trigger.value.conditionGroups) {
trigger.value.conditionGroups[index] = group
}
}
const handleDeviceChange = ({ productId, deviceId }: { productId?: number; deviceId?: number }) => {
trigger.value.productId = productId
trigger.value.deviceId = deviceId
updateValidationResult()
}
const addConditionGroup = () => {
if (!trigger.value.conditionGroups) {
trigger.value.conditionGroups = []
}
if (trigger.value.conditionGroups.length >= maxConditionGroups) {
return
}
const newGroup: ConditionGroupFormData = {
conditions: [],
logicOperator: 'AND'
}
trigger.value.conditionGroups.push(newGroup)
}
const removeConditionGroup = (index: number) => {
if (trigger.value.conditionGroups) {
trigger.value.conditionGroups.splice(index, 1)
delete groupValidations.value[index]
//
const newValidations: { [key: number]: { valid: boolean; message: string } } = {}
Object.keys(groupValidations.value).forEach(key => {
const numKey = parseInt(key)
if (numKey > index) {
newValidations[numKey - 1] = groupValidations.value[numKey]
} else if (numKey < index) {
newValidations[numKey] = groupValidations.value[numKey]
}
})
groupValidations.value = newValidations
updateValidationResult()
}
}
const handleGroupValidate = (index: number, result: { valid: boolean; message: string }) => {
groupValidations.value[index] = result
updateValidationResult()
}
const updateValidationResult = () => {
//
if (!trigger.value.productId || !trigger.value.deviceId) {
isValid.value = false
validationMessage.value = '请选择产品和设备'
emit('validate', { valid: false, message: validationMessage.value })
return
}
//
if (trigger.value.type === TriggerTypeEnum.DEVICE_STATE_UPDATE) {
isValid.value = true
validationMessage.value = '设备触发配置验证通过'
emit('validate', { valid: true, message: validationMessage.value })
return
}
//
if (!trigger.value.conditionGroups || trigger.value.conditionGroups.length === 0) {
isValid.value = false
validationMessage.value = '请至少添加一个触发条件组'
emit('validate', { valid: false, message: validationMessage.value })
return
}
const validations = Object.values(groupValidations.value)
const allValid = validations.every(v => v.valid)
if (allValid) {
isValid.value = true
validationMessage.value = '设备触发配置验证通过'
} else {
isValid.value = false
const errorMessages = validations
.filter(v => !v.valid)
.map(v => v.message)
validationMessage.value = `条件组配置错误: ${errorMessages.join('; ')}`
}
emit('validate', { valid: isValid.value, message: validationMessage.value })
}
//
watch(() => trigger.value.type, () => {
updateValidationResult()
})
//
watch(() => [trigger.value.productId, trigger.value.deviceId], () => {
updateValidationResult()
})
</script>
<style scoped>
.device-trigger-config {
display: flex;
flex-direction: column;
gap: 16px;
}
.state-update-notice {
margin-top: 8px;
}
.notice-tip {
margin: 4px 0 0 0;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.condition-groups-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.header-title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.condition-groups-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.condition-group {
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
background: var(--el-fill-color-blank);
}
.group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--el-fill-color-light);
border-bottom: 1px solid var(--el-border-color-lighter);
}
.group-title {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.empty-conditions {
padding: 40px 0;
text-align: center;
}
.validation-result {
margin-top: 8px;
}
</style>

View File

@ -0,0 +1,142 @@
<!-- 定时触发配置组件 -->
<template>
<div class="timer-trigger-config">
<div class="config-header">
<div class="header-left">
<Icon icon="ep:timer" class="header-icon" />
<span class="header-title">定时触发配置</span>
</div>
<div class="header-right">
<el-button
type="text"
size="small"
@click="showBuilder = !showBuilder"
>
<Icon :icon="showBuilder ? 'ep:edit' : 'ep:setting'" />
{{ showBuilder ? '手动编辑' : '可视化编辑' }}
</el-button>
</div>
</div>
<!-- 可视化编辑器 -->
<div v-if="showBuilder" class="visual-builder">
<CronBuilder v-model="localValue" @validate="handleValidate" />
</div>
<!-- 手动编辑 -->
<div v-else class="manual-editor">
<el-form-item label="CRON表达式" required>
<CronInput v-model="localValue" @validate="handleValidate" />
</el-form-item>
</div>
<!-- 下次执行时间预览 -->
<NextExecutionPreview :cron-expression="localValue" />
<!-- 验证结果 -->
<div v-if="validationMessage" class="validation-result">
<el-alert
:title="validationMessage"
:type="isValid ? 'success' : 'error'"
:closable="false"
show-icon
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import CronBuilder from '../inputs/CronBuilder.vue'
import CronInput from '../inputs/CronInput.vue'
import NextExecutionPreview from '../previews/NextExecutionPreview.vue'
/** 定时触发配置组件 */
defineOptions({ name: 'TimerTriggerConfig' })
interface Props {
modelValue?: string
}
interface Emits {
(e: 'update:modelValue', value: string): void
(e: 'validate', result: { valid: boolean; message: string }): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const localValue = useVModel(props, 'modelValue', emit, {
defaultValue: '0 0 12 * * ?'
})
//
const showBuilder = ref(true)
const validationMessage = ref('')
const isValid = ref(true)
//
const handleValidate = (result: { valid: boolean; message: string }) => {
isValid.value = result.valid
validationMessage.value = result.message
emit('validate', result)
}
//
onMounted(() => {
handleValidate({ valid: true, message: '定时触发配置验证通过' })
})
</script>
<style scoped>
.timer-trigger-config {
display: flex;
flex-direction: column;
gap: 16px;
}
.config-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--el-fill-color-light);
border-radius: 6px;
border: 1px solid var(--el-border-color-lighter);
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.header-icon {
color: var(--el-color-danger);
font-size: 18px;
}
.header-title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.visual-builder,
.manual-editor {
padding: 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
background: var(--el-fill-color-blank);
}
.validation-result {
margin-top: 8px;
}
</style>

View File

@ -0,0 +1,233 @@
<!-- CRON 可视化构建器组件 -->
<template>
<div class="cron-builder">
<div class="builder-header">
<span class="header-title">可视化 CRON 编辑器</span>
</div>
<div class="builder-content">
<!-- 快捷选项 -->
<div class="quick-options">
<span class="options-label">常用配置</span>
<el-button
v-for="option in quickOptions"
:key="option.label"
size="small"
@click="applyQuickOption(option)"
>
{{ option.label }}
</el-button>
</div>
<!-- 详细配置 -->
<div class="detailed-config">
<el-row :gutter="16">
<el-col :span="4">
<el-form-item label="秒">
<el-select v-model="cronParts.second" @change="updateCronExpression">
<el-option label="每秒" value="*" />
<el-option label="0秒" value="0" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="分钟">
<el-select v-model="cronParts.minute" @change="updateCronExpression">
<el-option label="每分钟" value="*" />
<el-option
v-for="i in 60"
:key="i-1"
:label="`${i-1}分`"
:value="String(i-1)"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="小时">
<el-select v-model="cronParts.hour" @change="updateCronExpression">
<el-option label="每小时" value="*" />
<el-option
v-for="i in 24"
:key="i-1"
:label="`${i-1}时`"
:value="String(i-1)"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="日">
<el-select v-model="cronParts.day" @change="updateCronExpression">
<el-option label="每日" value="*" />
<el-option
v-for="i in 31"
:key="i"
:label="`${i}日`"
:value="String(i)"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="月">
<el-select v-model="cronParts.month" @change="updateCronExpression">
<el-option label="每月" value="*" />
<el-option
v-for="(month, index) in months"
:key="index"
:label="month"
:value="String(index + 1)"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="周">
<el-select v-model="cronParts.week" @change="updateCronExpression">
<el-option label="每周" value="*" />
<el-option
v-for="(week, index) in weeks"
:key="index"
:label="week"
:value="String(index)"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
/** CRON 可视化构建器组件 */
defineOptions({ name: 'CronBuilder' })
interface Props {
modelValue: string
}
interface Emits {
(e: 'update:modelValue', value: string): void
(e: 'validate', result: { valid: boolean; message: string }): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const localValue = useVModel(props, 'modelValue', emit)
// CRON
const cronParts = reactive({
second: '0',
minute: '0',
hour: '12',
day: '*',
month: '*',
week: '?'
})
//
const months = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
const weeks = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
//
const quickOptions = [
{ label: '每分钟', cron: '0 * * * * ?' },
{ label: '每小时', cron: '0 0 * * * ?' },
{ label: '每天中午', cron: '0 0 12 * * ?' },
{ label: '每天凌晨', cron: '0 0 0 * * ?' },
{ label: '工作日9点', cron: '0 0 9 * * MON-FRI' },
{ label: '每周一', cron: '0 0 9 * * MON' }
]
//
const updateCronExpression = () => {
localValue.value = `${cronParts.second} ${cronParts.minute} ${cronParts.hour} ${cronParts.day} ${cronParts.month} ${cronParts.week}`
emit('validate', { valid: true, message: 'CRON表达式验证通过' })
}
const applyQuickOption = (option: any) => {
localValue.value = option.cron
parseCronExpression()
emit('validate', { valid: true, message: 'CRON表达式验证通过' })
}
const parseCronExpression = () => {
if (!localValue.value) return
const parts = localValue.value.split(' ')
if (parts.length >= 6) {
cronParts.second = parts[0] || '0'
cronParts.minute = parts[1] || '0'
cronParts.hour = parts[2] || '12'
cronParts.day = parts[3] || '*'
cronParts.month = parts[4] || '*'
cronParts.week = parts[5] || '?'
}
}
//
onMounted(() => {
if (localValue.value) {
parseCronExpression()
} else {
updateCronExpression()
}
})
</script>
<style scoped>
.cron-builder {
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
background: var(--el-fill-color-blank);
}
.builder-header {
padding: 12px 16px;
background: var(--el-fill-color-light);
border-bottom: 1px solid var(--el-border-color-lighter);
}
.header-title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.builder-content {
padding: 16px;
}
.quick-options {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.options-label {
font-weight: 500;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
.detailed-config {
margin-top: 16px;
}
:deep(.el-form-item) {
margin-bottom: 0;
}
:deep(.el-form-item__label) {
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>

View File

@ -0,0 +1,142 @@
<!-- CRON 表达式输入组件 -->
<template>
<div class="cron-input">
<el-input
v-model="localValue"
placeholder="请输入 CRON 表达式0 0 12 * * ?"
@blur="handleBlur"
@input="handleInput"
>
<template #suffix>
<el-tooltip content="CRON 表达式帮助" placement="top">
<Icon icon="ep:question-filled" class="input-help" @click="showHelp = !showHelp" />
</el-tooltip>
</template>
</el-input>
<!-- 帮助信息 -->
<div v-if="showHelp" class="cron-help">
<el-alert
title="CRON 表达式格式:秒 分 时 日 月 周"
type="info"
:closable="false"
show-icon
>
<template #default>
<div class="help-content">
<p><strong>示例</strong></p>
<ul>
<li><code>0 0 12 * * ?</code> - 每天中午12点执行</li>
<li><code>0 */5 * * * ?</code> - 5</li>
<li><code>0 0 9-17 * * MON-FRI</code> - 工作日9-17点每小时执行</li>
</ul>
<p><strong>特殊字符</strong></p>
<ul>
<li><code>*</code> - 匹配任意值</li>
<li><code>?</code> - 不指定值用于日和周</li>
<li><code>/</code> - 间隔触发 */5 5</li>
<li><code>-</code> - 范围 9-17 表示9到17</li>
<li><code>,</code> - 列举 MON,WED,FRI</li>
</ul>
</div>
</template>
</el-alert>
</div>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import { validateCronExpression } from '../../utils/validation'
/** CRON 表达式输入组件 */
defineOptions({ name: 'CronInput' })
interface Props {
modelValue: string
}
interface Emits {
(e: 'update:modelValue', value: string): void
(e: 'validate', result: { valid: boolean; message: string }): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const localValue = useVModel(props, 'modelValue', emit)
//
const showHelp = ref(false)
//
const handleInput = () => {
validateExpression()
}
const handleBlur = () => {
validateExpression()
}
const validateExpression = () => {
if (!localValue.value) {
emit('validate', { valid: false, message: '请输入CRON表达式' })
return
}
const isValid = validateCronExpression(localValue.value)
if (isValid) {
emit('validate', { valid: true, message: 'CRON表达式验证通过' })
} else {
emit('validate', { valid: false, message: 'CRON表达式格式不正确' })
}
}
//
watch(() => localValue.value, () => {
validateExpression()
})
//
onMounted(() => {
if (localValue.value) {
validateExpression()
}
})
</script>
<style scoped>
.cron-input {
width: 100%;
}
.input-help {
color: var(--el-text-color-placeholder);
cursor: pointer;
transition: color 0.2s;
}
.input-help:hover {
color: var(--el-color-primary);
}
.cron-help {
margin-top: 8px;
}
.help-content ul {
margin: 8px 0 0 0;
padding-left: 20px;
}
.help-content li {
margin-bottom: 4px;
}
.help-content code {
background: var(--el-fill-color-light);
padding: 2px 4px;
border-radius: 2px;
font-family: 'Courier New', monospace;
}
</style>

View File

@ -0,0 +1,257 @@
<!-- 场景描述输入组件 -->
<template>
<div class="description-input">
<el-input
ref="inputRef"
v-model="localValue"
type="textarea"
placeholder="请输入场景描述(可选)"
:rows="3"
maxlength="200"
show-word-limit
resize="none"
@input="handleInput"
/>
<!-- 描述模板 -->
<teleport to="body">
<div
v-if="showTemplates"
ref="templateDropdownRef"
class="templates"
:style="dropdownStyle"
>
<div class="templates-header">
<span class="templates-title">描述模板</span>
<el-button
type="text"
size="small"
@click="showTemplates = false"
>
<Icon icon="ep:close" />
</el-button>
</div>
<div class="templates-list">
<div
v-for="template in descriptionTemplates"
:key="template.title"
class="template-item"
@click="applyTemplate(template)"
>
<div class="template-title">{{ template.title }}</div>
<div class="template-content">{{ template.content }}</div>
</div>
</div>
</div>
</teleport>
<!-- 模板按钮 -->
<div v-if="!localValue && !showTemplates" class="template-trigger">
<el-button
type="text"
size="small"
@click="toggleTemplates"
>
<Icon icon="ep:document" class="mr-1" />
使用模板
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
/** 场景描述输入组件 */
defineOptions({ name: 'DescriptionInput' })
interface Props {
modelValue?: string
}
interface Emits {
(e: 'update:modelValue', value: string): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const localValue = useVModel(props, 'modelValue', emit, {
defaultValue: ''
})
const showTemplates = ref(false)
const templateDropdownRef = ref()
const inputRef = ref()
const dropdownStyle = ref({})
//
const descriptionTemplates = [
{
title: '温度控制场景',
content: '当环境温度超过设定阈值时,自动启动空调降温设备,确保环境温度保持在舒适范围内。'
},
{
title: '设备监控场景',
content: '实时监控关键设备的运行状态,当设备出现异常或离线时,立即发送告警通知相关人员。'
},
{
title: '节能控制场景',
content: '根据时间段和环境条件,自动调节设备功率或关闭非必要设备,实现智能节能管理。'
},
{
title: '安防联动场景',
content: '当检测到异常情况时,自动触发安防设备联动,包括报警器、摄像头录制等安全措施。'
},
{
title: '定时任务场景',
content: '按照预设的时间计划,定期执行设备检查、数据备份或系统维护等自动化任务。'
}
]
//
const calculateDropdownPosition = () => {
if (!inputRef.value) return
const inputElement = inputRef.value.$el || inputRef.value
const rect = inputElement.getBoundingClientRect()
const viewportHeight = window.innerHeight
const dropdownHeight = 300 //
let top = rect.bottom + 4
let left = rect.left
//
if (top + dropdownHeight > viewportHeight) {
top = rect.top - dropdownHeight - 4
}
//
const maxLeft = window.innerWidth - 400 //
if (left > maxLeft) {
left = maxLeft
}
if (left < 10) {
left = 10
}
dropdownStyle.value = {
top: `${top}px`,
left: `${left}px`
}
}
const handleInput = (value: string) => {
if (value.length > 0) {
showTemplates.value = false
}
}
const applyTemplate = (template: any) => {
localValue.value = template.content
showTemplates.value = false
}
const toggleTemplates = () => {
showTemplates.value = !showTemplates.value
if (showTemplates.value) {
nextTick(() => {
calculateDropdownPosition()
})
}
}
//
const handleClickOutside = (event: Event) => {
if (templateDropdownRef.value && !templateDropdownRef.value.contains(event.target as Node) &&
inputRef.value && !inputRef.value.$el.contains(event.target as Node)) {
showTemplates.value = false
}
}
//
onMounted(() => {
window.addEventListener('resize', calculateDropdownPosition)
window.addEventListener('scroll', calculateDropdownPosition)
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
window.removeEventListener('resize', calculateDropdownPosition)
window.removeEventListener('scroll', calculateDropdownPosition)
document.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped>
.description-input {
position: relative;
width: 100%;
}
.templates {
position: fixed;
z-index: 9999;
background: white;
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 300px;
max-width: 400px;
max-height: 400px;
overflow: hidden;
}
.templates-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-light);
}
.templates-title {
font-size: 12px;
color: var(--el-text-color-secondary);
font-weight: 500;
}
.templates-list {
max-height: 300px;
overflow-y: auto;
}
.template-item {
padding: 12px;
cursor: pointer;
transition: background-color 0.2s;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.template-item:hover {
background: var(--el-fill-color-light);
}
.template-item:last-child {
border-bottom: none;
}
.template-title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 4px;
}
.template-content {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
}
.template-trigger {
margin-top: 8px;
text-align: right;
}
</style>

View File

@ -0,0 +1,162 @@
<!-- 场景名称输入组件 -->
<template>
<div class="name-input">
<el-input
v-model="localValue"
placeholder="请输入场景名称"
maxlength="50"
show-word-limit
clearable
@blur="handleBlur"
@input="handleInput"
>
<template #prefix>
<Icon icon="ep:edit" class="input-icon" />
</template>
</el-input>
<!-- 智能提示 -->
<div v-if="showSuggestions && suggestions.length > 0" class="suggestions">
<div class="suggestions-header">
<span class="suggestions-title">推荐名称</span>
</div>
<div class="suggestions-list">
<div
v-for="suggestion in suggestions"
:key="suggestion"
class="suggestion-item"
@click="applySuggestion(suggestion)"
>
{{ suggestion }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
/** 场景名称输入组件 */
defineOptions({ name: 'NameInput' })
interface Props {
modelValue: string
}
interface Emits {
(e: 'update:modelValue', value: string): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const localValue = useVModel(props, 'modelValue', emit)
//
const showSuggestions = ref(false)
const suggestions = ref<string[]>([])
//
const nameTemplates = [
'温度过高自动降温',
'设备离线告警通知',
'湿度异常自动调节',
'夜间安防模式启动',
'能耗超标自动关闭',
'故障设备自动重启',
'定时设备状态检查',
'环境数据异常告警'
]
const handleInput = (value: string) => {
if (value.length > 0 && value.length < 10) {
//
suggestions.value = nameTemplates.filter(template =>
template.includes(value) || value.includes('温度') && template.includes('温度')
).slice(0, 5)
showSuggestions.value = suggestions.value.length > 0
} else {
showSuggestions.value = false
}
}
const handleBlur = () => {
//
setTimeout(() => {
showSuggestions.value = false
}, 200)
}
const applySuggestion = (suggestion: string) => {
localValue.value = suggestion
showSuggestions.value = false
}
//
onMounted(() => {
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement
if (!target.closest('.name-input')) {
showSuggestions.value = false
}
})
})
</script>
<style scoped>
.name-input {
position: relative;
width: 100%;
}
.input-icon {
color: var(--el-text-color-placeholder);
}
.suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
background: white;
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
box-shadow: var(--el-box-shadow-light);
margin-top: 4px;
}
.suggestions-header {
padding: 8px 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-light);
}
.suggestions-title {
font-size: 12px;
color: var(--el-text-color-secondary);
font-weight: 500;
}
.suggestions-list {
max-height: 200px;
overflow-y: auto;
}
.suggestion-item {
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.2s;
font-size: 14px;
color: var(--el-text-color-primary);
}
.suggestion-item:hover {
background: var(--el-fill-color-light);
}
.suggestion-item:last-child {
border-bottom: none;
}
</style>

View File

@ -0,0 +1,158 @@
<!-- 场景状态选择组件 -->
<template>
<div class="status-radio">
<el-radio-group
v-model="localValue"
@change="handleChange"
>
<el-radio :value="0" class="status-option">
<div class="status-content">
<div class="status-indicator enabled"></div>
<div class="status-info">
<div class="status-label">启用</div>
<div class="status-desc">场景规则生效满足条件时自动执行</div>
</div>
</div>
</el-radio>
<el-radio :value="1" class="status-option">
<div class="status-content">
<div class="status-indicator disabled"></div>
<div class="status-info">
<div class="status-label">禁用</div>
<div class="status-desc">场景规则暂停不会触发任何执行动作</div>
</div>
</div>
</el-radio>
</el-radio-group>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
/** 场景状态选择组件 */
defineOptions({ name: 'StatusRadio' })
interface Props {
modelValue: number
}
interface Emits {
(e: 'update:modelValue', value: number): void
(e: 'change', value: number): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const localValue = useVModel(props, 'modelValue', emit)
const handleChange = (value: number) => {
emit('change', value)
}
</script>
<style scoped>
.status-radio {
width: 100%;
}
.status-radio :deep(.el-radio) {
margin-bottom: 8px;
}
.status-radio :deep(.el-radio:last-child) {
margin-bottom: 0;
}
:deep(.el-radio-group) {
display: flex;
flex-direction: row;
gap: 16px;
width: 100%;
align-items: flex-start;
}
:deep(.el-radio) {
margin-right: 0;
width: auto;
flex: 1;
height: auto;
align-items: flex-start;
}
.status-option {
width: auto;
flex: 1;
}
:deep(.el-radio__input) {
margin-top: 12px;
flex-shrink: 0;
}
:deep(.el-radio__label) {
width: 100%;
padding-left: 8px;
}
.status-content {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
transition: all 0.2s;
width: 100%;
margin-left: 0;
}
:deep(.el-radio.is-checked) .status-content {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.status-content:hover {
border-color: var(--el-color-primary-light-3);
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
margin-top: 4px;
flex-shrink: 0;
}
.status-indicator.enabled {
background: var(--el-color-success);
box-shadow: 0 0 0 2px var(--el-color-success-light-8);
}
.status-indicator.disabled {
background: var(--el-color-danger);
box-shadow: 0 0 0 2px var(--el-color-danger-light-8);
}
.status-info {
flex: 1;
}
.status-label {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 4px;
}
.status-desc {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
}
</style>

View File

@ -0,0 +1,406 @@
<!-- 值输入组件 -->
<template>
<div class="value-input">
<!-- 布尔值选择 -->
<el-select
v-if="propertyType === 'bool'"
v-model="localValue"
placeholder="请选择布尔值"
@change="handleChange"
class="w-full"
>
<el-option label="真 (true)" value="true" />
<el-option label="假 (false)" value="false" />
</el-select>
<!-- 枚举值选择 -->
<el-select
v-else-if="propertyType === 'enum' && enumOptions.length > 0"
v-model="localValue"
placeholder="请选择枚举值"
@change="handleChange"
class="w-full"
>
<el-option
v-for="option in enumOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<!-- 范围输入 (between 操作符) -->
<div v-else-if="operator === 'between'" class="range-input">
<el-input
v-model="rangeStart"
:type="getInputType()"
placeholder="最小值"
@input="handleRangeChange"
class="range-start"
/>
<span class="range-separator"></span>
<el-input
v-model="rangeEnd"
:type="getInputType()"
placeholder="最大值"
@input="handleRangeChange"
class="range-end"
/>
</div>
<!-- 列表输入 (in 操作符) -->
<div v-else-if="operator === 'in'" class="list-input">
<el-input
v-model="localValue"
placeholder="请输入值列表,用逗号分隔"
@input="handleChange"
class="w-full"
>
<template #suffix>
<el-tooltip content="多个值用逗号分隔1,2,3" placement="top">
<Icon icon="ep:question-filled" class="input-tip" />
</el-tooltip>
</template>
</el-input>
<div v-if="listPreview.length > 0" class="list-preview">
<span class="preview-label">解析结果</span>
<el-tag
v-for="(item, index) in listPreview"
:key="index"
size="small"
class="preview-tag"
>
{{ item }}
</el-tag>
</div>
</div>
<!-- 日期时间输入 -->
<el-date-picker
v-else-if="propertyType === 'date'"
v-model="dateValue"
type="datetime"
placeholder="请选择日期时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
@change="handleDateChange"
class="w-full"
/>
<!-- 数字输入 -->
<el-input-number
v-else-if="isNumericType()"
v-model="numberValue"
:precision="getPrecision()"
:step="getStep()"
:min="getMin()"
:max="getMax()"
placeholder="请输入数值"
@change="handleNumberChange"
class="w-full"
/>
<!-- 文本输入 -->
<el-input
v-else
v-model="localValue"
:type="getInputType()"
:placeholder="getPlaceholder()"
@input="handleChange"
class="w-full"
>
<template #suffix>
<el-tooltip v-if="propertyConfig?.unit" :content="`单位:${propertyConfig.unit}`" placement="top">
<span class="input-unit">{{ propertyConfig.unit }}</span>
</el-tooltip>
</template>
</el-input>
<!-- 验证提示 -->
<div v-if="validationMessage" class="validation-message">
<el-text :type="isValid ? 'success' : 'danger'" size="small">
<Icon :icon="isValid ? 'ep:check' : 'ep:warning-filled'" />
{{ validationMessage }}
</el-text>
</div>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
/** 值输入组件 */
defineOptions({ name: 'ValueInput' })
interface Props {
modelValue?: string
propertyType?: string
operator?: string
propertyConfig?: any
}
interface Emits {
(e: 'update:modelValue', value: string): void
(e: 'validate', result: { valid: boolean; message: string }): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const localValue = useVModel(props, 'modelValue', emit, {
defaultValue: ''
})
//
const rangeStart = ref('')
const rangeEnd = ref('')
const dateValue = ref('')
const numberValue = ref<number>()
const validationMessage = ref('')
const isValid = ref(true)
//
const enumOptions = computed(() => {
if (props.propertyConfig?.enum) {
return props.propertyConfig.enum.map((item: any) => ({
label: item.name || item.label || item.value,
value: item.value
}))
}
return []
})
const listPreview = computed(() => {
if (props.operator === 'in' && localValue.value) {
return localValue.value.split(',').map(item => item.trim()).filter(item => item)
}
return []
})
//
const isNumericType = () => {
return ['int', 'float', 'double'].includes(props.propertyType || '')
}
const getInputType = () => {
switch (props.propertyType) {
case 'int':
case 'float':
case 'double':
return 'number'
default:
return 'text'
}
}
const getPlaceholder = () => {
const typeMap = {
'string': '请输入字符串',
'int': '请输入整数',
'float': '请输入浮点数',
'double': '请输入双精度数',
'struct': '请输入JSON格式数据',
'array': '请输入数组格式数据'
}
return typeMap[props.propertyType || ''] || '请输入值'
}
const getPrecision = () => {
return props.propertyType === 'int' ? 0 : 2
}
const getStep = () => {
return props.propertyType === 'int' ? 1 : 0.1
}
const getMin = () => {
return props.propertyConfig?.min || undefined
}
const getMax = () => {
return props.propertyConfig?.max || undefined
}
//
const handleChange = () => {
validateValue()
}
const handleRangeChange = () => {
if (rangeStart.value && rangeEnd.value) {
localValue.value = `${rangeStart.value},${rangeEnd.value}`
} else {
localValue.value = ''
}
validateValue()
}
const handleDateChange = (value: string) => {
localValue.value = value || ''
validateValue()
}
const handleNumberChange = (value: number | undefined) => {
localValue.value = value?.toString() || ''
validateValue()
}
//
const validateValue = () => {
if (!localValue.value) {
isValid.value = false
validationMessage.value = '请输入值'
emit('validate', { valid: false, message: validationMessage.value })
return
}
//
if (isNumericType()) {
const num = parseFloat(localValue.value)
if (isNaN(num)) {
isValid.value = false
validationMessage.value = '请输入有效的数字'
emit('validate', { valid: false, message: validationMessage.value })
return
}
//
const min = getMin()
const max = getMax()
if (min !== undefined && num < min) {
isValid.value = false
validationMessage.value = `值不能小于 ${min}`
emit('validate', { valid: false, message: validationMessage.value })
return
}
if (max !== undefined && num > max) {
isValid.value = false
validationMessage.value = `值不能大于 ${max}`
emit('validate', { valid: false, message: validationMessage.value })
return
}
}
//
if (props.operator === 'between') {
const parts = localValue.value.split(',')
if (parts.length !== 2) {
isValid.value = false
validationMessage.value = '范围格式错误'
emit('validate', { valid: false, message: validationMessage.value })
return
}
const start = parseFloat(parts[0])
const end = parseFloat(parts[1])
if (isNaN(start) || isNaN(end)) {
isValid.value = false
validationMessage.value = '范围值必须是数字'
emit('validate', { valid: false, message: validationMessage.value })
return
}
if (start >= end) {
isValid.value = false
validationMessage.value = '起始值必须小于结束值'
emit('validate', { valid: false, message: validationMessage.value })
return
}
}
//
if (props.operator === 'in') {
if (listPreview.value.length === 0) {
isValid.value = false
validationMessage.value = '请输入至少一个值'
emit('validate', { valid: false, message: validationMessage.value })
return
}
}
//
isValid.value = true
validationMessage.value = '输入值验证通过'
emit('validate', { valid: true, message: validationMessage.value })
}
//
watch(() => localValue.value, () => {
validateValue()
})
//
watch(() => props.operator, () => {
localValue.value = ''
rangeStart.value = ''
rangeEnd.value = ''
dateValue.value = ''
numberValue.value = undefined
})
//
onMounted(() => {
if (localValue.value) {
validateValue()
}
})
</script>
<style scoped>
.value-input {
width: 100%;
}
.range-input {
display: flex;
align-items: center;
gap: 8px;
}
.range-start,
.range-end {
flex: 1;
}
.range-separator {
font-size: 12px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
.list-input {
width: 100%;
}
.input-tip {
color: var(--el-text-color-placeholder);
cursor: help;
}
.input-unit {
font-size: 12px;
color: var(--el-text-color-secondary);
padding: 0 4px;
}
.list-preview {
margin-top: 8px;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.preview-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.preview-tag {
margin: 0;
}
.validation-message {
margin-top: 4px;
}
</style>

View File

@ -1,106 +0,0 @@
<template>
<el-select v-model="selectedOperator" class="w-1/1" clearable :placeholder="placeholder">
<!-- 根据属性类型展示不同的可选条件 -->
<el-option
v-for="(item, key) in filteredOperators"
:key="key"
:label="item.name"
:value="item.value"
/>
</el-select>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { IotRuleSceneTriggerConditionParameterOperatorEnum } from '@/api/iot/rule/scene/scene.types'
/** 条件选择器 */
defineOptions({ name: 'ConditionSelector' })
const props = defineProps({
placeholder: {
type: String,
default: '请选择条件'
},
modelValue: {
type: String,
default: ''
},
dataType: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue'])
const selectedOperator = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
//
const filteredOperators = computed(() => {
//
if (!props.dataType) {
return IotRuleSceneTriggerConditionParameterOperatorEnum
}
const operatorMap = new Map()
//
operatorMap.set('NOT_NULL', IotRuleSceneTriggerConditionParameterOperatorEnum.NOT_NULL)
//
switch (props.dataType) {
case 'int':
case 'float':
case 'double':
//
operatorMap.set('EQUALS', IotRuleSceneTriggerConditionParameterOperatorEnum.EQUALS)
operatorMap.set('NOT_EQUALS', IotRuleSceneTriggerConditionParameterOperatorEnum.NOT_EQUALS)
operatorMap.set('GREATER_THAN', IotRuleSceneTriggerConditionParameterOperatorEnum.GREATER_THAN)
operatorMap.set('GREATER_THAN_OR_EQUALS', IotRuleSceneTriggerConditionParameterOperatorEnum.GREATER_THAN_OR_EQUALS)
operatorMap.set('LESS_THAN', IotRuleSceneTriggerConditionParameterOperatorEnum.LESS_THAN)
operatorMap.set('LESS_THAN_OR_EQUALS', IotRuleSceneTriggerConditionParameterOperatorEnum.LESS_THAN_OR_EQUALS)
operatorMap.set('IN', IotRuleSceneTriggerConditionParameterOperatorEnum.IN)
operatorMap.set('NOT_IN', IotRuleSceneTriggerConditionParameterOperatorEnum.NOT_IN)
operatorMap.set('BETWEEN', IotRuleSceneTriggerConditionParameterOperatorEnum.BETWEEN)
operatorMap.set('NOT_BETWEEN', IotRuleSceneTriggerConditionParameterOperatorEnum.NOT_BETWEEN)
break
case 'enum':
//
operatorMap.set('EQUALS', IotRuleSceneTriggerConditionParameterOperatorEnum.EQUALS)
operatorMap.set('NOT_EQUALS', IotRuleSceneTriggerConditionParameterOperatorEnum.NOT_EQUALS)
operatorMap.set('IN', IotRuleSceneTriggerConditionParameterOperatorEnum.IN)
operatorMap.set('NOT_IN', IotRuleSceneTriggerConditionParameterOperatorEnum.NOT_IN)
break
case 'bool':
//
operatorMap.set('EQUALS', IotRuleSceneTriggerConditionParameterOperatorEnum.EQUALS)
operatorMap.set('NOT_EQUALS', IotRuleSceneTriggerConditionParameterOperatorEnum.NOT_EQUALS)
break
case 'text':
//
operatorMap.set('EQUALS', IotRuleSceneTriggerConditionParameterOperatorEnum.EQUALS)
operatorMap.set('NOT_EQUALS', IotRuleSceneTriggerConditionParameterOperatorEnum.NOT_EQUALS)
operatorMap.set('LIKE', IotRuleSceneTriggerConditionParameterOperatorEnum.LIKE)
break
case 'date':
//
operatorMap.set('EQUALS', IotRuleSceneTriggerConditionParameterOperatorEnum.EQUALS)
operatorMap.set('NOT_EQUALS', IotRuleSceneTriggerConditionParameterOperatorEnum.NOT_EQUALS)
operatorMap.set('GREATER_THAN', IotRuleSceneTriggerConditionParameterOperatorEnum.GREATER_THAN)
operatorMap.set('GREATER_THAN_OR_EQUALS', IotRuleSceneTriggerConditionParameterOperatorEnum.GREATER_THAN_OR_EQUALS)
operatorMap.set('LESS_THAN', IotRuleSceneTriggerConditionParameterOperatorEnum.LESS_THAN)
operatorMap.set('LESS_THAN_OR_EQUALS', IotRuleSceneTriggerConditionParameterOperatorEnum.LESS_THAN_OR_EQUALS)
operatorMap.set('BETWEEN', IotRuleSceneTriggerConditionParameterOperatorEnum.BETWEEN)
operatorMap.set('NOT_BETWEEN', IotRuleSceneTriggerConditionParameterOperatorEnum.NOT_BETWEEN)
break
// struct array
default:
return IotRuleSceneTriggerConditionParameterOperatorEnum
}
return Object.fromEntries(operatorMap)
})
</script>

View File

@ -1,367 +0,0 @@
<template>
<div>
<div class="m-10px">
<div class="relative bg-[#eff3f7] 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 v-if="isDeviceTrigger" class="flex items-center mr-60px">
<span class="mr-10px">产品</span>
<el-button type="primary" @click="productTableSelectRef?.open()" size="small" plain>
{{ product ? product.name : '选择产品' }}
</el-button>
</div>
<!-- TODO @puhui999只允许选择一个或者全部设备 -->
<div v-if="isDeviceTrigger" class="flex items-center mr-60px">
<span class="mr-10px">设备</span>
<el-button type="primary" @click="openDeviceSelect" size="small" plain>
{{ isEmpty(deviceList) ? '选择设备' : triggerConfig.deviceNames.join(',') }}
</el-button>
</div>
<!-- 删除触发器 -->
<div class="absolute top-auto right-16px bottom-auto">
<el-tooltip content="删除触发器" placement="top">
<slot></slot>
</el-tooltip>
</div>
</div>
<!-- 设备触发器条件 -->
<template v-if="isDeviceTrigger">
<!-- 设备上下线变更 - 无需额外配置 -->
<div
v-if="triggerConfig.type === IotRuleSceneTriggerTypeEnum.DEVICE_STATE_UPDATE"
class="bg-[#dbe5f6] flex items-center justify-center p-10px"
>
<span class="text-gray-600">设备上下线状态变更时触发无需额外配置</span>
</div>
<!-- 物模型属性上报设备事件上报设备服务调用 - 需要配置条件 -->
<div
v-else
class="bg-[#dbe5f6] flex p-10px"
v-for="(condition, index) in triggerConfig.conditions"
:key="index"
>
<div class="w-70%">
<DeviceListenerCondition
v-for="(parameter, index2) in condition.parameters"
:key="index2"
:model-value="parameter"
:condition-type="condition.type"
:thingModels="thingModels(condition)"
@update:model-value="(val) => (condition.parameters[index2] = val)"
class="mb-10px last:mb-0"
>
<el-tooltip content="删除参数" placement="top">
<el-button
type="danger"
circle
size="small"
@click="removeConditionParameter(condition.parameters, index2)"
>
<Icon icon="ep:delete" />
</el-button>
</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
size="small"
@click="addConditionParameter(condition.parameters)"
>
<Icon icon="ep:plus" />
</el-button>
</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" size="small" @click="removeCondition(index)">
<Icon icon="ep:delete" />
</el-button>
</el-tooltip>
</div>
</div>
</template>
<!-- 定时触发 -->
<div
v-if="triggerConfig.type === IotRuleSceneTriggerTypeEnum.TIMER"
class="bg-[#dbe5f6] flex items-center justify-between p-10px"
>
<span class="w-120px">CRON 表达式</span>
<crontab v-model="triggerConfig.cronExpression" />
</div>
<!-- 除了设备上下线变更其他设备触发类型都可以设置多个触发条件 -->
<!-- TODO @puhui999触发有点不太对可以在用下阿里云的呢~ -->
<el-text
v-if="
isDeviceTrigger && triggerConfig.type !== IotRuleSceneTriggerTypeEnum.DEVICE_STATE_UPDATE
"
class="ml-10px!"
type="primary"
@click="addCondition"
>
添加触发条件
</el-text>
</div>
<!-- 产品设备的选择 -->
<ProductTableSelect ref="productTableSelectRef" @success="handleProductSelect" />
<DeviceTableSelect
ref="deviceTableSelectRef"
multiple
:product-id="product?.id"
@success="handleDeviceSelect"
/>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import { isEmpty } from '@/utils/is'
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import DeviceListenerCondition from './DeviceListenerCondition.vue'
import ProductTableSelect from '@/views/iot/product/product/components/ProductTableSelect.vue'
import DeviceTableSelect from '@/views/iot/device/device/components/DeviceTableSelect.vue'
import { ProductApi, ProductVO } from '@/api/iot/product/product'
import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
import { ThingModelApi } from '@/api/iot/thingmodel'
import {
IotDeviceMessageIdentifierEnum,
IotDeviceMessageTypeEnum,
IotRuleSceneTriggerTypeEnum,
TriggerCondition,
TriggerConditionParameter,
TriggerConfig
} from '@/api/iot/rule/scene/scene.types'
import { Crontab } from '@/components/Crontab'
/** 场景联动之监听器组件 */
defineOptions({ name: 'DeviceListener' })
const props = defineProps<{ modelValue: any }>()
const emits = defineEmits(['update:modelValue'])
const triggerConfig = useVModel(props, 'modelValue', emits) as Ref<TriggerConfig>
const message = useMessage()
/** 计算属性:判断是否为设备触发类型 */
const isDeviceTrigger = computed(() => {
return [
IotRuleSceneTriggerTypeEnum.DEVICE_STATE_UPDATE,
IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST,
IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST,
IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE
].includes(triggerConfig.value.type as any)
})
/** 添加触发条件 */
const addCondition = () => {
//
let defaultConditionType: string = IotDeviceMessageTypeEnum.PROPERTY
switch (triggerConfig.value.type) {
case IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST:
defaultConditionType = IotDeviceMessageTypeEnum.PROPERTY
break
case IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST:
defaultConditionType = IotDeviceMessageTypeEnum.EVENT
break
case IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE:
defaultConditionType = IotDeviceMessageTypeEnum.SERVICE
break
}
//
triggerConfig.value.conditions?.push({
type: defaultConditionType,
identifier: IotDeviceMessageIdentifierEnum.PROPERTY_SET,
parameters: []
})
}
/** 移除触发条件 */
const removeCondition = (index: number) => {
triggerConfig.value.conditions?.splice(index, 1)
}
/** 添加参数 */
const addConditionParameter = (conditionParameters: TriggerConditionParameter[]) => {
if (!product.value) {
message.warning('请先选择一个产品')
return
}
if (conditionParameters.length >= 1) {
message.warning('只允许添加一个参数')
return
}
conditionParameters.push({} as TriggerConditionParameter)
}
/** 移除参数 */
const removeConditionParameter = (
conditionParameters: TriggerConditionParameter[],
index: number
) => {
conditionParameters.splice(index, 1)
}
/** 产品和设备选择引用 */
const productTableSelectRef = ref<InstanceType<typeof ProductTableSelect>>()
const deviceTableSelectRef = ref<InstanceType<typeof DeviceTableSelect>>()
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 initProductInfo = async () => {
if (!triggerConfig.value.productKey) {
return
}
try {
// 使APIproductKey
const productData = await ProductApi.getProductByKey(triggerConfig.value.productKey)
if (productData) {
product.value = productData
//
await getThingModelTSL()
}
} catch (error) {
console.error('获取产品信息失败:', error)
}
}
/**
* 初始化设备回显信息
*/
const initDeviceInfo = async () => {
if (!triggerConfig.value.productKey || !triggerConfig.value.deviceNames?.length) {
return
}
try {
// 使APIproductKeydeviceNames
const deviceData = await DeviceApi.getDevicesByProductKeyAndNames(
triggerConfig.value.productKey,
triggerConfig.value.deviceNames
)
if (deviceData && deviceData.length > 0) {
deviceList.value = deviceData
}
} catch (error) {
console.error('获取设备信息失败:', error)
}
}
/** 获取产品物模型 */
const thingModelTSL = ref<any>()
const thingModels = computed(() => (condition: TriggerCondition) => {
if (isEmpty(thingModelTSL.value)) {
return []
}
switch (condition.type) {
case IotDeviceMessageTypeEnum.PROPERTY:
return thingModelTSL.value?.properties || []
case IotDeviceMessageTypeEnum.SERVICE:
return thingModelTSL.value?.services || []
case IotDeviceMessageTypeEnum.EVENT:
return thingModelTSL.value?.events || []
}
return []
})
const getThingModelTSL = async () => {
if (!product.value) {
return
}
thingModelTSL.value = await ThingModelApi.getThingModelTSLByProductId(product.value.id)
}
/** 监听触发类型变化,自动设置条件类型 */
watch(
() => triggerConfig.value.type,
(newType) => {
if (!newType || newType === IotRuleSceneTriggerTypeEnum.TIMER) {
return
}
// 线
if (newType === IotRuleSceneTriggerTypeEnum.DEVICE_STATE_UPDATE) {
triggerConfig.value.conditions = []
return
}
//
if (triggerConfig.value.conditions && triggerConfig.value.conditions.length > 0) {
triggerConfig.value.conditions.forEach((condition) => {
switch (newType) {
case IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST:
condition.type = IotDeviceMessageTypeEnum.PROPERTY
break
case IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST:
condition.type = IotDeviceMessageTypeEnum.EVENT
break
case IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE:
condition.type = IotDeviceMessageTypeEnum.SERVICE
break
}
})
}
}
)
/** 初始化 */
onMounted(async () => {
//
if (triggerConfig.value) {
// conditions线
if (
!triggerConfig.value.conditions &&
triggerConfig.value.type !== IotRuleSceneTriggerTypeEnum.DEVICE_STATE_UPDATE
) {
triggerConfig.value.conditions = []
}
await initProductInfo()
await initDeviceInfo()
}
})
</script>

View File

@ -1,87 +0,0 @@
<template>
<div class="flex items-center w-1/1">
<!-- 选择服务 -->
<el-select
v-if="
[IotDeviceMessageTypeEnum.SERVICE, IotDeviceMessageTypeEnum.EVENT].includes(conditionType)
"
v-model="conditionParameter.identifier0"
class="!w-150px mr-10px"
clearable
placeholder="请选择服务"
>
<el-option
v-for="thingModel in thingModels"
:key="thingModel.identifier"
:label="thingModel.name"
:value="thingModel.identifier"
/>
</el-select>
<el-select
v-model="conditionParameter.identifier"
class="!w-150px mr-10px"
clearable
placeholder="请选择物模型"
>
<el-option
v-for="thingModel in getThingModels"
:key="thingModel.identifier"
:label="thingModel.name"
:value="thingModel.identifier"
/>
</el-select>
<ConditionSelector
v-model="conditionParameter.operator"
:data-type="model?.dataType"
class="!w-150px mr-10px"
/>
<ThingModelParamInput
v-if="
conditionParameter.operator !==
IotRuleSceneTriggerConditionParameterOperatorEnum.NOT_NULL.value
"
class="!w-200px mr-10px"
v-model="conditionParameter.value"
:thing-model="model"
/>
<!-- 按钮插槽 -->
<slot></slot>
</div>
</template>
<script setup lang="ts">
import ConditionSelector from './ConditionSelector.vue'
import {
IotDeviceMessageTypeEnum,
IotRuleSceneTriggerConditionParameterOperatorEnum,
TriggerConditionParameter
} from '@/api/iot/rule/scene/scene.types'
import { useVModel } from '@vueuse/core'
import ThingModelParamInput from '@/views/iot/rule/scene/components/ThingModelParamInput.vue'
/** 设备触发条件 */
defineOptions({ name: 'DeviceListenerCondition' })
const props = defineProps<{ modelValue: any; conditionType: any; thingModels: any }>()
const emits = defineEmits(['update:modelValue'])
const conditionParameter = useVModel(props, 'modelValue', emits) as Ref<TriggerConditionParameter>
/** 属性就是 thingModels服务和事件取对应的 outputParams */
const getThingModels = computed(() => {
switch (props.conditionType) {
case IotDeviceMessageTypeEnum.PROPERTY:
return props.thingModels || []
case IotDeviceMessageTypeEnum.SERVICE:
case IotDeviceMessageTypeEnum.EVENT:
return (
props.thingModels.find(
(item: any) => item.identifier === conditionParameter.value.identifier0
)?.outputParams || []
)
}
})
/** 获得物模型属性、类型 */
const model = computed(() =>
getThingModels.value.find((item: any) => item.identifier === conditionParameter.value.identifier)
)
</script>

View File

@ -1,166 +0,0 @@
<template>
<div>
<div class="m-10px">
<div class="relative bg-[#eff3f7] 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()" size="small" plain>
{{ product ? product.name : '选择产品' }}
</el-button>
</div>
<div class="flex items-center mr-60px">
<span class="mr-10px">设备</span>
<el-button type="primary" @click="openDeviceSelect" size="small" plain>
{{ isEmpty(deviceList) ? '选择设备' : triggerConfig.deviceNames.join(',') }}
</el-button>
</div>
<!-- 删除触发器 -->
<div class="absolute top-auto right-16px bottom-auto">
<el-tooltip content="删除触发器" placement="top">
<slot></slot>
</el-tooltip>
</div>
</div>
<!-- 设备状态变更说明 -->
<div
v-if="triggerConfig.type === IotRuleSceneTriggerTypeEnum.DEVICE_STATE_UPDATE"
class="bg-[#dbe5f6] flex items-center justify-center p-10px"
>
<el-icon class="mr-5px text-blue-500"><Icon icon="ep:info-filled" /></el-icon>
<span class="text-gray-600">当选中的设备上线或下线时触发场景联动</span>
</div>
<!-- 定时触发 -->
<div
v-if="triggerConfig.type === IotRuleSceneTriggerTypeEnum.TIMER"
class="bg-[#dbe5f6] flex items-center justify-between p-10px"
>
<span class="w-120px">CRON 表达式</span>
<crontab v-model="triggerConfig.cronExpression" />
</div>
</div>
<!-- 产品设备的选择 -->
<ProductTableSelect ref="productTableSelectRef" @success="handleProductSelect" />
<DeviceTableSelect
ref="deviceTableSelectRef"
multiple
:product-id="product?.id"
@success="handleDeviceSelect"
/>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import { isEmpty } from '@/utils/is'
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import ProductTableSelect from '@/views/iot/product/product/components/ProductTableSelect.vue'
import DeviceTableSelect from '@/views/iot/device/device/components/DeviceTableSelect.vue'
import { ProductApi, ProductVO } from '@/api/iot/product/product'
import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
import { IotRuleSceneTriggerTypeEnum, TriggerConfig } from '@/api/iot/rule/scene/scene.types'
import { Crontab } from '@/components/Crontab'
/** 设备状态监听器组件 */
defineOptions({ name: 'DeviceStateListener' })
const props = defineProps<{ modelValue: any }>()
const emits = defineEmits(['update:modelValue'])
const triggerConfig = useVModel(props, 'modelValue', emits) as Ref<TriggerConfig>
const message = useMessage()
/** 产品和设备选择引用 */
const productTableSelectRef = ref<InstanceType<typeof ProductTableSelect>>()
const deviceTableSelectRef = ref<InstanceType<typeof DeviceTableSelect>>()
const product = ref<ProductVO>()
const deviceList = ref<DeviceVO[]>([])
/** 处理产品选择 */
const handleProductSelect = (val: ProductVO) => {
product.value = val
triggerConfig.value.productKey = val.productKey
deviceList.value = []
}
/** 处理设备选择 */
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 initProductInfo = async () => {
if (!triggerConfig.value.productKey) {
return
}
try {
const productData = await ProductApi.getProductByKey(triggerConfig.value.productKey)
if (productData) {
product.value = productData
}
} catch (error) {
console.error('获取产品信息失败:', error)
}
}
/**
* 初始化设备回显信息
*/
const initDeviceInfo = async () => {
if (!triggerConfig.value.productKey || !triggerConfig.value.deviceNames?.length) {
return
}
try {
const deviceData = await DeviceApi.getDevicesByProductKeyAndNames(
triggerConfig.value.productKey,
triggerConfig.value.deviceNames
)
if (deviceData && deviceData.length > 0) {
deviceList.value = deviceData
}
} catch (error) {
console.error('获取设备信息失败:', error)
}
}
/** 初始化 */
onMounted(async () => {
if (triggerConfig.value) {
await initProductInfo()
await initDeviceInfo()
}
})
</script>

View File

@ -0,0 +1,127 @@
<!-- 执行器预览组件 -->
<template>
<div class="action-preview">
<div v-if="actions.length === 0" class="empty-preview">
<el-text type="info" size="small">暂无执行器配置</el-text>
</div>
<div v-else class="action-list">
<div
v-for="(action, index) in actions"
:key="index"
class="action-item"
>
<div class="action-header">
<Icon icon="ep:setting" class="action-icon" />
<span class="action-title">执行器 {{ index + 1 }}</span>
<el-tag :type="getActionTypeTag(action.type)" size="small">
{{ getActionTypeName(action.type) }}
</el-tag>
</div>
<div class="action-content">
<div class="action-summary">
{{ getActionSummary(action) }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ActionFormData, IotRuleSceneActionTypeEnum } from '@/api/iot/rule/scene/scene.types'
/** 执行器预览组件 */
defineOptions({ name: 'ActionPreview' })
interface Props {
actions: ActionFormData[]
}
const props = defineProps<Props>()
//
const actionTypeNames = {
[IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET]: '属性设置',
[IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE]: '服务调用',
[IotRuleSceneActionTypeEnum.ALERT_TRIGGER]: '触发告警',
[IotRuleSceneActionTypeEnum.ALERT_RECOVER]: '恢复告警'
}
const actionTypeTags = {
[IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET]: 'primary',
[IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE]: 'success',
[IotRuleSceneActionTypeEnum.ALERT_TRIGGER]: 'danger',
[IotRuleSceneActionTypeEnum.ALERT_RECOVER]: 'warning'
}
//
const getActionTypeName = (type: number) => {
return actionTypeNames[type] || '未知类型'
}
const getActionTypeTag = (type: number) => {
return actionTypeTags[type] || 'info'
}
const getActionSummary = (action: ActionFormData) => {
if (action.type === IotRuleSceneActionTypeEnum.ALERT_TRIGGER || action.type === IotRuleSceneActionTypeEnum.ALERT_RECOVER) {
return `告警配置: ${action.alertConfigId ? `配置ID ${action.alertConfigId}` : '未选择'}`
} else {
const paramsCount = action.params ? Object.keys(action.params).length : 0
return `设备控制: 产品${action.productId || '未选择'} 设备${action.deviceId || '未选择'} (${paramsCount}个参数)`
}
}
</script>
<style scoped>
.action-preview {
width: 100%;
}
.empty-preview {
text-align: center;
padding: 20px 0;
}
.action-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.action-item {
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
background: var(--el-fill-color-blank);
}
.action-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--el-fill-color-light);
border-bottom: 1px solid var(--el-border-color-lighter);
}
.action-icon {
color: var(--el-color-success);
font-size: 14px;
}
.action-title {
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.action-content {
padding: 8px 12px;
}
.action-summary {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
}
</style>

View File

@ -0,0 +1,65 @@
<!-- 配置预览组件 -->
<template>
<div class="config-preview">
<div class="preview-items">
<div class="preview-item">
<span class="item-label">场景名称</span>
<span class="item-value">{{ formData.name || '未设置' }}</span>
</div>
<div class="preview-item">
<span class="item-label">场景状态</span>
<el-tag :type="formData.status === 0 ? 'success' : 'danger'" size="small">
{{ formData.status === 0 ? '启用' : '禁用' }}
</el-tag>
</div>
<div v-if="formData.description" class="preview-item">
<span class="item-label">场景描述</span>
<span class="item-value">{{ formData.description }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { RuleSceneFormData } from '@/api/iot/rule/scene/scene.types'
/** 配置预览组件 */
defineOptions({ name: 'ConfigPreview' })
interface Props {
formData: RuleSceneFormData
}
defineProps<Props>()
</script>
<style scoped>
.config-preview {
width: 100%;
}
.preview-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.preview-item {
display: flex;
align-items: center;
gap: 8px;
}
.item-label {
font-size: 12px;
color: var(--el-text-color-secondary);
min-width: 80px;
flex-shrink: 0;
}
.item-value {
font-size: 12px;
color: var(--el-text-color-primary);
flex: 1;
}
</style>

View File

@ -0,0 +1,226 @@
<!-- 下次执行时间预览组件 -->
<template>
<div class="next-execution-preview">
<div class="preview-header">
<Icon icon="ep:timer" class="preview-icon" />
<span class="preview-title">执行时间预览</span>
</div>
<div v-if="isValidCron" class="preview-content">
<div class="current-expression">
<span class="expression-label">CRON表达式</span>
<code class="expression-code">{{ cronExpression }}</code>
</div>
<div class="description">
<span class="description-label">执行规律</span>
<span class="description-text">{{ cronDescription }}</span>
</div>
<div class="next-times">
<span class="times-label">接下来5次执行时间</span>
<div class="times-list">
<div
v-for="(time, index) in nextExecutionTimes"
:key="index"
class="time-item"
>
<Icon icon="ep:clock" class="time-icon" />
<span class="time-text">{{ time }}</span>
</div>
</div>
</div>
</div>
<div v-else class="preview-error">
<el-alert
title="CRON表达式无效"
description="请检查CRON表达式格式是否正确"
type="error"
:closable="false"
show-icon
/>
</div>
</div>
</template>
<script setup lang="ts">
import { validateCronExpression } from '../../utils/validation'
/** 下次执行时间预览组件 */
defineOptions({ name: 'NextExecutionPreview' })
interface Props {
cronExpression?: string
}
const props = defineProps<Props>()
//
const isValidCron = computed(() => {
return props.cronExpression ? validateCronExpression(props.cronExpression) : false
})
const cronDescription = computed(() => {
if (!isValidCron.value) return ''
// CRON
const parts = props.cronExpression?.split(' ') || []
if (parts.length < 6) return '无法解析'
const [second, minute, hour, day, month, week] = parts
//
let description = ''
if (second === '0' && minute === '0' && hour === '12' && day === '*' && month === '*' && week === '?') {
description = '每天中午12点执行'
} else if (second === '0' && minute === '*' && hour === '*' && day === '*' && month === '*' && week === '?') {
description = '每分钟执行一次'
} else if (second === '0' && minute === '0' && hour === '*' && day === '*' && month === '*' && week === '?') {
description = '每小时执行一次'
} else {
description = '按自定义时间规律执行'
}
return description
})
const nextExecutionTimes = computed(() => {
if (!isValidCron.value) return []
//
const now = new Date()
const times = []
for (let i = 1; i <= 5; i++) {
// 使CRON
//
const nextTime = new Date(now.getTime() + i * 60 * 60 * 1000)
times.push(nextTime.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}))
}
return times
})
</script>
<style scoped>
.next-execution-preview {
margin-top: 16px;
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
background: var(--el-fill-color-blank);
}
.preview-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: var(--el-fill-color-light);
border-bottom: 1px solid var(--el-border-color-lighter);
}
.preview-icon {
color: var(--el-color-primary);
font-size: 16px;
}
.preview-title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.preview-content {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.current-expression {
display: flex;
align-items: center;
gap: 8px;
}
.expression-label {
font-size: 12px;
color: var(--el-text-color-secondary);
min-width: 80px;
}
.expression-code {
font-family: 'Courier New', monospace;
background: var(--el-fill-color-light);
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
color: var(--el-color-primary);
}
.description {
display: flex;
align-items: center;
gap: 8px;
}
.description-label {
font-size: 12px;
color: var(--el-text-color-secondary);
min-width: 80px;
}
.description-text {
font-size: 12px;
color: var(--el-text-color-primary);
font-weight: 500;
}
.next-times {
display: flex;
flex-direction: column;
gap: 8px;
}
.times-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.times-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-left: 12px;
}
.time-item {
display: flex;
align-items: center;
gap: 6px;
}
.time-icon {
color: var(--el-color-success);
font-size: 12px;
}
.time-text {
font-size: 12px;
color: var(--el-text-color-primary);
font-family: 'Courier New', monospace;
}
.preview-error {
padding: 16px;
}
</style>

View File

@ -0,0 +1,131 @@
<!-- 触发器预览组件 -->
<template>
<div class="trigger-preview">
<div v-if="triggers.length === 0" class="empty-preview">
<el-text type="info" size="small">暂无触发器配置</el-text>
</div>
<div v-else class="trigger-list">
<div
v-for="(trigger, index) in triggers"
:key="index"
class="trigger-item"
>
<div class="trigger-header">
<Icon icon="ep:lightning" class="trigger-icon" />
<span class="trigger-title">触发器 {{ index + 1 }}</span>
<el-tag :type="getTriggerTypeTag(trigger.type)" size="small">
{{ getTriggerTypeName(trigger.type) }}
</el-tag>
</div>
<div class="trigger-content">
<div class="trigger-summary">
{{ getTriggerSummary(trigger) }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { TriggerFormData, IotRuleSceneTriggerTypeEnum } from '@/api/iot/rule/scene/scene.types'
/** 触发器预览组件 */
defineOptions({ name: 'TriggerPreview' })
interface Props {
triggers: TriggerFormData[]
}
const props = defineProps<Props>()
//
const triggerTypeNames = {
[IotRuleSceneTriggerTypeEnum.DEVICE_STATE_UPDATE]: '设备状态变更',
[IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST]: '属性上报',
[IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST]: '事件上报',
[IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE]: '服务调用',
[IotRuleSceneTriggerTypeEnum.TIMER]: '定时触发'
}
const triggerTypeTags = {
[IotRuleSceneTriggerTypeEnum.DEVICE_STATE_UPDATE]: 'warning',
[IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST]: 'primary',
[IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST]: 'success',
[IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE]: 'info',
[IotRuleSceneTriggerTypeEnum.TIMER]: 'danger'
}
//
const getTriggerTypeName = (type: number) => {
return triggerTypeNames[type] || '未知类型'
}
const getTriggerTypeTag = (type: number) => {
return triggerTypeTags[type] || 'info'
}
const getTriggerSummary = (trigger: TriggerFormData) => {
if (trigger.type === IotRuleSceneTriggerTypeEnum.TIMER) {
return `定时执行: ${trigger.cronExpression || '未配置'}`
} else if (trigger.type === IotRuleSceneTriggerTypeEnum.DEVICE_STATE_UPDATE) {
return `设备状态变更: 产品${trigger.productId || '未选择'} 设备${trigger.deviceId || '未选择'}`
} else {
const conditionCount = trigger.conditionGroups?.reduce((total, group) => total + (group.conditions?.length || 0), 0) || 0
return `设备监控: 产品${trigger.productId || '未选择'} 设备${trigger.deviceId || '未选择'} (${conditionCount}个条件)`
}
}
</script>
<style scoped>
.trigger-preview {
width: 100%;
}
.empty-preview {
text-align: center;
padding: 20px 0;
}
.trigger-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.trigger-item {
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
background: var(--el-fill-color-blank);
}
.trigger-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--el-fill-color-light);
border-bottom: 1px solid var(--el-border-color-lighter);
}
.trigger-icon {
color: var(--el-color-warning);
font-size: 14px;
}
.trigger-title {
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.trigger-content {
padding: 8px 12px;
}
.trigger-summary {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
}
</style>

View File

@ -0,0 +1,120 @@
<!-- 验证结果组件 -->
<template>
<div class="validation-result">
<div v-if="!validationResult" class="no-validation">
<el-text type="info" size="small">
<Icon icon="ep:info-filled" />
点击"验证配置"按钮检查规则配置
</el-text>
</div>
<div v-else class="validation-content">
<el-alert
:title="validationResult.valid ? '配置验证通过' : '配置验证失败'"
:description="validationResult.message"
:type="validationResult.valid ? 'success' : 'error'"
:closable="false"
show-icon
>
<template #default>
<div v-if="validationResult.valid" class="success-content">
<p>{{ validationResult.message || '所有配置项验证通过,规则可以正常运行' }}</p>
<div class="success-tips">
<Icon icon="ep:check" class="tip-icon" />
<span class="tip-text">规则配置完整且有效</span>
</div>
</div>
<div v-else class="error-content">
<p>{{ validationResult.message || '配置验证失败,请检查以下问题' }}</p>
<div class="error-tips">
<div class="tip-item">
<Icon icon="ep:warning-filled" class="tip-icon error" />
<span class="tip-text">请确保所有必填项都已配置</span>
</div>
<div class="tip-item">
<Icon icon="ep:warning-filled" class="tip-icon error" />
<span class="tip-text">请检查触发器和执行器配置是否正确</span>
</div>
</div>
</div>
</template>
</el-alert>
</div>
</div>
</template>
<script setup lang="ts">
/** 验证结果组件 */
defineOptions({ name: 'ValidationResult' })
interface Props {
validationResult?: { valid: boolean; message?: string } | null
}
defineProps<Props>()
</script>
<style scoped>
.validation-result {
width: 100%;
}
.no-validation {
text-align: center;
padding: 20px 0;
}
.validation-content {
width: 100%;
}
.success-content,
.error-content {
margin-top: 8px;
}
.success-content p,
.error-content p {
margin: 0 0 8px 0;
font-size: 14px;
line-height: 1.5;
}
.success-tips,
.error-tips {
display: flex;
flex-direction: column;
gap: 4px;
}
.tip-item {
display: flex;
align-items: center;
gap: 6px;
}
.tip-icon {
font-size: 12px;
flex-shrink: 0;
}
.tip-icon:not(.error) {
color: var(--el-color-success);
}
.tip-icon.error {
color: var(--el-color-danger);
}
.tip-text {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.success-tips .tip-text {
color: var(--el-color-success-dark-2);
}
.error-tips .tip-text {
color: var(--el-color-danger-dark-2);
}
</style>

View File

@ -0,0 +1,390 @@
<!-- 执行器配置组件 -->
<template>
<el-card class="action-section" shadow="never">
<template #header>
<div class="section-header">
<div class="header-left">
<Icon icon="ep:setting" class="section-icon" />
<span class="section-title">执行器配置</span>
<el-tag size="small" type="info">{{ actions.length }}/{{ maxActions }}</el-tag>
</div>
<div class="header-right">
<el-button
type="primary"
size="small"
@click="addAction"
:disabled="actions.length >= maxActions"
>
<Icon icon="ep:plus" />
添加执行器
</el-button>
</div>
</div>
</template>
<div class="section-content">
<!-- 空状态 -->
<div v-if="actions.length === 0" class="empty-state">
<el-empty description="暂无执行器配置">
<el-button type="primary" @click="addAction">
<Icon icon="ep:plus" />
添加第一个执行器
</el-button>
</el-empty>
</div>
<!-- 执行器列表 -->
<div v-else class="actions-list">
<div
v-for="(action, index) in actions"
:key="`action-${index}`"
class="action-item"
>
<div class="action-header">
<div class="action-title">
<Icon icon="ep:setting" class="action-icon" />
<span>执行器 {{ index + 1 }}</span>
<el-tag
:type="getActionTypeTag(action.type)"
size="small"
>
{{ getActionTypeName(action.type) }}
</el-tag>
</div>
<div class="action-actions">
<el-button
type="danger"
size="small"
text
@click="removeAction(index)"
v-if="actions.length > 1"
>
<Icon icon="ep:delete" />
删除
</el-button>
</div>
</div>
<div class="action-content">
<!-- 执行类型选择 -->
<ActionTypeSelector
:model-value="action.type"
@update:model-value="(value) => updateActionType(index, value)"
@change="onActionTypeChange(action, $event)"
/>
<!-- 设备控制配置 -->
<DeviceControlConfig
v-if="isDeviceAction(action.type)"
:model-value="action"
@update:model-value="(value) => updateAction(index, value)"
@validate="(result) => handleActionValidate(index, result)"
/>
<!-- 告警配置 -->
<AlertConfig
v-if="isAlertAction(action.type)"
:model-value="action.alertConfigId"
@update:model-value="(value) => updateActionAlertConfig(index, value)"
@validate="(result) => handleActionValidate(index, result)"
/>
</div>
</div>
</div>
<!-- 添加提示 -->
<div v-if="actions.length > 0 && actions.length < maxActions" class="add-more">
<el-button
type="primary"
plain
@click="addAction"
class="add-more-btn"
>
<Icon icon="ep:plus" />
继续添加执行器
</el-button>
<span class="add-more-text">
最多可添加 {{ maxActions }} 个执行器
</span>
</div>
<!-- 验证结果 -->
<div v-if="validationMessage" class="validation-result">
<el-alert
:title="validationMessage"
:type="isValid ? 'success' : 'error'"
:closable="false"
show-icon
/>
</div>
</div>
</el-card>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import ActionTypeSelector from '../selectors/ActionTypeSelector.vue'
import DeviceControlConfig from '../configs/DeviceControlConfig.vue'
import AlertConfig from '../configs/AlertConfig.vue'
import {
ActionFormData,
IotRuleSceneActionTypeEnum as ActionTypeEnum
} from '@/api/iot/rule/scene/scene.types'
import { createDefaultActionData } from '../../utils/transform'
/** 执行器配置组件 */
defineOptions({ name: 'ActionSection' })
interface Props {
actions: ActionFormData[]
}
interface Emits {
(e: 'update:actions', value: ActionFormData[]): void
(e: 'validate', result: { valid: boolean; message: string }): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const actions = useVModel(props, 'actions', emit)
//
const maxActions = 5
//
const actionValidations = ref<{ [key: number]: { valid: boolean; message: string } }>({})
const validationMessage = ref('')
const isValid = ref(true)
//
const actionTypeNames = {
[ActionTypeEnum.DEVICE_PROPERTY_SET]: '属性设置',
[ActionTypeEnum.DEVICE_SERVICE_INVOKE]: '服务调用',
[ActionTypeEnum.ALERT_TRIGGER]: '触发告警',
[ActionTypeEnum.ALERT_RECOVER]: '恢复告警'
}
const actionTypeTags = {
[ActionTypeEnum.DEVICE_PROPERTY_SET]: 'primary',
[ActionTypeEnum.DEVICE_SERVICE_INVOKE]: 'success',
[ActionTypeEnum.ALERT_TRIGGER]: 'danger',
[ActionTypeEnum.ALERT_RECOVER]: 'warning'
}
//
const isDeviceAction = (type: number) => {
return [
ActionTypeEnum.DEVICE_PROPERTY_SET,
ActionTypeEnum.DEVICE_SERVICE_INVOKE
].includes(type)
}
const isAlertAction = (type: number) => {
return [
ActionTypeEnum.ALERT_TRIGGER,
ActionTypeEnum.ALERT_RECOVER
].includes(type)
}
const getActionTypeName = (type: number) => {
return actionTypeNames[type] || '未知类型'
}
const getActionTypeTag = (type: number) => {
return actionTypeTags[type] || 'info'
}
//
const addAction = () => {
if (actions.value.length >= maxActions) {
return
}
const newAction = createDefaultActionData()
actions.value.push(newAction)
}
const removeAction = (index: number) => {
actions.value.splice(index, 1)
delete actionValidations.value[index]
//
const newValidations: { [key: number]: { valid: boolean; message: string } } = {}
Object.keys(actionValidations.value).forEach(key => {
const numKey = parseInt(key)
if (numKey > index) {
newValidations[numKey - 1] = actionValidations.value[numKey]
} else if (numKey < index) {
newValidations[numKey] = actionValidations.value[numKey]
}
})
actionValidations.value = newValidations
updateValidationResult()
}
const updateActionType = (index: number, type: number) => {
actions.value[index].type = type
onActionTypeChange(actions.value[index], type)
}
const updateAction = (index: number, action: ActionFormData) => {
actions.value[index] = action
}
const updateActionAlertConfig = (index: number, alertConfigId?: number) => {
actions.value[index].alertConfigId = alertConfigId
}
const onActionTypeChange = (action: ActionFormData, type: number) => {
//
if (isDeviceAction(type)) {
action.alertConfigId = undefined
if (!action.params) {
action.params = {}
}
} else if (isAlertAction(type)) {
action.productId = undefined
action.deviceId = undefined
action.params = undefined
}
}
const handleActionValidate = (index: number, result: { valid: boolean; message: string }) => {
actionValidations.value[index] = result
updateValidationResult()
}
const updateValidationResult = () => {
const validations = Object.values(actionValidations.value)
const allValid = validations.every(v => v.valid)
const hasValidations = validations.length > 0
if (!hasValidations) {
isValid.value = true
validationMessage.value = ''
} else if (allValid) {
isValid.value = true
validationMessage.value = '所有执行器配置验证通过'
} else {
isValid.value = false
const errorMessages = validations
.filter(v => !v.valid)
.map(v => v.message)
validationMessage.value = `执行器配置错误: ${errorMessages.join('; ')}`
}
emit('validate', { valid: isValid.value, message: validationMessage.value })
}
//
watch(() => actions.value.length, () => {
updateValidationResult()
})
</script>
<style scoped>
.action-section {
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.section-icon {
color: var(--el-color-primary);
font-size: 18px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.section-content {
padding: 0;
}
.empty-state {
padding: 40px 0;
text-align: center;
}
.actions-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.action-item {
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
background: var(--el-fill-color-blank);
}
.action-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--el-fill-color-light);
border-bottom: 1px solid var(--el-border-color-lighter);
}
.action-title {
display: flex;
align-items: center;
gap: 8px;
}
.action-icon {
color: var(--el-color-success);
font-size: 16px;
}
.action-content {
padding: 16px;
}
.add-more {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
padding: 16px;
border: 1px dashed var(--el-border-color);
border-radius: 6px;
background: var(--el-fill-color-lighter);
}
.add-more-btn {
flex-shrink: 0;
}
.add-more-text {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.validation-result {
margin-top: 16px;
}
</style>

View File

@ -0,0 +1,110 @@
<!-- 基础信息配置组件 -->
<template>
<el-card class="basic-info-section" shadow="never">
<template #header>
<div class="section-header">
<div class="header-left">
<Icon icon="ep:info-filled" class="section-icon" />
<span class="section-title">基础信息</span>
</div>
<div class="header-right">
<el-tag :type="formData.status === 0 ? 'success' : 'danger'" size="small">
{{ formData.status === 0 ? '启用' : '禁用' }}
</el-tag>
</div>
</div>
</template>
<div class="section-content">
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="场景名称" prop="name" required>
<NameInput v-model="formData.name" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="场景状态" prop="status" required>
<StatusRadio v-model="formData.status" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="场景描述" prop="description">
<DescriptionInput v-model="formData.description" />
</el-form-item>
</div>
</el-card>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import NameInput from '../inputs/NameInput.vue'
import DescriptionInput from '../inputs/DescriptionInput.vue'
import StatusRadio from '../inputs/StatusRadio.vue'
import { RuleSceneFormData } from '@/api/iot/rule/scene/scene.types'
/** 基础信息配置组件 */
defineOptions({ name: 'BasicInfoSection' })
interface Props {
modelValue: RuleSceneFormData
rules?: any
}
interface Emits {
(e: 'update:modelValue', value: RuleSceneFormData): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const formData = useVModel(props, 'modelValue', emit)
</script>
<style scoped>
.basic-info-section {
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.section-icon {
color: var(--el-color-primary);
font-size: 18px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.section-content {
padding: 0;
}
:deep(.el-form-item) {
margin-bottom: 20px;
}
:deep(.el-form-item:last-child) {
margin-bottom: 0;
}
</style>

View File

@ -0,0 +1,183 @@
<!-- 预览区域组件 -->
<template>
<el-card class="preview-section" shadow="never">
<template #header>
<div class="section-header">
<div class="header-left">
<Icon icon="ep:view" class="section-icon" />
<span class="section-title">配置预览</span>
</div>
<div class="header-right">
<el-button
type="primary"
size="small"
@click="handleValidate"
:loading="validating"
>
<Icon icon="ep:check" />
验证配置
</el-button>
</div>
</div>
</template>
<div class="section-content">
<!-- 基础信息预览 -->
<div class="preview-group">
<div class="group-header">
<Icon icon="ep:info-filled" class="group-icon" />
<span class="group-title">基础信息</span>
</div>
<div class="group-content">
<ConfigPreview :form-data="formData" />
</div>
</div>
<!-- 触发器预览 -->
<div class="preview-group">
<div class="group-header">
<Icon icon="ep:lightning" class="group-icon" />
<span class="group-title">触发器配置</span>
<el-tag size="small" type="primary">{{ formData.triggers.length }}</el-tag>
</div>
<div class="group-content">
<TriggerPreview :triggers="formData.triggers" />
</div>
</div>
<!-- 执行器预览 -->
<div class="preview-group">
<div class="group-header">
<Icon icon="ep:setting" class="group-icon" />
<span class="group-title">执行器配置</span>
<el-tag size="small" type="success">{{ formData.actions.length }}</el-tag>
</div>
<div class="group-content">
<ActionPreview :actions="formData.actions" />
</div>
</div>
<!-- 验证结果 -->
<div class="preview-group">
<div class="group-header">
<Icon icon="ep:circle-check" class="group-icon" />
<span class="group-title">验证结果</span>
</div>
<div class="group-content">
<ValidationResult :validation-result="validationResult" />
</div>
</div>
</div>
</el-card>
</template>
<script setup lang="ts">
import ConfigPreview from '../previews/ConfigPreview.vue'
import TriggerPreview from '../previews/TriggerPreview.vue'
import ActionPreview from '../previews/ActionPreview.vue'
import ValidationResult from '../previews/ValidationResult.vue'
import { RuleSceneFormData } from '@/api/iot/rule/scene/scene.types'
/** 预览区域组件 */
defineOptions({ name: 'PreviewSection' })
interface Props {
formData: RuleSceneFormData
validationResult?: { valid: boolean; message?: string } | null
}
interface Emits {
(e: 'validate'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
//
const validating = ref(false)
//
const handleValidate = async () => {
validating.value = true
try {
//
await new Promise(resolve => setTimeout(resolve, 500))
emit('validate')
} finally {
validating.value = false
}
}
</script>
<style scoped>
.preview-section {
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.section-icon {
color: var(--el-color-primary);
font-size: 18px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.section-content {
padding: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.preview-group {
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
background: var(--el-fill-color-blank);
}
.group-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: var(--el-fill-color-light);
border-bottom: 1px solid var(--el-border-color-lighter);
}
.group-icon {
color: var(--el-color-primary);
font-size: 16px;
}
.group-title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.group-content {
padding: 16px;
}
</style>

View File

@ -0,0 +1,395 @@
<!-- 触发器配置组件 -->
<template>
<el-card class="trigger-section" shadow="never">
<template #header>
<div class="section-header">
<div class="header-left">
<Icon icon="ep:lightning" class="section-icon" />
<span class="section-title">触发器配置</span>
<el-tag size="small" type="info">{{ triggers.length }}/{{ maxTriggers }}</el-tag>
</div>
<div class="header-right">
<el-button
type="primary"
size="small"
@click="addTrigger"
:disabled="triggers.length >= maxTriggers"
>
<Icon icon="ep:plus" />
添加触发器
</el-button>
</div>
</div>
</template>
<div class="section-content">
<!-- 空状态 -->
<div v-if="triggers.length === 0" class="empty-state">
<el-empty description="暂无触发器配置">
<el-button type="primary" @click="addTrigger">
<Icon icon="ep:plus" />
添加第一个触发器
</el-button>
</el-empty>
</div>
<!-- 触发器列表 -->
<div v-else class="triggers-list">
<div
v-for="(trigger, index) in triggers"
:key="`trigger-${index}`"
class="trigger-item"
>
<div class="trigger-header">
<div class="trigger-title">
<Icon icon="ep:lightning" class="trigger-icon" />
<span>触发器 {{ index + 1 }}</span>
<el-tag
:type="getTriggerTypeTag(trigger.type)"
size="small"
>
{{ getTriggerTypeName(trigger.type) }}
</el-tag>
</div>
<div class="trigger-actions">
<el-button
type="danger"
size="small"
text
@click="removeTrigger(index)"
v-if="triggers.length > 1"
>
<Icon icon="ep:delete" />
删除
</el-button>
</div>
</div>
<div class="trigger-content">
<!-- 触发类型选择 -->
<TriggerTypeSelector
:model-value="trigger.type"
@update:model-value="(value) => updateTriggerType(index, value)"
@change="onTriggerTypeChange(trigger, $event)"
/>
<!-- 设备触发配置 -->
<DeviceTriggerConfig
v-if="isDeviceTrigger(trigger.type)"
:model-value="trigger"
@update:model-value="(value) => updateTrigger(index, value)"
@validate="(result) => handleTriggerValidate(index, result)"
/>
<!-- 定时触发配置 -->
<TimerTriggerConfig
v-if="trigger.type === TriggerTypeEnum.TIMER"
:model-value="trigger.cronExpression"
@update:model-value="(value) => updateTriggerCronExpression(index, value)"
@validate="(result) => handleTriggerValidate(index, result)"
/>
</div>
</div>
</div>
<!-- 添加提示 -->
<div v-if="triggers.length > 0 && triggers.length < maxTriggers" class="add-more">
<el-button
type="primary"
plain
@click="addTrigger"
class="add-more-btn"
>
<Icon icon="ep:plus" />
继续添加触发器
</el-button>
<span class="add-more-text">
最多可添加 {{ maxTriggers }} 个触发器
</span>
</div>
<!-- 验证结果 -->
<div v-if="validationMessage" class="validation-result">
<el-alert
:title="validationMessage"
:type="isValid ? 'success' : 'error'"
:closable="false"
show-icon
/>
</div>
</div>
</el-card>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import TriggerTypeSelector from '../selectors/TriggerTypeSelector.vue'
import DeviceTriggerConfig from '../configs/DeviceTriggerConfig.vue'
import TimerTriggerConfig from '../configs/TimerTriggerConfig.vue'
import {
TriggerFormData,
IotRuleSceneTriggerTypeEnum as TriggerTypeEnum
} from '@/api/iot/rule/scene/scene.types'
import { createDefaultTriggerData } from '../../utils/transform'
/** 触发器配置组件 */
defineOptions({ name: 'TriggerSection' })
interface Props {
triggers: TriggerFormData[]
}
interface Emits {
(e: 'update:triggers', value: TriggerFormData[]): void
(e: 'validate', result: { valid: boolean; message: string }): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const triggers = useVModel(props, 'triggers', emit)
//
const maxTriggers = 5
//
const triggerValidations = ref<{ [key: number]: { valid: boolean; message: string } }>({})
const validationMessage = ref('')
const isValid = ref(true)
//
const triggerTypeNames = {
[TriggerTypeEnum.DEVICE_STATE_UPDATE]: '设备状态变更',
[TriggerTypeEnum.DEVICE_PROPERTY_POST]: '属性上报',
[TriggerTypeEnum.DEVICE_EVENT_POST]: '事件上报',
[TriggerTypeEnum.DEVICE_SERVICE_INVOKE]: '服务调用',
[TriggerTypeEnum.TIMER]: '定时触发'
}
const triggerTypeTags = {
[TriggerTypeEnum.DEVICE_STATE_UPDATE]: 'warning',
[TriggerTypeEnum.DEVICE_PROPERTY_POST]: 'primary',
[TriggerTypeEnum.DEVICE_EVENT_POST]: 'success',
[TriggerTypeEnum.DEVICE_SERVICE_INVOKE]: 'info',
[TriggerTypeEnum.TIMER]: 'danger'
}
//
const isDeviceTrigger = (type: number) => {
return [
TriggerTypeEnum.DEVICE_STATE_UPDATE,
TriggerTypeEnum.DEVICE_PROPERTY_POST,
TriggerTypeEnum.DEVICE_EVENT_POST,
TriggerTypeEnum.DEVICE_SERVICE_INVOKE
].includes(type)
}
const getTriggerTypeName = (type: number) => {
return triggerTypeNames[type] || '未知类型'
}
const getTriggerTypeTag = (type: number) => {
return triggerTypeTags[type] || 'info'
}
//
const addTrigger = () => {
if (triggers.value.length >= maxTriggers) {
return
}
const newTrigger = createDefaultTriggerData()
triggers.value.push(newTrigger)
}
const removeTrigger = (index: number) => {
triggers.value.splice(index, 1)
delete triggerValidations.value[index]
//
const newValidations: { [key: number]: { valid: boolean; message: string } } = {}
Object.keys(triggerValidations.value).forEach(key => {
const numKey = parseInt(key)
if (numKey > index) {
newValidations[numKey - 1] = triggerValidations.value[numKey]
} else if (numKey < index) {
newValidations[numKey] = triggerValidations.value[numKey]
}
})
triggerValidations.value = newValidations
updateValidationResult()
}
const updateTriggerType = (index: number, type: number) => {
triggers.value[index].type = type
onTriggerTypeChange(triggers.value[index], type)
}
const updateTrigger = (index: number, trigger: TriggerFormData) => {
triggers.value[index] = trigger
}
const updateTriggerCronExpression = (index: number, cronExpression?: string) => {
triggers.value[index].cronExpression = cronExpression
}
const onTriggerTypeChange = (trigger: TriggerFormData, type: number) => {
//
if (type === TriggerTypeEnum.TIMER) {
trigger.productId = undefined
trigger.deviceId = undefined
trigger.identifier = undefined
trigger.operator = undefined
trigger.value = undefined
trigger.conditionGroups = undefined
if (!trigger.cronExpression) {
trigger.cronExpression = '0 0 12 * * ?'
}
} else {
trigger.cronExpression = undefined
if (type === TriggerTypeEnum.DEVICE_STATE_UPDATE) {
trigger.conditionGroups = undefined
} else if (!trigger.conditionGroups) {
trigger.conditionGroups = []
}
}
}
const handleTriggerValidate = (index: number, result: { valid: boolean; message: string }) => {
triggerValidations.value[index] = result
updateValidationResult()
}
const updateValidationResult = () => {
const validations = Object.values(triggerValidations.value)
const allValid = validations.every(v => v.valid)
const hasValidations = validations.length > 0
if (!hasValidations) {
isValid.value = true
validationMessage.value = ''
} else if (allValid) {
isValid.value = true
validationMessage.value = '所有触发器配置验证通过'
} else {
isValid.value = false
const errorMessages = validations
.filter(v => !v.valid)
.map(v => v.message)
validationMessage.value = `触发器配置错误: ${errorMessages.join('; ')}`
}
emit('validate', { valid: isValid.value, message: validationMessage.value })
}
//
watch(() => triggers.value.length, () => {
updateValidationResult()
})
</script>
<style scoped>
.trigger-section {
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.section-icon {
color: var(--el-color-primary);
font-size: 18px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.section-content {
padding: 0;
}
.empty-state {
padding: 40px 0;
text-align: center;
}
.triggers-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.trigger-item {
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
background: var(--el-fill-color-blank);
}
.trigger-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--el-fill-color-light);
border-bottom: 1px solid var(--el-border-color-lighter);
}
.trigger-title {
display: flex;
align-items: center;
gap: 8px;
}
.trigger-icon {
color: var(--el-color-warning);
font-size: 16px;
}
.trigger-content {
padding: 16px;
}
.add-more {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
padding: 16px;
border: 1px dashed var(--el-border-color);
border-radius: 6px;
background: var(--el-fill-color-lighter);
}
.add-more-btn {
flex-shrink: 0;
}
.add-more-text {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.validation-result {
margin-top: 16px;
}
</style>

View File

@ -0,0 +1,145 @@
<!-- 执行器类型选择组件 -->
<template>
<div class="action-type-selector">
<el-form-item label="执行类型" required>
<el-select
v-model="localValue"
placeholder="请选择执行类型"
@change="handleChange"
class="w-full"
>
<el-option
v-for="option in actionTypeOptions"
:key="option.value"
:label="option.label"
:value="option.value"
>
<div class="action-option">
<div class="option-content">
<Icon :icon="option.icon" class="option-icon" />
<div class="option-info">
<div class="option-label">{{ option.label }}</div>
<div class="option-desc">{{ option.description }}</div>
</div>
</div>
<el-tag :type="option.tag" size="small">
{{ option.category }}
</el-tag>
</div>
</el-option>
</el-select>
</el-form-item>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import { IotRuleSceneActionTypeEnum } from '@/api/iot/rule/scene/scene.types'
/** 执行器类型选择组件 */
defineOptions({ name: 'ActionTypeSelector' })
interface Props {
modelValue: number
}
interface Emits {
(e: 'update:modelValue', value: number): void
(e: 'change', value: number): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const localValue = useVModel(props, 'modelValue', emit)
//
const actionTypeOptions = [
{
value: IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET,
label: '设备属性设置',
description: '设置目标设备的属性值',
icon: 'ep:edit',
tag: 'primary',
category: '设备控制'
},
{
value: IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE,
label: '设备服务调用',
description: '调用目标设备的服务',
icon: 'ep:service',
tag: 'success',
category: '设备控制'
},
{
value: IotRuleSceneActionTypeEnum.ALERT_TRIGGER,
label: '触发告警',
description: '触发系统告警通知',
icon: 'ep:warning',
tag: 'danger',
category: '告警通知'
},
{
value: IotRuleSceneActionTypeEnum.ALERT_RECOVER,
label: '恢复告警',
description: '恢复已触发的告警',
icon: 'ep:circle-check',
tag: 'warning',
category: '告警通知'
}
]
//
const handleChange = (value: number) => {
emit('change', value)
}
</script>
<style scoped>
.action-type-selector {
width: 100%;
}
.action-option {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 4px 0;
}
.option-content {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.option-icon {
font-size: 18px;
color: var(--el-color-primary);
flex-shrink: 0;
}
.option-info {
flex: 1;
}
.option-label {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 2px;
}
.option-desc {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
}
:deep(.el-select-dropdown__item) {
height: auto;
padding: 8px 20px;
}
</style>

View File

@ -0,0 +1,275 @@
<!-- 操作符选择器组件 -->
<template>
<div class="operator-selector">
<el-select
v-model="localValue"
placeholder="请选择操作符"
@change="handleChange"
class="w-full"
>
<el-option
v-for="operator in availableOperators"
:key="operator.value"
:label="operator.label"
:value="operator.value"
>
<div class="operator-option">
<div class="option-content">
<div class="option-label">{{ operator.label }}</div>
<div class="option-symbol">{{ operator.symbol }}</div>
</div>
<div class="option-desc">{{ operator.description }}</div>
</div>
</el-option>
</el-select>
<!-- 操作符说明 -->
<div v-if="selectedOperator" class="operator-description">
<div class="desc-content">
<Icon icon="ep:info-filled" class="desc-icon" />
<span class="desc-text">{{ selectedOperator.description }}</span>
</div>
<div v-if="selectedOperator.example" class="desc-example">
<span class="example-label">示例</span>
<code class="example-code">{{ selectedOperator.example }}</code>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
/** 操作符选择器组件 */
defineOptions({ name: 'OperatorSelector' })
interface Props {
modelValue?: string
propertyType?: string
}
interface Emits {
(e: 'update:modelValue', value: string): void
(e: 'change', value: string): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const localValue = useVModel(props, 'modelValue', emit)
//
const allOperators = [
{
value: '=',
label: '等于',
symbol: '=',
description: '值完全相等时触发',
example: 'temperature = 25',
supportedTypes: ['int', 'float', 'double', 'string', 'bool', 'enum']
},
{
value: '!=',
label: '不等于',
symbol: '≠',
description: '值不相等时触发',
example: 'power != false',
supportedTypes: ['int', 'float', 'double', 'string', 'bool', 'enum']
},
{
value: '>',
label: '大于',
symbol: '>',
description: '值大于指定值时触发',
example: 'temperature > 30',
supportedTypes: ['int', 'float', 'double', 'date']
},
{
value: '>=',
label: '大于等于',
symbol: '≥',
description: '值大于或等于指定值时触发',
example: 'humidity >= 80',
supportedTypes: ['int', 'float', 'double', 'date']
},
{
value: '<',
label: '小于',
symbol: '<',
description: '值小于指定值时触发',
example: 'temperature < 10',
supportedTypes: ['int', 'float', 'double', 'date']
},
{
value: '<=',
label: '小于等于',
symbol: '≤',
description: '值小于或等于指定值时触发',
example: 'battery <= 20',
supportedTypes: ['int', 'float', 'double', 'date']
},
{
value: 'in',
label: '包含于',
symbol: '∈',
description: '值在指定列表中时触发',
example: 'status in [1,2,3]',
supportedTypes: ['int', 'float', 'string', 'enum']
},
{
value: 'between',
label: '介于',
symbol: '⊆',
description: '值在指定范围内时触发',
example: 'temperature between 20,30',
supportedTypes: ['int', 'float', 'double', 'date']
},
{
value: 'contains',
label: '包含',
symbol: '⊃',
description: '字符串包含指定内容时触发',
example: 'message contains "error"',
supportedTypes: ['string']
},
{
value: 'startsWith',
label: '开始于',
symbol: '⊢',
description: '字符串以指定内容开始时触发',
example: 'deviceName startsWith "sensor"',
supportedTypes: ['string']
},
{
value: 'endsWith',
label: '结束于',
symbol: '⊣',
description: '字符串以指定内容结束时触发',
example: 'fileName endsWith ".log"',
supportedTypes: ['string']
}
]
//
const availableOperators = computed(() => {
if (!props.propertyType) {
return allOperators
}
return allOperators.filter(op =>
op.supportedTypes.includes(props.propertyType!)
)
})
const selectedOperator = computed(() => {
return allOperators.find(op => op.value === localValue.value)
})
//
const handleChange = (value: string) => {
emit('change', value)
}
//
watch(() => props.propertyType, () => {
//
if (localValue.value && selectedOperator.value) {
if (!selectedOperator.value.supportedTypes.includes(props.propertyType || '')) {
localValue.value = ''
}
}
})
</script>
<style scoped>
.operator-selector {
width: 100%;
}
.operator-option {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 4px 0;
}
.option-content {
display: flex;
align-items: center;
gap: 8px;
}
.option-label {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.option-symbol {
font-size: 16px;
color: var(--el-color-primary);
font-weight: bold;
min-width: 20px;
text-align: center;
}
.option-desc {
font-size: 12px;
color: var(--el-text-color-secondary);
max-width: 120px;
text-align: right;
}
.operator-description {
margin-top: 8px;
padding: 8px 12px;
background: var(--el-fill-color-light);
border-radius: 4px;
border: 1px solid var(--el-border-color-lighter);
}
.desc-content {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.desc-icon {
color: var(--el-color-primary);
font-size: 12px;
flex-shrink: 0;
}
.desc-text {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.desc-example {
display: flex;
align-items: center;
gap: 6px;
margin-left: 18px;
}
.example-label {
font-size: 11px;
color: var(--el-text-color-placeholder);
}
.example-code {
font-size: 11px;
color: var(--el-color-primary);
background: var(--el-fill-color-blank);
padding: 2px 4px;
border-radius: 2px;
font-family: 'Courier New', monospace;
}
:deep(.el-select-dropdown__item) {
height: auto;
padding: 8px 20px;
}
</style>

View File

@ -0,0 +1,370 @@
<!-- 产品设备选择器组件 -->
<template>
<div class="product-device-selector">
<el-row :gutter="16">
<!-- 产品选择 -->
<el-col :span="12">
<el-form-item label="选择产品" required>
<el-select
v-model="localProductId"
placeholder="请选择产品"
filterable
clearable
@change="handleProductChange"
class="w-full"
:loading="productLoading"
>
<el-option
v-for="product in productList"
:key="product.id"
:label="product.name"
:value="product.id"
>
<div class="product-option">
<div class="option-content">
<div class="option-name">{{ product.name }}</div>
<div class="option-key">{{ product.productKey }}</div>
</div>
<el-tag size="small" :type="product.status === 0 ? 'success' : 'danger'">
{{ product.status === 0 ? '正常' : '禁用' }}
</el-tag>
</div>
</el-option>
</el-select>
</el-form-item>
</el-col>
<!-- 设备选择模式 -->
<el-col :span="12">
<el-form-item label="设备选择模式" required>
<el-radio-group v-model="deviceSelectionMode" @change="handleDeviceSelectionModeChange">
<el-radio value="specific">选择设备</el-radio>
<el-radio value="all">全部设备</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<!-- 具体设备选择 -->
<el-row v-if="deviceSelectionMode === 'specific'" :gutter="16">
<el-col :span="24">
<el-form-item label="选择设备" required>
<el-select
v-model="localDeviceId"
placeholder="请先选择产品"
filterable
clearable
@change="handleDeviceChange"
class="w-full"
:loading="deviceLoading"
:disabled="!localProductId"
>
<el-option
v-for="device in deviceList"
:key="device.id"
:label="device.deviceName"
:value="device.id"
>
<div class="device-option">
<div class="option-content">
<div class="option-name">{{ device.deviceName }}</div>
<div class="option-nickname">{{ device.nickname || '无备注' }}</div>
</div>
<el-tag
size="small"
:type="getDeviceStatusTag(device.state)"
>
{{ getDeviceStatusText(device.state) }}
</el-tag>
</div>
</el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<!-- 选择结果展示 -->
<div v-if="localProductId && (localDeviceId !== undefined)" class="selection-result">
<div class="result-header">
<Icon icon="ep:check" class="result-icon" />
<span class="result-title">已选择设备</span>
</div>
<div class="result-content">
<div class="result-item">
<span class="result-label">产品</span>
<span class="result-value">{{ selectedProduct?.name }}</span>
<el-tag size="small" type="primary">{{ selectedProduct?.productKey }}</el-tag>
</div>
<div class="result-item">
<span class="result-label">设备</span>
<span v-if="deviceSelectionMode === 'all'" class="result-value"></span>
<span v-else class="result-value">{{ selectedDevice?.deviceName }}</span>
<el-tag
v-if="deviceSelectionMode === 'all'"
size="small"
type="warning"
>
全部
</el-tag>
<el-tag
v-else
size="small"
:type="getDeviceStatusTag(selectedDevice?.state)"
>
{{ getDeviceStatusText(selectedDevice?.state) }}
</el-tag>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import { ProductApi } from '@/api/iot/product/product'
import { DeviceApi } from '@/api/iot/device/device'
/** 产品设备选择器组件 */
defineOptions({ name: 'ProductDeviceSelector' })
interface Props {
productId?: number
deviceId?: number
}
interface Emits {
(e: 'update:productId', value?: number): void
(e: 'update:deviceId', value?: number): void
(e: 'change', value: { productId?: number; deviceId?: number }): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const localProductId = useVModel(props, 'productId', emit)
const localDeviceId = useVModel(props, 'deviceId', emit)
//
const deviceSelectionMode = ref<'specific' | 'all'>('specific')
//
const productLoading = ref(false)
const deviceLoading = ref(false)
const productList = ref<any[]>([])
const deviceList = ref<any[]>([])
//
const selectedProduct = computed(() => {
return productList.value.find(p => p.id === localProductId.value)
})
const selectedDevice = computed(() => {
return deviceList.value.find(d => d.id === localDeviceId.value)
})
//
const getDeviceStatusText = (state?: number) => {
switch (state) {
case 0: return '未激活'
case 1: return '在线'
case 2: return '离线'
default: return '未知'
}
}
const getDeviceStatusTag = (state?: number) => {
switch (state) {
case 0: return 'info'
case 1: return 'success'
case 2: return 'danger'
default: return 'info'
}
}
//
const handleProductChange = async (productId?: number) => {
localProductId.value = productId
localDeviceId.value = undefined
deviceList.value = []
if (productId) {
await getDeviceList(productId)
}
emitChange()
}
const handleDeviceChange = (deviceId?: number) => {
localDeviceId.value = deviceId
emitChange()
}
const handleDeviceSelectionModeChange = (mode: 'specific' | 'all') => {
deviceSelectionMode.value = mode
if (mode === 'all') {
// ID0
localDeviceId.value = 0
} else {
// ID
localDeviceId.value = undefined
}
emitChange()
}
const emitChange = () => {
emit('change', {
productId: localProductId.value,
deviceId: localDeviceId.value
})
}
// API
const getProductList = async () => {
productLoading.value = true
try {
const data = await ProductApi.getSimpleProductList()
productList.value = data || []
} catch (error) {
console.error('获取产品列表失败:', error)
//
productList.value = [
{ id: 1, name: '智能温度传感器', productKey: 'temp_sensor_001', status: 0 },
{ id: 2, name: '智能空调控制器', productKey: 'ac_controller_001', status: 0 },
{ id: 3, name: '智能门锁', productKey: 'smart_lock_001', status: 0 }
]
} finally {
productLoading.value = false
}
}
const getDeviceList = async (productId: number) => {
deviceLoading.value = true
try {
const data = await DeviceApi.getSimpleDeviceList(undefined, productId)
deviceList.value = data || []
} catch (error) {
console.error('获取设备列表失败:', error)
//
deviceList.value = [
{ id: 1, deviceName: 'sensor_001', nickname: '客厅温度传感器', state: 1, productId },
{ id: 2, deviceName: 'sensor_002', nickname: '卧室温度传感器', state: 2, productId },
{ id: 3, deviceName: 'sensor_003', nickname: '厨房温度传感器', state: 1, productId }
]
} finally {
deviceLoading.value = false
}
}
//
onMounted(async () => {
await getProductList()
// ID
if (localDeviceId.value === 0) {
deviceSelectionMode.value = 'all'
} else if (localDeviceId.value) {
deviceSelectionMode.value = 'specific'
}
if (localProductId.value) {
await getDeviceList(localProductId.value)
}
})
//
watch(() => localProductId.value, async (newProductId) => {
if (newProductId && deviceList.value.length === 0) {
await getDeviceList(newProductId)
}
})
</script>
<style scoped>
.product-device-selector {
width: 100%;
}
.product-option,
.device-option {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 4px 0;
}
.option-content {
flex: 1;
}
.option-name {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 2px;
}
.option-key,
.option-nickname {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.selection-result {
margin-top: 16px;
padding: 12px;
background: var(--el-fill-color-light);
border-radius: 6px;
border: 1px solid var(--el-border-color-lighter);
}
.result-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.result-icon {
color: var(--el-color-success);
font-size: 16px;
}
.result-title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.result-content {
display: flex;
flex-direction: column;
gap: 6px;
margin-left: 22px;
}
.result-item {
display: flex;
align-items: center;
gap: 8px;
}
.result-label {
font-size: 12px;
color: var(--el-text-color-secondary);
min-width: 40px;
}
.result-value {
font-size: 12px;
color: var(--el-text-color-primary);
font-weight: 500;
}
:deep(.el-select-dropdown__item) {
height: auto;
padding: 8px 20px;
}
</style>

View File

@ -0,0 +1,423 @@
<!-- 属性选择器组件 -->
<template>
<div class="property-selector">
<el-select
v-model="localValue"
placeholder="请选择监控项"
filterable
clearable
@change="handleChange"
class="w-full"
:loading="loading"
>
<el-option-group
v-for="group in propertyGroups"
:key="group.label"
:label="group.label"
>
<el-option
v-for="property in group.options"
:key="property.identifier"
:label="property.name"
:value="property.identifier"
>
<div class="property-option">
<div class="option-content">
<div class="option-name">{{ property.name }}</div>
<div class="option-identifier">{{ property.identifier }}</div>
</div>
<div class="option-meta">
<el-tag :type="getPropertyTypeTag(property.dataType)" size="small">
{{ getPropertyTypeName(property.dataType) }}
</el-tag>
</div>
</div>
</el-option>
</el-option-group>
</el-select>
<!-- 属性详情 -->
<div v-if="selectedProperty" class="property-details">
<div class="details-header">
<Icon icon="ep:info-filled" class="details-icon" />
<span class="details-title">{{ selectedProperty.name }}</span>
<el-tag :type="getPropertyTypeTag(selectedProperty.dataType)" size="small">
{{ getPropertyTypeName(selectedProperty.dataType) }}
</el-tag>
</div>
<div class="details-content">
<div class="detail-item">
<span class="detail-label">标识符</span>
<span class="detail-value">{{ selectedProperty.identifier }}</span>
</div>
<div v-if="selectedProperty.description" class="detail-item">
<span class="detail-label">描述</span>
<span class="detail-value">{{ selectedProperty.description }}</span>
</div>
<div v-if="selectedProperty.unit" class="detail-item">
<span class="detail-label">单位</span>
<span class="detail-value">{{ selectedProperty.unit }}</span>
</div>
<div v-if="selectedProperty.range" class="detail-item">
<span class="detail-label">取值范围</span>
<span class="detail-value">{{ selectedProperty.range }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import { IotRuleSceneTriggerTypeEnum } from '@/api/iot/rule/scene/scene.types'
import { ThingModelApi, ThingModelData } from '@/api/iot/thingmodel'
import { IoTThingModelTypeEnum } from '@/views/iot/utils/constants'
import type { IotThingModelTSLRespVO, PropertySelectorItem } from './types'
/** 属性选择器组件 */
defineOptions({ name: 'PropertySelector' })
interface Props {
modelValue?: string
triggerType: number
productId?: number
deviceId?: number
}
interface Emits {
(e: 'update:modelValue', value: string): void
(e: 'change', value: { type: string; config: any }): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const localValue = useVModel(props, 'modelValue', emit)
//
const loading = ref(false)
const propertyList = ref<PropertySelectorItem[]>([])
const thingModelTSL = ref<IotThingModelTSLRespVO | null>(null)
//
const propertyGroups = computed(() => {
const groups: { label: string; options: any[] }[] = []
if (props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST) {
groups.push({
label: '设备属性',
options: propertyList.value.filter(p => p.type === IoTThingModelTypeEnum.PROPERTY)
})
}
if (props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST) {
groups.push({
label: '设备事件',
options: propertyList.value.filter(p => p.type === IoTThingModelTypeEnum.EVENT)
})
}
if (props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE) {
groups.push({
label: '设备服务',
options: propertyList.value.filter(p => p.type === IoTThingModelTypeEnum.SERVICE)
})
}
return groups.filter(group => group.options.length > 0)
})
const selectedProperty = computed(() => {
return propertyList.value.find(p => p.identifier === localValue.value)
})
//
const getPropertyTypeName = (dataType: string) => {
const typeMap = {
'int': '整数',
'float': '浮点数',
'double': '双精度',
'text': '字符串',
'bool': '布尔值',
'enum': '枚举',
'date': '日期',
'struct': '结构体',
'array': '数组'
}
return typeMap[dataType] || dataType
}
const getPropertyTypeTag = (dataType: string) => {
const tagMap = {
'int': 'primary',
'float': 'success',
'double': 'success',
'text': 'info',
'bool': 'warning',
'enum': 'danger',
'date': 'primary',
'struct': 'info',
'array': 'warning'
}
return tagMap[dataType] || 'info'
}
//
const handleChange = (value: string) => {
const property = propertyList.value.find(p => p.identifier === value)
if (property) {
emit('change', {
type: property.dataType,
config: property
})
}
}
// TSL
const getThingModelTSL = async () => {
if (!props.productId) {
thingModelTSL.value = null
propertyList.value = []
return
}
loading.value = true
try {
thingModelTSL.value = await ThingModelApi.getThingModelTSLByProductId(props.productId)
parseThingModelData()
} catch (error) {
console.error('获取物模型TSL失败:', error)
// TSL
await getThingModelList()
} finally {
loading.value = false
}
}
//
const getThingModelList = async () => {
if (!props.productId) {
propertyList.value = []
return
}
try {
const data = await ThingModelApi.getThingModelList({ productId: props.productId })
propertyList.value = data || []
} catch (error) {
console.error('获取物模型列表失败:', error)
propertyList.value = []
}
}
// TSL
const parseThingModelData = () => {
const tsl = thingModelTSL.value
const properties: PropertySelectorItem[] = []
if (tsl) {
//
if (tsl.properties && Array.isArray(tsl.properties)) {
tsl.properties.forEach((prop) => {
properties.push({
identifier: prop.identifier,
name: prop.name,
description: prop.description,
dataType: prop.dataType,
type: IoTThingModelTypeEnum.PROPERTY,
accessMode: prop.accessMode,
required: prop.required,
unit: getPropertyUnit(prop),
range: getPropertyRange(prop),
property: prop
})
})
}
//
if (tsl.events && Array.isArray(tsl.events)) {
tsl.events.forEach((event) => {
properties.push({
identifier: event.identifier,
name: event.name,
description: event.description,
dataType: 'struct',
type: IoTThingModelTypeEnum.EVENT,
eventType: event.type,
required: event.required,
outputParams: event.outputParams,
event: event
})
})
}
//
if (tsl.services && Array.isArray(tsl.services)) {
tsl.services.forEach((service) => {
properties.push({
identifier: service.identifier,
name: service.name,
description: service.description,
dataType: 'struct',
type: IoTThingModelTypeEnum.SERVICE,
callType: service.callType,
required: service.required,
inputParams: service.inputParams,
outputParams: service.outputParams,
service: service
})
})
}
}
propertyList.value = properties
}
//
const getPropertyUnit = (property: any) => {
if (!property) return undefined
//
if (property.dataSpecs && property.dataSpecs.unit) {
return property.dataSpecs.unit
}
return undefined
}
//
const getPropertyRange = (property: any) => {
if (!property) return undefined
//
if (property.dataSpecs) {
const specs = property.dataSpecs
if (specs.min !== undefined && specs.max !== undefined) {
return `${specs.min}~${specs.max}`
}
}
//
if (property.dataSpecsList && Array.isArray(property.dataSpecsList)) {
return property.dataSpecsList.map((item: any) => `${item.name}(${item.value})`).join(', ')
}
return undefined
}
//
const getDataRange = (dataSpecs: any) => {
if (!dataSpecs) return undefined
if (dataSpecs.min !== undefined && dataSpecs.max !== undefined) {
return `${dataSpecs.min}~${dataSpecs.max}`
}
if (dataSpecs.dataSpecsList && Array.isArray(dataSpecs.dataSpecsList)) {
return dataSpecs.dataSpecsList.map((item: any) => `${item.name}(${item.value})`).join(', ')
}
return undefined
}
//
watch(() => props.productId, () => {
getThingModelTSL()
}, { immediate: true })
//
watch(() => props.triggerType, () => {
localValue.value = ''
})
</script>
<style scoped>
.property-selector {
width: 100%;
}
.property-option {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 4px 0;
}
.option-content {
flex: 1;
}
.option-name {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 2px;
}
.option-identifier {
font-size: 12px;
color: var(--el-text-color-secondary);
font-family: 'Courier New', monospace;
}
.option-meta {
flex-shrink: 0;
}
.property-details {
margin-top: 12px;
padding: 12px;
background: var(--el-fill-color-light);
border-radius: 6px;
border: 1px solid var(--el-border-color-lighter);
}
.details-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.details-icon {
color: var(--el-color-primary);
font-size: 14px;
}
.details-title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.details-content {
display: flex;
flex-direction: column;
gap: 4px;
margin-left: 22px;
}
.detail-item {
display: flex;
align-items: center;
gap: 8px;
}
.detail-label {
font-size: 12px;
color: var(--el-text-color-secondary);
min-width: 60px;
}
.detail-value {
font-size: 12px;
color: var(--el-text-color-primary);
font-family: 'Courier New', monospace;
}
:deep(.el-select-dropdown__item) {
height: auto;
padding: 8px 20px;
}
</style>

View File

@ -0,0 +1,264 @@
<!-- 触发器类型选择组件 -->
<template>
<div class="trigger-type-selector">
<el-form-item label="触发类型" required>
<el-select
v-model="localValue"
placeholder="请选择触发类型"
@change="handleChange"
class="w-full"
>
<el-option
v-for="option in triggerTypeOptions"
:key="option.value"
:label="option.label"
:value="option.value"
>
<div class="trigger-option">
<div class="option-content">
<Icon :icon="option.icon" class="option-icon" />
<div class="option-info">
<div class="option-label">{{ option.label }}</div>
<div class="option-desc">{{ option.description }}</div>
</div>
</div>
<el-tag :type="option.tag" size="small">
{{ option.category }}
</el-tag>
</div>
</el-option>
</el-select>
</el-form-item>
<!-- 类型说明 -->
<div v-if="selectedOption" class="type-description">
<div class="desc-header">
<Icon :icon="selectedOption.icon" class="desc-icon" />
<span class="desc-title">{{ selectedOption.label }}</span>
</div>
<div class="desc-content">
<p class="desc-text">{{ selectedOption.description }}</p>
<div class="desc-features">
<div
v-for="feature in selectedOption.features"
:key="feature"
class="feature-item"
>
<Icon icon="ep:check" class="feature-icon" />
<span class="feature-text">{{ feature }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import { IotRuleSceneTriggerTypeEnum } from '@/api/iot/rule/scene/scene.types'
/** 触发器类型选择组件 */
defineOptions({ name: 'TriggerTypeSelector' })
interface Props {
modelValue: number
}
interface Emits {
(e: 'update:modelValue', value: number): void
(e: 'change', value: number): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const localValue = useVModel(props, 'modelValue', emit)
//
const triggerTypeOptions = [
{
value: IotRuleSceneTriggerTypeEnum.DEVICE_STATE_UPDATE,
label: '设备状态变更',
description: '当设备上线、离线状态发生变化时触发',
icon: 'ep:connection',
tag: 'warning',
category: '设备状态',
features: [
'监控设备连接状态',
'实时响应设备变化',
'无需配置额外条件'
]
},
{
value: IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST,
label: '设备属性上报',
description: '当设备属性值满足指定条件时触发',
icon: 'ep:data-line',
tag: 'primary',
category: '数据监控',
features: [
'监控设备属性变化',
'支持多种比较条件',
'可配置阈值范围'
]
},
{
value: IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST,
label: '设备事件上报',
description: '当设备上报特定事件时触发',
icon: 'ep:bell',
tag: 'success',
category: '事件监控',
features: [
'监控设备事件',
'支持事件参数过滤',
'实时事件响应'
]
},
{
value: IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE,
label: '设备服务调用',
description: '当设备服务被调用时触发',
icon: 'ep:service',
tag: 'info',
category: '服务监控',
features: [
'监控服务调用',
'支持参数条件',
'服务执行跟踪'
]
},
{
value: IotRuleSceneTriggerTypeEnum.TIMER,
label: '定时触发',
description: '按照设定的时间计划定时触发',
icon: 'ep:timer',
tag: 'danger',
category: '定时任务',
features: [
'支持CRON表达式',
'灵活的时间配置',
'可视化时间设置'
]
}
]
//
const selectedOption = computed(() => {
return triggerTypeOptions.find(option => option.value === localValue.value)
})
//
const handleChange = (value: number) => {
emit('change', value)
}
</script>
<style scoped>
.trigger-type-selector {
width: 100%;
}
.trigger-option {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 4px 0;
}
.option-content {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.option-icon {
font-size: 18px;
color: var(--el-color-primary);
flex-shrink: 0;
}
.option-info {
flex: 1;
}
.option-label {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 2px;
}
.option-desc {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
}
.type-description {
margin-top: 16px;
padding: 16px;
background: var(--el-fill-color-light);
border-radius: 6px;
border: 1px solid var(--el-border-color-lighter);
}
.desc-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.desc-icon {
font-size: 20px;
color: var(--el-color-primary);
}
.desc-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.desc-content {
margin-left: 28px;
}
.desc-text {
font-size: 14px;
color: var(--el-text-color-regular);
margin: 0 0 12px 0;
line-height: 1.5;
}
.desc-features {
display: flex;
flex-direction: column;
gap: 6px;
}
.feature-item {
display: flex;
align-items: center;
gap: 6px;
}
.feature-icon {
font-size: 12px;
color: var(--el-color-success);
flex-shrink: 0;
}
.feature-text {
font-size: 12px;
color: var(--el-text-color-secondary);
}
:deep(.el-select-dropdown__item) {
height: auto;
padding: 8px 20px;
}
</style>

View File

@ -0,0 +1,168 @@
// IoT物模型TSL数据类型定义
/** 物模型TSL响应数据结构 */
export interface IotThingModelTSLRespVO {
productId: number
productKey: string
properties: ThingModelProperty[]
events: ThingModelEvent[]
services: ThingModelService[]
}
/** 物模型属性 */
export interface ThingModelProperty {
identifier: string
name: string
accessMode: string
required?: boolean
dataType: string
description?: string
dataSpecs?: ThingModelDataSpecs
dataSpecsList?: ThingModelDataSpecs[]
}
/** 物模型事件 */
export interface ThingModelEvent {
identifier: string
name: string
required?: boolean
type: string
description?: string
outputParams?: ThingModelParam[]
method?: string
}
/** 物模型服务 */
export interface ThingModelService {
identifier: string
name: string
required?: boolean
callType: string
description?: string
inputParams?: ThingModelParam[]
outputParams?: ThingModelParam[]
method?: string
}
/** 物模型参数 */
export interface ThingModelParam {
identifier: string
name: string
direction: string
paraOrder?: number
dataType: string
dataSpecs?: ThingModelDataSpecs
dataSpecsList?: ThingModelDataSpecs[]
}
/** 数值型数据规范 */
export interface ThingModelNumericDataSpec {
dataType: 'int' | 'float' | 'double'
max: string
min: string
step: string
precise?: string
defaultValue?: string
unit?: string
unitName?: string
}
/** 布尔/枚举型数据规范 */
export interface ThingModelBoolOrEnumDataSpecs {
dataType: 'bool' | 'enum'
name: string
value: number
}
/** 文本/时间型数据规范 */
export interface ThingModelDateOrTextDataSpecs {
dataType: 'text' | 'date'
length?: number
defaultValue?: string
}
/** 数组型数据规范 */
export interface ThingModelArrayDataSpecs {
dataType: 'array'
size: number
childDataType: string
dataSpecsList?: ThingModelDataSpecs[]
}
/** 结构体型数据规范 */
export interface ThingModelStructDataSpecs {
dataType: 'struct'
identifier: string
name: string
accessMode: string
required?: boolean
childDataType: string
dataSpecs?: ThingModelDataSpecs
dataSpecsList?: ThingModelDataSpecs[]
}
/** 数据规范联合类型 */
export type ThingModelDataSpecs =
| ThingModelNumericDataSpec
| ThingModelBoolOrEnumDataSpecs
| ThingModelDateOrTextDataSpecs
| ThingModelArrayDataSpecs
| ThingModelStructDataSpecs
/** 属性选择器内部使用的统一数据结构 */
export interface PropertySelectorItem {
identifier: string
name: string
description?: string
dataType: string
type: number // IoTThingModelTypeEnum
accessMode?: string
required?: boolean
unit?: string
range?: string
eventType?: string
callType?: string
inputParams?: ThingModelParam[]
outputParams?: ThingModelParam[]
property?: ThingModelProperty
event?: ThingModelEvent
service?: ThingModelService
}
/** 数据类型枚举 */
export enum DataTypeEnum {
INT = 'int',
FLOAT = 'float',
DOUBLE = 'double',
ENUM = 'enum',
BOOL = 'bool',
TEXT = 'text',
DATE = 'date',
STRUCT = 'struct',
ARRAY = 'array'
}
/** 访问模式枚举 */
export enum AccessModeEnum {
READ = 'r',
READ_write = 'rw'
}
/** 事件类型枚举 */
export enum EventTypeEnum {
INFO = 'info',
ALERT = 'alert',
ERROR = 'error'
}
/** 调用类型枚举 */
export enum CallTypeEnum {
ASYNC = 'async',
SYNC = 'sync'
}
/** 参数方向枚举 */
export enum ParamDirectionEnum {
INPUT = 'input',
OUTPUT = 'output'
}

View File

@ -1,192 +1,658 @@
<!-- 改进的场景联动规则管理页面 -->
<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" /> 新增
<!-- 页面头部 -->
<div class="page-header">
<div class="header-left">
<h2 class="page-title">
<Icon icon="ep:connection" class="title-icon" />
场景联动规则
</h2>
<p class="page-description"> 通过配置触发条件和执行动作实现设备间的智能联动控制 </p>
</div>
<div class="header-right">
<el-button type="primary" @click="handleAdd">
<Icon icon="ep:plus" />
新增规则
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
</div>
</div>
<!-- 列表 -->
<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">
<template #default="{ row }"> {{ row.triggers?.length }} </template>
</el-table-column>
<el-table-column label="执行器" align="center" prop="actions">
<template #default="{ row }"> {{ row.actions?.length }} </template>
</el-table-column>
<el-table-column
label="创建时间"
align="center"
prop="createTime"
:formatter="dateFormatter"
width="180px"
<!-- 搜索和筛选 -->
<el-card class="search-card" shadow="never">
<el-form
ref="queryFormRef"
:model="queryParams"
:inline="true"
label-width="80px"
@submit.prevent
>
<el-form-item label="规则名称">
<el-input
v-model="queryParams.name"
placeholder="请输入规则名称"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="规则状态">
<el-select
v-model="queryParams.status"
placeholder="请选择状态"
clearable
class="!w-240px"
>
<el-option label="启用" :value="0" />
<el-option label="禁用" :value="1" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">
<Icon icon="ep:search" />
搜索
</el-button>
<el-button @click="resetQuery">
<Icon icon="ep:refresh" />
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 统计卡片 -->
<el-row :gutter="16" class="stats-row">
<el-col :span="6">
<el-card class="stats-card" shadow="hover">
<div class="stats-content">
<div class="stats-icon total">
<Icon icon="ep:document" />
</div>
<div class="stats-info">
<div class="stats-number">{{ statistics.total }}</div>
<div class="stats-label">总规则数</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stats-card" shadow="hover">
<div class="stats-content">
<div class="stats-icon enabled">
<Icon icon="ep:check" />
</div>
<div class="stats-info">
<div class="stats-number">{{ statistics.enabled }}</div>
<div class="stats-label">启用规则</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stats-card" shadow="hover">
<div class="stats-content">
<div class="stats-icon disabled">
<Icon icon="ep:close" />
</div>
<div class="stats-info">
<div class="stats-number">{{ statistics.disabled }}</div>
<div class="stats-label">禁用规则</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stats-card" shadow="hover">
<div class="stats-content">
<div class="stats-icon active">
<Icon icon="ep:lightning" />
</div>
<div class="stats-info">
<div class="stats-number">{{ statistics.triggered }}</div>
<div class="stats-label">今日触发</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 数据表格 -->
<el-card class="table-card" shadow="never">
<el-table v-loading="loading" :data="list" stripe @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" />
<el-table-column label="规则名称" prop="name" min-width="200">
<template #default="{ row }">
<div class="rule-name-cell">
<span class="rule-name">{{ row.name }}</span>
<el-tag
:type="row.status === 0 ? 'success' : 'danger'"
size="small"
class="status-tag"
>
{{ row.status === 0 ? '启用' : '禁用' }}
</el-tag>
</div>
<div v-if="row.description" class="rule-description">
{{ row.description }}
</div>
</template>
</el-table-column>
<el-table-column label="触发条件" min-width="250">
<template #default="{ row }">
<div class="trigger-summary">
<el-tag
v-for="(trigger, index) in getTriggerSummary(row)"
:key="index"
type="primary"
size="small"
class="trigger-tag"
>
{{ trigger }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="执行动作" min-width="250">
<template #default="{ row }">
<div class="action-summary">
<el-tag
v-for="(action, index) in getActionSummary(row)"
:key="index"
type="success"
size="small"
class="action-tag"
>
{{ action }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="最近触发" prop="lastTriggeredTime" width="180">
<template #default="{ row }">
<span v-if="row.lastTriggeredTime">
{{ formatDate(row.lastTriggeredTime) }}
</span>
<span v-else class="text-gray-400">未触发</span>
</template>
</el-table-column>
<el-table-column label="创建时间" prop="createTime" width="180">
<template #default="{ row }">
{{ formatDate(row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<div class="action-buttons">
<el-button type="primary" link @click="handleEdit(row)">
<Icon icon="ep:edit" />
编辑
</el-button>
<el-button
:type="row.status === 0 ? 'warning' : 'success'"
link
@click="handleToggleStatus(row)"
>
<Icon :icon="row.status === 0 ? 'ep:video-pause' : 'ep:video-play'" />
{{ row.status === 0 ? '禁用' : '启用' }}
</el-button>
<el-button type="danger" link @click="handleDelete(row)">
<Icon icon="ep:delete" />
删除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
<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"
</el-card>
<!-- 批量操作 -->
<div v-if="selectedRows.length > 0" class="batch-actions">
<el-card shadow="always">
<div class="batch-content">
<span class="batch-info"> 已选择 {{ selectedRows.length }} </span>
<div class="batch-buttons">
<el-button @click="handleBatchEnable">
<Icon icon="ep:video-play" />
批量启用
</el-button>
<el-button @click="handleBatchDisable">
<Icon icon="ep:video-pause" />
批量禁用
</el-button>
<el-button type="danger" @click="handleBatchDelete">
<Icon icon="ep:delete" />
批量删除
</el-button>
</div>
</div>
</el-card>
</div>
<!-- 表单对话框 -->
<RuleSceneForm
v-model="formVisible"
:rule-scene="currentRule"
@success="handleFormSuccess"
/>
</ContentWrap>
<!-- 表单弹窗添加/修改 -->
<RuleSceneForm ref="formRef" @success="getList" />
</template>
<script setup lang="ts">
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { dateFormatter } from '@/utils/formatTime'
import { RuleSceneApi } from '@/api/iot/rule/scene'
import RuleSceneForm from './RuleSceneForm.vue'
import { ContentWrap } from '@/components/ContentWrap'
import RuleSceneForm from './components/RuleSceneForm.vue'
import { IotRuleScene } from '@/api/iot/rule/scene/scene.types'
import { getRuleSceneSummary } from './utils/transform'
import { formatDate } from '@/utils/formatTime'
/** IoT 场景联动 列表 */
defineOptions({ name: 'IotRuleScene' })
/** 改进的场景联动规则管理页面 */
defineOptions({ name: 'ImprovedRuleSceneIndex' })
const message = useMessage() //
const { t } = useI18n() //
const message = useMessage()
// const { t } = useI18n()
const loading = ref(true) //
const list = ref<IotRuleScene[]>([]) //
const total = ref(0) //
//
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: undefined,
description: undefined,
status: undefined,
createTime: []
name: '',
status: undefined as number | undefined
})
const queryFormRef = ref() //
/** 查询列表 */
//
const loading = ref(true)
const list = ref<IotRuleScene[]>([])
const total = ref(0)
const selectedRows = ref<IotRuleScene[]>([])
//
const formVisible = ref(false)
const currentRule = ref<IotRuleScene>()
//
const statistics = ref({
total: 0,
enabled: 0,
disabled: 0,
triggered: 0
})
//
const getList = async () => {
loading.value = true
try {
const data = await RuleSceneApi.getRuleScenePage(queryParams)
list.value = data.list
total.value = data.total
// API
const mockData = {
list: [
{
id: 1,
name: '温度过高自动降温',
description: '当温度超过30度时自动开启空调',
status: 0,
triggers: [
{
type: 2,
productKey: 'temp_sensor',
deviceNames: ['sensor_001'],
conditions: [
{
type: 'property',
identifier: 'temperature',
parameters: [{ operator: '>', value: '30' }]
}
]
}
],
actions: [
{
type: 1,
deviceControl: {
productKey: 'air_conditioner',
deviceNames: ['ac_001'],
type: 'property',
identifier: 'power',
params: { power: 1 }
}
}
],
lastTriggeredTime: new Date().toISOString(),
createTime: new Date().toISOString()
},
{
id: 2,
name: '设备离线告警',
description: '设备离线时发送告警通知',
status: 0,
triggers: [
{ type: 1, productKey: 'smart_device', deviceNames: ['device_001', 'device_002'] }
],
actions: [{ type: 100, alertConfigId: 1 }],
createTime: new Date().toISOString()
}
],
total: 2
}
list.value = mockData.list
total.value = mockData.total
//
updateStatistics()
} catch (error) {
console.error('获取列表失败:', error)
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
//
const updateStatistics = () => {
statistics.value = {
total: list.value.length,
enabled: list.value.filter((item) => item.status === 0).length,
disabled: list.value.filter((item) => item.status === 1).length,
triggered: list.value.filter((item) => item.lastTriggeredTime).length
}
}
//
const getTriggerSummary = (rule: IotRuleScene) => {
return getRuleSceneSummary(rule).triggerSummary
}
//
const getActionSummary = (rule: IotRuleScene) => {
return getRuleSceneSummary(rule).actionSummary
}
//
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
queryParams.name = ''
queryParams.status = undefined
handleQuery()
}
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
const handleAdd = () => {
currentRule.value = undefined
formVisible.value = true
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
const handleEdit = (row: IotRuleScene) => {
currentRule.value = row
formVisible.value = true
}
const handleDelete = async (row: IotRuleScene) => {
try {
//
await message.delConfirm()
//
await RuleSceneApi.deleteRuleScene(id)
message.success(t('common.delSuccess'))
//
await getList()
} catch {}
await ElMessageBox.confirm('确定要删除这个规则吗?', '提示', {
type: 'warning'
})
// API
message.success('删除成功')
getList()
} catch (error) {
//
}
}
/** 初始化 **/
const handleToggleStatus = async (row: IotRuleScene) => {
try {
const newStatus = row.status === 0 ? 1 : 0
const action = newStatus === 0 ? '启用' : '禁用'
await ElMessageBox.confirm(`确定要${action}这个规则吗?`, '提示', {
type: 'warning'
})
// API
row.status = newStatus
message.success(`${action}成功`)
updateStatistics()
} catch (error) {
//
}
}
const handleSelectionChange = (selection: IotRuleScene[]) => {
selectedRows.value = selection
}
const handleBatchEnable = async () => {
try {
await ElMessageBox.confirm(`确定要启用选中的 ${selectedRows.value.length} 个规则吗?`, '提示', {
type: 'warning'
})
// API
selectedRows.value.forEach((row) => {
row.status = 0
})
message.success('批量启用成功')
updateStatistics()
} catch (error) {
//
}
}
const handleBatchDisable = async () => {
try {
await ElMessageBox.confirm(`确定要禁用选中的 ${selectedRows.value.length} 个规则吗?`, '提示', {
type: 'warning'
})
// API
selectedRows.value.forEach((row) => {
row.status = 1
})
message.success('批量禁用成功')
updateStatistics()
} catch (error) {
//
}
}
const handleBatchDelete = async () => {
try {
await ElMessageBox.confirm(`确定要删除选中的 ${selectedRows.value.length} 个规则吗?`, '提示', {
type: 'warning'
})
// API
message.success('批量删除成功')
getList()
} catch (error) {
//
}
}
const handleFormSuccess = () => {
getList()
}
//
onMounted(() => {
getList()
})
</script>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.header-left {
flex: 1;
}
.page-title {
display: flex;
align-items: center;
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 600;
color: #303133;
}
.title-icon {
margin-right: 12px;
color: #409eff;
}
.page-description {
margin: 0;
color: #606266;
font-size: 14px;
}
.search-card {
margin-bottom: 16px;
}
.stats-row {
margin-bottom: 16px;
}
.stats-card {
cursor: pointer;
transition: all 0.3s;
}
.stats-card:hover {
transform: translateY(-2px);
}
.stats-content {
display: flex;
align-items: center;
}
.stats-icon {
width: 48px;
height: 48px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: white;
margin-right: 16px;
}
.stats-icon.total {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.stats-icon.enabled {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.stats-icon.disabled {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.stats-icon.active {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
}
.stats-number {
font-size: 24px;
font-weight: 600;
color: #303133;
line-height: 1;
}
.stats-label {
font-size: 14px;
color: #909399;
margin-top: 4px;
}
.table-card {
margin-bottom: 20px;
}
.rule-name-cell {
display: flex;
align-items: center;
gap: 8px;
}
.rule-name {
font-weight: 500;
color: #303133;
}
.status-tag {
flex-shrink: 0;
}
.rule-description {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.trigger-summary,
.action-summary {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.trigger-tag,
.action-tag {
margin: 0;
}
.action-buttons {
display: flex;
gap: 8px;
}
.batch-actions {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
}
.batch-content {
display: flex;
align-items: center;
gap: 16px;
}
.batch-info {
font-weight: 500;
color: #303133;
}
.batch-buttons {
display: flex;
gap: 8px;
}
</style>

View File

@ -0,0 +1,548 @@
/**
* IoT
*/
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
// 错误类型枚举
export enum ErrorType {
VALIDATION = 'validation',
NETWORK = 'network',
BUSINESS = 'business',
SYSTEM = 'system',
PERMISSION = 'permission'
}
// 错误级别枚举
export enum ErrorLevel {
INFO = 'info',
WARNING = 'warning',
ERROR = 'error',
CRITICAL = 'critical'
}
// 错误信息接口
export interface ErrorInfo {
type: ErrorType
level: ErrorLevel
code?: string
message: string
details?: any
timestamp?: Date
context?: string
}
// 用户反馈选项
export interface FeedbackOptions {
showMessage?: boolean
showNotification?: boolean
showDialog?: boolean
autoClose?: boolean
duration?: number
confirmText?: string
cancelText?: string
}
/**
*
*/
export class SceneRuleErrorHandler {
private static instance: SceneRuleErrorHandler
private errorLog: ErrorInfo[] = []
private maxLogSize = 100
private constructor() {}
static getInstance(): SceneRuleErrorHandler {
if (!SceneRuleErrorHandler.instance) {
SceneRuleErrorHandler.instance = new SceneRuleErrorHandler()
}
return SceneRuleErrorHandler.instance
}
/**
*
*/
handleError(error: ErrorInfo, options: FeedbackOptions = {}): Promise<boolean> {
// 记录错误日志
this.logError(error)
// 根据错误类型和级别选择处理方式
return this.processError(error, options)
}
/**
*
*/
private logError(error: ErrorInfo): void {
const errorWithTimestamp = {
...error,
timestamp: new Date()
}
this.errorLog.unshift(errorWithTimestamp)
// 限制日志大小
if (this.errorLog.length > this.maxLogSize) {
this.errorLog = this.errorLog.slice(0, this.maxLogSize)
}
// 开发环境下打印到控制台
if (import.meta.env.DEV) {
console.error('[SceneRule Error]', errorWithTimestamp)
}
}
/**
*
*/
private async processError(error: ErrorInfo, options: FeedbackOptions): Promise<boolean> {
const defaultOptions: FeedbackOptions = {
showMessage: true,
showNotification: false,
showDialog: false,
autoClose: true,
duration: 3000,
confirmText: '确定',
cancelText: '取消'
}
const finalOptions = { ...defaultOptions, ...options }
try {
// 根据错误级别决定反馈方式
switch (error.level) {
case ErrorLevel.INFO:
return this.handleInfoError(error, finalOptions)
case ErrorLevel.WARNING:
return this.handleWarningError(error, finalOptions)
case ErrorLevel.ERROR:
return this.handleNormalError(error, finalOptions)
case ErrorLevel.CRITICAL:
return this.handleCriticalError(error, finalOptions)
default:
return this.handleNormalError(error, finalOptions)
}
} catch (e) {
console.error('Error handler failed:', e)
return false
}
}
/**
*
*/
private async handleInfoError(error: ErrorInfo, options: FeedbackOptions): Promise<boolean> {
if (options.showMessage) {
ElMessage.info({
message: error.message,
duration: options.duration,
showClose: !options.autoClose
})
}
return true
}
/**
*
*/
private async handleWarningError(error: ErrorInfo, options: FeedbackOptions): Promise<boolean> {
if (options.showNotification) {
ElNotification.warning({
title: '警告',
message: error.message,
duration: options.duration
})
} else if (options.showMessage) {
ElMessage.warning({
message: error.message,
duration: options.duration,
showClose: !options.autoClose
})
}
return true
}
/**
*
*/
private async handleNormalError(error: ErrorInfo, options: FeedbackOptions): Promise<boolean> {
if (options.showDialog) {
try {
await ElMessageBox.alert(error.message, '错误', {
type: 'error',
confirmButtonText: options.confirmText
})
return true
} catch (e) {
return false
}
} else if (options.showNotification) {
ElNotification.error({
title: '错误',
message: error.message,
duration: options.duration
})
} else if (options.showMessage) {
ElMessage.error({
message: error.message,
duration: options.duration,
showClose: !options.autoClose
})
}
return true
}
/**
*
*/
private async handleCriticalError(error: ErrorInfo, _: FeedbackOptions): Promise<boolean> {
try {
await ElMessageBox.confirm(`${error.message}\n\n是否重新加载页面`, '严重错误', {
type: 'error',
confirmButtonText: '重新加载',
cancelButtonText: '继续使用'
})
// 用户选择重新加载
window.location.reload()
return true
} catch (e) {
// 用户选择继续使用
return false
}
}
/**
*
*/
getErrorLog(): ErrorInfo[] {
return [...this.errorLog]
}
/**
*
*/
clearErrorLog(): void {
this.errorLog = []
}
/**
*
*/
exportErrorLog(): string {
return JSON.stringify(this.errorLog, null, 2)
}
}
/**
*
*/
export const errorHandler = SceneRuleErrorHandler.getInstance()
/**
*
*/
export function handleValidationError(message: string, context?: string): Promise<boolean> {
return errorHandler.handleError(
{
type: ErrorType.VALIDATION,
level: ErrorLevel.WARNING,
message,
context
},
{
showMessage: true,
duration: 4000
}
)
}
/**
*
*/
export function handleNetworkError(error: any, context?: string): Promise<boolean> {
let message = '网络请求失败'
if (error?.response?.status) {
switch (error.response.status) {
case 400:
message = '请求参数错误'
break
case 401:
message = '未授权,请重新登录'
break
case 403:
message = '权限不足'
break
case 404:
message = '请求的资源不存在'
break
case 500:
message = '服务器内部错误'
break
case 502:
message = '网关错误'
break
case 503:
message = '服务暂不可用'
break
default:
message = `网络错误 (${error.response.status})`
}
} else if (error?.message) {
message = error.message
}
return errorHandler.handleError(
{
type: ErrorType.NETWORK,
level: ErrorLevel.ERROR,
code: error?.response?.status?.toString(),
message,
details: error,
context
},
{
showMessage: true,
duration: 5000
}
)
}
/**
*
*/
export function handleBusinessError(
message: string,
code?: string,
context?: string
): Promise<boolean> {
return errorHandler.handleError(
{
type: ErrorType.BUSINESS,
level: ErrorLevel.ERROR,
code,
message,
context
},
{
showMessage: true,
duration: 4000
}
)
}
/**
*
*/
export function handleSystemError(error: any, context?: string): Promise<boolean> {
const message = error?.message || '系统发生未知错误'
return errorHandler.handleError(
{
type: ErrorType.SYSTEM,
level: ErrorLevel.CRITICAL,
message,
details: error,
context
},
{
showDialog: true
}
)
}
/**
*
*/
export function handlePermissionError(
message: string = '权限不足',
context?: string
): Promise<boolean> {
return errorHandler.handleError(
{
type: ErrorType.PERMISSION,
level: ErrorLevel.WARNING,
message,
context
},
{
showNotification: true,
duration: 5000
}
)
}
/**
*
*/
export function showSuccess(message: string, duration: number = 3000): void {
ElMessage.success({
message,
duration,
showClose: false
})
}
/**
*
*/
export function showInfo(message: string, duration: number = 3000): void {
ElMessage.info({
message,
duration,
showClose: false
})
}
/**
*
*/
export function showWarning(message: string, duration: number = 4000): void {
ElMessage.warning({
message,
duration,
showClose: true
})
}
/**
*
*/
export function showConfirm(
message: string,
title: string = '确认',
options: {
type?: 'info' | 'success' | 'warning' | 'error'
confirmText?: string
cancelText?: string
} = {}
): Promise<boolean> {
const defaultOptions = {
type: 'warning' as const,
confirmText: '确定',
cancelText: '取消'
}
const finalOptions = { ...defaultOptions, ...options }
return ElMessageBox.confirm(message, title, {
type: finalOptions.type,
confirmButtonText: finalOptions.confirmText,
cancelButtonText: finalOptions.cancelText
})
.then(() => true)
.catch(() => false)
}
/**
*
*/
export class LoadingManager {
private loadingStates = new Map<string, boolean>()
private loadingInstances = new Map<string, any>()
/**
*
*/
startLoading(key: string, _: string = '加载中...'): void {
if (this.loadingStates.get(key)) {
return // 已经在加载中
}
this.loadingStates.set(key, true)
// 这里可以根据需要创建全局加载实例
// const loading = ElLoading.service({
// lock: true,
// text,
// background: 'rgba(0, 0, 0, 0.7)'
// })
// this.loadingInstances.set(key, loading)
}
/**
*
*/
stopLoading(key: string): void {
this.loadingStates.set(key, false)
const loading = this.loadingInstances.get(key)
if (loading) {
loading.close()
this.loadingInstances.delete(key)
}
}
/**
*
*/
isLoading(key: string): boolean {
return this.loadingStates.get(key) || false
}
/**
*
*/
clearAll(): void {
this.loadingInstances.forEach((loading) => loading.close())
this.loadingStates.clear()
this.loadingInstances.clear()
}
}
export const loadingManager = new LoadingManager()
/**
*
*/
export async function withErrorHandling<T>(
operation: () => Promise<T>,
options: {
loadingKey?: string
loadingText?: string
context?: string
showSuccess?: boolean
successMessage?: string
errorHandler?: (error: any) => Promise<boolean>
} = {}
): Promise<T | null> {
const {
loadingKey,
loadingText = '处理中...',
context,
showSuccess = false,
// successMessage = '操作成功',
errorHandler: customErrorHandler
} = options
try {
// 开始加载
if (loadingKey) {
loadingManager.startLoading(loadingKey, loadingText)
}
// 执行操作
const result = await operation()
// 显示成功消息
if (showSuccess) {
// showSuccess(successMessage)
}
return result
} catch (error) {
// 使用自定义错误处理器或默认处理器
if (customErrorHandler) {
await customErrorHandler(error)
} else {
await handleNetworkError(error, context)
}
return null
} finally {
// 结束加载
if (loadingKey) {
loadingManager.stopLoading(loadingKey)
}
}
}

View File

@ -0,0 +1,406 @@
/**
* IoT
*/
import {
IotRuleScene,
TriggerConfig,
ActionConfig,
RuleSceneFormData,
TriggerFormData,
ActionFormData
} from '@/api/iot/rule/scene/scene.types'
import { generateUUID } from '@/utils'
/**
*
*/
export function createDefaultFormData(): RuleSceneFormData {
return {
name: '',
description: '',
status: 0,
triggers: [],
actions: []
}
}
/**
*
*/
export function createDefaultTriggerData(): TriggerFormData {
return {
type: 2, // 默认为属性上报
productId: undefined,
deviceId: undefined,
identifier: undefined,
operator: undefined,
value: undefined,
cronExpression: undefined,
conditionGroups: []
}
}
/**
*
*/
export function createDefaultActionData(): ActionFormData {
return {
type: 1, // 默认为属性设置
productId: undefined,
deviceId: undefined,
params: {},
alertConfigId: undefined
}
}
/**
* API
*/
export function transformFormToApi(formData: RuleSceneFormData): IotRuleScene {
// 这里需要根据实际API结构进行转换
// 暂时返回基本结构
return {
id: formData.id,
name: formData.name,
description: formData.description,
status: Number(formData.status),
triggers: [], // 需要根据实际API结构转换
actions: [] // 需要根据实际API结构转换
} as IotRuleScene
}
/**
* API
*/
export function transformApiToForm(apiData: IotRuleScene): RuleSceneFormData {
return {
...apiData,
status: Number(apiData.status), // 确保状态为数字类型
triggers:
apiData.triggers?.map((trigger) => ({
...trigger,
type: Number(trigger.type),
// 为每个触发器添加唯一标识符,解决组件索引重用问题
key: generateUUID()
})) || [],
actions:
apiData.actions?.map((action) => ({
...action,
type: Number(action.type),
// 为每个执行器添加唯一标识符,解决组件索引重用问题
key: generateUUID()
})) || []
}
}
/**
*
*/
export function createDefaultTriggerConfig(type?: number): TriggerConfig {
const baseConfig: TriggerConfig = {
key: generateUUID(),
type: type || 2, // 默认为物模型属性上报
productKey: '',
deviceNames: [],
conditions: []
}
// 定时触发的默认配置
if (type === 100) {
return {
...baseConfig,
cronExpression: '0 0 12 * * ?', // 默认每天中午12点
productKey: undefined,
deviceNames: undefined,
conditions: undefined
}
}
// 设备状态变更的默认配置
if (type === 1) {
return {
...baseConfig,
conditions: undefined // 设备状态变更不需要条件
}
}
// 其他设备触发类型的默认配置
return {
...baseConfig,
conditions: [
{
type: 'property',
identifier: 'set',
parameters: [
{
identifier: '',
operator: '=',
value: ''
}
]
}
]
}
}
/**
*
*/
export function createDefaultActionConfig(type?: number): ActionConfig {
const baseConfig: ActionConfig = {
key: generateUUID(),
type: type || 1 // 默认为设备属性设置
}
// 告警相关的默认配置
if (type === 100 || type === 101) {
return {
...baseConfig,
alertConfigId: undefined
}
}
// 设备控制的默认配置
return {
...baseConfig,
deviceControl: {
productKey: '',
deviceNames: [],
type: 'property',
identifier: 'set',
params: {}
}
}
}
/**
*
*/
export function deepClone<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') {
return obj
}
if (obj instanceof Date) {
return new Date(obj.getTime()) as unknown as T
}
if (obj instanceof Array) {
return obj.map((item) => deepClone(item)) as unknown as T
}
if (typeof obj === 'object') {
const clonedObj = {} as T
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clonedObj[key] = deepClone(obj[key])
}
}
return clonedObj
}
return obj
}
/**
*
*/
export function cleanFormData(data: IotRuleScene): IotRuleScene {
const cleaned = deepClone(data)
// 清理触发器数据
cleaned.triggers =
cleaned.triggers?.filter((trigger) => {
// 移除类型为空的触发器
if (!trigger.type) return false
// 定时触发器必须有CRON表达式
if (trigger.type === 100 && !trigger.cronExpression) return false
// 设备触发器必须有产品和设备
if (trigger.type !== 100 && (!trigger.productKey || !trigger.deviceNames?.length))
return false
return true
}) || []
// 清理执行器数据
cleaned.actions =
cleaned.actions?.filter((action) => {
// 移除类型为空的执行器
if (!action.type) return false
// 告警类型必须有告警配置ID
if ((action.type === 100 || action.type === 101) && !action.alertConfigId) return false
// 设备控制类型必须有完整的设备控制配置
if (
(action.type === 1 || action.type === 2) &&
(!action.deviceControl?.productKey ||
!action.deviceControl?.deviceNames?.length ||
!action.deviceControl?.identifier ||
!action.deviceControl?.params ||
Object.keys(action.deviceControl.params).length === 0)
) {
return false
}
return true
}) || []
return cleaned
}
/**
* CRON
*/
export function formatCronExpression(cron: string): string {
if (!cron) return ''
// 简单的CRON表达式解析和格式化
const parts = cron.trim().split(' ')
if (parts.length < 5) return cron
const [second, minute, hour] = parts
// 构建可读的描述
let description = ''
if (second === '0' && minute === '0') {
if (hour === '*') {
description = '每小时'
} else if (hour.includes('/')) {
const interval = hour.split('/')[1]
description = `${interval}小时`
} else {
description = `每天${hour}`
}
} else if (second === '0') {
if (minute === '*') {
description = '每分钟'
} else if (minute.includes('/')) {
const interval = minute.split('/')[1]
description = `${interval}分钟`
} else {
description = `每小时第${minute}分钟`
}
} else {
if (second === '*') {
description = '每秒'
} else if (second.includes('/')) {
const interval = second.split('/')[1]
description = `${interval}`
}
}
return description || cron
}
/**
*
*/
export function validateAndFixData(data: IotRuleScene): IotRuleScene {
const fixed = deepClone(data)
// 确保必要字段存在
if (!fixed.triggers) fixed.triggers = []
if (!fixed.actions) fixed.actions = []
// 修复触发器数据
fixed.triggers = fixed.triggers.map((trigger) => {
const fixedTrigger = { ...trigger }
// 确保有key
if (!fixedTrigger.key) {
fixedTrigger.key = generateUUID()
}
// 定时触发器不需要产品和设备信息
if (fixedTrigger.type === 100) {
fixedTrigger.productKey = undefined
fixedTrigger.deviceNames = undefined
fixedTrigger.conditions = undefined
}
return fixedTrigger
})
// 修复执行器数据
fixed.actions = fixed.actions.map((action) => {
const fixedAction = { ...action }
// 确保有key
if (!fixedAction.key) {
fixedAction.key = generateUUID()
}
// 确保类型为数字
if (typeof fixedAction.type === 'string') {
fixedAction.type = Number(fixedAction.type)
}
// 修复设备控制参数字段名
if (fixedAction.deviceControl && 'data' in fixedAction.deviceControl) {
fixedAction.deviceControl.params = (fixedAction.deviceControl as any).data
delete (fixedAction.deviceControl as any).data
}
return fixedAction
})
return fixed
}
/**
* key
*/
export function isRuleSceneEqual(a: IotRuleScene, b: IotRuleScene): boolean {
const cleanA = transformFormToApi(a)
const cleanB = transformFormToApi(b)
return JSON.stringify(cleanA) === JSON.stringify(cleanB)
}
/**
*
*/
export function getRuleSceneSummary(ruleScene: IotRuleScene): {
triggerSummary: string[]
actionSummary: string[]
} {
const triggerSummary =
ruleScene.triggers?.map((trigger) => {
switch (trigger.type) {
case 1:
return `设备状态变更 (${trigger.deviceNames?.length || 0}个设备)`
case 2:
return `属性上报 (${trigger.deviceNames?.length || 0}个设备)`
case 3:
return `事件上报 (${trigger.deviceNames?.length || 0}个设备)`
case 4:
return `服务调用 (${trigger.deviceNames?.length || 0}个设备)`
case 100:
return `定时触发 (${formatCronExpression(trigger.cronExpression || '')})`
default:
return '未知触发类型'
}
}) || []
const actionSummary =
ruleScene.actions?.map((action) => {
switch (action.type) {
case 1:
return `属性设置 (${action.deviceControl?.deviceNames?.length || 0}个设备)`
case 2:
return `服务调用 (${action.deviceControl?.deviceNames?.length || 0}个设备)`
case 100:
return '告警触发'
case 101:
return '告警恢复'
default:
return '未知执行类型'
}
}) || []
return { triggerSummary, actionSummary }
}

View File

@ -0,0 +1,278 @@
/**
* IoT
*/
import { FormValidationRules, IotRuleScene, TriggerConfig, ActionConfig } from '@/api/iot/rule/scene/scene.types'
import { IotRuleSceneTriggerTypeEnum, IotRuleSceneActionTypeEnum } from '@/api/iot/rule/scene/scene.types'
/**
*
*/
export const getBaseValidationRules = (): FormValidationRules => ({
name: [
{ required: true, message: '场景名称不能为空', trigger: 'blur' },
{ type: 'string', min: 1, max: 50, message: '场景名称长度应在1-50个字符之间', trigger: 'blur' }
],
status: [
{ required: true, message: '场景状态不能为空', trigger: 'change' },
{ type: 'enum', enum: [0, 1], message: '状态值必须为0或1', trigger: 'change' }
],
description: [
{ type: 'string', max: 200, message: '场景描述不能超过200个字符', trigger: 'blur' }
],
triggers: [
{ required: true, message: '触发器数组不能为空', trigger: 'change' },
{ type: 'array', min: 1, message: '至少需要一个触发器', trigger: 'change' }
],
actions: [
{ required: true, message: '执行器数组不能为空', trigger: 'change' },
{ type: 'array', min: 1, message: '至少需要一个执行器', trigger: 'change' }
]
})
/**
* CRON
*/
export function validateCronExpression(cron: string): boolean {
if (!cron || cron.trim().length === 0) return false
// 基础的CRON表达式正则验证支持6位和7位格式
const cronRegex = /^(\*|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|\*\/([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])) (\*|([0-9]|1[0-9]|2[0-3])|\*\/([0-9]|1[0-9]|2[0-3])) (\*|([1-9]|1[0-9]|2[0-9]|3[0-1])|\*\/([1-9]|1[0-9]|2[0-9]|3[0-1])) (\*|([1-9]|1[0-2])|\*\/([1-9]|1[0-2])) (\*|([0-6])|\*\/([0-6]))( (\*|([1-9][0-9]{3})|\*\/([1-9][0-9]{3})))?$/
return cronRegex.test(cron.trim())
}
/**
*
*/
export function validateDeviceNames(deviceNames: string[]): boolean {
return Array.isArray(deviceNames) &&
deviceNames.length > 0 &&
deviceNames.every(name => name && name.trim().length > 0)
}
/**
*
*/
export function validateCompareValue(operator: string, value: string): boolean {
if (!value || value.trim().length === 0) return false
const trimmedValue = value.trim()
switch (operator) {
case 'between':
case 'not between':
const betweenValues = trimmedValue.split(',')
return betweenValues.length === 2 &&
betweenValues.every(v => v.trim().length > 0) &&
!isNaN(Number(betweenValues[0].trim())) &&
!isNaN(Number(betweenValues[1].trim()))
case 'in':
case 'not in':
const inValues = trimmedValue.split(',')
return inValues.length > 0 && inValues.every(v => v.trim().length > 0)
case '>':
case '>=':
case '<':
case '<=':
return !isNaN(Number(trimmedValue))
case '=':
case '!=':
case 'like':
case 'not null':
default:
return true
}
}
/**
*
*/
export function validateTriggerConfig(trigger: TriggerConfig): { valid: boolean; message?: string } {
if (!trigger.type) {
return { valid: false, message: '触发类型不能为空' }
}
// 定时触发验证
if (trigger.type === IotRuleSceneTriggerTypeEnum.TIMER) {
if (!trigger.cronExpression) {
return { valid: false, message: 'CRON表达式不能为空' }
}
if (!validateCronExpression(trigger.cronExpression)) {
return { valid: false, message: 'CRON表达式格式不正确' }
}
return { valid: true }
}
// 设备触发验证
if (!trigger.productKey) {
return { valid: false, message: '产品标识不能为空' }
}
if (!trigger.deviceNames || !validateDeviceNames(trigger.deviceNames)) {
return { valid: false, message: '设备名称不能为空' }
}
// 设备状态变更无需额外条件验证
if (trigger.type === IotRuleSceneTriggerTypeEnum.DEVICE_STATE_UPDATE) {
return { valid: true }
}
// 其他设备触发类型需要验证条件
if (!trigger.conditions || trigger.conditions.length === 0) {
return { valid: false, message: '触发条件不能为空' }
}
// 验证每个条件的参数
for (const condition of trigger.conditions) {
if (!condition.parameters || condition.parameters.length === 0) {
return { valid: false, message: '触发条件参数不能为空' }
}
for (const param of condition.parameters) {
if (!param.operator) {
return { valid: false, message: '操作符不能为空' }
}
if (!validateCompareValue(param.operator, param.value)) {
return { valid: false, message: `操作符 "${param.operator}" 对应的比较值格式不正确` }
}
}
}
return { valid: true }
}
/**
*
*/
export function validateActionConfig(action: ActionConfig): { valid: boolean; message?: string } {
if (!action.type) {
return { valid: false, message: '执行类型不能为空' }
}
// 告警触发/恢复验证
if (action.type === IotRuleSceneActionTypeEnum.ALERT_TRIGGER ||
action.type === IotRuleSceneActionTypeEnum.ALERT_RECOVER) {
if (!action.alertConfigId) {
return { valid: false, message: '告警配置ID不能为空' }
}
return { valid: true }
}
// 设备控制验证
if (action.type === IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET ||
action.type === IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE) {
if (!action.deviceControl) {
return { valid: false, message: '设备控制配置不能为空' }
}
const { deviceControl } = action
if (!deviceControl.productKey) {
return { valid: false, message: '产品标识不能为空' }
}
if (!deviceControl.deviceNames || !validateDeviceNames(deviceControl.deviceNames)) {
return { valid: false, message: '设备名称不能为空' }
}
if (!deviceControl.type) {
return { valid: false, message: '消息类型不能为空' }
}
if (!deviceControl.identifier) {
return { valid: false, message: '消息标识符不能为空' }
}
if (!deviceControl.params || Object.keys(deviceControl.params).length === 0) {
return { valid: false, message: '参数不能为空' }
}
return { valid: true }
}
return { valid: false, message: '未知的执行类型' }
}
/**
*
*/
export function validateRuleScene(ruleScene: IotRuleScene): { valid: boolean; message?: string } {
// 基础字段验证
if (!ruleScene.name || ruleScene.name.trim().length === 0) {
return { valid: false, message: '场景名称不能为空' }
}
if (ruleScene.status !== 0 && ruleScene.status !== 1) {
return { valid: false, message: '场景状态必须为0或1' }
}
if (!ruleScene.triggers || ruleScene.triggers.length === 0) {
return { valid: false, message: '至少需要一个触发器' }
}
if (!ruleScene.actions || ruleScene.actions.length === 0) {
return { valid: false, message: '至少需要一个执行器' }
}
// 验证每个触发器
for (let i = 0; i < ruleScene.triggers.length; i++) {
const triggerResult = validateTriggerConfig(ruleScene.triggers[i])
if (!triggerResult.valid) {
return { valid: false, message: `触发器${i + 1}: ${triggerResult.message}` }
}
}
// 验证每个执行器
for (let i = 0; i < ruleScene.actions.length; i++) {
const actionResult = validateActionConfig(ruleScene.actions[i])
if (!actionResult.valid) {
return { valid: false, message: `执行器${i + 1}: ${actionResult.message}` }
}
}
return { valid: true }
}
/**
*
*/
export function getOperatorOptions() {
return [
{ value: '=', label: '等于' },
{ value: '!=', label: '不等于' },
{ value: '>', label: '大于' },
{ value: '>=', label: '大于等于' },
{ value: '<', label: '小于' },
{ value: '<=', label: '小于等于' },
{ value: 'in', label: '包含' },
{ value: 'not in', label: '不包含' },
{ value: 'between', label: '介于之间' },
{ value: 'not between', label: '不在之间' },
{ value: 'like', label: '字符串匹配' },
{ value: 'not null', label: '非空' }
]
}
/**
*
*/
export function getTriggerTypeOptions() {
return [
{ value: IotRuleSceneTriggerTypeEnum.DEVICE_STATE_UPDATE, label: '设备上下线变更' },
{ value: IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST, label: '物模型属性上报' },
{ value: IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST, label: '设备事件上报' },
{ value: IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE, label: '设备服务调用' },
{ value: IotRuleSceneTriggerTypeEnum.TIMER, label: '定时触发' }
]
}
/**
*
*/
export function getActionTypeOptions() {
return [
{ value: IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET, label: '设备属性设置' },
{ value: IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE, label: '设备服务调用' },
{ value: IotRuleSceneActionTypeEnum.ALERT_TRIGGER, label: '告警触发' },
{ value: IotRuleSceneActionTypeEnum.ALERT_RECOVER, label: '告警恢复' }
]
}