还在用antdpro?手把手教你用 react 最流行的生态搭建轻量级后台管理

4,156 阅读7分钟

前言

你是否经历过公司的产品和 ui 要求左侧菜单栏要改成设计图上的样子? 苦恼 antd-pro 强绑定的 pro-layout 菜单栏不能自定义?你可以使用 umi,但是就要根据它的约定来开发,捆绑全家桶等等。手把手教你搭一个轻量级的后台模版,包括路由的权限、动态菜单等等。为方便使用 antd 组件库,你可以改成任意你喜欢的。数据请求的管理使用 react-query,类似 useRequest,但是更加将大。样式使用 tailwindcssstyled-components,因为 antd v5 将使用 css in js。路由的权限和菜单管理使用 react-router-auth-plus。。。

仓库地址

项目初始化

vite

# npm 7+
npm create vite spirit-admin -- --template react-ts

antd

tailwindcss

styled-components

react-query

axios

react-router

react-router-auth-plus (权限路由、动态菜单解决方案) 仓库地址 文章地址

等等...

数据请求 + mock

配置 axios

设置拦截器,并在 main.ts 入口文件中引入这个文件,使其在全局生效

// src/axios.ts
import axios, { AxiosError } from "axios";
import { history } from "./main";

// 设置 response 拦截器,状态码为 401 清除 token,并返回 login 页面。
axios.interceptors.response.use(
  function (response) {
    return response;
  },
  function (error: AxiosError) {
    if (error.response?.status === 401) {
      localStorage.removeItem("token");
      // 在 react 组件外使用路由方法, 使用方式会在之后路由配置时讲到
      history.push("/login");
    }
    return Promise.reject(error);
  }
);

// 设置 request 拦截器,请求中的 headers 带上 token
axios.interceptors.request.use(function (request) {
  request.headers = {
    authorization: localStorage.getItem("token") || "",
  };
  return request;
});

配置 react-query

在 App 外层包裹 QueryClientProvider,设置默认选项,窗口重新聚焦时和失败时不重新请求。

// App.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      refetchOnWindowFocus: false,
      retry: false,
    },
  },
});

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
        <App />
    </QueryClientProvider>
  </React.StrictMode>
);

我们只有两个请求,登录和获取当前用户,src 下新建 hooks 文件夹,再分别建 query、mutation 文件夹,query 是请求数据用的,mutation 是发起数据操作的请求用的。具体可以看 react-query 文档

获取当前用户接口

// src/hooks/query/useCurrentUserQuery.ts
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { queryClient } from "../../main";

// useQuery 需要唯一的 key,react-query v4 是数组格式
const currentUserQueryKey = ["currentUser"];

// 查询当前用户,如果 localStorage 里没有 token,则不请求
export const useCurrentUserQuery = () =>
  useQuery(currentUserQueryKey, () => axios.get("/api/me"), {
    enabled: !!localStorage.getItem("token"),
  });

// 可以在其它页面获取 useCurrentUserQuery 的数据
export const getCurrentUser = () => {
  const data: any = queryClient.getQueryData(currentUserQueryKey);
  return {
    username: data?.data.data.username,
  };
};

登录接口

// src/hooks/mutation/useLoginMutation.ts
import { useMutation } from "@tanstack/react-query";
import axios from "axios";

export const useLoginMutation = () =>
  useMutation((data) => axios.post("/api/login", data));

mock

数据请求使用 react-query + axios, 因为只有两个请求,/login(登录) 和 /me(当前用户),直接使用 express 本地 mock 一下数据。新建 mock 文件夹,分别建立 index.jsusers.js

// users.js 存放两种类型的用户
export const users = [
  { username: "admin", password: "admin" },
  { username: "employee", password: "employee" },
];
// index.js 主文件
import express from "express";
import { users } from "./users.js";

const app = express();
const port = 3000;

const router = express.Router();

