react-admin-template最详细的动态菜单路由权限配置(0-1)

一、配置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"
    }
}
3、vscode另开一个终端执行npx json-server --watch db.json --port 3002;即可访问接口:http://localhost:3002

源码:gitee.com/unicorn-zbf...

相关推荐
艾小码3 分钟前
为什么你的JavaScript代码总是出bug?这5个隐藏陷阱太坑了!
前端·javascript
辻戋2 小时前
从零实现React Scheduler调度器
前端·react.js·前端框架
徐同保2 小时前
使用yarn@4.6.0装包,项目是react+vite搭建的,项目无法启动,报错:
前端·react.js·前端框架
Qrun3 小时前
Windows11安装nvm管理node多版本
前端·vscode·react.js·ajax·npm·html5
中国lanwp3 小时前
全局 npm config 与多环境配置
前端·npm·node.js
JELEE.4 小时前
Django登录注册完整代码(图片、邮箱验证、加密)
前端·javascript·后端·python·django·bootstrap·jquery
TeleostNaCl6 小时前
解决 Chrome 无法访问网页但无痕模式下可以访问该网页 的问题
前端·网络·chrome·windows·经验分享
前端大卫7 小时前
为什么 React 中的 key 不能用索引?
前端
你的人类朋友7 小时前
【Node】手动归还主线程控制权:解决 Node.js 阻塞的一个思路
前端·后端·node.js
小李小李不讲道理9 小时前
「Ant Design 组件库探索」五:Tabs组件
前端·react.js·ant design