Bladeren bron

perf:【IoT 物联网】场景联动执行器优化

puhui999 11 maanden geleden
bovenliggende
commit
9684593623

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

@@ -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 // 告警配置编号
 }

+ 9 - 0
src/views/iot/rule/scene/form/RuleSceneForm.vue

@@ -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

+ 333 - 12
src/views/iot/rule/scene/form/configs/DeviceControlConfig.vue

@@ -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>

+ 6 - 0
src/views/iot/rule/scene/form/sections/ActionSection.vue

@@ -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
   }
 

+ 460 - 0
src/views/iot/rule/scene/form/selectors/ServiceSelector.vue

@@ -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>