feat: 类型帖互动、积分经济与发帖体验
补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user