Files
KCGL/inventory-web/src/utils/request.ts

288 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import axios from 'axios'
import type { InternalAxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
// 1. 创建 axios 实例
const service = axios.create({
// 【关键修改】
// 设置为 '/api',请求会自动拼接成 http://localhost:5173/api/...
// 然后被 Vite 代理转发到 http://127.0.0.1:8000/api/...
baseURL: '/api',
timeout: 60000
})
// ============================================================
// 2. 防重复提交 - Pending 请求池
// ============================================================
const pendingRequests = new Map<string, AbortController>()
// 生成唯一请求 Key:方法 + URL + 序列化参数
const generateRequestKey = (config: InternalAxiosRequestConfig): string => {
const method = (config.method || 'get').toLowerCase()
const url = config.url || ''
const params = config.params ? JSON.stringify(config.params) : ''
const data = config.data ? JSON.stringify(config.data) : ''
return `${method}:${url}:${params}:${data}`
}
// ============================================================
// 3. 无感刷新 Token 核心逻辑
// ============================================================
// 标记是否正在刷新 Token
let isRefreshing = false
// 重试队列:存储失败的请求,刷新成功后重新发送
const retryQueue: {
resolve: (value: unknown) => void
reject: (reason?: unknown) => void
config: InternalAxiosRequestConfig
}[] = []
// 处理队列中的请求
const processQueue = (newToken: string | null, error?: Error) => {
retryQueue.forEach(({ resolve, reject, config }) => {
if (newToken) {
// 更新 Authorization 头
config.headers['Authorization'] = 'Bearer ' + newToken
// 重新发送请求
service(config).then(resolve).catch(reject)
} else {
reject(error)
}
})
// 清空队列
retryQueue.length = 0
}
// 刷新 Token 接口
const refreshToken = async (refreshTokenValue: string): Promise<string> => {
const res = await axios.post('/api/v1/auth/refresh', {
refresh_token: refreshTokenValue
})
const data = res.data
if (data.access_token) {
return data.access_token
}
throw new Error('Token 刷新失败')
}
// ============================================================
// 4. 请求拦截器(添加 Token + 防重复提交)
// ============================================================
service.interceptors.request.use(
(config) => {
// 1. 添加 Token
const token = localStorage.getItem('access_token') || localStorage.getItem('token')
if (token && config.headers) {
// Flask-JWT-Extended 默认需要 'Bearer <token>' 格式
config.headers['Authorization'] = 'Bearer ' + token
}
// 2. 防重复提交检查
const requestKey = generateRequestKey(config)
// 排除一些不需要防重复的请求(如查询类 GET 请求可以根据需求调整)
const ignoreMethods = ['get', 'head']
if (!ignoreMethods.includes((config.method || 'get').toLowerCase())) {
if (pendingRequests.has(requestKey)) {
// 取消之前的请求
const controller = pendingRequests.get(requestKey)
controller?.abort('正在处理中,请勿重复操作')
pendingRequests.delete(requestKey)
console.warn(`[防重复] 取消重复请求: ${requestKey}`)
}
// 创建新的 AbortController 并存储
const controller = new AbortController()
config.signal = controller.signal
pendingRequests.set(requestKey, controller)
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// ============================================================
// 5. 响应拦截器(核心:无感刷新 + 清理 pending)
// ============================================================
service.interceptors.response.use(
(response) => {
// 清理 pending 请求池
const requestKey = generateRequestKey(response.config)
if (pendingRequests.has(requestKey)) {
pendingRequests.delete(requestKey)
}
// Axios 默认包了一层 data,所以这里取 response.data
const res = response.data
// 如果后端返回的是标准 Flask jsonify 结果,通常没有 code 字段(除非你自己封装了)
// 如果你使用了标准 HTTP 状态码(200, 201等),Axios 会直接进入这里
// 只有当业务逻辑明确返回错误码时才报错 (根据你的后端封装调整)
if (res.code && res.code !== 200) {
ElMessage.error(res.msg || 'Error')
return Promise.reject(new Error(res.msg || 'Error'))
} else {
return res // 返回解包后的数据
}
},
async (error: AxiosError) => {
console.log('err: ' + error) // for debug
// 清理 pending 请求池(无论成功还是失败都要清理)
if (error.config) {
const requestKey = generateRequestKey(error.config)
if (pendingRequests.has(requestKey)) {
pendingRequests.delete(requestKey)
}
}
// 如果不是 axios 错误,直接抛出
if (!error.response) {
return Promise.reject(error)
}
const originalConfig = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
let message = error.message || '请求失败'
// 处理 HTTP 状态码错误
const isLoginEndpoint = error.config && error.config.url && error.config.url.includes('/login')
const isRefreshEndpoint = error.config && error.config.url && error.config.url.includes('/refresh')
const status = error.response.status
const data = error.response.data as any
// ============================================================
// 核心:401 错误处理 + 无感刷新
// ============================================================
if (status === 401) {
// 0. 检查是否是互踢情况(账号在其他设备登录)
if (data?.reason === 'token_mismatch') {
message = '您的账号已在其他设备登录,请重新登录'
ElMessage.warning(message)
localStorage.clear()
window.location.href = '/login'
return Promise.reject(error)
}
// 1. 如果是登录接口的 401,不执行刷新
if (isLoginEndpoint) {
message = data?.msg || '用户名或密码错误'
ElMessage.error(message)
return Promise.reject(error)
}
// 2. 如果是刷新接口的 401,说明 refresh_token 也过期了
if (isRefreshEndpoint) {
message = '登录已彻底过期,请重新登录'
ElMessage.error(message)
// 清空所有 Token,跳转登录页
localStorage.clear()
window.location.href = '/login'
return Promise.reject(error)
}
// 3. 业务接口返回 401,尝试无感刷新
if (!originalConfig) {
return Promise.reject(error)
}
// 如果已经重试过(防止无限循环)
if (originalConfig._retry) {
message = '登录已过期,请重新登录'
localStorage.clear()
window.location.href = '/login'
return Promise.reject(error)
}
// 标记为已重试
originalConfig._retry = true
// 获取 refresh_token
const refreshTokenValue = localStorage.getItem('refresh_token')
if (!refreshTokenValue) {
// 没有 refresh_token,直接跳转登录
message = '登录已过期,请重新登录'
ElMessage.error(message)
localStorage.clear()
window.location.href = '/login'
return Promise.reject(error)
}
// 4. 尝试刷新 Token
if (!isRefreshing) {
isRefreshing = true
try {
console.log('正在刷新 Token...')
const newAccessToken = await refreshToken(refreshTokenValue)
// 刷新成功,更新本地 Token
const userStore = useUserStore()
userStore.setToken(newAccessToken)
// 更新当前请求的 Authorization
originalConfig.headers['Authorization'] = 'Bearer ' + newAccessToken
console.log('Token 刷新成功,重发队列中的请求')
// 处理队列中的请求
processQueue(newAccessToken)
// 重发当前请求
return service(originalConfig)
} catch (refreshError) {
console.error('Token 刷新失败:', refreshError)
// 刷新失败,清空队列并跳转登录
processQueue(null, refreshError as Error)
message = '登录已彻底过期,请重新登录'
ElMessage.error(message)
localStorage.clear()
window.location.href = '/login'
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
} else {
// 5. 正在刷新中,将当前请求加入队列等待
return new Promise((resolve, reject) => {
retryQueue.push({
resolve,
reject,
config: originalConfig
})
})
}
} else if (status === 403) {
message = '权限不足'
} else if (status === 404) {
message = '请求的资源不存在'
} else if (status === 500) {
message = '服务器内部错误'
} else if (data && data.msg) {
// 优先显示后端返回的错误信息
message = data.msg
}
// 登录接口的错误由调用方单独处理,不再显示全局提示
if (!isLoginEndpoint) {
ElMessage.error(message)
}
return Promise.reject(error)
}
)
export default service