后端使用Ant Design Pro开发记录

2,067 阅读13分钟

创建工程

我们可以直接下载代码压缩包,也可以使用使用如下命令下载代码:

 git clone https://github.com/ant-design/ant-design-pro.git 

当然,我们也可以使用pro命令创建工程:

 pro create szhome-admin-react

安装依赖

我这里使用pnpm命令安装依赖:

 pnpm install

正常此时我们可以使用如下命令运行工程:

 pnpm run dev

然后我们就可以根据终端显示的地址访问工程,通常是http://localhost:8000

基本改造

启用接口Mock

前面我们使用pnpm run dev启动工程,此时并没有启用Ant Design Pro提供的接口Mock能力。如果我们没有配置自己的接口地址,可能连登录都登录不了。

此时我们可以打开工程根目录下的package.json文件,找到scripts配置,可以看到pnpm run dev等价于pnpm run start:dev,而继续找到start:dev可以发现,它指定了MOCK=none,即不启用Mock能力。

现在我们知道pnpm run dev命令无法启用Mock能力,我们就可以在scripts中找到对应启用了Mock能力的命令:

 pnpm run start

配置主题

正常情况下你可以在工程启动后访问页面,并在页面右侧中央位置看到一个设置按钮。点击按钮,即可配置主题风格。Ant Design Pro支持实时预览,待主题风格调整满意后,即可点击最下方的拷贝设置按钮,此时你将得到一份主题风格的配置JSON。

回到源码,找到工程目录下的config/defaultSettings.ts,替换其中对应的配置,即可完成默认风格的设置。

主题配置入口

当你想要配置主题时,可能你找不到这个配置入口。这是因为高版本中默认好像把这个配置入口的按钮给去掉了,但源码还在,只是注释掉了。

又或者你不希望用户可以调整布局主题风格,你也可以主动把这个配置入口屏蔽掉。

你可以在src/app.tsx文件中找到childrenRender配置项,删除SettingDrawer组件即可隐藏主题风格配置的入口,同样放开注释即可放出主题风格配置的入口。

支持国际化

Ant Design Pro支持国际化,但有很多我们可能根本用不到的语言。只需要删除对应的目录,语言切换菜单即会更新为剩余的语言列表。国际化配置相关的文件在工程根目录下的src/locales目录中,删除不需要的语言的ts文件以及目录即可。

通常我会保留以下几种语言:

  • zh-CN:简体中文;
  • zh-TW:繁体中文;
  • en-US:英语;
  • ja-JP:日语。

如果我们把所有的国际化配置都写在src/locales目录下的文件中,则略显臃肿,且条理不清晰。我们还可以再src/pages目录下的各个组件目录创建locales子目录,并添加对应语言的ts文件,其内容即可自动被国际化组件识别。比如在src/pages/user目录下创建locales目录,并创建zh-CN.ts文件,文件内容按照国际化配置编写,如:

 export default {
   'pages.user.btn.add': '新增用户',
 };

如此即可在src/pages/user目录下的各个组件页面使用pages.user.btn.add国际化编码。

防截屏水印

Ant Design Pro默认情况下会给页面背景加一个水印,水印文本是当前登录用户的用户名,做了一定程度的倾斜。

但是也许你不想要水印,尤其是开发过程中,就是看水印不顺眼。这个水印的配置在工程根目录下的src/app.tsx文件中,你可以搜索waterMarkProps关键字,注释掉它的唯一一行content: initialState?.currentUser?.name,刷新页面就可以看到水印已经不见了。

OpenAPI文档链接

左侧菜单中有一个OpenAPI文档的菜单,它固定于左侧边栏的下方,如果你不想要这个菜单,也可以在src/app.tsx文件中layout配置项的links配置,置空即可去掉此链接。

多页签

Ant Design Pro V6版本终于更新了多页签功能的支持,而且使用方法特别简单。你只需要在config/config.ts中的配置加上如下配置即使用多页签:

 keepalive: [/./],
 tabsLayout: {
     hasDropdown: true,
     hasFixedHeader: true
 },

嗯,就是这么简单。

至于效果嘛,我觉得不够美观或者简洁。你也可以在src/global.less中调整页签的样式。当然,有了页签之后,页面的面包屑和标题可能会有人觉得多余,你也可以使用如下方式去掉:

 <PageContainer breadcrumb={{}} title={false}>
     ...
 </PageContainer>

