动态tab水平菜单,这个需求很常见,特别是对于后台管理系统来说;
实现的思路有点绕,有更好的姿势请留言,谢谢阅读。
只有一个时候是不允许关闭,所以也不会显示关闭的按钮,关闭其他也不会影响唯一的
tag
换行mobx
& mobx-react
react-router-dom v4
styled-components
react 16.4.x
antd 3.8.x
为了保持后台的风格一致化,直接基于antd
的基础上封装一下
实现的思路基本是一样的(哪怕是自己把组件都写了)
mobx
来维护打开的菜单数据,数据用数组来维护
tab
展示页面内容,同时关联侧边栏的菜单tab
自身可以关闭,注意规避只有一个的时候不显示关闭按钮,高亮的tab
的时候(tab
和路由匹配的情况),再次渲染组件url
以外的的所有tab
有兴趣的自行拓展,具体idea
如下
icon
,这样把icon
同步到水平菜单就比较好看了,目前水平都是直接写死Model
我们要考虑这么几点
item
的的组key
,和子key
,子name
以及访问的url
action
,删除的action
思路有了.剩下就是东西的出炉了,先构建model
,其实就是mobx
数据结构
import { observable, action, computed, toJS } from 'mobx';
function findObj(array, obj) {
for (let i = 0, j = array.length; i < j; i++) {
if (array[i].childKey === obj.childKey) {
return true;
}
}
return false;
}
class RouterStateModel {
@observable
currentUrl; // 当前访问的信息
@observable
urlHistory; // 访问过的路由信息
constructor() {
this.currentUrl = {};
this.urlHistory = [];
}
// 当前访问的信息
@action
addRoute = values => {
// 赋值
this.currentUrl = values;
// 若是数组为0
if (this.urlHistory.length === 0) {
// 则追加到数组中
this.urlHistory.push(this.currentUrl);
} else {
findObj(toJS(this.urlHistory), values)
? null
: this.urlHistory.push(this.currentUrl);
}
};
// 设置index为高亮路由
@action
setIndex = index => {
this.currentUrl = toJS(this.urlHistory[index]);
};
// 关闭单一路由
@action
closeCurrentTag = index => {
// 当历史集合长度大于一才重置,否则只剩下一个肯定保留额
this.urlHistory.splice(index, 1);
this.currentUrl = toJS(this.urlHistory[this.urlHistory.length - 1]);
};
// 关闭除了当前url的其他所有路由
@action
closeOtherTag = route => {
if (this.urlHistory.length > 1) {
this.urlHistory = [this.currentUrl];
} else {
return false;
}
};
// 获取当前激活的item,也就是访问的路由信息
@computed
get activeRoute() {
return toJS(this.currentUrl);
}
// 获取当前的访问历史集合
@computed
get historyCollection() {
return toJS(this.urlHistory);
}
}
const RouterState = new RouterStateModel();
export default RouterState;
import React, { Component } from 'react';
import { withRouter } from 'react-router-dom';
import { observer, inject } from 'mobx-react';
// antd
import { Layout, Menu, Icon } from 'antd';
const { Sider } = Layout;
const { SubMenu, Item } = Menu;
import RouterTree, { groupKey } from 'router';
// Logo组件
import Logo from 'pages/Layout/Logo';
@inject('rstat')
@withRouter
@observer
class Sidebar extends Component {
constructor(props) {
super(props);
// 初始化置空可以在遍历不到的时候应用默认值
this.state = {
openKeys: [''],
selectedKeys: ['0'],
rootSubmenuKeys: groupKey,
itemName: ''
};
}
setDefaultActiveItem = ({ location, rstat } = this.props) => {
RouterTree.map(item => {
if (item.pathname) {
// 做一些事情,这里只有二级菜单
}
// 因为菜单只有二级,简单的做个遍历就可以了
if (item.children && item.children.length > 0) {
item.children.map(childitem => {
// 为什么要用match是因为 url有可能带参数等,全等就不可以了
// 若是match不到会返回null
if (location.pathname.match(childitem.path)) {
this.setState({
openKeys: [item.key],
selectedKeys: [childitem.key]
});
// 设置title
document.title = childitem.text;
// 调用mobx方法,缓存初始化的路由访问
rstat.addRoute({
groupKey: item.key,
childKey: childitem.key,
childText: childitem.text,
pathname: childitem.path
});
}
});
}
});
};
getSnapshotBeforeUpdate(prevProps, prevState) {
const { location, match } = prevProps;
// 重定向的时候用到
if (!prevState.openKeys[0] && match.path === '/') {
let snapshop = '';
RouterTree.map(item => {
if (item.pathname) {
// 做一些事情,这里只有二级菜单
}
// 因为菜单只有二级,简单的做个遍历就可以了
if (item.children && item.children.length > 0) {
return item.children.map(childitem => {
// 为什么要用match是因为 url有可能带参数等,全等就不可以了
// 若是match不到会返回null
if (location.pathname.match(childitem.path)) {
snapshop = {
openKeys: [item.key],
selectedKeys: [childitem.key]
};
}
});
}
});
if (snapshop) {
return snapshop;
}
}
return null;
}
componentDidMount = () => {
// 设置菜单的默认值
this.setDefaultActiveItem();
};
componentDidUpdate = (prevProps, prevState, snapshot) => {
if (snapshot) {
this.setState(snapshot);
}
if (prevProps.location.pathname !== this.props.location.pathname) {
this.setState({
openKeys: [this.props.rstat.activeRoute.groupKey],
selectedKeys: [this.props.rstat.activeRoute.childKey]
});
}
};
OpenChange = openKeys => {
const latestOpenKey = openKeys.find(
key => this.state.openKeys.indexOf(key) === -1
);
if (this.state.rootSubmenuKeys.indexOf(latestOpenKey) === -1) {
this.setState({ openKeys });
} else {
this.setState({
openKeys: latestOpenKey ? [latestOpenKey] : [...openKeys]
});
}
};
// 路由跳转
gotoUrl = (itemurl, activeRoute) => {
// 拿到路由相关的信息
const { history, location } = this.props;
// 判断我们传入的静态路由表的路径是否和路由信息匹配
// 不匹配则允许跳转,反之打断函数
if (location.pathname === itemurl) {
return;
} else {
// 调用mobx方法,缓存路由访问
this.props.rstat.addRoute({
pathname: itemurl,
...activeRoute
});
history.push(itemurl);
}
};
render() {
const { openKeys, selectedKeys } = this.state;
const { collapsed, onCollapse } = this.props;
const SiderTree = RouterTree.map(item => (
<SubMenu
key={item.key}
title={
<span>
<Icon type={item.title.icon} />
<span>{item.title.text}</span>
</span>
}>
{item.children &&
item.children.map(menuItem => (
<Item
key={menuItem.key}
onClick={() => {
// 设置高亮的item
this.setState({ selectedKeys: [menuItem.key] });
// 设置文档标题
document.title = menuItem.text;
this.gotoUrl(menuItem.path, {
groupKey: item.key,
childKey: menuItem.key,
childText: menuItem.text
});
}}>
{menuItem.text}
</Item>
))}
</SubMenu>
));
return (
<Sider
collapsible
breakpoint="lg"
collapsed={collapsed}
onCollapse={onCollapse}
trigger={collapsed}>
<Logo collapsed={collapsed} />
<Menu
subMenuOpenDelay={0.3}
theme="dark"
openKeys={openKeys}
selectedKeys={selectedKeys}
mode="inline"
onOpenChange={this.OpenChange}>
{SiderTree}
</Menu>
</Sider>
);
}
}
export default Sidebar;
import React, { Component } from 'react';
import styled from 'styled-components';
import { withRouter } from 'react-router-dom';
import { observer, inject } from 'mobx-react';
import { Button, Popover } from 'antd';
import TagList from './TagList';
const DynamicTabMenuCSS = styled.div`
box-shadow: 0px 1px 1px -1px rgba(0, 0, 0, 0.2),
0px 1px 1px 0px rgba(0, 0, 0, 0.14), 0px 1px 3px 0px rgba(0, 0, 0, 0.12);
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
background-color: #fff;
.tag-menu {
flex: 1;
}
.operator {
padding:0 15px;
flex-shrink: 1;
}
`;
@inject('rstat')
@withRouter
@observer
class DynamicTabMenu extends Component {
constructor(props) {
super(props);
this.state = {
closeTagIcon: false // 控制关闭所有标签的状态
};
}
// 关闭其他标签
closeOtherTagFunc = () => {
this.props.rstat.closeOtherTag();
};
render() {
const { rstat } = this.props;
const { closeTagIcon } = this.state;
return (
<DynamicTabMenuCSS>
<div className="tag-menu">
<TagList />
</div>
<div
className="operator"
onClick={this.closeOtherTagFunc}
onMouseEnter={() => {
this.setState({
closeTagIcon: true
});
}}
onMouseLeave={() => {
this.setState({
closeTagIcon: false
});
}}>
<Popover
placement="bottom"
title="关闭标签"
content={'只会保留当前访问的标签'}
trigger="hover">
<Button type="dashed" shape="circle" icon="close" />
</Popover>
</div>
</DynamicTabMenuCSS>
);
}
}
export default DynamicTabMenu;
import React, { Component } from 'react';
import { withRouter } from 'react-router-dom';
import { observer, inject } from 'mobx-react';
import { Icon, Menu } from 'antd';
@inject('rstat')
@withRouter
@observer
class TagList extends Component {
constructor(props) {
super(props);
this.state = {
showCloseIcon: false, // 控制自身关闭icon
currentIndex: '' // 当前的索引
};
}
render() {
const { rstat, history, location } = this.props;
const { showCloseIcon, currentIndex } = this.state;
return (
<Menu selectedKeys={[rstat.activeRoute.childKey]} mode="horizontal">
{rstat.historyCollection &&
rstat.historyCollection.map((tag, index) => (
<Menu.Item
key={tag.childKey}
onMouseEnter={() => {
this.setState({
showCloseIcon: true,
currentIndex: tag.childKey
});
}}
onMouseLeave={() => {
this.setState({
showCloseIcon: false
});
}}
onClick={() => {
rstat.setIndex(index);
if (tag.pathname === location.pathname) {
return;
} else {
history.push(tag.pathname);
}
}}>
<span>
<Icon
type="tag-o"
style={{ padding: '0 0 0 10px' }}
/>
{tag.childText}
</span>
{showCloseIcon &&
rstat.historyCollection.length > 1 &&
currentIndex === tag.childKey ? (
<Icon
type="close-circle"
style={{
position: 'absolute',
top: 0,
right: -20,
fontSize: 24
}}
onClick={event => {
event.stopPropagation();
rstat.closeCurrentTag(index);
history.push(
rstat.activeRoute.pathname
);
}}
/>
) : null}
</Menu.Item>
))}
</Menu>
);
}
}
export default TagList;
import React from 'react';
import asyncComponent from 'components/asyncComponent/asyncComponent';
// 数据分析
const Monitor = asyncComponent(() => import('pages/DashBoard/Monitor'));
const Analyze = asyncComponent(() => import('pages/DashBoard/Analyze'));
// 音频管理
const VoiceList = asyncComponent(() => import('pages/AudioManage/VoiceList'));
const CallVoice = asyncComponent(() => import('pages/AudioManage/CallVoice'));
const PrivateChat = asyncComponent(() =>
import('pages/AudioManage/PrivateChat')
);
const Topic = asyncComponent(() => import('pages/AudioManage/Topic'));
// APP 管理
const USERLIST = asyncComponent(() => import('pages/AppManage/UserList'));
// 安全中心
const REPORT = asyncComponent(() => import('pages/Safety/Report'));
const RouterTree = [
{
key: 'g0',
title: {
icon: 'dashboard',
text: '数据分析'
},
exact: true,
path: '/dashboard',
children: [
{
key: '1',
text: '数据监控',
path: '/dashboard/monitor',
component: Monitor
},
{
key: '2',
text: '数据分析',
path: '/dashboard/analyze',
component: Analyze
}
]
},
{
key: 'g1',
title: {
icon: 'play-circle',
text: '音频管理'
},
exact: true,
path: '/voice',
children: [
{
key: '8',
text: '声兮列表',
path: '/voice/sxlist',
component: VoiceList
},
{
key: '9',
text: '回声列表',
path: '/voice/calllist',
component: CallVoice
},
{
key: '10',
text: '私聊列表',
path: '/voice/privatechat',
component: PrivateChat
},
{
key: '11',
text: '热门话题',
path: '/voice/topcis',
component: Topic
}
]
},
{
key: 'g2',
title: {
icon: 'schedule',
text: '活动中心'
},
exact: true,
path: '/active',
children: [
{
key: '17',
text: '活动列表',
path: '/active/list',
component: Analyze
},
{
key: '18',
text: '新建活动',
path: '/active/add',
component: Analyze
}
]
},
{
key: 'g3',
title: {
icon: 'scan',
text: '电影专栏'
},
exact: true,
path: '/active',
children: [
{
key: '22',
text: '电影大全',
path: '/active/list',
component: Analyze
}
]
},
{
key: 'g4',
title: {
icon: 'apple-o',
text: 'APP管理'
},
exact: true,
path: '/appmanage',
children: [
{
key: '29',
text: '移动交互',
path: '/appmanage/interaction',
component: Analyze
},
{
key: '30',
text: '用户列表',
path: '/appmanage/userlist',
component: USERLIST
},
{
key: '31',
text: '用户协议',
path: '/platform/license',
component: Analyze
},
{
key: '32',
text: '帮助中心',
path: '/platform/help',
component: Analyze
}
]
},
{
key: 'g5',
title: {
icon: 'safety',
text: '安全中心'
},
exact: true,
path: '/safety',
children: [
{
key: '36',
text: '举报处理',
path: '/safety/report',
component: REPORT
},
{
key: '37',
text: '广播中心',
path: '/safety/broadcast',
component: Analyze
}
]
},
{
key: 'g6',
title: {
icon: 'user',
text: '系统设置'
},
exact: true,
path: '/user',
children: [
{
key: '43',
text: '个人设置',
path: '/user/setting',
component: Analyze
},
{
key: '44',
text: '用户列表',
path: '/user/list',
component: Analyze
}
]
}
];
export const groupKey = RouterTree.map(item => item.key);
export default RouterTree;
为什么不做那种带两个箭头(可以往前往后),自我感觉意义不大,水平菜单的宽度不管是pad
上还是pc
上,
默认一行最起码可以打开五个tab
, 一般人的注意力都集中在几个常见的页面上,
假如你需要更多呢?这里也考虑到了,直接换行,用的flex
布局,有不对之处请留言,会及时修正,谢谢阅读