前言
動態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
同步到水平選單就比較好看了,目前水平都是直接寫死 - 加上水波紋動效,目前沒有..就是MD風格點一下擴散那種
- 拖拽,這樣可以擺出更符合自己使用習慣的水平選單
- 固定額外不被消除的標籤,類似chrome的固定,不會給關閉所有幹掉
程式碼實現
RouterStateModel.js(mobx狀態維護)
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;
複製程式碼
Sidebar.js(側邊欄元件)
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;
複製程式碼
DynamicTabMenu.js(動態選單元件)
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;
複製程式碼
TagList(標籤頁)
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;
複製程式碼
RouterTree
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
佈局...
有不對之處請留言,會及時修正,謝謝閱讀