feat: Add batch processing for audiobook chapters including parse, generate, and combined process actions.

This commit is contained in:
2026-03-11 14:08:09 +08:00
parent cd73871c64
commit a0047d5c29
10 changed files with 296 additions and 34 deletions

View File

@@ -139,6 +139,14 @@ export const audiobookApi = {
return `/audiobook/projects/${projectId}/segments/${segmentId}/audio`
},
parseAllChapters: async (projectId: number): Promise<void> => {
await apiClient.post(`/audiobook/projects/${projectId}/parse-all`)
},
processAll: async (projectId: number): Promise<void> => {
await apiClient.post(`/audiobook/projects/${projectId}/process-all`)
},
deleteProject: async (id: number): Promise<void> => {
await apiClient.delete(`/audiobook/projects/${id}`)
},

View File

@@ -57,6 +57,7 @@
"reanalyzeConfirm": "Re-analyzing will clear all character and chapter data. Continue?",
"analyzeStarted": "Analysis started",
"generateAll": "Generate Full Book",
"processAll": "⚡ Process All",
"downloadAll": "Download Full Book",
"deleteConfirm": "Delete project \"{{title}}\" and all its audio?",
"deleteSuccess": "Project deleted",
@@ -85,7 +86,9 @@
"chapters": {
"title": "Chapters ({{count}} total)",
"processAll": "Process All",
"processAll": "Process All",
"parseAll": "Batch Parse",
"generateAll": "Batch Generate",
"defaultTitle": "Chapter {{index}}",
"parse": "Parse Chapter",
"parsing": "Parsing",
@@ -96,6 +99,7 @@
"generateStarted": "Chapter {{index}} generation started",
"generateAllStarted": "Full book generation started",
"processAllStarted": "All tasks triggered",
"parseAllStarted": "Batch parsing started",
"doneBadge": "{{count}} segments done",
"segmentProgress": "{{done}}/{{total}} segments"
},

View File

@@ -57,6 +57,7 @@
"reanalyzeConfirm": "再分析するとすべてのキャラクターと章のデータが削除されます。続けますか?",
"analyzeStarted": "分析を開始しました",
"generateAll": "全冊生成",
"processAll": "⚡ 全冊一括処理",
"downloadAll": "全冊ダウンロード",
"deleteConfirm": "プロジェクト「{{title}}」とすべての音声を削除しますか?",
"deleteSuccess": "プロジェクトを削除しました",
@@ -85,7 +86,9 @@
"chapters": {
"title": "章一覧(全 {{count}} 章)",
"processAll": "すべて処理",
"processAll": "すべて処理",
"parseAll": "一括解析",
"generateAll": "一括生成",
"defaultTitle": "第 {{index}} 章",
"parse": "この章を解析",
"parsing": "解析中",
@@ -96,6 +99,7 @@
"generateStarted": "第 {{index}} 章の生成を開始しました",
"generateAllStarted": "全冊生成を開始しました",
"processAllStarted": "すべてのタスクを開始しました",
"parseAllStarted": "一括解析を開始しました",
"doneBadge": "{{count}} セグメント完了",
"segmentProgress": "{{done}}/{{total}} セグメント"
},

View File

@@ -57,6 +57,7 @@
"reanalyzeConfirm": "재분석하면 모든 캐릭터와 챕터 데이터가 삭제됩니다. 계속하시겠습니까?",
"analyzeStarted": "분석이 시작되었습니다",
"generateAll": "전체 책 생성",
"processAll": "⚡ 전체 일괄 처리",
"downloadAll": "전체 책 다운로드",
"deleteConfirm": "프로젝트 「{{title}}」와 모든 음성을 삭제하시겠습니까?",
"deleteSuccess": "프로젝트가 삭제되었습니다",
@@ -85,7 +86,9 @@
"chapters": {
"title": "챕터 목록 (총 {{count}}챕터)",
"processAll": "전체 처리",
"processAll": "전체 처리",
"parseAll": "일괄 파싱",
"generateAll": "일괄 생성",
"defaultTitle": "제 {{index}} 장",
"parse": "이 챕터 파싱",
"parsing": "파싱 중",
@@ -96,6 +99,7 @@
"generateStarted": "제 {{index}} 장 생성이 시작되었습니다",
"generateAllStarted": "전체 책 생성이 시작되었습니다",
"processAllStarted": "모든 작업이 시작되었습니다",
"parseAllStarted": "일괄 파싱이 시작되었습니다",
"doneBadge": "{{count}}개 세그먼트 완료",
"segmentProgress": "{{done}}/{{total}} 세그먼트"
},

