perf:【IoT 物联网】场景联动触发器服务调用参数输入

pull/805/head
puhui999 2025-08-04 15:49:53 +08:00
parent 38ad857c33
commit fe299d792e
3 changed files with 552 additions and 178 deletions

View File

@ -36,74 +36,15 @@ import { useVModel } from '@vueuse/core'
import BasicInfoSection from './sections/BasicInfoSection.vue'
import TriggerSection from './sections/TriggerSection.vue'
import ActionSection from './sections/ActionSection.vue'
import { IotRuleScene, IotRuleSceneDO, RuleSceneFormData } from '@/api/iot/rule/scene/scene.types'
import { IotRuleSceneDO, RuleSceneFormData } from '@/api/iot/rule/scene/scene.types'
import { RuleSceneApi } from '@/api/iot/rule/scene'
import {
IotRuleSceneTriggerTypeEnum,
IotRuleSceneActionTypeEnum,
IotDeviceMessageTypeEnum,
isDeviceTrigger
} from '@/views/iot/utils/constants'
import { ElMessage } from 'element-plus'
import { generateUUID } from '@/utils'
// CommonStatusEnum
// TODO @puhui999
const CommonStatusEnum = {
ENABLE: 0, //
DISABLE: 1 //
} as const
//
const getMessageTypeByTriggerType = (triggerType: number): string => {
switch (triggerType) {
case IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST:
return IotDeviceMessageTypeEnum.PROPERTY
case IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST:
return IotDeviceMessageTypeEnum.EVENT
case IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE:
return IotDeviceMessageTypeEnum.SERVICE
default:
return IotDeviceMessageTypeEnum.PROPERTY
}
}
//
const getMessageTypeByActionType = (actionType: number): string => {
switch (actionType) {
case IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET:
return IotDeviceMessageTypeEnum.PROPERTY
case IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE:
return IotDeviceMessageTypeEnum.SERVICE
default:
return IotDeviceMessageTypeEnum.PROPERTY
}
}
//
const getIdentifierByActionType = (actionType: number, params?: Record<string, any>): string => {
if (!params) return ''
switch (actionType) {
case IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET:
//
return Object.keys(params)[0] || ''
case IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE:
// method
return params.method || ''
default:
return ''
}
}
//
const isDeviceAction = (type: number): boolean => {
const deviceActionTypes = [
IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET,
IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE
] as number[]
return deviceActionTypes.includes(type)
}
import { CommonStatusEnum } from '@/utils/constants'
/** IoT 场景联动规则表单 - 主表单组件 */
defineOptions({ name: 'RuleSceneForm' })
@ -146,111 +87,11 @@ const createDefaultFormData = (): RuleSceneFormData => {
}
}
/**
* 将表单数据转换为后端 API 格式
* 转换为 IotRuleScene 格式与后端 API 接口对齐
*/
const convertFormToVO = (formData: RuleSceneFormData): IotRuleScene => {
return {
id: formData.id,
name: formData.name,
description: formData.description,
status: Number(formData.status),
triggers: formData.triggers.map((trigger) => ({
key: generateUUID(), //
type: trigger.type,
productKey: trigger.productId ? `product_${trigger.productId}` : undefined, //
deviceNames: trigger.deviceId ? [`device_${trigger.deviceId}`] : undefined, //
conditions: trigger.identifier
? [
{
type: getMessageTypeByTriggerType(trigger.type),
identifier: trigger.identifier,
parameters: [
{
identifier: trigger.identifier,
operator: trigger.operator || '=',
value: trigger.value || ''
}
]
}
]
: undefined,
cronExpression: trigger.cronExpression
})),
actions:
formData.actions?.map((action) => ({
key: generateUUID(), //
type: action.type,
deviceControl: isDeviceAction(action.type)
? {
productKey: action.productId ? `product_${action.productId}` : '',
deviceNames: action.deviceId ? [`device_${action.deviceId}`] : [],
type: getMessageTypeByActionType(action.type),
identifier: getIdentifierByActionType(action.type, action.params),
params: action.params || {}
}
: undefined,
alertConfigId: action.alertConfigId
})) || []
}
}
// TODO @puhui999
/**
* 将后端 DO 数据转换为表单格式
* 由于数据结构已对齐转换变得非常简单
*/
const convertVOToForm = (apiData: IotRuleSceneDO): RuleSceneFormData => {
//
const triggers = apiData.triggers?.length
? apiData.triggers.map((trigger: any) => ({
type: Number(trigger.type),
productId: trigger.productId,
deviceId: trigger.deviceId,
identifier: trigger.identifier,
operator: trigger.operator,
value: trigger.value,
cronExpression: trigger.cronExpression,
conditionGroups: trigger.conditionGroups || []
}))
: [
{
type: IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST,
productId: undefined,
deviceId: undefined,
identifier: undefined,
operator: undefined,
value: undefined,
cronExpression: undefined,
conditionGroups: []
}
]
return {
id: apiData.id,
name: apiData.name,
description: apiData.description,
status: Number(apiData.status),
triggers,
actions:
apiData.actions?.map((action: any) => ({
type: Number(action.type),
productId: action.productId,
deviceId: action.deviceId,
params: action.params || {},
alertConfigId: action.alertConfigId,
//
key: generateUUID()
})) || []
}
}
//
const formRef = ref()
const formData = ref<RuleSceneFormData>(createDefaultFormData())
//
const validateTriggers = (rule: any, value: any, callback: any) => {
const validateTriggers = (_rule: any, value: any, callback: any) => {
if (!value || !Array.isArray(value) || value.length === 0) {
callback(new Error('至少需要一个触发器'))
return
@ -301,7 +142,7 @@ const validateTriggers = (rule: any, value: any, callback: any) => {
callback()
}
const validateActions = (rule: any, value: any, callback: any) => {
const validateActions = (_rule: any, value: any, callback: any) => {
if (!value || !Array.isArray(value) || value.length === 0) {
callback(new Error('至少需要一个执行器'))
return
@ -387,18 +228,17 @@ const handleSubmit = async () => {
//
submitLoading.value = true
try {
//
const apiData = convertFormToVO(formData.value)
console.log('提交数据:', apiData)
// 使
console.log('提交数据:', formData.value)
// API
if (isEdit.value) {
//
await RuleSceneApi.updateRuleScene(apiData)
await RuleSceneApi.updateRuleScene(formData.value)
ElMessage.success('更新成功')
} else {
//
await RuleSceneApi.createRuleScene(apiData)
await RuleSceneApi.createRuleScene(formData.value)
ElMessage.success('创建成功')
}
@ -420,9 +260,28 @@ const handleClose = () => {
/** 初始化表单数据 */
const initFormData = () => {
if (props.ruleScene) {
//
// 使
isEdit.value = true
formData.value = convertVOToForm(props.ruleScene)
formData.value = {
...props.ruleScene,
//
triggers: props.ruleScene.triggers?.length
? props.ruleScene.triggers
: [
{
type: IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST,
productId: undefined,
deviceId: undefined,
identifier: undefined,
operator: undefined,
value: undefined,
cronExpression: undefined,
conditionGroups: []
}
],
//
actions: props.ruleScene.actions || []
}
} else {
// 使
isEdit.value = false

View File

@ -59,8 +59,8 @@
</el-form-item>
</el-col>
<!-- 操作符选择 -->
<el-col :span="6">
<!-- 操作符选择 - 服务调用不需要操作符 -->
<el-col v-if="triggerType !== IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE" :span="6">
<el-form-item label="操作符" required>
<OperatorSelector
:model-value="condition.operator"
@ -72,9 +72,26 @@
</el-col>
<!-- 值输入 -->
<el-col :span="12">
<el-form-item label="比较值" required>
<el-col :span="triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE ? 18 : 12">
<el-form-item
:label="
triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE
? '服务参数'
: '比较值'
"
required
>
<!-- 服务调用参数配置 -->
<ServiceParamsInput
v-if="triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE"
:model-value="condition.value"
@update:model-value="(value) => updateConditionField('value', value)"
:service-config="propertyConfig"
@validate="handleValueValidate"
/>
<!-- 普通值输入 -->
<ValueInput
v-else
:model-value="condition.value"
@update:model-value="(value) => updateConditionField('value', value)"
:property-type="propertyType"
@ -114,6 +131,7 @@ import DeviceSelector from '../selectors/DeviceSelector.vue'
import PropertySelector from '../selectors/PropertySelector.vue'
import OperatorSelector from '../selectors/OperatorSelector.vue'
import ValueInput from '../inputs/ValueInput.vue'
import ServiceParamsInput from '../inputs/ServiceParamsInput.vue'
import DeviceStatusConditionConfig from './DeviceStatusConditionConfig.vue'
import { TriggerFormData } from '@/api/iot/rule/scene/scene.types'
import { IotRuleSceneTriggerTypeEnum, getTriggerTypeOptions } from '@/views/iot/utils/constants'
@ -176,7 +194,7 @@ const triggerTypeOptions = getTriggerTypeOptions()
//
const updateConditionField = (field: keyof TriggerFormData, value: any) => {
condition.value[field] = value
;(condition.value as any)[field] = value
updateValidationResult()
}
@ -214,7 +232,7 @@ const handleOperatorChange = () => {
updateValidationResult()
}
const handleValueValidate = (result: { valid: boolean; message: string }) => {
const handleValueValidate = (_result: { valid: boolean; message: string }) => {
updateValidationResult()
}
@ -250,7 +268,11 @@ const updateValidationResult = () => {
return
}
if (!condition.value.operator) {
//
if (
props.triggerType !== IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE &&
!condition.value.operator
) {
isValid.value = false
validationMessage.value = '请选择操作符'
emit('validate', { valid: false, message: validationMessage.value })
@ -276,7 +298,10 @@ watch(
condition.value.productId,
condition.value.deviceId,
condition.value.identifier,
condition.value.operator,
//
props.triggerType !== IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE
? condition.value.operator
: null,
condition.value.value
],
() => {

View File

@ -0,0 +1,490 @@
<!-- 服务参数输入组件 -->
<template>
<div class="w-full min-w-0">
<!-- 服务参数配置 -->
<div v-if="serviceConfig && serviceConfig.service" class="space-y-12px">
<!-- JSON 输入框 -->
<div class="relative">
<el-input
v-model="paramsJson"
type="textarea"
:rows="4"
placeholder="请输入JSON格式的服务参数"
@input="handleParamsChange"
:class="{ 'is-error': jsonError }"
/>
<!-- 查看详细示例按钮 -->
<div class="absolute top-8px right-8px">
<el-button
ref="exampleTriggerRef"
type="info"
:icon="InfoFilled"
circle
size="small"
@click="toggleExampleDetail"
title="查看参数示例"
/>
</div>
</div>
<!-- 验证状态和错误提示 -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-8px">
<Icon
:icon="jsonError ? 'ep:warning' : 'ep:circle-check'"
:class="jsonError ? 'text-[var(--el-color-danger)]' : 'text-[var(--el-color-success)]'"
class="text-14px"
/>
<span
:class="jsonError ? 'text-[var(--el-color-danger)]' : 'text-[var(--el-color-success)]'"
class="text-12px"
>
{{ jsonError || 'JSON格式正确' }}
</span>
</div>
<!-- 快速填充按钮 -->
<div v-if="inputParams.length > 0" class="flex items-center gap-8px">
<span class="text-12px text-[var(--el-text-color-secondary)]">快速填充</span>
<el-button size="small" type="primary" plain @click="fillExampleJson">
示例数据
</el-button>
<el-button size="small" type="default" plain @click="clearParams"> </el-button>
</div>
</div>
<!-- 详细示例弹出层 -->
<Teleport to="body">
<div
v-if="showExampleDetail"
ref="exampleDetailRef"
class="example-detail-popover"
:style="examplePopoverStyle"
>
<div
class="p-16px bg-white rounded-8px shadow-lg border border-[var(--el-border-color)] min-w-400px max-w-500px"
>
<div class="flex items-center gap-8px mb-16px">
<Icon icon="ep:service" class="text-[var(--el-color-primary)] text-18px" />
<span class="text-16px font-600 text-[var(--el-text-color-primary)]">
{{ serviceConfig.name }} - 参数示例
</span>
</div>
<div class="space-y-16px">
<!-- 服务参数示例 -->
<div v-if="inputParams.length > 0">
<div class="flex items-center gap-8px mb-8px">
<Icon icon="ep:edit" class="text-[var(--el-color-primary)] text-14px" />
<span class="text-14px font-500 text-[var(--el-text-color-primary)]">
输入参数
</span>
</div>
<div class="ml-22px space-y-8px">
<div
v-for="param in inputParams"
:key="param.identifier"
class="flex items-center justify-between p-8px bg-[var(--el-fill-color-lighter)] rounded-4px"
>
<div class="flex-1">
<div class="text-12px font-500 text-[var(--el-text-color-primary)]">
{{ param.name }}
<el-tag v-if="param.required" size="small" type="danger" class="ml-4px"
>必填</el-tag
>
</div>
<div class="text-11px text-[var(--el-text-color-secondary)]">
{{ param.identifier }}
</div>
</div>
<div class="flex items-center gap-8px">
<el-tag :type="getParamTypeTag(param.dataType)" size="small">
{{ getParamTypeName(param.dataType) }}
</el-tag>
<span class="text-11px text-[var(--el-text-color-secondary)]">
{{ getExampleValue(param) }}
</span>
</div>
</div>
</div>
<div class="mt-12px ml-22px">
<div class="text-12px text-[var(--el-text-color-secondary)] mb-6px">
完整JSON格式
</div>
<pre
class="p-12px bg-[var(--el-fill-color-light)] rounded-4px text-11px text-[var(--el-text-color-primary)] overflow-x-auto border-l-3px border-[var(--el-color-primary)]"
><code>{{ generateExampleJson() }}</code></pre>
</div>
</div>
<!-- 无参数提示 -->
<div v-else>
<div class="text-center py-16px">
<p class="text-14px text-[var(--el-text-color-secondary)]">此服务无需输入参数</p>
</div>
</div>
</div>
<!-- 关闭按钮 -->
<div class="flex justify-end mt-16px">
<el-button size="small" @click="hideExampleDetail"></el-button>
</div>
</div>
</div>
</Teleport>
</div>
<!-- 无服务配置提示 -->
<div v-else class="text-center py-20px">
<p class="text-14px text-[var(--el-text-color-secondary)]">请先选择服务</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import { InfoFilled } from '@element-plus/icons-vue'
/** 服务参数输入组件 */
defineOptions({ name: 'ServiceParamsInput' })
interface Props {
modelValue?: string
serviceConfig?: 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 paramsJson = ref('')
const jsonError = ref('')
//
const showExampleDetail = ref(false)
const exampleTriggerRef = ref()
const exampleDetailRef = ref()
const examplePopoverStyle = ref({})
//
const inputParams = computed(() => {
return props.serviceConfig?.service?.inputParams || []
})
//
const handleParamsChange = () => {
try {
jsonError.value = '' //
if (paramsJson.value.trim()) {
const parsed = JSON.parse(paramsJson.value)
localValue.value = paramsJson.value
//
if (typeof parsed !== 'object' || parsed === null) {
jsonError.value = '参数必须是一个有效的JSON对象'
emit('validate', { valid: false, message: jsonError.value })
return
}
//
for (const param of inputParams.value) {
if (param.required && (!parsed[param.identifier] || parsed[param.identifier] === '')) {
jsonError.value = `参数 ${param.name} 为必填项`
emit('validate', { valid: false, message: jsonError.value })
return
}
}
} else {
localValue.value = ''
}
//
emit('validate', { valid: true, message: 'JSON格式正确' })
} catch (error) {
jsonError.value = `JSON格式错误: ${error instanceof Error ? error.message : '未知错误'}`
emit('validate', { valid: false, message: jsonError.value })
}
}
//
const fillExampleJson = () => {
const exampleData = generateExampleJson()
paramsJson.value = exampleData
handleParamsChange()
}
//
const clearParams = () => {
paramsJson.value = ''
localValue.value = ''
jsonError.value = ''
emit('validate', { valid: true, message: '' })
}
//
const getParamTypeName = (dataType: string) => {
const typeMap = {
int: '整数',
float: '浮点数',
double: '双精度',
text: '字符串',
bool: '布尔值',
enum: '枚举',
date: '日期',
struct: '结构体',
array: '数组'
}
return typeMap[dataType] || dataType
}
const getParamTypeTag = (dataType: string) => {
const tagMap = {
int: 'primary',
float: 'success',
double: 'success',
text: 'info',
bool: 'warning',
enum: 'danger',
date: 'primary',
struct: 'info',
array: 'warning'
}
return tagMap[dataType] || 'info'
}
const getExampleValue = (param: any) => {
switch (param.dataType) {
case 'int':
return '25'
case 'float':
case 'double':
return '25.5'
case 'bool':
return 'false'
case 'text':
return '"auto"'
case 'enum':
return '"option1"'
case 'struct':
return '{}'
case 'array':
return '[]'
default:
return '""'
}
}
const generateExampleJson = () => {
if (inputParams.value.length === 0) {
return '{}'
}
const example = {}
inputParams.value.forEach((param) => {
switch (param.dataType) {
case 'int':
example[param.identifier] = 25
break
case 'float':
case 'double':
example[param.identifier] = 25.5
break
case 'bool':
example[param.identifier] = false
break
case 'text':
example[param.identifier] = 'auto'
break
case 'struct':
example[param.identifier] = {}
break
case 'array':
example[param.identifier] = []
break
default:
example[param.identifier] = ''
}
})
return JSON.stringify(example, null, 2)
}
//
const toggleExampleDetail = () => {
if (showExampleDetail.value) {
hideExampleDetail()
} else {
showExampleDetailPopover()
}
}
const showExampleDetailPopover = () => {
if (!exampleTriggerRef.value) return
showExampleDetail.value = true
nextTick(() => {
updateExamplePopoverPosition()
})
}
const hideExampleDetail = () => {
showExampleDetail.value = false
}
const updateExamplePopoverPosition = () => {
if (!exampleTriggerRef.value || !exampleDetailRef.value) return
const triggerEl = exampleTriggerRef.value.$el
const triggerRect = triggerEl.getBoundingClientRect()
//
const left = triggerRect.left + triggerRect.width + 8
const top = triggerRect.top
//
const popoverWidth = 500 //
const viewportWidth = window.innerWidth
let finalLeft = left
if (left + popoverWidth > viewportWidth - 16) {
//
finalLeft = triggerRect.left - popoverWidth - 8
}
//
let finalTop = top
const popoverHeight = exampleDetailRef.value.offsetHeight || 300
const viewportHeight = window.innerHeight
if (top + popoverHeight > viewportHeight - 16) {
finalTop = Math.max(16, viewportHeight - popoverHeight - 16)
}
examplePopoverStyle.value = {
position: 'fixed',
left: `${finalLeft}px`,
top: `${finalTop}px`,
zIndex: 9999
}
}
//
const handleClickOutside = (event: MouseEvent) => {
if (
showExampleDetail.value &&
exampleDetailRef.value &&
exampleTriggerRef.value &&
!exampleDetailRef.value.contains(event.target as Node) &&
!exampleTriggerRef.value.$el.contains(event.target as Node)
) {
hideExampleDetail()
}
}
//
const handleResize = () => {
if (showExampleDetail.value) {
updateExamplePopoverPosition()
}
}
//
onMounted(() => {
if (localValue.value) {
try {
paramsJson.value = localValue.value
jsonError.value = ''
} catch (error) {
console.error('初始化参数失败:', error)
jsonError.value = '初始参数格式错误'
}
}
//
document.addEventListener('click', handleClickOutside)
window.addEventListener('resize', handleResize)
})
//
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
window.removeEventListener('resize', handleResize)
})
//
watch(
() => localValue.value,
(newValue) => {
if (newValue !== paramsJson.value) {
paramsJson.value = newValue || ''
}
}
)
//
watch(
() => props.serviceConfig,
() => {
//
paramsJson.value = ''
localValue.value = ''
jsonError.value = ''
}
)
</script>
<style scoped>
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(0.9) translateY(-4px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.example-detail-popover {
animation: fadeInScale 0.2s ease-out;
transform-origin: top left;
}
/* 弹出层箭头效果 */
.example-detail-popover::before {
position: absolute;
top: 20px;
left: -8px;
width: 0;
height: 0;
border-top: 8px solid transparent;
border-right: 8px solid var(--el-border-color);
border-bottom: 8px solid transparent;
content: '';
}
.example-detail-popover::after {
position: absolute;
top: 20px;
left: -7px;
width: 0;
height: 0;
border-top: 8px solid transparent;
border-right: 8px solid white;
border-bottom: 8px solid transparent;
content: '';
}
</style>