【前端】基于vite的react项目工程化(3)

367 阅读12分钟

1.前言

接下来需要将常用的组件集成到模版中,其中包括:

  • 1.登陆认证 ✅
  • 2.鉴权管理 ✅
  • 3.暗黑模式
  • 4.语言切换
  • 5.日志记录
  • 6.定时服务
  • 7.数据分析
  • 8.文件上传
  • 9.站内通知
  • 10.oss上传
  • ...

2.登陆认证

2.1 登陆页面

在开始写之前处理一下css的问题:

  • 1.antd5放弃使用Less改为CSS-in-JS,具体参考 这里
  • 2.通过 resetcss 将默认样式清空(放在public/css中后在index.html引入)
  • 3.全局配色的css文件放在src/assets/style/theme.module.css后引入到App.tsx
:root {
  --dark-bg-color: #fff;
  --dark-color: #000;
  --dark-home-bg-color: #f0f2f5;
  --dark-logo-color: #001529;
}

.dark {
  --dark-bg-color: #141414;
  --dark-color: #fff;
  --dark-home-bg-color: #000;
  --dark-logo-color: #141414;
}

接着来看登陆页面, 可以参考 表单 ,这里直接把模版拷贝过来:

import { Button, Form, type FormProps, Input } from 'antd'
import styles from './index.module.css'
import { useState } from 'react'

const Login: React.FC = () => {
  // 登陆时按钮loading
  const [loading] = useState(false)
  type FieldType = {
    username?: string
    password?: string
    remember?: string
  }
  const onFinish: FormProps<FieldType>['onFinish'] = values => {
    console.log('Success:', values)
  }
  const onFinishFailed: FormProps<FieldType>['onFinishFailed'] = errorInfo => {
    console.log('Failed:', errorInfo)
  }

  return (
    <div className={styles.login}>
      <div className={styles.wrapper}>
        <div className={styles.title}>系统登录</div>
        <Form
          name='basic'
          initialValues={{ remember: true }}
          onFinish={onFinish}
          onFinishFailed={onFinishFailed}
          autoComplete='off'
        >
          <Form.Item<FieldType>
            name='username'
            initialValue='user1'
            rules={[{ required: true, message: '请输入用户名!' }]}
          >
            <Input />
          </Form.Item>

          <Form.Item<FieldType>
            name='password'
            initialValue='111111'
            rules={[{ required: true, message: '请输入密码!' }]}
          >
            <Input.Password />
          </Form.Item>
          <Form.Item wrapperCol={{ flex: 1 }}>
            <Button type='primary' htmlType='submit' className={styles.submit} loading={loading}>
              登陆
            </Button>
          </Form.Item>
        </Form>
      </div>
    </div>
  )
}
export default Login

样式如下,其中login_bg.jpeg是一张1920 × 740的图:

.login {
  height: 100vh;
  background: url("/imgs/login_bg.jpeg") no-repeat;
  background-size: cover;
  background-position: center;
}

.title {
  font-size: 42px;
  line-height: 1.5;
  text-align: center;
  margin-bottom: 30px;
}

.wrapper {
  background-color: #fff;
  position: absolute;
  top: 50%;
  right: 10%;
  width: 500px;
  transform: translateY(-50%);
  padding: 50px;
}

.submit {
  width: 400px;
}

最终效果如下: 截屏2024-04-10 11.06.42.png

2.2 登陆逻辑

接着来写登陆逻辑,具体代码如下:

import { Button, Form, type FormProps, Input, message } from 'antd'
import styles from './index.module.css'
import { useState } from 'react'
import { userLoginParamType } from '@/types'
import { userLoginApi } from '@/api'
import storage from '@/utils/localStorage'

const Login: React.FC = () => {
  // 登陆时按钮loading
  const [loading, setLoading] = useState(false)
  // ...
  // 表单提交触发
  const onFinish: FormProps<FieldType>['onFinish'] = async (values: userLoginParamType) => {
    const data = await userLoginApi(values)
    if (data) {
      // 登陆按钮显示loading
      setLoading(true)
      // 将token保存到localstorage
      const { accessToken, refreshToken } = data.data
      storage.set('accessToken', accessToken) // 存到localstorage
      storage.set('refreshToken', refreshToken) // 存到localstorage
      // 显示登陆成功
      message.success(data.message)
      // 跳转首页
      setTimeout(() => {
        location.href = '/welcome'
        setLoading(false)
      })
    }
  }
  // 表单提交出错时触发
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  const onFinishFailed: FormProps<FieldType>['onFinishFailed'] = errorInfo => {
    // 登陆按钮取消loading
    setLoading(false)
    message.error('oops,出问题了!')
  }

  return (
    // ... 
    )
}
export default Login

效果如下: 1.gif

2.3 布局页面

此时是直接访问/welcome路由对应的组件,但更常见的应该是将后台所要访问的路由嵌套在layout布局组件当中作为子路由,先来写一下布局组件的内容, 具体参考 这里

import { Outlet } from 'react-router-dom'
import styles from './index.module.css'

import { Layout } from 'antd'
const { Sider, Content } = Layout
import MenuFC from '@/components/Menu/index'
import NavHeaderFC from '@/components/NavHeader/index'
import TabsFC from '@/components/Tabs'
import NavFooterFC from '@/components/NavFooter'

const LayoutFC: React.FC = () => {
  return (
    <Layout>
      {/* 左侧导航栏 */}
      <Sider collapsed={true}>
        <MenuFC />
      </Sider>
      {/* 右侧内容区 */}
      <Layout>
        {/* 右侧上 */}
        <NavHeaderFC />
        {/* 标签tab */}
        <TabsFC />
        {/* 右侧中 */}
        <Content>
          <div className={styles.content}>
            <div className={styles.wrapper}>
              <Outlet></Outlet>
            </div>
            {/* 右侧下 */}
            <NavFooterFC />
          </div>
        </Content>
      </Layout>
    </Layout>
  )
}
export default LayoutFC

对应的样式为:

.content {
  background-color: var(--dark-home-bg-color);
  height: calc(100vh - 90px);
  padding: 20px;
  overflow: auto;
}
.wrapper {
  min-height: calc(100vh - 210px);
}
.header {
  background-color: aquamarine;
}
.footer {
  background-color: bisque;
}

此时可以发现,好多模块还不存在,需要创建:

// @/components/Menu/index
const MenuFC: React.FC = () => {
  return <div>这是menu</div>
}
export default MenuFC
// @/components/NavHeader/index
const NavHeaderFC: React.FC = () => {
  return <div>这是NavHeaderFC</div>
}
export default NavHeaderFC
// @/components/Tabs
const TabsFC: React.FC = () => {
  return <div>这是TabsFC</div>
}
export default TabsFC
// @/components/NavFooter
const NavFooterFC: React.FC = () => {
  return <div>这是NavFooterFC</div>
}
export default NavFooterFC

然后还需要修改路由文件,将它们作为子路由使用:

import { Navigate, createBrowserRouter } from 'react-router-dom'
import { Error403 } from '@/pages/403.tsx'
import { Error404 } from '@/pages/404.tsx'
import { Welcome } from '@/pages/Welcome/Welcome.tsx'
import LoginFC from '@/pages/Login/Login'
import LayoutFC from '@/layout'

const routes = [
  // 登陆页面
  {
    path: '/login',
    element: <LoginFC />
  },
  // 后台内容页
  {
    id: 'layout',
    element: <LayoutFC />,
    children: [
      {
        path: '/welcome',
        element: <Welcome />
      }
    ]
  },
  // 错误兜底
  {
    path: '*',
    element: <Navigate to='/404' />
  },
  {
    path: '/404',
    element: <Error404 />
  },
  {
    path: '/403',
    element: <Error403 />
  }
]
// eslint-disable-next-line react-refresh/only-export-components
export default createBrowserRouter(routes)

具体效果如下,很丑但全部引入正确了: 截屏2024-04-10 18.57.36.png

2.3 路由守卫

此时有个问题,当前的/welcome路由在没有token的情况下也可以访问,这是因为当前没有对路由进行守卫,检测合法路径,具体实现思路是在layout/PrivateRoute.tsx中创建一个认证组件(HOC高阶组件),当ak和rk都存在时返回正常渲染的组件,否则跳转/login页面:

// layout/PrivateRoute.tsx
import { Navigate, useLocation } from 'react-router-dom'
import storage from '@/utils/localStorage'

type PrivateRouteProps = {
  component: React.ElementType
  path: string
}

// 判断被包裹的组件是否有ak和rk
const PrivateRoute: React.FC<PrivateRouteProps> = ({ component: Component }) => {
  // 获取路径
  const location = useLocation()

  // 获取ak和rk
  const ak = storage.get('accessToken')
  const rk = storage.get('refreshToken')

  if (ak && rk) {
    return <Component />
  } else {
    return <Navigate to='/login' replace state={{ from: location }} />
  }
}
export default PrivateRoute

接着修改路由文件:

// ...
import PrivateRoute from '@/layout/PrivateRoute'

const routes = [
  // 登陆页面
  // ...
  // 后台内容页
  {
    id: 'layout',
    element: <LayoutFC />,
    children: [
      {
        path: '/welcome',
        element: <PrivateRoute path='/welcome' component={Welcome} />
      }
    ]
  },
  // 错误兜底
  // ...
]
// ...

此时如果在登陆后没有ak和rk,他就会直接回到登陆页面,具体效果如下: 888.gif 此时已经能正确拦截缺失ak和rk的组件了,接着处理一下token过期重续的逻辑:

2.4 token过期重续

为什么前端登陆成功时后端会给2个token?

因为虽然ak和rk都是有效凭证,但它们的过期时间不一样,ak一般是30min,rk一般是7d,如果仅有ak,用户需要频繁登陆,有人可能说,既然如此,将ak的过期时间设置的长一点不就行了,这主要是出于安全考虑,ak代表着凭证,拥有它实际上就有了当前账户的所有权限,如果ak被劫持,对用户信息的损害较大,但是用rk换新ak的过程可以将风险降低,即就算ak泄漏,只能在30min中对用户账户进行破坏,而rk相比于ak的使用频率较低,使用https协议后很难被劫持,可以有效防止中间人攻击和数据包嗅探,提高传输过程中的安全性。

一般在跳转到后台时会先去获取用户的信息展示在后台的header中,此时这个请求就需要携带ak,所以先来实现一下请求这个接口:

// api/index.ts
// 获取用户信息
export function getUserInfoApi(): Promise<Result<userInfoResponseType>> {
  return request.get(GET_USER_INFO_URL)
}
// types/index.ts
// 用户角色模型
interface userRoleType {
  id: number
  rolename: string
  createTime: string
  updateTime: string
}
// 请求用户信息返回的结果类型
export interface userInfoResponseType {
  id: number
  username: string
  email: string
  createTime: string
  updateTime: string
  roles: userRoleType[] | []
}

接着就可以在NavHeaderFC中调用了

import { getUserInfoApi } from '@/api'
import { userInfoResponseType } from '@/types'
import { useEffect, useState } from 'react'

const NavHeaderFC: React.FC = () => {
  const [userInfo, setUserInfo] = useState<userInfoResponseType>()
  // 获取用户信息
  useEffect(() => {
    getUserInfo()
  }, [])
  // 获取用户信息
  const getUserInfo = async () => {
    const data = await getUserInfoApi()
    setUserInfo(data.data)
  }
  return <div>这是NavHeaderFC,当前用户:{userInfo ? `当前用户:${userInfo.username}` : '正在加载用户信息...'}</div>
}
export default NavHeaderFC

此时请求会报错:

截屏2024-04-10 21.55.18.png

这是因为在utils/request.ts const token = storage.get('AccessToken') 应该改成accessToken,然后重新请求就数据正常

截屏2024-04-10 21.57.17.png

此时如果ak过期了(将后端ak改为15s),重新刷新页面,就会报错401,这时候就需要去进行刷新ak了

