feat: 添加章节管理功能以支持清除回忆
- 在数据库模型中新增 is_active 字段,用于标记章节是否启用。 - 添加数据库迁移脚本以更新现有章节,确保默认值为 TRUE。 - 更新章节相关的 API 以仅返回 active 章节,并实现清除章节的功能。 - 在 Android 客户端中实现清除章节的确认弹窗和相应的 API 调用,提升用户体验。
This commit is contained in:
5
.github/workflows/docker-build-deploy.yml
vendored
5
.github/workflows/docker-build-deploy.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
8
api/migrations/add_chapter_is_active.sql
Normal file
8
api/migrations/add_chapter_is_active.sql
Normal 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;
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user