feat: Implement segment update and regeneration features in Audiobook API and frontend

This commit is contained in:
2026-03-12 15:48:35 +08:00
parent a1ee476e0f
commit bb6ad9b0a3
13 changed files with 485 additions and 31 deletions

View File

@@ -482,7 +482,7 @@ function CharactersPanel({
return (
<div className="w-72 shrink-0 flex flex-col border-r border-blue-500/20 bg-blue-500/5 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b border-blue-500/20 shrink-0">
<div className="flex items-center justify-between px-3 py-2 shrink-0">
<span className="text-xs font-medium text-blue-400/80">
{t('projectCard.characters.title', { count: charCount })}
</span>
@@ -625,6 +625,11 @@ function CharactersPanel({
)
}
const EMOTION_OPTIONS = ['开心', '愤怒', '悲伤', '恐惧', '厌恶', '低沉', '惊讶', '中性']
const EMOTION_ALPHA_DEFAULTS: Record<string, number> = {
开心: 0.6, 愤怒: 0.15, 悲伤: 0.4, 恐惧: 0.4, 厌恶: 0.6, 低沉: 0.6, 惊讶: 0.3, 中性: 0.5,
}
function ChaptersPanel({
project,
detail,
@@ -639,6 +644,8 @@ function ChaptersPanel({
onProcessAll,
onDownload,
onSequentialPlayingChange,
onUpdateSegment,
onRegenerateSegment,
}: {
project: AudiobookProject
detail: AudiobookProjectDetail | null
@@ -653,9 +660,50 @@ function ChaptersPanel({
onProcessAll: () => void
onDownload: (chapterIndex?: number) => void
onSequentialPlayingChange: (id: number | null) => void
onUpdateSegment: (segmentId: number, data: { text: string; emo_text?: string | null; emo_alpha?: number | null }) => Promise<void>
onRegenerateSegment: (segmentId: number) => Promise<void>
}) {
const { t } = useTranslation('audiobook')
const [expandedChapters, setExpandedChapters] = useState<Set<number>>(new Set())
const [editingSegId, setEditingSegId] = useState<number | null>(null)
const [editText, setEditText] = useState('')
const [editEmoText, setEditEmoText] = useState('')
const [editEmoAlpha, setEditEmoAlpha] = useState(0.5)
const [savingSegId, setSavingSegId] = useState<number | null>(null)
const [regeneratingSegs, setRegeneratingSegs] = useState<Set<number>>(new Set())
const startEdit = (seg: AudiobookSegment) => {
setEditingSegId(seg.id)
setEditText(seg.text)
setEditEmoText(seg.emo_text || '')
setEditEmoAlpha(seg.emo_alpha ?? 0.5)
}
const cancelEdit = () => setEditingSegId(null)
const saveEdit = async (segId: number) => {
setSavingSegId(segId)
try {
await onUpdateSegment(segId, {
text: editText,
emo_text: editEmoText || null,
emo_alpha: editEmoText ? editEmoAlpha : null,
})
setEditingSegId(null)
} finally {
setSavingSegId(null)
}
}
const handleRegenerate = async (segId: number) => {
setRegeneratingSegs(prev => new Set([...prev, segId]))
try {
await onRegenerateSegment(segId)
} finally {
setRegeneratingSegs(prev => { const n = new Set(prev); n.delete(segId); return n })
}
}
const status = project.status
const doneCount = segments.filter(s => s.status === 'done').length
@@ -676,7 +724,7 @@ function ChaptersPanel({
return (
<div className="flex-1 flex flex-col bg-emerald-500/5 overflow-hidden">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between px-3 py-2 border-b border-emerald-500/20 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">
{t('projectCard.chapters.title', { count: detail?.chapters.length ?? 0 })}
</span>
@@ -786,24 +834,119 @@ function ChaptersPanel({
)}
{chExpanded && chSegs.length > 0 && (
<div className="pt-2 border-t divide-y divide-border/50">
{chSegs.map(seg => (
<div key={seg.id} className={`py-2 space-y-1.5 ${sequentialPlayingId === seg.id ? 'bg-primary/5 px-1 rounded' : ''}`}>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs shrink-0">
{seg.character_name || t('projectCard.segments.unknownCharacter')}
</Badge>
{seg.emo_text && (
<Badge variant="secondary" className="text-xs shrink-0">{seg.emo_text}</Badge>
{chSegs.map(seg => {
const isEditing = editingSegId === seg.id
const isRegenerating = regeneratingSegs.has(seg.id) || seg.status === 'generating'
const isSaving = savingSegId === seg.id
return (
<div key={seg.id} className={`py-2 space-y-1.5 ${sequentialPlayingId === seg.id ? 'bg-primary/5 px-1 rounded' : ''}`}>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs shrink-0">
{seg.character_name || t('projectCard.segments.unknownCharacter')}
</Badge>
{!isEditing && seg.emo_text && (
<Badge variant="secondary" className="text-xs shrink-0">{seg.emo_text}</Badge>
)}
{isRegenerating && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
{!isRegenerating && seg.status === 'error' && <Badge variant="destructive" className="text-xs">{t('projectCard.segments.errorBadge')}</Badge>}
<div className="ml-auto flex items-center gap-1 shrink-0">
{!isEditing && (
<>
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={() => startEdit(seg)}
disabled={isRegenerating}
title={t('projectCard.segments.edit')}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={() => handleRegenerate(seg.id)}
disabled={isRegenerating}
title={t('projectCard.segments.regenerate')}
>
<RefreshCw className="h-3 w-3" />
</Button>
</>
)}
{isEditing && (
<>
<Button
size="icon"
variant="ghost"
className="h-5 w-5 text-destructive"
onClick={cancelEdit}
title={t('projectCard.segments.cancel')}
>
<X className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-5 w-5 text-primary"
onClick={() => saveEdit(seg.id)}
disabled={isSaving}
title={t('projectCard.segments.save')}
>
{isSaving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
</Button>
</>
)}
</div>
</div>
{isEditing ? (
<div className="space-y-2">
<Textarea
value={editText}
onChange={e => setEditText(e.target.value)}
className="text-xs min-h-[60px] resize-y"
rows={3}
/>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground shrink-0">{t('projectCard.segments.emotion')}:</span>
<select
value={editEmoText}
onChange={e => {
const v = e.target.value
setEditEmoText(v)
if (v && EMOTION_ALPHA_DEFAULTS[v]) setEditEmoAlpha(EMOTION_ALPHA_DEFAULTS[v])
}}
className="text-xs h-6 rounded border border-input bg-background px-1 focus:outline-none"
>
<option value="">{t('projectCard.segments.noEmotion')}</option>
{EMOTION_OPTIONS.map(e => <option key={e} value={e}>{e}</option>)}
</select>
{editEmoText && (
<div className="flex items-center gap-1.5 flex-1 min-w-[120px]">
<span className="text-xs text-muted-foreground shrink-0">{t('projectCard.segments.intensity')}:</span>
<input
type="range"
min={0.05}
max={0.9}
step={0.05}
value={editEmoAlpha}
onChange={e => setEditEmoAlpha(Number(e.target.value))}
className="flex-1 h-1.5 accent-primary"
/>
<span className="text-xs text-muted-foreground w-8 text-right">{editEmoAlpha.toFixed(2)}</span>
</div>
)}
</div>
</div>
) : (
<p className="text-xs text-muted-foreground break-words leading-relaxed">{seg.text}</p>
)}
{!isEditing && seg.status === 'done' && (
<LazyAudioPlayer audioUrl={audiobookApi.getSegmentAudioUrl(project.id, seg.id)} jobId={seg.id} />
)}
{seg.status === 'generating' && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
{seg.status === 'error' && <Badge variant="destructive" className="text-xs">{t('projectCard.segments.errorBadge')}</Badge>}
</div>
<p className="text-xs text-muted-foreground break-words leading-relaxed">{seg.text}</p>
{seg.status === 'done' && (
<LazyAudioPlayer audioUrl={audiobookApi.getSegmentAudioUrl(project.id, seg.id)} jobId={seg.id} />
)}
</div>
))}
)
})}
</div>
)}
</div>
@@ -1098,6 +1241,30 @@ export default function Audiobook() {
}
}
const handleUpdateSegment = async (segmentId: number, data: { text: string; emo_text?: string | null; emo_alpha?: number | null }) => {
if (!selectedProject) return
try {
await audiobookApi.updateSegment(selectedProject.id, segmentId, data)
toast.success(t('projectCard.segments.savedSuccess'))
fetchSegments()
} catch (e: any) {
toast.error(formatApiError(e))
throw e
}
}
const handleRegenerateSegment = async (segmentId: number) => {
if (!selectedProject) return
try {
await audiobookApi.regenerateSegment(selectedProject.id, segmentId)
setIsPolling(true)
fetchSegments()
} catch (e: any) {
toast.error(formatApiError(e))
throw e
}
}
const handleDownload = async (chapterIndex?: number) => {
if (!selectedProject) return
setLoadingAction(true)
@@ -1328,6 +1495,8 @@ export default function Audiobook() {
onProcessAll={handleProcessAll}
onDownload={handleDownload}
onSequentialPlayingChange={setSequentialPlayingId}
onUpdateSegment={handleUpdateSegment}
onRegenerateSegment={handleRegenerateSegment}
/>
</div>
</>