feat: 类型帖互动、积分经济与发帖体验

补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 03:17:07 +08:00
parent 1ef3d8e299
commit b9ba8cb8c9
58 changed files with 6913 additions and 506 deletions

View File

@@ -5,7 +5,7 @@ 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, type CommentNode, type User } from "@/lib/api";
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";
@@ -15,6 +15,8 @@ interface CommentSectionProps {
postId: string;
/** 帖子所属板块:板块管理员仅可管理授权板块的评论 */
boardId: number;
/** 楼主 ID:不可采纳楼主自己的评论 */
authorId: number;
comments: CommentNode[];
total: number; // 楼层数(主评论数)→ 分页与楼层号计算
totalComments: number; // 全部评论数(含回复)→ 头部徽标
@@ -22,6 +24,9 @@ interface CommentSectionProps {
size: number;
// SSR 直出的登录用户(layout 已取,避免客户端再请求一次 /api/me)
user: User | null;
/** 悬赏或问答:可采纳时在评论旁显示采纳按钮(二者按帖子类型二选一) */
acceptMode?: "bounty" | "question" | null;
acceptedCommentId?: number;
}
// 删除按钮的两段式确认:点一下进入确认态,3 秒未确认自动复原
@@ -35,12 +40,15 @@ function countReplies(node: CommentNode): number {
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("");
@@ -50,6 +58,7 @@ export default function CommentSection({
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);
@@ -133,11 +142,38 @@ export default function CommentSection({
}
};
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("");
@@ -247,11 +283,25 @@ export default function CommentSection({
replyTarget?.id === c.id ? "!text-[var(--accent)] font-semibold" : ""
}`}
aria-label={`回复 ${c.user.nickname || c.user.username}`}
title="回复"
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)] font-semibold"
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
@@ -261,7 +311,7 @@ export default function CommentSection({
confirmingId === c.id ? "!text-[var(--danger)] font-semibold" : "hover:text-[var(--danger)]"
}`}
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
title={confirmingId === c.id ? "再点一次确认删除" : "删除"}
data-tip={confirmingId === c.id ? "再点一次确认删除" : "删除"}
>
{confirmingId === c.id ? (
<span className="text-[11.5px] whitespace-nowrap">
@@ -325,11 +375,25 @@ export default function CommentSection({
replyTarget?.id === c.id ? "!text-[var(--accent)] font-semibold" : ""
}`}
aria-label={`回复 ${c.user.nickname || c.user.username}`}
title="回复"
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)] font-semibold"
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
@@ -339,7 +403,7 @@ export default function CommentSection({
confirmingId === c.id ? "!text-[var(--danger)] font-semibold" : "hover:text-[var(--danger)]"
}`}
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
title={confirmingId === c.id ? "再点一次确认删除" : "删除"}
data-tip={confirmingId === c.id ? "再点一次确认删除" : "删除"}
>
{confirmingId === c.id ? (
<span className="text-[11.5px] whitespace-nowrap">