diff --git a/.github/workflows/docker-build-deploy.yml b/.github/workflows/docker-build-deploy.yml index 8948828..b6e44ee 100644 --- a/.github/workflows/docker-build-deploy.yml +++ b/.github/workflows/docker-build-deploy.yml @@ -209,6 +209,11 @@ jobs: "docker exec -i life-echo-postgres psql -U $DB_USER -d $DB_NAME" \ < api/migrations/fix_chapter_order_index.sql + echo "添加章节 is_active 字段..." + ssh -p $SSH_PORT $SSH_USER@$SSH_HOST \ + "docker exec -i life-echo-postgres psql -U $DB_USER -d $DB_NAME" \ + < api/migrations/add_chapter_is_active.sql + echo "数据库迁移完成" - name: Verify deployment diff --git a/api/database/models.py b/api/database/models.py index 9322316..7659c9d 100644 --- a/api/database/models.py +++ b/api/database/models.py @@ -89,6 +89,7 @@ class Chapter(Base): updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now) category = Column(String, nullable=True) # 章节分类 is_new = Column(Boolean, default=True) # 是否为新内容(未读) + is_active = Column(Boolean, default=True) # 是否启用(清除回忆后置为 False) source_segments = Column(JSON, nullable=True) # 来源 segment IDs 列表 # Relationships diff --git a/api/migrations/add_chapter_is_active.sql b/api/migrations/add_chapter_is_active.sql new file mode 100644 index 0000000..97b878f --- /dev/null +++ b/api/migrations/add_chapter_is_active.sql @@ -0,0 +1,8 @@ +-- 为 chapters 表添加 is_active 字段 +-- 用于支持"清除回忆"功能:将章节标记为 disabled 而非物理删除 +-- 默认值为 TRUE,现有章节全部为 active + +ALTER TABLE chapters ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT TRUE; + +-- 确保现有数据全部为 active +UPDATE chapters SET is_active = TRUE WHERE is_active IS NULL; diff --git a/api/routers/books.py b/api/routers/books.py index 27bea96..89004d4 100644 --- a/api/routers/books.py +++ b/api/routers/books.py @@ -112,9 +112,12 @@ async def export_pdf( if book.user_id != current_user.id: raise HTTPException(status_code=403, detail="无权导出此回忆录") - # 获取所有章节 + # 获取所有 active 章节 from database.models import Chapter - stmt = select(Chapter).where(Chapter.user_id == current_user.id).order_by(Chapter.order_index) + stmt = select(Chapter).where( + Chapter.user_id == current_user.id, + Chapter.is_active == True + ).order_by(Chapter.order_index) result = await db.execute(stmt) chapters = result.scalars().all() diff --git a/api/routers/chapters.py b/api/routers/chapters.py index 7b68569..3768d06 100644 --- a/api/routers/chapters.py +++ b/api/routers/chapters.py @@ -21,8 +21,11 @@ async def get_chapters( is_new: Optional[bool] = Query(None, description="仅返回未读章节"), db: AsyncSession = Depends(get_async_db) ): - """获取用户所有章节(需要认证)""" - stmt = select(ChapterModel).where(ChapterModel.user_id == current_user.id) + """获取用户所有章节(需要认证,仅返回 active 章节)""" + stmt = select(ChapterModel).where( + ChapterModel.user_id == current_user.id, + ChapterModel.is_active == True + ) if is_new is True: stmt = stmt.where(ChapterModel.is_new == True) stmt = stmt.order_by(ChapterModel.order_index) @@ -75,6 +78,28 @@ async def get_chapter( } +@router.delete("/{chapter_id}") +async def disable_chapter( + chapter_id: str, + current_user: UserModel = Depends(get_current_user), + db: AsyncSession = Depends(get_async_db) +): + """清除章节(将章节标记为 disabled,需要认证,只能操作自己的章节)""" + chapter = await db.get(ChapterModel, chapter_id) + if not chapter: + raise HTTPException(status_code=404, detail="Chapter not found") + + # 验证用户权限 + if chapter.user_id != current_user.id: + raise HTTPException(status_code=403, detail="无权操作此章节") + + # 将章节标记为 disabled(不物理删除) + chapter.is_active = False + await db.commit() + + return {"status": "ok", "message": "章节已清除"} + + @router.post("/{chapter_id}/regenerate") async def regenerate_chapter( chapter_id: str, diff --git a/app-android/app/src/main/java/com/huaga/life_echo/network/ApiService.kt b/app-android/app/src/main/java/com/huaga/life_echo/network/ApiService.kt index ee8bf4f..c93ca55 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/network/ApiService.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/network/ApiService.kt @@ -165,6 +165,20 @@ class ApiService( } } + /** + * 清除章节(将章节标记为 disabled) + */ + suspend fun disableChapter(chapterId: String): Result { + return try { + val response = client.delete("$BASE_URL/api/chapters/$chapterId") { + contentType(ContentType.Application.Json) + } + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + suspend fun exportPdf(bookId: String, userId: String = "default_user"): Result { return try { val response = client.post("$BASE_URL/api/books/export-pdf") { diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MyMemoirScreen.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MyMemoirScreen.kt index b658377..2e4133f 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MyMemoirScreen.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MyMemoirScreen.kt @@ -216,6 +216,50 @@ fun MyMemoirScreen( ) } + // 当前章节是否有内容(active) + val hasActiveContent = remember(selectedChapter) { + selectedChapter?.content?.isNotBlank() == true + } + + // 清除回忆确认弹窗状态 + var showClearDialog by remember { mutableStateOf(false) } + + // 清除回忆确认弹窗 + if (showClearDialog) { + AlertDialog( + onDismissRequest = { showClearDialog = false }, + title = { + Text( + text = "清除回忆", + fontWeight = FontWeight.SemiBold + ) + }, + text = { + Text("清除回忆会完全清除当前章节的内容,确定继续吗?") + }, + confirmButton = { + TextButton( + onClick = { + showClearDialog = false + selectedChapter?.let { chapter -> + viewModel.disableChapter(chapter.id) + } + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text("确定清除") + } + }, + dismissButton = { + TextButton(onClick = { showClearDialog = false }) { + Text("取消") + } + } + ) + } + Column( modifier = Modifier .fillMaxSize() @@ -228,26 +272,53 @@ fun MyMemoirScreen( .windowInsetsPadding(WindowInsets.statusBars) ) - // 返回按钮 + // 顶部导航栏:返回按钮 + 清除回忆按钮 Row( modifier = Modifier .fillMaxWidth() - .padding(AppDimensions.cardPadding) - .clickable { viewModel.clearSelection() }, - verticalAlignment = Alignment.CenterVertically + .padding(AppDimensions.cardPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { - Icon( - imageVector = AppIcons.ArrowBack, - contentDescription = "返回", - tint = SlatePurple, - modifier = Modifier.size(AppDimensions.iconSize) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = "返回目录", - fontSize = AppTypography.bodyMedium, - color = SlatePurple - ) + // 返回按钮 + Row( + modifier = Modifier.clickable { viewModel.clearSelection() }, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = AppIcons.ArrowBack, + contentDescription = "返回", + tint = SlatePurple, + modifier = Modifier.size(AppDimensions.iconSize) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "返回目录", + fontSize = AppTypography.bodyMedium, + color = SlatePurple + ) + } + + // 清除回忆按钮 + TextButton( + onClick = { showClearDialog = true }, + enabled = hasActiveContent, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + disabledContentColor = SlatePurple.copy(alpha = 0.3f) + ) + ) { + Icon( + imageVector = AppIcons.Delete, + contentDescription = "清除回忆", + modifier = Modifier.size(AppDimensions.iconSizeSmall) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "清除回忆", + fontSize = AppTypography.captionLarge + ) + } } ChapterReadingView(chapter = chapterContent) diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/MyMemoirViewModel.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/MyMemoirViewModel.kt index 518cd8f..a1023cc 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/MyMemoirViewModel.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/MyMemoirViewModel.kt @@ -171,5 +171,38 @@ class MyMemoirViewModel( fun toggleFullTextReading() { showFullTextReading.value = !showFullTextReading.value } + + /** + * 清除章节(将章节标记为 disabled) + * 成功后刷新章节列表并清除选中状态 + */ + @RequiresApi(Build.VERSION_CODES.O) + fun disableChapter(chapterId: String, onSuccess: () -> Unit = {}, onError: (String) -> Unit = {}) { + viewModelScope.launch { + isLoading.value = true + try { + apiService.disableChapter(chapterId).fold( + onSuccess = { + // 清除选中状态,回到目录 + clearSelection() + // 刷新章节列表 + refreshChapters() + onSuccess() + }, + onFailure = { e -> + val errorMsg = "清除回忆失败: ${e.message}" + error.value = errorMsg + onError(errorMsg) + } + ) + } catch (e: Exception) { + val errorMsg = "清除回忆失败: ${e.message}" + error.value = errorMsg + onError(errorMsg) + } finally { + isLoading.value = false + } + } + } }