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 的行为是:
- 当前节点
label匹配时保留节点; - 当前节点的子节点
label匹配时保留父节点; - 不区分大小写;
- 空搜索关键字直接返回
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 管理宽度
选项数据异步更新时,optionWidthMap 和 level0Width 会重新计算,不需要手动维护额外的宽度状态。
使用事件委托
一级菜单节点可能会被 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>
selectedItems 和 removeSelectedItem 属于业务数据处理,不放入这个 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. 业务组件如何使用
业务组件只需要完成三步:
- 准备符合 Naive UI 要求的
options; - 绑定
v-model:value和业务变更事件; - 引入主题、宽度 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 下的代码即可,业务组件只需要替换数据源和业务转换逻辑。