vue3+ts+pinia实现嵌套树组件

特点:① 支持无限层级;② 支持全选/反选,子节点全勾选,父节点自动勾选

嵌套数组件效果

代码结构

public/
  img/
    arrow.svg
    folder.svg
src/
  views/
    warehouse/
      index.vue
      components/
        TreeItem.vue
  utils/
    staticData.ts
  stores/
    tree.ts

src/warehouse/index.vue

<template>
  <div class="warehouse">
    <van-space></van-space>
    <div class="checkbox-container">
      <span>全选/反选</span>
      <input
        type="checkbox"
        class="checkbox"
        :checked="isChecked"
        @change="checkAll"
      />
    </div>
    <section class="warehouse-nav">
      <ul>
        <tree-item :orgTree="orgTree"></tree-item>
      </ul>
      <van-divider>到底了~~</van-divider>
    </section>
  </div>
</template>
<script setup lang="ts">
import { TreeNode } from '@/utils/staticData'
import TreeItem from './components/TreeItem.vue'
import useTreeStore from '@/stores/tree'
defineOptions({
  name: 'warehouseComponent',
})
const orgTree = useTreeStore().getTree()
const isChecked = ref(false)

const isAllChecked = computed(() => useTreeStore().isCheckAll)
watch(isAllChecked, (val) => {
  isChecked.value = val
})
//全选反选关联
const checkAll = () => {
  isChecked.value = !isChecked.value
  const tree = useTreeStore().getTree()
  useTreeStore().isCheckAll = isChecked.value
  const toggleCheckAll = (nodes: TreeNode[]): void => {
    nodes.forEach((item) => {
      item.checked = isChecked.value
      if (item.children) {
        toggleCheckAll(item.children)
      }
    })
  }
  toggleCheckAll(tree)

  const allCheckedIds = useTreeStore().getAllCheckedIds(tree)
  useTreeStore().selectedIds = allCheckedIds
}

onMounted(() => {
  isChecked.value = useTreeStore().isCheckAll
})
</script>
<style lang="less" scoped>
.warehouse-nav {
  margin: 0.4rem 0.8rem;
  border-radius: 0.4rem;
  background-color: #fff;
}

.checkbox-container {
  display: flex;
  align-items: center;
  justify-content: right;
  padding: 0.6rem 1.2rem;
}

.checkbox {
  width: 1rem;
  height: 1rem;
}
</style>

src/warehouse/components/TreeItem.vue

<template>
  <li
    v-for="(item, index) in orgTree"
    :key="item.id"
    :class="[
      'warehouse-nav-item',
      orgTree.length - 1 !== index ? 'van-hairline--bottom' : '',
    ]"
  >
    <div class="warehouse-nav-item-inner">
      <span @click="toggleShow(item)">
        <img
          class="arrow-icon"
          :class="item.isShow ? 'rotate90' : ''"
          :style="{
            visibility:
              item.children && item.children.length ? 'visible' : 'hidden',
          }"
          src="/img/arrow.svg"
        />
        <img src="/img/folder.svg" class="folder-icon" />{{ item.text }}
      </span>
      <input
        type="checkbox"
        class="checkbox"
        :data-id="item.id"
        @change="handleCheckboxChange(item, $event)"
        :checked="item.checked"
      />
    </div>
    <ul v-show="item.isShow">
      <treeItem :orgTree="item.children ? item.children : []" />
    </ul>
  </li>
</template>
<script setup lang="ts">
import { TreeNode } from '@/utils/staticData'
import useTreeStore from '@/stores/tree'
defineOptions({
  name: 'treeItem',
})
withDefaults(
  defineProps<{
    orgTree: TreeNode[]
  }>(),
  {}
)
const toggleShow = (item: TreeNode) => {
  if (item.children && item.children.length) {
    item.isShow = !item.isShow
  }
}

/***
 * 全选反选
 * 支持无限层级联动
 * */
const handleCheckboxChange = async (item: TreeNode, event: Event) => {
  const target = event.target as HTMLInputElement
  item.checked = target.checked
  
  // 递归联动所有子节点勾选状态
  const toggleChildren = (node: TreeNode, checked: boolean) => {
    if (node.children) {
      node.children.forEach((child) => {
        child.checked = checked
        toggleChildren(child, checked)
      })
    }
  }
  toggleChildren(item, target.checked)
  
  // 联动所有父节点勾选状态
  await nextTick()
  if (item.pid) {
    const tree = useTreeStore().getTree()
    
    // 递归查找父节点
    const findParent = (nodes: TreeNode[], pid: string): TreeNode | null => {
      for (const node of nodes) {
        if (node.id === pid) {
          return node
        }
        if (node.children) {
          const found = findParent(node.children, pid)
          if (found) {
            return found
          }
        }
      }
      return null
    }
    
    // 检查父节点的所有子节点是否都已选中
    const allChildrenChecked = (node: TreeNode): boolean => {
      if (!node.children || node.children.length === 0) {
        return true
      }
      return node.children.every(child => child.checked && allChildrenChecked(child))
    }
    
    let currentParent = findParent(tree, item.pid)
    while (currentParent) {
      currentParent.checked = allChildrenChecked(currentParent)
      if (currentParent.pid) {
        currentParent = findParent(tree, currentParent.pid)
      } else {
        currentParent = null
      }
    }
  }
  
  // 使用store中的函数获取所有选中节点的ID
  const allCheckedIds = useTreeStore().getAllCheckedIds(useTreeStore().getTree())
  useTreeStore().selectedIds = allCheckedIds
  useTreeStore().isCheckAll = hasCheckedAll()
}
const hasCheckedAll = () => {
  const tree = useTreeStore().getTree()
  // 只要有一个节点(无论层级)未勾选,就视为“未全选”
  const allChecked = (nodes: TreeNode[]): boolean =>
    nodes.every((n) => n.checked && (!n.children || allChecked(n.children)))

  return allChecked(tree)
}
</script>
<style lang="less" scoped>
ul,
li {
  list-style: none;
}

