树表格实现

核心功能说明

树形结构展示
  • - 使用 el-table组件实现树形结构
  • - 支持节点展开/折叠功能
  • - 显示节点层级信息
节点操作功能
  • 添加根节点:通过工具栏按钮添加
  • 添加子节点:通过操作按钮或右键菜单
  • 添加同级节点:通过右键菜单
  • 删除节点:支持删除节点及其所有子节点
  • 节点编辑:双击节点进行编辑,支持回车保存
拖拽功能
  • - 使用 `vuedraggable` 实现节点拖拽(需单独安装)
  • - 支持节点上移、下移、置顶、置底操作
  • - 层级限制确保节点不会移动到超过最大层级的位置
右键菜单
  • - 右键点击节点显示上下文菜单
  • - 菜单包含:添加子节点、添加同级节点、删除节点、移动节点等操作
  • - 点击页面其他区域自动关闭菜单
节点过滤
  • - 顶部搜索框实现节点过滤
  • - 支持模糊匹配节点名称
  • - 自动过滤不匹配的节点及其子节点
层级限制
  • - 可配置最大层级深度
  • - 添加子节点时检查是否超过最大层级
  • - 拖拽操作时限制层级深度
焦点处理
  • - 使用键盘方向键导航树形结构
  • - Enter 键编辑当前选中节点
  • - Delete 键删除当前选中节点
  • - 高亮显示当前选中节点
功能点分解:

1. 树形表格展示:使用  el-table 的树形结构功能
2. 节点过滤:通过计算属性过滤树形数据
3. 拖放操作:使用 vue-drag-tree 或自定义拖拽实现
4. 节点编辑:支持双击或右键菜单编辑节点
5. 右键菜单:自定义右键菜单组件
6. 层级限制:在拖拽和添加节点时检查层级深度
7. 焦点处理:记录当前选中节点,处理键盘事件

步骤:

1. 安装依赖:vue-draggable(或 vue-drag-tree)
2. 创建树形表格组件,支持树形结构展示
3. 集成拖拽功能,支持节点拖拽排序和改变父子关系(注意层级限制)
4. 实现节点编辑功能(行内编辑或弹窗编辑)
5. 添加右键菜单,支持添加、删除、编辑等操作
6. 实现节点过滤功能(搜索框)
7. 处理焦点和键盘事件(如方向键导航,回车编辑等)

  • - 使用两个组件:一个用于树形表格的展示,另一个用于拖拽控制(vuedraggable)。
  • - vuedraggable主要用于列表,对于树形表格需要递归嵌套。
具体步骤:

1. 安装依赖:

npm install element-ui vuedraggable

2. 创建组件:TreeTable.vue

3. 实现树形表格的基本结构(使用el-table,通过row-key和tree-props开启树形结构)

