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