Naive UI 虚拟级联选择器适配 Element Plus 风格

1. 适用场景

当项目需要使用 Naive UI 的虚拟级联选择器,但页面交互和样式需要与 Element Plus 保持一致时,可以复用本文中的公共实现。

当前方案的核心思路:

rust 复制代码
Naive UI n-cascader
    -> 保留虚拟菜单、多选、筛选和级联能力
​
Element Plus
    -> 提供 ArrowDown、el-tag 和项目统一颜色体系
​
cascader-common
    -> 提供主题、宽度、悬浮标签和公共样式适配
​
业务组件
    -> 只处理接口数据、级联关系和业务模型转换

公共代码目录:

bash 复制代码
src/pages/admin/workbench/components/cascader-common/
├── cascaderConstants.ts
├── index.ts
├── na-cascader-common-mixin.scss
├── na-cascader-common.scss
├── useCascaderPanelWidth.ts
└── useCascaderPopover.ts

以下代码均按当前项目中的公共实现整理,可以直接用于类似的 Naive UI 级联器。

2. 公共统一出口

文件:cascader-common/index.ts

统一出口用于集中暴露公共能力,业务组件不需要分别定位每个文件。

ts 复制代码
export { cascaderFilter, elementPlusThemeOverrides, elementPlusThemeOverridesFull } from './cascaderConstants'
export { useCascaderPanelWidth } from './useCascaderPanelWidth'
export { useCascaderPopover } from './useCascaderPopover'

使用方式:

ts 复制代码
import {
  elementPlusThemeOverridesFull,
  useCascaderPanelWidth,
  useCascaderPopover,
} from './cascader-common'

3. 主题配置和搜索工具

文件:cascader-common/cascaderConstants.ts

3.1 完整实现代码

ts 复制代码
/** NaiveUI 级联选择器的 ElementPlus 主题覆盖 */
export const elementPlusThemeOverrides = {
  common: {
    primaryColor: '#1677ff',
    primaryColorHover: '#79BBFF',
    primaryColorPressed: '#337ECC',
    primaryColorSuppl: '#1677ff',
    infoColor: '#909399',
  },
}
​
/** 完整版主题覆盖(含 success/warning/error/caretColor,仅 VOrgCascaderNa 使用) */
export const elementPlusThemeOverridesFull = {
  common: {
    primaryColor: '#1677ff',
    primaryColorHover: '#79BBFF',
    primaryColorPressed: '#337ECC',
    primaryColorSuppl: '#1677ff',
    infoColor: '#909399',
    successColor: '#67C23A',
    warningColor: '#E6A23C',
    errorColor: '#F56C6C',
    caretColor: '#000',
  },
}
​
/** 自定义级联搜索匹配(模糊匹配当前节点和子节点 label) */
export function cascaderFilter(pattern: string, option: any) {
  if (!pattern)
    return true
​
  const lower = pattern.toLowerCase()
​
  if (option?.label?.toString().toLowerCase().includes(lower))
    return true
​
  if (option?.children?.some((child: any) =>
    child?.label?.toString().toLowerCase().includes(lower))) {
    return true
  }
​
  return false
}

3.2 主题配置使用方式

ts 复制代码
<script setup lang="ts">
import { elementPlusThemeOverridesFull } from './cascader-common'
</script>
​
<template>
  <n-config-provider :theme-overrides="elementPlusThemeOverridesFull">
    <n-cascader />
  </n-config-provider>
</template>

3.3 搜索工具使用方式

ini 复制代码
<n-cascader
  :filter="cascaderFilter"
  filterable
/>

cascaderFilter 的行为是:

  1. 当前节点 label 匹配时保留节点;
  2. 当前节点的子节点 label 匹配时保留父节点;
  3. 不区分大小写;
  4. 空搜索关键字直接返回 true

4. 级联面板动态宽度

文件:cascader-common/useCascaderPanelWidth.ts

4.1 功能说明

Naive UI 的级联面板宽度默认值不一定适合较长的中文名称。该 composable 负责:

  • 计算一级菜单宽度;
  • 计算每个一级菜单对应的二级菜单宽度;
  • 设置菜单最小宽度和最大宽度;
  • 监听动态生成的级联菜单;
  • 给当前一级菜单增加 active 高亮;
  • 菜单重新渲染后恢复宽度和高亮;
  • 组件卸载时断开 MutationObserver

