快速搭建一个企业级React后台管理系统模板(一)

2,584 阅读8分钟

本模板采用React+Vite+Zustand+antd搭建,并配置了eslint+prettier+commitlint统一代码风格和规范,封装了完整的请求函数,并对权限(路由,菜单,组件)加以控制,提供深色模式和浅色模式两种方案。

//如果你想开箱即用直接执行以下命令

git clone https://github.com/Yohan3416/ManageBase-React.git

//使用pnpm安装依赖
pnpm i

//把我们的项目运行起来
pnpm run dev

首先使用vite搭建一个基础的React项目模板

pnpm create vite

为你的项目起一个名称,选择React+Typscript+SWC

随后根据提示执行

pnpm i

pnpm run dev

安装依赖运行起来我们的初始项目

配置项目的husky@8.0.0 + eslint + prettier + commitlint对代码提供一套规范

如果你自己做项目,不在乎代码格式规范和提交规范,可以跳过这一部分

配置eslint

在根目录安装所需的依赖

pnpm i eslint @typescript-eslint/parser @typescript-eslint/plugin  eslint-config-prettier eslint-plugin-prettier -D

安装好之后,在根目录创建.eslintrc.cjs.eslintignore文件

module.exports = {
  root: true,
  env: { browser: true, es2020: true },
  extends: [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:@typescript-eslint/eslint-recommended",
    "plugin:react-hooks/recommended",
    "prettier",
  ],
  ignorePatterns: ["dist", ".eslintrc.cjs"],
  parser: "@typescript-eslint/parser",
  plugins: ["react-refresh", "@typescript-eslint", "prettier"],

  rules: {
    "react-refresh/only-export-components": [
      "warn",
      { allowConstantExport: true },
    ],
    "no-explicit-any": "off",
    "react-refresh/only-export-components": "off",
    "prettier/prettier": "error",
    "arrow-body-style": "off",
    "prefer-arrow-callback": "off",
    "prettier/prettier": ["error", { endOfLine: "auto" }],
  },
};

node_modules/

pnpm-lock.yaml

dist
public
.husky
.vscode
docs
*.md

.eslintrc
.prettierrc
index.html

commitlint.config.cjs
vite.config.ts

配置好之后,在pakage.json文件中添加执行eslint的脚本

//在scripts中添加
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",

配置prettier

//执行以下命令添加依赖
pnpm i prettier -D

在项目根目录下创建.prettierrc.prettierignore文件,关于详细配置,你可以查阅官方文档,我只是给一个我们团队常用的配置。

{
    "extends": ["taro/react","plugin:prettier/recommended"],
    "plugins": ["prettier"],
    "rules": {
      "prefer-const":"warn",
      "react/jsx-uses-react": "off",
      "react/react-in-jsx-scope": "off",
      "quotes":"off",
      "no-unused-vars":"off",
      "array-bracket-spacing":["error","never"],
      "no-var":"warn",
      "comma-spacing": [
        "error",
        {
          "before": false,
          "after": true
        }
      ],
      "no-cjs": "off",
      "@typescript-eslint/no-unused-vars": "warn",
      "no-commonjs": "off",
      "import/no-commonjs": "off",
      "@typescript-eslint/no-explicit-any": "off"
    }
  }
node_modules
dist
public
types
.husky
.vscode
docs
*.md

.eslintrc
.prettierrc
commitlint.config.cjs
index.html

package.json中增加脚本

//在scripts中添加
"format": "prettier --write src/",

代码保存自动格式化

在根目录下新建.vscode文件夹,再在.vscode文件夹之下新建settings.jsonextensions.json

{
    "editor.formatOnSave": true, // 开启保存文件自动格式化代码
    "editor.defaultFormatter": "esbenp.prettier-vscode", // 设置默认的代码格式化工具为 Prettier
    "eslint.enable": true
  }
{
    "recommendations": [
        "dbaeumer.vscode-eslint",
        "esbenp.prettier-vscode",
    ]
}

配置husky和lint-staged规范代码提交

husky

pnpm i husky@8.0.0 -D

//执行这行命令在根目录生成.husky文件夹 
npx husky install

//在package.json新增脚本
"prepare": "husky install",

.husky文件夹下

// .husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

# pnpm run lint
pnpm run lint:lint-staged --allow-empty

lint-staged

pnpm i lint-staged -D

package.json添加配置项和脚本

//package.json
{
    "script": {
    //...
     "lint:lint-staged": "lint-staged",
    }
    //...
      "lint-staged": {
    "*.{js,ts,jsx,tsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{cjs,json}": [
      "prettier --write"
    ]
  }
}

commitlint代码提交规范

