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
)}
-
问答 · 已解决
+ )} ++ + {answer.floor > 0 && ( + <> + · + + #{answer.floor} + + > + )} +
++ 该答案已被删除 +
+ ) : ( +- {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)}` : ""}