因为害怕自己并非明珠而不敢刻苦琢磨,又因为有几分相信自己是明珠,而不敢庸庸碌碌,与石砾为伍
一. 需求
1. 左侧文件夹 / 部门 列表
- 节点有
部门
和文件夹
- 文件夹下
只有文件夹
- 部门下 有
部门和文件夹
- 文件夹节点
有勾选框
, 部门节点无勾选框, 有部门图标
2. 右侧 文件列表(常规列表, 这里不做表述)
原来需求要求:
勾选父节点, 子节点全选; 子节点全选, 父节点不能全选; 勾选左侧文件夹, 右侧文件不能被勾选; 右侧文件被勾选, 左侧文件夹勾选框置灰;
这个版本只实现 每个文件夹独立被勾选, 左侧文件夹勾选, 右侧文件列表置灰;
二. 思路 & 疑难点
需求拆分:
- 数据层级渲染
- 部分节点不显示勾选框(不可勾选), 显示图标
- 每个文件夹独立, 脱离父子关系(子节点全选, 父节点不被全选)
- 支持数据回显(加载后部分文件默认已被勾选)
关联问题:
- 点击左侧文件夹, 右侧文件勾选框置灰
- 节点失焦后(点击区域外), 保留上一个节点的背景色(记录当前选中项)
- 点击三角箭头, 除了折叠还具有点击效果(获取右侧文件)
难点:
- 数据层级渲染(elementUI需要对数据做处理 / 造轮子需要用到递归)
- 失焦 / 样式选中 等样式(样式穿透)
- 节点父子关系(造轮子时候选中多层节点 / 以及回显多层节点) -
暂时有bug
三. elementUI
整体Code:
<el-tree
class="depart-tree"
ref="treeDepartment"
show-checkbox
:check-strictly="true"
:data="departmentList"
node-key="itemID"
:props="defaultPropsDepartment"
:default-expanded-keys="defaultItemID"
:default-checked-keys="checkedFoldersList"
@node-click="nodeClick"
@node-expand="nodeClick"
@node-collapse="nodeClick"
@check="checkClick"
>
<span
:title="node.label"
class="custom-tree-node"
slot-scope="{ node }"
style="color: #333333; font-size: 14px"
>
<span class="textOverflow2">
<!-- 部门图标 -->
<i v-if="node.data.isDepart == 1" class="treenode-depart"></i>
{{ node.label }}
</span>
</span>
</el-tree>
data() {
return {
// tree 部门文件
departmentList: [],
defaultItemID: [],
defaultPropsDepartment: {
children: 'children',
label: 'name'
},
checkedFoldersList: [],
}
},
methods() {
nodeClick(data) { // 节点被点击时触发
// 获取右侧文件列表
this.getFileList()
// 强制选中当前项
this.$nextTick(() => {
this.$refs.treeDepartment.setCurrentKey(data.itemCode) // 强制选中当前项
})
},
checkClick() { // 复选框点击时触发
// 获取选中文件夹列表
let department = JSON.parse(JSON.stringify(this.$refs.treeDepartment.getCheckedNodes()))
},
}
```css
/* tree */
.el-tree >>> .el-tree-node__content {
height: 40px;
}
.el-tree >>> .el-tree-node__expand-icon {
margin-left: 25px;
}
.el-tree >>> .el-tree-node__content:hover {
background: #e8f0ff;
}
/* 改变被点击节点背景颜色,字体颜色 */
.el-tree >>> .el-tree-node:focus > .el-tree-node__content {
background-color: #e8f0ff !important;
}
/*节点失焦时的背景颜色*/
.el-tree >>> .el-tree-node.is-current > .el-tree-node__content {
background-color: #e8f0ff !important;
}
/* !!! tree 禁用的复选框隐藏 */
/deep/.el-checkbox__input.is-disabled {
display: none;
}
/* 所有父节点均不显示复选框 */
/* .el-tree >>> .el-tree-node .is-leaf + .el-checkbox .el-checkbox__inner{display: inline-block;}
.el-tree >>> .el-tree-node .el-checkbox .el-checkbox__inner{display: none;} */
/* 一级节点无复选框 */
/* >>> .el-tree > .el-tree-node > .el-tree-node__content .el-checkbox {
display: none;
} */
数据处理:
因为后端返回的数据并不是常规
departmentList: [ {0: name: name, children: Arr}, {1: name: name, children: Arr} ]
而是departList: [ {0: departName: name, departChildren: } ]
&folderList: [ { 0: folderName: name, children: Arr } ]
所以需要对数据处理, 两个数组合并
递归:
// 递归
recursion(arr = [], childName, childName2, newChildName, changeNameFunc, func) {
arr.forEach(item => {
item[childName] = item[childName] || []
item[childName2] = item[childName2] || []
item[newChildName] = [...item[childName], ...item[childName2]]
changeNameFunc(item)
if (item[newChildName] && item[newChildName].length) {
this.recursion(item[newChildName], childName, childName2, newChildName, changeNameFunc, func)
} else {
func(item)
}
})
return arr
},
// 接口请求数据
getFolderList() {
this.$DataAccess.GetFolderByRoot().then(res => {
// 将 folderName 替换为 name
this.departmentList = JSON.parse(JSON.stringify(res.data.data.departmentList).replaceAll(/folderName/g, 'name'))
// 使用递归遍历数据
// childItems / folderChild 均改成 children
this.departmentList = this.recursion(this.departmentList, 'childItems', 'folderChild', 'children', (item) => {
if (item.isDepart == 1) { // 部门节点 禁用(增加类名, 在样式中隐藏复选框)
item.disabled = true
}
// name / folderName 均改成 name
item.name = item.name || item.folderName
}, (item) => { })
})
}
四. 原生的轮子
<template>
<div>
<li v-for="(item, index) in deps" :key="index" class="treenode-root">
<!-- 父节点 -->
<div
class="treenode-node-item"
@click="clickTag(item)"
:class="item.clicked ? 'treenode-node-select' : ''"
>
<!-- 层级样式 -->
<span style="margin-left: 20px;" v-for="(item, index) in level" :key="index"></span>
<!-- 展开图标 -->
<i
v-if="!item.expand && item.children.length"
class="treenode-switcher treenode-switcher-close"
style="padding: 5px 7px 5px 0"
@click="openTag(item)"
></i>
<!-- 折叠图标 -->
<i
v-if="item.expand && item.children.length"
class="treenode-switcher treenode-switcher-open"
style="padding: 5px 7px 5px 0"
@click="openTag(item)"
></i>
<!-- 无折叠图标 占位符 -->
<i v-if="!item.children.length" style="margin-right: 20px"></i>
<span :title="item.name" class="treenode-title" style="color: #333333; font-size: 14px">
<span class="textOverflow2">
<!-- 部门图标 -->
<i v-if="item.isDepart == 1" style="margin-right: 3px" class="treenode-depart"></i>
<!-- 勾选框 -->
<!-- {{ item.clicked }} -->
<i
v-if="item.isDepart == 0"
@click="checkTag(item)"
style="margin: 0 3px 0 4px"
class="check02 ico-check02"
:class="item.checked ? 'ico-checked02' : ''"
></i>
{{ item.name }}
</span>
</span>
</div>
<!-- 子节点 -->
<ul class="treenode" v-if="item.children && item.expand">
<s-tree
:data="item.children"
class="treenode"
:level="localLevel"
@tag-click="clickTag"
@tag-check="tagCheck"
:showFloderList="showFloderList"
/>
</ul>
</li>
</div>
</template>
<script>
import '@/css/css.css'
import '@/css/reset.css'
import '@/css/style.css'
import '@/css/public.paging.css'
export default {
props: {
// 父组件传值 - 需要被渲染的tree
data: {
type: Array,
required: false,
default() {
return [];
}
},
// 层级 父组件传1
level: {
type: Number,
default: 0
},
// 递归调用(该组件调用该组件时的传值)
showFloderList: {
type: Array,
default: () => []
}
},
name: 'sTree',
data() {
return {
// 层级
dataLevel: 1,
deps: [],
// 文件夹
floderList: []
}
},
created() {
this.addLevel()
},
mounted() {
this.showEchoFolder()
},
watch: {
data: { // 监听 tree的值
handler(newValue) {
this.deps = JSON.parse(JSON.stringify(newValue))
this.echoFolder(this.deps, this.floderList) // 监听 项目文件夹
},
deep: true,
}
},
methods: {
showEchoFolder() { // 文件夹回显
// this.treeFloderList = this.showFloderList
this.floderList = this.showFloderList.map(e => e.itemID)
this.deps = JSON.parse(JSON.stringify(this.data))
this.echoFolder(this.deps, this.floderList) // 监听 部门文件夹
},
clickTag(item) { // 子节点被点击时 触发
this.$emit('tag-click', item)
},
tagCheck(item) { // 子节点被勾选时 触发
this.$emit('tag-check', item)
},
checkTag(item) { // 节点被勾选 触发
item.checked = !item.checked
this.parentCheck(this.deps, item)
this.$emit('tag-check', item)
},
openTag(item) { // 节点展开/折叠 触发
item.expand = !item.expand
this.$forceUpdate()
},
addLevel() { // 增加子项层级(样式)
this.localLevel = this.level + 1
},
checkedChildren(list) {
if (list.children && list.children.length) {
item.children[j].checked = true
this.checkedChildren(list.children)
}
},
parentCheck(list, ids) { // 父节点勾选 字节点会全选(存在的bug - 只能勾选两级)
if (ids.checked) {
for (let i = 0; i < list.length; i++) {
let item = list[i]
if (item.itemID === ids.itemID) {
if (item.isDepart != 1) { // 部门不勾选
if (item.children && item.children.length) {
for (let j = 0; j < item.children.length; j++) {
item.children[j].checked = true
}
}
break;
}
} else {
if (item.children) {
this.parentCheck(item.children, ids)
}
}
}
} else {
for (let i = 0; i < list.length; i++) {
let item = list[i]
if (item.itemID === ids.itemID) {
if (item.isDepart != 1) { // 部门不勾选
if (item.children && item.children.length) {
for (let j = 0; j < item.children.length; j++) {
item.children[j].checked = false
if (item.children[j] && item.children[j].length) {
for (let h = 0; h < item.children[j].length; h++) {
item.children[j][h].checked = false
}
}
}
}
break;
}
} else {
if (item.children) {
this.parentCheck(item.children, ids)
}
}
}
}
},
echoFolder(list, ids) { // 勾选的文件夹回显
for (let i = 0; i < list.length; i++) {
let item = list[i]
if (ids.includes(item.itemID)) {
if (item.isDepart != 1) { // 部门不勾选
item.checked = true
if (item.children.length) {
for (let j = 0; j < item.children.length; j++) {
let item2 = item.children[j]
item2.checked = true
}
}
}
}
}
},
bgColor(list) {
for (let i = 0; i < list.length; i++) {
let item = list[i]
item.clicked = false
if(item.children.length != 0) {
this.bgColor(item)
}
}
}
}
}
</script>
<style scoped>
</style>
五. 拓展场景
场景一: 拖拽
1. 层级拖拽
<el-tree
ref="tree"
:data="data"
:props="props"
node-key="id"
:allow-drop="collapse"
@check-change="onTreeChang"
show-checkbox
draggable
default-expand-all
:indent="30"
>
</el-tree>
method事件
// 权限拖拽控制
collapse(moveNode, inNode, type) {
// return type == 'next';
// 一级拖动到一级
if (moveNode.level == 1 && inNode.level == 1) {
// 四种情况
if (moveNode.nextSibling == undefined) {
return type == 'prev';
} else if (inNode.nextSibling == undefined) {
return type == 'next';
} else if (moveNode.nextSibling.id !== inNode.id) {
return type == 'prev';
} else {
return type == 'next';
}
}
//是否为同级下的子节点
// 二级拖动到二级
if (
moveNode.level == 2 &&
inNode.level == 2 &&
moveNode.parent.id == inNode.parent.id
) {
// 四种情况
if (moveNode.nextSibling == undefined) {
return type == 'prev';
} else if (inNode.nextSibling == undefined) {
return type == 'next';
} else if (moveNode.nextSibling.id !== inNode.id) {
return type == 'prev';
} else {
return type == 'next';
}
}
// 二级拖动到一级 或者一级拖住啊到二级
if (
(moveNode.level == 2 && inNode.level == 1) ||
(moveNode.level == 1 && inNode.level == 2)
) {
// 四种情况
if (moveNode.nextSibling == undefined) {
return type == 'prev';
} else if (inNode.nextSibling == undefined) {
return type == 'next';
} else if (moveNode.nextSibling.id !== inNode.id) {
return type == 'prev';
} else {
return type == 'next';
}
}
}
2. 默认选中上次选中的节点, 定位滚动到该节点
<el-tree
class="depart-tree"
ref="treeDepartment"
id="filterTree"
show-checkbox
:default-expanded-keys="defaultItemID"
:check-strictly="true"
:data="departmentList"
node-key="itemID"
:props="defaultPropsDepartment"
:default-checked-keys="checkedFoldersList"
@node-click="nodeClick"
@node-expand="nodeClick"
@node-collapse="nodeClick"
@check="checkClick"
>
</el-tree>
defaultItem() { // 默认定位上一次点击的文件夹
let defaultData = JSON.parse(localStorage.getItem("defaultData"))
if (defaultData == 1) {
this.switchTree(0)
return
}
if (defaultData == 2) {
this.switchTree(1)
return
}
this.nodeClick(defaultData)
this.$nextTick(() => {
this.defaultItemID = [defaultData.itemID]
this.$refs.treeDepartment.setCurrentKey(defaultData.itemID) // 强制选中当前项
})
// 获得父容器的高度(clientHeight)
let parentNodeClientHeight = document.querySelector('#filterTree').clientHeight
this.$nextTick(() => {
this.$nextTick(() => {
this.$nextTick(() => {
// 获取选中节点距离父元素的顶部距离(offsetTop)
let nodeOffsetTop = document.querySelector('.is-current').offsetTop
setTimeout(() => {
document.getElementById('leftTree').scrollTop = (nodeOffsetTop - (parentNodeClientHeight / 2))
})
})
})
})
},
场景二: 鼠标悬停
1. 悬停控制右侧按钮显影
期望: 不要一次性展示这么多按钮,鼠标悬停到某一项时,才展示这一项的功能按钮(新增、删除)
<!--ElementUI 为例 -->
<el-tree
:data="data"
show-checkbox
node-key="id"
default-expand-all
:expand-on-click-node="false">
<!--自带的class属性 绑定鼠标悬停效果 -->
<span class="custom-tree-node" slot-scope="{ node, data }">
<span>{{ node.label }}</span>
<!--默认隐藏右侧按钮 -->
<span style="display:none">
<el-button type="text" size="mini" @click="() => append(data)"> Append </el-button>
<el-button type="text" size="mini" @click="() => remove(node, data)"> Delete </el-button>
</span>
</span>
</el-tree>
<style lang="scss" scoped>
/* 鼠标悬停至 tree-item 项时,控制 其子元素(右侧按钮)显示 */
.custom-tree-node:hover :nth-child(2) {
display: inline-block !important;
}
</style>
如果有人做过类似的轮子可以分享一下0.o…