feat(audiobook): implement chapter management with CRUD operations and enhance project detail responses

This commit is contained in:
2026-03-10 16:42:32 +08:00
parent 01b6f4633e
commit 3c30afc476
8 changed files with 393 additions and 156 deletions

View File

@@ -17,6 +17,7 @@ from schemas.audiobook import (
AudiobookProjectResponse,
AudiobookProjectDetail,
AudiobookCharacterResponse,
AudiobookChapterResponse,
AudiobookCharacterEdit,
AudiobookSegmentResponse,
AudiobookGenerateRequest,
@@ -53,11 +54,17 @@ def _project_to_detail(project, db: Session) -> AudiobookProjectDetail:
)
for c in (project.characters or [])
]
from db.models import AudiobookSegment
chapter_indices = db.query(AudiobookSegment.chapter_index).filter(
AudiobookSegment.project_id == project.id
).distinct().all()
chapter_count = len(chapter_indices)
chapters = [
AudiobookChapterResponse(
id=ch.id,
project_id=ch.project_id,
chapter_index=ch.chapter_index,
title=ch.title,
status=ch.status,
error_message=ch.error_message,
)
for ch in (project.chapters or [])
]
return AudiobookProjectDetail(
id=project.id,
user_id=project.user_id,
@@ -69,7 +76,7 @@ def _project_to_detail(project, db: Session) -> AudiobookProjectDetail:
created_at=project.created_at,
updated_at=project.updated_at,
characters=characters,
chapter_count=chapter_count,
chapters=chapters,
)
@@ -193,22 +200,67 @@ async def confirm_characters(
if project.status != "characters_ready":
raise HTTPException(status_code=400, detail="Project must be in 'characters_ready' state to confirm characters")
if not current_user.llm_api_key or not current_user.llm_base_url or not current_user.llm_model:
raise HTTPException(status_code=400, detail="LLM config not set. Please configure LLM API key first.")
from core.audiobook_service import identify_chapters
try:
identify_chapters(project_id, db, project)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
from core.audiobook_service import parse_chapters as _parse
return {"message": "Chapters identified", "project_id": project_id}
@router.get("/projects/{project_id}/chapters", response_model=list[AudiobookChapterResponse])
async def list_chapters(
project_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
project = crud.get_audiobook_project(db, project_id, current_user.id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
chapters = crud.list_audiobook_chapters(db, project_id)
return [
AudiobookChapterResponse(
id=ch.id, project_id=ch.project_id, chapter_index=ch.chapter_index,
title=ch.title, status=ch.status, error_message=ch.error_message,
)
for ch in chapters
]
@router.post("/projects/{project_id}/chapters/{chapter_id}/parse")
async def parse_chapter(
project_id: int,
chapter_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
project = crud.get_audiobook_project(db, project_id, current_user.id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
chapter = crud.get_audiobook_chapter(db, chapter_id)
if not chapter or chapter.project_id != project_id:
raise HTTPException(status_code=404, detail="Chapter not found")
if chapter.status == "parsing":
raise HTTPException(status_code=400, detail="Chapter is already being parsed")
if not current_user.llm_api_key or not current_user.llm_base_url or not current_user.llm_model:
raise HTTPException(status_code=400, detail="LLM config not set")
from core.audiobook_service import parse_one_chapter
from core.database import SessionLocal
async def run_parsing():
async def run():
async_db = SessionLocal()
try:
db_user = crud.get_user_by_id(async_db, current_user.id)
await _parse(project_id, db_user, async_db)
await parse_one_chapter(project_id, chapter_id, db_user, async_db)
finally:
async_db.close()
asyncio.create_task(run_parsing())
return {"message": "Chapter parsing started", "project_id": project_id}
asyncio.create_task(run())
return {"message": "Parsing started", "chapter_id": chapter_id}
@router.put("/projects/{project_id}/characters/{char_id}", response_model=AudiobookCharacterResponse)

View File

@@ -80,6 +80,19 @@ def _extract_epub_chapters(file_path: str) -> list[str]:
raise RuntimeError("ebooklib not installed. Run: pip install EbookLib")
def _sample_full_text(text: str, n_samples: int = 8, sample_size: int = 3000) -> list[str]:
if len(text) <= 30000:
return [text]
segment_size = len(text) // n_samples
samples = []
for i in range(n_samples):
start = i * segment_size
boundary = text.find("", start, start + 200)
actual_start = boundary + 1 if boundary != -1 else start
samples.append(text[actual_start:actual_start + sample_size])
return samples
def _chunk_chapter(text: str, max_chars: int = 4000) -> list[str]:
if len(text) <= max_chars:
return [text]
@@ -139,13 +152,22 @@ async def analyze_project(project_id: int, user: User, db: Session) -> None:
if not text.strip():
raise ValueError("No text content found in project.")
ps.append_line(project_id, f"\n[LLM] 模型:{user.llm_model},正在分析角色...\n")
samples = _sample_full_text(text)
n = len(samples)
ps.append_line(project_id, f"\n[LLM] 模型:{user.llm_model},共 {n} 个采样段,正在分析角色...\n")
ps.append_line(project_id, "")
def on_token(token: str) -> None:
ps.append_token(project_id, token)
characters_data = await llm.extract_characters(text, on_token=on_token)
def on_sample(i: int, total: int) -> None:
if i < total - 1:
ps.append_line(project_id, f"\n[LLM] 采样段 {i + 1}/{total} 完成,继续分析...\n")
else:
ps.append_line(project_id, f"\n[LLM] 全部 {total} 个采样段完成,正在合并角色列表...\n")
ps.append_line(project_id, "")
characters_data = await llm.extract_characters(samples, on_token=on_token, on_sample=on_sample)
has_narrator = any(c.get("name") == "narrator" for c in characters_data)
if not has_narrator:
@@ -196,17 +218,44 @@ async def analyze_project(project_id: int, user: User, db: Session) -> None:
crud.update_audiobook_project_status(db, project_id, "error", error_message=str(e))
async def parse_chapters(project_id: int, user: User, db: Session) -> None:
project = db.query(AudiobookProject).filter(AudiobookProject.id == project_id).first()
if not project:
def _get_chapter_title(text: str) -> str:
first_line = text.strip().split('\n')[0].strip()
return first_line[:80] if len(first_line) <= 80 else first_line[:77] + '...'
def identify_chapters(project_id: int, db, project) -> None:
if project.source_type == "epub" and project.source_path:
texts = _extract_epub_chapters(project.source_path)
else:
texts = _split_into_chapters(project.source_text or "")
crud.delete_audiobook_chapters(db, project_id)
crud.delete_audiobook_segments(db, project_id)
real_idx = 0
for text in texts:
if text.strip():
crud.create_audiobook_chapter(
db, project_id, real_idx, text.strip(),
title=_get_chapter_title(text),
)
real_idx += 1
crud.update_audiobook_project_status(db, project_id, "ready")
logger.info(f"Project {project_id} chapters identified: {real_idx} chapters")
async def parse_one_chapter(project_id: int, chapter_id: int, user: User, db) -> None:
from db.models import AudiobookChapter as ChapterModel
chapter = crud.get_audiobook_chapter(db, chapter_id)
if not chapter:
return
ps.reset(project_id)
try:
crud.update_audiobook_project_status(db, project_id, "parsing")
crud.update_audiobook_chapter_status(db, chapter_id, "parsing")
llm = _get_llm_service(user)
characters = crud.list_audiobook_characters(db, project_id)
if not characters:
raise ValueError("No characters found. Please analyze the project first.")
@@ -214,86 +263,63 @@ async def parse_chapters(project_id: int, user: User, db: Session) -> None:
char_map: dict[str, AudiobookCharacter] = {c.name: c for c in characters}
character_names = list(char_map.keys())
text = project.source_text or ""
if not text.strip():
raise ValueError("No text content found in project.")
label = chapter.title or f"{chapter.chapter_index + 1}"
ps.append_line(project_id, f"[{label}] 开始解析 ({len(chapter.source_text)} 字)")
if project.source_type == "epub" and project.source_path:
chapters = _extract_epub_chapters(project.source_path)
else:
chapters = _split_into_chapters(text)
crud.delete_audiobook_segments_for_chapter(db, project_id, chapter.chapter_index)
non_empty = [(i, t) for i, t in enumerate(chapters) if t.strip()]
ps.append_line(project_id, f"[解析] {len(non_empty)} 章,角色:{', '.join(character_names)}\n")
chunks = _chunk_chapter(chapter.source_text, max_chars=4000)
ps.append_line(project_id, f"{len(chunks)}\n")
crud.delete_audiobook_segments(db, project_id)
seg_counter = 0
for i, chunk in enumerate(chunks):
ps.append_line(project_id, f"{i + 1}/{len(chunks)}")
ps.append_line(project_id, "")
seg_counters: dict[int, int] = {}
for chapter_idx, chapter_text in non_empty:
chunks = _chunk_chapter(chapter_text, max_chars=4000)
logger.info(f"Chapter {chapter_idx}: {len(chapter_text)} chars → {len(chunks)} chunk(s)")
ps.append_line(project_id, f"[第 {chapter_idx + 1} 章] {len(chapter_text)} 字,{len(chunks)}")
def on_token(token: str) -> None:
ps.append_token(project_id, token)
for chunk_i, chunk in enumerate(chunks):
ps.append_line(project_id, f"{chunk_i + 1}/{len(chunks)}")
ps.append_line(project_id, "")
def on_token(token: str) -> None:
ps.append_token(project_id, token)
try:
segments_data = await llm.parse_chapter_segments(chunk, character_names, on_token=on_token)
except Exception as e:
logger.warning(f"Chapter {chapter_idx} chunk LLM parse failed, fallback to narrator: {e}")
ps.append_line(project_id, f"\n [回退] LLM 失败,整块归属 narrator")
narrator = char_map.get("narrator")
if narrator:
idx = seg_counters.get(chapter_idx, 0)
crud.create_audiobook_segment(
db=db,
project_id=project_id,
character_id=narrator.id,
text=chunk.strip(),
chapter_index=chapter_idx,
segment_index=idx,
)
seg_counters[chapter_idx] = idx + 1
continue
chunk_seg_count = 0
for seg in segments_data:
char_name = seg.get("character", "narrator")
seg_text = seg.get("text", "").strip()
if not seg_text:
continue
char = char_map.get(char_name) or char_map.get("narrator")
if char is None:
continue
idx = seg_counters.get(chapter_idx, 0)
try:
segments_data = await llm.parse_chapter_segments(chunk, character_names, on_token=on_token)
except Exception as e:
logger.warning(f"Chapter {chapter_id} chunk {i} failed: {e}")
ps.append_line(project_id, f"\n[回退] {e}")
narrator = char_map.get("narrator")
if narrator:
crud.create_audiobook_segment(
db=db,
project_id=project_id,
character_id=char.id,
text=seg_text,
chapter_index=chapter_idx,
segment_index=idx,
db, project_id, narrator.id, chunk.strip(),
chapter.chapter_index, seg_counter,
)
seg_counters[chapter_idx] = idx + 1
chunk_seg_count += 1
seg_counter += 1
continue
ps.append_line(project_id, f"\n [完成] 解析出 {chunk_seg_count}")
chunk_count = 0
for seg in segments_data:
seg_text = seg.get("text", "").strip()
if not seg_text:
continue
char = char_map.get(seg.get("character", "narrator")) or char_map.get("narrator")
if not char:
continue
crud.create_audiobook_segment(
db, project_id, char.id, seg_text,
chapter.chapter_index, seg_counter,
)
seg_counter += 1
chunk_count += 1
total_segs = sum(seg_counters.values())
ps.append_line(project_id, f"\n[完成] 全部解析完毕,共 {total_segs}")
crud.update_audiobook_project_status(db, project_id, "ready")
ps.append_line(project_id, f"\n{chunk_count}")
ps.append_line(project_id, f"\n[完成] 共 {seg_counter}")
crud.update_audiobook_chapter_status(db, chapter_id, "ready")
ps.mark_done(project_id)
logger.info(f"Project {project_id} chapter parsing complete: {len(chapters)} chapters")
logger.info(f"Chapter {chapter_id} parsed: {seg_counter} segments")
except Exception as e:
logger.error(f"Chapter parsing failed for project {project_id}: {e}", exc_info=True)
logger.error(f"parse_one_chapter {chapter_id} failed: {e}", exc_info=True)
ps.append_line(project_id, f"\n[错误] {e}")
ps.mark_done(project_id)
crud.update_audiobook_project_status(db, project_id, "error", error_message=str(e))
crud.update_audiobook_chapter_status(db, chapter_id, "error", error_message=str(e))
async def _bootstrap_character_voices(segments, user, backend, backend_type: str, db: Session) -> None:

View File

@@ -115,7 +115,7 @@ class LLMService:
logger.error(f"JSON parse failed. Raw response (first 500 chars): {raw[:500]}")
raise
async def extract_characters(self, text: str, on_token=None) -> list[Dict]:
async def extract_characters(self, text_samples: list[str], on_token=None, on_sample=None) -> list[Dict]:
system_prompt = (
"你是一个专业的小说分析助手兼声音导演。请分析给定的小说文本提取所有出现的角色包括旁白narrator\n"
"对每个角色instruct字段必须是详细的声音导演说明需覆盖以下六个维度每个维度单独一句用换行分隔\n"
@@ -128,9 +128,44 @@ class LLMService:
"只输出JSON格式如下不要有其他文字\n"
'{"characters": [{"name": "narrator", "description": "第三人称叙述者", "instruct": "音色信息:...\\n身份背景...\\n年龄设定...\\n外貌特征...\\n性格特质...\\n叙事风格..."}, ...]}'
)
user_message = f"请分析以下小说文本并提取角色:\n\n{text[:30000]}"
result = await self.stream_chat_json(system_prompt, user_message, on_token)
return result.get("characters", [])
raw_all: list[Dict] = []
for i, sample in enumerate(text_samples):
logger.info(f"Extracting characters from sample {i+1}/{len(text_samples)}")
user_message = f"请分析以下小说文本并提取角色:\n\n{sample}"
try:
result = await self.stream_chat_json(system_prompt, user_message, on_token)
raw_all.extend(result.get("characters", []))
except Exception as e:
logger.warning(f"Character extraction failed for sample {i+1}: {e}")
if on_sample:
on_sample(i, len(text_samples))
if len(text_samples) == 1:
return raw_all
return await self.merge_characters(raw_all)
async def merge_characters(self, raw_characters: list[Dict]) -> list[Dict]:
system_prompt = (
"你是一个专业的小说角色整合助手。你收到的是从同一本书不同段落中提取的角色列表,其中可能存在重复。\n"
"请完成以下任务:\n"
"1. 识别并合并重复角色:通过名字完全相同或高度相似(全名与简称、不同译写)来判断。\n"
"2. 合并时保留最完整、最详细的 description 和 instruct 字段。\n"
"3. narrator 角色只保留一个。\n"
"4. 去除无意义的占位角色name 为空或仅含标点)。\n"
"只输出 JSON不要有其他文字\n"
'{"characters": [{"name": "...", "description": "...", "instruct": "..."}, ...]}'
)
user_message = f"请整合以下角色列表:\n\n{json.dumps(raw_characters, ensure_ascii=False, indent=2)}"
try:
result = await self.chat_json(system_prompt, user_message)
return result.get("characters", [])
except Exception as e:
logger.warning(f"Character merge failed, falling back to name-dedup: {e}")
seen: dict[str, Dict] = {}
for c in raw_characters:
name = c.get("name", "")
if name and name not in seen:
seen[name] = c
return list(seen.values())
async def parse_chapter_segments(self, chapter_text: str, character_names: list[str], on_token=None) -> list[Dict]:
names_str = "".join(character_names)

View File

@@ -3,7 +3,7 @@ from typing import Optional, List, Dict, Any
from datetime import datetime
from sqlalchemy.orm import Session
from db.models import User, Job, VoiceCache, SystemSettings, VoiceDesign, AudiobookProject, AudiobookCharacter, AudiobookSegment
from db.models import User, Job, VoiceCache, SystemSettings, VoiceDesign, AudiobookProject, AudiobookChapter, AudiobookCharacter, AudiobookSegment
def get_user_by_username(db: Session, username: str) -> Optional[User]:
return db.query(User).filter(User.username == username).first()
@@ -449,6 +449,66 @@ def delete_audiobook_project(db: Session, project_id: int, user_id: int) -> bool
return True
def create_audiobook_chapter(
db: Session,
project_id: int,
chapter_index: int,
source_text: str,
title: Optional[str] = None,
) -> AudiobookChapter:
chapter = AudiobookChapter(
project_id=project_id,
chapter_index=chapter_index,
source_text=source_text,
title=title,
status="pending",
)
db.add(chapter)
db.commit()
db.refresh(chapter)
return chapter
def get_audiobook_chapter(db: Session, chapter_id: int) -> Optional[AudiobookChapter]:
return db.query(AudiobookChapter).filter(AudiobookChapter.id == chapter_id).first()
def list_audiobook_chapters(db: Session, project_id: int) -> List[AudiobookChapter]:
return db.query(AudiobookChapter).filter(
AudiobookChapter.project_id == project_id
).order_by(AudiobookChapter.chapter_index).all()
def update_audiobook_chapter_status(
db: Session,
chapter_id: int,
status: str,
error_message: Optional[str] = None,
) -> Optional[AudiobookChapter]:
chapter = db.query(AudiobookChapter).filter(AudiobookChapter.id == chapter_id).first()
if not chapter:
return None
chapter.status = status
if error_message is not None:
chapter.error_message = error_message
db.commit()
db.refresh(chapter)
return chapter
def delete_audiobook_chapters(db: Session, project_id: int) -> None:
db.query(AudiobookChapter).filter(AudiobookChapter.project_id == project_id).delete()
db.commit()
def delete_audiobook_segments_for_chapter(db: Session, project_id: int, chapter_index: int) -> None:
db.query(AudiobookSegment).filter(
AudiobookSegment.project_id == project_id,
AudiobookSegment.chapter_index == chapter_index,
).delete()
db.commit()
def create_audiobook_character(
db: Session,
project_id: int,

View File

@@ -141,9 +141,28 @@ class AudiobookProject(Base):
user = relationship("User", back_populates="audiobook_projects")
characters = relationship("AudiobookCharacter", back_populates="project", cascade="all, delete-orphan")
chapters = relationship("AudiobookChapter", back_populates="project", cascade="all, delete-orphan", order_by="AudiobookChapter.chapter_index")
segments = relationship("AudiobookSegment", back_populates="project", cascade="all, delete-orphan")
class AudiobookChapter(Base):
__tablename__ = "audiobook_chapters"
id = Column(Integer, primary_key=True, index=True)
project_id = Column(Integer, ForeignKey("audiobook_projects.id"), nullable=False, index=True)
chapter_index = Column(Integer, nullable=False)
title = Column(String(500), nullable=True)
source_text = Column(Text, nullable=False)
status = Column(String(20), default="pending", nullable=False)
error_message = Column(Text, nullable=True)
project = relationship("AudiobookProject", back_populates="chapters")
__table_args__ = (
Index('idx_chapter_project_idx', 'project_id', 'chapter_index'),
)
class AudiobookCharacter(Base):
__tablename__ = "audiobook_characters"

View File

@@ -34,9 +34,20 @@ class AudiobookCharacterResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
class AudiobookChapterResponse(BaseModel):
id: int
project_id: int
chapter_index: int
title: Optional[str] = None
status: str
error_message: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class AudiobookProjectDetail(AudiobookProjectResponse):
characters: List[AudiobookCharacterResponse] = []
chapter_count: int = 0
chapters: List[AudiobookChapterResponse] = []
class AudiobookGenerateRequest(BaseModel):

View File

@@ -21,9 +21,18 @@ export interface AudiobookCharacter {
voice_design_id?: number
}
export interface AudiobookChapter {
id: number
project_id: number
chapter_index: number
title?: string
status: string
error_message?: string
}
export interface AudiobookProjectDetail extends AudiobookProject {
characters: AudiobookCharacter[]
chapter_count: number
chapters: AudiobookChapter[]
}
export interface AudiobookSegment {
@@ -96,6 +105,15 @@ export const audiobookApi = {
await apiClient.post(`/audiobook/projects/${id}/confirm`)
},
listChapters: async (id: number): Promise<AudiobookChapter[]> => {
const response = await apiClient.get<AudiobookChapter[]>(`/audiobook/projects/${id}/chapters`)
return response.data
},
parseChapter: async (projectId: number, chapterId: number): Promise<void> => {
await apiClient.post(`/audiobook/projects/${projectId}/chapters/${chapterId}/parse`)
},
generate: async (id: number, chapterIndex?: number): Promise<void> => {
await apiClient.post(`/audiobook/projects/${id}/generate`, {
chapter_index: chapterIndex ?? null,

View File

@@ -36,10 +36,9 @@ const STATUS_COLORS: Record<string, string> = {
const STEP_HINTS: Record<string, string> = {
pending: '第 1 步点击「分析」LLM 将自动提取角色列表',
analyzing: '第 1 步LLM 正在提取角色,请稍候...',
characters_ready: '第 2 步:确认角色信息,可编辑后点击「确认角色 · 解析章节」',
parsing: '第 3 步:LLM 正在解析章节脚本,请稍候...',
ready: '第 4 步:按章节逐章生成音频,或一次性生成全书',
generating: '第 5 步:正在合成音频,已完成片段可立即播放',
characters_ready: '第 2 步:确认角色信息,可编辑后点击「确认角色 · 识别章节」',
ready: '第 3 步:逐章解析剧本LLM解析完的章节可立即生成音频',
generating: '第 4 步:正在合成音频,已完成片段可立即播放',
}
function SequentialPlayer({
@@ -353,18 +352,21 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
prevStatusRef.current = project.status
}, [project.status, project.title])
useEffect(() => {
if (!isPolling) return
if (['analyzing', 'parsing', 'generating'].includes(project.status)) return
if (!segments.some(s => s.status === 'generating')) setIsPolling(false)
}, [isPolling, project.status, segments])
const hasParsingChapter = detail?.chapters.some(c => c.status === 'parsing') ?? false
useEffect(() => {
const shouldPoll = isPolling || ['analyzing', 'parsing', 'generating'].includes(project.status)
if (!isPolling) return
if (['analyzing', 'generating'].includes(project.status)) return
if (hasParsingChapter) return
if (!segments.some(s => s.status === 'generating')) setIsPolling(false)
}, [isPolling, project.status, segments, hasParsingChapter])
useEffect(() => {
const shouldPoll = isPolling || ['analyzing', 'generating'].includes(project.status) || hasParsingChapter
if (!shouldPoll) return
const id = setInterval(() => { onRefresh(); fetchSegments() }, 1500)
const id = setInterval(() => { onRefresh(); fetchSegments(); fetchDetail() }, 1500)
return () => clearInterval(id)
}, [isPolling, project.status, onRefresh, fetchSegments])
}, [isPolling, project.status, hasParsingChapter, onRefresh, fetchSegments, fetchDetail])
const handleAnalyze = async () => {
const s = project.status
@@ -389,19 +391,28 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
const handleConfirm = async () => {
setLoadingAction(true)
setIsPolling(true)
try {
await audiobookApi.confirmCharacters(project.id)
toast.success('章节解析已开始')
toast.success('章节已识别')
onRefresh()
fetchDetail()
} catch (e: any) {
setIsPolling(false)
toast.error(formatApiError(e))
} finally {
setLoadingAction(false)
}
}
const handleParseChapter = async (chapterId: number, title?: string) => {
try {
await audiobookApi.parseChapter(project.id, chapterId)
toast.success(title ? `${title}」解析已开始` : '章节解析已开始')
fetchDetail()
} catch (e: any) {
toast.error(formatApiError(e))
}
}
const handleGenerate = async (chapterIndex?: number) => {
setLoadingAction(true)
setIsPolling(true)
@@ -472,19 +483,11 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
}
const status = project.status
const isActive = ['analyzing', 'parsing', 'generating'].includes(status)
const isActive = ['analyzing', 'generating'].includes(status)
const doneCount = segments.filter(s => s.status === 'done').length
const totalCount = segments.length
const progress = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0
const chapterMap = new Map<number, AudiobookSegment[]>()
segments.forEach(s => {
const arr = chapterMap.get(s.chapter_index) ?? []
arr.push(s)
chapterMap.set(s.chapter_index, arr)
})
const chapters = Array.from(chapterMap.entries()).sort(([a], [b]) => a - b)
return (
<div className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between gap-2">
@@ -531,8 +534,8 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
</div>
)}
{['analyzing', 'parsing'].includes(status) && (
<LogStream projectId={project.id} active={['analyzing', 'parsing'].includes(status)} />
{status === 'analyzing' && (
<LogStream projectId={project.id} active={status === 'analyzing'} />
)}
{project.error_message && (
@@ -611,57 +614,70 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
onClick={handleConfirm}
disabled={loadingAction || editingCharId !== null}
>
{loadingAction ? '解析中...' : '确认角色 · 解析章节'}
{loadingAction ? '识别中...' : '确认角色 · 识别章节'}
</Button>
)}
</div>
)}
{status === 'ready' && chapters.length > 0 && (
{detail && detail.chapters.length > 0 && ['ready', 'generating', 'done'].includes(status) && (
<div>
<div className="text-xs font-medium text-muted-foreground mb-2">
{chapters.length}
{detail.chapters.length}
</div>
<div className="space-y-1">
{chapters.map(([chIdx, chSegs]) => {
<div className="space-y-2">
{detail.chapters.map(ch => {
const chSegs = segments.filter(s => s.chapter_index === ch.chapter_index)
const chDone = chSegs.filter(s => s.status === 'done').length
const chTotal = chSegs.length
const chGenerating = chSegs.some(s => s.status === 'generating')
const chAllDone = chDone === chTotal && chTotal > 0
const chAllDone = chTotal > 0 && chDone === chTotal
const chTitle = ch.title || `${ch.chapter_index + 1}`
return (
<div key={chIdx} className="flex items-center justify-between border rounded px-2 py-1.5 text-sm">
<span className="text-xs text-muted-foreground shrink-0"> {chIdx + 1} </span>
<span className="text-xs text-muted-foreground mx-2 flex-1">{chDone}/{chTotal} </span>
<div className="flex gap-1 shrink-0">
{chGenerating ? (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span></span>
</div>
) : chAllDone ? (
<>
<Badge variant="outline" className="text-xs"></Badge>
<Button
size="sm" variant="ghost" className="h-5 w-5 p-0"
onClick={() => handleDownload(chIdx)}
title="下载此章"
>
<Download className="h-3 w-3" />
<div key={ch.id} className="border rounded px-3 py-2 space-y-2">
<div className="flex items-center justify-between text-sm gap-2">
<span className="text-xs font-medium truncate max-w-[55%]">{chTitle}</span>
<div className="flex gap-1 items-center shrink-0">
{ch.status === 'pending' && (
<Button size="sm" variant="outline" className="h-6 text-xs px-2" onClick={() => handleParseChapter(ch.id, ch.title)}>
</Button>
</>
) : (
<Button
size="sm" variant="outline" className="h-6 text-xs px-2"
disabled={loadingAction}
onClick={() => handleGenerate(chIdx)}
>
{loadingAction
? <Loader2 className="h-3 w-3 animate-spin" />
: '生成此章'
}
</Button>
)}
)}
{ch.status === 'parsing' && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span></span>
</div>
)}
{ch.status === 'ready' && !chGenerating && !chAllDone && (
<Button size="sm" variant="outline" className="h-6 text-xs px-2" disabled={loadingAction} onClick={() => handleGenerate(ch.chapter_index)}>
</Button>
)}
{ch.status === 'ready' && chGenerating && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span>{chDone}/{chTotal} </span>
</div>
)}
{ch.status === 'ready' && chAllDone && (
<>
<Badge variant="outline" className="text-xs"> {chDone} </Badge>
<Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={() => handleDownload(ch.chapter_index)} title="下载此章">
<Download className="h-3 w-3" />
</Button>
</>
)}
{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)}>
</Button>
)}
</div>
</div>
{ch.status === 'parsing' && (
<LogStream projectId={project.id} active={ch.status === 'parsing'} />
)}
</div>
)
})}