api.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  1. /**
  2. * API service layer for backend communication.
  3. * Provides functions for all API endpoints with error handling.
  4. * Includes JWT token management and automatic token refresh.
  5. *
  6. * Requirements: 10.1, 10.3, 2.1, 3.1, 4.1, 4.4
  7. */
  8. import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
  9. import type { Project } from '../atoms/project-atoms';
  10. import type { Task } from '../atoms/task-atoms';
  11. import type { Annotation } from '../atoms/annotation-atoms';
  12. import { toast } from './toast';
  13. /**
  14. * API base URL - defaults to localhost:8002 for development
  15. */
  16. const API_BASE_URL = process.env.NX_API_BASE_URL || 'http://localhost:8002';
  17. /**
  18. * Axios instance with default configuration
  19. */
  20. const apiClient: AxiosInstance = axios.create({
  21. baseURL: API_BASE_URL,
  22. timeout: 30000,
  23. headers: {
  24. 'Content-Type': 'application/json',
  25. },
  26. });
  27. /**
  28. * Flag to prevent multiple simultaneous token refresh requests
  29. */
  30. let isRefreshing = false;
  31. /**
  32. * Queue of failed requests waiting for token refresh
  33. */
  34. let failedQueue: Array<{
  35. resolve: (value?: any) => void;
  36. reject: (error?: any) => void;
  37. }> = [];
  38. /**
  39. * Process queued requests after token refresh
  40. */
  41. const processQueue = (error: any = null) => {
  42. failedQueue.forEach((promise) => {
  43. if (error) {
  44. promise.reject(error);
  45. } else {
  46. promise.resolve();
  47. }
  48. });
  49. failedQueue = [];
  50. };
  51. /**
  52. * Get stored authentication tokens from localStorage
  53. */
  54. const getStoredTokens = () => {
  55. try {
  56. const tokensStr = localStorage.getItem('auth_tokens');
  57. return tokensStr ? JSON.parse(tokensStr) : null;
  58. } catch {
  59. return null;
  60. }
  61. };
  62. /**
  63. * Update stored authentication tokens in localStorage
  64. */
  65. const updateStoredTokens = (tokens: any) => {
  66. localStorage.setItem('auth_tokens', JSON.stringify(tokens));
  67. };
  68. /**
  69. * Clear stored authentication data
  70. */
  71. const clearStoredAuth = () => {
  72. localStorage.removeItem('auth_tokens');
  73. localStorage.removeItem('current_user');
  74. };
  75. /**
  76. * Request interceptor to automatically attach JWT token
  77. */
  78. apiClient.interceptors.request.use(
  79. (config: InternalAxiosRequestConfig) => {
  80. // Skip token attachment for auth endpoints
  81. if (
  82. config.url?.includes('/api/auth/register') ||
  83. config.url?.includes('/api/auth/login') ||
  84. config.url?.includes('/api/auth/refresh')
  85. ) {
  86. return config;
  87. }
  88. // Get tokens from localStorage
  89. const tokens = getStoredTokens();
  90. // Attach access token to Authorization header
  91. if (tokens?.access_token) {
  92. config.headers.Authorization = `Bearer ${tokens.access_token}`;
  93. }
  94. return config;
  95. },
  96. (error) => {
  97. return Promise.reject(error);
  98. }
  99. );
  100. /**
  101. * Response interceptor for error handling and automatic token refresh
  102. */
  103. apiClient.interceptors.response.use(
  104. (response) => response,
  105. async (error: AxiosError) => {
  106. const originalRequest = error.config as InternalAxiosRequestConfig & {
  107. _retry?: boolean;
  108. };
  109. // Handle network errors (CORS, connection refused, etc.)
  110. if (!error.response) {
  111. console.error('Network Error:', error.message);
  112. // Check if it's a CORS error or connection error
  113. if (error.message === 'Network Error' || error.code === 'ERR_NETWORK') {
  114. // Check if user has auth tokens - if yes, might be a CORS issue with expired token
  115. const tokens = getStoredTokens();
  116. if (tokens) {
  117. // Clear auth data and redirect to login
  118. clearStoredAuth();
  119. toast.error('网络连接失败或登录已过期,请重新登录', '连接错误', 2000);
  120. setTimeout(() => {
  121. window.location.href = '/login';
  122. }, 500);
  123. } else {
  124. toast.error('无法连接到服务器,请检查网络连接', '网络错误');
  125. }
  126. }
  127. return Promise.reject({
  128. status: 0,
  129. message: error.message || '网络错误',
  130. originalError: error,
  131. });
  132. }
  133. // Handle 401 Unauthorized errors (token expired or invalid)
  134. if (error.response?.status === 401) {
  135. const errorData = error.response.data as any;
  136. // If not a retry and error is due to token expiration, try to refresh
  137. if (!originalRequest._retry && errorData?.error_type === 'token_expired') {
  138. // Prevent infinite retry loop
  139. originalRequest._retry = true;
  140. // If already refreshing, queue this request
  141. if (isRefreshing) {
  142. return new Promise((resolve, reject) => {
  143. failedQueue.push({ resolve, reject });
  144. })
  145. .then(() => {
  146. // Retry original request with new token
  147. return apiClient(originalRequest);
  148. })
  149. .catch((err) => {
  150. return Promise.reject(err);
  151. });
  152. }
  153. // Start token refresh process
  154. isRefreshing = true;
  155. try {
  156. const tokens = getStoredTokens();
  157. if (!tokens?.refresh_token) {
  158. throw new Error('No refresh token available');
  159. }
  160. // Call refresh token endpoint
  161. const response = await axios.post(
  162. `${API_BASE_URL}/api/auth/refresh`,
  163. {
  164. refresh_token: tokens.refresh_token,
  165. }
  166. );
  167. const newTokens = {
  168. access_token: response.data.access_token,
  169. refresh_token: response.data.refresh_token,
  170. token_type: response.data.token_type,
  171. };
  172. // Update stored tokens
  173. updateStoredTokens(newTokens);
  174. // Update user info if provided
  175. if (response.data.user) {
  176. localStorage.setItem(
  177. 'current_user',
  178. JSON.stringify(response.data.user)
  179. );
  180. }
  181. // Process queued requests
  182. processQueue();
  183. // Retry original request with new token
  184. originalRequest.headers.Authorization = `Bearer ${newTokens.access_token}`;
  185. return apiClient(originalRequest);
  186. } catch (refreshError) {
  187. // Token refresh failed - clear auth data and redirect to login
  188. processQueue(refreshError);
  189. clearStoredAuth();
  190. // Show error message and wait a bit before redirecting
  191. toast.error('登录已过期,请重新登录', '认证失败', 2000);
  192. // Redirect to login page after a short delay to allow toast to show
  193. setTimeout(() => {
  194. window.location.href = '/login';
  195. }, 500);
  196. return Promise.reject(refreshError);
  197. } finally {
  198. isRefreshing = false;
  199. }
  200. } else {
  201. // 401 error but not token expiration (invalid credentials, etc.)
  202. // OR token refresh already attempted but still failed
  203. // Clear auth data and redirect to login
  204. clearStoredAuth();
  205. // Show error message and wait a bit before redirecting
  206. toast.error('认证失败,请重新登录', '认证失败', 2000);
  207. // Redirect to login page after a short delay to allow toast to show
  208. setTimeout(() => {
  209. window.location.href = '/login';
  210. }, 500);
  211. return Promise.reject(error);
  212. }
  213. }
  214. // Extract error message from response
  215. let errorMessage = '发生了意外错误';
  216. if (error.response?.data) {
  217. const data = error.response.data as any;
  218. // Handle FastAPI validation errors (array of error objects)
  219. if (Array.isArray(data.detail)) {
  220. // Format validation errors into readable message
  221. errorMessage = data.detail
  222. .map((err: any) => {
  223. const field = err.loc?.join('.') || '字段';
  224. return `${field}: ${err.msg}`;
  225. })
  226. .join('; ');
  227. }
  228. // Handle simple string error message
  229. else if (typeof data.detail === 'string') {
  230. errorMessage = data.detail;
  231. }
  232. // Handle object error message
  233. else if (typeof data.detail === 'object' && data.detail !== null) {
  234. errorMessage = JSON.stringify(data.detail);
  235. }
  236. // Fallback to error message
  237. else if (data.message) {
  238. errorMessage = data.message;
  239. }
  240. } else if (error.message) {
  241. errorMessage = error.message;
  242. }
  243. // Log error for debugging
  244. console.error('API Error:', {
  245. url: error.config?.url,
  246. method: error.config?.method,
  247. status: error.response?.status,
  248. message: errorMessage,
  249. originalData: error.response?.data,
  250. });
  251. // Show error toast (skip for 401 errors as they're handled above with their own messages)
  252. if (error.response?.status !== 401) {
  253. // Determine toast type based on status code
  254. if (error.response?.status === 403) {
  255. toast.warning(errorMessage, '权限不足');
  256. } else if (error.response?.status === 404) {
  257. toast.warning(errorMessage, '资源不存在');
  258. } else if (error.response?.status && error.response.status >= 500) {
  259. toast.error(errorMessage, '服务器错误');
  260. } else {
  261. toast.error(errorMessage, '请求失败');
  262. }
  263. }
  264. // Return a rejected promise with formatted error
  265. return Promise.reject({
  266. status: error.response?.status,
  267. message: errorMessage,
  268. originalError: error,
  269. });
  270. }
  271. );
  272. // ============================================================================
  273. // Project API Functions
  274. // ============================================================================
  275. /**
  276. * Project creation data
  277. */
  278. export interface ProjectCreateData {
  279. name: string;
  280. description: string;
  281. config: string;
  282. }
  283. /**
  284. * Project update data
  285. */
  286. export interface ProjectUpdateData {
  287. name?: string;
  288. description?: string;
  289. config?: string;
  290. }
  291. /**
  292. * List all projects
  293. */
  294. export async function listProjects(): Promise<Project[]> {
  295. const response = await apiClient.get<Project[]>('/api/projects');
  296. return response.data;
  297. }
  298. /**
  299. * Create a new project
  300. */
  301. export async function createProject(
  302. data: ProjectCreateData
  303. ): Promise<Project> {
  304. const response = await apiClient.post<Project>('/api/projects', data);
  305. return response.data;
  306. }
  307. /**
  308. * Get project by ID
  309. */
  310. export async function getProject(projectId: string): Promise<Project> {
  311. const response = await apiClient.get<Project>(`/api/projects/${projectId}`);
  312. return response.data;
  313. }
  314. /**
  315. * Update project
  316. */
  317. export async function updateProject(
  318. projectId: string,
  319. data: ProjectUpdateData
  320. ): Promise<Project> {
  321. const response = await apiClient.put<Project>(
  322. `/api/projects/${projectId}`,
  323. data
  324. );
  325. return response.data;
  326. }
  327. /**
  328. * Delete project
  329. */
  330. export async function deleteProject(projectId: string): Promise<void> {
  331. await apiClient.delete(`/api/projects/${projectId}`);
  332. }
  333. /**
  334. * Get tasks for a specific project
  335. */
  336. export async function getProjectTasks(projectId: string): Promise<Task[]> {
  337. return listTasks({ project_id: projectId });
  338. }
  339. // ============================================================================
  340. // Task API Functions
  341. // ============================================================================
  342. /**
  343. * Task creation data
  344. */
  345. export interface TaskCreateData {
  346. project_id: string;
  347. name: string;
  348. data: Record<string, any>;
  349. assigned_to?: string | null;
  350. }
  351. /**
  352. * Task update data
  353. */
  354. export interface TaskUpdateData {
  355. name?: string;
  356. data?: Record<string, any>;
  357. status?: 'pending' | 'in_progress' | 'completed';
  358. assigned_to?: string | null;
  359. }
  360. /**
  361. * Task list filters
  362. */
  363. export interface TaskListFilters {
  364. project_id?: string;
  365. status?: string;
  366. assigned_to?: string;
  367. }
  368. /**
  369. * List all tasks with optional filters
  370. */
  371. export async function listTasks(filters?: TaskListFilters): Promise<Task[]> {
  372. const params = new URLSearchParams();
  373. if (filters?.project_id) params.append('project_id', filters.project_id);
  374. if (filters?.status) params.append('status', filters.status);
  375. if (filters?.assigned_to) params.append('assigned_to', filters.assigned_to);
  376. const response = await apiClient.get<Task[]>('/api/tasks', { params });
  377. return response.data;
  378. }
  379. /**
  380. * Create a new task
  381. */
  382. export async function createTask(data: TaskCreateData): Promise<Task> {
  383. const response = await apiClient.post<Task>('/api/tasks', data);
  384. return response.data;
  385. }
  386. /**
  387. * Get task by ID
  388. */
  389. export async function getTask(taskId: string): Promise<Task> {
  390. const response = await apiClient.get<Task>(`/api/tasks/${taskId}`);
  391. return response.data;
  392. }
  393. /**
  394. * Update task
  395. */
  396. export async function updateTask(
  397. taskId: string,
  398. data: TaskUpdateData
  399. ): Promise<Task> {
  400. const response = await apiClient.put<Task>(`/api/tasks/${taskId}`, data);
  401. return response.data;
  402. }
  403. /**
  404. * Delete task
  405. */
  406. export async function deleteTask(taskId: string): Promise<void> {
  407. await apiClient.delete(`/api/tasks/${taskId}`);
  408. }
  409. // ============================================================================
  410. // Annotation API Functions
  411. // ============================================================================
  412. /**
  413. * Annotation creation data
  414. */
  415. export interface AnnotationCreateData {
  416. task_id: string;
  417. user_id: string;
  418. result: Record<string, any>;
  419. }
  420. /**
  421. * Annotation update data
  422. */
  423. export interface AnnotationUpdateData {
  424. result: Record<string, any>;
  425. }
  426. /**
  427. * Annotation list filters
  428. */
  429. export interface AnnotationListFilters {
  430. task_id?: string;
  431. user_id?: string;
  432. }
  433. /**
  434. * List all annotations with optional filters
  435. */
  436. export async function listAnnotations(
  437. filters?: AnnotationListFilters
  438. ): Promise<Annotation[]> {
  439. const params = new URLSearchParams();
  440. if (filters?.task_id) params.append('task_id', filters.task_id);
  441. if (filters?.user_id) params.append('user_id', filters.user_id);
  442. const response = await apiClient.get<Annotation[]>('/api/annotations', {
  443. params,
  444. });
  445. return response.data;
  446. }
  447. /**
  448. * Create a new annotation
  449. */
  450. export async function createAnnotation(
  451. data: AnnotationCreateData
  452. ): Promise<Annotation> {
  453. const response = await apiClient.post<Annotation>('/api/annotations', data);
  454. return response.data;
  455. }
  456. /**
  457. * Get annotation by ID
  458. */
  459. export async function getAnnotation(annotationId: string): Promise<Annotation> {
  460. const response = await apiClient.get<Annotation>(
  461. `/api/annotations/${annotationId}`
  462. );
  463. return response.data;
  464. }
  465. /**
  466. * Update annotation
  467. */
  468. export async function updateAnnotation(
  469. annotationId: string,
  470. data: AnnotationUpdateData
  471. ): Promise<Annotation> {
  472. const response = await apiClient.put<Annotation>(
  473. `/api/annotations/${annotationId}`,
  474. data
  475. );
  476. return response.data;
  477. }
  478. /**
  479. * Get annotations by task ID
  480. */
  481. export async function getTaskAnnotations(taskId: string): Promise<Annotation[]> {
  482. const response = await apiClient.get<Annotation[]>(
  483. `/api/annotations/tasks/${taskId}/annotations`
  484. );
  485. return response.data;
  486. }
  487. /**
  488. * Export the configured axios instance for advanced usage
  489. */
  490. export { apiClient };