wavelet init

This commit is contained in:
ryan
2026-06-18 15:24:48 +08:00
parent d6a7011885
commit 99738bbc17
714 changed files with 139987 additions and 0 deletions
@@ -0,0 +1,305 @@
import axios, {AxiosError, AxiosResponse, CancelTokenSource, InternalAxiosRequestConfig} from 'axios';
import {toast} from 'sonner';
import {apiConfig} from './config';
import {
ApiErrorBase,
ForbiddenError,
NetworkError,
NotFoundError,
ServerError,
TimeoutError,
UnauthorizedError,
ValidationError,
} from './errors';
import {ApiError, ApiResponse} from './types';
/**
* API 客户端实例
* 统一处理请求配置、响应解析和错误处理
*/
const apiClient = axios.create({
baseURL: apiConfig.baseURL,
timeout: apiConfig.timeout,
withCredentials: apiConfig.withCredentials,
headers: {
'Content-Type': 'application/json',
},
});
/**
* 请求取消令牌存储
*/
const cancelTokens = new Map<string, CancelTokenSource>();
/**
* 请求缓存存储
* 存储正在进行的请求 Promise,避免重复请求
*/
const pendingRequests = new Map<string, Promise<AxiosResponse<ApiResponse>>>();
/**
* 生成请求的唯一键
* 包含方法、URL 和请求数据的哈希,确保不同参数的请求不会被误取消
*/
function getRequestKey(config: { method?: string; url?: string; data?: unknown }): string {
const baseKey = `${ config.method?.toUpperCase() }_${ config.url }`;
/* 序列化加入键中 */
if (config.data) {
try {
const dataHash = JSON.stringify(config.data);
return `${ baseKey }_${ dataHash }`;
} catch {
// 失败使用基础键
return baseKey;
}
}
return baseKey;
}
/**
* 请求拦截器
* 添加取消令牌和其他配置
*/
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const requestKey = getRequestKey(config);
const source = axios.CancelToken.source();
config.cancelToken = source.token;
cancelTokens.set(requestKey, source);
return config;
},
(error: unknown) => Promise.reject(error),
);
/**
* 直接启动登录流程
* @param currentPath - 当前路径,用于登录成功后重定向回来
*/
function initiateLogin(currentPath: string): Promise<never> {
if (currentPath.startsWith('/login') || currentPath.startsWith('/callback')) {
return Promise.reject(new UnauthorizedError());
}
if (typeof window !== 'undefined') {
sessionStorage.setItem('redirect_after_login', currentPath);
const loginUrl = new URL('/login', window.location.origin);
loginUrl.searchParams.set('callbackUrl', currentPath);
window.location.href = loginUrl.toString();
}
return new Promise<never>(() => { });
}
/**
* 响应拦截器
* 处理 API 响应和统一错误处理
*/
apiClient.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const requestKey = getRequestKey(response.config);
cancelTokens.delete(requestKey);
pendingRequests.delete(requestKey);
const resData = response.data as ApiError & ApiResponse;
if (resData && resData.error_msg) {
return Promise.reject(new ApiErrorBase(resData.error_msg));
}
return response;
},
(error: AxiosError<ApiError>) => {
if (error.config) {
const requestKey = getRequestKey(error.config);
cancelTokens.delete(requestKey);
pendingRequests.delete(requestKey);
}
/* 请求被取消时静默处理 */
if (axios.isCancel(error)) {
const cancelError = new Error(error.message || '请求已被取消') as Error & { __CANCEL__?: boolean };
cancelError.__CANCEL__ = true;
return Promise.reject(cancelError);
}
/* 401 未授权错误 */
if (error.response?.status === 401) {
return initiateLogin(window.location.pathname + window.location.search);
}
/* 403 权限不足错误 */
if (error.response?.status === 403) {
return Promise.reject(
new ForbiddenError(error.response.data?.error_msg || '权限不足,请过盾后重试', error.response.data?.error_code, error.response.data?.details),
);
}
/* 404 资源未找到错误 */
if (error.response?.status === 404) {
return Promise.reject(
new NotFoundError(error.response.data?.error_msg || '请求的资源不存在'),
);
}
/* 400 验证错误 */
if (error.response?.status === 400) {
return Promise.reject(
new ValidationError(
error.response.data?.error_msg || '请求参数验证失败',
error.response.data?.details,
),
);
}
/* 429 速率限制错误 */
if (error.response?.status === 429) {
const retryAfter = error.response.headers?.['retry-after'];
const message = error.response.data?.error_msg ||
`请求过于频繁,请 ${ retryAfter || '稍后' } 秒后重试`;
toast.error('请求频率限制', {
description: message,
id: 'rate-limit-error',
});
return Promise.reject(
new ApiErrorBase(message, 'RATE_LIMITED', 429),
);
}
/* 5xx 服务器错误 */
if (error.response && error.response.status >= 500) {
return Promise.reject(
new ServerError(
error.response.data?.error_msg || '服务器内部错误,请稍后重试',
error.response.status,
),
);
}
/* 网络超时错误 */
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
return Promise.reject(new TimeoutError());
}
/* 网络连接错误(ECONNREFUSED, ERR_NETWORK 等) */
if (
!error.response ||
error.code === 'ECONNREFUSED' ||
error.code === 'ERR_NETWORK' ||
error.message?.includes('Network Error') ||
error.message?.includes('Failed to fetch')
) {
return Promise.reject(
new NetworkError('无法连接到服务器,请确认后端服务已启动'),
);
}
/* 其他后端返回的错误 */
if (error.response?.data?.error_msg) {
return Promise.reject(
new ApiErrorBase(
error.response.data.error_msg,
error.response.data.error_code,
error.response.status,
error.response.data.details,
),
);
}
/* 兜底错误 */
return Promise.reject(
new ApiErrorBase(error.message || '网络请求失败'),
);
},
);
/**
* 取消指定请求
* @param method - 请求方法
* @param url - 请求 URL
*/
export function cancelRequest(method: string, url: string): void {
const requestKey = `${ method.toUpperCase() }_${ url }`;
const source = cancelTokens.get(requestKey);
if (source) {
source.cancel('请求已被手动取消');
cancelTokens.delete(requestKey);
}
}
/**
* 取消所有请求
*/
export function cancelAllRequests(): void {
cancelTokens.forEach((source) => {
source.cancel('所有请求已被取消');
});
cancelTokens.clear();
}
/**
* 创建带有请求去重功能的请求方法
* @param method HTTP 方法名
* @param hasBody 是否包含请求体
*/
function createRequestMethod(
method: 'get' | 'post' | 'put' | 'patch' | 'delete',
hasBody: boolean
) {
if (hasBody) {
return <T = ApiResponse>(url: string, data?: unknown, config?: InternalAxiosRequestConfig) => {
const requestKey = getRequestKey({ method: method.toUpperCase(), url, data });
if (pendingRequests.has(requestKey)) {
return pendingRequests.get(requestKey) as Promise<AxiosResponse<T>>;
}
const promise = apiClient[method]<T>(url, data, config);
pendingRequests.set(requestKey, promise as Promise<AxiosResponse<ApiResponse>>);
promise.then(
() => pendingRequests.delete(requestKey),
() => pendingRequests.delete(requestKey),
);
return promise;
};
}
return <T = ApiResponse>(url: string, config?: InternalAxiosRequestConfig) => {
const requestKey = getRequestKey({ method: method.toUpperCase(), url, data: config?.params });
if (pendingRequests.has(requestKey)) {
return pendingRequests.get(requestKey) as Promise<AxiosResponse<T>>;
}
const promise = apiClient[method]<T>(url, config);
pendingRequests.set(requestKey, promise as Promise<AxiosResponse<ApiResponse>>);
promise.then(
() => pendingRequests.delete(requestKey),
() => pendingRequests.delete(requestKey),
);
return promise;
};
}
/**
* 包装的 API 客户端
* 在原有 axios 实例基础上添加请求缓存功能
*/
const wrappedApiClient = {
get: createRequestMethod('get', false) as <T = ApiResponse>(url: string, config?: InternalAxiosRequestConfig) => Promise<AxiosResponse<T>>,
post: createRequestMethod('post', true) as <T = ApiResponse>(url: string, data?: unknown, config?: InternalAxiosRequestConfig) => Promise<AxiosResponse<T>>,
put: createRequestMethod('put', true) as <T = ApiResponse>(url: string, data?: unknown, config?: InternalAxiosRequestConfig) => Promise<AxiosResponse<T>>,
patch: createRequestMethod('patch', true) as <T = ApiResponse>(url: string, data?: unknown, config?: InternalAxiosRequestConfig) => Promise<AxiosResponse<T>>,
delete: createRequestMethod('delete', false) as <T = ApiResponse>(url: string, config?: InternalAxiosRequestConfig) => Promise<AxiosResponse<T>>,
};
export default wrappedApiClient;
@@ -0,0 +1,188 @@
import apiClient from './api-client';
import {ApiResponse} from './types';
import {InternalAxiosRequestConfig} from 'axios';
/**
* 服务基类
* 提供通用的 HTTP 方法封装
*
* @example
* ```typescript
* class UserService extends BaseService {
* protected static readonly basePath = '/api/v1/users';
*
* static async getAll() {
* return this.get<User[]>('/');
* }
* }
* ```
*/
export class BaseService {
/**
* API 基础路径
* 子类必须重写此属性
*/
protected static readonly basePath: string = '';
/**
* 获取完整的 API 路径
* @param path - API 路径
* @returns 完整路径
*/
protected static getFullPath(path: string): string {
return `${ this.basePath }${ path }`;
}
/**
* GET 请求
* @template T - 响应数据类型
* @param path - API 路径
* @param params - 查询参数
* @param config - 额外的请求配置
* @returns 响应数据
*/
protected static async get<T>(
path: string,
params?: Record<string, unknown>,
config?: InternalAxiosRequestConfig,
): Promise<T> {
const requestConfig: InternalAxiosRequestConfig = {
...config,
params,
} as InternalAxiosRequestConfig;
const response = await apiClient.get<ApiResponse<T>>(
this.getFullPath(path),
requestConfig,
);
return response.data.data;
}
/**
* POST 请求
* @template T - 响应数据类型
* @param path - API 路径
* @param data - 请求数据
* @param config - 额外的请求配置
* @returns 响应数据
*/
protected static async post<T>(
path: string,
data?: unknown,
config?: InternalAxiosRequestConfig,
): Promise<T> {
const response = await apiClient.post<ApiResponse<T>>(
this.getFullPath(path),
data,
config,
);
return response.data.data;
}
/**
* PUT 请求
* @template T - 响应数据类型
* @param path - API 路径
* @param data - 请求数据
* @param config - 额外的请求配置
* @returns 响应数据
*/
protected static async put<T>(
path: string,
data?: unknown,
config?: InternalAxiosRequestConfig,
): Promise<T> {
const response = await apiClient.put<ApiResponse<T>>(
this.getFullPath(path),
data,
config,
);
return response.data.data;
}
/**
* PATCH 请求
* @template T - 响应数据类型
* @param path - API 路径
* @param data - 请求数据
* @param config - 额外的请求配置
* @returns 响应数据
*/
protected static async patch<T>(
path: string,
data?: unknown,
config?: InternalAxiosRequestConfig,
): Promise<T> {
const response = await apiClient.patch<ApiResponse<T>>(
this.getFullPath(path),
data,
config,
);
return response.data.data;
}
/**
* DELETE 请求
* @template T - 响应数据类型
* @param path - API 路径
* @param params - 查询参数
* @param config - 额外的请求配置
* @returns 响应数据
*/
protected static async delete<T>(
path: string,
params?: Record<string, unknown>,
config?: InternalAxiosRequestConfig,
): Promise<T> {
const requestConfig: InternalAxiosRequestConfig = {
...config,
params,
} as InternalAxiosRequestConfig;
const response = await apiClient.delete<ApiResponse<T>>(
this.getFullPath(path),
requestConfig,
);
return response.data.data;
}
/**
* 原始 GET 请求(用于特殊 API 端点)
* @template T - 响应数据类型
* @param url - 完整 URL
* @param params - 查询参数
* @returns 响应数据(不经过 response.data.data 解包)
*
* @remarks
* 仅用于不遵循标准响应格式的特殊端点(如 /api.php)
*/
protected static async rawGet<T>(
url: string,
params?: unknown,
): Promise<T> {
const response = await apiClient.get<T>(
url,
{ params } as InternalAxiosRequestConfig,
);
return response.data;
}
/**
* 原始 POST 请求(用于特殊 API 端点)
* @template T - 响应数据类型
* @param url - 完整 URL
* @param data - 请求数据
* @param config - 额外的请求配置
* @returns 响应数据(不经过 response.data.data 解包)
*
* @remarks
* 仅用于不遵循标准响应格式的特殊端点(如 /api.php)
*/
protected static async rawPost<T>(
url: string,
data?: unknown,
config?: InternalAxiosRequestConfig,
): Promise<T> {
const response = await apiClient.post<T>(url, data, config);
return response.data;
}
}
@@ -0,0 +1,27 @@
/**
* API 配置
*/
/**
* 获取 API 基础 URL
* @returns API 基础 URL
*/
export function getApiBaseUrl(): string {
if (typeof window === 'undefined') {
return process.env.WAVELET_BACKEND_URL || process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || 'http://localhost:8000';
}
return process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || '';
}
/**
* API 配置选项
*/
export const apiConfig = {
/** Basic URL */
baseURL: getApiBaseUrl(),
/** 超时时间(毫秒) */
timeout: 15000,
/** 携带凭证 */
withCredentials: true,
} as const;
@@ -0,0 +1,105 @@
/**
* API 错误类型定义
*/
/**
* API 错误基类
*/
export class ApiErrorBase extends Error {
constructor(
message: string,
public readonly code?: string,
public readonly statusCode?: number,
public readonly details?: unknown,
) {
super(message);
this.name = 'ApiError';
Object.setPrototypeOf(this, ApiErrorBase.prototype);
}
}
/**
* 网络错误
*/
export class NetworkError extends ApiErrorBase {
constructor(message = '网络连接失败,请检查您的网络') {
super(message);
this.name = 'NetworkError';
Object.setPrototypeOf(this, NetworkError.prototype);
}
}
/**
* 超时错误
*/
export class TimeoutError extends ApiErrorBase {
constructor(message = '请求超时,请稍后重试') {
super(message);
this.name = 'TimeoutError';
Object.setPrototypeOf(this, TimeoutError.prototype);
}
}
/**
* 未授权错误 (401)
*/
export class UnauthorizedError extends ApiErrorBase {
constructor(message = '未授权,请先登录') {
super(message, 'UNAUTHORIZED', 401);
this.name = 'UnauthorizedError';
Object.setPrototypeOf(this, UnauthorizedError.prototype);
}
}
/**
* 权限不足错误 (403)
*/
export class ForbiddenError extends ApiErrorBase {
constructor(message = '权限不足', code = 'FORBIDDEN', details?: unknown) {
super(message, code, 403, details);
this.name = 'ForbiddenError';
Object.setPrototypeOf(this, ForbiddenError.prototype);
}
}
/**
* 资源未找到错误 (404)
*/
export class NotFoundError extends ApiErrorBase {
constructor(message = '请求的资源不存在') {
super(message, 'NOT_FOUND', 404);
this.name = 'NotFoundError';
Object.setPrototypeOf(this, NotFoundError.prototype);
}
}
/**
* 服务器错误 (5xx)
*/
export class ServerError extends ApiErrorBase {
constructor(message = '服务器内部错误,请稍后重试', statusCode = 500) {
super(message, 'SERVER_ERROR', statusCode);
this.name = 'ServerError';
Object.setPrototypeOf(this, ServerError.prototype);
}
}
/**
* 验证错误 (400)
*/
export class ValidationError extends ApiErrorBase {
constructor(message = '请求参数验证失败', details?: unknown) {
super(message, 'VALIDATION_ERROR', 400, details);
this.name = 'ValidationError';
Object.setPrototypeOf(this, ValidationError.prototype);
}
}
/**
* 检查错误是否为请求取消错误
* @param error - 错误对象
* @returns 是否为取消错误
*/
export function isCancelError(error: unknown): boolean {
return error !== null && typeof error === 'object' && ('__CANCEL__' in error && (error as { __CANCEL__?: boolean }).__CANCEL__ === true || ('message' in error && (error as { message?: string }).message === '请求已被取消'));
}
@@ -0,0 +1,27 @@
/**
* 核心服务模块
* 提供 API 请求的基础设施
*/
export { default as apiClient, cancelRequest, cancelAllRequests } from './api-client';
export { BaseService } from './base.service';
export { apiConfig } from './config';
export {
ApiErrorBase,
NetworkError,
TimeoutError,
UnauthorizedError,
ForbiddenError,
NotFoundError,
ServerError,
ValidationError,
isCancelError,
} from './errors';
export type {
ApiResponse,
ApiError,
PaginationParams,
PaginationResponse,
RequestConfig,
} from './types';
@@ -0,0 +1,64 @@
/**
* API 响应通用结构
* @template T - 数据类型
*/
export interface ApiResponse<T = unknown> {
/** 响应数据 */
data: T;
/** 响应消息 */
message?: string;
/** 响应状态码 */
code?: number;
}
/**
* API 错误响应结构
*/
export interface ApiError {
/** 错误消息 */
error_msg: string;
/** 错误代码 */
error_code?: string;
/** 错误详情 */
details?: unknown;
}
/**
* 分页请求参数
*/
export interface PaginationParams {
/** 页码,从 1 开始 */
page: number;
/** 每页数量 */
page_size: number;
}
/**
* 分页响应数据
* @template T - 列表项类型
*/
export interface PaginationResponse<T> {
/** 数据列表 */
items: T[];
/** 总数 */
total: number;
/** 当前页码 */
page: number;
/** 每页数量 */
page_size: number;
/** 总页数 */
total_pages: number;
}
/**
* HTTP 请求配置
*/
export interface RequestConfig {
/** 请求超时时间(毫秒) */
timeout?: number;
/** 请求头 */
headers?: Record<string, string>;
/** 是否携带凭证 */
withCredentials?: boolean;
}