// 登录接口,若成功返回 token,这里模拟 token 只有两种情况
router.post("/login", (req, res) => {
  setTimeout(() => {
    const username = req.body.username;
    const password = req.body.password;
    const user = users.find((user) => user.username === username);
    if (user && password === user.password) {
      res.status(200).json({
        code: 0,
        token: user.username === "admin" ? "admin-token" : "employee-token",
      });
    } else {
      res.status(200).json({ code: -1, message: "用户名或密码错误" });
    }
  }, 2000);
});

// 当前用户接口,请求时需在 headers 中带上 authorization,若不正确返回 401 状态码。根据用户类型返回权限和用户名
router.get("/me", (req, res) => {
  setTimeout(() => {
    const token = req.headers.authorization;
    if (!["admin-token", "employee-token"].includes(token)) {
      res.status(401).json({ code: -1, message: "请登录" });
    } else {
      const auth = token === "admin-token" ? ["application", "setting"] : [];
      const username = token === "admin-token" ? "admin" : "employee";
      res.status(200).json({ code: 0, data: { auth, username } });
    }
  }, 2000);
});

app.use(express.json());
// 接口前缀统一加上 /api
app.use("/api", router);
// 禁用 304 缓存
app.disable("etag");

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`);
});

package.json 中的 scripts 添加一条 mock 命令,需安装 nodemon,用来热更新 mock 文件的。npm run mock 启动 express 服务。

"scripts": {
  ...
  "mock": "nodemon mock/index.js"
}

现在在项目中还不能使用,需要在 vite 中配置 proxy 代理

// vite.config.ts
export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      "/api": {
        target: "http://localhost:3000",
        changeOrigin: true,
      },
    },
  },
});

路由权限配置

路由和权限这块使用的方案是 react-router-auth-plus,具体介绍见上篇

路由文件

新建一个 router.tsx,引入页面文件,配置项目所用到的所有路由,配置上权限。这里我们扩展一下 AuthRouterObject 类型,自定义一些参数,例如左侧菜单的 icon、name 等。设置上 /account/center/application 路由需要对应的权限。

import {
  AppstoreOutlined,
  HomeOutlined,
  UserOutlined,
} from "@ant-design/icons";
import React from "react";
import { AuthRouterObject } from "react-router-auth-plus";
import { Navigate } from "react-router-dom";
import BasicLayout from "./layouts/BasicLayout";
import Application from "./pages/application";
import Home from "./pages/home";
import Login from "./pages/login";
import NotFound from "./pages/404";
import Setting from "./pages/account/setting";
import Center from "./pages/account/center";

export interface MetaRouterObject extends AuthRouterObject {
  name?: string;
  icon?: React.ReactNode;
  hideInMenu?: boolean;
  hideChildrenInMenu?: boolean;
  children?: MetaRouterObject[];
}

// 只需在需要权限的路由配置 auth 即可
export const routers: MetaRouterObject[] = [
  { path: "/", element: <Navigate to="/home" replace /> },
  { path: "/login", element: <Login /> },
  {
    element: <BasicLayout />,
    children: [
      {
        path: "/home",
        element: <Home />,
        name: "主页",
        icon: <HomeOutlined />,
      },
      {
        path: "/account",
        name: "个人",
        icon: <UserOutlined />,
        children: [
          {
            path: "/account",
            element: <Navigate to="/account/center" replace />,
          },
          {
            path: "/account/center",
            name: "个人中心",
            element: <Center />,
          },
          {
            path: "/account/setting",
            name: "个人设置",
            element: <Setting />,
            // 权限
            auth: ["setting"],
          },
        ],
      },
      {
        path: "/application",
        element: <Application />,
        // 权限
        auth: ["application"],
        name: "应用",
        icon: <AppstoreOutlined />,
      },
    ],
  },
  { path: "*", element: <NotFound /> },
];

main.tsx

使用 HistoryRouter,在组件外可以路由跳转,这样就可以在 axios 拦截器中引入 history 跳转路由了。

import { createBrowserHistory } from "history";
import { unstable_HistoryRouter as HistoryRouter } from "react-router-dom";

export const history = createBrowserHistory({ window });

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <HistoryRouter history={history}>
        <App />
      </HistoryRouter>
    </QueryClientProvider>
  </React.StrictMode>
);

App.tsx

import { useAuthRouters } from "react-router-auth-plus";
import { routers } from "./router";
import NotAuth from "./pages/403";
import { Spin } from "antd";
import { useEffect, useLayoutEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useCurrentUserQuery } from "./hooks/query";

function App() {
  const navigate = useNavigate();
  const location = useLocation();

  // 获取当前用户,localStorage 里没 token 时不请求
  const { data, isFetching } = useCurrentUserQuery();

  // 第一次进入程序,不是 login 页面且没有 token,跳转到 login 页面
  useEffect(() => {
    if (!localStorage.getItem("token") && location.pathname !== "/login") {
      navigate("/login");
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // 第一次进入程序,若是 login 页面,且 token 没过期(code 为 0),自动登录进入 home 页面。使用 useLayoutEffect 可以避免看到先闪一下 login 页面,再跳到 home 页面。
  useLayoutEffect(() => {
    if (location.pathname === "/login" && data?.data.code === 0) {
      navigate("/home");
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [data?.data.code]);

  return useAuthRouters({
    // 传入当前用户的权限
    auth: data?.data.data.auth || [],
    // 若正在获取当前用户,展示 loading
    render: (element) =>
      isFetching ? (
        <div className="flex justify-center items-center h-full">
          <Spin size="large" />
        </div>
      ) : (
        element
      ),
    // 若进入没权限的页面,显示 403 页面
    noAuthElement: () => <NotAuth />,
    routers,
  });
}

export default App;

页面编写

login 页面

html 省略,antd Form 表单账号密码输入框和一个登录按钮

// src/pages/login/index.tsx
const Login: FC = () => {
  const navigate = useNavigate();

  const { mutateAsync: login, isLoading } = useLoginMutation();

  // Form 提交
  const handleFinish = async (values: any) => {
    const { data } = await login(values);
    if (data.code === 0) {
      localStorage.setItem("token", data.token);
      // 请求当前用户
      await queryClient.refetchQueries(currentUserQueryKey);
      navigate("/home")
      message.success("登录成功");
    } else {
      message.error(data.message);
    }
  };

  return ...
};

BasicLayout

BasicLayout 这里简写一下,具体可以看源码。BasicLayout 会接收到 routers,在 routers.tsx 配置的 children 会自动传入 routers,不需要像这样手动传入<BasicLayout routers={[]} />Outlet 相当于 children,是 react-router v6 新增的。将 routers 传入到 Outlet 的 context 中。之后就可以在页面中用 useOutletContext 获取到 routers 了。

// src/layouts
import { Layout } from "antd";
import { Outlet } from "react-router-dom";
import styled from "styled-components";

// 使用 styled-components 覆盖样式
const Header = styled(Layout.Header)`
  height: 48px;
  line-height: 48px;
  padding: 0 16px;
