Fork me on GitHub

记录一下React简易后台管理系统搭建过程

最近重新学了一下 react 技术栈,顺便重构了去年一个管理后台,不得不说前端变化实在是太快了,几个月没用 react 代码都快看不懂了。

源码地址 👉https://github.com/xiaojun1994/react-admin-lite

🌵 技术栈

基于 cra ts 版

1
2
3
npx create-react-app my-app --typescript
# or
yarn create react-app my-app --typescript
  • react + hooks
  • react-router
  • mobx
  • typescript
  • antd
  • mockjs

🍭 需求分析

  • antd 按需引入
  • 按配置文件自动创建菜单
  • 路由懒加载
  • 必须登录才能访问,如果没登录直接重定向到登录页面
  • 访问了一个不存在的路由跳转到 404 页面
  • 实现路由级别和按钮级别的权限控制,根据登录用户展示对应菜单
  • 使用 mock 数据
  • mobx 状态持久化,避免刷新丢失状态

🍺 目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
├── README.md
├── config-overrides.js rewired配置
├── global.d.ts 存放一些ts全局声明
├── package.json
├── public
│   ├── favicon.ico
│   ├── index.html
│   ├── logo192.png
│   ├── logo512.png
│   ├── manifest.json
│   └── robots.txt
├── src
│   ├── App.test.tsx
│   ├── App.tsx
│   ├── Layout 存放布局组件
│   ├── assets 存放一些图片资源
│   ├── common 存放一些公共变量、方法等
│   ├── components 存放公共组件
│   ├── config 存放一些配置文件
│   ├── index.less
│   ├── index.tsx
│   ├── mock 存放mock数据
│   ├── pages
│   ├── plugins 实际上就放了一个封装后的axios
│   ├── react-app-env.d.ts
│   ├── serviceWorker.ts
│   └── store mobx仓库
├── tsconfig.json
└── yarn.lock

🍬 按需引入 antd

先安装这几个依赖 babel-plugin-import customize-cra less less-loader react-app-rewired

根目录下新建 config-overrides.js 并写入以下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// config-overrides.js
const { override, fixBabelImports, addLessLoader, addDecoratorsLegacy } = require('customize-cra')

module.exports = override(
fixBabelImports('import', {
libraryName: 'antd',
libraryDirectory: 'es',
style: true
}),
addLessLoader({
javascriptEnabled: true,
modifyVars: {
'@primary-color': '#8994DF'
}
}),
addDecoratorsLegacy() // 配置以允许使用装饰器
)

然后别忘了改一下 package.json

1
2
3
4
5
6
7
8
// ...
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-scripts eject"
}
// ...

🏀 登录控制

没登录自动跳到登录页面,这个功能通过封装 Route 组件实现,让我们先搞个 AuthRoute

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/components/AuthRoute
import React, { useContext } from 'react'
import { Route, Redirect, RouteProps } from 'react-router-dom'
import * as stores from '../../store'

type PickRequired<T, K extends keyof T> = T & Required<Pick<T, K>>

const AuthRoute: React.FC<PickRequired<RouteProps, 'component'>> = props => {
const { isLogged } = useContext(stores.userStore)
const { component: Component, ...rest } = props

return (
<Route
{...rest}
render={props => {
return isLogged ? <Component {...props} /> : <Redirect to="/login" />
}}
/>
)
}

export default AuthRoute

⚽️ 按配置文件自动生成菜单

以下教程会通过 lazy Suspense 设置路由懒加载

配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// global.d.ts
interface IMenu {
name: string // 菜单名
key: string // 菜单对应路由
icon?: string // 菜单图标
component?: React.ComponentType<any> // 菜单路由对应页面
children?: IMenu[] // 子菜单
permissions?: Permission[] // 菜单权限
}
// src/config/menus.ts
import { lazy } from 'react'