// 安装依赖
pnpm i @commitlint/cli @commitlint/config-conventional -D

根目录创建commitlint.config.cjs配置文件

module.exports = {
  extends: ["@commitlint/config-conventional"],
  rules: {
    "subject-case": [0],
    "type-enum": [
      2,
      "always",
      [
        "feat", //新增功能
        "fix", //修复缺陷
        "docs", //文档变更
        "style", //代码格式修正
        "refactor", //代码重构
        "perf", //性能优化
        "test", //添加疏漏测试或者已有测试改动
        "build", //构建流程,外部依赖变更
        "cli", //修改 CI 配置,脚本
        "revert", //回滚 commit
        "chore", //对构建过程或辅助工具和库的更改(不影响源文件和测试用例)
      ],
    ],
  },
};

执行下面的命令生成commit-msg钩子用于git提交信息校验

npx husky add .husky/commit-msg "npx --no -- commitlint --edit $1"

配置路由和Ant Design

// 安装react-router-dom
pnpm i react-router-dom

src/pages新建页面文件夹

pages.png

这里只做示例写这么多,你可以随便写两个页面组件

先来实现一个懒加载组件,提高首页加载速度

// src/components/LazyComponent/LazyComponent.tsx

import { Suspense, LazyExoticComponent } from "react";
import LazyLoading from "./LazyLoading";

export default function LazyComponent(props: {
  lazyChildren: LazyExoticComponent<() => JSX.Element>;
  title: string;
}) {
  return (
    <Suspense fallback={<LazyLoading title={props.title} />}>
      <props.lazyChildren />
    </Suspense>
  );
}
// src/components/LazyComponent/LazyLoading.tsx

export default function LazyLoading(props: { title: string }) {
  return (
    <div id="loadingRoot">
      <div className="loader-container">
        <div className="loader">
          <div></div>
          <div></div>
          <div></div>
          <div></div>
        </div>
        <h1>{props.title}</h1>
      </div>
    </div>
  );
}

创建src/router/index.tsx文件,在里面对路由进行配置

// src/router/index.tsx
import { Navigate, createBrowserRouter } from "react-router-dom";
import Login from "../pages/Login/Login.tsx";
import MyLayout from "../Layout/Layout.tsx";
import Error403 from "../pages/403.tsx";
import Error404 from "../pages/404.tsx";
import LazyComponent from "../components/LazyComponent/LazyComponent.tsx";
import { lazy } from "react";

const routes = [
  {
    path: "/login",
    element: <Login />,
  },

  {
    path: "/",
    element: <MyLayout />,
    children: [
      {
        index: true,
        element: <Navigate to={"/databoard"} />,
      },
      {
        path: "/databoard",
        element: (
          <LazyComponent
            lazyChildren={lazy(
              () => import("../pages/LayoutPages/DataBoard/DataBoard.tsx"),
            )}
            title="数据看板"
          />
        ),
      },
      {
        path: "/cashier",
        element: (
          <LazyComponent
            lazyChildren={lazy(
              () => import("../pages/LayoutPages/Cashier/Cashier.tsx"),
            )}
            title="收银台"
          />
        ),
      },
      {
        path: "/bookstorage",
        element: (
          <LazyComponent
            lazyChildren={lazy(
              () => import("../pages/LayoutPages/BookStorage/BookStorage.tsx"),
            )}
            title="书籍入库"
          />
        ),
      },
      {
        path: "/storemanage",
        element: (
          <LazyComponent
            lazyChildren={lazy(
              () => import("../pages/LayoutPages/StoreManage/StoreManage.tsx"),
            )}
            title="库存管理"
          />
        ),
      },
      {
        path: "/OrderManage",
        element: (
          <LazyComponent
            lazyChildren={lazy(
              () => import("../pages/LayoutPages/OrderManage/OrderMannage.tsx"),
            )}
            title="订单管理"
          />
        ),
      },
      {
        path: "/department",
        children: [
          {
            index: true,
            element: <Navigate to={"/department/add"} />,
          },
          {
            path: "/department/add",
            element: (
              <LazyComponent
                lazyChildren={lazy(
                  () =>
                    import("../pages/LayoutPages/Department/Department.tsx"),
                )}
                title="部门管理"
              />
            ),
          },
        ],
      },
      {
        path: "/logrecord",
        element: (
          <LazyComponent
            lazyChildren={lazy(
              () => import("../pages/LayoutPages/LogRecord/LogRecord.tsx"),
            )}
            title="日志记录"
          />
        ),
      },
    ],
  },

  {
    path: "/403",
    element: <Error403 />,
  },
  {
    path: "/404",
    element: <Error404 />,
  },
  {
    path: "*",
    element: <Navigate to={"/404"} />,
  },
];

