فهرست منبع

feat(app): 添加 Alova 请求库支持

cc12458 1 هفته پیش
والد
کامیت
8e503b0679

+ 22 - 0
packages/request/alova.d.ts

@@ -0,0 +1,22 @@
+import 'alova';
+
+declare module 'alova' {
+  export interface AlovaCustomTypes {
+    /* eslint-disable perfectionist/sort-union-types */
+    /* eslint-disable perfectionist/sort-object-types */
+    meta: {
+      /* Token认证拦截器 */
+      authRole?: 'none' | 'login' | 'logout' | 'refreshToken';
+      login?: true;
+      logout?: true;
+      refreshToken?: true;
+      visitor?: true;
+
+      /* 解析响应 */
+      notParseResponse?: boolean;
+      notParseResponseBody?: boolean;
+    };
+  }
+}
+
+export {};

+ 28 - 0
packages/request/package.json

@@ -0,0 +1,28 @@
+{
+  "name": "@six/request",
+  "version": "0.0.0",
+  "license": "MIT",
+  "type": "module",
+  "exports": {
+    ".": {
+      "types": "./src/index.ts",
+      "default": "./src/index.ts"
+    },
+    "./alova": {
+      "types": "./alova.d.ts",
+      "default": "./alova.d.ts"
+    }
+  },
+  "dependencies": {
+    "@vben/hooks": "workspace:*",
+    "@vben/locales": "workspace:*",
+    "@vben/stores": "workspace:*",
+    "@vben/utils": "workspace:*",
+    "alova": "catalog:",
+    "qs": "catalog:"
+  },
+  "devDependencies": {
+    "@types/qs": "catalog:",
+    "axios-mock-adapter": "catalog:"
+  }
+}

+ 98 - 0
packages/request/src/alova.ts

@@ -0,0 +1,98 @@
+import type {
+  RespondedHandler,
+  RespondedHandlerRecord,
+  ResponseCompleteHandler,
+  ResponseErrorHandler,
+} from 'alova';
+
+import type { CreateAlovaClient, RequestedHandler } from './types';
+
+import { createAlova } from 'alova';
+import adapterFetch from 'alova/fetch';
+import vueHook from 'alova/vue';
+
+export const createAlovaClient = <CreateAlovaClient>new Proxy(createAlova, {
+  apply(target, thisArg, argArray) {
+    type AG = any;
+
+    const interceptor = {
+      request: new Set<RequestedHandler<AG>>(),
+      success: new Set<RespondedHandler<AG>>(),
+      error: new Set<ResponseErrorHandler<AG>>(),
+      complete: new Set<ResponseCompleteHandler<AG>>(),
+    };
+    const beforeRequest: RequestedHandler<any> = async (...args) => {
+      for (const _interceptor of interceptor.request)
+        await _interceptor(...args);
+    };
+    const responded = {
+      onSuccess: async (response, method) => {
+        let data = void 0;
+        for (const _interceptor of interceptor.success) {
+          try {
+            const result = await _interceptor(response, method);
+            if (result instanceof Response) response = result;
+            else if (result) data = result;
+          } catch (error) {
+            await responded.onError(error, method);
+            throw error;
+          }
+        }
+        return data;
+      },
+      onError: async (...args) => {
+        for (const _interceptor of interceptor.error)
+          await _interceptor(...args);
+      },
+      onComplete: async (...args) => {
+        for (const _interceptor of interceptor.complete)
+          await _interceptor(...args);
+      },
+    } satisfies RespondedHandlerRecord<AG>;
+
+    const METHODS = [
+      'request',
+      'delete',
+      'patch',
+      'post',
+      'put',
+      'get',
+      'head',
+      'options',
+    ];
+    const methodTypeRegExp = new RegExp(`^${METHODS.join('|')}$`, 'i');
+
+    const { tokenAuthentication, ...options } = argArray[0] ?? {};
+    const instance = target.call(thisArg, {
+      beforeRequest:
+        tokenAuthentication?.onAuthRequired(beforeRequest) ?? beforeRequest,
+      responded:
+        tokenAuthentication?.onResponseRefreshToken(responded) ?? responded,
+      statesHook: vueHook,
+      requestAdapter: adapterFetch(),
+      ...options,
+    });
+
+    return new Proxy(instance, {
+      get(target, p, receiver) {
+        if (p === 'interceptor')
+          return (type: keyof typeof interceptor, fn: any) => {
+            interceptor[type]?.add(fn);
+            return () => interceptor[type]?.delete(fn);
+          };
+
+        return typeof p !== 'string' ||
+          /* 特殊处理 options */ p === 'options' ||
+          !methodTypeRegExp.test(p)
+          ? Reflect.get(target, p, receiver)
+          : Reflect.get(
+              target,
+              p === '_options'
+                ? 'Options'
+                : p.replaceAll(/\b\w/g, (char) => char.toUpperCase()),
+              receiver,
+            ).bind(target);
+      },
+    });
+  },
+});

