perf:【IoT 物联网】场景联动属性选择器属性详情展示逻辑优化,直接使用el-popover

pull/806/head
puhui999 2025-08-05 15:30:56 +08:00
parent 6c954c4ff1
commit ab54879203
2 changed files with 144 additions and 669 deletions

View File

@ -37,90 +37,122 @@
</el-option-group> </el-option-group>
</el-select> </el-select>
<!-- 属性详情触发按钮 --> <!-- 属性详情弹出层 -->
<div class="relative"> <el-popover
<el-button v-if="selectedProperty"
v-if="selectedProperty" placement="right-start"
ref="detailTriggerRef" :width="350"
type="info" trigger="click"
:icon="InfoFilled" :show-arrow="true"
circle :offset="8"
size="small" popper-class="property-detail-popover"
@click="togglePropertyDetail" >
class="flex-shrink-0" <template #reference>
title="查看属性详情" <el-button
/> type="info"
:icon="InfoFilled"
circle
size="small"
class="flex-shrink-0"
title="查看属性详情"
/>
</template>
<!-- 属性详情弹出层 --> <!-- 弹出层内容 -->
<Teleport to="body"> <div class="property-detail-content">
<div <div class="flex items-center gap-8px mb-12px">
v-if="showPropertyDetail && selectedProperty" <Icon icon="ep:info-filled" class="text-[var(--el-color-info)] text-16px" />
ref="propertyDetailRef" <span class="text-14px font-500 text-[var(--el-text-color-primary)]">
class="property-detail-popover" {{ selectedProperty.name }}
:style="popoverStyle" </span>
> <el-tag :type="getPropertyTypeTag(selectedProperty.dataType)" size="small">
{{ getPropertyTypeName(selectedProperty.dataType) }}
</el-tag>
</div>
<div class="space-y-8px ml-24px">
<div class="flex items-start gap-8px">
<span class="text-12px text-[var(--el-text-color-secondary)] min-w-60px flex-shrink-0">
标识符
</span>
<span class="text-12px text-[var(--el-text-color-primary)] flex-1">
{{ selectedProperty.identifier }}
</span>
</div>
<div v-if="selectedProperty.description" class="flex items-start gap-8px">
<span class="text-12px text-[var(--el-text-color-secondary)] min-w-60px flex-shrink-0">
描述
</span>
<span class="text-12px text-[var(--el-text-color-primary)] flex-1">
{{ selectedProperty.description }}
</span>
</div>
<div v-if="selectedProperty.unit" class="flex items-start gap-8px">
<span class="text-12px text-[var(--el-text-color-secondary)] min-w-60px flex-shrink-0">
单位
</span>
<span class="text-12px text-[var(--el-text-color-primary)] flex-1">
{{ selectedProperty.unit }}
</span>
</div>
<div v-if="selectedProperty.range" class="flex items-start gap-8px">
<span class="text-12px text-[var(--el-text-color-secondary)] min-w-60px flex-shrink-0">
取值范围
</span>
<span class="text-12px text-[var(--el-text-color-primary)] flex-1">
{{ selectedProperty.range }}
</span>
</div>
<!-- 根据属性类型显示额外信息 -->
<div <div
class="p-16px bg-white rounded-8px shadow-lg border border-[var(--el-border-color)] min-w-300px max-w-400px" v-if="
selectedProperty.type === IoTThingModelTypeEnum.PROPERTY &&
selectedProperty.accessMode
"
class="flex items-start gap-8px"
> >
<div class="flex items-center gap-8px mb-12px"> <span class="text-12px text-[var(--el-text-color-secondary)] min-w-60px flex-shrink-0">
<Icon icon="ep:info-filled" class="text-[var(--el-color-info)] text-4px" /> 访问模式
<span class="text-14px font-500 text-[var(--el-text-color-primary)]"> </span>
{{ selectedProperty.name }} <span class="text-12px text-[var(--el-text-color-primary)] flex-1">
</span> {{ getAccessModeText(selectedProperty.accessMode) }}
<el-tag :type="getPropertyTypeTag(selectedProperty.dataType)" size="small"> </span>
{{ getPropertyTypeName(selectedProperty.dataType) }} </div>
</el-tag>
</div> <div
<div class="space-y-8px ml-24px"> v-if="
<div class="flex items-start gap-8px"> selectedProperty.type === IoTThingModelTypeEnum.EVENT && selectedProperty.eventType
<span "
class="text-12px text-[var(--el-text-color-secondary)] min-w-60px flex-shrink-0" class="flex items-start gap-8px"
> >
标识符 <span class="text-12px text-[var(--el-text-color-secondary)] min-w-60px flex-shrink-0">
</span> 事件类型
<span class="text-12px text-[var(--el-text-color-primary)] flex-1"> </span>
{{ selectedProperty.identifier }} <span class="text-12px text-[var(--el-text-color-primary)] flex-1">
</span> {{ getEventTypeText(selectedProperty.eventType) }}
</div> </span>
<div v-if="selectedProperty.description" class="flex items-start gap-8px"> </div>
<span
class="text-12px text-[var(--el-text-color-secondary)] min-w-60px flex-shrink-0" <div
> v-if="
描述 selectedProperty.type === IoTThingModelTypeEnum.SERVICE && selectedProperty.callType
</span> "
<span class="text-12px text-[var(--el-text-color-primary)] flex-1"> class="flex items-start gap-8px"
{{ selectedProperty.description }} >
</span> <span class="text-12px text-[var(--el-text-color-secondary)] min-w-60px flex-shrink-0">
</div> 调用类型
<div v-if="selectedProperty.unit" class="flex items-start gap-8px"> </span>
<span <span class="text-12px text-[var(--el-text-color-primary)] flex-1">
class="text-12px text-[var(--el-text-color-secondary)] min-w-60px flex-shrink-0" {{ getCallTypeText(selectedProperty.callType) }}
> </span>
单位
</span>
<span class="text-12px text-[var(--el-text-color-primary)] flex-1">
{{ selectedProperty.unit }}
</span>
</div>
<div v-if="selectedProperty.range" class="flex items-start gap-8px">
<span
class="text-12px text-[var(--el-text-color-secondary)] min-w-60px flex-shrink-0"
>
取值范围
</span>
<span class="text-12px text-[var(--el-text-color-primary)] flex-1">
{{ selectedProperty.range }}
</span>
</div>
</div>
<!-- 关闭按钮 -->
<div class="flex justify-end mt-12px">
<el-button size="small" @click="hidePropertyDetail"></el-button>
</div>
</div> </div>
</div> </div>
</Teleport> </div>
</div> </el-popover>
</div> </div>
</template> </template>
@ -153,25 +185,6 @@ const loading = ref(false)
const propertyList = ref<PropertySelectorItem[]>([]) const propertyList = ref<PropertySelectorItem[]>([])
const thingModelTSL = ref<IotThingModelTSLRespVO | null>(null) const thingModelTSL = ref<IotThingModelTSLRespVO | null>(null)
//
const showPropertyDetail = ref(false)
const detailTriggerRef = ref()
const propertyDetailRef = ref()
const popoverStyle = ref({})
//
const handleClickOutside = (event: MouseEvent) => {
if (
showPropertyDetail.value &&
propertyDetailRef.value &&
detailTriggerRef.value &&
!propertyDetailRef.value.contains(event.target as Node) &&
!detailTriggerRef.value.$el.contains(event.target as Node)
) {
hidePropertyDetail()
}
}
// //
const propertyGroups = computed(() => { const propertyGroups = computed(() => {
const groups: { label: string; options: any[] }[] = [] const groups: { label: string; options: any[] }[] = []
@ -235,65 +248,33 @@ const getPropertyTypeTag = (dataType: string) => {
return tagMap[dataType] || 'info' return tagMap[dataType] || 'info'
} }
// // - 访
const togglePropertyDetail = () => { const getAccessModeText = (accessMode: string) => {
if (showPropertyDetail.value) { const modeMap = {
hidePropertyDetail() r: '只读',
} else { w: '只写',
showPropertyDetailPopover() rw: '读写'
} }
return modeMap[accessMode] || accessMode
} }
const showPropertyDetailPopover = () => { // -
if (!selectedProperty.value || !detailTriggerRef.value) return const getEventTypeText = (eventType: string) => {
const typeMap = {
showPropertyDetail.value = true info: '信息',
alert: '告警',
nextTick(() => { error: '故障'
updatePopoverPosition() }
}) return typeMap[eventType] || eventType
} }
const hidePropertyDetail = () => { // -
showPropertyDetail.value = false const getCallTypeText = (callType: string) => {
} const typeMap = {
sync: '同步',
const updatePopoverPosition = () => { async: '异步'
if (!detailTriggerRef.value || !propertyDetailRef.value) return
const triggerEl = detailTriggerRef.value.$el
const triggerRect = triggerEl.getBoundingClientRect()
const popoverEl = propertyDetailRef.value
//
const left = triggerRect.left + triggerRect.width + 8
const top = triggerRect.top
//
const popoverWidth = 400 //
const viewportWidth = window.innerWidth
let finalLeft = left
if (left + popoverWidth > viewportWidth - 16) {
//
finalLeft = triggerRect.left - popoverWidth - 8
}
//
let finalTop = top
const popoverHeight = popoverEl.offsetHeight || 200
const viewportHeight = window.innerHeight
if (top + popoverHeight > viewportHeight - 16) {
finalTop = Math.max(16, viewportHeight - popoverHeight - 16)
}
popoverStyle.value = {
position: 'fixed',
left: `${finalLeft}px`,
top: `${finalTop}px`,
zIndex: 9999
} }
return typeMap[callType] || callType
} }
// //
@ -305,8 +286,7 @@ const handleChange = (value: string) => {
config: property config: property
}) })
} }
// // el-popover
hidePropertyDetail()
} }
// TSL // TSL
@ -441,21 +421,6 @@ const getPropertyRange = (property: any) => {
return undefined return undefined
} }
//
const getDataRange = (dataSpecs: any) => {
if (!dataSpecs) return undefined
if (dataSpecs.min !== undefined && dataSpecs.max !== undefined) {
return `${dataSpecs.min}~${dataSpecs.max}`
}
if (dataSpecs.dataSpecsList && Array.isArray(dataSpecs.dataSpecsList)) {
return dataSpecs.dataSpecsList.map((item: any) => `${item.name}(${item.value})`).join(', ')
}
return undefined
}
// //
watch( watch(
() => props.productId, () => props.productId,
@ -470,74 +435,30 @@ watch(
() => props.triggerType, () => props.triggerType,
() => { () => {
localValue.value = '' localValue.value = ''
hidePropertyDetail() // el-popover
} }
) )
//
const handleResize = () => {
if (showPropertyDetail.value) {
updatePopoverPosition()
}
}
//
onMounted(() => {
document.addEventListener('click', handleClickOutside)
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
window.removeEventListener('resize', handleResize)
})
</script> </script>
<style scoped> <style scoped>
@keyframes fadeInScale { /* 下拉选项样式 */
from {
opacity: 0;
transform: scale(0.9) translateY(-4px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
:deep(.el-select-dropdown__item) { :deep(.el-select-dropdown__item) {
height: auto; height: auto;
padding: 8px 20px; padding: 8px 20px;
} }
.property-detail-popover { /* 弹出层内容样式 */
animation: fadeInScale 0.2s ease-out; .property-detail-content {
transform-origin: top left; padding: 4px 0;
} }
/* 弹出层箭头效果(可选) */ /* 弹出层自定义样式 */
.property-detail-popover::before { :global(.property-detail-popover) {
position: absolute; /* 可以在这里添加全局弹出层样式 */
top: 20px; max-width: 400px !important;
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: '';
} }
.property-detail-popover::after { :global(.property-detail-popover .el-popover__content) {
position: absolute; padding: 16px !important;
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> </style>

View File

@ -1,446 +0,0 @@
<!-- 服务选择器组件 -->
<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
}>()
// TODO @puhui999
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) => {
// modelValue v-model
emit('update:modelValue', value)
// change
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'
}
// TODO @puhui999
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) => {
if (newValue && serviceList.value.length > 0) {
//
const service = serviceList.value.find((s) => s.identifier === newValue)
if (service) {
selectedService.value = service
}
}
},
{ immediate: true }
)
//
watch(
() => serviceList.value,
(newServiceList) => {
if (newServiceList.length > 0 && props.modelValue) {
// modelValue
const service = newServiceList.find((s) => s.identifier === props.modelValue)
if (service) {
selectedService.value = service
}
}
},
{ 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>