1
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Real API client for connecting to the FastAPI backend.
|
||||
* Handles auth tokens, request/response encryption, snake_case→camelCase conversion.
|
||||
*/
|
||||
|
||||
import { encrypt, decrypt } from './crypto';
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY;
|
||||
|
||||
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();
|
||||
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 }),
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* AES-256-GCM encryption/decryption for API request/response.
|
||||
* Uses Web Crypto API with a shared symmetric key.
|
||||
*/
|
||||
|
||||
const ALGO = 'AES-GCM';
|
||||
const IV_LENGTH = 12;
|
||||
const TAG_LENGTH = 128;
|
||||
|
||||
let cryptoKey: CryptoKey | null = null;
|
||||
|
||||
async function getCryptoKey(): Promise<CryptoKey> {
|
||||
if (cryptoKey) return cryptoKey;
|
||||
const keyB64 = import.meta.env.VITE_ENCRYPTION_KEY || '';
|
||||
if (!keyB64) throw new Error('VITE_ENCRYPTION_KEY not configured');
|
||||
let keyBytes = Uint8Array.from(atob(keyB64), c => c.charCodeAt(0));
|
||||
// AES-256 requires exactly 32 bytes — pad or truncate to match backend
|
||||
if (keyBytes.length !== 32) {
|
||||
const padded = new Uint8Array(32);
|
||||
padded.set(keyBytes.slice(0, 32));
|
||||
keyBytes = padded;
|
||||
}
|
||||
cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: ALGO }, false, ['encrypt', 'decrypt']);
|
||||
return cryptoKey;
|
||||
}
|
||||
|
||||
export async function encrypt(plaintext: string): Promise<string> {
|
||||
const key = await getCryptoKey();
|
||||
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
||||
const encoded = new TextEncoder().encode(plaintext);
|
||||
const cipherBuf = await crypto.subtle.encrypt({ name: ALGO, iv, tagLength: TAG_LENGTH }, key, encoded);
|
||||
const cipherBytes = new Uint8Array(cipherBuf);
|
||||
// Prepend IV to ciphertext
|
||||
const combined = new Uint8Array(iv.length + cipherBytes.length);
|
||||
combined.set(iv);
|
||||
combined.set(cipherBytes, iv.length);
|
||||
return btoa(String.fromCharCode(...combined));
|
||||
}
|
||||
|
||||
export async function decrypt(cipherB64: string): Promise<string> {
|
||||
const key = await getCryptoKey();
|
||||
const combined = Uint8Array.from(atob(cipherB64), c => c.charCodeAt(0));
|
||||
const iv = combined.slice(0, IV_LENGTH);
|
||||
const cipherBytes = combined.slice(IV_LENGTH);
|
||||
const plainBuf = await crypto.subtle.decrypt({ name: ALGO, iv, tagLength: TAG_LENGTH }, key, cipherBytes);
|
||||
return new TextDecoder().decode(plainBuf);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* Admin API layer - connects to the FastAPI backend.
|
||||
*/
|
||||
|
||||
import { api, setToken, clearToken } from './client';
|
||||
import type {
|
||||
User, CreditRecord, Project, GenerationRecord, GenerationParams,
|
||||
Industry, AdminUser, AdminStats, ModelConfig, SystemConfig, AdminNotification,
|
||||
} from '../types';
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────
|
||||
|
||||
export async function login(username: string, password: string, captchaToken?: string, rememberMe?: boolean): Promise<User> {
|
||||
const res = await api.post<{ accessToken: string; user: User }>('/auth/admin-login', { username, password, captcha_token: captchaToken, remember_me: rememberMe || false }, false);
|
||||
setToken(res.accessToken);
|
||||
return res.user;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await api.post('/auth/logout');
|
||||
clearToken();
|
||||
}
|
||||
|
||||
export async function getUser(): Promise<User | null> {
|
||||
try {
|
||||
return await api.get<User>('/auth/me');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function changePassword(oldPwd: string, newPwd: string): Promise<void> {
|
||||
await api.post('/auth/change-password', { old_password: oldPwd, new_password: newPwd });
|
||||
}
|
||||
|
||||
// ── Projects ──────────────────────────────────────────────
|
||||
|
||||
export async function getProjects(): Promise<Project[]> {
|
||||
return api.get<Project[]>('/projects');
|
||||
}
|
||||
|
||||
export async function createProject(name: string, industry: Industry): Promise<Project> {
|
||||
return api.post<Project>('/projects', { name, industry });
|
||||
}
|
||||
|
||||
export async function deleteProject(id: string): Promise<void> {
|
||||
await api.delete(`/projects/${id}`);
|
||||
}
|
||||
|
||||
// ── Generation ────────────────────────────────────────────
|
||||
|
||||
export async function getRecords(projectId?: string): Promise<GenerationRecord[]> {
|
||||
const q = projectId ? `?project_id=${projectId}` : '';
|
||||
return api.get<GenerationRecord[]>(`/generation-records${q}`);
|
||||
}
|
||||
|
||||
export async function optimizePrompt(projectId: string, params: GenerationParams): Promise<any> {
|
||||
return api.post('/generation-records/optimize', { project_id: projectId, ...params });
|
||||
}
|
||||
|
||||
export async function generateVideo(recordId: string): Promise<GenerationRecord> {
|
||||
return api.post<GenerationRecord>(`/generation-records/${recordId}/generate`);
|
||||
}
|
||||
|
||||
// ── Credits ───────────────────────────────────────────────
|
||||
|
||||
export async function getCredits(): Promise<{ credits: number; records: CreditRecord[] }> {
|
||||
return api.get('/credits');
|
||||
}
|
||||
|
||||
// ── Notifications ─────────────────────────────────────────
|
||||
|
||||
export async function getNotifications(): Promise<AdminNotification[]> {
|
||||
return api.get('/notifications');
|
||||
}
|
||||
|
||||
export async function getUnreadCount(): Promise<number> {
|
||||
const res = await api.get<{ count: number }>('/notifications/unread-count');
|
||||
return res.count;
|
||||
}
|
||||
|
||||
export async function markNotificationRead(id: string): Promise<void> {
|
||||
await api.put(`/notifications/${id}/read`);
|
||||
}
|
||||
|
||||
// ── Admin ─────────────────────────────────────────────────
|
||||
|
||||
export async function getAdminStats(): Promise<AdminStats> {
|
||||
return api.get('/admin/stats');
|
||||
}
|
||||
|
||||
export async function getAdminUsers(search?: string): Promise<AdminUser[]> {
|
||||
const q = search ? `?search=${encodeURIComponent(search)}` : '';
|
||||
return api.get(`/admin/users${q}`);
|
||||
}
|
||||
|
||||
export async function adjustCredits(userId: string, amount: number, description: string): Promise<void> {
|
||||
await api.post(`/admin/users/${userId}/credits`, { amount, description });
|
||||
}
|
||||
|
||||
export async function toggleUserStatus(userId: string, isActive: boolean): Promise<void> {
|
||||
await api.put(`/admin/users/${userId}/status`, { is_active: isActive });
|
||||
}
|
||||
|
||||
export async function getModelConfigs(): Promise<ModelConfig[]> {
|
||||
return api.get('/admin/model-configs');
|
||||
}
|
||||
|
||||
export async function saveModelConfig(config: Partial<ModelConfig> & { id?: string }): Promise<ModelConfig> {
|
||||
if (config.id) return api.put(`/admin/model-configs/${config.id}`, config);
|
||||
return api.post('/admin/model-configs', config);
|
||||
}
|
||||
|
||||
export async function deleteModelConfig(id: string): Promise<void> {
|
||||
await api.delete(`/admin/model-configs/${id}`);
|
||||
}
|
||||
|
||||
export async function getSystemConfigs(): Promise<SystemConfig[]> {
|
||||
return api.get('/admin/system-configs');
|
||||
}
|
||||
|
||||
export async function updateSystemConfig(id: string, value: string): Promise<void> {
|
||||
await api.put(`/admin/system-configs/${id}`, { value });
|
||||
}
|
||||
|
||||
export async function uploadPdf(file: File, configKey: string): Promise<{ url: string }> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('config_key', configKey);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const baseUrl = (import.meta as any).env?.VITE_API_URL || 'http://localhost:8000/api';
|
||||
const res = await fetch(`${baseUrl}/admin/upload-pdf`, {
|
||||
method: 'POST',
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) throw new Error('上传失败');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getCreditRecords(filters?: { user_id?: string; type?: string }): Promise<any> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.user_id) params.set('user_id', filters.user_id);
|
||||
if (filters?.type) params.set('type', filters.type);
|
||||
const q = params.toString() ? `?${params}` : '';
|
||||
return api.get(`/admin/credit-records${q}`);
|
||||
}
|
||||
|
||||
export async function getIndustryConfigs(): Promise<any[]> {
|
||||
return api.get('/admin/industry-configs');
|
||||
}
|
||||
|
||||
export async function saveIndustryConfig(config: any): Promise<any> {
|
||||
if (config.id) return api.put(`/admin/industry-configs/${config.id}`, config);
|
||||
return api.post('/admin/industry-configs', config);
|
||||
}
|
||||
|
||||
export async function deleteIndustryConfig(id: string): Promise<void> {
|
||||
await api.delete(`/admin/industry-configs/${id}`);
|
||||
}
|
||||
|
||||
export async function getVideoEngines(): Promise<any[]> {
|
||||
return api.get('/admin/video-engines');
|
||||
}
|
||||
|
||||
export async function saveVideoEngine(engine: any): Promise<any> {
|
||||
if (engine.id) return api.put(`/admin/video-engines/${engine.id}`, engine);
|
||||
return api.post('/admin/video-engines', engine);
|
||||
}
|
||||
|
||||
export async function deleteVideoEngine(id: string): Promise<void> {
|
||||
await api.delete(`/admin/video-engines/${id}`);
|
||||
}
|
||||
|
||||
export async function getImageEngines(): Promise<any[]> {
|
||||
return api.get('/admin/image-engines');
|
||||
}
|
||||
|
||||
export async function saveImageEngine(engine: any): Promise<any> {
|
||||
if (engine.id) return api.put(`/admin/image-engines/${engine.id}`, engine);
|
||||
return api.post('/admin/image-engines', engine);
|
||||
}
|
||||
|
||||
export async function deleteImageEngine(id: string): Promise<void> {
|
||||
await api.delete(`/admin/image-engines/${id}`);
|
||||
}
|
||||
|
||||
export async function getCreditRatios(): Promise<any[]> {
|
||||
return api.get('/admin/credit-ratios');
|
||||
}
|
||||
|
||||
export async function saveCreditRatio(ratio: any): Promise<any> {
|
||||
if (ratio.id) return api.put(`/admin/credit-ratios/${ratio.id}`, ratio);
|
||||
return api.post('/admin/credit-ratios', ratio);
|
||||
}
|
||||
|
||||
export async function deleteCreditRatio(id: string): Promise<void> {
|
||||
await api.delete(`/admin/credit-ratios/${id}`);
|
||||
}
|
||||
|
||||
export async function getPaymentConfigs(): Promise<any[]> {
|
||||
return api.get('/admin/payment-configs');
|
||||
}
|
||||
|
||||
export async function updatePaymentConfig(id: string, value: string): Promise<void> {
|
||||
await api.put(`/admin/payment-configs/${id}`, { value });
|
||||
}
|
||||
|
||||
export async function getAdminNotifications(): Promise<{ total: number; items: any[] }> {
|
||||
return api.get('/admin/notifications');
|
||||
}
|
||||
|
||||
export async function createAdminNotification(data: { title: string; content: string; type: string; target_user_id?: string }): Promise<void> {
|
||||
await api.post('/admin/notifications', data);
|
||||
}
|
||||
|
||||
export async function deleteAdminNotification(id: string): Promise<void> {
|
||||
await api.delete(`/admin/notifications/${id}`);
|
||||
}
|
||||
|
||||
export async function getNotificationReadUsers(id: string): Promise<{ total: number; items: { userId: string; username: string; readAt: string }[] }> {
|
||||
return api.get(`/admin/notifications/${id}/read-users`);
|
||||
}
|
||||
|
||||
// ── Menu Config ─────────────────────────────────────────
|
||||
|
||||
export async function getMenuConfigs(): Promise<any[]> {
|
||||
return api.get('/admin/menu-configs');
|
||||
}
|
||||
|
||||
export async function saveMenuConfig(config: any): Promise<any> {
|
||||
if (config.id) return api.put(`/admin/menu-configs/${config.id}`, config);
|
||||
return api.post('/admin/menu-configs', config);
|
||||
}
|
||||
|
||||
export async function deleteMenuConfig(id: string): Promise<void> {
|
||||
await api.delete(`/admin/menu-configs/${id}`);
|
||||
}
|
||||
|
||||
// ── User Creation ───────────────────────────────────────
|
||||
|
||||
export async function createUser(data: { username: string; password: string; email?: string; phone?: string; credits: number; user_type: string; allowed_menus?: string[] | null }): Promise<any> {
|
||||
return api.post('/admin/users', data);
|
||||
}
|
||||
|
||||
export async function updateUserMenus(userId: string, allowedMenus: string[] | null): Promise<void> {
|
||||
await api.put(`/admin/users/${userId}/menus`, { allowed_menus: allowedMenus });
|
||||
}
|
||||
|
||||
export async function resetUserPassword(userId: string, newPassword: string): Promise<void> {
|
||||
await api.put(`/admin/users/${userId}/reset-password`, { new_password: newPassword });
|
||||
}
|
||||
|
||||
export async function adminChangePassword(oldPwd: string, newPwd: string): Promise<void> {
|
||||
await api.post('/admin/change-password', { old_password: oldPwd, new_password: newPwd });
|
||||
}
|
||||
|
||||
// ── Recharge Packages ───────────────────────────────────
|
||||
|
||||
export async function getRechargePackages(): Promise<any[]> {
|
||||
return api.get('/admin/recharge-packages');
|
||||
}
|
||||
|
||||
export async function saveRechargePackage(pkg: any): Promise<any> {
|
||||
if (pkg.id) return api.put(`/admin/recharge-packages/${pkg.id}`, pkg);
|
||||
return api.post('/admin/recharge-packages', pkg);
|
||||
}
|
||||
|
||||
export async function deleteRechargePackage(id: string): Promise<void> {
|
||||
await api.delete(`/admin/recharge-packages/${id}`);
|
||||
}
|
||||
|
||||
// ── Operation Logs ──────────────────────────────────────
|
||||
|
||||
export async function getOperationLogs(page?: number): Promise<{ total: number; items: any[] }> {
|
||||
const q = page ? `?page=${page}` : '';
|
||||
return api.get(`/admin/operation-logs${q}`);
|
||||
}
|
||||
|
||||
// ── Generation Records (Admin) ─────────────────────────────
|
||||
|
||||
export async function getAdminGenerationRecords(params?: {
|
||||
userId?: string; status?: string; page?: number; pageSize?: number;
|
||||
}): Promise<{ total: number; items: any[] }> {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.userId) q.set('user_id', params.userId);
|
||||
if (params?.status) q.set('status', params.status);
|
||||
if (params?.page) q.set('page', String(params.page));
|
||||
if (params?.pageSize) q.set('page_size', String(params.pageSize));
|
||||
const qs = q.toString();
|
||||
return api.get(`/admin/generation-records${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export async function adminUpdateGenerationStatus(
|
||||
recordId: string, status: string, videoUrl?: string
|
||||
): Promise<void> {
|
||||
await api.put(`/admin/generation-records/${recordId}/status`, { status, video_url: videoUrl });
|
||||
}
|
||||
|
||||
export async function adminGenerateVideo(
|
||||
recordId: string, aspectRatio: string, resolution: string
|
||||
): Promise<void> {
|
||||
await api.post(`/admin/generation-records/${recordId}/generate`, { aspect_ratio: aspectRatio, resolution });
|
||||
}
|
||||
Reference in New Issue
Block a user