From e65a432f5c9fc11d15d53ae0e16e5f4b7d62400a Mon Sep 17 00:00:00 2001
From: wsj <593216709@qq.com>
Date: Sat, 17 Oct 2026 20:53:17 +0800
Subject: [PATCH] feat: api
---
src/api/request.ts | 302 ++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 302 insertions(+), 0 deletions(-)
diff --git a/src/api/request.ts b/src/api/request.ts
new file mode 100644
index 0000000..19e5816
--- /dev/null
+++ b/src/api/request.ts
@@ -0,0 +1,302 @@
+import axios from 'axios';
+import type {
+ AxiosError,
+ AxiosRequestConfig,
+ AxiosResponse,
+ InternalAxiosRequestConfig,
+} from 'axios';
+import { showErrorMessage } from '@/utils/feedback';
+
+/** 一个请求的配置。TReq 为请求参数 / 请求体的类型。 */
+export type ApiRequestConfig<TReq = unknown> = Omit<
+ AxiosRequestConfig<TReq>,
+ 'signal'
+> & {
+ url: string;
+};
+
+/** 每个调用方可以单独设置的请求选项。 */
+export interface RequestOptions<TParse extends boolean = boolean> {
+ /** 请求失败时是否自动提示错误信息,默认 true。 */
+ showError?: boolean;
+ /** 是否合并相同的并发请求,默认 true;为 false 时每次都单独发送。 */
+ merge?: boolean;
+ /** 是否解析响应:true 返回 response.data,false 返回完整 AxiosResponse。默认 true。 */
+ parse?: TParse;
+ /** 用于取消当前调用方发起的请求。 */
+ signal?: AbortSignal;
+}
+
+/** 请求失败时抛出的统一错误,保留 Axios 返回的完整信息。 */
+export class ApiError extends Error {
+ readonly code?: string;
+ readonly status?: number;
+ readonly data?: unknown;
+ readonly response?: AxiosResponse;
+ readonly config?: InternalAxiosRequestConfig;
+ readonly isCanceled: boolean;
+ readonly originalError?: unknown;
+ /** 相同请求被多个调用方共享时,保证错误信息只提示一次。 */
+ notified = false;
+
+ constructor(
+ message: string,
+ options: {
+ code?: string;
+ status?: number;
+ data?: unknown;
+ response?: AxiosResponse;
+ config?: InternalAxiosRequestConfig;
+ isCanceled?: boolean;
+ originalError?: unknown;
+ } = {},
+ ) {
+ super(message);
+ this.name = 'ApiError';
+ this.code = options.code;
+ this.status = options.status;
+ this.data = options.data;
+ this.response = options.response;
+ this.config = options.config;
+ this.isCanceled = options.isCanceled ?? false;
+ this.originalError = options.originalError;
+ }
+}
+
+const http = axios.create({
+ // 开发环境走 Vite 代理(同源),生产环境使用真实接口地址。
+ baseURL: import.meta.env.DEV
+ ? '/proxy-api'
+ : import.meta.env.VITE_API_BASE_URL ?? '',
+ timeout: 15000,
+});
+
+/** 递归排序对象字段,保证相同参数得到相同的请求 key。 */
+const sortValue = (value: unknown): unknown => {
+ if (Array.isArray(value)) return value.map(sortValue);
+ if (value && typeof value === 'object') {
+ return Object.keys(value as Record<string, unknown>)
+ .sort()
+ .reduce<Record<string, unknown>>((result, key) => {
+ result[key] = sortValue((value as Record<string, unknown>)[key]);
+ return result;
+ }, {});
+ }
+ return value;
+};
+
+const stringify = (value: unknown): string => {
+ if (value === undefined) return '';
+ try {
+ return JSON.stringify(sortValue(value));
+ } catch {
+ return String(value);
+ }
+};
+
+const getRequestKey = <TReq>(config: ApiRequestConfig<TReq>): string =>
+ [
+ (config.method ?? 'GET').toUpperCase(),
+ config.url,
+ stringify(config.params),
+ stringify(config.data),
+ ].join('|');
+
+/** 正在进行中的相同请求,值为完整的 AxiosResponse。 */
+const pendingRequests = new Map<string, Promise<AxiosResponse>>();
+
+const createCanceledError = () =>
+ new ApiError('请求已取消', { isCanceled: true, code: 'ERR_CANCELED' });
+
+const toApiError = (error: unknown): ApiError => {
+ if (error instanceof ApiError) return error;
+
+ const axiosError = error as AxiosError<{ message?: string; msg?: string }>;
+ const response = axiosError?.response;
+ const data = response?.data;
+ const serverMessage =
+ typeof data?.message === 'string'
+ ? data.message
+ : typeof data?.msg === 'string'
+ ? data.msg
+ : undefined;
+ const isCanceled = axios.isCancel(error) || axiosError?.code === 'ERR_CANCELED';
+
+ return new ApiError(
+ isCanceled ? '请求已取消' : serverMessage ?? axiosError?.message ?? '网络请求失败',
+ {
+ code: axiosError?.code,
+ status: response?.status,
+ data,
+ response,
+ config: axiosError?.config,
+ isCanceled,
+ originalError: error,
+ },
+ );
+};
+
+/** 发送底层请求并始终保留完整响应;解析与提示交给每个调用方处理。 */
+const execute = async <TReq, TRes>(
+ config: ApiRequestConfig<TReq>,
+ options: RequestOptions,
+): Promise<AxiosResponse<TRes>> => {
+ try {
+ return await http.request<TRes, AxiosResponse<TRes>, TReq>({
+ ...config,
+ // 合并请求时不能把某个调用方的 signal 交给底层请求,否则会取消其他调用方。
+ signal: options.merge === false ? options.signal : undefined,
+ });
+ } catch (error) {
+ throw toApiError(error);
+ }
+};
+
+/** 后端统一响应包装:{ code, message, data, success }。 */
+interface ResultEnvelope {
+ code: number | string;
+ message?: string;
+ data?: unknown;
+ success?: boolean;
+}
+
+const isResultEnvelope = (body: unknown): body is ResultEnvelope => {
+ if (!body || typeof body !== 'object') return false;
+ const record = body as Record<string, unknown>;
+ return (
+ 'code' in record &&
+ ('data' in record || 'success' in record || 'message' in record)
+ );
+};
+
+const isSuccessCode = (code: number | string) => code === 200;
+
+const formatResponse = <TRes, TParse extends boolean>(
+ response: AxiosResponse<TRes>,
+ options: RequestOptions<TParse>,
+): TParse extends true ? TRes : AxiosResponse<TRes> => {
+ if (options.parse === false) {
+ return response as TParse extends true ? TRes : AxiosResponse<TRes>;
+ }
+
+ const body = response.data as unknown;
+ if (isResultEnvelope(body)) {
+ const succeeded = isSuccessCode(body.code) || body.success === true ;
+ if (!succeeded) {
+ throw new ApiError(
+ body.message || '请求失败',
+ {
+ code: String(body.code),
+ status: response.status,
+ data: body,
+ response,
+ config: response.config,
+ },
+ );
+ }
+ return body.data as TParse extends true ? TRes : AxiosResponse<TRes>;
+ }
+
+ return body as TParse extends true ? TRes : AxiosResponse<TRes>;
+};
+
+const notifyError = (error: ApiError, options: RequestOptions) => {
+ if (error.isCanceled || options.showError === false || error.notified) return;
+ error.notified = true;
+ showErrorMessage(error.message);
+};
+
+/** 让共享请求对每个调用方单独支持取消。 */
+const raceWithSignal = <T>(promise: Promise<T>, signal: AbortSignal): Promise<T> => {
+ if (signal.aborted) return Promise.reject(createCanceledError());
+
+ return new Promise<T>((resolve, reject) => {
+ const onAbort = () => reject(createCanceledError());
+ signal.addEventListener('abort', onAbort, { once: true });
+
+ promise.then(
+ (value) => {
+ signal.removeEventListener('abort', onAbort);
+ resolve(value);
+ },
+ (error) => {
+ signal.removeEventListener('abort', onAbort);
+ reject(error);
+ },
+ );
+ });
+};
+
+/**
+ * 发送网络请求。
+ *
+ * 默认合并相同的并发请求、自动提示错误,并且只返回服务端的 data。
+ *
+ * @example
+ * const result = await request<{ keyword: string }, PageResult<User>>({
+ * url: '/api/users',
+ * method: 'GET',
+ * params: { keyword },
+ * });
+ *
+ * // 不解析,拿到完整的 AxiosResponse
+ * const response = await request<void, User[]>(
+ * { url: '/api/users' },
+ * { parse: false },
+ * );
+ *
+ * // 不合并、不自动提示,并通过 signal 取消
+ * const controller = new AbortController();
+ * await request({ url: '/api/users' }, {
+ * merge: false,
+ * showError: false,
+ * signal: controller.signal,
+ * });
+ */
+export const request = async <
+ TReq = unknown,
+ TRes = unknown,
+ TParse extends boolean = true,
+>(
+ config: ApiRequestConfig<TReq>,
+ options: RequestOptions<TParse> = {},
+): Promise<TParse extends true ? TRes : AxiosResponse<TRes>> => {
+ const { signal, merge = true } = options;
+
+ if (signal?.aborted) {
+ throw createCanceledError();
+ }
+
+ let task: Promise<AxiosResponse<TRes>>;
+
+ if (merge) {
+ const key = getRequestKey(config);
+ const existing = pendingRequests.get(key) as Promise<AxiosResponse<TRes>> | undefined;
+
+ if (existing) {
+ task = existing;
+ } else {
+ task = execute<TReq, TRes>(config, options);
+ pendingRequests.set(key, task);
+
+ const cleanup = () => {
+ if (pendingRequests.get(key) === task) {
+ pendingRequests.delete(key);
+ }
+ };
+ task.then(cleanup, cleanup);
+ }
+ } else {
+ task = execute<TReq, TRes>(config, options);
+ }
+
+ try {
+ const response = merge && signal ? await raceWithSignal(task, signal) : await task;
+ console.log('response', response);
+ return formatResponse(response, options);
+ } catch (error) {
+ const apiError = toApiError(error);
+ notifyError(apiError, options);
+ throw apiError;
+ }
+};
--
Gitblit v1.9.3