feat: update Audiobook component with improved status icons and loading indicators

This commit is contained in:
2026-03-13 18:11:45 +08:00
parent 96ec3629a2
commit 70bb6d37f4

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Book, BookOpen, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp, Play, Square, Pencil, Check, X, Loader2, Zap, Settings2, PanelLeftClose, PanelLeftOpen, Wand2, Volume2, Bot, Flame, Headphones, Clock, CheckCircle2, AlertCircle, CircleDot } from 'lucide-react' import { Book, BookOpen, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp, Play, Square, Pencil, Check, X, Loader2, Zap, Settings2, PanelLeftClose, PanelLeftOpen, Wand2, Volume2, Bot, Headphones, Clock, CheckCircle2, AlertCircle, CircleDot } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
@@ -33,18 +33,26 @@ function LazyAudioPlayer({ audioUrl, jobId, compact }: { audioUrl: string; jobId
} }
const STATUS_CONFIG: Record<string, { Icon: React.ElementType; iconCls: string; cardBg: string; cardBgSel: string }> = { const STATUS_CONFIG: Record<string, { Icon: React.ElementType; iconCls: string; cardBg: string; cardBgSel: string }> = {
pending: { Icon: Clock, iconCls: 'h-3 w-3 text-muted-foreground', cardBg: 'bg-background/50', cardBgSel: 'bg-muted' }, pending: { Icon: Clock, iconCls: 'h-3 w-3 text-muted-foreground/40', cardBg: 'bg-background/50', cardBgSel: 'bg-muted' },
analyzing: { Icon: Loader2, iconCls: 'h-3 w-3 text-blue-500 animate-spin', cardBg: 'bg-blue-500/5', cardBgSel: 'bg-blue-500/20' }, analyzing: { Icon: Loader2, iconCls: 'h-3 w-3 text-sky-500 animate-spin', cardBg: 'bg-sky-500/5', cardBgSel: 'bg-sky-500/20' },
characters_ready: { Icon: Loader2, iconCls: 'h-3 w-3 text-blue-500 animate-spin', cardBg: 'bg-blue-500/5', cardBgSel: 'bg-blue-500/20' }, characters_ready: { Icon: Loader2, iconCls: 'h-3 w-3 text-violet-500 animate-spin', cardBg: 'bg-violet-500/5', cardBgSel: 'bg-violet-500/20' },
parsing: { Icon: Loader2, iconCls: 'h-3 w-3 text-amber-500 animate-spin', cardBg: 'bg-amber-500/5', cardBgSel: 'bg-amber-500/20' }, parsing: { Icon: Loader2, iconCls: 'h-3 w-3 text-amber-500 animate-spin', cardBg: 'bg-amber-500/5', cardBgSel: 'bg-amber-500/20' },
processing: { Icon: Loader2, iconCls: 'h-3 w-3 text-amber-500 animate-spin', cardBg: 'bg-amber-500/5', cardBgSel: 'bg-amber-500/20' }, processing: { Icon: Loader2, iconCls: 'h-3 w-3 text-red-500 animate-spin', cardBg: 'bg-orange-500/5', cardBgSel: 'bg-orange-500/20' },
ready: { Icon: CircleDot, iconCls: 'h-3 w-3 text-green-500', cardBg: 'bg-green-500/5', cardBgSel: 'bg-green-500/20' }, ready: { Icon: CircleDot, iconCls: 'h-3 w-3 text-cyan-500', cardBg: 'bg-cyan-500/5', cardBgSel: 'bg-cyan-500/20' },
generating: { Icon: Loader2, iconCls: 'h-3 w-3 text-amber-500 animate-spin', cardBg: 'bg-amber-500/5', cardBgSel: 'bg-amber-500/20' }, generating: { Icon: Loader2, iconCls: 'h-3 w-3 text-amber-400 animate-spin', cardBg: 'bg-amber-400/5', cardBgSel: 'bg-amber-400/20' },
done: { Icon: CheckCircle2, iconCls: 'h-3 w-3 text-emerald-500', cardBg: 'bg-emerald-500/5', cardBgSel: 'bg-emerald-500/20' }, done: { Icon: CheckCircle2, iconCls: 'h-3 w-3 text-emerald-500', cardBg: 'bg-emerald-500/5', cardBgSel: 'bg-emerald-500/20' },
error: { Icon: AlertCircle, iconCls: 'h-3 w-3 text-destructive', cardBg: 'bg-destructive/5', cardBgSel: 'bg-destructive/20' }, error: { Icon: AlertCircle, iconCls: 'h-3 w-3 text-destructive', cardBg: 'bg-destructive/5', cardBgSel: 'bg-destructive/20' },
} }
const STEP_HINT_STATUSES = ['pending', 'analyzing', 'characters_ready', 'ready', 'generating']
function NsfwIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<circle cx="12" cy="12" r="10" />
<text x="12" y="15.5" textAnchor="middle" fontSize="8" fontWeight="900" fill="currentColor" stroke="none">18+</text>
</svg>
)
}
function SequentialPlayer({ function SequentialPlayer({
segments, segments,
@@ -839,7 +847,7 @@ function NSFWScriptDialog({ open, onClose, onCreated }: { open: boolean; onClose
<DialogContent className="sm:max-w-2xl max-h-[90vh] flex flex-col"> <DialogContent className="sm:max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader className="shrink-0"> <DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Flame className="h-4 w-4 text-orange-500" /> <NsfwIcon className="h-4 w-4 text-red-500" />
NSFW NSFW
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
@@ -1046,8 +1054,8 @@ function ProjectListSidebar({
</Button> </Button>
)} )}
{hasNsfwAccess && ( {hasNsfwAccess && (
<Button size="icon" variant="ghost" className="h-7 w-7 text-orange-500 hover:text-orange-600" onClick={onNSFWScript} title="NSFW 生成剧本"> <Button size="icon" variant="ghost" className="h-7 w-7 text-red-500 hover:text-orange-600" onClick={onNSFWScript} title="NSFW 生成剧本">
<Flame className="h-4 w-4" /> <NsfwIcon className="h-4 w-4" />
</Button> </Button>
)} )}
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onAIScript} title="AI 生成剧本"> <Button size="icon" variant="ghost" className="h-7 w-7" onClick={onAIScript} title="AI 生成剧本">
@@ -1068,14 +1076,12 @@ function ProjectListSidebar({
const ProjectIcon = p.source_type === 'epub' const ProjectIcon = p.source_type === 'epub'
? BookOpen ? BookOpen
: isNsfw : isNsfw
? Flame ? NsfwIcon
: p.source_type === 'ai_generated' : p.source_type === 'ai_generated'
? Wand2 ? Zap
: Book : Book
const iconClass = isNsfw const iconClass = isNsfw
? 'h-3.5 w-3.5 shrink-0 text-orange-500' ? 'h-3.5 w-3.5 shrink-0 text-red-500'
: p.source_type === 'ai_generated'
? 'h-3.5 w-3.5 shrink-0 text-violet-500'
: 'h-3.5 w-3.5 shrink-0 text-muted-foreground' : 'h-3.5 w-3.5 shrink-0 text-muted-foreground'
return ( return (
<button <button
@@ -1083,13 +1089,14 @@ function ProjectListSidebar({
onClick={() => onSelect(p.id)} onClick={() => onSelect(p.id)}
className={`w-full text-left rounded-lg border p-3 flex flex-col gap-1.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 ${ className={`w-full text-left rounded-lg border p-3 flex flex-col gap-1.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 ${
selectedId === p.id selectedId === p.id
? `${STATUS_CONFIG[p.status]?.cardBgSel ?? 'bg-background'} border-border shadow-sm` ? 'bg-muted/60 border-border shadow-sm'
: `${STATUS_CONFIG[p.status]?.cardBg ?? 'bg-background/50'} border-border/40 hover:opacity-90` : 'bg-muted/20 border-border/30 hover:bg-muted/40'
}`} }`}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{(() => { const sc = STATUS_CONFIG[p.status]; return sc ? <sc.Icon className={`${sc.iconCls} shrink-0`} /> : null })()}
<ProjectIcon className={iconClass} /> <ProjectIcon className={iconClass} />
<span className="text-sm font-medium truncate flex-1 text-center" title={p.title}>{p.title}</span> <span className="text-sm font-medium truncate flex-1" title={p.title}>{p.title}</span>
</div> </div>
</button> </button>
) )
@@ -1189,9 +1196,9 @@ function CharactersPanel({
if (collapsed && hasChaptersOutline) { if (collapsed && hasChaptersOutline) {
return ( return (
<div className="w-44 shrink-0 flex flex-col rounded-xl border border-emerald-500/20 bg-emerald-500/5 overflow-hidden"> <div className="w-44 shrink-0 flex flex-col rounded-xl border border-border/40 bg-muted/20 overflow-hidden">
<div className="flex items-center justify-between px-2 py-2 shrink-0 border-b border-emerald-500/20"> <div className="flex items-center justify-between px-2 py-2 shrink-0 border-b border-border/30">
<span className="text-xs font-medium text-emerald-400/80 truncate"> <span className="text-xs font-medium text-muted-foreground truncate">
{t('projectCard.chapters.title', { count: detail!.chapters.length })} {t('projectCard.chapters.title', { count: detail!.chapters.length })}
</span> </span>
<Button size="icon" variant="ghost" className="h-6 w-6 shrink-0 ml-1" onClick={onToggle}> <Button size="icon" variant="ghost" className="h-6 w-6 shrink-0 ml-1" onClick={onToggle}>
@@ -1201,7 +1208,7 @@ function CharactersPanel({
<div className="flex-1 overflow-y-auto px-2 py-2 space-y-1.5"> <div className="flex-1 overflow-y-auto px-2 py-2 space-y-1.5">
{detail!.chapters.map(ch => { {detail!.chapters.map(ch => {
const chTitle = ch.title || t('projectCard.chapters.defaultTitle', { index: ch.chapter_index + 1 }) const chTitle = ch.title || t('projectCard.chapters.defaultTitle', { index: ch.chapter_index + 1 })
const dotClass = ch.status === 'done' ? 'bg-green-400' : ch.status === 'error' ? 'bg-destructive' : ch.status === 'parsing' || ch.status === 'generating' ? 'bg-amber-400' : ch.status === 'ready' ? 'bg-blue-400' : 'bg-muted-foreground/50' const dotClass = ch.status === 'error' ? 'bg-destructive' : ch.status === 'parsing' || ch.status === 'generating' ? 'bg-muted-foreground/80' : ch.status === 'done' ? 'bg-muted-foreground/30' : ch.status === 'ready' ? 'bg-muted-foreground/50' : 'bg-muted-foreground/25'
return ( return (
<button <button
key={ch.id} key={ch.id}
@@ -1570,9 +1577,9 @@ function ChaptersPanel({
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<div className="flex-1 flex flex-col rounded-xl border border-emerald-500/20 bg-emerald-500/5 overflow-hidden"> <div className="flex-1 flex flex-col rounded-xl border border-border/40 bg-muted/20 overflow-hidden">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between px-3 py-2 shrink-0"> <div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between px-3 py-2 shrink-0">
<span className="text-xs font-medium text-emerald-400/80"> <span className="text-xs font-medium text-muted-foreground">
{t('projectCard.chapters.title', { count: detail?.chapters.length ?? 0 })} {t('projectCard.chapters.title', { count: detail?.chapters.length ?? 0 })}
</span> </span>
{hasChapters && ( {hasChapters && (
@@ -1608,7 +1615,7 @@ function ChaptersPanel({
{!hasChapters ? ( {!hasChapters ? (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground" /> <div className="flex-1 flex items-center justify-center text-xs text-muted-foreground" />
) : ( ) : (
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef}> <div className="flex-1 overflow-y-auto px-3 py-2 space-y-2" ref={scrollContainerRef}>
{detail!.chapters.map(ch => { {detail!.chapters.map(ch => {
const chSegs = segments.filter(s => s.chapter_index === ch.chapter_index) const chSegs = segments.filter(s => s.chapter_index === ch.chapter_index)
const chDone = chSegs.filter(s => s.status === 'done').length const chDone = chSegs.filter(s => s.status === 'done').length
@@ -1623,11 +1630,11 @@ function ChaptersPanel({
else next.add(ch.id) else next.add(ch.id)
return next return next
}) })
const chCardBorder = ch.status === 'error' ? 'border-destructive/40' : ch.status === 'parsing' || ch.status === 'generating' || chGenerating ? 'border-muted-foreground/50' : ch.status === 'done' || (ch.status === 'ready' && chAllDone) ? 'border-border/30' : ch.status === 'ready' ? 'border-muted-foreground/30' : 'border-border/25'
return ( return (
<div key={ch.id} id={`ch-${ch.id}`}> <div key={ch.id} id={`ch-${ch.id}`} className={`rounded-xl border overflow-clip ${chCardBorder}`}>
{/* Chapter header — flat, full-width, click to expand */}
<button <button
className="sticky top-0 z-10 w-full flex items-center gap-2 px-3 py-2.5 bg-emerald-500/5 backdrop-blur-sm hover:bg-emerald-500/10 border-b text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1" className="sticky top-0 z-10 w-full flex items-center gap-2 px-3 py-2.5 bg-background/90 backdrop-blur-sm hover:bg-muted/60 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
onClick={toggleChExpand} onClick={toggleChExpand}
> >
<span className="shrink-0 text-muted-foreground"> <span className="shrink-0 text-muted-foreground">
@@ -1706,14 +1713,13 @@ function ChaptersPanel({
</button> </button>
{ch.status === 'parsing' && ( {ch.status === 'parsing' && (
<div className="px-3 py-1 border-b"> <div className="px-3 py-1 border-t">
<LogStream projectId={project.id} chapterId={ch.id} active={ch.status === 'parsing'} /> <LogStream projectId={project.id} chapterId={ch.id} active={ch.status === 'parsing'} />
</div> </div>
)} )}
{/* Segments — flat list, each is its own card */}
{chExpanded && chSegs.length > 0 && ( {chExpanded && chSegs.length > 0 && (
<div className="px-3 py-2 space-y-2 border-b"> <div className="px-3 py-2 space-y-2 border-t">
{chSegs.map(seg => { {chSegs.map(seg => {
const isEditing = editingSegId === seg.id const isEditing = editingSegId === seg.id
const isRegenerating = regeneratingSegs.has(seg.id) || seg.status === 'generating' const isRegenerating = regeneratingSegs.has(seg.id) || seg.status === 'generating'
@@ -1874,6 +1880,7 @@ export default function Audiobook() {
const [detail, setDetail] = useState<AudiobookProjectDetail | null>(null) const [detail, setDetail] = useState<AudiobookProjectDetail | null>(null)
const [segments, setSegments] = useState<AudiobookSegment[]>([]) const [segments, setSegments] = useState<AudiobookSegment[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [detailLoading, setDetailLoading] = useState(false)
const [loadingAction, setLoadingAction] = useState(false) const [loadingAction, setLoadingAction] = useState(false)
const [isPolling, setIsPolling] = useState(false) const [isPolling, setIsPolling] = useState(false)
const [generatingChapterIndices, setGeneratingChapterIndices] = useState<Set<number>>(new Set()) const [generatingChapterIndices, setGeneratingChapterIndices] = useState<Set<number>>(new Set())
@@ -1930,8 +1937,8 @@ export default function Audiobook() {
setIsPolling(false) setIsPolling(false)
autoExpandedRef.current.clear() autoExpandedRef.current.clear()
prevStatusRef.current = '' prevStatusRef.current = ''
fetchDetail() setDetailLoading(true)
fetchSegments() Promise.all([fetchDetail(), fetchSegments()]).finally(() => setDetailLoading(false))
}, [selectedProjectId, fetchDetail, fetchSegments]) }, [selectedProjectId, fetchDetail, fetchSegments])
const status = selectedProject?.status ?? '' const status = selectedProject?.status ?? ''
@@ -2302,8 +2309,8 @@ export default function Audiobook() {
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex items-center gap-2 min-w-0 flex-1">
{(() => { {(() => {
const isNsfw = selectedProject.source_type === 'ai_generated' && !!(selectedProject.script_config as any)?.nsfw_mode const isNsfw = selectedProject.source_type === 'ai_generated' && !!(selectedProject.script_config as any)?.nsfw_mode
const Icon = selectedProject.source_type === 'epub' ? BookOpen : isNsfw ? Flame : selectedProject.source_type === 'ai_generated' ? Wand2 : Book const Icon = selectedProject.source_type === 'epub' ? BookOpen : isNsfw ? NsfwIcon : selectedProject.source_type === 'ai_generated' ? Zap : Book
const cls = isNsfw ? 'h-4 w-4 shrink-0 text-orange-500' : selectedProject.source_type === 'ai_generated' ? 'h-4 w-4 shrink-0 text-violet-500' : 'h-4 w-4 shrink-0 text-muted-foreground' const cls = isNsfw ? 'h-4 w-4 shrink-0 text-red-500' : 'h-4 w-4 shrink-0 text-muted-foreground'
return <Icon className={cls} /> return <Icon className={cls} />
})()} })()}
<span className="font-medium break-words min-w-0">{selectedProject.title}</span> <span className="font-medium break-words min-w-0">{selectedProject.title}</span>
@@ -2340,11 +2347,6 @@ export default function Audiobook() {
</div> </div>
</div> </div>
{STEP_HINT_STATUSES.includes(status) && (
<div className="shrink-0 mx-4 mt-2 text-xs text-muted-foreground bg-muted/50 rounded px-3 py-2 border-l-2 border-primary/40">
{t(`stepHints.${status}`)}
</div>
)}
{(status === 'analyzing' || (status === 'generating' && selectedProject.source_type === 'ai_generated')) && ( {(status === 'analyzing' || (status === 'generating' && selectedProject.source_type === 'ai_generated')) && (
<div className="shrink-0 mx-4 mt-2"> <div className="shrink-0 mx-4 mt-2">
@@ -2418,6 +2420,20 @@ export default function Audiobook() {
</div> </div>
)} )}
{detailLoading ? (
<div className="flex-1 flex overflow-hidden mt-2 px-2 pb-2 gap-2">
<div className="w-56 shrink-0 flex flex-col gap-2">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-14 rounded-lg bg-muted/60 animate-pulse" style={{ animationDelay: `${i * 80}ms` }} />
))}
</div>
<div className="flex-1 flex flex-col gap-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-12 rounded-lg bg-muted/60 animate-pulse" style={{ animationDelay: `${i * 60}ms` }} />
))}
</div>
</div>
) : (
<div className="flex-1 flex overflow-hidden mt-2 px-2 pb-2 gap-2"> <div className="flex-1 flex overflow-hidden mt-2 px-2 pb-2 gap-2">
<CharactersPanel <CharactersPanel
project={selectedProject} project={selectedProject}
@@ -2453,6 +2469,7 @@ export default function Audiobook() {
onScrollToChapterDone={() => setScrollToChapterId(null)} onScrollToChapterDone={() => setScrollToChapterId(null)}
/> />
</div> </div>
)}
</> </>
)} )}
</div> </div>