"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; } /** 匿名马赛克头像:不含真实 src / 姓名,避免进 DOM */ function MosaicAvatar({ size }: { size: number }) { return ( ); } /** 匿名马赛克昵称条:固定宽度,不含真实文本 */ function MosaicName() { return ; } 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(null); const [acceptingId, setAcceptingId] = useState(null); const [approvingId, setApprovingId] = useState(null); const [rejectTargetId, setRejectTargetId] = useState(null); const [rejecting, setRejecting] = useState(false); const [purgeTargetId, setPurgeTargetId] = useState(null); const [purging, setPurging] = useState(false); const [restoreTargetId, setRestoreTargetId] = useState(null); const [restoring, setRestoring] = useState(false); const [editingId, setEditingId] = useState(null); const [editContent, setEditContent] = useState(""); const [savingEdit, setSavingEdit] = useState(false); const [historyCommentId, setHistoryCommentId] = useState(null); // 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起) const [expandedFloors, setExpandedFloors] = useState>(new Set()); const textareaRef = useRef(null); useEffect(() => { if (!commentsOpen) { setReplyTarget(null); setReplyContent(""); } }, [commentsOpen]); // 登录回跳 / 隐藏块「去回复」:滚到评论框并聚焦 useEffect(() => { if (!user || !commentsOpen) return; const focusComposer = () => { const hash = window.location.hash; if (hash !== "#comment-input" && hash !== "#comments") return; const el = textareaRef.current ?? document.getElementById("comment-input"); if (!(el instanceof HTMLTextAreaElement)) return; el.scrollIntoView({ behavior: "smooth", block: "center" }); window.setTimeout(() => el.focus({ preventScroll: true }), 280); }; focusComposer(); window.addEventListener("hashchange", focusComposer); return () => window.removeEventListener("hashchange", focusComposer); }, [user, 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 ?? 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.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 (