4.2 完整实现代码

ts 复制代码
/**
 * 级联选择器面板宽度动态计算(适用于二级级联面板)
 *
 * @param wrapperSelector 外层 wrapper 选择器
 * @param cascaderSelector cascader 选择器
 * @param cascaderOptions 级联选项(响应式)
 * @param minSecondWidth 二级面板最小宽度,默认 180
 */
export function useCascaderPanelWidth(
  wrapperSelector: string,
  cascaderSelector: string,
  cascaderOptions: MaybeRefOrGetter<any[]>,
  minSecondWidth = 180,
) {
  const activeLevel0Value = ref<string>('')
  let lastAttachedSubmenu: HTMLElement | null = null
​
  // 计算文本宽度(粗略估算)。
  function calculateTextWidth(text: string) {
    let width = 0
​
    for (let i = 0; i < text.length; i++) {
      const char = text[i]
​
      // 中文字符约 14px,英文、数字等字符约 8px。
      if (/[\u4E00-\u9FA5]/.test(char))
        width += 14
      else
        width += 8
    }
​
    return width
  }
​
  function getOptions() {
    return typeof cascaderOptions === 'function'
      ? cascaderOptions()
      : cascaderOptions.value
  }
​
  // 为每个第一级选项计算其子选项的最大宽度。
  const optionWidthMap = computed(() => {
    const map: Record<string, number> = {}
​
    getOptions().forEach((option: any) => {
      let maxWidth = 0
​
      if (option.children && option.children.length > 0) {
        option.children.forEach((child: any) => {
          const width = calculateTextWidth(child.label || '')
​
          if (width > maxWidth)
            maxWidth = width
        })
      }
​
      // 额外加上内边距、图标、复选框等空间。
      map[option.value as string] = Math.min(
        Math.max(maxWidth + 60, minSecondWidth),
        600,
      )
    })
​
    return map
  })
​
  // 计算第一级面板的宽度。
  const level0Width = computed(() => {
    let maxWidth = 0
​
    getOptions().forEach((option: any) => {
      const width = calculateTextWidth(option.label || '')
​
      if (width > maxWidth)
        maxWidth = width
    })
​
    return Math.min(
      Math.max(maxWidth + 60, minSecondWidth),
      800,
    )
  })
​
  function getSelector(menuPart: string) {
    return `${wrapperSelector} ${cascaderSelector} .n-cascader-menu${menuPart}`
  }
​
  // 动态设置一级和二级面板宽度。
  function updatePanelWidths() {
    const cascaderMenu = document.querySelector(getSelector(''))
​
    if (!cascaderMenu)
      return
​
    const firstSubmenu = cascaderMenu.querySelector(
      '.n-cascader-submenu:nth-child(1)',
    ) as HTMLElement
​
    if (firstSubmenu)
      firstSubmenu.style.width = `${level0Width.value}px`
​
    const secondSubmenu = cascaderMenu.querySelector(
      '.n-cascader-submenu:nth-child(2)',
    ) as HTMLElement
​
    if (secondSubmenu && activeLevel0Value.value) {
      const width = optionWidthMap.value[activeLevel0Value.value] || minSecondWidth
      secondSubmenu.style.width = `${width}px`
    }
  }
​
  // 使用事件委托为第一级选项添加点击事件监听。
  function attachOptionListeners() {
    const cascaderMenu = document.querySelector(getSelector(''))
​
    if (!cascaderMenu)
      return
​
    const firstSubmenu = cascaderMenu.querySelector(
      '.n-cascader-submenu:nth-child(1)',
    ) as HTMLElement
​
    if (!firstSubmenu)
      return
​
    // 相同 DOM 节点只绑定一次。
    if (lastAttachedSubmenu === firstSubmenu)
      return
​
    firstSubmenu.addEventListener('click', (event) => {
      const target = event.target as HTMLElement
      const option = target.closest('.n-cascader-option')
​
      if (!option)
        return
​
      // 通过文本找到当前一级选项对应的数据。
      const label = option.textContent?.trim()
      const optionData = getOptions().find((item: any) => item.label === label)
​
      if (!optionData)
        return
​
      // 移除其他一级选项的 active 状态。
      const options = firstSubmenu.querySelectorAll('.n-cascader-option')
      options.forEach(item => item.classList.remove('is-active-level'))
​
      option.classList.add('is-active-level')
      activeLevel0Value.value = optionData.value as string
​
      // 等待二级菜单完成渲染后再计算宽度。
      setTimeout(() => {
        updatePanelWidths()
      }, 50)
    })
​
    lastAttachedSubmenu = firstSubmenu
​
    // 延迟恢复 active 状态,确保 DOM 已完成渲染。
    setTimeout(() => {
      restoreActiveState(firstSubmenu)
    }, 100)
  }
​
  // 恢复上一次的一级菜单高亮状态。
  function restoreActiveState(firstSubmenu: HTMLElement) {
    if (!activeLevel0Value.value)
      return
​
    const options = firstSubmenu.querySelectorAll('.n-cascader-option')
​
    options.forEach((option) => {
      const label = option.textContent?.trim()
      const optionData = getOptions().find((item: any) => item.label === label)
​
      if (optionData && optionData.value === activeLevel0Value.value)
        option.classList.add('is-active-level')
    })
  }
​
  // 监听级联面板的动态创建和状态变化。
  let observer: MutationObserver | null = null
​
  onMounted(() => {
    nextTick(() => {
      observer = new MutationObserver(() => {
        requestAnimationFrame(() => {
          const cascaderMenu = document.querySelector(getSelector(''))
​
          if (cascaderMenu) {
            attachOptionListeners()
            updatePanelWidths()
          }
        })
      })
​
      observer.observe(document.body, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: ['class'],
      })
    })
  })
