- assign-resources-form:只保存叶子键 getCheckedKeys(true),回显 setCheckedKeys(leafOnly) - http.ts:_retry 标记只重放一次 + 排除 login/refresh Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
139 lines
4.8 KiB
TypeScript
139 lines
4.8 KiB
TypeScript
import axios, { type AxiosError, type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
|
||
import { clearTokens, getAccessToken, getRefreshToken, setTokens } from '@/utils/token'
|
||
|
||
/** 后端统一响应包裹:{ code, message, data },code=0 成功。 */
|
||
interface ApiEnvelope<T = unknown> {
|
||
code: number
|
||
message: string
|
||
data: T
|
||
}
|
||
|
||
/** 业务错误(code 为 HTTP 状态码 * 100,如 40100/40300/50000)。 */
|
||
export class ApiError extends Error {
|
||
constructor(
|
||
public code: number,
|
||
message: string,
|
||
) {
|
||
super(message)
|
||
this.name = 'ApiError'
|
||
}
|
||
}
|
||
|
||
/** 会话过期回调(token 刷新失败时触发),由应用启动时注册。 */
|
||
let onAuthExpired: (() => void) | null = null
|
||
|
||
export function setAuthExpiredHandler(handler: () => void): void {
|
||
onAuthExpired = handler
|
||
}
|
||
|
||
interface HttpClientOptions {
|
||
baseURL?: string
|
||
timeout?: number
|
||
}
|
||
|
||
/**
|
||
* 面向对象的 Axios 封装:
|
||
* - 请求拦截器:注入 Authorization 头
|
||
* - 响应拦截器:拆 { code, message, data },code=0 返回 data;401 自动刷新重放;其余抛 ApiError
|
||
*/
|
||
export class HttpClient {
|
||
private instance: AxiosInstance
|
||
/** 无拦截器的裸实例,供刷新令牌等内部请求使用(避免递归拦截)。 */
|
||
private rawInstance: AxiosInstance
|
||
private refreshing: Promise<string> | null = null
|
||
|
||
constructor(options: HttpClientOptions = {}) {
|
||
const baseURL = options.baseURL ?? import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000/api/v1'
|
||
const timeout = options.timeout ?? 10000
|
||
|
||
this.instance = axios.create({ baseURL, timeout })
|
||
this.rawInstance = axios.create({ baseURL, timeout })
|
||
this.setupInterceptors()
|
||
}
|
||
|
||
private setupInterceptors(): void {
|
||
this.instance.interceptors.request.use((config) => {
|
||
const token = getAccessToken()
|
||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||
return config
|
||
})
|
||
|
||
this.instance.interceptors.response.use(
|
||
(response) => {
|
||
const body = response.data as ApiEnvelope
|
||
if (body.code === 0) return body.data as AxiosResponse
|
||
return Promise.reject(new ApiError(body.code, body.message))
|
||
},
|
||
async (error: AxiosError) => {
|
||
const config = error.config as (AxiosRequestConfig & { _retry?: boolean }) | undefined
|
||
const url = config?.url ?? ''
|
||
const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/refresh')
|
||
// 401 自动刷新并重放一次(登录/刷新接口自身 401 不重放,_retry 防死循环)
|
||
if (error.response?.status === 401 && config && !config._retry && !isAuthEndpoint) {
|
||
config._retry = true
|
||
try {
|
||
const token = await this.refresh()
|
||
config.headers = config.headers ?? {}
|
||
config.headers.Authorization = `Bearer ${token}`
|
||
return this.instance.request(config)
|
||
} catch {
|
||
clearTokens()
|
||
onAuthExpired?.()
|
||
}
|
||
}
|
||
const body = error.response?.data as ApiEnvelope | undefined
|
||
return Promise.reject(new ApiError(body?.code ?? -1, body?.message ?? error.message))
|
||
},
|
||
)
|
||
}
|
||
|
||
/** 刷新令牌(并发去重),返回新 accessToken。 */
|
||
private refresh(): Promise<string> {
|
||
if (!this.refreshing) {
|
||
this.refreshing = this.doRefresh().finally(() => {
|
||
this.refreshing = null
|
||
})
|
||
}
|
||
return this.refreshing
|
||
}
|
||
|
||
private async doRefresh(): Promise<string> {
|
||
const refreshToken = getRefreshToken()
|
||
if (!refreshToken) throw new ApiError(40100, '未登录')
|
||
const { data } = await this.rawInstance.post<ApiEnvelope<{ accessToken: string; refreshToken: string }>>(
|
||
'/auth/refresh',
|
||
{ refreshToken },
|
||
)
|
||
if (data.code !== 0) throw new ApiError(data.code, data.message)
|
||
setTokens(data.data.accessToken, data.data.refreshToken)
|
||
return data.data.accessToken
|
||
}
|
||
|
||
request<T>(config: AxiosRequestConfig): Promise<T> {
|
||
return this.instance.request(config) as Promise<T>
|
||
}
|
||
|
||
get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||
return this.request<T>({ ...config, method: 'GET', url })
|
||
}
|
||
|
||
post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
||
return this.request<T>({ ...config, method: 'POST', url, data })
|
||
}
|
||
|
||
put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
||
return this.request<T>({ ...config, method: 'PUT', url, data })
|
||
}
|
||
|
||
patch<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
||
return this.request<T>({ ...config, method: 'PATCH', url, data })
|
||
}
|
||
|
||
delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||
return this.request<T>({ ...config, method: 'DELETE', url })
|
||
}
|
||
}
|
||
|
||
/** 全局单例,业务直接使用 http.get/post/... */
|
||
export const http = new HttpClient()
|