需求1:根据点开的路由页面,缓存标签页面,达到切换tabs时缓存页面数据,比如未完成的表单。
需求2:标签页面第一个标签为路由导航页面,根据顶部header路由切换
分析:基于umi4中自带的keepalive实现
1.config.js文件中引入keepalive插件
// https://umijs.org/config/
import { defineConfig } from '@umijs/max';
import defaultSettings from './defaultSettings';
import proxy from './proxy';
import routes from './routes';
import deploy from './deploy';
// const logicalTransformPlugin = require('../postcss/index.js');
const { PROXY_ENV, DEPLOY_ENV } = process.env;
export default defineConfig({
define:{
DEPLOY_ENV:DEPLOY_ENV||false,
...deploy[DEPLOY_ENV || 'yonyou']
},
base:'/bamBudget/', // 用于外系统做ng反向代理
publicPath:'/bamBudget/',
hash: true,
antd: {},
request: {},
initialState: {},
model: {},
dva: {
immer: { enableES5: true },
},
// mfsu:true,
layout: {
// https://umijs.org/zh-CN/plugins/plugin-layout
locale: true,
siderWidth: 600,
...defaultSettings,
},
// https://umijs.org/zh-CN/plugins/plugin-locale
locale: false,
targets: {
ie: 11,
chrome: 78,
},
// umi routes: https://umijs.org/docs/routing
routes,
access: {},
// Theme for antd: https://ant.design/docs/react/customize-theme-cn
theme: {
// 如果不想要 configProvide 动态设置主题需要把这个设置为 default
// 只有设置为 variable, 才能使用 configProvide 动态设置主色调
// https://ant.design/docs/react/customize-theme-variable-cn
'root-entry-name': 'default',
'primary-color': '#4A90E5',
'layout-header-background': '#4A90E5',
},
ignoreMomentLocale: true,
proxy: proxy[PROXY_ENV || 'local'],
manifest: {
basePath: '/',
},
keepalive:[/admin/,/^\/budget/,/^\/systems\//, /budgetManagement/,/multidimensionalquery/],
tabsLayout:{
hasCustomTabs: true,
// hasDropdown: true,
},
// Fast Refresh 热更新
fastRefresh: true,
presets: ['umi-presets-pro'],
jsMinifier: 'terser',
// legacy: {
// buildOnly: true,
// nodeModulesTransform: true
// },
// devtool:'eval-cheap-module-source-map',
// extraPostCSSPlugins:[
// logicalTransformPlugin(),
// ]
});
2.app.tsx中使用
import { IconMap } from '@/components/Icon/indexIcon';
import RightContent from '@/components/RightContent';
import type { Settings as LayoutSettings } from '@ant-design/pro-components';
import type { MenuDataItem } from '@ant-design/pro-components';
import type { RunTimeLayoutConfig } from '@umijs/max';
import { WaterMark } from '@ant-design/pro-components';
import { history, getDvaApp } from '@umijs/max';
import defaultSettings from '../config/defaultSettings';
import routes from '../config/routes';
import { message, Tabs, Spin} from 'antd';
import { customRequestConfig } from '@/utils/request';
import storage from 'redux-persist/lib/storage';
import { persistStore, persistReducer } from 'redux-persist';
import { PersistGate } from 'redux-persist/integration/react';
import autoMergeLevel2 from 'redux-persist/lib/stateReconciler/autoMergeLevel2';
import { loopMenuItem } from '@/utils/dealTreeData';
import { getYearDate } from '@/utils/getDate';
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
import React,{ useState } from 'react';
import { getCurrentUser, getAuthRoutes } from './services/User/login';
import 'moment/locale/zh-cn';
import { menuHideList } from './utils/contrast';
import { StyleProvider } from '@ant-design/cssinjs';
import ErrorPage from '@/components/ErrorPage';
import LayoutTabs from '@/components/LayoutTabs';
import { loginTag } from '@/utils/symbol';
import { getToken,clearToken, setIHRInfo } from '@/utils/authority';
import {getTicket} from '@/shared/service/getTicket';
import {to} from 'await-to-js';
const loginPath = ['/bamBudget/user/login','/bamBudget/cas/login', '/bamBudget/user/innerlogin'];
type Menu = {
name?: string;
[key: string]: any;
};
interface selectOptions {
value: string;
label: string;
}
const localObj = {};
const loopRoutes = (menus: Menu[]): void => {
menus.forEach((item) => {
if (item.children) {
loopRoutes(item.children);
}
if (item.path && item.path != '/user' && item.path != '/' && !item?.redirect) {
localObj[item.path as string] = item.name
}
});
}
const persistConfig = {
key: 'root',
storage, //用localstorage进行存储
stateReconciler: autoMergeLevel2
};
const persistEnhancer = () => createStore => (reducer, initialState, enhancer) => {
const store = createStore(persistReducer(persistConfig, reducer), initialState, enhancer);
const persist = persistStore(store, null);
return {
persist,
...store,
};
};
export const dva = {
config: {
onError(e) {
e.preventDefault();
console.error(e.message);
},
extraEnhancers: [persistEnhancer()],
},
};
var firstObj ={};
export const getCustomTabs = (initialState) => {
loopRoutes(initialState?.routes || [])
if(initialState?.routes){
initialState?.routes?.forEach(item => {
firstObj[item.path] = item.name
})
}
return (s) => {
return (
<LayoutTabs {...s} localObj={localObj} firstObj={firstObj}/>
);
};
};
//映射菜单对应的图标
// const loopMenuItem1 = (menus: MenuDataItem[]): MenuDataItem[] =>
// // eslint-disable-next-line @typescript-eslint/no-shadow
// menus.map(({ icon, routes, ...item }) => ({
// ...item,
// icon: icon && IconMap[icon as string],
// children: routes && loopMenuItem1(routes),
// }));
/**
* @see https://umijs.org/zh-CN/plugins/plugin-initial-state
* */
export async function getInitialState(): Promise<{
settings?: Partial<LayoutSettings>;
currentUser?: API.CurrentUser;
loading?: boolean;
routes?: MenuDataItem[];
fetchUserInfo?: (id: string) => Promise<API.CurrentUser | undefined>;
fetchAuthRouteInfo?: () => Promise<API.MenuDataItem[] | undefined>;
years?: selectOptions[];
}> {
const fetchUserInfo = async () => {
const res = await getCurrentUser();
return res.data;
};
const transName = (data) => {
data.routes.forEach(item => {
if(item.menuName?.split('_非上市').length !=1){
item.menuName = item.menuName?.split('_非上市')[0]
}
if(item?.routes && item?.routes?.length != 0){
transName(item)
}
})
}
const fetchAuthRouteInfo = async () => {
try {
let msg = await getAuthRoutes();
if(msg?.data?.length !=0){
msg.data.forEach(item => {
if(item.menuName?.split('_非上市').length !=1){
item.menuName = item.menuName?.split('_非上市')[0]
}
if(item?.routes && item?.routes?.length != 0) {
transName(item)
}
})
}
return loopMenuItem(msg.data)
} catch (error) {
console.log(error);
}
return undefined;
};
const fetchTicket = async ()=> {
const [err,res] = await to(getTicket());
if(err) {
console.error('获取统一用户ticket失败!');
return null;
}
return res?.data?.ticket??'';
}
if (!loginPath.includes(location.pathname)) {
const currentUser = await fetchUserInfo();
const authInfo = await fetchAuthRouteInfo();
const ticket = await fetchTicket();
setIHRInfo(currentUser,ticket);
return {
currentUser,
settings: defaultSettings,
routes: authInfo || [],
fetchAuthRouteInfo,
};
}
return {
settings: defaultSettings,
fetchAuthRouteInfo,
};
}
const HeaderIcon = ()=>{
console.log(DEPLOY_ENV,'DEPLOY_ENV')
return ( true || DEPLOY_ENV==='uat' || DEPLOY_ENV==='prd')? (
<div className="header-left-logo">
</div>
):null;
}
export function rootContainer(container: React.ReactNode) {
// if (getToken()) {
// return React.createElement('div', null, null)
// }
let env=DEPLOY_ENV=='uat'?'UAT':DEPLOY_ENV=='sit'?'SIT':DEPLOY_ENV=='prd'?'PRD':"DEV"
let obj=location.pathname.includes('/bamBudget/user/login')
let title=!obj?localStorage.getItem("userInfo")!=undefined?JSON.parse(localStorage.getItem("userInfo"))?.userAccount:'':''
if(DEPLOY_ENV =='prd'){
return (
<StyleProvider hashPriority='high' children={container} />
)
}
return (
<WaterMark content={['集中化预算管理系统'+env+'环境',title]}>
<StyleProvider hashPriority='high' children={container} />
</WaterMark>
)
}
class CustomBoundary extends React.Component<
Record<string, any>,
{ hasError: boolean; errorInfo: string }
> {
state = { hasError: false, errorInfo: '' };
static getDerivedStateFromError(error: Error) {
return { hasError: true, errorInfo: error.message };
}
componentDidCatch(error: any, errorInfo: ErrorInfo) {
// You can also log the error to an error reporting service
// eslint-disable-next-line no-console
console.log(error, errorInfo);
}
render() {
if (this.state.hasError) {
// You can render any custom fallback UI
return (
<ErrorPage name='maintain' onClick={()=>{window.location.reload()}} title={'系统维护中,给您带来不便,敬请谅解'} />
);
}
return this.props.children;
}
}
const getTitle = (logo,title,props) => {
return <>
{
logo
}
<div style={{color: '#ffffff'}}>
<div style={{fontSize: '18px', height: '25px', lineHeight: 1}}>{`集中化预算管理系统${DEPLOY_ENV === 'uat' ? '(UAT)' : ''}`}</div>
<div style={{fontSize: '12px', lineHeight: 1}}>热线电话:4001381860-04</div>
</div>
</>
}
//ProLayout 支持的api https://procomponents.ant.design/components/layout
export const layout: RunTimeLayoutConfig = ({ initialState, setInitialState }) => {
const [collapsed,setCollapsed] = useState(false);
const collapsedButtonContent = (collapsed:boolean)=>{
return (
<div className="cusMuencollapsed" onClick={()=>{setCollapsed(!collapsed)}}>
{collapsed?<MenuUnfoldOutlined />:<MenuFoldOutlined />}
</div>
)
}
return {
logo:<HeaderIcon />,
pageTitleRender:false,
token:{
header: {
colorBgHeader: '#4A90E5',
colorHeaderTitle: '#fff',
colorTextMenu: '#fff',
colorTextMenuActive: '#fff',
colorTextMenuSelected: '#fff',
colorBgMenuItemActive: '#1466CF',
colorBgMenuItemHover: '#1466CF',
colorBgMenuItemSelected: '#1466CF',
colorTextRightActionsItem: '#fff',
},
sider:{
colorBgCollapsedButton: '#fff',
colorTextCollapsedButtonHover: 'rgba(0,0,0,0.65)',
colorTextCollapsedButton: 'rgba(0,0,0,0.45)',
colorMenuBackground: '#2B2C3A',
colorBgMenuItemCollapsedHover: '#4A90E5',
colorBgMenuItemCollapsedSelected: '#4A90E5',
colorMenuItemDivider: 'rgba(255,255,255,0.15)',
colorBgMenuItemHover: '#4A90E5',
colorBgMenuItemSelected: '#4A90E5',
colorTextMenuSelected: '#fff',
colorTextMenuItemHover: 'rgba(255,255,255,0.75)',
colorTextMenu: 'rgba(255,255,255,0.75)',
colorTextMenuSecondary: 'rgba(255,255,255,0.65)',
colorTextMenuTitle: 'rgba(255,255,255,0.95)',
colorTextMenuActive: 'rgba(255,255,255,0.95)',
colorTextSubMenuSelected: '#fff',
}
},
rightContentRender: () => <RightContent />,
// headerRender:(props,d) => {
// console.log(props);
// return <StyleProvider hashPriority="high">
// {d}
// </StyleProvider>
// },
disableContentMargin: false,
onPageChange: () => {
setInitialState((preInitialState: any) => ({
...preInitialState,
years:getYearDate(10)
}));
},
menuHeaderRender: undefined,
menuDataRender: () => {
//return loopMenuItem(routes || []);
return menuHideList.includes(history.location.pathname) ? [] : (initialState?.routes ?? [])
// return routes;
},
collapsed: collapsed,
collapsedButtonRender:collapsedButtonContent,
getCustomTabs:getCustomTabs(initialState),
menu: {
locale: false,
},
ErrorBoundary:CustomBoundary,
// 自定义 403 页面
// unAccessible: <div>unAccessible</div>,
// 增加一个 loading 的状态
childrenRender: (children, props) => {
const app = getDvaApp();
const persistor = app._store.persist;
return (
<div className='right-context-box'>
<PersistGate
loading={<Spin />}
persistor={persistor}
>
{children}
</PersistGate>
</div>
);
},
onMenuHeaderClick: () => {return;},
...initialState?.settings,
headerTitleRender: getTitle,
};
};
// 配置运行时request
export const request = {
...customRequestConfig,
};
通过getCustomTabs获得tabs的内容,返回的是LayoutTabs组件
3.LayoutTabs.tsx组件
import { useDispatch } from '@umijs/max';
import { message, Tabs } from 'antd';
const { TabPane } = Tabs;
const LayoutTabs: React.FC = (props) => {
const dispatch = useDispatch();
const {
isKeep,
keepElements,
navigate,
dropByCacheKey,
activeKey,
localObj,
firstObj, // 第一层路由
} = props;
// console.log(props, 'props')
// 对路由进行分割
var firstData = {} // 第一个动态路由导航
var transData ={} // 除第一个剩余的路由导航
Object.entries(keepElements?.current)?.map(([key,value])=>{
if(!Object.keys(firstObj).includes(key)){
transData[key] = value
}else{
if(activeKey == key){
firstData[key] = value?.location?.pathname??key;;
}else{
let transKey = activeKey.split('/')[1].split('/')[0]
let firstKey = "/" + transKey
firstData[firstKey] = firstKey
}
dispatch({
type: 'menuTabs/saveActiveKey',
payload: activeKey,
})
}
})
return (
<div className="runtime-keep-alive-tabs-layout" hidden={!isKeep}>
<Tabs
hideAdd
onChange={(key: string) => {
navigate(key);
}}
activeKey={activeKey}
destroyInactiveTabPane={true}
type="editable-card"
onEdit={(targetKey: any): void => {
const allTabs = [];
// 每次将第一层的路由放在第一个位置
allTabs.push(Object.values(firstData)[0]);
if(keepElements?.current){
Object.entries(transData)?.forEach(([key,value])=>{
const pathname = value?.location?.pathname??key;
allTabs.push(pathname);
})
}
let editIndex = -1;
for (let i = 0; i < allTabs.length; i++) {
if (allTabs[i] === targetKey) {
editIndex = i;
}
}
let newActiveKey = activeKey;
if(editIndex>-1 && editIndex<allTabs.length){
// 删除的是当前激活的页签
if(activeKey === targetKey){
// 删除的是最后一个,上一个需要激活
if(editIndex === allTabs.length-1){
newActiveKey = allTabs[editIndex-1];
}else{
newActiveKey = allTabs[editIndex+1];
}
}
dropByCacheKey(targetKey);
if (newActiveKey !== location?.pathname) {
navigate(newActiveKey);
}
}else{
console.error('tab页中key出现异常,需要排查所有路由对应的tab!');
}
}}
>
{
JSON.stringify(firstData) != '{}' &&
<TabPane tab={<div>{localObj[Object.values(firstData)[0]] || Object.values(firstData)[0]}</div>} key={Object.values(firstData)[0]} closable={false}/>
}
{Object.entries(transData)?.map(
([key,value]: any) => {
const pathname = value?.location?.pathname??key;
return (
<TabPane tab={<div>{localObj[pathname] || pathname}</div>} key={pathname} closable={Object.entries(keepElements.current).length === 1 ? false : true} />
)
})
}
</Tabs>
</div>
)
}
export default LayoutTabs;
4.路由导航页面
import layoutTabs from '@/utils/tabsTool';
import { useModel, useSelector } from '@umijs/max';
import { Card, Col, List, Row } from 'antd';
import { useEffect, useState } from 'react';
import styles from './style.less';
import navigation from '@/assets/navigation/navigation.png'
function NavigationMenu() {
const { pushAndRefreshTab } = layoutTabs();
const activeKey = useSelector((state) => state.menuTabs.activeKey);
const {
initialState: { routes },
} = useModel('@@initialState');
const [loading, setLoading] = useState(true);
const [routeMenu, setRouteMenu] = useState(null);
const [saveTip, SetSaveTip] =useState(undefined)
console.log(routes, 'routes', activeKey, 'activeKey');
useEffect(() => {
// console.log(routeMenu, 'routeMenu');
if (activeKey && routes?.length != 0) {
setRouteMenu(routes?.filter((item) => item.path == activeKey)[0]);
setLoading(false)
}
}, [activeKey, routes]);
const pushClik = (item) => {
pushAndRefreshTab(item.path.replace('/bamBudget', ''));
};
const textMenu = (data) => {
for(let i=0; i<data.length; i++){
if(data[i].children && data[i].children?.length !=0){
SetSaveTip(true)
return true;
}
}
SetSaveTip(false)
return false;
}
const cardContent = (data, tag, tip) => {
let middleData = [];
let transData = [];
if (data?.children) {
middleData = data?.children?.filter((item) => !item?.children || item?.children.length == 0);
transData = data?.children?.filter((item) => item?.children && item?.children.length != 0);
} else {
middleData = [data];
}
return (
<div>
{middleData.length != 0 && (
<div style={tag ? { marginLeft: '10px', marginTop: '10px' } : {}}>
<List
grid={{
gutter: 16,
column: 3,
}}
dataSource={[...middleData]}
renderItem={(item, index) => (
<List.Item>
<span style={tip == saveTip ? {marginLeft: '23px'} : {}} className={styles.titletag} onClick={() => pushClik(item)}>
{item.name}
</span>
</List.Item>
)}
/>
</div>
)}
{transData.length != 0 && (
<List
grid={{
gutter: 16,
column: 1,
}}
dataSource={[...transData]}
renderItem={(item, index) => (
<List.Item>
<span style={tip ? {paddingLeft: '23px'} : {}}>
<span
className={item?.children && item.children?.length != 0 ? styles.titleTip : ''}
>
{item.name}
</span>
</span>
{item?.children && item.children.length != 0 ? cardContent(item, 'tag', textMenu(item.children) ) : ''}
</List.Item>
)}
/>
)}
</div>
);
};
const titleContent =(name) => {
return <div className={styles.titeBox}>
<img src={navigation}></img>
<span>{name}</span>
</div>
}
return (
<div className={styles.cardBox}>
<Row gutter={[16, 16]}>
{routeMenu &&
routeMenu?.children?.map((item) => {
return (
<Col span={12}>
<Card title={titleContent(item?.name)} bordered={false} loading={loading}>
{cardContent(item, '', '')}
</Card>
</Col>
);
})}
</Row>
</div>
);
}
export default NavigationMenu;