​
  // 组件销毁时清理观察器和 DOM 引用。
  onUnmounted(() => {
    if (observer) {
      observer.disconnect()
      observer = null
    }
​
    lastAttachedSubmenu = null
  })
​
  return {
    activeLevel0Value,
    updatePanelWidths,
  }
}

4.3 使用方式

ts 复制代码
const cascaderOptions = ref([
  {
    label: '一级分类',
    value: 'category',
    children: [
      {
        label: '名称较长的二级选项',
        value: 'category-item',
        leaf: true,
      },
    ],
  },
])
​
useCascaderPanelWidth(
  '.common-cascader-wrapper',
  '.common-cascader',
  cascaderOptions,
  180,
)

4.4 实现要点

使用 MaybeRefOrGetter

参数既可以接收 ref

ts 复制代码
useCascaderPanelWidth('.wrapper', '.cascader', options)

也可以接收 getter:

ts 复制代码
useCascaderPanelWidth(
  '.wrapper',
  '.cascader',
  () => options.value,
)

使用 computed 管理宽度

选项数据异步更新时,optionWidthMaplevel0Width 会重新计算,不需要手动维护额外的宽度状态。

使用事件委托

一级菜单节点可能会被 Naive UI 动态重建,因此不对每个菜单项单独绑定事件,而是给当前一级 submenu 绑定一次事件。

使用 MutationObserver

级联面板不是组件初始化时就一定存在。监听 document.body 可以捕获面板的创建、销毁和 active class 变化。

5. 折叠标签悬浮面板

文件:cascader-common/useCascaderPopover.ts

5.1 功能说明

n-cascader 配置 max-tag-count="1" 后,输入框只显示一个标签,其余标签折叠。该 composable 负责折叠标签悬浮面板的显示状态:

  • 已选数量不超过一个时不显示;
  • 鼠标进入输入框时显示;
  • 鼠标离开后延迟隐藏;
  • 鼠标移动到悬浮面板时保持显示;
  • 级联下拉面板打开时隐藏悬浮面板。

5.2 完整实现代码

ts 复制代码
/**
 * 级联选择器标签悬浮弹窗逻辑
 *
 * @param selectedCount 已选数量(响应式或普通值)
 */
