效果如下:


参考: https://github.com/yunsii/ant-design-pro-plus


安装包

yarn add use-switch-tabs

创建组件SwitchTabs

src/component/SwitchTabs/index.tsx

import React, { useEffect, useRef, useMemo } from 'react';
import { Tabs, Dropdown, Menu } from 'antd';
import { history, useLocation, useIntl } from 'umi';
import type { TabsProps } from 'antd/lib/tabs';
import type { MenuProps } from 'antd/lib/menu';
import type * as H from 'history-with-query';
import { useMemoizedFn } from 'ahooks';
import type { UseSwitchTabsOptions, ActionType } from 'use-switch-tabs';
import useSwitchTabs from 'use-switch-tabs';
import classNames from 'classnames';
import _get from 'lodash/get';

import styles from './index.less';

enum CloseTabKey {
  Current = 'current',
  Others = 'others',
  ToRight = 'toRight',
}

export interface RouteTab {
  /** tab's title */
  tab: React.ReactNode;
  key: string;
  content: JSX.Element;
  closable?: boolean;
  /** used to extends tab's properties */
  location: Omit<H.Location, 'key'>;
}

export interface SwitchTabsProps
  extends Omit<UseSwitchTabsOptions, 'location' | 'history'>,
    Omit<TabsProps, 'hideAdd' | 'activeKey' | 'onEdit' | 'onChange' | 'children'> {
  fixed?: boolean;
  footerRender?: (() => React.ReactNode) | false;
}

export default function SwitchTabs(props: SwitchTabsProps): JSX.Element {
  const { mode, fixed, originalRoutes, setTabName, persistent, children, footerRender, ...rest } =
    props;

  const { formatMessage } = useIntl();
  const location = useLocation() as any;
  const actionRef = useRef<ActionType>();

  const { tabs, activeKey, handleSwitch, handleRemove, handleRemoveOthers, handleRemoveRightTabs } =
    useSwitchTabs({
      children,
      setTabName,
      originalRoutes,
      mode,
      persistent,
      location,
      history,
      actionRef,
    });

  const remove = useMemoizedFn((key: string) => {
    handleRemove(key);
  });

  const handleTabEdit = useMemoizedFn((targetKey: string, action: 'add' | 'remove') => {
    if (action === 'remove') {
      remove(targetKey);
    }
  });

  const handleTabsMenuClick = useMemoizedFn((tabKey: string): MenuProps['onClick'] => (event) => {
    const { key, domEvent } = event;
    domEvent.stopPropagation();

    if (key === CloseTabKey.Current) {
      handleRemove(tabKey);
    } else if (key === CloseTabKey.Others) {
      handleRemoveOthers(tabKey);
    } else if (key === CloseTabKey.ToRight) {
      handleRemoveRightTabs(tabKey);
    }
  });

  const setMenu = useMemoizedFn((key: string, index: number) => (
    <Menu onClick={handleTabsMenuClick(key)}>
      <Menu.Item disabled={tabs.length === 1} key={CloseTabKey.Current}>
        {formatMessage({ id: 'component.switchTabs.closeCurrent' })}
      </Menu.Item>
      <Menu.Item disabled={tabs.length === 1} key={CloseTabKey.Others}>
        {formatMessage({ id: 'component.switchTabs.closeOthers' })}
      </Menu.Item>
      <Menu.Item disabled={tabs.length === index + 1} key={CloseTabKey.ToRight}>
        {formatMessage({ id: 'component.switchTabs.closeToRight' })}
      </Menu.Item>
    </Menu>
  ));

  const setTab = useMemoizedFn((tab: React.ReactNode, key: string, index: number) => (
    <span onContextMenu={(event) => event.preventDefault()}>
      <Dropdown overlay={setMenu(key, index)} trigger={['contextMenu']}>
        <span className={styles.tabTitle}>{tab}</span>
      </Dropdown>
    </span>
  ));

  useEffect(() => {
    window.tabsAction = actionRef.current!;
  }, []);

  const footer = useMemo(() => {
    if (typeof footerRender === 'function') {
      return footerRender();
    }
    return footerRender;
  }, [footerRender]);

  return (
    <Tabs
      tabPosition="top"
      type="editable-card"
      tabBarStyle={{ margin: 0 }}
      tabBarGutter={0}
      animated
      className={classNames('switch-tabs', { 'switch-tabs-fixed': fixed })}
      {...rest}
      hideAdd
      activeKey={activeKey}
      onEdit={handleTabEdit as TabsProps['onEdit']}
      onChange={handleSwitch}
    >
      {tabs.map((item, index) => (
        <Tabs.TabPane
          tab={setTab(item.title, item.key, index)}
          key={item.key}
          closable={item.closable}
          forceRender={_get(persistent, 'force', false)}
        >
          <main className={styles.content}>{item.content}</main>
          {footer}
        </Tabs.TabPane>
      ))}
    </Tabs>
  );
}

