Files
jiang13-bbs/frontend/components/CommentSection.tsx
freefire 0e61a14ea3 polish(frontend): 修正中文字重发虚,并疏朗首页与数据区
补齐 Noto 600/800 并关闭伪粗;Header 加大加粗导航并加板块入口;去掉灰块底、加大列表行距,站点数据格居中缩小数字。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 04:10:14 +08:00

518 lines
20 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 { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Trash2, MessagesSquare, ChevronRight, ChevronDown } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { apiCreateComment, apiDeleteComment, apiAcceptBounty, apiAcceptQuestion, type CommentNode, type User } from "@/lib/api";
import { canModerateBoard } from "@/lib/roles";
import { toast } from "@/lib/toast";
import Avatar from "./Avatar";
import Pagination from "./Pagination";
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;
}
// 删除按钮的两段式确认:点一下进入确认态,3 秒未确认自动复原
const CONFIRM_RESET_MS = 3000;
// 递归统计子树回复数(删除级联提示用)
function countReplies(node: CommentNode): number {
return node.replies?.reduce((acc, r) => acc + 1 + countReplies(r), 0) ?? 0;
}
export default function CommentSection({
postId,
boardId,
authorId,
comments,
total,
totalComments,
page,
size,
user,
acceptMode = null,
acceptedCommentId = 0,
}: CommentSectionProps) {
const router = useRouter();
const [content, setContent] = useState("");
// 内联回复目标:在该评论下方就地发子评论(同一时间只有一个回复框)
const [replyTarget, setReplyTarget] = useState<{ id: number; nickname: string } | null>(null);
const [replyContent, setReplyContent] = useState("");
const [submitting, setSubmitting] = useState(false);
const [deletingId, setDeletingId] = useState<number | null>(null);
const [confirmingId, setConfirmingId] = useState<number | null>(null);
const [acceptingId, setAcceptingId] = useState<number | null>(null);
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim()) return;
setSubmitting(true);
try {
const res = await apiCreateComment(postId, content);
if (res.comment) {
setContent("");
if (res.comment.status === "pending") {
toast("已提交,等待审核通过后公开");
router.refresh();
} else {
// 新楼层在最后一页:刷新后由服务端直出最新分页
router.refresh();
}
} else if (res.error === "未登录") {
router.push(`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`);
} else {
toast(res.error || "评论失败");
}
} catch {
toast("网络错误,评论失败");
} finally {
setSubmitting(false);
}
};
const handleSubmitReply = async (e: React.FormEvent) => {
e.preventDefault();
if (!replyTarget || !replyContent.trim()) return;
setSubmitting(true);
try {
const res = await apiCreateComment(postId, replyContent, replyTarget.id);
if (res.comment) {
setReplyTarget(null);
setReplyContent("");
if (res.comment.status === "pending") {
toast("已提交,等待审核通过后公开");
router.refresh();
} else {
// 刷新后由 SSR 直出新树(子评论全量挂在本楼)
router.refresh();
}
} else if (res.error === "未登录") {
router.push(`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`);
} else {
toast(res.error || "回复失败");
}
} catch {
toast("网络错误,回复失败");
} finally {
setSubmitting(false);
}
};
const handleDelete = async (commentId: number) => {
if (confirmingId !== commentId) {
setConfirmingId(commentId);
setTimeout(() => {
setConfirmingId((cur) => (cur === commentId ? null : cur));
}, CONFIRM_RESET_MS);
return;
}
setConfirmingId(null);
setDeletingId(commentId);
try {
const res = await apiDeleteComment(postId, commentId);
if (res.message) {
router.refresh();
} else {
toast(res.error || "删除失败");
}
} catch {
toast("网络错误,删除失败");
} finally {
setDeletingId(null);
}
};
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 canDelete = (comment: CommentNode) =>
!!user && (user.id === comment.user.id || canModerateBoard(user, boardId));
const canAccept = (comment: CommentNode) =>
!!acceptMode &&
!!user &&
comment.status === "published" &&
comment.user.id !== authorId;
const openReply = (c: CommentNode) => {
setReplyTarget({ id: c.id, nickname: c.user.nickname || c.user.username });
setReplyContent("");
};
// 切换某楼层子回复的展开/折叠
const toggleFloor = (floorId: number) => {
setExpandedFloors((prev) => {
const next = new Set(prev);
if (next.has(floorId)) next.delete(floorId);
else next.add(floorId);
return next;
});
};
const totalPages = Math.max(1, Math.ceil(total / size));
// 内联回复表单(出现在目标评论正下方)
const inlineReplyForm = replyTarget && (
<form onSubmit={handleSubmitReply} className="cmt-reply-form mt-3">
<div className="text-[12px] mb-2" style={{ color: "var(--ink-3)" }}>
回复 <span style={{ color: "var(--accent)" }}>@{replyTarget.nickname}</span>
</div>
<textarea
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
}}
rows={2}
autoFocus
className="field"
aria-label={`回复 ${replyTarget.nickname}`}
placeholder="友善回复..."
/>
<div className="flex items-center justify-between gap-3 mt-2">
<span className="meta text-[11.5px]">支持 Markdown · Ctrl + Enter 发送</span>
<div className="flex gap-2">
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => {
setReplyTarget(null);
setReplyContent("");
}}
>
取消
</button>
<button type="submit" disabled={submitting || !replyContent.trim()} className="btn btn-primary btn-sm">
{submitting ? "发送中..." : "回复"}
</button>
</div>
</div>
</form>
);
// 单条评论渲染(递归):depth 0 = 楼层主评论,>0 = 子回复
// 主评论下方子回复默认折叠,点击"展开 X 条回复"展开
// depth=1(回复楼层)不显示指向;depth≥2(回复子评论)显示"回复者 ▶ 被回复者"
const renderComment = (node: CommentNode, depth: number, floor?: number, parent?: CommentNode) => {
const c = node;
const replyCount = countReplies(node);
const isFloor = depth === 0;
const isExpanded = isFloor ? expandedFloors.has(c.id) : true;
const parentAuthor = parent?.user.nickname || parent?.user.username;
if (!isFloor) {
// 子回复:紧凑行式布局(抖音风格)
const isReplyToReply = depth >= 2;
return (
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24">
<div className="flex gap-2.5">
<Link href={`/u/${c.user.id}`} className="shrink-0" aria-label={c.user.nickname || c.user.username}>
<Avatar name={c.user.nickname || c.user.username} src={c.user.avatar || undefined} size={28} />
</Link>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<Link href={`/u/${c.user.id}`} className="group shrink-0">
<span className="cmt-reply-name group-hover:text-[var(--accent)] transition-colors">
{c.user.nickname || c.user.username}
</span>
</Link>
{c.status === "pending" && <span className="badge-pending shrink-0 !text-[10px] !py-0.5 !px-1.5">待审</span>}
{isReplyToReply && parent && (
<>
<ChevronRight size={12} className="cmt-reply-arrow" aria-hidden="true" />
<a href={`#comment-${parent.id}`} className="cmt-reply-target">
{parentAuthor}
</a>
</>
)}
</div>
<div className="prose-content prose-comment mt-0.5">
<ReactMarkdown>{c.content}</ReactMarkdown>
</div>
{/* 底部行:时间 · 回复(左),删除(右) */}
<div className="flex items-center justify-between mt-1.5">
<div className="flex items-center gap-3">
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
{user && (
<button
onClick={() => openReply(c)}
className={`cmt-reply-btn transition-colors ${
replyTarget?.id === c.id ? "!text-[var(--accent)]" : ""
}`}
aria-label={`回复 ${c.user.nickname || c.user.username}`}
data-tip="回复"
>
回复
</button>
)}
{acceptedCommentId === c.id && (
<span className="badge-type !text-[10px] !py-0.5 !px-1.5">已采纳</span>
)}
{canAccept(c) && (
<button
type="button"
className="cmt-reply-btn !text-[var(--accent)]"
disabled={acceptingId === c.id}
onClick={() => void handleAccept(c.id, c.user.id)}
data-tip={acceptMode === "question" ? "采纳为最佳答案" : "采纳并发放悬赏积分"}
>
{acceptingId === c.id ? "采纳中…" : acceptMode === "question" ? "采纳答案" : "采纳"}
</button>
)}
</div>
{canDelete(c) && (
<button
onClick={() => handleDelete(c.id)}
disabled={deletingId === c.id}
className={`meta transition-colors disabled:opacity-50 p-1 ${
confirmingId === c.id ? "!text-[var(--danger)]" : "hover:text-[var(--danger)]"
}`}
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
data-tip={confirmingId === c.id ? "再点一次确认删除" : "删除"}
>
{confirmingId === c.id ? (
<span className="text-[11.5px] whitespace-nowrap">
确认删除?{countReplies(c) > 0 && `(含 ${countReplies(c)} 条回复)`}
</span>
) : (
<Trash2 size={14} />
)}
</button>
)}
</div>
{replyTarget?.id === c.id && inlineReplyForm}
</div>
</div>
{node.replies && node.replies.length > 0 && (
<div className="cmt-children">
{node.replies.map((r) => renderComment(r, depth + 1, undefined, node))}
</div>
)}
</div>
);
}
// 主评论(楼层)
return (
<div
key={c.id}
id={`comment-${c.id}`}
className="py-5 border-t first:border-t-0 scroll-mt-24"
style={{ borderColor: "var(--line)" }}
>
<div className="flex gap-3">
<Link href={`/u/${c.user.id}`} className="shrink-0 mt-0.5" aria-label={c.user.nickname || c.user.username}>
<Avatar name={c.user.nickname || c.user.username} src={c.user.avatar || undefined} size={38} />
</Link>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 mb-1.5">
<Link href={`/u/${c.user.id}`} className="group">
<span className="truncate group-hover:text-[var(--accent)] transition-colors text-sm" style={{ color: "var(--ink)" }}>
{c.user.nickname || c.user.username}
</span>
</Link>
{c.status === "pending" && <span className="badge-pending shrink-0">待审</span>}
{floor && (
<a href={`#comment-${c.id}`} className="cmt-floor-label" aria-label={`楼层 ${floor}`}>
#{floor}
</a>
)}
</div>
<div className="prose-content prose-comment">
<ReactMarkdown>{c.content}</ReactMarkdown>
</div>
{/* 底部行:时间 · 回复(左),删除(右) */}
<div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-3">
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
{user && (
<button
onClick={() => openReply(c)}
className={`cmt-reply-btn transition-colors ${
replyTarget?.id === c.id ? "!text-[var(--accent)]" : ""
}`}
aria-label={`回复 ${c.user.nickname || c.user.username}`}
data-tip="回复"
>
回复
</button>
)}
{acceptedCommentId === c.id && (
<span className="badge-type !text-[10px] !py-0.5 !px-1.5">已采纳</span>
)}
{canAccept(c) && (
<button
type="button"
className="cmt-reply-btn !text-[var(--accent)]"
disabled={acceptingId === c.id}
onClick={() => void handleAccept(c.id, c.user.id)}
data-tip={acceptMode === "question" ? "采纳为最佳答案" : "采纳并发放悬赏积分"}
>
{acceptingId === c.id ? "采纳中…" : acceptMode === "question" ? "采纳答案" : "采纳"}
</button>
)}
</div>
{canDelete(c) && (
<button
onClick={() => handleDelete(c.id)}
disabled={deletingId === c.id}
className={`meta transition-colors disabled:opacity-50 p-1 ${
confirmingId === c.id ? "!text-[var(--danger)]" : "hover:text-[var(--danger)]"
}`}
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
data-tip={confirmingId === c.id ? "再点一次确认删除" : "删除"}
>
{confirmingId === c.id ? (
<span className="text-[11.5px] whitespace-nowrap">
确认删除?{countReplies(c) > 0 && `(含 ${countReplies(c)} 条回复)`}
</span>
) : (
<Trash2 size={14} />
)}
</button>
)}
</div>
{replyTarget?.id === c.id && inlineReplyForm}
{/* 折叠/展开子回复 */}
{node.replies && node.replies.length > 0 && (
<div className="mt-3">
<button
type="button"
onClick={() => toggleFloor(c.id)}
className="cmt-fold-btn"
aria-expanded={isExpanded}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span>
{isExpanded ? "收起回复" : `展开 ${replyCount} 条回复`}
</span>
</button>
{isExpanded && (
<div className="cmt-replies-wrap">
{node.replies.map((r) => renderComment(r, 1, undefined, node))}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
};
return (
<section className="panel p-6 sm:p-8 mt-6" aria-label="评论区">
<div className="flex items-center gap-2.5 mb-6">
<h2 className="text-lg font-semibold" style={{ color: "var(--ink)" }}>
评论
</h2>
<span
className="text-[12px] font-medium px-2 py-0.5 rounded-full tabular-nums"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
{totalComments}
</span>
<span className="meta ml-auto text-[11.5px]">共 {totalPages} 页楼层</span>
</div>
{user ? (
<form onSubmit={handleSubmit} className="mb-8">
<textarea
ref={textareaRef}
id="comment-input"
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
}}
placeholder="友善发言,发表一个新楼层..."
rows={3}
className="field"
aria-label="写评论"
/>
<div className="flex items-center justify-between gap-3 mt-2.5">
<span className="meta text-[11.5px]">支持 Markdown · Ctrl + Enter 发送</span>
<button type="submit" disabled={submitting || !content.trim()} className="btn btn-primary">
{submitting ? "发送中..." : "发表评论"}
</button>
</div>
</form>
) : (
<div
className="mb-8 px-5 py-4 rounded-2xl flex items-center justify-between gap-4 flex-wrap border"
style={{ borderColor: "var(--line)", background: "transparent" }}
>
<span className="text-sm" style={{ color: "var(--ink-2)" }}>
登录后参与讨论
</span>
<Link href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`} className="btn btn-line btn-sm">
去登录
</Link>
</div>
)}
<div>
{comments.map((node, i) => renderComment(node, 0, (page - 1) * size + i + 1))}
{comments.length === 0 && (
<div className="text-center py-14">
<MessagesSquare size={28} className="mx-auto mb-3" style={{ color: "var(--ink-3)" }} />
<p className="text-sm" style={{ color: "var(--ink-2)" }}>
暂无评论
</p>
<p className="meta text-[12.5px] mt-1">来说两句,抢占沙发</p>
</div>
)}
</div>
<Pagination total={total} page={page} size={size} basePath={`/post/${postId}`} />
</section>
);
}