feat: Add voice management functionality with delete capability and UI integration
This commit is contained in:
@@ -170,6 +170,17 @@ async def prepare_and_create_voice_design(
|
|||||||
raise HTTPException(status_code=500, detail="Failed to prepare voice design")
|
raise HTTPException(status_code=500, detail="Failed to prepare voice design")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{design_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_voice_design(
|
||||||
|
design_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
deleted = crud.delete_voice_design(db, design_id, current_user.id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Voice design not found")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{design_id}/prepare-clone")
|
@router.post("/{design_id}/prepare-clone")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def prepare_voice_clone_prompt(
|
async def prepare_voice_clone_prompt(
|
||||||
|
|||||||
@@ -339,6 +339,14 @@ def count_voice_designs(
|
|||||||
query = query.filter(VoiceDesign.backend_type == backend_type)
|
query = query.filter(VoiceDesign.backend_type == backend_type)
|
||||||
return query.count()
|
return query.count()
|
||||||
|
|
||||||
|
def delete_voice_design(db: Session, design_id: int, user_id: int) -> bool:
|
||||||
|
design = get_voice_design(db, design_id, user_id)
|
||||||
|
if not design:
|
||||||
|
return False
|
||||||
|
db.delete(design)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
def update_voice_design_usage(db: Session, design_id: int, user_id: int) -> Optional[VoiceDesign]:
|
def update_voice_design_usage(db: Session, design_id: int, user_id: int) -> Optional[VoiceDesign]:
|
||||||
design = get_voice_design(db, design_id, user_id)
|
design = get_voice_design(db, design_id, user_id)
|
||||||
if design:
|
if design:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const Login = lazy(() => import('@/pages/Login'))
|
|||||||
const Home = lazy(() => import('@/pages/Home'))
|
const Home = lazy(() => import('@/pages/Home'))
|
||||||
const Settings = lazy(() => import('@/pages/Settings'))
|
const Settings = lazy(() => import('@/pages/Settings'))
|
||||||
const UserManagement = lazy(() => import('@/pages/UserManagement'))
|
const UserManagement = lazy(() => import('@/pages/UserManagement'))
|
||||||
|
const VoiceManagement = lazy(() => import('@/pages/VoiceManagement'))
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated, isLoading } = useAuth()
|
const { isAuthenticated, isLoading } = useAuth()
|
||||||
@@ -100,6 +101,14 @@ function App() {
|
|||||||
</SuperAdminRoute>
|
</SuperAdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/voices"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<VoiceManagement />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</UserPreferencesProvider>
|
</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 { Link, useLocation } from 'react-router-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -43,6 +43,12 @@ export function Navbar({ onToggleSidebar }: NavbarProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Link to="/voices">
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Mic className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{user?.is_superuser && (
|
{user?.is_superuser && (
|
||||||
<Link to="/users">
|
<Link to="/users">
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon">
|
||||||
|
|||||||
@@ -432,6 +432,10 @@ export const voiceDesignApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
delete: async (id: number): Promise<void> => {
|
||||||
|
await apiClient.delete(API_ENDPOINTS.VOICE_DESIGNS.DELETE(id))
|
||||||
|
},
|
||||||
|
|
||||||
prepareAndCreate: async (data: VoiceDesignCreate): Promise<VoiceDesign> => {
|
prepareAndCreate: async (data: VoiceDesignCreate): Promise<VoiceDesign> => {
|
||||||
const response = await apiClient.post<VoiceDesign>(
|
const response = await apiClient.post<VoiceDesign>(
|
||||||
API_ENDPOINTS.VOICE_DESIGNS.PREPARE_AND_CREATE,
|
API_ENDPOINTS.VOICE_DESIGNS.PREPARE_AND_CREATE,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const API_ENDPOINTS = {
|
|||||||
LIST: '/voice-designs',
|
LIST: '/voice-designs',
|
||||||
CREATE: '/voice-designs',
|
CREATE: '/voice-designs',
|
||||||
PREPARE_AND_CREATE: '/voice-designs/prepare-and-create',
|
PREPARE_AND_CREATE: '/voice-designs/prepare-and-create',
|
||||||
|
DELETE: (id: number) => `/voice-designs/${id}`,
|
||||||
PREPARE_CLONE: (id: number) => `/voice-designs/${id}/prepare-clone`,
|
PREPARE_CLONE: (id: number) => `/voice-designs/${id}/prepare-clone`,
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -8,5 +8,6 @@
|
|||||||
"changeLanguage": "Change Language",
|
"changeLanguage": "Change Language",
|
||||||
"customVoiceTab": "Custom",
|
"customVoiceTab": "Custom",
|
||||||
"voiceDesignTab": "Create Voice",
|
"voiceDesignTab": "Create Voice",
|
||||||
"voiceCloneTab": "Clone"
|
"voiceCloneTab": "Clone",
|
||||||
}
|
"voiceManagement": "Voice Management"
|
||||||
|
}
|
||||||
@@ -55,5 +55,12 @@
|
|||||||
"browserNotSupported": "Your browser does not support recording",
|
"browserNotSupported": "Your browser does not support recording",
|
||||||
"recordingComplete": "Recording complete",
|
"recordingComplete": "Recording complete",
|
||||||
"releaseToFinish": "Release to finish",
|
"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": "言語変更",
|
"changeLanguage": "言語変更",
|
||||||
"customVoiceTab": "カスタム",
|
"customVoiceTab": "カスタム",
|
||||||
"voiceDesignTab": "音色作成",
|
"voiceDesignTab": "音色作成",
|
||||||
"voiceCloneTab": "クローン"
|
"voiceCloneTab": "クローン",
|
||||||
}
|
"voiceManagement": "音声管理"
|
||||||
|
}
|
||||||
@@ -55,5 +55,12 @@
|
|||||||
"browserNotSupported": "お使いのブラウザは録音機能をサポートしていません",
|
"browserNotSupported": "お使いのブラウザは録音機能をサポートしていません",
|
||||||
"recordingComplete": "録音完了",
|
"recordingComplete": "録音完了",
|
||||||
"releaseToFinish": "離して完了",
|
"releaseToFinish": "離して完了",
|
||||||
"holdToRecord": "長押しで録音"
|
"holdToRecord": "長押しで録音",
|
||||||
}
|
"myVoices": "マイ音声",
|
||||||
|
"loadFailed": "音声の読み込みに失敗しました",
|
||||||
|
"deleteFailed": "削除に失敗しました",
|
||||||
|
"deleteConfirmDesc": "「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
|
||||||
|
"local": "ローカル",
|
||||||
|
"aliyun": "Aliyun",
|
||||||
|
"deleting": "削除中..."
|
||||||
|
}
|
||||||
@@ -8,5 +8,6 @@
|
|||||||
"changeLanguage": "언어 변경",
|
"changeLanguage": "언어 변경",
|
||||||
"customVoiceTab": "커스텀",
|
"customVoiceTab": "커스텀",
|
||||||
"voiceDesignTab": "음색 생성",
|
"voiceDesignTab": "음색 생성",
|
||||||
"voiceCloneTab": "복제"
|
"voiceCloneTab": "복제",
|
||||||
}
|
"voiceManagement": "음성 관리"
|
||||||
|
}
|
||||||
@@ -55,5 +55,12 @@
|
|||||||
"browserNotSupported": "브라우저가 녹음 기능을 지원하지 않습니다",
|
"browserNotSupported": "브라우저가 녹음 기능을 지원하지 않습니다",
|
||||||
"recordingComplete": "녹음 완료",
|
"recordingComplete": "녹음 완료",
|
||||||
"releaseToFinish": "놓아서 완료",
|
"releaseToFinish": "놓아서 완료",
|
||||||
"holdToRecord": "길게 눌러서 녹음"
|
"holdToRecord": "길게 눌러서 녹음",
|
||||||
}
|
"myVoices": "내 음성",
|
||||||
|
"loadFailed": "음성 불러오기 실패",
|
||||||
|
"deleteFailed": "삭제 실패",
|
||||||
|
"deleteConfirmDesc": "「{{name}}」을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||||
|
"local": "로컬",
|
||||||
|
"aliyun": "Aliyun",
|
||||||
|
"deleting": "삭제 중..."
|
||||||
|
}
|
||||||
@@ -8,5 +8,6 @@
|
|||||||
"changeLanguage": "切换语言",
|
"changeLanguage": "切换语言",
|
||||||
"customVoiceTab": "自定义",
|
"customVoiceTab": "自定义",
|
||||||
"voiceDesignTab": "创造音色",
|
"voiceDesignTab": "创造音色",
|
||||||
"voiceCloneTab": "克隆"
|
"voiceCloneTab": "克隆",
|
||||||
}
|
"voiceManagement": "音色管理"
|
||||||
|
}
|
||||||
@@ -55,5 +55,12 @@
|
|||||||
"browserNotSupported": "您的浏览器不支持录音功能",
|
"browserNotSupported": "您的浏览器不支持录音功能",
|
||||||
"recordingComplete": "录制完成",
|
"recordingComplete": "录制完成",
|
||||||
"releaseToFinish": "松开完成",
|
"releaseToFinish": "松开完成",
|
||||||
"holdToRecord": "按住录音"
|
"holdToRecord": "按住录音",
|
||||||
}
|
"myVoices": "我的音色",
|
||||||
|
"loadFailed": "加载音色失败",
|
||||||
|
"deleteFailed": "删除失败",
|
||||||
|
"deleteConfirmDesc": "确定要删除「{{name}}」吗?此操作不可撤销。",
|
||||||
|
"local": "本地",
|
||||||
|
"aliyun": "阿里云",
|
||||||
|
"deleting": "删除中..."
|
||||||
|
}
|
||||||
@@ -8,5 +8,6 @@
|
|||||||
"changeLanguage": "切換語言",
|
"changeLanguage": "切換語言",
|
||||||
"customVoiceTab": "自訂",
|
"customVoiceTab": "自訂",
|
||||||
"voiceDesignTab": "創造音色",
|
"voiceDesignTab": "創造音色",
|
||||||
"voiceCloneTab": "複製"
|
"voiceCloneTab": "複製",
|
||||||
}
|
"voiceManagement": "音色管理"
|
||||||
|
}
|
||||||
@@ -55,5 +55,12 @@
|
|||||||
"browserNotSupported": "您的瀏覽器不支援錄音功能",
|
"browserNotSupported": "您的瀏覽器不支援錄音功能",
|
||||||
"recordingComplete": "錄製完成",
|
"recordingComplete": "錄製完成",
|
||||||
"releaseToFinish": "放開完成",
|
"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