零依赖的Vue2Tree实现

36 阅读11分钟

概述

前端开发中,树形结构组件是企业管理系统、文件管理器、组织架构等场景中不可或缺的UI组件。这是一个基于Vue2开发的功能丰富的树形组件,它不仅支持传统的嵌套数据结构,还能处理扁平化数据,满足各种复杂业务需求。 这个树形组件提供了单选、多选、复选框、筛选、展开折叠等丰富功能,同时保持了出色的用户体验和性能表现。无论你是需要简单的目录树,还是复杂的企业组织架构,这个组件都能完美胜任。

效果

下载.gif

核心特性

🎯 丰富的数据结构支持

  • 嵌套结构:传统的树形嵌套数据格式
  • 扁平结构:通过parentId关联的扁平数据,轻松处理数据库查询结果

🎨 灵活的模式

  • 单选模式:只能选择一个节点
  • 多选模式:支持选择多个节点
  • 严格模式:父子节点选择状态独立,不相互影响
  • 节点插槽:节点内容支持自定义插槽

🔍 筛选功能

  • 实时关键词筛选
  • 智能的筛选结果展示

⚡ 用户体验

  • 响应式设计,完美适配移动端
  • 平滑的动画过渡效果
  • 可配置的点击展开/折叠行为
  • 只读模式支持

🛠 API方法

  • 展开/折叠所有节点
  • 获取/设置选中节点
  • 获取/设置展开节点

安装与使用

基本使用

<template>
  <div>
    <Tree
      :data="treeData"
      mode="multiple"
      :show-checkbox="true"
      :default-expand-all="true"
      @node-click="handleNodeClick"
      @check-change="handleCheckChange"
    />
  </div>
</template>

<script>
import Tree from './components/newTree.vue'
export default {
  components: {
    Tree
  },
  data() {
    return {
      treeData: [
        {
          id: 1,
          name: '技术部',
          children: [
            { id: 2, name: '前端组' },
            { id: 3, name: '后端组' }
          ]
        }
      ]
    }
  },
  methods: {
    handleNodeClick(node) {
      console.log('点击节点:', node)
    },
    handleCheckChange(checked, node, checkedKeys, checkedNodes) {
      console.log('选中状态变化:', checkedKeys)
    }
  }
}
</script>

Props 配置

树形组件提供了丰富的配置选项,让你能够灵活定制组件行为:

参数类型默认值说明
dataSourceArray[]树形数据,支持嵌套和扁平结构
modeString'single'选择模式:'single' 或 'multiple'
showCheckboxBooleantrue是否显示复选框
filterableBooleantrue是否开启筛选
defaultExpandAllBooleanfalse是否默认展开所有节点
expandOnClickNodeBooleanfalse点击节点时是否展开/折叠
isViewBooleanfalse是否为只读模式
filterableBooleanfalse是否开启筛选功能
checkStrictlyBooleanfalse是否开启严格模式(父子不关联)
dataStructureString'nested'数据结构类型:'nested' 或 'flat'
rootValue[String, Number, null]null扁平结构的根节点值
nodeStructureObject{children: 'children',label: 'name',id: 'id',disabled: 'disabled',parentId: 'parentId',} 树节点映射对象

数据结构配置

通过 props 配置项,你可以自定义数据字段的映射关系:

props: {
  children: 'children',  // 子节点字段名
  label: 'name',         // 显示文本字段名
  id: 'id',              // 节点唯一标识字段名
  disabled: 'disabled',  // 禁用状态字段名
  parentId: 'parentId'   // 扁平结构的父节点字段名
}

事件说明

组件提供了完整的事件系统,让你能够轻松响应用户操作:

事件名参数说明
node-clicknode节点点击事件
node-expandnode节点展开事件
node-collapsenode节点折叠事件
check-changechecked, node, checkedKeys, checkedNodes复选框状态变化
changecheckedKeys, checkedNodes选中状态变化
selectnode, checkedKeys, checkedNodes节点选择事件

数据格式

嵌套结构数据

const nestedData = [
  {
    id: 1,
    name: '技术部',
    children: [
      {
        id: 2,
        name: '前端组',
        children: [
          { id: 5, name: '组件开发组' },
          { id: 6, name: 'UI交互组' }
        ]
      },
      {
        id: 3,
        name: '后端组',
        children: [
          { id: 8, name: '赵六' },
          { id: 9, name: '钱七' }
        ]
      }
    ]
  }
]

扁平结构数据

const flatData = [
  { id: 1, name: '一级节点 1', parentId: null },
  { id: 2, name: '二级节点 1-1', parentId: 1 },
  { id: 3, name: '三级节点 1-1-1', parentId: 2 },
  { id: 4, name: '三级节点 1-1-2', parentId: 2, disabled: true },
  { id: 5, name: '一级节点 2', parentId: null }
]

核心功能讲解

1. 严格模式 vs 非严格模式

非严格模式(默认):父子节点选中状态关联

  • 选中父节点时,自动选中所有子节点
  • 取消选中父节点时,自动取消选中所有子节点
  • 子节点状态变化会影响父节点的选中状态 严格模式:父子节点选中状态独立
  • 每个节点的选中状态完全独立
  • 父子节点之间没有关联关系

2. 筛选功能

开启筛选功能后,用户可以实时搜索节点:

    <Tree
      :data="treeData"
      :filterable="true"
      filter-placeholder="搜索部门或人员"
    />

筛选功能支持:

  • 实时关键词匹配
  • 自动展开匹配节点的父级

3. 只读模式

在只读模式下,组件会显示所有节点为展开状态,但禁止用户进行任何交互:

    <Tree
      :data="treeData"
      :is-view="true"
      :default-expand-all="true"
    />

进阶用法

  • 通过ref引用,你可以在父组件中调用树形组件的方法:
   // 展开所有节点
    this.$refs.tree.expandAll()
    // 折叠所有节点
    this.$refs.tree.collapseAll()
    // 设置特定节点展开
    this.$refs.tree.setExpandedKeys([1, 2, 3])

自定义节点内容

  • 插槽
    <!-- 自定义树节点示例 -->
    <template #node-content="node">
        <slot name="item" :item="node"> </slot>
     </template>

源码

1. index.vue

