3.基于Umi4多tabs缓存标签,展示路由导航页面

1,887 阅读5分钟

需求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;

5.参考链接

juejin.cn/post/715352… alitajs.com/zh-CN/docs/…