feat(audiobook): implement audiobook project management features

This commit is contained in:
2026-03-09 11:39:36 +08:00
parent 28218e6616
commit a3d7d318e0
13 changed files with 1565 additions and 4 deletions

View File

@@ -16,6 +16,7 @@ const Home = lazy(() => import('@/pages/Home'))
const Settings = lazy(() => import('@/pages/Settings'))
const UserManagement = lazy(() => import('@/pages/UserManagement'))
const VoiceManagement = lazy(() => import('@/pages/VoiceManagement'))
const Audiobook = lazy(() => import('@/pages/Audiobook'))
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth()
@@ -109,6 +110,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/audiobook"
element={
<ProtectedRoute>
<Audiobook />
</ProtectedRoute>
}
/>
</Routes>
</Suspense>
</UserPreferencesProvider>

View File

@@ -1,4 +1,4 @@
import { Menu, LogOut, Users, Settings, Globe, Home, Mic } from 'lucide-react'
import { Menu, LogOut, Users, Settings, Globe, Home, Mic, BookOpen } from 'lucide-react'
import { Link, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
@@ -49,6 +49,12 @@ export function Navbar({ onToggleSidebar }: NavbarProps) {
</Button>
</Link>
<Link to="/audiobook">
<Button variant="ghost" size="icon">
<BookOpen className="h-5 w-5" />
</Button>
</Link>
{user?.is_superuser && (
<Link to="/users">
<Button variant="ghost" size="icon">

View File

@@ -0,0 +1,125 @@
import apiClient from '@/lib/api'
export interface AudiobookProject {
id: number
user_id: number
title: string
source_type: string
status: string
llm_model?: string
error_message?: string
created_at: string
updated_at: string
}
export interface AudiobookCharacter {
id: number
project_id: number
name: string
description?: string
instruct?: string
voice_design_id?: number
}
export interface AudiobookProjectDetail extends AudiobookProject {
characters: AudiobookCharacter[]
}
export interface AudiobookSegment {
id: number
project_id: number
chapter_index: number
segment_index: number
character_id: number
character_name?: string
text: string
audio_path?: string
status: string
}
export interface LLMConfig {
base_url?: string
model?: string
has_key: boolean
}
export const audiobookApi = {
createProject: async (data: {
title: string
source_type: string
source_text?: string
}): Promise<AudiobookProject> => {
const response = await apiClient.post<AudiobookProject>('/audiobook/projects', data)
return response.data
},
uploadEpub: async (title: string, file: File): Promise<AudiobookProject> => {
const formData = new FormData()
formData.append('title', title)
formData.append('file', file)
const response = await apiClient.post<AudiobookProject>(
'/audiobook/projects/upload',
formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }
)
return response.data
},
listProjects: async (): Promise<AudiobookProject[]> => {
const response = await apiClient.get<AudiobookProject[]>('/audiobook/projects')
return response.data
},
getProject: async (id: number): Promise<AudiobookProjectDetail> => {
const response = await apiClient.get<AudiobookProjectDetail>(`/audiobook/projects/${id}`)
return response.data
},
analyze: async (id: number): Promise<void> => {
await apiClient.post(`/audiobook/projects/${id}/analyze`)
},
updateCharacterVoice: async (projectId: number, charId: number, voiceDesignId: number): Promise<AudiobookCharacter> => {
const response = await apiClient.put<AudiobookCharacter>(
`/audiobook/projects/${projectId}/characters/${charId}`,
{ voice_design_id: voiceDesignId }
)
return response.data
},
generate: async (id: number): Promise<void> => {
await apiClient.post(`/audiobook/projects/${id}/generate`)
},
getSegments: async (id: number, chapter?: number): Promise<AudiobookSegment[]> => {
const params = chapter !== undefined ? { chapter } : {}
const response = await apiClient.get<AudiobookSegment[]>(
`/audiobook/projects/${id}/segments`,
{ params }
)
return response.data
},
getDownloadUrl: (id: number, chapter?: number): string => {
const base = import.meta.env.VITE_API_URL || ''
const chapterParam = chapter !== undefined ? `?chapter=${chapter}` : ''
return `${base}/audiobook/projects/${id}/download${chapterParam}`
},
deleteProject: async (id: number): Promise<void> => {
await apiClient.delete(`/audiobook/projects/${id}`)
},
getLLMConfig: async (): Promise<LLMConfig> => {
const response = await apiClient.get<LLMConfig>('/auth/llm-config')
return response.data
},
setLLMConfig: async (config: { base_url: string; api_key: string; model: string }): Promise<void> => {
await apiClient.put('/auth/llm-config', config)
},
deleteLLMConfig: async (): Promise<void> => {
await apiClient.delete('/auth/llm-config')
},
}

View File

@@ -0,0 +1,352 @@
import { useState, useEffect, useCallback } from 'react'
import { toast } from 'sonner'
import { Book, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Navbar } from '@/components/Navbar'
import { audiobookApi, type AudiobookProject, type AudiobookProjectDetail, type AudiobookSegment } from '@/lib/api/audiobook'
import { formatApiError } from '@/lib/api'
const STATUS_LABELS: Record<string, string> = {
pending: '待分析',
analyzing: '分析中',
ready: '待生成',
generating: '生成中',
done: '已完成',
error: '出错',
}
const STATUS_COLORS: Record<string, string> = {
pending: 'secondary',
analyzing: 'default',
ready: 'default',
generating: 'default',
done: 'default',
error: 'destructive',
}
function LLMConfigPanel({ onSaved }: { onSaved?: () => void }) {
const [baseUrl, setBaseUrl] = useState('')
const [apiKey, setApiKey] = useState('')
const [model, setModel] = useState('')
const [loading, setLoading] = useState(false)
const [existing, setExisting] = useState<{ base_url?: string; model?: string; has_key: boolean } | null>(null)
useEffect(() => {
audiobookApi.getLLMConfig().then(setExisting).catch(() => {})
}, [])
const handleSave = async () => {
if (!baseUrl || !apiKey || !model) {
toast.error('请填写完整的 LLM 配置')
return
}
setLoading(true)
try {
await audiobookApi.setLLMConfig({ base_url: baseUrl, api_key: apiKey, model })
toast.success('LLM 配置已保存')
setApiKey('')
const updated = await audiobookApi.getLLMConfig()
setExisting(updated)
onSaved?.()
} catch (e: any) {
toast.error(formatApiError(e))
} finally {
setLoading(false)
}
}
return (
<div className="border rounded-lg p-4 space-y-3">
<div className="font-medium text-sm">LLM </div>
{existing && (
<div className="text-xs text-muted-foreground">
: {existing.base_url || '未设置'} / {existing.model || '未设置'} / {existing.has_key ? '已配置密钥' : '未配置密钥'}
</div>
)}
<Input placeholder="Base URL (e.g. https://api.openai.com/v1)" value={baseUrl} onChange={e => setBaseUrl(e.target.value)} />
<Input placeholder="API Key" type="password" value={apiKey} onChange={e => setApiKey(e.target.value)} />
<Input placeholder="Model (e.g. gpt-4o)" value={model} onChange={e => setModel(e.target.value)} />
<Button size="sm" onClick={handleSave} disabled={loading}>{loading ? '保存中...' : '保存配置'}</Button>
</div>
)
}
function CreateProjectPanel({ onCreated }: { onCreated: () => void }) {
const [title, setTitle] = useState('')
const [sourceType, setSourceType] = useState<'text' | 'epub'>('text')
const [text, setText] = useState('')
const [epubFile, setEpubFile] = useState<File | null>(null)
const [loading, setLoading] = useState(false)
const handleCreate = async () => {
if (!title) { toast.error('请输入书名'); return }
if (sourceType === 'text' && !text) { toast.error('请输入文本内容'); return }
if (sourceType === 'epub' && !epubFile) { toast.error('请选择 epub 文件'); return }
setLoading(true)
try {
if (sourceType === 'text') {
await audiobookApi.createProject({ title, source_type: 'text', source_text: text })
} else {
await audiobookApi.uploadEpub(title, epubFile!)
}
toast.success('项目已创建')
setTitle(''); setText(''); setEpubFile(null)
onCreated()
} catch (e: any) {
toast.error(formatApiError(e))
} finally {
setLoading(false)
}
}
return (
<div className="border rounded-lg p-4 space-y-3">
<div className="font-medium text-sm"></div>
<Input placeholder="书名" value={title} onChange={e => setTitle(e.target.value)} />
<div className="flex gap-2">
<Button size="sm" variant={sourceType === 'text' ? 'default' : 'outline'} onClick={() => setSourceType('text')}></Button>
<Button size="sm" variant={sourceType === 'epub' ? 'default' : 'outline'} onClick={() => setSourceType('epub')}> epub</Button>
</div>
{sourceType === 'text' && (
<Textarea placeholder="粘贴小说文本..." rows={6} value={text} onChange={e => setText(e.target.value)} />
)}
{sourceType === 'epub' && (
<Input type="file" accept=".epub" onChange={e => setEpubFile(e.target.files?.[0] || null)} />
)}
<Button size="sm" onClick={handleCreate} disabled={loading}>{loading ? '创建中...' : '创建项目'}</Button>
</div>
)
}
function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefresh: () => void }) {
const [detail, setDetail] = useState<AudiobookProjectDetail | null>(null)
const [segments, setSegments] = useState<AudiobookSegment[]>([])
const [expanded, setExpanded] = useState(false)
const [loadingAction, setLoadingAction] = useState(false)
const fetchDetail = useCallback(async () => {
try {
const d = await audiobookApi.getProject(project.id)
setDetail(d)
} catch {}
}, [project.id])
const fetchSegments = useCallback(async () => {
try {
const s = await audiobookApi.getSegments(project.id)
setSegments(s)
} catch {}
}, [project.id])
useEffect(() => {
if (expanded) {
fetchDetail()
fetchSegments()
}
}, [expanded, fetchDetail, fetchSegments])
useEffect(() => {
if (['analyzing', 'generating'].includes(project.status)) {
const interval = setInterval(() => {
onRefresh()
if (expanded) { fetchDetail(); fetchSegments() }
}, 3000)
return () => clearInterval(interval)
}
}, [project.status, expanded, onRefresh, fetchDetail, fetchSegments])
const handleAnalyze = async () => {
setLoadingAction(true)
try {
await audiobookApi.analyze(project.id)
toast.success('分析已开始')
onRefresh()
} catch (e: any) {
toast.error(formatApiError(e))
} finally {
setLoadingAction(false)
}
}
const handleGenerate = async () => {
setLoadingAction(true)
try {
await audiobookApi.generate(project.id)
toast.success('生成已开始')
onRefresh()
} catch (e: any) {
toast.error(formatApiError(e))
} finally {
setLoadingAction(false)
}
}
const handleDelete = async () => {
if (!confirm(`确认删除项目「${project.title}」及所有音频?`)) return
try {
await audiobookApi.deleteProject(project.id)
toast.success('项目已删除')
onRefresh()
} catch (e: any) {
toast.error(formatApiError(e))
}
}
const doneCount = segments.filter(s => s.status === 'done').length
const totalCount = segments.length
const progress = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0
return (
<div className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Book className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="font-medium truncate">{project.title}</span>
<Badge variant={(STATUS_COLORS[project.status] || 'secondary') as any} className="shrink-0">
{STATUS_LABELS[project.status] || project.status}
</Badge>
</div>
<div className="flex gap-1 shrink-0">
{project.status === 'pending' && (
<Button size="sm" variant="outline" onClick={handleAnalyze} disabled={loadingAction}></Button>
)}
{project.status === 'ready' && (
<Button size="sm" onClick={handleGenerate} disabled={loadingAction}></Button>
)}
{project.status === 'done' && (
<Button size="sm" variant="outline" asChild>
<a href={audiobookApi.getDownloadUrl(project.id)} download>
<Download className="h-3 w-3 mr-1" />
</a>
</Button>
)}
<Button size="icon" variant="ghost" onClick={() => { setExpanded(!expanded) }}>
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
<Button size="icon" variant="ghost" onClick={handleDelete}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
{project.error_message && (
<div className="text-xs text-destructive bg-destructive/10 rounded p-2">{project.error_message}</div>
)}
{['generating', 'done'].includes(project.status) && totalCount > 0 && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">{doneCount}/{totalCount} </div>
<Progress value={progress} />
</div>
)}
{expanded && detail && (
<div className="space-y-3 pt-2 border-t">
{detail.characters.length > 0 && (
<div>
<div className="text-xs font-medium text-muted-foreground mb-2"></div>
<div className="space-y-1">
{detail.characters.map(char => (
<div key={char.id} className="flex items-center justify-between text-sm border rounded px-2 py-1">
<span className="font-medium">{char.name}</span>
<span className="text-xs text-muted-foreground truncate max-w-[200px]">{char.instruct}</span>
{char.voice_design_id && (
<Badge variant="outline" className="text-xs"> #{char.voice_design_id}</Badge>
)}
</div>
))}
</div>
</div>
)}
{segments.length > 0 && (
<div>
<div className="text-xs font-medium text-muted-foreground mb-2">
({segments.length} )
</div>
<div className="max-h-64 overflow-y-auto space-y-1">
{segments.slice(0, 50).map(seg => (
<div key={seg.id} className="flex items-start gap-2 text-xs border rounded px-2 py-1">
<Badge variant="outline" className="shrink-0 text-xs">{seg.character_name || '?'}</Badge>
<span className="text-muted-foreground truncate">{seg.text}</span>
<Badge variant={seg.status === 'done' ? 'default' : 'secondary'} className="shrink-0 text-xs">
{seg.status}
</Badge>
</div>
))}
{segments.length > 50 && (
<div className="text-xs text-muted-foreground text-center py-1">... {segments.length - 50} </div>
)}
</div>
</div>
)}
</div>
)}
</div>
)
}
export default function Audiobook() {
const [projects, setProjects] = useState<AudiobookProject[]>([])
const [showCreate, setShowCreate] = useState(false)
const [showLLM, setShowLLM] = useState(false)
const [loading, setLoading] = useState(true)
const fetchProjects = useCallback(async () => {
try {
const list = await audiobookApi.listProjects()
setProjects(list)
} catch (e: any) {
toast.error(formatApiError(e))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchProjects()
}, [fetchProjects])
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 container max-w-3xl mx-auto px-4 py-6 space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => setShowLLM(!showLLM)}>LLM </Button>
<Button size="sm" onClick={() => setShowCreate(!showCreate)}>
<Plus className="h-4 w-4 mr-1" />
</Button>
<Button size="icon" variant="ghost" onClick={fetchProjects}>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
{showLLM && <LLMConfigPanel onSaved={() => setShowLLM(false)} />}
{showCreate && <CreateProjectPanel onCreated={() => { setShowCreate(false); fetchProjects() }} />}
{loading ? (
<div className="text-center text-muted-foreground py-12">...</div>
) : projects.length === 0 ? (
<div className="text-center text-muted-foreground py-12">
<Book className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p></p>
<p className="text-sm mt-1"></p>
</div>
) : (
<div className="space-y-3">
{projects.map(p => (
<ProjectCard key={p.id} project={p} onRefresh={fetchProjects} />
))}
</div>
)}
</main>
</div>
)
}