feat: Implement batch cancellation for audiobook processing with enhanced frontend progress display.

This commit is contained in:
2026-03-11 14:22:35 +08:00
parent a0047d5c29
commit b7b6f5ef8e
9 changed files with 146 additions and 10 deletions

View File

@@ -147,6 +147,10 @@ export const audiobookApi = {
await apiClient.post(`/audiobook/projects/${projectId}/process-all`)
},
cancelBatch: async (projectId: number): Promise<void> => {
await apiClient.post(`/audiobook/projects/${projectId}/cancel-batch`)
},
deleteProject: async (id: number): Promise<void> => {
await apiClient.delete(`/audiobook/projects/${id}`)
},

View File

@@ -12,6 +12,7 @@
"characters_ready": "Awaiting Character Review",
"parsing": "Parsing Chapters",
"ready": "Ready",
"processing": "Processing",
"generating": "Generating",
"done": "Done",
"error": "Error"
@@ -63,6 +64,11 @@
"deleteSuccess": "Project deleted",
"allDoneToast": "\"{{title}}\" — all audio generation complete!",
"segmentsProgress": "{{done}} / {{total}} segments done",
"chaptersProgress": "Chapters parsed: {{parsed}} / {{total}}",
"chaptersParsing": "{{count}} parsing",
"chaptersError": "{{count}} error",
"cancelBatch": "✖ Cancel Batch",
"cancelledToast": "Cancel signal sent. Running tasks will finish then stop.",
"characters": {
"title": "Characters ({{count}})",

View File

@@ -12,6 +12,7 @@
"characters_ready": "キャラクター確認待ち",
"parsing": "章を解析中",
"ready": "生成待ち",
"processing": "処理中",
"generating": "生成中",
"done": "完了",
"error": "エラー"
@@ -63,6 +64,11 @@
"deleteSuccess": "プロジェクトを削除しました",
"allDoneToast": "「{{title}}」の音声生成がすべて完了しました!",
"segmentsProgress": "{{done}} / {{total}} セグメント完了",
"chaptersProgress": "章解析:{{parsed}} / {{total}} 章",
"chaptersParsing": "{{count}} 解析中",
"chaptersError": "{{count}} エラー",
"cancelBatch": "✖ バッチキャンセル",
"cancelledToast": "キャンセルシグナルを送信しました。実行中のタスクは完了後停止します。",
"characters": {
"title": "キャラクター({{count}} 人)",

View File

@@ -12,6 +12,7 @@
"characters_ready": "캐릭터 확인 대기",
"parsing": "챕터 파싱 중",
"ready": "생성 대기",
"processing": "처리 중",
"generating": "생성 중",
"done": "완료",
"error": "오류"
@@ -63,6 +64,11 @@
"deleteSuccess": "프로젝트가 삭제되었습니다",
"allDoneToast": "「{{title}}」 음성 생성이 모두 완료되었습니다!",
"segmentsProgress": "{{done}} / {{total}} 세그먼트 완료",
"chaptersProgress": "챕터 파싱: {{parsed}} / {{total}} 챕터",
"chaptersParsing": "{{count}} 파싱 중",
"chaptersError": "{{count}} 오류",
"cancelBatch": "✖ 일괄 취소",
"cancelledToast": "취소 신호가 전송되었습니다. 실행 중인 작업은 완료 후 중지됩니다.",
"characters": {
"title": "캐릭터 목록 ({{count}}명)",

View File

@@ -12,6 +12,7 @@
"characters_ready": "角色待确认",
"parsing": "解析章节",
"ready": "待生成",
"processing": "处理中",
"generating": "生成中",
"done": "已完成",
"error": "出错"
@@ -63,6 +64,11 @@
"deleteSuccess": "项目已删除",
"allDoneToast": "「{{title}}」音频全部生成完成!",
"segmentsProgress": "{{done}} / {{total}} 片段完成",
"chaptersProgress": "章节解析:{{parsed}} / {{total}} 章",
"chaptersParsing": "{{count}} 解析中",
"chaptersError": "{{count}} 出错",
"cancelBatch": "✖ 取消批量操作",
"cancelledToast": "已发送取消信号,当前正在运行的任务会完成后停止",
"characters": {
"title": "角色列表({{count}} 个)",

View File

@@ -12,6 +12,7 @@
"characters_ready": "角色待確認",
"parsing": "解析章節",
"ready": "待生成",
"processing": "處理中",
"generating": "生成中",
"done": "已完成",
"error": "出錯"
@@ -63,6 +64,11 @@
"deleteSuccess": "專案已刪除",
"allDoneToast": "「{{title}}」音訊全部生成完成!",
"segmentsProgress": "{{done}} / {{total}} 片段完成",
"chaptersProgress": "章節解析:{{parsed}} / {{total}} 章",
"chaptersParsing": "{{count}} 解析中",
"chaptersError": "{{count}} 出錯",
"cancelBatch": "✖ 取消批量操作",
"cancelledToast": "已發送取消信號,當前正在執行的任務會完成後停止",
"characters": {
"title": "角色列表({{count}} 個)",

View File

@@ -33,6 +33,7 @@ const STATUS_COLORS: Record<string, string> = {
analyzing: 'default',
characters_ready: 'default',
parsing: 'default',
processing: 'default',
ready: 'default',
generating: 'default',
done: 'outline',
@@ -554,6 +555,18 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
}
}
const handleCancelBatch = async () => {
try {
await audiobookApi.cancelBatch(project.id)
toast.success(t('projectCard.cancelledToast'))
setIsPolling(false)
setGeneratingChapterIndices(new Set())
setTimeout(() => { onRefresh(); fetchDetail(); fetchSegments() }, 1000)
} catch (e: any) {
toast.error(formatApiError(e))
}
}
const handleDownload = async (chapterIndex?: number) => {
setLoadingAction(true)
try {
@@ -621,6 +634,26 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
const totalCount = segments.length
const progress = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0
// Chapter parsing progress
const chaptersParsed = detail?.chapters.filter(c => ['ready', 'done'].includes(c.status) || (segments.some(s => s.chapter_index === c.chapter_index))).length ?? 0
const chaptersParsing = detail?.chapters.filter(c => c.status === 'parsing').length ?? 0
const chaptersError = detail?.chapters.filter(c => c.status === 'error').length ?? 0
const chaptersTotal = detail?.chapters.length ?? 0
const chapterProgress = chaptersTotal > 0 ? Math.round((chaptersParsed / chaptersTotal) * 100) : 0
const hasGenerating = segments.some(s => s.status === 'generating')
// Frontend display status override
const displayStatus = (() => {
if (['ready', 'generating', 'done'].includes(status)) {
if (chaptersParsing > 0 && hasGenerating) return 'processing'
if (chaptersParsing > 0) return 'parsing'
if (hasGenerating) return 'generating'
if (totalCount > 0 && doneCount === totalCount && chaptersTotal > 0 && chaptersParsing === 0 && chaptersError === 0) return 'done'
if (status === 'done' && (chaptersError > 0 || (chaptersTotal > 0 && chaptersParsed < chaptersTotal))) return 'ready'
}
return status
})()
return (
<div className="border rounded-lg p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
@@ -629,8 +662,8 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
<span className="font-medium break-words">{project.title}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<Badge variant={(STATUS_COLORS[status] || 'secondary') as any}>
{t(`status.${status}`, { defaultValue: status })}
<Badge variant={(STATUS_COLORS[displayStatus] || 'secondary') as any}>
{t(`status.${displayStatus}`, { defaultValue: displayStatus })}
</Badge>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => setExpanded(!expanded)}>
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
@@ -652,12 +685,37 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
<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">
{t('projectCard.segmentsProgress', { done: doneCount, total: totalCount })}
</div>
<Progress value={progress} />
{chaptersTotal > 0 && ['ready', 'generating', 'done'].includes(status) && (chaptersParsing > 0 || chaptersError > 0 || (totalCount > 0 && doneCount > 0)) && (
<div className="space-y-2">
{(chaptersParsing > 0 || chaptersError > 0 || chaptersParsed < chaptersTotal) && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground flex items-center gap-1">
<span>📝</span>
<span>{t('projectCard.chaptersProgress', { parsed: chaptersParsed, total: chaptersTotal })}</span>
{chaptersParsing > 0 && (
<span className="text-primary">({t('projectCard.chaptersParsing', { count: chaptersParsing })})</span>
)}
{chaptersError > 0 && (
<span className="text-destructive">({t('projectCard.chaptersError', { count: chaptersError })})</span>
)}
</div>
<Progress value={chapterProgress} />
</div>
)}
{totalCount > 0 && doneCount > 0 && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground flex items-center gap-1">
<span>🎵</span>
<span>{t('projectCard.segmentsProgress', { done: doneCount, total: totalCount })}</span>
</div>
<Progress value={progress} />
</div>
)}
{(chaptersParsing > 0 || hasGenerating) && (
<Button size="sm" variant="outline" className="h-6 text-xs px-2 text-destructive border-destructive/40" onClick={handleCancelBatch}>
{t('projectCard.cancelBatch')}
</Button>
)}
</div>
)}