diff --git a/backend/service/post_interact.go b/backend/service/post_interact.go index d74db4a..b603da2 100644 --- a/backend/service/post_interact.go +++ b/backend/service/post_interact.go @@ -327,13 +327,29 @@ func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) ( // ---- 详情附加状态 ---- +// AcceptedAnswer 问答/悬赏已采纳答案(详情页正文下展示;与评论分页无关) +type AcceptedAnswer struct { + ID uint `json:"id"` + Floor int `json:"floor"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` + Deleted bool `json:"deleted,omitempty"` + User struct { + ID uint `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + } `json:"user"` +} + type QuestionState struct { - Solved bool `json:"solved"` - AcceptedCommentID uint `json:"accepted_comment_id"` - AcceptedFloor int `json:"accepted_floor"` - CanAccept bool `json:"can_accept"` - CanSolve bool `json:"can_solve"` - CanReopen bool `json:"can_reopen"` + Solved bool `json:"solved"` + AcceptedCommentID uint `json:"accepted_comment_id"` + AcceptedFloor int `json:"accepted_floor"` + AcceptedAnswer *AcceptedAnswer `json:"accepted_answer,omitempty"` + CanAccept bool `json:"can_accept"` + CanSolve bool `json:"can_solve"` + CanReopen bool `json:"can_reopen"` } type PollVoter struct { @@ -365,23 +381,26 @@ type PollState struct { } type BountyState struct { - Points int `json:"points"` - AcceptedCommentID uint `json:"accepted_comment_id"` - AcceptedFloor int `json:"accepted_floor"` - Escrowed bool `json:"escrowed"` - Refunded bool `json:"refunded"` - Expired bool `json:"expired"` - EndsAt string `json:"ends_at,omitempty"` - Settled bool `json:"settled"` - CanAccept bool `json:"can_accept"` - CanRefund bool `json:"can_refund"` + Points int `json:"points"` + AcceptedCommentID uint `json:"accepted_comment_id"` + AcceptedFloor int `json:"accepted_floor"` + AcceptedAnswer *AcceptedAnswer `json:"accepted_answer,omitempty"` + Escrowed bool `json:"escrowed"` + Refunded bool `json:"refunded"` + Expired bool `json:"expired"` + EndsAt string `json:"ends_at,omitempty"` + Settled bool `json:"settled"` + CanAccept bool `json:"can_accept"` + CanRefund bool `json:"can_refund"` } type LotteryEntrant struct { - ID uint `json:"id"` - Nickname string `json:"nickname"` - Username string `json:"username"` - Avatar string `json:"avatar"` + ID uint `json:"id"` + Nickname string `json:"nickname"` + Username string `json:"username"` + Avatar string `json:"avatar"` + CommentID uint `json:"comment_id"` // 中奖楼层(该用户最早主评),每人仅一条 + Floor int `json:"floor"` } type LotteryState struct { @@ -424,10 +443,12 @@ func (s *PostService) buildQuestionState(post *model.Post, canManage bool) *Ques if err != nil || m == nil { m = &QuestionMeta{} } + floor := FloorNumber(s.db, post.ID, m.AcceptedCommentID) return &QuestionState{ Solved: m.Solved, AcceptedCommentID: m.AcceptedCommentID, - AcceptedFloor: FloorNumber(s.db, post.ID, m.AcceptedCommentID), + AcceptedFloor: floor, + AcceptedAnswer: s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor), CanAccept: canManage && !m.Solved, CanSolve: canManage && !m.Solved, CanReopen: canManage && m.Solved, @@ -530,10 +551,12 @@ func (s *PostService) buildBountyState(post *model.Post, canManage bool) *Bounty return nil } settled := m.AcceptedCommentID > 0 || m.Refunded || m.Expired + floor := FloorNumber(s.db, post.ID, m.AcceptedCommentID) return &BountyState{ Points: m.Points, AcceptedCommentID: m.AcceptedCommentID, - AcceptedFloor: FloorNumber(s.db, post.ID, m.AcceptedCommentID), + AcceptedFloor: floor, + AcceptedAnswer: s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor), Escrowed: m.Escrowed, Refunded: m.Refunded, Expired: m.Expired, @@ -544,6 +567,40 @@ func (s *PostService) buildBountyState(post *model.Post, canManage bool) *Bounty } } +// loadAcceptedAnswer 加载已采纳评论快照(公开可见正文);软删则清空正文并标记 deleted。 +func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int) *AcceptedAnswer { + if postID == 0 || commentID == 0 { + return nil + } + var c model.Comment + err := s.db.Unscoped().Preload("User"). + Where("id = ? AND post_id = ?", commentID, postID). + First(&c).Error + if err != nil { + return nil + } + // 待审评论不当作公开答案资料 + if c.Status != model.ContentStatusPublished && !c.DeletedAt.Valid { + return nil + } + ans := &AcceptedAnswer{ + ID: c.ID, + Floor: floor, + CreatedAt: c.CreatedAt, + } + ans.User.ID = c.User.ID + ans.User.Username = c.User.Username + ans.User.Nickname = c.User.Nickname + ans.User.Avatar = c.User.Avatar + if c.DeletedAt.Valid { + ans.Deleted = true + ans.Content = "" + return ans + } + ans.Content = c.Content + return ans +} + func (s *PostService) lotteryEligibleIDs(postID, authorID uint) ([]uint, error) { var ids []uint err := s.db.Model(&model.Comment{}). @@ -554,6 +611,23 @@ func (s *PostService) lotteryEligibleIDs(postID, authorID uint) ([]uint, error) return ids, err } +// lotteryWinningFloor 中奖用户的中奖楼层:其在本帖最早一条已发布主评论(每人仅一楼)。 +func (s *PostService) lotteryWinningFloor(postID, userID uint) (commentID uint, floor int) { + if postID == 0 || userID == 0 { + return 0, 0 + } + var c model.Comment + err := s.db.Select("id"). + Where("post_id = ? AND user_id = ? AND parent_id IS NULL AND status = ? AND deleted_at IS NULL", + postID, userID, model.ContentStatusPublished). + Order("created_at ASC, id ASC"). + First(&c).Error + if err != nil { + return 0, 0 + } + return c.ID, FloorNumber(s.db, postID, c.ID) +} + func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canManage bool) *LotteryState { m, err := parseLotteryMeta(post.TypeMeta) if err != nil { @@ -577,8 +651,14 @@ func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canMana } for _, id := range m.WinnerIDs { if u, ok := umap[id]; ok { + cid, floor := s.lotteryWinningFloor(post.ID, id) winners = append(winners, LotteryEntrant{ - ID: u.ID, Nickname: u.Nickname, Username: u.Username, Avatar: u.Avatar, + ID: u.ID, + Nickname: u.Nickname, + Username: u.Username, + Avatar: u.Avatar, + CommentID: cid, + Floor: floor, }) } } diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 6b73ce0..120ff4f 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -303,6 +303,64 @@ body { border-color: color-mix(in srgb, var(--ok) 30%, var(--line)); } +/* 正文下方已采纳答案:问答资料卡 */ +.j13-accepted-answer { + margin-top: 1.75rem; + padding: 1.15rem 1.25rem 1.25rem; + border-radius: 14px; + border: 1px solid color-mix(in srgb, var(--ok) 28%, var(--line)); + background: color-mix(in srgb, var(--ok) 7%, var(--panel)); + box-shadow: inset 3px 0 0 0 var(--ok); +} +.dark .j13-accepted-answer { + background: color-mix(in srgb, var(--ok) 12%, var(--panel)); + border-color: color-mix(in srgb, var(--ok) 36%, var(--line)); +} +.j13-accepted-answer__head { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 12px 16px; + margin-bottom: 0.9rem; + padding-bottom: 0.85rem; + border-bottom: 1px solid color-mix(in srgb, var(--ok) 18%, var(--line)); +} +.j13-accepted-answer__meta { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; + flex: 1; +} +.j13-accepted-answer__status { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--ink); + line-height: 1.3; +} +.j13-accepted-answer__author { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} +.j13-accepted-answer__body { + min-width: 0; +} +/* 正文下方类型特殊块(投票/悬赏/抽奖/问答状态) */ +.j13-interact-below { + margin-top: 1.75rem; +} +.j13-interact-below + .j13-accepted-answer, +.j13-interact-below + .j13-question-below { + margin-top: 1rem; +} +.j13-question-below { + margin-top: 1.75rem; +} + .j13-compose-field { width: 100%; padding: 10px 14px; @@ -1134,12 +1192,15 @@ a.j13-mention-token:hover { padding: 4px 11px; border-radius: 999px; white-space: nowrap; - background: var(--gold); - color: #ffffff; + /* 中奖:明亮类金色(不用偏棕的 --gold 实心白字) */ + background: color-mix(in srgb, #d4a017 22%, var(--panel)); + color: #8a6d00; + box-shadow: inset 0 0 0 1px color-mix(in srgb, #d4a017 42%, transparent); } .dark .badge-winner { - background: var(--gold); - color: #1a1204; + background: color-mix(in srgb, #e8c56b 26%, transparent); + color: #f5dfa0; + box-shadow: inset 0 0 0 1px color-mix(in srgb, #e8c56b 48%, transparent); } /* 站点公告列表 */ diff --git a/frontend/app/post/[id]/page.tsx b/frontend/app/post/[id]/page.tsx index d4e8b0d..d2743dd 100644 --- a/frontend/app/post/[id]/page.tsx +++ b/frontend/app/post/[id]/page.tsx @@ -35,7 +35,8 @@ import RoleBadge from "@/components/RoleBadge"; import MarkdownBody from "@/components/MarkdownBody"; import ContentLockGate from "@/components/ContentLockGate"; import PostAttachments from "@/components/PostAttachments"; -import PostInteractPanels from "@/components/PostInteractPanels"; +import PostInteractPanels, { QuestionStatusCard } from "@/components/PostInteractPanels"; +import AcceptedAnswerCard from "@/components/AcceptedAnswerCard"; import { canModerateBoard } from "@/lib/roles"; import { IconComment, IconEye } from "@/components/Icons"; import { normalizePostType, postTypeLabel, contentAccessLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta"; @@ -479,6 +480,24 @@ export default async function PostDetailPage({ params, searchParams }: PageProps ? ("bounty" as const) : null; + const acceptedAnswer = + post.question?.accepted_answer || post.bounty?.accepted_answer || null; + const acceptedAnswerVariant = + postType === "bounty" && post.bounty?.accepted_answer + ? ("bounty" as const) + : ("question" as const); + const questionState = + postType === "question" + ? post.question ?? { + solved: false, + accepted_comment_id: 0, + accepted_floor: 0, + can_accept: canManageTyped, + can_solve: canManageTyped, + can_reopen: false, + } + : null; + const jsonLd = { "@context": "https://schema.org", "@type": "DiscussionForumPosting", @@ -494,6 +513,20 @@ export default async function PostDetailPage({ params, searchParams }: PageProps interactionType: { "@type": "ReadAction" }, userInteractionCount: post.view_count, }, + ...(acceptedAnswer && + !acceptedAnswer.deleted && + !post.content_locked && { + acceptedAnswer: { + "@type": "Comment", + text: acceptedAnswer.content, + dateCreated: acceptedAnswer.created_at, + author: { + "@type": "Person", + name: acceptedAnswer.user.nickname || acceptedAnswer.user.username, + }, + url: `#comment-${acceptedAnswer.id}`, + }, + }), }; // 目录项与 Markdown 标题按渲染顺序一一对应(extractToc 顺序 == 文档顺序) @@ -650,26 +683,6 @@ export default async function PostDetailPage({ params, searchParams }: PageProps )} - - {post.content_locked ? ( )} + {/* 类型特殊块(投票/悬赏/抽奖/问答)统一放正文最下方 */} + + {!post.content_locked && acceptedAnswer ? ( + + ) : questionState ? ( + + ) : null} + {/* 文末点赞:分隔线后居中,唯一的点赞入口(作者行不再放置) */}
w.id) + ? (post.lottery.winners || []) + .map((w) => w.comment_id || 0) + .filter((id) => id > 0) : [] } /> diff --git a/frontend/components/AcceptedAnswerCard.tsx b/frontend/components/AcceptedAnswerCard.tsx new file mode 100644 index 0000000..e5b0c87 --- /dev/null +++ b/frontend/components/AcceptedAnswerCard.tsx @@ -0,0 +1,86 @@ +import Link from "next/link"; +import { CheckCircle2 } from "lucide-react"; +import type { AcceptedAnswer } from "@/lib/api"; +import Avatar from "@/components/Avatar"; +import MarkdownBody from "@/components/MarkdownBody"; +import AcceptedAnswerReopen from "@/components/AcceptedAnswerReopen"; + +/** 正文下方的已采纳答案卡:头像 + 昵称 + 全文;问答已解决时并入状态与「重新打开」 */ +export default async function AcceptedAnswerCard({ + answer, + variant = "question", + postId, + canReopen = false, +}: { + answer: AcceptedAnswer; + /** 问答用「最佳答案」,悬赏用「已采纳」 */ + variant?: "question" | "bounty"; + postId?: number; + canReopen?: boolean; +}) { + const name = answer.user.nickname || answer.user.username || "用户"; + const label = variant === "bounty" ? "已采纳" : "最佳答案"; + const showReopen = variant === "question" && canReopen && !!postId; + + return ( +
+
+
+ {variant === "question" && ( +

问答 · 已解决

+ )} +
+ + + {label} + + + + +
+ + {name} + +

+ + {answer.floor > 0 && ( + <> + · + + #{answer.floor} + + + )} +

+
+
+
+ {showReopen && } +
+ +
+ {answer.deleted ? ( +

+ 该答案已被删除 +

+ ) : ( + + )} +
+
+ ); +} diff --git a/frontend/components/AcceptedAnswerReopen.tsx b/frontend/components/AcceptedAnswerReopen.tsx new file mode 100644 index 0000000..5189442 --- /dev/null +++ b/frontend/components/AcceptedAnswerReopen.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { apiReopenQuestion } from "@/lib/api"; +import { toast } from "@/lib/toast"; + +/** 已采纳答案卡上的「重新打开」——并入正文下方资料卡,避免与上方状态条重复 */ +export default function AcceptedAnswerReopen({ postId }: { postId: number }) { + const router = useRouter(); + const [busy, setBusy] = useState(false); + + const reopen = async () => { + setBusy(true); + try { + const res = await apiReopenQuestion(String(postId)); + if (res.post) { + toast("已重新打开", "ok"); + router.refresh(); + } else toast(res.error || "操作失败", "error"); + } finally { + setBusy(false); + } + }; + + return ( + + ); +} diff --git a/frontend/components/CommentSection.tsx b/frontend/components/CommentSection.tsx index 0075bba..42a861a 100644 --- a/frontend/components/CommentSection.tsx +++ b/frontend/components/CommentSection.tsx @@ -55,8 +55,8 @@ interface CommentSectionProps { /** 悬赏或问答:可采纳时在评论旁显示采纳按钮(二者按帖子类型二选一) */ acceptMode?: "bounty" | "question" | null; acceptedCommentId?: number; - /** 抽奖已开奖:中奖用户 ID,用于楼层旁显示「中奖」徽标 */ - winnerUserIds?: number[]; + /** 抽奖已开奖:中奖楼层评论 ID(每名中奖者仅其最早主评一条) */ + winnerCommentIds?: number[]; /** 全站是否开放评论;缺省 true。关闭时隐藏发评/回复入口 */ allowComments?: boolean; } @@ -115,10 +115,10 @@ export default function CommentSection({ user, acceptMode = null, acceptedCommentId = 0, - winnerUserIds = [], + winnerCommentIds = [], allowComments: allowCommentsProp = true, }: CommentSectionProps) { - const winnerIdSet = new Set(winnerUserIds.filter((id) => id > 0)); + const winnerCommentIdSet = new Set(winnerCommentIds.filter((id) => id > 0)); const router = useRouter(); const [content, setContent] = useState(""); // 内联回复目标:在该评论下方就地发子评论(同一时间只有一个回复框) @@ -875,7 +875,7 @@ export default function CommentSection({ {acceptedCommentId === c.id && ( 已采纳 )} - {winnerIdSet.has(c.user.id) && ( + {winnerCommentIdSet.has(c.id) && ( 中奖楼层 )} {floor && ( diff --git a/frontend/components/PostInteractPanels.tsx b/frontend/components/PostInteractPanels.tsx index 80c029c..8780a4e 100644 --- a/frontend/components/PostInteractPanels.tsx +++ b/frontend/components/PostInteractPanels.tsx @@ -21,23 +21,21 @@ import Avatar from "@/components/Avatar"; export default function PostInteractPanels({ postId, - question, poll, bounty, lottery, loggedIn, }: { postId: number; - question?: QuestionState | null; poll?: PollState | null; bounty?: BountyState | null; lottery?: LotteryState | null; loggedIn: boolean; }) { - if (!question && !poll && !bounty && !lottery) return null; + // 问答状态改在正文下方展示(未解决 / 已解决资料卡),此处不再放 + if (!poll && !bounty && !lottery) return null; return ( -
- {question && } +
{poll && } {bounty && } {lottery && } @@ -54,7 +52,8 @@ function formatEndsAt(iso?: string) { } } -function QuestionPanel({ postId, state }: { postId: number; state: QuestionState }) { +/** 正文下方的问答状态卡(未解决 / 仅标记已解决);有采纳答案时改用 AcceptedAnswerCard */ +export function QuestionStatusCard({ postId, state }: { postId: number; state: QuestionState }) { const router = useRouter(); const [busy, setBusy] = useState(false); @@ -72,7 +71,10 @@ function QuestionPanel({ postId, state }: { postId: number; state: QuestionState }; return ( -
+

@@ -319,20 +321,22 @@ function BountyPanel({ postId, state }: { postId: number; state: BountyState }) 悬赏 {state.points} 积分

- {state.accepted_comment_id ? ( - <> - 已采纳{" "} - - #{state.accepted_floor || "?"} - - - ) : ( - statusText - )} + {state.accepted_answer + ? "悬赏积分已发放给采纳答案" + : state.accepted_comment_id + ? ( + <> + 已采纳{" "} + + #{state.accepted_floor || "?"} + + + ) + : statusText} {state.ends_at && !state.settled ? ` · 截止 ${formatEndsAt(state.ends_at)}` : ""}

@@ -426,13 +430,20 @@ function LotteryPanel({
    {state.winners.map((w) => (
  • - - - {w.nickname || w.username} - + + + + {w.nickname || w.username} + + {w.comment_id && w.floor ? ( + + #{w.floor} + + ) : null} +
  • ))}
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 906ba2e..1175159 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -235,10 +235,25 @@ export interface Post { deleted_at?: string; } +export interface AcceptedAnswer { + id: number; + floor: number; + content: string; + created_at: string; + deleted?: boolean; + user: { + id: number; + username: string; + nickname: string; + avatar: string; + }; +} + export interface QuestionState { solved: boolean; accepted_comment_id: number; accepted_floor: number; + accepted_answer?: AcceptedAnswer | null; can_accept: boolean; can_solve: boolean; can_reopen: boolean; @@ -276,6 +291,7 @@ export interface BountyState { points: number; accepted_comment_id: number; accepted_floor: number; + accepted_answer?: AcceptedAnswer | null; escrowed: boolean; refunded: boolean; expired?: boolean; @@ -290,6 +306,9 @@ export interface LotteryEntrant { nickname: string; username: string; avatar: string; + /** 中奖楼层评论 ID(该用户最早主评,每人仅一条) */ + comment_id?: number; + floor?: number; } export interface LotteryState {