| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561 |
- /**
- * API service layer for backend communication.
- * Provides functions for all API endpoints with error handling.
- * Includes JWT token management and automatic token refresh.
- *
- * Requirements: 10.1, 10.3, 2.1, 3.1, 4.1, 4.4
- */
- import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
- import type { Project } from '../atoms/project-atoms';
- import type { Task } from '../atoms/task-atoms';
- import type { Annotation } from '../atoms/annotation-atoms';
- import { toast } from './toast';
- /**
- * API base URL - defaults to localhost:8002 for development
- */
- const API_BASE_URL = process.env.NX_API_BASE_URL || 'http://localhost:8002';
- /**
- * Axios instance with default configuration
- */
- const apiClient: AxiosInstance = axios.create({
- baseURL: API_BASE_URL,
- timeout: 30000,
- headers: {
- 'Content-Type': 'application/json',
- },
- });
- /**
- * Flag to prevent multiple simultaneous token refresh requests
- */
- let isRefreshing = false;
- /**
- * Queue of failed requests waiting for token refresh
- */
- let failedQueue: Array<{
- resolve: (value?: any) => void;
- reject: (error?: any) => void;
- }> = [];
- /**
- * Process queued requests after token refresh
- */
- const processQueue = (error: any = null) => {
- failedQueue.forEach((promise) => {
- if (error) {
- promise.reject(error);
- } else {
- promise.resolve();
- }
- });
- failedQueue = [];
- };
- /**
- * Get stored authentication tokens from localStorage
- */
- const getStoredTokens = () => {
- try {
- const tokensStr = localStorage.getItem('auth_tokens');
- return tokensStr ? JSON.parse(tokensStr) : null;
- } catch {
- return null;
- }
- };
- /**
- * Update stored authentication tokens in localStorage
- */
- const updateStoredTokens = (tokens: any) => {
- localStorage.setItem('auth_tokens', JSON.stringify(tokens));
- };
- /**
- * Clear stored authentication data
- */
- const clearStoredAuth = () => {
- localStorage.removeItem('auth_tokens');
- localStorage.removeItem('current_user');
- };
- /**
- * Request interceptor to automatically attach JWT token
- */
- apiClient.interceptors.request.use(
- (config: InternalAxiosRequestConfig) => {
- // Skip token attachment for auth endpoints
- if (
- config.url?.includes('/api/auth/register') ||
- config.url?.includes('/api/auth/login') ||
- config.url?.includes('/api/auth/refresh')
- ) {
- return config;
- }
- // Get tokens from localStorage
- const tokens = getStoredTokens();
- // Attach access token to Authorization header
- if (tokens?.access_token) {
- config.headers.Authorization = `Bearer ${tokens.access_token}`;
- }
- return config;
- },
- (error) => {
- return Promise.reject(error);
- }
- );
- /**
- * Response interceptor for error handling and automatic token refresh
- */
- apiClient.interceptors.response.use(
- (response) => response,
- async (error: AxiosError) => {
- const originalRequest = error.config as InternalAxiosRequestConfig & {
- _retry?: boolean;
- };
- // Handle network errors (CORS, connection refused, etc.)
- if (!error.response) {
- console.error('Network Error:', error.message);
-
- // Check if it's a CORS error or connection error
- if (error.message === 'Network Error' || error.code === 'ERR_NETWORK') {
- // Check if user has auth tokens - if yes, might be a CORS issue with expired token
- const tokens = getStoredTokens();
- if (tokens) {
- // Clear auth data and redirect to login
- clearStoredAuth();
- toast.error('网络连接失败或登录已过期,请重新登录', '连接错误', 2000);
-
- setTimeout(() => {
- window.location.href = '/login';
- }, 500);
- } else {
- toast.error('无法连接到服务器,请检查网络连接', '网络错误');
- }
- }
-
- return Promise.reject({
- status: 0,
- message: error.message || '网络错误',
- originalError: error,
- });
- }
- // Handle 401 Unauthorized errors (token expired or invalid)
- if (error.response?.status === 401) {
- const errorData = error.response.data as any;
- // If not a retry and error is due to token expiration, try to refresh
- if (!originalRequest._retry && errorData?.error_type === 'token_expired') {
- // Prevent infinite retry loop
- originalRequest._retry = true;
- // If already refreshing, queue this request
- if (isRefreshing) {
- return new Promise((resolve, reject) => {
- failedQueue.push({ resolve, reject });
- })
- .then(() => {
- // Retry original request with new token
- return apiClient(originalRequest);
- })
- .catch((err) => {
- return Promise.reject(err);
- });
- }
- // Start token refresh process
- isRefreshing = true;
- try {
- const tokens = getStoredTokens();
- if (!tokens?.refresh_token) {
- throw new Error('No refresh token available');
- }
- // Call refresh token endpoint
- const response = await axios.post(
- `${API_BASE_URL}/api/auth/refresh`,
- {
- refresh_token: tokens.refresh_token,
- }
- );
- const newTokens = {
- access_token: response.data.access_token,
- refresh_token: response.data.refresh_token,
- token_type: response.data.token_type,
- };
- // Update stored tokens
- updateStoredTokens(newTokens);
- // Update user info if provided
- if (response.data.user) {
- localStorage.setItem(
- 'current_user',
- JSON.stringify(response.data.user)
- );
- }
- // Process queued requests
- processQueue();
- // Retry original request with new token
- originalRequest.headers.Authorization = `Bearer ${newTokens.access_token}`;
- return apiClient(originalRequest);
- } catch (refreshError) {
- // Token refresh failed - clear auth data and redirect to login
- processQueue(refreshError);
- clearStoredAuth();
- // Show error message and wait a bit before redirecting
- toast.error('登录已过期,请重新登录', '认证失败', 2000);
- // Redirect to login page after a short delay to allow toast to show
- setTimeout(() => {
- window.location.href = '/login';
- }, 500);
- return Promise.reject(refreshError);
- } finally {
- isRefreshing = false;
- }
- } else {
- // 401 error but not token expiration (invalid credentials, etc.)
- // OR token refresh already attempted but still failed
- // Clear auth data and redirect to login
- clearStoredAuth();
-
- // Show error message and wait a bit before redirecting
- toast.error('认证失败,请重新登录', '认证失败', 2000);
-
- // Redirect to login page after a short delay to allow toast to show
- setTimeout(() => {
- window.location.href = '/login';
- }, 500);
-
- return Promise.reject(error);
- }
- }
- // Extract error message from response
- let errorMessage = '发生了意外错误';
- if (error.response?.data) {
- const data = error.response.data as any;
- // Handle FastAPI validation errors (array of error objects)
- if (Array.isArray(data.detail)) {
- // Format validation errors into readable message
- errorMessage = data.detail
- .map((err: any) => {
- const field = err.loc?.join('.') || '字段';
- return `${field}: ${err.msg}`;
- })
- .join('; ');
- }
- // Handle simple string error message
- else if (typeof data.detail === 'string') {
- errorMessage = data.detail;
- }
- // Handle object error message
- else if (typeof data.detail === 'object' && data.detail !== null) {
- errorMessage = JSON.stringify(data.detail);
- }
- // Fallback to error message
- else if (data.message) {
- errorMessage = data.message;
- }
- } else if (error.message) {
- errorMessage = error.message;
- }
- // Log error for debugging
- console.error('API Error:', {
- url: error.config?.url,
- method: error.config?.method,
- status: error.response?.status,
- message: errorMessage,
- originalData: error.response?.data,
- });
- // Show error toast (skip for 401 errors as they're handled above with their own messages)
- if (error.response?.status !== 401) {
- // Determine toast type based on status code
- if (error.response?.status === 403) {
- toast.warning(errorMessage, '权限不足');
- } else if (error.response?.status === 404) {
- toast.warning(errorMessage, '资源不存在');
- } else if (error.response?.status && error.response.status >= 500) {
- toast.error(errorMessage, '服务器错误');
- } else {
- toast.error(errorMessage, '请求失败');
- }
- }
- // Return a rejected promise with formatted error
- return Promise.reject({
- status: error.response?.status,
- message: errorMessage,
- originalError: error,
- });
- }
- );
- // ============================================================================
- // Project API Functions
- // ============================================================================
- /**
- * Project creation data
- */
- export interface ProjectCreateData {
- name: string;
- description: string;
- config: string;
- }
- /**
- * Project update data
- */
- export interface ProjectUpdateData {
- name?: string;
- description?: string;
- config?: string;
- }
- /**
- * List all projects
- */
- export async function listProjects(): Promise<Project[]> {
- const response = await apiClient.get<Project[]>('/api/projects');
- return response.data;
- }
- /**
- * Create a new project
- */
- export async function createProject(
- data: ProjectCreateData
- ): Promise<Project> {
- const response = await apiClient.post<Project>('/api/projects', data);
- return response.data;
- }
- /**
- * Get project by ID
- */
- export async function getProject(projectId: string): Promise<Project> {
- const response = await apiClient.get<Project>(`/api/projects/${projectId}`);
- return response.data;
- }
- /**
- * Update project
- */
- export async function updateProject(
- projectId: string,
- data: ProjectUpdateData
- ): Promise<Project> {
- const response = await apiClient.put<Project>(
- `/api/projects/${projectId}`,
- data
- );
- return response.data;
- }
- /**
- * Delete project
- */
- export async function deleteProject(projectId: string): Promise<void> {
- await apiClient.delete(`/api/projects/${projectId}`);
- }
- /**
- * Get tasks for a specific project
- */
- export async function getProjectTasks(projectId: string): Promise<Task[]> {
- return listTasks({ project_id: projectId });
- }
- // ============================================================================
- // Task API Functions
- // ============================================================================
- /**
- * Task creation data
- */
- export interface TaskCreateData {
- project_id: string;
- name: string;
- data: Record<string, any>;
- assigned_to?: string | null;
- }
- /**
- * Task update data
- */
- export interface TaskUpdateData {
- name?: string;
- data?: Record<string, any>;
- status?: 'pending' | 'in_progress' | 'completed';
- assigned_to?: string | null;
- }
- /**
- * Task list filters
- */
- export interface TaskListFilters {
- project_id?: string;
- status?: string;
- assigned_to?: string;
- }
- /**
- * List all tasks with optional filters
- */
- export async function listTasks(filters?: TaskListFilters): Promise<Task[]> {
- const params = new URLSearchParams();
- if (filters?.project_id) params.append('project_id', filters.project_id);
- if (filters?.status) params.append('status', filters.status);
- if (filters?.assigned_to) params.append('assigned_to', filters.assigned_to);
- const response = await apiClient.get<Task[]>('/api/tasks', { params });
- return response.data;
- }
- /**
- * Create a new task
- */
- export async function createTask(data: TaskCreateData): Promise<Task> {
- const response = await apiClient.post<Task>('/api/tasks', data);
- return response.data;
- }
- /**
- * Get task by ID
- */
- export async function getTask(taskId: string): Promise<Task> {
- const response = await apiClient.get<Task>(`/api/tasks/${taskId}`);
- return response.data;
- }
- /**
- * Update task
- */
- export async function updateTask(
- taskId: string,
- data: TaskUpdateData
- ): Promise<Task> {
- const response = await apiClient.put<Task>(`/api/tasks/${taskId}`, data);
- return response.data;
- }
- /**
- * Delete task
- */
- export async function deleteTask(taskId: string): Promise<void> {
- await apiClient.delete(`/api/tasks/${taskId}`);
- }
- // ============================================================================
- // Annotation API Functions
- // ============================================================================
- /**
- * Annotation creation data
- */
- export interface AnnotationCreateData {
- task_id: string;
- user_id: string;
- result: Record<string, any>;
- }
- /**
- * Annotation update data
- */
- export interface AnnotationUpdateData {
- result: Record<string, any>;
- }
- /**
- * Annotation list filters
- */
- export interface AnnotationListFilters {
- task_id?: string;
- user_id?: string;
- }
- /**
- * List all annotations with optional filters
- */
- export async function listAnnotations(
- filters?: AnnotationListFilters
- ): Promise<Annotation[]> {
- const params = new URLSearchParams();
- if (filters?.task_id) params.append('task_id', filters.task_id);
- if (filters?.user_id) params.append('user_id', filters.user_id);
- const response = await apiClient.get<Annotation[]>('/api/annotations', {
- params,
- });
- return response.data;
- }
- /**
- * Create a new annotation
- */
- export async function createAnnotation(
- data: AnnotationCreateData
- ): Promise<Annotation> {
- const response = await apiClient.post<Annotation>('/api/annotations', data);
- return response.data;
- }
- /**
- * Get annotation by ID
- */
- export async function getAnnotation(annotationId: string): Promise<Annotation> {
- const response = await apiClient.get<Annotation>(
- `/api/annotations/${annotationId}`
- );
- return response.data;
- }
- /**
- * Update annotation
- */
- export async function updateAnnotation(
- annotationId: string,
- data: AnnotationUpdateData
- ): Promise<Annotation> {
- const response = await apiClient.put<Annotation>(
- `/api/annotations/${annotationId}`,
- data
- );
- return response.data;
- }
- /**
- * Get annotations by task ID
- */
- export async function getTaskAnnotations(taskId: string): Promise<Annotation[]> {
- const response = await apiClient.get<Annotation[]>(
- `/api/annotations/tasks/${taskId}/annotations`
- );
- return response.data;
- }
- /**
- * Export the configured axios instance for advanced usage
- */
- export { apiClient };
|