// request.ts
import axios, { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import { CODE_MESSAGE } from '../constant'
import { IConfig, Result } from '../types'
import { hideLoading, showLoading } from './loading'
import storage from './localStorage'
import { message } from 'antd'
import axiosConfig from '@/config/axios.config'

// InternalAxiosRequestConfig不存在isShowLoading和isShowError,所以在此声明
declare module 'axios' {
  interface AxiosRequestConfig {
    isShowLoading?: boolean
    isShowError?: boolean
  }
}

// 1.创建axios实例
const instance = axios.create({
  baseURL: axiosConfig.baseURL,
  timeout: axiosConfig.requestTimeout,
  timeoutErrorMessage: axiosConfig.timeoutErrorMessage,
  headers: {
    'Content-Type': 'application/json;charset=UTF-8'
  }
})

// 2.请求拦截器
// 请求拦截器配置
const requestConfig = (config: InternalAxiosRequestConfig) => {
  // 2.1 InternalAxiosRequestConfig声明isShowLoading和isShowError
  if (config.isShowLoading) showLoading()
  // 2.2 添加ak用于鉴权
  const token = storage.get('accessToken')
  if (token) config.headers!.Authorization = 'Bearer ' + token
  // 返回设置好的配置
  return config
}
instance.interceptors.request.use(requestConfig, (error: AxiosError) => Promise.reject(error))

// 3.响应拦截器
// 响应拦截器配置
const responseData = async ({ data, status }: AxiosResponse) => {
  // 此时应该是100 - 399之间的状态码
  // 隐藏loading图标
  hideLoading()
  // 获得状态码
  const code: number = data && data['code'] ? data['code'] : status
  // 处理不同的状态码
  switch (code) {
    case 200:
      return data
    default:
      return data
  }
}

instance.interceptors.response.use(responseData, async (error: AxiosError) => {
  // 此时应该是400 - 599之间的状态码
  // 在此拦截以后,后面使用时就不需要再try/catch了,只需要判断结果是不是undefined
  // 第一次登陆后存了ak和rk,接着带着ak获取了用户信息,
  // 过15s后ak失效,带着rk重新请求ak,此时ak30min,rk7d
  // 过了30s后rk失效,带着rk重新请求ak,此时返回401,删除ak和rk跳转login
  hideLoading()
  // 当请求失败且有响应时,处理状态码
  if (error.response) {
    // 对于400及以上的错误码进行处理
    if (error.response.status >= 400) {
      // 处理401刷新token
      const resUrl = error.response.config.url ? error.response.config.url : ''
      if (error.response.status === 401 && !resUrl.includes('/refresh')) {
        // 刷新token
        await refreshToken()
        location.reload()
        return instance(error.response.config)
      } else {
        // 删除ak/rk,跳转login
        storage.remove('accessToken')
        storage.remove('refreshToken')
        message.error('登录过期,请重新登录')
        setTimeout(() => {
          location.href = '/login'
        }, 1000)
      }
      // 弹窗错误消息
      const errorMessage = CODE_MESSAGE[error.response.status] || '未知错误'
      message.error(errorMessage)
    }
  } else {
    // 发生了一些意外错误,如请求被阻止、取消或配置错误
    message.error(CODE_MESSAGE[500])
  }
  // 错误在这里处理完成了不用往下传递了
  // return Promise.reject(error)
})

// 刷新token的逻辑
async function refreshToken() {
  const res = await instance.get(import.meta.env.VITE_BASE_API + '/user/refresh', {
    headers: {
      Authorization: 'Bearer ' + storage.get('accessToken')
    },
    params: {
      refresh_token: storage.get('refreshToken')
    }
  })
  storage.set('accessToken', res.data.access_token)
  storage.set('refreshToken', res.data.refresh_token)
  return res
}

// 4.导出请求方法
export default {
  get<T>(
    url: string,
    params?: object,
    options: IConfig = { isShowLoading: true, isShowError: true }
  ): Promise<Result<T>> {
    return instance.get(url, { params, ...options })
  },
  post<T>(url: string, params?: object, options: IConfig = { isShowLoading: true, isShowError: true }): Promise<T> {
    return instance.post(url, params, options)
  }
}

具体效果如下: 7777.gif

3.鉴权管理

3.1 静态路由

前面已经实现了登陆认证,接下来实现鉴权管理,包括动态路由和菜单,按钮权限等 在做之前先写一下Menu的内容,具体如下:

import { useState } from 'react'
import styles from './index.module.css'
import { Menu } from 'antd'
import { ReactNode } from 'react'
import Sider from 'antd/es/layout/Sider'
import {
  AccountBookFilled,
  AppstoreAddOutlined,
  ClusterOutlined,
  ContactsOutlined,
  ContainerOutlined,
  CopyFilled,
  CreditCardOutlined,
  DesktopOutlined,
  MailOutlined,
  UnorderedListOutlined,
  UserOutlined
} from '@ant-design/icons'
export interface CustomMenuItem {
  id: number
  label: string
  key: string
  permissionType?: 'menu' | 'btn'
  icon?: ReactNode
  children?: CustomMenuItem[]
}

const MenuFC: React.FC = () => {
  const [collapsed] = useState<boolean>(false) // 是否折叠
  const [menulist] = useState<CustomMenuItem[]>([
    {
      id: 1,
      label: '工作台',
      key: '/dashboard',
      icon: <DesktopOutlined />
    },
    {
      id: 2,
      label: '系统管理',
      key: '71749055',
      icon: <ContainerOutlined />,
      children: [
        {
          id: 5,
          label: '用户管理',
          key: '/userlist',
          icon: <UserOutlined />
        },
        {
          id: 6,
          label: '角色管理',
          key: '/rolelist',
          icon: <ContactsOutlined />
        },
        {
          id: 7,
          label: '权限管理',
          key: '/permissionlist',
          icon: <ClusterOutlined />
        }
      ]
    },
    {
      id: 3,
      label: '资源管理',
      key: '43684909',
      icon: <AppstoreAddOutlined />,
      children: [
        {
          id: 8,
          label: '课程管理',
          key: '/courselist',
          icon: <UnorderedListOutlined />
        },
        {
          id: 9,
          label: '分类管理',
          key: '/categorylist',
          icon: <MailOutlined />
        }
      ]
    },
    {
      id: 4,
      label: '销售管理',
      key: '66100925',
      icon: <AccountBookFilled />,

      children: [
        {
          id: 10,
          label: '订单管理',
          key: '/orderlist',
          icon: <CopyFilled />
        },
        {
          id: 11,
          label: '交易管理',
          key: '/transcationlist',
          icon: <CreditCardOutlined />
        }
      ]
    }
  ]) //左侧菜单栏内容
  // 点击logo触发
  const handleClickLogo = () => {}

  return (
    <Sider collapsed={collapsed}>
      <div className={styles.nav}>
        <div className={styles.logo} onClick={handleClickLogo}>
          <img src='/imgs/logo.png' className={styles.img} />
          {collapsed ? '' : <span>我的后台</span>}
        </div>
        <Menu
          mode='inline'
          theme='dark'
          items={menulist}
          style={{
            width: collapsed ? 80 : 'auto',
            height: 'calc(100vh - 50px)'
          }}
        />
      </div>
    </Sider>
  )
}
export default MenuFC
.nav {
  background-color: var(--dark-bg-color);
  color: var(--dark-bolor);
  height: 100vh;
}

.logo {
  display: flex;
  align-items: center;
  font-size: 16px;
  background-color: var(--dark-logo-color);
  color: #fff;
  height: 50px;
  line-height: 50px;
  cursor: pointer;
  padding-left: 4px;
}

.img {
  width: 32px;
  height: 32px;
  margin: 0 16px;
}

这里对layout做了一下修改,将<Sider collapsed={false}>挪到了menu组件内,此时效果如下: 截屏2024-04-11 19.37.13.png

3.2 动态路由

这里的菜单都是固定写死在组件里的,它们应该是用户登陆后动态获取并生成的才对,所以接下来就需要去获取用户所拥有的权限,并将其动态生成所需要的数据,该数据的结构如下:

export interface CustomMenuItem {
  id: number 
  label: string
  key: string 
  permissiontype?: 'menu' | 'btn' // 权限的类型menu菜单btn组件
  icon?: ReactNode
  children?: CustomMenuItem[] // 子菜单
}

先来写获取权限的请求:

import { USER_LOGIN_URL, GET_USER_INFO_URL, GET_USER_PERMISSION_URL } from '@/constant'
import { PermissionTree, Result, userInfoResponseType, userLoginParamType, userLoginResponseType } from '../types'
import request from '../utils/request'
// 获取用户权限
export function getPermissionList(): Promise<Result<PermissionTree>> {
  return request.get(GET_USER_PERMISSION_URL)
}

其类型和前面CustomMenuItem类型都写在types中:

// 左侧菜单栏渲染时所需数据的类型
import { ReactNode } from 'react'
export interface CustomMenuItem {
  id: number
  label: string
  key: string
  permissiontype?: 'menu' | 'btn'
  icon?: ReactNode
  children?: CustomMenuItem[]
}
// 获取用户权限列表的数据类型
export type PermissionItem = {
  id: number
  permissionName: string
  parentId: number
  symbol: string
  menuIcon: string
  menuPath: string
  permissionType: string
  createTime: string
  updateTime: string
  children?: PermissionItem[]
}

export type PermissionTree = PermissionItem[] | null

请求路径写成了常量:export const GET_USER_PERMISSION_URL = '/user/getPermissionList' //获取用户权限

然后就可以在login组件中进行调用了

// ...
  // 表单提交触发
  const onFinish: FormProps<FieldType>['onFinish'] = async (values: userLoginParamType) => {
    // ...
    if (data) {
      // ...
      // 获取用户权限
      const permissionData = await getPermissionList()
      storage.set('permissionData', permissionData.data)
      // ...
      })
    }
  }
    // ...
  return (
    //...
  )
}
export default LoginFC

接着需要去router/index.tsx中动态生成路由:

import { Navigate, createBrowserRouter } from 'react-router-dom'
import { Error403 } from '@/pages/403.tsx'
import { Error404 } from '@/pages/404.tsx'
import LoginFC from '@/pages/Login/Login'
import LayoutFC from '@/layout'
import storage from '@/utils/localStorage'
import { getDynamicRoute } from '@/utils/getDynamicRoute'
import { PermissionItem } from '@/types'

// 生成动态路由,替换layout的children中写死的路由
const permissionDataStr = storage.get('permissionData')
let layoutChildren
if (permissionDataStr) {
  try {
    const permissionData = permissionDataStr as PermissionItem[]
    layoutChildren = getDynamicRoute(permissionData)
    console.log(layoutChildren)
  } catch (error) {
    console.error('Failed to parse permission data from local storage:', error)
  }
}
const routes = [
  // 登陆页面
  {
    path: '/login',
    element: <LoginFC />
  },
  // 后台内容页
  {
    id: 'layout',
    element: <LayoutFC />,
    children: layoutChildren
    // children: [
    //   {
    //     path: '/welcome',
    //     element: <PrivateRoute path='/welcome' component={WelcomeFC} />
    //   }
    // ]
  },
  // 错误兜底
  {
    path: '*',
    element: <Navigate to='/404' />
  },
  {
    path: '/404',
    element: <Error404 />
  },
  {
    path: '/403',
    element: <Error403 />
  }
]
// eslint-disable-next-line react-refresh/only-export-components
export default createBrowserRouter(routes)

其中getDynamicRoute函数会遍历用户权限列表,生成动态路由,具体逻辑如下:

import PrivateRoute from '@/layout/PrivateRoute'
import PermissionFC from '@/pages/system/permission'
import RoleFC from '@/pages/system/role'
import UserFC from '@/pages/system/user'
import WelcomeFC from '@/pages/welcome'
import { PermissionItem } from '@/types'
import { Navigate } from 'react-router-dom'

interface MenuRouteConfig {
  path: string
  element: JSX.Element | null
  children?: MenuRouteConfig[]
}

// 遍历用户权限列表,生成动态路由(已更新)
export function getDynamicRoute(menuTree: PermissionItem[]): MenuRouteConfig[] {
  const routes = menuTree.flatMap((item): MenuRouteConfig[] => {
    if (item.menuPath.trim() === '') {
      // 跳过当前层级的空路径项,但遍历其子项
      return item.children?.flatMap(child => getDynamicRoute([child])) || []
    }

    const routeConfig: MenuRouteConfig = {
      path: item.menuPath,
      element:
        item.permissionType === 'menu' ? (
          <PrivateRoute path={item.menuPath} component={resolveComponent(item)} />
        ) : null,
      children: [] as MenuRouteConfig[]
    }

    // 处理子项
    if (item.children && item.children.length > 0) {
      routeConfig.children = getDynamicRoute(item.children)
    }

    return [routeConfig]
  })

  return routes.filter((route): route is MenuRouteConfig => route.element !== null)
}

// 示例组件加载器,实际项目中可能需要按路径映射到具体的组件
function resolveComponent(item: PermissionItem) {
  switch (item.permissionName) {
    case '工作台':
      return WelcomeFC
    case '用户管理':
      return UserFC
    case '角色管理':
      return RoleFC
    case '权限管理':
      return PermissionFC
    default:
      // 如果没有找到对应的组件,可以根据实际情况处理,比如重定向或者抛出错误
      return () => <Navigate to='/404' replace />
  }
}

此时如果访问已经生成的路由则会正常显示,访问的路由不存在则会404

user1可访问的有8个路径,都是可以访问的 截屏2024-04-11 21.15.16.png user4可访问的1个路径可以访问 截屏2024-04-11 21.16.30.png

3.3 动态菜单

现在还需要处理一下左侧的菜单,虽然路由已经动态生成了,但左侧菜单还是写死在组件内的,所以也需要根据用户权限动态生成

截屏2024-04-11 21.17.18.png

此时在Menu组件就可以获取permissionData将其转换成所需要的菜单数据结构:

// ...
import storage from '@/utils/localStorage'
import { transformMenuTree } from '@/utils/transformMenuTree'

const MenuFC: React.FC = () => {
  //...
  const [menulist, setMenulist] = useState<CustomMenuItem[]>([]) //左侧菜单栏内容
  useEffect(() => {
    // 将用户权限数据转为所需的menu类型
    const rawData = storage.get('permissionData')
    const menuData = transformMenuTree(rawData as PermissionItem[], true)
    setMenulist(menuData)
  }, [])
   // ...

  return (
   // ...
  )
}
export default MenuFC

其中transformMenuTree函数定义在utils/transformMenuTree.tsx

import { ReactElement } from 'react'
import {
  AccountBookFilled,
  AppstoreAddOutlined,
  ClusterOutlined,
  ContactsOutlined,
  ContainerOutlined,
  CopyFilled,
  CreditCardOutlined,
  DesktopOutlined,
  MailOutlined,
  UnorderedListOutlined,
  UserOutlined
} from '@ant-design/icons'
import { CustomMenuItem } from '@/types'

