clientRequest.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. // src/types/request.ts
  2. /**
  3. * API 通用响应格式
  4. */
  5. export interface ApiResponse<T = any> {
  6. data: T
  7. code: number
  8. msg: string
  9. success?: boolean // 兼容部分后端返回格式
  10. }
  11. /**
  12. * 客户端请求配置
  13. * 剔除服务端专属的 cache/next 配置
  14. */
  15. export interface ClientRequestConfig {
  16. /** 请求超时时间(毫秒) */
  17. timeout?: number
  18. /** 请求头 */
  19. headers?: Record<string, string>
  20. /** 取消请求信号 */
  21. signal?: AbortSignal
  22. /** 是否携带凭证(cookie) */
  23. credentials?: RequestCredentials
  24. }
  25. /**
  26. * GET/DELETE 请求专用配置
  27. */
  28. export interface ClientGetDeleteConfig extends ClientRequestConfig {
  29. /** 数组参数格式化方式 */
  30. arrayFormat?: "indices" | "repeat" | "comma"
  31. }
  32. /**
  33. * 客户端请求错误类型
  34. */
  35. export type ClientRequestError = Error & {
  36. /** HTTP 状态码 */
  37. statusCode?: number
  38. /** 原始错误信息 */
  39. originalError?: Error
  40. /** 后端返回的错误信息 */
  41. responseMsg?: string
  42. }
  43. /**
  44. * 客户端请求工具类
  45. * 专用于 Next.js "use client" 组件
  46. */
  47. class ClientHttpClient {
  48. // 基础配置
  49. private readonly baseURL: string
  50. private readonly defaultTimeout: number = 15000 // 默认超时 15 秒
  51. private readonly defaultHeaders: Record<string, string> = {
  52. 'Content-Type': 'application/json;charset=UTF-8',
  53. 'X-Client-Type': 'browser', // 标记客户端请求
  54. }
  55. constructor() {
  56. // 从环境变量获取基础地址,兜底值保证代码不报错
  57. this.baseURL = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8080"
  58. }
  59. /**
  60. * 通用请求核心方法
  61. */
  62. private async request<TResponse = any>(
  63. method: 'GET' | 'POST' | 'PUT' | 'DELETE',
  64. url: string,
  65. options: {
  66. params?: Record<string, any> | null // GET/DELETE 参数
  67. data?: Record<string, any> | null // POST/PUT 数据
  68. config?: ClientRequestConfig
  69. }
  70. ): Promise<ApiResponse<TResponse>> {
  71. const { params, data, config = {} } = options
  72. let fullUrl = `${this.baseURL}${url}`
  73. // 处理 GET/DELETE 请求参数
  74. if ((method === 'GET' || method === 'DELETE') && params) {
  75. fullUrl = this.buildUrl(url, params, (config as ClientGetDeleteConfig)?.arrayFormat)
  76. }
  77. // 创建超时控制器
  78. const controller = new AbortController()
  79. const timeoutId = setTimeout(() => controller.abort(), config.timeout || this.defaultTimeout)
  80. try {
  81. // 构建请求选项
  82. const fetchOptions: RequestInit = {
  83. method,
  84. headers: { ...this.defaultHeaders, ...config.headers },
  85. signal: config.signal || controller.signal,
  86. credentials: config.credentials || 'same-origin', // 默认携带同域 cookie
  87. }
  88. // 处理 POST/PUT 请求体
  89. if ((method === 'POST' || method === 'PUT') && data) {
  90. fetchOptions.body = JSON.stringify(data)
  91. }
  92. // 发送请求
  93. const response = await fetch(fullUrl, fetchOptions)
  94. clearTimeout(timeoutId)
  95. // 处理 HTTP 错误状态
  96. if (!response.ok) {
  97. const error = new Error(`请求失败:${response.statusText}`) as ClientRequestError
  98. error.statusCode = response.status
  99. // 尝试解析后端返回的错误信息
  100. try {
  101. const errorData = await response.json() as ApiResponse
  102. // (确保类型安全)
  103. error.responseMsg = errorData.msg ?? errorData.data?.msg ?? error.message ?? '请求失败'
  104. error.message = error.responseMsg
  105. } catch {
  106. // 非 JSON 响应时使用默认提示
  107. error.message = `HTTP ${response.status}: 请求失败`
  108. }
  109. throw error
  110. }
  111. // 解析响应数据
  112. const responseData = await response.json() as ApiResponse<TResponse>
  113. // 兼容不同的后端响应格式
  114. if (responseData.code !== undefined && responseData.code !== 200 && !responseData.success) {
  115. const error = new Error(responseData.msg || '业务处理失败') as ClientRequestError
  116. error.statusCode = responseData.code
  117. error.responseMsg = responseData.msg
  118. throw error
  119. }
  120. return responseData
  121. } catch (error) {
  122. clearTimeout(timeoutId)
  123. // 统一错误处理
  124. throw this.handleError(error as Error)
  125. }
  126. }
  127. /**
  128. * 构建带参数的 URL
  129. */
  130. private buildUrl(
  131. url: string,
  132. params: Record<string, any>,
  133. arrayFormat: "indices" | "repeat" | "comma" = "repeat"
  134. ): string {
  135. if (!params || Object.keys(params).length === 0) {
  136. return `${this.baseURL}${url}`
  137. }
  138. const searchParams = new URLSearchParams()
  139. Object.entries(params).forEach(([key, value]) => {
  140. if (value === undefined || value === null) return
  141. if (Array.isArray(value)) {
  142. switch (arrayFormat) {
  143. case 'comma':
  144. searchParams.set(key, value.map(String).join(','))
  145. break
  146. case 'repeat':
  147. value.forEach(v => searchParams.append(key, String(v)))
  148. break
  149. case 'indices':
  150. default:
  151. value.forEach((v, i) => searchParams.set(`${key}[${i}]`, String(v)))
  152. break
  153. }
  154. } else {
  155. searchParams.set(key, String(value))
  156. }
  157. })
  158. const queryString = searchParams.toString()
  159. return queryString ? `${this.baseURL}${url}?${queryString}` : `${this.baseURL}${url}`
  160. }
  161. /**
  162. * 统一错误处理(客户端友好提示)
  163. */
  164. private handleError(error: Error): ClientRequestError {
  165. const clientError = error as ClientRequestError
  166. clientError.originalError = { ...error }
  167. // 超时错误
  168. if (error.name === 'AbortError') {
  169. clientError.message = '请求超时,请检查网络或稍后重试'
  170. clientError.statusCode = 408
  171. }
  172. // 网络错误
  173. else if (error instanceof TypeError && error.message.includes('fetch')) {
  174. clientError.message = '网络连接失败,请检查您的网络设置'
  175. clientError.statusCode = 0
  176. }
  177. // 未知错误
  178. else if (!clientError.statusCode) {
  179. clientError.message = clientError.message || '请求失败,请联系客服'
  180. clientError.statusCode = 500
  181. }
  182. return clientError
  183. }
  184. /**
  185. * 客户端 GET 请求
  186. */
  187. async get<TResponse = any>(
  188. url: string,
  189. params?: Record<string, any> | null,
  190. config?: ClientGetDeleteConfig
  191. ): Promise<ApiResponse<TResponse>> {
  192. return this.request<TResponse>('GET', url, { params, config })
  193. }
  194. /**
  195. * 客户端 POST 请求
  196. */
  197. async post<TResponse = any>(
  198. url: string,
  199. data?: Record<string, any> | null,
  200. config?: ClientRequestConfig
  201. ): Promise<ApiResponse<TResponse>> {
  202. return this.request<TResponse>('POST', url, { data, config })
  203. }
  204. /**
  205. * 客户端 PUT 请求
  206. */
  207. async put<TResponse = any>(
  208. url: string,
  209. data?: Record<string, any> | null,
  210. config?: ClientRequestConfig
  211. ): Promise<ApiResponse<TResponse>> {
  212. return this.request<TResponse>('PUT', url, { data, config })
  213. }
  214. /**
  215. * 客户端 DELETE 请求
  216. */
  217. async delete<TResponse = any>(
  218. url: string,
  219. params?: Record<string, any> | null,
  220. config?: ClientGetDeleteConfig
  221. ): Promise<ApiResponse<TResponse>> {
  222. return this.request<TResponse>('DELETE', url, { params, config })
  223. }
  224. /**
  225. * 获取当前配置信息(调试用)
  226. */
  227. getConfig() {
  228. return {
  229. baseURL: this.baseURL,
  230. defaultTimeout: this.defaultTimeout,
  231. defaultHeaders: { ...this.defaultHeaders }
  232. }
  233. }
  234. }
  235. // 创建单例实例(避免重复创建)
  236. const clientHttpClient = new ClientHttpClient()
  237. // 导出简化的方法名(方便使用)
  238. export const clientGet = clientHttpClient.get.bind(clientHttpClient)
  239. export const clientPost = clientHttpClient.post.bind(clientHttpClient)
  240. export const clientPut = clientHttpClient.put.bind(clientHttpClient)
  241. export const clientDelete = clientHttpClient.delete.bind(clientHttpClient)
  242. export { clientHttpClient }