Files
jiang13-bbs/frontend/components/CommentSection.tsx
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

1549 lines
54 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 { Fragment, 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 { postPath, userPath, commentAnchor } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
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 (
<span className="cmt-mosaic-avatar" style={{ width: size, height: size }} aria-hidden>
<span className="cmt-mosaic-avatar-fill" />
</span>
);
}
/** 匿名马赛克昵称条:固定宽度,不含真实文本 */
function MosaicName() {
return <span className="cmt-mosaic-name" aria-hidden />;
}
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<HTMLDivElement>(null);
const btnRef = useRef<HTMLButtonElement>(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 (
<div className="cmt-more" ref={ref}>
<button
ref={btnRef}
type="button"
className="cmt-more-btn"
aria-haspopup="menu"
aria-expanded={open}
data-cmt-focus="more"
onClick={() => setOpen((v) => !v)}
>
更多
</button>
{open && (
<div role="menu" className="cmt-more-menu" aria-label="评论操作">
{normal.map((item) => (
<button
key={item.key}
type="button"
role="menuitem"
disabled={item.disabled}
onClick={() => {
setOpen(false);
item.onSelect();
}}
>
{item.label}
</button>
))}
{normal.length > 0 && danger.length > 0 ? <div className="cmt-more-sep" role="separator" /> : null}
{danger.map((item) => (
<button
key={item.key}
type="button"
role="menuitem"
className="is-danger"
disabled={item.disabled}
onClick={() => {
setOpen(false);
item.onSelect();
}}
>
{item.label}
</button>
))}
</div>
)}
</div>
);
}
function focusCommentControl(id: number, kind: "reply" | "more") {
window.requestAnimationFrame(() => {
document
.querySelector<HTMLElement>(`[data-comment-id="${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 urlStyle = useUrlStyle();
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<Record<number, string>>({});
const editDrafts = useRef<Record<number, string>>({});
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<string | null>(null);
// 旧帖回复确认:记录待确认的提交入口(主楼层 / 内联回复)
const [necroConfirm, setNecroConfirm] = useState<null | "floor" | "reply">(null);
const [discardReplyOpen, setDiscardReplyOpen] = useState(false);
const [discardEditOpen, setDiscardEditOpen] = 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);
const [historyCommentContent, setHistoryCommentContent] = useState("");
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(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-{floor}(主楼)或 #comment-{floor}-r{cid}(楼中楼)。
// 锚点数字是帖内相对楼层号,直接决定目标分页;目标是折叠楼层里的回复时先展开;
// 楼层号锚点(#楼)、「回复 @xx」、采纳答案卡等页内跳转经 hashchange 同样获得高亮。
const attemptedHashRef = useRef<string | null>(null); // 已跨页尝试过的锚点,防循环
useEffect(() => {
if (!commentsOpen) return;
let cancelled = false;
const locate = () => {
const m = window.location.hash.match(/^#comment-(\d+)(?:-r(\d+))?$/);
if (!m) return;
const floor = Number(m[1]);
const replyId = m[2] ? Number(m[2]) : 0;
if (floor > total) return; // 楼层不存在(异常/过期链接),停留在当前页
const destPage = Math.max(1, Math.ceil(floor / size));
const hash = window.location.hash;
if (destPage !== page) {
// 不在目标页:带锚点换页,comments 刷新后本 effect 重新运行完成定位
if (attemptedHashRef.current === hash) return;
attemptedHashRef.current = hash;
router.replace(`${postPath(urlStyle, Number(postId))}?page=${destPage}${hash}`, {
scroll: false,
});
return;
}
const flashTarget = () => {
const el = replyId
? document.querySelector<HTMLElement>(`[data-comment-id="${replyId}"]`)
: document.getElementById(`comment-${floor}`);
if (el) scrollFlashTo(el);
};
if (!replyId) {
flashTarget();
return;
}
// 楼中楼:先展开所属楼层(折叠态不渲染回复节点),再滚动高亮
const node = findComment(comments, replyId);
const rootId = node?.root_id ?? node?.parent_id ?? 0;
if (node && rootId) {
setExpandedFloors((prev) => {
if (prev.has(rootId)) return prev;
const next = new Set(prev);
next.add(rootId);
return next;
});
window.setTimeout(() => {
if (!cancelled) flashTarget();
}, 160);
} else {
flashTarget();
}
};
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, total]);
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) {
const anchor = commentAnchor(total + 1, true);
setAwayHref(
destPage > 1
? `${postPath(urlStyle, Number(postId))}?page=${destPage}${anchor}`
: `${postPath(urlStyle, Number(postId))}${anchor}`
);
} else {
setAwayHref(null);
}
router.refresh();
} else if (res.error === "未登录") {
router.push(`/login?redirect=${encodeURIComponent(postPath(urlStyle, Number(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(postPath(urlStyle, Number(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 (
<div className="cmt-md-editor mt-1">
<MarkdownEditor
density="comment"
value={editContent}
onChange={(v) => {
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={
<>
<button
type="button"
className="btn btn-line btn-sm"
disabled={savingEdit}
onClick={cancelEdit}
>
取消
</button>
<button
type="button"
className="btn btn-primary cmt-submit"
disabled={savingEdit || editUploading || !editContent.trim()}
aria-busy={savingEdit}
onClick={() => void handleSaveEdit()}
>
{savingEdit ? "保存中…" : "保存修改"}
</button>
</>
}
/>
</div>
);
}
return <MarkdownBodyClient content={c.content} className="prose-content prose-comment" />;
};
const timeMeta = (c: CommentNode) => {
const full = formatDateTime(c.created_at);
const short = formatCommentTime(c.created_at);
return (
<time className="cmt-time" dateTime={c.created_at} tabIndex={0} data-tip={full}>
{short}
</time>
);
};
const editedMark = (c: CommentNode) => {
if (!c.edited) return null;
if (canViewHistory) {
return (
<button
type="button"
className="cmt-edited"
onClick={() => {
setHistoryCommentContent(c.content);
setHistoryCommentId(c.id);
}}
>
已编辑
</button>
);
}
return (
<span className="cmt-edited" data-tip="此评论曾被编辑">
已编辑
</span>
);
};
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));
// 内联回复表单:渲染在所属楼层块外侧(见下方列表 map),任意回复层级均全宽;全站关评时不渲染
const inlineReplyForm = commentsOpen && replyTarget && (
<form onSubmit={handleSubmitReply} className="cmt-reply-form">
<div className="flex items-center justify-between gap-2 mb-2 text-[13px]" style={{ color: "var(--ink-2)" }}>
<span>
回复 <span style={{ color: "var(--accent)", fontWeight: 600 }}>@{replyTarget.nickname}</span>
{replyTarget.floor ? <span className="cmt-time"> · #{replyTarget.floor}</span> : null}
</span>
<button type="button" className="cmt-reply-btn" onClick={requestCancelReply}>
取消
</button>
</div>
<div className="cmt-md-editor">
<MarkdownEditor
density="comment"
value={replyContent}
onChange={(v) => {
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={
<button
type="submit"
disabled={submitting || replyUploading || !replyContent.trim()}
aria-busy={submitting}
className="btn btn-primary cmt-submit"
>
{submitting ? "回复中…" : "回复"}
</button>
}
/>
</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
? 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 ? (
<MosaicAvatar size={isFloor ? 34 : 32} />
) : author ? (
<Link
href={userPath(urlStyle, author.id)}
className={isFloor ? "shrink-0 mt-0.5" : "shrink-0"}
aria-label={displayName}
>
<Avatar name={displayName} src={author.avatar || undefined} size={isFloor ? 34 : 32} />
</Link>
) : (
<Avatar name={displayName} size={isFloor ? 34 : 32} />
);
const nameRow = showMosaic ? (
<MosaicName />
) : author ? (
<Link href={userPath(urlStyle, author.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>
) : (
<span
className={`truncate ${isFloor ? "text-sm" : "cmt-reply-name"}`}
style={{ color: "var(--ink)" }}
>
{displayName}
</span>
);
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={userPath(urlStyle, 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}
{c.content ? (
<div className="cmt-tombstone-orig">
<span className="cmt-tombstone-audit-label">
{isMod && user?.id !== author?.id ? "原文 · 仅管理可见" : "原文 · 仅你可见"}
</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 tombstoneMenu = <CommentMoreMenu items={moreItemsFor(c)} />;
if (!isFloor) {
return (
<div
key={c.id}
id={floor ? `comment-${floor}-r${c.id}` : undefined}
data-comment-id={c.id}
className="cmt-reply-row scroll-mt-24"
>
<div className="flex gap-3">
{identity}
<div className="flex-1 min-w-0">
<div className="cmt-meta">
<div className="cmt-meta-lead">
{nameRow}
{timeMeta(c)}
</div>
</div>
{tombstoneBody}
<div className="cmt-actions">{tombstoneMenu}</div>
</div>
</div>
{node.replies && node.replies.length > 0 && (
<div className="cmt-children">
{node.replies.map((r) => renderComment(r, depth + 1, floor, node))}
</div>
)}
</div>
);
}
return (
<div
key={c.id}
id={floor ? `comment-${floor}` : undefined}
data-comment-id={c.id}
className="cmt-item scroll-mt-24"
>
<div className="flex gap-3">
{identity}
<div className="flex-1 min-w-0">
<div className="cmt-meta">
<div className="cmt-meta-lead">
{nameRow}
{timeMeta(c)}
</div>
{floor ? (
<a href={`#comment-${floor}`} className="cmt-floor-label" aria-label={`楼层 ${floor}`}>
#{floor}
</a>
) : null}
</div>
{tombstoneBody}
<div className="cmt-actions">{tombstoneMenu}</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, floor, node))}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
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 (
<div
key={c.id}
id={floor ? `comment-${floor}-r${c.id}` : undefined}
data-comment-id={c.id}
className="cmt-reply-row scroll-mt-24"
>
<div className="flex gap-3">
{author ? (
<Link href={userPath(urlStyle, author.id)} className="shrink-0" aria-label={authorName}>
<Avatar name={authorName} src={author.avatar || undefined} size={32} />
</Link>
) : (
<Avatar name={authorName} size={32} />
)}
<div className="flex-1 min-w-0">
<div className="cmt-meta">
<div className="cmt-meta-lead">
{author ? (
<Link href={userPath(urlStyle, author.id)} className="cmt-name-link group">
<span className="cmt-name group-hover:text-[var(--accent)] transition-colors">
{authorName}
</span>
</Link>
) : (
<span className="cmt-name">{authorName}</span>
)}
{author && <LevelBadge level={author.level} />}
{author &&
(author.badges ?? []).slice(0, 2).map((ub) => (
<BadgeChip key={ub.id} badge={ub.badge} />
))}
{isOp && <span className="cmt-badge">楼主</span>}
{c.status === "pending" && <span className="badge-pending shrink-0">待审</span>}
{acceptedCommentId === c.id && <span className="badge-accepted shrink-0">已采纳</span>}
{timeMeta(c)}
{editedMark(c)}
{isReplyToReply && parent && (
<a href={`#comment-${floor}-r${parent.id}`} className="cmt-reply-target">
回复 {parentAuthor}
</a>
)}
</div>
</div>
{renderContentOrEditor(c)}
<div className="cmt-actions">
{commentsOpen && user && !replyBlocked && editingId !== c.id && (
<button
type="button"
onClick={() => openReply(c, floor)}
className={`cmt-reply-btn${replyTarget?.id === c.id ? " !text-[var(--accent)]" : ""}`}
aria-label={`回复 ${authorName}`}
data-cmt-focus="reply"
>
回复
</button>
)}
{canAccept(c) && (
<button
type="button"
className="cmt-reply-btn !text-[var(--accent)]"
disabled={acceptingId === c.id}
onClick={() => author && void handleAccept(c.id, author.id)}
data-tip={acceptMode === "question" ? "采纳为最佳答案" : "采纳并发放悬赏积分"}
>
{acceptingId === c.id ? "采纳中…" : acceptMode === "question" ? "采纳答案" : "采纳"}
</button>
)}
{canApprove(c) && (
<>
<button
type="button"
className="cmt-reply-btn !text-[var(--accent)]"
disabled={approvingId === c.id || rejecting}
onClick={() => void handleApprove(c.id)}
data-tip="通过评审,公开此评论"
>
{approvingId === c.id ? "通过中…" : "通过评审"}
</button>
<button
type="button"
className="cmt-reply-btn !text-[var(--danger)]"
disabled={approvingId === c.id || rejecting}
onClick={() => setRejectTargetId(c.id)}
data-tip="拒绝此评论,不公开"
>
拒绝
</button>
</>
)}
<CommentMoreMenu items={moreItemsFor(c)} />
</div>
</div>
</div>
{node.replies && node.replies.length > 0 && (
<div className="cmt-children">
{node.replies.map((r) => renderComment(r, depth + 1, floor, node))}
</div>
)}
</div>
);
}
// 主评论(楼层)
const isOp = (author?.id ?? c.user_id) === authorId;
return (
<div
key={c.id}
id={floor ? `comment-${floor}` : undefined}
data-comment-id={c.id}
className="cmt-item scroll-mt-24"
>
<div className="flex gap-3">
{author ? (
<Link href={userPath(urlStyle, author.id)} className="shrink-0" aria-label={authorName}>
<Avatar name={authorName} src={author.avatar || undefined} size={34} />
</Link>
) : (
<Avatar name={authorName} size={34} />
)}
<div className="flex-1 min-w-0">
<div className="cmt-meta">
<div className="cmt-meta-lead">
{author ? (
<Link href={userPath(urlStyle, author.id)} className="cmt-name-link group">
<span className="cmt-name group-hover:text-[var(--accent)] transition-colors">
{authorName}
</span>
</Link>
) : (
<span className="cmt-name">{authorName}</span>
)}
{author && <LevelBadge level={author.level} />}
{author &&
(author.badges ?? []).slice(0, 2).map((ub) => (
<BadgeChip key={ub.id} badge={ub.badge} />
))}
{isOp && <span className="cmt-badge">楼主</span>}
{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>}
{timeMeta(c)}
{editedMark(c)}
</div>
{floor ? (
<a href={`#comment-${floor}`} className="cmt-floor-label" aria-label={`楼层 ${floor}`}>
#{floor}
</a>
) : null}
</div>
{renderContentOrEditor(c)}
<div className="cmt-actions">
{commentsOpen && user && !replyBlocked && editingId !== c.id && (
<button
type="button"
onClick={() => openReply(c, floor)}
className={`cmt-reply-btn${replyTarget?.id === c.id ? " !text-[var(--accent)]" : ""}`}
aria-label={`回复 ${authorName}`}
data-cmt-focus="reply"
>
回复
</button>
)}
{canAccept(c) && (
<button
type="button"
className="cmt-reply-btn !text-[var(--accent)]"
disabled={acceptingId === c.id}
onClick={() => author && void handleAccept(c.id, author.id)}
data-tip={acceptMode === "question" ? "采纳为最佳答案" : "采纳并发放悬赏积分"}
>
{acceptingId === c.id ? "采纳中…" : acceptMode === "question" ? "采纳答案" : "采纳"}
</button>
)}
{canApprove(c) && (
<>
<button
type="button"
className="cmt-reply-btn !text-[var(--accent)]"
disabled={approvingId === c.id || rejecting}
onClick={() => void handleApprove(c.id)}
data-tip="通过评审,公开此评论"
>
{approvingId === c.id ? "通过中…" : "通过评审"}
</button>
<button
type="button"
className="cmt-reply-btn !text-[var(--danger)]"
disabled={approvingId === c.id || rejecting}
onClick={() => setRejectTargetId(c.id)}
data-tip="拒绝此评论,不公开"
>
拒绝
</button>
</>
)}
<CommentMoreMenu items={moreItemsFor(c)} />
</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, floor, node))}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
};
if (!commentsOpen) {
return <CommentsUnsupported />;
}
if (commentsRequireLogin && !user) {
return <CommentsLoginRequired redirect={`${postPath(urlStyle, Number(postId))}#comments`} />;
}
return (
<section
id="comments"
className="panel p-4 sm:p-6 lg:p-8 mt-4 sm:mt-6 scroll-mt-24 max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none"
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>
{totalPages > 1 ? (
<span className="meta ml-auto text-[12.5px]" style={{ color: "var(--ink-2)" }}>
第 {page} / {totalPages} 页
</span>
) : null}
</div>
{replyBlocked ? (
<div
className="mb-8 px-5 py-4 rounded-2xl flex items-center gap-2.5 border"
style={{ borderColor: "var(--line)", background: "transparent" }}
role="status"
>
<Lock size={15} style={{ color: "var(--ink-3)" }} className="shrink-0" />
<span className="text-sm" style={{ color: "var(--ink-2)" }}>
帖子已被锁定,暂时无法回复
</span>
</div>
) : user ? (
<form onSubmit={handleSubmit} className="mb-6">
<div className="cmt-md-editor cmt-md-editor-floor scroll-mt-28">
<MarkdownEditor
id="comment-input"
density="comment"
value={content}
onChange={(v) => {
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={
<button
type="submit"
disabled={submitting || floorUploading || !content.trim()}
aria-busy={submitting}
className="btn btn-primary cmt-submit"
>
{submitting ? "发表中…" : "发表评论"}
</button>
}
/>
</div>
{awayHref ? (
<p className="cmt-posted-note" role="status">
评论已发表,<Link href={awayHref}>查看我的评论</Link>
</p>
) : null}
</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(postPath(urlStyle, Number(postId)))}`} className="btn btn-line btn-sm">
去登录
</Link>
</div>
)}
<div>
{/* 回复框统一渲染在所属楼层块外侧:无论回复楼层还是任意深度子回复,
都按全宽显示,不受头像列与嵌套缩进挤压 */}
{comments.map((node, i) => (
<Fragment key={node.id}>
{renderComment(node, 0, (page - 1) * size + i + 1)}
{replyTarget?.rootId === node.id ? inlineReplyForm : null}
</Fragment>
))}
{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={postPath(urlStyle, Number(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);
}}
/>
<ConfirmDialog
open={discardReplyOpen}
title="放弃这条回复?"
message="可以保留草稿,稍后再写;放弃后将清空这次输入。"
confirmLabel="放弃"
cancelLabel="保留草稿"
danger
onConfirm={() => closeReply(true)}
onCancel={() => closeReply(false)}
/>
<ConfirmDialog
open={discardEditOpen}
title="放弃修改?"
message="取消后将恢复这条评论原来的内容。"
confirmLabel="放弃修改"
cancelLabel="继续编辑"
danger
onConfirm={finishCancelEdit}
onCancel={() => setDiscardEditOpen(false)}
/>
{/* 旧帖回复确认:确认后才发送并扣分(提示由后端按查看者下发) */}
<ConfirmDialog
open={necroConfirm !== null}
title="回复旧帖提醒"
message={`该帖已超过 ${necroReply?.after_hours ?? 0} 小时没有新回复,回复将扣除 ${necroReply?.penalty ?? 0} 积分。确定继续吗?`}
confirmLabel="继续回复"
onConfirm={() => {
const kind = necroConfirm;
setNecroConfirm(null);
if (kind === "floor") void submitFloor();
else if (kind === "reply") void submitReply();
}}
onCancel={() => setNecroConfirm(null)}
/>
<EditHistoryModal
open={historyCommentId != null}
title="评论编辑历史"
currentContent={historyCommentContent}
fetchPage={(p, s) => {
if (historyCommentId == null) return Promise.reject(new Error("未选择评论"));
return apiCommentHistory(historyCommentId, p, s);
}}
onClose={() => setHistoryCommentId(null)}
/>
</section>
);
}