const menus: IMenu[] = [
{
name: '合作商户',
key: '/information',
icon: 'user',
component: lazy(() => import('../pages/Information')),
permissions: ['admin']
},
{
name: '订单查询',
key: '/order-query',
icon: 'file-text',
component: lazy(() => import('../pages/OrderQuery')),
permissions: ['admin']
},
{
name: '交易查询',
key: '/trade-query',
icon: 'pay-circle',
component: lazy(() => import('../pages/TradeQuery')),
permissions: ['admin']
},
{
name: '报表导出',
key: '/reports',
icon: 'upload',
component: lazy(() => import('../pages/Reports')),
permissions: ['admin']
},
{
name: '一级菜单',
key: '/level1',
icon: 'man',
children: [
{
name: '二级菜单',
key: '/level2',
icon: 'woman',
children: [
{
name: '三级菜单',
key: '/level3',
icon: 'api',
component: lazy(() => import('../pages/Hello'))
}
]
}
]
},
{
name: '权限测试',
key: '/permission',
icon: 'medicine-box',
component: lazy(() => import('../pages/Permission'))
}
]

export default menus

封装 Menus 组件

Menus 组件负责根据配置文件递归生成侧边菜单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// src/components/Menus
import React from 'react'
import { Icon, Menu } from 'antd'
import { ClickParam } from 'antd/lib/menu'
import { withRouter, RouteComponentProps } from 'react-router-dom'
import menus from '../../config/menus'
import { usePermission } from '../../common/hooks'

const Menus: React.FC<RouteComponentProps> = props => {
const { location, history } = props
const hasPermission = usePermission()

function handleNavClick({ key }: ClickParam) {
history.push(key)
}

function hasChild(menu: any) {
return Array.isArray(menu.children) && menu.children.length > 0
}

function genSubMenu(menu: any) {
return (
<Menu.SubMenu
title={
<span>
{menu.icon && <Icon type={menu.icon} />}
<span>{menu.name}</span>
</span>
}
key={menu.key}
>
{genMenus(menu.children)}
</Menu.SubMenu>
)
}

function genMenItem(menu: any) {
return (
<Menu.Item key={menu.key}>
{menu.icon && <Icon type={menu.icon} />}
<span>{menu.name}</span>
</Menu.Item>
)
}

function genMenus(menus: any) {
return menus.reduce((prev: any, next: any) => {
return prev.concat(
hasChild(next)
? hasPermission(next) && genSubMenu(next)
: hasPermission(next) && genMenItem(next)
)
}, [])
}

return (
<Menu theme="dark" mode="inline" selectedKeys={[location.pathname]} onClick={handleNavClick}>
{genMenus(menus)}
</Menu>
)
}

export default withRouter(Menus)

封装 Content 组件

Content 组件负责根据配置文件递归生成路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// src/components/Content
import React, { Suspense } from 'react'
import { Layout, Spin } from 'antd'
import { Switch } from 'react-router-dom'
import AuthRoute from '../AuthRoute'
import menus from '../../config/menus'
import NoMatch from '../../pages/404'
import { usePermission } from '../../common/hooks'

const Content: React.FC = props => {
const hasPermission = usePermission()

function hasChild(menu: any) {
return Array.isArray(menu.children) && menu.children.length > 0
}

function genRoute(menu: any) {
if (!menu.component) return null
return <AuthRoute path={menu.key} component={menu.component} key={menu.key} />
}

function genRoutes(menus: any) {
return menus.reduce((prev: any, next: any) => {
return prev.concat(
hasChild(next)
? hasPermission(next) && genRoutes(next.children)
: hasPermission(next) && genRoute(next)
)
}, [])
}

return (
<Layout.Content
style={{
margin: '24px'
}}
>
<Suspense
fallback={
<div style={{ textAlign: 'center', marginTop: '50px' }}>
<Spin tip="Loading..." />
</div>
}
>
<Switch>
{genRoutes(menus)}
<AuthRoute component={NoMatch} />
</Switch>
</Suspense>
</Layout.Content>
)
}

export default Content

🚁 按钮级别权限控制

