Files
Canto/qwen3-tts-frontend/src/lib/api.ts

428 lines
13 KiB
TypeScript

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<string, string> = {
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<LoginResponse> => {
const params = new URLSearchParams()
params.append('username', credentials.username)
params.append('password', credentials.password)
const response = await apiClient.post<LoginResponse>(
API_ENDPOINTS.AUTH.LOGIN,
params,
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
)
return response.data
},
getCurrentUser: async (): Promise<User> => {
const response = await apiClient.get<User>(API_ENDPOINTS.AUTH.ME)
return response.data
},
changePassword: async (data: PasswordChangeRequest): Promise<User> => {
const response = await apiClient.post<User>(
API_ENDPOINTS.AUTH.CHANGE_PASSWORD,
data
)
return response.data
},
getPreferences: async (): Promise<UserPreferences> => {
const response = await apiClient.get<UserPreferences>(API_ENDPOINTS.AUTH.PREFERENCES)
return response.data
},
updatePreferences: async (data: UserPreferences): Promise<void> => {
await apiClient.put(API_ENDPOINTS.AUTH.PREFERENCES, data)
},
setAliyunKey: async (apiKey: string): Promise<void> => {
await apiClient.post(API_ENDPOINTS.AUTH.SET_ALIYUN_KEY, { api_key: apiKey })
},
deleteAliyunKey: async (): Promise<void> => {
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<Language[]> => {
const response = await apiClient.get<string[]>(API_ENDPOINTS.TTS.LANGUAGES)
return response.data.map((lang) => ({
code: lang,
name: LANGUAGE_NAMES[lang] || lang,
}))
},
getSpeakers: async (backend?: string): Promise<Speaker[]> => {
const params = backend ? { backend } : {}
const response = await apiClient.get<Speaker[]>(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<JobCreateResponse> => {
const response = await apiClient.post<JobCreateResponse>(API_ENDPOINTS.TTS.CUSTOM_VOICE, data)
return response.data
},
createVoiceDesignJob: async (data: VoiceDesignForm): Promise<JobCreateResponse> => {
const response = await apiClient.post<JobCreateResponse>(API_ENDPOINTS.TTS.VOICE_DESIGN, data)
return response.data
},
createVoiceCloneJob: async (data: VoiceCloneForm): Promise<JobCreateResponse> => {
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<JobCreateResponse>(
API_ENDPOINTS.TTS.VOICE_CLONE,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
)
return response.data
},
}
const normalizeJobType = (jobType: string): JobType => {
const typeMap: Record<string, JobType> = {
'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<Job> => {
const response = await apiClient.get<any>(API_ENDPOINTS.JOBS.GET(id))
return normalizeJob(response.data)
},
listJobs: async (skip = 0, limit = 100, status?: string): Promise<JobListResponse> => {
const params: Record<string, any> = { skip, limit }
if (status) params.status = status
const response = await apiClient.get<any>(API_ENDPOINTS.JOBS.LIST, { params })
return {
...response.data,
jobs: response.data.jobs.map(normalizeJob),
}
},
deleteJob: async (id: number): Promise<void> => {
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<UserListResponse> => {
const response = await apiClient.get<UserListResponse>(
API_ENDPOINTS.USERS.LIST,
{ params: { skip, limit } }
)
return response.data
},
getUser: async (id: number): Promise<User> => {
const response = await apiClient.get<User>(API_ENDPOINTS.USERS.GET(id))
return response.data
},
createUser: async (data: UserCreateRequest): Promise<User> => {
const response = await apiClient.post<User>(API_ENDPOINTS.USERS.CREATE, data)
return response.data
},
updateUser: async (id: number, data: UserUpdateRequest): Promise<User> => {
const response = await apiClient.put<User>(API_ENDPOINTS.USERS.UPDATE(id), data)
return response.data
},
deleteUser: async (id: number): Promise<void> => {
await apiClient.delete(API_ENDPOINTS.USERS.DELETE(id))
},
}
export const voiceDesignApi = {
list: async (backend?: string): Promise<VoiceDesignListResponse> => {
const params = backend ? { backend_type: backend } : {}
const response = await apiClient.get<VoiceDesignListResponse>(
API_ENDPOINTS.VOICE_DESIGNS.LIST,
{ params }
)
return response.data
},
get: async (id: number): Promise<VoiceDesign> => {
const response = await apiClient.get<VoiceDesign>(
API_ENDPOINTS.VOICE_DESIGNS.GET(id)
)
return response.data
},
create: async (data: VoiceDesignCreate): Promise<VoiceDesign> => {
const response = await apiClient.post<VoiceDesign>(
API_ENDPOINTS.VOICE_DESIGNS.CREATE,
data
)
return response.data
},
update: async (id: number, name: string): Promise<VoiceDesign> => {
const response = await apiClient.patch<VoiceDesign>(
API_ENDPOINTS.VOICE_DESIGNS.UPDATE(id),
{ name }
)
return response.data
},
delete: async (id: number): Promise<void> => {
await apiClient.delete(API_ENDPOINTS.VOICE_DESIGNS.DELETE(id))
},
}
export default apiClient