html
复制代码
<template>
<view class="tree-select">
<!-- 已选标签区域 + 全选按钮 -->
<view v-if="showSelectedTags" class="selected-area">
<view class="selected-tags">
<view v-for="item in selectedNodes" :key="item[nodeKey]" class="tag">
<text class="tag-text">{{ item[labelField] }}</text>
<text class="tag-close" @click.stop="removeTag(item)">×</text>
</view>
</view>
<view class="action-buttons">
<view v-if="showSelectAll" class="select-all-btn" @click="toggleSelectAll">
{{ isAllSelected ? '取消全选' : '全选' }}
</view>
<view v-if="selectedNodes.length" class="clear-btn" @click="clearAll">清空</view>
</view>
</view>
<!-- 树形区域 -->
<scroll-view class="tree-scroll" scroll-y>
<view v-if="flatList.length === 0" class="empty-tip">暂无数据</view>
<view
v-for="item in flatList"
:key="item[nodeKey]"
class="tree-item"
:style="{ paddingLeft: (item._level * 32 + 24) + 'rpx' }"
>
<!-- 展开/折叠图标 -->
<view v-if="item._hasChildren" class="expand-icon" :class="{ expanded: item._expanded }" @click.stop="toggleExpand(item)">
{{ item._expanded ? '∨' : '>' }}
</view>
<view v-else class="expand-placeholder"></view>
<!-- 复选框 -->
<view class="checkbox" :class="{ checked: item._checked, indeterminate: item._indeterminate }" @click.stop="toggleCheck(item)">
<text v-if="item._checked">✓</text>
<text v-else-if="item._indeterminate">---</text>
</view>
<!-- 标签 -->
<text class="node-label" :class="{ disabled: item[disabledField] }" @click.stop="toggleCheck(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 lang="scss" 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>
html
复制代码
<template>
<TreeSelect
v-model="selectedIds"
:tree-data="menuTree"
node-key="id"
label-field="name"
children-field="children"
:check-strictly="true"
:show-select-all="true"
/>
</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>