<template>
  <div class="myTreeView" style="height: 100%; position: relative">
    <!-- 加载动画 -->
    <div v-if="isLoading" class="loading-overlay">
      <div class="loading-spinner"></div>
    </div>
    <Tree
      ref="tree"
      :data="dataSource"
      :mode="mode"
      :selected-keys.sync="selectKeys"
      :default-expand-all="defaultExpandAll"
      :expand-on-click-node="expandOnClickNode"
      :is-view="isView"
      :filterable="filterable"
      :show-checkbox="showCheckbox"
      :filter-placeholder="filterPlaceholder"
      :props="nodeStructure"
      :check-strictly="checkStrictly"
      :data-structure="dataStructure"
      :root-value="rootValue"
      @node-click="handleNodeClick"
      @node-expand="handleNodeExpand"
      @node-collapse="handleNodeCollapse"
      @check-change="handleCheckChange"
      @change="handleChange"
    >
      <!-- 添加具名作用域插槽 -->
      <template #node-content="node">
        <slot name="item" :item="node"> </slot>
      </template>
    </Tree>
  </div>
</template>

<script>
import Tree from './ChildComponents/newTree.vue';
export default {
  name: 'ha-tree-view',
  components: {
    Tree,
  },
  props: {
    dataSource: {
      type: Array,
      required: true,
      default: () => [
        {
          id: 1,
          name: '总经办',
          parentId: 0,
        },
        {
          id: 2,
          name: '办公室',
          parentId: 0,
        },
        {
          id: 3,
          name: '财务部',
          parentId: 0,
        },
        {
          id: 5,
          name: '运营中心',
          parentId: 0,
        },
        {
          id: 6,
          name: '信息中心',
          parentId: 0,
        },
        {
          id: 61,
          name: '前端开发组',
          parentId: 6,
        },
        {
          id: 611,
          name: '张三',
          parentId: 61,
        },
        {
          id: 612,
          name: '李四',
          parentId: 61,
        },
        {
          id: 62,
          name: '后端开发组',
          parentId: 6,
        },
        {
          id: 621,
          name: '王五',
          parentId: 62,
        },
        {
          id: 622,
          name: '周奇奇',
          parentId: 62,
        },
        {
          id: 7,
          name: '行政人事部',
          parentId: 0,
        },
        {
          id: 52,
          name: '华北业务部',
          parentId: 5,
        },
        {
          id: 51,
          name: '华中业务部',
          parentId: 5,
        },
        {
          id: 53,
          name: '华南业务部',
          parentId: 5,
        },
        {
          id: 54,
          name: '西北业务部',
          parentId: 5,
        },
      ],
    },
    // 多选还是单选
    mode: {
      type: String,
      default: 'single',
      validator: (value) => ['single', 'multiple'].includes(value),
    },
    // 选中的值
    values: {
      type: Array,
      default: () => [],
    },
    // 是否展开所有节点(查看模式下此属性为true)
    defaultExpandAll: {
      type: Boolean,
      default: false,
    },
    //点击节点内容是否触发展开和折叠节点
    expandOnClickNode: {
      type: Boolean,
      default: false,
    },
    // 是否为查看模式
    isView: {
      type: Boolean,
      default: false,
    },
    // 是否开启筛选功能
    filterable: {
      type: Boolean,
      default: true,
    },
    // 筛选框提示语
    filterPlaceholder: {
      type: String,
      default: '请输入关键词筛选',
    },
    // 是否展示复选框
    showCheckbox: {
      type: Boolean,
      default: true,
    },
    // 树节点属性
    nodeStructure: {
      type: Object,
      default: () => ({
        children: 'children',
        label: 'name',
        id: 'id',
        disabled: 'disabled',
        parentId: 'parentId',
      }),
    },
    //严格模式
    checkStrictly: {
      type: Boolean,
      default: true,
    },
    // 数据结构类型,支持 'nested'(嵌套)和 'flat'(扁平)
    dataStructure: {
      type: String,
      default: 'flat',
      validator: (value) => ['nested', 'flat'].includes(value),
    },
    // 根节点值
    rootValue: {
      type: [String, Number, null],
      default: null,
    },
  },
  computed: {
    // 计算属性判断是否正在加载
    isLoading() {
      // 如果 dataSource 不存在或者为空数组,则认为仍在加载
      return !this.dataSource || this.dataSource.length === 0;
    },
  },
  mounted() {
    // 在组件挂载完成后,初始化树结构
    // this.fetchData();
  },
  data() {
    return {
      // dataSource: [],
      selectKeys: [],
    };
  },
  watch: {
    selectKeys: {
      handler(newVal) {
        console.log('selectKeysChange', newVal);
        if (newVal !== undefined && JSON.stringify(newVal) !== JSON.stringify(this.values)) {
          this.$emit('update:values', newVal);
        }
      },
      deep: true
    },
  },
  methods: {
    setCheckedKeys(ids) {
      const checkedKeys = Array.isArray(ids) ? ids : ids ? [ids] : [];
      if (this.$refs.tree) {
        this.$refs.tree.setCheckedKeys(checkedKeys);
      }
    },
    async fetchData() {
      try {
        const response = await fetch('http://localhost:3000/api/treeFlat');
        this.dataSource = await response.json();
      } catch (error) {
        console.error('Failed to fetch tree data:', error);
      }
    },
    // 暴露给外部使用方法
    getCheckedNodes() {
      console.log('getCheckedNodes', this.$refs.tree.getSelectedNodes());
      return this.$refs.tree.getSelectedNodes();
    },
    getCheckedKeys() {
      console.log('getCheckedKeys', this.$refs.tree.getCheckedKeys());
      return this.$refs.tree.getCheckedKeys();
    },
    getHalfCheckedKeys() {
      console.log('getHalfCheckedKeys', this.$refs.tree.getHalfCheckedKeys());
      return this.$refs.tree.getCheckedKeys();
    },
    //传出外部使用方法
    handleNodeClick(node) {
      console.log('handleNodeClick', node);
      this.$emit('node-click', JSON.stringify(node), node[this.nodeStructure.id]);
    },
    handleNodeExpand(node) {
      console.log('handleNodeExpand', node);
      this.$emit('node-expand', node);
    },
    handleNodeCollapse(node) {
      console.log('handleNodeCollapse', node);
      this.$emit('node-collapse', node);
    },
    handleCheckChange(node, keys, nodes, checked) {
      console.log('handleCheckChange', node, keys, nodes, checked);
      this.$emit('check-change', node, keys, nodes, checked);
    },
    handleChange(node) {
      console.log('handleChange', node);
      this.$emit('change', node);
    },
  },
};
</script>

<style scoped>
.myTreeView {
  position: relative;
}

.loading-overlay {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(255, 255, 255, 0.8);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 1000;
}

.loading-spinner {
  border: 4px solid #f3f3f3;
  border-top: 4px solid #409eff;
  border-radius: 50%;
  width: 40px;
  height: 40px;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}
</style>

