特性
- 可以自定义主键、配置选项
- 支持预定义节点图标:folder文件夹|normal普通样式
- 多个提示文本可以自定义
- 支持动态接口增删改节点
- 可以自定义根节点id
- 可以设置最多允许添加的层级深度
- 支持拖拽排序,排序过程还可以针对拖拽的节点深度进行自定义限制
- 支持隐藏一级节点(根节点)复选框☑
- 支持屏蔽一级节点(根节点)勾选☑
- 支持跨节点层级拖拽排序
sgTree源码
<template> <div :class="$options.name" :styleType="styleType"> <div class="tree-header" v-if="!readonly_"> <div class="sg-left"> <template v-if="uploadData"> <el-tooltip popper-class="sg-el-tooltip" :enterable="false" effect="dark" :content="`支持拖拽到树上传文件`" placement="top-start" :transition="`none`" > <el-button type="text" icon="el-icon-upload" size="mini" @click="(d) => $refs.sgUpload.triggerUploadFile()" > 批量导入 </el-button> </el-tooltip> <el-button type="text" icon="el-icon-download" size="mini" @click="downloadTpl"> 下载模板 </el-button> </template> </div> <div class="sg-right"> <el-button type="text" size="mini" @click.stop="addRoot" >{{ (data.text || {}).addRootButtonText || `添加根节点` }}<i class="el-icon-circle-plus-outline"></i ></el-button> </div> </div> <div class="tree-container"> <el-tree :class=" hideRootNodeCheckbox === '' || hideRootNodeCheckbox ? 'hideRootNodeCheckbox' : '' " ref="tree" :data="treeData" :node-key="mainKey" :props=" data.props || { label: 'label', //指定节点标签为节点对象的某个属性值 children: 'children', //指定子树为节点对象的某个属性值 disabled: 'leaf', //指定节点选择框是否禁用为节点对象的某个属性值 isLeaf: 'leaf', //指定节点是否为叶子节点,仅在指定了 lazy 属性的情况下生效 } " :icon-class="`${data.iconType}-tree-node`" :indent="data.indent || 10" @current-change="current_change" @node-click="nodeClick" highlight-current @node-drag-start="nodeDragStart" @node-drag-enter="nodeDragEnter" @node-drag-leave="nodeDragLeave" @node-drag-over="nodeDragOver" @node-drag-end="nodeDragEnd" @node-drop="nodeDrop" :draggable="draggable === '' || draggable" :allow-drop="allowDrop" :allow-drag="allowDrag" :show-checkbox="showCheckbox" :check-strictly="checkStrictly" @check-change="handleCheckChange" @check="handleCheck" :default-expand-all="defaultExpandAll === '' || defaultExpandAll" :default-checked-keys="defaultCheckedKeys" :check-on-click-node="checkOnClickNode === '' || checkOnClickNode" > <el-popover popper-class="tree-el-popover" placement="right" trigger="hover" title="" content="" :transition="`none`" :disabled="readonly_" slot-scope="{ node, data }" > <span class="right"> <el-button title="添加" type="text" size="" icon="el-icon-circle-plus-outline" @click.stop="addNode(node, data)" v-if="showAddButton(node)" >添加</el-button > <el-button title="删除" type="text" size="" icon="el-icon-remove-outline" @click.stop="remove(node, data)" >删除</el-button > </span> <div slot="reference" class="node-label"> <img class="icon" v-if="data.customIconURL" :src="data.customIconURL" :style="data.customIconStyle" /> <div class="left" :title="node.label"> {{ node.label }} </div> </div> </el-popover> </el-tree> <!-- 上传组件 --> <sgUpload :disabledWhenShowSels="['.v-modal']" :drag="uploadData ? allowDragUpload : false" ref="sgUpload" :data="uploadData" @uploadSuccess="uploadSuccess" @uploadError="uploadError" @importError="importError" @showLoading="showLoading" @hideLoading="hideLoading" hideUploadTray /> </div> </div> </template> <script> import sgUpload from "@/vue/components/admin/sgUpload"; export default { name: "sgTree", components: { sgUpload, }, data() { return { // 动态树:增删改_________________________________________________________ rootNode: null, //根节点 rootResolve: null, //根节点 focusNodeId: null, //聚焦高亮新添加ID mainKey: "id", //默认主键 defaultRootId: "root", //默认根节点ID就是root maxAddLevel: null, // 最多允许添加的层级 allowDragUpload: true, //在拖拽节点过程中控制上传组件能否拖拽上传 // _________________________________________________________ readonly_: null, }; }, props: [ "styleType", //样式风格 "treeData", "data", "readonly", "draggable", //是否开启拖拽节点功能 "uploadData", /* 例子 uploadData: { accept: '.xls,.xlsx', actionUrl: `${this.$d.API_ROOT_URL}/core/resource/upload`, }, */ "allowNodeDrag", "allowNodeDrop", //是否允许拖拽的方法判断 "showCheckbox", //节点是否可被选择 "checkStrictly", //在显示复选框的情况下,是否严格的遵循父子不互相关联的做法,默认为 false "hideRootNodeCheckbox", //隐藏一级节点复选框☑ "disabledRootNode", //屏蔽一级节点勾选☑ "defaultExpandAll", //展开所有节点 "defaultCheckedKeys", //默认勾选的节点的 key 的数组 "checkOnClickNode", //是否在点击节点的时候选中节点,默认值为 false,即只有在点击复选框时才会选中节点。 ], watch: { readonly: { handler(newValue, oldValue) { this.readonly_ = newValue === "" || newValue; }, deep: true, //深度监听 immediate: true, //立即执行 }, data: { /* data.iconType= 节点图标: folder 文件夹 normal 普通样式 plus 加减符号样式 win11 win11系统箭头样式 */ handler(d) { d.nodeKey && (this.mainKey = d.nodeKey); //主键 d.rootId && (this.defaultRootId = d.rootId); //根节点ID d.maxAddLevel && (this.maxAddLevel = d.maxAddLevel); // 最多允许添加的层级 }, deep: true, immediate: true, }, }, methods: { showLoading(file) { this.$emit(`showLoading`, file); }, hideLoading(file) { this.$emit(`hideLoading`, file); }, // 取消选中 unCheckAll(d) { this.$refs.tree.setCheckedKeys([]); this.handleCheckChange([], []); }, handleCheckChange(data, checked, indeterminate) { this.$emit(`checkChange`, { checkedNodes: this.$refs.tree.getCheckedNodes(), checkedLeafOnlyNodes: this.$refs.tree.getCheckedNodes(true, false), //(leafOnly, includeHalfChecked) 接收两个 boolean 类型的参数,1. 是否只是叶子节点,默认值为 false 2. 是否包含半选节点,默认值为 false【注意:懒加载树形不管用!必须要明确叶子节点展开后面没有子节点了才能识别!】 data, checked, indeterminate, }); }, handleCheck(data, status) { /* 共两个参数,依次为: 1、传递给 data 属性的数组中该节点所对应的对象 2、树目前的选中状态对象,包含 checkedNodes、checkedKeys、halfCheckedNodes、halfCheckedKeys 四个属性 */ this.$emit(`check`, { data, status }); }, // 拖拽---------------------------------------- nodeDragStart(node, ev) { this.allowDragUpload = false; this.$emit(`nodeDragStart`, node, ev); }, nodeDragEnter(draggingNode, dropNode, ev) { this.$emit(`nodeDragEnter`, draggingNode, dropNode, ev); }, nodeDragLeave(draggingNode, dropNode, ev) { this.$emit(`nodeDragLeave`, draggingNode, dropNode, ev); }, nodeDragOver(draggingNode, dropNode, ev) { this.$emit(`nodeDragOver`, draggingNode, dropNode, ev); }, nodeDragEnd(draggingNode, dropNode, dropType, ev) { // dropType有'before'、'after'、'inner'和'none'4种情况 this.allowDragUpload = true; if (dropType === `none`) return; //自己拖拽到自己身上 this.$emit(`nodeDragEnd`, draggingNode, dropNode, dropType, ev); }, nodeDrop(draggingNode, dropNode, dropType, ev) { // dropType有'before'、'after'、'inner'和'none'4种情况 if (dropType === `none`) return; //自己拖拽到自己身上 this.$emit(`nodeDrop`, draggingNode, dropNode, dropType, ev); }, allowDrop(draggingNode, dropNode, dropType) { // 拖拽时判定目标节点能否被放置。dropType 参数有三种情况:'prev'、'inner' 和 'next',分别表示放置在目标节点前、插入至目标节点和放置在目标节点后(注意:很奇葩上面node开头的绑定方法dropType有before、after、inner和none4种情况) return this.allowNodeDrop ? this.allowNodeDrop(draggingNode, dropNode, dropType) : true; }, allowDrag(draggingNode) { return this.allowNodeDrag ? this.allowNodeDrag(draggingNode) : true; }, // ---------------------------------------- showAddButton(node) { if (this.maxAddLevel) { return node.level < this.maxAddLevel; // 最多允许添加的层级 } else return true; }, downloadTpl(d) { this.$emit(`downloadTpl`); }, uploadSuccess(d, f) { this.$emit(`uploadSuccess`, d, f); }, uploadError(d, f) { this.$emit(`uploadError`, d, f); }, importError(d, f) { this.$emit(`importError`, d, f); }, // 聚焦到某一个节点 focusNode(id, { triggerCurrentChange = true } = {}) { if (!id) return; this.$nextTick(() => { this.expandAllNodes(true); // 展开父节点&当前节点---------------------------------------- /* let currentNode = this.$refs.tree.getNode(id); let parentNode = (currentNode || {}).parent; parentNode && !parentNode.expanded && parentNode.expand(); //展开父节点(否者不展开会感觉怪怪的) currentNode && !currentNode.expanded && currentNode.expand(); //展开当前节点 */ // ---------------------------------------- this.$refs.tree.setCurrentKey(id); //高亮显示某个节点 triggerCurrentChange && this.$emit(`currentChange`, this.$refs.tree.getCurrentNode()); this.$nextTick(() => { setTimeout(() => { let dom = this.$refs.tree.$el.querySelector(`.el-tree-node.is-current`); dom && dom.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest", }); //缓慢滚动 }, 500); }); }); }, // 展开or折叠所有节点 expandAllNodes(expanded = true) { this.$refs.tree.store._getAllNodes().forEach((node) => { node.loaded && (node.expanded = expanded); //已经加载了的节点才展开 }); }, // 添加根节点 addRoot() { this.addNode(this.$refs.tree.root, { [this.mainKey]: this.defaultRootId }); }, //通过id勾选节点 setCheckedKeys(ids) { this.$refs.tree.setCheckedKeys(ids); }, // 通过id展开指定节点(通常是用于外部调用) expandNodeById(id) { let node = this.$refs.tree.getNode(id); node.expand(); }, // 添加节点 addNode(node, data) { let resolve = (d) => { if (data.ID === this.defaultRootId) { this.treeData.unshift(d); } else { data.children || this.$set(data, "children", []); data.children.push(d); } node.expand(); }; let reject = (d) => { // this.rootLoading = false; node.loading = false; this.$message.error(d.msg); //添加节点失败 }; this.$emit(`addNode`, { node, data, resolve, reject }); }, // 删除节点 remove(node, data) { this.$confirm( (this.data.text || {}).removeConfirmTip || `此操作将永久删除该节点及其下面的节点,是否继续?`, (this.data.text || {}).removeConfirmTitle || `提示`, { dangerouslyUseHTMLString: true, confirmButtonText: `确定`, cancelButtonText: `取消`, type: "warning", } ) .then(() => { this.removeNodeData(node, data); }) .catch(() => {}); }, // 删除节点数据(通过接口向后台删除数据) removeNodeData(node, data) { node.loading = true; //出现加载动画 let resolve = (d) => { node.loading = false; this.$message.success(`删除成功`); // 从父节点异步删除子节点 const parent = node.parent; const children = parent.data.children || parent.data; const index = children.findIndex((d) => d[this.mainKey] === data[this.mainKey]); children.splice(index, 1); // 从显示界面删除节点(有bug,只是删除了树节点的Virtual DOM,实际数据还在) /* let childNodes = node.parent.childNodes; childNodes.splice( childNodes.findIndex((d) => d.data[this.mainKey] === data[this.mainKey]), 1 ); */ }; let reject = (d) => { // this.rootLoading = false; node.loading = false; this.$message.error(d.msg); //删除失败 }; this.$emit(`removeNode`, { node, data, resolve, reject }); }, // 当前选中节点变化时触发的事件 current_change(d) { this.$emit(`currentChange`, d); }, //点击节点 nodeClick(d) { this.focusNodeId = null; this.$emit(`nodeClick`, d); }, }, }; </script> <style lang="scss" scoped> @import "~@/css/sg"; .sgTree { $treeHeaderHeight: 30px; width: 100%; height: 100%; display: flex; flex-wrap: nowrap; flex-direction: column; white-space: nowrap; flex-shrink: 0; flex-grow: 1; position: relative; .tree-header { display: flex; justify-content: space-between; align-items: center; height: $treeHeaderHeight; & > .sg-left { } & > .sg-right { } } .tree-container { position: relative; overflow: auto; box-sizing: border-box; height: calc(100% - #{$treeHeaderHeight}); flex-shrink: 0; flex-grow: 1; user-select: none; @include scrollbarHover(); /* >>> .tree-container .el-tree .el-tree-node__content { cursor: pointer; } */ >>> .el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content { background-color: #409eff22; // 高亮当前选中节点背景 } >>> .el-tree { * { transition: none; } .el-tree-node__children { min-width: max-content; //这样才会出现水平滚动条 } // 高亮当前即将拖拽放入的节点 .el-tree-node.is-drop-inner > .el-tree-node__content { color: white; background-color: #409eff; } .normal-tree-node, .plus-tree-node, .folder-tree-node, .win11-tree-node { & + label:not(.el-checkbox) { /*单行省略号*/ overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } flex-shrink: 0; display: block; padding: 0 !important; margin: 0; width: 20px; height: 20px; margin-right: 5px; background: transparent url("~@/../static/img/fileType/folder/folder.svg") no-repeat center / contain; margin-left: 20px; & ~ span:not(.el-icon-loading) { width: 100%; .node-label { height: 40px; display: flex; align-items: center; box-sizing: border-box; padding-right: 20px; margin-right: -20px; //让右侧更多空间成为el-popover img.icon { width: 20px; height: 20px; object-position: center; object-fit: contain; margin-left: -25px; margin-right: 5px; } } } &.expanded { flex-shrink: 0; transform: rotate(0deg); background-image: url("~@/../static/img/fileType/folder/folder-open.svg"); } &.is-leaf { background-image: none; } } .normal-tree-node { margin-left: 10px; background-image: url("~@/../static/img/fileType/folder/arrow-right.svg"); &.expanded { transform: rotate(90deg); //旋转角度 background-image: url("~@/../static/img/fileType/folder/arrow-right.svg"); } &.is-leaf { background-image: none; } } .plus-tree-node { margin-left: 10px; background-image: url("~@/../static/img/fileType/folder/plus.svg"); &.expanded { background-image: url("~@/../static/img/fileType/folder/minus.svg"); } &.is-leaf { background-image: none; } } .win11-tree-node { margin-left: 10px; background-image: url("~@/../static/img/fileType/folder/win11-right.svg"); &.expanded { transform: rotate(90deg); //旋转角度 background-image: url("~@/../static/img/fileType/folder/win11-right.svg"); } &.is-leaf { background-image: none; } } // 隐藏一级节点的复选框 &.hideRootNodeCheckbox > div > .el-tree-node__content .el-checkbox { display: none; } } } &[styleType="win11"] { .tree-container { border-radius: 0; background: white; border-right: 1px solid #f7f7f7; .el-tree { padding-left: 0; } >>> .el-tree-node__content { border-radius: 4px; &:hover { background-color: #d9d9d944; // 移入中节点背景 } } >>> .el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content { background-color: #d9d9d9; // 高亮当前选中节点背景 } // 高亮当前即将拖拽放入的节点 >>> .el-tree .el-tree-node.is-drop-inner > .el-tree-node__content { color: white; background-color: #409eff; } } } } .tree-el-popover { .el-button { padding-top: 0; padding-bottom: 0; } } </style>
应用
<template> <div :class="$options.name"> <sgTree v-loading="loading" :key="$route.query.BMID + sgTree_fresh" :treeData="treeData" :data="treeConfigData" @currentChange="currentChange" @addNode="addNode" @removeNode="removeNode" :uploadData="{ name: `file`, accept: '.xls,.xlsx', actionUrl: `${$d.API_ROOT_URL}/core/column/importColumn`, //批量导入树结构接口 actionData: { BMID: $global.getBMID(), PID: `root`, sgLog: `强哥请求来源:${$options.name}导入栏目xls`, }, }" @uploadSuccess="uploadSuccess" @uploadError="uploadError" @importError="importError" @downloadTpl="downloadTpl" draggable :allowNodeDrop="allowNodeDrop" @nodeDragEnd="nodeDragEnd" /> </div> </template> <script> import sgTree from "@/vue/components/admin/sgTree"; export default { name: "sgBody", components: { sgTree, }, data() { return { sgTree_fresh: false, autoId: 0, //自增编号 treeConfigData: { nodeKey: `ID`, //主键 props: { label: "MC", isLeaf: "leaf" }, //配置选项 iconType: "plus", //节点图标:folder文件夹|normal普通样式|plus加减符号样式 text: { addRootButtonText: "添加根目录", //添加根节点按钮文本 removeConfirmTitle: "警告!!!", //删除节点提示标题 removeConfirmTip: "此操作将永久删除该节点及其下面的子节点,是否继续?", //删除节点提示内容 }, }, treeData: [], loading: false, }; }, created() { this.initTreeData(); }, methods: { //初始化数据 initTreeData({ d } = {}) { this.$global.获取整棵树的数据({ cb: (d) => { this.treeData = d; }, }); }, // 拖拽节点相关方法---------------------------------------- allowNodeDrop(draggingNode, dropNode, dropType) { // 只允许拖拽同级别前后排序 let isPrevOrNext = dropType === "prev" || dropType === "next"; // 同在第一级根节点下 let isSameRootLevel = draggingNode.level === dropNode.level && draggingNode.level === 1; // 同在一个节点(非根节点)下 let isSameChildLevel = draggingNode.parent && dropNode.parent && draggingNode.parent.data && dropNode.parent.data && draggingNode.parent.data.ID === dropNode.parent.data.ID; return isPrevOrNext && (isSameRootLevel || isSameChildLevel); }, nodeDragEnd(draggingNode, dropNode, dropType, ev) { // 只允许拖拽同级别前后排序 let isBeforeOrAfter = dropType === "before" || dropType === "after"; if (isBeforeOrAfter) { /* console.log("被拖拽的节点", draggingNode.data.MC, draggingNode.data.PXZ); console.log("停靠的节点", dropNode.data.MC, dropNode.data.PXZ); */ let theSameLevelDatas = (dropNode.parent.childNodes || []).map((v) => v.data); // 获取同一级节点数据 theSameLevelDatas.forEach((v, i) => (v.PXZ = i)); //重新排序 // console.log("拖拽排序", theSameLevelDatas); //这里需要调用后台接口 let IDS = theSameLevelDatas.map((v) => v.ID); //排序后的ID顺序数组 let data = { IDS, sgLog: `强哥请求来源:${this.$options.name}更改同一层级树节点排序值`, }; this.$d.修改节点排序({ data, r: { s: (d) => { // console.log("【成功】", d); }, }, }); } }, // ---------------------------------------- // 获取当前聚焦节点的数据 currentChange(d) { console.log(``, d); }, // 添加节点 addNode({ data, resolve }) { this.$d.新增节点({ data: { MC: `新增节点名称(${++this.autoId})`, }, doing: { l: { show: () => (this.loading = true), close: () => (this.loading = false) }, s: (d) => resolve(d), f: (d) => reject(d), //删除失败 }, }); }, // 删除节点 removeNode({ node, data, resolve, reject }) { this.$d.删除节点({ data: { ID: data.ID }, doing: { s: (d) => resolve(d), f: (d) => reject(d), //删除失败 }, }); }, updateList(d) {}, uploadSuccess(d, f) { this.sgTree_fresh = !this.sgTree_fresh; }, uploadError(d, f) { this.$message.error(d.msg); }, // 导入失败 importError(d, f) {}, // 下载导入模板 downloadTpl(d) {}, }, }; </script>
关联懒加载树节点组件