# Axios请求封装

Vue3+TS使用Axios对请求进行统一拦截,通过TS对传入格式和返回格式进行校验。

# 一、Axios请求拦截处理

// request.js

import axios from 'axios'
import storage from 'store'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios'
import { ACCESS_TOKEN } from '@/constants/app'
import { Notification } from '@arco-design/web-vue'

let hasInvalid = false // 请求已失效,禁止重复提示

// 创建 axios 实例
const request: AxiosInstance = axios.create({
  // API 请求的默认前缀
  baseURL: import.meta.env.VITE_APP_API_BASE_URL,
  timeout: 1000 * 60 * 10 // 请求超时时间
})

// 异常拦截处理器
const errorHandler = (error: AxiosError) => {
  if (error.response) {
    const data: any = error.response.data

    if (error.response.status === 404) {
      Notification.warning({
        title: '不存在该页面',
        content: data.msg
      })
      return new Promise(() => {})
    }

    if (error.response.status === 401) {
      if (hasInvalid) return new Promise(() => {})
      hasInvalid = true
      Notification.warning({
        title: '授权验证失败',
        content: '请重新登录'
      })
      storage.clearAll()
      setTimeout(() => {
        hasInvalid = false
        window.location.reload()
      }, 1000)
      return new Promise(() => {})
    }
    if (error.response.status === 415) {
      if (hasInvalid) return new Promise(() => {})
      hasInvalid = true
      Notification.warning({
        title: '系统授权过期',
        content: '请重新登录'
      })
      storage.clearAll()
      setTimeout(() => {
        hasInvalid = false
        window.location.href = '/'
      }, 1000)
      return new Promise(() => {})
    }
  }
  return Promise.reject(error)
}

// request interceptor
request.interceptors.request.use((config: InternalAxiosRequestConfig) => {
  const token = storage.get(ACCESS_TOKEN)
  // 如果 token 存在
  // 让每个请求携带自定义 token 请根据实际情况自行修改
  if (token) {
    config.headers['Authorization'] = token
  }
  return config
}, errorHandler)

// response interceptor
request.interceptors.response.use((response: AxiosResponse) => {
  return response.data
}, errorHandler)

// 通过一个方法传入泛型,自定义接口返回格式
function createRequest<T = ApiResponseData<any>>(config: AxiosRequestConfig): Promise<T> {
  return request(config)
}

export default createRequest

# 二、接口API定义

对于项目中不同模块的接口,可以专门创建对应的接口处理文件来进行分类处理,这样在调用时只需关注调用哪个接口和传什么参数就行了。在项目中统一在api文件下进行处理接口。

# Api目录

├── src
│   ├── api                  # Api ajax请求处理
│   │   ├── public           # 公共接口
│   │   │   ├── index.ts     # api接口定义
│   │   │   └── types.ts     # api类型校验
│   │   ├── empower          # 授权接口接口
│   │   │   ├── index.ts     # api接口定义
│   │   │   └── types.ts     # api类型校验

# 定义统一返回格式

通过全局声明文件定义一个统一的接口返回格式,并通过泛型支持每个接口自定义返回格式。

// api.d.ts

/**
 * @desc 接口请求数据通用返回格式
 * */
interface ApiResponseData<T> {
  code: number
  data: T
  message: string
  [key: string]: any
}

# Api定义

// empower.ts

/**
 * @desc 用户授权接口
 * 接口对传入参数和返回结果进行类型检查
 * */
import request from '@/utils/request'
import type * as Login from './types'

// 接口地址
const api = {
  login: '/api/login', // 登录
  logout: '/auth/logout', // 退出登录
  info: '/api/owner/info', // 获取用户信息
  captcha: '/api/captcha', // 获取验证码
  updatePwd: '/api/user/updatePwd' // 修改密码
}

// 登录
export function loginApi(data: Login.LoginReq) {
  return request<Login.LoginRes>({
    url: api.login,
    method: 'post',
    data
  })
}

// 获取用户信息
export function captchaApi() {
  return request<Login.CaptchaRes>({
    url: api.captcha,
    method: 'get'
    // params
  })
}

// 退出登录
export function logoutApi() {
  return request({
    url: api.logout,
    method: 'post'
  })
}

// 修改密码
export function updatePwdApi(data: Login.UpdatePwdReq) {
  return request({
    url: api.updatePwd,
    method: 'post',
    data
  })
}

