<template>
<div class="optimized-multi-cascader">
<!-- 触发输入框 -->
<van-field readonly clickable :value="displayText" placeholder="请选择" class="trigger-input" @click="showPicker = true">
<template #right-icon>
<van-badge v-if="selectedLeafNodes.length > 0" :content="selectedLeafNodes.length" />
</template>
</van-field>
<!-- 级联选择器弹窗 -->
<van-popup v-model:show="showPicker" round position="bottom" class="cascader-popup" :style="{ height: '70vh' }">
<!-- 头部操作区 -->
<div class="popup-header">
<van-button type="default" size="small" plain @click="showPicker = false"> 取消 </van-button>
<div class="header-title">{{ title }}</div>
<van-button type="primary" size="small" @click="handleConfirm"> 确定({{ selectedLeafNodes.length }}) </van-button>
</div>
<!-- 级联面板容器 -->
<div class="cascader-container">
<!-- 三级面板 -->
<div v-for="(column, level) in columns" :key="level" class="cascader-column" :style="{ width: getColumnWidth(level) }">
{{ column }}
<!-- 面板项虚拟滚动容器 -->
<virtual-list :size="48" :remain="10" :data="column" class="virtual-list">
<template #default="{ item }">
<div
class="cascader-item"
:class="{
active: activeLevels[level] === item.value,
selected: getCheckState(item),
indeterminate: item.indeterminate
}"
@click="handleItemClick(item, level)"
>
<van-checkbox :model-value="getCheckState(item)" :indeterminate="item.indeterminate" @click.stop="toggleCheck(item)" />
<span class="item-text" :title="item.text">
{{ truncateText(item.text, level) }}
</span>
<van-icon v-if="hasChildren(item)" name="arrow" class="arrow-icon" />
</div>
</template>
</virtual-list>
</div>
</div>
<!-- 已选标签展示区 -->
<div v-if="selectedLeafNodes.length > 0" class="selected-tags-container">
<div class="tags-header">
<span class="tags-title">已选内容:</span>
<van-button type="danger" size="mini" plain @click="clearAll"> 清空 </van-button>
</div>
<div class="tags-scroller">
<van-tag v-for="value in selectedLeafNodes" :key="value" type="primary" size="medium" closeable @close="removeTag(value)" class="selected-tag">
{{ getTagDisplayText(value) }}
</van-tag>
</div>
</div>
</van-popup>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from "vue"
import { cloneDeep } from "lodash-es"
import VirtualList from "vue-virtual-scroll-list"
const props = defineProps({
modelValue: {
type: Array,
default: () => []
},
options: {
type: Array,
required: true,
validator: (value) => Array.isArray(value) && value.every(validateNode)
},
title: {
type: String,
default: "请选择"
},
maxDisplayTextLength: {
type: Number,
default: 20
}
})
const emit = defineEmits(["update:modelValue"])
// 数据验证
function validateNode(node) {
return (node.text && node.value && !node.children) || (Array.isArray(node.children) && node.children.every(validateNode))
}
// 响应式状态
const showPicker = ref(false)
const columns = ref([[], [], []])
const activeLevels = ref([null, null, null])
const processedData = ref([])
const selectedLeafNodes = ref([])
// 初始化处理数据
const processData = (data) => {
return data.map((item) => ({
...item,
indeterminate: false,
checked: false,
children: item.children ? processData(item.children) : null,
// 添加路径信息用于显示
path: item.path || [item.text]
}))
}
// 处理子节点路径
const processPaths = (nodes, parentPath = []) => {
return nodes.map((node) => {
const currentPath = [...parentPath, node.text]
return {
...node,
path: currentPath,
children: node.children ? processPaths(node.children, currentPath) : null
}
})
}
// 递归查找节点
const findNode = (nodes, value) => {
for (const node of nodes) {
if (node.value === value) return node
if (node.children) {
const found = findNode(node.children, value)
if (found) return found
}
}
return null
}
// 更新选中状态
const updateCheckStatus = (node) => {
if (!node.children) {
node.checked = selectedLeafNodes.value.includes(node.value)
return
}
let allChecked = true
let someChecked = false
node.children.forEach((child) => {
updateCheckStatus(child)
if (!child.checked) allChecked = false
if (child.checked || child.indeterminate) someChecked = true
})
node.checked = allChecked
node.indeterminate = !allChecked && someChecked
}
// 切换选中状态
const toggleCheck = (node) => {
const shouldCheck = !(node.checked || node.indeterminate)
const toggleChildren = (currentNode, state) => {
if (currentNode.children) {
currentNode.children.forEach((child) => toggleChildren(child, state))
} else {
const index = selectedLeafNodes.value.indexOf(currentNode.value)
if (state && index === -1) {
selectedLeafNodes.value.push(currentNode.value)
} else if (!state && index !== -1) {
selectedLeafNodes.value.splice(index, 1)
}
}
}
toggleChildren(node, shouldCheck)
processedData.value.forEach(updateCheckStatus)
}
// 处理项点击
const handleItemClick = async (item, level) => {
activeLevels.value[level] = item.value
// 更新级联面板
const newColumns = columns.value.slice(0, level + 1)
if (item.children) {
newColumns[level + 1] = item.children
if (level < 1) {
newColumns[level + 2] = []
}
}
columns.value = newColumns
await nextTick()
}
// 确认选择
const handleConfirm = () => {
emit("update:modelValue", [...selectedLeafNodes.value])
showPicker.value = false
}
// 显示文本优化
const displayText = computed(() => {
if (selectedLeafNodes.value.length === 0) return ""
const displayItems = selectedLeafNodes.value.slice(0, 3).map(getTagDisplayText)
if (selectedLeafNodes.value.length > 3) {
displayItems.push(`+${selectedLeafNodes.value.length - 3}`)
}
return displayItems.join(", ")
})
// 获取标签显示文本
const getTagDisplayText = (value) => {
const node = findNode(processedData.value, value)
if (!node) return ""
// 显示完整路径
return node.path.slice(-3).join("/")
}
// 移除标签
const removeTag = (value) => {
const index = selectedLeafNodes.value.indexOf(value)
if (index !== -1) {
selectedLeafNodes.value.splice(index, 1)
processedData.value.forEach(updateCheckStatus)
emit("update:modelValue", [...selectedLeafNodes.value])
}
}
// 清空所有选择
const clearAll = () => {
selectedLeafNodes.value = []
processedData.value.forEach(updateCheckStatus)
emit("update:modelValue", [])
}
// 文本截断处理
const truncateText = (text, level) => {
const maxLength = props.maxDisplayTextLength - level * 3
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text
}
// 列宽计算
const getColumnWidth = (level) => {
const baseWidth = [35, 30, 35] // 三级列宽百分比
return `${baseWidth[level]}%`
}
// 初始化处理数据
processedData.value = processPaths(processData(cloneDeep(props.options)))
columns.value[0] = processedData.value
// 监听外部值变化
watch(
() => props.modelValue,
(newVal) => {
selectedLeafNodes.value = [...newVal]
processedData.value.forEach(updateCheckStatus)
},
{ immediate: true }
)
// 辅助方法
const hasChildren = (item) => item.children && item.children.length > 0
const getCheckState = (item) => {
if (item.children) return item.checked
return selectedLeafNodes.value.includes(item.value)
}
</script>
<style scoped>
.optimized-multi-cascader {
--active-color: #1989fa;
--hover-bg: #f5f7fa;
--border-color: #ebedf0;
--tag-bg: #e8f4ff;
--text-color: #323233;
--secondary-text: #969799;
}
.trigger-input {
background: #f7f8fa;
border-radius: 8px;
cursor: pointer;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
}
.header-title {
font-weight: 500;
font-size: 16px;
color: var(--text-color);
}
.cascader-container {
display: flex;
height: calc(70vh - 132px);
overflow-x: auto;
border-bottom: 1px solid var(--border-color);
}
.cascader-column {
flex-shrink: 0;
height: 100%;
border-right: 1px solid var(--border-color);
}
.virtual-list {
height: 100%;
overflow-y: auto;
}
.cascader-item {
display: flex;
align-items: center;
padding: 12px 16px;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.cascader-item:hover {
background: var(--hover-bg);
}
.cascader-item.active {
background-color: rgba(25, 137, 250, 0.1);
}
.cascader-item.selected {
background-color: rgba(25, 137, 250, 0.05);
}
.cascader-item.indeterminate::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 60%;
background-color: var(--active-color);
border-radius: 2px;
}
.item-text {
flex: 1;
margin-left: 8px;
font-size: 14px;
color: var(--text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.arrow-icon {
margin-left: 8px;
color: var(--secondary-text);
font-size: 14px;
}
.selected-tags-container {
padding: 12px 16px;
}
.tags-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.tags-title {
font-size: 14px;
color: var(--secondary-text);
}
.tags-scroller {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 80px;
overflow-y: auto;
padding: 4px 0;
}
.selected-tag {
background: var(--tag-bg);
color: var(--active-color);
border: none;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: transform 0.2s;
}
.selected-tag:hover {
transform: translateY(-2px);
}
</style>
基于vant自定义级联选择器-支持多选
最后编辑于 :
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。