1.前言
- 在【技术/前端】基于vite的react项目工程化(1) 中将项目所需要的各种工程化检测添加完成
- 在【技术/前端】基于vite的react项目工程化(2) 中将项目中所需要的基础类库添加完成
接下来需要将常用的组件集成到模版中,其中包括:
- 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;
}
最终效果如下:
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
效果如下:
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)
具体效果如下,很丑但全部引入正确了:
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,他就会直接回到登陆页面,具体效果如下:
此时已经能正确拦截缺失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
此时请求会报错:
这是因为在utils/request.ts中 const token = storage.get('AccessToken') 应该改成accessToken,然后重新请求就数据正常
此时如果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)
}
}
具体效果如下:
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组件内,此时效果如下:
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个路径,都是可以访问的
user4可访问的1个路径可以访问
3.3 动态菜单
现在还需要处理一下左侧的菜单,虽然路由已经动态生成了,但左侧菜单还是写死在组件内的,所以也需要根据用户权限动态生成
此时在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
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);
}
具体效果如下:
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
最终效果如下:
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项
// ...
此时效果如下:
此时还是有问题的:
- 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
此时效果如下:
此时其实还是有问题的,比如重复点击同一个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
},
// ...
}))
目前效果如下:
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;
}
此时效果如下:
顺便把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;
}
}
}
效果如下:
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
效果如下:
可以看到它的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已经可以找到了,接下来试一下效果:
此时有点问题,就是它始终渲染的是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])
此时效果如下:
其实目前还是有问题, 当点击左侧导航菜单时,tabs会加1,这时切换tab到第一个然后将其关闭时就会报错,具体效果如下:
这是因为在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]
},
})
这样就解决了上面的问题,现在整体效果如下:
其实关于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: '销售管理'
}
}
目前效果如下:
至此将下一步按钮权限所需要的内容准备完成,先来总结一下:
- 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;
}
具体效果如下:
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 (
// ...
)
}
结果如下:
接下来需要把该数据转换成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
具体效果如下:
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"
]
此时用户除了没有批量删除用户的权限其他都有,所以除了批量删除按钮其他都能显示,具体如下:
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组件,它是通过open和onCancel控制开关的,同时当点击确认时,会触发onOk,这些属性在它的类型文件中可以找到:
里面是一个
Form表单,它通过const [form] = Form.useForm()收集表单数据:userId,username, email, roleName
接着在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
此时效果如下:
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
此时效果如下:
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
具体效果如下:
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'修改了,最终效果如下:
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
效果如下: