You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

11 KiB

API 层设计

文档版本:v1.1
最后更新:2025-01-18


目录

  1. API 客户端配置
  2. API 服务模块
  3. Mock API 服务
  4. API/Mock 自动切换

1. API 客户端配置

1.1 Axios 实例配置

// src/services/api/client.ts
import axios, {
  AxiosInstance,
  AxiosError,
  InternalAxiosRequestConfig,
} from "axios";
import { useAppStore } from "@stores/appStore";

// 创建 axios 实例
const apiClient: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 30000,
  headers: {
    "Content-Type": "application/json",
  },
});

// 请求拦截器
apiClient.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    // 添加 token
    const token = localStorage.getItem("token");
    if (token && config.headers) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => Promise.reject(error),
);

// 响应拦截器
apiClient.interceptors.response.use(
  (response) => response.data,
  (error: AxiosError) => {
    // 统一错误处理
    if (error.response?.status === 401) {
      // token 过期,清除用户状态
      useAppStore.getState().setUser(null);
      localStorage.removeItem("token");
      window.location.href = "/login";
    }
    return Promise.reject(error);
  },
);

export default apiClient;

1.2 请求/响应类型

// src/types/api.ts
export interface ApiResponse<T> {
  data: T;
  message?: string;
  code?: number;
}

export interface PaginatedResponse<T> {
  data: T[];
  total: number;
  page: number;
  pageSize: number;
}

export interface ApiError {
  message: string;
  code: string;
  details?: Record<string, any>;
}

2. API 服务模块

2.1 分镜 API 服务

// src/services/api/storyboards.ts
import apiClient from "./client";
import type {
  Storyboard,
  CreateStoryboardDto,
  UpdateStoryboardDto,
  ApiResponse,
} from "@types";

export const storyboardApi = {
  // 获取分镜列表
  getList: async (projectId: string): Promise<Storyboard[]> => {
    const response = await apiClient.get<ApiResponse<Storyboard[]>>(
      `/projects/${projectId}/storyboards`,
    );
    return response.data;
  },

  // 获取单个分镜
  getById: async (id: string): Promise<Storyboard> => {
    const response = await apiClient.get<ApiResponse<Storyboard>>(
      `/storyboards/${id}`,
    );
    return response.data;
  },

  // 创建分镜
  create: async (data: CreateStoryboardDto): Promise<Storyboard> => {
    const response = await apiClient.post<ApiResponse<Storyboard>>(
      `/projects/${data.projectId}/storyboards`,
      data,
    );
    return response.data;
  },

  // 更新分镜
  update: async (
    id: string,
    data: UpdateStoryboardDto,
  ): Promise<Storyboard> => {
    const response = await apiClient.patch<ApiResponse<Storyboard>>(
      `/storyboards/${id}`,
      data,
    );
    return response.data;
  },

  // 删除分镜
  delete: async (id: string): Promise<void> => {
    await apiClient.delete(`/storyboards/${id}`);
  },

  // 调整分镜顺序
  reorder: async (
    projectId: string,
    storyboardIds: string[],
  ): Promise<void> => {
    await apiClient.put(`/projects/${projectId}/storyboards/reorder`, {
      storyboardIds,
    });
  },
};

2.2 项目 API 服务

// src/services/api/projects.ts
import apiClient from "./client";
import type {
  Project,
  CreateProjectDto,
  UpdateProjectDto,
  ApiResponse,
  PaginatedResponse,
} from "@types";

export const projectApi = {
  // 获取项目列表
  getList: async (params?: {
    page?: number;
    pageSize?: number;
    search?: string;
  }): Promise<PaginatedResponse<Project>> => {
    const response = await apiClient.get<
      ApiResponse<PaginatedResponse<Project>>
    >("/projects", { params });
    return response.data;
  },

  // 获取单个项目
  getById: async (id: string): Promise<Project> => {
    const response = await apiClient.get<ApiResponse<Project>>(
      `/projects/${id}`,
    );
    return response.data;
  },

  // 创建项目
  create: async (data: CreateProjectDto): Promise<Project> => {
    const response = await apiClient.post<ApiResponse<Project>>(
      "/projects",
      data,
    );
    return response.data;
  },

  // 更新项目
  update: async (id: string, data: UpdateProjectDto): Promise<Project> => {
    const response = await apiClient.patch<ApiResponse<Project>>(
      `/projects/${id}`,
      data,
    );
    return response.data;
  },

  // 删除项目
  delete: async (id: string): Promise<void> => {
    await apiClient.delete(`/projects/${id}`);
  },
};

2.3 文件上传服务

// src/services/api/upload.ts
import apiClient from "./client";
import type { ApiResponse } from "@types";

export interface UploadResponse {
  url: string;
  filename: string;
  size: number;
}