empower接口类型声明

// types.ts
// 登录
export interface LoginReq {
  name: string
  password: string
}
export type LoginRes = ApiResponseData<UserInfo>

// 用户信息
export type UserInfoRes = ApiResponseData<UserInfo>

// 修改密码
export interface UpdatePwdReq {
  old_pwd: string
  new_pwd: string
}

// 验证码
interface Captcha {
  img: string
  key: string
}
export type CaptchaRes = ApiResponseData<Captcha>

# 请求配置

除了基础的接口请求字段配置外,有些接口还需要额外的配置,如FormData格式传输,获取二进制文件,接口上传进度等,需要对config配置字段进行类型校验。

/**
 * @description 全局公共 Api
 * @author changz
 * */

import request from '@/utils/request'
import type * as Public from './types'

// 接口地址
const api = {
  uploadFile: '/api/approval-flow/uploadFile', // 上传文件
  download: '/api/task/download' // 下载文件
}

// 上传文件
export function uploadFileApi(data: Public.UploadFormData, config: Public.ConfigData) {
  return request({
    url: api.uploadFile,
    method: 'post',
    data,
    onUploadProgress: config.onUploadProgress
  })
}

// 下载文件
export function downloadApi(params: Public.DownloadReq) {
  return request({
    url: api.download,
    method: 'get',
    params,
    responseType: 'blob'
  })
}

config配置和FormData格式

import FormData from 'form-data'

// 上传文件
// 定义FormData格式上传类型
export interface UploadFormData extends FormData {
  append<T extends string | Blob>(
    name: string,
    value: T
  ): void
}
// 接口配置,类似于进度条headers等
export interface ConfigData {
  onUploadProgress?: ((progressEvent: any) => void)
  // ...其他配置
}
// export type UploadProgress = ((progressEvent: any) => void)

// 下载文件
export interface DownloadReq {
  id: number
}
export type DownloadRes = ApiResponseData<any>

# 三、使用

使用时要传入定义好的参数和返回值,不能随便传入和返回。

<template>
  <div class="empower">
    <div class="empower-wrap">
      <div class="wrap-form">
        <a-form ref="empower" :model="formData" :rules="formRule" :label-col-props="{span: 4}" :wrapper-col-props="{span: 18}">
          <a-form-item field="name" label="用户名" :validate-trigger="['blur']">
            <a-input v-model="formData.name" size="large" placeholder="请输入用户名" allow-clear>
              <template #prefix><icon-user /></template>
            </a-input>
          </a-form-item>
          <a-form-item field="password" label="密码" :validate-trigger="['blur']">
            <a-input-password v-model="formData.password" size="large" placeholder="请输入密码" allow-clear>
              <template #prefix><icon-lock /></template>
            </a-input-password>
          </a-form-item>
          <!-- <a-form-item field="code" label="验证码" :validate-trigger="['blur']">
            <a-input v-model="formData.code" size="large" placeholder="请输入验证码" allow-clear @press-enter="submitForm">
              <template #prefix><icon-safe /></template>
            </a-input>
            <div class="form-img" @click="getVerifyCode">
              <img v-if="!codeLoad" :src="formData.verifyImg" alt="">
              <icon-loading v-else />
            </div>
          </a-form-item> -->
          <a-form-item :wrapper-col-props="{offset: 4}">
            <a-button type="primary" :loading="submitLoad" @click="submitForm">提交</a-button>
          </a-form-item>
        </a-form>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
/**
 * @desc 登录
 * @author changz
 * */
import { ref, reactive, getCurrentInstance, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useEmpowerStore } from '@/stores/modules/empower'
import type { FormInstance } from '@arco-design/web-vue/es/form'

import { captchaApi, loginApi } from '@/api/empower'

const instance = getCurrentInstance()
const router = useRouter()
const empowerStore = useEmpowerStore()

const empower = ref<FormInstance>()
const submitLoad = ref(false)
// const codeLoad = ref(false)
const formData = reactive({
  name: 'admin',
  password: 'admin',
  code: '',
  verifyImg: '',
  key: ''
})
const formRule = reactive({
  name: [{ required: true, message: '请输入用户名' }],
  password: [{ required: true, message: '请输入密码' }],
  code: [{ required: true, message: '请输入验证码' }]
})

onMounted(() => {
  // getVerifyCode()
})

