vue3+ts 封装跟随弹框组件,支持多种模式【多选,分组,tab等】

组件: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>
相关推荐
林九生3 小时前
【Vue3】v-dialog 中使用 execCommand(‘copy‘) 复制文本失效的原因与解决方案
前端·javascript·vue.js
yi碗汤园3 小时前
【一文了解】C#的StringSplitOptions枚举
开发语言·前端·c#
cxr8284 小时前
BMAD框架实践:掌握story-checklist提升用户故事质量
前端·人工智能·agi·智能体·ai赋能
小菜全5 小时前
《React vs Vue:选择适合你的前端框架》
vue.js·react.js·前端框架
emma羊羊5 小时前
【xsslabs】第12-19关
前端·javascript·靶场·xss
Larry_Yanan7 小时前
QML学习笔记(十七)QML的属性变更信号
javascript·c++·笔记·qt·学习·ui
真的想不出名儿7 小时前
vue项目引入字体
前端·javascript·vue.js
胡楚昊7 小时前
Polar WEB(1-20)
前端
笨蛋不要掉眼泪8 小时前
SpringBoot项目Excel成绩录入功能详解:从文件上传到数据入库的全流程解析
java·vue.js·spring boot·后端·spring·excel