132 lines
3.8 KiB
TypeScript
132 lines
3.8 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|
|
|
|
/** 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<string, unknown>).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<string | null> {
|
|
try {
|
|
return await decrypt(data);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
|
const { method = 'GET', body, auth = true, encryptBody = USE_ENCRYPTION } = options;
|
|
|
|
const headers: Record<string, string> = {
|
|
'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,
|
|
});
|
|
|
|
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) {
|
|
const msg = parsed?.detail || `请求失败 (${res.status})`;
|
|
if (res.status === 401) {
|
|
clearToken();
|
|
// Only redirect to login if not already on login page
|
|
const currentPath = window.location.pathname;
|
|
if (currentPath !== '/login') {
|
|
window.location.href = '/login';
|
|
}
|
|
}
|
|
throw new Error(msg);
|
|
}
|
|
|
|
return keysToCamel(parsed) as T;
|
|
}
|
|
|
|
// Convenience methods
|
|
export const api = {
|
|
get: <T>(path: string, auth = true) => apiRequest<T>(path, { auth }),
|
|
post: <T>(path: string, body?: unknown, auth = true) => apiRequest<T>(path, { method: 'POST', body, auth }),
|
|
put: <T>(path: string, body?: unknown, auth = true) => apiRequest<T>(path, { method: 'PUT', body, auth }),
|
|
delete: <T>(path: string, auth = true) => apiRequest<T>(path, { method: 'DELETE', auth }),
|
|
};
|