1. 引言
本文以 umi 为技术底座,完整讲解一个后台管理项目的实战落地过程,覆盖从基本工程搭建、登录注册、菜单权限配置、页面跳转、用户操作埋点,到开发调试与生产构建的完整链路。通过本文,你可以掌握一套可复用的 umi 后台管理项目开发范式。
2. 基本工程搭建
2.1 初始化项目
umi 提供了官方脚手架,可以快速生成一个带路由、布局和权限模型的基础工程。推荐使用 pnpm 作为包管理器,初始化命令如下:
bash
# 使用 pnpm 创建 umi 项目
pnpm create umi
选择需要的功能:antd、dva、qiankun、pro-layout 等
进入项目目录
cd my-admin
安装依赖
pnpm install
启动开发服务
pnpm start
初始化完成后,项目会生成 src、config、public 等目录,其中 config/config.ts 是核心配置文件,负责路由、插件和构建相关的全局配置。
2.2 目录结构规划
一个清晰的后台管理项目目录结构,可以显著降低后续维护成本。推荐按业务模块划分:
text
src/
├── pages/ # 页面组件,按路由组织
├── components/ # 通用业务组件
├── layouts/ # 全局布局
├── services/ # 接口请求层
├── models/ # 全局状态管理
├── utils/ # 工具函数
├── constants/ # 常量定义
└── access.ts # 权限定义
2.3 配置路由与布局
umi 采用约定式路由与配置式路由相结合的方式。后台管理项目通常使用配置式路由,以便统一管理菜单和权限。在 config/routes.ts 中定义路由结构:
typescript
export default [
{
path: '/user',
layout: false,
routes: [
{ path: '/user/login', component: './User/Login' },
{ path: '/user/register', component: './User/Register' },
],
},
{
path: '/',
component: '@/layouts/BasicLayout',
routes: [
{ path: '/dashboard', name: '工作台', icon: 'DashboardOutlined', component: './Dashboard' },
{ path: '/system', name: '系统管理', icon: 'SettingOutlined' },
],
},
];
3. 登录注册
3.1 登录页实现
登录页是后台管理系统的入口。使用 antd 的 Form 组件实现表单校验,配合 ProForm 可以进一步简化开发。核心逻辑包括:表单校验、调用登录接口、存储 Token、跳转首页。
typescript
import { ProForm, ProFormText } from '@ant-design/pro-components';
import { history } from 'umi';
import { login } from '@/services/auth';
const LoginPage = () => {
const handleSubmit = async (values: { username: string; password: string }) => {
const { token } = await login(values);
localStorage.setItem('token', token);
history.push('/dashboard');
};
return (
<ProForm onFinish={handleSubmit}>
<ProFormText name="username" label="用户名" rules={[{ required: true }]} />
<ProFormText.Password name="password" label="密码" rules={[{ required: true }]} />
</ProForm>
);
};
export default LoginPage;
3.2 注册页与验证码
注册页通常需要额外的校验逻辑,例如确认密码、图形验证码或短信验证码。建议将验证码逻辑封装为独立组件,便于复用:
typescript
const CaptchaInput = ({ onSend }: { onSend: (phone: string) => Promise<void> }) => {
const [countdown, setCountdown] = useState(0);
const handleSend = async (phone: string) => {
await onSend(phone);
setCountdown(60);
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer);
return 0;
}
return prev - 1;
});
}, 1000);
};
return (
<Space>
<Input placeholder="手机号" />
<Button disabled={countdown > 0} onClick={() => handleSend('')}>
{countdown > 0 ? ${countdown}s 后重发 : '发送验证码'}
</Button>
</Space>
);
};
3.3 Token 管理与请求拦截
登录成功后,需要统一管理 Token。推荐在 request.ts 中封装请求拦截器,自动携带 Token,并统一处理 401 未授权场景:
typescript
import { request } from 'umi';
import { history } from 'umi';
const authRequest = request.extend({
requestInterceptors: [(url, options) => {
const token = localStorage.getItem('token');
if (token) {
options.headers = { ...options.headers, Authorization: Bearer ${token} };
}
return { url, options };
}],
responseInterceptors: [(response) => {
if (response.status === 401) {
localStorage.removeItem('token');
history.push('/user/login');
}
return response;
}],
});
export default authRequest;
4. 菜单权限配置
4.1 权限模型设计
后台管理系统的权限通常分为菜单权限和操作权限两层。菜单权限决定用户能看到哪些页面,操作权限决定用户能执行哪些按钮操作。推荐使用基于角色的权限模型(RBAC):
- 用户:系统使用者,可关联一个或多个角色。
- 角色:权限的集合,例如管理员、运营、访客。
- 菜单/操作:具体的页面入口或按钮动作。
4.2 动态路由与菜单生成
umi 支持通过 access.ts 定义权限规则,并结合 initialState 实现动态菜单。登录后从接口获取用户权限列表,动态生成菜单:
typescript
// access.ts
export default function access(initialState: { currentUser?: API.CurrentUser }) {
const { currentUser } = initialState || {};
return {
canAdmin: currentUser && currentUser.roles.includes('admin'),
canEdit: currentUser && currentUser.permissions.includes('edit'),
};
}
typescript
// 动态菜单配置
const menuData = [
{ path: '/dashboard', name: '工作台', icon: 'DashboardOutlined' },
{ path: '/system/user', name: '用户管理', icon: 'UserOutlined', access: 'canAdmin' },
{ path: '/system/role', name: '角色管理', icon: 'TeamOutlined', access: 'canAdmin' },
{ path: '/system/menu', name: '菜单管理', icon: 'MenuOutlined', access: 'canAdmin' },
];
4.3 按钮级权限控制
除了菜单权限,后台管理还经常需要控制页面内的按钮是否可见。可以通过自定义 Hook 或高阶组件实现:
typescript
import { useAccess } from 'umi';
const UserTable = () => {
const access = useAccess();
return (
<Table
columns={[
{ title: '用户名', dataIndex: 'username' },
{ title: '操作', render: (_, record) => (
<Space>
{access.canEdit && <Button type="link">编辑</Button>}
{access.canAdmin && <Button type="link" danger>删除</Button>}
</Space>
) }
]}
/>
);
};
5. 页面跳转
5.1 声明式跳转
umi 提供 Link 组件和 history 对象两种跳转方式。声明式跳转适合菜单、面包屑等静态场景:
tsx
import { Link } from 'umi';
const Breadcrumb = () => (
<Link to="/system/user">用户管理</Link>
);
5.2 编程式跳转
在表单提交、按钮点击等交互场景中,通常使用编程式跳转,并支持携带查询参数:
typescript
import { history } from 'umi';
// 跳转到详情页并携带 id
history.push(/system/user/detail?id=${userId});
// 带 state 跳转,适合传递复杂对象
history.push('/system/user/detail', { userId, from: 'list' });
// 返回上一页
history.back();
5.3 路由守卫与重定向
未登录用户访问受保护页面时,需要自动重定向到登录页。umi 的 wrappers 机制可以方便地实现路由守卫。下面给出一个更完整的守卫实现,支持免登录白名单、登录后重定向回原页面,以及全局路由鉴权配置:
typescript
// wrappers/auth.tsx
import { Redirect, useLocation } from 'umi';
import { isLogin } from '@/utils/auth';
// 免登录白名单,这些页面无需登录即可访问
const WHITE_LIST = ['/user/login', '/user/register', '/user/forgot'];
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
const location = useLocation();
// 白名单页面直接放行
if (WHITE_LIST.includes(location.pathname)) {
return <>{children}</>;
}
// 未登录则重定向到登录页,并携带来源路径,登录成功后跳回原页面
if (!isLogin()) {
const redirect = encodeURIComponent(location.pathname + location.search);
return <Redirect to={`/user/login?redirect=${redirect}`} />;
}
return <>{children}</>;
};
export default AuthWrapper;
typescript
// utils/auth.ts
export const TOKEN_KEY = 'token';
export const isLogin = () => Boolean(localStorage.getItem(TOKEN_KEY));
export const getToken = () => localStorage.getItem(TOKEN_KEY);
export const setToken = (token: string) => localStorage.setItem(TOKEN_KEY, token);
export const clearToken = () => localStorage.removeItem(TOKEN_KEY);
typescript
// 路由配置中应用守卫
{
path: '/dashboard',
component: './Dashboard',
wrappers: ['@/wrappers/auth'],
}
登录成功后,根据 redirect 参数跳回原页面,实现免登录重定向闭环:
typescript
// 登录页提交成功后
const { token } = await login(values);
setToken(token);
const redirect = new URLSearchParams(location.search).get('redirect');
history.push(redirect || '/dashboard');
6. 页面用户操作埋点
6.1 埋点方案设计
用户操作埋点用于采集用户在页面上的行为数据,为产品决策提供依据。常见的埋点类型包括:
- 页面浏览:PV/UV、停留时长、来源页面。
- 按钮点击:按钮名称、所在页面、点击次数。
- 表单提交:提交成功率、失败原因。
- 搜索行为:关键词、搜索结果数量。
6.2 统一埋点工具封装
建议封装统一的埋点工具函数,避免在每个页面重复写上报逻辑:
typescript
// utils/tracker.ts
interface TrackEvent {
event: string;
page: string;
params?: Record<string, unknown>;
}
export const track = ({ event, page, params }: TrackEvent) => {
// 上报到埋点服务
fetch('/api/track', {
method: 'POST',
body: JSON.stringify({
event,
page,
params,
timestamp: Date.now(),
userId: localStorage.getItem('userId'),
}),
});
};
6.3 页面浏览埋点
页面浏览埋点可以在路由切换时统一触发。umi 提供了 onRouteChange 配置,可以在路由变化时执行埋点逻辑:
typescript
// app.tsx
import { track } from '@/utils/tracker';
export function onRouteChange({ location }: { location: { pathname: string } }) {
track({
event: 'page_view',
page: location.pathname,
});
}
6.4 按钮点击埋点
按钮点击埋点可以通过封装高阶组件或自定义 Hook 实现,减少业务代码侵入:
typescript
// hooks/useTrack.ts
import { track } from '@/utils/tracker';
export const useTrack = (page: string) => {
const trackClick = (event: string, params?: Record<string, unknown>) => {
track({ event, page, params });
};
return { trackClick };
};
// 页面中使用
const { trackClick } = useTrack('/system/user');
const handleDelete = (id: number) => {
trackClick('user_delete', { id });
// 执行删除逻辑
};
7. 开发调试
7.1 本地开发代理
开发阶段前后端分离,需要配置代理解决跨域问题。在 config/config.ts 中配置 proxy:
typescript
export default {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
pathRewrite: { '^/api': '' },
},
},
};
7.2 调试工具与日志
开发调试时,合理使用浏览器 DevTools 和日志输出可以快速定位问题。建议在 utils/logger.ts 中封装分级日志工具:
typescript
// utils/logger.ts
const isDev = process.env.NODE_ENV === 'development';
export const logger = {
info: (...args: unknown[]) => { if (isDev) console.info('[INFO]', ...args); },
warn: (...args: unknown[]) => { if (isDev) console.warn('[WARN]', ...args); },
error: (...args: unknown[]) => { console.error('[ERROR]', ...args); },
};
7.3 状态调试
使用 dva 或 valtio 等状态管理方案时,可以借助 Redux DevTools 或自定义中间件查看状态变化。推荐在开发环境开启状态日志,方便追踪数据流:
typescript
// models/user.ts
export default {
namespace: 'user',
state: { list: [], loading: false },
reducers: {
save(state, { payload }) {
logger.info('user/save', payload);
return { ...state, ...payload };
},
},
};
8. 开发生产构建
8.1 环境变量管理
不同环境(开发、测试、生产)通常需要不同的接口地址和配置。umi 支持通过 .env 文件管理环境变量:
text
# .env.development
API_BASE_URL=http://localhost:8080/api
.env.production
API_BASE_URL=https://api.example.com/api
typescript
// 代码中读取环境变量
const apiBase = process.env.API_BASE_URL;
8.2 生产构建配置
生产构建需要关注代码压缩、资源分包、CDN 路径等。umi 默认集成了 webpack 优化,但部分场景需要手动调整:
typescript
export default {
define: {
'process.env.API_BASE_URL': process.env.API_BASE_URL,
},
hash: true, // 生成带 hash 的资源文件名
publicPath: process.env.NODE_ENV === 'production' ? 'https://cdn.example.com/' : '/',
chunks: ['vendors', 'umi'],
chainWebpack(config) {
config.optimization.splitChunks({
cacheGroups: {
vendors: {
name: 'vendors',
test: /[\\/]node_modules[\\/]/,
priority: 10,
chunks: 'all',
},
},
});
},
};
8.3 构建与部署
生产构建命令为 pnpm build,构建产物输出到 dist 目录。部署时需要注意:
- 将
dist目录部署到 Nginx 或对象存储。 - 配置 SPA 路