feat: Add voice management functionality with delete capability and UI integration
This commit is contained in:
@@ -15,6 +15,7 @@ const Login = lazy(() => import('@/pages/Login'))
|
||||
const Home = lazy(() => import('@/pages/Home'))
|
||||
const Settings = lazy(() => import('@/pages/Settings'))
|
||||
const UserManagement = lazy(() => import('@/pages/UserManagement'))
|
||||
const VoiceManagement = lazy(() => import('@/pages/VoiceManagement'))
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth()
|
||||
@@ -100,6 +101,14 @@ function App() {
|
||||
</SuperAdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/voices"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<VoiceManagement />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</UserPreferencesProvider>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Menu, LogOut, Users, Settings, Globe, Home } from 'lucide-react'
|
||||
import { Menu, LogOut, Users, Settings, Globe, Home, Mic } from 'lucide-react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -43,6 +43,12 @@ export function Navbar({ onToggleSidebar }: NavbarProps) {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link to="/voices">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Mic className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{user?.is_superuser && (
|
||||
<Link to="/users">
|
||||
<Button variant="ghost" size="icon">
|
||||
|
||||
@@ -432,6 +432,10 @@ export const voiceDesignApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await apiClient.delete(API_ENDPOINTS.VOICE_DESIGNS.DELETE(id))
|
||||
},
|
||||
|
||||
prepareAndCreate: async (data: VoiceDesignCreate): Promise<VoiceDesign> => {
|
||||
const response = await apiClient.post<VoiceDesign>(
|
||||
API_ENDPOINTS.VOICE_DESIGNS.PREPARE_AND_CREATE,
|
||||
|
||||
@@ -31,6 +31,7 @@ export const API_ENDPOINTS = {
|
||||
LIST: '/voice-designs',
|
||||
CREATE: '/voice-designs',
|
||||
PREPARE_AND_CREATE: '/voice-designs/prepare-and-create',
|
||||
DELETE: (id: number) => `/voice-designs/${id}`,
|
||||
PREPARE_CLONE: (id: number) => `/voice-designs/${id}/prepare-clone`,
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -8,5 +8,6 @@
|
||||
"changeLanguage": "Change Language",
|
||||
"customVoiceTab": "Custom",
|
||||
"voiceDesignTab": "Create Voice",
|
||||
"voiceCloneTab": "Clone"
|
||||
}
|
||||
"voiceCloneTab": "Clone",
|
||||
"voiceManagement": "Voice Management"
|
||||
}
|
||||
@@ -55,5 +55,12 @@
|
||||
"browserNotSupported": "Your browser does not support recording",
|
||||
"recordingComplete": "Recording complete",
|
||||
"releaseToFinish": "Release to finish",
|
||||
"holdToRecord": "Hold to record"
|
||||
}
|
||||
"holdToRecord": "Hold to record",
|
||||
"myVoices": "My Voices",
|
||||
"loadFailed": "Failed to load voices",
|
||||
"deleteFailed": "Delete failed",
|
||||
"deleteConfirmDesc": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"local": "Local",
|
||||
"aliyun": "Aliyun",
|
||||
"deleting": "Deleting..."
|
||||
}
|
||||
@@ -8,5 +8,6 @@
|
||||
"changeLanguage": "言語変更",
|
||||
"customVoiceTab": "カスタム",
|
||||
"voiceDesignTab": "音色作成",
|
||||
"voiceCloneTab": "クローン"
|
||||
}
|
||||
"voiceCloneTab": "クローン",
|
||||
"voiceManagement": "音声管理"
|
||||
}
|
||||
@@ -55,5 +55,12 @@
|
||||
"browserNotSupported": "お使いのブラウザは録音機能をサポートしていません",
|
||||
"recordingComplete": "録音完了",
|
||||
"releaseToFinish": "離して完了",
|
||||
"holdToRecord": "長押しで録音"
|
||||
}
|
||||
"holdToRecord": "長押しで録音",
|
||||
"myVoices": "マイ音声",
|
||||
"loadFailed": "音声の読み込みに失敗しました",
|
||||
"deleteFailed": "削除に失敗しました",
|
||||
"deleteConfirmDesc": "「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"local": "ローカル",
|
||||
"aliyun": "Aliyun",
|
||||
"deleting": "削除中..."
|
||||
}
|
||||
@@ -8,5 +8,6 @@
|
||||
"changeLanguage": "언어 변경",
|
||||
"customVoiceTab": "커스텀",
|
||||
"voiceDesignTab": "음색 생성",
|
||||
"voiceCloneTab": "복제"
|
||||
}
|
||||
"voiceCloneTab": "복제",
|
||||
"voiceManagement": "음성 관리"
|
||||
}
|
||||
@@ -55,5 +55,12 @@
|
||||
"browserNotSupported": "브라우저가 녹음 기능을 지원하지 않습니다",
|
||||
"recordingComplete": "녹음 완료",
|
||||
"releaseToFinish": "놓아서 완료",
|
||||
"holdToRecord": "길게 눌러서 녹음"
|
||||
}
|
||||
"holdToRecord": "길게 눌러서 녹음",
|
||||
"myVoices": "내 음성",
|
||||
"loadFailed": "음성 불러오기 실패",
|
||||
"deleteFailed": "삭제 실패",
|
||||
"deleteConfirmDesc": "「{{name}}」을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||
"local": "로컬",
|
||||
"aliyun": "Aliyun",
|
||||
"deleting": "삭제 중..."
|
||||
}
|
||||
@@ -8,5 +8,6 @@
|
||||
"changeLanguage": "切换语言",
|
||||
"customVoiceTab": "自定义",
|
||||
"voiceDesignTab": "创造音色",
|
||||
"voiceCloneTab": "克隆"
|
||||
}
|
||||
"voiceCloneTab": "克隆",
|
||||
"voiceManagement": "音色管理"
|
||||
}
|
||||
@@ -55,5 +55,12 @@
|
||||
"browserNotSupported": "您的浏览器不支持录音功能",
|
||||
"recordingComplete": "录制完成",
|
||||
"releaseToFinish": "松开完成",
|
||||
"holdToRecord": "按住录音"
|
||||
}
|
||||
"holdToRecord": "按住录音",
|
||||
"myVoices": "我的音色",
|
||||
"loadFailed": "加载音色失败",
|
||||
"deleteFailed": "删除失败",
|
||||
"deleteConfirmDesc": "确定要删除「{{name}}」吗?此操作不可撤销。",
|
||||
"local": "本地",
|
||||
"aliyun": "阿里云",
|
||||
"deleting": "删除中..."
|
||||
}
|
||||
@@ -8,5 +8,6 @@
|
||||
"changeLanguage": "切換語言",
|
||||
"customVoiceTab": "自訂",
|
||||
"voiceDesignTab": "創造音色",
|
||||
"voiceCloneTab": "複製"
|
||||
}
|
||||
"voiceCloneTab": "複製",
|
||||
"voiceManagement": "音色管理"
|
||||
}
|
||||
@@ -55,5 +55,12 @@
|
||||
"browserNotSupported": "您的瀏覽器不支援錄音功能",
|
||||
"recordingComplete": "錄製完成",
|
||||
"releaseToFinish": "放開完成",
|
||||
"holdToRecord": "按住錄音"
|
||||
}
|
||||
"holdToRecord": "按住錄音",
|
||||
"myVoices": "我的音色",
|
||||
"loadFailed": "載入音色失敗",
|
||||
"deleteFailed": "刪除失敗",
|
||||
"deleteConfirmDesc": "確定要刪除「{{name}}」嗎?此操作無法撤銷。",
|
||||
"local": "本地",
|
||||
"aliyun": "阿里雲",
|
||||
"deleting": "刪除中..."
|
||||
}
|
||||
124
qwen3-tts-frontend/src/pages/VoiceManagement.tsx
Normal file
124
qwen3-tts-frontend/src/pages/VoiceManagement.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Trash2, Cpu, Cloud } from 'lucide-react'
|
||||
import { Navbar } from '@/components/Navbar'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { voiceDesignApi } from '@/lib/api'
|
||||
import type { VoiceDesign } from '@/types/voice-design'
|
||||
|
||||
export default function VoiceManagement() {
|
||||
const { t } = useTranslation(['voice', 'common'])
|
||||
const [voices, setVoices] = useState<VoiceDesign[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [deleteTarget, setDeleteTarget] = useState<VoiceDesign | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const res = await voiceDesignApi.list()
|
||||
setVoices(res.designs)
|
||||
} catch {
|
||||
toast.error(t('voice:loadFailed'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
try {
|
||||
setIsDeleting(true)
|
||||
await voiceDesignApi.delete(deleteTarget.id)
|
||||
toast.success(t('voice:voiceDeleted'))
|
||||
setDeleteTarget(null)
|
||||
await load()
|
||||
} catch {
|
||||
toast.error(t('voice:deleteFailed'))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar />
|
||||
<div className="container mx-auto p-4 sm:p-6 max-w-[800px]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('voice:myVoices')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center text-muted-foreground py-8">{t('common:loading')}</div>
|
||||
) : voices.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">{t('voice:noVoices')}</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{voices.map((voice) => (
|
||||
<div key={voice.id} className="flex items-start justify-between py-4 gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium truncate">{voice.name}</span>
|
||||
<Badge variant="outline" className="shrink-0 gap-1">
|
||||
{voice.backend_type === 'local'
|
||||
? <><Cpu className="h-3 w-3" />{t('voice:local')}</>
|
||||
: <><Cloud className="h-3 w-3" />{t('voice:aliyun')}</>
|
||||
}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{voice.instruct}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('voice:createdAt')}: {new Date(voice.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(voice)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('voice:deleteVoice')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('voice:deleteConfirmDesc', { name: deleteTarget?.name })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common:cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} disabled={isDeleting}>
|
||||
{isDeleting ? t('voice:deleting') : t('common:delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user