<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自定義級聯選擇器-支持多選
最后編輯于 :
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
- 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
- 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
- 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...