import axios from 'axios' import type { LoginRequest, LoginResponse, User, PasswordChangeRequest, UserPreferences } from '@/types/auth' import type { Job, JobCreateResponse, JobListResponse, JobType } from '@/types/job' import type { Language, Speaker, CustomVoiceForm, VoiceDesignForm, VoiceCloneForm } from '@/types/tts' import type { UserCreateRequest, UserUpdateRequest, UserListResponse } from '@/types/user' import type { VoiceDesign, VoiceDesignCreate, VoiceDesignListResponse } from '@/types/voice-design' import { API_ENDPOINTS, LANGUAGE_NAMES, SPEAKER_DESCRIPTIONS_ZH } from '@/lib/constants' const apiClient = axios.create({ baseURL: import.meta.env.VITE_API_URL, headers: { 'Content-Type': 'application/json', }, }) apiClient.interceptors.request.use((config) => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) interface ValidationError { type: string loc: string[] msg: string input?: any ctx?: any } const FIELD_NAMES: Record = { username: '用户名', email: '邮箱', password: '密码', current_password: '当前密码', new_password: '新密码', confirm_password: '确认密码', is_active: '激活状态', is_superuser: '超级管理员', } const formatValidationErrors = (errors: ValidationError[]): string => { return errors.map((error) => { const fieldPath = error.loc.slice(1) const fieldName = fieldPath[fieldPath.length - 1] const translatedField = FIELD_NAMES[fieldName] || fieldName switch (error.type) { case 'string_pattern_mismatch': if (fieldName === 'username') { return `${translatedField}只能包含字母、数字、下划线和连字符` } return `${translatedField}格式不正确` case 'string_too_short': return `${translatedField}长度不能少于${error.ctx?.min_length || '指定'}个字符` case 'string_too_long': return `${translatedField}长度不能超过${error.ctx?.max_length || '指定'}个字符` case 'value_error': if (error.msg.includes('uppercase')) { return `${translatedField}必须包含至少一个大写字母` } if (error.msg.includes('lowercase')) { return `${translatedField}必须包含至少一个小写字母` } if (error.msg.includes('digit')) { return `${translatedField}必须包含至少一个数字` } if (error.msg.includes('alphanumeric')) { return `${translatedField}只能包含字母、数字、下划线和连字符` } return `${translatedField}: ${error.msg}` case 'missing': return `${translatedField}为必填项` case 'value_error.email': return `${translatedField}格式不正确` default: return `${translatedField}: ${error.msg}` } }).join('; ') } export const formatApiError = (error: any): string => { if (!error.response) { if (error.message === 'Network Error' || !navigator.onLine) { return '网络连接失败,请检查您的网络连接' } return error.message || '请求失败,请稍后重试' } const status = error.response.status const data = error.response.data switch (status) { case 400: if (data?.detail) { if (typeof data.detail === 'string') { return data.detail } if (Array.isArray(data.detail)) { return data.detail.map((err: any) => err.msg || err.message).join('; ') } } return '请求参数错误,请检查输入' case 422: if (data?.detail && Array.isArray(data.detail)) { return formatValidationErrors(data.detail) } return '输入验证失败,请检查表单' case 401: return '认证失败,请重新登录' case 403: return '没有权限执行此操作' case 404: return '请求的资源不存在' case 413: return '上传文件过大,请选择较小的文件' case 429: return '请求过于频繁,请稍后再试' case 500: return '服务器错误,请稍后重试' case 502: case 503: case 504: return '服务暂时不可用,请稍后重试' default: return data?.detail || data?.message || `请求失败 (${status})` } } apiClient.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401 && window.location.pathname !== '/login') { localStorage.removeItem('token') window.location.href = '/login' } error.message = formatApiError(error) return Promise.reject(error) } ) export const authApi = { login: async (credentials: LoginRequest): Promise => { const params = new URLSearchParams() params.append('username', credentials.username) params.append('password', credentials.password) const response = await apiClient.post( API_ENDPOINTS.AUTH.LOGIN, params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, } ) return response.data }, getCurrentUser: async (): Promise => { const response = await apiClient.get(API_ENDPOINTS.AUTH.ME) return response.data }, changePassword: async (data: PasswordChangeRequest): Promise => { const response = await apiClient.post( API_ENDPOINTS.AUTH.CHANGE_PASSWORD, data ) return response.data }, getPreferences: async (): Promise => { const response = await apiClient.get(API_ENDPOINTS.AUTH.PREFERENCES) return response.data }, updatePreferences: async (data: UserPreferences): Promise => { await apiClient.put(API_ENDPOINTS.AUTH.PREFERENCES, data) }, setAliyunKey: async (apiKey: string): Promise => { await apiClient.post(API_ENDPOINTS.AUTH.SET_ALIYUN_KEY, { api_key: apiKey }) }, deleteAliyunKey: async (): Promise => { await apiClient.delete(API_ENDPOINTS.AUTH.SET_ALIYUN_KEY) }, verifyAliyunKey: async (): Promise<{ valid: boolean; message: string }> => { const response = await apiClient.get<{ valid: boolean; message: string }>( API_ENDPOINTS.AUTH.VERIFY_ALIYUN_KEY ) return response.data }, } export const ttsApi = { getLanguages: async (): Promise => { const response = await apiClient.get(API_ENDPOINTS.TTS.LANGUAGES) return response.data.map((lang) => ({ code: lang, name: LANGUAGE_NAMES[lang] || lang, })) }, getSpeakers: async (backend?: string): Promise => { const params = backend ? { backend } : {} const response = await apiClient.get(API_ENDPOINTS.TTS.SPEAKERS, { params }) return response.data.map((speaker) => ({ name: speaker.name, description: SPEAKER_DESCRIPTIONS_ZH[speaker.name] || speaker.description, })) }, createCustomVoiceJob: async (data: CustomVoiceForm): Promise => { const response = await apiClient.post(API_ENDPOINTS.TTS.CUSTOM_VOICE, data) return response.data }, createVoiceDesignJob: async (data: VoiceDesignForm): Promise => { const response = await apiClient.post(API_ENDPOINTS.TTS.VOICE_DESIGN, data) return response.data }, createVoiceCloneJob: async (data: VoiceCloneForm): Promise => { const formData = new FormData() formData.append('text', data.text) if (data.ref_audio) { formData.append('ref_audio', data.ref_audio) } if (data.language) { formData.append('language', data.language) } if (data.ref_text) { formData.append('ref_text', data.ref_text) } if (data.use_cache !== undefined) { formData.append('use_cache', String(data.use_cache)) } if (data.x_vector_only_mode !== undefined) { formData.append('x_vector_only_mode', String(data.x_vector_only_mode)) } if (data.max_new_tokens !== undefined) { formData.append('max_new_tokens', String(data.max_new_tokens)) } if (data.temperature !== undefined) { formData.append('temperature', String(data.temperature)) } if (data.top_k !== undefined) { formData.append('top_k', String(data.top_k)) } if (data.top_p !== undefined) { formData.append('top_p', String(data.top_p)) } if (data.repetition_penalty !== undefined) { formData.append('repetition_penalty', String(data.repetition_penalty)) } if (data.backend) { formData.append('backend', data.backend) } const response = await apiClient.post( API_ENDPOINTS.TTS.VOICE_CLONE, formData, { headers: { 'Content-Type': 'multipart/form-data', }, } ) return response.data }, } const normalizeJobType = (jobType: string): JobType => { const typeMap: Record = { 'custom-voice': 'custom_voice', 'voice-design': 'voice_design', 'voice-clone': 'voice_clone', } return typeMap[jobType] || jobType as JobType } const normalizeJob = (job: any): Job => { let parameters = job.input_params || job.parameters || {} if (typeof parameters === 'string') { try { parameters = JSON.parse(parameters) } catch (e) { console.error('Failed to parse job parameters:', e) parameters = {} } } return { ...job, type: normalizeJobType(job.job_type || job.type), parameters, audio_url: job.download_url || job.audio_url, } } export const jobApi = { getJob: async (id: number): Promise => { const response = await apiClient.get(API_ENDPOINTS.JOBS.GET(id)) return normalizeJob(response.data) }, listJobs: async (skip = 0, limit = 100, status?: string): Promise => { const params: Record = { skip, limit } if (status) params.status = status const response = await apiClient.get(API_ENDPOINTS.JOBS.LIST, { params }) return { ...response.data, jobs: response.data.jobs.map(normalizeJob), } }, deleteJob: async (id: number): Promise => { await apiClient.delete(API_ENDPOINTS.JOBS.DELETE(id)) }, getAudioUrl: (id: number, audioPath?: string): string => { if (audioPath) { if (audioPath.startsWith('http')) { if (audioPath.includes('localhost') || audioPath.includes('127.0.0.1')) { const url = new URL(audioPath) return url.pathname } return audioPath } else { return audioPath.startsWith('/') ? audioPath : `/${audioPath}` } } else { return API_ENDPOINTS.JOBS.AUDIO(id) } }, } export const userApi = { listUsers: async (skip = 0, limit = 100): Promise => { const response = await apiClient.get( API_ENDPOINTS.USERS.LIST, { params: { skip, limit } } ) return response.data }, getUser: async (id: number): Promise => { const response = await apiClient.get(API_ENDPOINTS.USERS.GET(id)) return response.data }, createUser: async (data: UserCreateRequest): Promise => { const response = await apiClient.post(API_ENDPOINTS.USERS.CREATE, data) return response.data }, updateUser: async (id: number, data: UserUpdateRequest): Promise => { const response = await apiClient.put(API_ENDPOINTS.USERS.UPDATE(id), data) return response.data }, deleteUser: async (id: number): Promise => { await apiClient.delete(API_ENDPOINTS.USERS.DELETE(id)) }, } export const voiceDesignApi = { list: async (backend?: string): Promise => { const params = backend ? { backend_type: backend } : {} const response = await apiClient.get( API_ENDPOINTS.VOICE_DESIGNS.LIST, { params } ) return response.data }, get: async (id: number): Promise => { const response = await apiClient.get( API_ENDPOINTS.VOICE_DESIGNS.GET(id) ) return response.data }, create: async (data: VoiceDesignCreate): Promise => { const response = await apiClient.post( API_ENDPOINTS.VOICE_DESIGNS.CREATE, data ) return response.data }, update: async (id: number, name: string): Promise => { const response = await apiClient.patch( API_ENDPOINTS.VOICE_DESIGNS.UPDATE(id), { name } ) return response.data }, delete: async (id: number): Promise => { await apiClient.delete(API_ENDPOINTS.VOICE_DESIGNS.DELETE(id)) }, } export default apiClient