Umi项目搭建

umijs是一个基于react的前端框, 无论是用于前台还是后台,都是一个不错的选择

下面根据umijs的官方文档,来搭建一个umi项目, 并且添加一些常用的配置

安装umi

可查看官网: umijsopen in new window

执行以下命令安装umi:

npx create-umi@latest

会提示是否安装umi, 如图, 此时的umi版本是4.3.11, 我们选择Y安装

安装1

接下来要求我们输入项目名称, 选择 Ant Design Pro 模板,就能使用 @umijs/max 来创建项目了。输入后继续操作

安装1

下面就是根据情况选择了, 我选了npm, 源选择taobao, 回车继续, 等待依赖安装完成

安装1

看到下面,说明项目创建成功了

安装1

运行项目:

npm run dev

优化项目

进来之后就是这个页面, 但是这个页面是umijs的默认页面, 我们需要对项目进行优化

默认页面

表格组件修改

默认的表格属性可以进行优化, 如图, 去掉表格左上角的标题, 以及选择时出现的已选择xx项 取消选择的提示

默认页面

在ProTable组件中添加如下属性:

headerTitle = "查询表格"
// 取消当选择行数据时,表格上方的'取消选择'提示框
tableAlertRender = { false }
// 取消当选择行数据时,表格上方的'已选择*项'提示框
tableAlertOptionRender = { false }
<ProTable<API.UserInfo>
  headerTitle="查询表格"
  actionRef={actionRef}
  rowKey="id"
  search={{
    labelWidth: 120,
  }}
  // 取消当选择行数据时,表格上方的'已选择*项  取消选择'提示框
  tableAlertRender={false}
  tableAlertOptionRender={false}
  toolBarRender={() => [
    <Button
      key="1"
      type="primary"
      onClick={() => handleModalVisible(true)}
    >
      新建
    </Button>,
  ]}
  columns={columns}
  rowSelection={{
    onChange: (_, selectedRows) => setSelectedRows(selectedRows),
  }}
/>

一下是一些表格属性, 供参考

<EditableProTable<any>
  headerTitle={config?.headerTitle}
  actionRef={actionRef}
  rowKey={config?.rowKey}
  editable={{
    type: 'multiple',
    onlyAddOneLineAlertMessage: '只能新增一行',
    onlyOneLineEditorAlertMessage: '只能编辑一行',
    onSave: async (key, row) => {
      if (saveRowData && typeof row.id === 'number') {
        await saveRowData(row)
        actionRef.current?.cancelEditable(key)
        actionRef.current?.reload()
      }
      if (addRowData && row.id?.startsWith('add')) {
        await addRowData(row)
        actionRef.current?.reload()
      }
    },
    onDelete: async (key, row) => {
      await handleAction('deleted', row)
    },
    // 当同时开启多行编辑时,保存之前编辑的行(或者取消之前添加的行)
    onChange: async (keys) => {
      // 如果同时编辑多行,先将之前的保存
      if (keys.length > 1) {
        // 如果是新增的行, 取消编辑状态
        if (String(keys[0])?.startsWith('add')) {
          actionRef.current?.cancelEditable(keys[0])
        } else {
          actionRef.current?.saveEditable(keys[0])
        }
        setEditRowKeys(keys.slice(-1))
      } else {
        setEditRowKeys(keys)
      }
    },
  }}
  // 参考文档 https://procomponents.ant.design/components/editable-table#recordcreatorprops
  recordCreatorProps={{
    position: 'top',
    record: () => ({ id: (Math.random() * 1000000).toFixed(0) }),
    style: {
      display: 'none',
    },
  }}
  options={{
    setting: {
      listsHeight: 400,
    },
    fullScreen: true,
  }}
  scroll={{ x: true }}
  search={
    search === false
      ? search
      : {
        labelWidth: 'auto',
        span: {
          xs: 12,
          sm: 12,
          md: 12,
          lg: 6,
          xl: 6,
          xxl: 6,
        },
      }
  }
  toolBarRender={toolBarRender}
  toolbar={toolBar}
  params={{ ...params, ...useParams() }}
  request={request}
  columns={columns}
  columnsState={{
    persistenceKey: location.pathname?.toString() + '/columns',
    persistenceType: 'localStorage',
    // option操作栏默认固定在右侧
    defaultValue: {
      option: {
        fixed: 'right',
      },
    },
  }}
  pagination={{
    ...pagination,
    showSizeChanger: true,
    showQuickJumper: true,
    pageSizeOptions: [
      '3',
      '5',
      '10',
      '20',
      '50',
      '100',
      '300',
      '500',
      '800',
      '1000',
      '5000',
    ],
    onChange: (page, pageSize) => {
      setPagination({ current: page, pageSize })
      sessionStorage.setItem(
        location.pathname?.toString() + '/pagination',
        JSON.stringify({ current: page, pageSize }),
      )
    },
  }}
  rowSelection={{
    onChange: (_, selectedRows) => setSelectedRows(selectedRows),
  }}
  // 取消当选择行数据时,表格上方的'已选择*项  取消选择'提示框
  tableAlertRender={false}
  tableAlertOptionRender={false}
