diff --git a/backend/model/models.go b/backend/model/models.go
index b2fb726..eb7e1dd 100644
--- a/backend/model/models.go
+++ b/backend/model/models.go
@@ -306,7 +306,7 @@ type PostAttachmentUnlock struct {
type Comment struct {
ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"index;not null" json:"post_id"`
- UserID uint `gorm:"index;not null" json:"user_id"`
+ UserID uint `gorm:"index;not null" json:"user_id,omitempty"`
ParentID *uint `gorm:"index" json:"parent_id"` // 父评论 ID,NULL = 主评论(楼层)
RootID *uint `gorm:"index" json:"root_id"` // 所属楼层(顶层主评论)ID,子评论必填
Depth int `gorm:"not null;default:0" json:"depth"` // 层级:主评论 0,子评论 = 父 + 1
diff --git a/backend/service/comment.go b/backend/service/comment.go
index 51bcc9d..bb4b80f 100644
--- a/backend/service/comment.go
+++ b/backend/service/comment.go
@@ -32,11 +32,13 @@ func NewCommentService(db *gorm.DB) *CommentService {
// CommentNode 评论树节点:嵌入 model.Comment 使 JSON 字段拍平,replies 为空时省略
type CommentNode struct {
model.Comment
- Deleted bool `json:"deleted,omitempty"` // 软删占位
- StaffDeleted bool `json:"staff_deleted,omitempty"` // DeletedBy≠评论者
- Edited bool `json:"edited,omitempty"` // 相对创建已编辑
+ Deleted bool `json:"deleted,omitempty"` // 软删占位
+ StaffDeleted bool `json:"staff_deleted,omitempty"` // DeletedBy≠评论者
+ Edited bool `json:"edited,omitempty"` // 相对创建已编辑
DeletedByUser *CommentActorBrief `json:"deleted_by_user,omitempty"` // 管理删执行者(公开)
Replies []CommentNode `json:"replies,omitempty"`
+ // 覆盖嵌入的 User:指针 + omitempty,管理删对公众可整段省略(避免空对象仍进 JSON/DOM)
+ User *model.User `json:"user,omitempty"`
}
// CommentActorBrief 评论相关公开用户摘要(删评管理员链接等)
@@ -119,14 +121,14 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A
build = func(pid uint) []CommentNode {
out := []CommentNode{}
for _, c := range children[pid] {
- out = append(out, toCommentNode(c, build(c.ID), revealAudit, deleters))
+ out = append(out, toCommentNode(c, build(c.ID), revealAudit, viewerID, deleters))
placed[c.ID] = true
}
return out
}
result := make([]CommentNode, len(roots))
for i := range roots {
- result[i] = toCommentNode(roots[i], build(roots[i].ID), revealAudit, deleters)
+ result[i] = toCommentNode(roots[i], build(roots[i].ID), revealAudit, viewerID, deleters)
placed[roots[i].ID] = true
}
for _, r := range replies {
@@ -135,7 +137,7 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A
}
for i := range result {
if result[i].ID == *r.RootID {
- result[i].Replies = append(result[i].Replies, toCommentNode(r, build(r.ID), revealAudit, deleters))
+ result[i].Replies = append(result[i].Replies, toCommentNode(r, build(r.ID), revealAudit, viewerID, deleters))
break
}
}
@@ -178,14 +180,25 @@ func (s *CommentService) loadCommentActorsByIDs(ids []uint) map[uint]CommentActo
return out
}
-// toCommentNode 软删占位:清空公开正文;版主保留原文;自删理由仅版主可见。
-func toCommentNode(c model.Comment, replies []CommentNode, revealAudit bool, deleters map[uint]CommentActorBrief) CommentNode {
+// commentAuthorPtr 有预加载作者时返回拷贝,供 JSON omitempty 整段省略。
+func commentAuthorPtr(c model.Comment) *model.User {
+ if c.User.ID == 0 {
+ return nil
+ }
+ u := c.User
+ return &u
+}
+
+// toCommentNode 软删占位:公众清空正文;版主与评论者本人保留原文。
+// 自删理由仅版主可见。管理删:公众与其他登录用户不返回评论者身份。
+func toCommentNode(c model.Comment, replies []CommentNode, revealAudit bool, viewerID uint, deleters map[uint]CommentActorBrief) CommentNode {
edited := c.UpdatedAt.Sub(c.CreatedAt) > time.Minute
if !c.DeletedAt.Valid {
- return CommentNode{Comment: c, Edited: edited, Replies: replies}
+ return CommentNode{Comment: c, Edited: edited, Replies: replies, User: commentAuthorPtr(c)}
}
staff := c.DeletedBy != 0 && c.DeletedBy != c.UserID
- if !revealAudit {
+ isCommenter := viewerID != 0 && viewerID == c.UserID
+ if !revealAudit && !isCommenter {
c.Content = ""
}
if !staff {
@@ -201,6 +214,11 @@ func toCommentNode(c model.Comment, replies []CommentNode, revealAudit bool, del
deleter = &cp
}
}
+ // 版主审计、评论者本人仍可见身份;其余观众剥离
+ if staff && !revealAudit && !isCommenter {
+ c.UserID = 0
+ c.User = model.User{}
+ }
return CommentNode{
Comment: c,
Deleted: true,
@@ -208,6 +226,7 @@ func toCommentNode(c model.Comment, replies []CommentNode, revealAudit bool, del
Edited: edited,
DeletedByUser: deleter,
Replies: replies,
+ User: commentAuthorPtr(c),
}
}
@@ -356,7 +375,7 @@ func (s *CommentService) ListByUser(userID uint, page, size int) ([]UserCommentI
query := s.db.Table("comments").
Select("comments.id, comments.post_id, posts.title AS post_title, comments.content, comments.created_at").
Joins("JOIN posts ON posts.id = comments.post_id").
- Where("comments.user_id = ? AND comments.status = ? AND posts.deleted_at IS NULL", userID, model.ContentStatusPublished)
+ Where("comments.user_id = ? AND comments.status = ? AND comments.deleted_at IS NULL AND posts.deleted_at IS NULL", userID, model.ContentStatusPublished)
var total int64
if err := query.Count(&total).Error; err != nil {
@@ -378,7 +397,7 @@ func (s *CommentService) CountByUser(userID uint) (int64, error) {
var total int64
err := s.db.Table("comments").
Joins("JOIN posts ON posts.id = comments.post_id").
- Where("comments.user_id = ? AND comments.status = ? AND posts.deleted_at IS NULL", userID, model.ContentStatusPublished).
+ Where("comments.user_id = ? AND comments.status = ? AND comments.deleted_at IS NULL AND posts.deleted_at IS NULL", userID, model.ContentStatusPublished).
Count(&total).Error
return total, err
}
@@ -603,16 +622,16 @@ func (s *CommentService) Update(actor *Actor, commentID, userID uint, content st
if err := s.db.Preload("User").First(&comment, comment.ID).Error; err != nil {
return nil, err
}
- node := toCommentNode(comment, nil, false, nil)
+ node := toCommentNode(comment, nil, false, userID, nil)
return &node, nil
}
// CommentEditHistoryItem 管理可见的评论修订记录
type CommentEditHistoryItem struct {
- ID uint `json:"id"`
- Editor CommentActorBrief `json:"editor"`
- OldContent string `json:"old_content"`
- CreatedAt time.Time `json:"created_at"`
+ ID uint `json:"id"`
+ Editor CommentActorBrief `json:"editor"`
+ OldContent string `json:"old_content"`
+ CreatedAt time.Time `json:"created_at"`
}
// ListEditHistory 评论编辑历史(仅版主;含软删评论;按创建时间倒序分页)
diff --git a/backend/service/comment_node_test.go b/backend/service/comment_node_test.go
new file mode 100644
index 0000000..2b39d80
--- /dev/null
+++ b/backend/service/comment_node_test.go
@@ -0,0 +1,125 @@
+package service
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/freefire/jiang13-bbs/model"
+ "gorm.io/gorm"
+)
+
+func staffDeletedComment() model.Comment {
+ uid := uint(7)
+ c := model.Comment{
+ ID: 11,
+ PostID: 22,
+ UserID: uid,
+ Content: "不该出现在公开 JSON",
+ Status: model.ContentStatusPublished,
+ DeletedBy: 9,
+ User: model.User{
+ ID: uid,
+ Username: "alice",
+ Nickname: "爱丽丝",
+ Avatar: "/uploads/avatars/alice.jpg",
+ },
+ }
+ c.DeletedAt = gorm.DeletedAt{Time: time.Now(), Valid: true}
+ c.CreatedAt = time.Now().Add(-time.Hour)
+ c.UpdatedAt = c.CreatedAt
+ return c
+}
+
+func marshalNode(t *testing.T, n CommentNode) map[string]any {
+ t.Helper()
+ b, err := json.Marshal(n)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ var raw map[string]any
+ if err := json.Unmarshal(b, &raw); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ return raw
+}
+
+func TestToCommentNodeHidesStaffDeletedAuthorFromPublic(t *testing.T) {
+ c := staffDeletedComment()
+ raw := marshalNode(t, toCommentNode(c, nil, false, 0, nil))
+ if _, ok := raw["user"]; ok {
+ t.Fatalf("公众 JSON 不应含 user: %v", raw["user"])
+ }
+ if _, ok := raw["user_id"]; ok {
+ t.Fatalf("公众 JSON 不应含 user_id: %v", raw["user_id"])
+ }
+ if raw["staff_deleted"] != true {
+ t.Fatalf("staff_deleted=%v", raw["staff_deleted"])
+ }
+ if s, _ := raw["content"].(string); s != "" {
+ t.Fatalf("公众不应看到正文: %q", s)
+ }
+ blob, _ := json.Marshal(raw)
+ for _, leak := range []string{"alice", "爱丽丝", "/uploads/avatars/alice.jpg"} {
+ if strings.Contains(string(blob), leak) {
+ t.Fatalf("公众 JSON 泄漏身份 %q: %s", leak, blob)
+ }
+ }
+}
+
+func TestToCommentNodeKeepsAuthorForCommenter(t *testing.T) {
+ c := staffDeletedComment()
+ raw := marshalNode(t, toCommentNode(c, nil, false, 7, nil))
+ if raw["user_id"] != float64(7) {
+ t.Fatalf("评论者应看到 user_id: %v", raw["user_id"])
+ }
+ user, ok := raw["user"].(map[string]any)
+ if !ok {
+ t.Fatalf("评论者应看到 user")
+ }
+ if user["username"] != "alice" {
+ t.Fatalf("username=%v", user["username"])
+ }
+ if raw["content"] != "不该出现在公开 JSON" {
+ t.Fatalf("评论者应看到原文, got %v", raw["content"])
+ }
+}
+
+func TestToCommentNodeKeepsAuthorForModerator(t *testing.T) {
+ c := staffDeletedComment()
+ raw := marshalNode(t, toCommentNode(c, nil, true, 99, nil))
+ if raw["user_id"] != float64(7) {
+ t.Fatalf("版主应看到 user_id")
+ }
+ if _, ok := raw["user"]; !ok {
+ t.Fatal("版主应看到 user")
+ }
+ if raw["content"] != "不该出现在公开 JSON" {
+ t.Fatalf("版主应看到原文, got %v", raw["content"])
+ }
+}
+
+func TestToCommentNodeSelfDeleteKeepsAuthor(t *testing.T) {
+ c := staffDeletedComment()
+ c.DeletedBy = c.UserID
+ raw := marshalNode(t, toCommentNode(c, nil, false, 0, nil))
+ if raw["staff_deleted"] != nil && raw["staff_deleted"] != false {
+ t.Fatalf("自删不应标 staff_deleted: %v", raw["staff_deleted"])
+ }
+ if raw["user_id"] != float64(7) {
+ t.Fatalf("自删应保留作者: %v", raw["user_id"])
+ }
+ if s, _ := raw["content"].(string); s != "" {
+ t.Fatalf("公众不应看到自删正文: %q", s)
+ }
+}
+
+func TestToCommentNodeSelfDeleteKeepsContentForCommenter(t *testing.T) {
+ c := staffDeletedComment()
+ c.DeletedBy = c.UserID
+ raw := marshalNode(t, toCommentNode(c, nil, false, 7, nil))
+ if raw["content"] != "不该出现在公开 JSON" {
+ t.Fatalf("自删评论者应看到原文, got %v", raw["content"])
+ }
+}
diff --git a/backend/service/post_interact.go b/backend/service/post_interact.go
index dc10c41..fc28609 100644
--- a/backend/service/post_interact.go
+++ b/backend/service/post_interact.go
@@ -38,7 +38,7 @@ type BountyMeta struct {
Escrowed bool `json:"escrowed"`
Refunded bool `json:"refunded"`
Expired bool `json:"expired"`
- EndsAt string `json:"ends_at"` // RFC3339;空表示待发布时补写
+ EndsAt string `json:"ends_at"` // RFC3339;空表示待发布时补写
ExpireDays int `json:"expire_days,omitempty"` // 发帖入参,规范化后可清
}
@@ -58,16 +58,16 @@ type LotteryMeta struct {
}
var (
- ErrPollClosed = errors.New("投票已结束")
- ErrAlreadyVoted = errors.New("你已投过票")
- ErrInvalidPollOpt = errors.New("无效的投票选项")
- ErrPollOptionsLocked = errors.New("已有人投票,无法修改选项或匿名设置")
- ErrBountySettled = errors.New("悬赏已结算")
- ErrBountyNotEscrow = errors.New("悬赏未托管")
- ErrLotteryDrawn = errors.New("已开奖")
- ErrLotteryNoEntries = errors.New("暂无回帖用户可抽奖")
+ ErrPollClosed = errors.New("投票已结束")
+ ErrAlreadyVoted = errors.New("你已投过票")
+ ErrInvalidPollOpt = errors.New("无效的投票选项")
+ ErrPollOptionsLocked = errors.New("已有人投票,无法修改选项或匿名设置")
+ ErrBountySettled = errors.New("悬赏已结算")
+ ErrBountyNotEscrow = errors.New("悬赏未托管")
+ ErrLotteryDrawn = errors.New("已开奖")
+ ErrLotteryNoEntries = errors.New("暂无回帖用户可抽奖")
ErrLotteryPrizesLocked = errors.New("已有评论,无法修改奖品设置")
- ErrQuestionNotType = errors.New("本文不是问答帖")
+ ErrQuestionNotType = errors.New("本文不是问答帖")
)
func parseQuestionMeta(raw string) (*QuestionMeta, error) {
@@ -476,17 +476,12 @@ 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"`
+ ID uint `json:"id"`
+ Floor int `json:"floor"`
+ Content string `json:"content"`
+ CreatedAt time.Time `json:"created_at"`
+ Deleted bool `json:"deleted,omitempty"`
+ User *CommentActorBrief `json:"user,omitempty"` // 管理删对公众省略
}
type QuestionState struct {
@@ -554,17 +549,17 @@ type LotteryEntrant struct {
}
type LotteryState struct {
- Slots int `json:"slots"`
- Prizes []LotteryPrize `json:"prizes"`
- Drawn bool `json:"drawn"`
- Closed bool `json:"closed"`
- EndsAt string `json:"ends_at,omitempty"`
- EntryCount int `json:"entry_count"`
- Eligible bool `json:"eligible"`
- CooldownHours int `json:"cooldown_hours"`
- CanDraw bool `json:"can_draw"`
- CanEditPrizes bool `json:"can_edit_prizes"`
- Winners []LotteryEntrant `json:"winners"`
+ Slots int `json:"slots"`
+ Prizes []LotteryPrize `json:"prizes"`
+ Drawn bool `json:"drawn"`
+ Closed bool `json:"closed"`
+ EndsAt string `json:"ends_at,omitempty"`
+ EntryCount int `json:"entry_count"`
+ Eligible bool `json:"eligible"`
+ CooldownHours int `json:"cooldown_hours"`
+ CanDraw bool `json:"can_draw"`
+ CanEditPrizes bool `json:"can_edit_prizes"`
+ Winners []LotteryEntrant `json:"winners"`
}
func (s *PostService) fillInteractState(detail *PostDetail, post *model.Post, viewerID uint, loadActor func() *Actor) {
@@ -580,23 +575,23 @@ func (s *PostService) fillInteractState(detail *PostDetail, post *model.Post, vi
switch pt {
case model.PostTypeQuestion:
- detail.Question = s.buildQuestionState(post, canManage)
+ detail.Question = s.buildQuestionState(post, viewerID, canManage, isMod)
case model.PostTypePoll:
detail.Poll = s.buildPollState(post, viewerID, canManage)
case model.PostTypeBounty:
- detail.Bounty = s.buildBountyState(post, canManage)
+ detail.Bounty = s.buildBountyState(post, viewerID, canManage, isMod)
case model.PostTypeLottery:
detail.Lottery = s.buildLotteryState(post, viewerID, canManage)
}
}
-func (s *PostService) buildQuestionState(post *model.Post, canManage bool) *QuestionState {
+func (s *PostService) buildQuestionState(post *model.Post, viewerID uint, canManage, isMod bool) *QuestionState {
m, err := parseQuestionMeta(post.TypeMeta)
if err != nil || m == nil {
m = &QuestionMeta{}
}
floor := FloorNumber(s.db, post.ID, m.AcceptedCommentID)
- answer := s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor)
+ answer := s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor, viewerID, isMod)
solvedAt := m.SolvedAt
if m.Solved && solvedAt == "" && answer != nil && !answer.CreatedAt.IsZero() {
solvedAt = answer.CreatedAt.UTC().Format(time.RFC3339) // 旧数据:无 solved_at 时用采纳评论时间
@@ -610,7 +605,7 @@ func (s *PostService) buildQuestionState(post *model.Post, canManage bool) *Ques
CanAccept: canManage && !m.Solved,
CanSolve: canManage && !m.Solved,
// 已采纳答案后不可重新打开;仅「手动标已解决」可撤回
- CanReopen: canManage && m.Solved && m.AcceptedCommentID == 0,
+ CanReopen: canManage && m.Solved && m.AcceptedCommentID == 0,
}
}
@@ -713,7 +708,7 @@ func (s *PostService) buildPollState(post *model.Post, viewerID uint, canClose b
}
}
-func (s *PostService) buildBountyState(post *model.Post, canManage bool) *BountyState {
+func (s *PostService) buildBountyState(post *model.Post, viewerID uint, canManage, isMod bool) *BountyState {
m, err := parseBountyMeta(post.TypeMeta)
if err != nil {
return nil
@@ -724,7 +719,7 @@ func (s *PostService) buildBountyState(post *model.Post, canManage bool) *Bounty
Points: m.Points,
AcceptedCommentID: m.AcceptedCommentID,
AcceptedFloor: floor,
- AcceptedAnswer: s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor),
+ AcceptedAnswer: s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor, viewerID, isMod),
Escrowed: m.Escrowed,
Refunded: m.Refunded,
Expired: m.Expired,
@@ -736,7 +731,8 @@ func (s *PostService) buildBountyState(post *model.Post, canManage bool) *Bounty
}
// loadAcceptedAnswer 加载已采纳评论快照(公开可见正文);软删则清空正文并标记 deleted。
-func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int) *AcceptedAnswer {
+// 管理删:公众不返回评论者身份(与评论树 toCommentNode 一致)。
+func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int, viewerID uint, isMod bool) *AcceptedAnswer {
if postID == 0 || commentID == 0 {
return nil
}
@@ -756,13 +752,23 @@ func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int) *Acc
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
+ staff := c.DeletedAt.Valid && c.DeletedBy != 0 && c.DeletedBy != c.UserID
+ hideAuthor := staff && !isMod && viewerID != c.UserID
+ if !hideAuthor && c.User.ID != 0 {
+ ans.User = &CommentActorBrief{
+ ID: c.User.ID,
+ Username: c.User.Username,
+ Nickname: c.User.Nickname,
+ Avatar: c.User.Avatar,
+ }
+ }
if c.DeletedAt.Valid {
ans.Deleted = true
- ans.Content = ""
+ if isMod || viewerID == c.UserID {
+ ans.Content = c.Content
+ } else {
+ ans.Content = ""
+ }
return ans
}
ans.Content = c.Content
@@ -1513,12 +1519,10 @@ func (s *PostService) loadManageablePost(actor *Actor, userID, postID uint) (*mo
func (s *PostService) detailAfterInteract(post *model.Post, viewerID uint) (*PostDetail, error) {
detail := buildPostDetail(post)
- locked, hint := s.evalContentAccess(post, viewerID, nil)
- detail.ContentLocked = locked
+ sanitized, fullyLocked, hint := s.sanitizePostContent(post, viewerID, nil)
+ detail.Content = sanitized
+ detail.ContentLocked = fullyLocked
detail.AccessHint = hint
- if locked {
- detail.Content = ""
- }
atts, _ := s.listAttachmentDTOs(post.ID, viewerID, post.UserID)
detail.Attachments = atts
s.fillInteractState(detail, post, viewerID, nil)
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index 7271907..8470558 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -3913,7 +3913,7 @@ html.dark .prose-content pre[data-theme] span {
font-size: 13.5px;
}
-/* 软删占位:头像/昵称马赛克,可读结构但不可辨认身份 */
+/* 软删占位:匿名马赛克(不含真实头像/昵称) */
.cmt-mosaic-avatar {
position: relative;
display: inline-flex;
@@ -3923,9 +3923,11 @@ html.dark .prose-content pre[data-theme] span {
user-select: none;
flex-shrink: 0;
}
-.cmt-mosaic-avatar > * {
- filter: blur(7px) saturate(0.35) contrast(0.9);
- transform: scale(1.2);
+.cmt-mosaic-avatar-fill {
+ display: block;
+ width: 100%;
+ height: 100%;
+ background: color-mix(in srgb, var(--ink-3) 50%, var(--panel));
}
.cmt-mosaic-avatar::after {
content: "";
@@ -3947,15 +3949,34 @@ html.dark .prose-content pre[data-theme] span {
pointer-events: none;
}
.cmt-mosaic-name {
+ position: relative;
display: inline-block;
- max-width: 9rem;
+ width: 4.5rem;
+ height: 14px;
+ border-radius: 4px;
overflow: hidden;
- white-space: nowrap;
- filter: blur(5px);
- letter-spacing: 0.04em;
- user-select: none;
+ background: color-mix(in srgb, var(--ink-3) 40%, var(--panel));
+ pointer-events: none;
+ user-select: none;
+ vertical-align: middle;
+}
+.cmt-mosaic-name::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background-image:
+ repeating-linear-gradient(
+ 0deg,
+ color-mix(in srgb, var(--ink) 18%, transparent) 0 1px,
+ transparent 1px 3px
+ ),
+ repeating-linear-gradient(
+ 90deg,
+ color-mix(in srgb, var(--ink) 18%, transparent) 0 1px,
+ transparent 1px 3px
+ );
+ mix-blend-mode: soft-light;
pointer-events: none;
- transform: scaleY(1.05);
}
.cmt-tombstone {
border-radius: 12px;
diff --git a/frontend/app/post/[id]/page.tsx b/frontend/app/post/[id]/page.tsx
index 7a81b0e..d0745d5 100644
--- a/frontend/app/post/[id]/page.tsx
+++ b/frontend/app/post/[id]/page.tsx
@@ -546,10 +546,12 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
"@type": "Comment",
text: acceptedAnswer.content,
dateCreated: acceptedAnswer.created_at,
- author: {
- "@type": "Person",
- name: acceptedAnswer.user.nickname || acceptedAnswer.user.username,
- },
+ author: acceptedAnswer.user
+ ? {
+ "@type": "Person",
+ name: acceptedAnswer.user.nickname || acceptedAnswer.user.username,
+ }
+ : undefined,
url: `#comment-${acceptedAnswer.id}`,
},
}),
diff --git a/frontend/components/AcceptedAnswerCard.tsx b/frontend/components/AcceptedAnswerCard.tsx
index 6daa847..392eb96 100644
--- a/frontend/components/AcceptedAnswerCard.tsx
+++ b/frontend/components/AcceptedAnswerCard.tsx
@@ -16,7 +16,8 @@ export default async function AcceptedAnswerCard({
/** 解决/采纳时刻;缺省用答案创建时间 */
solvedAt?: string;
}) {
- const name = answer.user.nickname || answer.user.username || "用户";
+ const author = answer.user;
+ const name = author ? author.nickname || author.username || "用户" : "";
const isQuestion = variant === "question";
const title = isQuestion ? "问答" : "悬赏";
const statusLabel = isQuestion ? "已解决" : "已采纳";
@@ -48,42 +49,73 @@ export default async function AcceptedAnswerCard({
- - {answer.floor > 0 && ( - <> - · - - #{answer.floor} - - > - )} -
-+ + {answer.floor > 0 && ( + <> + · + + #{answer.floor} + + > + )} +
++ + {answer.floor > 0 && ( + <> + · + + #{answer.floor} + + > + )} +
+- 该答案已被删除 -
+ <> ++ 该答案已被删除 +
+ {answer.content ? ( +{c.delete_reason}