feat(life): hide deleted comments and approved moderation status
Completely remove deleted comments and their replies from lists, previews, and counts. Hide the "approved" moderation status badge to reduce visual clutter.
This commit is contained in:
@@ -817,7 +817,7 @@ Life Post 可配置:
|
|||||||
- 拥有 `life.posts.update-any` / `life.posts.delete-any` 权限的用户可以管理其他用户的 Life Post。
|
- 拥有 `life.posts.update-any` / `life.posts.delete-any` 权限的用户可以管理其他用户的 Life Post。
|
||||||
- 已注册并完成邮箱验证且拥有 `life.posts.create` 或 `life.posts.update` 权限的用户发布或编辑 Life Post 时必须选择 1 个 Life Category。
|
- 已注册并完成邮箱验证且拥有 `life.posts.create` 或 `life.posts.update` 权限的用户发布或编辑 Life Post 时必须选择 1 个 Life Category。
|
||||||
- 已注册并完成邮箱验证且拥有 `life.comments.create` 权限的用户可以评论 Life Post,并回复顶层评论。
|
- 已注册并完成邮箱验证且拥有 `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 不出现在信息流、搜索或 Category 筛选结果中,也不能继续编辑、评论或设置 Reaction。
|
||||||
- 已软删除的 Life Post 详情页返回未找到,不公开软删除字段。
|
- 已软删除的 Life Post 详情页返回未找到,不公开软删除字段。
|
||||||
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
|
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
|
||||||
@@ -839,9 +839,9 @@ Life Post 可配置:
|
|||||||
- Life Post 和 Life Comment 必须进入 AI 审核;未审核通过的内容不向普通访客公开。
|
- Life Post 和 Life Comment 必须进入 AI 审核;未审核通过的内容不向普通访客公开。
|
||||||
- 作者本人和拥有对应 `*-any` 管理权限的用户可以看到相关内容的审核状态,并可触发重新审核。
|
- 作者本人和拥有对应 `*-any` 管理权限的用户可以看到相关内容的审核状态,并可触发重新审核。
|
||||||
- 未审核通过的 Life Post 详情只对作者本人和拥有对应管理权限的用户可见;普通访客访问时返回未找到。
|
- 未审核通过的 Life Post 详情只对作者本人和拥有对应管理权限的用户可见;普通访客访问时返回未找到。
|
||||||
- Life Post 必须展示审核状态:审核中、未审核、审核失败、审核不通过;审核通过也可作为状态展示。
|
- Life Post 必须展示未通过或未完成的审核状态:审核中、未审核、审核失败、审核不通过;审核通过不显示状态标签。
|
||||||
- 新增或更新 Life Post 后先进入不可公开状态,AI 审核通过后才出现在普通公开 Feed。
|
- 新增或更新 Life Post 后先进入不可公开状态,AI 审核通过后才出现在普通公开 Feed。
|
||||||
- Life Comment 和回复审核通过后才出现在普通公开评论列表、评论数量和评论预览中。
|
- Life Comment 和回复审核通过且未删除后才出现在普通公开评论列表、评论数量和评论预览中。
|
||||||
- 审核失败不等于审核通过;失败内容保持不可公开,用户可重新审核。
|
- 审核失败不等于审核通过;失败内容保持不可公开,用户可重新审核。
|
||||||
- `reviewing` 表示审核正在进行中,前端不展示重新审核入口;只有 `unreviewed`、`rejected` 和 `failed` 这类非进行中且未通过状态可触发重新审核。
|
- `reviewing` 表示审核正在进行中,前端不展示重新审核入口;只有 `unreviewed`、`rejected` 和 `failed` 这类非进行中且未通过状态可触发重新审核。
|
||||||
- Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。
|
- Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。
|
||||||
|
|||||||
@@ -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[] {
|
function buildLifeCommentTree(rows: LifeCommentRow[]): LifeComment[] {
|
||||||
const comments = new Map<number, LifeComment>();
|
const comments = new Map<number, LifeComment>();
|
||||||
const topLevelComments: LifeComment[] = [];
|
const topLevelComments: LifeComment[] = [];
|
||||||
@@ -3137,6 +3152,7 @@ async function lifeCommentCountsForPosts(
|
|||||||
|
|
||||||
const params: unknown[] = [postIds];
|
const params: unknown[] = [postIds];
|
||||||
const conditions = ['lc.post_id = ANY($1::integer[])'];
|
const conditions = ['lc.post_id = ANY($1::integer[])'];
|
||||||
|
addVisibleLifeCommentCondition(conditions);
|
||||||
addModerationVisibilityCondition(conditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
addModerationVisibilityCondition(conditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
||||||
|
|
||||||
const rows = await query<{ postId: number; total: number }>(
|
const rows = await query<{ postId: number; total: number }>(
|
||||||
@@ -3168,6 +3184,7 @@ async function lifeCommentPreviewForPosts(
|
|||||||
|
|
||||||
const params: unknown[] = [postIds];
|
const params: unknown[] = [postIds];
|
||||||
const previewConditions = ['lc.post_id = ANY($1::integer[])', 'lc.parent_comment_id IS NULL'];
|
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);
|
addModerationVisibilityCondition(previewConditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
||||||
params.push(lifeCommentPreviewLimit);
|
params.push(lifeCommentPreviewLimit);
|
||||||
|
|
||||||
@@ -3227,6 +3244,7 @@ export async function listLifeComments(
|
|||||||
|
|
||||||
const params: unknown[] = [postId];
|
const params: unknown[] = [postId];
|
||||||
const topLevelConditions = ['lc.post_id = $1', 'lc.parent_comment_id IS NULL'];
|
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);
|
addModerationVisibilityCondition(topLevelConditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
||||||
addModerationLanguageCondition(topLevelConditions, params, 'lc', languageCode);
|
addModerationLanguageCondition(topLevelConditions, params, 'lc', languageCode);
|
||||||
|
|
||||||
@@ -3251,6 +3269,7 @@ export async function listLifeComments(
|
|||||||
? await (async () => {
|
? await (async () => {
|
||||||
const replyParams: unknown[] = [topLevelIds];
|
const replyParams: unknown[] = [topLevelIds];
|
||||||
const replyConditions = ['lc.parent_comment_id = ANY($1::integer[])'];
|
const replyConditions = ['lc.parent_comment_id = ANY($1::integer[])'];
|
||||||
|
addVisibleLifeCommentCondition(replyConditions);
|
||||||
addModerationVisibilityCondition(replyConditions, replyParams, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
addModerationVisibilityCondition(replyConditions, replyParams, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
||||||
addModerationLanguageCondition(replyConditions, replyParams, 'lc', languageCode);
|
addModerationLanguageCondition(replyConditions, replyParams, 'lc', languageCode);
|
||||||
return query<LifeCommentRow>(
|
return query<LifeCommentRow>(
|
||||||
@@ -3264,6 +3283,7 @@ export async function listLifeComments(
|
|||||||
: [];
|
: [];
|
||||||
const totalParams: unknown[] = [postId];
|
const totalParams: unknown[] = [postId];
|
||||||
const totalConditions = ['lc.post_id = $1'];
|
const totalConditions = ['lc.post_id = $1'];
|
||||||
|
addVisibleLifeCommentCondition(totalConditions);
|
||||||
addModerationVisibilityCondition(totalConditions, totalParams, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
addModerationVisibilityCondition(totalConditions, totalParams, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
||||||
addModerationLanguageCondition(totalConditions, totalParams, 'lc', languageCode);
|
addModerationLanguageCondition(totalConditions, totalParams, 'lc', languageCode);
|
||||||
const total = await queryOne<{ total: number }>(
|
const total = await queryOne<{ total: number }>(
|
||||||
|
|||||||
@@ -479,7 +479,7 @@ function canManageComment(comment: LifeComment) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canSeeCommentModeration(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) {
|
function commentKey(postId: number) {
|
||||||
@@ -579,6 +579,10 @@ function moderationTone(status: AiModerationStatus) {
|
|||||||
return tones[status];
|
return tones[status];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moderationStatusVisible(status: AiModerationStatus) {
|
||||||
|
return status !== 'approved';
|
||||||
|
}
|
||||||
|
|
||||||
function canRetryModeration(post: LifePost) {
|
function canRetryModeration(post: LifePost) {
|
||||||
return post.moderationStatus !== 'approved' && post.moderationStatus !== 'reviewing' && canManage(post);
|
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 {
|
function countCommentTree(comment: LifeComment): number {
|
||||||
for (const comment of comments) {
|
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) {
|
if (comment.id === id) {
|
||||||
comment.deleted = true;
|
const removedCount = countCommentTree(comment);
|
||||||
comment.body = '';
|
comments.splice(index, 1);
|
||||||
comment.author = null;
|
return removedCount;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (markCommentDeleted(comment.replies, id)) {
|
const removedCount = removeCommentFromTree(comment.replies, id);
|
||||||
return true;
|
if (removedCount > 0) {
|
||||||
|
return removedCount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteComment(post: LifePost, comment: LifeComment) {
|
async function deleteComment(post: LifePost, comment: LifeComment) {
|
||||||
@@ -1046,7 +1058,16 @@ async function deleteComment(post: LifePost, comment: LifeComment) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await api.deleteLifeComment(comment.id);
|
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) {
|
if (replyTargetId.value === comment.id) {
|
||||||
cancelReply(comment.id);
|
cancelReply(comment.id);
|
||||||
}
|
}
|
||||||
@@ -1451,7 +1472,12 @@ onUnmounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="life-post__review-actions">
|
<div class="life-post__review-actions">
|
||||||
<StatusBadge :label="moderationLabel(post.moderationStatus)" :tone="moderationTone(post.moderationStatus)" compact />
|
<StatusBadge
|
||||||
|
v-if="moderationStatusVisible(post.moderationStatus)"
|
||||||
|
:label="moderationLabel(post.moderationStatus)"
|
||||||
|
:tone="moderationTone(post.moderationStatus)"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="canRetryModeration(post)"
|
v-if="canRetryModeration(post)"
|
||||||
class="life-icon-button life-review-button"
|
class="life-icon-button life-review-button"
|
||||||
|
|||||||
Reference in New Issue
Block a user