generated from chenhao/template-h5
- 恢复 Cashout/CashoutRecord/CashoutAccount 路由及 My/Balance 提现入口,去掉 VIP 校验 - 请求被 abort 时保持 pending,不再触发 caller 的 catch 弹 toast - App.vue 未登录不请求微信分享配置 - ResetPwd 手机号校验改用 cellphone 字段并统一 showFailToast - Team 页 active=1 走 referrer 单条数据分支 - 忽略 AGENTS.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
308 lines
9.5 KiB
JavaScript
308 lines
9.5 KiB
JavaScript
/**
|
||
* 通用网络请求封装 - 使用 fetch
|
||
*/
|
||
|
||
const BASE_URL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:3001';
|
||
|
||
// 默认超时时间 15 秒
|
||
const DEFAULT_TIMEOUT = 30000;
|
||
|
||
const pendingControllers = new Set();
|
||
const persistentControllers = new Set(); // 不会被 abortAll 取消的控制器
|
||
let loadingCount = 0;
|
||
// 记录是否已经在当前页面显示过骨架屏
|
||
let hasShownSkeletonInPage = false;
|
||
// 记录上次显示骨架屏时的页面路径
|
||
let lastSkeletonPath = '';
|
||
|
||
/**
|
||
* 显示骨架屏 - 只在页面切换且首次请求时显示
|
||
*/
|
||
function showSkeleton() {
|
||
loadingCount++;
|
||
|
||
// 检查是否正在页面切换或首次进入页面
|
||
const isChanging = window.__isPageChanging ? window.__isPageChanging() : false;
|
||
const currentPath = window.__currentPath ? window.__currentPath() : '';
|
||
|
||
// 当页面路径变化时,重置标志
|
||
if (lastSkeletonPath !== currentPath) {
|
||
hasShownSkeletonInPage = false;
|
||
lastSkeletonPath = currentPath;
|
||
}
|
||
|
||
// 只有在页面切换期间或当前页面尚未显示过骨架屏时,才显示骨架屏
|
||
if (isChanging || !hasShownSkeletonInPage) {
|
||
window.dispatchEvent(new Event('showSkeleton'));
|
||
hasShownSkeletonInPage = true;
|
||
}
|
||
}
|
||
|
||
function closeSkeleton() {
|
||
loadingCount = Math.max(loadingCount - 1, 0);
|
||
if (loadingCount === 0) {
|
||
window.dispatchEvent(new Event('closeSkeleton'));
|
||
}
|
||
}
|
||
|
||
// 错误码定义
|
||
const ERR_CODE = {
|
||
SUCCESS: 200,
|
||
UNAUTHORIZED: 401,
|
||
FORBIDDEN: 403,
|
||
NOT_FOUND: 404,
|
||
SERVER_ERROR: 500,
|
||
};
|
||
|
||
function getHeaders(contentType = 'application/json') {
|
||
const headers = {
|
||
'Content-Type': contentType,
|
||
};
|
||
const token = localStorage.getItem('member_token');
|
||
if (token) {
|
||
headers['Authorization'] = `Bearer ${token}`;
|
||
}
|
||
return headers;
|
||
}
|
||
|
||
function handleResponse(response) {
|
||
return response.text().then(text => {
|
||
let data;
|
||
try {
|
||
data = JSON.parse(text);
|
||
} catch (e) {
|
||
// JSON 解析失败,返回原始文本
|
||
return Promise.reject({
|
||
status: response.status,
|
||
message: '数据解析失败',
|
||
data: null
|
||
});
|
||
}
|
||
|
||
// 检查 HTTP 状态码
|
||
if (!response.ok) {
|
||
return Promise.reject({
|
||
status: response.status,
|
||
message: data.message || '请求失败',
|
||
data: null
|
||
});
|
||
}
|
||
|
||
// 检查业务状态码
|
||
if (data.status !== ERR_CODE.SUCCESS) {
|
||
return Promise.reject(data);
|
||
}
|
||
|
||
return data;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 处理 token 失效
|
||
*/
|
||
function handleUnauthorized(requestToken) {
|
||
// 只有本次请求发出时携带的 token 失效,才清除登录状态
|
||
if (!requestToken) {
|
||
return;
|
||
}
|
||
|
||
// 如果本地 token 已经被新的登录流程更新,不处理旧请求的 401/403
|
||
if (localStorage.getItem('member_token') !== requestToken) {
|
||
return;
|
||
}
|
||
|
||
localStorage.removeItem('member_token');
|
||
localStorage.removeItem('member_username');
|
||
|
||
if (window.location.hash !== '#/Login') {
|
||
window.location.href = '#/Login';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 统一错误处理
|
||
*/
|
||
function handleError(err, requestToken) {
|
||
// token 失效
|
||
if (err.status === ERR_CODE.UNAUTHORIZED) {
|
||
handleUnauthorized(requestToken);
|
||
}
|
||
return Promise.reject(err);
|
||
}
|
||
|
||
/**
|
||
* 带超时的 fetch
|
||
* @param {string} url - 请求地址
|
||
* @param {object} options - 请求选项
|
||
* @param {number} timeout - 超时时间
|
||
* @param {boolean} persistent - 是否为持久请求(不被 abortAll 取消)
|
||
*/
|
||
function fetchWithTimeout(url, options, timeout = DEFAULT_TIMEOUT, persistent = false) {
|
||
const controller = new AbortController();
|
||
const externalSignal = options.signal;
|
||
|
||
if (externalSignal) {
|
||
if (externalSignal.aborted) {
|
||
controller.abort();
|
||
} else {
|
||
externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
|
||
}
|
||
}
|
||
|
||
// 根据 persistent 标志决定放入哪个集合
|
||
if (persistent) {
|
||
persistentControllers.add(controller);
|
||
} else {
|
||
pendingControllers.add(controller);
|
||
}
|
||
// 持久请求不显示骨架屏
|
||
if (!persistent) {
|
||
showSkeleton();
|
||
}
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const timer = setTimeout(() => {
|
||
controller.abort();
|
||
reject({ status: 0, message: '网络超时,请稍后重试', data: null });
|
||
}, timeout);
|
||
|
||
fetch(url, { ...options, signal: controller.signal })
|
||
.then(res => {
|
||
clearTimeout(timer);
|
||
resolve(res);
|
||
})
|
||
.catch(err => {
|
||
clearTimeout(timer);
|
||
if (err.name === 'AbortError') {
|
||
// ponytail: 请求取消后保持 pending,让 catch 不触发(避免路由切换时集体弹 toast)
|
||
return;
|
||
}
|
||
reject({ status: 0, message: '网络连接失败', data: null });
|
||
})
|
||
.finally(() => {
|
||
// 从对应的集合中删除控制器
|
||
pendingControllers.delete(controller);
|
||
persistentControllers.delete(controller);
|
||
// 只有非持久请求才关闭骨架屏
|
||
if (!persistent) {
|
||
closeSkeleton();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 通用请求
|
||
* @param {string} url - 请求路径
|
||
* @param {object} data - 请求数据
|
||
* @param {string} method - 请求方法,默认 POST
|
||
* @param {number} timeout - 超时时间 ms
|
||
* @param {AbortSignal} signal - 外部信号
|
||
* @param {boolean} persistent - 是否为持久请求
|
||
* @returns {Promise}
|
||
*/
|
||
export function request(url, data, method = 'POST', timeout = DEFAULT_TIMEOUT, signal, persistent = false) {
|
||
const requestToken = localStorage.getItem('member_token');
|
||
return fetchWithTimeout(BASE_URL + url, {
|
||
method,
|
||
headers: getHeaders(),
|
||
body: JSON.stringify(data),
|
||
signal,
|
||
}, timeout, persistent).then(handleResponse).catch(err => handleError(err, requestToken));
|
||
}
|
||
|
||
export const get = (url, params, timeout = DEFAULT_TIMEOUT, signal, persistent = false) => {
|
||
const requestToken = localStorage.getItem('member_token');
|
||
let query = '';
|
||
if (params) {
|
||
query = '?' + new URLSearchParams(params).toString();
|
||
}
|
||
return fetchWithTimeout(BASE_URL + url + query, {
|
||
method: 'GET',
|
||
headers: getHeaders(),
|
||
signal,
|
||
}, timeout, persistent).then(handleResponse).catch(err => handleError(err, requestToken));
|
||
};
|
||
|
||
export const post = (url, data, timeout = DEFAULT_TIMEOUT, signal, persistent = false) => request(url, data, 'POST', timeout, signal, persistent);
|
||
export const put = (url, data, timeout = DEFAULT_TIMEOUT, signal, persistent = false) => request(url, data, 'PUT', timeout, signal, persistent);
|
||
export const del = (url, data, timeout = DEFAULT_TIMEOUT, signal, persistent = false) => request(url, data, 'DELETE', timeout, signal, persistent);
|
||
|
||
/**
|
||
* POST FormData 请求
|
||
* @param {string} url - 请求路径
|
||
* @param {FormData} formData - FormData 对象
|
||
* @param {number} timeout - 超时时间 ms
|
||
* @param {AbortSignal} signal - 外部信号
|
||
* @param {boolean} persistent - 是否为持久请求
|
||
* @returns {Promise}
|
||
*/
|
||
export function postForm(url, formData, timeout = DEFAULT_TIMEOUT, signal, persistent = false) {
|
||
const headers = {};
|
||
const token = localStorage.getItem('member_token');
|
||
if (token) {
|
||
headers['Authorization'] = `Bearer ${token}`;
|
||
}
|
||
return fetchWithTimeout(BASE_URL + url, {
|
||
method: 'POST',
|
||
headers,
|
||
body: formData,
|
||
signal,
|
||
}, timeout, persistent).then(handleResponse).catch(err => handleError(err, token));
|
||
}
|
||
|
||
/**
|
||
* 上传文件
|
||
* @param {string} url - 请求路径
|
||
* @param {File[]} files - 文件数组
|
||
* @param {number} timeout - 超时时间 ms(默认 30 秒上传较长)
|
||
* @param {AbortSignal} signal - 外部信号
|
||
* @param {boolean} persistent - 是否为持久请求
|
||
* @returns {Promise}
|
||
*/
|
||
export function upload(url, files, timeout = 30000, signal, persistent = false) {
|
||
const formData = new FormData();
|
||
files.forEach(file => {
|
||
formData.append('file', file);
|
||
});
|
||
|
||
const headers = {};
|
||
const token = localStorage.getItem('member_token');
|
||
if (token) {
|
||
headers['Authorization'] = `Bearer ${token}`;
|
||
}
|
||
|
||
return fetchWithTimeout(BASE_URL + url, {
|
||
method: 'POST',
|
||
headers,
|
||
body: formData,
|
||
signal,
|
||
}, timeout, persistent)
|
||
.then(res => res.text().then(text => {
|
||
try {
|
||
const data = JSON.parse(text);
|
||
if (data.status !== ERR_CODE.SUCCESS) {
|
||
return Promise.reject(data);
|
||
}
|
||
return data;
|
||
} catch (e) {
|
||
return Promise.reject({ status: 0, message: '数据解析失败', data: null });
|
||
}
|
||
}))
|
||
.catch(handleError);
|
||
}
|
||
|
||
/**
|
||
* 取消所有请求并关闭骨架屏
|
||
* 注意:持久请求不会被取消
|
||
*/
|
||
export function abortAll() {
|
||
pendingControllers.forEach(controller => controller.abort());
|
||
pendingControllers.clear();
|
||
loadingCount = 0;
|
||
// 重置页面骨架屏标志,为下一次页面切换做准备
|
||
hasShownSkeletonInPage = false;
|
||
window.dispatchEvent(new Event('closeSkeleton'));
|
||
}
|
||
|
||
export default { get, post, put, del, upload, postForm, request, abortAll }; |