215 lines
5.4 KiB
JavaScript
215 lines
5.4 KiB
JavaScript
import { message } from 'antd'
|
||
|
||
/**
|
||
* 统一请求封装
|
||
* 基于原生 fetch,支持:
|
||
* - baseURL(通过 vite 环境变量 VITE_API_BASE_URL 配置)
|
||
* - 自动注入 token
|
||
* - 统一响应解析(约定 { code, data, message })
|
||
* - 统一错误提示
|
||
* - 超时控制
|
||
*/
|
||
|
||
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'
|
||
const DEFAULT_TIMEOUT = 15000
|
||
|
||
const TOKEN_KEY = 'token'
|
||
|
||
export function getToken() {
|
||
return localStorage.getItem(TOKEN_KEY)
|
||
}
|
||
|
||
export function setToken(token) {
|
||
localStorage.setItem(TOKEN_KEY, token)
|
||
}
|
||
|
||
export function removeToken() {
|
||
localStorage.removeItem(TOKEN_KEY)
|
||
}
|
||
|
||
/**
|
||
* 拼接 query 参数
|
||
*/
|
||
function buildQuery(params) {
|
||
if (!params || typeof params !== 'object') return ''
|
||
const usp = new URLSearchParams()
|
||
Object.entries(params).forEach(([key, value]) => {
|
||
if (value === undefined || value === null || value === '') return
|
||
if (Array.isArray(value)) {
|
||
value.forEach((v) => usp.append(key, v))
|
||
} else {
|
||
usp.append(key, value)
|
||
}
|
||
})
|
||
const str = usp.toString()
|
||
return str ? `?${str}` : ''
|
||
}
|
||
|
||
/**
|
||
* fetch 超时封装
|
||
*/
|
||
function fetchWithTimeout(url, options, timeout) {
|
||
const controller = new AbortController()
|
||
const timer = setTimeout(() => controller.abort(), timeout)
|
||
return fetch(url, { ...options, signal: controller.signal }).finally(() =>
|
||
clearTimeout(timer)
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 统一错误处理
|
||
*/
|
||
function handleError(error) {
|
||
if (error?.name === 'AbortError') {
|
||
message.error('请求超时,请稍后重试')
|
||
} else if (error?.silent) {
|
||
// 静默错误,不弹提示
|
||
} else {
|
||
message.error(error?.message || '网络异常')
|
||
}
|
||
return Promise.reject(error)
|
||
}
|
||
|
||
/**
|
||
* 处理未授权
|
||
*/
|
||
function handleUnauthorized() {
|
||
removeToken()
|
||
message.error('登录已失效,请重新登录')
|
||
// 避免循环跳转
|
||
if (window.location.pathname !== '/login') {
|
||
window.location.href = '/login'
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 核心请求方法
|
||
* @param {string} url - 接口路径
|
||
* @param {object} options - 配置项
|
||
* @param {'GET'|'POST'|'PUT'|'DELETE'|'PATCH'} options.method
|
||
* @param {object} options.params - URL 参数
|
||
* @param {object|FormData} options.data - 请求体
|
||
* @param {object} options.headers - 自定义 headers
|
||
* @param {number} options.timeout - 超时时间(ms)
|
||
* @param {boolean} options.raw - 为 true 时返回完整响应不做业务解析
|
||
* @param {boolean} options.silent - 为 true 时静默错误(不弹提示)
|
||
*/
|
||
export async function request(url, options = {}) {
|
||
const {
|
||
method = 'GET',
|
||
params,
|
||
data,
|
||
headers = {},
|
||
timeout = DEFAULT_TIMEOUT,
|
||
raw = false,
|
||
silent = false,
|
||
} = options
|
||
|
||
const token = getToken()
|
||
const finalHeaders = {
|
||
Accept: 'application/json',
|
||
...headers,
|
||
}
|
||
if (token) {
|
||
finalHeaders.Authorization = `Bearer ${token}`
|
||
}
|
||
|
||
let body
|
||
if (data !== undefined && data !== null) {
|
||
if (data instanceof FormData) {
|
||
body = data
|
||
// FormData 由浏览器自动设置 Content-Type
|
||
} else {
|
||
finalHeaders['Content-Type'] =
|
||
finalHeaders['Content-Type'] || 'application/json;charset=UTF-8'
|
||
body = JSON.stringify(data)
|
||
}
|
||
}
|
||
|
||
const fullUrl = `${BASE_URL}${url}${buildQuery(params)}`
|
||
|
||
try {
|
||
const res = await fetchWithTimeout(
|
||
fullUrl,
|
||
{
|
||
method,
|
||
headers: finalHeaders,
|
||
body,
|
||
credentials: 'include',
|
||
},
|
||
timeout
|
||
)
|
||
|
||
// 401 未授权
|
||
if (res.status === 401) {
|
||
handleUnauthorized()
|
||
return Promise.reject({ message: '未授权', silent: true })
|
||
}
|
||
|
||
if (raw) {
|
||
return res
|
||
}
|
||
|
||
const contentType = res.headers.get('content-type') || ''
|
||
let payload
|
||
if (contentType.includes('application/json')) {
|
||
payload = await res.json()
|
||
} else {
|
||
payload = await res.text()
|
||
}
|
||
|
||
if (!res.ok) {
|
||
const err = {
|
||
message: payload?.message || `请求失败 (${res.status})`,
|
||
status: res.status,
|
||
data: payload,
|
||
silent,
|
||
}
|
||
return handleError(err)
|
||
}
|
||
|
||
// 业务约定:{ success, code, data, message } 或 { code, data, message }
|
||
// - success === true 表示成功
|
||
// - 或 code === 0 / 200 表示成功
|
||
if (payload && typeof payload === 'object' && ('success' in payload || 'code' in payload)) {
|
||
const isSuccess =
|
||
payload.success === true ||
|
||
payload.code === 0 ||
|
||
payload.code === 200 ||
|
||
payload.code === '0' ||
|
||
payload.code === '200'
|
||
if (isSuccess) {
|
||
return payload.data
|
||
}
|
||
return handleError({
|
||
message: payload.message || payload.msg || '操作失败',
|
||
code: payload.code,
|
||
data: payload.data,
|
||
silent,
|
||
})
|
||
}
|
||
|
||
// 非约定结构直接返回
|
||
return payload
|
||
} catch (error) {
|
||
if (error && error.silent === undefined) {
|
||
error.silent = silent
|
||
}
|
||
return handleError(error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 快捷方法
|
||
*/
|
||
export const http = {
|
||
get: (url, params, options = {}) => request(url, { ...options, method: 'GET', params }),
|
||
post: (url, data, options = {}) => request(url, { ...options, method: 'POST', data }),
|
||
put: (url, data, options = {}) => request(url, { ...options, method: 'PUT', data }),
|
||
patch: (url, data, options = {}) => request(url, { ...options, method: 'PATCH', data }),
|
||
delete: (url, params, options = {}) =>
|
||
request(url, { ...options, method: 'DELETE', params }),
|
||
}
|
||
|
||
export default http
|