.rotate90 {
  transform: rotate(90deg);
}

.checkbox {
  width: 1rem;
  height: 1rem;
}

.warehouse-nav {
  &-item {
    padding: 0.8rem 0.6rem;
  }

  .warehouse-nav-item .warehouse-nav-item {
    margin-left: 0.9rem;
  }

  .arrow-icon {
    height: 1.2rem;
    transition: all 0.2s;
  }

  .folder-icon {
    margin-right: 0.6rem;
    height: 1.6rem;
  }
}

.warehouse-nav-item-inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
</style>

src/utils/staticData.ts

export interface TreeNode {
  id: string
  pid?: string
  text: string
  children?: TreeNode[]
  checked?: boolean // 动态状态,建议在初始化时设置
  isShow?: boolean // 动态状态,建议在初始化时设置
  level?: number // 可选字段,记录节点层级
}

export const ORG_TREE: TreeNode[] = [
  {
    id: '1',
    pid: null,
    text: 'XX集团有限公司',
    children: [
      // 核心决策机构
      {
        id: '100',
        pid: '1',
        text: '董事会',
        children: [
          {
            id: '101',
            pid: '100',
            text: '董事长办公室',
          },
          {
            id: '102',
            pid: '100',
            text: '董事会秘书处',
          },
        ],
      },
      {
        id: '200',
        pid: '1',
        text: '经营管理层',
        children: [
          {
            id: '201',
            pid: '200',
            text: '总裁办公室',
          },
        ],
      },
      
      // 精简的职能部门
      {
        id: '300',
        pid: '1',
        text: '管理中心',
        children: [
          {
            id: '301',
            pid: '300',
            text: '人力资源部',
          },
          {
            id: '302',
            pid: '300',
            text: '财务部',
          },
          {
            id: '303',
            pid: '300',
            text: '行政部',
          },
          {
            id: '304',
            pid: '300',
            text: '法务部',
          },
        ],
      },
      
      // 核心业务部门
      {
        id: '400',
        pid: '1',
        text: '业务中心',
        children: [
          {
            id: '401',
            pid: '400',
            text: '产品研发部',
          },
          {
            id: '402',
            pid: '400',
            text: '生产制造部',
          },
          {
            id: '403',
            pid: '400',
            text: '市场销售部',
          },
          {
            id: '404',
            pid: '400',
            text: '客户服务部',
          },
        ],
      },
      
      // 简化的分支机构
      {
        id: '500',
        pid: '1',
        text: '分支机构',
        children: [
          {
            id: '501',
            pid: '500',
            text: '北京分公司',
          },
          {
            id: '502',
            pid: '500',
            text: '上海分公司',
          },
          {
            id: '503',
            pid: '500',
            text: '广州分公司',
          },
          {
            id: '504',
            pid: '500',
            text: '深圳分公司',
          },
        ],
      },
      
      // 主要子公司
      {
        id: '600',
        pid: '1',
        text: '子公司',
        children: [
          {
            id: '601',
            pid: '600',
            text: 'XX科技有限公司',
          },
        ],
      },
    ],
  },
]

src/stores/tree.ts

/**
 * 树形结构
 */
import { ORG_TREE, TreeNode } from '@/utils/staticData'
export default defineStore('tree', () => {
  const isCheckAll = ref(false)
  const selectedIds = ref<string[]>([])

  // 递归获取所有选中节点的ID
  const getAllCheckedIds = (nodes: TreeNode[]): string[] => {
    let ids: string[] = []
    nodes.forEach((node) => {
      if (node.checked) {
        ids.push(node.id)
      }
      if (node.children) {
        ids = ids.concat(getAllCheckedIds(node.children))
      }
    })
    return ids
  }

  function getTree() {
    return reactive<TreeNode[]>(ORG_TREE)
  }

  return {
    selectedIds,
    isCheckAll,
    getTree,
    getAllCheckedIds,
  }
})

参考https://cn.vuejs.org/examples/#tree


原创文章,如需转载,请注明出处。