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

pull/805/head
puhui999 2025-08-04 21:04:18 +08:00
parent e3a8e98ff8
commit 9684593623
5 changed files with 810 additions and 12 deletions

View File

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

View File

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

View File

@ -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
})
// tickServiceSelector
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>

View File

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

View File

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