export function useCascaderPopover(selectedCount: MaybeRef<number>) {
  const tagPopoverVisible = ref(false)
  const isDropdownOpen = ref(false)
  let popoverTimer: ReturnType<typeof setTimeout> | null = null
​
  // 仅已选超过 1 个时才展示弹窗。
  const tagPopoverEnabled = computed(() => {
    const count = typeof selectedCount === 'number'
      ? selectedCount
      : selectedCount.value ?? 0
​
    return count > 1
  })
​
  // 级联下拉面板显示状态变化。
  function onCascaderShowChange(show: boolean) {
    isDropdownOpen.value = show
​
    if (show)
      hideTagPopover()
  }
​
  // 显示悬浮标签面板。
  function showTagPopover() {
    if (!tagPopoverEnabled.value || isDropdownOpen.value)
      return
​
    if (popoverTimer) {
      clearTimeout(popoverTimer)
      popoverTimer = null
    }
​
    tagPopoverVisible.value = true
  }
​
  // 延迟隐藏悬浮标签面板,避免鼠标移动过程中闪烁。
  function hideTagPopover() {
    if (popoverTimer) {
      clearTimeout(popoverTimer)
      popoverTimer = null
    }
​
    popoverTimer = setTimeout(() => {
      tagPopoverVisible.value = false
    }, 100)
  }
​
  return {
    tagPopoverVisible,
    tagPopoverEnabled,
    onCascaderShowChange,
    showTagPopover,
    hideTagPopover,
  }
}

5.3 使用方式

ts 复制代码
const selectedValues = ref<string[]>([])
​
const {
  tagPopoverVisible,
  onCascaderShowChange,
  showTagPopover,
  hideTagPopover,
} = useCascaderPopover(
  computed(() => selectedValues.value.length),
)

模板接入:

html 复制代码
<div
  class="common-cascader-wrapper"
  @mouseenter="showTagPopover"
  @mouseleave="hideTagPopover"
  @mousedown="hideTagPopover"
>
  <n-cascader
    v-model:value="selectedValues"
    :max-tag-count="1"
    @update:show="onCascaderShowChange"
  />
​
  <Transition name="cascader-tag-popover-fade">
    <div
      v-if="tagPopoverVisible"
      class="cascader-tag-popover-panel"
      @mouseenter="showTagPopover"
      @mouseleave="hideTagPopover"
      @mousedown.stop
    >
      <div class="cascader-tag-popover-list">
        <el-tag
          v-for="item in selectedItems"
          :key="item.value"
          class="cascader-tag-popover-item"
          closable
          size="small"
          effect="plain"
          @close="removeSelectedItem(item.value)"
        >
          {{ item.label }}
        </el-tag>
      </div>
    </div>
  </Transition>
</div>

selectedItemsremoveSelectedItem 属于业务数据处理,不放入这个 composable。

6. Naive UI 输入框样式适配

文件:cascader-common/na-cascader-common-mixin.scss

6.1 输入框样式 mixin 完整代码