`;

// 同上
const Slider = styled(Layout.Sider)`
  .ant-layout-sider-children {
    display: flex;
    flex-direction: column;
  }
`;

interface BasicLayoutProps {
  routers?: MetaRouterObject[];
}

const BasicLayout: FC<BasicLayoutProps> = ({ routers }) => {
  // 样式省略简写
  return (
    <Layout>
      <Header>
        ...顶部
      </Header>
      <Layout hasSider>
        <Slider>
          ...左侧菜单
        </Slider>
        <Layout>
          <Layout.Content>
            <Outlet context={{ routers }} />
          </Layout.Content>
        </Layout>
      </Layout>
    </Layout>
  );
};

动态菜单栏

把左侧菜单栏单独拆分成一个组件,在 BasicLayout 中引入,传入 routers 参数。

// src/layouts/BasicLayout/components/SliderMenu.tsx
import { Menu } from "antd";
import { FC, useEffect, useState } from "react";
import { useAuthMenus } from "react-router-auth-plus";
import { useNavigate } from "react-router-dom";
import { useLocation } from "react-router-dom";
import { MetaRouterObject } from "../../../router";
import { ItemType } from "antd/lib/menu/hooks/useItems";

// 转化成 antd Menu 组件需要的格式。只有配置了 name 和不隐藏的才展示
const getMenuItems = (routers: MetaRouterObject[]): ItemType[] => {
  const menuItems = routers.reduce((total: ItemType[], router) => {
    if (router.name && !router.hideInMenu) {
      total?.push({
        key: router.path as string,
        icon: router.icon,
        label: router.name,
        children:
          router.children &&
          router.children.length > 0 &&
          !router.hideChildrenInMenu
            ? getMenuItems(router.children)
            : undefined,
      });
    }
    return total;
  }, []);

  return menuItems;
};

interface SlideMenuProps {
  routers: MetaRouterObject[];
}

const SlideMenu: FC<SlideMenuProps> = ({ routers }) => {
  const location = useLocation();
  const navigate = useNavigate();

  const [selectedKeys, setSelectedKeys] = useState<string[]>([]);

  // useAuthMenus 先过滤掉没有权限的路由。再通过 getMenuItems 获得 antd Menu组件需要的格式
  const menuItems = getMenuItems(useAuthMenus(routers));

  // 默认打开的下拉菜单
  const defaultOpenKey = menuItems.find((i) =>
    location.pathname.startsWith(i?.key as string)
  )?.key as string;
  
  // 选中菜单
  useEffect(() => {
    setSelectedKeys([location.pathname]);
  }, [location.pathname]);

  return (
    <Menu
      style={{ borderRightColor: "white" }}
      className="h-full"
      mode="inline"
      selectedKeys={selectedKeys}
      defaultOpenKeys={defaultOpenKey ? [defaultOpenKey] : []}
      items={menuItems}
      {/* 选中菜单回调导航到其路由 */}
      onSelect={({ key }) => navigate(key)}
    />
  );
};

export default SlideMenu;

封装页面通用面包屑

封装一个在 BasicLayout 下全局通用的面包屑。

// src/components/PageBreadcrumb.tsx
import { Breadcrumb } from "antd";
import { FC } from "react";
import {
  Link,
  matchRoutes,
  useLocation,
  useOutletContext,
} from "react-router-dom";
import { MetaRouterObject } from "../router";

const PageBreadcrumb: FC = () => {
  const location = useLocation();
  // 获取在 BasicLayout 中传入的 routers
  const { routers } = useOutletContext<{ routers: MetaRouterObject[] }>();
  // 使用 react-router 的 matchRoutes 方法匹配路由数组
  const match = matchRoutes(routers, location);

  // 处理一下生成面包屑数组
  const breadcrumbs =
    (match || []).reduce((total: MetaRouterObject[], current) => {
      if ((current.route as MetaRouterObject).name) {
        total.push(current.route);
      }
      return total;
    }, []);
  
  // 最后一个面包屑不能点击,前面的都能点击跳转
  return (
    <Breadcrumb>
      {breadcrumbs.map((i, index) => (
        <Breadcrumb.Item key={i.path}>
          {index === breadcrumbs.length - 1 ? (
            i.name
          ) : (
            <Link to={i.path as string}>{i.name}</Link>
          )}
        </Breadcrumb.Item>
      ))}
    </Breadcrumb>
  );
};

export default PageBreadcrumb;

这样就能在页面中引入这个组件使用了,如果你想在每个页面中都使用,可以写在 BasicLayout 的 Content 中,并在 routers 配置中加一个 hideBreadcrumb 选项,通过配置来控制是否在当前路由页面显示面包屑。

function Home() {
  return (
    <div>
      <PageBreadcrumb />
    </div>
  );
}

总结

react 的生态是越来越多样化了,学的东西也越来越多(太卷了)。总的来说,上面所使用的一些库,或多或少都要有所了解。应该都要锻炼自己有具备能搭建一个简易版的后台管理模版的能力。都看到了这里了,觉得还不错的可以点个 star 哦。 github 地址