组件:SmartSelectorPlus.vue
javascript
<!-- 智能跟随弹框组件 -->
<template>
<div class="smart-popover-container" ref="containerRef">
<!-- 触发器插槽 -->
<div ref="triggerRef" class="smart-popover-trigger" @click.stop="handleTriggerClick" @mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave">
<slot name="trigger">
<el-input ref="inputRef" v-model="displayValue" :placeholder="placeholder" :style="{ width: inputWidth }"
:type="multiline ? 'textarea' : 'text'" :autosize="autosize" :size="size" :readonly="readonly"
:disabled="disabled" :clearable="clearable" @focus="handleInputFocus" @blur="handleInputBlur"
@clear="handleClear">
<template #suffix>
<el-icon :class="{ 'rotate-180': visible }">
<arrow-down />
</el-icon>
</template>
</el-input>
</slot>
</div>
<!-- 弹框内容 -->
<teleport to="body">
<transition name="smart-popover-fade">
<div v-if="visible" ref="popoverRef" class="smart-popover-content"
:class="[popoverClass, `placement-${placement}`]" :style="popoverStyle" @click.stop>
<!-- 箭头指示器 -->
<div v-if="showArrow" class="smart-popover-arrow" :style="arrowStyle"></div>
<div class="smart-popover-inner" :style="{ maxHeight: maxHeight }">
<!-- 自定义头部 -->
<div v-if="$slots.header" class="smart-popover-header">
<slot name="header"></slot>
</div>
<!-- Tab栏 -->
<el-tabs v-if="hasTabs" v-model="activeTab" class="smart-popover-tabs" @tab-click="handleTabChange">
<el-tab-pane v-for="tab in tabs" :key="tab.name" :label="tab.label" :name="tab.name" />
</el-tabs>
<!-- 搜索框 -->
<div v-if="searchable" class="smart-popover-search">
<el-input v-model="searchText" placeholder="搜索选项..." size="small" clearable @input="handleSearch">
<template #prefix>
<el-icon>
<search />
</el-icon>
</template>
</el-input>
</div>
<!-- 滚动内容区域 -->
<el-scrollbar class="smart-popover-scroll">
<!-- 自定义内容插槽 -->
<slot v-if="$slots.default" :close="closePopover"></slot>
<!-- 默认选项内容 -->
<template v-else>
<template v-for="(group, index) in filteredGroups" :key="index">
<!-- 分组标题 -->
<div v-if="group.title" class="group-title">
{{ group.title }}
</div>
<!-- 选项网格 -->
<div class="options-grid" :class="gridClass">
<div v-for="(item, itemIndex) in group.options" :key="`${index}-${itemIndex}`" class="option-item"
:class="{
'is-selected': isSelected(item),
'is-disabled': isDisabled(item)
}" @click="handleSelect(item)">
<!-- 自定义选项内容 -->
<slot name="option" :item="item" :selected="isSelected(item)">
{{ getOptionLabel(item) }}
</slot>
</div>
</div>
<!-- 分组分割线 -->
<el-divider v-if="index < filteredGroups.length - 1" />
</template>
<!-- 无数据提示 -->
<div v-if="filteredGroups.length === 0" class="empty-content">
<slot name="empty">
<el-empty description="暂无数据" :image-size="80" />
</slot>
</div>
</template>
</el-scrollbar>
<!-- 自定义底部 -->
<div v-if="$slots.footer" class="smart-popover-footer">
<slot name="footer" :close="closePopover"></slot>
</div>
</div>
</div>
</transition>
</teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { ArrowDown, Search } from '@element-plus/icons-vue'
// 类型定义
interface Option {
label?: string
value?: any
disabled?: boolean
[key: string]: any
}
interface Group {
title?: string
options: Option[]
}
interface Tab {
name: string
label: string
options?: Option[]
groups?: Group[]
}
// modelValue: 绑定值
// options/groups/tabs: 选项数据
// multiple: 多选模式
// searchable: 是否可搜索
// trigger: 触发方式(click/hover/focus/manual)
// placement: 弹出位置
// width/maxHeight: 尺寸设置
// gridColumns: 网格列数
const props = withDefaults(defineProps<{
modelValue?: string | number | Array<any>
// options?: Option[]
options?: any
// groups?: Group[]
groups?: any
// tabs?: Tab[]
tabs?: any
placeholder?: string
width?: string | number
inputWidth?: string
maxHeight?: string
separator?: string
multiline?: boolean
autosize?: object | boolean
placement?: 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end'
readonly?: boolean
disabled?: boolean
clearable?: boolean
popoverClass?: string
size?: 'large' | 'default' | 'small'
singleSelect?: boolean
multiple?: boolean
searchable?: boolean
showArrow?: boolean
trigger?: 'click' | 'hover' | 'focus' | 'manual'
offset?: number
zIndex?: number
gridColumns?: number | 'auto'
}>(), {
placeholder: '请选择',
width: 300,
inputWidth: '200px',
maxHeight: '300px',
separator: ',',
placement: 'bottom-start',
size: 'default',
trigger: 'click',
offset: 8,
zIndex: 2000,
gridColumns: 'auto'
})
const emit = defineEmits(['update:modelValue', 'select', 'clear', 'open', 'close', 'tab-change', 'search'])
// 响应式数据
const containerRef = ref<HTMLElement>()
const triggerRef = ref<HTMLElement>()
const popoverRef = ref<HTMLElement>()
const inputRef = ref()
const visible = ref(false)
const activeTab = ref('')
const searchText = ref('')
const popoverStyle = ref({})
const arrowStyle = ref({})
// 计算属性
const hasTabs = computed(() => props.tabs && props.tabs.length > 0)
const currentGroups = computed(() => {
if (hasTabs.value && props.tabs) {
const tab = props.tabs.find((t: any) => t.name === activeTab.value)
if (tab?.groups) return tab.groups
if (tab?.options) return [{ options: tab.options }]
return []
}
return props.groups && props.groups.length > 0 ? props.groups : [{ options: props.options || [] }]
})
const filteredGroups: any = computed(() => {
if (!searchText.value.trim()) return currentGroups.value
return currentGroups.value.map((group: any) => ({
...group,
options: group.options.filter((item: any) => {
const label = getOptionLabel(item).toLowerCase()
return label.includes(searchText.value.toLowerCase())
})
})).filter((group: any) => group.options.length > 0)
})
const displayValue = computed({
get: () => {
if (props.multiple || Array.isArray(props.modelValue)) {
const values = Array.isArray(props.modelValue) ? props.modelValue : []
return values.join(props.separator)
}
return props.modelValue?.toString() || ''
},
set: (val) => {
if (props.multiple) {
const values = val ? val.split(props.separator) : []
emit('update:modelValue', values)
} else {
emit('update:modelValue', val)
}
}
})
const gridClass = computed(() => {
if (typeof props.gridColumns === 'number') {
return `grid-cols-${props.gridColumns}`
}
return 'grid-auto'
})
// 工具函数
const getOptionLabel = (item: Option | string): string => {
if (typeof item === 'string') return item
return item?.label || item?.value?.toString() || ''
}
const getOptionValue = (item: Option | string): any => {
if (typeof item === 'string') return item
return item?.value !== undefined ? item.value : item?.label
}
const isSelected = (item: Option | string): boolean => {
const value = getOptionValue(item)
if (props.multiple || Array.isArray(props.modelValue)) {
const values = Array.isArray(props.modelValue) ? props.modelValue : []
return values.includes(value)
}
return props.modelValue === value
}
const isDisabled = (item: Option | string): boolean => {
if (typeof item === 'string') return false
return Boolean(item?.disabled)
}
// 弹框位置计算
const calculatePosition = () => {
if (!triggerRef.value || !popoverRef.value) return
const triggerRect = triggerRef.value.getBoundingClientRect()
const popoverRect = popoverRef.value.getBoundingClientRect()
const viewport = {
width: window.innerWidth,
height: window.innerHeight
}
let top = 0
let left = 0
let arrowTop = ''
let arrowLeft = ''
switch (props.placement) {
case 'bottom':
case 'bottom-start':
top = triggerRect.bottom + props.offset
left = props.placement === 'bottom'
? triggerRect.left + (triggerRect.width - popoverRect.width) / 2
: triggerRect.left
arrowTop = `-${props.offset}px`
arrowLeft = props.placement === 'bottom'
? '50%'
: `${Math.min(20, triggerRect.width / 2)}px`
break
case 'bottom-end':
top = triggerRect.bottom + props.offset
left = triggerRect.right - popoverRect.width
arrowTop = `-${props.offset}px`
arrowLeft = `${popoverRect.width - Math.min(20, triggerRect.width / 2)}px`
break
case 'top':
case 'top-start':
top = triggerRect.top - popoverRect.height - props.offset
left = props.placement === 'top'
? triggerRect.left + (triggerRect.width - popoverRect.width) / 2
: triggerRect.left
arrowTop = `${popoverRect.height}px`
arrowLeft = props.placement === 'top'
? '50%'
: `${Math.min(20, triggerRect.width / 2)}px`
break
// 可以继续添加其他位置...
}
// 边界检查和调整
if (left < 10) left = 10
if (left + popoverRect.width > viewport.width - 10) {
left = viewport.width - popoverRect.width - 10
}
if (top < 10) top = triggerRect.bottom + props.offset
if (top + popoverRect.height > viewport.height - 10) {
top = triggerRect.top - popoverRect.height - props.offset
}
popoverStyle.value = {
position: 'fixed',
top: `${top}px`,
left: `${left}px`,
width: typeof props.width === 'number' ? `${props.width}px` : props.width,
zIndex: props.zIndex ?? 3000
}
if (props.showArrow) {
arrowStyle.value = {
top: arrowTop,
left: arrowLeft,
transform: arrowLeft === '50%' ? 'translateX(-50%)' : ''
}
}
}
// 事件处理
const openPopover = async () => {
if (props.disabled || visible.value) return
visible.value = true
emit('open')
await nextTick()
calculatePosition()
}
const closePopover = () => {
if (!visible.value) return
visible.value = false
searchText.value = ''
emit('close')
}
const handleTriggerClick = () => {
if (props.trigger === 'click') {
visible.value ? closePopover() : openPopover()
}
}
const handleMouseEnter = () => {
if (props.trigger === 'hover') {
openPopover()
}
}
const handleMouseLeave = () => {
if (props.trigger === 'hover') {
setTimeout(() => {
if (!popoverRef.value?.matches(':hover')) {
closePopover()
}
}, 100)
}
}
const handleInputFocus = () => {
if (props.trigger === 'focus') {
openPopover()
}
}
const handleInputBlur = () => {
if (props.trigger === 'focus') {
setTimeout(() => {
if (!popoverRef.value?.matches(':hover')) {
closePopover()
}
}, 200)
}
}
const handleSelect = (item: Option | string) => {
if (isDisabled(item)) return
const value = getOptionValue(item)
if (props.singleSelect || (!props.multiple && !Array.isArray(props.modelValue))) {
// 单选模式
emit('update:modelValue', value)
emit('select', typeof item === 'string' ? { value: item, label: item } : item)
closePopover()
} else {
// 多选模式
const currentValues = Array.isArray(props.modelValue) ? [...props.modelValue] : []
const index = currentValues.indexOf(value)
if (index > -1) {
currentValues.splice(index, 1)
} else {
currentValues.push(value)
}
emit('update:modelValue', currentValues)
emit('select', typeof item === 'string' ? { value: item, label: item } : item)
}
}
const handleTabChange = (tab: any) => {
activeTab.value = tab.props.name
emit('tab-change', tab.props.name)
}
const handleSearch = (keyword: string) => {
emit('search', keyword)
}
const handleClear = () => {
emit('update:modelValue', props.multiple ? [] : '')
emit('clear')
}
// 点击外部关闭
const handleClickOutside = (event: MouseEvent) => {
if (!visible.value) return
const target = event.target as Node
if (
containerRef.value?.contains(target) ||
popoverRef.value?.contains(target)
) {
return
}
closePopover()
}
// 键盘事件
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closePopover()
}
}
// 窗口调整大小时重新计算位置
const handleResize = () => {
if (visible.value) {
calculatePosition()
}
}
// 生命周期
onMounted(() => {
// 初始化第一个Tab
if (hasTabs.value && props.tabs) {
activeTab.value = props.tabs[0].name
}
document.addEventListener('click', handleClickOutside, true)
document.addEventListener('keydown', handleKeydown)
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside, true)
document.removeEventListener('keydown', handleKeydown)
window.removeEventListener('resize', handleResize)
})
// 监听器
watch(visible, (newVisible) => {
if (newVisible) {
nextTick(calculatePosition)
}
})
// 暴露方法
defineExpose({
open: openPopover,
close: closePopover,
toggle: () => visible.value ? closePopover() : openPopover()
})
</script>
<style scoped lang="scss">
.smart-popover-container {
position: relative;
display: inline-block;
}
.smart-popover-trigger {
.rotate-180 {
transform: rotate(180deg);
transition: transform 0.3s;
}
}
.smart-popover-content {
background: white;
border-radius: 8px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
border: 1px solid #e4e7ed;
position: fixed;
z-index: 3000;
&.placement-bottom,
&.placement-bottom-start,
&.placement-bottom-end {
.smart-popover-arrow {
top: -8px;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 8px solid white;
&::before {
content: '';
position: absolute;
top: -1px;
left: -8px;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 8px solid #e4e7ed;
}
}
}
}
.smart-popover-arrow {
position: absolute;
width: 0;
height: 0;
}
.smart-popover-inner {
padding: 12px;
overflow: hidden;
z-index: 9999;
}
.smart-popover-header {
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
.smart-popover-tabs {
:deep(.el-tabs__header) {
margin: 0 0 12px 0;
}
}
.smart-popover-search {
margin-bottom: 12px;
}
.smart-popover-scroll {
max-height: inherit;
}
.smart-popover-footer {
margin-top: 12px;
padding-top: 8px;
border-top: 1px solid #f0f0f0;
}
.group-title {
padding: 8px 0;
font-weight: 600;
color: var(--el-color-primary);
font-size: 14px;
}
.options-grid {
display: grid;
gap: 8px;
padding: 4px 0;
&.grid-auto {
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
}
&.grid-cols-1 {
grid-template-columns: repeat(1, 1fr);
}
&.grid-cols-2 {
grid-template-columns: repeat(2, 1fr);
}
&.grid-cols-3 {
grid-template-columns: repeat(3, 1fr);
}
&.grid-cols-4 {
grid-template-columns: repeat(4, 1fr);
}
&.grid-cols-5 {
grid-template-columns: repeat(5, 1fr);
}
&.grid-cols-6 {
grid-template-columns: repeat(6, 1fr);
}
}
.option-item {
padding: 8px 12px;
background: #f8f9fa;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
font-size: 13px;
border: 1px solid transparent;
user-select: none;
&:hover:not(.is-disabled) {
background: var(--el-color-primary-light-8);
border-color: var(--el-color-primary-light-5);
transform: translateY(-1px);
}
&.is-selected {
background: var(--el-color-primary);
color: white;
font-weight: 500;
}
&.is-disabled {
background: #f5f7fa;
color: #c0c4cc;
cursor: not-allowed;
opacity: 0.6;
}
}
.empty-content {
padding: 20px;
text-align: center;
}
:deep(.el-divider--horizontal) {
margin: 12px 0;
}
// 过渡动画
.smart-popover-fade-enter-active,
.smart-popover-fade-leave-active {
transition: all 0.2s ease;
}
.smart-popover-fade-enter-from,
.smart-popover-fade-leave-to {
opacity: 0;
transform: scale(0.95);
}
</style>
使用示例:
javascript
<template>
<div class="examples-container">
<h1>SmartPopoverPlus 使用示例</h1>
<!-- 1. 基础用法 -->
<section class="example-section">
<h2>1. 基础用法</h2>
<SmartPopoverPlus
v-model="form.basic"
:options="basicOptions"
placeholder="选择基础选项"
@select="handleSelect"
/>
<p>已选择: {{ form.basic }}</p>
</section>
<!-- 2. 多选模式 -->
<section class="example-section">
<h2>2. 多选模式</h2>
<SmartPopoverPlus
v-model="form.multiple"
:options="basicOptions"
placeholder="多选模式"
multiple
separator=" | "
/>
<p>已选择: {{ form.multiple }}</p>
</section>
<!-- 3. 分组选项 -->
<section class="example-section">
<h2>3. 分组选项</h2>
<SmartPopoverPlus
v-model="form.grouped"
:groups="groupedOptions"
placeholder="分组选择"
width="400px"
searchable
/>
<p>已选择: {{ form.grouped }}</p>
</section>
<!-- 4. Tab栏模式 -->
<section class="example-section">
<h2>4. Tab栏模式</h2>
<SmartPopoverPlus
v-model="form.tabbed"
:tabs="tabbedOptions"
placeholder="Tab栏选择"
width="500px"
multiple
@tab-change="handleTabChange"
/>
<p>已选择: {{ form.tabbed }}</p>
</section>
<!-- 5. 自定义触发器 -->
<section class="example-section">
<h2>5. 自定义触发器</h2>
<SmartPopoverPlus
v-model="form.customTrigger"
:options="basicOptions"
>
<template #trigger>
<el-button type="primary">
自定义按钮触发器
<el-icon><ArrowDown /></el-icon>
</el-button>
</template>
</SmartPopoverPlus>
<p>已选择: {{ form.customTrigger }}</p>
</section>
<!-- 6. 自定义选项内容 -->
<section class="example-section">
<h2>6. 自定义选项内容</h2>
<SmartPopoverPlus
v-model="form.customOption"
:options="userOptions"
placeholder="选择用户"
width="300px"
>
<template #option="{ item, selected }">
<div class="custom-option" :class="{ 'is-selected': selected }">
<el-avatar :size="24" :src="item.avatar" />
<div class="user-info">
<div class="name">{{ item.label }}</div>
<div class="role">{{ item.role }}</div>
</div>
</div>
</template>
</SmartPopoverPlus>
<p>已选择: {{ form.customOption }}</p>
</section>
<!-- 7. Hover触发 -->
<section class="example-section">
<h2>7. Hover触发</h2>
<SmartPopoverPlus
v-model="form.hover"
:options="basicOptions"
trigger="hover"
placeholder="鼠标悬停触发"
/>
<p>已选择: {{ form.hover }}</p>
</section>
<!-- 8. 完全自定义内容 -->
<section class="example-section">
<h2>8. 完全自定义内容</h2>
<SmartPopoverPlus
v-model="form.custom"
placeholder="自定义内容"
width="350px"
>
<template #header>
<div style="font-weight: bold; color: #409eff;">自定义头部</div>
</template>
<template #default="{ close }">
<div class="custom-content">
<h4>这是完全自定义的内容</h4>
<p>你可以放置任何内容在这里</p>
<el-button-group>
<el-button size="small" @click="handleCustomAction('action1', close)">
操作1
</el-button>
<el-button size="small" @click="handleCustomAction('action2', close)">
操作2
</el-button>
</el-button-group>
</div>
</template>
<template #footer="{ close }">
<div style="text-align: right;">
<el-button size="small" @click="close">关闭</el-button>
</div>
</template>
</SmartPopoverPlus>
<p>已选择: {{ form.custom }}</p>
</section>
<!-- 9. 不同网格布局 -->
<section class="example-section">
<h2>9. 网格布局</h2>
<el-row :gutter="20">
<el-col :span="8">
<SmartPopoverPlus
v-model="form.grid2"
:options="basicOptions"
placeholder="2列布局"
:grid-columns="2"
width="200px"
/>
</el-col>
<el-col :span="8">
<SmartPopoverPlus
v-model="form.grid3"
:options="basicOptions"
placeholder="3列布局"
:grid-columns="3"
width="250px"
/>
</el-col>
<el-col :span="8">
<SmartPopoverPlus
v-model="form.gridAuto"
:options="basicOptions"
placeholder="自动布局"
grid-columns="auto"
width="300px"
/>
</el-col>
</el-row>
</section>
<!-- 10. 程序控制 -->
<section class="example-section">
<h2>10. 程序控制</h2>
<el-space>
<SmartPopoverPlus
ref="programmaticRef"
v-model="form.programmatic"
:options="basicOptions"
trigger="manual"
placeholder="程序控制"
/>
<el-button @click="openProgrammatic">打开</el-button>
<el-button @click="closeProgrammatic">关闭</el-button>
<el-button @click="toggleProgrammatic">切换</el-button>
</el-space>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ArrowDown } from '@element-plus/icons-vue'
import SmartPopoverPlus from './SmartSelectorPlus.vue'
const form = reactive({
basic: '',
multiple: [],
grouped: '',
tabbed: [],
customTrigger: '',
customOption: '',
hover: '',
custom: '',
grid2: '',
grid3: '',
gridAuto: '',
programmatic: ''
})
const programmaticRef = ref()
// 基础选项
const basicOptions = [
'选项一', '选项二', '选项三', '选项四', '选项五', '选项六'
]
// 分组选项
const groupedOptions = [
{
title: '常用',
options: ['苹果', '香蕉', '橙子']
},
{
title: '其他',
options: ['葡萄', '西瓜', '梨子']
}
]
// Tab栏选项
const tabbedOptions = [
{
name: '水果',
label: '水果',
options: ['苹果', '香蕉', '橙子']
},
{
name: '蔬菜',
label: '蔬菜',
options: ['西红柿', '黄瓜', '胡萝卜']
}
]
// 用户选项(自定义内容示例)
const userOptions = [
{ label: '张三', value: 'zhangsan', role: '管理员', avatar: 'https://i.pravatar.cc/40?img=1' },
{ label: '李四', value: 'lisi', role: '编辑', avatar: 'https://i.pravatar.cc/40?img=2' },
{ label: '王五', value: 'wangwu', role: '访客', avatar: 'https://i.pravatar.cc/40?img=3' }
]
// 事件处理
function handleSelect(item:any) {
console.log('选择了:', item)
}
function handleTabChange(name:string) {
console.log('切换到 Tab:', name)
}
function handleCustomAction(action:string, close:Function) {
console.log('执行自定义操作:', action)
close()
}
// 程序控制
function openProgrammatic() {
programmaticRef.value?.open()
}
function closeProgrammatic() {
programmaticRef.value?.close()
}
function toggleProgrammatic() {
programmaticRef.value?.toggle()
}
</script>
<style scoped>
.examples-container {
padding: 20px;
}
.example-section {
margin-bottom: 40px;
}
.custom-option {
display: flex;
align-items: center;
gap: 8px;
}
.custom-option.is-selected {
background: #409eff20;
}
.user-info .name {
font-weight: 600;
}
.user-info .role {
font-size: 12px;
color: #909399;
}
</style>