scss 复制代码
// 级联选择器 scoped 公共样式 mixin
// 使用方式:在组件的 scoped style 中 @include
​
@mixin na-cascader-selection-style {
  :deep(.n-base-selection) {
    --n-border: 1px solid #d9d9d9 !important;
    --n-border-hover: 1px solid #d9d9d9 !important;
    --n-border-active: 1px solid #d9d9d9 !important;
    --n-border-focus: 1px solid #d9d9d9 !important;
    --n-box-shadow-active: none !important;
    --n-box-shadow-focus: none !important;
​
    --n-height: 32px !important;
​
    &.n-base-selection--focus {
      --n-border: 1px solid var(--el-color-info) !important;
    }
​
    .n-base-selection-tags {
      padding-right: 30px;
      padding-top: 1px;
​
      .n-base-selection-tag-wrapper {
        padding-bottom: 2px;
​
        .n-tag {
          font-size: 12px !important;
          height: 24px;
          background-color: var(--el-color-info-light-9);
          border-color: var(--el-color-info-light-8);
          border-radius: 4px;
          color: #909399;
        }
​
        .n-tag__close {
          margin-left: 6px;
          border-radius: 50%;
          cursor: pointer;
          font-size: calc(var(--el-icon-size) - 2px);
          height: var(--el-icon-size);
          width: var(--el-icon-size);
          color: #909399;
          padding: 2px;
​
          .n-base-icon {
            font-size: 10px !important;
          }
​
          &::before {
            display: none;
          }
​
          &:hover {
            color: var(--el-color-white);
            background-color: #909399;
          }
        }
​
        .n-tag__border {
          border-color: var(--el-color-info-light-9);
        }
      }
    }
​
    .n-base-suffix {
      right: 13px;
​
      .n-base-clear__clear {
        display: flex !important;
        align-items: center !important;
        justify-content: center !important;
​
        .n-base-icon {
          color: transparent !important;
          position: relative !important;
          display: inline-flex !important;
          align-items: center !important;
          justify-content: center !important;
          width: 16px !important;
          height: 16px !important;
​
          svg {
            display: none !important;
          }
​
          // 使用 CSS mask 替换 Naive UI 默认清空图标。
          &::before {
            content: '' !important;
            position: absolute !important;
            top: 50% !important;
            left: 50% !important;
            transform: translate(-50%, -50%) !important;
            width: 14px !important;
            height: 14px !important;
            background-color: #a8abb2 !important;
            -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath fill='currentColor' d='m466.752 512-90.496-90.496a32 32 0 0 1 45.248-45.248L512 466.752l90.496-90.496a32 32 0 1 1 45.248 45.248L557.248 512l90.496 90.496a32 32 0 1 1-45.248 45.248L512 557.248l-90.496 90.496a32 32 0 0 1-45.248-45.248z'/%3E%3Cpath fill='currentColor' d='M512 896a384 384 0 1 0 0-768 384 384 0 0 0 0 768m0 64a448 448 0 1 1 0-896 448 448 0 0 1 0 896'/%3E%3C/svg%3E") !important;
            mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath fill='currentColor' d='m466.752 512-90.496-90.496a32 32 0 0 1 45.248-45.248L512 466.752l90.496-90.496a32 32 0 1 1 45.248 45.248L557.248 512l90.496 90.496a32 32 0 1 1-45.248 45.248L512 557.248l-90.496 90.496a32 32 0 0 1-45.248-45.248z'/%3E%3Cpath fill='currentColor' d='M512 896a384 384 0 1 0 0-768 384 384 0 0 0 0 768m0 64a448 448 0 1 1 0-896 448 448 0 0 1 0 896'/%3E%3C/svg%3E") !important;
            -webkit-mask-size: 100% 100% !important;
            mask-size: 100% 100% !important;
            -webkit-mask-repeat: no-repeat !important;
            mask-repeat: no-repeat !important;
            -webkit-mask-position: center !important;
            mask-position: center !important;
            transition: background-color 0.2s !important;
          }
​
          &:hover::before {
            background-color: #909399 !important;
          }
        }
      }
​
      .n-base-suffix__arrow {
        color: #a8abb2;
        font-size: 14px;
        transition:
          transform 0.3s,
          color 0.2s;
​
        .n-base-icon {
          display: inline-flex;
          align-items: center;
          justify-content: center;
        }
      }
    }
​
    .n-base-selection-input-tag__input {
      caret-color: #303133 !important;
      height: 24px !important;
    }
​
    .n-base-selection-placeholder__inner {
      color: #a8abb2 !important;
    }
  }
}

6.2 样式处理内容

这个 mixin 主要处理:

  • 输入框 32px 高度;
  • 默认、hover、active、focus 边框;
  • 去除 Naive UI 默认 focus 阴影;
  • 多选标签高度、颜色、边框和圆角;
  • 标签关闭按钮样式;
  • 清空图标替换;
  • 下拉箭头、placeholder 和输入光标颜色。

7. Naive UI 级联菜单样式适配

文件:cascader-common/na-cascader-common-mixin.scss

7.1 菜单样式 mixin 完整代码

