perf:【IoT 物联网】场景联动重构优化
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 @super:autoRefreshEnable,autoRefreshTimer;对应上
|
||||
const autoRefresh = ref(false) // 自动刷新开关
|
||||
let autoRefreshTimer: any = null // 自动刷新定时器
|
||||
|
||||
// 消息方法选项
|
||||
const methodOptions = computed(() => {
|
||||
@@ -172,6 +172,7 @@ watch(
|
||||
onBeforeUnmount(() => {
|
||||
if (autoRefreshTimer) {
|
||||
clearInterval(autoRefreshTimer)
|
||||
autoRefreshTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 @AI:unocss -->
|
||||
<el-form-item style="float: right">
|
||||
<!--TODO @AI:有个 bug:回车后,会刷新,修复下 -->
|
||||
<el-form-item class="float-right">
|
||||
<el-input
|
||||
v-model="queryParams.name"
|
||||
placeholder="请输入任务名称"
|
||||
|
||||
192
src/views/iot/rule/scene/111index.vue
Normal file
192
src/views/iot/rule/scene/111index.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<el-form
|
||||
class="-mb-15px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="68px"
|
||||
>
|
||||
<el-form-item label="场景名称" prop="name">
|
||||
<el-input
|
||||
v-model="queryParams.name"
|
||||
placeholder="请输入场景名称"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="场景状态" prop="status">
|
||||
<el-select
|
||||
v-model="queryParams.status"
|
||||
placeholder="请选择场景状态"
|
||||
clearable
|
||||
class="!w-240px"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间" prop="createTime">
|
||||
<el-date-picker
|
||||
v-model="queryParams.createTime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
type="daterange"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
|
||||
class="!w-220px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
|
||||
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
@click="openForm('create')"
|
||||
v-hasPermi="['iot:rule-scene:create']"
|
||||
>
|
||||
<Icon icon="ep:plus" class="mr-5px" /> 新增
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
|
||||
<el-table-column label="场景编号" align="center" prop="id" />
|
||||
<el-table-column label="场景名称" align="center" prop="name" />
|
||||
<el-table-column label="场景描述" align="center" prop="description" />
|
||||
<el-table-column label="场景状态" align="center" prop="status">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="触发器" align="center" prop="triggers">
|
||||
<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-table-column label="操作" align="center" min-width="120px">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="openForm('update', scope.row.id)"
|
||||
v-hasPermi="['iot:rule-scene:update']"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row.id)"
|
||||
v-hasPermi="['iot:rule-scene:delete']"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 表单弹窗:添加/修改 -->
|
||||
<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 { IotRuleScene } from '@/api/iot/rule/scene/scene.types'
|
||||
|
||||
/** IoT 场景联动 列表 */
|
||||
defineOptions({ name: 'IotRuleScene' })
|
||||
|
||||
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: []
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await RuleSceneApi.getRuleScenePage(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value.resetFields()
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
/** 添加/修改操作 */
|
||||
const formRef = ref()
|
||||
const openForm = (type: string, id?: number) => {
|
||||
formRef.value.open(type, id)
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
// 删除的二次确认
|
||||
await message.delConfirm()
|
||||
// 发起删除
|
||||
await RuleSceneApi.deleteRuleScene(id)
|
||||
message.success(t('common.delSuccess'))
|
||||
// 刷新列表
|
||||
await getList()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 初始化 **/
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
1101
src/views/iot/rule/scene/IoT场景联动规则表单设计思路文档.md
Normal file
1101
src/views/iot/rule/scene/IoT场景联动规则表单设计思路文档.md
Normal file
File diff suppressed because it is too large
Load Diff
315
src/views/iot/rule/scene/components/RuleSceneForm.vue
Normal file
315
src/views/iot/rule/scene/components/RuleSceneForm.vue
Normal file
@@ -0,0 +1,315 @@
|
||||
<!-- 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,
|
||||
handleNetworkError,
|
||||
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>
|
||||
@@ -25,7 +25,7 @@
|
||||
{{ product ? product.name : '选择产品' }}
|
||||
</el-button>
|
||||
</div>
|
||||
<!-- TODO @puhui999:单选设备 -->
|
||||
<!-- 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>
|
||||
|
||||
288
src/views/iot/rule/scene/components/configs/AlertConfig.vue
Normal file
288
src/views/iot/rule/scene/components/configs/AlertConfig.vue
Normal 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>
|
||||
260
src/views/iot/rule/scene/components/configs/ConditionConfig.vue
Normal file
260
src/views/iot/rule/scene/components/configs/ConditionConfig.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
233
src/views/iot/rule/scene/components/inputs/CronBuilder.vue
Normal file
233
src/views/iot/rule/scene/components/inputs/CronBuilder.vue
Normal 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>
|
||||
142
src/views/iot/rule/scene/components/inputs/CronInput.vue
Normal file
142
src/views/iot/rule/scene/components/inputs/CronInput.vue
Normal 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>
|
||||
184
src/views/iot/rule/scene/components/inputs/DescriptionInput.vue
Normal file
184
src/views/iot/rule/scene/components/inputs/DescriptionInput.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<!-- 场景描述输入组件 -->
|
||||
<template>
|
||||
<div class="description-input">
|
||||
<el-input
|
||||
v-model="localValue"
|
||||
type="textarea"
|
||||
placeholder="请输入场景描述(可选)"
|
||||
:rows="3"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
resize="none"
|
||||
@input="handleInput"
|
||||
/>
|
||||
|
||||
<!-- 描述模板 -->
|
||||
<div v-if="showTemplates" class="templates">
|
||||
<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>
|
||||
|
||||
<!-- 模板按钮 -->
|
||||
<div v-if="!localValue && !showTemplates" class="template-trigger">
|
||||
<el-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="showTemplates = true"
|
||||
>
|
||||
<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 descriptionTemplates = [
|
||||
{
|
||||
title: '温度控制场景',
|
||||
content: '当环境温度超过设定阈值时,自动启动空调降温设备,确保环境温度保持在舒适范围内。'
|
||||
},
|
||||
{
|
||||
title: '设备监控场景',
|
||||
content: '实时监控关键设备的运行状态,当设备出现异常或离线时,立即发送告警通知相关人员。'
|
||||
},
|
||||
{
|
||||
title: '节能控制场景',
|
||||
content: '根据时间段和环境条件,自动调节设备功率或关闭非必要设备,实现智能节能管理。'
|
||||
},
|
||||
{
|
||||
title: '安防联动场景',
|
||||
content: '当检测到异常情况时,自动触发安防设备联动,包括报警器、摄像头录制等安全措施。'
|
||||
},
|
||||
{
|
||||
title: '定时任务场景',
|
||||
content: '按照预设的时间计划,定期执行设备检查、数据备份或系统维护等自动化任务。'
|
||||
}
|
||||
]
|
||||
|
||||
const handleInput = (value: string) => {
|
||||
if (value.length > 0) {
|
||||
showTemplates.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const applyTemplate = (template: any) => {
|
||||
localValue.value = template.content
|
||||
showTemplates.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.description-input {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.templates {
|
||||
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;
|
||||
}
|
||||
|
||||
.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>
|
||||
162
src/views/iot/rule/scene/components/inputs/NameInput.vue
Normal file
162
src/views/iot/rule/scene/components/inputs/NameInput.vue
Normal 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>
|
||||
185
src/views/iot/rule/scene/components/inputs/StatusRadio.vue
Normal file
185
src/views/iot/rule/scene/components/inputs/StatusRadio.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<!-- 场景状态选择组件 -->
|
||||
<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 class="status-note">
|
||||
<Icon icon="ep:info-filled" class="note-icon" />
|
||||
<span class="note-text">
|
||||
{{ localValue === 0 ? '启用状态下,规则将实时监控并执行相应动作' : '禁用状态下,规则不会执行任何操作' }}
|
||||
</span>
|
||||
</div>
|
||||
</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: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.el-radio) {
|
||||
margin-right: 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.status-option {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
: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: calc(100% - 28px);
|
||||
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;
|
||||
}
|
||||
|
||||
.status-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.note-icon {
|
||||
color: var(--el-color-primary);
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.note-text {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.5;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
406
src/views/iot/rule/scene/components/inputs/ValueInput.vue
Normal file
406
src/views/iot/rule/scene/components/inputs/ValueInput.vue
Normal 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>
|
||||
127
src/views/iot/rule/scene/components/previews/ActionPreview.vue
Normal file
127
src/views/iot/rule/scene/components/previews/ActionPreview.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
131
src/views/iot/rule/scene/components/previews/TriggerPreview.vue
Normal file
131
src/views/iot/rule/scene/components/previews/TriggerPreview.vue
Normal 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>
|
||||
@@ -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>
|
||||
390
src/views/iot/rule/scene/components/sections/ActionSection.vue
Normal file
390
src/views/iot/rule/scene/components/sections/ActionSection.vue
Normal 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>
|
||||
@@ -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>
|
||||
183
src/views/iot/rule/scene/components/sections/PreviewSection.vue
Normal file
183
src/views/iot/rule/scene/components/sections/PreviewSection.vue
Normal 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>
|
||||
395
src/views/iot/rule/scene/components/sections/TriggerSection.vue
Normal file
395
src/views/iot/rule/scene/components/sections/TriggerSection.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,325 @@
|
||||
<!-- 产品设备选择器组件 -->
|
||||
<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-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" 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 class="result-value">{{ selectedDevice?.deviceName }}</span>
|
||||
<el-tag
|
||||
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 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 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()
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,341 @@
|
||||
<!-- 属性选择器组件 -->
|
||||
<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.type)" size="small">
|
||||
{{ getPropertyTypeName(property.type) }}
|
||||
</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.type)" size="small">
|
||||
{{ getPropertyTypeName(selectedProperty.type) }}
|
||||
</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'
|
||||
|
||||
/** 属性选择器组件 */
|
||||
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<any[]>([])
|
||||
|
||||
// 计算属性
|
||||
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.category === 'property')
|
||||
})
|
||||
}
|
||||
|
||||
if (props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST) {
|
||||
groups.push({
|
||||
label: '设备事件',
|
||||
options: propertyList.value.filter(p => p.category === 'event')
|
||||
})
|
||||
}
|
||||
|
||||
if (props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE) {
|
||||
groups.push({
|
||||
label: '设备服务',
|
||||
options: propertyList.value.filter(p => p.category === 'service')
|
||||
})
|
||||
}
|
||||
|
||||
return groups.filter(group => group.options.length > 0)
|
||||
})
|
||||
|
||||
const selectedProperty = computed(() => {
|
||||
return propertyList.value.find(p => p.identifier === localValue.value)
|
||||
})
|
||||
|
||||
// 工具函数
|
||||
const getPropertyTypeName = (type: string) => {
|
||||
const typeMap = {
|
||||
'int': '整数',
|
||||
'float': '浮点数',
|
||||
'double': '双精度',
|
||||
'string': '字符串',
|
||||
'bool': '布尔值',
|
||||
'enum': '枚举',
|
||||
'date': '日期',
|
||||
'struct': '结构体',
|
||||
'array': '数组'
|
||||
}
|
||||
return typeMap[type] || type
|
||||
}
|
||||
|
||||
const getPropertyTypeTag = (type: string) => {
|
||||
const tagMap = {
|
||||
'int': 'primary',
|
||||
'float': 'success',
|
||||
'double': 'success',
|
||||
'string': 'info',
|
||||
'bool': 'warning',
|
||||
'enum': 'danger',
|
||||
'date': 'primary',
|
||||
'struct': 'info',
|
||||
'array': 'warning'
|
||||
}
|
||||
return tagMap[type] || 'info'
|
||||
}
|
||||
|
||||
// 事件处理
|
||||
const handleChange = (value: string) => {
|
||||
const property = propertyList.value.find(p => p.identifier === value)
|
||||
if (property) {
|
||||
emit('change', {
|
||||
type: property.type,
|
||||
config: property
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// API 调用
|
||||
const getPropertyList = async () => {
|
||||
if (!props.productId) {
|
||||
propertyList.value = []
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
// 这里应该调用真实的API获取物模型数据
|
||||
// 暂时使用模拟数据
|
||||
propertyList.value = [
|
||||
// 属性
|
||||
{
|
||||
identifier: 'temperature',
|
||||
name: '温度',
|
||||
type: 'float',
|
||||
category: 'property',
|
||||
description: '环境温度',
|
||||
unit: '°C',
|
||||
range: '-40~80'
|
||||
},
|
||||
{
|
||||
identifier: 'humidity',
|
||||
name: '湿度',
|
||||
type: 'float',
|
||||
category: 'property',
|
||||
description: '环境湿度',
|
||||
unit: '%',
|
||||
range: '0~100'
|
||||
},
|
||||
{
|
||||
identifier: 'power',
|
||||
name: '电源状态',
|
||||
type: 'bool',
|
||||
category: 'property',
|
||||
description: '设备电源开关状态'
|
||||
},
|
||||
// 事件
|
||||
{
|
||||
identifier: 'alarm',
|
||||
name: '告警事件',
|
||||
type: 'struct',
|
||||
category: 'event',
|
||||
description: '设备告警事件'
|
||||
},
|
||||
{
|
||||
identifier: 'fault',
|
||||
name: '故障事件',
|
||||
type: 'struct',
|
||||
category: 'event',
|
||||
description: '设备故障事件'
|
||||
},
|
||||
// 服务
|
||||
{
|
||||
identifier: 'restart',
|
||||
name: '重启服务',
|
||||
type: 'struct',
|
||||
category: 'service',
|
||||
description: '设备重启服务'
|
||||
}
|
||||
]
|
||||
} catch (error) {
|
||||
console.error('获取物模型失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听产品变化
|
||||
watch(() => props.productId, () => {
|
||||
getPropertyList()
|
||||
}, { 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
548
src/views/iot/rule/scene/utils/errorHandler.ts
Normal file
548
src/views/iot/rule/scene/utils/errorHandler.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
406
src/views/iot/rule/scene/utils/transform.ts
Normal file
406
src/views/iot/rule/scene/utils/transform.ts
Normal 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 }
|
||||
}
|
||||
278
src/views/iot/rule/scene/utils/validation.ts
Normal file
278
src/views/iot/rule/scene/utils/validation.ts
Normal 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: '告警恢复' }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user