feat(comments): 软删占位、理由与恢复,管理删仅对访客打码
自删/管理删差异文案与理由可见性,评论者与管理员免马赛克,并支持恢复与处理人链接。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,15 +11,18 @@ import {
|
||||
ChevronDown,
|
||||
BadgeCheck,
|
||||
XCircle,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
apiCreateComment,
|
||||
apiDeleteComment,
|
||||
apiRestoreComment,
|
||||
apiPurgeComment,
|
||||
apiAcceptBounty,
|
||||
apiAcceptQuestion,
|
||||
apiAdminApproveComment,
|
||||
apiAdminRejectComment,
|
||||
postDeleteTypeLabel,
|
||||
type CommentNode,
|
||||
type User,
|
||||
} from "@/lib/api";
|
||||
@@ -30,6 +33,7 @@ import Avatar from "./Avatar";
|
||||
import Pagination from "./Pagination";
|
||||
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import DeleteCommentModal, { type DeleteCommentPayload } from "@/components/DeleteCommentModal";
|
||||
|
||||
interface CommentSectionProps {
|
||||
postId: string;
|
||||
@@ -51,14 +55,48 @@ interface CommentSectionProps {
|
||||
allowComments?: boolean;
|
||||
}
|
||||
|
||||
// 删除按钮的两段式确认:点一下进入确认态,3 秒未确认自动复原
|
||||
const CONFIRM_RESET_MS = 3000;
|
||||
|
||||
// 递归统计子树回复数(删除级联提示用)
|
||||
// 递归统计子树回复数(彻底删除级联提示用)
|
||||
function countReplies(node: CommentNode): number {
|
||||
return node.replies?.reduce((acc, r) => acc + 1 + countReplies(r), 0) ?? 0;
|
||||
}
|
||||
|
||||
function findComment(nodes: CommentNode[], id: number): CommentNode | null {
|
||||
for (const n of nodes) {
|
||||
if (n.id === id) return n;
|
||||
if (n.replies?.length) {
|
||||
const hit = findComment(n.replies, id);
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 马赛克头像:保留轮廓,不可辨认 */
|
||||
function MosaicAvatar({
|
||||
name,
|
||||
src,
|
||||
size,
|
||||
}: {
|
||||
name: string;
|
||||
src?: string;
|
||||
size: number;
|
||||
}) {
|
||||
return (
|
||||
<span className="cmt-mosaic-avatar" style={{ width: size, height: size }} aria-hidden>
|
||||
<Avatar name={name || "用户"} src={src || undefined} size={size} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 马赛克昵称 */
|
||||
function MosaicName({ text, className = "" }: { text: string; className?: string }) {
|
||||
return (
|
||||
<span className={`cmt-mosaic-name ${className}`} style={{ color: "var(--ink-2)" }} aria-hidden>
|
||||
{text || "用户"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CommentSection({
|
||||
postId,
|
||||
boardId,
|
||||
@@ -79,14 +117,16 @@ export default function CommentSection({
|
||||
const [replyTarget, setReplyTarget] = useState<{ id: number; nickname: string } | null>(null);
|
||||
const [replyContent, setReplyContent] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
|
||||
const [acceptingId, setAcceptingId] = useState<number | null>(null);
|
||||
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);
|
||||
const [restoreTargetId, setRestoreTargetId] = useState<number | null>(null);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
|
||||
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
|
||||
const [commentsOpen, setCommentsOpen] = useState(allowCommentsProp !== false);
|
||||
@@ -164,19 +204,20 @@ export default function CommentSection({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (commentId: number) => {
|
||||
if (confirmingId !== commentId) {
|
||||
setConfirmingId(commentId);
|
||||
setTimeout(() => {
|
||||
setConfirmingId((cur) => (cur === commentId ? null : cur));
|
||||
}, CONFIRM_RESET_MS);
|
||||
return;
|
||||
}
|
||||
setConfirmingId(null);
|
||||
setDeletingId(commentId);
|
||||
const deleteTarget = deleteTargetId != null ? findComment(comments, deleteTargetId) : null;
|
||||
const deleteMode: "self" | "staff" =
|
||||
deleteTarget && user && user.id === deleteTarget.user_id ? "self" : "staff";
|
||||
const purgeTarget = purgeTargetId != null ? findComment(comments, purgeTargetId) : null;
|
||||
const purgeReplyCount = purgeTarget ? countReplies(purgeTarget) : 0;
|
||||
|
||||
const handleDeleteConfirm = async (opts: DeleteCommentPayload) => {
|
||||
if (deleteTargetId == null) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await apiDeleteComment(postId, commentId);
|
||||
const res = await apiDeleteComment(postId, deleteTargetId, opts);
|
||||
if (res.message) {
|
||||
toast(deleteMode === "self" ? "已删除评论" : "已删除该评论", "ok");
|
||||
setDeleteTargetId(null);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast(res.error || "删除失败");
|
||||
@@ -184,7 +225,7 @@ export default function CommentSection({
|
||||
} catch {
|
||||
toast("网络错误,删除失败");
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -256,6 +297,25 @@ export default function CommentSection({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreConfirm = async () => {
|
||||
if (restoreTargetId == null) return;
|
||||
setRestoring(true);
|
||||
try {
|
||||
const res = await apiRestoreComment(postId, restoreTargetId);
|
||||
if (res.message) {
|
||||
toast("评论已恢复", "ok");
|
||||
setRestoreTargetId(null);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast(res.error || "恢复失败");
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "恢复失败");
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 引用功能已移除:与"回复"功能重叠,且会割裂讨论上下文(抖音风格无此功能)
|
||||
|
||||
const canSoftDelete = (comment: CommentNode) =>
|
||||
@@ -266,6 +326,13 @@ export default function CommentSection({
|
||||
const canPurge = (comment: CommentNode) =>
|
||||
!!user && canModerateBoard(user, boardId);
|
||||
|
||||
/** 作者可恢复自删;版主可恢复本板任意软删(含管理删除) */
|
||||
const canRestore = (comment: CommentNode) => {
|
||||
if (!comment.deleted || !user) return false;
|
||||
if (canModerateBoard(user, boardId)) return true;
|
||||
return user.id === comment.user_id && !comment.staff_deleted;
|
||||
};
|
||||
|
||||
const canApprove = (comment: CommentNode) =>
|
||||
!comment.deleted &&
|
||||
!!user &&
|
||||
@@ -348,42 +415,135 @@ export default function CommentSection({
|
||||
const isExpanded = isFloor ? expandedFloors.has(c.id) : true;
|
||||
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)",
|
||||
}}
|
||||
const displayName = c.user?.nickname || c.user?.username || "用户";
|
||||
const typeLabel = postDeleteTypeLabel(c.delete_type);
|
||||
const isMod = !!user && canModerateBoard(user, boardId);
|
||||
const isCommenter = !!user && user.id === c.user_id;
|
||||
// 管理删:仅普通访客打码;管理员与评论者本人看真实身份
|
||||
const showMosaic = !!c.staff_deleted && !isMod && !isCommenter;
|
||||
const selfReasonForStaff = !c.staff_deleted && !!c.delete_reason && isMod;
|
||||
const staffPublicMeta = c.staff_deleted && (!!typeLabel || !!c.delete_reason);
|
||||
const deleter = c.deleted_by_user;
|
||||
const deleterName = deleter
|
||||
? deleter.nickname || deleter.username || "管理员"
|
||||
: "";
|
||||
|
||||
const identity = showMosaic ? (
|
||||
<MosaicAvatar name={displayName} src={c.user?.avatar} size={isFloor ? 38 : 28} />
|
||||
) : (
|
||||
<Link
|
||||
href={`/u/${c.user_id}`}
|
||||
className={isFloor ? "shrink-0 mt-0.5" : "shrink-0"}
|
||||
aria-label={displayName}
|
||||
>
|
||||
此评论已删除
|
||||
<Avatar name={displayName} src={c.user?.avatar || undefined} size={isFloor ? 38 : 28} />
|
||||
</Link>
|
||||
);
|
||||
|
||||
const nameRow = showMosaic ? (
|
||||
<MosaicName text={displayName} className={isFloor ? "text-sm" : "cmt-reply-name"} />
|
||||
) : (
|
||||
<Link href={`/u/${c.user_id}`} className="group">
|
||||
<span
|
||||
className={`truncate group-hover:text-[var(--accent)] transition-colors ${
|
||||
isFloor ? "text-sm" : "cmt-reply-name"
|
||||
}`}
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
const tombstoneBody = (
|
||||
<div className="cmt-tombstone">
|
||||
<p className="cmt-tombstone-title">
|
||||
{c.staff_deleted ? "管理员已删除此评论" : "评论者已删除此评论"}
|
||||
</p>
|
||||
{staffPublicMeta ? (
|
||||
<p className="cmt-tombstone-reason">
|
||||
{typeLabel ? (
|
||||
<>
|
||||
<span style={{ color: "var(--ink-2)", fontWeight: 600 }}>{typeLabel}</span>
|
||||
{c.delete_reason ? " · " : null}
|
||||
</>
|
||||
) : null}
|
||||
{c.delete_reason || null}
|
||||
</p>
|
||||
) : null}
|
||||
{c.staff_deleted && deleter ? (
|
||||
<div className="cmt-tombstone-mod">
|
||||
<span>处理人</span>
|
||||
<Link href={`/u/${deleter.id}`} aria-label={`查看管理员 ${deleterName}`}>
|
||||
<Avatar name={deleterName} src={deleter.avatar || undefined} size={20} />
|
||||
{deleterName}
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
{selfReasonForStaff ? (
|
||||
<div className="cmt-tombstone-audit">
|
||||
<span className="cmt-tombstone-audit-label">自删理由 · 仅管理可见</span>
|
||||
<p className="cmt-tombstone-reason !mt-1">{c.delete_reason}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{isMod && c.content ? (
|
||||
<div className="cmt-tombstone-orig">
|
||||
<span className="cmt-tombstone-audit-label">原文 · 仅管理可见</span>
|
||||
<div className="mt-1.5">
|
||||
<MarkdownBodyClient content={c.content} className="prose-content prose-comment" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{c.staff_deleted ? (
|
||||
<span className="cmt-tombstone-tag">管理删除</span>
|
||||
) : (
|
||||
<span className="cmt-tombstone-tag cmt-tombstone-tag-self">评论者删除</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const purgeBtn = canPurge(c) && (
|
||||
<button
|
||||
type="button"
|
||||
className="cmt-reply-btn !text-[var(--danger)]"
|
||||
disabled={purging}
|
||||
className="cmt-mod-btn cmt-mod-btn-danger"
|
||||
disabled={purging || restoring}
|
||||
onClick={() => setPurgeTargetId(c.id)}
|
||||
data-tip="彻底删除,不占位"
|
||||
>
|
||||
彻底删除
|
||||
清除
|
||||
</button>
|
||||
);
|
||||
const restoreBtn = canRestore(c) && (
|
||||
<button
|
||||
type="button"
|
||||
className="cmt-mod-btn"
|
||||
style={{ color: "var(--accent)" }}
|
||||
disabled={restoring || purging}
|
||||
onClick={() => setRestoreTargetId(c.id)}
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
恢复
|
||||
</button>
|
||||
);
|
||||
const tombstoneActions =
|
||||
restoreBtn || purgeBtn ? (
|
||||
<div className="cmt-mod-actions">
|
||||
{restoreBtn}
|
||||
{purgeBtn}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (!isFloor) {
|
||||
return (
|
||||
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24 opacity-80">
|
||||
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24">
|
||||
<div className="flex gap-2.5">
|
||||
<span className="shrink-0 w-7 h-7 rounded-full" style={{ background: "var(--line)" }} aria-hidden />
|
||||
{identity}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="mb-1">{nameRow}</div>
|
||||
{tombstoneBody}
|
||||
<div className="flex items-center gap-3 mt-1.5">
|
||||
<div className="flex items-center justify-between gap-3 mt-1.5 flex-wrap">
|
||||
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
|
||||
{purgeBtn}
|
||||
{tombstoneActions}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -400,16 +560,14 @@ export default function CommentSection({
|
||||
<div
|
||||
key={c.id}
|
||||
id={`comment-${c.id}`}
|
||||
className="py-5 border-t first:border-t-0 scroll-mt-24 opacity-90"
|
||||
className="py-5 border-t first:border-t-0 scroll-mt-24"
|
||||
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 />
|
||||
{identity}
|
||||
<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>
|
||||
{nameRow}
|
||||
{floor && (
|
||||
<a href={`#comment-${c.id}`} className="cmt-floor-label" aria-label={`楼层 ${floor}`}>
|
||||
#{floor}
|
||||
@@ -417,9 +575,9 @@ export default function CommentSection({
|
||||
)}
|
||||
</div>
|
||||
{tombstoneBody}
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<div className="flex items-center justify-between gap-3 mt-2 flex-wrap">
|
||||
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
|
||||
{purgeBtn}
|
||||
{tombstoneActions}
|
||||
</div>
|
||||
{node.replies && node.replies.length > 0 && (
|
||||
<div className="mt-3">
|
||||
@@ -532,34 +690,32 @@ export default function CommentSection({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{canSoftDelete(c) && (
|
||||
<button
|
||||
onClick={() => handleDelete(c.id)}
|
||||
disabled={deletingId === c.id}
|
||||
className={`meta transition-colors disabled:opacity-50 p-1 ${
|
||||
confirmingId === c.id ? "!text-[var(--danger)]" : "hover:text-[var(--danger)]"
|
||||
}`}
|
||||
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
|
||||
data-tip={confirmingId === c.id ? "再点一次确认删除(占位)" : "删除(占位)"}
|
||||
>
|
||||
{confirmingId === c.id ? (
|
||||
<span className="text-[11.5px] whitespace-nowrap">确认删除?</span>
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
{(canSoftDelete(c) || (canPurge(c) && !c.deleted)) && (
|
||||
<div className="cmt-mod-actions" role="group" aria-label="评论管理">
|
||||
{canSoftDelete(c) && (
|
||||
<button
|
||||
type="button"
|
||||
className="cmt-mod-btn"
|
||||
onClick={() => setDeleteTargetId(c.id)}
|
||||
disabled={deleting}
|
||||
aria-label="删除评论"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
{canPurge(c) && (
|
||||
<button
|
||||
type="button"
|
||||
className="cmt-mod-btn cmt-mod-btn-danger"
|
||||
onClick={() => setPurgeTargetId(c.id)}
|
||||
disabled={purging}
|
||||
aria-label="彻底删除"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{replyTarget?.id === c.id && inlineReplyForm}
|
||||
@@ -659,34 +815,32 @@ export default function CommentSection({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{canSoftDelete(c) && (
|
||||
<button
|
||||
onClick={() => handleDelete(c.id)}
|
||||
disabled={deletingId === c.id}
|
||||
className={`meta transition-colors disabled:opacity-50 p-1 ${
|
||||
confirmingId === c.id ? "!text-[var(--danger)]" : "hover:text-[var(--danger)]"
|
||||
}`}
|
||||
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
|
||||
data-tip={confirmingId === c.id ? "再点一次确认删除(占位)" : "删除(占位)"}
|
||||
>
|
||||
{confirmingId === c.id ? (
|
||||
<span className="text-[11.5px] whitespace-nowrap">确认删除?</span>
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
{(canSoftDelete(c) || canPurge(c)) && (
|
||||
<div className="cmt-mod-actions" role="group" aria-label="评论管理">
|
||||
{canSoftDelete(c) && (
|
||||
<button
|
||||
type="button"
|
||||
className="cmt-mod-btn"
|
||||
onClick={() => setDeleteTargetId(c.id)}
|
||||
disabled={deleting}
|
||||
aria-label="删除评论"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
{canPurge(c) && (
|
||||
<button
|
||||
type="button"
|
||||
className="cmt-mod-btn cmt-mod-btn-danger"
|
||||
onClick={() => setPurgeTargetId(c.id)}
|
||||
disabled={purging}
|
||||
aria-label="彻底删除"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{replyTarget?.id === c.id && inlineReplyForm}
|
||||
@@ -829,11 +983,37 @@ export default function CommentSection({
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteCommentModal
|
||||
open={deleteTargetId != null}
|
||||
mode={deleteMode}
|
||||
busy={deleting}
|
||||
onClose={() => {
|
||||
if (!deleting) setDeleteTargetId(null);
|
||||
}}
|
||||
onConfirm={(opts) => void handleDeleteConfirm(opts)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={restoreTargetId != null}
|
||||
title="恢复评论"
|
||||
message="确定恢复这条评论?正文与评论者信息将重新公开显示。"
|
||||
confirmLabel="确认恢复"
|
||||
busy={restoring}
|
||||
onConfirm={handleRestoreConfirm}
|
||||
onCancel={() => {
|
||||
if (!restoring) setRestoreTargetId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={purgeTargetId != null}
|
||||
title="彻底删除评论"
|
||||
message="将从数据库永久删除此评论及其全部子回复,不保留占位、不可恢复。刷屏垃圾请用此操作。"
|
||||
confirmLabel="彻底删除"
|
||||
message={
|
||||
purgeReplyCount > 0
|
||||
? `将永久清除此评论及其 ${purgeReplyCount} 条回复,不保留占位、不可恢复。刷屏/垃圾内容请用此操作;一般违规请用「删除」保留占位。`
|
||||
: "将永久清除此评论,不保留占位、不可恢复。刷屏/垃圾内容请用此操作;一般违规请用「删除」保留占位。"
|
||||
}
|
||||
confirmLabel="永久清除"
|
||||
danger
|
||||
busy={purging}
|
||||
onConfirm={handlePurgeConfirm}
|
||||
|
||||
Reference in New Issue
Block a user