scss 复制代码
@mixin na-cascader-menu-style($min-width: 180px) {
  :deep(.not-found) {
    font-size: 12px;
    color: var(--el-text-color-secondary);
  }
​
  // 级联面板顶部的小三角。
  :deep(.v-binder-follower-content:has(.n-cascader-menu)) {
    &::before {
      content: '';
      position: absolute;
      top: 6px;
      left: 100px;
      border-style: solid;
      border-width: 0 6px 6px 6px;
      border-color: transparent transparent #e4e7ed transparent;
      z-index: 1;
      opacity: 0;
      transform: scaleY(0.85) translateY(-4px);
      transform-origin: center top;
      animation: arrow-fade-in-scale 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards;
    }
​
    &::after {
      content: '';
      position: absolute;
      top: 7px;
      left: 100px;
      border-style: solid;
      border-width: 0 6px 6px 6px;
      border-color: transparent transparent #fff transparent;
      z-index: 2;
      opacity: 0;
      transform: scaleY(0.85) translateY(-4px);
      transform-origin: center top;
      animation: arrow-fade-in-scale 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards;
    }
  }
​
  :deep(.n-cascader-menu) {
    color: #606266 !important;
    margin-top: 13px !important;
​
    .n-cascader-submenu,
    .n-cascader-submenu.n-cascader-submenu--virtual {
      min-width: #{$min-width} !important;
      max-width: 800px !important;
    }
​
    // 一级菜单激活时使用 Element Plus 主色。
    .n-cascader-option.is-active-level {
      color: #1677ff !important;
      font-weight: 500 !important;
​
      .n-base-icon {
        color: #1677ff !important;
        font-weight: 500 !important;
      }
    }
​
    // 虚拟列表容器为空时显示"暂无数据"。
    .v-vl:empty {
      position: relative;
      min-height: 100px;
      display: flex;
      align-items: center;
      justify-content: center;
​
      &::before {
        content: '暂无数据';
        color: #909399;
        font-size: 12px;
      }
    }
  }
​
  // 面板小三角的淡入和缩放动画。
  @keyframes arrow-fade-in-scale {
    from {
      opacity: 0;
      transform: scaleY(0.85) translateY(-4px);
    }
​
    to {
      opacity: 1;
      transform: scaleY(1) translateY(0);
    }
  }
}

7.2 使用方式

scss 复制代码
<style lang="scss" scoped>
@use './cascader-common/na-cascader-common-mixin' as *;
​
.common-cascader {
  width: 100%;
​
  @include na-cascader-selection-style;
  @include na-cascader-menu-style(180px);
}
</style>

这里的 180px 是菜单最小宽度,实际一级、二级宽度由 useCascaderPanelWidth 动态设置。

8. 悬浮标签面板公共样式

文件:cascader-common/na-cascader-common.scss

8.1 完整代码

