feat(moderation): 帖评删除占位感知与硬删清场

管理删帖须填类型与理由并展示专用页;评论软删占位、版主可硬删不占楼。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 07:01:04 +08:00
parent 2b27703e6c
commit bcb4f6a603
15 changed files with 825 additions and 116 deletions

View File

@@ -34,6 +34,7 @@ import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import Avatar from "@/components/Avatar";
import ConfirmDialog from "@/components/ConfirmDialog";
import StaffDeletePostModal from "@/components/StaffDeletePostModal";
const PAGE_SIZE = 15;
@@ -139,6 +140,8 @@ export default function ContentAdmin({
run: () => Promise<void>;
} | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const [staffDeletePost, setStaffDeletePost] = useState<AdminContentPost | null>(null);
const [staffDeleteBusy, setStaffDeleteBusy] = useState(false);
const refreshCounts = useCallback(() => {
apiAdminContentCounts().then(setCounts).catch(() => {});
@@ -515,18 +518,7 @@ export default function ContentAdmin({
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() =>
askConfirm(
"移入回收站",
`确定软删帖子「${p.title}」吗?可稍后在「已删除」中恢复。`,
() =>
apiAdminSoftDeletePost(p.id).then(() => {
toast("已移入回收站", "ok");
reload(postsPage);
}),
true
)
}
onClick={() => setStaffDeletePost(p)}
>
<Trash2 size={14} /> 删除
</button>
@@ -691,6 +683,25 @@ export default function ContentAdmin({
});
}}
/>
<StaffDeletePostModal
open={!!staffDeletePost}
title={staffDeletePost?.title}
busy={staffDeleteBusy}
onClose={() => !staffDeleteBusy && setStaffDeletePost(null)}
onConfirm={(opts) => {
if (!staffDeletePost) return;
setStaffDeleteBusy(true);
apiAdminSoftDeletePost(staffDeletePost.id, opts)
.then(() => {
toast("已移入回收站", "ok");
setStaffDeletePost(null);
reload(postsPage);
})
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
.finally(() => setStaffDeleteBusy(false));
}}
/>
</div>
);
}

View File

@@ -76,7 +76,7 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
const approval = n.type === "approved" || n.type === "rejected";
const iconNode = n.type === "approved"
? <CheckCircle2 size={16} />
: n.type === "rejected"
: n.type === "rejected" || n.type === "deleted"
? <XCircle size={16} />
: n.type === "like"
? <Heart size={16} />
@@ -87,14 +87,14 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
: <MessageCircle size={16} />;
const iconColor = n.type === "approved"
? "var(--ok)"
: n.type === "rejected"
: n.type === "rejected" || n.type === "deleted"
? "var(--danger)"
: n.type === "like"
? "var(--clay)"
: "var(--accent)";
const iconBg = n.type === "approved"
? "var(--ok-soft)"
: n.type === "rejected"
: n.type === "rejected" || n.type === "deleted"
? "var(--danger-soft)"
: n.type === "like"
? "var(--clay-soft)"
@@ -143,7 +143,9 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
? "在消息中提到了你"
: n.type === "pending_review"
? "有新内容待审核"
: "点赞了你的帖子"}
: n.type === "deleted"
? "删除了你的帖子"
: "点赞了你的帖子"}
</span>
</>
)}

View File

@@ -18,12 +18,13 @@ import {
fetchComments,
fetchPosts,
fetchUserProfile,
postDeleteTypeLabel,
type UserProfile,
type PostListItem,
type Board,
} from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import CommentSection from "@/components/CommentSection";
import PostActions from "@/components/PostActions";
import LikeButton from "@/components/LikeButton";
@@ -50,9 +51,16 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
try {
// 带登录态:作者/管理团队看待审帖时标签标题也应是真实标题而非"帖子不存在"
const { post } = await fetchPostDetail(id, authCookieHeader(await cookies()));
if (post.tombstone) {
return {
title: post.title || "帖子已删除",
description: "该帖子已被删除",
robots: { index: false, follow: false },
};
}
const desc = post.content_locked
? post.access_hint || "部分内容需满足条件后可见"
: post.content.slice(0, 160);
: (post.content || "").slice(0, 160);
return {
title: post.title,
description: desc,
@@ -383,10 +391,63 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
notFound();
}
// 评论 / 当前用户 / 作者资料 / 同板块热议并行拉取;侧栏数据失败一律静默降级,不阻塞正文
const [commentsData, me, profile, relatedData] = await Promise.all([
// 软删专用页:列表已不可见,原链接展示删除说明
if (post.tombstone) {
const typeLabel = postDeleteTypeLabel(post.delete_type);
return (
<div className="max-w-lg mx-auto py-16 px-4 text-center">
<p
className="inline-flex items-center rounded-full px-3 py-1 text-[12px] font-semibold mb-4"
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
>
帖子已删除
</p>
<h1 className="text-[20px] sm:text-[22px] font-extrabold mb-3" style={{ color: "var(--ink)" }}>
{post.title || "(无标题)"}
</h1>
{post.staff_deleted ? (
<div
className="panel text-left p-5 sm:p-6 mt-2"
style={{ borderColor: "var(--danger-soft)" }}
>
<p className="text-[14px] font-medium mb-3" style={{ color: "var(--ink)" }}>
此帖已被管理员删除
</p>
{typeLabel && (
<p className="text-[13px] mb-2" style={{ color: "var(--ink-2)" }}>
<span style={{ color: "var(--ink-3)" }}>类型:</span>
{typeLabel}
</p>
)}
{post.delete_reason && (
<p className="text-[13px] leading-relaxed" style={{ color: "var(--ink-2)" }}>
<span style={{ color: "var(--ink-3)" }}>理由:</span>
{post.delete_reason}
</p>
)}
</div>
) : (
<p className="meta text-[14px] mt-2">作者已删除此帖</p>
)}
<div className="mt-8 flex items-center justify-center gap-3">
<Link href="/" className="btn btn-line btn-sm">
返回首页
</Link>
{post.board?.id ? (
<Link href={`/?board=${post.board_id}`} className="btn btn-primary btn-sm">
浏览板块
</Link>
) : null}
</div>
</div>
);
}
// 评论 / 当前用户 / 站点设置 / 作者资料 / 同板块热议并行拉取;侧栏数据失败一律静默降级,不阻塞正文
const [commentsData, me, siteSettings, profile, relatedData] = await Promise.all([
fetchComments(id, commentPage, cookie),
getMeCached(cookie || undefined),
getPublicSettingsCached(),
fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null),
fetchPosts(1, 8, post.board_id, "recommended", "", false, cookie).catch(() => null),
]);
@@ -554,6 +615,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
pinned={post.pinned}
recommended={post.recommended}
status={post.status}
title={post.title}
/>
</div>
@@ -659,6 +721,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
page={commentsData.page}
size={commentsData.size}
user={me.user}
allowComments={siteSettings.allow_comments}
acceptMode={acceptMode}
acceptedCommentId={
post.question?.accepted_comment_id ||