+ 0 - 0
packages/request/src/composables.ts


+ 118 - 0
packages/request/src/index.ts

@@ -0,0 +1,118 @@
+import type { SimpleAlovaOptions } from './types';
+
+import { useAppConfig } from '@vben/hooks';
+import { useAccessStore } from '@vben/stores';
+
+import { createServerTokenAuthentication } from 'alova/client';
+
+import { createAlovaClient } from './alova';
+
+const { requestBaseURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
+
+export default function createRequestClient(options?: SimpleAlovaOptions) {
+  const store = options?.tokenStore ?? useAccessStore();
+  const transform = options?.transform ?? ((body) => body);
+  const interceptor = options?.interceptor ?? {
+    async onSuccess(response, method) {
+      if (response.status > 400) {
+        const message = response.statusText;
+        // eslint-disable-next-line no-throw-literal
+        throw { message };
+      }
+
+      /* prettier-ignore */
+      if (method.meta?.notParseResponse) return response.clone();
+
+      /* prettier-ignore */
+      const [contentType] = response.headers.get('content-type')?.split(';') ?? [];
+      switch (contentType) {
+        case 'application/json': {
+          const body = transform(await response.json(), method);
+          if (method.meta?.notParseResponseBody) return body;
+          if (body.code === 0) return body.data;
+          throw body;
+        }
+        case 'application/octet-stream': {
+          /* prettier-ignore */
+          const disposition = response.headers.get('content-disposition') ?? method.url;
+          /* prettier-ignore */
+          const fileName = disposition.match(/fileName=["']?([^"';]+)["']?/i)?.[1];
+          return fileName
+            ? {
+                fileName: decodeURIComponent(fileName),
+                source: await response.blob(),
+              }
+            : response;
+        }
+        default: {
+          return response;
+        }
+      }
+    },
+  };
+
+  const instance = createAlovaClient({
+    id: options?.id,
+    baseURL:
+      options?.baseURL ??
+      requestBaseURL[options?.id?.toString().toLocaleLowerCase() ?? 'url'] ??
+      requestBaseURL.url,
+    tokenAuthentication: createServerTokenAuthentication({
+      refreshTokenOnSuccess: {
+        async isExpired(response) {
+          return response.status === 401;
+        },
+        async handler(response) {
+          if (store.refreshToken) {
+            console.log(response);
+          }
+        },
+        metaMatches: {
+          authRole: 'refreshToken',
+          refreshToken: true,
+        },
+      },
+      login: {
+        async handler(response, method) {
+          const data = await interceptor.onSuccess?.(response.clone(), method);
+          store.loginExpired = false;
+          store.accessToken = data.accessToken;
+          store.refreshToken = data.refreshToken;
+        },
+        metaMatches: {
+          authRole: 'login',
+          login: true,
+        },
+      },
+      logout: {
+        async handler() {
+          store.loginExpired = true;
+          store.accessToken = null;
+          store.refreshToken = null;
+        },
+        metaMatches: {
+          authRole: 'logout',
+          logout: true,
+        },
+      },
+      assignToken(method) {
+        method.config.headers.Authorization ??= store.accessToken;
+      },
+      visitorMeta: {
+        authRole: 'none',
+        visitor: true,
+      },
+    }),
+    cacheLogger: import.meta.env.DEV,
+  });
+  for (const [type, _interceptor] of Object.entries(interceptor)) {
+    instance.interceptor(<any>type.slice(2).toLowerCase(), _interceptor);
+  }
+
+  return instance;
+}
+
+export * from './alova';
+export * from './composables';
+export * from './types';
+export * from 'alova/client';

+ 158 - 0
packages/request/src/types.ts

@@ -0,0 +1,158 @@
+import type {
+  Alova,
+  AlovaDefaultCacheAdapter,
+  AlovaGenerics,
+  AlovaGlobalCacheAdapter,
+  AlovaMethodCommonConfig,
+  AlovaMethodCreateConfig,
+  AlovaOptions,
+  AlovaRequestAdapter,
+  Method,
+  RequestBody,
+  RespondedAlovaGenerics,
+  RespondedHandler,
+  RespondedHandlerRecord,
+  ResponseCompleteHandler,
+  ResponseErrorHandler,
+  StatesExport,
+  StatesHook,
+} from 'alova';
+import type { TokenAuthenticationResult } from 'alova/client';
+import type { FetchRequestInit } from 'alova/fetch';
+import type { VueHookExportType } from 'alova/vue';
+
+export type AlovaFetchRequestGenerics<
+  Responded = unknown,
+  Transformed = unknown,
+> = AlovaGenerics<
+  Responded,
+  Transformed,
+  FetchRequestInit,
+  Response,
+  Headers,
+  AlovaDefaultCacheAdapter,
+  AlovaDefaultCacheAdapter,
+  VueHookExportType<unknown>
+>;
+
+export type RequestedHandler<AG extends AlovaGenerics> = (
+  method: Method<AG>,
+) => Promise<void> | void;
+
+/* eslint-disable perfectionist/sort-interfaces */
+export interface AlovaClient<AG extends AlovaGenerics> extends Alova<AG> {
+  request<Responded = unknown, Transformed = unknown>(
+    config: AlovaMethodCommonConfig<AG, Responded, Transformed>,
+  ): Method<RespondedAlovaGenerics<AG, Responded, Transformed>>;
+
+  get<Responded = unknown, Transformed = unknown>(
+    url: string,
+    config?: AlovaMethodCreateConfig<AG, Responded, Transformed>,
+  ): Method<RespondedAlovaGenerics<AG, Responded, Transformed>>;
+
+  post<Responded = unknown, Transformed = unknown>(
+    url: string,
+    data?: RequestBody,
+    config?: AlovaMethodCreateConfig<AG, Responded, Transformed>,
+  ): Method<RespondedAlovaGenerics<AG, Responded, Transformed>>;
+
+  put<Responded = unknown, Transformed = unknown>(
+    url: string,
+    data?: RequestBody,
+    config?: AlovaMethodCreateConfig<AG, Responded, Transformed>,
+  ): Method<RespondedAlovaGenerics<AG, Responded, Transformed>>;
+
+  delete<Responded = unknown, Transformed = unknown>(
+    url: string,
+    data?: RequestBody,
+    config?: AlovaMethodCreateConfig<AG, Responded, Transformed>,
+  ): Method<RespondedAlovaGenerics<AG, Responded, Transformed>>;
+
+  head<Responded = unknown, Transformed = unknown>(
+    url: string,
+    config?: AlovaMethodCreateConfig<AG, Responded, Transformed>,
+  ): Method<RespondedAlovaGenerics<AG, Responded, Transformed>>;
+
+  _options<Responded = unknown, Transformed = unknown>(
+    url: string,
+    config?: AlovaMethodCreateConfig<AG, Responded, Transformed>,
+  ): Method<RespondedAlovaGenerics<AG, Responded, Transformed>>;
+
+  patch<Responded = unknown, Transformed = unknown>(
+    url: string,
+    data?: RequestBody,
+    config?: AlovaMethodCreateConfig<AG, Responded, Transformed>,
+  ): Method<RespondedAlovaGenerics<AG, Responded, Transformed>>;
+
+  interceptor(type: 'request', fn: RequestedHandler<AG>): () => void;
+  interceptor(type: 'success', fn: RespondedHandler<AG>): () => void;
+  interceptor(type: 'error', fn: ResponseErrorHandler<AG>): () => void;
+  interceptor(type: 'complete', fn: ResponseCompleteHandler<AG>): () => void;
+}
+
+export interface ResponseBodyData<D = unknown> {
+  code: number;
+  data?: D;
+  message?: string;
+}
+
+export type SimpleAlovaOptions<
+  AG extends AlovaGenerics = AlovaFetchRequestGenerics,
+> = Pick<AlovaOptions<any>, 'baseURL' | 'id'> & {
+  interceptor?: RespondedHandlerRecord<AG>;
+  tokenStore?: {
+    accessToken?: null | string;
+    loginExpired?: boolean;
+    refreshToken?: null | string;
+  };
+
+  transform?: <Responded = unknown, Transformed = unknown>(
+    data: Record<string, any>,
+    method: Method<RespondedAlovaGenerics<AG, Responded, Transformed>>,
+  ) => ResponseBodyData<Responded>;
+};
+
+export interface CreateAlovaClient {
+  <
+    RequestConfig,
+    Response,
+    ResponseHeader,
+    L1Cache extends AlovaGlobalCacheAdapter = AlovaDefaultCacheAdapter,
+    L2Cache extends AlovaGlobalCacheAdapter = AlovaDefaultCacheAdapter,
+    SE extends StatesExport<any> = StatesExport<any>,
+  >(
+    options: Partial<
+      Omit<
+        AlovaOptions<
+          AlovaGenerics<
+            any,
+            any,
+            RequestConfig,
+            Response,
+            ResponseHeader,
+            L1Cache,
+            L2Cache,
+            SE
+          >
+        >,
+        'beforeRequest' | 'responded'
+      > & {
+        tokenAuthentication: TokenAuthenticationResult<
+          StatesHook<SE>,
+          AlovaRequestAdapter<RequestConfig, Response, ResponseHeader>
+        >;
+      }
+    >,
+  ): AlovaClient<
+    AlovaGenerics<
+      any,
+      any,
+      RequestConfig,
+      Response,
+      ResponseHeader,
+      L1Cache,
+      L2Cache,
+      SE
+    >
+  >;
+}

+ 6 - 0
packages/request/tsconfig.json

@@ -0,0 +1,6 @@
+{
+  "$schema": "https://json.schemastore.org/tsconfig",
+  "extends": "@vben/tsconfig/web.json",
+  "include": ["src", "alova.d.ts"],
+  "exclude": ["node_modules"]
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 75 - 547
pnpm-lock.yaml


+ 4 - 2
pnpm-workspace.yaml

@@ -63,9 +63,11 @@ catalog:
   '@vue/reactivity': ^3.5.17
   '@vue/shared': ^3.5.17
   '@vue/test-utils': ^2.4.6
-  '@vueuse/core': ^13.4.0
-  '@vueuse/integrations': ^13.4.0
+  '@vueuse/core': ^13.7.0
+  '@vueuse/integrations': ^13.7.0
   '@vueuse/motion': ^3.0.3
+  '@vueuse/router': ^13.9.0
+  alova: ^3.3.4
   ant-design-vue: ^4.2.6
   archiver: ^7.0.1
   autoprefixer: ^10.4.21

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است