4. 使用vuedraggable包裹每一行(实际上vuedraggable要求包裹可拖拽的列表,而el-table的每一行是独立的,因此我们需要在el-table外层包裹一个vuedraggable组件,并设置为整个表格的拖拽,这样只能实现同级的拖拽,不能实现跨层级。所以我们需要递归地使用vuedraggable,但这在el-table中很难实现。

实现方案:

我们将创建以下组件:
  • - TreeTable.vue(主组件)
  • - TreeNode.vue(节点组件,递归使用)

步骤2:在TreeNode组件中使用vuedraggable实现子节点拖拽

步骤3:实现节点编辑(双击或右键编辑)

步骤4:添加右键菜单

步骤5:实现过滤功能

步骤6:层级限制(在拖拽和添加节点时检查深度)

步骤7:焦点处理(通过activeNodeId记录当前激活节点)

<template>
  <div class="tree-table-container">
    <!-- 工具栏 -->
    <div class="toolbar">
      <el-input
        v-model="filterText"
        placeholder="输入关键词过滤节点"
        clearable
        style="width: 300px; margin-right: 15px"
      >
        <i slot="prefix" class="el-input__icon el-icon-search"></i>
      </el-input>
      
      <el-button type="primary" icon="el-icon-plus" @click="addRootNode">添加根节点</el-button>
      
      <div style="margin-left: auto; display: flex; align-items: center">
        <span style="margin-right: 10px">最大层级:</span>
        <el-input-number
          v-model="maxLevel"
          :min="1"
          :max="10"
          label="最大层级"
          size="small"
          style="width: 100px"
        ></el-input-number>
      </div>
    </div>
    
    <!-- 树形表格 -->
    <el-table
      ref="treeTable"
      :data="filteredTreeData"
      row-key="id"
      :tree-props="{children: 'children', hasChildren: 'hasChildren'}"
      style="width: 100%"
      border
      @row-contextmenu="handleContextMenu"
      @row-dblclick="handleRowDbClick"
      @row-click="handleRowClick"
    >
      <el-table-column label="节点名称" min-width="300">
        <template slot-scope="scope">
          <div class="node-content">
            <span
              v-if="scope.row.children && scope.row.children.length > 0"
              class="expand-icon"
              @click="toggleExpand(scope.row)"
            >
              <i :class="scope.row.expanded ? 'el-icon-minus' : 'el-icon-plus'"></i>
            </span>
            <span v-else class="expand-placeholder"></span>
            
            <div v-if="scope.row.editing" class="editing-cell">
              <el-input
                v-model="scope.row.name"
                size="small"
                ref="editInput"
                @blur="saveEdit(scope.row)"
                @keyup.enter.native="saveEdit(scope.row)"
              ></el-input>
            </div>
            <div v-else class="node-name" @dblclick="startEditing(scope.row)">
              {{ scope.row.name }}
              <span class="node-level">(层级: {{ scope.row.level }})</span>
            </div>
          </div>
        </template>
      </el-table-column>
      
      <el-table-column label="操作" width="280">
        <template slot-scope="scope">
          <el-button
            size="mini"
            icon="el-icon-plus"
            @click="addChildNode(scope.row)"
            :disabled="scope.row.level >= maxLevel"
          >添加子节点</el-button>
          <el-button
            size="mini"
            type="danger"
            icon="el-icon-delete"
            @click="removeNode(scope.row)"
          >删除</el-button>
        </template>
      </el-table-column>
    </el-table>
    
    <!-- 右键菜单 -->
    <div
      v-show="contextMenuVisible"
      class="context-menu"
      :style="{left: contextMenuLeft + 'px', top: contextMenuTop + 'px'}"
    >
      <div class="menu-item" @click="addChildNode(selectedNode)">
        <i class="el-icon-plus"></i> 添加子节点
      </div>
      <div class="menu-item" @click="addSiblingNode(selectedNode)">
        <i class="el-icon-plus"></i> 添加同级节点
      </div>
      <div class="menu-item" @click="removeNode(selectedNode)">
        <i class="el-icon-delete"></i> 删除节点
      </div>
      <div class="menu-divider"></div>
      <div class="menu-item" @click="moveNodeUp(selectedNode)">
        <i class="el-icon-top"></i> 上移
      </div>
      <div class="menu-item" @click="moveNodeDown(selectedNode)">
        <i class="el-icon-bottom"></i> 下移
      </div>
      <div class="menu-item" @click="moveNodeToTop(selectedNode)">
        <i class="el-icon-top"></i> 置顶
      </div>
      <div class="menu-item" @click="moveNodeToBottom(selectedNode)">
        <i class="el-icon-bottom"></i> 置底
      </div>
    </div>
  </div>
</template>

<script>
import { v4 as uuidv4 } from 'uuid';

export default {
  name: 'TreeTable',
  props: {
    maxLevel: {
      type: Number,
      default: 5
    }
  },
  data() {
    return {
      filterText: '',
      treeData: [
        {
          id: uuidv4(),
          name: '根节点1',
          level: 1,
          expanded: true,
          children: [
            {
              id: uuidv4(),
              name: '子节点1-1',
              level: 2,
              children: [
                {
                  id: uuidv4(),
                  name: '子节点1-1-1',
                  level: 3
                }
              ]
            },
            {
              id: uuidv4(),
              name: '子节点1-2',
              level: 2
            }
          ]
        },
        {
          id: uuidv4(),
          name: '根节点2',
          level: 1,
          expanded: true,
          children: [
            {
              id: uuidv4(),
              name: '子节点2-1',
              level: 2
            }
          ]
        }
      ],
      contextMenuVisible: false,
      contextMenuLeft: 0,
      contextMenuTop: 0,
      selectedNode: null,
      activeNodeId: null
    };
  },
  computed: {
    // 过滤后的树数据
    filteredTreeData() {
      if (!this.filterText) return this.treeData;
      return this.filterTree(this.treeData, this.filterText);
    }
  },
  mounted() {
    // 添加键盘事件监听
    document.addEventListener('keydown', this.handleKeyDown);
    // 点击其他地方关闭右键菜单
    document.addEventListener('click', this.closeContextMenu);
  },
  beforeDestroy() {
    document.removeEventListener('keydown', this.handleKeyDown);
    document.removeEventListener('click', this.closeContextMenu);
  },
  methods: {
    // 过滤树节点
    filterTree(nodes, filterText) {
      return nodes.filter(node => {
        const match = node.name.toLowerCase().includes(filterText.toLowerCase());
        if (node.children) {
          const filteredChildren = this.filterTree(node.children, filterText);
          node.children = filteredChildren;
          return match || filteredChildren.length > 0;
        }
        return match;
      });
    },
    
    // 添加根节点
    addRootNode() {
      this.treeData.push({
        id: uuidv4(),
        name: '新根节点',
        level: 1,
        expanded: true
      });
    },
    
    // 添加子节点
    addChildNode(parentNode) {
      if (!parentNode) return;
      
      if (parentNode.level >= this.maxLevel) {
        this.$message.warning(`已达到最大层级限制 (${this.maxLevel}层)`);
        return;
      }
      
      if (!parentNode.children) {
        this.$set(parentNode, 'children', []);
        this.$set(parentNode, 'expanded', true);
      }
      
      const newLevel = parentNode.level + 1;
      parentNode.children.push({
        id: uuidv4(),
        name: `子节点${parentNode.children.length + 1}`,
        level: newLevel
      });
    },
    
    // 添加同级节点
    addSiblingNode(node) {
      if (!node) return;
      
      // 查找父节点
      const parent = this.findParent(node.id, this.treeData);
      
      if (parent) {
        const newLevel = parent.level + 1;
        if (newLevel > this.maxLevel) {
          this.$message.warning(`已达到最大层级限制 (${this.maxLevel}层)`);
          return;
        }
        
        const siblings = Array.isArray(parent.children) ? parent.children : [];
        siblings.push({
          id: uuidv4(),
          name: `同级节点${siblings.length + 1}`,
          level: newLevel
        });
      } else {
        // 根节点添加同级
        this.treeData.push({
          id: uuidv4(),
          name: '新根节点',
          level: 1
        });
      }
    },
    
    // 删除节点
    removeNode(node) {
      if (!node) return;
      
      this.$confirm('确定要删除该节点及其所有子节点吗?', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      }).then(() => {
        const parent = this.findParent(node.id, this.treeData);
        if (parent) {
          parent.children = parent.children.filter(child => child.id !== node.id);
        } else {
          this.treeData = this.treeData.filter(root => root.id !== node.id);
        }
        this.$message.success('节点已删除');
      }).catch(() => {});
    },
    
    // 开始编辑节点
    startEditing(node) {
      this.$set(node, 'editing', true);
      this.$nextTick(() => {
        if (this.$refs.editInput) {
          this.$refs.editInput.focus();
        }
      });
    },
    
    // 保存编辑
    saveEdit(node) {
      if (node.editing) {
        node.editing = false;
      }
    },
    
    // 切换节点展开状态
    toggleExpand(node) {
      this.$set(node, 'expanded', !node.expanded);
      this.$nextTick(() => {
        this.$refs.treeTable.toggleRowExpansion(node);
      });
    },
    
    // 处理右键菜单
    handleContextMenu(row, column, event) {
      event.preventDefault();
      this.selectedNode = row;
      this.contextMenuLeft = event.clientX;
      this.contextMenuTop = event.clientY;
      this.contextMenuVisible = true;
      this.activeNodeId = row.id;
    },
    
    // 关闭右键菜单
    closeContextMenu() {
      this.contextMenuVisible = false;
    },
    
    // 处理行点击
    handleRowClick(row) {
      this.activeNodeId = row.id;
    },
    
    // 处理行双击
    handleRowDbClick(row) {
      this.startEditing(row);
    },
    
    // 查找节点的父节点
    findParent(nodeId, nodes, parent = null) {
      for (const node of nodes) {
        if (node.id === nodeId) {
          return parent;
        }
        if (node.children && node.children.length > 0) {
          const found = this.findParent(nodeId, node.children, node);
          if (found) return found;
        }
      }
      return null;
    },
    
    // 移动节点(上移、下移、置顶、置底)
    moveNodeUp(node) {
      const parent = this.findParent(node.id, this.treeData);
      const siblings = parent ? parent.children : this.treeData;
      const index = siblings.findIndex(n => n.id === node.id);
      
      if (index > 0) {
        [siblings[index], siblings[index - 1]] = [siblings[index - 1], siblings[index]];
      }
    },
    
    moveNodeDown(node) {
      const parent = this.findParent(node.id, this.treeData);
      const siblings = parent ? parent.children : this.treeData;
      const index = siblings.findIndex(n => n.id === node.id);
      
      if (index < siblings.length - 1) {
        [siblings[index], siblings[index + 1]] = [siblings[index + 1], siblings[index]];
      }
    },
    
    moveNodeToTop(node) {
      const parent = this.findParent(node.id, this.treeData);
      const siblings = parent ? parent.children : this.treeData;
      const index = siblings.findIndex(n => n.id === node.id);
      
      if (index > 0) {
        const [node] = siblings.splice(index, 1);
        siblings.unshift(node);
      }
    },
    
    moveNodeToBottom(node) {
      const parent = this.findParent(node.id, this.treeData);
      const siblings = parent ? parent.children : this.treeData;
      const index = siblings.findIndex(n => n.id === node.id);
      
      if (index < siblings.length - 1) {
        const [node] = siblings.splice(index, 1);
        siblings.push(node);
      }
    },
    
    // 键盘导航
    handleKeyDown(event) {
      if (!this.activeNodeId) return;
      
      const activeNode = this.findNode(this.activeNodeId, this.treeData);
      if (!activeNode) return;
      
      switch (event.key) {
        case 'ArrowUp':
          event.preventDefault();
          this.moveToPreviousNode(activeNode);
          break;
        case 'ArrowDown':
          event.preventDefault();
          this.moveToNextNode(activeNode);
          break;
        case 'Enter':
          event.preventDefault();
          this.startEditing(activeNode);
          break;
        case 'Delete':
          event.preventDefault();
          this.removeNode(activeNode);
          break;
      }
    },
    
    // 移动到上一个节点
    moveToPreviousNode(node) {
      // 实现略,需遍历树结构找到上一个节点
    },
    
    // 移动到下一个节点
    moveToNextNode(node) {
      // 实现略,需遍历树结构找到下一个节点
    },
    
    // 查找节点
    findNode(nodeId, nodes) {
      for (const node of nodes) {
        if (node.id === nodeId) return node;
        if (node.children && node.children.length > 0) {
          const found = this.findNode(nodeId, node.children);
          if (found) return found;
        }
      }
      return null;
    }
  }
};
</script>