/>

.umirc.ts

这是构建时配置, 用于配置构建时的一些配置, 比如代理, 路由, 主题等

import { defineConfig } from '@umijs/max'
// 项目中全部的路由配置
import { routes } from './src/routes'

export default defineConfig({
  antd: {
    configProvider: {},
    // 开启暗色主题
    dark: false,
    // 开启紧凑主题
    compact: false,
    style: 'less',
    theme: {},
    appConfig: {},
    // 是否使用 moment.js 作为日期处理工具
    momentPicker: false,
    // 配置 antd 的 StyleProvider 组件,该组件用于兼容低版本浏览器
    styleProvider: {
      hashPriority: 'high',
      legacyTransformer: true,
    },
  },
  dva: {},
  access: {},
  model: {},
  initialState: {},
  request: {
    dataField: 'data',
  },
  locale: {
    default: 'zh-CN',
    antd: true,
    title: true,
    // 浏览器语言检测
    baseNavigator: true,
    baseSeparator: '-',
  },
  layout: {
    locale: true,
  },
  routes,
  npmClient: 'npm',
  tailwindcss: {},
})

app.ts

包含布局, 动态路由, 请求相关, 全局数据重新获取 等的配置

动态路由的实现思路:

1.在layout配置中的menu属性中配置request属性

2.通过pro-components提供的getMenuData方法传入设定好的路由,然后得到菜单数据

import Logo from '@/components/Logo'
import RightRender from '@/components/RightRender'
import { dataSourceMap, defaultDataSource } from '@/config'
import {
  DATA_SOURCE_STORAGE,
  LOGIN_PATH,
  USER_TOKEN_STORAGE,
} from '@/constants'
import { asyncRoutes } from '@/routes/asyncRoutes'
import { filterAdminRoutes, menuRender } from '@/routes/handleRouteRender'
import { reqUserInfo } from '@/services/userController'
import { getToken } from '@/utils/authorization'
import { extractDomainSuffix } from '@/utils/getNationCode'
import { getMenuData } from '@ant-design/pro-components'
import { RunTimeLayoutConfig } from '@umijs/max'
import { message } from 'antd'
import type { RequestConfig } from 'umi'
import { getDvaApp, getIntl, matchRoutes } from 'umi'
import { GLOBAL } from '../typings'

// 运行时配置 全局初始化数据配置,用于 Layout 用户信息和权限初始化
export async function getInitialState(): Promise<{
  userInfo: API.UserInfo
  dataSource: keyof typeof dataSourceMap
}> {
  // 获取用户信息
  const token = localStorage.getItem(USER_TOKEN_STORAGE)
  let data = {} as API.UserInfo
  try {
    if (token) {
      data = (await reqUserInfo()).data
    }
  } catch (error) {
  }
  
  return {
    userInfo: data,
    dataSource: defaultDataSource,
  }
}

// 运行时配置, 用于配置全局的 layout
export const layout: RunTimeLayoutConfig = ({ initialState }) => {
  const intl = getIntl()
  
  return {
    title: intl.formatMessage({ id: 'site.title' }),
    logo: <Logo />,
    menu: {
      locale: true,
      // 每当 initialState?.dataSource 发生修改时重新执行 request
      params: {
        dataSource: initialState?.dataSource,
      },
      request: async (params, defaultMenuData) => {
        const menuData = getMenuData(
          filterAdminRoutes(
            asyncRoutes[initialState!.dataSource],
            initialState?.userInfo,
          ),
          { locale: true },
          intl.formatMessage,
          menuRender,
        )
        return menuData.menuData
      },
    },
    layout: 'mix',
    logout: () => {
      // 跳转到登录页
      window.location.href = LOGIN_PATH
    },
    // 右上角渲染
    rightRender: () => <RightRender />,
  }
}