上面我们已经实现了菜单级别权限控制,但是这还不够,可能有时候还需要按钮级别权限控制

自定义 hooks

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/common/hooks.tsx
import React, { useContext } from 'react'
import { userStore } from '../store'

// ...

// 处理按钮级别权限,类似高阶组件
function useAuthComponent(...permissions: Permission[]) {
const { userinfo } = useContext(userStore)
const hasPermission = permissions.includes(userinfo.permission)

return <P extends {}>(BaseComponent: React.ComponentType<P>): React.FC<P> => props =>
hasPermission ? <BaseComponent {...props} /> : null
}

export { usePermission, useAuthComponent }

这个 hooks 我封装的不是很好,以后看能否优化

使用教程

1
2
3
4
5
6
7
8
9
10
11
12
13
import React from 'react'
import { Button } from 'antd'
import { observer } from 'mobx-react-lite'
import { useAuthComponent } from '../common/hooks'

const Permission: React.FC = props => {
// @ts-ignore 某些组件会报错,忽略它
const AuthButton = useAuthComponent('admin')(Button)

return <AuthButton type="primary">权限为admin该按钮才显示</AuthButton>
}

export default observer(Permission)

🏉 使用 mock 数据

需要安装 mockjs

简单示例,具体请移步官网

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import Mock from 'mockjs'

Mock.setup({
// 模拟延迟(200-600毫秒之间)
timeout: '200-600'
})

Mock.mock(/login/, (val: any) => {
const isAdmin = val.url.match(/admin/)
return isAdmin
? {
t: {
account: '[email protected]',
name: 'admin',
permission: 'admin'
}
}
: {
t: {
account: '[email protected]',
name: 'guest',
permission: 'guest'
}
}
})

Mock.mock(/getMerchantInfo/, {
t: {
'account|3-8': [
{
name: '@cname',
account: '@email'
}
],
'product|5-10': [
{
'productId|5': /\d/,
'productType|1': [10, 20, 30, 40, 50],
'status|0-1': 0,
'road|5-10': [
{
name: '测试商户',
roadCode: /10021\d{6}/,
'status|0-1': 0
}
]
}
]
}
})

Mock.mock(/order-query/, {
t: {}
})

Mock.mock(/trade-query/, {
't|10': [
{
'id|+1': 0,
merOrderNo: /\d{12}/,
payOrderId: /\d{12}/,
roadCode: /10021\d{6}/,
merName: '@word()',
'amount|10-10000.2-2': 10,
'status|1': [10, 20, 30, 40, 50],
createTime: "@date('yyyy-MM-dd hh:mm:ss')"
}
]
})

之后在 App.tsx 引入它即可

🤡 mobx 持久化存储

很简陋,凑合用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// src/common/utils.ts
import { autorun } from 'mobx'
/**
* mobx 状态持久化
*/
let is_first_run = true
function mobxPersist<T, K extends keyof T>(store: T, fields: K[]): T {
autorun(() => {
fields.forEach(field => {
if (is_first_run) {
const data = window.sessionStorage.getItem(field as string)
data && (store[field] = JSON.parse(data))
}
window.sessionStorage.setItem(field as string, JSON.stringify(store[field]))
})
is_first_run = false
})
return store
}

export { mobxPersist }

使用教程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { createContext } from 'react'
import { observable, action, computed } from 'mobx'
import { mobxPersist } from '../../common/utils'

export class UserStore {
@observable userinfo: any = {}

@action
saveUserinfo = (data: any) => {
this.userinfo = data
}

@computed
get isLogged() {
return Object.keys(this.userinfo).length > 0
}

@action
cleanUserinfo = () => {
this.userinfo = {}
}
}

export default createContext(mobxPersist(new UserStore(), ['userinfo']))

🌝 结尾

暂时写这么多,感觉还有很大优化空间,等我慢慢完善,ts 给我感觉就像臭豆腐一样,闻着臭,吃着还行~🤖