返回 AiToEarn
client.ts
根目录 / project / aitoearn-web / src / utils / request / client.ts
1 import type { RequestData, RequestParams, RequestQuery } from './types'
2 import { createElement } from 'react'
3 import { directTrans } from '@/app/i18n/client'
4 import { useConfigManagerDialogStore } from '@/store/configManagerDialog'
5 import { useUserStore } from '@/store/user'
6 import { notification } from '@/utils/ui/notification'
7 import FetchService from './FetchService'
8
9 interface ResponseType<T> {
10 code: string | number
11 data: T
12 message: string
13 url: string
14 }
15
16 interface ApiErrorIssue {
17 message?: string
18 path?: unknown
19 }
20
21 type RequestParamsWithSilent = RequestParams & {
22 silent?: boolean // 是否静默处理错误,不显示提示
23 authToken?: string // 临时指定本次请求使用的 token
24 }
25
26 export type RequestOptions = Pick<RequestParamsWithSilent, 'authToken' | 'cache'>
27
28 const fetchService = new FetchService({
29 baseURL: `${process.env.NEXT_PUBLIC_API_URL}/`,
30 requestInterceptor(requestParams) {
31 const authToken = 'authToken' in requestParams ? requestParams.authToken : undefined
32 const token = authToken ?? useUserStore.getState().token
33 requestParams.headers = {
34 ...(requestParams.headers || {}),
35 Authorization: token ? `Bearer ${token}` : '',
36 }
37
38 // 添加语言头
39 if (typeof window !== 'undefined') {
40 const lng = useUserStore.getState().lang
41 requestParams.headers = {
42 ...requestParams.headers,
43 'Accept-Language': lng,
44 }
45 }
46
47 return requestParams
48 },
49 responseInterceptor(response) {
50 return response
51 },
52 })
53
54 function isRecord(value: unknown): value is Record<string, unknown> {
55 return typeof value === 'object' && value !== null
56 }
57
58 function getIssuePath(path: unknown) {
59 if (!Array.isArray(path))
60 return ''
61
62 return path
63 .filter((item): item is string | number => typeof item === 'string' || typeof item === 'number')
64 .join('.')
65 }
66
67 function getIssueText(issue: ApiErrorIssue) {
68 const message = typeof issue.message === 'string' ? issue.message.trim() : ''
69 const path = getIssuePath(issue.path)
70
71 if (path && message)
72 return `${path}: ${message}`
73 return message || path
74 }
75
76 function getApiErrorDetails(errorData: unknown) {
77 if (!isRecord(errorData) || !Array.isArray(errorData.issues))
78 return []
79
80 const platform = typeof errorData.platform === 'string' ? errorData.platform : ''
81
82 return errorData.issues
83 .map((issue) => {
84 if (!isRecord(issue))
85 return ''
86
87 const issueText = getIssueText({
88 message: typeof issue.message === 'string' ? issue.message : undefined,
89 path: issue.path,
90 })
91 if (!issueText)
92 return ''
93
94 return platform ? `${platform} / ${issueText}` : issueText
95 })
96 .filter((detail): detail is string => detail.length > 0)
97 }
98
99 function createConfigManagerTip() {
100 const tip = directTrans('common', 'apiErrorConfigTip')
101 const action = directTrans('common', 'apiErrorConfigAction')
102
103 return createElement(
104 'span',
105 { className: 'font-normal text-muted-foreground' },
106 tip,
107 ' ',
108 createElement(
109 'button',
110 {
111 type: 'button',
112 className: 'cursor-pointer rounded-sm text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
113 onClick: () => useConfigManagerDialogStore.getState().openDialog('api-error'),
114 },
115 action,
116 ),
117 )
118 }
119
120 function createApiErrorContent(message: string, details: string[] = [], showConfigTip = true) {
121 return createElement(
122 'div',
123 { className: 'flex flex-col gap-1' },
124 createElement('span', null, message),
125 details.length > 0
126 ? createElement(
127 'ul',
128 { className: 'mt-1 space-y-0.5 font-normal text-muted-foreground' },
129 details.slice(0, 4).map(detail => createElement('li', { key: detail }, detail)),
130 )
131 : null,
132 showConfigTip ? createConfigManagerTip() : null,
133 )
134 }
135
136 function getApiErrorMessage(data: ResponseType<unknown>, fallback: string) {
137 const message = data.message || fallback
138 if (data.code !== 16183 || !isRecord(data.data) || typeof data.data.field !== 'string') {
139 return message
140 }
141
142 return message
143 .split(data.data.field)
144 .join('')
145 .replace(/:\s+/g, ':')
146 .replace(/:\s{2,}/g, ': ')
147 .replace(/\s{2,}/g, ' ')
148 .trim()
149 }
150
151 export async function request<T>(params: RequestParamsWithSilent) {
152 try {
153 const res = await fetchService.request(params)
154 const data: ResponseType<T> = await res.json()
155
156 // 使用项目的静态翻译方法(只使用国际化字段,不再使用硬编码回退)
157 const networkBusy = directTrans('common', 'networkBusy')
158
159 if (data.code === 401 || data.code === 12000) {
160 return data
161 }
162
163 if (data.code !== 0) {
164 data.message = getApiErrorMessage(data, networkBusy)
165 if (!params.silent && typeof window !== 'undefined') {
166 const errorDetails = getApiErrorDetails(data.data)
167 notification.warning({
168 content: createApiErrorContent(data.message, errorDetails),
169 key: 'apiErrorMessage',
170 duration: errorDetails.length > 0 ? 6 : 3,
171 })
172 }
173 return data
174 }
175
176 return data
177 }
178 catch (e) {
179 if (
180 (useUserStore.getState().token || params.url.includes('login/'))
181 && !params.silent
182 && typeof window !== 'undefined'
183 ) {
184 const errText = directTrans('common', 'networkError')
185 notification.error({
186 content: createApiErrorContent(errText),
187 key: 'apiErrorMessage',
188 duration: 3,
189 })
190 }
191 return null
192 }
193 }
194
195 export default {
196 get<T>(url: string, data?: RequestQuery, silent?: boolean, options?: RequestOptions) {
197 return request<T>({
198 ...options,
199 url,
200 params: data,
201 method: 'GET',
202 silent,
203 })
204 },
205 post<T>(url: string, data?: RequestData, silent?: boolean, options?: RequestOptions) {
206 return request<T>({
207 ...options,
208 url,
209 data,
210 method: 'POST',
211 silent,
212 })
213 },
214 put<T>(url: string, data?: RequestData, silent?: boolean, options?: RequestOptions) {
215 return request<T>({
216 ...options,
217 url,
218 data,
219 method: 'PUT',
220 silent,
221 })
222 },
223 delete<T>(url: string, data?: RequestData, silent?: boolean, options?: RequestOptions) {
224 return request<T>({
225 ...options,
226 url,
227 data,
228 method: 'DELETE',
229 silent,
230 })
231 },
232 patch<T>(url: string, data?: RequestData, silent?: boolean, options?: RequestOptions) {
233 return request<T>({
234 ...options,
235 url,
236 data,
237 method: 'PATCH',
238 silent,
239 })
240 },
241 }
242
242 lines TYPESCRIPT