import { useState, useEffect, useCallback, useRef } from 'react' import { toast } from 'sonner' import { Book, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp, Play, Square, Pencil, Check, X } 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 { AudioPlayer } from '@/components/AudioPlayer' import { audiobookApi, type AudiobookProject, type AudiobookProjectDetail, type AudiobookCharacter, type AudiobookSegment } from '@/lib/api/audiobook' import apiClient, { formatApiError } from '@/lib/api' const STATUS_LABELS: Record = { pending: '待分析', analyzing: '分析中', characters_ready: '角色待确认', parsing: '解析章节', ready: '待生成', generating: '生成中', done: '已完成', error: '出错', } const STATUS_COLORS: Record = { pending: 'secondary', analyzing: 'default', characters_ready: 'default', parsing: 'default', ready: 'default', generating: 'default', done: 'outline', error: 'destructive', } const STEP_HINTS: Record = { pending: '第 1 步:点击「分析」,LLM 将自动提取角色列表', analyzing: '第 1 步:LLM 正在提取角色,请稍候...', characters_ready: '第 2 步:确认角色信息,可编辑后点击「确认角色 · 解析章节」', parsing: '第 3 步:LLM 正在解析章节脚本,请稍候...', ready: '第 4 步:按章节逐章生成音频,或一次性生成全书', generating: '第 5 步:正在合成音频,已完成片段可立即播放', } function SequentialPlayer({ segments, projectId, onPlayingChange, }: { segments: AudiobookSegment[] projectId: number onPlayingChange: (segmentId: number | null) => void }) { const [displayIndex, setDisplayIndex] = useState(null) const [isLoading, setIsLoading] = useState(false) const audioRef = useRef(new Audio()) const blobUrlsRef = useRef>({}) const currentIndexRef = useRef(null) const doneSegments = segments.filter(s => s.status === 'done') useEffect(() => { const audio = audioRef.current return () => { audio.pause() audio.src = '' Object.values(blobUrlsRef.current).forEach(url => URL.revokeObjectURL(url)) } }, []) const stop = useCallback(() => { audioRef.current.pause() audioRef.current.src = '' currentIndexRef.current = null setDisplayIndex(null) setIsLoading(false) onPlayingChange(null) }, [onPlayingChange]) const playSegment = useCallback(async (index: number) => { if (index >= doneSegments.length) { currentIndexRef.current = null setDisplayIndex(null) onPlayingChange(null) return } const seg = doneSegments[index] currentIndexRef.current = index setDisplayIndex(index) onPlayingChange(seg.id) setIsLoading(true) try { if (!blobUrlsRef.current[seg.id]) { const response = await apiClient.get( audiobookApi.getSegmentAudioUrl(projectId, seg.id), { responseType: 'blob' } ) blobUrlsRef.current[seg.id] = URL.createObjectURL(response.data) } const audio = audioRef.current audio.src = blobUrlsRef.current[seg.id] await audio.play() } catch { playSegment(index + 1) } finally { setIsLoading(false) } }, [doneSegments, projectId, onPlayingChange]) useEffect(() => { const audio = audioRef.current const handleEnded = () => { if (currentIndexRef.current !== null) { playSegment(currentIndexRef.current + 1) } } audio.addEventListener('ended', handleEnded) return () => audio.removeEventListener('ended', handleEnded) }, [playSegment]) if (doneSegments.length === 0) return null return (
{displayIndex !== null ? ( <> {isLoading ? '加载中...' : `第 ${displayIndex + 1} / ${doneSegments.length} 段`} ) : ( )}
) } 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 (
LLM 配置
{existing && (
当前: {existing.base_url || '未设置'} / {existing.model || '未设置'} / {existing.has_key ? '已配置密钥' : '未配置密钥'}
)} setBaseUrl(e.target.value)} /> setApiKey(e.target.value)} /> setModel(e.target.value)} />
) } function CreateProjectPanel({ onCreated }: { onCreated: () => void }) { const [title, setTitle] = useState('') const [sourceType, setSourceType] = useState<'text' | 'epub'>('text') const [text, setText] = useState('') const [epubFile, setEpubFile] = useState(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 (
新建有声书项目
setTitle(e.target.value)} />
{sourceType === 'text' && (