"use client"; import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { MessagesSquare, ChevronRight, ChevronDown, Lock, } from "lucide-react"; import { apiCreateComment, apiDeleteComment, apiRestoreComment, apiPurgeComment, apiUpdateComment, apiAcceptBounty, apiAcceptQuestion, apiCommentHistory, apiAdminApproveComment, apiAdminRejectComment, postDeleteTypeLabel, type CommentNode, type User, } from "@/lib/api"; import { canModerateBoard, canStaffSoftDelete, canPurgeContent, isStaff } from "@/lib/roles"; import { toast } from "@/lib/toast"; import { formatCommentTime, formatDateTime } from "@/lib/format"; import { useAllowComments, useCommentsRequireLogin } from "@/components/CommentsPolicyProvider"; import CommentsUnsupported from "@/components/CommentsUnsupported"; import CommentsLoginRequired from "@/components/CommentsLoginRequired"; import Avatar from "./Avatar"; import LevelBadge from "./LevelBadge"; import BadgeChip from "./BadgeChip"; import Pagination from "./Pagination"; import MarkdownBodyClient from "@/components/MarkdownBodyClient"; import MarkdownEditor from "@/components/MarkdownEditor"; import ConfirmDialog from "@/components/ConfirmDialog"; import DeleteCommentModal, { type DeleteCommentPayload } from "@/components/DeleteCommentModal"; import EditHistoryModal from "@/components/EditHistoryModal"; import { scrollFlashTo } from "@/lib/flash"; import { fetchCommentLocation } from "@/lib/api"; 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[]; /** 帖子是否被管理员手动锁定(普通用户不可回复/编辑评论,staff 豁免) */ postLocked: boolean; /** 评论可编辑时长(小时);0=不锁定;staff 豁免 */ commentEditLockHours: number; /** 旧帖回复确认提示(后端按查看者计算;存在时提交回复前需用户确认,确认后发送并扣分) */ necroReply?: { after_hours: number; penalty: number } | null; } // 递归统计子树回复数(彻底删除级联提示用) function countReplies(node: CommentNode): number { return node.replies?.reduce((acc, r) => acc + 1 + countReplies(r), 0) ?? 0; } /** 评论正文字符上限(与后端 handler/comment.go 校验一致) */ const COMMENT_MAX = 5000; 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 ; } type MoreItem = { key: string; label: string; danger?: boolean; disabled?: boolean; onSelect: () => void; }; /** 低频操作:编辑、历史、删除、永久删除。Escape 关闭并回到触发按钮。 */ function CommentMoreMenu({ items }: { items: MoreItem[] }) { const [open, setOpen] = useState(false); const ref = useRef(null); const btnRef = useRef(null); useEffect(() => { if (!open) return; const onDown = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key !== "Escape") return; e.preventDefault(); e.stopPropagation(); setOpen(false); btnRef.current?.focus(); }; document.addEventListener("mousedown", onDown); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); }; }, [open]); if (items.length === 0) return null; const normal = items.filter((item) => !item.danger); const danger = items.filter((item) => item.danger); return (
{open && (
{normal.map((item) => ( ))} {normal.length > 0 && danger.length > 0 ?
: null} {danger.map((item) => ( ))}
)}
); } function focusCommentControl(id: number, kind: "reply" | "more") { window.requestAnimationFrame(() => { document.querySelector(`#comment-${id} [data-cmt-focus="${kind}"]`)?.focus(); }); } export default function CommentSection({ postId, boardId, authorId, comments, total, totalComments, page, size, user, acceptMode = null, acceptedCommentId = 0, winnerCommentIds = [], postLocked, commentEditLockHours, necroReply = null, }: CommentSectionProps) { const winnerCommentIdSet = new Set(winnerCommentIds.filter((id) => id > 0)); const router = useRouter(); const commentsOpen = useAllowComments(); const commentsRequireLogin = useCommentsRequireLogin(); const staffViewer = !!user && isStaff(user.role); /** 锁帖后普通用户禁止回复与编辑自己的评论,staff 豁免 */ const replyBlocked = postLocked && !staffViewer; /** 评论是否已超过可编辑时限(staff 豁免;0=不启用) */ const commentTimeLocked = (createdAt: string) => !staffViewer && commentEditLockHours > 0 && Date.now() - new Date(createdAt).getTime() > commentEditLockHours * 3_600_000; const [content, setContent] = useState(""); // 内联回复目标:同一时间只有一个回复框;草稿按目标评论隔离 const [replyTarget, setReplyTarget] = useState<{ id: number; nickname: string; floor?: number; rootId: number; } | null>(null); const [replyContent, setReplyContent] = useState(""); const replyDrafts = useRef>({}); const editDrafts = useRef>({}); const submitLock = useRef(false); const [submitting, setSubmitting] = useState(false); const [floorError, setFloorError] = useState(""); const [replyError, setReplyError] = useState(""); const [editError, setEditError] = useState(""); const [floorUploading, setFloorUploading] = useState(false); const [replyUploading, setReplyUploading] = useState(false); const [editUploading, setEditUploading] = useState(false); const [awayHref, setAwayHref] = useState(null); // 旧帖回复确认:记录待确认的提交入口(主楼层 / 内联回复) const [necroConfirm, setNecroConfirm] = useState(null); const [discardReplyOpen, setDiscardReplyOpen] = useState(false); const [discardEditOpen, setDiscardEditOpen] = 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); const [historyCommentContent, setHistoryCommentContent] = useState(""); // 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起) const [expandedFloors, setExpandedFloors] = useState>(new Set()); 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 = 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]); // 深链定位:#comment-{id} → 滚动 + 主题色浅底高亮渐隐。 // 目标是折叠楼层里的子回复时先展开所属楼层;不在当前页时查楼层号换页后重试。 // 楼层号锚点(#楼)、「回复 @xx」、采纳答案卡等页内跳转经 hashchange 同样获得高亮。 const locatedRef = useRef(null); // 已跨页尝试过的评论 ID,防循环 useEffect(() => { if (!commentsOpen) return; let cancelled = false; const locate = async () => { const m = window.location.hash.match(/^#comment-(\d+)$/); if (!m) return; const id = Number(m[1]); const el = document.getElementById(`comment-${id}`); if (el) { scrollFlashTo(el); return; } // 不在 DOM:先看是否在本页数据里(折叠的子回复)→ 展开所属楼层后定位 const node = findComment(comments, id); if (node) { const rootId = node.root_id ?? node.parent_id ?? node.id; setExpandedFloors((prev) => { if (prev.has(rootId)) return prev; const next = new Set(prev); next.add(rootId); return next; }); window.setTimeout(() => { if (!cancelled) scrollFlashTo(document.getElementById(`comment-${id}`)); }, 160); return; } // 不在本页:查楼层号 → 换页(comments 变化后本 effect 再次运行并定位) if (locatedRef.current === id) return; // 已尝试仍找不到(如被拒/未公开),放弃 locatedRef.current = id; try { const floor = await fetchCommentLocation(postId, id); if (cancelled || floor <= 0) return; const destPage = Math.max(1, Math.ceil(floor / size)); if (destPage === page) return; // 同页却无节点(异常数据),放弃 router.replace(`/post/${postId}?page=${destPage}#comment-${id}`, { scroll: false }); } catch { /* 定位失败静默:停留在当前页 */ } }; locate(); window.addEventListener("hashchange", locate); return () => { cancelled = true; window.removeEventListener("hashchange", locate); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [comments, commentsOpen, page, postId, size]); const submitFloor = async () => { if (!commentsOpen || !content.trim() || submitLock.current || floorUploading) return; submitLock.current = true; setSubmitting(true); setFloorError(""); try { const res = await apiCreateComment(postId, content); if (res.comment) { setContent(""); const destPage = Math.max(1, Math.ceil((total + 1) / size)); if (res.comment.status === "pending") { toast("已提交,等待审核通过后公开"); } if (destPage !== page) { setAwayHref( destPage > 1 ? `/post/${postId}?page=${destPage}#comment-${res.comment.id}` : `/post/${postId}#comment-${res.comment.id}` ); } else { setAwayHref(null); } router.refresh(); } else if (res.error === "未登录") { router.push(`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`); } else { const msg = res.error || "评论失败"; setFloorError(msg); toast(msg); } } catch { setFloorError("网络错误,评论失败"); toast("网络错误,评论失败"); } finally { submitLock.current = false; setSubmitting(false); } }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); // 旧帖回复:确认后才发送并扣分(后端按查看者下发提示,staff/待审用户不会拿到) if (necroReply) { setNecroConfirm("floor"); return; } void submitFloor(); }; const submitReply = async () => { if (!commentsOpen || !replyTarget || !replyContent.trim() || submitLock.current || replyUploading) return; submitLock.current = true; setSubmitting(true); setReplyError(""); const target = replyTarget; try { const res = await apiCreateComment(postId, replyContent, target.id); if (res.comment) { delete replyDrafts.current[target.id]; setReplyTarget(null); setReplyContent(""); setExpandedFloors((prev) => { const next = new Set(prev); next.add(target.rootId); return next; }); if (res.comment.status === "pending") { toast("已提交,等待审核通过后公开"); } router.refresh(); focusCommentControl(target.id, "reply"); } else if (res.error === "未登录") { router.push(`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`); } else { const msg = res.error || "回复失败"; setReplyError(msg); toast(msg); } } catch { setReplyError("网络错误,回复失败"); toast("网络错误,回复失败"); } finally { submitLock.current = false; setSubmitting(false); } }; const handleSubmitReply = (e: React.FormEvent) => { e.preventDefault(); // 旧帖回复:确认后才发送并扣分 if (necroReply) { setNecroConfirm("reply"); return; } void submitReply(); }; 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 || canStaffSoftDelete(user, boardId, comment.user?.role)); const canEdit = (comment: CommentNode) => { if (!canSoftDelete(comment)) return false; // 作者本人编辑:锁帖与可编辑时限限制(staff 豁免;版主编辑他人不受此限) if (user?.id === comment.user?.id && (replyBlocked || commentTimeLocked(comment.created_at))) { return false; } return true; }; const canViewHistory = !!user; const canPurge = (comment: CommentNode) => !!user && canPurgeContent(user.role) && canModerateBoard(user, boardId); /** 作者可恢复自删;版主可恢复本板任意软删(含管理删除),板管受作者角色保护 */ const canRestore = (comment: CommentNode) => { if (!comment.deleted || !user) return false; if ( canModerateBoard(user, boardId) && canStaffSoftDelete(user, boardId, comment.user?.role) ) { 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 rememberReplyDraft = () => { if (!replyTarget) return; replyDrafts.current[replyTarget.id] = replyContent; }; const openReply = (c: CommentNode, floor?: number) => { rememberReplyDraft(); if (editingId != null) { editDrafts.current[editingId] = editContent; setEditingId(null); } const rootId = c.root_id ?? (c.parent_id ? c.parent_id : c.id); setReplyTarget({ id: c.id, nickname: c.user?.nickname || c.user?.username || "用户", floor, rootId: c.depth ? rootId : c.id, }); setReplyContent(replyDrafts.current[c.id] ?? ""); setReplyError(""); }; const closeReply = (discard: boolean) => { const id = replyTarget?.id; if (id != null) { if (discard) delete replyDrafts.current[id]; else replyDrafts.current[id] = replyContent; } setReplyTarget(null); setReplyContent(""); setReplyError(""); setDiscardReplyOpen(false); if (id != null) focusCommentControl(id, "reply"); }; const requestCancelReply = () => { if (replyContent.trim()) { setDiscardReplyOpen(true); return; } closeReply(true); }; const startEdit = (c: CommentNode) => { rememberReplyDraft(); setReplyTarget(null); if (editingId != null && editingId !== c.id) { editDrafts.current[editingId] = editContent; } setEditingId(c.id); setEditContent(editDrafts.current[c.id] ?? c.content); setEditError(""); }; const finishCancelEdit = () => { const id = editingId; if (id != null) delete editDrafts.current[id]; setEditingId(null); setEditContent(""); setEditError(""); setDiscardEditOpen(false); if (id != null) focusCommentControl(id, "more"); }; const cancelEdit = () => { if (editingId == null) return; const original = findComment(comments, editingId)?.content ?? ""; if (editContent !== original) { setDiscardEditOpen(true); return; } finishCancelEdit(); }; const handleSaveEdit = async () => { if (editingId == null || !editContent.trim() || submitLock.current || editUploading) return; submitLock.current = true; setSavingEdit(true); setEditError(""); const id = editingId; try { const res = await apiUpdateComment(postId, id, editContent); if (res.error) { setEditError(res.error); toast(res.error); return; } delete editDrafts.current[id]; toast("评论已更新", "ok"); setEditingId(null); setEditContent(""); router.refresh(); focusCommentControl(id, "more"); } catch (e) { const msg = e instanceof Error ? e.message : "更新失败"; setEditError(msg); toast(msg); } finally { submitLock.current = false; setSavingEdit(false); } }; const renderContentOrEditor = (c: CommentNode) => { if (editingId === c.id) { return (
{ setEditContent(v.slice(0, COMMENT_MAX)); if (editError) setEditError(""); }} maxChars={COMMENT_MAX} disabled={savingEdit} autoFocus allowHide={false} ariaLabel="编辑评论" placeholder="编辑评论…" shortcutName="保存" error={editError} onUploadingChange={setEditUploading} onSubmitShortcut={() => { void handleSaveEdit(); }} footer={ <> } />
); } return ; }; const timeMeta = (c: CommentNode) => { const full = formatDateTime(c.created_at); const short = formatCommentTime(c.created_at); return ( ); }; const editedMark = (c: CommentNode) => { if (!c.edited) return null; if (canViewHistory) { return ( ); } return ( 已编辑 ); }; const moreItemsFor = (c: CommentNode): MoreItem[] => { const items: MoreItem[] = []; if (canEdit(c) && editingId !== c.id) { items.push({ key: "edit", label: "编辑", disabled: savingEdit, onSelect: () => startEdit(c), }); } // 未编辑(无修订快照)的评论不展示历史入口,避免打开空记录 if (canViewHistory && c.edited) { items.push({ key: "history", label: "编辑历史", onSelect: () => { setHistoryCommentContent(c.content); setHistoryCommentId(c.id); }, }); } if (canRestore(c)) { items.push({ key: "restore", label: "恢复", disabled: restoring || purging, onSelect: () => setRestoreTargetId(c.id), }); } if (canSoftDelete(c)) { items.push({ key: "delete", label: "删除", danger: true, disabled: deleting, onSelect: () => setDeleteTargetId(c.id), }); } if (canPurge(c)) { items.push({ key: "purge", label: "永久删除", danger: true, disabled: purging, onSelect: () => setPurgeTargetId(c.id), }); } return items; }; // 切换某楼层子回复的展开/折叠 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 && (
回复 @{replyTarget.nickname} {replyTarget.floor ? · #{replyTarget.floor} : null}
{ setReplyContent(v.slice(0, COMMENT_MAX)); if (replyError) setReplyError(""); }} maxChars={COMMENT_MAX} disabled={submitting} autoFocus allowHide={false} ariaLabel={`回复 ${replyTarget.nickname}`} placeholder="写下你的回复…" shortcutName="回复" error={replyError} onUploadingChange={setReplyUploading} onSubmitShortcut={() => { void submitReply(); }} footer={ } />
); // 单条评论渲染(递归):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 ? parent.user.nickname || parent.user.username : parent?.deleted ? "已删除" : undefined; // 软删占位:自删不打码;管理删对公众匿名马赛克(身份以 API 是否返回 user 为准) if (c.deleted) { const author = c.user; const displayName = author?.nickname || author?.username || "用户"; const typeLabel = postDeleteTypeLabel(c.delete_type); const isMod = !!user && canModerateBoard(user, boardId); const showMosaic = !!c.staff_deleted && !author; 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 ? ( ) : author ? ( ) : ( ); const nameRow = showMosaic ? ( ) : author ? ( {displayName} ) : ( {displayName} ); const tombstoneBody = (