const router = createBrowserRouter(routes) as ReturnType<
  typeof createBrowserRouter
>;

export default router;

修改App.tsx文件

import { RouterProvider } from 'react-router-dom'
import Router from './router';

function App() {
  return (
    <RouterProvider router={router} />
  );
}

export default App;

使用antd优化403和404页面

安装Ant Design

pnpm install antd
// src/pages/403.tsx
import React from 'react';  
import { Result, Button } from 'antd';  
import { useHistory } from 'react-router-dom';  

const Error403 = () => {  
    const history = useHistory();  

    const handleBack = () => {  
        history.push('/'); // 跳转回主页或其他你希望的路径  
    };  

    return (  
        <Result  
            status="403"  
            title="403"  
            subTitle="抱歉,您没有权限访问此页面."  
            extra={<Button type="primary" onClick={handleBack}>返回首页</Button>}  
        />  
    );  
};  

export default Error403;
// src/pages/404.tsx
import React from 'react';  
import { Result, Button } from 'antd';  
import { useHistory } from 'react-router-dom';  

const Error404 = () => {  
    const history = useHistory();  

    const handleBack = () => {  
        history.push('/'); // 跳转回主页或其他你希望的路径  
    };  

    return (  
        <Result  
            status="404"  
            title="404"  
            subTitle="抱歉,您访问的页面不存在."  
            extra={<Button type="primary" onClick={handleBack}>返回首页</Button>}  
        />  
    );  
};  

export default Error404;

状态管理ZuStand配置(持久化)

npm install zustand

在src下新建store文件夹

// src/store/index.ts 用于整体导出

export { useUserStore } from "./user";
// src/store/user.ts 用于存放用户信息

import { create } from "zustand";
import { persist } from "zustand/middleware"; //用于持久化存储
import { produce } from "immer";

type Action = {
  updateToken: (token: string) => void;
  updateRole: (role: string) => void;
  updateUserName?: (username: string) => void;
};

interface State {
  username: string;
  role: string;
  token: string;
} //用于规定本仓库的数据结构

export const useUserStore = create<State & Action>()(
  persist( //使用持久化组件包裹
    (set) => ({
      username: "默认名称",
      role: "admin",
      token: "",
      updateToken: (token) =>
        set(
          produce((state) => {
            localStorage.setItem("token", token);
            state.token = token;
          }),
        ),
      updateRole: (role) =>
        set(
          produce((state) => {
            state.role = role;
          }),
        ),
      updateUsername: (username: string) =>
        set(
          produce((state) => {
            state.username = username;
          }),
        ),
    }),
    {
      name: "user-storage", //在localStorage中对应的key
    },
  ),
);

登录页面搭建

在后期配置Ant自动导入过后,message等组件不可用,我们先封装一下

// src/utils/AntGlobal.tsx

import { App } from "antd";
import type { MessageInstance } from "antd/es/message/interface";
import type { ModalStaticFunctions } from "antd/es/modal/confirm";
import type { NotificationInstance } from "antd/es/notification/interface";

let message: MessageInstance;
let notification: NotificationInstance;
let modal: Omit<ModalStaticFunctions, "warn">;

const AntdGlobal = () => {
  const staticFunction = App.useApp();
  message = staticFunction.message;
  modal = staticFunction.modal;
  notification = staticFunction.notification;
  return null;
};

export default AntdGlobal;
export { message, notification, modal };

使用Mock模拟登录接口

安装mock

pnpm i mockjs@1.1.0 @types/mockjs@1.0.10 -D

src下新建mock文件夹

import Mock from "mockjs";

Mock.setup({
  timeout: "300-800",
});

const checkLogin = (config: { body: string }) => {
  const Config = JSON.parse(config.body);
  if (Config.username === "superadmin" && Config.password === "123456") {
    return {
      code: 200,
      data: {
        username: "SuperAdmin",
        token: Mock.Random.guid(),
        role: "SuperAdmin",
      },
    };
  } else if (Config.username === "admin" && Config.password === "123456") {
    return {
      code: 200,
      data: {
        username: "Admin",
        token: Mock.Random.guid(),
        role: "Admin",
      },
    };
  } else {
    return {
      code: 500,
      msg: "账号或者密码错误",
    };
  }
};

Mock.mock(/\/user\/login/, "post", checkLogin);

在main.tsx文件中导入

import "./mock/index.ts";

封装axios和接口

axios可以自行安装

在utils文件夹下新建 request.ts

import axios, {
  AxiosError,
  AxiosResponse,
  InternalAxiosRequestConfig,
} from "axios";