scss 复制代码
// 级联选择器公共样式 - 非 scoped 部分(tag-popover)
​
.cascader-tag-popover-wrapper {
  position: relative;
}
​
.cascader-tag-popover-panel {
  position: absolute;
  top: 100%;
  left: 0;
  z-index: 10;
  margin-top: 8px;
  min-width: 440px;
  max-width: 600px;
  background-color: #fff;
  border: 1px solid #e4e7ed;
  border-radius: 8px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
​
.cascader-tag-popover-list {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  max-height: 160px;
  overflow-y: auto;
  padding: 12px;
}
​
.cascader-tag-popover-item {
  border-radius: 4px;
  font-size: 12px;
}
​
.cascader-tag-popover-empty {
  width: 100%;
  padding: 8px 12px;
  font-size: 12px;
  color: #909399;
  text-align: center;
}
​
.cascader-tag-popover-fade-enter-active,
.cascader-tag-popover-fade-leave-active {
  transition:
    opacity 0.15s ease,
    transform 0.15s ease;
}
​
.cascader-tag-popover-fade-enter-from,
.cascader-tag-popover-fade-leave-to {
  opacity: 0;
  transform: translateY(-4px);
}

8.2 引入方式

悬浮面板样式放在非 scoped style 中:

scss 复制代码
<style lang="scss">
@use './cascader-common/na-cascader-common.scss' as *;
</style>

组件自己的局部样式和 mixin 调用放在 scoped style 中:

scss 复制代码
<style lang="scss" scoped>
@use './cascader-common/na-cascader-common-mixin' as *;
​
.common-cascader-wrapper {
  position: relative;
}
​
.common-cascader {
  width: 100%;
​
  @include na-cascader-selection-style;
  @include na-cascader-menu-style(180px);
}
</style>

10. 业务组件如何使用

业务组件只需要完成三步:

  1. 准备符合 Naive UI 要求的 options
  2. 绑定 v-model:value 和业务变更事件;
  3. 引入主题、宽度 composable、悬浮标签 composable 和公共样式。

示例:

html 复制代码
<script setup lang="ts">
import { ArrowDown } from '@element-plus/icons-vue'
import {
  elementPlusThemeOverridesFull,
  useCascaderPanelWidth,
  useCascaderPopover,
} from './cascader-common'
​
const selectedValues = ref<string[]>([])
const options = ref([])
​
useCascaderPanelWidth(
  '.business-cascader-wrapper',
  '.business-cascader',
  options,
)
​
const {
  tagPopoverVisible,
  onCascaderShowChange,
  showTagPopover,
  hideTagPopover,
} = useCascaderPopover(computed(() => selectedValues.value.length))
​
function handleChange(values: string[]) {
  // 在这里处理接口参数或业务模型转换。
  console.log(values)
}
</script>
​
<template>
  <div
    class="business-cascader-wrapper"
    @mouseenter="showTagPopover"
    @mouseleave="hideTagPopover"
    @mousedown="hideTagPopover"
  >
    <n-config-provider :theme-overrides="elementPlusThemeOverridesFull">
      <n-cascader
        v-model:value="selectedValues"
        class="business-cascader"
        :options="options"
        multiple
        filterable
        clearable
        :max-tag-count="1"
        :to="false"
        @update:value="handleChange"
        @update:show="onCascaderShowChange"
      >
        <template #arrow>
          <el-icon>
            <ArrowDown />
          </el-icon>
        </template>
      </n-cascader>
    </n-config-provider>
​
    <div
      v-if="tagPopoverVisible"
      class="cascader-tag-popover-panel"
    >
      <!-- 根据业务选项渲染完整标签 -->
    </div>
  </div>
</template>
​
<style lang="scss">
@use './cascader-common/na-cascader-common.scss' as *;
</style>
​
<style lang="scss" scoped>
@use './cascader-common/na-cascader-common-mixin' as *;
​
.business-cascader-wrapper {
  position: relative;
}
​
.business-cascader {
  width: 100%;
​
  @include na-cascader-selection-style;
  @include na-cascader-menu-style(180px);
}
</style>

业务层只需要根据实际情况替换:

  • options 数据来源;
  • handleChange 中的业务处理;
  • 悬浮面板中的标签数据。

11. 复用清单

公共实现可以直接复用:

  • cascader-common/index.ts
  • cascaderConstants.ts
  • useCascaderPanelWidth.ts
  • useCascaderPopover.ts
  • na-cascader-common-mixin.scss
  • na-cascader-common.scss。 业务组件只需要替换数据和事件处理,不需要重新编写样式与悬浮面板交互。

12. 总结

这套公共实现将级联选择器拆成了几个独立能力:

rust 复制代码
主题覆盖              -> Naive UI 使用 Element Plus 颜色
Element Plus 图标     -> 替换默认下拉箭头
selection mixin       -> 输入框、标签、清空按钮样式
menu mixin            -> 菜单、active、空状态和小三角
useCascaderPanelWidth -> 动态宽度和一级菜单高亮
useCascaderPopover    -> 折叠标签悬浮展示
业务组件              -> 接口、级联关系和模型转换

后续遇到类似需求时,直接复用 cascader-common 下的代码即可,业务组件只需要替换数据源和业务转换逻辑。

相关推荐
ynchyong2 小时前
微信小程序启动顺序遇到的一个坑
前端·微信小程序
用户233376852182 小时前
接口卡死排查实录-缺失return的UB死循环
前端·后端
光影少年2 小时前
如何实现RN 多环境、多渠道打包
前端·react native·react.js
闲坐含香咀翠2 小时前
百万行数据透视表,我是怎么把 Vue 响应式开销砍到零的
前端·vue.js·性能优化
xiaopang2 小时前
小红书小组件(miniwidget)开发实战:单页面viewState切换架构
前端
labixiong2 小时前
告别scroll 监听:CSS 滚动驱动动画,主线程堵死也仍跟手
前端·css·html
用户302822530682 小时前
别让 Agent 被 Webhook 叫醒就开工:实现一个幂等启动层
javascript
云析赢指标公式网42 小时前
文华WH6布林轨道均线强弱共振指标公式
前端·算法
许彰午3 小时前
34-安全复盘96个问题
前端·vue.js·安全