文件
fenleixinxi/uniapp/common/utils/request.js
MiAn 111054d984 feat: 新增会员浏览/拨打额度并优化无图展示逻辑
新增会员每日浏览完整信息和拨打电话的额度配置,调整付费查看接口描述为解锁完整信息,优化帖子无图时的兜底展示逻辑,修复图片加载路径问题,删除废弃工具脚本。
2026-10-28 11:06:39 +08:00

217 行
7.3 KiB
JavaScript

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

/**
* 全链路统一请求层
* - 统一拼接 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