// 假设这是一个完整的图标组件映射,实际应用中请按需添加更多
const iconMap: Record<string, () => ReactElement> = {
  AccountBookFilled: () => <AccountBookFilled />,
  AppstoreAddOutlined: () => <AppstoreAddOutlined />,
  ClusterOutlined: () => <ClusterOutlined />,
  ContactsOutlined: () => <ContactsOutlined />,
  ContainerOutlined: () => <ContainerOutlined />,
  CopyFilled: () => <CopyFilled />,
  CreditCardOutlined: () => <CreditCardOutlined />,
  DesktopOutlined: () => <DesktopOutlined />,
  MailOutlined: () => <MailOutlined />,
  UnorderedListOutlined: () => <UnorderedListOutlined />,
  UserOutlined: () => <UserOutlined />
}

// 将用户权限数据转成左侧菜单所需要的结构
export function transformMenuTree(menuTree: any[], randomKeyForEmptyPath: boolean): CustomMenuItem[] {
  return menuTree.flatMap(item => {
    if (item.permissionType === 'menu') {
      const customMenuItem: CustomMenuItem = {
        id: item.id,
        label: item.permissionName,
        key: item.menuPath || (randomKeyForEmptyPath ? generateRandomKey() : undefined),
        permissiontype: item.permissionType,
        icon: item.menuIcon ? iconMap[item.menuIcon]() : undefined
      }

      // 如果子项存在并且有至少一个子菜单(permissionType为menu)
      if (item.children && item.children.length > 0) {
        const filteredChildren = item.children.filter(
          (child: { permissionType: string }) => child.permissionType === 'menu'
        )

        // 只有当有筛选出的子菜单时才设置children属性
        if (filteredChildren.length > 0) {
          customMenuItem.children = transformMenuTree(filteredChildren, randomKeyForEmptyPath)
        }
      }

      // 返回符合条件的菜单项
      return [customMenuItem]
    }

    return [] // 当前项不符合条件,则返回空数组
  })
}

// 用于生成随机key的辅助函数
function generateRandomKey(): string {
  let result = ''
  const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
  for (let i = 0; i < 8; i++) {
    result += characters.charAt(Math.floor(Math.random() * characters.length))
  }
  return result
}

此时页面上的menu和路由就都是动态生成的了,此时类型文件如下:

// 用户角色模型
interface userRoleType {
  id: number
  rolename: string
  createTime: string
  updateTime: string
}
// 请求用户信息返回的结果类型
export interface userInfoResponseType {
  id: number
  username: string
  email: string
  createTime: string
  updateTime: string
  roles: userRoleType[] | []
}

// 左侧菜单栏渲染时所需数据的类型
import { ReactElement } from 'react'
export interface CustomMenuItem {
  id: number
  label: string
  key: string
  permissiontype?: 'menu' | 'btn'
  icon?: ReactElement<any, any>
  children?: CustomMenuItem[]
}

// 获取用户权限列表的数据类型
export type PermissionItem = {
  id: number
  permissionName: string
  parentId: number
  symbol: string
  menuIcon: string
  menuPath: string
  permissionType: string
  createTime: string
  updateTime: string
  children?: PermissionItem[]
}

export type PermissionTree = PermissionItem[] | null

除此以外还需要处理点击跳转对应路由,具体逻辑如下:

  // ...
  // 点击logo触发回到欢迎页
  const handleClickLogo = () => {
    navigate('/welcome')
  }
  // 点击菜单跳转对应页面
  const handleClickMenu = ({ key }: { key: string }) => {
    // 跳转对应页面
    // key:当前菜单的key
    // keypath:菜单层级数组['system','user']
    navigate(key)
  }

  return (
    <Sider collapsed={collapsed}>
        // ...
        <Menu
          // ...
          onClick={handleClickMenu}
        />
      </div>
    </Sider>
  )
}
export default MenuFC

1111.gif

3.4 退出功能

现在测试时有时候需要手动操作token,太麻烦了,接着来做一下退出功能,它应该是在header组件中:

import { getUserInfoApi } from '@/api'
import { userInfoResponseType } from '@/types'
import { useEffect, useState } from 'react'
import styles from './index.module.css'
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons'
import { Dropdown, MenuProps, Switch } from 'antd'
import storage from '@/utils/localStorage'

const NavHeaderFC: React.FC = () => {
  // 伸缩菜单
  const [collapsed, setCollapsed] = useState(false)
  const toggleCollapsed = () => {
    setCollapsed(!collapsed)
  }
  // 获取用户信息
  const [userInfo, setUserInfo] = useState<userInfoResponseType>()
  useEffect(() => {
    getUserInfo()
  }, [])
  // 获取用户信息
  const getUserInfo = async () => {
    const data = await getUserInfoApi()
    setUserInfo(data.data)
  }
  // 下拉框内容
  const items: MenuProps['items'] = [
    {
      key: 'id',
      label: '邮箱:' + (userInfo ? userInfo.email : 'Oops,您好像还没设置邮箱')
    },
    {
      key: 'logout',
      label: '退出'
    }
  ]
  // 点击下拉框触发
  const onClick: MenuProps['onClick'] = ({ key }) => {
    // 用户点击退出
    if (key === 'logout') {
      // 清空localstorage后跳转login页面
      storage.clear()
      location.href = '/login'
    }
  }
  // 切换主题颜色
  const handleSwitch = (isDark: boolean) => {
    if (isDark) {
      document.documentElement.dataset.theme = 'dark'
      document.documentElement.classList.add('dark')
    } else {
      document.documentElement.dataset.theme = 'light'
      document.documentElement.classList.remove('dark')
    }
    storage.set('isDark', isDark)
  }

  return (
    <div className={styles.navheader}>
      {/* 左侧:伸缩菜单 */}
      <div className={styles.left}>
        <div onClick={toggleCollapsed}>
          {collapsed ? <MenuUnfoldOutlined rev={undefined} /> : <MenuFoldOutlined rev={undefined} />}
        </div>
      </div>
      {/* 右侧:退出登陆 */}
      <div className='right'>
        <Switch
          checked={true}
          checkedChildren='暗黑'
          unCheckedChildren='默认'
          style={{ marginRight: 10 }}
          onChange={handleSwitch}
        />
        <Dropdown menu={{ items, onClick }} trigger={['hover']}>
          <span className={styles.nickName}>{userInfo ? userInfo.username : '用户信息加载中'}</span>
        </Dropdown>
      </div>
    </div>
  )
}
export default NavHeaderFC
.navheader {
  display: flex;
  justify-content: space-between;
  align-items: center;
  height: 50px;
  padding: 0 20px;
  background-color: var(--dark-bg-color);
  color: var(--dark-color);
}

.left {
  display: flex;
  align-items: center;
}

.nickname {
  cursor: pointer;
  color: var(--dark-color);
}

具体效果如下: 333.gif

3.5 菜单伸缩

此时左侧伸缩和右侧的颜色切换还没有实现,先来实现一下左侧菜单伸缩,它是在menu组件中通过collapsed控制的,涉及到跨组件通信,把collapsed保存到zustand:

import { CustomMenuItem } from '@/types'
import { create } from 'zustand'

// 新增菜单列表类型定义
type MenuList = CustomMenuItem[]

// 更新菜单列表的 action 类型
// ...
type updateCollapsed = (collapsed: boolean) => void

// State 接口
interface State extends Action {
  // ...
  collapsed: boolean
}

// 添加更新菜单列表的动作
type Action = {
  // ...
  updateCollapsed: updateCollapsed
}

// 左侧菜单 zustand 存储
export const useMenuStore = create<State & Action>(set => ({
  collapsed: false, // 设置菜单伸缩
  updateCollapsed: collapsed => set(() => ({ collapsed: collapsed })),
  // ..
}))

接着修改menu组件内容:

// ...
import { useMenuStore } from '@/store'

const MenuFC: React.FC = () => {
  // const [collapsed] = useState<boolean>(false) // 是否折叠
  const menuStore = useMenuStore() // 是否折叠
  // ...
  return (
    <Sider collapsed={menuStore.collapsed}>
      <div className={styles.nav}>
        <div className={styles.logo} onClick={handleClickLogo}>
          // ...
          {menuStore.collapsed ? '' : <span>我的后台</span>}
        </div>
        <Menu
          // ...
          style={{
            width: menuStore.collapsed ? 80 : 'auto',
            height: 'calc(100vh - 50px)'
          }}
          onClick={handleClickMenu}
        />
      </div>
    </Sider>
  )
}
export default MenuFC

接着修改header组件内容:

// ...
import { useMenuStore } from '@/store'

const NavHeaderFC: React.FC = () => {
  // 伸缩菜单
  // const [collapsed, setCollapsed] = useState(false)
  const menuStore = useMenuStore() // 是否折叠
  const toggleCollapsed = () => {
    console.log(!menuStore.collapsed)
    menuStore.updateCollapsed(!menuStore.collapsed)
  }
  // ...

  return (
    <div className={styles.navheader}>
      {/* 左侧:伸缩菜单 */}
      <div className={styles.left}>
        <div onClick={toggleCollapsed}>
          {menuStore.collapsed ? <MenuUnfoldOutlined rev={undefined} /> : <MenuFoldOutlined rev={undefined} />}
        </div>
      </div>
      {/* 右侧:退出登陆 */}
      //...
  )
}
export default NavHeaderFC

最终效果如下: 33333.gif

3.6 tabs功能

至于主题切换就放到后面来实现,接着来实现一下tabs功能,这个看着简单,其实还是有点难度的, 原理是在menu组件中点击对应的菜单时将其记录下来保存在zustand中,然后在tabfc中取出来渲染,还有一种情况是当手动在地址栏输入对应路由时也需要将其添加:

import { Tabs } from 'antd'
import { useState } from 'react'

const TabsFC: React.FC = () => {
  const [activeKey] = useState() //当前激活的tab的key
  const [breadCrumb] = useState() // 当前所有的tab项

  // 标签页变化时触发
  const handleChange = (activeKey: string) => {
    console.log(activeKey)
  }
  //关闭当前标签
  const handleDel = (currActiveKey: string) => {
    console.log(currActiveKey)
  }
  return (
    <Tabs
      activeKey={activeKey}
      items={breadCrumb}
      tabBarStyle={{
        height: 40,
        marginBottom: 0,
        marginTop: 2,
        backgroundColor: 'var(--dark-bg-color)'
      }}
      type='editable-card'
      hideAdd
      onChange={handleChange}
      onEdit={path => {
        handleDel(path as string)
      }}
    />
  )
}
export default TabsFC

可以看到breadCrumb是写死在组件中的,它应该是在用户点击了对应菜单后记录下来的才对,此时又涉及到跨组件通信,所以需要把它存在zustand中

import { TabType } from '@/types'
import { create } from 'zustand'

// 更新菜单列表的 action 类型
type updateBreadCrumb = (newTab: TabType) => void

// State 接口
interface State extends Action {
  // 面包屑路径
  breadCrumb: TabType[]
}

type Action = {
  updateBreadCrumb: updateBreadCrumb
}

export const useMenuStore = create<State & Action>(set => ({
  breadCrumb: [],
  updateBreadCrumb: newTab => {
    set(state => ({
      breadCrumb: [...state.breadCrumb, newTab]
    }))
  }
}))

它的类型如下:

// tab类型
export interface TabType {
 key: string
 label: string
 tabPath: string
 parentTab: string
}

接着在menu组件去将点击过的菜单记录下来

// ...
// 点击菜单跳转对应页面
const handleClickMenu = ({ key }: { key: string }) => {
    // 将点击过的tab保存下来
    const tabName = findLabelByPath(menulist, key) // 根据路径找到对应的名称
    console.log(tabName)
    // 存入zustand
    const newTab: TabType = { key: generateRandomEightDigits(), label: tabName, tabPath: key, parentTab: '' }
    menuStore.updateBreadCrumb(newTab)

    // 跳转对应页面
    // ...
}

由于现在只有点击过的路径,所以需要根据路径找出对应的名字,具体实现在findLabelByPath:

// 根据路径找到对应的名称
export function findLabelByPath(menuItems: any[], path: string): string {
  for (const item of menuItems) {
    if (item.key === path) {
      return item.label
    }
    if (item.children && item.children.length > 0) {
      const foundLabel = findLabelByPath(item.children, path)
      if (foundLabel !== '') {
        return foundLabel
      }
    }
  }
  return ''
}

// 随机生成字符串的函数
export function generateRandomEightDigits() {
  return Math.floor(Math.random() * (99999999 - 10000000 + 1)) + 10000000 + ''
}

这时候就已经实现了将点击过的tab记录下来了,接着就需要去到tabFC组件中去渲染了:

// ...
// const [breadCrumb] = useState()
const { breadCrumb } = useMenuStore() // 当前所有的tab项
// ...

此时效果如下: 234121.gif 此时还是有问题的:

  • 1.没有默认tab
  • 2.始终高亮第一个tab
  • 3.没有实现关闭

首先默认tab直接在store中添加即可:

export const useMenuStore = create<State & Action>(set => ({
  breadCrumb: [
    {
      key: 'randomstr',
      tabPath: 'welcome',
      label: '工作台',
      parentTab: ''
    }
  ]
})

接着解决 “始终高亮第一个tab” 的问题,这是因为activeKey也是写死在页面中的,应该在标签页变化时更新它的值,同时在menu组件点击不同菜单时也需要更新,因此它也要存到zustand中:

import { create } from 'zustand'

// 新增菜单列表类型定义
type MenuList = CustomMenuItem[]

// 更新菜单列表的 action 类型
type UpdateActiveKey = (activeKey: string) => void

// State 接口
interface State extends Action {
  // 当前激活的tab
  activeKey: string
}

// 添加更新菜单列表的动作
type Action = {
  updateActiveKey: UpdateActiveKey
}

// 左侧菜单 zustand 存储
export const useMenuStore = create<State & Action>(set => ({
  activeKey: 'randomstr',
  updateActiveKey: activeKey => set(() => ({ activeKey: activeKey }))
}))

然后在menu中更新当前高亮的tab:

 // 点击菜单跳转对应页面
const handleClickMenu = ({ key }: { key: string }) => {
    // 将点击过的tab保存下来
    // ...
    // 存入zustand
    const randomStr = generateRandomEightDigits()
    const newTab: TabType = { key: randomStr, label: tabName, tabPath: key, parentTab: '' }
    menuStore.updateBreadCrumb(newTab)
    // 激活当前点击的tab
    menuStore.updateActiveKey(randomStr)

    // 跳转对应页面
    // ...
  }

同时在tabFC组件中也要更新高亮tab:

import { useMenuStore } from '@/store'
import { Tabs } from 'antd'

const TabsFC: React.FC = () => {
  // const [activeKey, setActiveKey] = useState<string>() //当前激活的tab的key
  // const [breadCrumb] = useState()
  const { breadCrumb, activeKey, updateActiveKey } = useMenuStore() // tab项相关数据及其更新方法

  // 标签页变化时触发
  const handleChange = (activeKey: string) => {
    // setActiveKey(activeKey)
    updateActiveKey(activeKey)
  }
  //关闭当前标签
  // ...
  return (
    // ...
  )
}
export default TabsFC

此时效果如下: 34345676.gif 此时其实还是有问题的,比如重复点击同一个tab时重复添加,需要给它去重,在menu组件内:

// ...
const { breadCrumb } = useMenuStore() // 当前所有的tab项
// 点击菜单跳转对应页面
const handleClickMenu = ({ key }: { key: string }) => {
    // 将点击过的tab保存下来
    const tabName = findLabelByPath(menulist, key) // 根据路径找到对应的名称
    // 存入zustand
    if (!breadCrumb.some(item => item.label === tabName)) {
      // 如果不存在,则创建新的面包屑对象并添加到面包屑状态中
      const randomStr = generateRandomEightDigits()
      const newTab: TabType = { key: randomStr, label: tabName, tabPath: key, parentTab: '' }
      menuStore.updateBreadCrumb(newTab)
      // 激活当前点击的tab
      menuStore.updateActiveKey(randomStr)
    }
// ...

此时重复点击就只会添加一次,接下来处理关闭逻辑,可能tabs一共只有一个,这时如果关了就让它导航到/welcome,否则需要去判断当前关闭的tab是不是激活的tab,如果不是的话直接关闭,否则就需要更新激活的tabkey,具体实现如下:

import { useMenuStore } from '@/store'
import { Tabs } from 'antd'
import { useNavigate } from 'react-router-dom'

const TabsFC: React.FC = () => {
  const {
    breadCrumb,
    updateBreadCrumb,
    removeBreadCrumbByKey,
    getPreviousItemByKey,
    getPrevKey,
    activeKey,
    updateActiveKey
  } = useMenuStore() // 当前所有的tab项

  const navigate = useNavigate() //跳转
  // 标签页变化时触发
  const handleChange = (activeKey: string) => {
    updateActiveKey(activeKey)
  }
  //关闭当前标签
  const handleDel = (currActiveKey: string) => {
    // 可能只有1个标签
    if (breadCrumb.length === 1) {
      updateBreadCrumb(null)
      navigate('/welcome')
    } else {
      // 多个标签时判断当前关闭的tab是不是激活的tab
      if (currActiveKey === activeKey) {
        // 注意下面代码的顺序不能换
        // 找到前一个tab的key后将其tab高亮
        const prevKey = getPrevKey(currActiveKey)
        updateActiveKey(prevKey)
        // 找到前一个tab的path跳转
        const path = getPreviousItemByKey(currActiveKey) + ''
        // 将当前tab删掉
        removeBreadCrumbByKey(currActiveKey)
        navigate(path === '' ? '/welcome' : path)
      } else {
        removeBreadCrumbByKey(currActiveKey)
      }
    }
  }
  return (
    <Tabs
      activeKey={activeKey}
      items={breadCrumb}
      tabBarStyle={{
        height: 40,
        marginBottom: 0,
        marginTop: 2,
        backgroundColor: 'var(--dark-bg-color)'
      }}
      type='editable-card'
      hideAdd
      onChange={handleChange}
      onEdit={path => {
        handleDel(path as string)
      }}
    />
  )
}
export default TabsFC

此时store中新增了几个用于操作标签的方法:

import { CustomMenuItem, TabType } from '@/types'
import { create } from 'zustand'

// 新增菜单列表类型定义
type MenuList = CustomMenuItem[]

// 更新菜单列表的 action 类型
// ...
type UpdateBreadCrumb = (newTab: TabType | null) => void
type RemoveBreadCrumbByKey = (keyToRemove: string) => void
type GetPreviousItemByKey = (key: string) => void
type GetPrevKey = (keyToFind: string) => string
// State 接口
interface State extends Action {
  // ...
}

// 添加更新菜单列表的动作
type Action = {
  updateBreadCrumb: UpdateBreadCrumb
  updateActiveKey: UpdateActiveKey
  removeBreadCrumbByKey: RemoveBreadCrumbByKey
  getPreviousItemByKey: GetPreviousItemByKey
  getPrevKey: GetPrevKey
}

// 左侧菜单 zustand 存储
export const useMenuStore = create<State & Action>((set, get) => ({
  // ...
  updateBreadCrumb: newTab => {
    set(state => ({
      breadCrumb: newTab ? [...state.breadCrumb, newTab] : []
    }))
  },
  removeBreadCrumbByKey: (keyToRemove: string) => {
    // 根据key删除tab
    set(state => ({
      breadCrumb: state.breadCrumb.filter(item => item.key !== keyToRemove)
    }))
  },
  // 获取前一个tab的路径函数
  getPreviousItemByKey: (keyToFind: string) => {
    try {
      const { breadCrumb } = get()
      const index = breadCrumb.findIndex(item => item.key === keyToFind)
      if (index > 0) {
        return breadCrumb[index - 1].tabPath
      }
    } catch (e) {
      return null // 如果没有找到匹配项,或者该键是数组的第一个元素,则返回null
    }
  },
  // 根据当前key找前一个key
  getPrevKey: (keyToFind: string) => {
    const { breadCrumb } = get()
    const index = breadCrumb.findIndex(item => item.key === keyToFind)
    console.log(breadCrumb)
    console.log(keyToFind)
    return breadCrumb[index - 1].key
  },
    // ...
}))

目前效果如下: 98765.gif

3.7 组件美化

写完了这些,把welcome组件美化一下:

import styles from './index.module.css'

const WelcomeFC: React.FC = () => {
  return (
    <div className={styles.welcome}>
      <div className={styles.content}>
        <div className={styles.subtitle}>欢迎使用</div>
        <div className={styles.title}>React18通用后台管理系统</div>
        <div className={styles.desc}>React18+ReactRouter6.0+AntD5.4+TypeScript5.0+Vite实现通用后台</div>
      </div>
      <div className={styles.img}></div>
    </div>
  )
}
export default WelcomeFC
.welcome {
  display: flex;
  justify-content: center;
  align-items: center;
  background-color: var(--dark-bg-color);
  border-radius: 5px;
  height: calc(100vh - 170px);
}

.content {
  position: relative;
  bottom: 40px;

  .subtitle {
    font-size: 30px;
    line-height: 42px;
    color: var(--dark-color);
  }

  .title {
    font-size: 40px;
    line-height: 62px;
    color: #ed6c00;
  }

  .desc {
    text-align: center;
    font-size: 14px;
    color: gray;
  }
}

.img {
  background: url("/imgs/welcome-bg.png") no-repeat;
  background-size: contain;
  width: 370px;
  height: 320px;
  margin-left: 100px;
}

此时效果如下: 截屏2024-04-12 12.05.40.png

顺便把footer也处理一下:

import { GITHUB, JUE_JIN } from '@/constant'
import styles from './index.module.css'

const NavFooterFC: React.FC = () => {
  return (
    <div className={styles.footer}>
      <div>
        <a href={JUE_JIN} target='_blank' rel='noreferrer'>
          我的主页
        </a>
        <span className={styles.gutter}>|</span>
        <a href='#' target='_blank' rel='noreferrer'>
          联系我
        </a>
        <span className={styles.gutter}>|</span>
        <a href={GITHUB} target='_blank' rel='noreferrer'>
          项目地址
        </a>
        <span className={styles.gutter}>|</span>
        <a href='#' target='_blank' rel='noreferrer'>
          赞助我
        </a>
      </div>
      <div>Copyright ©{new Date().getFullYear()} React18通用后台 All Rights Reserved.</div>
    </div>
  )
}
export default NavFooterFC
.footer {
  text-align: center;
  line-height: 30px;
  color: #b0aeae;
  font-size: 14px;
  margin-top: 20px;

  .gutter {
    margin: 0 10px;
  }

  a {
    color: #b0aeae;

    &:hover {
      color: #ed6c00;
    }
  }
}

效果如下: 截屏2024-04-12 12.21.31.png

3.8 面包屑导航

接着把面包屑导航也做一下:

import { Breadcrumb } from 'antd'
const BreadcrumbFC: React.FC = () => {
    return (
    <Breadcrumb 
      items={[{ title: 'xx', path: '/xxx' }]}
      style={{ marginLeft: 10 }}
    />
}
export default BreadcrumbFC

接着在header组件引入:

 // ...
  return (
    <div className={styles.navheader}>
      {/* 左侧:伸缩菜单 */}
      <div className={styles.left}>
        {/* ... */}
        {/* 当前位置 */}
        <BreadCrumb />
      </div>
      {/* 右侧:退出登陆 */}
      {/* ... */}
    </div>
  )
}
export default NavHeaderFC

效果如下: 截屏2024-04-12 21.37.03.png 可以看到它的items需要的数据格式是[{"title": "xx", "path": "/xxx"}],其中title指的是显示的名称,path是点击要跳转的路径,这个数据可以通过以前写好的breadCrumb动态生成:

const BreadcrumbFC: React.FC = () => {
  const { breadCrumb, getCurrentKeyTab } = useMenuStore()
  const [breadList, setBreadList] = useState<BreadCrumbType[]>([])
  const { pathname } = useLocation()
  // 将breadCrumb转化成所需要的[{"title": "xx", "path": "/xxx"}]格式
  useEffect(() => {
    // 取出最后一个tab(因为每次添加时都是加在了最后)
    let newBreadlist: BreadCrumbType[]
    const lastItem = breadCrumb[breadCrumb.length - 1]
      ? breadCrumb[breadCrumb.length - 1]
      : {
          // 最后一个tab可能因为删除而不存在,直接手动赋值
          label: '工作台',
          tabPath: '/welcome',
          parentTab: '',
          key: 'randomstr'
        }
      if (lastItem.tabPath === 'welcome') {
      newBreadlist = [
        {
          title: '工作台',
          path: '/welcome'
        }
      ]
    } else {
      // 找出:父级/子级菜单
      newBreadlist = [
        {
          title: `${lastItem.parentTab}/${lastItem.label as string}`,
          path: lastItem.tabPath as string
        }
      ]
    }
      setBreadList(newBreadlist)
  }, [breadCrumb, pathname, getCurrentKeyTab])
  
  return (
      <Breadcrumb
      items={breadList.map(item => ({ title: <span>{item.title}</span>, path: item.path }))}
      style={{ marginLeft: 10 }}
    />
  )
}

其中BreadCrumbType的定义如下:

// antd的BreadCrumb所需要的类型
export type BreadCrumbType = {
  title: string
  path: string
}

getCurrentKeyTab在store中定义如下:

type GetCurrentKey = (keyToFind: string) => string
type Action = {
  getCurrentKey: GetCurrentKey
}
// 根据当前key找到tabPath
getCurrentKey: (keyToFind: string) => {
    const { breadCrumb } = get()
    const index = breadCrumb.findIndex(item => item.key === keyToFind)
    return breadCrumb[index].tabPath
  },

并且此时可以发现是要用到parentTab,它是在menu组件的handleClickMenu定义的,但组件里直接把它写成了''空字符串,需要修改一下:

// 点击菜单跳转对应页面
const handleClickMenu = ({ key }: { key: string }) => {
    // 将点击过的tab保存下来
    // ...
    // 存入zustand
    if (!breadCrumb.some(item => item.label === tabName)) {
      // 如果不存在,则创建新的面包屑对象并添加到面包屑状态中
      const randomStr = generateRandomEightDigits() // 生成随机字符串
      const rawData = storage.get('permissionData')
      const parentTab = getParentTabByTabName(tabName, rawData as PermissionItem[]) //根据tabName获取其上级目录名称
      const newTab: TabType = { key: randomStr, label: tabName, tabPath: key, parentTab: parentTab }
      menuStore.updateBreadCrumb(newTab)
      // 激活当前点击的tab
      menuStore.updateActiveKey(randomStr)
    }

    // 跳转对应页面
    // key:当前菜单的key
    // keypath:菜单层级数组['system','user']
    navigate(key)
}

它的寻找过程封装成了getParentTabByTabName放在了@/utils/getParentTabByTabName:

import { PermissionItem } from '@/types'

export function getParentTabByTabName(tabName: string, permissionData: PermissionItem[]): string {
  function searchForParent(permissionName: string, data: PermissionItem[]): PermissionItem | null {
    for (const item of data) {
      if (item.permissionName === permissionName) {
        if (item.parentId === 0) {
          return null
        }
        return searchForParentWithId(item.parentId, permissionData)
      }

      if (item.children) {
        const parentItem = searchForParent(permissionName, item.children)
        if (parentItem) {
          return parentItem
        }
      }
    }

    return null
  }

  function searchForParentWithId(id: number, data: PermissionItem[]): PermissionItem | null {
    for (const item of data) {
      if (item.id === id) {
        return item
      } else if (item.children) {
        const parentItem = searchForParentWithId(id, item.children)
        if (parentItem) {
          return parentItem
        }
      }
    }
    return null
  }

  if (!permissionData || permissionData.length === 0) {
    return ''
  }

  const targetParent = searchForParent(tabName, permissionData)

  // 确保 targetParent 已经被设置
  return targetParent ? targetParent.permissionName : ''
}

至此parentTab已经可以找到了,接下来试一下效果: 9999.gif 此时有点问题,就是它始终渲染的是tabs最后一个,当切换tab时它并没有跟着切换,可以在切换高亮tab时触发一下(在useEffect的依赖中加上activeKey),activeKey触发时对比if (activeKey != lastItem.key) ,如果当前已经切换了,但是该tab并不是最后一个时,需要把它的tab内容找出来渲染,具体如下:

// 将breadCrumb转化成所需要的[{"title": "xx", "path": "/xxx"}]格式
  useEffect(() => {
    // 取出最后一个tab(因为每次添加时都是加在了最后)
    // ...
    // 点击切换tab时也需要更新顶部导航
    if (activeKey != lastItem.key) {
      const currentTab = getCurrentKeyTab(activeKey)
      newBreadlist = [
        {
          title: `${currentTab.parentTab}/${currentTab.label as string}`,
          path: currentTab.tabPath as string
        }
      ]
    } else if (lastItem.tabPath === 'welcome') {
      // ...
    } else {
      // ...
    }
    setBreadList(newBreadlist)
  }, [breadCrumb, pathname, activeKey, getCurrentKeyTab])

此时效果如下: 987654.gif

其实目前还是有问题, 当点击左侧导航菜单时,tabs会加1,这时切换tab到第一个然后将其关闭时就会报错,具体效果如下:

098765.gif

这是因为在menu的handleDel函数中调用了getPrevKey(currActiveKey),它里面直接返回了return breadCrumb[index - 1].key,但有可能index=0,此时index-1就找不到内容,需要修改一下:

// 根据当前key找前一个key
  getPrevKey: (keyToFind: string) => {
    // ...
    // 如果寻找的key是数组第一个,再往前就不存在元素了
    if (index === 0) {
      console.log(breadCrumb)
      return breadCrumb[index + 1].key
    } else {
      return breadCrumb[index - 1].key
    }
    // return breadCrumb[index - 1].key
  },

同时下面的getPreviousItemByKey(currActiveKey)也需要修改一下:

// 获取前一个tab的路径函数
  getPreviousItemByKey: (keyToFind: string) => {
    try {
      const { breadCrumb } = get()
      const index = breadCrumb.findIndex(item => item.key === keyToFind)
      if (index > 0) {
        return breadCrumb[index - 1].tabPath
      } else {
        return breadCrumb[index + 1].tabPath
      }
    } catch (e) {
      return null // 如果没有找到匹配项,或者该键是数组的第一个元素,则返回null
    }
  },

这时候再来尝试一下,就会发现点击左侧菜单后tabs+1,然后切到前一个tab,这时删除就不会报错,但此时只剩下一个tab,再来删除时就又会报错,这是因为if (breadCrumb.length === 1)判断中没有更新activeKey,继续加上:

if (breadCrumb.length === 1) {
      // 更新tabs后跳转welcome
      updateBreadCrumb(null)
      updateBreadCrumb({
        key: 'randomstr',
        tabPath: 'welcome',
        label: '工作台',
        parentTab: ''
      })
      updateActiveKey('randomstr')
      navigate('/welcome')
} else {
     // ...
}

此时该问题就已经被解决了,并且welcome是默认存在的,它还有一个问题,当存在多个tabs时,并且这些tabs包含了welcome的tab时,点击左侧菜单是没法跳转的,这是因为在menu的handleClickMenu函数中if (!breadCrumb.some(item => item.label === tabName)) 后还需要加else,具体代码如下:

// 点击菜单跳转对应页面
const handleClickMenu = ({ key }: { key: string }) => {
// 将点击过的tab保存下来
const tabName = findLabelByPath(menulist, key) // 根据路径找到对应的名称
// 存入zustand
if (!breadCrumb.some(item => item.label === tabName)) {
  // ...
} else {
  // 找到对应的key并激活 ++++
  const tab = menuStore.getCurrentTabByKey(tabName)
  menuStore.updateActiveKey(tab.key)
}

// 跳转对应页面
// ...
}

此时还有封装getCurrentTabByKey方法去通过tabName找到tab后激活它

type GetCurrentTabByTabName = (keyToFind: string) => TabType

type Action = {
  // ...
  getCurrentTabByTabName: GetCurrentTabByTabName
}
export const useMenuStore = create<State & Action>((set, get) => ({
    // ...
    // 根据当前tabname找到该tab
  getCurrentTabByTabName: (tabName: string) => {
    const { breadCrumb } = get()
    const index = breadCrumb.findIndex(item => item.label === tabName)
    return breadCrumb[index]
  },
})

这样就解决了上面的问题,现在整体效果如下:

kkk.gif 其实关于tab还有改进的地方:

  • 1.工作台标签不可关闭
  • 2.支持拖拽排序

要设置"工作台标签不可关闭",需要以下设置closable

// tab类型
export interface TabType {
  key: string
  label: string
  tabPath: string
  parentTab: string
  closable?: boolean
}
// 通过搜索将所有初始化工作台的地方加上下列代码
breadCrumb: [
    {
      // ...
      closable: false
    }
  ],

支持拖拽排序需要通过dnd-kit,这个放在以后实现

接着来看一下当用户在地址栏直接输入路由后如何处理:

// ...
import storage from '@/utils/localStorage'
import { PATH_LABEL_OBJ } from '@/constant'

const TabsFC: React.FC = () => {
  const {
    // ...
    getCurrentTabByTabPath //根据路径找到tab
  } = useMenuStore() // tab项相关数据及其操作

  const navigate = useNavigate() //跳转
  // 用户手动输入路由
  const { pathname } = useLocation()
  useEffect(() => {
    // 判断当前路径是否已经存在
    const tab = getCurrentTabByTabPath(pathname)
    if (!tab) {
      // 当前路径未存在
      // 判断路径是否为用户的合法路由
      const routeArray = storage.get('routesArray') as string[]
      const isExist = routeArray.includes(pathname)
      if (isExist) {
        const randomstr = generateRandomEightDigits()
        const newTab = {
          key: randomstr,
          label: PATH_LABEL_OBJ[pathname].label,
          tabPath: pathname,
          parentTab: PATH_LABEL_OBJ[pathname].parentTab
        } as TabType
        updateBreadCrumb(newTab)
        updateActiveKey(randomstr)
      }
    } else {
      // 当前路径已经存在,直接高亮对应tab
      updateActiveKey(tab.key)
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [pathname])

  // ...
  return (
    // ...
  )
}
export default TabsFC

封装的getCurrentTabByTabPath方法如下:

// ...
// 更新菜单列表的 action 类型
// ...
type GetCurrentTabByTabPath = (pathToFind: string) => TabType

// State 接口
interface State extends Action {
  // 当前激活的tab
  // ...
}

// 添加更新菜单列表的动作
type Action = {
  // ...
  getCurrentTabByTabPath: GetCurrentTabByTabPath
}

// 左侧菜单 zustand 存储
export const useMenuStore = create<State & Action>((set, get) => ({
  // 当前激活的标签
  // ...
  // 根据当前tabPath找到该tab
  getCurrentTabByTabPath: (tabPath: string) => {
    const { breadCrumb } = get()
    const index = breadCrumb.findIndex(item => item.tabPath === tabPath)
    return breadCrumb[index]
  },
  // 左侧菜单伸缩
  // ...
}))

在上面用到了storage.get('routesArray') as string[]它是在生成动态router时指定的,具体逻辑如下:

// ...
let layoutChildren
if (permissionDataStr) {
  try {
    // ...
    // 将路径放入一个数组:在tabs中会用到
    const routesArray = layoutChildren.map(route => route.path)
    storage.set('routesArray', routesArray)
  } catch (error) {
    // ...
  }
}
const routes = [
  // ...
]
// eslint-disable-next-line react-refresh/only-export-components
export default createBrowserRouter(routes)

最后映射表如下:

// 路径名称映射表
export const PATH_LABEL_OBJ: { [key: string]: { label: string; parentTab?: string } } = {
  '/welcome': {
    label: '工作台'
  },
  '/userlist': {
    label: '用户管理',
    parentTab: '系统管理'
  },
  '/rolelist': {
    label: '角色管理',
    parentTab: '系统管理'
  },
  '/permissionlist': {
    label: '权限管理',
    parentTab: '系统管理'
  },
  '/courselist': {
    parentTab: '资源管理',
    label: '课程管理'
  },
  '/categorylist': {
    parentTab: '资源管理',
    label: '分类管理'
  },
  '/orderlist': {
    label: '订单管理',
    parentTab: '销售管理'
  },
  '/transcationlist': {
    label: '交易管理',
    parentTab: '销售管理'
  }
}

目前效果如下: 1234567654321123.gif

至此将下一步按钮权限所需要的内容准备完成,先来总结一下:

  • 1.新增路由时getDynamicRoute中resolveComponent添加对应的组件
  • 2.新增路由时transformMenuTree中iconMap添加对应的图标
  • 3.新增路由时常量PATH_LABEL_OBJ添加对应映射名单

3.9 按钮权限-用户管理

3.9.1 用户管理页面

接下来就开发按钮权限相关内容了,首先来处理用户管理, 参考 表格

import { Button, Form, Input, Space, Table, TableColumnsType } from 'antd'
import styles from './index.module.css'
import SearchFormFC from '@/components/SearchForm'
interface DataType {
  key: string
  username: string
  address: string
  role: string
}
// 更新用户
function handleEdit(record: any): void {
  console.log(record)
}
// 删除用户
function handleDel(userId: any): void {
  console.log(userId)
}
// 表头名称
const columns: TableColumnsType<DataType> = [
  {
    title: '用户ID',
    dataIndex: 'key',
    width: 100,
    align: 'center'
  },
  {
    title: '用户名',
    dataIndex: 'username',
    width: 150,
    align: 'center'
  },
  {
    title: '邮箱',
    dataIndex: 'address',
    width: 250,
    align: 'center'
  },
  {
    title: '角色',
    dataIndex: 'role',
    width: 150,
    align: 'center'
  },
  {
    title: '操作',
    key: 'action',
    width: 150,
    align: 'center',
    render: (_, record) => (
      <Space size='small'>
        <Button type='text' onClick={() => handleEdit(record)}>
          编辑
        </Button>
        <Button type='text' danger onClick={id => handleDel(id)}>
          删除
        </Button>
      </Space>
    )
  }
]
// 表格数据
const data: DataType[] = [
  {
    key: '1',
    username: 'user1',
    role: '超级管理员',
    address: '2098739876@qq.com'
  },
  {
    key: '2',
    username: 'user2',
    role: '运营人员',
    address: '20345765876@qq.com'
  },
  {
    key: '3',
    username: 'user3',
    role: '讲师',
    address: '876539876@qq.com'
  },
  {
    key: '4',
    username: 'user4',
    role: '学员',
    address: '23476876@qq.com'
  },
  {
    key: '5',
    username: 'user5',
    role: '学员',
    address: '9865239876@qq.com'
  },
  {
    key: '6',
    username: 'user6',
    role: '运营人员',
    address: '4567876@qq.com'
  },
  {
    key: '7',
    username: 'user7',
    role: '学员',
    address: '87639876@qq.com'
  },
  {
    key: '8',
    username: 'user8',
    role: '学员',
    address: '9865239876@qq.com'
  },
  {
    key: '9',
    username: 'user9',
    role: '学员',
    address: '4567876@qq.com'
  },
  {
    key: '10',
    username: 'user10',
    role: '学员',
    address: '87639876@qq.com'
  },
  {
    key: '11',
    username: 'user11',
    role: '学员',
    address: '55638876@qq.com'
  }
]
// checkbox选择时触发
const rowSelection = {
  onChange: (selectedRowKeys: React.Key[], selectedRows: DataType[]) => {
    console.log(`当前选择: ${selectedRowKeys}`, 'selectedRows: ', selectedRows)
  }
}

const UserFC: React.FC = () => {
  const [form] = Form.useForm()
  return (
    <div>
      {/* 检索框 */}
      <SearchFormFC form={form} initialValues={{ state: 1 }}>
        <Form.Item name='userId' label='用户ID'>
          <Input placeholder='请输入用户ID' />
        </Form.Item>
        <Form.Item name='userName' label='用户名称'>
          <Input placeholder='请输入用户名称' />
        </Form.Item>
      </SearchFormFC>
      {/* 用户新增/批量删除 */}
      <div className={styles.headerwrapper}>
        <div className='title'>用户列表</div>
        <div className='action'>
          <Button type='primary' className={styles.btn}>
            新增
          </Button>
          <Button type='primary' danger>
            批量删除
          </Button>
        </div>
      </div>
      {/* 表格数据 */}
      <Table
        rowSelection={{
          ...rowSelection
        }}
        columns={columns}
        dataSource={data}
      />
    </div>
  )
}
export default UserFC
.headerwrapper {
  display: flex;
  justify-content: space-between;
  align-items: center; /* 添加此行以实现垂直居中对齐 */
  background-color: white;
  border-radius: 0 5px;
  padding: 15px;
}

.btn {
  margin-right: 10px;
}

其中搜索封装成了新的组件SearchFormFC

import { Space, Button, Form } from 'antd'
import styles from './index.module.css'

export default function SearchFormFC(props: any) {
  return (
    <Form className={styles.searchform} form={props.form} layout='inline' initialValues={props.initialValues}>
      {props.children}
      <Form.Item>
        <Space>
          <Button type='primary' onClick={props.submit}>
            搜索
          </Button>
          <Button type='default' onClick={props.reset}>
            重置
          </Button>
        </Space>
      </Form.Item>
    </Form>
  )
}
.searchform{
  display: flex;
  justify-content: baseline;
  align-items: center; /* 添加此行以实现垂直居中对齐 */
  background-color: white;
  border-radius: 5px;
  padding: 15px;
  margin-bottom: 10px;
}

具体效果如下: 2345.gif

3.9.2 获取用户列表接口

在初次加载时,需要渲染请求来的用户数据,接下来编写所需要的接口:

// api/index.ts
// 分页获取用户
export function getUserByPaginationApi(param: PaginationType): Promise<Result<userPaginationResponseType>> {
  return request.get(GET_USER_BY_PAGINATION_URL, param)
}
// 分页参数类型
export type PaginationType = {
  pageSize: number
  pageNumber: number
}
// 分页获取数据返回类型
export type userPaginationResponseType = {
  total: number
  userList: userInfoResponseType[]
}
export const GET_USER_BY_PAGINATION_URL = '/user/getUserByPagination' //分页获取用户

接着可以在userFC组件进行调用:

const UserFC: React.FC = () => {
  const [form] = Form.useForm()
  // 初始化时获取要分页展示的用户数据
  useEffect(() => {
    getUserByPagination()
  }, [])
  const getUserByPagination = async () => {
    const data = await getUserByPaginationApi({ pageSize: 10, pageNumber: 0 })
    console.log(data)
  }
  return (
    // ...
  )
}

结果如下: 截屏2024-04-13 15.07.26.png 接下来需要把该数据转换成table所需要的数据,具体转换逻辑如下:

// 将分页获取的用户数据转换成table所需的数据格式
export function transformData(originalData: userInfoResponseType[] | null) {
  return originalData?.map(user => ({
    key: user.id.toString(),
    username: user.username,
    address: user.email,
    role: user.roles.map((role: userRoleType) => role.rolename).join(',')
  }))
}

接着就可以将原本写死的数据去掉,换成转换的数据了:

import { Button, Form, Input, Space, Table, TableColumnsType } from 'antd'
import styles from './index.module.css'
import SearchFormFC from '@/components/SearchForm'
import { useEffect, useState } from 'react'
import { getUserByPaginationApi } from '@/api'
import { transformUserPageToTableData } from '@/utils/transformUserPageToTableData'
import { TableDataType } from '@/types'

// 更新用户
function handleEdit(record: any): void {
  console.log(record)
}
// 删除用户
function handleDel(userId: any): void {
  console.log(userId)
}
// 表头名称
const columns: TableColumnsType<TableDataType> = [
  {
    title: '用户ID',
    dataIndex: 'key',
    width: 100,
    align: 'center'
  },
  {
    title: '用户名',
    dataIndex: 'username',
    width: 150,
    align: 'center'
  },
  {
    title: '邮箱',
    dataIndex: 'address',
    width: 250,
    align: 'center'
  },
  {
    title: '角色',
    dataIndex: 'role',
    width: 150,
    align: 'center'
  },
  {
    title: '操作',
    key: 'action',
    width: 150,
    align: 'center',
    render: (_, record) => (
      <Space size='small'>
        <Button type='text' onClick={() => handleEdit(record)}>
          编辑
        </Button>
        <Button type='text' danger onClick={id => handleDel(id)}>
          删除
        </Button>
      </Space>
    )
  }
]
// 表格数据
// const data: TableDataType[] = [
//   {
//     key: '1',
//     username: 'user1',
//     role: '超级管理员',
//     address: '2098739876@qq.com'
//   },
//   {
//     key: '2',
//     username: 'user2',
//     role: '运营人员',
//     address: '20345765876@qq.com'
//   },
//   {
//     key: '3',
//     username: 'user3',
//     role: '讲师',
//     address: '876539876@qq.com'
//   },
//   {
//     key: '4',
//     username: 'user4',
//     role: '学员',
//     address: '23476876@qq.com'
//   },
//   {
//     key: '5',
//     username: 'user5',
//     role: '学员',
//     address: '9865239876@qq.com'
//   },
//   {
//     key: '6',
//     username: 'user6',
//     role: '运营人员',
//     address: '4567876@qq.com'
//   },
//   {
//     key: '7',
//     username: 'user7',
//     role: '学员',
//     address: '87639876@qq.com'
//   },
//   {
//     key: '8',
//     username: 'user8',
//     role: '学员',
//     address: '9865239876@qq.com'
//   },
//   {
//     key: '9',
//     username: 'user9',
//     role: '学员',
//     address: '4567876@qq.com'
//   },
//   {
//     key: '10',
//     username: 'user10',
//     role: '学员',
//     address: '87639876@qq.com'
//   },
//   {
//     key: '11',
//     username: 'user11',
//     role: '学员',
//     address: '55638876@qq.com'
//   }
// ]
// checkbox选择时触发
const rowSelection = {
  onChange: (selectedRowKeys: React.Key[], selectedRows: TableDataType[]) => {
    console.log(`当前选择: ${selectedRowKeys}`, 'selectedRows: ', selectedRows)
  }
}

const UserFC: React.FC = () => {
  const [form] = Form.useForm()
  const [tableData, setTableData] = useState<TableDataType[]>([])
  // 初始化时获取要分页展示的用户数据
  useEffect(() => {
    getUserByPagination()
  }, [])
  const getUserByPagination = async () => {
    // ++++++
    // 获取数据
    const rawData = await getUserByPaginationApi({ pageSize: 10, pageNumber: 0 })
    // 转换数据
    const transformData = transformUserPageToTableData(rawData.data.userList)
    // 设置数据
    setTableData(transformData as TableDataType[])
    // ++++++
  }
  return (
    <div>
      {/* 检索框 */}
      <SearchFormFC form={form} initialValues={{ state: 1 }}>
        <Form.Item name='userId' label='用户ID'>
          <Input placeholder='请输入用户ID' />
        </Form.Item>
        <Form.Item name='userName' label='用户名称'>
          <Input placeholder='请输入用户名称' />
        </Form.Item>
      </SearchFormFC>
      {/* 用户新增/批量删除 */}
      <div className={styles.headerwrapper}>
        <div className='title'>用户列表</div>
        <div className='action'>
          <Button type='primary' className={styles.btn}>
            新增
          </Button>
          <Button type='primary' danger>
            批量删除
          </Button>
        </div>
      </div>
      {/* 表格数据 */}
      <Table
        rowSelection={{
          ...rowSelection
        }}
        columns={columns}
        dataSource={tableData}
      />
    </div>
  )
}
export default UserFC

具体效果如下: 98765234.gif

3.9.3 按钮权限

接下来处理一下新增用户操作,此时如果用户拥有新增的权限(通过组件上的auth=),才会显示该按钮,否则直接隐藏,这里可以把它封装成一个高阶组件进行判断后渲染,具体如下:

import React, { useEffect, useState } from 'react'
import { Button } from 'antd'
import storage from '@/utils/localStorage'
import { PermissionItem, Props } from '@/types'
import { collectBtnSymbols } from '@/utils/collectBtnSymbols'

const AuthButton: React.FC<Props> = ({ auth, type, onClick, children, danger }) => {
  const [hasPermission, setHasPermission] = useState<boolean>(false)
  useEffect(() => {
    // 获取localstorage中的用户权限信息
    const permissionData = storage.get('permissionData') as PermissionItem[]
    // 找出所有按钮权限
    const btnSymbols = collectBtnSymbols(permissionData)
    // 比对是否拥有对应权限
    setHasPermission(btnSymbols.includes(auth))
  }, [auth])

  return (
    <>
      {hasPermission ? (
        <Button type={type} onClick={onClick} danger={danger}>
          {children}
        </Button>
      ) : null}
    </>
  )
}
export default AuthButton

类型文件如下:

// 权限按钮组件的props的类型
export interface Props {
  auth: string
  type: 'primary' | 'default' | 'text' | 'dashed' | 'link'
  danger?: boolean
  onClick: () => void
  children: React.ReactNode
}

其中collectBtnSymbols是为了收集用户所有的按钮权限,其逻辑如下:

import { PermissionItem } from '@/types'

// 找出用户所有的按钮权限
export function collectBtnSymbols(data: PermissionItem[]): string[] {
  const btnSymbols: string[] = []

  function traverseTree(item: PermissionItem) {
    if (item.permissionType === 'btn') {
      btnSymbols.push(item.symbol)
    }

    if (Array.isArray(item.children)) {
      item.children.forEach(traverseTree)
    }
  }

  data.forEach(traverseTree)

  return btnSymbols
}

此时将user组件中以下内容替换

{/* <Button type='primary' className={styles.btn}>新增</Button> */}
<AuthButton auth='user@add' type='primary' onClick={handleAdd}>新增</AuthButton>

{/* <Button type='primary' danger>批量删除</Button> */}
<AuthButton auth='user@batchdel' type='primary' onClick={handleBatchdel}>批量删除</AuthButton>
[
  // ...
  {
    // ...
    render: (_, record) => (
      <Space size='small'>
        <AuthButton auth='user@update' type='link' onClick={() => handleEdit(record)}>编辑</AuthButton>
        <AuthButton auth='user@delete' type='text' danger={true} onClick={() => handleDel(record)}>删除</AuthButton>
      </Space>
    )
  }
]

可以看到此时auth='user@add' user@update user@delete user@batchdel,说明此处需要添加/批量删除/更新/删除用户的权限,而打印出来的btnSymbols为:

[
    "user@add",
    "user@delete",
    "user@update",
    "user@query",
    "role@add",
    "role@delete",
    "role@update",
    "role@query",
    "role@assign",
    "permission@add",
    "permission@delete",
    "permission@update",
    "permission@query",
    "permission@assign"
]

此时用户除了没有批量删除用户的权限其他都有,所以除了批量删除按钮其他都能显示,具体如下: 1234567.gif

3.9.4 新增用户界面

接下来可以处理一下新增用户的界面和逻辑,具体参考modal

import { Form, Input, Modal, Select } from 'antd'
import { useEffect, useState } from 'react'
import storage from '@/utils/localStorage'

export interface CreateUserProps {
  visible: boolean
  onCancel: () => void
  onOk: (form: any) => void
}

const CreateUserFC: React.FC<CreateUserProps> = ({ visible, onCancel, onOk }) => {
  const [form] = Form.useForm()
  useEffect(() => {
    // 比对用户权限确定要显示的rolelist
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    const userInfo = storage.get('userInfo')
    // TODO:获取系统当前有哪些角色,判断当前用户的角色所拥有的可分配角色
  }, [])

  const [roleList] = useState([
    { _id: '超级管理员', roleName: '超级管理员' },
    { _id: '运营', roleName: '运营' },
    { _id: '讲师', roleName: '讲师' },
    { _id: '学员', roleName: '学员' }
  ])

  const getCreateUserInfo = () => {
    // 获取表单数据
    const formData = form.getFieldsValue()
    // 传递数据到父组件
    return onOk(formData)
  }
  return (
    <Modal open={visible} onCancel={onCancel} onOk={getCreateUserInfo} closeIcon={null} cancelText='取消' okText='确认'>
      <Form form={form} labelCol={{ span: 4 }} labelAlign='right'>
        {/* 用户名称 */}
        <Form.Item
          label='用户名称'
          name='username'
          rules={[
            { required: true, message: '请输入用户名称' },
            { min: 5, max: 30, message: '用户名称最小5个字符最大12个字符' }
          ]}
        >
          <Input placeholder='请输入用户名称'></Input>
        </Form.Item>
        {/* 用户密码 */}
        <Form.Item
          label='用户密码'
          name='password'
          rules={[
            { required: true, message: '请输入用户密码' },
            { min: 5, max: 30, message: '用户名称最小5个字符最大12个字符' }
          ]}
        >
          <Input placeholder='请输入用户密码' type='password'></Input>
        </Form.Item>
        {/* 用户邮箱 */}
        <Form.Item
          label='用户邮箱'
          name='email'
          rules={[
            { required: true, message: '请输入用户邮箱' },
            { type: 'email', message: '请输入正确的邮箱' },
            {
              pattern: /^\w+@qq.com$/,
              message: '邮箱必须以@qq.com结尾'
            }
          ]}
        >
          <Input placeholder='请输入用户邮箱'></Input>
        </Form.Item>
        {/* 用户角色 */}
        <Form.Item label='系统角色' name='roleList'>
          <Select placeholder='请选择角色' mode='multiple'>
            {roleList.map(item => {
              return (
                <Select.Option value={item._id} key={item._id}>
                  {item.roleName}
                </Select.Option>
              )
            })}
          </Select>
        </Form.Item>
      </Form>
    </Modal>
  )
}
export default CreateUserFC

最外层是Modal组件,它是通过openonCancel控制开关的,同时当点击确认时,会触发onOk,这些属性在它的类型文件中可以找到: 截屏2024-04-15 12.39.36.png 里面是一个Form表单,它通过const [form] = Form.useForm()收集表单数据:userIdusernameemailroleName

接着在user组件里引用:

import { Form, Input, Space, Table, TableColumnsType } from 'antd'
import styles from './index.module.css'
import SearchFormFC from '@/components/SearchForm'
import { useEffect, useState } from 'react'
import { getUserByPaginationApi } from '@/api'
import { transformUserPageToTableData } from '@/utils/transformUserPageToTableData'
import { TableDataType } from '@/types'
import AuthButton from '@/components/AuthButton'
import CreateUserFC from './CreateUser'

// 批量删除用户
function handleBatchdel(): void {
  console.log('批量删除')
}

// 更新用户
function handleEdit(record: any): void {
  console.log(record)
}
// 删除用户
function handleDel(userId: any): void {
  console.log(userId)
}
// 表头名称
const columns: TableColumnsType<TableDataType> = [
  {
    title: '用户ID',
    dataIndex: 'key',
    width: 100,
    align: 'center'
  },
  {
    title: '用户名',
    dataIndex: 'username',
    width: 150,
    align: 'center'
  },
  {
    title: '邮箱',
    dataIndex: 'address',
    width: 250,
    align: 'center'
  },
  {
    title: '角色',
    dataIndex: 'role',
    width: 150,
    align: 'center'
  },
  {
    title: '操作',
    key: 'action',
    width: 150,
    align: 'center',
    render: (_, record) => (
      <Space size='small'>
        <AuthButton auth='user@update' type='link' onClick={() => handleEdit(record)}>
          编辑
        </AuthButton>
        <AuthButton auth='user@delete' type='text' danger={true} onClick={() => handleDel(record)}>
          删除
        </AuthButton>
      </Space>
    )
  }
]
// checkbox选择时触发
const rowSelection = {
  onChange: (selectedRowKeys: React.Key[], selectedRows: TableDataType[]) => {
    console.log(`当前选择: ${selectedRowKeys}`, 'selectedRows: ', selectedRows)
  }
}

const UserFC: React.FC = () => {
  const [form] = Form.useForm() // 搜索组件的form表单
  // ...
  const [createUserVisible, setCreateUserVisible] = useState(false)
  
  // 初始化时获取要分页展示的用户数据
  // ...
  
  // 显示新增用户弹框
  const handleShowAdd = () => {
    setCreateUserVisible(true)
    console.log('新增用户')
  }
  // 取消弹框
  const handleCreateUserCancel = () => {
    setCreateUserVisible(false)
  }
  // 确认新增用户
  const handleCreateUserConfirm = (createUserParams: any) => {
    setCreateUserVisible(false)
    // 将数据传到后端进行提交
    console.log('=========', createUserParams)
  }
  return (
    <div>
      // ...
      {/* 创建/更新用户 */}
      <CreateUserFC visible={createUserVisible} onCancel={handleCreateUserCancel} onOk={handleCreateUserConfirm} />
    </div>
  )
}
export default UserFC

此时效果如下: 12222.gif

3.9.4 新增用户接口

此时用户角色是被写死在页面的,需要向后端请求,先来调用api进行获取:

// 获取所有角色
export function getAllRolesApi(): Promise<Result<RoleAddResponseType>> {
  return request.post(GET_ALL_ROLES_URL)
}
export const GET_ALL_ROLES_URL = '/role/getAllRoles' //获取所有的角色
// 获取所有角色时返回的结果类型
export type RoleAddResponseType = userRoleType[]
// ...
function transformRoles(rolesData: Array<{ id: number; rolename: string }>): Array<{ _id: string; roleName: string }> {
  return rolesData.map(role => ({
    _id: role.rolename, //这里是因为form返回的select数据都是它的key
    roleName: role.rolename
  }))
}

const CreateUserFC: React.FC<CreateUserProps> = ({ visible, onCancel, onOk }) => {
  const [form] = Form.useForm()
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  const [roleList, setRoleList] = useState([
    { _id: '超级管理员', roleName: '超级管理员' },
    { _id: '运营', roleName: '运营' },
    { _id: '讲师', roleName: '讲师' }
    // { _id: '学员', roleName: '学员' }
  ])

  // 获取所有的角色
  useEffect(() => {
    getAllRoles()
  }, [])

  const getAllRoles = async () => {
    const allRolesResult = await getAllRolesApi()
    // 将数据转换成所需要的结构
    const newRoleList = transformRoles(allRolesResult.data)
    setRoleList(newRoleList)
  }

  // ...
  return (
    // ...
  )
}
export default CreateUserFC

此时可以将transformRoles函数抽离到utils/transformRoles.ts:

import { userRoleType, SelectParamType } from '@/types'

export function transformRoles(rolesData: userRoleType[]): SelectParamType {
  return rolesData.map(role => ({
    _id: role.id.toString(),
    roleName: role.rolename
  }))
}
// 角色下拉框所需要的数据类型
export interface SelectParamItemType {
  _id: string
  roleName: string
}
export type SelectParamType = SelectParamItemType[]

接着来调用api进行新增:

import { Form, Input, Space, Table, TableColumnsType } from 'antd'
import styles from './index.module.css'
import SearchFormFC from '@/components/SearchForm'
import { useEffect, useState } from 'react'
import { addUserApi, getUserByPaginationApi } from '@/api'
import { transformUserPageToTableData } from '@/utils/transformUserPageToTableData'
import { TableDataType } from '@/types'
import AuthButton from '@/components/AuthButton'
import CreateUserFC from './CreateUser'

// 批量删除用户
function handleBatchdel(): void {
  console.log('批量删除')
}

// 更新用户
function handleEdit(record: any): void {
  console.log(record)
}
// 删除用户
function handleDel(userId: any): void {
  console.log(userId)
}
// 表头名称
const columns: TableColumnsType<TableDataType> = [
  {
    title: '用户ID',
    dataIndex: 'key',
    width: 100,
    align: 'center'
  },
  {
    title: '用户名',
    dataIndex: 'username',
    width: 150,
    align: 'center'
  },
  {
    title: '邮箱',
    dataIndex: 'address',
    width: 250,
    align: 'center'
  },
  {
    title: '角色',
    dataIndex: 'role',
    width: 150,
    align: 'center'
  },
  {
    title: '操作',
    key: 'action',
    width: 150,
    align: 'center',
    render: (_, record) => (
      <Space size='small'>
        <AuthButton auth='user@update' type='link' onClick={() => handleEdit(record)}>
          编辑
        </AuthButton>
        <AuthButton auth='user@delete' type='text' danger={true} onClick={() => handleDel(record)}>
          删除
        </AuthButton>
      </Space>
    )
  }
]
// checkbox选择时触发
const rowSelection = {
  onChange: (selectedRowKeys: React.Key[], selectedRows: TableDataType[]) => {
    console.log(`当前选择: ${selectedRowKeys}`, 'selectedRows: ', selectedRows)
  }
}

const UserFC: React.FC = () => {
  const [form] = Form.useForm() // 搜索组件的form表单
  const [tableData, setTableData] = useState<TableDataType[]>([]) // 表格所需要的数据
  const [createUserVisible, setCreateUserVisible] = useState(false)
  // 初始化时获取要分页展示的用户数据
  useEffect(() => {
    getUserByPagination()
  }, [])
  const getUserByPagination = async () => {
    // 获取数据
    const rawData = await getUserByPaginationApi({ pageSize: 10, pageNumber: 0 })
    // 转换数据
    const transformData = transformUserPageToTableData(rawData.data.userList)
    // 设置数据
    setTableData(transformData as TableDataType[])
  }
  // 显示新增用户弹框
  const handleShowAdd = () => {
    setCreateUserVisible(true)
    console.log('新增用户')
  }
  // 取消弹框
  const handleCreateUserCancel = () => {
    setCreateUserVisible(false)
  }
  // 确认新增用户
  const handleCreateUserConfirm = async (createUserParams: any) => {
    setCreateUserVisible(false)
    // 将数据传到后端进行提交
    // addUserApi
    console.log(createUserParams)
    const reasult = await addUserApi(createUserParams)
    console.log(reasult)
    // 重新请求数据并渲染
    getUserByPagination()
  }
  return (
    <div>
      {/* 检索框 */}
      <SearchFormFC form={form} initialValues={{ state: 1 }}>
        <Form.Item name='userId' label='用户ID'>
          <Input placeholder='请输入用户ID' />
        </Form.Item>
        <Form.Item name='userName' label='用户名称'>
          <Input placeholder='请输入用户名称' />
        </Form.Item>
      </SearchFormFC>
      {/* 用户新增/批量删除 */}
      <div className={styles.headerwrapper}>
        <div className='title'>用户列表</div>
        <div className='action'>
          <AuthButton auth='user@add' type='primary' onClick={handleShowAdd}>
            新增
          </AuthButton>
          <AuthButton auth='user@batchdel' type='primary' onClick={handleBatchdel}>
            批量删除
          </AuthButton>
        </div>
      </div>
      {/* 表格数据 */}
      <Table
        rowSelection={{
          ...rowSelection
        }}
        columns={columns}
        dataSource={tableData}
      />
      {/* 创建/更新用户 */}
      <CreateUserFC visible={createUserVisible} onCancel={handleCreateUserCancel} onOk={handleCreateUserConfirm} />
    </div>
  )
}
export default UserFC

此时效果如下:

123456776543.gif

3.9.5 删除用户

接着来处理删除,更新,搜索逻辑:

  • 1.删除时需要将其角色也清除
  • 2.更新时和创建时模版类似
  • 3.搜索时可能会有符合搜索条件

首先是删除逻辑,封装一下删除的方法:

// 根据用户id删除用户
export function deleteUserByUserIdApi(userid: number): Promise<Result<[]>> {
  return request.delete(DELETE_USER_BY_USER_ID_URL + '/?userid=' + userid)
}


此时delete方法还没有,需要在axios中添加:

// ...

// 4.导出请求方法
export default {
  // ...
  // 添加delete方法
  // 添加delete方法
  delete<T>(
    url: string,
    params?: object,
    options: IConfig = { isShowLoading: true, isShowError: true }
  ): Promise<Result<T>> {
    return instance.delete(url, { params: { ...params }, ...options })
  }
}

接着就可以在userlist中调用了:

// ...

const UserFC: React.FC = () => {
  const [form] = Form.useForm() // 搜索组件的form表单
  const [tableData, setTableData] = useState<TableDataType[]>([]) // 表格所需要的数据
  const [createUserVisible, setCreateUserVisible] = useState(false)
  // 初始化时获取要分页展示的用户数据
  useEffect(() => {
    getUserByPagination()
  }, [])
  const getUserByPagination = async (pageSize: number = 10, pageNumber: number = 0) => {
    // 获取数据
    const rawData = await getUserByPaginationApi({ pageSize, pageNumber })
    // 转换数据
    const transformData = transformUserPageToTableData(rawData.data.userList)
    // 设置数据
    setTableData(transformData as TableDataType[])
  }
  // 显示新增用户弹框
  const handleShowAdd = () => {
    setCreateUserVisible(true)
  }
  // 取消弹框
  const handleCreateUserCancel = () => {
    setCreateUserVisible(false)
  }
  // 确认新增用户
  const handleCreateUserConfirm = async (createUserParams: any) => {
    setCreateUserVisible(false)
    // 将数据传到后端进行提交
    const result = await addUserApi(createUserParams)
    if (result) {
      message.success(result.message)
      // 重新请求数据并渲染
      getUserByPagination()
    }
  }
  // 批量删除用户
  function handleBatchdel(): void {
    console.log('批量删除')
  }

  // 更新用户
  function handleEdit(record: any): void {
    console.log(record)
  }
  // 删除用户
  async function handleDel(rawData: { address: string; key: string; role: string; username: string }) {
    const deleteResult = await deleteUserByUserIdApi(Number(rawData.key))
    if (deleteResult) {
      getUserByPagination()
      message.success(deleteResult.message)
    }
  }

  // 表头名称
  const columns: TableColumnsType<TableDataType> = [
    {
      title: '用户ID',
      dataIndex: 'key',
      width: 100,
      align: 'center'
    },
    {
      title: '用户名',
      dataIndex: 'username',
      width: 150,
      align: 'center'
    },
    {
      title: '邮箱',
      dataIndex: 'address',
      width: 250,
      align: 'center'
    },
    {
      title: '角色',
      dataIndex: 'role',
      width: 150,
      align: 'center'
    },
    {
      title: '操作',
      key: 'action',
      width: 150,
      align: 'center',
      render: (_, record) => (
        <Space size='small'>
          <AuthButton auth='user@update' type='link' onClick={() => handleEdit(record)}>
            编辑
          </AuthButton>
          <AuthButton auth='user@delete' type='text' danger={true} onClick={() => handleDel(record)}>
            删除
          </AuthButton>
        </Space>
      )
    }
  ]
  // checkbox选择时触发
  const rowSelection = {
    onChange: (selectedRowKeys: React.Key[], selectedRows: TableDataType[]) => {
      console.log(`当前选择: ${selectedRowKeys}`, 'selectedRows: ', selectedRows)
    }
  }
  return (
    <div>
      {/* 检索框 */}
      <SearchFormFC form={form} initialValues={{ state: 1 }}>
        <Form.Item name='userId' label='用户ID'>
          <Input placeholder='请输入用户ID' />
        </Form.Item>
        <Form.Item name='userName' label='用户名称'>
          <Input placeholder='请输入用户名称' />
        </Form.Item>
      </SearchFormFC>
      {/* 用户新增/批量删除 */}
      <div className={styles.headerwrapper}>
        <div className='title'>用户列表</div>
        <div className='action'>
          <AuthButton auth='user@add' type='primary' onClick={handleShowAdd}>
            新增
          </AuthButton>
          <AuthButton auth='user@batchdel' type='primary' onClick={handleBatchdel}>
            批量删除
          </AuthButton>
        </div>
      </div>
      {/* 表格数据 */}
      <Table
        rowSelection={{
          ...rowSelection
        }}
        columns={columns}
        dataSource={tableData}
      />
      {/* 创建/更新用户 */}
      <CreateUserFC visible={createUserVisible} onCancel={handleCreateUserCancel} onOk={handleCreateUserConfirm} />
    </div>
  )
}
export default UserFC

具体效果如下: asdvasd.gif

3.9.6 批量删除用户

接着来实现一下批量删除用户:

// 批量删除用户
export function batchDeleteUserApi(batchDeleteUserParams: batchDeleteUserParamsType): Promise<Result<[]>> {
  return request.post(BATCH_DELETE_USER_URL, batchDeleteUserParams)
}
export const BATCH_DELETE_USER_URL = '/user/batchDeleteUser' //批量删除用户
// 批量删除用户时所传的参数类型
export type batchDeleteUserParamsType = {
  userids: number[]
}

接着去调用它:

// ...

const UserFC: React.FC = () => {
  const [form] = Form.useForm() // 搜索组件的form表单
  const [tableData, setTableData] = useState<TableDataType[]>([]) // 表格所需要的数据
  const [createUserVisible, setCreateUserVisible] = useState(false)
  const [selectedRowKeys, SetSelectedRowKeys] = useState<React.Key[]>([])
  // 初始化时获取要分页展示的用户数据
  useEffect(() => {
    getUserByPagination()
  }, [])
  const getUserByPagination = async (pageSize: number = 10, pageNumber: number = 0) => {
    // 获取数据
    const rawData = await getUserByPaginationApi({ pageSize, pageNumber })
    // 转换数据
    const transformData = transformUserPageToTableData(rawData.data.userList)
    // 设置数据
    setTableData(transformData as TableDataType[])
  }
  // 显示新增用户弹框
  const handleShowAdd = () => {
    setCreateUserVisible(true)
  }
  // 取消弹框
  const handleCreateUserCancel = () => {
    setCreateUserVisible(false)
  }
  // 确认新增用户
  const handleCreateUserConfirm = async (createUserParams: any) => {
    setCreateUserVisible(false)
    // 将数据传到后端进行提交
    const result = await addUserApi(createUserParams)
    if (result) {
      message.success(result.message)
      // 重新请求数据并渲染
      getUserByPagination()
    }
  }
  // 批量删除用户
  async function handleBatchdel() {
    // batchDeleteUserApi
    // 获取选中的行数据
    if (selectedRowKeys.length > 0) {
      const ids = selectedRowKeys.map(Number)
      const result = await batchDeleteUserApi({ userIds: ids })
      if (result) {
        message.success(result.message)
        // 重新请求数据
        getUserByPagination()
      } else {
        message.error('批量删除用户失败')
      }
    } else {
      message.error('当前并未选中任何数据')
    }
  }

  // 更新用户
  function handleEdit(record: any): void {
    console.log(record)
  }
  // 删除用户
  async function handleDel(rawData: { address: string; key: string; role: string; username: string }) {
    const deleteResult = await deleteUserByUserIdApi(Number(rawData.key))
    if (deleteResult) {
      getUserByPagination()
      message.success(deleteResult.message)
    }
  }

  // 表头名称
  const columns: TableColumnsType<TableDataType> = [
    {
      title: '用户ID',
      dataIndex: 'key',
      width: 100,
      align: 'center'
    },
    {
      title: '用户名',
      dataIndex: 'username',
      width: 150,
      align: 'center'
    },
    {
      title: '邮箱',
      dataIndex: 'address',
      width: 250,
      align: 'center'
    },
    {
      title: '角色',
      dataIndex: 'role',
      width: 150,
      align: 'center'
    },
    {
      title: '操作',
      key: 'action',
      width: 150,
      align: 'center',
      render: (_, record) => (
        <Space size='small'>
          <AuthButton auth='user@update' type='link' onClick={() => handleEdit(record)}>
            编辑
          </AuthButton>
          <AuthButton auth='user@delete' type='text' danger={true} onClick={() => handleDel(record)}>
            删除
          </AuthButton>
        </Space>
      )
    }
  ]
  // checkbox选择时触发
  const rowSelection = {
    onChange: (selectedRowKeys: React.Key[]) => {
      SetSelectedRowKeys(selectedRowKeys)
    }
  }
  return (
    <div>
      {/* 检索框 */}
      <SearchFormFC form={form} initialValues={{ state: 1 }}>
        <Form.Item name='userId' label='用户ID'>
          <Input placeholder='请输入用户ID' />
        </Form.Item>
        <Form.Item name='userName' label='用户名称'>
          <Input placeholder='请输入用户名称' />
        </Form.Item>
      </SearchFormFC>
      {/* 用户新增/批量删除 */}
      <div className={styles.headerwrapper}>
        <div className='title'>用户列表</div>
        <div className='action'>
          <AuthButton auth='user@add' type='primary' onClick={handleShowAdd}>
            新增
          </AuthButton>
          <AuthButton auth='user@add' type='primary' onClick={handleBatchdel}>
            批量删除
          </AuthButton>
        </div>
      </div>
      {/* 表格数据 */}
      <Table
        rowSelection={{
          ...rowSelection
        }}
        columns={columns}
        dataSource={tableData}
      />
      {/* 创建/更新用户 */}
      <CreateUserFC visible={createUserVisible} onCancel={handleCreateUserCancel} onOk={handleCreateUserConfirm} />
    </div>
  )
}
export default UserFC

在这里为了让批量删除的按钮现在能先显示出来,所以将auth='user@add'修改了,最终效果如下:

123452345234.gif

3.9.7 更新用户

接着来更新用户,创建用户和更新用户的组件其实很类似,为了复用该组件,除了设置setCreateUserVisible使它显示,还需要传给组件当前操作的类型setType('update'),同时还要将当前需要更新的用户id传给组件让他去获得该用户的相关信息setUpdateUserKey(record.key)

// 。。。

const UserFC: React.FC = () => {
  const [form] = Form.useForm() // 搜索组件的form表单
  const [tableData, setTableData] = useState<TableDataType[]>([]) // 表格所需要的数据
  const [createUserVisible, setCreateUserVisible] = useState(false) // 是否显示创建用户的组件
  const [type, setType] = useState<'update' | 'add'>('add') // 是否显示创建用户的组件
  const [updateUserKey, setUpdateUserKey] = useState<string>('') // 选中的行数据的key
  const [selectedRowKeys, SetSelectedRowKeys] = useState<React.Key[]>([]) // 当前多选的行key数组
  // 初始化时获取要分页展示的用户数据
  useEffect(() => {
    getUserByPagination()
  }, [])
  const getUserByPagination = async (pageSize: number = 10, pageNumber: number = 0) => {
    // 获取数据
    const rawData = await getUserByPaginationApi({ pageSize, pageNumber })
    // 转换数据
    const transformData = transformUserPageToTableData(rawData.data.userList)
    // 设置数据
    setTableData(transformData as TableDataType[])
  }
  // 显示新增用户弹框
  // 点击新增 - 触发handleShowAdd显示输入框 - 用户输入 - 确定 - 触发handleCreateUserConfirm新增用户
  // 点击编辑 - 触发handleEdit显示输入框 - 用户输入 - 确定 -  触发handleCreateUserConfirm更新用户
  const handleShowAdd = () => {
    setCreateUserVisible(true)
    setType('add')
  }
  // 取消弹框
  const handleCreateUserCancel = () => {
    setCreateUserVisible(false)
  }
  // 确认新增用户/更新用户
  const handleCreateUserConfirm = async (createUserParams: any) => {
    setCreateUserVisible(false)
    if (type === 'update') {
      // 将数据传到后端进行更新
      createUserParams.rawUserId = Number(updateUserKey) // 待更新用户的id
      const result = await updateUserApi(createUserParams)
      if (result) {
        message.success(result.message)
        setType('add')
        // 重新请求数据并渲染
        getUserByPagination()
      }
    } else {
      // 将数据传到后端进行新增
      const result = await addUserApi(createUserParams)
      if (result) {
        message.success(result.message)
        // 重新请求数据并渲染
        getUserByPagination()
      }
    }
  }
  // 更新用户
  function handleEdit(record: any): void {
    // 显示创建/更新的组件
    setCreateUserVisible(true)
    // 设置为更新类型而不是添加类型
    setType('update')
    // 将当前行数据传给组件获取数据回显
    setUpdateUserKey(record.key)
  }
  // 批量删除用户
  // ...
  // 删除用户
  // ...

  // 表头名称
  // ...
  // checkbox选择时触发
  const rowSelection = {
    onChange: (selectedRowKeys: React.Key[]) => {
      SetSelectedRowKeys(selectedRowKeys)
    }
  }
  return (
    <div>
      {/* 检索框 */}
      {/* 表格数据 */}
      {/* 创建/更新用户 */}
      <CreateUserFC
        visible={createUserVisible}
        onCancel={handleCreateUserCancel}
        onOk={handleCreateUserConfirm}
        type={type}
        updateUserKey={updateUserKey}
      />
    </div>
  )
}
export default UserFC

然后自组件中要接收这些数据

import { getAllRolesApi, getUserByUserIdApi } from '@/api'
import { SelectParamType } from '@/types'
import { transformRoles } from '@/utils/transformRoles.ts'
import { Form, Input, Modal, Select } from 'antd'
import { useCallback, useEffect, useState } from 'react'

export interface CreateUserProps {
  visible: boolean
  onCancel: () => void
  onOk: (form: any) => void
  type?: 'update' | 'add'
  updateUserKey?: string
}

const CreateUserFC: React.FC<CreateUserProps> = ({ visible, onCancel, onOk, type = 'add', updateUserKey }) => {
  const [form] = Form.useForm()
  const [roleList, setRoleList] = useState<SelectParamType>([])
  const [changeRoleList, setChangeRoleList] = useState<SelectParamType>([])

  // 获取所有角色
  const getAllRoles = async () => {
    const allRolesResult = await getAllRolesApi()
    // 将数据转换成所需要的结构
    const newRoleList = transformRoles(allRolesResult.data)
    setRoleList(newRoleList)
  }

  // 获取当前要更新的用户数据
  const getUserInfo = useCallback(
    async (id: number) => {
      const result = await getUserByUserIdApi(id)
      form.setFieldValue('username', result.data[0].username)
      form.setFieldValue('password', result.data[0].password)
      form.setFieldValue('email', result.data[0].email)
      const newRoleList = transformRoles(result.data[0].roles)
      if (newRoleList) {
        form.setFieldValue('roleList', newRoleList)
      }
    },
    [form]
  )
  // 根据type类型判断是否需要获取用户信息
  useEffect(() => {
    getAllRoles()
    if (type === 'update') {
      getUserInfo(Number(updateUserKey))
    } else {
      return () => {
        form.setFieldsValue({
          username: undefined,
          password: undefined,
          email: undefined,
          roleList: [] // 清空roleList字段
        })
      }
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [form, type, updateUserKey, getUserInfo])

  // 点击确认时触发
  const getCreateUserInfo = () => {
    // 获取表单数据
    const formData = form.getFieldsValue()
    formData.roleList = changeRoleList
    // 清空数据避免下一次点开时看到上一次数据
    form.setFieldsValue({
      username: undefined,
      password: undefined,
      email: undefined,
      roleList: [] // 清空roleList字段
    })
    // 传递数据到父组件
    return onOk(formData)
  }
  // 下列选择内容变化时候触发
  const handleSelectChange = (recorde: any) => {
    setChangeRoleList(recorde)
  }
  return (
    <Modal open={visible} onCancel={onCancel} onOk={getCreateUserInfo} closeIcon={null} cancelText='取消' okText='确认'>
      <Form form={form} labelCol={{ span: 4 }} labelAlign='right'>
        {/* 用户名称 */}
        <Form.Item
          label='用户名称'
          name='username'
          rules={[
            { required: true, message: '请输入用户名称' },
            { min: 5, max: 30, message: '用户名称最小5个字符最大12个字符' }
          ]}
        >
          <Input placeholder='请输入用户名称'></Input>
        </Form.Item>
        {/* 用户密码 */}
        <Form.Item
          label='用户密码'
          name='password'
          rules={[
            { required: true, message: '请输入用户密码' },
            { min: 5, max: 30, message: '用户名称最小5个字符最大12个字符' }
          ]}
        >
          <Input placeholder='请输入用户密码' type='password'></Input>
        </Form.Item>
        {/* 用户邮箱 */}
        <Form.Item
          label='用户邮箱'
          name='email'
          rules={[
            { required: true, message: '请输入用户邮箱' },
            { type: 'email', message: '请输入正确的邮箱' },
            {
              pattern: /^\w+@qq.com$/,
              message: '邮箱必须以@qq.com结尾'
            }
          ]}
        >
          <Input placeholder='请输入用户邮箱'></Input>
        </Form.Item>
        {/* 用户角色 */}
        <Form.Item label='系统角色' name='roleList'>
          <Select placeholder='请选择角色' mode='multiple' onChange={handleSelectChange}>
            {roleList.map(item => {
              return (
                <Select.Option value={item._id} key={item.roleName}>
                  {item.roleName}
                </Select.Option>
              )
            })}
          </Select>
        </Form.Item>
      </Form>
    </Modal>
  )
}
export default CreateUserFC

效果如下: 12345421.gif