(`[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 (
{
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));
// 内联回复表单:渲染在所属楼层块外侧(见下方列表 map),任意回复层级均全宽;全站关评时不渲染
const inlineReplyForm = commentsOpen && replyTarget && (
);
// 单条评论渲染(递归):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 ? (
) : null}
{selfReasonForStaff ? (
自删理由 · 仅管理可见
{c.delete_reason}
) : null}
{c.content ? (
{isMod && user?.id !== author?.id ? "原文 · 仅管理可见" : "原文 · 仅你可见"}
) : null}
{c.staff_deleted ? (
管理删除
) : (
评论者删除
)}
);
const tombstoneMenu = ;
if (!isFloor) {
return (
{identity}
{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) && (
<>
>
)}
{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) && (
<>
>
)}
{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}暂无评论
来说两句,抢占沙发