这种方式需要在每个页面都加上这样的配置,比较麻烦。任何问题我们总是应该想一些比较简单的、全局性的方案,比如在src/global.tsx中加入下面这样的代码:

 PageContainer.defaultProps = {
   breadcrumb: {},
   title: false,
   ghost: true,
 }

注意,defaultProps这是个已标记过期的属性,通过VSCode点击可以看到相关说明。如果你有时间,可以仔细阅读说明文档,修改为最新的实现方式。

使用自己的服务端接口

停用Mock

正如前面提到的如何启用Mock功能,反之,使用pnpm run dev就可以以不使用Mock功能的方式启动工程。或者明确指定停用Mock:

 pnpm run start:no-mock
 ## 或
 pnpm run start MOCK=none

声明代理

停用Mock后,我们需要指定自己的服务端接口地址,首先是config/proxy.ts中的代理配置,将dev环境的配置放开:

 dev: {
   // localhost:8000/api/** -> http://localhost:8080
   '/api/': {
     // 要代理的地址
     target: 'http://localhost:8080',
     // 配置了这个可以从 http 代理到 https
     // 依赖 origin 的功能可能需要这个,比如 cookie
     changeOrigin: true,
     pathRewrite: {'/api': ''}
   },
 },

如上,这意味着前端工程在请求后端localhost:8000/api/**接口时,实际上后端提供的是localhost:8080/**接口。

修改baseURL

最后需要指定Ant Design Pro请求后端接口的基础路径,找到src/app.tsx,拉倒最后几行代码,可以看到RequestConfig配置项,将baseURL修改为你的前端工程访问地址。如:

 export const request: RequestConfig = {
   // baseURL: 'https://proapi.azurewebsites.net',
   baseURL: 'http://localhost:8080',
   ...errorConfig,
 };

请求示例

现在我们就可以编写请求后端接口的方法了:

 import { request } from '@umijs/max';
 
 export async function detail(id: Number | string) {
   return request(`/api/user/${id}`, {
     method: 'GET'
   });
 }

如上,我们在请求后端接口时只是使用了/user/${id},实际上Ant Design Pro根据配置 ,首先会将baseURL补上,即请求http://localhost:8080/api/user/${id},然后再根据代理配置,实际请求的接口地址则是http://localhost:8080/user/${id}

接口响应数据结构

接口响应定义通常有着一致数据结构,Ant Design Pro的要求的后端接口响应数据结构如下:

 // 1. 请求处理失败
 {
     success: false,
     error: "5001",
     message: "请求参数错误"
 }
 ​
 // 2. 请求处理成功,返回数组,无分页
 {
     success: true,
     data: [{
         name: "admin"
     }]
 }
 ​
 // 3. 请求处理成功,返回对象
 {
     success: true,
     data: {
         name: "admin"
     }
 }
 ​
 // 4. 请求处理成功,分页
 {
     success: true,
     data: [{
         name: "admin"
     }],
     total: 32,
     pageSize: 10,
     current: 1
 }

接口改造

登录

登录是使用Ant Design Pro的第一步,登录页实现在src/pages/user/login目录。在该目录的index.tsx文件中,你可以找到handleSubmit方法,这里是处理登录的逻辑。

 const handleSubmit = async (values: API.LoginParams) => {
   try {
     // 登录
     const msg = await login({ ...values, type });
     if (msg.status === 'ok') {
       const defaultLoginSuccessMessage = intl.formatMessage({
         id: 'pages.login.success',
         defaultMessage: '登录成功!',
       });
       message.success(defaultLoginSuccessMessage);
       await fetchUserInfo();
       const urlParams = new URL(window.location.href).searchParams;
       window.location.href = urlParams.get('redirect') || '/';
       return;
     }
     console.log(msg);
     // 如果失败去设置用户错误信息
     setUserLoginState(msg);
   } catch (error) {
     const defaultLoginFailureMessage = intl.formatMessage({
       id: 'pages.login.failure',
       defaultMessage: '登录失败,请重试!',
     });
     console.log(error);
     message.error(defaultLoginFailureMessage);
   }
 };

从上面的代码中可以看出来,它首先请求了login方法,如果该方法返回值的status属性值为ok,表示登录成功。登录成功时先提示了消息,然后继续请求fetchUserInfo方法加载当前登录用户信息,然后跳转页面。如果登录失败,则设置用户登录失败的状态。这期间如果发生异常,也会给出提示消息。

再来看login方法,它定义在src/services/ant-design-pro/api.ts文件中:

 export async function login(body: API.LoginParams, options?: { [key: string]: any }) {
   return request<API.LoginResult>('/api/login/account', {
     method: 'POST',
     headers: {
       'Content-Type': 'application/json',
     },
     data: body,
     ...(options || {}),
   });
 }

可以看到它就是简单的请求了/api/login/account这样一个方法。因此当我们要使用自己的服务端接口时,我们需要定义一个登录方法,响应结果和/api/login/account一样即可。你可以通过F12查看开启Mock情况下的该接口返回结构,也可以在mock/user.ts文件中找到该接口的响应定义。

然后是fetchUserInfo方法,这个方法就定义在登录页中:

 const { initialState, setInitialState } = useModel('@@initialState');
 ​
 // ...
 ​
 const fetchUserInfo = async () => {
   const userInfo = await initialState?.fetchUserInfo?.();
   if (userInfo) {
     flushSync(() => {
       setInitialState((s) => ({
         ...s,
         currentUser: userInfo,
       }));
     });
   }
 };

要想理解这个方法,务必先看官方文档:全局初始数据,否则可能会云里雾里,搞不清楚这些变量、方法都是哪里来的,在干什么。而一旦阅读完这篇篇幅极短的文档后,你就会豁然开朗,原来这里兜了个圈子,真正加载当前登录用户信息的实现在src/app.tsx文件中的getInitialState方法中。

 export async function getInitialState(): Promise<{
   settings?: Partial<LayoutSettings>;
   currentUser?: API.CurrentUser;
   loading?: boolean;
   fetchUserInfo?: () => Promise<API.CurrentUser | undefined>;
 }> {
   const fetchUserInfo = async () => {
     try {
       const msg = await queryCurrentUser({
         skipErrorHandler: true,
       });
       return msg.data;
     } catch (error) {
       history.push(loginPath);
     }
     return undefined;
   };
   // 如果不是登录页面,执行
   const { location } = history;
   if (![loginPath, '/user/register', '/user/register-result'].includes(location.pathname)) {
     const currentUser = await fetchUserInfo();
     return {
       fetchUserInfo,
       currentUser,
       settings: defaultSettings as Partial<LayoutSettings>,
     };
   }
   return {
     fetchUserInfo,
     settings: defaultSettings as Partial<LayoutSettings>,
   };
 }

这里显示检测是否为登录、注册、注册结果页,如果都不是,则需要调用queryCurrentUser方法查询当前登录用户信息,然后将结果返回,继而在登录页的fetchUserInfo方法将返回的用户信息写入到全局数据中。

这里有个意外之喜,那就是我们可以看到这里的全局数据中有一个叫setting的,它的取值来自config/defaultSetting.ts,是配置页面主题风格的。那么如果我们对这一块逻辑进行修改,从后端返回每个人的风格配置,是不是就可以实现每个人不同的页面风格的能力。

所以,真是的登录用户信息还得看queryCurrentUser方法的实现,在src/services/ant-design-pro/api.ts中:

 export async function currentUser(options?: { [key: string]: any }) {
   return request<{
     data: API.CurrentUser;
   }>('/api/currentUser', {
     method: 'GET',
     ...(options || {}),
   });
 }

然后我们就看到这个方法仅仅是调用后端/api/currentUser接口,它的响应结果所包含的字段,可以查看API.CurrentUser类型,该类型的定义在src/services/ant-design-pro/typings.d.ts中:

 declare namespace API {
   type CurrentUser = {
     name?: string;
     avatar?: string;
     userid?: string;
     email?: string;
     signature?: string;
     title?: string;
     group?: string;
     tags?: { key?: string; label?: string }[];
     notifyCount?: number;
     unreadCount?: number;
     country?: string;
     access?: string;
     geographic?: {
       province?: { label?: string; key?: string };
       city?: { label?: string; key?: string };
     };
     address?: string;
     phone?: string;
   };
 }

嗯,That's all。当然,如果你喜欢,你完全可以自己写一个登录页,毕竟Ant Design Pro的登录页也不太好看。

请求拦截器

当你改写登录接口时也许不会想到需要拦截所有的请求,一旦登录成功后,你就会有在所有请求发起前往Header中加入token的需求。

我们可以给我们所使用的reqeust加上一个拦截器,它的相关配置在src/requestErrorConfig.ts中。在这个文件中搜索requestInterceptors,你会看到这里已经有一个拦截器,它给所有的接口请求都加了参数?token=123。这是一个示例,我们把它移除,加上我们自己的拦截器逻辑即可:

 // 请求拦截器
 requestInterceptors: [
   (url: string, options: RequestConfig) => {
     let tokens: any = {'Trace-id': uuidv4()};
 ​
     if(url !== '/system/login' && url !== '/system/captcha'){
       const token = localStorage.getItem('token');
       tokens['Authorization'] = 'Bearer ' + token;
     }
 ​
     return {
       url,
       options: { ...options, interceptors: true, headers: tokens },
     };
   }
 ],

如上, 我在拦截器实现了在每个请求的请求头中放入了AuthorizationTrace-id头信息的功能。这样后端就可以完成认证以及日志追踪了。

数组参数

在设计分页查询接口时,后端通常会使用List<T>来接收前端的数组参数,而分页查询接口中很多状态、枚举类的字段通常也会使用下拉框多选、传递数组的方式实现多值匹配的目的。

比如后端参数如下,它期望接收多个状态值:

@Data
public class UserQry {
    @Schema(description = "状态,支持范围查询")
    private List<String> status;
}

此时我们在前端可能像下面这样定义请求参数类型:

type Param = {
    status?: string[];
} & Page;

然后我们点击查询按钮,可以看到前端发起的请求是/system/user?current=1&status[]=1&status[]=3,而这种传参后端如果要处理,可能需要费些力气。尤其是如果你之前使用的Umi@3而现在升级到Umi@4,后端可能会问你:“前面都好好的,为什么现在传参变了?”

实际上这就是Umi版本的问题:

Umi@3 默认会用相同的 Key 来序列化数组。Umi@4 请求基于 axios,默认是带括号 [] 的形式序列化。

两者之间在处理数组参数时的差异如下:

// Umi@3
import { useRequest } from 'umi';
// a: [1,2,3] => a=1&a=2&a=3


// Umi@4
import { useRequest } from '@umijs/max';
// a: [1,2,3] => a[]=1&a[]=2&a[]=3

所以,简单的解决方案就是让Umi@4保持和Umi@3一样的行为,你可以在src/app.tsx中加入如下配置:

// 你可能需要安装query-string
import queryString from 'query-string';

export const request: RequestConfig = {
  baseURL: 'https://proapi.azurewebsites.net',
  paramsSerializer(params) {
    // 序列化参数
    return queryString.stringify(params);
  },
  ...errorConfig,
};

这个问题困扰我很久,在看到这个文档之前,我都是在请求接口前自行把数组类型的参数序列化为逗号分隔,像这样:

params: {
  ...params,
  status: params.status?.join(','),
},

问题虽然解决,但略显蠢了些。每个有数组参数的接口都要这么干,一个接口有多个数组需要干多次,希望大家不要和我一样走这种弯路。

在接口请求方面,Umi@与Umi@4有着比较大的差异,主要是因为Umi@4弃用了umi-request而改用axios。你或许也应该看看这个文档:umi@3 到 umi@4

退出登录

由于我自定义了登录页面,使用完全不同的登录接口,甚至数据结构都有变化。登录虽然成功了,但登出却出现了问题。因此我还需要改造退出登录的功能。

退出登录的实现在src/components/RightContent/AvatarDropdown.tsx中,可以看到这个文件中有一个loginOut方法,只需要修改此方法,即可完成退出登录的改造。这个可能需要结合你自己的登录实现来做调整,比如你在登录时保存了token,则在登出时将token清除即可。

常见问题

Absolute route path "/*" nested under path "/user" is not valid

登录成功后可能会看到白屏,F12打开开发者工具,会看到如下报错:

image-20240522174918050

解决方法:找到工程根目录下的config/routes.ts,把/user路由下的404路由删掉即可。

export 'useSyncExternalStore' (imported as 'React2') was not found in 'react'

如下图,工程编译通过,但访问页面时报如下错误:

image-20240522172826870

export 'useSyncExternalStore' (imported as 'React2') was not found in 'react'为关键字搜索解决方案,在Stackoverflow上找到如下解决方案:

 npm install react@latest react-dom@latest
 npm install -d @types/react@latest @types/react-dom@latest

根本原因就是React版本低了,升级到React 18,问题解决。