React中使用Ant Design 以Menu导航菜单形式展示Tree树形结构

引言

最近要做一个组织机构树的树级菜单展示,UI框架使用的是Ant Design,这不正好可以使用Tree组件,如图示
React中使用Ant Design 以Menu导航菜单形式展示Tree树形结构_第1张图片
奈何领导说太丑,指明要换成类似Menu形式的树形菜单,如图示
React中使用Ant Design 以Menu导航菜单形式展示Tree树形结构_第2张图片

于是乎,有两种修改方案

  • 先考虑改造Tree控件,这其中不仅需要修改css,还要修改Tree的内部结构
  • 不使用Tree控件直接使用Menu导航菜单,在其上添加所需要的功能

这里采用第二种方案,首先确认需要在其上添加的功能,主要包括:
1. 节点异步加载功能(打开某个部门时,异步加载该部门下的子部门及其成员)
2. 全局检索功能(在搜索框输入内容时,对菜单执行检索、高亮显示匹配的菜单并自动打开匹配菜单的所有父级菜单),见下图
3. 右键对本节点菜单进行编辑、子节点的添加、删除(视具体业务逻辑而定)
React中使用Ant Design 以Menu导航菜单形式展示Tree树形结构_第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;

异步节点加载

  1. 节点结构的抽离具体体现在 loop方法,其中直接修改使用了一部分Tree控件的代码,其中有一个重要字段canDeploy,代表当前节点下是否有子节点,是一个分界值。
  2. 当有子节点的话肯定是可以打开的,点击的时候调用handleTitleClick方法,该方法的调用可以直接查看Ant Design官方API文档,接着为当前点击的节点添加激活状态,见common.js,当叶子节点选中时见handleSelect方法,这个时候要修改Menu组件的selectedKeys选项,其中只有叶子节点才有selectedKeys配置。
  3. 调用接口进行异步节点的加载见handleAsyncLoadData方法,该方法在请求过程中以更换Icon的方式来显示loading效果,如果当前节点下面已有子节点列表,说明该节点被打开过,无需再次加载,没有子节点的话,打开后会默认填充假节点数据,填充后即可展示。

全局检索

  1. 关注搜索框的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方法

码不动了,对比着官方Menu组件和Tree组件,应该可以理解。(有其他的方案也可以在下面进行交流优化)

你可能感兴趣的:(React)