export const request: RequestConfig = {
  timeout: 30000,
  baseURL: dataSourceMap[defaultDataSource].baseUrl,
  errorConfig: {
    errorThrower() {
    },
    errorHandler: (error: any) => {
      const { formatMessage } = getIntl()
      // token失效跳转登录页
      if (error.response?.status === 401) {
        message
          .open({
            key: 'login',
            content: formatMessage({ id: 'site.unauthorized.title' }),
            duration: 3,
          })
          .then()
        // 清除token
        localStorage.removeItem(USER_TOKEN_STORAGE)
        setTimeout(() => {
          window.location.href = LOGIN_PATH
        }, 100)
      } else {
        message.error(error.message).then()
      }
    },
  },
  requestInterceptors: [
    (url: any, options: any) => {
      
      return {
        url,
        options: {
          ...options,
          // 上传进度条和其他配置
          ...options.config,
          headers: {
            Authorization: getToken(),
          },
          params: {
            ...options.params,
          },
        },
      }
    },
  ],
  responseInterceptors: [
    // 对状态码检查
    (response: any) => {
      if (response.data.code && response.data.code !== 200) {
        message.error(response?.data?.msg).then()
      }
      
      return response
    },
  ],
}

export function onRouteChange({
  clientRoutes,
  location,
}: {
  clientRoutes: GLOBAL.IRouteObject[]
  location: Location
}) {
  const route = matchRoutes(clientRoutes, location.pathname)?.pop()?.route as
    | GLOBAL.IRouteObject
    | undefined
  if (route) {
    const dispatch = getDvaApp()._store.dispatch
    const userStore = getDvaApp()._store.getState().user
    // 如果用户信息不存在则请求用户信息
    if (!userStore.userInfo?.username) {
    }
  }
}

处理路由渲染和权限判断的方法

// @/routes/handleRouteRender.ts
import * as Icon from '@ant-design/icons'
import { MenuDataItem } from '@ant-design/pro-components'
import { cloneDeep } from 'lodash'
import React, { ReactNode } from 'react'
import { GLOBAL } from '../../typings'

/**
 * @description antd-pro的路由渲染方法
 * */
export const menuRender = (menuData: MenuDataItem[]) => {
  return menuData.map((menu) => {
    return {
      ...menu,
      icon: renderIcon(menu.icon),
    }
  })
}

/**
 * @description 渲染路由的图标, 用于展示在菜单栏
 * @param icon 路由的图标
 * @returns ReactNode
 *  */
export const renderIcon = (icon: string | ReactNode | undefined) => {
  // 如果是ReactNode则直接返回
  if (React.isValidElement(icon)) {
    return icon
  }
  // 如果是字符串则渲染对应的图标
  if (typeof icon === 'string') {
    // 先去掉 '<' 和 '/>'
    const iconIndex = icon.replace(/<|\/>/g, '').trim()
    const IconComponent = (Icon as any)[iconIndex as string]
    if (!IconComponent) {
      return null
    }
    return React.createElement(IconComponent)
  }
  // 其他情况返回null
  return null
}

/**
 * @description 过滤最高权限路由, 当用户名为administrator时, 且路由标志admin=true时, 返回该路由
 * @param routes 路由列表
 * @param userInfo 用户信息
 * @returns 过滤后的路由列表
 *  */
export const filterAdminRoutes = (
  routes: GLOBAL.IRouteObject[],
  userInfo: API.UserInfo | undefined,
) => {
  // 如果当前用户为管理员, 则返回所有路由
  if (userInfo && userInfo?.username?.toLowerCase() === 'administrator') {
    return routes
  }
  // 否则返回非admin路由
  return routes.filter((route) => !route.admin)
}

/**
 * @description 根据用户的menuTree过滤路由, 只返回用户有权限的路由
 * @param routes 路由列表
 * @param menuTree 用户的菜单树
 * @returns 过滤后的路由列表
 * */