src/component/SwitchTabs/index.less

.tabTitle {
  position: relative;
  font-weight: initial;

  &::before {
    position: absolute;
    top: -10px;
    left: -16px;
    width: calc(100% + 24px);
    height: calc(100% + 20px);
    content: ' ';
  }

  &::after {
    position: absolute;
    top: -10px;
    right: -40px;
    width: 12px;
    height: calc(100% + 20px);
    content: ' ';
  }
}

.content {
  flex: auto;
}

:global {
  .custom-by-switch-tabs {
    .ant-layout {
      .ant-layout {
        // 当值 Tabs force render 时向右切换出现闪烁的纵向滚动条
        overflow: hidden;
      }
    }
  }

  .switch-tabs-fixed {
    > .ant-tabs-nav {
      // height: 41px;
      // border-bottom: 1px solid #fff;
      box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
    }

    > .ant-tabs-content-holder {
      // 当值 Tabs force render 时向右切换出现闪烁的纵向滚动条
      overflow: hidden;

      .ant-tabs-content {
        //add by goby
        > div.ant-tabs-tabpane-hidden {
          display: none;
        }
        .ant-tabs-tabpane {
          display: flex;
          flex-direction: column;
        }

        .ant-tabs-tabpane-active {
          height: calc(100vh - 88px);
          overflow: auto;
        }
      }
    }
  }

  .switch-tabs {
    .ant-tabs-tab-remove {
      width: 1.6em;
      height: 1.6em;
      padding: 0;
      border-radius: 0.8em;

      &:hover {
        background: #d9d9d9;
      }
    }
  }
}

创建SwitchTabsLayout

src/component/SwitchTabsLayout/index.tsx

/**
 * Ant Design Pro v4 use `@ant-design/pro-layout` to handle Layout.
 * You can view component api by:
 * https://github.com/ant-design/ant-design-pro-layout
 */
import type { MenuDataItem } from '@ant-design/pro-layout';
import React from 'react';
import type * as H from 'history-with-query';
import { useIntl, useLocation } from 'umi';
import _isArray from 'lodash/isArray';
import memoizedOne from 'memoize-one';
import deepEqual from 'fast-deep-equal';
import type { Route } from '@ant-design/pro-layout/lib/typings';
import { PageLoading } from '@ant-design/pro-layout';

import type { Mode, RouteConfig } from 'use-switch-tabs';
import { isSwitchTab } from 'use-switch-tabs';
import type { SwitchTabsProps } from '@/components/SwitchTabs';
import SwitchTabs from '@/components/SwitchTabs';

export interface MakeUpRoute extends Route, Pick<RouteConfig, 'follow' | 'redirect'> {}

/** 根据路由定义中的 name 本地化标题 */
function localeRoutes(
  routes: MakeUpRoute[],
  formatMessage: any,
  parent: MakeUpRoute | null = null,
): MenuDataItem[] {
  const result: MenuDataItem[] = [];

  routes.forEach((item) => {
    const { routes: itemRoutes, ...rest } = item;

    if (item.layout === false || item.path?.startsWith('/_demos')) {
      return;
    }
    // 为标签页展示的页面注入 redirect 路由
    if (item.redirect && item.path !== '/') {
      result.push(item);
      return;
    }
    if (!item.name) {
      return;
    }

    // 初始化 locale 字段
    let newItem: MenuDataItem = {
      ...rest,
      routes: [],
      locale: item.name,
    };

    const localeId = parent ? `${parent.locale}.${newItem.locale}` : `menu.${newItem.locale}`;

    newItem = {
      ...rest,
      locale: localeId,
      name: formatMessage({ id: localeId }),
    };

    if (_isArray(itemRoutes) && itemRoutes.length) {
      newItem = {
        ...newItem,
        children: localeRoutes(itemRoutes, formatMessage, newItem),
      };
    }

    result.push(newItem);
  });

  return result;
}

