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...

相关推荐
孤城2864 分钟前
MAC电脑常用操作
前端·macos·快捷键·新手·电脑使用
木亦Sam5 分钟前
Vue DevTools逆向工程:自己实现一个组件热更新调试器
前端
酷酷的阿云5 分钟前
动画与过渡效果:UnoCSS内置动画库的实战应用
前端·css·typescript
dleei5 分钟前
使用docker创建gitlab仓库
前端·docker·gitlab
勤劳的代码小蜜蜂6 分钟前
大文件上传:告别传统传输瓶颈,让数据流转更高效
前端
前端大卫6 分钟前
Echarts 饼图的创新绘制技巧(附 Demo 和源码)
前端·javascript·echarts
wiedereshen7 分钟前
Vue学习记录(十) --- Vue3综合应用
前端
展信佳_daydayup9 分钟前
Vue3项目部署到服务器
前端
理查der驾10 分钟前
mini-react 第四天:事件绑定,更新props
react.js
LanceJiang11 分钟前
前端检测版本更新-Worker 项目实践
前端