// 获取验证码
// const getVerifyCode = () => {
//   codeLoad.value = true
//   captchaApi().then(res => {
//     codeLoad.value = false
//     if (res.code !== 200) {
//       instance?.proxy?.$notification.warning({
//         title: '提示',
//         content: res.msg
//       })
//       return
//     }
//     const { img, key } = res.data
//     formData.verifyImg = img
//     formData.key = key
//     formData.code = ''
//   }).catch(err => {
//     codeLoad.value = false
//     instance?.proxy?.$notification.warning({
//       title: '提示',
//       content: err.message
//     })
//   })
// }

const submitForm = () => {
  empower.value?.validate(errors => {
    if (!errors) {
      // const { name, password, code, key } = formData
      const { name, password } = formData
      const params = {
        name,
        password
        // code,
        // key
      }
      submitLoad.value = true
      loginApi(params)
        .then((res) => {
          submitLoad.value = false
          if (res.code !== 200) {
            instance?.proxy?.$notification.error({
              title: '错误',
              content: res.message
            })
            return
          }
          router.push({ path: '/' })
          // 延迟 1 秒显示欢迎信息
          setTimeout(() => {
            instance?.proxy?.$notification.success({
              title: '欢迎',
              content: '欢迎回来'
            })
          }, 1000)
        })
        .catch(err => {
          submitLoad.value = false
          instance?.proxy?.$notification.error({
            title: '错误',
            content: err.message
          })
        })
    } else {
      instance?.proxy?.$message.warning('表单填写不完整!')
    }
  })
}

</script>

<style lang="less" scoped>
.empower {
  width: 100%;
  height: 100%;
  background: url('../../assets/images/login-bg.jpg') center center no-repeat;
  background-size: 100% 100%;
  .empower-wrap {
    .position_center();
    width: 500px;
    .wrap-form {
      width: 100%;
      padding: 50px 30px 20px 30px;
      background-color: #fff;
      border-radius: 5px;
      box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
      .form-img {
        width: 140px;
        height: 34px;
        img {
          width: 100%;
          height: 100%;
        }
      }
    }
  }
}
</style>

上传文件接口:

<template>
  <h1>{{ title }}</h1>
  <a-upload :show-file-list="false" @before-upload="beforeUpload" :custom-request="customRequest">
    <template #upload-button>
      <a-button type="primary" :loading="uploadLoad">
        <template #icon>
          <icon-upload />
        </template>
        <template #default>上传图片</template>
      </a-button>
    </template>
  </a-upload>
</template>

<script setup lang="ts" name="UserCenter">
import { ref, getCurrentInstance } from 'vue'
import FormData from 'form-data'
import type { RequestOption } from '@arco-design/web-vue/es/upload/interfaces'

import { uploadFileApi } from '@/api/public'

const instance = getCurrentInstance()
const title = ref<string>('个人信息')
const uploadLoad = ref(false)
const percent = ref(0)

const beforeUpload = (file: File) => {
  const { name, size } = file
  const fileExtension = name.split('.').pop()
  const limitType = fileExtension === 'jpg' || fileExtension === 'jpeg' || fileExtension === 'png' || fileExtension === 'gif'
  if (!limitType) {
    instance?.proxy?.$message.error('请上传 JPG、PNG、JPEG 或 GIF 格式图片!')
  }
  const limitSize = size / 1024 / 1024 < 8
  if (!limitSize) {
    instance?.proxy?.$message.error('文件不可大于 8MB!')
  }
  return limitType && limitSize
}

// 获取图片
const customRequest = (option: RequestOption): any => {
  const { fileItem } = option
  const { file } = fileItem
  const params = new FormData()
  params.append('file', file)
  uploadLoad.value = true
  // const controller = new AbortController()

  uploadFileApi(params, {
    onUploadProgress: (e: ProgressEvent) => {
      const completeProgress = (((e.loaded / e.total) * 100) / 100) | 0
      percent.value = completeProgress
    }
  })
    .then(res => {
      uploadLoad.value = false
      if (res.code !== 200) {
        instance?.proxy?.$notification.warning({
          title: '提示',
          content: res.msg
        })
        return false
      }
      instance?.proxy?.$message.success('上传成功')
      console.log(res)
      percent.value = 0
    })
    .catch(err => {
      uploadLoad.value = false
      instance?.proxy?.$notification.warning({
        title: '提示',
        content: err.message
      })
    })
}
</script>