核心功能说明
树形结构展示
- - 使用 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>
1318

被折叠的 条评论
为什么被折叠?



