React从0到1后台管理系统实战
框架:React18 + Vite3 + TypeScript4皮肤:AntDesign / SemiDesign / TDesign
目录
前言
2022年7月写,React18 + Vite3 + TypeScript4
React是一门纯粹的前端语言,少了Vue的语法糖,少了Vue的模版限定,这样子写出来的系统框架灵活度更高。
组件 | 选型 |
---|---|
脚手架 | vite |
状态管理 | jotai |
路由 | react-router-dom |
mock | vite-plugin-mock |
网络请求 | axios |
- 脚手架:
脚手架是前端项目创建的工具,官方默认是使用CRA,但是笔者使用下来CRA并不方便,例如要自定义webpack、区分开发测试生产环境等都非常麻烦,最致命的是启动项目很慢,相比而言咱们尤大大的Vite就太方便了,配置简约、插件方便、秒启动。 - 状态管理:
react的状态管理框架群魔乱舞,官方的useContext、老牌的redux都是首选,但是笔者使用下来useContext太原始,只能称为一个函数,需要自己再封装一堆代码才能用;redux太重,笔者并不喜欢为了维护一个状态,还要遵循框架的dispatch模式,最后选择了jotai。
笔者认为就一个状态管理,越轻量越好,jotai的原子化就很轻
//三行代码就搞定一个状态的维护
import { atom, useAtom } from "jotai";
const valueAtom = atom(0);
const [value, setValue] = useAtom(valueAtom);
- 路由:
React的路由管理没有其他选择,只有官方的react-router-dom,做为一个后管系统,动态路由是必不可少的,在v6之前需要一个三方库react-router-config来管理动态路由配置,在v6以后官方出了一个useRoutes函数来取代react-router-config - mock:
mock数据是偏后端的东西,前端选择一个简单能用的就好了,vite内部集成的插件vite-plugin-mock够用了 - 网络请求:
不管是react还是vue,网络请求库axios都是默认首选,这个好像没得商量,不过react的fetch框架也可以一试,在实际项目中咱们一般都会针对网络请求库做二次封装,因为需要定义全局请求头和全局异常拦截。
完整项目地址:https://gitee.com/nieoding/radmin
废话少说,接下来进入正题,从0到1开撸后台
一、创建项目
- 脚手架创建项目
# 国内环境推荐全局安装tyarn,比npm安装依赖包快,很少发生因为被墙而安装包失败的事情
npm install yarn tyarn -g
# 如果使用tyarn
tyarn create vite myapp --template react-ts
# 如果使用npm 7+, 需要额外的双横线:
npm init vite@latest myapp -- --template react-ts
# 安装依赖包
cd myapp
tyarn install
# 启动项目
tyarn dev
- 人工创建一些新子目录,搭个后管系统框架
myapp
├─ mock # Mock数据
├─ public
├─ src
│ ├─ api # 后台接口
│ ├─ assets
│ ├─ components # 通用组件
│ ├─ config # 系统配置
│ ├─ layouts # 界面布局
│ ├─ store # 状态管理
│ ├─ utils # 工具库
│ ├─ views # 页面·
│ ├─ App.css
│ ├─ App.tsx
│ ├─ index.css
│ ├─ main.tsx
│ └─ vite-env.d.ts
├─ index.html
├─ package.json
├─ tsconfig.json
├─ tsconfig.node.json
└─ vite.config.ts
- 添加alias@配置 (前端一般约定用@来定义代码根目录src, 例如
import page1 from "@/views/page1"
)
# 编辑器不认识path模块,需要安装一下(仅安装到开发依赖包)
tyarn add @types/node -D
# 修改tsconfig.json(让编辑器支持)
{
"compilerOptions": {
...
"paths": {
"@/*": ["./src/*"]
}
}
}
# 修改vite.config.ts(让程序支持)
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import {resolve} from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': resolve(__dirname, "src")
}
}
})
二、Mock数据
假定前后端约定使用JWT Token规则,即 请求头携带 authorization: Bearer {token}
假定后端提供的接口如下
接口 | 地址 | 请求方法 | 参数 | 返回 |
---|---|---|---|---|
鉴权-登录 | /api/auth/login | POST | {username:string, password:string} | {token: string} |
鉴权-用户信息 | /api/auth/userinfo | GET | empty | {id:int, username: string, role: string} |
鉴权-退出 | /api/auth/logout | POST | empty | empty |
用户-列表 | /api/user | GET | empty | user[] |
用户-详情 | /api/user/{id} | GET | empty | user |
用户-新增 | /api/user | POST | user | user |
用户-修改 | /api/user/{id} | PUT | user | user |
用户-删除 | /api/user/{id} | DELETE | empty | empty |
1. 引入库
tyarn add jsonwebtoken mockjs vite-plugin-mock -D
在vite.config.ts里面配置vite-plugin-mock
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import {resolve} from 'path'
import {viteMockServe} from 'vite-plugin-mock'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), viteMockServe()],
resolve: {
alias: {
'@': resolve(__dirname, "src")
}
}
})
修改完需要重启一下应用才能生效
2. 范例
// mock/demo.ts
export default [
{
url: '/api/ping',
method: 'get',
response: 'pong',
},
]
保存以后vite会直接热更新的,访问一下http://127.0.0.1:5173/api/ping,就可以看到mock数据生效了
3. 真实实现
给mock做个目录约束
mock
├── models # 存放数据模型
│ └── user.ts
├── services # 存放接口
│ ├── auth.ts
│ └── user.ts
└── utils.ts # 工具库
源码实现
// models/user.ts
const Mock = require('mockjs')
const queryset = [
{
id: Mock.mock('@increment'),
username: 'admin',
password: 'admin',
role: 'admin',
},
{
id: Mock.mock('@increment'),
username: 'guest',
password: 'guest',
role: 'guest',
}
]
export default queryset;
// utils.ts
import { ServerResponse } from 'http';
import jwt from 'jsonwebtoken'
import users from './models/user'
export const JWT_SECRET_KEY = "Hello World!" // JWT生成密钥
// 处理POST报文
export async function parseJson(req){
let body = '';
await new Promise((resolve)=>{
req.on('data', (chunk)=>{
body += chunk
})
req.on('end', ()=>{resolve(undefined)})
})
return JSON.parse(body)
}
// 根据token取用户信息
export function parseUser(headers){
const info = headers.authorization
if(!info){return null}
const [prefix, token] = info.split(' ')
if(prefix!=='Bearer'){return null}
try{
const data = jwt.verify(token, JWT_SECRET_KEY)
return users.find(item=>item.id===data.id)
}
catch (e){
return null
}
}
// 未授权请求
export function authFailed(res: ServerResponse, message: string){
res.statusCode = 401
res.setHeader('Content-Type', 'text/plain;charset=utf-8')
res.end(message)
}
// 错误请求
export function apiFailed(res: ServerResponse, message: Object){
res.statusCode = 500
res.setHeader('Content-Type', 'application/json;charset=utf-8')
res.end(JSON.stringify(message))
}
// 正常响应
export function response(res: ServerResponse, message: Object){
res.statusCode = 200
res.setHeader('Content-Type', 'application/json;charset=utf-8')
res.end(JSON.stringify(message))
}
// services/auth.ts
import { authFailed, JWT_SECRET_KEY, parseJson, parseUser, response } from "../utils"
import users from '../models/user'
import jwt from 'jsonwebtoken'
const TOKEN_TIMEOUT = 60 * 60 // Token 1小时过期
export default [
{
url: '/api/auth/login',
method: 'post',
timeout: 300, // 模拟响应延时
rawResponse: async(req, res) => {
const body = await parseJson(req)
const user = users.find(item => item.username === body.username)
if(!user){
return authFailed(res, "用户不存在")
}
if(user.password !== body.password){
return authFailed(res, "密码错误")
}
const payload = {
id: user.id,
username: user.username,
exp: Math.floor(Date.now()/1000) + TOKEN_TIMEOUT,
}
const token = jwt.sign(payload, JWT_SECRET_KEY, {algorithm: 'HS256'})
return response(res, {token: token})
}
},
{
url: '/api/auth/userinfo',
method: 'get',
rawResponse: (req, res) => {
const user = parseUser(req.headers)
if(!user){return authFailed(res, "Token过期")}
return response(res, user)
}
},
{
url: '/api/auth/logout',
method: 'post',
}
]
// services/user.ts
// 一套完整的CRUD实现
import users from '../models/user'
import { apiFailed, parseJson, response } from '../utils'
const Mock = require('mockjs')
export default [
{
user: '/api/user',
method: 'get',
response: users
},
{
url: '/api/user/:id',
method: 'get',
response: ({query}) => {
return users.find(item => item.id.toString() === query.id)
}
},
{
url: '/api/user',
method: 'post',
rawResponse: async(req, res) => {
const instance = await parseJson(req)
if(users.find(item => item.username === instance.username)){
return apiFailed(res, '用户名重复')
}
instance.id = Mock.mock('@increment')
users.push(instance)
return response(res, instance)
}
},
{
url: '/api/user/:id',
method: 'put',
response: ({query, body}) => {
const instance = users.find(item => item.id.toString() === query.id)
instance && Object.assign(instance, body)
return instance
}
},
{
url: '/api/user/:id',
method: 'delete',
response: ({query}) => {
const instance = users.find(item => item.id.toString() === query.id)
instance && users.splice(users.indexOf(instance))
return {}
}
}
]
二、路由与菜单
1. 引入库
tyarn add react-router-dom
2. 范例
简单而言,一个后管系统的逻辑是:首先一个登录界面,登录成功以后的系统就像一个app,有着一样的布局(头部+侧边栏+内容+尾部)
真正的业务逻辑页面应该是被一个总路由器来调度替换布局中的内容模块。
我们来重写一下App.tsx,简单体验实现一下静态版本的路由调度:
import {BrowserRouter, Outlet, Route, Routes, Link} from 'react-router-dom';
function LoginView(){
return <div>login page <Link to="/">login</Link></div>
}
function NotFoundView() {
return <div>404 no found <Link to="/">home</Link></div>
}
function HomepageView(){
return <div>home page</div>
}
function DemoView(){
return <div>demo page</div>
}
function AppLayout() {
return <>
{/* 头部 */}
<div>
<Link to="/">home</Link> 
<Link to="demo">demo</Link> 
<Link to="login">logout</Link>
</div>
{/* 内容体 */}
<Outlet/>
</>
}
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="login" element={<LoginView/>}/>
<Route path="/" element={<AppLayout/>}>
<Route path="/" element={<HomepageView/>}/>
<Route path="demo" element={<DemoView/>}/>
</Route>
<Route path="*" element={<NotFoundView/>}/>
</Routes>
</BrowserRouter>
)
}
export default App
核心思路是利用Outlet包裹 + 二级路由 来实现实际路由调度,可参见官方文档
3. 真实实现
真正的后管,用户不同,看到的菜单是不同的,所以我们要实现路由的可配置化;
上面的范例是将页面函数化,写在同一个文件里,但实际情况,所有业务逻辑页面都应该放在views目录下,在页面很多的情况下,路由的配置文件也不能用·import page1 from '@/views/page1'
这样的写法加载页面,而应该使用懒加载React.lazy,在需要的时候才加载某页面,参见官方文档
3.1 首先我们把页面搬家到views里面
src/views
├── 404.tsx # 404
├── demo.tsx # 范例
├── home.tsx # 首页
└── login.tsx # 登录
// src/views/404.tsx
export default function(){
return <div>404 no found</div>
}
// src/views/demo.tsx
export default function (){
return <div>demo</div>
}
// src/views/home.tsx
export default function() {
return <div>home</div>
}
// src/views/login.tsx
import { Link } from "react-router-dom";
export default function(){
return <div>login page <Link to="/">login</Link></div>
}
3.2 书写路由配置文件
// src/config/router.config.tsx
import React from "react"
export interface RouteItem {
name: string, // 菜单唯一标识
path: string, // 路由路径
children?: RouteItem[], // 子路由
component?: Function, // 路由元素,类似于element,支持React.lazy
hidden?: boolean, // true则不在菜单中显示
redirect?: string, // 重定向到哪个路由
meta?: {
title: string, // 菜单显示的名称
icon?: JSX.Element, // 菜单显示的图标
permission?: string[] // 根据用户权限显示菜单
}
}
export const LOGIN_PATH = 'login' // 默认登录路径
export const KEY_HOME = 'home' // 默认主页标识
const routers: RouteItem[] = [
{
path: '/',
name: KEY_HOME,
component: React.lazy(() => import('@/views/home')),
meta: {
title: "首页"
}
},
{
path: 'demo',
name: 'demoPage',
component: React.lazy(() => import('@/views/demo')),
meta: {
title: "演示"
}
},
]
export default routers;
3.3 模版搬家到layouts目录
// src/layouts/AppLayout.tsx
import { Link, Outlet } from "react-router-dom";
import routers from '@/config/router.config';
export default function (){
return <>
{/* 头部 */}
<div>
{
routers.map((item, idx)=> <span key={idx}><Link to={item.path}>{item.meta?.title}</Link> </span>)
}
<Link to="login">logout</Link>
</div>
{/* 内容体 */}
<Outlet/>
</>
}
考虑到毛胚版的html显示多集菜单比较复杂,这里的菜单渲染routers.map(...)
只做了简单的处理,待到下文整合antd等框架时会再做完善
3.7 封装路由相关工具函数
// src/utils/router.tsx
import { RouteItem } from "@/config/router.config";
import React from "react";
import { Navigate, Outlet, Route } from "react-router-dom";
export function renderRouter(item: RouteItem){
return (
<Route key={item.name} path={item.path} element={
<React.Suspense fallback={<></>}>
{
item.children ? <Outlet/> : (item.component && <item.component/>)
}
</React.Suspense>
}>
{
item.children && item.children.map(child => renderRouter(child))
}
{
item.redirect && <Route path="" element={<Navigate to={item.redirect}/>}/>
}
</Route>
)
}
3.6 重写App.tsx
import {BrowserRouter, Outlet, Route, Routes, Navigate} from 'react-router-dom';
import LoginView from '@/views/login'
import NotFoundView from '@/views/404'
import AppLayout from '@/layouts/AppLayout';
import routers, { RouteItem } from '@/config/router.config';
import React from 'react';
import { renderRouter } from './utils/router';
function AppRouter(){
return (
<BrowserRouter>
<Routes>
<Route path="login" element={<LoginView/>}/>
<Route path="/" element={<AppLayout/>}>
{
routers.map(item=>renderRouter(item))
}
</Route>
<Route path="*" element={<NotFoundView/>}/>
</Routes>
</BrowserRouter>
)
}
function App() {
return (
<>
<AppRouter/>
</>
)
}
export default App
效果和范例是一样的,但是路由基本骨架就已经算完成了,接下来还需要与登录接口、会话管理、权限拦截整合在一起才算完整。
三、会话管理
1. 引入库
tyarn add jotai
2. 实现
子目录store
专门用来存放会话数据,方便扩展
// src/store/index.ts
import { RouteItem } from "@/config/router.config";
import { atom } from "jotai";
// 用户信息结构,与后端约定
export interface UserInfo {
id: number,
username: string,
role: string
}
// 本地用户结构
export interface User {
info?: UserInfo,
routers?: RouteItem[], // 根据当前用户角色筛选的路由列表
}
// 会话 - 用户信息
export const userStore = atom<User>({})
页面需要使用会话的时候,导入这个userStore
即可配合useAtom
使用。
四、与服务端交互
1. 引入库
tyarn add axios
2. 二次封装axios
// src/utils/request.tsx
import React from "react";
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
import { userStore } from "@/store";
import { useAtom } from "jotai";
import { LOGIN_PATH } from "@/config/router.config";
const conf: AxiosRequestConfig = {
baseURL: '/api',
timeout: 6000
}
const request: AxiosInstance = axios.create(conf)
let intercrepted = false
function useAjaxEffect(){
const [,setUser] = useAtom(userStore)
if(intercrepted){return}
intercrepted = true // 避免重复注册拦截器
request.interceptors.request.use((config: AxiosRequestConfig) => {
const token = window.localStorage.getItem('access-token')
// 如果token存在,则写入到头部(遵循jwt交互标准)
token && (config.headers = {Authorization: `Bearer ${token}`})
return config
})
request.interceptors.response.use((response: AxiosResponse) => {
// 正常请求不做什么处理
return response
}, (error) => {
// 仅拦截401错误(登录页登陆的错误返回也是401,这里需要排除掉)
if(error.response.status === 401 && (window.location.pathname !== LOGIN_PATH)){
alert('token过期')
setTimeout(() =>{
window.localStorage.removeItem('access-token')
setUser({})
window.location.reload()
}, 300)
} else {
// 其他错误由业务代码自行处理
return Promise.reject(error)
}
})
}
function AjaxEffectFragment(){
useAjaxEffect()
return <React.Fragment/>
}
export {AjaxEffectFragment};
export default request
useAjaxEffect
是实际拦截器代码,使用到了会话useAtom
钩子函数,React要求useXXX钩子函数必需是被jsx函数体调用,而不能在js里全局主动调用,参见官方文档。
所以我们导出一个碎片函数AjaxEffectFragment
让app真实注入拦截
3. App注入拦截
修改App.tsx
...
import { AjaxEffectFragment } from '@/utils/request';
...
function App() {
return (
<>
<AjaxEffectFragment/>
<AppRouter/>
</>
)
}
4. 封装接口
我们把所有与服务器交互的接口都统一放在api
目录下
// src/api/auth.ts
import request from '@/utils/request'
const URL = '/auth'
export function login(params: any){
return request({
url: `${URL}/login/`,
method: 'post',
data: params
})
}
export function logout(){
return request({
url: `${URL}/logout/`,
method: 'post',
})
}
export function userinfo(){
return request({
url: `${URL}/userinfo/`,
method: 'get',
})
}
文章开头我们已经做了mock数据,所以现在接口是可以正常访问的,调用的接口也很简单
import * as service from '@/api/auth'
const response = await service.userinfo();
五、整合登录
路由、会话、接口3大件前面都搭好了,接下来就需要真正前后端将token、权限、路由完全打通。
1. 重写登录页面
// src/views/login.tsx
import * as service from '@/api/auth'
import { useNavigate } from 'react-router-dom'
export default function(){
const navigate = useNavigate()
function handleLogin(){
service.login({username: 'admin', password: 'admin'}).then(data => {
// 将token写入本地存储
window.localStorage.setItem('access-token', data.data.token)
// 重新进入首页
navigate('/')
}).catch(err => {
alert(err.response.data)
})
}
return <div>login page <a onClick={handleLogin}>login</a></div>
}
2. 路由工具库加入一些新的函数
// src/utils/router.tsx
import routers, { LOGIN_PATH, RouteItem } from "@/config/router.config";
import { userStore } from "@/store";
import { useAtom } from "jotai";
import React from "react";
import { Navigate, Outlet, Route, useLocation } from "react-router-dom";
import * as service from '@/api/auth'
export interface AppMenu {
key: string,
path: string,
router: RouteItem,
text?: string,
icon?: JSX.Element,
children: AppMenu[],
parent?: AppMenu,
}
export interface AppCrumb {
name?: string,
path: string,
}
// 路径头加反斜杠
function slashPath(source: string){
return source[0] === '/' ? source: `/${source}`
}
// 将路由转换为菜单(路径从相对路径转换为绝对路径)
function generateMenu(router:RouteItem, parent: AppMenu|undefined=undefined): AppMenu{
const parentPath = parent ? slashPath(parent.path) : ''
const path = slashPath(router.path)
const menu: AppMenu = {
key: router.name,
path: `${parentPath}${path}`,
router: router,
text: router.meta?.title,
icon: router.meta?.icon,
parent: parent,
children: []
}
if(router.children){
router.children.forEach(child=>{
!child.hidden && menu.children.push(generateMenu(child, menu))
})
}
return menu;
}
// 将路由转换为菜单
export function generateMenus(source: RouteItem[]): AppMenu[]{
return source.filter(item=>!item.hidden).map(item=>generateMenu(item))
}
// 根据路径找到菜单项
export function getMenuByPath(source: AppMenu[], path: string): AppMenu|undefined {
let result = source.find(item=>item.path === path)
if(result){return result}
for(const item of source){
result = getMenuByPath(item.children, path)
if(result){return result}
}
}
// 根据标识找到菜单项
export function getMenuByKey(source: AppMenu[], key: string): AppMenu|undefined {
let result = source.find(item=>item.key === key)
if(result){return result}
for(const item of source){
result = getMenuByKey(item.children, key)
if(result){return result}
}
}
// 根据菜单项形成面包屑
export function generateCrumbs(source: AppMenu|undefined): AppCrumb[]{
const result: AppCrumb[] = []
while(source){
result.push({
name: source.text,
path: source.path
})
source = source.parent
}
return result.reverse()
}
export function renderRouter(item: RouteItem){
return (
<Route key={item.name} path={item.path} element={
<React.Suspense fallback={<></>}>
{
item.children ? <Outlet/> : (item.component && <item.component/>)
}
</React.Suspense>
}>
{
item.children && item.children.map(child => renderRouter(child))
}
{
item.redirect && <Route path="" element={<Navigate to={item.redirect}/>}/>
}
</Route>
)
}
function hasPermission(route: RouteItem, role:string): boolean{
const permission = route.meta && route.meta.permission
if(!permission){
// 如果没有配置则开放访问
return true
}
// 简单的判断:服务端role == 客户端的permission
return permission.find(item=>item===role)!=undefined
}
/*
判断权限
逻辑:按层级从上往下判断
*/
function checkPermission(source: RouteItem[], path: string, role:string){
path = path.replace(/^\/+/,"").replace(/\/+$/, ''); // 去除路径的前后斜杠,例如( /abc/def/ => abc/def )
if(path===''){
//首页不判断权限
return true
}
const ar = path.split('/')
let leafRouters = source
for(let i=0;i<ar.length;i++){
const leaf = leafRouters.find(item=>item.path===ar[i])
if(!leaf || !hasPermission(leaf, role)){
return false
}
if(i===ar.length-1){
// 终节点找到,且已经通过权限验证(优先上层的,然后自己的)
return true
} else {
if(!leaf.children){
break
}
leafRouters = leaf.children
}
}
return false
}
// 根据用户角色筛选路由
function filterRouters(source: RouteItem[], role: string): RouteItem[] {
const result: RouteItem[] = []
source.forEach(item => {
if(hasPermission(item, role)){
const cloneItem = Object.assign({}, item)
item.children && (cloneItem.children = filterRouters(item.children, role))
result.push(cloneItem)
}
})
return result
}
export function RequireAuth(props: {children: JSX.Element}){
const [user, setUser] = useAtom(userStore)
const location = useLocation()
const [loading, setLoading] = React.useState(true)
React.useEffect(()=>{
if(!user.info && window.localStorage.getItem("access-token")){
// 如果会话不存在 且 本地存储Token存在 (浏览器刷新页面触发)
(async () => {
// 接口读取用户信息
const {data} = await service.userinfo()
user.info = data
user.routers = filterRouters(routers, data.role)
// 用户信息写入会话
setUser(user)
setLoading(false)
})()
} else {
setLoading(false)
}
}, [user.info])
if(user.info && user.routers){
// 如果会话存在
// 如果有权限就跳转业务页面,否则跳转首页(也可以添加一个403页面跳转)
return checkPermission(user.routers, location.pathname, user.info.role) ? props.children : <Navigate to="/"/>
}
else if(loading){
// 显示一个loading页面
return <div></div>
}
else {
// token不存在,则跳转登录页面
return <Navigate to={LOGIN_PATH}/>
}
}
最重要的新组件RequireAuth
实现了上面流程图逻辑
3. App用RequireAuth
包裹
原来根路由是这样写的:<Route path="/" element={<AppLayout/>}>
,现在修改为<Route path="/" element={<RequireAuth><AppLayout/></RequireAuth>}>
// App.tsx
import {BrowserRouter, Route, Routes, Navigate, useLocation} from 'react-router-dom';
import LoginView from '@/views/login'
import NotFoundView from '@/views/404'
import AppLayout from '@/layouts/AppLayout';
import routers from '@/config/router.config';
import { AjaxEffectFragment } from '@/utils/request';
import { renderRouter, RequireAuth } from './utils/router';
function AppRouter(){
return (
<BrowserRouter>
<Routes>
<Route path="login" element={<LoginView/>}/>
<Route path="/" element={<RequireAuth><AppLayout/></RequireAuth>}>
{
routers.map(item=>renderRouter(item))
}
</Route>
<Route path="*" element={<NotFoundView/>}/>
</Routes>
</BrowserRouter>
)
}
function App() {
return (
<>
<AjaxEffectFragment/>
<AppRouter/>
</>
)
}
export default App
4. 加入登出代码
// src/layouts/AppLayout.tsx
import { Link, Outlet } from "react-router-dom";
import routers from '@/config/router.config';
import * as service from '@/api/auth'
import { useAtom } from "jotai";
import { userStore } from "@/store";
export default function (){
const [, setUser] = useAtom(userStore)
async function handleLogout(){
if(!confirm("确认退出?")){return}
await service.logout()
// 清除本地Token和当前会话
window.localStorage.removeItem('access-token')
setUser({})
}
return <>
{/* 头部 */}
<div>
{
routers.map((item, idx)=> <span key={idx}><Link to={item.path}>{item.meta?.title}</Link> </span>)
}
<a onClick={handleLogout}>logout</a>
</div>
{/* 内容体 */}
<Outlet/>
</>
}
接下来是界面美化工作,将分章节介绍如何套用现在国内几个前端界面库, 如下是笔者对这些库的个人评价:
库 | 出品 | 优点 | 缺点 |
---|---|---|---|
AntDesign | 蚂蚁 | 齐全;BUG少;生态好 | 框架重;带私货,强推蚂蚁自己的其他框架(eg. UMI);暗黑模式切换不方便 |
SemiDesign | 字节 | 框架很轻;更新异常活跃 | 不完善(缺Pro版本);生态差;技术很傲娇,github基本提BUG都会被拒 |
TDesign | 腾讯 | 官方支持Vue,React,ng三个版本 | 都是beta版,生产慎用 |
六、整合组件库 - AntDesign篇
1. 准备工作
vite3对全局样式做了美化,需手工清理一下,将main.tsx 行4 对index.css的引用删除掉
// main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
// import './index.css'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
2. 安装依赖包
tyarn add @ant-design/icons @ant-design/pro-components antd
tyarn add vite-plugin-imp less -D
antd是框架库;
ant-design/icons是antd的矢量图标库,v4版本以后官方从框架中剥离开,需要单独安装;
ant-design/pro-components是antdPro组件库,其中的ProLayout,ProTable都是很方便的组件
vite-plugin-imp 和 less 都是为了动态打包样式用的,仅需要安装在开发环境。
3. vite动态加载配置
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import {resolve} from 'path'
import {viteMockServe} from 'vite-plugin-mock'
import vitePluginImp from 'vite-plugin-imp'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
viteMockServe(),
vitePluginImp({
libList: [
{
libName: "antd",
style: (name) => `antd/lib/${name}/style/index.less`,
},
],
})
],
resolve: {
alias: [
{
find: /^~/,
replacement: ''
},
{
find: '@',
replacement: resolve(__dirname, "src")
}
]
},
css: {
preprocessorOptions: {
less: {
// 支持内联 JavaScript
javascriptEnabled: true,
}
}
},
})
4. 修改登录页面
// src/views/login.tsx
import * as service from '@/api/auth'
import { LockOutlined, UserOutlined } from '@ant-design/icons'
import { LoginForm, ProFormText } from '@ant-design/pro-components'
import { Alert } from 'antd'
import React from 'react'
import { useNavigate } from 'react-router-dom'
export default function(){
const navigate = useNavigate()
const [error, setError] = React.useState('')
async function handleLogin(params: any){
await service.login(params).then(data => {
// 将token写入本地存储
window.localStorage.setItem('access-token', data.data.token)
// 重新进入首页
navigate('/')
}).catch(err => {
setError(err.response.data)
})
}
return (
<div style={{paddingTop: 100}}>
<LoginForm
title="管理系统"
subTitle=" "
logo="https://gw.alipayobjects.com/zos/rmsportal/KDpgvguMpGfqaHPjicRK.svg"
onFinish={handleLogin}
message={
error && <Alert style={{marginBottom: 5}} message={error} type="error"/>
}
>
<ProFormText
name="username"
fieldProps={{
size: "large",
prefix: <UserOutlined/>
}}
placeholder="用户名"
rules={[
{required: true, message: "请输入用户名"}
]}
/>
<ProFormText.Password
name="password"
fieldProps={{
size: "large",
prefix: <LockOutlined/>
}}
placeholder="密码"
rules={[
{required: true, message: "请输入密码"}
]}
/>
</LoginForm>
</div>
)
}
5. 修改AppLayout
// src/layouts/AppLayout.tsx
import { Outlet, useNavigate } from "react-router-dom";
import * as service from '@/api/auth'
import { useAtom } from "jotai";
import { userStore } from "@/store";
import {Route} from '@ant-design/pro-layout/lib/typings';
import { DefaultFooter, PageContainer, ProLayout } from "@ant-design/pro-components";
import React from "react";
import { AppMenu, generateMenus } from "@/utils/router";
import { Button, Modal, Tooltip } from "antd";
import { PoweroffOutlined } from "@ant-design/icons";
export default function (){
const [user, setUser] = useAtom(userStore)
const navigate = useNavigate()
const menus: AppMenu[] = React.useMemo(()=>{
return user.routers ? generateMenus(user.routers): []
}, [user.routers])
// 将菜单集转换为AntD的菜单格式
const route: Route = React.useMemo(()=>{
return {
path: '/',
routes: convertRoutes(menus)
}
}, [menus])
function convertRoute(menu:AppMenu): Route{
return {
path: menu.path,
name: menu.text,
icon: menu.icon,
routes: convertRoutes(menu.children)
}
}
function convertRoutes(source: AppMenu[]): Route[]{
return source.map(item=>convertRoute(item))
}
async function handleLogout(){
Modal.confirm({title: '确认', content: '确认退出吗?', onOk: ()=>{
(async () => {
await service.logout()
// 清除本地Token和当前会话
window.localStorage.removeItem('access-token')
setUser({})
})()
}})
}
return <>
<div style={{height: '100vh'}}>
<ProLayout
title="管理系统"
route={route}
location={{pathname: window.location.pathname}}
menuItemRender={(item:any,dom)=>(
<a onClick={()=>{navigate(item.path)}}>{dom}</a>
)}
breadcrumbProps={{
itemRender: (currentRoute, _params, routes, _paths) => {
const last = routes.indexOf(currentRoute) == routes.length -1
return last ? <span>{route.breadcrumbName}</span> : <a onClick={()=>{navigate(currentRoute.path)}}>{route.breadcrumbName}</a>
}
}}
rightContentRender={()=>(
<div>
{user.info?.username}
<Tooltip title="安全退出"><Button icon={<PoweroffOutlined/>} type="text" onClick={handleLogout}/></Tooltip>
</div>
)}
footerRender={()=>(
<DefaultFooter copyright="2022"/>
)}
>
<PageContainer>
<Outlet/>
</PageContainer>
</ProLayout>
</div>
</>
}
七、整合组件库 - SemiDesign篇
1. 准备工作
将main.tsx 行4 对index.css的引用删除掉
2. 安装依赖包
tyarn add @douyinfe/semi-ui
3. 增加一个空白页模版
// src/layouts/BlankLayout.tsx
import { Row } from "@douyinfe/semi-ui";
import React from "react";
export default function(props:{children: React.ReactNode}){
return (
<div
style={{
height: '100vh',
backgroundColor: 'var(--semi-color-bg-1)',
}}
>
<Row type="flex" justify="center" style={{padding: "10% 0"}}>
{props.children}
</Row>
</div>
)
}
该模版实现全局居中
4. 修改登录页面
// src/views/login.tsx
import * as service from '@/api/auth'
import BlankLayout from '@/layouts/BlankLayout'
import { Button, Card, Form, Toast } from '@douyinfe/semi-ui'
import { FormApi } from '@douyinfe/semi-ui/lib/es/form'
import React from 'react'
import { useNavigate } from 'react-router-dom'
export default function(){
const [submiting, setSubmiting] = React.useState(false)
const [formApi, setFormApi] = React.useState<FormApi>()
const navigate = useNavigate()
function handleLogin(values: any){
setSubmiting(true)
service.login(values).then(data => {
// 将token写入本地存储
window.localStorage.setItem('access-token', data.data.token)
// 重新进入首页
navigate('/')
}).catch(err => {
formApi?.reset()
Toast.error(err.response.data)
}).finally(() =>{
setSubmiting(false)
})
}
return (
<BlankLayout>
<Card
shadows="always"
style={{width: 360, cursor:'auto'}}
footerLine
header={
<h2 style={{width: '100%', textAlign: 'center', color: 'var(--semi-color-text-2'}}>管理系统</h2>
}
footer={
<Button theme="solid" style={{width: '100%'}} loading={submiting} onClick={()=>{formApi?.submitForm()}}>登录</Button>
}
>
<Form getFormApi={api=>{setFormApi(api)}} labelAlign='right' labelPosition='inset' onSubmit={handleLogin}>
<Form.Input field="username" label="用户名" rules={[{required: true, message: '请输入用户名'}]}/>
<Form.Input field="password" label="密 码" type="password" rules={[{required: true, message: '请输入密码'}]}/>
</Form>
</Card>
</BlankLayout>
)
}
5. 修改AppLayout
// src/layouts/AppLayout.tsx
import { Outlet, useNavigate } from "react-router-dom";
import * as service from '@/api/auth'
import { useAtom } from "jotai";
import { userStore } from "@/store";
import { Avatar, Breadcrumb, Dropdown, Layout, Modal, Nav } from "@douyinfe/semi-ui";
import { IconBytedanceLogo, IconQuit } from '@douyinfe/semi-icons';
import React from "react";
import { AppMenu, generateCrumbs, generateMenus, getMenuByKey, getMenuByPath } from "@/utils/router";
export default function (){
const { Header, Footer, Sider, Content } = Layout;
const navigate = useNavigate();
const [user, setUser] = useAtom(userStore)
const [currentMenu, setCurrentMenu] = React.useState<AppMenu>()
const menus: AppMenu[] = React.useMemo(() =>{
return user.routers? generateMenus(user.routers): []
},[user.routers])
React.useEffect(()=>{
const menu = getMenuByPath(menus, window.location.pathname)
setCurrentMenu(menu)
},[menus, window.location.pathname])
// 转换为semi的格式(TS定义不详,使用any)
function convertMenus(source: AppMenu[]): any[]{
return source.map(item => {
return {
itemKey: item.key,
text: item.text,
icon: item.icon,
items: convertMenus(item.children)
}
})
}
function handleNavigate(data: any){
const menu = getMenuByKey(menus,data.itemKey)
setCurrentMenu(menu)
menu && navigate(menu.path)
}
function handleLogout(){
Modal.confirm({title: '确认', content: '确认退出?', onOk: ()=>{
(async ()=>{
await service.logout()
// 清除本地Token和当前会话
window.localStorage.removeItem('access-token')
setUser({})
})()
}})
}
return <>
<Layout style={{ border: '1px solid var(--semi-color-border)'}}>
<Sider style={{ backgroundColor: 'var(--semi-color-bg-1)' }}>
<Nav
selectedKeys={currentMenu && [currentMenu.key]}
style={{ maxWidth: 220, height: '100%' }}
header={{
logo: <img src="//lf1-cdn-tos.bytescm.com/obj/ttfe/ies/semi/webcast_logo.svg" />,
text: '管理系统',
}}
footer={{
collapseButton: true,
}}
items={convertMenus(menus)}
onSelect={handleNavigate}
>
</Nav>
</Sider>
<Layout>
<Header style={{ backgroundColor: 'var(--semi-color-bg-1)' }}>
<Nav
mode="horizontal"
header={
<>
<Breadcrumb
routes={generateCrumbs(currentMenu)}
onClick={(item:any) => {navigate(item.path)}}
/>
</>
}
footer={
<Dropdown
position="bottomLeft"
render={
<Dropdown.Menu>
<Dropdown.Item icon={<IconQuit/>} onClick={handleLogout}>退出登录</Dropdown.Item>
</Dropdown.Menu>
}
>
<Avatar color="orange" size="small">
{user.info?.username[0]}
</Avatar>
</Dropdown>
}
></Nav>
</Header>
<Content
style={{
padding: '24px',
minHeight: '545px',
backgroundColor: 'var(--semi-color-bg-0)',
}}
>
<Outlet/>
</Content>
<Footer
style={{
display: 'flex',
justifyContent: 'space-between',
padding: '20px',
color: 'var(--semi-color-text-2)',
backgroundColor: 'rgba(var(--semi-grey-0), 1)',
}}
>
<span
style={{
display: 'flex',
alignItems: 'center',
}}
>
<IconBytedanceLogo size="large" style={{ marginRight: '8px' }} />
<span>Copyright © 2022</span>
</span>
<span>
<span style={{ marginRight: '24px' }}>平台客服</span>
<span>反馈建议</span>
</span>
</Footer>
</Layout>
</Layout>
</>
}
八、整合组件库 - TDesign篇
1. 准备工作
老规矩,main.tsx
删除对index.css的引用
但是还需要引入tdesign的公共样式包
// main.tsx
...
import 'tdesign-react/es/style/index.css';
2. 安装依赖包
tyarn add tdesign-react tdesign-icons-react
tyarn add less -D
和antd类似,tdesign的图标库tdesign-icons-react
也是单独安装的
tdisign官方范例对于less
的使用是重度依赖
下文的模版修改均参考官方项目TDesign Starter
3. 修改登录界面
单独写页面样式
// src/views/login.module.less
.loginWrapper {
height: 100vh;
display: flex;
flex-direction: column;
background-size: cover;
background-position: 100%;
position: relative;
&.dark {
background-color: var(--td-bg-color-page);
background-image: url('https://tdesign.tencent.com/starter/react/assets/assets-login-bg-black.ff89ae69.png');
}
&.light {
background-color: white;
background-image: url('https://tdesign.tencent.com/starter/react/assets/assets-login-bg-white.439b0654.png');
}
}
.loginContainer {
position: absolute;
top: 22%;
left: 5%;
min-height: 500px;
line-height: 22px;
}
.loginHeader {
height: 64px;
padding: 0 24px;
display: flex;
justify-content: space-between;
align-items: center;
backdrop-filter: blur(5px);
color: var(--td-text-color-primary);
}
.title {
font-size: 36px;
line-height: 44px;
color: var(--td-text-color-primary);
margin-top: 4px;
margin-bottom: 0;
}
.itemContainer {
width: 400px;
margin-top: 48px;
}
登录界面代码
// src/views/login.tsx
import { Outlet, useNavigate } from "react-router-dom";
import * as service from '@/api/auth'
import { useAtom } from "jotai";
import { userStore } from "@/store";
import { Breadcrumb, Button, Col, dialog, Dropdown, Layout, Menu, Row } from "tdesign-react";
import Style from './AppLayout.module.less'
import React from "react";
import { AppMenu, generateCrumbs, generateMenus, getMenuByPath } from "@/utils/router";
import { ChevronDownIcon, PoweroffIcon, UserCircleIcon, ViewListIcon } from "tdesign-icons-react";
function confirm(props: {message: React.ReactNode, onOk: Function}){
let ok = false
const dlg = dialog.confirm?.({
header: true,
theme: "info",
body: props.message,
onClose: () =>{
dlg?.hide?.()
},
onConfirm: () =>{
dlg?.hide?.()
ok = true
},
onClosed: () =>{
ok && props.onOk()
}
})
}
export default function (){
const {Header, Content, Footer} = Layout;
const {MenuItem, SubMenu} = Menu;
const {DropdownMenu, DropdownItem} = Dropdown;
const {BreadcrumbItem} = Breadcrumb;
const navigate = useNavigate()
const [user, setUser] = useAtom(userStore)
const [collapsed, setCollapsed] = React.useState(false)
const [currentMenu, setCurrentMenu] = React.useState<AppMenu>()
const menus: AppMenu[] = React.useMemo(()=>{
return user.routers ? generateMenus(user.routers) : []
}, [user.routers])
React.useEffect(() =>{
setCurrentMenu(getMenuByPath(menus, window.location.pathname))
}, [menus, window.location.pathname])
function RenderMenu(props: {source: AppMenu}){
const source: AppMenu = props.source;
return source.children.length == 0 ?
(
<MenuItem value={source.key} icon={source.icon} onClick={()=>{navigate(source.path)}}>{source.text}</MenuItem>
) :
(
<SubMenu value={source.key} icon={source.icon} title={source.text}>
<>
{
source.children.map((item, idx) => <RenderMenu key={idx} source={item}/>)
}
</>
</SubMenu>
)
}
async function handleLogout(){
confirm({
message: "确认退出?",
onOk: () => {
(async () =>{
await service.logout()
// 清除本地Token和当前会话
window.localStorage.removeItem('access-token')
setUser({})
})()
}
})
}
return <>
<Layout className={Style.sidePanel}>
<Menu
logo={
<div className={Style.menuLogo}>
<img src="https://cdc.cdn-go.cn/tdc/latest/images/tdesign.svg"/>
{!collapsed && "管理系统"}
</div>
}
collapsed={collapsed}
value={currentMenu?.key}
>
{
menus.map((item, idx) => <RenderMenu key={idx} source={item}/>)
}
</Menu>
<Layout className={Style.sideContainer}>
<Header className={Style.headerPanel}>
<Row align="middle">
<Col>
<Button shape="square" size="large" variant="text" icon={<ViewListIcon/>} onClick={()=>{setCollapsed(!collapsed)}}/>
</Col>
</Row>
<Row align="middle" style={{display:'flex', alignItems: 'center', justifyContent: 'center'}}>
<Col>
<Dropdown className={Style.dropdown}>
<Button variant="text">
<span style={{display:'inline-flex', alignItems: 'center', justifyContent: 'center'}}>
<UserCircleIcon/>
<span style={{display: 'inline-block', margin: '0 5px'}}>{user.info?.username}</span>
<ChevronDownIcon/>
</span>
</Button>
<DropdownMenu>
<DropdownItem onClick={handleLogout}>
<>
<PoweroffIcon/>
退出登录
</>
</DropdownItem>
</DropdownMenu>
</Dropdown>
</Col>
</Row>
</Header>
<Content className={Style.contentPanel}>
<Breadcrumb className={Style.breadcrumbs} style={{marginBottom: 8}}>
{
generateCrumbs(currentMenu).map((item, idx) => <BreadcrumbItem key={idx} onClick={()=>navigate(item.path)}>{item.name}</BreadcrumbItem>)
}
</Breadcrumb>
<Outlet/>
</Content>
<Footer>
<Row justify="center">Copyright @ 2022</Row>
</Footer>
</Layout>
</Layout>
</>
}
4. 修改AppLayout
界面样式
// src/layouts/AppLayout.module.less
.sidePanel {
height: 100vh;
display: flex;
flex-direction: row!important;
}
.sideContainer {
flex: 1;
min-width: 760px;
overflow: auto;
}
.menuLogo {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
color: var(--td-text-color-primary);
}
.headerPanel {
flex-shrink: 0;
padding-left: 20px;
padding-right: 20px;
position: sticky;
top: 0;
z-index: 101;
display: flex;
justify-content: space-between;
border-bottom: 1px solid var(--td-component-stroke);
}
.contentPanel {
margin: 24px;
padding: 0;
overflow: auto;
}
.dropdown {
:global {
.t-dropdown__item {
max-width: none !important;
width: 117px;
&-text {
display: flex;
align-items: center;
}
.t-icon {
margin-right: 8px;
}
}
}
}
界面代码
// src/layouts/AppLayout.tsx
import { Outlet, useNavigate } from "react-router-dom";
import * as service from '@/api/auth'
import { useAtom } from "jotai";
import { userStore } from "@/store";
import { Breadcrumb, Button, Col, dialog, Dropdown, Layout, Menu, Row } from "tdesign-react";
import Style from './AppLayout.module.less'
import React from "react";
import { AppMenu, generateCrumbs, generateMenus, getMenuByPath } from "@/utils/router";
import { ChevronDownIcon, PoweroffIcon, UserCircleIcon, ViewListIcon } from "tdesign-icons-react";
function confirm(props: {message: React.ReactNode, onOk: Function}){
let ok = false
const dlg = dialog.confirm?.({
header: true,
theme: "info",
body: props.message,
onClose: () =>{
dlg?.hide?.()
},
onConfirm: () =>{
dlg?.hide?.()
ok = true
},
onClosed: () =>{
ok && props.onOk()
}
})
}
export default function (){
const {Header, Content, Footer} = Layout;
const {MenuItem, SubMenu} = Menu;
const {DropdownMenu, DropdownItem} = Dropdown;
const {BreadcrumbItem} = Breadcrumb;
const navigate = useNavigate()
const [user, setUser] = useAtom(userStore)
const [collapsed, setCollapsed] = React.useState(false)
const [currentMenu, setCurrentMenu] = React.useState<AppMenu>()
const menus: AppMenu[] = React.useMemo(()=>{
return user.routers ? generateMenus(user.routers) : []
}, [user.routers])
React.useEffect(() =>{
setCurrentMenu(getMenuByPath(menus, window.location.pathname))
}, [menus, window.location.pathname])
function handleNavigate(source: AppMenu){
setCurrentMenu(source)
navigate(source.path)
}
function RenderMenu(props: {source: AppMenu}){
const source: AppMenu = props.source;
return source.children.length == 0 ?
(
<MenuItem value={source.key} icon={source.icon} onClick={()=>{navigate(source.path)}}>{source.text}</MenuItem>
) :
(
<SubMenu value={source.key} icon={source.icon} title={source.text}>
<>
{
source.children.map((item, idx) => <RenderMenu key={idx} source={item}/>)
}
</>
</SubMenu>
)
}
async function handleLogout(){
confirm({
message: "确认退出?",
onOk: () => {
(async () =>{
await service.logout()
// 清除本地Token和当前会话
window.localStorage.removeItem('access-token')
setUser({})
})()
}
})
}
return <>
<Layout className={Style.sidePanel}>
<Menu
logo={
<div className={Style.menuLogo}>
<img src="https://cdc.cdn-go.cn/tdc/latest/images/tdesign.svg"/>
{!collapsed && "管理系统"}
</div>
}
collapsed={collapsed}
value={currentMenu?.key}
>
{
menus.map((item, idx) => <RenderMenu key={idx} source={item}/>)
}
</Menu>
<Layout className={Style.sideContainer}>
<Header className={Style.headerPanel}>
<Row align="middle">
<Col>
<Button shape="square" size="large" variant="text" icon={<ViewListIcon/>} onClick={()=>{setCollapsed(!collapsed)}}/>
</Col>
</Row>
<Row align="middle" style={{display:'flex', alignItems: 'center', justifyContent: 'center'}}>
<Col>
<Dropdown className={Style.dropdown}>
<Button variant="text">
<span style={{display:'inline-flex', alignItems: 'center', justifyContent: 'center'}}>
<UserCircleIcon/>
<span style={{display: 'inline-block', margin: '0 5px'}}>{user.info?.username}</span>
<ChevronDownIcon/>
</span>
</Button>
<DropdownMenu>
<DropdownItem onClick={handleLogout}>
<>
<PoweroffIcon/>
退出登录
</>
</DropdownItem>
</DropdownMenu>
</Dropdown>
</Col>
</Row>
</Header>
<Content className={Style.contentPanel}>
<Breadcrumb className={Style.breadcrumbs} style={{marginBottom: 8}}>
{
generateCrumbs(currentMenu).map((item, idx) => <BreadcrumbItem key={idx} onClick={()=>navigate(item.path)}>{item.name}</BreadcrumbItem>)
}
</Breadcrumb>
<Outlet/>
</Content>
<Footer>
<Row justify="center">Copyright @ 2022</Row>
</Footer>
</Layout>
</Layout>
</>
}
更多推荐
所有评论(0)