View File

@@ -57,6 +57,7 @@
"reanalyzeConfirm": "重新分析将清除所有角色和章节数据,确定继续?",
"analyzeStarted": "分析已开始",
"generateAll": "生成全书",
"processAll": "⚡ 全书一键处理",
"downloadAll": "下载全书",
"deleteConfirm": "确认删除项目「{{title}}」及所有音频?",
"deleteSuccess": "项目已删除",
@@ -85,7 +86,9 @@
"chapters": {
"title": "章节列表(共 {{count}} 章)",
"processAll": "一键全部处理",
"processAll": "全部处理",
"parseAll": "批量解析",
"generateAll": "批量生成",
"defaultTitle": "第 {{index}} 章",
"parse": "解析此章",
"parsing": "解析中",
@@ -96,6 +99,7 @@
"generateStarted": "第 {{index}} 章生成已开始",
"generateAllStarted": "全书生成已开始",
"processAllStarted": "全部任务已触发",
"parseAllStarted": "批量解析已开始",
"doneBadge": "已完成 {{count}} 段",
"segmentProgress": "{{done}}/{{total}} 段"
},

View File

@@ -57,6 +57,7 @@
"reanalyzeConfirm": "重新分析將清除所有角色和章節資料,確定繼續?",
"analyzeStarted": "分析已開始",
"generateAll": "生成全書",
"processAll": "⚡ 全書一鍵處理",
"downloadAll": "下載全書",
"deleteConfirm": "確認刪除專案「{{title}}」及所有音訊?",
"deleteSuccess": "專案已刪除",
@@ -85,7 +86,9 @@
"chapters": {
"title": "章節列表(共 {{count}} 章)",
"processAll": "一鍵全部處理",
"processAll": "全部處理",
"parseAll": "批量解析",
"generateAll": "批量生成",
"defaultTitle": "第 {{index}} 章",
"parse": "解析此章",
"parsing": "解析中",
@@ -96,6 +99,7 @@
"generateStarted": "第 {{index}} 章生成已開始",
"generateAllStarted": "全書生成已開始",
"processAllStarted": "全部任務已觸發",
"parseAllStarted": "批量解析已開始",
"doneBadge": "已完成 {{count}} 段",
"segmentProgress": "{{done}}/{{total}} 段"
},

View File

