react-admin是一个开箱即用的中大型后台管理系统,不仅只有前端解决方案,更是提供了基于nestjs的后端解决方案。
前端基于vite5、react、react-router、zustand、antd5搭建的中后台管理系统
后端基于nestjs、typeorm、mysql等技术栈
此专栏记录从0-1实现一个完整的中后台管理系统的开发过程
现已完成功能
- [ √] 登录页(三种登录风格可选)
- [ √] 明暗主题切换
- [ √] 国际化
- [ √] 面包屑
- [ √] 多标签页(多种标签风格可选)
- [ √] KeepAlive缓存
- [ √] 图标展示及图标选择组件
- [ √] 全局菜单搜索
后续计划
- 后端项目搭建
- 后端登录实现
- 后端组织、菜单、用户、角色管理开发
1. 创建vite项目
pnpm create vite react-admin --template react-swc-ts
2. 配置路径别名
- 安装
@types/node
pnpm add @types/node -D
- 修改
vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import path from 'node:path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
});
- 修改
tsconfig.json
// tsconfig.json
{
"compilerOptions": {
// ...
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
}
}
3. 添加路由
- 安装
react-router-dom
pnpm add react-router-dom@latest
- 在
src目录下新建pages和router文件夹 - 在
pages目录下新建页面组件home/index.tsx,about/index.tsx
// src/pages/home/index.tsx
const Home = () => {
return <div>Home Page</div>;
};
export default Home;
// src/pages/about/index.tsx
const About = () => {
return <div>About Page</div>;
};
export default About;
- 创建
src/components/LazyLoadComponent/index.tsx
import { Suspense } from 'react';
import AppLoading from '../app/AppLoading';
const LazyLoadComponent = ({
Component,
}: {
Component: React.LazyExoticComponent<() => JSX.Element>;
}) => {
return (
<Suspense fallback={<AppLoading />}>
<Component />
</Suspense>
);
};
export default LazyLoadComponent;
- 在
router目录下创建index.tsx和routes.tsx
// src/router/routes.tsx
import { lazy } from 'react';
import { Navigate } from 'react-router-dom';
import type { RouteObject } from 'react-router-dom';
import LazyLoadComponent from '@/components/LazyLoadComponent';
const routes: RouteObject[] = [
{
path: '/',
children: [
{
index: true,
element: <Navigate to="/home" replace />,
},
{
path: '/home',
element: (
<LazyLoadComponent Component={lazy(() => import('@/pages/home'))} />
),
},
{
path: '/about',
element: (
<LazyLoadComponent Component={lazy(() => import('@/pages/about'))} />
),
},
],
},
];
export default router;
// src/router/index.tsx
import { createBrowserRouter, Navigate } from 'react-router-dom';
import routes from './routes';
const router = createBrowserRouter(routes, {
basename: '/react',
});
export default router;
- 修改src/App.tsx
// src/App.tsx
import { RouterProvider } from 'react-router-dom';
import router from './router';
const App = () => {
return <RouterProvider router={router} />;
};
4. 添加zustand
- 安装
zustand依赖
pnpm add zustand
- 在
src目录下新建store文件夹,添加counter.ts - 在组件中使用
import useCounterStore from '@/store/counter';
const Home = () => {
// const counter = useCounterStore((state) => state.counter)
// const increase = useCounterStore((state) => state.increase);
const { counter, increase } = useCounterStore();
return (
<div>
<div>Home Page</div>
<button onClick={() => increase(1)}>counter: {counter}</button>
</div>
);
};
export default Home;
5. 添加antd
- 安装依赖
antd,dayjs
pnpm add antd
pnpm add dayjs
- 创建
src/store/index.ts
import { create } from 'zustand';
interface GlobalState {
primaryColor: string;
}
const useGloabalState = create<GlobalState>()(() => ({
primaryColor: '#00b96b',
}));
export default useGloabalState;
- 修改
src/App.tsx
import { RouterProvider } from 'react-router-dom';
import { ConfigProvider } from 'antd';
import useGlobalStore from '@/store';
import router from './router';
import dayjs from 'dayjs';
import zhCN from 'antd/locale/zh_CN';
import 'dayjs/locale/zh-cn';
import 'antd/dist/reset.css';
dayjs.locale('zh-cn');
const App = () => {
const { primaryColor } = useGlobalStore();
return (
<ConfigProvider
locale={zhCN}
theme={{
token: {
colorPrimary: primaryColor,
},
}}
>
<RouterProvider router={router} />
</ConfigProvider>
);
};
export default App;
- 在
src/pages/home/index.tsx里引入组件
import { Button } from 'antd';
import useCounterStore from '@/store/counter';
const Home = () => {
// const counter = useCounterStore((state) => state.counter)
// const increase = useCounterStore((state) => state.increase);
const { counter, increase } = useCounterStore();
return (
<div>
<div>Home Page</div>
<button onClick={() => increase(1)}>counter: {counter}</button>
<Button type="primary">Primary</Button>
</div>
);
};
export default Home;
6. 配置环境变量
新建 .env(所有环境生效).env.development(开发环境配置) .env.production(生产环境配置)
- 定义变量 以 VITE_ 为前缀定义变量
# .env
VITE_APP_TITLE = 'react后台管理系统'
VITE_BASE_API_PREFIX = '/api'
# .env.development
# public path
VITE_PUBLIC_PATH = /react-admin
VITE_APP_PORT = 5173
# .env.production
# public path
VITE_PUBLIC_PATH = /react-admin
VITE_APP_PORT = 8000
- 定义类型 修改vite-env.d.ts
/// <reference types="vite/client" />
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_PUBLIC_PATH: string;
readonly VITE_APP_TITLE: string;
readonly VITE_APP_PORT: string;
readonly VITE_BASE_API_PREFIX: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
- 使用变量
替换
src/router/index.tsx中的basename,替换index.html中的title
import { createBrowserRouter } from 'react-router-dom';
import routes from './routes';
const router = createBrowserRouter(routes, {
basename: import.meta.env.VITE_PUBLIC_PATH,
});
export default router;
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>%VITE_APP_TITLE%</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
- 在
vite.config.ts中使用环境变量 修改默认导出为一个函数,使用loadEnv读取环境变量,并使用环境变量
import { ConfigEnv, defineConfig, loadEnv, UserConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import path from 'node:path';
// https://vitejs.dev/config/
export default ({ mode }: ConfigEnv): UserConfig => {
const envConfig = loadEnv(mode, process.cwd());
return defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
base: envConfig.VITE_PUBLIC_PATH,
server: {
port: Number(envConfig.VITE_APP_PORT),
proxy: {
'/api': {
target: 'http://localhost:3000'
changeOrigin: true,
},
},
},
});
};
7. 配置请求
- 安装依赖
pnpm add axios
- 新建
src/utils/axios.ts
import axios from 'axios';
import storage from '@/utils/storage'
const instance = axios.create({
baseURL: import.meta.env.VITE_BASE_API_PREFIX,
headers: {
'Content-Type': 'application/json',
},
});
instance.interceptors.request.use(
(config) => {
// Add authorization token if available
const token = storage.getItem('token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
(error) => {
Promise.reject(error);
},
);
instance.interceptors.response.use(
(response) => {
if (response.status >= 200 && response.status < 300) {
response.data.success = true;
return response.data;
} else {
return response.data;
}
},
(error) => {
if (error.response) {
if (error.response.data) {
error.response.data.success = false;
}
return error.response?.data || error;
}
},
);
- 新建
src/services/user/index.ts、src/services/user/interface.ts、src/types/custom-types.d.ts
// src/types/custom-types.d.ts
export interface IBasicResponse<T> {
error_code: string;
message: string;
data: T;
success: boolean;
}
export interface IPaging {
page_no: number;
page_size: number;
total: number;
}
export interface IPagingResponse<T> extends IBasicResponse<T> {
data: {
paging: IPaging;
data: T[];
};
}
// src/services/user/index.ts
import axios from '@/utils/axios';
import { ILoginParams, ILoginResponse } from './interface';
import { IBasicResponse } from '@/types/custom-types';
export function login(params: ILoginParams) {
return axios<ILoginResponse, IBasicResponse<ILoginResponse>, ILoginParams>(
'/login',
{
method: 'POST',
data: params,
},
);
}
// src/services/user/interface.ts
export interface ILoginParams {
username: string;
password: string;
captcha: string;
}
export interface ILoginResponse {
access_token: string;
refresh_token: string;
}
8. 配置prettier,eslint,stylelint
- 安装依赖
pnpm create @eslint/config@latest
pnpm add @eslint/eslintrc -D
pnpm add eslint-plugin-import -D # eslint import规则
pnpm add prettier -D
## prettier用于格式化代码配合vscode插件,不在eslint中使用
# pnpm add eslint-plugin-prettier -D # eslint prettier
# pnpm add eslint-config-prettier -D # 解决eslint与prettier冲突
- 创建
.prettierrc
{
"endOfLine": "auto",
"printWidth": 120,
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"bracketSpacing": true
}
- 修改
eslint.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { fixupConfigRules } from '@eslint/compat';
import { FlatCompat } from '@eslint/eslintrc';
import pluginJs from '@eslint/js';
// import pluginReactConfig from 'eslint-plugin-react/configs/recommended.js';
import pluginEslintImport from 'eslint-plugin-import';
// import pluginEslintPrettier from 'eslint-plugin-prettier/recommended';
import pluginReactHooks from 'eslint-plugin-react-hooks';
import globals from 'globals';
import tsEslint from 'typescript-eslint';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const flatCompat = new FlatCompat({
baseDirectory: __dirname, // 可选;默认值: process.cwd()
resolvePluginsRelativeTo: __dirname, // 可选
recommendedConfig: pluginJs.configs.recommended, // 默认使用 "eslint:recommended"
});
export default [
{
ignores: ['.idea', '.vscode', '**/dist/'],
files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'],
languageOptions: {
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
// 全局变量
globals: { ...globals.node, ...globals.es2022, ...globals.browser },
},
settings: {
react: {
version: 'detect',
},
},
plugins: {
import: pluginEslintImport,
'react-hooks': pluginReactHooks,
},
},
pluginJs.configs.recommended,
...tsEslint.configs.recommended,
// pluginEslintPrettier, // 更新这里用prettier只做格式化不加入格式检查
// ...fixupConfigRules(pluginReactConfig),
// 因为v9变化较大,为了兼容之前的 config,官方提供了转换整个旧的 config 的方法
...fixupConfigRules(
flatCompat.config({
extends: ['plugin:react/recommended', 'plugin:react/jsx-runtime'],
plugins: ['react', 'react-refresh'],
}),
),
{
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-explicit-any': 'error',
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
// 引入type时使用 type 关键字如: import { type xx } from 'xxx'
'@typescript-eslint/no-import-type-side-effects': 'error',
'@typescript-eslint/consistent-type-imports': 'error',
// 引入type另起一行而不是跟现有的import一起使用如: import type { xx } from 'xx'
'import/consistent-type-specifier-style': ['error', 'prefer-top-level'],
//import导入顺序规则
'import/order': [
'error',
{
//按照分组顺序进行排序
groups: [
'builtin',
'external',
'parent',
'sibling',
'index',
'internal',
'object',
'type',
],
//通过路径自定义分组
pathGroups: [
{
pattern: 'react*', //对含react的包进行匹配
group: 'builtin', //将其定义为builtin模块
position: 'before', //定义在builtin模块中的优先级
},
{
pattern: '@/components/**',
group: 'parent',
position: 'before',
},
{
pattern: '@/utils/**',
group: 'parent',
position: 'after',
},
{
pattern: '@/services/**',
group: 'parent',
position: 'after',
},
],
//将react包不进行排序,并放在前排,可以保证react包放在第一行
pathGroupsExcludedImportTypes: ['react'],
'newlines-between': 'always', //每个分组之间换行
//根据字母顺序对每个组内的顺序进行排序
alphabetize: {
order: 'asc',
caseInsensitive: true,
},
},
],
},
},
];
- 新建
.prettierignore
# 忽略格式化文件 (根据项目需要自行添加)
node_modules
dist
- 配置
stylelint
- 安装依赖
pnpm create stylelint
pnpm add stylelint-config-prettier -D
- 修改
.stylelintrc.json
{ "extends": ["stylelint-config-standard", "stylelint-config-prettier"] }
- vscode保存自动格式化
{
"workbench.iconTheme": "material-icon-theme",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"files.autoSave": "onFocusChange",
"eslint.codeActionsOnSave.rules": null,
"editor.tabSize": 2,
"editor.fontSize": 14,
"editor.formatOnSave": true,
"eslint.validate": [
"javascript",
"typescript",
"vue",
"typescriptreact",
"javascriptreact"
],
"stylelint.validate": ["css", "less", "scss", "vue"],
"editor.codeActionsOnSave": {
"source.fixAll": "always",
"source.fixAll.eslint": "always",
"source.fixAll.stylelint": "always"
},
"files.exclude": {
"**/.git": false
},
"prettier.useEditorConfig": false,
"eslint.useFlatConfig": true,
"eslint.workingDirectories": ["eslint.config.js"],
"stylelint.packageManager": "pnpm",
"stylelint.config": {}
}
9. 配置husky、lint-staged、@commitlint/cli
husky:一个为git客户端增加hook的工具
lint-staged:仅对git代码暂存区文件进行处理,配合husky使用
@commitlint/cli:让commit信息规范化
- 安装依赖
pnpm add husky -D
pnpm add lint-staged -D
pnpm add @commitlint/cli -D
pnpm add @commitlint/config-conventional -D
- 配置
husky
# 生成 .husky 的文件夹
pnpm exec husky init
# 添加 hooks,会在 .husky 目录下生成一个 pre-commit 脚本文件
echo "npx --no-install lint-staged" > .husky/pre-commit
# 添加 commit-msg
echo 'npx --no-install commitlint --edit "$1"' > .husky/commit-msg
- 修改
package.json
"lint-staged": {
"**/src/*.{js,jsx,ts,tsx}": [
"eslint --fix"
],
"*.{js,jsx,,md,json}": [
"prettier --write"
],
"*.ts?(x)": [
"prettier --parser=typescript --write"
],
"*.css": [
"stylelint --fix"
]
},
- 新建
commitlint.config.cjs
module.exports = {
extends: ['@commitlint/config-conventional'],
};
提交格式:
git commit -m <type>[optional scope]: <description> # 注意冒号后面有空格
- type:提交的类型(如新增、修改、更新等)
- optional scope:涉及的模块,可选
- description:任务描述
type类型:
| 类别 | 含义 |
|---|---|
| feat | 新功能 |
| fix | 修复 bug |
| style | 样式修改(UI校验) |
| docs | 文档更新 |
| refactor | 重构代码(既没有新增功能,也没有修复 bug) |
| perf | 优化相关,比如提升性能、体验 |
| test | 增加测试,包括单元测试、集成测试等 |
| build | 构建系统或外部依赖项的更改 |
| ci | 自动化流程配置或脚本修改 |
| revert | 回退某个commit提交 |
| chore | 杂项 |
- 示范(非规范提交,将提交失败)
git commit -m 'feat: 增加登录功能' # 提交成功
git commit -m '修复登录失败的bug' # 将会提交失败
10. 配置unocss、添加ahooks
unocss 原子化css,ahooks一个好用的react hooks库
- 安装依赖
pnpm add unocss -D
pnpm add ahooks
- 在vite.config.ts里面引入
import UnoCSS from 'unocss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [UnoCSS()],
});
- 创建
src/uno.config.ts
import {
defineConfig,
presetUno,
presetIcons,
presetAttributify,
presetTypography,
presetWebFonts,
transformerDirectives,
transformerVariantGroup,
} from 'unocss';
export default defineConfig({
presets: [
// 默认预设
presetUno({ dark: 'class' }),
// 支持attributify mode,简单说就是为了避免样式写太长难维护,能将py-2 px-2这种相关属性整合起来写成p="y-2 x-4"
presetAttributify(),
presetIcons(),
presetTypography(),
presetWebFonts(),
],
transformers: [transformerDirectives(), transformerVariantGroup()],
// 自定义规则
rules: [
['size-full', { width: '100%', height: '100%' }],
[
'flex-center',
{ display: 'flex', 'align-items': 'center', 'justify-content': 'center' },
],
],
});
- 在
src/main.tsx引入unocss
import React from 'react';
import ReactDOM from 'react-dom/client';
import 'virtual:uno.css';
import App from './App.tsx';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
- 在
src/pages/home/index.tsx使用unocss和ahooks
import { useCountDown } from 'ahooks';
import { Button } from 'antd';
import useCounterStore from '@/store/counter';
const Home = () => {
// const counter = useCounterStore((state) => state.counter)
// const increase = useCounterStore((state) => state.increase);
const { counter, increase } = useCounterStore();
const [, formattedRes] = useCountDown({
targetDate: `${new Date().getFullYear()}-12-31 23:59:59`,
});
const { days, hours, minutes, seconds, milliseconds } = formattedRes;
return (
<div>
<div className="text-blue">Home Page</div>
<button onClick={() => increase(1)}>counter: {counter}</button>
<Button type="primary">Primary</Button>
<p>
There are {days} days {hours} hours {minutes} minutes {seconds} seconds{' '}
{milliseconds} milliseconds until {new Date().getFullYear()}-12-31
23:59:59
</p>
</div>
);
};
export default Home;
11. 配置css
使用lightningcss来解析打包css,使用原生css,嵌套使用postcss-nesting插件
pnpm add lightningcss -D
pnpm add browserslist -D
pnpm add postcss-nest -D
修改vite.config.ts
import browserslist from 'browserslist';
import { browserslistToTargets } from 'lightningcss';
import postcssNesting from 'postcss-nesting';
{
css: {
transformer: 'lightningcss',
lightningcss: {
targets: browserslistToTargets(browserslist('>= 0.25%')),
},
postcss: {
plugins: [postcssNesting],
},
},
build: {
cssCodeSplit: true,
cssMinify: 'lightningcss',
}
}
12. 配置svg
- 安装依赖
pnpm add vite-plugin-svgr
- 配置vite.config.ts
import svgr from 'vite-plugin-svgr'; // 新增
export default defineConfig({
plugins: [
react(),
svgr(), // 新增
],
// ...
});
- 配置tsconfig.app.json
{
"compilerOptions": {
// ...
"types": ["vite-plugin-svgr/client", "node"]
}
}
- 引用
方式一:携带后缀 ?react
import GoogleSvg from '@/assets/svg/google.svg?react'; // !不要遗漏 ?react
const Login = () => {
return (
<div>
<GoogleSvg />
</div>
);
};
export default Login;
方式二:如果你不想要每次编写后缀,你需要修改 vite.config.ts 配置,确保在使用 svgr 插件时指定了 include 字段。如下:
svgr({ include: 'src/assets/icons/*.svg' });
13. 打包配置
- 分包,修改
vite.config.ts
build: {
rollupOptions: {
output: {
manualChunks: {
react: ['react', 'react-dom', 'react-router-dom'],
lib: ['zustand']
antd: ['antd'],
},
},
},
},
- 生成
.gz文件
- 安装依赖
pnpm add vite-plugin-compression -D
- 修改
vite.config.ts默认情况下插件在开发 (serve) 和生产 (build) 模式中都会调用,使用apply属性指明它们仅在build或serve模式时调用
import viteCompression from 'vite-plugin-compression'
plugins: [
//...
{
...viteCompression(),
apply: 'build',
},
],
js和css文件分离
build: {
rollupOptions: {
output: {
chunkFileNames: "static/js/[name]-[hash].js",
entryFileNames: "static/js/[name]-[hash].js",
assetFileNames: "static/[ext]/[name]-[hash].[ext]",
},
},
},
- 分析生成包的大小
- 安装依赖
pnpm add rollup-plugin-visualizer -D
- 修改
vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
plugins: [
//...
visualizer({ open: true }),
],
最后项目地址: github.com/fsj930210/r…