/** * useRequest — 替换 @szyx-mobile/use-request。 * 接口与原版完全兼容,底层使用 axios.create() + AbortController。 */ import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react' import axios, { type AxiosRequestConfig, type AxiosResponse, isAxiosError, isCancel, } from 'axios' import { z } from 'zod' import { RequestError } from './errors' import { _notifyApiError } from './globalErrorHandler' export interface RequestOptions { /** true = 不自动触发请求(需手动调用 fetch/fetchAsync)。*/ manual?: boolean /** 延迟多少毫秒后才将 loading 置为 true(默认 0)。*/ loadingDelay?: number /** 请求标签,用于日志或错误追踪。*/ tag?: string /** true = 在响应拦截器中打印完整响应。*/ log?: boolean } // ─── 内部 ValidationError(对齐 @szyx-mobile/use-axios 的 ValidationError)──── export class ValidationError extends z.ZodError { constructor( issues: z.ZodIssue[], public readonly response: AxiosResponse, ) { super(issues) this.name = 'ValidationError' } } // ─── Reducer ────────────────────────────────────────────────────────────────── interface State { response: R | undefined error: RequestError | undefined loading: boolean } type Action = | { type: 'start' } | { type: 'resolve'; response: R } | { type: 'reject'; error: RequestError } function reducer(state: State, action: Action): State { switch (action.type) { case 'start': return { ...state, loading: true } case 'resolve': return { response: action.response, error: undefined, loading: false } case 'reject': return { ...state, error: action.error, loading: false } } } const initialState = { response: undefined, error: undefined, loading: false } // ─── useRequest ─────────────────────────────────────────────────────────────── export function useRequest< S extends z.ZodTypeAny, T extends z.infer = z.infer, D = unknown, R = AxiosResponse, >( url: string, method: 'GET' | 'POSTJSON' | 'POSTFORM' | 'PUT' | 'UPLOAD', params?: D, validationSchema?: S, options?: RequestOptions, config?: AxiosRequestConfig, ): { response: R | undefined error: RequestError | undefined loading: boolean fetch: (config?: AxiosRequestConfig) => void fetchAsync: (config?: AxiosRequestConfig) => Promise cancel: () => void requestInterceptors: ReturnType['interceptors']['request'] responseInterceptors: ReturnType['interceptors']['response'] } { const [state, dispatch] = useReducer(reducer, initialState as State) // axios 实例用 useRef 持有,避免每次 render 重建(丢失拦截器) // eslint-disable-next-line react-hooks/exhaustive-deps const instance = useMemo(() => axios.create(), []) const abortControllerRef = useRef(undefined) const validationSchemaRef = useRef(validationSchema) const optionsRef = useRef(options) // 保持 ref 同步(避免闭包陈旧引用) useEffect(() => { validationSchemaRef.current = validationSchema }) useEffect(() => { optionsRef.current = options }) // Zod 校验响应拦截器(对齐 useValidatedAxios 行为) useEffect(() => { const id = instance.interceptors.response.use( (r: AxiosResponse) => { const schema = validationSchemaRef.current if (schema) { const result = schema.safeParse(r.data) if (!result.success) { return Promise.reject(new ValidationError(result.error.issues, r)) } r.data = result.data } return r }, ) return () => { instance.interceptors.response.eject(id) } }, [instance]) const cancel = useCallback(() => { abortControllerRef.current?.abort() }, []) // axios 方法映射 function resolveAxiosMethod(m: typeof method): string { switch (m) { case 'GET': return 'get' case 'PUT': return 'put' default: return 'post' } } function mergeRequestData(base: unknown, override: unknown): unknown { if (override === undefined || override === null) { return base } if (base === undefined || base === null) { return override } if ( typeof base === 'object' && typeof override === 'object' && !(override instanceof FormData) && !(override instanceof ArrayBuffer) && !Array.isArray(override) ) { return {...base as Record, ...override as Record} } return override } const fetchAsync = useCallback( async (overrideConfig?: AxiosRequestConfig): Promise => { const loadingDelay = optionsRef.current?.loadingDelay ?? 0 const timer = setTimeout(() => { dispatch({ type: 'start' }) }, loadingDelay) cancel() abortControllerRef.current = new AbortController() // Content-Type 按 method 设置 const methodHeaders: Record = {} if (method === 'POSTFORM') { methodHeaders['Content-Type'] = 'application/x-www-form-urlencoded' } else if (method === 'UPLOAD') { methodHeaders['Content-Type'] = 'multipart/form-data' } const axiosMethod = resolveAxiosMethod(method) const isGet = method === 'GET' const mergedConfig: AxiosRequestConfig = { timeout: 30_000, ...config, ...overrideConfig, url, method: axiosMethod, params: isGet ? (overrideConfig?.params ?? config?.params ?? params) : (overrideConfig?.params ?? config?.params), data: isGet ? (overrideConfig?.data ?? config?.data) : mergeRequestData(config?.data, overrideConfig?.data ?? params), headers: { ...methodHeaders, ...(config?.headers as Record | undefined), ...(overrideConfig?.headers as Record | undefined), }, signal: abortControllerRef.current.signal, } try { const r = await instance.request, D>(mergedConfig) clearTimeout(timer) dispatch({ type: 'resolve', response: r as unknown as R }) return r as unknown as R } catch (e: unknown) { clearTimeout(timer) let wrapped: RequestError if (isCancel(e)) { wrapped = new RequestError('网络请求已取消', 'Cancel', e) } else if (e instanceof ValidationError) { if (e.issues.length === 0) { wrapped = new RequestError('数据格式校验错误', 'ValidationError', e) } else { const first = e.issues[0] if (first.path.length > 0 && first.path[0] === 'status') { const resp = e.response as AxiosResponse<{ message?: string }> | undefined wrapped = new RequestError( resp?.data?.message ?? '接口返回失败', 'ValidationError', e, ) } else { wrapped = new RequestError(first.message, 'ValidationError', e) } } } else if (isAxiosError(e)) { wrapped = new RequestError('网络请求失败', 'AxiosError', e) } else { wrapped = new RequestError('网络请求失败', 'OtherError', e) } _notifyApiError(wrapped) dispatch({ type: 'reject', error: wrapped }) return Promise.reject(wrapped) } }, // eslint-disable-next-line react-hooks/exhaustive-deps [cancel, config, instance, method, params, url], ) const fetch = useCallback( (overrideConfig?: AxiosRequestConfig) => { fetchAsync(overrideConfig).catch(() => { /* error already in state */ }) }, [fetchAsync], ) // 自动触发(manual !== true 时挂载自动发请求) useEffect(() => { if (!optionsRef.current?.manual) { fetch() } // 仅在 mount 时触发一次 // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // unmount 时取消请求 useEffect(() => { return () => { cancel() } }, [cancel]) return { response: state.response, error: state.error, loading: state.loading, fetch, fetchAsync, cancel, requestInterceptors: instance.interceptors.request, responseInterceptors: instance.interceptors.response, } }