perf:【IoT 物联网】场景联动执行器优化
parent
e3a8e98ff8
commit
9684593623
|
@ -226,6 +226,7 @@ interface ActionFormData {
|
|||
type: number // 执行类型
|
||||
productId?: number // 产品编号
|
||||
deviceId?: number // 设备编号
|
||||
identifier?: string // 物模型标识符(服务调用时使用)
|
||||
params?: Record<string, any> // 请求参数
|
||||
alertConfigId?: number // 告警配置编号
|
||||
}
|
||||
|
@ -277,6 +278,7 @@ interface ActionDO {
|
|||
type: number // 执行类型
|
||||
productId?: number // 产品编号
|
||||
deviceId?: number // 设备编号
|
||||
identifier?: string // 物模型标识符(服务调用时使用)
|
||||
params?: Record<string, any> // 请求参数
|
||||
alertConfigId?: number // 告警配置编号
|
||||
}
|
||||
|
|
|
@ -170,6 +170,15 @@ const validateActions = (_rule: any, value: any, callback: any) => {
|
|||
callback(new Error(`执行器 ${i + 1}: 设备不能为空`))
|
||||
return
|
||||
}
|
||||
|
||||
// 服务调用需要验证服务标识符
|
||||
if (action.type === IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE) {
|
||||
if (!action.identifier) {
|
||||
callback(new Error(`执行器 ${i + 1}: 服务不能为空`))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!action.params || Object.keys(action.params).length === 0) {
|
||||
callback(new Error(`执行器 ${i + 1}: 参数配置不能为空`))
|
||||
return
|
||||
|
|
|
@ -20,7 +20,82 @@
|
|||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 控制参数配置 - 只要选择了产品就显示,支持全部设备和单独设备 -->
|
||||
<!-- 服务选择 - 服务调用类型时显示 -->
|
||||
<div v-if="action.productId && isServiceInvokeAction" class="space-y-16px">
|
||||
<el-form-item label="服务" required>
|
||||
<ServiceSelector
|
||||
v-model="action.identifier"
|
||||
:product-id="action.productId"
|
||||
@change="handleServiceChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 服务参数配置 -->
|
||||
<div v-if="action.identifier" class="space-y-16px">
|
||||
<el-form-item label="服务参数" required>
|
||||
<div class="w-full space-y-8px">
|
||||
<!-- JSON 输入框 -->
|
||||
<div class="relative">
|
||||
<el-input
|
||||
v-model="paramsJson"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
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="selectedService?.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="fillServiceExampleJson">
|
||||
示例数据
|
||||
</el-button>
|
||||
<el-button size="small" type="default" plain @click="clearParams"> 清空 </el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 控制参数配置 - 属性设置类型时显示 -->
|
||||
<div v-if="action.productId && isPropertySetAction" class="space-y-16px">
|
||||
<!-- 参数配置 -->
|
||||
<el-form-item label="参数" required>
|
||||
|
@ -100,8 +175,51 @@
|
|||
</div>
|
||||
|
||||
<div class="space-y-16px">
|
||||
<!-- 物模型属性示例 -->
|
||||
<div v-if="thingModelProperties.length > 0">
|
||||
<!-- 服务参数示例 - 服务调用时显示 -->
|
||||
<div v-if="isServiceInvokeAction && selectedService?.inputParams?.length > 0">
|
||||
<div class="flex items-center gap-8px mb-8px">
|
||||
<Icon icon="ep:service" class="text-[var(--el-color-success)] 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 selectedService.inputParams.slice(0, 4)"
|
||||
: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 }}
|
||||
</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="getPropertyTypeTag(param.dataType)" size="small">
|
||||
{{ getPropertyTypeName(param.dataType) }}
|
||||
</el-tag>
|
||||
<span class="text-11px text-[var(--el-text-color-secondary)]">
|
||||
{{ getExampleValueForParam(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-success)]"
|
||||
><code>{{ generateServiceExampleJson() }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 物模型属性示例 - 属性设置时显示 -->
|
||||
<div v-if="isPropertySetAction && thingModelProperties.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)]">
|
||||
|
@ -184,7 +302,8 @@ import { useVModel } from '@vueuse/core'
|
|||
import { InfoFilled } from '@element-plus/icons-vue'
|
||||
import ProductSelector from '../selectors/ProductSelector.vue'
|
||||
import DeviceSelector from '../selectors/DeviceSelector.vue'
|
||||
import { ActionFormData } from '@/api/iot/rule/scene/scene.types'
|
||||
import ServiceSelector from '../selectors/ServiceSelector.vue'
|
||||
import { ActionFormData, ThingModelService } from '@/api/iot/rule/scene/scene.types'
|
||||
import { IotRuleSceneActionTypeEnum } from '@/views/iot/utils/constants'
|
||||
|
||||
/** 设备控制配置组件 */
|
||||
|
@ -207,6 +326,9 @@ const thingModelProperties = ref<any[]>([])
|
|||
const loadingThingModel = ref(false)
|
||||
const propertyValues = ref<Record<string, any>>({})
|
||||
|
||||
// 服务调用相关状态
|
||||
const selectedService = ref<ThingModelService | null>(null)
|
||||
|
||||
// 示例弹出层相关状态
|
||||
const showExampleDetail = ref(false)
|
||||
const exampleTriggerRef = ref()
|
||||
|
@ -218,15 +340,28 @@ const isPropertySetAction = computed(() => {
|
|||
return action.value.type === IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET
|
||||
})
|
||||
|
||||
const isServiceInvokeAction = computed(() => {
|
||||
return action.value.type === IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE
|
||||
})
|
||||
|
||||
// 事件处理
|
||||
const handleProductChange = (productId?: number) => {
|
||||
console.log('🔄 handleProductChange called:', {
|
||||
productId,
|
||||
currentProductId: action.value.productId
|
||||
})
|
||||
|
||||
// 当产品变化时,清空设备选择和参数配置
|
||||
if (action.value.productId !== productId) {
|
||||
action.value.deviceId = undefined
|
||||
action.value.identifier = undefined // 清空服务标识符
|
||||
action.value.params = {}
|
||||
paramsJson.value = ''
|
||||
jsonError.value = ''
|
||||
propertyValues.value = {}
|
||||
selectedService.value = null // 清空选中的服务
|
||||
|
||||
console.log('🧹 Cleared action data due to product change')
|
||||
}
|
||||
|
||||
// 加载新产品的物模型属性
|
||||
|
@ -244,6 +379,30 @@ const handleDeviceChange = (deviceId?: number) => {
|
|||
}
|
||||
}
|
||||
|
||||
const handleServiceChange = (serviceIdentifier?: string, service?: ThingModelService) => {
|
||||
console.log('🔄 handleServiceChange called:', { serviceIdentifier, service: service?.name })
|
||||
|
||||
// 更新服务对象
|
||||
selectedService.value = service || null
|
||||
|
||||
// 当服务变化时,清空参数配置并根据服务输入参数生成默认参数结构
|
||||
action.value.params = {}
|
||||
paramsJson.value = ''
|
||||
jsonError.value = ''
|
||||
|
||||
// 如果选择了服务且有输入参数,生成默认参数结构
|
||||
if (service && service.inputParams && service.inputParams.length > 0) {
|
||||
const defaultParams = {}
|
||||
service.inputParams.forEach((param) => {
|
||||
defaultParams[param.identifier] = getDefaultValueForParam(param)
|
||||
})
|
||||
action.value.params = defaultParams
|
||||
paramsJson.value = JSON.stringify(defaultParams, null, 2)
|
||||
|
||||
console.log('✅ Generated default params:', defaultParams)
|
||||
}
|
||||
}
|
||||
|
||||
// 快速填充示例数据
|
||||
const fillExampleJson = () => {
|
||||
const exampleData = generateExampleJson()
|
||||
|
@ -251,6 +410,15 @@ const fillExampleJson = () => {
|
|||
handleParamsChange()
|
||||
}
|
||||
|
||||
// 快速填充服务示例数据
|
||||
const fillServiceExampleJson = () => {
|
||||
if (selectedService.value && selectedService.value.inputParams) {
|
||||
const exampleData = generateServiceExampleJson()
|
||||
paramsJson.value = exampleData
|
||||
handleParamsChange()
|
||||
}
|
||||
}
|
||||
|
||||
// 清空参数
|
||||
const clearParams = () => {
|
||||
paramsJson.value = ''
|
||||
|
@ -260,14 +428,14 @@ const clearParams = () => {
|
|||
}
|
||||
|
||||
// 更新属性值(保留但不在模板中使用)
|
||||
const updatePropertyValue = (identifier: string, value: any) => {
|
||||
propertyValues.value[identifier] = value
|
||||
// 同步更新到 action.params
|
||||
action.value.params = { ...propertyValues.value }
|
||||
// 同步更新 JSON 显示
|
||||
paramsJson.value = JSON.stringify(action.value.params, null, 2)
|
||||
jsonError.value = ''
|
||||
}
|
||||
// const updatePropertyValue = (identifier: string, value: any) => {
|
||||
// propertyValues.value[identifier] = value
|
||||
// // 同步更新到 action.params
|
||||
// action.value.params = { ...propertyValues.value }
|
||||
// // 同步更新 JSON 显示
|
||||
// paramsJson.value = JSON.stringify(action.value.params, null, 2)
|
||||
// jsonError.value = ''
|
||||
// }
|
||||
|
||||
// 加载物模型属性
|
||||
const loadThingModelProperties = async (productId: number) => {
|
||||
|
@ -322,6 +490,40 @@ const loadThingModelProperties = async (productId: number) => {
|
|||
}
|
||||
}
|
||||
|
||||
// 从TSL加载服务信息
|
||||
const loadServiceFromTSL = async (productId: number, serviceIdentifier: string) => {
|
||||
console.log('🔍 loadServiceFromTSL called:', { productId, serviceIdentifier })
|
||||
try {
|
||||
const { ThingModelApi } = await import('@/api/iot/thingmodel')
|
||||
const tslData = await ThingModelApi.getThingModelTSLByProductId(productId)
|
||||
console.log('📡 TSL data loaded:', tslData)
|
||||
|
||||
if (tslData?.services) {
|
||||
const service = tslData.services.find((s: any) => s.identifier === serviceIdentifier)
|
||||
console.log('🎯 Found service:', service)
|
||||
|
||||
if (service) {
|
||||
// 设置服务对象
|
||||
selectedService.value = service
|
||||
|
||||
console.log('✅ Service set:', {
|
||||
serviceIdentifier,
|
||||
selectedService: selectedService.value?.name
|
||||
})
|
||||
|
||||
// 确保在下一个tick中更新,让ServiceSelector有时间处理
|
||||
await nextTick()
|
||||
} else {
|
||||
console.warn('⚠️ Service not found in TSL')
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ No services in TSL data')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 加载服务信息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleParamsChange = () => {
|
||||
try {
|
||||
jsonError.value = '' // 清除之前的错误
|
||||
|
@ -364,6 +566,29 @@ const getPropertyTypeName = (dataType: string) => {
|
|||
return typeMap[dataType] || dataType
|
||||
}
|
||||
|
||||
// 根据参数类型获取默认值
|
||||
const getDefaultValueForParam = (param: any) => {
|
||||
switch (param.dataType) {
|
||||
case 'int':
|
||||
return 0
|
||||
case 'float':
|
||||
case 'double':
|
||||
return 0.0
|
||||
case 'bool':
|
||||
return false
|
||||
case 'text':
|
||||
return ''
|
||||
case 'enum':
|
||||
// 如果有枚举值,使用第一个
|
||||
if (param.dataSpecs?.dataSpecsList && param.dataSpecs.dataSpecsList.length > 0) {
|
||||
return param.dataSpecs.dataSpecsList[0].value
|
||||
}
|
||||
return ''
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const getPropertyTypeTag = (dataType: string) => {
|
||||
const tagMap = {
|
||||
int: 'primary',
|
||||
|
@ -397,6 +622,28 @@ const getExampleValue = (property: any) => {
|
|||
}
|
||||
}
|
||||
|
||||
// 获取参数示例值
|
||||
const getExampleValueForParam = (param: any) => {
|
||||
switch (param.dataType) {
|
||||
case 'int':
|
||||
return '0'
|
||||
case 'float':
|
||||
case 'double':
|
||||
return '0.0'
|
||||
case 'bool':
|
||||
return 'false'
|
||||
case 'text':
|
||||
return '"text"'
|
||||
case 'enum':
|
||||
if (param.dataSpecs?.dataSpecsList && param.dataSpecs.dataSpecsList.length > 0) {
|
||||
return `"${param.dataSpecs.dataSpecsList[0].name}"`
|
||||
}
|
||||
return '"option1"'
|
||||
default:
|
||||
return '""'
|
||||
}
|
||||
}
|
||||
|
||||
const generateExampleJson = () => {
|
||||
if (thingModelProperties.value.length === 0) {
|
||||
return JSON.stringify(
|
||||
|
@ -433,6 +680,20 @@ const generateExampleJson = () => {
|
|||
return JSON.stringify(example, null, 2)
|
||||
}
|
||||
|
||||
// 生成服务示例JSON
|
||||
const generateServiceExampleJson = () => {
|
||||
if (!selectedService.value || !selectedService.value.inputParams) {
|
||||
return JSON.stringify({}, null, 2)
|
||||
}
|
||||
|
||||
const example = {}
|
||||
selectedService.value.inputParams.forEach((param) => {
|
||||
example[param.identifier] = getDefaultValueForParam(param)
|
||||
})
|
||||
|
||||
return JSON.stringify(example, null, 2)
|
||||
}
|
||||
|
||||
// 示例弹出层控制方法 - 参考 PropertySelector 的设计
|
||||
const toggleExampleDetail = () => {
|
||||
if (showExampleDetail.value) {
|
||||
|
@ -531,6 +792,12 @@ onMounted(() => {
|
|||
loadThingModelProperties(action.value.productId)
|
||||
}
|
||||
|
||||
// 如果是服务调用类型且已有标识符,初始化服务选择
|
||||
if (action.value.productId && isServiceInvokeAction.value && action.value.identifier) {
|
||||
// 加载物模型TSL以获取服务信息
|
||||
loadServiceFromTSL(action.value.productId, action.value.identifier)
|
||||
}
|
||||
|
||||
// 添加事件监听器
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
@ -558,10 +825,64 @@ watch(
|
|||
console.error('参数格式化失败:', error)
|
||||
jsonError.value = '参数格式化失败'
|
||||
}
|
||||
} else {
|
||||
// 参数为空时清空JSON显示
|
||||
if (paramsJson.value !== '') {
|
||||
paramsJson.value = ''
|
||||
jsonError.value = ''
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// 监听action.value变化,处理编辑模式的数据回显
|
||||
watch(
|
||||
() => action.value,
|
||||
async (newAction) => {
|
||||
console.log('🔄 action.value changed:', {
|
||||
type: newAction?.type,
|
||||
productId: newAction?.productId,
|
||||
identifier: newAction?.identifier,
|
||||
isServiceInvokeAction: isServiceInvokeAction.value
|
||||
})
|
||||
|
||||
if (newAction) {
|
||||
// 处理服务调用的数据回显
|
||||
if (isServiceInvokeAction.value && newAction.productId && newAction.identifier) {
|
||||
// 异步加载服务信息以设置selectedService
|
||||
await loadServiceFromTSL(newAction.productId, newAction.identifier)
|
||||
} else if (isServiceInvokeAction.value) {
|
||||
// 清空服务选择
|
||||
selectedService.value = null
|
||||
}
|
||||
|
||||
// 处理参数回显
|
||||
if (newAction.params && Object.keys(newAction.params).length > 0) {
|
||||
try {
|
||||
const newJsonString = JSON.stringify(newAction.params, null, 2)
|
||||
if (paramsJson.value !== newJsonString) {
|
||||
paramsJson.value = newJsonString
|
||||
propertyValues.value = { ...newAction.params }
|
||||
jsonError.value = ''
|
||||
console.log('✅ Params restored:', newAction.params)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 参数格式化失败:', error)
|
||||
jsonError.value = '参数格式化失败'
|
||||
}
|
||||
} else {
|
||||
if (paramsJson.value !== '') {
|
||||
paramsJson.value = ''
|
||||
propertyValues.value = {}
|
||||
jsonError.value = ''
|
||||
console.log('🧹 Params cleared')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
@ -139,6 +139,7 @@ const createDefaultActionData = (): ActionFormData => {
|
|||
type: ActionTypeEnum.DEVICE_PROPERTY_SET, // 默认为设备属性设置
|
||||
productId: undefined,
|
||||
deviceId: undefined,
|
||||
identifier: undefined, // 物模型标识符(服务调用时使用)
|
||||
params: {},
|
||||
alertConfigId: undefined
|
||||
}
|
||||
|
@ -197,10 +198,15 @@ const onActionTypeChange = (action: ActionFormData, type: number) => {
|
|||
if (!action.params) {
|
||||
action.params = {}
|
||||
}
|
||||
// 如果从其他类型切换到设备控制类型,清空identifier(让用户重新选择)
|
||||
if (action.identifier && type !== action.type) {
|
||||
action.identifier = undefined
|
||||
}
|
||||
} else if (isAlertAction(type)) {
|
||||
// 告警类型:清理设备配置
|
||||
action.productId = undefined
|
||||
action.deviceId = undefined
|
||||
action.identifier = undefined // 清理服务标识符
|
||||
action.params = undefined
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,460 @@
|
|||
<!-- 服务选择器组件 -->
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<el-select
|
||||
:model-value="modelValue"
|
||||
@update:model-value="handleChange"
|
||||
placeholder="请选择服务"
|
||||
filterable
|
||||
clearable
|
||||
class="w-full"
|
||||
:loading="loading"
|
||||
:disabled="!productId"
|
||||
>
|
||||
<el-option
|
||||
v-for="service in serviceList"
|
||||
:key="service.identifier"
|
||||
:label="service.name"
|
||||
:value="service.identifier"
|
||||
>
|
||||
<div class="flex items-center justify-between w-full py-4px">
|
||||
<div class="flex items-center gap-12px flex-1">
|
||||
<Icon
|
||||
icon="ep:service"
|
||||
class="text-18px text-[var(--el-color-success)] flex-shrink-0"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<div class="text-14px font-500 text-[var(--el-text-color-primary)] mb-2px">
|
||||
{{ service.name }}
|
||||
</div>
|
||||
<div class="text-12px text-[var(--el-text-color-secondary)] leading-relaxed">
|
||||
{{ service.identifier }}
|
||||
</div>
|
||||
<div
|
||||
v-if="service.description"
|
||||
class="text-11px text-[var(--el-text-color-secondary)] mt-2px"
|
||||
>
|
||||
{{ service.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-8px">
|
||||
<el-tag :type="getCallTypeTag(service.callType)" size="small">
|
||||
{{ getCallTypeLabel(service.callType) }}
|
||||
</el-tag>
|
||||
<el-button
|
||||
ref="detailTriggerRef"
|
||||
type="info"
|
||||
:icon="InfoFilled"
|
||||
circle
|
||||
size="small"
|
||||
@click.stop="showServiceDetail(service)"
|
||||
title="查看服务详情"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
|
||||
<!-- 服务详情弹出层 -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showServiceDetailPopover && selectedService"
|
||||
ref="serviceDetailRef"
|
||||
class="service-detail-popover"
|
||||
:style="servicePopoverStyle"
|
||||
>
|
||||
<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-success)] text-18px" />
|
||||
<span class="text-16px font-600 text-[var(--el-text-color-primary)]">
|
||||
{{ selectedService.name }}
|
||||
</span>
|
||||
<el-tag :type="getCallTypeTag(selectedService.callType)" size="small">
|
||||
{{ getCallTypeLabel(selectedService.callType) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="space-y-16px">
|
||||
<!-- 基本信息 -->
|
||||
<div>
|
||||
<div class="flex items-center gap-8px mb-8px">
|
||||
<Icon icon="ep:info" class="text-[var(--el-color-info)] text-14px" />
|
||||
<span class="text-14px font-500 text-[var(--el-text-color-primary)]">基本信息</span>
|
||||
</div>
|
||||
<div class="ml-22px space-y-4px">
|
||||
<div class="text-12px">
|
||||
<span class="text-[var(--el-text-color-secondary)]">标识符:</span>
|
||||
<span class="text-[var(--el-text-color-primary)]">{{
|
||||
selectedService.identifier
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="selectedService.description" class="text-12px">
|
||||
<span class="text-[var(--el-text-color-secondary)]">描述:</span>
|
||||
<span class="text-[var(--el-text-color-primary)]">{{
|
||||
selectedService.description
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="text-12px">
|
||||
<span class="text-[var(--el-text-color-secondary)]">调用方式:</span>
|
||||
<span class="text-[var(--el-text-color-primary)]">{{
|
||||
getCallTypeLabel(selectedService.callType)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入参数 -->
|
||||
<div v-if="selectedService.inputParams && selectedService.inputParams.length > 0">
|
||||
<div class="flex items-center gap-8px mb-8px">
|
||||
<Icon icon="ep:download" 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 selectedService.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 }}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输出参数 -->
|
||||
<div v-if="selectedService.outputParams && selectedService.outputParams.length > 0">
|
||||
<div class="flex items-center gap-8px mb-8px">
|
||||
<Icon icon="ep:upload" class="text-[var(--el-color-warning)] 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 selectedService.outputParams"
|
||||
: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 }}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关闭按钮 -->
|
||||
<div class="flex justify-end mt-16px">
|
||||
<el-button size="small" @click="hideServiceDetail">关闭</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { InfoFilled } from '@element-plus/icons-vue'
|
||||
import { ThingModelApi } from '@/api/iot/thingmodel'
|
||||
import { ThingModelService } from '@/api/iot/rule/scene/scene.types'
|
||||
import { getThingModelServiceCallTypeLabel } from '@/views/iot/utils/constants'
|
||||
|
||||
/** 服务选择器组件 */
|
||||
defineOptions({ name: 'ServiceSelector' })
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string
|
||||
productId?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value?: string): void
|
||||
(e: 'change', value?: string, service?: ThingModelService): void
|
||||
}>()
|
||||
|
||||
const localValue = useVModel(props, 'modelValue', emit)
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const serviceList = ref<ThingModelService[]>([])
|
||||
const showServiceDetailPopover = ref(false)
|
||||
const selectedService = ref<ThingModelService | null>(null)
|
||||
const detailTriggerRef = ref()
|
||||
const serviceDetailRef = ref()
|
||||
const servicePopoverStyle = ref({})
|
||||
|
||||
// 事件处理
|
||||
const handleChange = (value?: string) => {
|
||||
const service = serviceList.value.find((s) => s.identifier === value)
|
||||
emit('change', value, service)
|
||||
}
|
||||
|
||||
// 获取物模型TSL数据
|
||||
const getThingModelTSL = async () => {
|
||||
if (!props.productId) {
|
||||
serviceList.value = []
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const tslData = await ThingModelApi.getThingModelTSLByProductId(props.productId)
|
||||
serviceList.value = tslData?.services || []
|
||||
} catch (error) {
|
||||
console.error('获取物模型TSL失败:', error)
|
||||
serviceList.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
const getCallTypeLabel = (callType: string) => {
|
||||
return getThingModelServiceCallTypeLabel(callType) || callType
|
||||
}
|
||||
|
||||
const getCallTypeTag = (callType: string) => {
|
||||
return callType === 'sync' ? 'primary' : 'success'
|
||||
}
|
||||
|
||||
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 showServiceDetail = (service: ThingModelService) => {
|
||||
selectedService.value = service
|
||||
showServiceDetailPopover.value = true
|
||||
|
||||
nextTick(() => {
|
||||
updateServicePopoverPosition()
|
||||
})
|
||||
}
|
||||
|
||||
const hideServiceDetail = () => {
|
||||
showServiceDetailPopover.value = false
|
||||
selectedService.value = null
|
||||
}
|
||||
|
||||
const updateServicePopoverPosition = () => {
|
||||
if (!detailTriggerRef.value || !serviceDetailRef.value) return
|
||||
|
||||
const triggerEl = detailTriggerRef.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 = serviceDetailRef.value.offsetHeight || 300
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
if (top + popoverHeight > viewportHeight - 16) {
|
||||
finalTop = Math.max(16, viewportHeight - popoverHeight - 16)
|
||||
}
|
||||
|
||||
servicePopoverStyle.value = {
|
||||
position: 'fixed',
|
||||
left: `${finalLeft}px`,
|
||||
top: `${finalTop}px`,
|
||||
zIndex: 9999
|
||||
}
|
||||
}
|
||||
|
||||
// 监听产品变化
|
||||
watch(
|
||||
() => props.productId,
|
||||
() => {
|
||||
getThingModelTSL()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 监听modelValue变化,处理编辑模式的回显
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
console.log('🔄 ServiceSelector modelValue changed:', {
|
||||
newValue,
|
||||
serviceListLength: serviceList.value.length,
|
||||
serviceList: serviceList.value.map((s) => s.identifier)
|
||||
})
|
||||
|
||||
if (newValue && serviceList.value.length > 0) {
|
||||
// 确保服务列表已加载,然后设置选中的服务
|
||||
const service = serviceList.value.find((s) => s.identifier === newValue)
|
||||
console.log('🎯 ServiceSelector found service:', service)
|
||||
|
||||
if (service) {
|
||||
selectedService.value = service
|
||||
console.log('✅ ServiceSelector service set:', service.name)
|
||||
} else {
|
||||
console.warn('⚠️ ServiceSelector service not found for identifier:', newValue)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 监听服务列表变化,处理异步加载后的回显
|
||||
watch(
|
||||
() => serviceList.value,
|
||||
(newServiceList) => {
|
||||
console.log('📋 ServiceSelector serviceList changed:', {
|
||||
length: newServiceList.length,
|
||||
services: newServiceList.map((s) => s.identifier),
|
||||
modelValue: props.modelValue
|
||||
})
|
||||
|
||||
if (newServiceList.length > 0 && props.modelValue) {
|
||||
// 服务列表加载完成后,如果有modelValue,设置选中的服务
|
||||
const service = newServiceList.find((s) => s.identifier === props.modelValue)
|
||||
console.log('🎯 ServiceSelector found service in list:', service)
|
||||
|
||||
if (service) {
|
||||
selectedService.value = service
|
||||
console.log('✅ ServiceSelector service set from list:', service.name)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 监听窗口大小变化
|
||||
const handleResize = () => {
|
||||
if (showServiceDetailPopover.value) {
|
||||
updateServicePopoverPosition()
|
||||
}
|
||||
}
|
||||
|
||||
// 点击外部关闭弹出层
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
showServiceDetailPopover.value &&
|
||||
serviceDetailRef.value &&
|
||||
detailTriggerRef.value &&
|
||||
!serviceDetailRef.value.contains(event.target as Node) &&
|
||||
!detailTriggerRef.value.$el.contains(event.target as Node)
|
||||
) {
|
||||
hideServiceDetail()
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.service-detail-popover {
|
||||
animation: fadeInScale 0.2s ease-out;
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.service-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: '';
|
||||
}
|
||||
|
||||
.service-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: '';
|
||||
}
|
||||
|
||||
:deep(.el-select-dropdown__item) {
|
||||
height: auto;
|
||||
padding: 8px 20px;
|
||||
}
|
||||
</style>
|
Loading…
Reference in New Issue