840 lines
32 KiB
TypeScript
840 lines
32 KiB
TypeScript
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>
|
||
)
|
||
}
|