feat: Implement segment update and regeneration features in Audiobook API and frontend
This commit is contained in:
@@ -36,7 +36,7 @@ export function Navbar({ onToggleSidebar }: NavbarProps) {
|
||||
)}
|
||||
|
||||
{location.pathname !== '/' && (
|
||||
<Link to="/" className="mr-auto">
|
||||
<Link to="/">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Home className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
@@ -138,6 +138,25 @@ export const audiobookApi = {
|
||||
return `/audiobook/projects/${id}/download${chapterParam}`
|
||||
},
|
||||
|
||||
updateSegment: async (
|
||||
projectId: number,
|
||||
segmentId: number,
|
||||
data: { text: string; emo_text?: string | null; emo_alpha?: number | null }
|
||||
): Promise<AudiobookSegment> => {
|
||||
const response = await apiClient.put<AudiobookSegment>(
|
||||
`/audiobook/projects/${projectId}/segments/${segmentId}`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
regenerateSegment: async (projectId: number, segmentId: number): Promise<AudiobookSegment> => {
|
||||
const response = await apiClient.post<AudiobookSegment>(
|
||||
`/audiobook/projects/${projectId}/segments/${segmentId}/regenerate`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getSegmentAudioUrl: (projectId: number, segmentId: number): string => {
|
||||
return `/audiobook/projects/${projectId}/segments/${segmentId}/audio`
|
||||
},
|
||||
|
||||
@@ -115,7 +115,16 @@
|
||||
|
||||
"segments": {
|
||||
"errorBadge": "Error",
|
||||
"unknownCharacter": "?"
|
||||
"unknownCharacter": "?",
|
||||
"edit": "Edit",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"regenerate": "Regenerate",
|
||||
"regenerating": "Generating...",
|
||||
"savedSuccess": "Segment saved",
|
||||
"emotion": "Emotion",
|
||||
"noEmotion": "No emotion",
|
||||
"intensity": "Intensity"
|
||||
},
|
||||
|
||||
"sequential": {
|
||||
|
||||
@@ -114,7 +114,16 @@
|
||||
|
||||
"segments": {
|
||||
"errorBadge": "エラー",
|
||||
"unknownCharacter": "?"
|
||||
"unknownCharacter": "?",
|
||||
"edit": "編集",
|
||||
"save": "保存",
|
||||
"cancel": "キャンセル",
|
||||
"regenerate": "再生成",
|
||||
"regenerating": "生成中...",
|
||||
"savedSuccess": "セグメントを保存しました",
|
||||
"emotion": "感情",
|
||||
"noEmotion": "感情なし",
|
||||
"intensity": "強度"
|
||||
},
|
||||
|
||||
"sequential": {
|
||||
|
||||
@@ -114,7 +114,16 @@
|
||||
|
||||
"segments": {
|
||||
"errorBadge": "오류",
|
||||
"unknownCharacter": "?"
|
||||
"unknownCharacter": "?",
|
||||
"edit": "편집",
|
||||
"save": "저장",
|
||||
"cancel": "취소",
|
||||
"regenerate": "재생성",
|
||||
"regenerating": "생성 중...",
|
||||
"savedSuccess": "세그먼트가 저장되었습니다",
|
||||
"emotion": "감정",
|
||||
"noEmotion": "감정 없음",
|
||||
"intensity": "강도"
|
||||
},
|
||||
|
||||
"sequential": {
|
||||
|
||||
@@ -118,7 +118,16 @@
|
||||
|
||||
"segments": {
|
||||
"errorBadge": "出错",
|
||||
"unknownCharacter": "?"
|
||||
"unknownCharacter": "?",
|
||||
"edit": "编辑",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"regenerate": "重新生成",
|
||||
"regenerating": "生成中...",
|
||||
"savedSuccess": "片段已保存",
|
||||
"emotion": "情绪",
|
||||
"noEmotion": "无情绪",
|
||||
"intensity": "强度"
|
||||
},
|
||||
|
||||
"sequential": {
|
||||
|
||||
@@ -114,7 +114,16 @@
|
||||
|
||||
"segments": {
|
||||
"errorBadge": "出錯",
|
||||
"unknownCharacter": "?"
|
||||
"unknownCharacter": "?",
|
||||
"edit": "編輯",
|
||||
"save": "儲存",
|
||||
"cancel": "取消",
|
||||
"regenerate": "重新生成",
|
||||
"regenerating": "生成中...",
|
||||
"savedSuccess": "片段已儲存",
|
||||
"emotion": "情緒",
|
||||
"noEmotion": "無情緒",
|
||||
"intensity": "強度"
|
||||
},
|
||||
|
||||
"sequential": {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user