// 创建一个axios实例
const request = axios.create({
  baseURL: "http://localhost:3000", //请求的基地址,包含协议,ip,端口
  timeout: 5000, //请求超时的时间
});

// 请求拦截器,用于在发出请求前一些操作,一般会携带上token
request.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    return config;
  },
  (error: AxiosError) => {
    return Promise.reject(error);
  },
);

// 响应拦截器,用于请求成功后,数据返回后的操作,一般可以在这里判断一下状态码
request.interceptors.response.use(
  (response: AxiosResponse) => {
    return response;
  },
  (error: AxiosError) => {
    return Promise.reject(error);
  },
);

export default request;

封装我们的登录接口

// src/apis/login.ts

import request from "../utils/request";

const login = (data: object) => {
  return new Promise((resolve, reject) => {
    request
      .post("/user/login", data)
      .then((res: any) => {
        resolve(res.data);
      })
      .catch((err) => {
        reject(err);
      });
  });
};
export default login;

正式搭建

// pages/Login/Login.tsx

import { useState } from "react";
import type { FormProps } from "antd";
import { Button, Checkbox, Form, Input } from "antd";
import { message } from "../../utils/AntdGlobal";
import login from "../../api/login";
import "./Login.scss";
import { useUserStore } from "../../store";
import { useNavigate } from "react-router-dom";

type FieldType = {
  username?: string;
  password?: string;
  remember?: string;
};

function Login() {
  const [isLoading, setIsLoading] = useState(false);
  const navigate = useNavigate();

  const handleLogin = (values: FieldType) => {
    return login(values)
      .then((res: any) => {
        if (res.code === 200) {
          useUserStore.getState().updateToken(res.data.token);
          useUserStore.getState().updateRole(res.data.role);
          message.success("登录成功");
          setTimeout(() => {
            navigate("/", { replace: true });
          }, 1000);
          if (values.remember) {
            if (values.username && values.password) {
              localStorage.setItem("username", values.username);
              localStorage.setItem("password", values.password);
            }
          } else {
            if (localStorage.getItem("username"))
              localStorage.removeItem("username");
          }
        } else {
          message.error(res.msg);
        }
      })
      .catch(() => {
        message.error("登录失败");
      });
  };

  const onFinish: FormProps<FieldType>["onFinish"] = (values = {}) => {
    setIsLoading(true);
    handleLogin(values).finally(() => {
      setTimeout(() => {
        setIsLoading(false);
      }, 1000);
    });
  };
  const onFinishFailed: FormProps<FieldType>["onFinishFailed"] = (
    errorInfo,
  ) => {
    console.log("Failed:", errorInfo);
  };
  return (
    <div className="loginContainer">
      <Form
        name="basic"
        labelCol={{ span: 8 }}
        wrapperCol={{ span: 16 }}
        style={{ maxWidth: 600, minWidth: 350 }}
        initialValues={{ remember: false }}
        onFinish={onFinish}
        onFinishFailed={onFinishFailed}
        autoComplete="off"
      >
        <Form.Item<FieldType>
          label="用户名"
          name="username"
          rules={[{ required: true, message: "请输入用户名!" }]}
        >
          <Input />
        </Form.Item>

        <Form.Item<FieldType>
          label="密码"
          name="password"
          rules={[{ required: true, message: "请输入密码!" }]}
        >
          <Input.Password />
        </Form.Item>

        <Form.Item<FieldType>
          name="remember"
          valuePropName="checked"
          wrapperCol={{ offset: 8, span: 16 }}
        >
          <Checkbox>Remember me</Checkbox>
        </Form.Item>

        <Form.Item wrapperCol={{ offset: 8, span: 16 }}>
          <Button type="primary" htmlType="submit" loading={isLoading}>
            登录
          </Button>
        </Form.Item>
      </Form>
    </div>
  );
}

export default Login;

导航守卫的实现

我们发现,即使在没有登录的情况下,直接访问首页地址,依然能够进入到后台,这不是我们想要的结果

新建文件如下

//router/loader.ts

import { redirect } from "react-router-dom";
export function Loader() {
  const tk = localStorage.getItem("token");
  if (tk) {
    return null;
  } else {
    return redirect("/login");
  }
}

修改router/index.tsx,配置loader

//引入
import { Loader } from "./loader.ts";

//只需要在path = `/`路由中配置loader属性,会在到达该页面和他的子页面时,先执行loader

loader: Loader,

尝试清空本地存储的数据,刷新页面,是不是就重定向到登录页面了

下一期将搭建Layout页面,并根据角色不同获取动态路由进行菜单级权限控制和登出功能