diff --git a/qwen3-tts-backend/api/audiobook.py b/qwen3-tts-backend/api/audiobook.py index a0e26e4..aeb7d67 100644 --- a/qwen3-tts-backend/api/audiobook.py +++ b/qwen3-tts-backend/api/audiobook.py @@ -535,6 +535,8 @@ async def get_segments( character_id=seg.character_id, character_name=char_name, text=seg.text, + emo_text=seg.emo_text, + emo_alpha=seg.emo_alpha, audio_path=seg.audio_path, status=seg.status, )) diff --git a/qwen3-tts-frontend/src/pages/Audiobook.tsx b/qwen3-tts-frontend/src/pages/Audiobook.tsx index a45537d..6370983 100644 --- a/qwen3-tts-frontend/src/pages/Audiobook.tsx +++ b/qwen3-tts-frontend/src/pages/Audiobook.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { Book, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp, Play, Square, Pencil, Check, X, Loader2, Zap } from 'lucide-react' +import { Book, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp, Play, Square, Pencil, Check, X, Loader2, Zap, Settings2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' @@ -147,8 +147,6 @@ function LogStream({ projectId, chapterId, active }: { projectId: number; chapte const [lines, setLines] = useState([]) const [done, setDone] = useState(false) const containerRef = useRef(null) - const activeRef = useRef(active) - activeRef.current = active useEffect(() => { if (!active) return @@ -329,63 +327,571 @@ function CreateProjectPanel({ onCreated }: { onCreated: () => void }) { ) } -function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefresh: () => void }) { +function ProjectListSidebar({ + projects, + selectedId, + onSelect, + onNew, + onLLM, + loading, +}: { + projects: AudiobookProject[] + selectedId: number | null + onSelect: (id: number) => void + onNew: () => void + onLLM: () => void + loading: boolean +}) { + const { t } = useTranslation('audiobook') + return ( +
+
+ {t('title')} +
+ + +
+
+
+ {loading ? ( +
{t('loading')}
+ ) : projects.length === 0 ? ( +
{t('noProjects')}
+ ) : ( + projects.map(p => ( + + )) + )} +
+
+ ) +} + +function EmptyState() { + const { t } = useTranslation('audiobook') + return ( +
+
+ +

{t('noProjects')}

+

{t('noProjectsHint')}

+
+
+ ) +} + +function CharactersPanel({ + project, + detail, + loadingAction, + onConfirm, + onAnalyze, + onFetchDetail, +}: { + project: AudiobookProject + detail: AudiobookProjectDetail | null + loadingAction: boolean + onConfirm: () => void + onAnalyze: () => void + onFetchDetail: () => Promise +}) { const { t } = useTranslation('audiobook') - const [detail, setDetail] = useState(null) - const [segments, setSegments] = useState([]) - const [expanded, setExpanded] = useState(false) - const [loadingAction, setLoadingAction] = useState(false) - const [isPolling, setIsPolling] = useState(false) const [editingCharId, setEditingCharId] = useState(null) const [editFields, setEditFields] = useState({ name: '', gender: '', description: '', instruct: '', use_indextts2: false }) + const [regeneratingVoices, setRegeneratingVoices] = useState>(new Set()) + const [voiceKeys, setVoiceKeys] = useState>({}) + const status = project.status + const isActive = ['analyzing', 'generating'].includes(status) + + useEffect(() => { + if (status !== 'characters_ready') setEditingCharId(null) + }, [status]) + + const startEditChar = (char: AudiobookCharacter) => { + setEditingCharId(char.id) + setEditFields({ name: char.name, gender: char.gender || '', description: char.description || '', instruct: char.instruct || '', use_indextts2: char.use_indextts2 ?? false }) + } + + const saveEditChar = async (char: AudiobookCharacter) => { + try { + await audiobookApi.updateCharacter(project.id, char.id, { + name: editFields.name || char.name, + gender: editFields.gender || undefined, + description: editFields.description, + instruct: editFields.instruct, + use_indextts2: editFields.use_indextts2, + }) + setEditingCharId(null) + await onFetchDetail() + toast.success(t('projectCard.characters.savedSuccess')) + } catch (e: any) { + toast.error(formatApiError(e)) + } + } + + const handleRegeneratePreview = async (charId: number) => { + setRegeneratingVoices(prev => new Set(prev).add(charId)) + try { + await audiobookApi.regenerateCharacterPreview(project.id, charId) + toast.success(t('projectCard.characters.savedSuccess')) + setVoiceKeys(prev => ({ ...prev, [charId]: (prev[charId] || 0) + 1 })) + } catch (e: any) { + toast.error(formatApiError(e)) + } finally { + setRegeneratingVoices(prev => { const n = new Set(prev); n.delete(charId); return n }) + } + } + + const genderLabel = (gender: string) => { + if (gender === '男') return t('projectCard.characters.genderMale') + if (gender === '女') return t('projectCard.characters.genderFemale') + if (gender === '未知') return t('projectCard.characters.genderUnknown') + return gender + } + + const charCount = detail?.characters.length ?? 0 + + return ( +
+
+ + {t('projectCard.characters.title', { count: charCount })} + + {!isActive && status !== 'pending' && charCount > 0 && ( + + )} +
+ + {(!detail || charCount === 0) ? ( +
+ {status === 'pending' ? t('stepHints.pending') : t('projectCard.characters.title', { count: 0 })} +
+ ) : ( +
+ {detail.characters.map(char => ( +
+ {editingCharId === char.id ? ( +
+ setEditFields(f => ({ ...f, name: e.target.value }))} + placeholder={t('projectCard.characters.namePlaceholder')} + /> + + setEditFields(f => ({ ...f, instruct: e.target.value }))} + placeholder={t('projectCard.characters.instructPlaceholder')} + /> + setEditFields(f => ({ ...f, description: e.target.value }))} + placeholder={t('projectCard.characters.descPlaceholder')} + /> + +
+ + +
+
+ ) : ( +
+
+
+ {char.name} + {char.gender && ( + + {genderLabel(char.gender)} + + )} +
+
+ {char.use_indextts2 && ( + + IndexTTS2 + + )} + {status === 'characters_ready' && ( + + )} +
+
+ {char.instruct && {char.instruct}} +
+ {char.voice_design_id + ? {t('projectCard.characters.voiceDesign', { id: char.voice_design_id })} + : {t('projectCard.characters.noVoice')} + } +
+
+ )} + {!editingCharId && char.voice_design_id && ( +
+
+ +
+ {status === 'characters_ready' && ( + + )} +
+ )} +
+ ))} +
+ )} + + {status === 'characters_ready' && ( +
+ +
+ )} +
+ ) +} + +function ChaptersPanel({ + project, + detail, + segments, + loadingAction, + generatingChapterIndices, + sequentialPlayingId, + onParseChapter, + onGenerate, + onParseAll, + onGenerateAll, + onProcessAll, + onDownload, + onSequentialPlayingChange, +}: { + project: AudiobookProject + detail: AudiobookProjectDetail | null + segments: AudiobookSegment[] + loadingAction: boolean + generatingChapterIndices: Set + sequentialPlayingId: number | null + onParseChapter: (chapterId: number, title?: string) => void + onGenerate: (chapterIndex?: number) => void + onParseAll: () => void + onGenerateAll: () => void + onProcessAll: () => void + onDownload: (chapterIndex?: number) => void + onSequentialPlayingChange: (id: number | null) => void +}) { + const { t } = useTranslation('audiobook') + const [expandedChapters, setExpandedChapters] = useState>(new Set()) + const status = project.status + const doneCount = segments.filter(s => s.status === 'done').length + + useEffect(() => { + if (!detail || segments.length === 0) return + const generatingChapterIds = detail.chapters + .filter(ch => segments.some(s => s.chapter_index === ch.chapter_index && s.status === 'generating')) + .map(ch => ch.id) + if (generatingChapterIds.length === 0) return + setExpandedChapters(prev => { + const next = new Set(prev) + generatingChapterIds.forEach(id => next.add(id)) + return next.size === prev.size ? prev : next + }) + }, [segments, detail]) + + const hasChapters = detail && detail.chapters.length > 0 && ['ready', 'generating', 'done'].includes(status) + + return ( +
+
+ + {t('projectCard.chapters.title', { count: detail?.chapters.length ?? 0 })} + + {hasChapters && ( +
+ {detail!.chapters.some(c => ['pending', 'error', 'ready'].includes(c.status)) && ( + + )} + {detail!.chapters.some(c => c.status === 'ready') && ( + + )} + {detail!.chapters.some(c => ['pending', 'error'].includes(c.status)) && detail!.chapters.some(c => c.status === 'ready') && ( + + )} +
+ )} +
+ + {!hasChapters ? ( +
+ ) : ( +
+ {detail!.chapters.map(ch => { + const chSegs = segments.filter(s => s.chapter_index === ch.chapter_index) + const chDone = chSegs.filter(s => s.status === 'done').length + const chTotal = chSegs.length + const chGenerating = chSegs.some(s => s.status === 'generating') + const chAllDone = chTotal > 0 && chDone === chTotal + const chTitle = ch.title || t('projectCard.chapters.defaultTitle', { index: ch.chapter_index + 1 }) + const chExpanded = expandedChapters.has(ch.id) + const toggleChExpand = () => setExpandedChapters(prev => { + const next = new Set(prev) + if (next.has(ch.id)) next.delete(ch.id) + else next.add(ch.id) + return next + }) + return ( +
+
+ {chTitle} + {chSegs.length > 0 && ( + + )} +
+
+ {ch.status === 'pending' && ( + + )} + {ch.status === 'parsing' && ( +
+ + {t('projectCard.chapters.parsing')} +
+ )} + {ch.status === 'ready' && !chGenerating && !chAllDone && !generatingChapterIndices.has(ch.chapter_index) && ( + <> + + + + )} + {ch.status === 'ready' && (chGenerating || generatingChapterIndices.has(ch.chapter_index)) && ( +
+ + {t('projectCard.chapters.segmentProgress', { done: chDone, total: chTotal })} +
+ )} + {ch.status === 'ready' && chAllDone && ( + <> + + {t('projectCard.chapters.doneBadge', { count: chDone })} + + + + )} + {ch.status === 'error' && ( + <> + + {ch.error_message && ( + {ch.error_message} + )} + + )} +
+ {ch.status === 'parsing' && ( + + )} + {chExpanded && chSegs.length > 0 && ( +
+ {chSegs.map(seg => ( +
+
+ + {seg.character_name || t('projectCard.segments.unknownCharacter')} + + {seg.emo_text && ( + {seg.emo_text} + )} + {seg.status === 'generating' && } + {seg.status === 'error' && {t('projectCard.segments.errorBadge')}} +
+

{seg.text}

+ {seg.status === 'done' && ( + + )} +
+ ))} +
+ )} +
+ ) + })} +
+ )} + + {doneCount > 0 && ( +
+ +
+ )} +
+ ) +} + +export default function Audiobook() { + const { t } = useTranslation('audiobook') + const [projects, setProjects] = useState([]) + const [selectedProjectId, setSelectedProjectId] = useState(null) + const [detail, setDetail] = useState(null) + const [segments, setSegments] = useState([]) + const [loading, setLoading] = useState(true) + const [loadingAction, setLoadingAction] = useState(false) + const [isPolling, setIsPolling] = useState(false) const [generatingChapterIndices, setGeneratingChapterIndices] = useState>(new Set()) const [sequentialPlayingId, setSequentialPlayingId] = useState(null) - const [charsCollapsed, setCharsCollapsed] = useState(false) - const [chaptersCollapsed, setChaptersCollapsed] = useState(false) - const [expandedChapters, setExpandedChapters] = useState>(new Set()) - const [voiceKeys, setVoiceKeys] = useState>({}) - const [regeneratingVoices, setRegeneratingVoices] = useState>(new Set()) - const prevStatusRef = useRef(project.status) + const [showCreate, setShowCreate] = useState(false) + const [showLLM, setShowLLM] = useState(false) + const prevStatusRef = useRef('') const autoExpandedRef = useRef(new Set()) + const selectedProject = projects.find(p => p.id === selectedProjectId) ?? null + + const fetchProjects = useCallback(async () => { + try { + const list = await audiobookApi.listProjects() + setProjects(list) + } catch (e: any) { + toast.error(formatApiError(e)) + } finally { + setLoading(false) + } + }, []) + const fetchDetail = useCallback(async () => { - try { setDetail(await audiobookApi.getProject(project.id)) } catch {} - }, [project.id]) + if (!selectedProjectId) return + try { setDetail(await audiobookApi.getProject(selectedProjectId)) } catch {} + }, [selectedProjectId]) const fetchSegments = useCallback(async () => { - try { setSegments(await audiobookApi.getSegments(project.id)) } catch {} - }, [project.id]) + if (!selectedProjectId) return + try { setSegments(await audiobookApi.getSegments(selectedProjectId)) } catch {} + }, [selectedProjectId]) useEffect(() => { - if (expanded) { fetchDetail(); fetchSegments() } - }, [expanded, fetchDetail, fetchSegments]) + fetchProjects() + }, [fetchProjects]) useEffect(() => { - const s = project.status - if (['characters_ready', 'ready', 'generating'].includes(s) && !autoExpandedRef.current.has(s)) { - autoExpandedRef.current.add(s) - setExpanded(true) + if (!selectedProjectId) { + setDetail(null) + setSegments([]) + return + } + setDetail(null) + setSegments([]) + setGeneratingChapterIndices(new Set()) + setIsPolling(false) + autoExpandedRef.current.clear() + prevStatusRef.current = '' + fetchDetail() + fetchSegments() + }, [selectedProjectId, fetchDetail, fetchSegments]) + + const status = selectedProject?.status ?? '' + + useEffect(() => { + if (!selectedProject) return + if (['done', 'error'].includes(status)) setIsPolling(false) + if (['characters_ready', 'ready', 'generating'].includes(status) && !autoExpandedRef.current.has(status)) { + autoExpandedRef.current.add(status) fetchDetail() fetchSegments() } - if (['done', 'error'].includes(s)) setIsPolling(false) - }, [project.status, fetchDetail, fetchSegments]) + }, [status, selectedProject, fetchDetail, fetchSegments]) useEffect(() => { - if (prevStatusRef.current === 'generating' && project.status === 'done') { - toast.success(t('projectCard.allDoneToast', { title: project.title })) + if (!selectedProject) return + if (prevStatusRef.current === 'generating' && status === 'done') { + toast.success(t('projectCard.allDoneToast', { title: selectedProject.title })) } - prevStatusRef.current = project.status - }, [project.status, project.title, t]) + prevStatusRef.current = status + }, [status, selectedProject, t]) const hasParsingChapter = detail?.chapters.some(c => c.status === 'parsing') ?? false useEffect(() => { if (!isPolling) return - if (['analyzing', 'generating'].includes(project.status)) return + if (['analyzing', 'generating'].includes(status)) return if (hasParsingChapter) return if (!segments.some(s => s.status === 'generating')) setIsPolling(false) - }, [isPolling, project.status, segments, hasParsingChapter]) + }, [isPolling, status, segments, hasParsingChapter]) useEffect(() => { if (generatingChapterIndices.size === 0) return @@ -405,39 +911,26 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr } }, [segments, generatingChapterIndices]) - useEffect(() => { - const shouldPoll = isPolling || ['analyzing', 'generating'].includes(project.status) || hasParsingChapter || generatingChapterIndices.size > 0 - if (!shouldPoll) return - const id = setInterval(() => { onRefresh(); fetchSegments(); fetchDetail() }, 1500) - return () => clearInterval(id) - }, [isPolling, project.status, hasParsingChapter, generatingChapterIndices, onRefresh, fetchSegments, fetchDetail]) + const shouldPoll = isPolling || ['analyzing', 'generating'].includes(status) || hasParsingChapter || generatingChapterIndices.size > 0 useEffect(() => { - if (!detail || segments.length === 0) return - const generatingChapterIds = detail.chapters - .filter(ch => segments.some(s => s.chapter_index === ch.chapter_index && s.status === 'generating')) - .map(ch => ch.id) - if (generatingChapterIds.length === 0) return - setExpandedChapters(prev => { - const next = new Set(prev) - generatingChapterIds.forEach(id => next.add(id)) - return next.size === prev.size ? prev : next - }) - }, [segments, detail]) + if (!shouldPoll || !selectedProjectId) return + const id = setInterval(() => { fetchProjects(); fetchSegments(); fetchDetail() }, 1500) + return () => clearInterval(id) + }, [shouldPoll, selectedProjectId, fetchProjects, fetchSegments, fetchDetail]) const handleAnalyze = async () => { - const s = project.status - if (['characters_ready', 'ready', 'done'].includes(s)) { + if (!selectedProject) return + if (['characters_ready', 'ready', 'done'].includes(status)) { if (!confirm(t('projectCard.reanalyzeConfirm'))) return } autoExpandedRef.current.clear() - setEditingCharId(null) setLoadingAction(true) setIsPolling(true) try { - await audiobookApi.analyze(project.id, { turbo: true }) + await audiobookApi.analyze(selectedProject.id, { turbo: true }) toast.success(t('projectCard.analyzeStarted')) - onRefresh() + fetchProjects() } catch (e: any) { setIsPolling(false) toast.error(formatApiError(e)) @@ -447,11 +940,12 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr } const handleConfirm = async () => { + if (!selectedProject) return setLoadingAction(true) try { - await audiobookApi.confirmCharacters(project.id) + await audiobookApi.confirmCharacters(selectedProject.id) toast.success(t('projectCard.confirm.chaptersRecognized')) - onRefresh() + fetchProjects() fetchDetail() } catch (e: any) { toast.error(formatApiError(e)) @@ -461,8 +955,9 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr } const handleParseChapter = async (chapterId: number, title?: string) => { + if (!selectedProject) return try { - await audiobookApi.parseChapter(project.id, chapterId) + await audiobookApi.parseChapter(selectedProject.id, chapterId) toast.success(title ? t('projectCard.chapters.parseStarted', { title }) : t('projectCard.chapters.parseStartedDefault')) @@ -473,6 +968,7 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr } const handleGenerate = async (chapterIndex?: number) => { + if (!selectedProject) return setLoadingAction(true) if (chapterIndex !== undefined) { setGeneratingChapterIndices(prev => new Set([...prev, chapterIndex])) @@ -480,11 +976,11 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr setIsPolling(true) } try { - await audiobookApi.generate(project.id, chapterIndex) + await audiobookApi.generate(selectedProject.id, chapterIndex) toast.success(chapterIndex !== undefined ? t('projectCard.chapters.generateStarted', { index: chapterIndex + 1 }) : t('projectCard.chapters.generateAllStarted')) - onRefresh() + fetchProjects() fetchSegments() } catch (e: any) { if (chapterIndex !== undefined) { @@ -499,12 +995,13 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr } const handleParseAll = async () => { + if (!selectedProject) return setLoadingAction(true) setIsPolling(true) try { - await audiobookApi.parseAllChapters(project.id) + await audiobookApi.parseAllChapters(selectedProject.id) toast.success(t('projectCard.chapters.parseAllStarted')) - onRefresh() + fetchProjects() fetchDetail() } catch (e: any) { setIsPolling(false) @@ -515,11 +1012,12 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr } const handleRetryFailed = async () => { + if (!selectedProject) return setIsPolling(true) try { - await audiobookApi.parseAllChapters(project.id, true) + await audiobookApi.parseAllChapters(selectedProject.id, true) toast.success(t('projectCard.chapters.parseAllStarted')) - onRefresh() + fetchProjects() fetchDetail() } catch (e: any) { setIsPolling(false) @@ -527,26 +1025,8 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr } } - const handleRegeneratePreview = async (charId: number) => { - if (!project) return - setRegeneratingVoices(prev => new Set(prev).add(charId)) - try { - await audiobookApi.regenerateCharacterPreview(project.id, charId) - toast.success(t('projectCard.characters.savedSuccess')) // or add a new toast key - setVoiceKeys(prev => ({ ...prev, [charId]: (prev[charId] || 0) + 1 })) - } catch (e: any) { - toast.error(formatApiError(e)) - } finally { - setRegeneratingVoices(prev => { - const next = new Set(prev) - next.delete(charId) - return next - }) - } - } - const handleGenerateAll = async () => { - if (!detail) return + if (!selectedProject || !detail) return setLoadingAction(true) const ready = detail.chapters.filter(c => c.status === 'ready') if (ready.length > 0) { @@ -554,9 +1034,9 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr } setIsPolling(true) try { - await audiobookApi.generate(project.id) + await audiobookApi.generate(selectedProject.id) toast.success(t('projectCard.chapters.generateAllStarted')) - onRefresh() + fetchProjects() fetchSegments() } catch (e: any) { setIsPolling(false) @@ -567,7 +1047,7 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr } const handleProcessAll = async () => { - if (!detail) return + if (!selectedProject || !detail) return setLoadingAction(true) const ready = detail.chapters.filter(c => c.status === 'ready') if (ready.length > 0) { @@ -575,9 +1055,9 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr } setIsPolling(true) try { - await audiobookApi.processAll(project.id) + await audiobookApi.processAll(selectedProject.id) toast.success(t('projectCard.chapters.processAllStarted')) - onRefresh() + fetchProjects() fetchDetail() fetchSegments() } catch (e: any) { @@ -589,21 +1069,23 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr } const handleCancelBatch = async () => { + if (!selectedProject) return try { - await audiobookApi.cancelBatch(project.id) + await audiobookApi.cancelBatch(selectedProject.id) toast.success(t('projectCard.cancelledToast')) setIsPolling(false) setGeneratingChapterIndices(new Set()) - setTimeout(() => { onRefresh(); fetchDetail(); fetchSegments() }, 1000) + setTimeout(() => { fetchProjects(); fetchDetail(); fetchSegments() }, 1000) } catch (e: any) { toast.error(formatApiError(e)) } } const handleDownload = async (chapterIndex?: number) => { + if (!selectedProject) return setLoadingAction(true) try { - const response = await apiClient.get(`/audiobook/projects/${project.id}/download`, { + const response = await apiClient.get(`/audiobook/projects/${selectedProject.id}/download`, { responseType: 'blob', params: chapterIndex !== undefined ? { chapter: chapterIndex } : {}, }) @@ -611,8 +1093,8 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr const a = document.createElement('a') a.href = url a.download = chapterIndex !== undefined - ? `${project.title}_ch${chapterIndex + 1}.mp3` - : `${project.title}.mp3` + ? `${selectedProject.title}_ch${chapterIndex + 1}.mp3` + : `${selectedProject.title}.mp3` a.click() URL.revokeObjectURL(url) } catch (e: any) { @@ -623,52 +1105,23 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr } const handleDelete = async () => { - if (!confirm(t('projectCard.deleteConfirm', { title: project.title }))) return + if (!selectedProject) return + if (!confirm(t('projectCard.deleteConfirm', { title: selectedProject.title }))) return try { - await audiobookApi.deleteProject(project.id) + await audiobookApi.deleteProject(selectedProject.id) toast.success(t('projectCard.deleteSuccess')) - onRefresh() + setSelectedProjectId(null) + setDetail(null) + setSegments([]) + fetchProjects() } catch (e: any) { toast.error(formatApiError(e)) } } - const startEditChar = (char: AudiobookCharacter) => { - setEditingCharId(char.id) - setEditFields({ name: char.name, gender: char.gender || '', description: char.description || '', instruct: char.instruct || '', use_indextts2: char.use_indextts2 ?? false }) - } - - const saveEditChar = async (char: AudiobookCharacter) => { - try { - await audiobookApi.updateCharacter(project.id, char.id, { - name: editFields.name || char.name, - gender: editFields.gender || undefined, - description: editFields.description, - instruct: editFields.instruct, - use_indextts2: editFields.use_indextts2, - }) - setEditingCharId(null) - await fetchDetail() - toast.success(t('projectCard.characters.savedSuccess')) - } catch (e: any) { - toast.error(formatApiError(e)) - } - } - - const genderLabel = (gender: string) => { - if (gender === '男') return t('projectCard.characters.genderMale') - if (gender === '女') return t('projectCard.characters.genderFemale') - if (gender === '未知') return t('projectCard.characters.genderUnknown') - return gender - } - - const status = project.status - const isActive = ['analyzing', 'generating'].includes(status) const doneCount = segments.filter(s => s.status === 'done').length const totalCount = segments.length const progress = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0 - - // Chapter parsing progress const chaptersParsed = detail?.chapters.filter(c => ['ready', 'done'].includes(c.status) || (segments.some(s => s.chapter_index === c.chapter_index))).length ?? 0 const chaptersParsing = detail?.chapters.filter(c => c.status === 'parsing').length ?? 0 const chaptersError = detail?.chapters.filter(c => c.status === 'error').length ?? 0 @@ -676,504 +1129,192 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr const chapterProgress = chaptersTotal > 0 ? Math.round((chaptersParsed / chaptersTotal) * 100) : 0 const hasGenerating = segments.some(s => s.status === 'generating') - // Frontend display status override const displayStatus = (() => { - if (['ready', 'generating', 'done'].includes(status)) { + if (!selectedProject) return '' + const s = selectedProject.status + if (['ready', 'generating', 'done'].includes(s)) { if (chaptersParsing > 0 && hasGenerating) return 'processing' if (chaptersParsing > 0) return 'parsing' if (hasGenerating) return 'generating' if (totalCount > 0 && doneCount === totalCount && chaptersTotal > 0 && chaptersParsing === 0 && chaptersError === 0) return 'done' - if (status === 'done' && (chaptersError > 0 || (chaptersTotal > 0 && chaptersParsed < chaptersTotal))) return 'ready' + if (s === 'done' && (chaptersError > 0 || (chaptersTotal > 0 && chaptersParsed < chaptersTotal))) return 'ready' } - return status + return s })() const isTurboMode = ['analyzing', 'parsing', 'processing'].includes(displayStatus) return ( -
-
-
- - {project.title} -
-
- {isTurboMode && ( - - {t('status.turboActive')} - - )} - - {t(`status.${displayStatus}`, { defaultValue: displayStatus })} - - -
-
- - {STEP_HINT_STATUSES.includes(status) && ( -
- {t(`stepHints.${status}`)} -
- )} - - {status === 'analyzing' && ( - - )} - - {project.error_message && ( -
{project.error_message}
- )} - - {chaptersTotal > 0 && ['ready', 'generating', 'done'].includes(status) && (chaptersParsing > 0 || chaptersError > 0 || (totalCount > 0 && doneCount > 0)) && ( -
- {(chaptersParsing > 0 || chaptersError > 0 || chaptersParsed < chaptersTotal) && ( -
-
-
- 📝 - {t('projectCard.chaptersProgress', { parsed: chaptersParsed, total: chaptersTotal })} - {chaptersParsing > 0 && ( - ({t('projectCard.chaptersParsing', { count: chaptersParsing })}) - )} - {chaptersError > 0 && ( - <> - ({t('projectCard.chaptersError', { count: chaptersError })}) - - - )} -
- {chaptersParsing > 0 && totalCount > 0 && ( - - )} -
- -
- )} - {totalCount > 0 && doneCount > 0 && ( -
-
-
- 🎵 - {t('projectCard.segmentsProgress', { done: doneCount, total: totalCount })} -
- {!chaptersParsing && hasGenerating && ( - - )} -
- -
- )} - {chaptersParsing > 0 && !totalCount && ( -
- -
- )} -
- )} - -
-
- {status === 'pending' && ( - - )} - {status === 'ready' && ( - - )} - {status === 'done' && ( - - )} -
- -
- - {expanded && ( -
- {detail && detail.characters.length > 0 && ( -
-
- - {!isActive && status !== 'pending' && ( - - )} -
- {!charsCollapsed &&
- {detail.characters.map(char => ( -
- {editingCharId === char.id ? ( -
- setEditFields(f => ({ ...f, name: e.target.value }))} - placeholder={t('projectCard.characters.namePlaceholder')} - /> - - setEditFields(f => ({ ...f, instruct: e.target.value }))} - placeholder={t('projectCard.characters.instructPlaceholder')} - /> - setEditFields(f => ({ ...f, description: e.target.value }))} - placeholder={t('projectCard.characters.descPlaceholder')} - /> - -
- - -
-
- ) : ( -
-
- {char.name} - {char.gender && ( - - {genderLabel(char.gender)} - - )} -
- {char.instruct} -
- {char.use_indextts2 && ( - - IndexTTS2 - - )} - {char.voice_design_id - ? {t('projectCard.characters.voiceDesign', { id: char.voice_design_id })} - : {t('projectCard.characters.noVoice')} - } - {status === 'characters_ready' && ( - - )} -
-
- )} - - {!editingCharId && char.voice_design_id && ( -
-
- -
- - {status === 'characters_ready' && ( - - )} -
- )} -
- ))} -
} - {status === 'characters_ready' && ( - - )} -
- )} - - {detail && detail.chapters.length > 0 && ['ready', 'generating', 'done'].includes(status) && ( -
-
- -
- {detail.chapters.some(c => ['pending', 'error', 'ready'].includes(c.status)) && ( - - )} - {detail.chapters.some(c => c.status === 'ready') && ( - - )} - {detail.chapters.some(c => ['pending', 'error'].includes(c.status)) && detail.chapters.some(c => c.status === 'ready') && ( - - )} -
-
- {!chaptersCollapsed &&
- {detail.chapters.map(ch => { - const chSegs = segments.filter(s => s.chapter_index === ch.chapter_index) - const chDone = chSegs.filter(s => s.status === 'done').length - const chTotal = chSegs.length - const chGenerating = chSegs.some(s => s.status === 'generating') - const chAllDone = chTotal > 0 && chDone === chTotal - const chTitle = ch.title || t('projectCard.chapters.defaultTitle', { index: ch.chapter_index + 1 }) - const chExpanded = expandedChapters.has(ch.id) - const toggleChExpand = () => setExpandedChapters(prev => { - const next = new Set(prev) - if (next.has(ch.id)) next.delete(ch.id) - else next.add(ch.id) - return next - }) - return ( -
-
- {chTitle} - {chSegs.length > 0 && ( - - )} -
-
- {ch.status === 'pending' && ( - - )} - {ch.status === 'parsing' && ( -
- - {t('projectCard.chapters.parsing')} -
- )} - {ch.status === 'ready' && !chGenerating && !chAllDone && !generatingChapterIndices.has(ch.chapter_index) && ( - <> - - - - )} - {ch.status === 'ready' && (chGenerating || generatingChapterIndices.has(ch.chapter_index)) && ( -
- - {t('projectCard.chapters.segmentProgress', { done: chDone, total: chTotal })} -
- )} - {ch.status === 'ready' && chAllDone && ( - <> - - {t('projectCard.chapters.doneBadge', { count: chDone })} - - - - )} - {ch.status === 'error' && ( - <> - - {ch.error_message && ( - {ch.error_message} - )} - - )} -
- {ch.status === 'parsing' && ( - - )} - {chExpanded && chSegs.length > 0 && ( -
- {chSegs.map(seg => ( -
-
- - {seg.character_name || t('projectCard.segments.unknownCharacter')} - - {seg.emo_text && ( - - {seg.emo_text} - - )} - {seg.status === 'generating' && } - {seg.status === 'error' && {t('projectCard.segments.errorBadge')}} -
-

{seg.text}

- {seg.status === 'done' && ( - - )} -
- ))} -
- )} -
- ) - })} -
} - {!chaptersCollapsed && doneCount > 0 && ( -
- -
- )} -
- )} - -
- )} -
- ) -} - -export default function Audiobook() { - const { t } = useTranslation('audiobook') - const [projects, setProjects] = useState([]) - 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 ( -
+
-
-
-

{t('title')}

-
- - - -
+
+ { + if (id !== selectedProjectId) { + setSelectedProjectId(id) + setIsPolling(false) + setGeneratingChapterIndices(new Set()) + } + }} + onNew={() => { setShowCreate(v => !v); setShowLLM(false) }} + onLLM={() => { setShowLLM(v => !v); setShowCreate(false) }} + loading={loading} + /> +
+ {showLLM && ( +
+ setShowLLM(false)} /> +
+ )} + {showCreate && ( +
+ { setShowCreate(false); fetchProjects() }} /> +
+ )} + {!selectedProject ? ( + + ) : ( + <> +
+
+ + {selectedProject.title} +
+
+ {isTurboMode && ( + + {t('status.turboActive')} + + )} + + {t(`status.${displayStatus}`, { defaultValue: displayStatus })} + + {status === 'pending' && ( + + )} + {status === 'ready' && ( + + )} + {status === 'done' && ( + + )} + +
+
+ + {STEP_HINT_STATUSES.includes(status) && ( +
+ {t(`stepHints.${status}`)} +
+ )} + + {status === 'analyzing' && ( +
+ +
+ )} + + {selectedProject.error_message && ( +
+ {selectedProject.error_message} +
+ )} + + {chaptersTotal > 0 && ['ready', 'generating', 'done'].includes(status) && (chaptersParsing > 0 || chaptersError > 0 || (totalCount > 0 && doneCount > 0)) && ( +
+ {(chaptersParsing > 0 || chaptersError > 0 || chaptersParsed < chaptersTotal) && ( +
+
+
+ 📝 + {t('projectCard.chaptersProgress', { parsed: chaptersParsed, total: chaptersTotal })} + {chaptersParsing > 0 && ( + ({t('projectCard.chaptersParsing', { count: chaptersParsing })}) + )} + {chaptersError > 0 && ( + <> + ({t('projectCard.chaptersError', { count: chaptersError })}) + + + )} +
+ {chaptersParsing > 0 && totalCount > 0 && ( + + )} +
+ +
+ )} + {totalCount > 0 && doneCount > 0 && ( +
+
+
+ 🎵 + {t('projectCard.segmentsProgress', { done: doneCount, total: totalCount })} +
+ {!chaptersParsing && hasGenerating && ( + + )} +
+ +
+ )} + {chaptersParsing > 0 && !totalCount && ( +
+ +
+ )} +
+ )} + +
+ + +
+ + )}
- - {showLLM && setShowLLM(false)} />} - {showCreate && { setShowCreate(false); fetchProjects() }} />} - - {loading ? ( -
{t('loading')}
- ) : projects.length === 0 ? ( -
- -

{t('noProjects')}

-

{t('noProjectsHint')}

-
- ) : ( -
- {projects.map(p => ( - - ))} -
- )} -
+
) }