Files
PayrollManagementSystem/frontend/src/api/request.ts
T
liuwenxinandClaude Code 7f8f88c148 fix(frontend): 错误提示单一化/成功提示后置/日志页首次成功拉取
- request.ts blob 失败分支:先解析真实文案再统一提示——此前先弹兜底
  「导出失败」再异步补弹真实 message(双提示),401 时跳转提示与
  实际原因错位;401 语义文案在导出场景不再失真
- useConfirmAction:收尾(列表刷新)成功后才弹成功提示——此前先弹
  「已删除」再刷新,after 失败被吞时成功/失败提示同屏矛盾
- 日志页登录 tab 首次成功加载后不再重复拉取(loginLoadedOk 由
  fetcher 成功返回置位,失败切回可重拉,不复活「失败停空表」旧坑)
- login_flow.py 密码改从 E2E_ADMIN_PASSWORD 读(与 system_management
  同一约定),硬编码默认值在他机上会假绿为「凭据错误」
- e2e 冒烟:登录 8 项 + 系统管理 13 项全过(拦截器全局改动验证)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 09:22:12 +08:00

106 lines
3.4 KiB
TypeScript

import axios, { type AxiosError, type AxiosRequestConfig, type AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'
import { clearToken, getToken } from '@/utils/storage'
/** 后端统一响应信封(见 PROJECT_STANDARDS 第 3 节) */
export interface ApiResult<T = unknown> {
code: number
message: string
data: T
}
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 15000,
})
// 请求拦截:注入 Bearer 令牌
service.interceptors.request.use((config) => {
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
/**
* 令牌失效时的跳转去重标志。
*
* 页面初始化时常常并发多个请求,令牌过期会同时触发多次 401;
* 没有这个标志就会弹出多条提示并反复跳转。
*/
let redirecting = false
function handleUnauthorized(message: string): void {
if (redirecting) return
redirecting = true
clearToken()
ElMessage.error(message)
// 用整页跳转而非 router.replace:既避免 request → router → store → request
// 的循环依赖,也能顺带清空内存中的应用状态
const current = window.location.pathname + window.location.search
window.location.href = `/login?redirect=${encodeURIComponent(current)}`
window.setTimeout(() => {
redirecting = false
}, 1000)
}
service.interceptors.response.use(
(response: AxiosResponse<ApiResult>) => {
// 文件流(导出下载)不是统一信封,直通给调用方处理
if (response.config.responseType === 'blob') {
return response
}
const body = response.data
if (body.code !== 200) {
// 防御性分支:后端非 2xx 走 reject 拦截器,这里是 2xx 但业务码异常的情况
ElMessage.error(body.message || '请求失败')
return Promise.reject(new Error(body.message || '请求失败'))
}
return response
},
async (error: AxiosError<ApiResult>) => {
const status = error.response?.status
const raw = error.response?.data
let message = (raw as ApiResult | undefined)?.message ?? '网络异常,请稍后重试'
// blob 请求失败时 data 是 Blob(如导出接口返回的 JSON 信封错误):
// 先解析出真实文案再统一提示 —— 先弹兜底再异步补弹会造成双提示,
// 401 时跳转提示也会与实际原因错位
if (raw instanceof Blob) {
message = '导出失败,请稍后重试'
try {
const body = JSON.parse(await raw.text()) as { message?: string }
if (body.message) message = body.message
} catch {
/* 非 JSON 响应,保持兜底提示 */
}
}
if (status === 401) {
// 401 严格表示「受保护接口的鉴权失败」。登录接口自身失败返回 400/403,
// 因此不会出现「用户输错一次密码就被弹回登录页」的循环。
handleUnauthorized(message)
} else {
ElMessage.error(message)
}
return Promise.reject(error)
},
)
/**
* 发起请求并按统一信封解包。
*
* 成功时直接返回 `data` 字段,调用方不必层层 `.data`;
* 失败时已由拦截器提示并 reject,调用方通常只需 try/catch 做状态回滚。
*/
export async function request<T>(config: AxiosRequestConfig): Promise<T> {
const response = await service.request<ApiResult<T>>(config)
return response.data.data
}
export default service