- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接 - 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配 - 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成 - middleware 与相关组件适配
1515 lines
53 KiB
TypeScript
1515 lines
53 KiB
TypeScript
"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";
|
||
import { postPath, userPath } 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>(`#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 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-{id} → 滚动 + 主题色浅底高亮渐隐。
|
||
// 目标是折叠楼层里的子回复时先展开所属楼层;不在当前页时查楼层号换页后重试。
|
||
// 楼层号锚点(#楼)、「回复 @xx」、采纳答案卡等页内跳转经 hashchange 同样获得高亮。
|
||
const locatedRef = useRef<number | null>(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(`${postPath(urlStyle, Number(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
|
||
? `${postPath(urlStyle, Number(postId))}?page=${destPage}#comment-${res.comment.id}`
|
||
: `${postPath(urlStyle, Number(postId))}#comment-${res.comment.id}`
|
||
);
|
||
} 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));
|
||
|
||
// 内联回复表单(出现在目标评论正下方;全站关评时不渲染)
|
||
const inlineReplyForm = commentsOpen && replyTarget && (
|
||
<form onSubmit={handleSubmitReply} className="cmt-reply-form mt-2">
|
||
<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={`comment-${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={`comment-${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-${c.id}`} 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={`comment-${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-${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>
|
||
{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, floor, node))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 主评论(楼层)
|
||
const isOp = (author?.id ?? c.user_id) === authorId;
|
||
return (
|
||
<div key={c.id} id={`comment-${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-${c.id}`} 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>
|
||
{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, 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) => 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">来说两句,抢占沙发</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>
|
||
);
|
||
}
|