feat: Add voice management functionality with delete capability and UI integration

This commit is contained in:
2026-03-06 14:35:59 +08:00
parent ad90e5f96c
commit 964ebb824c
17 changed files with 224 additions and 21 deletions

View File

@@ -170,6 +170,17 @@ async def prepare_and_create_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")
@limiter.limit("10/minute")
async def prepare_voice_clone_prompt(

View File

@@ -339,6 +339,14 @@ def count_voice_designs(
query = query.filter(VoiceDesign.backend_type == backend_type)
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]:
design = get_voice_design(db, design_id, user_id)
if design:

View File

@@ -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>

View File

@@ -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">

View File

@@ -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,

View File

@@ -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

View File

@@ -8,5 +8,6 @@
"changeLanguage": "Change Language",
"customVoiceTab": "Custom",
"voiceDesignTab": "Create Voice",
"voiceCloneTab": "Clone"
}
"voiceCloneTab": "Clone",
"voiceManagement": "Voice Management"
}

View File

@@ -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..."
}

View File

@@ -8,5 +8,6 @@
"changeLanguage": "言語変更",
"customVoiceTab": "カスタム",
"voiceDesignTab": "音色作成",
"voiceCloneTab": "クローン"
}
"voiceCloneTab": "クローン",
"voiceManagement": "音声管理"
}

View File

@@ -55,5 +55,12 @@
"browserNotSupported": "お使いのブラウザは録音機能をサポートしていません",
"recordingComplete": "録音完了",
"releaseToFinish": "離して完了",
"holdToRecord": "長押しで録音"
}
"holdToRecord": "長押しで録音",
"myVoices": "マイ音声",
"loadFailed": "音声の読み込みに失敗しました",
"deleteFailed": "削除に失敗しました",
"deleteConfirmDesc": "「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
"local": "ローカル",
"aliyun": "Aliyun",
"deleting": "削除中..."
}

View File

@@ -8,5 +8,6 @@
"changeLanguage": "언어 변경",
"customVoiceTab": "커스텀",
"voiceDesignTab": "음색 생성",
"voiceCloneTab": "복제"
}
"voiceCloneTab": "복제",
"voiceManagement": "음성 관리"
}

View File

@@ -55,5 +55,12 @@
"browserNotSupported": "브라우저가 녹음 기능을 지원하지 않습니다",
"recordingComplete": "녹음 완료",
"releaseToFinish": "놓아서 완료",
"holdToRecord": "길게 눌러서 녹음"
}
"holdToRecord": "길게 눌러서 녹음",
"myVoices": "내 음성",
"loadFailed": "음성 불러오기 실패",
"deleteFailed": "삭제 실패",
"deleteConfirmDesc": "「{{name}}」을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"local": "로컬",
"aliyun": "Aliyun",
"deleting": "삭제 중..."
}

View File

@@ -8,5 +8,6 @@
"changeLanguage": "切换语言",
"customVoiceTab": "自定义",
"voiceDesignTab": "创造音色",
"voiceCloneTab": "克隆"
}
"voiceCloneTab": "克隆",
"voiceManagement": "音色管理"
}

View File

@@ -55,5 +55,12 @@
"browserNotSupported": "您的浏览器不支持录音功能",
"recordingComplete": "录制完成",
"releaseToFinish": "松开完成",
"holdToRecord": "按住录音"
}
"holdToRecord": "按住录音",
"myVoices": "我的音色",
"loadFailed": "加载音色失败",
"deleteFailed": "删除失败",
"deleteConfirmDesc": "确定要删除「{{name}}」吗?此操作不可撤销。",
"local": "本地",
"aliyun": "阿里云",
"deleting": "删除中..."
}

View File

@@ -8,5 +8,6 @@
"changeLanguage": "切換語言",
"customVoiceTab": "自訂",
"voiceDesignTab": "創造音色",
"voiceCloneTab": "複製"
}
"voiceCloneTab": "複製",
"voiceManagement": "音色管理"
}

View File

@@ -55,5 +55,12 @@
"browserNotSupported": "您的瀏覽器不支援錄音功能",
"recordingComplete": "錄製完成",
"releaseToFinish": "放開完成",
"holdToRecord": "按住錄音"
}
"holdToRecord": "按住錄音",
"myVoices": "我的音色",
"loadFailed": "載入音色失敗",
"deleteFailed": "刪除失敗",
"deleteConfirmDesc": "確定要刪除「{{name}}」嗎?此操作無法撤銷。",
"local": "本地",
"aliyun": "阿里雲",
"deleting": "刪除中..."
}

View 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>
)
}