217 行
7.3 KiB
JavaScript
217 行
7.3 KiB
JavaScript
/**
|
||
* 全链路统一请求层
|
||
* - 统一拼接 baseURL / 版本号
|
||
* - 自动注入 token
|
||
* - 统一响应结构解包:{ code, message, data }
|
||
* - 401 自动清理登录态并跳转登录
|
||
*
|
||
* 该文件为纯 ES Module,Node 测试脚本可直接 import(需先注入 globalThis.uni)。
|
||
*/
|
||
import config from '../config/index.js'
|
||
import storage from './storage.js'
|
||
|
||
// 401 跳转节流标记(毫秒)
|
||
let lastLoginRedirectAt = 0
|
||
|
||
/**
|
||
* 便于测试注入的运行时环境(默认使用全局 uni)
|
||
* @returns {Object} 运行时环境对象
|
||
*/
|
||
export function getRuntime() {
|
||
return {
|
||
request: typeof uni !== 'undefined' && uni.request ? uni.request.bind(uni) : null,
|
||
showToast: typeof uni !== 'undefined' && uni.showToast ? uni.showToast.bind(uni) : () => {},
|
||
navigateTo: typeof uni !== 'undefined' && uni.navigateTo ? uni.navigateTo.bind(uni) : () => {},
|
||
showLoading: typeof uni !== 'undefined' && uni.showLoading ? uni.showLoading.bind(uni) : () => {},
|
||
hideLoading: typeof uni !== 'undefined' && uni.hideLoading ? uni.hideLoading.bind(uni) : () => {},
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 拼接完整地址
|
||
* @param {string} url 原始相对或绝对路径
|
||
* @returns {string} 完整网络请求地址
|
||
*/
|
||
export function buildUrl(url) {
|
||
if (/^https?:\/\//i.test(url)) return url
|
||
const path = url.startsWith('/') ? url : `/${url}`
|
||
return `${config.baseURL}/${config.apiVersion}${path}`
|
||
}
|
||
|
||
/**
|
||
* 组装请求头
|
||
* 同时注入 Authorization、token、X-Token,确保在任何 Nginx/Apache 代理环境下 token 都能安全穿透到后端
|
||
* @param {Object} extra 额外的自定义请求头
|
||
* @returns {Object} 组装后的完整 Headers
|
||
*/
|
||
export function buildHeaders(extra = {}) {
|
||
const token = storage.get(config.storageKeys.token, '')
|
||
const headers = {
|
||
'Content-Type': 'application/json;charset=UTF-8',
|
||
'X-Client': 'fenleixinxi',
|
||
...extra,
|
||
}
|
||
if (token) {
|
||
// 1. 标准 HTTP Bearer Token
|
||
headers.Authorization = `Bearer ${token}`
|
||
// 2. 兼容 FastAdmin 原生与 Nginx/Apache 反向代理(部分反向代理会丢弃 Authorization,但保留常规 Header)
|
||
headers.token = token
|
||
headers.Token = token
|
||
headers['X-Token'] = token
|
||
}
|
||
return headers
|
||
}
|
||
|
||
/**
|
||
* 清理请求参数中 undefined / null / 空串 的无效字段
|
||
* 防止在微信小程序等环境下 GET 请求被底层错误序列化为 "key=undefined"
|
||
* @param {Object} data 原始请求参数
|
||
* @returns {Object} 清理后的参数
|
||
*/
|
||
export function cleanParams(data) {
|
||
if (!data || typeof data !== 'object' || Array.isArray(data)) return data
|
||
const cleaned = {}
|
||
for (const key of Object.keys(data)) {
|
||
const val = data[key]
|
||
if (val !== undefined && val !== null && val !== '') {
|
||
cleaned[key] = val
|
||
}
|
||
}
|
||
return cleaned
|
||
}
|
||
|
||
/**
|
||
* 底层请求统一封装(返回 Promise<data>)
|
||
* @param {Object} options 请求配置 { url, method, data, header, silent }
|
||
* @returns {Promise<any>} 请求结果
|
||
*/
|
||
export function request(options = {}) {
|
||
const {
|
||
url,
|
||
method = 'GET',
|
||
data = {},
|
||
header = {},
|
||
silent = false, // 失败是否静默(不弹 toast)
|
||
} = options
|
||
|
||
const requestData = String(method).toUpperCase() === 'GET' ? cleanParams(data) : data
|
||
const runtime = getRuntime()
|
||
const finalUrl = buildUrl(url)
|
||
const headers = buildHeaders(header)
|
||
|
||
if (!runtime.request) {
|
||
return Promise.reject(toErr('当前环境不支持网络请求'))
|
||
}
|
||
|
||
return new Promise((resolve, reject) => {
|
||
runtime.request({
|
||
url: finalUrl,
|
||
method: method.toUpperCase(),
|
||
data: requestData,
|
||
header: headers,
|
||
timeout: config.timeout,
|
||
success(res) {
|
||
// HTTP 层 401 判定
|
||
if (res.statusCode === 401) {
|
||
onUnauthorized(runtime)
|
||
return reject(toErr('登录已过期,请重新登录', 401))
|
||
}
|
||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||
return handleFail(
|
||
{ message: `网络异常(${res.statusCode})`, statusCode: res.statusCode },
|
||
{ url, method, data, silent, runtime, resolve, reject }
|
||
)
|
||
}
|
||
let body = res.data || {}
|
||
// 若真机或网关未自动反序列化 JSON,或响应携带 UTF-8 BOM 字符,做安全容错剥离并反序列化
|
||
if (typeof body === 'string') {
|
||
try {
|
||
const cleaned = body.replace(/^\uFEFF/, '').trim()
|
||
body = JSON.parse(cleaned)
|
||
} catch (e) {
|
||
// 保留原 body
|
||
}
|
||
}
|
||
// 业务层 401 判定
|
||
if (body.code === 401) {
|
||
onUnauthorized(runtime)
|
||
return reject(toErr(body.message || body.msg || '登录已过期,请重新登录', 401))
|
||
}
|
||
// 业务层:约定 code===0 或 code===200 为成功
|
||
if (body.code === 0 || body.code === 200) {
|
||
resolve(body.data !== undefined ? body.data : body)
|
||
} else {
|
||
const err = toErr(body.message || body.msg || '请求失败', body.code)
|
||
if (!silent) runtime.showToast({ title: err.message, icon: 'none' })
|
||
reject(err)
|
||
}
|
||
},
|
||
fail(err) {
|
||
handleFail(err, { url, method, data, silent, runtime, resolve, reject })
|
||
},
|
||
})
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 失败处理:抛出异常(必要时弹 toast 提示)
|
||
* @param {Object} err 错误对象
|
||
* @param {Object} ctx 请求上下文
|
||
*/
|
||
function handleFail(err, ctx) {
|
||
const { silent, runtime, reject } = ctx
|
||
const e = toErr(err && (err.errMsg || err.message) ? err.errMsg || err.message : '网络请求失败')
|
||
if (!silent) runtime.showToast({ title: e.message, icon: 'none' })
|
||
reject(e)
|
||
}
|
||
|
||
/**
|
||
* 401 未登录/登录失效统一处理
|
||
* 1. 清除本地缓存的 token 与用户信息
|
||
* 2. 3秒内节流防抖,避免并发请求时多次重复弹窗与重复跳转
|
||
* 3. 避免当前已经在登录页时重复跳转
|
||
* 4. 记录来源页面便于登录后无感跳回
|
||
* @param {Object} runtime 运行时对象
|
||
*/
|
||
function onUnauthorized(runtime) {
|
||
storage.remove(config.storageKeys.token)
|
||
storage.remove(config.storageKeys.userInfo)
|
||
|
||
const now = Date.now()
|
||
if (now - lastLoginRedirectAt < 3000) {
|
||
return
|
||
}
|
||
lastLoginRedirectAt = now
|
||
|
||
const pages = (typeof getCurrentPages === 'function' && getCurrentPages()) || []
|
||
const current = pages[pages.length - 1]
|
||
const rawRoute = current && (current.route || (current.$page && current.$page.route))
|
||
const route = typeof rawRoute === 'string' ? rawRoute : ''
|
||
|
||
if (!route || !route.includes('pages/auth/login')) {
|
||
runtime.showToast({ title: '登录已过期', icon: 'none' })
|
||
const redirectParam = route ? `?redirect=${encodeURIComponent('/' + route)}` : ''
|
||
runtime.navigateTo({ url: `/pages/auth/login${redirectParam}` })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 创建标准化 Error 对象
|
||
* @param {string} message 错误消息
|
||
* @param {number} code 业务或状态码
|
||
* @returns {Error}
|
||
*/
|
||
function toErr(message, code = -1) {
|
||
const e = new Error(message)
|
||
e.code = code
|
||
return e
|
||
}
|
||
|
||
/** 语法糖 */
|
||
export const get = (url, data, opts = {}) => request({ url, method: 'GET', data, ...opts })
|
||
export const post = (url, data, opts = {}) => request({ url, method: 'POST', data, ...opts })
|
||
export const put = (url, data, opts = {}) => request({ url, method: 'PUT', data, ...opts })
|
||
export const del = (url, data, opts = {}) => request({ url, method: 'DELETE', data, ...opts })
|
||
|
||
export default request
|