// simple-http.uts // 简单强大的 UniappX UTS 网络请求封装 // 支持 token 管理、无感刷新、多方法兼容 // ================ 类型定义 ================ // 响应接口 import HttpError, { IResponse, IRequestOption, IHttpError, IFileInfo, INetworkInfo, IDownRespone, IUploadInfo, IUploadOption, IUploadResult, IResponseToken } from "@/types/http.uts" import userState, { getUserToken, setUserToken, clearToken } from '@/stores/user.uts' // ================ 简单 HTTP 请求类 ================ class SimpleHttp { // 配置 private baseOptions : UTSJSONObject = { baseURL: 'http://localhost:8000', timeout: 15000, tokenKey: 'access_token', refreshTokenKey: 'refresh_token', tokenExpireKey: 'token_expire', tokenType: 'Bearer', enableRefreshToken: true, maxRetryCount: 3, retryDelay: 1000, debug: true } as UTSJSONObject // 状态 private isRefreshing = false private refreshSubscribers : Array<() => void> = [] private requestQueue : Array<() => Promise> = [] private isProcessingQueue = false constructor(options : UTSJSONObject = {}) { this.baseOptions = { ...this.baseOptions, ...options } this.setupNetworkListener() } // ================ 核心请求方法 ================ /** * 发送请求 */ async request(options : IRequestOption) : Promise { try { //await this.checkNetwork() // 检查网络 if (options.showLoading != null && options.showLoading == true) { uni.showLoading({ title: options.loadingText ?? '加载中...', mask: true }) } const requestConfig = this.buildRequestConfig(options) const response = await this.sendRequest(requestConfig) const { statusCode, data } = response if (statusCode >= 200 && statusCode < 300 && data != null) { if (data?.access_token != null) { const access_token = data.access_token ?? '' const access_expired = data.access_expired ?? 0 const refresh_token = data.refresh_token ?? '' const refresh_expired = data.refresh_expired ?? 0 setUserToken(access_token, access_expired, refresh_token, refresh_expired) } return Promise.resolve(data) } else if (statusCode == 401) { return this.handleTokenExpired(options) } else { throw HttpError({ errCode: statusCode, errMsg: `HTTP 错误: ${statusCode}`, options: options }) } } catch (error) { console.log(error) throw HttpError({ errCode: 5000, options, cause: error as Error }) } finally { if (options.showLoading != null && options.showLoading == true) { uni.hideLoading() } } } /** * GET 请求 */ async get(url : string, data : any = {}, config : IRequestOption = { url: '' }) : Promise { //const mergedConfig = config != null ? config : { url: '' } return this.request({ ...(config), url, method: 'GET', data } as IRequestOption) } /** * POST 请求 */ async post(url : string, data : any = {}, config : IRequestOption = { url: '' }) : Promise { //const mergedConfig = config != null ? config : { url: '' } return this.request({ ...(config), url, method: 'POST', data } as IRequestOption) } /** * PUT 请求 */ async put(url : string, data : any = {}, config : IRequestOption = { url: '' }) : Promise { //const mergedConfig = config != null ? config : { url: '' } return this.request({ ...(config), url, method: 'PUT', data }) } /** * DELETE 请求 */ async delete(url : string, data ?: any, config : IRequestOption = { url: '' }) : Promise { //const mergedConfig = config != null ? config : { url: '' } return this.request({ ...(config), url, method: 'DELETE' } as IRequestOption) } /** * PATCH 请求 */ async patch(url : string, data ?: any, config : IRequestOption = { url: '' }) : Promise { //const mergedConfig = config != null ? config : { url: '' } return this.request({ ...(config), url, method: 'PATCH', data } as IRequestOption) } /** * 检查 Token 是否有效 */ isTokenValid() : boolean { const token = getUserToken() return (token != null) } // ================ Token 无感刷新 ================ /** * 刷新 Token */ private async refreshToken() : Promise { if (this.isRefreshing) { return new Promise((resolve) => { this.refreshSubscribers.push(() => resolve(true)) }) } this.isRefreshing = true try { const tokenResult = getUserToken() if (tokenResult == null) { clearToken() this.goToLogin() return false } const { refreshToken } = tokenResult if (refreshToken == null) { this.log('无刷新 Token') clearToken() this.goToLogin() return false } // 调用刷新接口(需后端实现 Login::refresh 并返回 { access_token, refresh_token, ... }) const response = await this.post('/wxchat/api/login/refresh', { refresh_token: refreshToken }, { url: "", needToken: false, showLoading: false }) if (response.code == 0) { const { access_token, access_expired, refresh_token, refresh_expired } = response setUserToken(access_token ?? '', access_expired ?? 0, refresh_token ?? '', refresh_expired ?? 0) // 通知所有等待的请求 this.refreshSubscribers.forEach(callback => callback()) this.refreshSubscribers = [] return true } else { this.log('刷新 Token 失败:', response.message) clearToken() this.goToLogin() return false } } catch (error) { this.log('刷新 Token 异常:', error) clearToken() this.goToLogin() return false } finally { this.isRefreshing = false } } /** * 处理 Token 过期 */ private async handleTokenExpired(options : IRequestOption) : Promise { if (this.baseOptions.enableRefreshToken == null) { throw HttpError({ errCode: 401, errMsg: 'Token 已过期', options: options }) } const success = await this.refreshToken() if (success) { return this.request(options) } else { throw HttpError({ errCode: 401, errMsg: '登录已过期,请重新登录', options: options }) } } /** * 跳转到登录页 */ private goToLogin() : void { uni.showModal({ title: '登录提示', content: '登录已过期,请重新登录', showCancel: false, confirmText: '去登录', success: () => { uni.reLaunch({ url: '/pages/login/index' }) } }) } // ================ 请求处理 ================ /** * 构建请求配置 */ private buildRequestConfig(options : IRequestOption) : IRequestOption { const { url, contentType } = options options.url = url?.startsWith('http') == true ? url! : `${this.baseOptions.baseURL}${url?.startsWith('/') == true ? url! : '/' + (url ?? '')}` if (options.header == null) { options.header = {} as UTSJSONObject } // #ifdef APP const sysInfo = uni.getSystemInfoSync() options.header['App-Platform'] = sysInfo.osName options.header['App-Version-Code'] = sysInfo.appVersionCode options.header['App-Version-Name'] = sysInfo.appVersion options.header['App-Channel'] = sysInfo.osName options.header['App-Device-ID'] = sysInfo.deviceId options.header['App-Device-Model'] = sysInfo.deviceModel options.header['App-OS-Version'] = sysInfo.osVersion // #endif switch (contentType) { case 'json': options.header['Content-Type'] = 'application/json' break case 'form': options.header['Content-Type'] = 'application/x-www-form-urlencoded' break case 'multipart': options.header['Content-Type'] = 'multipart/form-data' break } if (options.needToken != null && options.needToken) { const tokenResult = getUserToken() if (tokenResult != null) { const { accessToken } = tokenResult if (accessToken != null) { options.header['Authorization'] = `${this.baseOptions.tokenType} ${accessToken}` } } } return options; } /** * 发送请求 */ private async sendRequest(options : IRequestOption) : Promise> { return new Promise((resolve, reject) => { uni.request({ url: options.url, data: options.data, header: options.header, method: options.method, timeout: options.timeout, enableChunked: true, success: (res : RequestSuccess) => { resolve(res) }, fail: (err : RequestFail) => { reject(HttpError({ errCode: err.errCode, errMsg: err.errMsg, options: options, })) }, complete: (option : any) => { } }) }) } // ================ 网络状态 ================ /** * 检查网络状态 */ private async checkNetwork() : Promise { return new Promise((resolve, reject) => { uni.getNetworkType({ success: (res) => { if (res.networkType === 'none') { reject(new Error('网络连接已断开')) } else { resolve() } }, fail: () => { resolve() // 网络检查失败不阻止请求 } }) }) } /** * 获取网络信息 */ async getNetworkInfo() : Promise { return new Promise((resolve) => { uni.getNetworkType({ success: (res) => { resolve({ isConnected: res.networkType !== 'none', networkType: res.networkType, isWifi: res.networkType == 'wifi', isCellular: ['2g', '3g', '4g', '5g'].includes(res.networkType) }) }, fail: () => { resolve({ isConnected: false, networkType: 'unknown', isWifi: false, isCellular: false }) } }) }) } /** * 设置网络监听 */ private setupNetworkListener() : void { uni.onNetworkStatusChange((res) => { if (!res.isConnected) { uni.showToast({ title: '网络连接已断开', icon: 'none', duration: 3000 }) } }) } // ================ 工具方法 ================ /** * 日志记录 */ private log(...args : any[]) : void { if (this.baseOptions.debug != null && this.baseOptions.debug == true) { console.log('[SimpleHttp]', ...args) } } /** * 设置基础 URL */ setBaseURL(baseURL : string) : void { this.baseOptions.baseURL = baseURL } /** * 设置超时时间 */ setTimeout(timeout : number) : void { this.baseOptions.timeout = timeout } /** * 设置调试模式 */ setDebug(debug : boolean) : void { this.baseOptions.debug = debug } /** * 设置 Token 刷新开关 */ setEnableRefreshToken(enable : boolean) : void { this.baseOptions.enableRefreshToken = enable } /** * 获取配置 */ getConfig() : UTSJSONObject { return { ...this.baseOptions } } // ================ 上传和下载 ================ /** * 上传文件 */ async upload(options : IUploadOption, onProgress ?: (current : number, total : number, percent : number) => void) : Promise { let { url, filePath, name = 'file', formData, header } = options const headers : UTSJSONObject = { ...(header ?? {} as UTSJSONObject) } const tokenResult = getUserToken() if (tokenResult != null) { const { accessToken } = tokenResult if (accessToken != null) { { headers['Authorization'] = `${this.baseOptions.tokenType} ${accessToken}` } } } return await new Promise((resolve, reject) => { const uploadTask = uni.uploadFile({ url: url.startsWith('http') ? url : `${this.baseOptions.baseURL}${url}`, filePath, name, formData: formData ?? {}, header: headers, success: (response : UploadFileSuccess) => { const { statusCode, data } = response // 处理 HTTP 204 等无内容响应 if (statusCode == 204 || data == null) { //resolve({ code: 0, message: 'success', data: null } as IResponse) reject(HttpError({ errCode: -1, errMsg: `响应解析失败,原始数据>: ${data.substring(0, 200)}`, options })) } if (statusCode >= 200 && statusCode < 300) { try { const obj = JSON.parse(data) as UTSJSONObject; let resInfo = obj.parse() const { access_token, access_expired, refresh_token, refresh_expired } = resInfo!! if (resInfo?.access_token != null) { setUserToken(access_token ?? '', access_expired ?? 0, refresh_token ?? '', refresh_expired ?? 0) } if (resInfo != null) { resolve(resInfo) } // // 业务状态码判断(根据你的业务逻辑调整) if (resInfo?.code != 0 && resInfo?.code != 200) { reject(HttpError({ errCode: resInfo?.code ?? 500, errMsg: resInfo?.message ?? '业务错误', options })) } } catch (parseError) { console.log(parseError) // 返回的不是 JSON,可能是之前遇到的响应头问题 reject(HttpError({ errCode: -1, errMsg: `响应解析失败,原始数据: ${data.substring(0, 200)}`, options })) } return } // 401 未授权处理 if (statusCode == 401) { // if (this.baseOptions.enableRefreshToken) { // try { // // const refreshSuccess = await this.refreshToken() // // if (refreshSuccess) { // // // 重试上传 // // const retryResult = await this.upload(options) // // resolve(retryResult) // // return // // } // } catch (refreshError) { // // refreshToken 失败,继续走登录过期逻辑 // } // } reject(HttpError({ errCode: 401, errMsg: '登录已过期,请重新登录', options })) } // 其他 HTTP 错误 reject(HttpError({ errCode: statusCode, errMsg: `HTTP 错误: ${statusCode}`, options })) }, fail: (error : UploadFileFail) => { console.log(error) reject(HttpError({ errCode: error.errCode ?? -1, errMsg: error.errMsg ?? '上传失败', options })) } }) uploadTask.onProgressUpdate((res) => { console.log('上传进度', res.progress) }) }) } /** * 并行上传多个文件 */ async uploadMultipleFilesParallel(options : IUploadOption, files : IFileInfo[]) : Promise { // 显式指定 map 回调的返回类型为 Promise const uploadPromises = files.map((file) : Promise => { // 显式指定 upload 的泛型参数,并添加 return 返回 Promise return this.upload({ url: options.url, header: options.header, timeout: options.timeout, filePath: file.path, //name: file.name ?? 'file', }, null).then((response : IResponse) : IFileInfo => { // 显式指定 then 的返回类型 let { code, data } = response if (code == 0 && data != null) { const result = (data as UTSJSONObject).parse() file.fid = result?.fid ?? 0 file.name = result?.name ?? file.name file.path = result?.url ?? file.path } return file // 直接返回 file,不要用 Promise.resolve }).catch((error) : IFileInfo => { console.log(error); return file }) }) return Promise.all(uploadPromises) } // /** // * 带整体进度回调的并行上传 // */ // async uploadMultipleFilesWithProgress(options : IUploadOption, files : IFileInfo[], onProgress ?: (completed : number, total : number, results : IUploadResult[]) => void) : Promise[]> { // const results : IUploadResult[] = new Array() // let completedCount = 0 // const uploadPromises = files.map((file) => { // return this.upload({ // url: options.url, // header: options.header, // timeout: options.timeout, // filePath: file.path, // name: file.name ?? 'file', // formData: { // ...(options.formData ?? {}), // fileIndex: file.index.toString(), // fileCount: files.length.toString() // } // }).then((result) => { // results[file.index] = { // index: file.index, // success: true, // data: result // } // completedCount++ // onProgress?.(completedCount, files.length, [...results]) // return results[file.index] // }).catch((error) => { // results[file.index] = { // index: file.index, // success: false, // error: error // } // completedCount++ // onProgress?.(completedCount, files.length, [...results]) // return results[file.index] // }) // }) // await Promise.all(uploadPromises) // return results // } } const httpApi = new SimpleHttp() // 导出 export default httpApi export type { IResponse, IRequestOption, INetworkInfo, IDownRespone, IUploadOption, IFileInfo } // import http, { SimpleHttp, IResponse } from './simple-http' // // 使用默认配置 // const api = http // // 或者创建自定义实例 // const customApi = new SimpleHttp('https://api.yourservice.com', { // timeout: 20000, // debug: true, // tokenType: 'Bearer', // enableRefreshToken: true // })