自删/管理删差异文案与理由可见性,评论者与管理员免马赛克,并支持恢复与处理人链接。 Co-authored-by: Cursor <cursoragent@cursor.com>
1027 lines
38 KiB
TypeScript
1027 lines
38 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useRef, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import Link from "next/link";
|
||
import {
|
||
Trash2,
|
||
MessagesSquare,
|
||
MessageSquareOff,
|
||
ChevronRight,
|
||
ChevronDown,
|
||
BadgeCheck,
|
||
XCircle,
|
||
RotateCcw,
|
||
} from "lucide-react";
|
||
import {
|
||
apiCreateComment,
|
||
apiDeleteComment,
|
||
apiRestoreComment,
|
||
apiPurgeComment,
|
||
apiAcceptBounty,
|
||
apiAcceptQuestion,
|
||
apiAdminApproveComment,
|
||
apiAdminRejectComment,
|
||
postDeleteTypeLabel,
|
||
type CommentNode,
|
||
type User,
|
||
} 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";
|
||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||
import DeleteCommentModal, { type DeleteCommentPayload } from "@/components/DeleteCommentModal";
|
||
|
||
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;
|
||
/** 全站是否开放评论;缺省 true。关闭时隐藏发评/回复入口 */
|
||
allowComments?: boolean;
|
||
}
|
||
|
||
// 递归统计子树回复数(彻底删除级联提示用)
|
||
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,
|
||
allowComments: allowCommentsProp = true,
|
||
}: CommentSectionProps) {
|
||
const router = useRouter();
|
||
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);
|
||
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,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 (!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 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) => {
|
||
setReplyTarget({ id: c.id, nickname: c.user.nickname || c.user.username });
|
||
setReplyContent("");
|
||
};
|
||
|
||
// 切换某楼层子回复的展开/折叠
|
||
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 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">
|
||
<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">
|
||
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
|
||
{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">
|
||
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
|
||
{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>}
|
||
{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">
|
||
<MarkdownBodyClient
|
||
content={c.content}
|
||
className="prose-content prose-comment"
|
||
/>
|
||
</div>
|
||
{/* 底部行:时间 · 回复(左),删除(右) */}
|
||
<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>
|
||
{commentsOpen && user && (
|
||
<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>
|
||
)}
|
||
{acceptedCommentId === c.id && (
|
||
<span className="badge-type !text-[10px] !py-0.5 !px-1.5">已采纳</span>
|
||
)}
|
||
{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>
|
||
{(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>
|
||
)}
|
||
{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">
|
||
<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>}
|
||
{floor && (
|
||
<a href={`#comment-${c.id}`} className="cmt-floor-label" aria-label={`楼层 ${floor}`}>
|
||
#{floor}
|
||
</a>
|
||
)}
|
||
</div>
|
||
<MarkdownBodyClient
|
||
content={c.content}
|
||
className="prose-content prose-comment"
|
||
/>
|
||
{/* 底部行:时间 · 回复(左),删除(右) */}
|
||
<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>
|
||
{commentsOpen && user && (
|
||
<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>
|
||
)}
|
||
{acceptedCommentId === c.id && (
|
||
<span className="badge-type !text-[10px] !py-0.5 !px-1.5">已采纳</span>
|
||
)}
|
||
{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>
|
||
{(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>
|
||
)}
|
||
{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>
|
||
);
|
||
};
|
||
|
||
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>
|
||
|
||
{!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}
|
||
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">
|
||
{commentsOpen ? "来说两句,抢占沙发" : "评论开放后即可参与讨论"}
|
||
</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);
|
||
}}
|
||
/>
|
||
</section>
|
||
);
|
||
}
|