export const filterRoutesByMenuTree = (
  routes: any[],
  menuTree: API.UserMenuItem[],
) => {
  // 如果用户没有菜单树, 则返回空
  if (!menuTree || !menuTree.length) {
    return []
  }
  
  // 将routes和menuTree的name字段进行递归比对, 如果相同则返回
  return cloneDeep(routes).filter((route) => {
    const menu = menuTree.find((item) => item.name === route.name)
    if (menu) {
      if (route.routes) {
        route.routes = filterRoutesByMenuTree(route.routes, menu.children || [])
      }
      return true
    }
    return false
  })
}

路由权限access判断

// '@/permission/router.ts
import { GLOBAL } from '../../typings'

/**
 * @description 路由全部路径 采用递归的形式 将所有父级路由和子路由的路径拼接成字符串
 * @param routes 路由配置 - 可传本地路由配置 或者 用户信息中的权限路由配置
 * @param {string} basePath 基础路径
 * @returns {string[]} 返回所有路由的路径
 * */
export const getAllRoutePaths = (
  routes: GLOBAL.IRouteObject[],
  basePath: string = '',
): string[] => {
  if (!Array.isArray(routes)) {
    return []
  }
  
  let allRoutePaths: string[] = []
  routes.forEach((route) => {
    const fullPath = basePath + route.path
    if (route.routes) {
      const nestedRoutePaths = getAllRoutePaths(route.routes, fullPath + '/')
      allRoutePaths.push(...nestedRoutePaths)
    } else {
      allRoutePaths.push(fullPath)
    }
  })
  
  return allRoutePaths
}

/**
 * @description 获取路由元信息, 即获取原始路由配置的所有信息
 * @param routes 路由配置
 * @param path 路由路径
 * */
export const getRouteMeta = (
  routes: GLOBAL.IRouteObject[],
  path: string = '',
): GLOBAL.IRouteObject | undefined => {
  if (Array.isArray(routes) && path) {
    return routes.find((route) => route.path === path)
  }
  return undefined
}

/**
 * @description 最高权限路由判断, 路由标志admin=true时, 且用户名为administrator时 返回true, admin为false时返回true
 * @param routes 路由配置
 * @param path 路由路径
 * @param userInfo 用户信息
 * */
export const canAccessAdminRoute = (
  routes: GLOBAL.IRouteObject[],
  path: string = '',
  userInfo: API.UserInfo | undefined,
) => {
  // 获取路由元信息
  const routeMeta = getRouteMeta(routes, path)
  // 只判断一种情况, 当路由标志admin=true时, 且用户名不为administrator时 false, 其他情况返回true
  if (routeMeta && routeMeta.admin) {
    return !!(userInfo && userInfo.username.toLowerCase() === 'administrator')
  }
  return true
}

/**
 * @description 判断当前路由是否有权限访问
 * @param routes 路由配置
 * */