@@ -495,6 +495,43 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
}
}
const handleParseAll = async () => {
setLoadingAction(true)
setIsPolling(true)
try {
await audiobookApi.parseAllChapters(project.id)
toast.success(t('projectCard.chapters.parseAllStarted'))
onRefresh()
fetchDetail()
} catch (e: any) {
setIsPolling(false)
toast.error(formatApiError(e))
} finally {
setLoadingAction(false)
}
}
const handleGenerateAll = async () => {
if (!detail) return
setLoadingAction(true)
const ready = detail.chapters.filter(c => c.status === 'ready')
if (ready.length > 0) {
setGeneratingChapterIndices(prev => new Set([...prev, ...ready.map(c => c.chapter_index)]))
}
setIsPolling(true)
try {
await audiobookApi.generate(project.id)
toast.success(t('projectCard.chapters.generateAllStarted'))
onRefresh()
fetchSegments()
} catch (e: any) {
setIsPolling(false)
toast.error(formatApiError(e))
} finally {
setLoadingAction(false)
}
}
const handleProcessAll = async () => {
if (!detail) return
setLoadingAction(true)
@@ -504,11 +541,7 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
}
setIsPolling(true)
try {
const pending = detail.chapters.filter(c => c.status === 'pending' || c.status === 'error')
await Promise.all([
...pending.map(c => audiobookApi.parseChapter(project.id, c.id)),
...ready.map(c => audiobookApi.generate(project.id, c.chapter_index)),
])
await audiobookApi.processAll(project.id)
toast.success(t('projectCard.chapters.processAllStarted'))
onRefresh()
fetchDetail()
@@ -630,7 +663,7 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
<div className="flex items-center justify-between gap-2 pt-1 border-t">
<div className="flex items-center gap-1 flex-wrap">
{!isActive && (
{!isActive && status !== 'characters_ready' && (
<Button
size="sm"
variant={status === 'pending' ? 'default' : 'outline'}
@@ -642,8 +675,9 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
</Button>
)}
{status === 'ready' && (
<Button size="sm" className="h-7 text-xs px-2" onClick={() => handleGenerate()} disabled={loadingAction}>
{t('projectCard.generateAll')}
<Button size="sm" className="h-7 text-xs px-2" onClick={handleProcessAll} disabled={loadingAction}>
{loadingAction ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
{t('projectCard.processAll')}
</Button>
)}
{status === 'done' && (
@@ -756,16 +790,40 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
{chaptersCollapsed ? <ChevronDown className="h-3 w-3" /> : <ChevronUp className="h-3 w-3" />}
{t('projectCard.chapters.title', { count: detail.chapters.length })}
</button>
{detail.chapters.some(c => ['pending', 'error', 'ready'].includes(c.status)) && (
<Button
size="sm"
className="h-6 text-xs px-2 self-start sm:self-auto"
disabled={loadingAction}
onClick={handleProcessAll}
>
{loadingAction ? <Loader2 className="h-3 w-3 animate-spin" /> : t('projectCard.chapters.processAll')}
</Button>
)}
<div className="flex items-center gap-1 self-start sm:self-auto flex-wrap">
{detail.chapters.some(c => ['pending', 'error', 'ready'].includes(c.status)) && (
<Button
size="sm"
variant="outline"
className="h-6 text-xs px-2"
disabled={loadingAction}
onClick={handleParseAll}
>
{t('projectCard.chapters.parseAll')}
</Button>
)}
{detail.chapters.some(c => c.status === 'ready') && (
<Button
size="sm"
variant="outline"
className="h-6 text-xs px-2"
disabled={loadingAction}
onClick={handleGenerateAll}
>
{t('projectCard.chapters.generateAll')}
</Button>
)}
{detail.chapters.some(c => ['pending', 'error'].includes(c.status)) && detail.chapters.some(c => c.status === 'ready') && (
<Button
size="sm"
className="h-6 text-xs px-2"
disabled={loadingAction}
onClick={handleProcessAll}
>
{loadingAction ? <Loader2 className="h-3 w-3 animate-spin" /> : t('projectCard.chapters.processAll')}
</Button>
)}
</div>
</div>
{!chaptersCollapsed && <div className="space-y-2 max-h-96 overflow-y-auto pr-1">
{detail.chapters.map(ch => {
@@ -805,12 +863,17 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
</div>
)}
{ch.status === 'ready' && !chGenerating && !chAllDone && !generatingChapterIndices.has(ch.chapter_index) && (
<Button size="sm" variant="outline" className="h-6 text-xs px-2" disabled={loadingAction} onClick={() => {
setExpandedChapters(prev => { const n = new Set(prev); n.add(ch.id); return n })
handleGenerate(ch.chapter_index)
}}>
{t('projectCard.chapters.generate')}
</Button>
<>
<Button size="sm" variant="outline" className="h-6 text-xs px-2" disabled={loadingAction} onClick={() => {
setExpandedChapters(prev => { const n = new Set(prev); n.add(ch.id); return n })
handleGenerate(ch.chapter_index)
}}>
{t('projectCard.chapters.generate')}
</Button>
<Button size="sm" variant="ghost" className="h-6 text-xs px-2 text-muted-foreground" disabled={loadingAction} onClick={() => handleParseChapter(ch.id, ch.title)}>
{t('projectCard.chapters.reparse')}
</Button>
</>
)}
{ch.status === 'ready' && (chGenerating || generatingChapterIndices.has(ch.chapter_index)) && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
@@ -829,9 +892,14 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
</>
)}
{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)}>
{t('projectCard.chapters.reparse')}
</Button>
<>
<Button size="sm" variant="outline" className="h-6 text-xs px-2 text-destructive border-destructive/40" onClick={() => handleParseChapter(ch.id, ch.title)}>
{t('projectCard.chapters.reparse')}
</Button>
{ch.error_message && (
<span className="text-xs text-destructive/80 truncate max-w-[200px]" title={ch.error_message}>{ch.error_message}</span>
)}
</>
)}
</div>
{ch.status === 'parsing' && (