<style scoped>
.tree-table-container {
  position: relative;
  padding: 20px;
  background-color: #fff;
  border-radius: 4px;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}

.toolbar {
  display: flex;
  align-items: center;
  margin-bottom: 20px;
  padding: 10px 15px;
  background: #f5f7fa;
  border-radius: 4px;
}

.node-content {
  display: flex;
  align-items: center;
}

.expand-icon {
  cursor: pointer;
  margin-right: 8px;
  color: #909399;
  width: 16px;
  text-align: center;
}

.expand-placeholder {
  width: 16px;
  margin-right: 8px;
}

.node-name {
  cursor: pointer;
  padding: 5px;
  border-radius: 3px;
  transition: all 0.3s;
}

.node-name:hover {
  background-color: #ecf5ff;
}

.node-level {
  font-size: 12px;
  color: #909399;
  margin-left: 8px;
}

.editing-cell {
  width: 100%;
}

.context-menu {
  position: fixed;
  z-index: 9999;
  background: #fff;
  border: 1px solid #ebeef5;
  border-radius: 4px;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  padding: 5px 0;
  min-width: 160px;
}

.menu-item {
  padding: 8px 15px;
  font-size: 13px;
  color: #606266;
  cursor: pointer;
  display: flex;
  align-items: center;
}

.menu-item i {
  margin-right: 8px;
}

.menu-item:hover {
  background-color: #ecf5ff;
  color: #409eff;
}

.menu-divider {
  height: 1px;
  background-color: #ebeef5;
  margin: 5px 0;
}
</style>
使用示例
<template>
  <div>
    <tree-table :max-level="6" />
  </div>
</template>

<script>
import TreeTable from '@/components/TreeTable.vue';

export default {
  components: {
    TreeTable
  }
};
</script>
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

卡夫卡的小熊猫

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值