diff --git a/DESIGN.md b/DESIGN.md index 986aa0b..9462937 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -817,7 +817,7 @@ Life Post 可配置: - 拥有 `life.posts.update-any` / `life.posts.delete-any` 权限的用户可以管理其他用户的 Life Post。 - 已注册并完成邮箱验证且拥有 `life.posts.create` 或 `life.posts.update` 权限的用户发布或编辑 Life Post 时必须选择 1 个 Life Category。 - 已注册并完成邮箱验证且拥有 `life.comments.create` 权限的用户可以评论 Life Post,并回复顶层评论。 -- 评论作者拥有 `life.comments.delete` 权限时可以删除自己的评论;拥有 `life.comments.delete-any` 权限的用户可以删除其他用户评论;删除评论后正文不再展示,已有回复保留在原位置。 +- 评论作者拥有 `life.comments.delete` 权限时可以删除自己的评论;拥有 `life.comments.delete-any` 权限的用户可以删除其他用户评论;删除后的 Life Comment 不再出现在评论列表、评论预览或评论数量中。 - 已软删除的 Life Post 不出现在信息流、搜索或 Category 筛选结果中,也不能继续编辑、评论或设置 Reaction。 - 已软删除的 Life Post 详情页返回未找到,不公开软删除字段。 - 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。 @@ -839,9 +839,9 @@ Life Post 可配置: - Life Post 和 Life Comment 必须进入 AI 审核;未审核通过的内容不向普通访客公开。 - 作者本人和拥有对应 `*-any` 管理权限的用户可以看到相关内容的审核状态,并可触发重新审核。 - 未审核通过的 Life Post 详情只对作者本人和拥有对应管理权限的用户可见;普通访客访问时返回未找到。 -- Life Post 必须展示审核状态:审核中、未审核、审核失败、审核不通过;审核通过也可作为状态展示。 +- Life Post 必须展示未通过或未完成的审核状态:审核中、未审核、审核失败、审核不通过;审核通过不显示状态标签。 - 新增或更新 Life Post 后先进入不可公开状态,AI 审核通过后才出现在普通公开 Feed。 -- Life Comment 和回复审核通过后才出现在普通公开评论列表、评论数量和评论预览中。 +- Life Comment 和回复审核通过且未删除后才出现在普通公开评论列表、评论数量和评论预览中。 - 审核失败不等于审核通过;失败内容保持不可公开,用户可重新审核。 - `reviewing` 表示审核正在进行中,前端不展示重新审核入口;只有 `unreviewed`、`rejected` 和 `failed` 这类非进行中且未通过状态可触发重新审核。 - Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。 diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 6d3f00d..8a875d7 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -3095,6 +3095,21 @@ function lifeCommentProjection(whereClause: string): string { `; } +function addVisibleLifeCommentCondition(conditions: string[]): void { + conditions.push('lc.deleted_at IS NULL'); + conditions.push(` + ( + lc.parent_comment_id IS NULL + OR EXISTS ( + SELECT 1 + FROM life_post_comments parent_comment + WHERE parent_comment.id = lc.parent_comment_id + AND parent_comment.deleted_at IS NULL + ) + ) + `); +} + function buildLifeCommentTree(rows: LifeCommentRow[]): LifeComment[] { const comments = new Map(); const topLevelComments: LifeComment[] = []; @@ -3137,6 +3152,7 @@ async function lifeCommentCountsForPosts( const params: unknown[] = [postIds]; const conditions = ['lc.post_id = ANY($1::integer[])']; + addVisibleLifeCommentCondition(conditions); addModerationVisibilityCondition(conditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll); const rows = await query<{ postId: number; total: number }>( @@ -3168,6 +3184,7 @@ async function lifeCommentPreviewForPosts( const params: unknown[] = [postIds]; const previewConditions = ['lc.post_id = ANY($1::integer[])', 'lc.parent_comment_id IS NULL']; + addVisibleLifeCommentCondition(previewConditions); addModerationVisibilityCondition(previewConditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll); params.push(lifeCommentPreviewLimit); @@ -3227,6 +3244,7 @@ export async function listLifeComments( const params: unknown[] = [postId]; const topLevelConditions = ['lc.post_id = $1', 'lc.parent_comment_id IS NULL']; + addVisibleLifeCommentCondition(topLevelConditions); addModerationVisibilityCondition(topLevelConditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll); addModerationLanguageCondition(topLevelConditions, params, 'lc', languageCode); @@ -3251,6 +3269,7 @@ export async function listLifeComments( ? await (async () => { const replyParams: unknown[] = [topLevelIds]; const replyConditions = ['lc.parent_comment_id = ANY($1::integer[])']; + addVisibleLifeCommentCondition(replyConditions); addModerationVisibilityCondition(replyConditions, replyParams, 'lc', 'lc.created_by_user_id', userId, canViewAll); addModerationLanguageCondition(replyConditions, replyParams, 'lc', languageCode); return query( @@ -3264,6 +3283,7 @@ export async function listLifeComments( : []; const totalParams: unknown[] = [postId]; const totalConditions = ['lc.post_id = $1']; + addVisibleLifeCommentCondition(totalConditions); addModerationVisibilityCondition(totalConditions, totalParams, 'lc', 'lc.created_by_user_id', userId, canViewAll); addModerationLanguageCondition(totalConditions, totalParams, 'lc', languageCode); const total = await queryOne<{ total: number }>( diff --git a/frontend/src/views/LifeView.vue b/frontend/src/views/LifeView.vue index 4fb23be..0986c1c 100644 --- a/frontend/src/views/LifeView.vue +++ b/frontend/src/views/LifeView.vue @@ -479,7 +479,7 @@ function canManageComment(comment: LifeComment) { } function canSeeCommentModeration(comment: LifeComment) { - return currentUser.value?.id === comment.author?.id || can('life.comments.delete-any'); + return moderationStatusVisible(comment.moderationStatus) && (currentUser.value?.id === comment.author?.id || can('life.comments.delete-any')); } function commentKey(postId: number) { @@ -579,6 +579,10 @@ function moderationTone(status: AiModerationStatus) { return tones[status]; } +function moderationStatusVisible(status: AiModerationStatus) { + return status !== 'approved'; +} + function canRetryModeration(post: LifePost) { return post.moderationStatus !== 'approved' && post.moderationStatus !== 'reviewing' && canManage(post); } @@ -1019,21 +1023,29 @@ async function submitReply(post: LifePost, comment: LifeComment) { } } -function markCommentDeleted(comments: LifeComment[], id: number): boolean { - for (const comment of comments) { +function countCommentTree(comment: LifeComment): number { + return 1 + comment.replies.reduce((total, reply) => total + countCommentTree(reply), 0); +} + +function removeCommentFromTree(comments: LifeComment[], id: number): number { + for (let index = 0; index < comments.length; index += 1) { + const comment = comments[index]; + if (!comment) { + continue; + } if (comment.id === id) { - comment.deleted = true; - comment.body = ''; - comment.author = null; - return true; + const removedCount = countCommentTree(comment); + comments.splice(index, 1); + return removedCount; } - if (markCommentDeleted(comment.replies, id)) { - return true; + const removedCount = removeCommentFromTree(comment.replies, id); + if (removedCount > 0) { + return removedCount; } } - return false; + return 0; } async function deleteComment(post: LifePost, comment: LifeComment) { @@ -1046,7 +1058,16 @@ async function deleteComment(post: LifePost, comment: LifeComment) { try { await api.deleteLifeComment(comment.id); - markCommentDeleted(commentsForPost(post), comment.id); + const removedCount = removeCommentFromTree(commentsForPost(post), comment.id); + if (removedCount > 0) { + const nextTotal = Math.max(0, commentCount(post) - removedCount); + post.commentCount = nextTotal; + updateCommentPage(post, (page) => ({ + ...page, + items: [...page.items], + total: nextTotal + })); + } if (replyTargetId.value === comment.id) { cancelReply(comment.id); } @@ -1451,7 +1472,12 @@ onUnmounted(() => {
- +