init commit

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-26 15:34:31 +08:00
commit 80513a3258
141 changed files with 24966 additions and 0 deletions

View File

@@ -0,0 +1,346 @@
import axios from 'axios'
import type { LoginRequest, LoginResponse, User } 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 { 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: '密码',
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
},
}
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 (): Promise<Speaker[]> => {
const response = await apiClient.get<Speaker[]>(API_ENDPOINTS.TTS.SPEAKERS)
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))
}
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')) {
return audioPath
} else {
const baseUrl = import.meta.env.VITE_API_URL
const normalizedPath = audioPath.startsWith('/') ? audioPath : `/${audioPath}`
return `${baseUrl}${normalizedPath}`
}
} else {
return `${import.meta.env.VITE_API_URL}${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 default apiClient

View File

@@ -0,0 +1,214 @@
export const API_ENDPOINTS = {
AUTH: {
LOGIN: '/auth/token',
ME: '/auth/me',
},
TTS: {
LANGUAGES: '/tts/languages',
SPEAKERS: '/tts/speakers',
CUSTOM_VOICE: '/tts/custom-voice',
VOICE_DESIGN: '/tts/voice-design',
VOICE_CLONE: '/tts/voice-clone',
},
JOBS: {
LIST: '/jobs',
GET: (id: number) => `/jobs/${id}`,
DELETE: (id: number) => `/jobs/${id}`,
AUDIO: (id: number) => `/jobs/${id}/download`,
},
USERS: {
LIST: '/users',
CREATE: '/users',
GET: (id: number) => `/users/${id}`,
UPDATE: (id: number) => `/users/${id}`,
DELETE: (id: number) => `/users/${id}`,
},
} as const
export const LANGUAGE_NAMES: Record<string, string> = {
'Auto': '自动检测',
'Chinese': '中文',
'English': '英语',
'Japanese': '日语',
'Korean': '韩语',
'German': '德语',
'French': '法语',
'Russian': '俄语',
'Portuguese': '葡萄牙语',
'Spanish': '西班牙语',
'Italian': '意大利语',
'Cantonese': '粤语',
}
export const SPEAKER_DESCRIPTIONS_ZH: Record<string, string> = {
'Vivian': '女性,专业清晰',
'Serena': '女性,温柔温暖',
'Uncle_Fu': '男性,成熟权威',
'Dylan': '男性,年轻活力',
'Eric': '男性,沉稳稳重',
'Ryan': '男性,友好随和',
'Aiden': '男性,低沉浑厚',
'Ono_Anna': '女性,可爱活泼',
'Sohee': '女性,柔和悦耳',
}
export const DEFAULT_FORM_VALUES = {
CUSTOM_VOICE: {
text: '',
language: 'Auto',
speaker: '',
instruct: '',
},
VOICE_DESIGN: {
text: '',
language: 'Auto',
instruct: '',
},
VOICE_CLONE: {
text: '',
ref_audio: null,
ref_text: '',
},
} as const
export const PRESET_INSTRUCTS = [
{
label: '开心',
instruct: '非常开心',
text: '今天天气真好,我们一起去公园玩吧!',
},
{
label: '悲伤',
instruct: '很悲伤,带着哭腔',
text: '对不起,我真的尽力了,但还是让你失望了。',
},
{
label: '愤怒',
instruct: '非常愤怒,语气激烈',
text: '你怎么能这样做!这简直太过分了!',
},
{
label: '温柔关怀',
instruct: '温柔体贴的女性声音,语速平缓,音调柔和,充满关怀和安慰',
text: '别担心,一切都会好起来的。我会一直陪在你身边。',
},
{
label: '兴奋激动',
instruct: '非常兴奋激动,语速加快,音调上扬,充满活力和热情',
text: '太棒了!我们终于成功了!这真是太令人激动了!',
},
{
label: '焦虑紧张',
instruct: '焦虑不安的语气,语速略快,音调不稳定,带有紧张和担忧',
text: '怎么办?时间不够了,我们来不及了,这可怎么办才好?',
},
{
label: '专业播音员',
instruct: '专业新闻播音员。性别:女性。音高:中等偏高,音域稳定。语速:标准播音语速,吐字清晰。音量:适中,音色饱满。情绪:沉稳专业,不带个人感情色彩。语调:平直中略有起伏,重点词汇加重。性格特征:严谨、客观、权威。',
text: '据新华社报道,我国航天事业取得重大突破,神舟系列飞船成功完成载人飞行任务。',
},
{
label: '温暖导师',
instruct: '温暖的中年女性导师。音色:温和醇厚,带有亲和力。语速:不急不缓,娓娓道来。音调:平稳中带有鼓励性上扬。情绪:关怀、耐心、鼓励。性格:善解人意,循循善诱,充满正能量。适合场景:心理咨询、教育引导。',
text: '每个人都有自己的节奏,不要着急。慢慢来,你一定能找到属于自己的那条路。',
},
{
label: '活力少年',
instruct: '充满活力的青少年男性。音高:略高,富有朝气。语速:偏快,吐字利落。音量:响亮明快。情绪:开朗乐观,精力充沛。语调:跳跃感强,抑扬顿挫。性格:外向、自信、热情,充满青春气息。',
text: '哇,这个游戏太酷了!咱们组队一起玩吧,我保证带你们飞!',
},
] as const
export const PRESET_VOICE_DESIGNS = [
{
label: '甜美少女',
instruct: '年轻女性,音色清甜明亮,略带少女的娇俏感。音高偏高,语调活泼富于变化。语速适中,吐字清晰。情绪愉悦轻松,充满青春活力。适合场景:客服语音、语音助手、娱乐内容。',
text: '您好,很高兴为您服务!请问有什么可以帮助您的吗?',
},
{
label: '成熟女性',
instruct: '成熟知性的女性声音,音色温润饱满,带有职业女性的干练气质。音高中等,音域稳定。语速适中偏快,条理清晰。情绪从容自信,传递专业可靠的感觉。',
text: '根据最新的市场分析报告,本季度业绩呈现稳步增长态势,各项指标均达到预期目标。',
},
{
label: '磁性男声',
instruct: '中低音男性声音,音色深沉磁性,富有感染力。语速偏慢,节奏沉稳。音量适中,声音浑厚有力。适合情感类、故事讲述、品牌宣传等场景。',
text: '夜深了,城市的灯火依然璀璨。每一盏灯下,都有一个关于梦想的故事。',
},
{
label: '活力青年',
instruct: '充满活力的年轻男性,音色明亮清晰,带有青春朝气。语速较快,节奏感强。情绪热情积极,富有感染力。适合运动、游戏、娱乐等场景。',
text: '兄弟们,准备好了吗?今天我们要挑战全新的副本,冲冲冲!',
},
{
label: '权威专家',
instruct: '中年男性专家形象,音色沉稳权威,声音浑厚有力。语速适中,吐字清晰标准。情绪严肃专业,传递信任感和专业度。适合学术讲座、知识科普、正式场合。',
text: '从历史发展的角度来看,科技创新始终是推动社会进步的核心动力。',
},
{
label: '温柔妈妈',
instruct: '温柔慈爱的中年女性,音色柔和温暖,充满母性关怀。语速舒缓,音调平和安抚。情绪温暖体贴,给人安全感。适合儿童内容、情感陪伴、睡前故事。',
text: '宝贝,该睡觉了。妈妈给你讲个故事,从前有一只小兔子,它住在森林里...',
},
{
label: '播音主持',
instruct: '专业播音主持人声音,音色饱满圆润,标准普通话发音。音高适中,音域宽广。语速标准,节奏把控精准。情绪专业沉稳,字正腔圆。适合新闻播报、节目主持、正式朗读。',
text: '各位听众朋友大家好,欢迎收听今天的节目。接下来为您带来今日要闻。',
},
{
label: '俏皮少女',
instruct: '俏皮可爱的少女音色,声音轻快灵动,带有少女特有的活泼感。音调偏高且富于变化,语气中带有撒娇和卖萌的元素。语速时快时慢,吐字清晰但带有可爱的语气词。',
text: '哎呀,人家不是故意的嘛~你就原谅我一次好不好?拜托拜托啦~',
},
] as const
export const PRESET_REF_TEXTS = [
{
label: '自然生活',
text: '在这个快节奏的世界里,我们总是在赶路,却忘了停下来听听内心的声音。其实,生活不仅仅是眼前的忙碌,还有远方的诗意和偶然发现的小确幸。希望这段录音,能像午后的微风一样,带给你一点点温柔和力量。无论未来如何变化,请记得保持对生活的热爱,去拥抱每一个灿烂的明天。',
},
{
label: '专业正式',
text: '科技的进步让我们能够跨越时空的界限,用数字化的方式延续情感与记忆。语音克隆不仅是精密的代码逻辑,更是连接人类与未来智能的纽带。通过深度学习与神经网络的不断演进,每一个细微的语调起伏,都能被精准地捕捉。让我们共同见证,技术如何赋予声音更具生命力的表达。',
},
{
label: '文学叙事',
text: '春天的风拂过柳梢,带着泥土的芬芳和花开的消息。你是否也曾期待过,在某个街角的转弯处,遇见那个久违的自己?无论是高亢的欢笑,还是低沉的呢喃,每一种声音都是独一无二的生命印记。让我们在此刻记录当下,让回忆在流淌的声音里,化作永恒的旋律。',
},
] as const
export const POLL_INTERVAL = 2000
export const TIMEOUT_WARNING = 30000
export const MAX_FILE_SIZE = 10 * 1024 * 1024
export const MIN_AUDIO_DURATION = 3
export const ADVANCED_PARAMS_INFO = {
max_new_tokens: {
label: '最大生成长度',
description: '控制生成音频的最大长度。值越大,可生成的音频越长,但处理时间也会增加',
tooltip: '建议值: 2048-4096。超过 8000 可能导致生成时间过长',
},
temperature: {
label: '温度',
description: '控制生成的随机性。值越高生成越随机多样,值越低越稳定一致',
tooltip: '推荐范围: 0.1-0.5 (稳定) | 0.6-1.0 (多样) | >1.0 (创意)',
},
top_k: {
label: 'Top K',
description: '采样时只考虑概率最高的 K 个候选。值越小生成越确定,越大越多样',
tooltip: '常用值: 20-50。过小可能导致重复,过大可能不连贯',
},
top_p: {
label: 'Top P (核采样)',
description: '累积概率阈值,只从累积概率达到 P 的候选中采样。控制输出多样性',
tooltip: '推荐值: 0.7-0.9。0.9 更自然多变,0.7 更稳定可控',
},
repetition_penalty: {
label: '重复惩罚',
description: '惩罚重复内容的生成。值越大越避免重复,但过大可能影响自然度',
tooltip: '建议范围: 1.0-1.2。1.0 表示无惩罚,1.05 适合大多数场景',
},
} as const

View File

@@ -0,0 +1,60 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`
}
export function getRelativeTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffSecs = Math.floor(diffMs / 1000)
const diffMins = Math.floor(diffSecs / 60)
const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffSecs < 60) return '刚刚'
if (diffMins < 60) return `${diffMins}分钟前`
if (diffHours < 24) return `${diffHours}小时前`
if (diffDays < 7) return `${diffDays}天前`
return date.toLocaleDateString('zh-CN')
}
export function getAudioDuration(file: File): Promise<number> {
return new Promise((resolve, reject) => {
const audio = new Audio()
audio.addEventListener('loadedmetadata', () => {
resolve(Math.floor(audio.duration))
})
audio.addEventListener('error', () => {
reject(new Error('Failed to load audio file'))
})
audio.src = URL.createObjectURL(file)
})
}
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null
return function(...args: Parameters<T>) {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => func(...args), wait)
}
}