/** * 通用网络请求封装 - 使用 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 };