import { useState, useEffect, useCallback, useRef } from 'react' import { toast } from 'sonner' import { Book, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp, Play, Square, Pencil, Check, X, Loader2 } 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 步:确认角色信息,可编辑后点击「确认角色 · 识别章节」', ready: '第 3 步:逐章解析剧本(LLM),解析完的章节可立即生成音频', generating: '第 4 步:正在合成音频,已完成片段可立即播放', } 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 LogStream({ projectId, active }: { projectId: number; active: boolean }) { const [lines, setLines] = useState([]) const [done, setDone] = useState(false) const bottomRef = useRef(null) const activeRef = useRef(active) activeRef.current = active useEffect(() => { if (!active) return setLines([]) setDone(false) const token = localStorage.getItem('token') const apiBase = (import.meta.env.VITE_API_URL as string) || '' const controller = new AbortController() fetch(`${apiBase}/audiobook/projects/${projectId}/logs`, { headers: { Authorization: `Bearer ${token}` }, signal: controller.signal, }).then(async res => { const reader = res.body?.getReader() if (!reader) return const decoder = new TextDecoder() let buffer = '' while (true) { const { done: streamDone, value } = await reader.read() if (streamDone) break buffer += decoder.decode(value, { stream: true }) const parts = buffer.split('\n\n') buffer = parts.pop() ?? '' for (const part of parts) { const line = part.trim() if (!line.startsWith('data: ')) continue try { const msg = JSON.parse(line.slice(6)) if (msg.done) { setDone(true) } else if (typeof msg.index === 'number') { setLines(prev => { const next = [...prev] next[msg.index] = msg.line return next }) } } catch {} } } }).catch(() => {}) return () => controller.abort() }, [projectId, active]) useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [lines]) if (lines.length === 0) return null return (
{lines.map((line, i) => (
{line}
))} {!done && ( )}
) } 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' && (