template view classtree-select !-- 已选标签区域 全选按钮 -- view v-ifshowSelectedTags classselected-area view classselected-tags view v-foritem in selectedNodes :keyitem[nodeKey] classtag text classtag-text{{ item[labelField] }}/text text classtag-close click.stopremoveTag(item)×/text /view /view view classaction-buttons view v-ifshowSelectAll classselect-all-btn clicktoggleSelectAll {{ isAllSelected ? 取消全选 : 全选 }} /view view v-ifselectedNodes.length classclear-btn clickclearAll清空/view /view /view !-- 树形区域 -- scroll-view classtree-scroll scroll-y view v-ifflatList.length 0 classempty-tip暂无数据/view view v-foritem in flatList :keyitem[nodeKey] classtree-item :style{ paddingLeft: (item._level * 32 24) rpx } !-- 展开/折叠图标 -- view v-ifitem._hasChildren classexpand-icon :class{ expanded: item._expanded } click.stoptoggleExpand(item) {{ item._expanded ? ∨ : }} /view view v-else classexpand-placeholder/view !-- 复选框 -- view classcheckbox :class{ checked: item._checked, indeterminate: item._indeterminate } click.stoptoggleCheck(item) text v-ifitem._checked✓/text text v-else-ifitem._indeterminate—/text /view !-- 标签 -- text classnode-label :class{ disabled: item[disabledField] } click.stoptoggleCheck(item) {{ item[labelField] }} /text /view /scroll-view /view /template script setup import { ref, computed, watch, onMounted } from vue const props defineProps({ modelValue: { type: Array, default: () [] }, treeData: { type: Array, default: () [] }, nodeKey: { type: String, default: id }, labelField: { type: String, default: label }, childrenField: { type: String, default: children }, disabledField: { type: String, default: disabled }, expandAll: { type: Boolean, default: false }, showSelectedTags: { type: Boolean, default: true }, checkStrictly: { type: Boolean, default: false }, // false: 父子联动; true: 独立选择 showSelectAll: { type: Boolean, default: true } // 是否显示全选按钮 }) const emit defineEmits([update:modelValue, change]) // 内部数据 const nodeMap new Map() let allNodes [] const flatList ref([]) const selectedSet ref(new Set(props.modelValue)) // ---------- 构建节点树 ---------- function buildChildren(parent, childrenList, level) { for (const raw of childrenList) { const node { ...raw } node._level level node._parent parent node._checked false node._indeterminate false node._expanded props.expandAll ? true : false const grandChildren node[props.childrenField] const hasChildren Array.isArray(grandChildren) grandChildren.length 0 node._hasChildren hasChildren node._children hasChildren ? grandChildren : [] nodeMap.set(node[props.nodeKey], node) if (hasChildren) { buildChildren(node, grandChildren, level 1) } } } function initData() { nodeMap.clear() const roots [] for (const raw of props.treeData) { const node { ...raw } node._level 0 node._parent null node._checked false node._indeterminate false node._expanded props.expandAll ? true : true const childrenRaw node[props.childrenField] const hasChildren Array.isArray(childrenRaw) childrenRaw.length 0 node._hasChildren hasChildren node._children hasChildren ? childrenRaw : [] nodeMap.set(node[props.nodeKey], node) roots.push(node) if (hasChildren) { buildChildren(node, childrenRaw, 1) } } allNodes Array.from(nodeMap.values()) // 根据 modelValue 初始化选中状态 const keys props.modelValue || [] keys.forEach(key { const node nodeMap.get(key) if (node !node[props.disabledField]) { if (props.checkStrictly) { setChecked(node, true) } else { setCheckedCascade(node, true) } } }) // 更新半选状态仅在联动模式下 if (!props.checkStrictly) { const nodesByLevel [...allNodes].sort((a,b) b._level - a._level) for (const node of nodesByLevel) { if (node._parent) updateIndeterminate(node._parent) } } updateFlatList() emitChange() } function updateFlatList() { const visible [] function dfs(node) { visible.push(node) if (node._expanded node._children.length) { for (const childRaw of node._children) { const child nodeMap.get(childRaw[props.nodeKey]) if (child) dfs(child) } } } for (const node of allNodes) { if (node._level 0 node._parent null) { dfs(node) } } flatList.value visible } function toggleExpand(node) { node._expanded !node._expanded updateFlatList() } function setChecked(node, checked) { node._checked checked if (checked) { selectedSet.value.add(node[props.nodeKey]) } else { selectedSet.value.delete(node[props.nodeKey]) } } function setCheckedCascade(node, checked) { setChecked(node, checked) node._indeterminate false if (node._children.length) { for (const childRaw of node._children) { const child nodeMap.get(childRaw[props.nodeKey]) if (child) setCheckedCascade(child, checked) } } } function updateIndeterminate(node) { if (props.checkStrictly) return const children node._children if (!children.length) { node._indeterminate false return } let checkedCount 0, indeterminateCount 0 for (const childRaw of children) { const child nodeMap.get(childRaw[props.nodeKey]) if (child) { if (child._checked) checkedCount if (child._indeterminate) indeterminateCount } } if (checkedCount children.length) { if (!node._checked) setChecked(node, true) node._indeterminate false } else if (checkedCount 0 indeterminateCount 0) { if (node._checked) setChecked(node, false) node._indeterminate false } else { if (node._checked) setChecked(node, false) node._indeterminate true } } function updateAncestors(node) { if (props.checkStrictly) return let p node._parent while (p) { updateIndeterminate(p) p p._parent } } function toggleCheck(node) { if (node[props.disabledField]) return if (props.checkStrictly) { const newVal !node._checked setChecked(node, newVal) node._indeterminate false } else { const newVal !node._checked setCheckedCascade(node, newVal) updateAncestors(node) } updateFlatList() emitChange() } // 全选/取消全选统一处理两种模式 function toggleSelectAll() { const shouldSelect !isAllSelected.value // 获取所有可选节点未禁用 const selectableNodes allNodes.filter(n !n[props.disabledField]) if (props.checkStrictly) { // 严格模式直接设置每个节点的选中状态 for (const node of selectableNodes) { setChecked(node, shouldSelect) node._indeterminate false } } else { // 联动模式为避免重复级联先清除所有选中再设置根节点的选中状态级联会带动子节点 // 但更高效的方式是直接设置所有节点的 _checked 为 shouldSelect然后重新计算半选状态。 // 因为联动模式下全选时所有节点都应该选中且没有半选状态。 // 直接设置所有节点 _checked 和 _indeterminate for (const node of allNodes) { node._checked shouldSelect node._indeterminate false } // 更新 selectedSet selectedSet.value.clear() if (shouldSelect) { for (const node of allNodes) { if (node._checked) selectedSet.value.add(node[props.nodeKey]) } } // 由于直接设置了所有节点无需再调用 setCheckedCascade但需要更新选中集合 // 直接调用 emitChange 即可 emitChange() updateFlatList() return } emitChange() updateFlatList() } // 判断是否全选所有可选节点都被选中 const isAllSelected computed(() { const selectableNodes allNodes.filter(n !n[props.disabledField]) if (selectableNodes.length 0) return false return selectableNodes.every(n n._checked) }) function emitChange() { const keys [] for (const node of allNodes) { if (node._checked) keys.push(node[props.nodeKey]) } selectedSet.value.clear() keys.forEach(k selectedSet.value.add(k)) emit(update:modelValue, keys) const selectedObjs keys.map(k nodeMap.get(k)).filter(Boolean) emit(change, keys, selectedObjs) } function removeTag(node) { if (props.checkStrictly) { setChecked(node, false) node._indeterminate false } else { setCheckedCascade(node, false) updateAncestors(node) } updateFlatList() emitChange() } function clearAll() { for (const node of allNodes) { node._checked false node._indeterminate false } updateFlatList() emitChange() } const selectedNodes computed(() { return Array.from(selectedSet.value) .map(key nodeMap.get(key)) .filter(Boolean) }) watch(() props.treeData, () { initData() }, { deep: true, immediate: true }) watch(() props.modelValue, (newVal) { const newSet new Set(newVal) if (newSet.size ! selectedSet.value.size || !Array.from(newSet).every(k selectedSet.value.has(k))) { initData() } }, { deep: true }) onMounted(() { initData() }) /script style langscss scoped .tree-select { width: 100%; background: #fff; border-radius: 12rpx; overflow: hidden; .selected-area { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 20rpx 24rpx; border-bottom: 1rpx solid #eee; background: #fafafa; .selected-tags { flex: 1; display: flex; flex-wrap: wrap; gap: 16rpx; .tag { display: inline-flex; align-items: center; background: #e8f4ff; border-radius: 8rpx; padding: 8rpx 16rpx; font-size: 24rpx; color: #2979ff; .tag-text { max-width: 200rpx; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tag-close { margin-left: 8rpx; font-size: 32rpx; line-height: 1; color: #999; font-weight: bold; :active { color: #666; } } } } .action-buttons { display: flex; gap: 20rpx; .select-all-btn, .clear-btn { padding: 8rpx 16rpx; font-size: 24rpx; color: #2979ff; background: #e8f4ff; border-radius: 8rpx; :active { opacity: 0.7; } } .clear-btn { color: #999; background: #f0f0f0; } } } .tree-scroll { max-height: 500rpx; overflow-y: auto; } .empty-tip { text-align: center; padding: 60rpx 0; color: #999; font-size: 28rpx; } .tree-item { display: flex; align-items: center; padding: 20rpx 0; border-bottom: 1rpx solid #f5f5f5; .expand-placeholder { width: 48rpx; height: 48rpx; flex-shrink: 0; } .expand-icon { width: 48rpx; height: 48rpx; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 32rpx; color: #666; .expanded { transform: rotate(0deg); } } .checkbox { width: 40rpx; height: 40rpx; flex-shrink: 0; border-radius: 6rpx; margin-right: 16rpx; display: flex; align-items: center; justify-content: center; font-size: 28rpx; font-weight: bold; background: #fff; border: 2rpx solid #ddd; .checked { background: #2979ff; border-color: #2979ff; color: #fff; } .indeterminate { background: #2979ff; border-color: #2979ff; color: #fff; font-size: 32rpx; } } .node-label { flex: 1; font-size: 28rpx; color: #333; .disabled { color: #ccc; } } } } /style使用代码template TreeSelect v-modelselectedIds :tree-datamenuTree node-keyid label-fieldname children-fieldchildren :check-strictlytrue :show-select-alltrue / /template script setup import { ref } from vue import TreeSelect from /component/TreeSelect.vue; const menuTree ref([ { id: 1, name: 总部, children: [ { id: 11, name: 研发部 }, { id: 12, name: 市场部, children: [ { id: 121, name: 广告组 } ]} ]}, { id: 2, name: 分公司, children: [ { id: 21, name: 销售部 } ]} ]) const selectedIds ref([1]); /script