一、配置vite.config.ts和tsconfig.json准备工作
1、确保你已经安装了Node.js(建议使用最新的稳定版本)。
2、安装Vite CLI工具,并创建项目:npm create vite@latest ZY-web
3、执行命令npm i --save-dev @types/node,@types/node它是TypeScript中用于提供Node.js核心模块类型定义的一个类型库。
javascript
//vite.config.ts
import { resolve } from "path";
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:3120/',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
plugins: [react()],
resolve: {
alias: {
"@": resolve(__dirname, '/src'),
},
},
})
二、配置多环境开发
1、根目录创建.env、.env.development、.env.test、.env.production文件
ini
//.env
#port
VITE_PORT=3001
# open 自动打开浏览器
VITE_OPEN=true
# gzip 开启压缩
VITE_USE_COMPRESS=true
# console 生产环境去掉console
VITE_USE_CONSOLE=true
//.env.development
# 本地环境
NODE_ENV=development
# 接口地址
VITE_API_URL=""
# 登录页
VITE_LOGIN_URL=""
//.env.production
# 本地环境
NODE_ENV=production
# 接口地址
VITE_API_URL=""
# 登录页
VITE_LOGIN_URL=""
//.env.test
# 本地环境
NODE_ENV=test
# 接口地址
VITE_API_URL=""
# 登录页
VITE_LOGIN_URL=""
2、写一个加载env的方法,安装压缩插件,执行npm i vite-plugin-compression --save-dev
typescript
import { fileURLToPath, URL } from "node:url";
import { ConfigEnv, defineConfig, loadEnv, UserConfig } from 'vite'
import react from '@vitejs/plugin-react'
import viteCompression from "vite-plugin-compression"
// https://vitejs.dev/config/
export default defineConfig((mdoe: ConfigEnv): UserConfig => {
const env = loadEnv(mdoe.mode, process.cwd())
const viteEnv = warppEnv(env)
console.log(viteEnv)
return {
plugins: [
react(),
// gzip
viteEnv.VITE_USE_COMPRESS && viteCompression({
gzipExtensions: ['js', 'css', 'html', 'svg'],
threshold: 10240,
minRatio: 0.8,
brotliOptions: {
mode: 'text',
quality: 11,
},
})
],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
}
},
server: {
host: '0.0.0.0',
port: viteEnv.VITE_PORT,
open: viteEnv.VITE_OPEN,
proxy: {
'/api': {
target: viteEnv.VITE_API_URL,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
build: {
// esbuild打包更快,但是不能去除console日志,使用tarser可以去除
outDir: 'dist',
minify: viteEnv.VITE_USE_CONSOLE ? 'esbuild' : 'terser',
target: 'es2015',
rollupOptions: {
output: {
// 打包后输出的文件名
entryFileNames: 'static/js/[name].[hash].js',
chunkFileNames: 'assets/js/[name].[hash].js',
assetFileNames: 'static/[extname]/[name].[hash][extname]',
},
}
}
}
})
// 加载env的方法
function warppEnv(envConf: { [x: string]: string; }) {
const ret: any = {};
for (const envName of Object.keys(envConf)) {
let realName: any = envConf[envName].replace(/\\n/g, '\n');
realName = realName === 'true' ? true : realName === 'false' ? false : realName
if (envName === 'VITE_PORT') {
realName = Number(realName)
}
if (envName === 'VITE_PROXY') {
try {
realName = JSON.parse(realName)
} catch (err) {
console.error(`Parse VITE_PROXY error: ${err.message}`);
};
}
ret[envName] = realName;
process.env[envName] = realName;
}
return ret;
}
3、新建路由文件route/index.tsx,执行npm i react-router-dom
javascript
import { Navigate, useRoutes } from "react-router-dom";
import Login from "@/views/login";
const rootRouter = [
{
path: '/',
element: <Navigate to="/login" />
},
{
path: '/login',
element: <Login />,
meta: {
title: '登录',
key: 'login',
requireAuth: false
}
}
]
const Router = () => {
const routes = useRoutes(rootRouter)
return routes
}
export default Router
4、新建view/login/index.tsx
javascript
const Login = () => {
return (
<>login</>
)
}
export default Login;
5、修改App.tsx文件,此时已经可以重定向到登录页
javascript
//.env
import { BrowserRouter, useRoutes } from 'react-router-dom';
import Router from './router/index';
function App() {
return (
<BrowserRouter>
<Router />
</BrowserRouter>
)
}
export default App
三、封装路由懒加载高阶组件lazy+suspense
1.安装antd ui,执行命令npm install antd --save,新建文件src\layout\utils\lazyLoad.tsx
arduino
import { Spin } from 'antd';
import React, { Suspense } from 'react';
const lazyLoad = (Comp: React.LazyExoticComponent<any>): React.ReactNode => {
return (
<Suspense fallback={
<Spin
size="large"
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
width: '100%'
}}
/>
}>
<Comp />
</Suspense>
)
}
export default lazyLoad;
2.新建src\router\staticRouter.tsx;新建/views/errorPage/403
javascript
import lazyLoad from '@/layout/utils/lazyLoad';
import React from 'react';
export const staticRouter = [
{
path: "/home",
key: '/home',
label: '首页',
icon: 'HomeOutlined',
element: lazyLoad(React.lazy(() => import("@/views/home/index")))
},
{
path: "/about",
key: '/about',
label: 'about',
icon: 'AndroidOutlined',
element: lazyLoad(React.lazy(() => import("@/views/about")))
}
]
3.新建src\router\index.tsx导入路由文件处理路由文件
javascript
//.env
import { BrowserRouter, useRoutes } from 'react-router-dom';
import Router from './router/index';
function App() {
return (
<BrowserRouter>
<Router />
</BrowserRouter>
)
}
export default App
四、封装lazyoutIndex组件布局
1、新建src\layout\index.tsx文件,新建src\layout\index.less
css
//index.tsx
import { Layout } from "antd"
import { Outlet } from "react-router-dom";
import './index.less'
import LayoutMenu from "./menu";
const lazyoutIndex = () => {
return (
<div className="container">
<Layout.Sider collapsible width={220} theme="dark">
<LayoutMenu />
</Layout.Sider>
<Layout>
<Layout.Header>
Header
</Layout.Header>
<Layout.Content>
<Outlet></Outlet>
</Layout.Content>
<Layout.Footer>
Footer
</Layout.Footer>
</Layout>
</div>
)
}
export default lazyoutIndex;
//新建src\layout\index.less
.container {
display: flex;
min-width: 950;
height: 100%;
.ant-layout-sider {
background-color: #001529;
}
.ant-layout-sider-trigger {
position: fixed;
bottom: 0;
z-index: 1;
height: 48px;
color: #fff;
text-align: center;
background-color: #002140;
cursor: pointer;
transition: all 0.3s;
}
.ant-layout {
overflow: hidden;
.ant-layout-content {
box-sizing: border-box;
flex: 1;
padding: 10px 12px;
overflow: scroll;
}
}
}
css
//新建src\layout\index.less
.container {
display: flex;
min-width: 950;
height: 100%;
.ant-layout-sider {
background-color: #001529;
}
.ant-layout-sider-trigger {
position: fixed;
bottom: 0;
z-index: 1;
height: 48px;
color: #fff;
text-align: center;
background-color: #002140;
cursor: pointer;
transition: all 0.3s;
}
.ant-layout {
overflow: hidden;
.ant-layout-content {
box-sizing: border-box;
flex: 1;
padding: 10px 12px;
overflow: scroll;
}
}
}
2、修改src\index.css
css
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
margin: 0;
padding: 0;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
height: 100%;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
五、左侧菜单栏布局
1、新建src\layout\components\Logo\index.tsx
javascript
import logo from "@/assets/react.svg"
import style from "./index.module.less"
const Logo = () => {
return (
<div className={style.logo}>
<img src={logo} alt="" className={style.logo_img} />
</div>
)
}
export default Logo;
2、新建src\layout\components\Logo\index.module.less文件
css
.logo {
display: flex;
align-items: center;
justify-content: center;
height: 55px;
.logo_img {
width: 30px;
margin: 0;
}
}
3、新建menu组件src\layout\menu\index.tsx
javascript
import { useEffect, useState } from "react";
import Logo from "../components/Logo";
import { Menu } from "antd";
import {
ContainerOutlined,
DesktopOutlined,
PieChartOutlined,
} from '@ant-design/icons';
const LayoutMenu = () => {
const [menuList, setMenuList] = useState([])
const getMenuData = () => {
try {
let data = [
{
key: 'home',
label: '首页',
icon: <PieChartOutlined />,
path: '/home',
children: [
{
key: 'dashboard',
label: 'Dashboard',
path: '/home/dashboard',
}
]
},
{
key: 'product',
label: '产品',
icon: <DesktopOutlined />,
path: '/product',
},
{
key: 'about',
label: '关于',
icon: <ContainerOutlined />,
path: '/about',
},
]
setMenuList(data)
} catch (error) {
console.error(error)
}
}
useEffect(() => {
getMenuData()
}, [])
return (
<div className="layout-menu">
<Logo />
<Menu
theme="dark"
mode="inline"
items={menuList}
/>
</div>
)
}
export default LayoutMenu;
4、layout组件导入Menu组件
javascript
import { Layout } from "antd"
import { Outlet } from "react-router-dom";
import './index.less'
import LayoutMenu from "./menu";
// const { Header, Footer, Sider, Content } = Layout;
const lazyoutIndex = () => {
return (
<div className="container">
<Layout.Sider collapsible width={220} theme="dark">
<LayoutMenu />
</Layout.Sider>
<Layout>
<Layout.Header>
Header
</Layout.Header>
<Layout.Content>
<Outlet></Outlet>
</Layout.Content>
<Layout.Footer>
Footer
</Layout.Footer>
</Layout>
</div>
)
}
export default lazyoutIndex;
六、处理自动捕捉菜单的展开逻辑,点击菜单跳转的逻辑
ini
import { useEffect, useState } from "react";
import Logo from "../components/Logo";
import { Menu } from "antd";
import {
ContainerOutlined,
PieChartOutlined,
} from '@ant-design/icons';
import { useLocation, useNavigate } from "react-router-dom"; // 引入 useNavigate
const LayoutMenu = () => {
const [menuList, setMenuList] = useState([]);
// 获取菜单数据
const getMenuData = () => {
try {
let data = [
{
key: '/home',
label: '首页',
icon: <PieChartOutlined />,
path: '/home',
children: [
{
key: '/home/dashboard',
label: 'Dashboard',
path: '/home/dashboard',
}
]
},
{
key: '/about',
label: '关于',
icon: <ContainerOutlined />,
path: '/about',
},
];
setMenuList(data);
} catch (error) {
console.error(error);
}
};
useEffect(() => {
getMenuData();
}, []);
const [openKeys, setOpenKeys] = useState([]); // 当前展开的菜单项
const [selectedKeys, setSelectedKeys] = useState([]); // 当前选中的菜单项
const { pathname } = useLocation(); // 获取当前路由路径
const navigate = useNavigate(); // 获取 navigate 函数
// 根据路由路径设置选中的菜单项和展开的菜单项
useEffect(() => {
// 设置选中的菜单项
setSelectedKeys([pathname]);
// 设置展开的菜单项
const parentKey = findParentKey(pathname, menuList);
if (parentKey) {
setOpenKeys([parentKey]);
}
}, [pathname, menuList]);
// 查找当前路径的父级菜单项
const findParentKey = (path, menuList) => {
for (const menu of menuList) {
if (menu.children) {
const child = menu.children.find(child => child.key === path);
if (child) {
return menu.key; // 返回父级菜单项的 key
}
const parentKey = findParentKey(path, menu.children); // 递归查找
if (parentKey) {
return menu.key; // 返回父级菜单项的 key
}
}
}
return null;
};
// 处理菜单展开事件
const onOpenChange = (keys) => {
setOpenKeys(keys);
};
// 处理菜单项点击事件
const onMenuClick = ({ key }) => {
navigate(key); // 跳转到对应的路由路径
};
return (
<div className="layout-menu">
<Logo />
<Menu
theme="dark"
mode="inline"
items={menuList}
openKeys={openKeys}
selectedKeys={selectedKeys}
onOpenChange={onOpenChange}
onClick={onMenuClick} // 绑定点击事件
/>
</div>
);
};
export default LayoutMenu;
七、配置redux存储器
1、执行命令npm install redux react-redux @reduxjs/toolkit redux-persist
redux-persist:持久化存储
@reduxjs/toolkit:异步处理、函数简化了使用预构建配置和中间件创建 Redux 存储的过程
2、新建src\redux\reducers\authSlice.ts及src\redux\reducers\index.ts以及src\redux\store.tsx
javascript
//.env
// reducers/authSlice.js
// 使用 @reduxjs/toolkit 可以轻松处理异步事件,而无需额外安装 redux-thunk。
// @reduxjs/toolkit 已经内置了 redux-thunk,并且提供了更简洁的方式来定义异步逻辑,即通过 createAsyncThunk 创建异步 Action
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios';
import { message } from 'antd';
const authSlice = createSlice({
name: 'auth',
initialState: {
token: sessionStorage.getItem('authToken') || null,
userInfo: null,
authRouter: [],
authMenu: []
},
reducers: {
setToken: (state, action) => {
state.token = action.payload;
sessionStorage.setItem('authToken', action.payload); // 将 token 存储到 sessionStorage
},
clearToken: (state) => {
state.token = null;
sessionStorage.removeItem('authToken'); // 清除 sessionStorage 中的 token
},
setAuthRouter: (state, action) => {
state.authRouter = action.payload;
sessionStorage.setItem('authRouter', action.payload); // 将 全部路由 存储到 sessionStorage
},
setMenu: (state, action) => {
state.authMenu = action.payload;
sessionStorage.setItem('authMenu', action.payload); // 将 menulist 存储到 sessionStorage
}
},
extraReducers: (builder) => {
builder
.addCase(loginAction.fulfilled, (state, action) => {
state.token = action.payload; // 登录成功,保存 token
})
.addCase(fetchUserInfo.fulfilled, (state, action) => {
state.userInfo = action.payload; // 保存用户信息
})
},
});
export const { setToken, clearToken, setAuthRouter, setMenu } = authSlice.actions;
export default authSlice.reducer;
javascript
// reducers/index.js
//将 authSlice 的 Reducer 组合到根 Reducer 中
import { combineReducers } from '@reduxjs/toolkit';
import authReducer from './authSlice';
const rootReducer = combineReducers({
auth: authReducer,
// 其他 reducer
});
export default rootReducer;
javascript
import { configureStore } from '@reduxjs/toolkit';//在 Store 中集成 redux-persist,确保状态可以持久化存储。
import { persistStore, persistReducer } from 'redux-persist';
import storage from 'redux-persist/lib/storage/session'; // 使用 sessionStorage
import rootReducer from './reducers';
const persistConfig = {
key: 'root',
storage, // 使用 sessionStorage
};
const persistedReducer = persistReducer(persistConfig, rootReducer);
const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['persist/PERSIST'],
},
}),
});
const persistor = persistStore(store);
export { store, persistor };
3、在应用入口集成 Redux
javascript
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
import { Provider } from 'react-redux'
import { PersistGate } from 'redux-persist/integration/react'
import { store, persistor } from './redux/store'
import '@ant-design/v5-patch-for-react-19';//解决antd v5 默认兼容 React 16 ~ 18:https://ant.design/docs/react/v5-for-19
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Provider store={store}>
<PersistGate persistor={persistor}>
<App />
</PersistGate>
</Provider>
</StrictMode>
)
4、在Login组件中使用 useSelector 和 useDispatch 访问和更新 Redux 状态。
typescript
//src\views\login\index.tsx
import styles from './login.module.less'
import initLoginBg from './init'
import type { Dispatch } from 'redux'
import React, { ChangeEvent, useEffect, useState } from "react";
import { Button, message, Space, Input } from "antd";
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import { loginAction, setToken, fetchUserInfo, setAuthRouter, setMenu } from "@/redux/reducers/authSlice";
import { rootRouter } from '@/router';
const Login = () => {
// 加载完组件后,加载背景
useEffect(() => {
initLoginBg()
window.onresize = function () {
initLoginBg()
}
}, [])
// 定义dispatch工具, 发送action动作执行reducer
const dispatch: Dispatch<any> = useDispatch()
// 获取用户输入信息
const [usernameVal, setUsernameVal] = useState('')
const [passwordVal, setPasswordVal] = useState('')
const [captchaImg, setCaptchaImg] = useState('')
const usernameChange = (e: ChangeEvent<HTMLInputElement>) => {
setUsernameVal(e.target.value)
}
const passwordChange = (e: ChangeEvent<HTMLInputElement>) => {
setPasswordVal(e.target.value)
}
// const captchaChange = (e: ChangeEvent<HTMLInputElement>) => {
// setCaptchaImg(e.target.value);
// }
// 获取查询参数
const navigate = useNavigate()
// 点击登录
const gotoLogin = async () => {
const result = await dispatch(loginAction({ username: usernameVal, password: passwordVal }) as unknown as any);
const userInfo = await dispatch(fetchUserInfo() as unknown as any)
if (loginAction.fulfilled.match(result)) {
dispatch(setToken(result.payload)); // 登录成功,获取用户信息并保存
// 异步获取用户信息及权限路由
const menus = userInfo.payload.menus
const routers = menus.concat(rootRouter); // 全部路由
const arr = extractPaths(routers)
// 存入 redux
dispatch(setAuthRouter(JSON.stringify(routers)));
dispatch(setMenu(arr));
navigate("/home");
} else {
return message.error("登录失败")
}
}
return (
<div className={styles.loginPage}>
{/* 存放背景 */}
<canvas id="canvas" style={{ display: 'bloack' }}></canvas>
{/* 登陆盒子 */}
<div className={styles.loginBox + ' loginbox'}>
{/* 标题部分 */}
<div className={styles.title}>
<h1>react+ts+vite通用后台管理系统</h1>
<p>Created By UnicornZhou</p>
</div>
{/* 表单部分 */}
<div className="form">
<Space direction="vertical" size="large" style={{ display: 'flex' }}>
<Input placeholder="用户名" onChange={usernameChange} />
<Input.Password placeholder="密码" onChange={passwordChange} />
{/* 验证码盒子 */}
{/* <div className="captchaBox">
<Input placeholder="验证码" onChange={captchaChange} />
<div className="captchaImg" >
<img height="38" src={'https://gitee.com/rucaptcha?1677072725090'} alt="" />
</div>
</div> */}
<Button
type="primary"
className="loginBtn"
block
onClick={gotoLogin}
>
登录
</Button>
</Space>
</div>
</div>
</div>
)
}
// 递归处理路由路径
function extractPaths(data) {
const paths = [];
function traverse(node) {
if (node.path) {
paths.push(node.path); // 将当前节点的 path 添加到数组中
}
if (node.children && node.children.length > 0) {
node.children.forEach(child => traverse(child)); // 递归遍历子节点
}
}
data.forEach(item => traverse(item)); // 遍历数组中的每一项
return paths;
}
export default Login
ini
//src\views\login\init.ts
export default function initLoginBg() {
var windowWidth =
document.documentElement.clientWidth || document.body.clientWidth
var windowHeight =
document.documentElement.clientHeight || document.body.clientHeight
// var windowWidth = window.clientWidth;
// var windowHeight = window.clientHeight;
var canvas = document.getElementById('canvas') as HTMLCanvasElement,
ctx = canvas.getContext('2d') as CanvasRenderingContext2D,
w = (canvas.width = windowWidth),
h = (canvas.height = windowHeight),
hue = 217,
stars: IntStart[] = [],
count = 0,
maxStars = 1500 //星星数量
var canvas2 = document.createElement('canvas'),
ctx2 = canvas2.getContext('2d') as CanvasRenderingContext2D
canvas2.width = 100
canvas2.height = 100
var half = canvas2.width / 2,
gradient2 = ctx2.createRadialGradient(half, half, 0, half, half, half)
gradient2.addColorStop(0.025, '#CCC')
gradient2.addColorStop(0.1, 'hsl(' + hue + ', 61%, 33%)')
gradient2.addColorStop(0.25, 'hsl(' + hue + ', 64%, 6%)')
gradient2.addColorStop(1, 'transparent')
ctx2.fillStyle = gradient2
ctx2.beginPath()
ctx2.arc(half, half, half, 0, Math.PI * 2)
ctx2.fill()
// End cache
function random(min: number, max = 0) {
if (arguments.length < 2) {
max = min
min = 0
}
if (min > max) {
var hold = max
max = min
min = hold
}
return Math.floor(Math.random() * (max - min + 1)) + min
}
function maxOrbit(x: number, y: number) {
var max = Math.max(x, y),
diameter = Math.round(Math.sqrt(max * max + max * max))
return diameter / 2
//星星移动范围,值越大范围越小,
}
interface IntStart {
orbitRadius: number
radius: number
orbitX: number
orbitY: number
timePassed: number
speed: number
alpha: number
draw: () => void
}
var Star = function (this: IntStart) {
this.orbitRadius = random(maxOrbit(w, h))
this.radius = random(60, this.orbitRadius) / 18
//星星大小
this.orbitX = w / 2
this.orbitY = h / 2
this.timePassed = random(0, maxStars)
this.speed = random(this.orbitRadius) / 500000
//星星移动速度
this.alpha = random(2, 10) / 10
count++
stars[count] = this
}
Star.prototype.draw = function () {
var x = Math.sin(this.timePassed) * this.orbitRadius + this.orbitX,
y = Math.cos(this.timePassed) * this.orbitRadius + this.orbitY,
twinkle = random(10)
if (twinkle === 1 && this.alpha > 0) {
this.alpha -= 0.05
} else if (twinkle === 2 && this.alpha < 1) {
this.alpha += 0.05
}
ctx.globalAlpha = this.alpha
ctx.drawImage(
canvas2,
x - this.radius / 2,
y - this.radius / 2,
this.radius,
this.radius
)
this.timePassed += this.speed
}
for (var i = 0; i < maxStars; i++) {
new Star.prototype.constructor()
}
function animation() {
ctx.globalCompositeOperation = 'source-over'
ctx.globalAlpha = 0.5 //尾巴
ctx.fillStyle = 'hsla(' + hue + ', 64%, 6%, 2)'
ctx.fillRect(0, 0, w, h)
ctx.globalCompositeOperation = 'lighter'
for (var i = 1, l = stars.length; i < l; i++) {
stars[i].draw()
}
window.requestAnimationFrame(animation)
}
animation()
}
css
//src\views\login\login.module.less
.loginPage {
position: relative;
.loginbox {
// 控制表单元素
.ant-input,
.ant-input-password {
background-color: rgba(255, 255, 255, 0);
border-color: #1890ff;
color: #fff;
height: 38px;
}
// placeholder字体颜色的控制
.ant-input::-webkit-input-placeholder {
// color:#1890ff;
color: rgba(24, 144, 255, 0.8);
}
// 单独控制密码盒子的高度
.ant-input-password .ant-input {
height: 28px;
}
// 控制眼睛图标
.ant-input-password-icon.anticon,
.ant-input-password-icon.anticon:hover {
color: #1890ff;
}
// 控制验证码盒子
.captchaBox {
display: flex;
.captchaImg {
margin-left: 20px;
cursor: pointer;
}
}
// 控制登录按钮
.loginBtn {
height: 38px;
}
}
.loginBox {
width: 500px;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
color: #fff;
h1 {
font-weight: bold;
font-size: 22px;
text-align: center;
color: #fff;
}
p {
text-align: center;
margin: 20px 0;
}
.title {
margin-bottom: 40px;
position: relative;
&:before,
&:after {
content: '';
width: 100px;
height: 2px;
position: absolute;
background: linear-gradient(to right, rgba(255, 255, 255, 0), #1976d2);
left: -20px;
top: 18px;
}
&:after {
left: auto;
background: linear-gradient(to left, rgba(255, 255, 255, 0), #1976d2);
right: -20px;
}
}
}
}
八、路由权限菜单逻辑
梳理逻辑:用户点击登录触发login接口获取token存入redux存储器,然后再利用token去获取用户信息包含了用户的角色和菜单权限,获取了用户的菜单权限后再合并静态路由,把完整的路由存入redux存储器并保存在本地localstorage,然后把完整的路由经过遍历处理给左侧菜单渲染
1、先安装axios,执行npm i axios;模拟后台接口请求写在下面
,在authslice中添加异步登录接口loginAction和获取用户信息接口fetchUserInfo
typescript
// reducers/authSlice.js
// 使用 @reduxjs/toolkit 可以轻松处理异步事件,而无需额外安装 redux-thunk。
// @reduxjs/toolkit 已经内置了 redux-thunk,并且提供了更简洁的方式来定义异步逻辑,即通过 createAsyncThunk 创建异步 Action
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios';
import { message } from 'antd';
const authSlice = createSlice({
name: 'auth',
initialState: {
token: sessionStorage.getItem('authToken') || null,
userInfo: null,
authRouter: [],
authMenu: []
},
reducers: {
setToken: (state, action) => {
state.token = action.payload;
sessionStorage.setItem('authToken', action.payload); // 将 token 存储到 sessionStorage
},
clearToken: (state) => {
state.token = null;
sessionStorage.removeItem('authToken'); // 清除 sessionStorage 中的 token
},
setAuthRouter: (state, action) => {
state.authRouter = action.payload;
sessionStorage.setItem('authRouter', action.payload); // 将 全部路由 存储到 sessionStorage
},
setMenu: (state, action) => {
state.authMenu = action.payload;
sessionStorage.setItem('authMenu', action.payload); // 将 menulist 存储到 sessionStorage
}
},
extraReducers: (builder) => {
builder
.addCase(loginAction.fulfilled, (state, action) => {
state.token = action.payload; // 登录成功,保存 token
})
.addCase(fetchUserInfo.fulfilled, (state, action) => {
state.userInfo = action.payload; // 保存用户信息
})
},
});
// 定义异步 Action:登录
export const loginAction = createAsyncThunk(
'auth/login', // Action 类型
async (credentials: { username: string; password: string }, { rejectWithValue }) => {
try {
const response = await axios.get('http://localhost:3002/login'); // 发送登录请求
return response.data.token; // 返回 token
} catch (error) {
return rejectWithValue(error.response.data); // 返回错误信息
}
}
);
// 定义异步 Action:获取用户信息
export const fetchUserInfo = createAsyncThunk(
'auth/fetchUserInfo',
async (_, { getState }) => {
try {
const token = (getState() as any).auth.token; // 从 state 中获取 token
const response = await axios.get('http://localhost:3002/user', {
headers: { Authorization: `Bearer ${token}` },
});
return response.data; // 返回用户信息
} catch (error) {
console.log(error)
return message.error(error.message);
}
}
);
export const { setToken, clearToken, setAuthRouter, setMenu } = authSlice.actions;
export default authSlice.reducer;
2、处理路由,导入staticRouter静态路由
typescript
//src\router\index.tsx
import { useRoutes } from "react-router-dom";
import lazyLoad from '@/layout/utils/lazyLoad';
import React from 'react';
import NoPermission from "@/views/errorPage/403";
import NotFound from "@/views/errorPage/404";
import LazyoutIndex from "@/layout/index";
import { staticRouter } from "./staticRouter";
export interface RouterObject {
path?: string;
element?: React.ReactNode;
children?: RouterObject[] | null;
}
export const rootRouter = [
{
path: '/login',
element: lazyLoad(React.lazy(() => import('@/views/login')))
},
{
element: <LazyoutIndex />,
hide: false, //需要配置false才不会过滤掉菜单
children: staticRouter
},
{
path: '/403',
element: <NoPermission />
},
{
path: '/404',
element: <NotFound />
}
]
const Router = () => {
const routes = useRoutes(rootRouter)
return routes
}
export default Router
3、路由守卫src\router\authRouter.tsx
javascript
import { store } from '@/redux/store';
import { JSX } from 'react';
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
// 路由守卫组件
const AuthRouter = (props: { children: JSX.Element }) => {
const { pathname } = useLocation();
const token = store.getState().auth.token;
const authMenu = store.getState().auth.authMenu
if (token) {
if (pathname === '/') {
return <Navigate to="/home" replace />;
} else if (!authMenu.includes(pathname)) {
// 如果有 token 且当前路径找不到菜单
return <Navigate to="/404" replace />;
}
} else {
// 如果没有 token 且当前路径不是登录页,跳转到登录页
if (pathname !== '/login') {
return <Navigate to="/login" replace />;
}
}
// 如果有权限,渲染子组件
return props.children;
};
export default AuthRouter;
4、处理菜单逻辑src\layout\menu\index.tsx
ini
import { useEffect, useState } from "react";
import Logo from "../components/Logo";
import { Menu } from "antd";
import * as Icons from '@ant-design/icons';
import { useLocation, useNavigate } from "react-router-dom"; // 引入 useNavigate
import { store } from "@/redux/store";
import React from "react";
const LayoutMenu = () => {
const [menuList, setMenuList] = useState([]);
const authRouter: any = store.getState().auth.authRouter;
// 获取菜单数据
const getMenuData = () => {
try {
const arr = JSON.parse(authRouter).filter(item => item.hide === false)
const menuArr = handlerRouters(arr); // 处理路由路径
setMenuList(menuArr)
} catch (error) {
console.error(error);
}
};
// 处理路由路径
const handlerRouters = (menuData, routes = []) => {
menuData.forEach(element => {
// 如果当前节点没有子节点,则处理并添加到 routes
if (!element.children || element.children.length === 0) {
// 如果路径包含 '/' 并且有 icon,则渲染 icon
if (element.path.includes('/') && element.icon) {
element.icon = renderIcon(element.icon);
}
return routes.push(element); // 添加当前节点到 routes
}
// 如果有子节点,则递归处理子节点
handlerRouters(element.children, routes);
});
return routes; // 返回所有处理后的路由
};
// 动态渲染菜单
const cunstomIcons: { [key: string]: any } = Icons
const renderIcon = (icon: string) => {
if (!icon) return null;
return React.createElement(cunstomIcons[icon])
}
useEffect(() => {
getMenuData();
}, []);
const [openKeys, setOpenKeys] = useState([]); // 当前展开的菜单项
const [selectedKeys, setSelectedKeys] = useState([]); // 当前选中的菜单项
const { pathname } = useLocation(); // 获取当前路由路径
const navigate = useNavigate(); // 获取 navigate 函数
// 根据路由路径设置选中的菜单项和展开的菜单项
useEffect(() => {
// 设置选中的菜单项
setSelectedKeys([pathname]);
// 设置展开的菜单项
const parentKey = findParentKey(pathname, menuList);
if (parentKey) {
setOpenKeys([parentKey]);
}
}, [pathname, menuList]);
// 查找当前路径的父级菜单项
const findParentKey = (path, menuList) => {
for (const menu of menuList) {
if (menu.children) {
const child = menu.children.find(child => child.key === path);
if (child) {
return menu.key; // 返回父级菜单项的 key
}
const parentKey = findParentKey(path, menu.children); // 递归查找
if (parentKey) {
return menu.key; // 返回父级菜单项的 key
}
}
}
return null;
};
// 处理菜单展开事件
const onOpenChange = (keys) => {
setOpenKeys(keys);
};
// 处理菜单项点击事件
const onMenuClick = ({ key }) => {
console.log(key)
navigate(key); // 跳转到对应的路由路径
};
return (
<div className="layout-menu">
<Logo />
<Menu
theme="dark"
mode="inline"
items={menuList}
openKeys={openKeys}
selectedKeys={selectedKeys}
onOpenChange={onOpenChange}
onClick={onMenuClick} // 绑定点击事件
/>
</div>
);
};
export default LayoutMenu;
5、注入路由守卫src\App.tsx
javascript
import { BrowserRouter } from 'react-router-dom';
import Router from './router/index';
import AuthRouter from './router/authRouter';
function App() {
return (
<BrowserRouter>
<AuthRouter>
<Router />
</AuthRouter>
</BrowserRouter>
)
}
export default App
九、模拟接口请求
1、安装npm i json-server -g
2、根目录创建db.json
perl
{
"login": {
"token": "sdafsdfsdfsdfsdf"
},
"user": {
"id": 1,
"name": "admin",
"avatar": "https://s3.ax1x.com/2021/08/26/QW9p95.jpg",
"roles": [
"admin"
],
"menus": [],
"introduction": "I'm an admin",
"email": "admin@example.com",
"created_at": "2016-04-21T15:00:32.000Z",
"updated_at": "2016-05-13T14:31:20.000Z"
}
}