Files
jiang13-bbs/frontend/components/CommentSection.tsx
freefire d93635c961 feat: 全站评论开关,并统一后台表单控件
关闭评论后前台隐藏评论能力;外观/设置/公告改用 AdminField 与 Stepper。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 01:22:00 +08:00

1140 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
Trash2,
MessagesSquare,
ChevronRight,
ChevronDown,
BadgeCheck,
XCircle,
RotateCcw,
Pencil,
History,
} from "lucide-react";
import {
apiCreateComment,
apiDeleteComment,
apiRestoreComment,
apiPurgeComment,
apiUpdateComment,
apiAcceptBounty,
apiAcceptQuestion,
apiAdminApproveComment,
apiAdminRejectComment,
postDeleteTypeLabel,
type CommentNode,
type User,
} from "@/lib/api";
import { canModerateBoard } from "@/lib/roles";
import { toast } from "@/lib/toast";
import { useAllowComments } from "@/components/CommentsPolicyProvider";
import CommentsUnsupported from "@/components/CommentsUnsupported";
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";
import CommentEditHistoryModal from "@/components/CommentEditHistoryModal";
interface CommentSectionProps {
postId: string;
/** 帖子所属板块:板块管理员仅可管理授权板块的评论 */
boardId: number;
/** 楼主 ID:不可采纳楼主自己的评论 */
authorId: number;
comments: CommentNode[];
total: number; // 楼层数(主评论数)→ 分页与楼层号计算
totalComments: number; // 全部评论数(含回复)→ 头部徽标
page: number;
size: number;
// SSR 直出的登录用户(layout 已取,避免客户端再请求一次 /api/me)
user: User | null;
/** 悬赏或问答:可采纳时在评论旁显示采纳按钮(二者按帖子类型二选一) */
acceptMode?: "bounty" | "question" | null;
acceptedCommentId?: number;
/** 抽奖已开奖:中奖楼层评论 ID(每名中奖者仅其最早主评一条) */
winnerCommentIds?: number[];
}
// 递归统计子树回复数(彻底删除级联提示用)
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,
authorId,
comments,
total,
totalComments,
page,
size,
user,
acceptMode = null,
acceptedCommentId = 0,
winnerCommentIds = [],
}: CommentSectionProps) {
const winnerCommentIdSet = new Set(winnerCommentIds.filter((id) => id > 0));
const router = useRouter();
const commentsOpen = useAllowComments();
const [content, setContent] = useState("");
// 内联回复目标:在该评论下方就地发子评论(同一时间只有一个回复框)
const [replyTarget, setReplyTarget] = useState<{ id: number; nickname: string } | null>(null);
const [replyContent, setReplyContent] = useState("");
const [submitting, setSubmitting] = useState(false);
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);
const [editingId, setEditingId] = useState<number | null>(null);
const [editContent, setEditContent] = useState("");
const [savingEdit, setSavingEdit] = useState(false);
const [historyCommentId, setHistoryCommentId] = useState<number | null>(null);
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (!commentsOpen) {
setReplyTarget(null);
setReplyContent("");
}
}, [commentsOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!commentsOpen || !content.trim()) return;
setSubmitting(true);
try {
const res = await apiCreateComment(postId, content);
if (res.comment) {
setContent("");
if (res.comment.status === "pending") {
toast("已提交,等待审核通过后公开");
router.refresh();
} else {
// 新楼层在最后一页:刷新后由服务端直出最新分页
router.refresh();
}
} else if (res.error === "未登录") {
router.push(`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`);
} else {
toast(res.error || "评论失败");
}
} catch {
toast("网络错误,评论失败");
} finally {
setSubmitting(false);
}
};
const handleSubmitReply = async (e: React.FormEvent) => {
e.preventDefault();
if (!commentsOpen || !replyTarget || !replyContent.trim()) return;
setSubmitting(true);
try {
const res = await apiCreateComment(postId, replyContent, replyTarget.id);
if (res.comment) {
setReplyTarget(null);
setReplyContent("");
if (res.comment.status === "pending") {
toast("已提交,等待审核通过后公开");
router.refresh();
} else {
// 刷新后由 SSR 直出新树(子评论全量挂在本楼)
router.refresh();
}
} else if (res.error === "未登录") {
router.push(`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`);
} else {
toast(res.error || "回复失败");
}
} catch {
toast("网络错误,回复失败");
} finally {
setSubmitting(false);
}
};
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, deleteTargetId, opts);
if (res.message) {
toast(deleteMode === "self" ? "已删除评论" : "已删除该评论", "ok");
setDeleteTargetId(null);
router.refresh();
} else {
toast(res.error || "删除失败");
}
} catch {
toast("网络错误,删除失败");
} finally {
setDeleting(false);
}
};
const handleAccept = async (commentId: number, commentAuthorId: number) => {
if (!acceptMode || !user || commentAuthorId === authorId) return;
setAcceptingId(commentId);
try {
const res =
acceptMode === "question"
? await apiAcceptQuestion(postId, commentId)
: await apiAcceptBounty(postId, commentId);
if (res.post) {
toast(acceptMode === "question" ? "已采纳为最佳答案" : "已采纳并发放悬赏", "ok");
router.refresh();
} else {
toast(res.error || "采纳失败");
}
} catch {
toast("网络错误,采纳失败");
} finally {
setAcceptingId(null);
}
};
const handleApprove = async (commentId: number) => {
setApprovingId(commentId);
try {
await apiAdminApproveComment(commentId);
toast("已通过评审", "ok");
router.refresh();
} catch (e) {
toast(e instanceof Error ? e.message : "通过评审失败");
} finally {
setApprovingId(null);
}
};
const handleRejectConfirm = async () => {
if (rejectTargetId == null) return;
setRejecting(true);
try {
await apiAdminRejectComment(rejectTargetId);
toast("已拒绝该评论", "ok");
setRejectTargetId(null);
router.refresh();
} catch (e) {
toast(e instanceof Error ? e.message : "拒绝失败");
} finally {
setRejecting(false);
}
};
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 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) =>
!comment.deleted &&
!!user &&
(user.id === comment.user?.id || canModerateBoard(user, boardId));
const canEdit = (comment: CommentNode) => canSoftDelete(comment);
const canViewHistory = !!user && canModerateBoard(user, boardId);
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 &&
comment.status === "pending" &&
canModerateBoard(user, boardId);
const canAccept = (comment: CommentNode) =>
!comment.deleted &&
!!acceptMode &&
!!user &&
comment.status === "published" &&
comment.user?.id !== authorId;
const openReply = (c: CommentNode) => {
setEditingId(null);
setReplyTarget({ id: c.id, nickname: c.user.nickname || c.user.username });
setReplyContent("");
};
const startEdit = (c: CommentNode) => {
setReplyTarget(null);
setEditingId(c.id);
setEditContent(c.content);
};
const cancelEdit = () => {
setEditingId(null);
setEditContent("");
};
const handleSaveEdit = async () => {
if (editingId == null || !editContent.trim()) return;
setSavingEdit(true);
try {
const res = await apiUpdateComment(postId, editingId, editContent);
if (res.error) {
toast(res.error);
return;
}
toast("评论已更新", "ok");
cancelEdit();
router.refresh();
} catch (e) {
toast(e instanceof Error ? e.message : "更新失败");
} finally {
setSavingEdit(false);
}
};
const renderContentOrEditor = (c: CommentNode) => {
if (editingId === c.id) {
return (
<div className="mt-1">
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
rows={4}
className="input w-full text-sm"
disabled={savingEdit}
autoFocus
/>
<div className="flex gap-2 mt-2">
<button
type="button"
className="btn btn-primary btn-sm"
disabled={savingEdit || !editContent.trim()}
onClick={() => void handleSaveEdit()}
>
{savingEdit ? "保存中…" : "保存"}
</button>
<button
type="button"
className="btn btn-line btn-sm"
disabled={savingEdit}
onClick={cancelEdit}
>
取消
</button>
</div>
</div>
);
}
return <MarkdownBodyClient content={c.content} className="prose-content prose-comment" />;
};
const timeMeta = (c: CommentNode) => (
<span className="meta text-[11.5px] inline-flex items-center gap-1.5 flex-wrap">
<span>{new Date(c.created_at).toLocaleString("zh-CN")}</span>
{c.edited && <span title="此评论曾被编辑">已编辑</span>}
</span>
);
// 切换某楼层子回复的展开/折叠
const toggleFloor = (floorId: number) => {
setExpandedFloors((prev) => {
const next = new Set(prev);
if (next.has(floorId)) next.delete(floorId);
else next.add(floorId);
return next;
});
};
const totalPages = Math.max(1, Math.ceil(total / size));
// 内联回复表单(出现在目标评论正下方;全站关评时不渲染)
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>
</div>
<textarea
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
}}
rows={2}
autoFocus
className="field"
aria-label={`回复 ${replyTarget.nickname}`}
placeholder="友善回复..."
/>
<div className="flex items-center justify-between gap-3 mt-2">
<span className="meta text-[11.5px]">支持 Markdown · Ctrl + Enter 发送</span>
<div className="flex gap-2">
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => {
setReplyTarget(null);
setReplyContent("");
}}
>
取消
</button>
<button type="submit" disabled={submitting || !replyContent.trim()} className="btn btn-primary btn-sm">
{submitting ? "发送中..." : "回复"}
</button>
</div>
</div>
</form>
);
// 单条评论渲染(递归):depth 0 = 楼层主评论,>0 = 子回复
// 主评论下方子回复默认折叠,点击"展开 X 条回复"展开
// depth=1(回复楼层)不显示指向;depth≥2(回复子评论)显示"回复者 ▶ 被回复者"
const renderComment = (node: CommentNode, depth: number, floor?: number, parent?: CommentNode) => {
const c = node;
const replyCount = countReplies(node);
const isFloor = depth === 0;
const isExpanded = isFloor ? expandedFloors.has(c.id) : true;
const parentAuthor = parent?.user?.nickname || parent?.user?.username;
// 软删占位:自删不打码;管理删打码 + 管理员主页链接;版主可见原文
if (c.deleted) {
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-mod-btn cmt-mod-btn-danger"
disabled={purging || restoring}
onClick={() => setPurgeTargetId(c.id)}
>
清除
</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 historyBtn = canViewHistory && (
<button
type="button"
className="cmt-mod-btn"
onClick={() => setHistoryCommentId(c.id)}
aria-label="编辑历史"
>
<History size={12} />
历史
</button>
);
const tombstoneActions =
restoreBtn || purgeBtn || historyBtn ? (
<div className="cmt-mod-actions">
{historyBtn}
{restoreBtn}
{purgeBtn}
</div>
) : null;
if (!isFloor) {
return (
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24">
<div className="flex gap-2.5">
{identity}
<div className="flex-1 min-w-0">
<div className="mb-1">{nameRow}</div>
{tombstoneBody}
<div className="flex items-center justify-between gap-3 mt-1.5 flex-wrap">
{timeMeta(c)}
{tombstoneActions}
</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"
style={{ borderColor: "var(--line)" }}
>
<div className="flex gap-3">
{identity}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 mb-1.5">
{nameRow}
{floor && (
<a href={`#comment-${c.id}`} className="cmt-floor-label" aria-label={`楼层 ${floor}`}>
#{floor}
</a>
)}
</div>
{tombstoneBody}
<div className="flex items-center justify-between gap-3 mt-2 flex-wrap">
{timeMeta(c)}
{tombstoneActions}
</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) {
// 子回复:紧凑行式布局(抖音风格)
const isReplyToReply = depth >= 2;
return (
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24">
<div className="flex gap-2.5">
<Link href={`/u/${c.user.id}`} className="shrink-0" aria-label={c.user.nickname || c.user.username}>
<Avatar name={c.user.nickname || c.user.username} src={c.user.avatar || undefined} size={28} />
</Link>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<Link href={`/u/${c.user.id}`} className="group shrink-0">
<span className="cmt-reply-name group-hover:text-[var(--accent)] transition-colors">
{c.user.nickname || c.user.username}
</span>
</Link>
{c.status === "pending" && <span className="badge-pending shrink-0 !text-[10px] !py-0.5 !px-1.5">待审</span>}
{acceptedCommentId === c.id && (
<span className="badge-accepted shrink-0 !text-[10px] !py-0.5 !px-1.5">已采纳</span>
)}
{isReplyToReply && parent && (
<>
<ChevronRight size={12} className="cmt-reply-arrow" aria-hidden="true" />
<a href={`#comment-${parent.id}`} className="cmt-reply-target">
{parentAuthor}
</a>
</>
)}
</div>
<div className="mt-0.5">
{renderContentOrEditor(c)}
</div>
{/* 底部行:时间 · 回复(左),删除(右) */}
<div className="flex items-center justify-between mt-1.5">
<div className="flex items-center gap-3">
{timeMeta(c)}
{commentsOpen && user && editingId !== c.id && (
<button
onClick={() => openReply(c)}
className={`cmt-reply-btn transition-colors ${
replyTarget?.id === c.id ? "!text-[var(--accent)]" : ""
}`}
aria-label={`回复 ${c.user.nickname || c.user.username}`}
data-tip="回复"
>
回复
</button>
)}
{canAccept(c) && (
<button
type="button"
className="cmt-reply-btn !text-[var(--accent)]"
disabled={acceptingId === c.id}
onClick={() => void handleAccept(c.id, c.user.id)}
data-tip={acceptMode === "question" ? "采纳为最佳答案" : "采纳并发放悬赏积分"}
>
{acceptingId === c.id ? "采纳中…" : acceptMode === "question" ? "采纳答案" : "采纳"}
</button>
)}
{canApprove(c) && (
<>
<button
type="button"
className="cmt-reply-btn !text-[var(--accent)] inline-flex items-center gap-1"
disabled={approvingId === c.id || rejecting}
onClick={() => void handleApprove(c.id)}
data-tip="通过评审,公开此评论"
>
<BadgeCheck size={13} />
{approvingId === c.id ? "通过中…" : "通过评审"}
</button>
<button
type="button"
className="cmt-reply-btn !text-[var(--danger)] inline-flex items-center gap-1"
disabled={approvingId === c.id || rejecting}
onClick={() => setRejectTargetId(c.id)}
data-tip="拒绝此评论,不公开"
>
<XCircle size={13} />
拒绝
</button>
</>
)}
</div>
{(canEdit(c) || canSoftDelete(c) || (canPurge(c) && !c.deleted) || canViewHistory) && (
<div className="cmt-mod-actions" role="group" aria-label="评论管理">
{canEdit(c) && editingId !== c.id && (
<button
type="button"
className="cmt-mod-btn"
onClick={() => startEdit(c)}
disabled={savingEdit}
aria-label="编辑评论"
>
<Pencil size={12} />
编辑
</button>
)}
{canViewHistory && (
<button
type="button"
className="cmt-mod-btn"
onClick={() => setHistoryCommentId(c.id)}
aria-label="编辑历史"
>
<History size={12} />
历史
</button>
)}
{canSoftDelete(c) && (
<button
type="button"
className="cmt-mod-btn"
onClick={() => setDeleteTargetId(c.id)}
disabled={deleting}
aria-label="删除评论"
>
<Trash2 size={12} />
删除
</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}
</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"
style={{ borderColor: "var(--line)" }}
>
<div className="flex gap-3">
<Link href={`/u/${c.user.id}`} className="shrink-0 mt-0.5" aria-label={c.user.nickname || c.user.username}>
<Avatar name={c.user.nickname || c.user.username} src={c.user.avatar || undefined} size={38} />
</Link>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 mb-1.5 flex-wrap">
<Link href={`/u/${c.user.id}`} className="group">
<span className="truncate group-hover:text-[var(--accent)] transition-colors text-sm" style={{ color: "var(--ink)" }}>
{c.user.nickname || c.user.username}
</span>
</Link>
{c.status === "pending" && <span className="badge-pending shrink-0">待审</span>}
{acceptedCommentId === c.id && (
<span className="badge-accepted shrink-0">已采纳</span>
)}
{winnerCommentIdSet.has(c.id) && (
<span className="badge-winner shrink-0">中奖楼层</span>
)}
{floor && (
<a href={`#comment-${c.id}`} className="cmt-floor-label" aria-label={`楼层 ${floor}`}>
#{floor}
</a>
)}
</div>
{renderContentOrEditor(c)}
{/* 底部行:时间 · 回复(左),删除(右) */}
<div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-3">
{timeMeta(c)}
{commentsOpen && user && editingId !== c.id && (
<button
onClick={() => openReply(c)}
className={`cmt-reply-btn transition-colors ${
replyTarget?.id === c.id ? "!text-[var(--accent)]" : ""
}`}
aria-label={`回复 ${c.user.nickname || c.user.username}`}
data-tip="回复"
>
回复
</button>
)}
{canAccept(c) && (
<button
type="button"
className="cmt-reply-btn !text-[var(--accent)]"
disabled={acceptingId === c.id}
onClick={() => void handleAccept(c.id, c.user.id)}
data-tip={acceptMode === "question" ? "采纳为最佳答案" : "采纳并发放悬赏积分"}
>
{acceptingId === c.id ? "采纳中…" : acceptMode === "question" ? "采纳答案" : "采纳"}
</button>
)}
{canApprove(c) && (
<>
<button
type="button"
className="cmt-reply-btn !text-[var(--accent)] inline-flex items-center gap-1"
disabled={approvingId === c.id || rejecting}
onClick={() => void handleApprove(c.id)}
data-tip="通过评审,公开此评论"
>
<BadgeCheck size={13} />
{approvingId === c.id ? "通过中…" : "通过评审"}
</button>
<button
type="button"
className="cmt-reply-btn !text-[var(--danger)] inline-flex items-center gap-1"
disabled={approvingId === c.id || rejecting}
onClick={() => setRejectTargetId(c.id)}
data-tip="拒绝此评论,不公开"
>
<XCircle size={13} />
拒绝
</button>
</>
)}
</div>
{(canEdit(c) || canSoftDelete(c) || canPurge(c) || canViewHistory) && (
<div className="cmt-mod-actions" role="group" aria-label="评论管理">
{canEdit(c) && editingId !== c.id && (
<button
type="button"
className="cmt-mod-btn"
onClick={() => startEdit(c)}
disabled={savingEdit}
aria-label="编辑评论"
>
<Pencil size={12} />
编辑
</button>
)}
{canViewHistory && (
<button
type="button"
className="cmt-mod-btn"
onClick={() => setHistoryCommentId(c.id)}
aria-label="编辑历史"
>
<History size={12} />
历史
</button>
)}
{canSoftDelete(c) && (
<button
type="button"
className="cmt-mod-btn"
onClick={() => setDeleteTargetId(c.id)}
disabled={deleting}
aria-label="删除评论"
>
<Trash2 size={12} />
删除
</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}
{/* 折叠/展开子回复 */}
{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 (!commentsOpen) {
return <CommentsUnsupported />;
}
return (
<section className="panel p-6 sm:p-8 mt-6" aria-label="评论区">
<div className="flex items-center gap-2.5 mb-6">
<h2 className="text-lg font-semibold" style={{ color: "var(--ink)" }}>
评论
</h2>
<span
className="text-[12px] font-medium px-2 py-0.5 rounded-full tabular-nums"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
{totalComments}
</span>
<span className="meta ml-auto text-[11.5px]">共 {totalPages} 页楼层</span>
</div>
{user ? (
<form onSubmit={handleSubmit} className="mb-8">
<textarea
ref={textareaRef}
id="comment-input"
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
}}
placeholder="友善发言,发表一个新楼层..."
rows={3}
className="field"
aria-label="写评论"
/>
<div className="flex items-center justify-between gap-3 mt-2.5">
<span className="meta text-[11.5px]">支持 Markdown · Ctrl + Enter 发送</span>
<button type="submit" disabled={submitting || !content.trim()} className="btn btn-primary">
{submitting ? "发送中..." : "发表评论"}
</button>
</div>
</form>
) : (
<div
className="mb-8 px-5 py-4 rounded-2xl flex items-center justify-between gap-4 flex-wrap border"
style={{ borderColor: "var(--line)", background: "transparent" }}
>
<span className="text-sm" style={{ color: "var(--ink-2)" }}>
登录后参与讨论
</span>
<Link href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`} className="btn btn-line btn-sm">
去登录
</Link>
</div>
)}
<div>
{comments.map((node, i) => renderComment(node, 0, (page - 1) * size + i + 1))}
{comments.length === 0 && (
<div className="text-center py-14">
<MessagesSquare size={28} className="mx-auto mb-3" style={{ color: "var(--ink-3)" }} />
<p className="text-sm" style={{ color: "var(--ink-2)" }}>
暂无评论
</p>
<p className="meta text-[12.5px] mt-1">来说两句,抢占沙发</p>
</div>
)}
</div>
<Pagination total={total} page={page} size={size} basePath={`/post/${postId}`} />
<ConfirmDialog
open={rejectTargetId != null}
title="拒绝评论"
message="确定拒绝这条待审评论?拒绝后不会公开,作者会收到通知。"
confirmLabel="确认拒绝"
danger
busy={rejecting}
onConfirm={handleRejectConfirm}
onCancel={() => {
if (!rejecting) setRejectTargetId(null);
}}
/>
<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={
purgeReplyCount > 0
? `将永久清除此评论及其 ${purgeReplyCount} 条回复,不保留占位、不可恢复。刷屏/垃圾内容请用此操作;一般违规请用「删除」保留占位。`
: "将永久清除此评论,不保留占位、不可恢复。刷屏/垃圾内容请用此操作;一般违规请用「删除」保留占位。"
}
confirmLabel="永久清除"
danger
busy={purging}
onConfirm={handlePurgeConfirm}
onCancel={() => {
if (!purging) setPurgeTargetId(null);
}}
/>
<CommentEditHistoryModal
open={historyCommentId != null}
commentId={historyCommentId}
onClose={() => setHistoryCommentId(null)}
/>
</section>
);
}