2. newTree.vue - 树形组件核心

  • 数据处理和转换逻辑
  • 状态管理(选中、展开、筛选)
  • 事件处理和API方法实现
  • 扁平结构与嵌套结构的转换
<template>
  <div class="tree-container">
    <!-- 搜索框(如果开启筛选) -->
    <div v-if="filterable" class="tree-filter">
      <input v-model="filterText" type="text" :placeholder="filterPlaceholder" class="tree-filter__input" @input="handleFilter" />
      <span v-if="filterText" class="tree-filter__clear" @click="clearFilter"> × </span>
    </div>
    <!-- 树节点 -->
    <div class="tree-board" style="height: calc(100% - 50px); overflow-y: auto">
      <tree-node
        v-for="node in formattedData"
        :key="getNodeId(node)"
        :node="node"
        :level="0"
        :mode="mode"
        :show-checkbox="computedShowCheckbox"
        :selected-ids="selectedIds"
        :indeterminate-ids="indeterminateIds"
        :expanded-keys="expandedKeys"
        :props="props"
        :node-content-slot="$scopedSlots['node-content']"
        :expand-on-click-node="expandOnClickNode"
        :filter-text="filterText"
        @toggle="handleNodeToggle"
        @select="handleNodeSelect"
        @node-click="handleNodeClick"
      >
      </tree-node>
      <!-- 空状态 -->
      <div v-if="filterable && filterText && formattedData.length === 0" class="tree-empty">暂无匹配数据</div>
    </div>
  </div>
</template>

<script>
import TreeNode from './newTreeNode.vue';

