347 lines
11 KiB
Markdown
347 lines
11 KiB
Markdown
# API 层开发
|
||
|
||
:::tip 说明
|
||
前端 API 层位于 `src/api/` 目录,封装所有与后端的 HTTP 请求。底层基于 Axios 封装了 `VAxios` 类(位于 `src/utils/http/axios/`),提供请求拦截、响应解包、错误处理、重复请求取消等能力。
|
||
:::
|
||
|
||
## 目录结构
|
||
|
||
```
|
||
src/api/
|
||
├── common/
|
||
│ ├── user.ts # 登录、登出、获取用户信息、验证码
|
||
│ └── menu.ts # 获取菜单(adminMenus)
|
||
├── system/
|
||
│ ├── position.ts # 岗位管理接口
|
||
│ ├── user.ts # 用户管理接口
|
||
│ ├── role.ts # 角色管理接口
|
||
│ ├── menu.ts # 菜单管理接口
|
||
│ ├── dept.ts # 部门管理接口
|
||
│ ├── level.ts # 职级管理接口
|
||
│ ├── dictionary.ts # 字典管理接口
|
||
│ ├── loginLog.ts # 登录日志接口
|
||
│ ├── operLog.ts # 操作日志接口
|
||
│ └── tenant.ts # 租户管理接口
|
||
└── ...
|
||
```
|
||
|
||
## Axios 封装架构
|
||
|
||
的 HTTP 层不是简单的 Axios 实例,而是一套完整的封装体系:
|
||
|
||
```
|
||
src/utils/http/axios/
|
||
├── index.ts # 入口:创建 VAxios 实例,定义 transform 处理逻辑
|
||
├── Axios.ts # VAxios 类:封装 AxiosInstance,管理拦截器
|
||
├── axiosTransform.ts # AxiosTransform 抽象类:定义拦截器钩子接口
|
||
├── axiosCancel.ts # AxiosCanceler:重复请求取消(基于 CancelToken Map)
|
||
├── checkStatus.ts # HTTP 状态码错误提示
|
||
├── helper.ts # 工具函数(时间戳追加、日期格式化)
|
||
└── types.ts # 类型定义(RequestOptions、Result、CreateAxiosOptions)
|
||
```
|
||
|
||
### VAxios 类
|
||
|
||
`Axios.ts` 中的 `VAxios` 类是对 `AxiosInstance` 的封装,核心能力:
|
||
|
||
```typescript
|
||
// src/utils/http/axios/Axios.ts
|
||
export class VAxios {
|
||
private axiosInstance: AxiosInstance;
|
||
private options: CreateAxiosOptions;
|
||
|
||
constructor(options: CreateAxiosOptions) {
|
||
this.options = options;
|
||
this.axiosInstance = axios.create(options);
|
||
this.setupInterceptors(); // 自动注册拦截器
|
||
}
|
||
|
||
// 通用请求方法
|
||
request<T = any>(config: AxiosRequestConfig, options?: RequestOptions): Promise<T> {
|
||
let conf: AxiosRequestConfig = cloneDeep(config);
|
||
const transform = this.getTransform();
|
||
const opt: RequestOptions = Object.assign({}, requestOptions, options);
|
||
const { beforeRequestHook, transformRequestData } = transform || {};
|
||
|
||
if (beforeRequestHook) conf = beforeRequestHook(conf, opt);
|
||
conf.requestOptions = opt;
|
||
|
||
return new Promise((resolve, reject) => {
|
||
this.axiosInstance.request<any, AxiosResponse<Result>>(conf)
|
||
.then((res) => {
|
||
if (transformRequestData && !axios.isCancel(res)) {
|
||
const ret = transformRequestData(res, opt);
|
||
resolve(ret);
|
||
}
|
||
})
|
||
.catch((e) => reject(e));
|
||
});
|
||
}
|
||
|
||
// 文件上传
|
||
uploadFile<T = any>(config: AxiosRequestConfig, params: UploadFileParams) {
|
||
const formData = new window.FormData();
|
||
formData.append(params.name || 'file', params.file);
|
||
|
||
return this.axiosInstance.request<T>({
|
||
method: 'POST',
|
||
data: formData,
|
||
headers: { 'Content-type': ContentTypeEnum.FORM_DATA },
|
||
...config,
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
### 全局实例
|
||
|
||
`index.ts` 导出全局单例 `http`,所有 API 函数使用此实例:
|
||
|
||
```typescript
|
||
// src/utils/http/axios/index.ts
|
||
export const http = createAxios();
|
||
```
|
||
|
||
## API 定义示例
|
||
|
||
以岗位管理为例(`src/api/system/position.ts`),实际代码如下:
|
||
|
||
```typescript
|
||
import { http } from '@/utils/http/axios';
|
||
|
||
// 分页查询岗位列表
|
||
export function getPositionPage(params?) {
|
||
return http.request({
|
||
url: '/position/page',
|
||
method: 'GET',
|
||
params,
|
||
});
|
||
}
|
||
|
||
// 获取全部岗位列表(无分页,用于下拉选择)
|
||
export function getPositionList(params?) {
|
||
return http.request({
|
||
url: '/position/list',
|
||
method: 'GET',
|
||
params,
|
||
});
|
||
}
|
||
|
||
// 根据 ID 获取详情
|
||
export function getPositionDetail(positionId) {
|
||
return http.request({
|
||
url: '/position/detail/' + positionId,
|
||
method: 'get',
|
||
});
|
||
}
|
||
|
||
// 添加岗位
|
||
export function positionAdd(data: any) {
|
||
return http.request({
|
||
url: '/position/add',
|
||
method: 'POST',
|
||
data,
|
||
});
|
||
}
|
||
|
||
// 更新岗位
|
||
export function positionUpdate(data: any) {
|
||
return http.request({
|
||
url: '/position/update',
|
||
method: 'PUT',
|
||
data,
|
||
});
|
||
}
|
||
|
||
// 删除岗位
|
||
export function positionDelete(positionId) {
|
||
return http.request({
|
||
url: '/position/delete/' + positionId,
|
||
method: 'DELETE',
|
||
});
|
||
}
|
||
|
||
// 批量删除岗位
|
||
export function positionBatchDelete(data: any) {
|
||
return http.request({
|
||
url: '/position/batchDelete',
|
||
method: 'DELETE',
|
||
data,
|
||
});
|
||
}
|
||
```
|
||
|
||
:::tip API 路径说明
|
||
API 函数中的 `url` 不需要写 `/api/v1` 前缀。`VAxios` 创建时配置了 `prefixUrl` 和 `requestOptions.urlPrefix`,拦截器的 `beforeRequestHook` 会自动拼接前缀。最终请求路径为 `/api/v1/position/page`。
|
||
:::
|
||
|
||
## 请求拦截器
|
||
|
||
请求拦截器在 `index.ts` 的 `transform` 中定义,自动注入 Token:
|
||
|
||
```typescript
|
||
requestInterceptors: (config, options) => {
|
||
const token = storage.get('ACCESS-TOKEN', '');
|
||
if (token && config.requestOptions?.withToken !== false) {
|
||
(config as Recordable).headers.Authorization = options.authenticationScheme
|
||
? `${options.authenticationScheme} ${token}`
|
||
: token;
|
||
}
|
||
return config;
|
||
},
|
||
```
|
||
|
||
Token 存储在 `localStorage` 中,键名为 `ACCESS-TOKEN`,通过 `storage` 工具类管理(支持 7 天过期)。
|
||
|
||
## 响应拦截器
|
||
|
||
响应解包逻辑在 `transformRequestData` 中处理:
|
||
|
||
```typescript
|
||
transformRequestData: (res: AxiosResponse<Result>, options: RequestOptions) => {
|
||
const { code, data, msg } = res.data;
|
||
|
||
// 业务成功(code === 0)
|
||
if (code === ResultEnum.SUCCESS) {
|
||
return data; // 直接返回 data 字段,调用方拿到的是业务数据
|
||
}
|
||
|
||
// 业务失败(code === 1)
|
||
if (code === ResultEnum.ERROR) {
|
||
const message = useMessage();
|
||
message.error(msg);
|
||
throw new Error(msg);
|
||
}
|
||
|
||
// 登录超时(code === 401)
|
||
if (code === ResultEnum.TIMEOUT) {
|
||
const dialog = useDialog();
|
||
dialog.warning({
|
||
title: '提示',
|
||
content: '登录身份已失效,请重新登录!',
|
||
positiveText: '确定',
|
||
negativeText: '取消',
|
||
onPositiveClick: () => {
|
||
storage.clear();
|
||
window.location.href = '/login';
|
||
},
|
||
});
|
||
throw new Error(msg);
|
||
}
|
||
},
|
||
```
|
||
|
||
解包后的返回值约定:
|
||
|
||
| 接口类型 | 返回值结构 | 说明 |
|
||
|----------|-----------|------|
|
||
| 普通接口 | `data`(直接返回业务数据) | 拦截器已解包 `res.data.data` |
|
||
| 分页接口 | `{ data: [...], count: 100 }` | 后端通过 `R.ok(data=list, count=total)` 返回 |
|
||
| 登录接口 | `{ access_token: "..." }` | 使用 `isTransformResponse: false`,自行处理 |
|
||
|
||
## 响应头 Token 自动续签
|
||
|
||
后端支持滑动窗口续签,响应拦截器会检测 `x-refresh-token` 头并自动更新本地存储:
|
||
|
||
```typescript
|
||
responseInterceptors: (res: AxiosResponse<any>) => {
|
||
const newToken = res.headers['x-refresh-token'];
|
||
if (newToken) {
|
||
storage.set('ACCESS-TOKEN', newToken);
|
||
}
|
||
return res;
|
||
},
|
||
```
|
||
|
||
## 重复请求取消
|
||
|
||
`AxiosCanceler` 类通过 `pendingMap` 维护请求标识,相同请求自动取消前一个:
|
||
|
||
```typescript
|
||
// src/utils/http/axios/axiosCancel.ts
|
||
|
||
export class AxiosCanceler {
|
||
addPending(config: AxiosRequestConfig) {
|
||
this.removePending(config); // 先移除已有的同请求
|
||
const url = getPendingUrl(config);
|
||
config.cancelToken = new axios.CancelToken((cancel) => {
|
||
if (!pendingMap.has(url)) {
|
||
pendingMap.set(url, cancel);
|
||
}
|
||
});
|
||
}
|
||
|
||
removePending(config: AxiosRequestConfig) {
|
||
const url = getPendingUrl(config);
|
||
if (pendingMap.has(url)) {
|
||
pendingMap.get(url)(url); // 取消请求
|
||
pendingMap.delete(url);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
## 接口调用方式
|
||
|
||
在 Vue 组件中调用 API:
|
||
|
||
```typescript
|
||
import { getPositionList, positionAdd } from '@/api/system/position'
|
||
|
||
// 查询列表(loadDataTable 传给 BasicTable 的 request 属性)
|
||
const loadDataTable = async (res: any) => {
|
||
const result = await getPositionList({ ...formParams, ...res });
|
||
return result; // 拦截器已解包,result 即为 { data, count, msg, ok, code }
|
||
};
|
||
|
||
// 添加数据
|
||
const handleSubmit = async () => {
|
||
await formRef.value?.validate();
|
||
await positionAdd(formData);
|
||
message('操作成功');
|
||
emit('success');
|
||
};
|
||
```
|
||
|
||
:::tip 温馨提示
|
||
所有 API 函数返回的是已解包的响应数据(经过拦截器处理),分页接口直接返回 `{ data, count }` 结构,调用方无需再 `.data` 解包。登录接口使用 `isTransformResponse: false` 跳过自动解包,自行处理返回值。
|
||
:::
|
||
|
||
## 错误处理
|
||
|
||
错误处理分两层:
|
||
|
||
1. **业务错误**(code !== 0):拦截器统一弹出 `message.error(msg)`,并 `throw new Error`
|
||
2. **HTTP 错误**(网络异常、超时等):`responseInterceptorsCatch` 处理
|
||
|
||
```typescript
|
||
responseInterceptorsCatch: (error: any) => {
|
||
const { response, code } = error || {};
|
||
const message = useMessage();
|
||
const dialog = useDialog();
|
||
// 超时
|
||
if (code === 'ECONNABORTED') {
|
||
message.error('接口请求超时,请刷新页面重试!');
|
||
return;
|
||
}
|
||
// 网络异常
|
||
if (error.toString().includes('Network Error')) {
|
||
dialog.warning({
|
||
title: '网络异常',
|
||
content: '请检查您的网络连接是否正常',
|
||
positiveText: '确定',
|
||
negativeText: '取消',
|
||
});
|
||
return Promise.reject(error);
|
||
}
|
||
// 其他 HTTP 状态码
|
||
checkStatus(error.response?.status, response?.data?.msg);
|
||
},
|
||
```
|
||
|
||
`checkStatus` 函数根据 HTTP 状态码弹出对应中文提示(400/401/403/404/405/408/500/501/502/503/504/505)。
|
||
|
||
## 总结
|
||
|
||
API 层通过 `VAxios` 类封装 Axios,提供请求拦截(自动注入 Token)、响应解包(`code===0` 返回 `data`)、重复请求取消(`AxiosCanceler`)、Token 自动续签(`x-refresh-token` 响应头)、错误处理(业务错误 + HTTP 状态码)等能力。各模块按目录组织接口函数,调用方拿到的是已解包的业务数据。
|