export const canRouteAccess = (routes: GLOBAL.IRouteObject[]) => {
  return (
    currentPath: string,
    userInfo: API.UserInfo | undefined,
    path: string | undefined,
  ) => {
    // 获取全部的路由的完整路径
    const allRoutePaths: string[] = getAllRoutePaths(routes)
    // 判断当前路径是否是超级管理员权限
    const accessAdminRoute = canAccessAdminRoute(routes, path, userInfo)
    
    // 检查当前路径是否在权限配置中, 同时检查是否存在动态参数
    return (
      accessAdminRoute &&
      allRoutePaths.some((path) => {
        if (path.includes(':')) {
          const pathReg = new RegExp(
            path.replace(/:\w+/g, '\\w+').replace(/\//g, '\\/'),
          )
          return pathReg.test(currentPath)
        }
        return path.indexOf(currentPath) !== -1
      })
    )
  }
}

access.ts

项目权限控制文件

import { USER_TOKEN_STORAGE } from '@/constants'
import { canRouteAccess } from '@/permission/router'
import { asyncRoutes } from '@/routes/asyncRoutes'

interface InitialState {
  userInfo: API.UserInfo
}

export default (initialState: InitialState) => {
  // 是否登录
  const isLogin = !!localStorage.getItem(USER_TOKEN_STORAGE)
  
  // 当前路由是否有权限访问
  const isRouteAccess = canRouteAccess(
    asyncRoutes[initialState.dataSource] || [],
  )
  
  return {
    isLogin,
    isRouteAccess,
  }
}

动态切换baseURL

import { getRequestInstance } from 'umi'

/**
 * 更新请求的基础路径
 * @param baseURL 基础路径
 * @returns void
 * @example
 * import { updateRequestBaseURL } from '@/utils/updateRequestBaseURL'
 * updateRequestBaseURL('http://localhost:8080')
 * */
export function updateRequestBaseURL(baseURL: string) {
  // 更新运行时配置
  const instance = getRequestInstance()
  instance.defaults.baseURL = baseURL
}

右上角渲染组件

import DataSourceSelect from '@/components/DataSource'
import { dataSourceMap } from '@/config'
import { DATA_SOURCE_STORAGE, LOGIN_PATH } from '@/constants'
import { updateRequestBaseURL } from '@/utils/updateRequestBaseURL'
import { useModel } from '@@/exports'
import type { MenuProps } from 'antd'
import { Avatar, Dropdown, Switch, theme } from 'antd'
import { useState } from 'react'
import {
  SelectLang,
  useAntdConfig,
  useAntdConfigSetter,
  useIntl,
  useNavigate,
} from 'umi'

const { darkAlgorithm, defaultAlgorithm } = theme

/**
 * 右上角的组件
 * 1. 退出登录
 * 2. 语言选择
 * 3. 主题选择
 * 4. 数据源选择
 * */

const RightRender = () => {
  const { initialState } = useModel('@@initialState')
  const { formatMessage } = useIntl()
  const navigate = useNavigate()
  const setAntdConfig = useAntdConfigSetter()
  const antdConfig = useAntdConfig()
  
  const [dataSource, setDataSource] = useState<keyof typeof dataSourceMap>(
    initialState!.dataSource,
  )
  
  // 选择数据源
  const handleSelectDataSource = (value: keyof typeof dataSourceMap) => {
    setDataSource(value)
    localStorage.setItem(DATA_SOURCE_STORAGE, value)
    // 修改request请求的baseURL
    updateRequestBaseURL(dataSourceMap[value as keyof typeof dataSourceMap])
    // 触发刷新
    window.location.reload()
  }
  
  const logout = () => {
    navigate(LOGIN_PATH)
  }
  
  const items: MenuProps['items'] = [
    {
      key: '1',
      label: (
        <span onClick={logout}>{formatMessage({ id: 'site.logout' })}</span>
      ),
    },
  ]
  
  return (
    <div className="flex items-center space-x-1">
      {/*主题*/}
      <Switch
        checkedChildren={formatMessage({ id: 'site.theme.dark' })}
        unCheckedChildren={formatMessage({ id: 'site.theme.light' })}
        checked={
          Array.isArray(antdConfig?.theme?.algorithm) &&
          antdConfig?.theme?.algorithm.includes(darkAlgorithm)
        }
        onChange={(data) => {
          // 此配置会与原配置深合并
          setAntdConfig({
            theme: {
              algorithm: [data ? darkAlgorithm : defaultAlgorithm],
            },
          })
        }}
      />
      {/*数据源*/}
      <DataSourceSelect
        dataSource={dataSource}
        onChange={handleSelectDataSource}
      />
      {/*切换语言*/}
      <SelectLang reload={false} />
      {/*退出登录*/}
      <Dropdown menu={{ items }} placement="bottomLeft">
        <Avatar
          style={{ cursor: 'pointer' }}
          src={
            initialState?.userInfo?.avatar_path ||
            'https://img.alicdn.com/tfs/TB1YHEpwUT1gK0jSZFhXXaAtVXa-28-27.svg'
          }
          size="small"
        />
      </Dropdown>
    </div>
  )
}

export default RightRender

环境变量

UMI4这个环境变量属实巨坑...

现在看看是如何在UMI4中使用环境变量的吧......

cross-env

首先我们安装这个东西cross-env:

npm install cross-env -D

修改启动命令

假设环境有uat和prd两个环境

在package.json里面修改如下:

  "scripts": {
"dev:uat": "cross-env UMI_ENV=uat max build",
"dev:prd": "cross-env UMI_ENV=prd max build",
"build:uat": "cross-env UMI_ENV=uat max build",
"build:prd": "cross-env UMI_ENV=prd max build",
},

UMI_ENV=uat指的是process.env.UMI_ENV的值为uat

如果使用的是普通的UMI,则将max build替换为umi devumi build

创建配置文件

在UMI中,每个环境会对应一个配置文件(这个文件在项目的根目录,不在src里面)

这个配置文件指的是.umirc.tsconfig.ts,取决于项目中使用哪一个

由于我们使用了两个环境,uatprd因此我们需要在.umirc.ts的基础上再创建两个文件:

分别是.umirc.uat.ts.umirc.prd.ts, 如果使用config.ts,同理

配置文件

查看官网,配置文件的加载规则如下:

配置文件

因此我们自定义的环境不能包含dev和prod,test这三个默认的环境

创建环境变量

最后一步,当我们创建好了对应环境的配置文件之后,我可们可以在该配置文件中定义环境变量:

.umirc.uat.ts文件中:

export default defineConfig({
  // ... 其他配置
  tailwindcss: {},
  define: {
    "process.env.UMI_ENV": process.env.UMI_ENV,
    "process.env.UMI_APP_BASE_VIETNAM_API": 'https://uat.a.in:8081/api/v1/',
    "process.env.UMI_APP_BASE_THAILAND_API": 'https://uat.a.in:8081/api/v1/',
    "process.env.UMI_APP_BASE_INDONESIA_API": 'https://uat.a.in:8081/api/v1/',
    "process.env.UMI_APP_BASE_MALAYSIA_API": 'https://uat.a.in:8081/api/v1/',
  },
})

.umirc.prd.ts文件中:

export default defineConfig({
  // ... 其他配置
  tailwindcss: {},
  define: {
    "process.env.UMI_ENV": process.env.UMI_ENV,
    "process.env.UMI_APP_BASE_VIETNAM_API": 'https://prd.a.in:8081/api/v1/',
    "process.env.UMI_APP_BASE_THAILAND_API": 'https://prd.a.in:8081/api/v1/',
    "process.env.UMI_APP_BASE_INDONESIA_API": 'https://prd.a.in:8081/api/v1/',
    "process.env.UMI_APP_BASE_MALAYSIA_API": 'https://prd.a.in:8081/api/v1/',
  },
})

使用环境变量

在项目的任意位置使用process.env.UMI_APP_BASE_VIETNAM_API即可读取到对应的环境变量

其它变量使用方法同上

实现layout和tab切换

由于UMI不提供layout和tab切换的功能(至少我没发现),因此我们需要自己实现

先说一下实现后的效果:

  • 可以通过tabs标签页组件进行切换
  • 通过tab切换的组件会保留该组件的状态 (比如表单数据, 表格数据等)
  • 可右键关闭标签页

以下是效果图

标签页

实现思路

  1. 自定义layout组件
  2. 实现tabs组件(antd)
  3. 监听路由的变化, 设置Tabs的items值, 达到切换的效果
  4. 实现组件缓存, 保留组件的状态
  5. 右键关闭标签页
  6. 国际化(可选)

自定义layout组件

layout目录结构如下:

├── layout
│   ├── RightClickMenu
│   │   ├── index.tsx
│   ├── style
│   │   ├── tabs.less
│   ├── index.tsx

代码

layout本体layout/index.tsx

import useMenuName from '@/hooks/useMenuName'
import RightClickMenu from '@/layouts/RightClickMenu'
import { Tabs, TabsProps } from 'antd'
import { useEffect, useRef, useState } from 'react'
import { useOutlet } from 'react-router-dom'
import { useLocation } from 'umi'
import './style/index.less'

/**
 * 全局 Layout 组件
 * 用于缓存组件、处理路由变化、设置当前激活的 tab
 * */
const Layout = () => {
  const location = useLocation()
  const componentList = useRef(new Map())
  const outlet = useOutlet()
  const { getMenuName } = useMenuName()
  const [tabs, setTabs] = useState<TabsProps['items']>([])
  const [activeKey, setActiveKey] = useState<string>('')

  useEffect(() => {
    // 缓存组件
    setAliveComponent(location.pathname)
    // 处理路由变化
    handleRouteChange(location.pathname)
    // 设置当前激活的 tab
    setActiveKey(location.pathname)
  }, [location.pathname])

  // 缓存组件
  const setAliveComponent = (key: string) => {
    // 如果缓存中没有当前组件,则添加
    if (!componentList.current.has(key)) {
      componentList.current.set(key, outlet)
    }
  }

  // 当路径变化时,更新 tabs
  const handleRouteChange = (path: string) => {
    // 如果已存在,则不添加
    if (tabs?.find((item) => item.key === location.pathname)) {
      return
    }

    // 添加新 tab
    const newTab = {
      key: path,
      label: getMenuName(path).sourceName,
      children: componentList.current.get(path),
      closable: tabs?.length !== 1,
    }

    setTabs((prev) => [...prev!, newTab])
  }

  // 关闭 其它/左边/右边 标签
  const handleTabCloseOther = (
    type: 'other' | 'left' | 'right' | 'self',
    key: string,
  ) => {
    if (!key || tabs?.length === 1 || !tabs) {
      return
    }

    const currentIndex = tabs?.findIndex((item) => item.key === key)

    // 缓存的组件列表
    const cacheList = componentList.current

    if (type === 'other') {
      setTabs([tabs[currentIndex!]])
      // 删除缓存
      cacheList.forEach((_, k) => {
        if (k !== key) {
          cacheList.delete(k)
        }
      })
    } else if (type === 'left') {
      setTabs(tabs.slice(currentIndex))
      cacheList.forEach((_, k) => {
        if (
          k !== key &&
          tabs.findIndex((item) => item.key === k) < currentIndex
        ) {
          cacheList.delete(k)
        }
      })
    } else if (type === 'right') {
      setTabs(tabs.slice(0, currentIndex! + 1))
      cacheList.forEach((_, k) => {
        if (
          k !== key &&
          tabs.findIndex((item) => item.key === k) > currentIndex
        ) {
          cacheList.delete(k)
        }
      })
    } else if (type === 'self') {
      setTabs((prev) => prev!.filter((item) => item.key !== key))
      cacheList.delete(key)
    }
  }

  return (
    <Tabs
      size="small"
      type="editable-card"
      hideAdd={true}
      tabBarStyle={{
        paddingLeft: 40,
      }}
      onEdit={(key) =>
        handleTabCloseOther('self', typeof key === 'string' ? key : '')
      }
      activeKey={activeKey}
      onChange={(key) => setActiveKey(key)}
    >
      {/* 为了实现动态可关闭按钮的启用和关闭 以及右键菜单 使用JSX方式渲染tab */}
      {tabs?.map((tab) => (
        <Tabs.TabPane
          key={tab.key}
          tab={
            <RightClickMenu
              clickTab={tab}
              handleTabCloseOther={handleTabCloseOther}
            />
          }
          closable={tabs.length !== 1 && tab.key !== activeKey}
        >
          {tab.children}
        </Tabs.TabPane>
      ))}
    </Tabs>
  )
}

export default Layout

右键菜单组件layout/RightClickMenu/index.tsx

import { Dropdown, MenuProps } from 'antd'
import { Tab } from 'rc-tabs/lib/interface'
import { useIntl } from 'umi'

interface RightClickMenuProps {
  clickTab: Tab
  handleTabCloseOther: (key: 'other' | 'left' | 'right', tabKey: string) => void
}

const RightClickMenu = (props: RightClickMenuProps) => {
  const { clickTab, handleTabCloseOther } = props
  const { formatMessage } = useIntl()

  const rightClickMenus: MenuProps['items'] = [
    {
      key: 'left',
      label: formatMessage({ id: 'layout.right.menu.left' }),
    },
    {
      key: 'right',
      label: formatMessage({ id: 'layout.right.menu.right' }),
    },
    {
      key: 'other',
      label: formatMessage({ id: 'layout.right.menu.other' }),
    },
  ]

  return (
    <Dropdown
      menu={{
        items: rightClickMenus,
        onClick: (e) => {
          handleTabCloseOther(e.key as 'other' | 'left' | 'right', clickTab.key)
        },
      }}
      trigger={['contextMenu']}
    >
      {/* 如果tab的label设为了ReactNode 这里需要修改 ! */}
      <span>{formatMessage({ id: clickTab.label as string })}</span>
    </Dropdown>
  )
}

export default RightClickMenu
Last Updated:
Contributors: huangdingxin