|
|
@@ -9,7 +9,7 @@ interface RequestConfig {
|
|
|
timeout?: number
|
|
|
headers?: Record<string, string>
|
|
|
signal?: AbortSignal
|
|
|
- // Next.js 缓存选项
|
|
|
+ // Next.js 缓存选项(仅服务端有效)
|
|
|
cache?: "force-cache" | "no-store" | "no-cache" | "default"
|
|
|
next?: {
|
|
|
revalidate?: number | false
|
|
|
@@ -17,13 +17,22 @@ interface RequestConfig {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+// 扩展 GET/DELETE 请求的配置(增加数组参数格式化)
|
|
|
+interface GetDeleteRequestConfig extends RequestConfig {
|
|
|
+ arrayFormat?: "indices" | "repeat" | "comma"
|
|
|
+}
|
|
|
+
|
|
|
// 定义可能的错误类型
|
|
|
-type HttpClientError = Error
|
|
|
+type HttpClientError = Error & {
|
|
|
+ statusCode?: number // 新增:记录 HTTP 状态码
|
|
|
+ originalError?: Error // 新增:保留原始错误
|
|
|
+ isClientError?: boolean // 新增:标记是否为客户端错误
|
|
|
+}
|
|
|
|
|
|
class ServerHttpClient {
|
|
|
- private baseURL: string
|
|
|
- private defaultTimeout: number
|
|
|
- private defaultHeaders: Record<string, string>
|
|
|
+ private readonly baseURL: string
|
|
|
+ private readonly defaultTimeout: number
|
|
|
+ private readonly defaultHeaders: Record<string, string>
|
|
|
|
|
|
constructor() {
|
|
|
this.baseURL = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8080"
|
|
|
@@ -33,212 +42,222 @@ class ServerHttpClient {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- async serverGet<TResponse = any, TParams extends Record<string, any> | null = Record<string, any> | null>(
|
|
|
- url: string,
|
|
|
- params?: TParams,
|
|
|
- config?: RequestConfig & { arrayFormat?: "indices" | "repeat" | "comma" },
|
|
|
- ): Promise<ApiResponse<TResponse>> {
|
|
|
- try {
|
|
|
- const fullUrl = this.buildUrl(url, params, config?.arrayFormat)
|
|
|
- const controller = new AbortController()
|
|
|
- const timeoutId = setTimeout(() => controller.abort(), config?.timeout ?? this.defaultTimeout)
|
|
|
-
|
|
|
- const response = await fetch(fullUrl, {
|
|
|
- method: "GET",
|
|
|
- headers: {
|
|
|
- ...this.defaultHeaders,
|
|
|
- ...config?.headers,
|
|
|
- },
|
|
|
- signal: config?.signal ?? controller.signal,
|
|
|
- cache: config?.cache,
|
|
|
- next: config?.next,
|
|
|
- })
|
|
|
-
|
|
|
- clearTimeout(timeoutId)
|
|
|
-
|
|
|
- if (!response.ok) {
|
|
|
- throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
|
+ /**
|
|
|
+ * 通用请求方法(核心复用逻辑)
|
|
|
+ */
|
|
|
+ private async request<TResponse = any>(
|
|
|
+ method: "GET" | "POST" | "PUT" | "DELETE",
|
|
|
+ url: string,
|
|
|
+ options: {
|
|
|
+ params?: Record<string, any> | null
|
|
|
+ data?: Record<string, any> | null
|
|
|
+ config?: RequestConfig & { arrayFormat?: "indices" | "repeat" | "comma" }
|
|
|
+ isClientRequest?: boolean // 标记是否为客户端请求
|
|
|
}
|
|
|
+ ): Promise<ApiResponse<TResponse>> {
|
|
|
+ const { params, data, config, isClientRequest = false } = options
|
|
|
+ let fullUrl = `${this.baseURL}${url}`
|
|
|
|
|
|
- const data: ApiResponse<TResponse> = await response.json()
|
|
|
- return data
|
|
|
- } catch (error: any) {
|
|
|
- throw this.handleError(error)
|
|
|
+ // 处理 GET/DELETE 的 URL 参数
|
|
|
+ if ((method === "GET" || method === "DELETE") && params) {
|
|
|
+ fullUrl = this.buildUrl(url, params, config?.arrayFormat)
|
|
|
}
|
|
|
- }
|
|
|
|
|
|
- async serverPost<TResponse = any, TData extends Record<string, any> | null = Record<string, any> | null>(
|
|
|
- url: string,
|
|
|
- data?: TData,
|
|
|
- config?: RequestConfig,
|
|
|
- ): Promise<ApiResponse<TResponse>> {
|
|
|
+ // 创建请求控制器(处理超时)
|
|
|
+ const controller = new AbortController()
|
|
|
+ const timeoutId = setTimeout(() => controller.abort(), config?.timeout ?? this.defaultTimeout)
|
|
|
+
|
|
|
try {
|
|
|
- const fullUrl = `${this.baseURL}${url}`
|
|
|
- const controller = new AbortController()
|
|
|
- const timeoutId = setTimeout(() => controller.abort(), config?.timeout ?? this.defaultTimeout)
|
|
|
-
|
|
|
- const response = await fetch(fullUrl, {
|
|
|
- method: "POST",
|
|
|
- headers: {
|
|
|
- ...this.defaultHeaders,
|
|
|
- ...config?.headers,
|
|
|
- },
|
|
|
+ // 客户端请求移除服务端缓存配置
|
|
|
+ const fetchOptions: RequestInit = {
|
|
|
+ method,
|
|
|
+ headers: { ...this.defaultHeaders, ...config?.headers },
|
|
|
body: data ? JSON.stringify(data) : undefined,
|
|
|
signal: config?.signal ?? controller.signal,
|
|
|
- cache: config?.cache,
|
|
|
- next: config?.next,
|
|
|
- })
|
|
|
-
|
|
|
- clearTimeout(timeoutId)
|
|
|
-
|
|
|
- if (!response.ok) {
|
|
|
- const errorData = await response.json().catch(() => ({}))
|
|
|
- throw new Error((errorData as any)?.msg || `HTTP ${response.status}: ${response.statusText}`)
|
|
|
}
|
|
|
|
|
|
- const responseData: ApiResponse<TResponse> = await response.json()
|
|
|
- return responseData
|
|
|
- } catch (error: any) {
|
|
|
- throw this.handleError(error)
|
|
|
- }
|
|
|
- }
|
|
|
+ // 仅服务端请求保留缓存配置
|
|
|
+ if (!isClientRequest) {
|
|
|
+ fetchOptions.cache = config?.cache as RequestCache
|
|
|
+ ;(fetchOptions as any).next = config?.next
|
|
|
+ }
|
|
|
|
|
|
- async serverPut<TResponse = any, TData extends Record<string, any> | null = Record<string, any> | null>(
|
|
|
- url: string,
|
|
|
- data?: TData,
|
|
|
- config?: RequestConfig,
|
|
|
- ): Promise<ApiResponse<TResponse>> {
|
|
|
- try {
|
|
|
- const fullUrl = `${this.baseURL}${url}`
|
|
|
- const controller = new AbortController()
|
|
|
- const timeoutId = setTimeout(() => controller.abort(), config?.timeout ?? this.defaultTimeout)
|
|
|
-
|
|
|
- const response = await fetch(fullUrl, {
|
|
|
- method: "PUT",
|
|
|
- headers: {
|
|
|
- ...this.defaultHeaders,
|
|
|
- ...config?.headers,
|
|
|
- },
|
|
|
- body: data ? JSON.stringify(data) : undefined,
|
|
|
- signal: config?.signal ?? controller.signal,
|
|
|
- cache: config?.cache,
|
|
|
- next: config?.next,
|
|
|
- })
|
|
|
+ const response = await fetch(fullUrl, fetchOptions)
|
|
|
|
|
|
clearTimeout(timeoutId)
|
|
|
|
|
|
+ // 处理 HTTP 错误状态
|
|
|
if (!response.ok) {
|
|
|
- const errorData = await response.json().catch(() => ({}))
|
|
|
- throw new Error((errorData as any)?.msg || `HTTP ${response.status}: ${response.statusText}`)
|
|
|
+ let errorMsg = `HTTP ${response.status}: ${response.statusText}`
|
|
|
+ try {
|
|
|
+ const errorData = await response.json()
|
|
|
+ errorMsg = (errorData as ApiResponse)?.msg || errorMsg
|
|
|
+ } catch {
|
|
|
+ // 解析错误数据失败时使用默认提示
|
|
|
+ }
|
|
|
+
|
|
|
+ const error = new Error(errorMsg) as HttpClientError
|
|
|
+ error.statusCode = response.status
|
|
|
+ error.isClientError = isClientRequest
|
|
|
+ throw error
|
|
|
}
|
|
|
|
|
|
+ // 解析响应数据
|
|
|
const responseData: ApiResponse<TResponse> = await response.json()
|
|
|
return responseData
|
|
|
- } catch (error: any) {
|
|
|
- throw this.handleError(error)
|
|
|
+ } catch (error) {
|
|
|
+ clearTimeout(timeoutId) // 确保超时器被清理
|
|
|
+ throw this.handleError(error as Error, isClientRequest)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- async serverDelete<TResponse = any, TParams extends Record<string, any> | null = Record<string, any> | null>(
|
|
|
- url: string,
|
|
|
- params?: TParams,
|
|
|
- config?: RequestConfig & { arrayFormat?: "indices" | "repeat" | "comma" },
|
|
|
+ /**
|
|
|
+ * 服务端 GET 请求方法
|
|
|
+ */
|
|
|
+ async serverGet<TResponse = any, TParams extends Record<string, any> | null = null>(
|
|
|
+ url: string,
|
|
|
+ params?: TParams,
|
|
|
+ config?: GetDeleteRequestConfig,
|
|
|
): Promise<ApiResponse<TResponse>> {
|
|
|
- try {
|
|
|
- const fullUrl = this.buildUrl(url, params, config?.arrayFormat)
|
|
|
- const controller = new AbortController()
|
|
|
- const timeoutId = setTimeout(() => controller.abort(), config?.timeout ?? this.defaultTimeout)
|
|
|
-
|
|
|
- const response = await fetch(fullUrl, {
|
|
|
- method: "DELETE",
|
|
|
- headers: {
|
|
|
- ...this.defaultHeaders,
|
|
|
- ...config?.headers,
|
|
|
- },
|
|
|
- signal: config?.signal ?? controller.signal,
|
|
|
- cache: config?.cache,
|
|
|
- next: config?.next,
|
|
|
- })
|
|
|
+ return this.request<TResponse>("GET", url, { params, config })
|
|
|
+ }
|
|
|
|
|
|
- clearTimeout(timeoutId)
|
|
|
+ /**
|
|
|
+ * 服务端 POST 请求方法
|
|
|
+ */
|
|
|
+ async serverPost<TResponse = any, TData extends Record<string, any> | null = null>(
|
|
|
+ url: string,
|
|
|
+ data?: TData,
|
|
|
+ config?: RequestConfig,
|
|
|
+ ): Promise<ApiResponse<TResponse>> {
|
|
|
+ return this.request<TResponse>("POST", url, { data, config })
|
|
|
+ }
|
|
|
|
|
|
- if (!response.ok) {
|
|
|
- const errorData = await response.json().catch(() => ({}))
|
|
|
- throw new Error((errorData as any)?.msg || `HTTP ${response.status}: ${response.statusText}`)
|
|
|
- }
|
|
|
+ /**
|
|
|
+ * 客户端 POST 请求方法(新增)
|
|
|
+ * 适配浏览器环境,自动移除服务端缓存配置,优化错误提示
|
|
|
+ */
|
|
|
+ async clientPost<TResponse = any, TData extends Record<string, any> | null = null>(
|
|
|
+ url: string,
|
|
|
+ data?: TData,
|
|
|
+ config?: Omit<RequestConfig, "cache" | "next">, // 客户端禁用服务端缓存配置
|
|
|
+ ): Promise<ApiResponse<TResponse>> {
|
|
|
+ return this.request<TResponse>("POST", url, {
|
|
|
+ data,
|
|
|
+ config,
|
|
|
+ isClientRequest: true // 标记为客户端请求
|
|
|
+ })
|
|
|
+ }
|
|
|
|
|
|
- const responseData: ApiResponse<TResponse> = await response.json()
|
|
|
- return responseData
|
|
|
- } catch (error: any) {
|
|
|
- throw this.handleError(error)
|
|
|
- }
|
|
|
+ /**
|
|
|
+ * 服务端 PUT 请求方法
|
|
|
+ */
|
|
|
+ async serverPut<TResponse = any, TData extends Record<string, any> | null = null>(
|
|
|
+ url: string,
|
|
|
+ data?: TData,
|
|
|
+ config?: RequestConfig,
|
|
|
+ ): Promise<ApiResponse<TResponse>> {
|
|
|
+ return this.request<TResponse>("PUT", url, { data, config })
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 服务端 DELETE 请求方法
|
|
|
+ */
|
|
|
+ async serverDelete<TResponse = any, TParams extends Record<string, any> | null = null>(
|
|
|
+ url: string,
|
|
|
+ params?: TParams,
|
|
|
+ config?: GetDeleteRequestConfig,
|
|
|
+ ): Promise<ApiResponse<TResponse>> {
|
|
|
+ return this.request<TResponse>("DELETE", url, { params, config })
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 构建带参数的 URL
|
|
|
+ */
|
|
|
private buildUrl(
|
|
|
- url: string,
|
|
|
- params?: Record<string, any> | null,
|
|
|
- arrayFormat: "indices" | "repeat" | "comma" = "indices",
|
|
|
+ url: string,
|
|
|
+ params?: Record<string, any> | null,
|
|
|
+ arrayFormat: "indices" | "repeat" | "comma" = "indices",
|
|
|
): string {
|
|
|
- const fullUrl = `${this.baseURL}${url}`
|
|
|
-
|
|
|
- if (!params) {
|
|
|
- return fullUrl
|
|
|
- }
|
|
|
+ if (!params) return `${this.baseURL}${url}`
|
|
|
|
|
|
const searchParams = new URLSearchParams()
|
|
|
|
|
|
- for (const key in params) {
|
|
|
- const value = params[key]
|
|
|
+ for (const [key, value] of Object.entries(params)) {
|
|
|
+ if (value === undefined || value === null) continue
|
|
|
+
|
|
|
if (Array.isArray(value)) {
|
|
|
switch (arrayFormat) {
|
|
|
case "repeat":
|
|
|
- value.forEach((v) => searchParams.append(key, String(v)))
|
|
|
+ value.forEach(v => searchParams.append(key, String(v)))
|
|
|
break
|
|
|
case "comma":
|
|
|
- searchParams.set(key, value.map((v) => String(v)).join(","))
|
|
|
+ searchParams.set(key, value.map(String).join(","))
|
|
|
break
|
|
|
case "indices":
|
|
|
default:
|
|
|
value.forEach((v, i) => searchParams.set(`${key}[${i}]`, String(v)))
|
|
|
break
|
|
|
}
|
|
|
- } else if (value !== undefined && value !== null) {
|
|
|
+ } else {
|
|
|
searchParams.set(key, String(value))
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const queryString = searchParams.toString()
|
|
|
- return queryString ? `${fullUrl}?${queryString}` : fullUrl
|
|
|
+ return queryString ? `${this.baseURL}${url}?${queryString}` : `${this.baseURL}${url}`
|
|
|
}
|
|
|
|
|
|
- private handleError(error: any): HttpClientError {
|
|
|
- if (error.name === "AbortError") {
|
|
|
- return new Error("请求超时")
|
|
|
- }
|
|
|
+ /**
|
|
|
+ * 统一错误处理(区分客户端/服务端错误)
|
|
|
+ */
|
|
|
+ private handleError(error: Error, isClientRequest = false): HttpClientError {
|
|
|
+ const httpError = error as HttpClientError
|
|
|
+ httpError.originalError = { ...error } // 保留原始错误信息
|
|
|
+ httpError.isClientError = isClientRequest // 标记客户端错误
|
|
|
|
|
|
- if (error instanceof TypeError && error.message.includes("fetch")) {
|
|
|
- return new Error("网络连接失败")
|
|
|
+ if (error.name === "AbortError") {
|
|
|
+ httpError.message = isClientRequest
|
|
|
+ ? "请求超时,请检查网络或稍后重试" // 客户端更友好的提示
|
|
|
+ : "请求超时"
|
|
|
+ httpError.statusCode = 408
|
|
|
+ } else if (error instanceof TypeError && error.message.includes("fetch")) {
|
|
|
+ httpError.message = isClientRequest
|
|
|
+ ? "网络连接失败,请检查您的网络设置" // 客户端更友好的提示
|
|
|
+ : "网络连接失败"
|
|
|
+ httpError.statusCode = 0
|
|
|
+ } else if (!httpError.statusCode) {
|
|
|
+ httpError.message = httpError.message || (isClientRequest
|
|
|
+ ? "请求失败,请联系客服"
|
|
|
+ : "未知错误,请联系管理员")
|
|
|
+ httpError.statusCode = 500
|
|
|
}
|
|
|
|
|
|
- return error instanceof Error ? error : new Error("未知错误")
|
|
|
+ return httpError
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 获取客户端配置信息
|
|
|
+ */
|
|
|
getInstance() {
|
|
|
return {
|
|
|
baseURL: this.baseURL,
|
|
|
timeout: this.defaultTimeout,
|
|
|
- headers: this.defaultHeaders,
|
|
|
+ headers: { ...this.defaultHeaders }, // 返回拷贝,避免外部修改内部配置
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-// 创建服务端 HTTP 客户端实例
|
|
|
+// 创建服务端 HTTP 客户端实例(单例)
|
|
|
const serverHttpClient = new ServerHttpClient()
|
|
|
|
|
|
+// 导出请求方法(绑定实例上下文)
|
|
|
export const serverGet = serverHttpClient.serverGet.bind(serverHttpClient)
|
|
|
export const serverPost = serverHttpClient.serverPost.bind(serverHttpClient)
|
|
|
+export const clientPost = serverHttpClient.clientPost.bind(serverHttpClient) // 导出新增的clientPost
|
|
|
export const serverPut = serverHttpClient.serverPut.bind(serverHttpClient)
|
|
|
export const serverDelete = serverHttpClient.serverDelete.bind(serverHttpClient)
|
|
|
|
|
|
-export type { ApiResponse, RequestConfig, HttpClientError }
|
|
|
-export { serverHttpClient }
|
|
|
+// 导出类型和实例
|
|
|
+export type { ApiResponse, RequestConfig, GetDeleteRequestConfig, HttpClientError }
|
|
|
+export { serverHttpClient }
|