const memoizedOneLocaleRoutes = memoizedOne(localeRoutes, deepEqual);

export interface RouteTabsLayoutProps
  extends Pick<SwitchTabsProps, 'persistent' | 'fixed' | 'setTabName' | 'footerRender'> {
  mode?: Mode | false;
  loading?: boolean;
  routes?: MakeUpRoute[];
  children: React.ReactElement;
}

export default function SwitchTabsLayout(props: RouteTabsLayoutProps): JSX.Element {
  const { mode, loading, routes, children, ...rest } = props;

  const { formatMessage } = useIntl();
  const location = useLocation() as H.Location;
  const originalTabsRoutes = memoizedOneLocaleRoutes(routes!, formatMessage);

  if (mode && isSwitchTab(location as any, originalTabsRoutes)) {
    if (loading) {
      return <PageLoading />;
    }
    if (routes) {
      return (
        <SwitchTabs
          mode={mode}
          {...rest}
          originalRoutes={originalTabsRoutes}
          // animated={false}
        >
          {children}
        </SwitchTabs>
      );
    }
  }
  return children;
}

修改src/app.tsx

import

import SwitchTabsLayout from '@/components/SwitchTabsLayout';

调整layout

export const layout: RunTimeLayoutConfig = ({ initialState, setInitialState }) => {
  // +++
  const { switchTabs, ...restSettings } = initialState?.settings || {};


  return {
    // rightContentRender: () => <RightContent />,
    rightContentRender: () => (
      <RightContent switchTabsReloadable={switchTabs?.mode && switchTabs.reloadable} />
    ),
    
    ...
    
    // +++
    className: switchTabs?.mode && 'custom-by-switch-tabs',
    
    ...
    
    childrenRender: (children, props) => {
      console.log('childrenRender props', props);

      const { route } = props;
      return (
        <SwitchTabsLayout
          mode={switchTabs?.mode}
          persistent={switchTabs?.persistent}
          fixed={switchTabs?.fixed}
          routes={route!.routes}
          footerRender={() => <Footer />}
        >
          {children}
        </SwitchTabsLayout>
      );
    },
    ...initialState?.settings,
  };
};

修改 src/global.less

增加

.custom-by-switch-tabs {
  .ant-pro-basicLayout-content {
    margin: unset;

    & .ant-pro-page-container {
      margin: unset;
    }
  }
}

可选

右上角增加刷新按钮

修改 src/components/RightContent/index.tsx


import { Space, Dropdown, Tooltip } from 'antd';
import { QuestionCircleOutlined, DownOutlined, ReloadOutlined } from '@ant-design/icons';
import React from 'react';
import { useModel, SelectLang, useLocation, history,useIntl } from 'umi';
import Avatar from './AvatarDropdown';
import HeaderSearch from '../HeaderSearch';
import styles from './index.less';
import NoticeIconView from '../NoticeIcon';
export type SiderTheme = 'light' | 'dark';

export interface GlobalHeaderRightProps {
  switchTabsReloadable?: boolean;
}

const GlobalHeaderRight: React.FC<GlobalHeaderRightProps> = ({ switchTabsReloadable }) => {
  const uri: any = useLocation();
  const { query } = uri;

  const { initialState } = useModel('@@initialState');
  const { formatMessage } = useIntl();
  
  ...
  
  return (
    <Space className={className}>
      ...
      
      {switchTabsReloadable ? (
        <Tooltip title={formatMessage({ id: 'component.globalHeader.reload' })}>
          <a
            style={{
              color: 'inherit',
            }}
            className={styles.action}
            onClick={() => window.tabsAction.reloadTab()}
          >
            <ReloadOutlined />
          </a>
        </Tooltip>
      ) : null}
      
      {initialState?.currentUser && !isHideHeader && <span
        className={styles.action}
      >
      </span>}
      <Avatar menu={false} />
      {/* <SelectLang className={styles.action} /> */}
    </Space>
  );
};

export default GlobalHeaderRight;