export default {
  name: 'Tree',
  components: {
    TreeNode,
  },
  props: {
    data: {
      type: Array,
      required: true,
    },
    mode: {
      type: String,
      default: 'single',
      validator: (value) => ['single', 'multiple'].includes(value),
    },
    showCheckbox: {
      type: Boolean,
      default: true,
    },
    selectedKeys: {
      type: Array,
      default: () => [],
    },
    defaultExpandedKeys: {
      type: Array,
      default: () => [],
    },
    defaultExpandAll: {
      type: Boolean,
      default: false,
    },
    expandOnClickNode: {
      type: Boolean,
      default: false,
    },
    isView: {
      type: Boolean,
      default: false,
    },
    filterable: {
      type: Boolean,
      default: false,
    },
    filterPlaceholder: {
      type: String,
      default: '请输入关键词筛选',
    },
    props: {
      type: Object,
      default: () => ({
        children: 'children',
        label: 'label',
        id: 'id',
        disabled: 'disabled',
        parentId: 'parentId',
      }),
    },
    checkStrictly: {
      type: Boolean,
      default: false,
    },
    dataStructure: {
      type: String,
      default: 'nested',
      validator: (value) => ['nested', 'flat'].includes(value),
    },
    rootValue: {
      type: [String, Number, null],
      default: null,
    },
  },
  data() {
    return {
      selectedIds: Array.isArray(this.selectedKeys) ? [...this.selectedKeys] : [],
      indeterminateIds: [],
      expandedKeys: Array.isArray(this.defaultExpandedKeys) ? [...this.defaultExpandedKeys] : [],
      filterText: '',
      internalData: [],
      // 映射表
      nodeMap: new Map(), // 节点ID -> 节点对象
      parentMap: new Map(), // 节点ID -> 父节点对象
      childrenMap: new Map(), // 节点ID -> 子节点数组
    };
  },
  computed: {
    formattedData() {
      if (!Array.isArray(this.internalData)) {
        return [];
      }
      if (this.filterable) {
        if (!this.filterText) {
          return this.internalData;
        } else {
          const filter = (nodes) => {
            const result = [];
            nodes.forEach((node) => {
              if (!node || !node[this.props.label]) return;
              if (node[this.props.label].includes(this.filterText)) {
                result.push({ ...node });
              } else if (node.children && node.children.length > 0) {
                const filteredChildren = filter(node.children);
                if (filteredChildren.length > 0) {
                  result.push({
                    ...node,
                    children: filteredChildren,
                  });
                }
              }
            });
            return result;
          };
          return filter(this.internalData);
        }
      } else {
        return this.internalData;
      }
    },
    computedShowCheckbox() {
      return this.isView ? false : this.showCheckbox;
    },
  },
  watch: {
    data: {
      handler(newData) {
        console.log('newTree-Data', newData);
        this.initData(newData);
      },
      immediate: true,
      deep: true,
    },
    selectedKeys: {
      handler(newVal, oldVal) {
        console.log('newTree-watch-selectedKeys', newVal, oldVal);
        if (newVal !== undefined && JSON.stringify(newVal) !== JSON.stringify(this.selectedIds)) {
          if (this.mode === 'multiple' && !this.checkStrictly) {
            const { selectedIds, indeterminateIds } = this.calculateCheckedState(newVal);
            this.selectedIds = [...selectedIds];
            this.indeterminateIds = [...indeterminateIds];
          } else {
            this.selectedIds = Array.isArray(newVal) ? [...newVal] : [];
            this.indeterminateIds = [];
          }
          this.$nextTick(() => {
            this.$forceUpdate();
          });
        }
      },
      immediate: true,
      deep: true,
    },
    selectedIds: {
      handler(newVal, oldVal) {
        console.log('newTree-watch-selectedIds', newVal, oldVal);
        if (newVal !== undefined && JSON.stringify(newVal) !== JSON.stringify(this.selectedKeys)) {
          this.$emit('update:selectedKeys', [...newVal]);
        }
      },
      deep: true,
    },
    defaultExpandAll: {
      handler(newVal) {
        if (newVal && this.internalData.length > 0) {
          this.expandAll();
        }
      },
      immediate: true,
    },
    isView: {
      handler(newVal) {
        if (newVal && this.internalData.length > 0) {
          this.expandAll();
        }
      },
      immediate: true,
    },
  },
  mounted() {},
  methods: {
    // 初始化数据
    initData(data) {
      this.filterText = '';
      if (!data || !Array.isArray(data)) {
        this.internalData = [];
        return;
      }

      if (this.dataStructure === 'flat') {
        this.internalData = this.flatToNested(data);
      } else {
        this.internalData = this.deepClone(data);
      }

      // 构建映射表
      this.buildNodeMaps(this.internalData);

      if (this.isView) {
        this.expandAll();
      } else {
        if (this.defaultExpandAll) {
          this.expandAll();
        }
        if (this.defaultExpandedKeys && this.defaultExpandedKeys.length > 0) {
          this.expandedKeys = [...this.defaultExpandedKeys];
        }
      }
    },

    // 构建节点映射表
    buildNodeMaps(nodes, parent = null) {
      if (!nodes || !Array.isArray(nodes)) return;
      nodes.forEach((node) => {
        const nodeId = this.getNodeId(node);
        // 存储节点映射
        this.nodeMap.set(nodeId, node);
        // 存储父子关系
        if (parent) {
          this.parentMap.set(nodeId, parent);
        }
        // 存储子节点关系
        const children = node[this.props.children];
        if (children && children.length > 0) {
          this.childrenMap.set(nodeId, children);
          this.buildNodeMaps(children, node);
        } else {
          this.childrenMap.set(nodeId, []);
        }
      });
    },

    // 使用映射表查找节点
    findNodeInFullData(nodes, targetNodeId) {
      return this.nodeMap.get(targetNodeId) || null;
    },

    // 使用映射表查找父节点
    findParent(nodes, childNode) {
      const childNodeId = this.getNodeId(childNode);
      return this.parentMap.get(childNodeId) || null;
    },

    // 获取节点的所有子节点
    getNodeChildren(node) {
      const nodeId = this.getNodeId(node);
      return this.childrenMap.get(nodeId) || [];
    },

    // 已知选中节点-计算选中状态
    calculateCheckedState(keys) {
      const newSelectedIds = [];
      const newIndeterminateIds = [];

      keys.forEach((key) => {
        const node = this.findNodeInFullData(this.internalData, key);
        if (node) {
          this.selectNodeAndChildrenForCalc(node, newSelectedIds, newIndeterminateIds);
        }
      });

      this.updateAllParentStatesForCalc(newSelectedIds, newIndeterminateIds);
      return {
        selectedIds: [...newSelectedIds],
        indeterminateIds: [...newIndeterminateIds],
      };
    },

    // 已知选中节点-选中节点及其所有子节点
    selectNodeAndChildrenForCalc(node, targetSelectedIds, targetIndeterminateIds) {
      const nodeId = this.getNodeId(node);

      if (!targetSelectedIds.includes(nodeId)) {
        targetSelectedIds.push(nodeId);
      }

      const indeterminateIndex = targetIndeterminateIds.indexOf(nodeId);
      if (indeterminateIndex > -1) {
        targetIndeterminateIds.splice(indeterminateIndex, 1);
      }

      const children = this.getNodeChildren(node);
      if (children && children.length > 0) {
        children.forEach((child) => {
          this.selectNodeAndChildrenForCalc(child, targetSelectedIds, targetIndeterminateIds);
        });
      }
    },

    // 已知选中节点-更新所有父节点状态
    updateAllParentStatesForCalc(targetSelectedIds, targetIndeterminateIds) {
      const updateParentsForNode = (node) => {
        let parent = this.findParent(this.internalData, node);
        while (parent) {
          this.updateNodeStateForCalc(parent, targetSelectedIds, targetIndeterminateIds);
          parent = this.findParent(this.internalData, parent);
        }
      };

      const traverseAndUpdate = (nodes) => {
        nodes.forEach((node) => {
          const children = node[this.props.children];
          if (!children || children.length === 0) {
            updateParentsForNode(node);
          } else {
            traverseAndUpdate(children);
          }
        });
      };

      traverseAndUpdate(this.internalData);
    },

    // 已知选中节点-更新单个节点状态
    updateNodeStateForCalc(node, targetSelectedIds, targetIndeterminateIds) {
      const nodeId = this.getNodeId(node);
      const children = this.getNodeChildren(node);

      if (!children || children.length === 0) return;

      let selectedCount = 0;
      let indeterminateCount = 0;

      children.forEach((child) => {
        const childId = this.getNodeId(child);
        if (targetSelectedIds.includes(childId)) {
          selectedCount++;
        } else if (targetIndeterminateIds.includes(childId)) {
          indeterminateCount++;
        }
      });

      const currentIndex = targetSelectedIds.indexOf(nodeId);
      const indeterminateIndex = targetIndeterminateIds.indexOf(nodeId);

      if (selectedCount === children.length) {
        if (currentIndex === -1) {
          targetSelectedIds.push(nodeId);
        }
        if (indeterminateIndex > -1) {
          targetIndeterminateIds.splice(indeterminateIndex, 1);
        }
      } else if (selectedCount > 0 || indeterminateCount > 0) {
        if (currentIndex > -1) {
          targetSelectedIds.splice(currentIndex, 1);
        }
        if (indeterminateIndex === -1) {
          targetIndeterminateIds.push(nodeId);
        }
      } else {
        if (currentIndex > -1) {
          targetSelectedIds.splice(currentIndex, 1);
        }
        if (indeterminateIndex > -1) {
          targetIndeterminateIds.splice(indeterminateIndex, 1);
        }
      }
    },

    // 扁平结构转换为嵌套结构
    flatToNested(flatData) {
      if (!flatData || !Array.isArray(flatData)) return [];
      const nodeMap = new Map();
      const rootNodes = [];

      flatData.forEach((item) => {
        const node = { ...item };
        nodeMap.set(this.getNodeId(node), node);
      });

      flatData.forEach((item) => {
        const node = nodeMap.get(this.getNodeId(item));
        const parentId = node[this.props.parentId];

        if (parentId === this.rootValue) {
          rootNodes.push(node);
        } else if (parentId === null || parentId === undefined) {
          rootNodes.push(node);
        } else {
          const parentNode = nodeMap.get(parentId);
          if (parentNode) {
            if (!parentNode[this.props.children]) {
              parentNode[this.props.children] = [];
            }
            parentNode[this.props.children].push(node);
          } else {
            rootNodes.push(node);
          }
        }
      });

      return rootNodes;
    },

    // 获取所有节点ID
    getAllNodeIds(nodes) {
      let ids = [];
      if (!nodes || !Array.isArray(nodes)) return ids;
      nodes.forEach((node) => {
        ids.push(this.getNodeId(node));
        const children = node[this.props.children];
        if (children && children.length > 0) {
          ids.push(...this.getAllNodeIds(children));
        }
      });
      return ids;
    },

    // 获取节点ID
    getNodeId(node) {
      return node[this.props.id];
    },

    // 计算筛选后的节点数量
    countFilteredNodes(nodes) {
      if (!Array.isArray(nodes) || !this.filterText) return 0;

      let count = 0;
      const traverse = (nodeList) => {
        nodeList.forEach((node) => {
          const label = node[this.props.label];
          if (label && label.toLowerCase().includes(this.filterText.toLowerCase())) {
            count++;
          }
          const children = node[this.props.children];
          if (children && children.length > 0) {
            traverse(children);
          }
        });
      };

      traverse(nodes);
      return count;
    },

    // 处理筛选输入
    handleFilter() {
      // 筛选逻辑已经在 computed 中处理
    },

    // 清除筛选
    clearFilter() {
      this.filterText = '';
    },

    // 处理节点切换
    handleNodeToggle(node, expanded) {
      const nodeId = this.getNodeId(node);
      if (expanded) {
        if (!this.expandedKeys.includes(nodeId)) {
          this.expandedKeys.push(nodeId);
        }
        this.$emit('node-expand', node);
      } else {
        const index = this.expandedKeys.indexOf(nodeId);
        if (index > -1) {
          this.expandedKeys.splice(index, 1);
        }
        this.$emit('node-collapse', node);
      }
    },

    // 处理节点选择
    handleNodeSelect(node) {
      if (node[this.props.disabled]) return;
      const nodeId = this.getNodeId(node);
      if (this.mode === 'single') {
        this.selectedIds = [nodeId];
        this.indeterminateIds = [];
        const isCurrentlySelected = this.selectedIds.includes(nodeId);
        this.emitChangeEvents(node, isCurrentlySelected);
      } else if (this.mode === 'multiple') {
        if (this.checkStrictly) {
          this.toggleNodeCheckedStrictly(node);
        } else {
          this.toggleNodeChecked(node);
        }
      }
    },

    // 严格模式切换节点选中状态
    toggleNodeCheckedStrictly(node) {
      const nodeId = this.getNodeId(node);
      let ids = JSON.parse(JSON.stringify(this.selectedIds));
      const index = ids.indexOf(nodeId);
      if (index > -1) {
        ids.splice(index, 1);
      } else {
        ids.push(nodeId);
      }
      this.selectedIds = ids;
      this.emitChangeEvents(node, index === -1);
    },
    // 手动切换节点选中状态
    toggleNodeChecked(node) {
      const nodeId = this.getNodeId(node);
      const isCurrentlySelected = this.selectedIds.includes(nodeId);

      let sourceNode;
      if (this.filterable && this.filterText.trim()) {
        sourceNode = this.findNodeInFullData(this.internalData, nodeId);
        if (!sourceNode) {
          console.warn(`未能在完整数据中找到节点ID为 ${nodeId} 的节点`);
          sourceNode = node;
        }
      } else {
        sourceNode = node;
      }

      // 使用临时变量处理所有状态变更
      const tempSelectedIds = [...this.selectedIds];
      const tempIndeterminateIds = [...this.indeterminateIds];

      if (isCurrentlySelected) {
        this.deselectNodeAndChildrenOptimized(sourceNode, tempSelectedIds, tempIndeterminateIds);
      } else {
        this.selectNodeAndChildrenOptimized(sourceNode, tempSelectedIds, tempIndeterminateIds);
      }

      this.updateParentStatesOptimized(sourceNode, tempSelectedIds, tempIndeterminateIds);

      // 数据处理结束后统一赋值
      this.selectedIds = tempSelectedIds;
      this.indeterminateIds = tempIndeterminateIds;

      this.emitChangeEvents(sourceNode, !isCurrentlySelected);
    },

    // 优化的选中节点及其所有子节点
    selectNodeAndChildrenOptimized(node, tempSelectedIds, tempIndeterminateIds) {
      if (!node) return;

      const nodeId = this.getNodeId(node);

      // 如果节点已禁用,则不选中
      if (node[this.props.disabled]) return;

      // 添加到选中列表(如果不存在)
      if (!tempSelectedIds.includes(nodeId)) {
        tempSelectedIds.push(nodeId);
      }

      // 从半选列表中移除(如果存在)
      const indeterminateIndex = tempIndeterminateIds.indexOf(nodeId);
      if (indeterminateIndex > -1) {
        tempIndeterminateIds.splice(indeterminateIndex, 1);
      }

      // 递归处理所有子节点
      const children = this.getNodeChildren(node);
      if (children && children.length > 0) {
        children.forEach((child) => {
          this.selectNodeAndChildrenOptimized(child, tempSelectedIds, tempIndeterminateIds);
        });
      }
    },

    // 优化的取消选中节点及其所有子节点
    deselectNodeAndChildrenOptimized(node, tempSelectedIds, tempIndeterminateIds) {
      if (!node) return;

      const nodeId = this.getNodeId(node);

      // 从选中列表中移除
      const selectedIndex = tempSelectedIds.indexOf(nodeId);
      if (selectedIndex > -1) {
        tempSelectedIds.splice(selectedIndex, 1);
      }

      // 从半选列表中移除
      const indeterminateIndex = tempIndeterminateIds.indexOf(nodeId);
      if (indeterminateIndex > -1) {
        tempIndeterminateIds.splice(indeterminateIndex, 1);
      }

      // 递归处理所有子节点
      const children = this.getNodeChildren(node);
      if (children && children.length > 0) {
        children.forEach((child) => {
          this.deselectNodeAndChildrenOptimized(child, tempSelectedIds, tempIndeterminateIds);
        });
      }
    },

    // 优化的更新父节点状态
    updateParentStatesOptimized(node, tempSelectedIds, tempIndeterminateIds) {
      if (!node) return;

      const visitedParents = new Set();

      const updateParentChain = (currentNode) => {
        if (!currentNode) return;

        // 始终使用完整数据查找父节点
        const parent = this.findParent(this.internalData, currentNode);

        if (parent && !visitedParents.has(this.getNodeId(parent))) {
          const parentId = this.getNodeId(parent);
          visitedParents.add(parentId);

          // 更新父节点状态
          this.updateNodeStateOptimized(parent, tempSelectedIds, tempIndeterminateIds);

          // 继续向上更新
          updateParentChain(parent);
        }
      };
      updateParentChain(node);
    },
    // 优化的更新单个节点状态
    updateNodeStateOptimized(node, tempSelectedIds, tempIndeterminateIds) {
      if (!node) return;
      const nodeId = this.getNodeId(node);
      const children = this.getNodeChildren(node);
      // 如果没有子节点,直接返回
      if (!children || children.length === 0) return;
      // 统计子节点的选中和半选状态
      let selectedCount = 0;
      let indeterminateCount = 0;
      children.forEach((child) => {
        if (!child) return;
        const childId = this.getNodeId(child);
        if (tempSelectedIds.includes(childId)) {
          selectedCount++;
        } else if (tempIndeterminateIds.includes(childId)) {
          indeterminateCount++;
        }
      });
      const totalChildren = children.length;
      const currentSelectedIndex = tempSelectedIds.indexOf(nodeId);
      const currentIndeterminateIndex = tempIndeterminateIds.indexOf(nodeId);
      // 根据子节点状态更新当前节点状态
      if (selectedCount === totalChildren) {
        // 所有子节点都选中 - 当前节点应该选中
        if (currentSelectedIndex === -1) {
          tempSelectedIds.push(nodeId);
        }
        // 移除半选状态
        if (currentIndeterminateIndex > -1) {
          tempIndeterminateIds.splice(currentIndeterminateIndex, 1);
        }
      } else if (selectedCount > 0 || indeterminateCount > 0) {
        // 部分子节点选中或有半选状态 - 当前节点应该半选
        if (currentSelectedIndex > -1) {
          tempSelectedIds.splice(currentSelectedIndex, 1);
        }
        if (currentIndeterminateIndex === -1) {
          tempIndeterminateIds.push(nodeId);
        }
      } else {
        // 没有子节点选中 - 当前节点应该取消选中
        if (currentSelectedIndex > -1) {
          tempSelectedIds.splice(currentSelectedIndex, 1);
        }
        if (currentIndeterminateIndex > -1) {
          tempIndeterminateIds.splice(currentIndeterminateIndex, 1);
        }
      }
    },
    // 处理节点点击
    handleNodeClick(node) {
      if (node[this.props.disabled]) return;
      this.$emit('node-click', node);
      if (this.expandOnClickNode && node[this.props.children] && node[this.props.children].length > 0) {
        const nodeId = this.getNodeId(node);
        const isExpanded = this.expandedKeys.includes(nodeId);
        if (isExpanded) {
          const index = this.expandedKeys.indexOf(nodeId);
          if (index > -1) {
            this.expandedKeys.splice(index, 1);
          }
          this.$emit('node-collapse', node);
        } else {
          if (!this.expandedKeys.includes(nodeId)) {
            this.expandedKeys.push(nodeId);
          }
          this.$emit('node-expand', node);
        }
      }
    },
    // 发射变更事件
    emitChangeEvents(node, checked) {
      this.$emit('update:selectedKeys', this.selectedIds);
      const nodes = this.getSelectedNodes().filter((node) => !node[this.props.disabled]);
      const keys = nodes.map((node) => node[this.props.id]);
      this.$emit('check-change', checked, node, keys, nodes);
    },

    // 获取所有选中的节点
    getSelectedNodes() {
      const selectedNodes = [];
      this.findNodesByIds(this.internalData, this.selectedIds, selectedNodes);
      return selectedNodes;
    },

    // 根据ID查找节点
    findNodesByIds(nodes, ids, result = []) {
      nodes.forEach((node) => {
        const nodeId = this.getNodeId(node);
        if (ids.includes(nodeId)) {
          result.push(node);
        }
        const children = node[this.props.children];
        if (children && children.length > 0) {
          this.findNodesByIds(children, ids, result);
        }
      });
      return result;
    },

    // 获取所有半选的节点
    getIndeterminateNodes() {
      const indeterminateNodes = [];
      this.findNodesByIds(this.formattedData, this.indeterminateIds, indeterminateNodes);
      return indeterminateNodes;
    },

    // 获取所有选中的节点ID
    getCheckedKeys() {
      return [...this.selectedIds];
    },

    // 获取所有半选的节点ID
    getHalfCheckedKeys() {
      return [...this.indeterminateIds];
    },

    // 设置选中的节点
    setCheckedKeys(keys) {
      if (this.mode === 'multiple' && !this.checkStrictly) {
        // 避免没必要的计算
        if (JSON.stringify(this.selectedIds) === JSON.stringify(keys)) return;
        const { selectedIds, indeterminateIds } = this.calculateCheckedState(keys);
        this.selectedIds = selectedIds;
        this.indeterminateIds = indeterminateIds;
        if (JSON.stringify(this.selectedIds) !== JSON.stringify(this.selectedKeys)) {
          // 如果处理后选中节点数据和外部传入数据不一致则触发变更事件
          this.$emit('update:selectedKeys', this.selectedIds);
        }
      } else {
        this.selectedIds = [...keys];
        this.indeterminateIds = [];
      }

      console.log('newTree-setCheckedKeys final selectedIds:', this.selectedIds);
      console.log('newTree-setCheckedKeys final indeterminateIds:', this.indeterminateIds);

      this.$nextTick(() => {
        this.$forceUpdate();
      });
      this.$emit('change', this.selectedIds, this.getSelectedNodes());
    },

    // 设置节点展开状态
    setExpandedKeys(keys) {
      this.expandedKeys = [...keys];
    },

    // 展开所有节点
    expandAll() {
      const allNodeIds = this.getAllNodeIds(this.internalData);
      this.expandedKeys = [...new Set(allNodeIds)];
    },

    // 折叠所有节点
    collapseAll() {
      this.expandedKeys = [];
    },

    // 深度克隆
    deepClone(obj) {
      if (obj === null || typeof obj !== 'object') return obj;
      if (obj instanceof Date) return new Date(obj);
      if (obj instanceof Array) return obj.map((item) => this.deepClone(item));
      if (obj instanceof Object) {
        const clonedObj = {};
        Object.keys(obj).forEach((key) => {
          clonedObj[key] = this.deepClone(obj[key]);
        });
        return clonedObj;
      }
    },
  },
};
</script>

