Files
Canto/qwen3-tts-frontend/src/pages/Audiobook.tsx

840 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = {
pending: '待分析',
analyzing: '分析中',
characters_ready: '角色待确认',
parsing: '解析章节',
ready: '待生成',
generating: '生成中',
done: '已完成',
error: '出错',
}
const STATUS_COLORS: Record<string, string> = {
pending: 'secondary',
analyzing: 'default',
characters_ready: 'default',
parsing: 'default',
ready: 'default',
generating: 'default',
done: 'outline',
error: 'destructive',
}
const STEP_HINTS: Record<string, string> = {
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<number | null>(null)
const [isLoading, setIsLoading] = useState(false)
const audioRef = useRef<HTMLAudioElement>(new Audio())
const blobUrlsRef = useRef<Record<number, string>>({})
const currentIndexRef = useRef<number | null>(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 (
<div className="flex items-center gap-2">
{displayIndex !== null ? (
<>
<Button size="sm" variant="outline" onClick={stop}>
<Square className="h-3 w-3 mr-1 fill-current" />
</Button>
<span className="text-xs text-muted-foreground">
{isLoading ? '加载中...' : `${displayIndex + 1} / ${doneSegments.length}`}
</span>
</>
) : (
<Button size="sm" variant="outline" onClick={() => playSegment(0)}>
<Play className="h-3 w-3 mr-1" />{doneSegments.length}
</Button>
)}
</div>
)
}
function LogStream({ projectId, active }: { projectId: number; active: boolean }) {
const [lines, setLines] = useState<string[]>([])
const [done, setDone] = useState(false)
const containerRef = useRef<HTMLDivElement>(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(() => {
const el = containerRef.current
if (el) el.scrollTop = el.scrollHeight
}, [lines])
if (lines.length === 0) return null
return (
<div ref={containerRef} className="rounded border border-green-900/40 bg-black/90 text-green-400 font-mono text-xs p-3 max-h-52 overflow-y-auto leading-relaxed">
{lines.map((line, i) => (
<div key={i} className="whitespace-pre-wrap">{line}</div>
))}
{!done && (
<span className="inline-block w-2 h-3 bg-green-400 animate-pulse ml-0.5 align-middle" />
)}
</div>
)
}
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 => {
const file = e.target.files?.[0] || null
setEpubFile(file)
if (file && !title) {
setTitle(file.name.replace(/\.epub$/i, ''))
}
}} />
)}
<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 [isPolling, setIsPolling] = useState(false)
const [editingCharId, setEditingCharId] = useState<number | null>(null)
const [editFields, setEditFields] = useState({ name: '', description: '', instruct: '' })
const [sequentialPlayingId, setSequentialPlayingId] = useState<number | null>(null)
const prevStatusRef = useRef(project.status)
const autoExpandedRef = useRef(new Set<string>())
const fetchDetail = useCallback(async () => {
try { setDetail(await audiobookApi.getProject(project.id)) } catch {}
}, [project.id])
const fetchSegments = useCallback(async () => {
try { setSegments(await audiobookApi.getSegments(project.id)) } catch {}
}, [project.id])
useEffect(() => {
if (expanded) { fetchDetail(); fetchSegments() }
}, [expanded, fetchDetail, fetchSegments])
useEffect(() => {
const s = project.status
if (['characters_ready', 'ready', 'generating'].includes(s) && !autoExpandedRef.current.has(s)) {
autoExpandedRef.current.add(s)
setExpanded(true)
fetchDetail()
fetchSegments()
}
if (['done', 'error'].includes(s)) setIsPolling(false)
}, [project.status, fetchDetail, fetchSegments])
useEffect(() => {
if (prevStatusRef.current === 'generating' && project.status === 'done') {
toast.success(`${project.title}」音频全部生成完成!`)
}
prevStatusRef.current = project.status
}, [project.status, project.title])
const hasParsingChapter = detail?.chapters.some(c => c.status === 'parsing') ?? false
useEffect(() => {
if (!isPolling) return
if (['analyzing', 'generating'].includes(project.status)) return
if (hasParsingChapter) return
if (!segments.some(s => s.status === 'generating')) setIsPolling(false)
}, [isPolling, project.status, segments, hasParsingChapter])
useEffect(() => {
const shouldPoll = isPolling || ['analyzing', 'generating'].includes(project.status) || hasParsingChapter
if (!shouldPoll) return
const id = setInterval(() => { onRefresh(); fetchSegments(); fetchDetail() }, 1500)
return () => clearInterval(id)
}, [isPolling, project.status, hasParsingChapter, onRefresh, fetchSegments, fetchDetail])
const handleAnalyze = async () => {
const s = project.status
if (['characters_ready', 'ready', 'done'].includes(s)) {
if (!confirm('重新分析将清除所有角色和章节数据,确定继续?')) return
}
autoExpandedRef.current.clear()
setEditingCharId(null)
setLoadingAction(true)
setIsPolling(true)
try {
await audiobookApi.analyze(project.id)
toast.success('分析已开始')
onRefresh()
} catch (e: any) {
setIsPolling(false)
toast.error(formatApiError(e))
} finally {
setLoadingAction(false)
}
}
const handleConfirm = async () => {
setLoadingAction(true)
try {
await audiobookApi.confirmCharacters(project.id)
toast.success('章节已识别')
onRefresh()
fetchDetail()
} catch (e: any) {
toast.error(formatApiError(e))
} finally {
setLoadingAction(false)
}
}
const handleParseChapter = async (chapterId: number, title?: string) => {
try {
await audiobookApi.parseChapter(project.id, chapterId)
toast.success(title ? `${title}」解析已开始` : '章节解析已开始')
fetchDetail()
} catch (e: any) {
toast.error(formatApiError(e))
}
}
const handleGenerate = async (chapterIndex?: number) => {
setLoadingAction(true)
setIsPolling(true)
try {
await audiobookApi.generate(project.id, chapterIndex)
toast.success(chapterIndex !== undefined ? `${chapterIndex + 1} 章生成已开始` : '全书生成已开始')
onRefresh()
fetchSegments()
} catch (e: any) {
setIsPolling(false)
toast.error(formatApiError(e))
} finally {
setLoadingAction(false)
}
}
const handleProcessAll = async () => {
if (!detail) return
setLoadingAction(true)
setIsPolling(true)
try {
const pending = detail.chapters.filter(c => c.status === 'pending' || c.status === 'error')
const ready = detail.chapters.filter(c => c.status === 'ready')
await Promise.all([
...pending.map(c => audiobookApi.parseChapter(project.id, c.id)),
...ready.map(c => audiobookApi.generate(project.id, c.chapter_index)),
])
toast.success('全部任务已触发')
onRefresh()
fetchDetail()
fetchSegments()
} catch (e: any) {
setIsPolling(false)
toast.error(formatApiError(e))
} finally {
setLoadingAction(false)
}
}
const handleDownload = async (chapterIndex?: number) => {
setLoadingAction(true)
try {
const response = await apiClient.get(`/audiobook/projects/${project.id}/download`, {
responseType: 'blob',
params: chapterIndex !== undefined ? { chapter: chapterIndex } : {},
})
const url = URL.createObjectURL(response.data)
const a = document.createElement('a')
a.href = url
a.download = chapterIndex !== undefined
? `${project.title}_ch${chapterIndex + 1}.mp3`
: `${project.title}.mp3`
a.click()
URL.revokeObjectURL(url)
} 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 startEditChar = (char: AudiobookCharacter) => {
setEditingCharId(char.id)
setEditFields({ name: char.name, description: char.description || '', instruct: char.instruct || '' })
}
const saveEditChar = async (char: AudiobookCharacter) => {
try {
await audiobookApi.updateCharacter(project.id, char.id, {
name: editFields.name || char.name,
description: editFields.description,
instruct: editFields.instruct,
})
setEditingCharId(null)
await fetchDetail()
toast.success('角色已保存')
} catch (e: any) {
toast.error(formatApiError(e))
}
}
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
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[status] || 'secondary') as any} className="shrink-0">
{STATUS_LABELS[status] || status}
</Badge>
</div>
<div className="flex gap-1 shrink-0">
{!isActive && (
<Button
size="sm"
variant={status === 'pending' ? 'default' : 'outline'}
onClick={handleAnalyze}
disabled={loadingAction}
>
{status === 'pending' ? '分析' : '重新分析'}
</Button>
)}
{status === 'ready' && (
<Button size="sm" onClick={() => handleGenerate()} disabled={loadingAction}>
</Button>
)}
{status === 'done' && (
<Button size="sm" variant="outline" onClick={() => handleDownload()} disabled={loadingAction}>
<Download className="h-3 w-3 mr-1" />
</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>
{STEP_HINTS[status] && (
<div className="text-xs text-muted-foreground bg-muted/50 rounded px-3 py-2 border-l-2 border-primary/40">
{STEP_HINTS[status]}
</div>
)}
{status === 'analyzing' && (
<LogStream projectId={project.id} active={status === 'analyzing'} />
)}
{project.error_message && (
<div className="text-xs text-destructive bg-destructive/10 rounded p-2">{project.error_message}</div>
)}
{totalCount > 0 && doneCount > 0 && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">{doneCount} / {totalCount} </div>
<Progress value={progress} />
</div>
)}
{expanded && (
<div className="space-y-4 pt-2 border-t">
{detail && detail.characters.length > 0 && (
<div>
<div className="text-xs font-medium text-muted-foreground mb-2">
{detail.characters.length}
</div>
<div className="space-y-1.5">
{detail.characters.map(char => (
<div key={char.id} className="border rounded px-3 py-2">
{editingCharId === char.id ? (
<div className="space-y-2">
<Input
className="h-7 text-sm"
value={editFields.name}
onChange={e => setEditFields(f => ({ ...f, name: e.target.value }))}
placeholder="角色名"
/>
<Input
className="h-7 text-sm"
value={editFields.instruct}
onChange={e => setEditFields(f => ({ ...f, instruct: e.target.value }))}
placeholder="音色描述(用于 TTS"
/>
<Input
className="h-7 text-sm"
value={editFields.description}
onChange={e => setEditFields(f => ({ ...f, description: e.target.value }))}
placeholder="角色描述"
/>
<div className="flex gap-1">
<Button size="sm" className="h-6 text-xs px-2" onClick={() => saveEditChar(char)}>
<Check className="h-3 w-3 mr-1" />
</Button>
<Button size="sm" variant="ghost" className="h-6 text-xs px-2" onClick={() => setEditingCharId(null)}>
<X className="h-3 w-3 mr-1" />
</Button>
</div>
</div>
) : (
<div className="flex items-center justify-between text-sm">
<span className="font-medium shrink-0 w-20 truncate">{char.name}</span>
<span className="text-xs text-muted-foreground truncate mx-2 flex-1">{char.instruct}</span>
<div className="flex items-center gap-1 shrink-0">
{char.voice_design_id
? <Badge variant="outline" className="text-xs"> #{char.voice_design_id}</Badge>
: <Badge variant="secondary" className="text-xs"></Badge>
}
{status === 'characters_ready' && (
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => startEditChar(char)}>
<Pencil className="h-3 w-3" />
</Button>
)}
</div>
</div>
)}
</div>
))}
</div>
{status === 'characters_ready' && (
<Button
className="w-full mt-3"
onClick={handleConfirm}
disabled={loadingAction || editingCharId !== null}
>
{loadingAction ? '识别中...' : '确认角色 · 识别章节'}
</Button>
)}
</div>
)}
{detail && detail.chapters.length > 0 && ['ready', 'generating', 'done'].includes(status) && (
<div>
<div className="flex items-center justify-between mb-2">
<div className="text-xs font-medium text-muted-foreground">
{detail.chapters.length}
</div>
{detail.chapters.some(c => ['pending', 'error', 'ready'].includes(c.status)) && (
<Button
size="sm"
className="h-6 text-xs px-2"
disabled={loadingAction}
onClick={handleProcessAll}
>
{loadingAction ? <Loader2 className="h-3 w-3 animate-spin" /> : '一键全部处理'}
</Button>
)}
</div>
<div className="space-y-2">
{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 || `${ch.chapter_index + 1}`
return (
<div key={ch.id} className="border rounded px-3 py-2 space-y-2">
<div className="flex items-center justify-between text-sm gap-2">
<span className="text-xs font-medium truncate max-w-[55%]">{chTitle}</span>
<div className="flex gap-1 items-center shrink-0">
{ch.status === 'pending' && (
<Button size="sm" variant="outline" className="h-6 text-xs px-2" onClick={() => handleParseChapter(ch.id, ch.title)}>
</Button>
)}
{ch.status === 'parsing' && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span></span>
</div>
)}
{ch.status === 'ready' && !chGenerating && !chAllDone && (
<Button size="sm" variant="outline" className="h-6 text-xs px-2" disabled={loadingAction} onClick={() => handleGenerate(ch.chapter_index)}>
</Button>
)}
{ch.status === 'ready' && chGenerating && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span>{chDone}/{chTotal} </span>
</div>
)}
{ch.status === 'ready' && chAllDone && (
<>
<Badge variant="outline" className="text-xs"> {chDone} </Badge>
<Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={() => handleDownload(ch.chapter_index)} title="下载此章">
<Download className="h-3 w-3" />
</Button>
</>
)}
{ch.status === 'error' && (
<Button size="sm" variant="outline" className="h-6 text-xs px-2 text-destructive border-destructive/40" onClick={() => handleParseChapter(ch.id, ch.title)}>
</Button>
)}
</div>
</div>
{ch.status === 'parsing' && (
<LogStream projectId={project.id} active={ch.status === 'parsing'} />
)}
</div>
)
})}
</div>
{doneCount > 0 && (
<div className="mt-2">
<SequentialPlayer segments={segments} projectId={project.id} onPlayingChange={setSequentialPlayingId} />
</div>
)}
</div>
)}
{['generating', 'done'].includes(status) && segments.length > 0 && (
<div>
<div className="flex items-center justify-between mb-2">
<div className="text-xs font-medium text-muted-foreground">
{segments.length}
</div>
<SequentialPlayer segments={segments} projectId={project.id} onPlayingChange={setSequentialPlayingId} />
</div>
<div className="space-y-2">
{segments.slice(0, 50).map(seg => (
<div
key={seg.id}
className={`border rounded px-2 py-2 space-y-2 transition-colors ${sequentialPlayingId === seg.id ? 'border-primary/50 bg-primary/5' : ''}`}
>
<div className="flex items-start gap-2 text-xs">
<Badge variant="outline" className="shrink-0 text-xs mt-0.5">{seg.character_name || '?'}</Badge>
<span className="text-muted-foreground flex-1 min-w-0 break-words leading-relaxed">{seg.text}</span>
{seg.status === 'generating' ? (
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 mt-0.5">
<Loader2 className="h-3 w-3 animate-spin" />
</div>
) : seg.status !== 'done' ? (
<Badge
variant={seg.status === 'error' ? 'destructive' : 'secondary'}
className="shrink-0 text-xs mt-0.5"
>
{seg.status === 'error' ? '出错' : '待生成'}
</Badge>
) : null}
</div>
{seg.status === 'done' && (
<AudioPlayer
audioUrl={audiobookApi.getSegmentAudioUrl(project.id, seg.id)}
jobId={seg.id}
/>
)}
</div>
))}
{segments.length > 50 && (
<div className="text-xs text-muted-foreground text-center py-1">
50 {segments.length}
</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>
)
}