Files
GERP/src/api/request.ts
T
TermiNexus 57421368ca refactor(frontend): Phase 3 质量整改——死状态清理、类型收口、路由收敛、上传统一
- stores: 移除未使用的 token/loading/locale 死状态;AdminCheckResult 统一收口 types/api.ts
- router: 移除全 Mock 的 SKU 页生产路由(源码保留待独立工程决策)
- api/request: 新增 requestRaw() 显式原始响应封装(替代 responseType hack)
- Manager.vue: SimpleUpload 弃用 XMLHttpRequest,改统一 axios upload + onUploadProgress(拦截器收益全覆盖)
2026-08-13 18:14:25 +08:00

190 lines
7.8 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, {
type AxiosInstance,
type AxiosRequestConfig,
type AxiosResponse,
type InternalAxiosRequestConfig,
} from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { ApiResult } from '@/types/api'
import { getServerScopeParams } from '@/utils/serverScope'
import router from '@/router'
import { useUserStore } from '@/stores/user'
import { useTabsStore } from '@/stores/tabs'
/** 后端响应统一格式 */
type BackendResponse<T> = AxiosResponse<ApiResult<T>>
/** 扩展请求配置:silent=true 时失败不弹全局提示(用于登录页会话静默恢复等场景) */
declare module 'axios' {
export interface AxiosRequestConfig {
silent?: boolean
}
}
/** 业务错误码:登录失效 */
const CODE_UNAUTHORIZED = 401
/** 业务错误码:成功 */
const CODE_SUCCESS = 0
/** 防止重复弹窗 */
let isLoginExpiredShown = false
const request: AxiosInstance = axios.create({
baseURL: '/',
timeout: 30000,
// Cookie 认证:跨域请求需要携带凭证
withCredentials: true,
headers: {
'Content-Type': 'application/json',
// 后端需要时区信息(与原 GERP fetch 拦截器一致)
'X-User-Timezone': Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai',
},
})
/**
* 请求拦截器:
* - 注入时区头(每次刷新,用户可能跨时区)
* - 注入 server 作用域参数(server/admin_verify),对齐原生 CompanyCommon.buildApiUrl
* 主实例不注入 server;URL 含 admin_verify=1 时注入特权标记
*/
request.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
config.headers['X-User-Timezone'] = Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai'
// 动态接口禁止 HTTP 缓存:后端接口均无 Cache-Control,浏览器可能启发式缓存
// (如菜单树变更后刷新仍返回旧数据),统一加 no-cache 头保证实时性
config.headers['Cache-Control'] = 'no-cache'
config.headers['Pragma'] = 'no-cache'
// 合并 server 作用域参数到 query(不覆盖调用方已显式传入的同名参数)
const scopeParams = getServerScopeParams()
if (Object.keys(scopeParams).length) {
config.params = { ...scopeParams, ...(config.params as Record<string, unknown>) }
}
return config
},
(error) => Promise.reject(error),
)
/** 响应拦截器:统一处理 {code, msg, data} 格式与登录失效 */
request.interceptors.response.use(
<T>(response: BackendResponse<T>) => {
const result = response.data
// 文件流等非业务响应直接放行
if (result == null || typeof result !== 'object' || !('code' in result)) {
return response as unknown as AxiosResponse<T>
}
if (result.code === CODE_SUCCESS) {
// 标准 {code, data} 结构:返回 data
if ('data' in result) return result.data as unknown as T
// 兼容扁平结构(如 GetELTUserInfo 返回 {code, UserUUID, UserName, ...} 无 data 字段)
// 返回整个 result(含 code),调用方按需取业务字段,避免拿到 undefined 导致缓存写不进 userInfo
return result as unknown as T
}
// 业务错误:弹出提示并拒绝
ElMessage.error(result.msg || '请求失败')
return Promise.reject(new Error(result.msg || 'Business Error'))
},
(error) => {
const status = error?.response?.status
const url: string = error?.config?.url || ''
// 后端可读业务 JSON({code, msg}):提取 msg 附加到 error.message,
// 保证 silent 调用方与各处 catch 的 (e as Error).message 都能拿到友好文案
// (如 Mail/Login 401 返回 {"code":1,"msg":"邮箱或密码错误"},
// 而不是 axios 默认的 "Request failed with status code 401")
const bizData = error?.response?.data as { code?: number; msg?: string } | undefined
const bizMsg =
bizData && typeof bizData === 'object' && typeof bizData.msg === 'string' && bizData.msg
? bizData.msg
: ''
if (bizMsg) error.message = bizMsg
// 静默模式:调用方自行处理失败(如登录页会话恢复、邮箱登录/JMAP),不弹全局提示
const silent = (error?.config as InternalAxiosRequestConfig | undefined)?.silent === true
if (silent) {
return Promise.reject(error)
}
// 登录接口自身的 401 是"凭证错误"(如用户名或密码错误),不是登录失效,
// 直接展示后端 msg,不弹登录失效窗、不跳转。
const isLoginRequest = /\/Auth\/Login/i.test(url)
if (status === CODE_UNAUTHORIZED && !isLoginRequest) {
handleLoginExpired()
} else {
const msg = bizMsg || error?.message || '网络错误'
ElMessage.error(msg)
}
return Promise.reject(error)
},
)
/** 登录失效处理:弹窗提示并跳转登录页 */
function handleLoginExpired(): void {
if (isLoginExpiredShown) return
// 已在登录页则不再跳转,避免 redirect 参数嵌套编码
// (login 页 restoreSession 等 API 若再次 401,会重复触发本函数,导致 fullPath 含旧 redirect 被再次编码)
if (router.currentRoute.value.name === 'Login') return
isLoginExpiredShown = true
ElMessageBox.alert('登录状态已失效,请重新登录', '提示', {
type: 'warning',
showClose: false,
confirmButtonText: '重新登录',
})
.then(() => {
// 清除本地用户状态(内存态 + localStorage)
// 必须清内存态:登录页守卫依据 userStore.isLoggedIn 判断,若不清内存,
// 点"重新登录"会被守卫重定向回 /dashboard,表现为跳不到登录页
useUserStore().reset()
useTabsStore().reset()
localStorage.removeItem('gerp-user')
// 使用 Vue Router 跳转登录页
// hash 路由模式下 window.location.href 会保留 #/path,导致 Vue Router 按 hash 误路由到业务页("直接显示后台")
// 改用 router.push 走标准导航,redirect 取当前完整路径(含 hash)用于登录后回跳
const redirect = router.currentRoute.value.fullPath
router.push({ name: 'Login', query: { redirect } })
})
.finally(() => {
isLoginExpiredShown = false
})
}
/** GET 请求封装 */
export function get<T = unknown>(url: string, params?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<T> {
return request.get(url, { params, ...config }) as unknown as Promise<T>
}
/** POST 请求封装 */
export function post<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return request.post(url, data, config) as unknown as Promise<T>
}
/** PUT 请求封装 */
export function put<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return request.put(url, data, config) as unknown as Promise<T>
}
/** DELETE 请求封装 */
export function del<T = unknown>(url: string, params?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<T> {
return request.delete(url, { params, ...config }) as unknown as Promise<T>
}
/** PATCH 请求封装 */
export function patch<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return request.patch(url, data, config) as unknown as Promise<T>
}
/** 原始响应请求:返回 AxiosResponse(绕过 {code,data} 解包),
* 供需要访问原始响应体(非标准结构/流式响应)的场景使用,
* 替代 responseType:'text' + 双重类型断言的 hack */
export function requestRaw<T = unknown>(config: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return request.request(config) as Promise<AxiosResponse<T>>
}
/** 上传文件 */
export function upload<T = unknown>(url: string, formData: FormData, config?: AxiosRequestConfig): Promise<T> {
return request.post(url, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
...config,
}) as unknown as Promise<T>
}
export default request