620 lines
16 KiB
Plaintext
620 lines
16 KiB
Plaintext
// 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<any>> = []
|
||
private isProcessingQueue = false
|
||
constructor(options : UTSJSONObject = {}) {
|
||
this.baseOptions = { ...this.baseOptions, ...options }
|
||
this.setupNetworkListener()
|
||
}
|
||
// ================ 核心请求方法 ================
|
||
/**
|
||
* 发送请求
|
||
*/
|
||
async request(options : IRequestOption) : Promise<IResponse> {
|
||
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<IResponse> {
|
||
//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<IResponse> {
|
||
//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<IResponse> {
|
||
//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<IResponse> {
|
||
//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<IResponse> {
|
||
//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<boolean> {
|
||
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<IResponse> {
|
||
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<RequestSuccess<IResponse>> {
|
||
return new Promise((resolve, reject) => {
|
||
uni.request<IResponse>({
|
||
url: options.url,
|
||
data: options.data,
|
||
header: options.header,
|
||
method: options.method,
|
||
timeout: options.timeout,
|
||
enableChunked: true,
|
||
success: (res : RequestSuccess<IResponse>) => {
|
||
resolve(res)
|
||
},
|
||
fail: (err : RequestFail) => {
|
||
reject(HttpError({
|
||
errCode: err.errCode,
|
||
errMsg: err.errMsg,
|
||
options: options,
|
||
}))
|
||
},
|
||
complete: (option : any) => {
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
// ================ 网络状态 ================
|
||
|
||
/**
|
||
* 检查网络状态
|
||
*/
|
||
private async checkNetwork() : Promise<void> {
|
||
return new Promise((resolve, reject) => {
|
||
uni.getNetworkType({
|
||
success: (res) => {
|
||
if (res.networkType === 'none') {
|
||
reject(new Error('网络连接已断开'))
|
||
} else {
|
||
resolve()
|
||
}
|
||
},
|
||
fail: () => {
|
||
resolve() // 网络检查失败不阻止请求
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 获取网络信息
|
||
*/
|
||
async getNetworkInfo() : Promise<INetworkInfo> {
|
||
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<IResponse> {
|
||
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<IResponse>()
|
||
|
||
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<T>(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<IFileInfo[]> {
|
||
// 显式指定 map 回调的返回类型为 Promise<IFileInfo>
|
||
const uploadPromises = files.map((file) : Promise<IFileInfo> => {
|
||
// 显式指定 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<IUploadInfo>()
|
||
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<T = any>(options : IUploadOption, files : IFileInfo[], onProgress ?: (completed : number, total : number, results : IUploadResult<T>[]) => void) : Promise<IUploadResult<T>[]> {
|
||
// const results : IUploadResult<T>[] = new Array()
|
||
// let completedCount = 0
|
||
// const uploadPromises = files.map((file) => {
|
||
// return this.upload<T>({
|
||
// 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
|
||
// }) |