/** * Real API client for connecting to the FastAPI backend. * Handles auth tokens, request/response encryption, snake_case→camelCase conversion. */ import { encrypt, decrypt, isCryptoAvailable } from './crypto'; const BASE_URL = import.meta.env.VITE_API_BASE || 'http://localhost:8000'; const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY && isCryptoAvailable(); interface RequestOptions { method?: string; body?: unknown; auth?: boolean; encryptBody?: boolean; signal?: AbortSignal; skipAuthRedirect?: boolean; } /** Convert snake_case string to camelCase */ function toCamel(s: string): string { return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); } /** Recursively convert all snake_case keys in an object/array to camelCase */ function keysToCamel(obj: unknown): unknown { if (Array.isArray(obj)) return obj.map(keysToCamel); if (obj !== null && typeof obj === 'object') { return Object.fromEntries( Object.entries(obj as Record).map(([k, v]) => [toCamel(k), keysToCamel(v)]) ); } return obj; } function getToken(): string | null { return localStorage.getItem('auth_token'); } export function setToken(token: string): void { localStorage.setItem('auth_token', token); } export function clearToken(): void { localStorage.removeItem('auth_token'); } /** Try to decrypt response data, return null if it fails */ async function tryDecrypt(data: string): Promise { try { return await decrypt(data); } catch { return null; } } export async function apiRequest(path: string, options: RequestOptions = {}): Promise { const { method = 'GET', body, auth = true, encryptBody = USE_ENCRYPTION, signal, skipAuthRedirect } = options; const headers: Record = { 'Content-Type': 'application/json', }; if (auth) { const token = getToken(); if (token) headers['Authorization'] = `Bearer ${token}`; } let bodyStr: string | undefined; if (body !== undefined) { const json = JSON.stringify(body); if (encryptBody) { headers['X-Encrypted'] = 'true'; bodyStr = JSON.stringify({ data: await encrypt(json) }); } else { bodyStr = json; } } const res = await fetch(`${BASE_URL}/api${path}`, { method, headers, body: bodyStr, signal, }); if (res.status === 204) return undefined as T; const text = await res.text(); if (!text) return undefined as T; // Parse the response body let parsed: any; try { parsed = JSON.parse(text); } catch { throw new Error(`响应解析失败 (${res.status})`); } // If the response is encrypted, decrypt it const hasEncryptedData = parsed && typeof parsed.data === 'string'; if (hasEncryptedData && encryptBody) { const decrypted = await tryDecrypt(parsed.data); if (decrypted !== null) { parsed = JSON.parse(decrypted); } // If decryption fails, fall through — parsed still has {data: "..."} // which means the response was NOT actually encrypted (just has a "data" field) } // Handle error responses if (!res.ok) { let msg = parsed?.detail?.message || parsed?.message || `请求失败 (${res.status})`; if (typeof parsed?.detail === 'string') { msg = parsed.detail; } // 检测"被踢出"错误 — 单设备登录互斥 if (res.status === 401 && typeof parsed?.detail === 'string' && parsed.detail.includes('其他设备登录')) { window.dispatchEvent(new CustomEvent('kicked-out', { detail: { message: parsed.detail } })); throw new Error(parsed.detail); // 阻止后续自动跳转,由通知按钮处理 } if (res.status === 401 && !options.skipAuthRedirect) { clearToken(); window.location.href = '/login'; } throw new Error(msg); } return keysToCamel(parsed) as T; } // Convenience methods export const api = { get: (path: string, options: boolean | { auth?: boolean; signal?: AbortSignal; skipAuthRedirect?: boolean } = true) => { const opts = typeof options === 'boolean' ? { auth: options } : options; return apiRequest(path, opts); }, post: (path: string, body?: unknown, auth = true, skipAuthRedirect = false) => apiRequest(path, { method: 'POST', body, auth, skipAuthRedirect }), put: (path: string, body?: unknown, auth = true) => apiRequest(path, { method: 'PUT', body, auth }), delete: (path: string, body?: unknown, auth = true) => apiRequest(path, { method: 'DELETE', body, auth }), };