- stores: 移除未使用的 token/loading/locale 死状态;AdminCheckResult 统一收口 types/api.ts - router: 移除全 Mock 的 SKU 页生产路由(源码保留待独立工程决策) - api/request: 新增 requestRaw() 显式原始响应封装(替代 responseType hack) - Manager.vue: SimpleUpload 弃用 XMLHttpRequest,改统一 axios upload + onUploadProgress(拦截器收益全覆盖)
190 lines
7.8 KiB
TypeScript
190 lines
7.8 KiB
TypeScript
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
|