export const uploadApi = {
  // 上传单个文件
  uploadFile: async (
    file: File,
    onProgress?: (progress: number) => void,
  ): Promise<UploadResponse> => {
    const formData = new FormData();
    formData.append("file", file);

    const response = await apiClient.post<ApiResponse<UploadResponse>>(
      "/upload",
      formData,
      {
        headers: {
          "Content-Type": "multipart/form-data",
        },
        onUploadProgress: (progressEvent) => {
          if (onProgress && progressEvent.total) {
            const progress = Math.round(
              (progressEvent.loaded * 100) / progressEvent.total,
            );
            onProgress(progress);
          }
        },
      },
    );

    return response.data;
  },

  // 批量上传文件
  uploadFiles: async (files: File[]): Promise<UploadResponse[]> => {
    const formData = new FormData();
    files.forEach((file) => {
      formData.append("files", file);
    });

    const response = await apiClient.post<ApiResponse<UploadResponse[]>>(
      "/upload/batch",
      formData,
      {
        headers: {
          "Content-Type": "multipart/form-data",
        },
      },
    );

    return response.data;
  },
};

3. Mock API 服务

3.1 Mock 数据

// src/services/mock/mockData.ts
import type { Storyboard, Project } from "@types";

export const mockData = {
  projects: [
    {
      id: "project-1",
      name: "示例项目",
      description: "这是一个示例项目",
      createdAt: "2025-01-01T00:00:00Z",
      updatedAt: "2025-01-01T00:00:00Z",
    },
  ] as Project[],

  storyboards: [
    {
      id: "storyboard-1",
      projectId: "project-1",
      title: "开场镜头",
      description: "主角登场",
      order: 0,
      duration: 5,
      thumbnailUrl: "/images/storyboard-1.jpg",
      createdAt: "2025-01-01T00:00:00Z",
      updatedAt: "2025-01-01T00:00:00Z",
    },
  ] as Storyboard[],
};

3.2 Mock 客户端

// src/services/mock/mockClient.ts
import { mockData } from "./mockData";
import type {
  Storyboard,
  CreateStoryboardDto,
  UpdateStoryboardDto,
} from "@types";

// 模拟延迟
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export const mockStoryboardApi = {
  getList: async (projectId: string): Promise<Storyboard[]> => {
    await delay(300);
    return mockData.storyboards.filter((s) => s.projectId === projectId);
  },

  getById: async (id: string): Promise<Storyboard> => {
    await delay(200);
    const storyboard = mockData.storyboards.find((s) => s.id === id);
    if (!storyboard) throw new Error("Storyboard not found");
    return storyboard;
  },

  create: async (data: CreateStoryboardDto): Promise<Storyboard> => {
    await delay(500);
    const newStoryboard: Storyboard = {
      id: `storyboard-${Date.now()}`,
      ...data,
      order: mockData.storyboards.filter((s) => s.projectId === data.projectId)
        .length,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    };
    mockData.storyboards.push(newStoryboard);
    return newStoryboard;
  },

  update: async (
    id: string,
    data: UpdateStoryboardDto,
  ): Promise<Storyboard> => {
    await delay(300);
    const index = mockData.storyboards.findIndex((s) => s.id === id);
    if (index === -1) throw new Error("Storyboard not found");
    mockData.storyboards[index] = {
      ...mockData.storyboards[index],
      ...data,
      updatedAt: new Date().toISOString(),
    };
    return mockData.storyboards[index];
  },

  delete: async (id: string): Promise<void> => {
    await delay(300);
    const index = mockData.storyboards.findIndex((s) => s.id === id);
    if (index !== -1) {
      mockData.storyboards.splice(index, 1);
    }
  },

  reorder: async (
    projectId: string,
    storyboardIds: string[],
  ): Promise<void> => {
    await delay(300);
    storyboardIds.forEach((id, index) => {
      const storyboard = mockData.storyboards.find((s) => s.id === id);
      if (storyboard) {
        storyboard.order = index;
      }
    });
  },
};

4. API/Mock 自动切换

4.1 统一导出

// src/services/api/index.ts
import { storyboardApi } from "./storyboards";
import { projectApi } from "./projects";
import { mockStoryboardApi } from "../mock/mockClient";
import { mockProjectApi } from "../mock/mockClient";

const useMock = import.meta.env.VITE_USE_MOCK === "true";

export const api = {
  storyboards: useMock ? mockStoryboardApi : storyboardApi,
  projects: useMock ? mockProjectApi : projectApi,
  // ... 其他模块
};

4.2 使用方式

// 在 Hook 中使用
import { api } from "@services/api";

export function useStoryboards(projectId: string) {
  return useQuery({
    queryKey: ["storyboards", projectId],
    queryFn: () => api.storyboards.getList(projectId),
  });
}

5. 错误处理

5.1 错误类型定义

// src/types/api.ts
export class ApiError extends Error {
  constructor(
    public message: string,
    public code: string,
    public status?: number,
    public details?: Record<string, any>,
  ) {
    super(message);
    this.name = "ApiError";
  }
}

5.2 错误处理 Hook

// src/hooks/useApiError.ts
import { useCallback } from "react";
import { toast } from "@/hooks/use-toast";
import type { ApiError } from "@types";

export function useApiError() {
  const handleError = useCallback((error: unknown) => {
    if (error instanceof ApiError) {
      toast({
        title: "操作失败",
        description: error.message,
        variant: "destructive",
      });
    } else {
      toast({
        title: "未知错误",
        description: "请稍后重试",
        variant: "destructive",
      });
    }
  }, []);

  return { handleError };
}

相关文档


最后更新:2025-01-18 | 版本:v1.1