<style scoped>
/* 样式保持不变 */
.tree-container {
  width: 100%;
  box-sizing: border-box;
  min-width: 300px;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  font-size: 14px;
  line-height: 1.5;
  color: #606266;
}

.tree-filter {
  max-height: 50px;
  position: relative;
  margin-bottom: 12px;
}
.tree-board {
  height: calc(100% - 45px);
}
.tree-filter__input {
  width: 100%;
  padding: 8px 32px 8px 8px;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  font-size: 14px;
  transition: border-color 0.3s;
  box-sizing: border-box;
}

.tree-filter__input:focus {
  outline: none;
  border-color: #409eff;
}

.tree-filter__clear {
  position: absolute;
  right: 8px;
  top: 50%;
  transform: translateY(-50%);
  cursor: pointer;
  color: #c0c4cc;
  font-size: 16px;
  width: 16px;
  height: 16px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.tree-filter__clear:hover {
  color: #909399;
}

.tree-empty {
  padding: 20px;
  text-align: center;
  color: #909399;
  font-size: 14px;
}

.tree-container {
  height: 100%;
  padding: 12px;
}
.tree-board {
  height: calc(100% - 50px);
}
.tree-board::-webkit-scrollbar {
  width: 6px;
}

.tree-board::-webkit-scrollbar-track {
  background: #f1f1f1;
  border-radius: 3px;
}

.tree-board::-webkit-scrollbar-thumb {
  background: #c1c1c1;
  border-radius: 3px;
}

.tree-board::-webkit-scrollbar-thumb:hover {
  background: #a8a8a8;
}

@media (max-width: 768px) {
  .tree-container {
    height: 100%;
    padding: 8px;
    border: none;
    border-radius: 0;
  }

  .tree-filter__input {
    padding: 8px 32px 8px 8px;
    font-size: 16px;
  }

  .tree-filter__clear {
    right: 12px;
    width: 20px;
    height: 20px;
    font-size: 18px;
  }

  .tree-filter-result {
    padding: 12px;
    font-size: 14px;
  }
}

@media (hover: none) and (pointer: coarse) {
  .tree-container {
    -webkit-overflow-scrolling: touch;
  }
}
</style>

3. newTreeNode.vue - 树节点组件

  • 单个节点的渲染逻辑
  • 用户交互处理(点击、选择)
  • 递归渲染子节点
  • 筛选高亮显示
<script>
export default {
  name: 'TreeNode',
  props: {
    node: {
      type: Object,
      required: true,
    },
    level: {
      type: Number,
      default: 0,
    },
    mode: {
      type: String,
      default: 'single',
    },
    showCheckbox: {
      type: Boolean,
      default: true,
    },
    selectedIds: {
      type: Array,
      default: () => [],
    },
    indeterminateIds: {
      type: Array,
      default: () => [],
    },
    props: {
      type: Object,
      default: () => ({
        children: 'children',
        label: 'label',
        id: 'id',
        disabled: 'disabled',
      }),
    },
    expandOnClickNode: {
      type: Boolean,
      default: false,
    },
    expandedKeys: {
      type: Array,
      default: () => [],
    },
    filterText: {
      type: String,
      default: '',
    },
    nodeContentSlot: {
      type: Function,
      default: null,
    },
  },
  computed: {
    isExpanded() {
      const idKey = this.props.id;
      return this.expandedKeys.includes(this.node[idKey]);
    },
    hasChildren() {
      const childrenKey = this.props.children;
      return this.node[childrenKey] && this.node[childrenKey].length > 0;
    },
    isSelected() {
      const idKey = this.props.id;
      return this.selectedIds.includes(this.node[idKey]);
    },
    isIndeterminate() {
      const idKey = this.props.id;
      return this.indeterminateIds.includes(this.node[idKey]);
    },
    nodeText() {
      const labelKey = this.props.label;
      return this.node[labelKey];
    },
    nodeId() {
      const idKey = this.props.id;
      return this.node[idKey];
    },
    // 高亮显示筛选文本
    highlightedText() {
      if (!this.filterText || !this.nodeText) {
        return this.nodeText;
      }

      const text = this.nodeText;
      const filterText = this.filterText.toLowerCase();
      const index = text.toLowerCase().indexOf(filterText);

      if (index === -1) {
        return text;
      }

      const before = text.substring(0, index);
      const match = text.substring(index, index + filterText.length);
      const after = text.substring(index + filterText.length);

      return {
        before,
        match,
        after,
      };
    },
  },
  watch: {},
  methods: {
    toggleExpand() {
      if (this.hasChildren) {
        const bool = !this.isExpanded;
        this.$emit('toggle', this.node, bool);
      }
    },

    handleSelect() {
      if (this.node[this.props.disabled]) return;
      this.$emit('select', this.node);
    },

    handleNodeClick() {
      this.$emit('node-click', this.node);

      if (this.expandOnClickNode && this.hasChildren) {
        this.toggleExpand();
      }
    },
  },
  render(h) {
    const { node, level, mode, showCheckbox, hasChildren, isExpanded, isSelected, isIndeterminate, nodeText, nodeId, highlightedText, filterText } =
      this;

    const renderExpandIcon = () => {
      if (!hasChildren) {
        return h('span', {
          class: ['tree-node__expand-icon', 'is-leaf'],
        });
      }
      return h(
        'span',
        {
          class: ['tree-node__expand-icon', { expanded: isExpanded && hasChildren }],
          on: {
            click: (e) => {
              e.stopPropagation();
              this.toggleExpand();
            },
          },
        },
        [
          // 添加 SVG 图标
          h(
            'svg',
            {
              attrs: {
                viewBox: '0 0 16 16',
                width: '16',
                height: '16',
              },
              style: {
                transform: isExpanded && hasChildren ? 'rotate(90deg)' : 'rotate(0)',
                transition: 'transform 0.3s',
              },
            },
            [
              h('path', {
                attrs: {
                  d: 'M6 4L10 8L6 12',
                  stroke: '#c0c4cc',
                  'stroke-width': '1.5',
                  fill: 'none',
                },
              }),
            ]
          ),
        ]
      );
    };

    // 渲染选择器
    const renderSelector = () => {
      if (!showCheckbox) return null;

      const checkboxClass = {
        'tree-node__checkbox': true,
        indeterminate: isIndeterminate && !isSelected,
      };

      if (mode === 'multiple') {
        return h(
          'span',
          {
            class: checkboxClass,
          },
          [
            h('input', {
              attrs: {
                type: 'checkbox',
                id: `checkbox-${nodeId}`,
                disabled: node[this.props.disabled],
              },
              domProps: {
                checked: isSelected,
                indeterminate: isIndeterminate && !isSelected,
              },
              on: {
                change: (e) => {
                  e.stopPropagation();
                  this.handleSelect();
                },
                click: (e) => {
                  e.stopPropagation();
                },
              },
            }),
          ]
        );
      } else {
        return h(
          'span',
          {
            class: 'tree-node__checkbox',
          },
          [
            h('input', {
              attrs: {
                type: 'radio',
                id: `radio-${nodeId}`,
                disabled: node[this.props.disabled],
              },
              domProps: {
                checked: isSelected,
              },
              on: {
                change: (e) => {
                  e.stopPropagation();
                  this.handleSelect();
                },
                click: (e) => {
                  e.stopPropagation();
                },
              },
            }),
          ]
        );
      }
    };

    // 渲染节点标签(支持高亮)
    const renderLabel = () => {
      if (this.nodeContentSlot && typeof this.nodeContentSlot === 'function') {
        return h(
          'div',
          {
            class: 'slot-node__content',
          },
          [this.nodeContentSlot(this.node)]
        );
      }
      // 动态类名:单选模式下选中时添加 is-active 类
      const labelClass = {
        'tree-node__label': true,
        'is-active': mode === 'single' && isSelected,
      };
      if (typeof highlightedText === 'object') {
        return h(
          'span',
          {
            class: 'tree-node__label',
          },
          [
            highlightedText.before,
            h(
              'span',
              {
                class: 'tree-node__highlight',
              },
              highlightedText.match
            ),
            highlightedText.after,
          ]
        );
      } else {
        return h(
          'span',
          {
            class: labelClass, // 使用动态类名
          },
          nodeText
        );
      }
    };

    // 渲染节点内容
    const renderNodeContent = () => {
      const contentClass = {
        'tree-node__content': true,
        'is-selected': mode === 'single' && isSelected, // 添加选中状态类
      };
      return h(
        'div',
        {
          class: contentClass,
          style: {
            paddingLeft: `${level * 12 + 4}px`,
            cursor: node[this.props.disabled] ? 'not-allowed' : 'pointer',
          },
          on: {
            click: (e) => {
              e.stopPropagation();
              this.handleNodeClick();
              if (mode === 'single') {
                this.handleSelect();
              }
            },
          },
        },
        [renderExpandIcon(), renderSelector(), renderLabel()]
      );
    };

    // 渲染子节点
    const renderChildren = () => {
      if (!hasChildren || !isExpanded) return null;
      const childrenKey = this.props.children;
      return h(
        'div',
        {
          class: 'tree-node__children',
        },
        node[childrenKey].map((child) =>
          h('TreeNode', {
            props: {
              node: child,
              level: level + 1,
              mode: mode,
              showCheckbox: showCheckbox,
              selectedIds: this.selectedIds,
              indeterminateIds: this.indeterminateIds,
              props: this.props,
              expandOnClickNode: this.expandOnClickNode,
              expandedKeys: this.expandedKeys,
              filterText: this.filterText,
              nodeContentSlot: this.nodeContentSlot, // 直接传递插槽函数
            },
            on: {
              toggle: (node, expanded) => this.$emit('toggle', node, expanded),
              select: (node) => this.$emit('select', node),
              'node-click': (node) => this.$emit('node-click', node),
            },
          })
        )
      );
    };

    return h(
      'div',
      {
        class: 'tree-node',
      },
      [renderNodeContent(), renderChildren()]
    );
  },
};
</script>

<style scoped>
.tree-node {
  white-space: nowrap;
  outline: none;
  margin: 0;
  padding: 0;
  transform: translateZ(0);
  contain: layout style;
}

.tree-node__content {
  display: flex;
  align-items: center;
  height: 26px;
  cursor: pointer;
  transition: background-color 0.3s;
  border-radius: 4px;
  will-change: transform;
  /* 移动端优化:增加触摸区域 */
  min-height: 44px;
  padding: 8px 0;
}

.tree-node__content:hover {
  background-color: #f5f7fa;
}

.tree-node__checkbox {
  position: relative;
  margin-right: 8px;
  /* 移动端优化:增大触摸目标 */
  min-width: 18px;
  min-height: 18px;
}

.tree-node__checkbox input {
  margin: 0;
  cursor: pointer;
  /* 移动端优化:增大触摸目标 */
  width: 18px;
  height: 18px;
}

/* 半选状态指示器 */
.indeterminate-indicator {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 8px;
  height: 2px;
  background: #409eff;
  transform: translate(-50%, -50%);
  pointer-events: none;
}

.tree-node__expand-icon {
  display: inline-flex;
  justify-content: center;
  align-items: center;
  width: 16px;
  height: 16px;
  margin-right: 6px;
  cursor: pointer;
  user-select: none;
  /* 移动端优化:增大触摸目标 */
  min-width: 24px;
  min-height: 24px;
}


.tree-node__expand-icon.is-leaf {
  cursor: default;
  opacity: 0; /* 隐藏无子节点的图标 */
}
.slot-node__content {
  width: 100%;
  height: 100%;
}
.tree-node__label {
  font-size: 14px;
  padding: 0 4px;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  /* 允许文本换行 */
  white-space: normal;
  word-break: break-word;
  flex: 1;
}
.tree-node__label.is-active {
  color: #409eff;
  font-weight: 500;
}

.tree-node__highlight {
  background-color: #fffe8c;
  color: #333;
  padding: 0 1px;
  border-radius: 2px;
}

.tree-node__children {
  overflow: hidden;
}

/* 禁用状态优化 */
.tree-node__checkbox input:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* 移动端适配 */
@media (max-width: 768px) {
  .tree-node__content {
    height: auto;
    min-height: 44px; /* iOS推荐的最小触摸目标尺寸 */
    padding: 12px 0;
  }

  .tree-node__expand-icon {
    width: 20px;
    height: 20px;
    min-width: 28px;
    min-height: 28px;
  }

  .tree-node__expand-icon:not(.is-leaf):before {
    font-size: 16px;
  }

  .tree-node__checkbox {
    min-width: 22px;
    min-height: 22px;
  }

  .tree-node__checkbox input {
    width: 20px;
    height: 20px;
  }

  .tree-node__label {
    font-size: 16px; /* 移动端更适合阅读的字体大小 */
    line-height: 1.4;
  }
  .tree-node__label.is-active {
    color: #66b1ff;
  }

  .tree-node__children {
    margin-left: 8px; /* 移动端缩进调整 */
  }
}

/* 触摸设备交互优化 */
@media (hover: none) and (pointer: coarse) {
  .tree-node__content {
    transition: background-color 0.1s; /* 更快的反馈 */
  }

  .tree-node__content:active {
    background-color: #e6f3ff; /* 触摸反馈颜色 */
  }

  .tree-node__expand-icon:active,
  .tree-node__checkbox:active {
    opacity: 0.7; /* 触摸反馈 */
  }
}

/* 深色模式支持 */
@media (prefers-color-scheme: dark) {
  .tree-node__content:hover {
    background-color: #2c2c2c;
  }

  .tree-node__highlight {
    background-color: #ffeb3b;
    color: #000;
  }
}
</style>

总结

这个Vue2树形组件是一个功能完整的前端组件,它具有以下突出优点:

  1. 功能丰富:支持单选、多选、筛选、展开折叠等多种交互模式
  2. 数据结构灵活:同时支持嵌套和扁平两种数据结构
  3. API完善:提供了丰富的配置选项和方法调用
  4. 用户体验优秀:响应式设计,移动端友好
  5. 易于集成:简单的API设计,快速上手

无论你是要构建简单的文件目录,还是复杂的企业组织架构,这个树形组件都能提供出色的解决方案。 希望这个组件能够帮助你在项目中快速实现树形结构需求,提升开发效率!