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

@@ -1,12 +1,21 @@
"use client";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Trash2, MessagesSquare, ChevronRight, ChevronDown, BadgeCheck, XCircle } from "lucide-react";
import {
Trash2,
MessagesSquare,
MessageSquareOff,
ChevronRight,
ChevronDown,
BadgeCheck,
XCircle,
} from "lucide-react";
import {
apiCreateComment,
apiDeleteComment,
apiPurgeComment,
apiAcceptBounty,
apiAcceptQuestion,
apiAdminApproveComment,
@@ -16,6 +25,7 @@ import {
} from "@/lib/api";
import { canModerateBoard } from "@/lib/roles";
import { toast } from "@/lib/toast";
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
import Avatar from "./Avatar";
import Pagination from "./Pagination";
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
@@ -37,6 +47,8 @@ interface CommentSectionProps {
/** 悬赏或问答:可采纳时在评论旁显示采纳按钮(二者按帖子类型二选一) */
acceptMode?: "bounty" | "question" | null;
acceptedCommentId?: number;
/** 全站是否开放评论;缺省 true。关闭时隐藏发评/回复入口 */
allowComments?: boolean;
}
// 删除按钮的两段式确认:点一下进入确认态,3 秒未确认自动复原
@@ -59,6 +71,7 @@ export default function CommentSection({
user,
acceptMode = null,
acceptedCommentId = 0,
allowComments: allowCommentsProp = true,
}: CommentSectionProps) {
const router = useRouter();
const [content, setContent] = useState("");
@@ -72,13 +85,33 @@ export default function CommentSection({
const [approvingId, setApprovingId] = useState<number | null>(null);
const [rejectTargetId, setRejectTargetId] = useState<number | null>(null);
const [rejecting, setRejecting] = useState(false);
const [purgeTargetId, setPurgeTargetId] = useState<number | null>(null);
const [purging, setPurging] = useState(false);
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
const [commentsOpen, setCommentsOpen] = useState(allowCommentsProp !== false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
setCommentsOpen(allowCommentsProp !== false);
}, [allowCommentsProp]);
// 管理后台开关变更后即时隐藏发评入口(后端仍硬拒绝)
useEffect(() => {
return realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
if (typeof data?.allow_comments === "boolean") {
setCommentsOpen(data.allow_comments);
if (!data.allow_comments) {
setReplyTarget(null);
setReplyContent("");
}
}
});
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim()) return;
if (!commentsOpen || !content.trim()) return;
setSubmitting(true);
try {
const res = await apiCreateComment(postId, content);
@@ -105,7 +138,7 @@ export default function CommentSection({
const handleSubmitReply = async (e: React.FormEvent) => {
e.preventDefault();
if (!replyTarget || !replyContent.trim()) return;
if (!commentsOpen || !replyTarget || !replyContent.trim()) return;
setSubmitting(true);
try {
const res = await apiCreateComment(postId, replyContent, replyTarget.id);
@@ -204,19 +237,47 @@ export default function CommentSection({
}
};
const handlePurgeConfirm = async () => {
if (purgeTargetId == null) return;
setPurging(true);
try {
const res = await apiPurgeComment(postId, purgeTargetId);
if (res.message) {
toast("已彻底删除", "ok");
setPurgeTargetId(null);
router.refresh();
} else {
toast(res.error || "彻底删除失败");
}
} catch (e) {
toast(e instanceof Error ? e.message : "彻底删除失败");
} finally {
setPurging(false);
}
};
// 引用功能已移除:与"回复"功能重叠,且会割裂讨论上下文(抖音风格无此功能)
const canDelete = (comment: CommentNode) =>
!!user && (user.id === comment.user.id || canModerateBoard(user, boardId));
const canSoftDelete = (comment: CommentNode) =>
!comment.deleted &&
!!user &&
(user.id === comment.user?.id || canModerateBoard(user, boardId));
const canPurge = (comment: CommentNode) =>
!!user && canModerateBoard(user, boardId);
const canApprove = (comment: CommentNode) =>
!!user && comment.status === "pending" && canModerateBoard(user, boardId);
!comment.deleted &&
!!user &&
comment.status === "pending" &&
canModerateBoard(user, boardId);
const canAccept = (comment: CommentNode) =>
!comment.deleted &&
!!acceptMode &&
!!user &&
comment.status === "published" &&
comment.user.id !== authorId;
comment.user?.id !== authorId;
const openReply = (c: CommentNode) => {
setReplyTarget({ id: c.id, nickname: c.user.nickname || c.user.username });
@@ -235,8 +296,8 @@ export default function CommentSection({
const totalPages = Math.max(1, Math.ceil(total / size));
// 内联回复表单(出现在目标评论正下方)
const inlineReplyForm = replyTarget && (
// 内联回复表单(出现在目标评论正下方;全站关评时不渲染)
const inlineReplyForm = commentsOpen && replyTarget && (
<form onSubmit={handleSubmitReply} className="cmt-reply-form mt-3">
<div className="text-[12px] mb-2" style={{ color: "var(--ink-3)" }}>
回复 <span style={{ color: "var(--accent)" }}>@{replyTarget.nickname}</span>
@@ -285,7 +346,104 @@ export default function CommentSection({
const replyCount = countReplies(node);
const isFloor = depth === 0;
const isExpanded = isFloor ? expandedFloors.has(c.id) : true;
const parentAuthor = parent?.user.nickname || parent?.user.username;
const parentAuthor = parent?.user?.nickname || parent?.user?.username;
// 软删占位:保留楼层/回复位,不展示正文;子回复仍可展开
if (c.deleted) {
const tombstoneBody = (
<div
className="rounded-lg px-3 py-2.5 text-[13px]"
style={{
background: "var(--panel-2)",
color: "var(--ink-3)",
border: "1px dashed var(--line)",
}}
>
此评论已删除
</div>
);
const purgeBtn = canPurge(c) && (
<button
type="button"
className="cmt-reply-btn !text-[var(--danger)]"
disabled={purging}
onClick={() => setPurgeTargetId(c.id)}
data-tip="彻底删除,不占位"
>
彻底删除
</button>
);
if (!isFloor) {
return (
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24 opacity-80">
<div className="flex gap-2.5">
<span className="shrink-0 w-7 h-7 rounded-full" style={{ background: "var(--line)" }} aria-hidden />
<div className="flex-1 min-w-0">
{tombstoneBody}
<div className="flex items-center gap-3 mt-1.5">
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
{purgeBtn}
</div>
</div>
</div>
{node.replies && node.replies.length > 0 && (
<div className="cmt-children">
{node.replies.map((r) => renderComment(r, depth + 1, undefined, node))}
</div>
)}
</div>
);
}
return (
<div
key={c.id}
id={`comment-${c.id}`}
className="py-5 border-t first:border-t-0 scroll-mt-24 opacity-90"
style={{ borderColor: "var(--line)" }}
>
<div className="flex gap-3">
<span className="shrink-0 w-[38px] h-[38px] rounded-full mt-0.5" style={{ background: "var(--line)" }} aria-hidden />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 mb-1.5">
<span className="text-sm" style={{ color: "var(--ink-3)" }}>
已删除
</span>
{floor && (
<a href={`#comment-${c.id}`} className="cmt-floor-label" aria-label={`楼层 ${floor}`}>
#{floor}
</a>
)}
</div>
{tombstoneBody}
<div className="flex items-center gap-3 mt-2">
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
{purgeBtn}
</div>
{node.replies && node.replies.length > 0 && (
<div className="mt-3">
<button
type="button"
onClick={() => toggleFloor(c.id)}
className="cmt-fold-btn"
aria-expanded={isExpanded}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span>{isExpanded ? "收起回复" : `展开 ${replyCount} 条回复`}</span>
</button>
{isExpanded && (
<div className="cmt-replies-wrap">
{node.replies.map((r) => renderComment(r, 1, undefined, node))}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
if (!isFloor) {
// 子回复:紧凑行式布局(抖音风格)
@@ -323,7 +481,7 @@ export default function CommentSection({
<div className="flex items-center justify-between mt-1.5">
<div className="flex items-center gap-3">
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
{user && (
{commentsOpen && user && (
<button
onClick={() => openReply(c)}
className={`cmt-reply-btn transition-colors ${
@@ -374,7 +532,7 @@ export default function CommentSection({
</>
)}
</div>
{canDelete(c) && (
{canSoftDelete(c) && (
<button
onClick={() => handleDelete(c.id)}
disabled={deletingId === c.id}
@@ -382,17 +540,27 @@ export default function CommentSection({
confirmingId === c.id ? "!text-[var(--danger)]" : "hover:text-[var(--danger)]"
}`}
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
data-tip={confirmingId === c.id ? "再点一次确认删除" : "删除"}
data-tip={confirmingId === c.id ? "再点一次确认删除(占位)" : "删除(占位)"}
>
{confirmingId === c.id ? (
<span className="text-[11.5px] whitespace-nowrap">
确认删除?{countReplies(c) > 0 && `(含 ${countReplies(c)} 条回复)`}
</span>
<span className="text-[11.5px] whitespace-nowrap">确认删除?</span>
) : (
<Trash2 size={14} />
)}
</button>
)}
{canPurge(c) && (
<button
type="button"
onClick={() => setPurgeTargetId(c.id)}
disabled={purging}
className="meta p-1 hover:text-[var(--danger)] transition-colors"
data-tip="彻底删除,不占位"
aria-label="彻底删除"
>
<XCircle size={14} />
</button>
)}
</div>
{replyTarget?.id === c.id && inlineReplyForm}
</div>
@@ -440,7 +608,7 @@ export default function CommentSection({
<div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-3">
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
{user && (
{commentsOpen && user && (
<button
onClick={() => openReply(c)}
className={`cmt-reply-btn transition-colors ${
@@ -491,7 +659,7 @@ export default function CommentSection({
</>
)}
</div>
{canDelete(c) && (
{canSoftDelete(c) && (
<button
onClick={() => handleDelete(c.id)}
disabled={deletingId === c.id}
@@ -499,17 +667,27 @@ export default function CommentSection({
confirmingId === c.id ? "!text-[var(--danger)]" : "hover:text-[var(--danger)]"
}`}
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
data-tip={confirmingId === c.id ? "再点一次确认删除" : "删除"}
data-tip={confirmingId === c.id ? "再点一次确认删除(占位)" : "删除(占位)"}
>
{confirmingId === c.id ? (
<span className="text-[11.5px] whitespace-nowrap">
确认删除?{countReplies(c) > 0 && `(含 ${countReplies(c)} 条回复)`}
</span>
<span className="text-[11.5px] whitespace-nowrap">确认删除?</span>
) : (
<Trash2 size={14} />
)}
</button>
)}
{canPurge(c) && (
<button
type="button"
onClick={() => setPurgeTargetId(c.id)}
disabled={purging}
className="meta p-1 hover:text-[var(--danger)] transition-colors"
data-tip="彻底删除,不占位"
aria-label="彻底删除"
>
<XCircle size={14} />
</button>
)}
</div>
{replyTarget?.id === c.id && inlineReplyForm}
@@ -555,7 +733,34 @@ export default function CommentSection({
<span className="meta ml-auto text-[11.5px]">共 {totalPages} 页楼层</span>
</div>
{user ? (
{!commentsOpen ? (
<div
className="mb-8 rounded-2xl border px-5 py-6 text-center sm:px-6"
style={{
borderColor: "color-mix(in srgb, var(--ink-3) 35%, var(--line))",
background:
"repeating-linear-gradient(-45deg, transparent, transparent 6px, color-mix(in srgb, var(--ink-3) 8%, transparent) 6px, color-mix(in srgb, var(--ink-3) 8%, transparent) 7px), var(--panel-2)",
}}
role="status"
>
<span
className="mx-auto mb-3 flex h-11 w-11 items-center justify-center rounded-xl"
style={{
background: "color-mix(in srgb, var(--ink-3) 14%, transparent)",
color: "var(--ink-3)",
}}
aria-hidden
>
<MessageSquareOff size={22} />
</span>
<p className="text-[15px] font-semibold" style={{ color: "var(--ink)" }}>
评论功能暂时关闭
</p>
<p className="meta mt-1.5 text-[12.5px] leading-relaxed">
站点当前暂停发表新评论与回复,历史评论仍可浏览。
</p>
</div>
) : user ? (
<form onSubmit={handleSubmit} className="mb-8">
<textarea
ref={textareaRef}
@@ -602,7 +807,9 @@ export default function CommentSection({
<p className="text-sm" style={{ color: "var(--ink-2)" }}>
暂无评论
</p>
<p className="meta text-[12.5px] mt-1">来说两句,抢占沙发</p>
<p className="meta text-[12.5px] mt-1">
{commentsOpen ? "来说两句,抢占沙发" : "评论开放后即可参与讨论"}
</p>
</div>
)}
</div>
@@ -621,6 +828,19 @@ export default function CommentSection({
if (!rejecting) setRejectTargetId(null);
}}
/>
<ConfirmDialog
open={purgeTargetId != null}
title="彻底删除评论"
message="将从数据库永久删除此评论及其全部子回复,不保留占位、不可恢复。刷屏垃圾请用此操作。"
confirmLabel="彻底删除"
danger
busy={purging}
onConfirm={handlePurgeConfirm}
onCancel={() => {
if (!purging) setPurgeTargetId(null);
}}
/>
</section>
);
}