{c.staff_deleted ? "管理员已删除此评论" : "评论者已删除此评论"}

{staffPublicMeta ? (

{typeLabel ? ( <> {typeLabel} {c.delete_reason ? " · " : null} ) : null} {c.delete_reason || null}

) : null} {c.staff_deleted && deleter ? (
处理人 {deleterName}
) : null} {selfReasonForStaff ? (
自删理由 · 仅管理可见

{c.delete_reason}

) : null} {c.content ? (
{isMod && user?.id !== author?.id ? "原文 · 仅管理可见" : "原文 · 仅你可见"}
) : null} {c.staff_deleted ? ( 管理删除 ) : ( 评论者删除 )}
); const tombstoneMenu = ; if (!isFloor) { return (
{identity}
{nameRow} {timeMeta(c)}
{tombstoneBody}
{tombstoneMenu}
{node.replies && node.replies.length > 0 && (
{node.replies.map((r) => renderComment(r, depth + 1, floor, node))}
)}
); } return (
{identity}
{nameRow} {timeMeta(c)}
{floor ? ( #{floor} ) : null}
{tombstoneBody}
{tombstoneMenu}
{node.replies && node.replies.length > 0 && (
{isExpanded && (
{node.replies.map((r) => renderComment(r, 1, floor, node))}
)}
)}
); } const author = c.user; const authorName = author?.nickname || author?.username || "用户"; if (!isFloor) { const isReplyToReply = depth >= 2; const isOp = (author?.id ?? c.user_id) === authorId; return (
{author ? ( ) : ( )}
{author ? ( {authorName} ) : ( {authorName} )} {author && } {author && (author.badges ?? []).slice(0, 2).map((ub) => ( ))} {isOp && 楼主} {c.status === "pending" && 待审} {acceptedCommentId === c.id && 已采纳} {timeMeta(c)} {editedMark(c)} {isReplyToReply && parent && ( 回复 {parentAuthor} )}
{renderContentOrEditor(c)}
{commentsOpen && user && !replyBlocked && editingId !== c.id && ( )} {canAccept(c) && ( )} {canApprove(c) && ( <> )}
{replyTarget?.id === c.id && inlineReplyForm}
{node.replies && node.replies.length > 0 && (
{node.replies.map((r) => renderComment(r, depth + 1, floor, node))}
)}
); } // 主评论(楼层) const isOp = (author?.id ?? c.user_id) === authorId; return (
{author ? ( ) : ( )}
{author ? ( {authorName} ) : ( {authorName} )} {author && } {author && (author.badges ?? []).slice(0, 2).map((ub) => ( ))} {isOp && 楼主} {c.status === "pending" && 待审} {acceptedCommentId === c.id && 已采纳} {winnerCommentIdSet.has(c.id) && 中奖楼层} {timeMeta(c)} {editedMark(c)}
{floor ? ( #{floor} ) : null}
{renderContentOrEditor(c)}
{commentsOpen && user && !replyBlocked && editingId !== c.id && ( )} {canAccept(c) && ( )} {canApprove(c) && ( <> )}
{replyTarget?.id === c.id && inlineReplyForm} {node.replies && node.replies.length > 0 && (
{isExpanded && (
{node.replies.map((r) => renderComment(r, 1, floor, node))}
)}
)}
); }; if (!commentsOpen) { return ; } if (commentsRequireLogin && !user) { return ; } return (

评论

{totalComments} {totalPages > 1 ? ( 第 {page} / {totalPages} 页 ) : null}
{replyBlocked ? (
帖子已被锁定,暂时无法回复
) : user ? (
{ setContent(v.slice(0, COMMENT_MAX)); if (floorError) setFloorError(""); }} maxChars={COMMENT_MAX} disabled={submitting} allowHide={false} ariaLabel="发表评论" placeholder="写下你的看法,参与讨论…" error={floorError} onUploadingChange={setFloorUploading} onSubmitShortcut={() => { void submitFloor(); }} footer={ } />
{awayHref ? (

评论已发表,查看我的评论

) : null}
) : (
登录后参与讨论 去登录
)}
{comments.map((node, i) => renderComment(node, 0, (page - 1) * size + i + 1))} {comments.length === 0 && (

暂无评论

来说两句,抢占沙发

)}
{ if (!rejecting) setRejectTargetId(null); }} /> { if (!deleting) setDeleteTargetId(null); }} onConfirm={(opts) => void handleDeleteConfirm(opts)} /> { if (!restoring) setRestoreTargetId(null); }} /> 0 ? `将永久清除此评论及其 ${purgeReplyCount} 条回复,不保留占位、不可恢复。刷屏/垃圾内容请用此操作;一般违规请用「删除」保留占位。` : "将永久清除此评论,不保留占位、不可恢复。刷屏/垃圾内容请用此操作;一般违规请用「删除」保留占位。" } confirmLabel="永久清除" danger busy={purging} onConfirm={handlePurgeConfirm} onCancel={() => { if (!purging) setPurgeTargetId(null); }} /> closeReply(true)} onCancel={() => closeReply(false)} /> setDiscardEditOpen(false)} /> {/* 旧帖回复确认:确认后才发送并扣分(提示由后端按查看者下发) */} { const kind = necroConfirm; setNecroConfirm(null); if (kind === "floor") void submitFloor(); else if (kind === "reply") void submitReply(); }} onCancel={() => setNecroConfirm(null)} /> { if (historyCommentId == null) return Promise.reject(new Error("未选择评论")); return apiCommentHistory(historyCommentId, p, s); }} onClose={() => setHistoryCommentId(null)} />
); }