feat: 添加章节管理功能以支持清除回忆

- 在数据库模型中新增 is_active 字段,用于标记章节是否启用。
- 添加数据库迁移脚本以更新现有章节,确保默认值为 TRUE。
- 更新章节相关的 API 以仅返回 active 章节,并实现清除章节的功能。
- 在 Android 客户端中实现清除章节的确认弹窗和相应的 API 调用,提升用户体验。
This commit is contained in:
penghanyuan
2026-02-14 10:57:51 +01:00
parent df91719a2f
commit 39736a2ae2
8 changed files with 180 additions and 20 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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()

View File

@@ -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,

View File

@@ -165,6 +165,20 @@ class ApiService(
}
}
/**
* 清除章节(将章节标记为 disabled
*/
suspend fun disableChapter(chapterId: String): Result<Unit> {
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<ByteArray> {
return try {
val response = client.post("$BASE_URL/api/books/export-pdf") {

View File

@@ -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)

View File

@@ -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
}
}
}
}