Uniapp+Vue3树形选择器

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>
相关推荐
沙振宇3 小时前
【Web】使用Vue3+PlayCanvas开发3D游戏(九)纹理视觉效果
前端·游戏·3d·纹理
前端 贾公子3 小时前
uniapp中@input修改input内容不生效 | 过滤赋值无效 | 连续非法字符不更新的问题
开发语言·前端·javascript
533_3 小时前
[vxe-table] 表头:点击出现输入框
android·java·javascript
写不来代码的草莓熊3 小时前
el-date-picker ,自定义输入数字自动转换显示yyyy-mm-dd HH:mm:ss格式 【仅双日历 datetimerange专用】
开发语言·前端·javascript
绺年3 小时前
关于 mac 使用ssh配置
前端
LDX前端校草3 小时前
verdaccio数据迁移
前端
炸炸鱼.3 小时前
LVS-DR 群集部署
前端·chrome·lvs
Ava的硅谷新视界3 小时前
TypeScript 中用判别联合类型替代 instanceof 检查
前端·javascript·typescript
ZC跨境爬虫3 小时前
海南大学交友平台开发实战 day9(头像上传存入 SQLite+BLOB 存储 + 前后端联调避坑全记录)
前端·数据库·python·sqlite