最近要做一个组织机构树的树级菜单展示,UI框架使用的是Ant Design,这不正好可以使用Tree组件,如图示
奈何领导说太丑,指明要换成类似Menu形式的树形菜单,如图示
于是乎,有两种修改方案
这里采用第二种方案,首先确认需要在其上添加的功能,主要包括:
1. 节点异步加载功能(打开某个部门时,异步加载该部门下的子部门及其成员)
2. 全局检索功能(在搜索框输入内容时,对菜单执行检索、高亮显示匹配的菜单并自动打开匹配菜单的所有父级菜单),见下图
3. 右键对本节点菜单进行编辑、子节点的添加、删除(视具体业务逻辑而定)
全局状态管理使用mobx,具体可以查看相关文档
Tree.js
import React from "react";
import { inject, observer } from "mobx-react";
import {addSubmenuSelected, removeSubmenuSelected} from '../utils/common';
import { Menu, Icon, Input } from "antd";
import { ContextMenu, MenuItem, ContextMenuTrigger } from "react-contextmenu";
import '../assets/css/tree.css';
const SubMenu = Menu.SubMenu;
/*组织节点扁平化列表*/
let dataList = [];
/* 父节点列表 */
let parentList = [];
const generateList = (data) => {
for (let i = 0; i < data.length; i++) {
const node = data[i];
const nodeId = node.nodeId;
dataList.push({nodeId, name: node.name, parentNodeId: node.parentNodeId});
if (node.children.length > 0) {
generateList(node.children);
}
}
};
const getParentKey = (nodeId, tree) => {
let parentKey;
for (let i = 0; i < tree.length; i++) {
const node = tree[i];
if (node.children.length > 0) {
if (node.children.some(item => item.nodeId === nodeId)) {
parentKey = node.nodeId;
} else if (getParentKey(nodeId, node.children)) {
parentKey = getParentKey(nodeId, node.children);
}
}
}
return parentKey;
};
const getAllParentKey = (parentIds) => {
if (parentIds.length === 0) {
return;
}
let ids = [];
parentIds.forEach(item => {
if (!parentList.includes(item)) {
parentList.push(item);
}
dataList.forEach(node => {
if (node.nodeId === item) {
if (node.parentNodeId !== null && !ids.includes(node.parentNodeId)) {
ids.push(node.parentNodeId);
}
}
});
});
return getAllParentKey(ids);
};
const getDatasetNode = (currentNode) => {
let current = currentNode;
while (current.nodeName !== 'LI') {
current = current.parentNode;
}
return current;
};
/*节点自增标示*/
let count = 10;
@inject("rootStore")
@observer
class Tree extends React.Component {
state = {
searchValue: "",
selectedKeys: [],
openKeys: [],
rightClickNode: null
};
componentDidMount() {
document.querySelector('.react-contextmenu-wrapper').addEventListener('contextmenu', this.handleRightClick);
}
componentWillUnmount() {
document.querySelector('.react-contextmenu-wrapper').removeEventListener('contextmenu', this.handleRightClick);
}
renderIcon = (type, flag) => {
switch (type) {
case 'ROOT':
return (
<Icon type="home" style={ flag === 1? {color: '#00EE76'} : {}} />
);
case 'GROUP':
return (
<Icon type="usergroup-add" style={ flag === 1? {color: '#00EE76'} : {}} />
);
case 'BUSINESS':
return (
<Icon type="bank" style={ flag === 1? {color: '#00EE76'} : {}} />
);
case 'LOADING':
return (
<Icon type="loading" style={flag === 1? {color: '#00EE76'} : {}}>Icon>
);
default:
return (
<Icon type="team" style={ flag === 1? {color: '#00EE76'} : {}} />
);
}
};
loop = data => data.map(item => {
let {searchValue} = this.state;
const index = item.name.indexOf(searchValue);
const beforeStr = item.name.substr(0, index);
const afterStr = item.name.substr(index + searchValue.length);
const title = index > -1 ? (
<span>
{this.renderIcon(item.nodeType, searchValue? 1 : 2)}
{beforeStr}
<span style={{color: '#00EE76'}}>{searchValue}span>
{afterStr}
span>
) : <span>
{this.renderIcon(item.nodeType, 2)}
<span>{item.name}span>
span>;
if (item.canDeploy) {
return (
<SubMenu
key={item.nodeId}
data-id={item.nodeId}
data-privilege={item.privilege}
onTitleClick={this.handleTitleClick(item)}
title={title}
>
{this.loop(item.children)}
SubMenu>
);
}
return (
<Menu.Item key={item.nodeId} data-id={item.nodeId} data-privilege={item.privilege}>
{title}
Menu.Item>
);
});
handleChange = (e) => {
const value = e.target.value;
let {treeData} = this.props.rootStore.treeStore;
/* 获取包含搜索内容的所有节点key */
let openKeys = dataList.map((item) => {
if (item.name.indexOf(value) > -1) {
return getParentKey(item.nodeId, treeData);
}
return null;
}).filter((item, i, self) => item && self.indexOf(item) === i);
/* 重置需要展开的父节点id */
parentList = [];
/* 将所选中的内容的节点id的全部父节点id写入parentList中 */
getAllParentKey(openKeys);
openKeys = parentList;
this.setState({
openKeys,
searchValue: value,
});
};
handleClick = e => {
/* 每个menuItem绑定点击事件 */
console.log("click ", e);
};
handleOpenChange = (openKeys) => {
/* 可获取当前所有已经打开面板的key列表 */
// console.log(openKeys);
this.setState({
openKeys
});
};
handleAsyncLoadData = (treeNode) => {
let nodeTypeTemp = treeNode.nodeType;
treeNode.nodeType = 'LOADING';
return new Promise((resolve) => {
if (treeNode.children.length > 0) {
treeNode.nodeType = nodeTypeTemp;
resolve();
return;
}
setTimeout(() => {
treeNode.nodeType = nodeTypeTemp;
treeNode.children = [
{ name: 'Child' + count, nodeId: (count++ + ''), parentNodeId: treeNode.nodeId, nodeType: 'GROUP', children: [], canDeploy: true, privilege: 7 },
{ name: 'Child' + count, nodeId: (count++ + ''), parentNodeId: treeNode.nodeId, nodeType: 'GROUP', children: [], canDeploy: false, privilege: 7 },
];
resolve();
}, 2000);
});
};
handleTitleClick = (treeNode) => ({key, domEvent}) => {
// console.log(key);
addSubmenuSelected(domEvent);
this.setState({
selectedKeys: []
});
this.handleAsyncLoadData(treeNode);
};
handleSelect = ({ item, key, selectedKeys }) => {
/* 只有menuItem才能选中,选中会执行该函数 */
console.log(item, key, selectedKeys);
removeSubmenuSelected();
this.setState({
selectedKeys
});
};
loopAdd = (node, data) => {
data.forEach((item) => {
if (node.parentNodeId === item.nodeId) {
console.log(item);
item.canDeploy = true;
item.children.push(node);
/* this.setState({
openKeys: this.state.openKeys.concat(item.nodeId)
}); */
return 1;
} else {
if (item.children.length > 0) {
return this.loopAdd(node, item.children);
}
}
});
};
loopEdit = (node, data) => {
data.forEach((item) => {
if (node.nodeId === item.nodeId) {
Object.keys(node).forEach(key => {
if (key !== 'children') {
item[key] = node[key];
}
});
return 1;
} else {
if (item.children.length > 0) {
return this.loopEdit(node, item.children);
}
}
});
};
loopDelete = (parentId, nodeId, data) => {
console.log(parentId, nodeId);
data.forEach((item) => {
if (parentId === item.nodeId) {
let index = 0;
item.children.forEach((child, key) => {
if (child.nodeId === nodeId) {
index = key;
}
});
// this.props.rootStore.accountStore.updateSelectedNode(item);
item.children.splice(index, 1);
return 1;
} else {
if (item.children.length > 0) {
return this.loopDelete(parentId, nodeId, item.children);
}
}
});
};
/* 右键点击处理 */
handleMenuItemClick = (e, data) => {
e.preventDefault();
let {treeData} = this.props.rootStore.treeStore;
console.log(data);
switch (data.status) {
case 0:
/* 添加节点 */
this.loopAdd({
name: 'Child' + count,
nodeId: (count++ + ''),
parentNodeId: data.nodeId,
nodeType: 'GROUP',
children: [],
privilege: '1',
canDeploy: true
}, treeData);
break;
case 1:
this.loopEdit({
name: 'edit' + count,
nodeId: data.nodeId,
parentNodeId: data.nodeId,
nodeType: 'GROUP',
children: [],
privilege: '1',
canDeploy: true
}, treeData);
break;
case 2:
this.loopDelete('2', data.nodeId, treeData);
break;
default:
return;
}
// 右键处理完毕后,重置右击节点数据
this.setState({
rightClickNode: null
});
};
handleRightClick = (event) => {
// console.log(event.target);
let dataNode = getDatasetNode(event.target);
this.setState({
rightClickNode: dataNode.dataset
});
// console.log(dataNode.dataset);
};
render() {
let { treeData } = this.props.rootStore.treeStore;
let {selectedKeys, searchValue, openKeys, rightClickNode} = this.state;
/* 节点扁平化处理 */
dataList = [];
generateList(treeData);
return (
<div className="tree">
<Input style={{marginBottom: '50px'}} placeholder="search value" value={searchValue} onChange={this.handleChange} />
<ContextMenuTrigger id="context-menu" holdToDisplay={1000}>
<Menu
onClick={this.handleClick}
style={{ width: "100%" }}
onOpenChange={this.handleOpenChange}
mode="inline"
theme="dark"
openKeys={openKeys}
selectedKeys={selectedKeys}
onSelect={this.handleSelect}
>
{this.loop(treeData)}
Menu>
ContextMenuTrigger>
<ContextMenu id="context-menu">
<MenuItem
onClick={this.handleMenuItemClick}
disabled={rightClickNode? (['0', '1'].includes(rightClickNode.privilege)) : false}
data={{nodeId: rightClickNode? rightClickNode.id : '', status: 0}}
>
添加
MenuItem>
<MenuItem
onClick={this.handleMenuItemClick}
disabled={rightClickNode? (['0', '1'].includes(rightClickNode.privilege)) : false}
data={{nodeId: rightClickNode? rightClickNode.id : '', status: 1}}
>
编辑
MenuItem>
<MenuItem divider />
<MenuItem
onClick={this.handleMenuItemClick}
disabled={rightClickNode? (['0', '1'].includes(rightClickNode.privilege)) : false}
data={{nodeId: rightClickNode? rightClickNode.id : '', status: 2}}
>
删除
MenuItem>
ContextMenu>
div>
);
}
}
export default Tree;
common.js
export const removeSubmenuSelected = function () {
document.querySelectorAll('.submenu-selected').forEach((domNode) => {
domNode.classList.remove('submenu-selected');
});
};
export const addSubmenuSelected = function (domEvent) {
document.querySelectorAll('.submenu-selected').forEach((domNode) => {
domNode.classList.remove('submenu-selected');
});
domEvent.currentTarget.classList.add('submenu-selected');
}
treeStore.js
import {observable, action} from 'mobx';
class TreeStore {
constructor(rootStore) {
this.rootStore = rootStore;
}
@observable treeData = [{
name: 'parent1',
nodeId: '1',
nodeType: 'ROOT',
canDeploy: true,
privilege: '7',
parentNodeId: null,
children: [
{
name: 'parent2',
nodeId: '2',
nodeType: 'GROUP',
canDeploy: true,
parentNodeId: '1',
privilege: '0',
children: [
{
name: 'leaf1',
nodeId: '3',
parentNodeId: '2',
nodeType: 'GROUP',
canDeploy: true,
children: [],
privilege: '7'
},
{
name: 'leaf2',
nodeId: '4',
parentNodeId: '2',
nodeType: 'BUSINESS',
canDeploy: false,
children: [],
privilege: '7'
},
{
name: 'leaf3',
nodeId: '5',
parentNodeId: '2',
privilege: '7',
nodeType: 'TEAM',
canDeploy: true,
children: []
}
]
},
{
name: 'parent3',
nodeId: '6',
parentNodeId: '1',
nodeType: 'GROUP',
canDeploy: true,
privilege: '7',
children: [
{
name: 'leaf4',
nodeId: '7',
parentNodeId: '6',
privilege: '7',
nodeType: 'GROUP',
children: []
},
{
name: 'leaf5',
nodeId: '8',
parentNodeId: '6',
nodeType: 'BUSINESS',
privilege: '7',
children: []
},
{
name: 'leaf6',
nodeId: '9',
parentNodeId: '6',
nodeType: 'TEAM',
privilege: '0',
children: []
}
]
},
]
}];
/*更新树,该方法未使用*/
@action updateTree(treeData) {
this.treeData = treeData;
}
}
export default TreeStore;
loop方法
,其中直接修改使用了一部分Tree控件的代码,其中有一个重要字段canDeploy
,代表当前节点下是否有子节点,是一个分界值。handleTitleClick方法
,该方法的调用可以直接查看Ant Design官方API文档,接着为当前点击的节点添加激活状态,见common.js
,当叶子节点选中时见handleSelect方法
,这个时候要修改Menu组件的selectedKeys
选项,其中只有叶子节点才有selectedKeys
配置。handleAsyncLoadData方法
,该方法在请求过程中以更换Icon的方式来显示loading效果,如果当前节点下面已有子节点列表,说明该节点被打开过,无需再次加载,没有子节点的话,打开后会默认填充假节点数据,填充后即可展示。handleChange方法
,其中dataList
是将整个树形菜单的数据进行拉平处理的产物,执行遍历找到所有匹配内容的节点的父节点,因为要自动打开匹配内容节点的所有父级节点,所以使用getAllParentKey方法
来获取上一步获得的父节点的所有父节点,将这写需要打开的父节点装到parentList
里面,将它赋值给Menu配置项openKeys
即可实现匹配内容节点的父节点实现自动打开。这里实现右击菜单使用了react-contextmenu
,使用方法可自行github查找
1. 该右击插件并没有提供右击回调函数,导致我们无从捕获当前被右击选择的节点是谁,这里采用绑定事件的方式来进行回调处理,见componentDidMount, componentWillUnmount
2. 右击时是需要的获取到一些节点的信息的,比如节点ID,节点权限。此时需要注意到在loop方法
中,已经为其分发了一些data-*
的属性值,查看控制台DOM节点可以看到这些分发的属性都作用在列表的li
标签上,接下来就要获取右击选中元素,查看该标签是否为li
标签,不是则继续向父级查找,直到找到第一个为止,然后获取其上的data-*
的数据。li
标签查找方法见getDatasetNode方法
,将最后获取的数据赋值给rightClickNode
3. 添加、编辑、删除处理方法见handleMenuItemClick
,该参数接收的data即为rightClickNode
中的数据
4. 添加具体方法见loopAdd方法
,这里都是测试使用数据
5. 编辑具体方法见loopEdit方法
6. 删除具体方法见loopDelete方法