fix: 管理删评对公众剥离作者身份,评论者本人仍可见原文
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -306,7 +306,7 @@ type PostAttachmentUnlock struct {
|
|||||||
type Comment struct {
|
type Comment struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
PostID uint `gorm:"index;not null" json:"post_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 = 主评论(楼层)
|
ParentID *uint `gorm:"index" json:"parent_id"` // 父评论 ID,NULL = 主评论(楼层)
|
||||||
RootID *uint `gorm:"index" json:"root_id"` // 所属楼层(顶层主评论)ID,子评论必填
|
RootID *uint `gorm:"index" json:"root_id"` // 所属楼层(顶层主评论)ID,子评论必填
|
||||||
Depth int `gorm:"not null;default:0" json:"depth"` // 层级:主评论 0,子评论 = 父 + 1
|
Depth int `gorm:"not null;default:0" json:"depth"` // 层级:主评论 0,子评论 = 父 + 1
|
||||||
|
|||||||
@@ -32,11 +32,13 @@ func NewCommentService(db *gorm.DB) *CommentService {
|
|||||||
// CommentNode 评论树节点:嵌入 model.Comment 使 JSON 字段拍平,replies 为空时省略
|
// CommentNode 评论树节点:嵌入 model.Comment 使 JSON 字段拍平,replies 为空时省略
|
||||||
type CommentNode struct {
|
type CommentNode struct {
|
||||||
model.Comment
|
model.Comment
|
||||||
Deleted bool `json:"deleted,omitempty"` // 软删占位
|
Deleted bool `json:"deleted,omitempty"` // 软删占位
|
||||||
StaffDeleted bool `json:"staff_deleted,omitempty"` // DeletedBy≠评论者
|
StaffDeleted bool `json:"staff_deleted,omitempty"` // DeletedBy≠评论者
|
||||||
Edited bool `json:"edited,omitempty"` // 相对创建已编辑
|
Edited bool `json:"edited,omitempty"` // 相对创建已编辑
|
||||||
DeletedByUser *CommentActorBrief `json:"deleted_by_user,omitempty"` // 管理删执行者(公开)
|
DeletedByUser *CommentActorBrief `json:"deleted_by_user,omitempty"` // 管理删执行者(公开)
|
||||||
Replies []CommentNode `json:"replies,omitempty"`
|
Replies []CommentNode `json:"replies,omitempty"`
|
||||||
|
// 覆盖嵌入的 User:指针 + omitempty,管理删对公众可整段省略(避免空对象仍进 JSON/DOM)
|
||||||
|
User *model.User `json:"user,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommentActorBrief 评论相关公开用户摘要(删评管理员链接等)
|
// CommentActorBrief 评论相关公开用户摘要(删评管理员链接等)
|
||||||
@@ -119,14 +121,14 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A
|
|||||||
build = func(pid uint) []CommentNode {
|
build = func(pid uint) []CommentNode {
|
||||||
out := []CommentNode{}
|
out := []CommentNode{}
|
||||||
for _, c := range children[pid] {
|
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
|
placed[c.ID] = true
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
result := make([]CommentNode, len(roots))
|
result := make([]CommentNode, len(roots))
|
||||||
for i := range 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
|
placed[roots[i].ID] = true
|
||||||
}
|
}
|
||||||
for _, r := range replies {
|
for _, r := range replies {
|
||||||
@@ -135,7 +137,7 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A
|
|||||||
}
|
}
|
||||||
for i := range result {
|
for i := range result {
|
||||||
if result[i].ID == *r.RootID {
|
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
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,14 +180,25 @@ func (s *CommentService) loadCommentActorsByIDs(ids []uint) map[uint]CommentActo
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// toCommentNode 软删占位:清空公开正文;版主保留原文;自删理由仅版主可见。
|
// commentAuthorPtr 有预加载作者时返回拷贝,供 JSON omitempty 整段省略。
|
||||||
func toCommentNode(c model.Comment, replies []CommentNode, revealAudit bool, deleters map[uint]CommentActorBrief) CommentNode {
|
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
|
edited := c.UpdatedAt.Sub(c.CreatedAt) > time.Minute
|
||||||
if !c.DeletedAt.Valid {
|
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
|
staff := c.DeletedBy != 0 && c.DeletedBy != c.UserID
|
||||||
if !revealAudit {
|
isCommenter := viewerID != 0 && viewerID == c.UserID
|
||||||
|
if !revealAudit && !isCommenter {
|
||||||
c.Content = ""
|
c.Content = ""
|
||||||
}
|
}
|
||||||
if !staff {
|
if !staff {
|
||||||
@@ -201,6 +214,11 @@ func toCommentNode(c model.Comment, replies []CommentNode, revealAudit bool, del
|
|||||||
deleter = &cp
|
deleter = &cp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 版主审计、评论者本人仍可见身份;其余观众剥离
|
||||||
|
if staff && !revealAudit && !isCommenter {
|
||||||
|
c.UserID = 0
|
||||||
|
c.User = model.User{}
|
||||||
|
}
|
||||||
return CommentNode{
|
return CommentNode{
|
||||||
Comment: c,
|
Comment: c,
|
||||||
Deleted: true,
|
Deleted: true,
|
||||||
@@ -208,6 +226,7 @@ func toCommentNode(c model.Comment, replies []CommentNode, revealAudit bool, del
|
|||||||
Edited: edited,
|
Edited: edited,
|
||||||
DeletedByUser: deleter,
|
DeletedByUser: deleter,
|
||||||
Replies: replies,
|
Replies: replies,
|
||||||
|
User: commentAuthorPtr(c),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +375,7 @@ func (s *CommentService) ListByUser(userID uint, page, size int) ([]UserCommentI
|
|||||||
query := s.db.Table("comments").
|
query := s.db.Table("comments").
|
||||||
Select("comments.id, comments.post_id, posts.title AS post_title, comments.content, comments.created_at").
|
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").
|
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
|
var total int64
|
||||||
if err := query.Count(&total).Error; err != nil {
|
if err := query.Count(&total).Error; err != nil {
|
||||||
@@ -378,7 +397,7 @@ func (s *CommentService) CountByUser(userID uint) (int64, error) {
|
|||||||
var total int64
|
var total int64
|
||||||
err := s.db.Table("comments").
|
err := s.db.Table("comments").
|
||||||
Joins("JOIN posts ON posts.id = comments.post_id").
|
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
|
Count(&total).Error
|
||||||
return total, err
|
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 {
|
if err := s.db.Preload("User").First(&comment, comment.ID).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
node := toCommentNode(comment, nil, false, nil)
|
node := toCommentNode(comment, nil, false, userID, nil)
|
||||||
return &node, nil
|
return &node, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommentEditHistoryItem 管理可见的评论修订记录
|
// CommentEditHistoryItem 管理可见的评论修订记录
|
||||||
type CommentEditHistoryItem struct {
|
type CommentEditHistoryItem struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Editor CommentActorBrief `json:"editor"`
|
Editor CommentActorBrief `json:"editor"`
|
||||||
OldContent string `json:"old_content"`
|
OldContent string `json:"old_content"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListEditHistory 评论编辑历史(仅版主;含软删评论;按创建时间倒序分页)
|
// ListEditHistory 评论编辑历史(仅版主;含软删评论;按创建时间倒序分页)
|
||||||
|
|||||||
125
backend/service/comment_node_test.go
Normal file
125
backend/service/comment_node_test.go
Normal file
@@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ type BountyMeta struct {
|
|||||||
Escrowed bool `json:"escrowed"`
|
Escrowed bool `json:"escrowed"`
|
||||||
Refunded bool `json:"refunded"`
|
Refunded bool `json:"refunded"`
|
||||||
Expired bool `json:"expired"`
|
Expired bool `json:"expired"`
|
||||||
EndsAt string `json:"ends_at"` // RFC3339;空表示待发布时补写
|
EndsAt string `json:"ends_at"` // RFC3339;空表示待发布时补写
|
||||||
ExpireDays int `json:"expire_days,omitempty"` // 发帖入参,规范化后可清
|
ExpireDays int `json:"expire_days,omitempty"` // 发帖入参,规范化后可清
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,16 +58,16 @@ type LotteryMeta struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrPollClosed = errors.New("投票已结束")
|
ErrPollClosed = errors.New("投票已结束")
|
||||||
ErrAlreadyVoted = errors.New("你已投过票")
|
ErrAlreadyVoted = errors.New("你已投过票")
|
||||||
ErrInvalidPollOpt = errors.New("无效的投票选项")
|
ErrInvalidPollOpt = errors.New("无效的投票选项")
|
||||||
ErrPollOptionsLocked = errors.New("已有人投票,无法修改选项或匿名设置")
|
ErrPollOptionsLocked = errors.New("已有人投票,无法修改选项或匿名设置")
|
||||||
ErrBountySettled = errors.New("悬赏已结算")
|
ErrBountySettled = errors.New("悬赏已结算")
|
||||||
ErrBountyNotEscrow = errors.New("悬赏未托管")
|
ErrBountyNotEscrow = errors.New("悬赏未托管")
|
||||||
ErrLotteryDrawn = errors.New("已开奖")
|
ErrLotteryDrawn = errors.New("已开奖")
|
||||||
ErrLotteryNoEntries = errors.New("暂无回帖用户可抽奖")
|
ErrLotteryNoEntries = errors.New("暂无回帖用户可抽奖")
|
||||||
ErrLotteryPrizesLocked = errors.New("已有评论,无法修改奖品设置")
|
ErrLotteryPrizesLocked = errors.New("已有评论,无法修改奖品设置")
|
||||||
ErrQuestionNotType = errors.New("本文不是问答帖")
|
ErrQuestionNotType = errors.New("本文不是问答帖")
|
||||||
)
|
)
|
||||||
|
|
||||||
func parseQuestionMeta(raw string) (*QuestionMeta, error) {
|
func parseQuestionMeta(raw string) (*QuestionMeta, error) {
|
||||||
@@ -476,17 +476,12 @@ func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) (
|
|||||||
|
|
||||||
// AcceptedAnswer 问答/悬赏已采纳答案(详情页正文下展示;与评论分页无关)
|
// AcceptedAnswer 问答/悬赏已采纳答案(详情页正文下展示;与评论分页无关)
|
||||||
type AcceptedAnswer struct {
|
type AcceptedAnswer struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Floor int `json:"floor"`
|
Floor int `json:"floor"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
Deleted bool `json:"deleted,omitempty"`
|
Deleted bool `json:"deleted,omitempty"`
|
||||||
User struct {
|
User *CommentActorBrief `json:"user,omitempty"` // 管理删对公众省略
|
||||||
ID uint `json:"id"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
Nickname string `json:"nickname"`
|
|
||||||
Avatar string `json:"avatar"`
|
|
||||||
} `json:"user"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type QuestionState struct {
|
type QuestionState struct {
|
||||||
@@ -554,17 +549,17 @@ type LotteryEntrant struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type LotteryState struct {
|
type LotteryState struct {
|
||||||
Slots int `json:"slots"`
|
Slots int `json:"slots"`
|
||||||
Prizes []LotteryPrize `json:"prizes"`
|
Prizes []LotteryPrize `json:"prizes"`
|
||||||
Drawn bool `json:"drawn"`
|
Drawn bool `json:"drawn"`
|
||||||
Closed bool `json:"closed"`
|
Closed bool `json:"closed"`
|
||||||
EndsAt string `json:"ends_at,omitempty"`
|
EndsAt string `json:"ends_at,omitempty"`
|
||||||
EntryCount int `json:"entry_count"`
|
EntryCount int `json:"entry_count"`
|
||||||
Eligible bool `json:"eligible"`
|
Eligible bool `json:"eligible"`
|
||||||
CooldownHours int `json:"cooldown_hours"`
|
CooldownHours int `json:"cooldown_hours"`
|
||||||
CanDraw bool `json:"can_draw"`
|
CanDraw bool `json:"can_draw"`
|
||||||
CanEditPrizes bool `json:"can_edit_prizes"`
|
CanEditPrizes bool `json:"can_edit_prizes"`
|
||||||
Winners []LotteryEntrant `json:"winners"`
|
Winners []LotteryEntrant `json:"winners"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PostService) fillInteractState(detail *PostDetail, post *model.Post, viewerID uint, loadActor func() *Actor) {
|
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 {
|
switch pt {
|
||||||
case model.PostTypeQuestion:
|
case model.PostTypeQuestion:
|
||||||
detail.Question = s.buildQuestionState(post, canManage)
|
detail.Question = s.buildQuestionState(post, viewerID, canManage, isMod)
|
||||||
case model.PostTypePoll:
|
case model.PostTypePoll:
|
||||||
detail.Poll = s.buildPollState(post, viewerID, canManage)
|
detail.Poll = s.buildPollState(post, viewerID, canManage)
|
||||||
case model.PostTypeBounty:
|
case model.PostTypeBounty:
|
||||||
detail.Bounty = s.buildBountyState(post, canManage)
|
detail.Bounty = s.buildBountyState(post, viewerID, canManage, isMod)
|
||||||
case model.PostTypeLottery:
|
case model.PostTypeLottery:
|
||||||
detail.Lottery = s.buildLotteryState(post, viewerID, canManage)
|
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)
|
m, err := parseQuestionMeta(post.TypeMeta)
|
||||||
if err != nil || m == nil {
|
if err != nil || m == nil {
|
||||||
m = &QuestionMeta{}
|
m = &QuestionMeta{}
|
||||||
}
|
}
|
||||||
floor := FloorNumber(s.db, post.ID, m.AcceptedCommentID)
|
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
|
solvedAt := m.SolvedAt
|
||||||
if m.Solved && solvedAt == "" && answer != nil && !answer.CreatedAt.IsZero() {
|
if m.Solved && solvedAt == "" && answer != nil && !answer.CreatedAt.IsZero() {
|
||||||
solvedAt = answer.CreatedAt.UTC().Format(time.RFC3339) // 旧数据:无 solved_at 时用采纳评论时间
|
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,
|
CanAccept: canManage && !m.Solved,
|
||||||
CanSolve: 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)
|
m, err := parseBountyMeta(post.TypeMeta)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -724,7 +719,7 @@ func (s *PostService) buildBountyState(post *model.Post, canManage bool) *Bounty
|
|||||||
Points: m.Points,
|
Points: m.Points,
|
||||||
AcceptedCommentID: m.AcceptedCommentID,
|
AcceptedCommentID: m.AcceptedCommentID,
|
||||||
AcceptedFloor: floor,
|
AcceptedFloor: floor,
|
||||||
AcceptedAnswer: s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor),
|
AcceptedAnswer: s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor, viewerID, isMod),
|
||||||
Escrowed: m.Escrowed,
|
Escrowed: m.Escrowed,
|
||||||
Refunded: m.Refunded,
|
Refunded: m.Refunded,
|
||||||
Expired: m.Expired,
|
Expired: m.Expired,
|
||||||
@@ -736,7 +731,8 @@ func (s *PostService) buildBountyState(post *model.Post, canManage bool) *Bounty
|
|||||||
}
|
}
|
||||||
|
|
||||||
// loadAcceptedAnswer 加载已采纳评论快照(公开可见正文);软删则清空正文并标记 deleted。
|
// 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 {
|
if postID == 0 || commentID == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -756,13 +752,23 @@ func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int) *Acc
|
|||||||
Floor: floor,
|
Floor: floor,
|
||||||
CreatedAt: c.CreatedAt,
|
CreatedAt: c.CreatedAt,
|
||||||
}
|
}
|
||||||
ans.User.ID = c.User.ID
|
staff := c.DeletedAt.Valid && c.DeletedBy != 0 && c.DeletedBy != c.UserID
|
||||||
ans.User.Username = c.User.Username
|
hideAuthor := staff && !isMod && viewerID != c.UserID
|
||||||
ans.User.Nickname = c.User.Nickname
|
if !hideAuthor && c.User.ID != 0 {
|
||||||
ans.User.Avatar = c.User.Avatar
|
ans.User = &CommentActorBrief{
|
||||||
|
ID: c.User.ID,
|
||||||
|
Username: c.User.Username,
|
||||||
|
Nickname: c.User.Nickname,
|
||||||
|
Avatar: c.User.Avatar,
|
||||||
|
}
|
||||||
|
}
|
||||||
if c.DeletedAt.Valid {
|
if c.DeletedAt.Valid {
|
||||||
ans.Deleted = true
|
ans.Deleted = true
|
||||||
ans.Content = ""
|
if isMod || viewerID == c.UserID {
|
||||||
|
ans.Content = c.Content
|
||||||
|
} else {
|
||||||
|
ans.Content = ""
|
||||||
|
}
|
||||||
return ans
|
return ans
|
||||||
}
|
}
|
||||||
ans.Content = c.Content
|
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) {
|
func (s *PostService) detailAfterInteract(post *model.Post, viewerID uint) (*PostDetail, error) {
|
||||||
detail := buildPostDetail(post)
|
detail := buildPostDetail(post)
|
||||||
locked, hint := s.evalContentAccess(post, viewerID, nil)
|
sanitized, fullyLocked, hint := s.sanitizePostContent(post, viewerID, nil)
|
||||||
detail.ContentLocked = locked
|
detail.Content = sanitized
|
||||||
|
detail.ContentLocked = fullyLocked
|
||||||
detail.AccessHint = hint
|
detail.AccessHint = hint
|
||||||
if locked {
|
|
||||||
detail.Content = ""
|
|
||||||
}
|
|
||||||
atts, _ := s.listAttachmentDTOs(post.ID, viewerID, post.UserID)
|
atts, _ := s.listAttachmentDTOs(post.ID, viewerID, post.UserID)
|
||||||
detail.Attachments = atts
|
detail.Attachments = atts
|
||||||
s.fillInteractState(detail, post, viewerID, nil)
|
s.fillInteractState(detail, post, viewerID, nil)
|
||||||
|
|||||||
@@ -3913,7 +3913,7 @@ html.dark .prose-content pre[data-theme] span {
|
|||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 软删占位:头像/昵称马赛克,可读结构但不可辨认身份 */
|
/* 软删占位:匿名马赛克(不含真实头像/昵称) */
|
||||||
.cmt-mosaic-avatar {
|
.cmt-mosaic-avatar {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -3923,9 +3923,11 @@ html.dark .prose-content pre[data-theme] span {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.cmt-mosaic-avatar > * {
|
.cmt-mosaic-avatar-fill {
|
||||||
filter: blur(7px) saturate(0.35) contrast(0.9);
|
display: block;
|
||||||
transform: scale(1.2);
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: color-mix(in srgb, var(--ink-3) 50%, var(--panel));
|
||||||
}
|
}
|
||||||
.cmt-mosaic-avatar::after {
|
.cmt-mosaic-avatar::after {
|
||||||
content: "";
|
content: "";
|
||||||
@@ -3947,15 +3949,34 @@ html.dark .prose-content pre[data-theme] span {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.cmt-mosaic-name {
|
.cmt-mosaic-name {
|
||||||
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
max-width: 9rem;
|
width: 4.5rem;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
background: color-mix(in srgb, var(--ink-3) 40%, var(--panel));
|
||||||
filter: blur(5px);
|
pointer-events: none;
|
||||||
letter-spacing: 0.04em;
|
user-select: 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;
|
pointer-events: none;
|
||||||
transform: scaleY(1.05);
|
|
||||||
}
|
}
|
||||||
.cmt-tombstone {
|
.cmt-tombstone {
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|||||||
@@ -546,10 +546,12 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
"@type": "Comment",
|
"@type": "Comment",
|
||||||
text: acceptedAnswer.content,
|
text: acceptedAnswer.content,
|
||||||
dateCreated: acceptedAnswer.created_at,
|
dateCreated: acceptedAnswer.created_at,
|
||||||
author: {
|
author: acceptedAnswer.user
|
||||||
"@type": "Person",
|
? {
|
||||||
name: acceptedAnswer.user.nickname || acceptedAnswer.user.username,
|
"@type": "Person",
|
||||||
},
|
name: acceptedAnswer.user.nickname || acceptedAnswer.user.username,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
url: `#comment-${acceptedAnswer.id}`,
|
url: `#comment-${acceptedAnswer.id}`,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ export default async function AcceptedAnswerCard({
|
|||||||
/** 解决/采纳时刻;缺省用答案创建时间 */
|
/** 解决/采纳时刻;缺省用答案创建时间 */
|
||||||
solvedAt?: string;
|
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 isQuestion = variant === "question";
|
||||||
const title = isQuestion ? "问答" : "悬赏";
|
const title = isQuestion ? "问答" : "悬赏";
|
||||||
const statusLabel = isQuestion ? "已解决" : "已采纳";
|
const statusLabel = isQuestion ? "已解决" : "已采纳";
|
||||||
@@ -48,42 +49,73 @@ export default async function AcceptedAnswerCard({
|
|||||||
<CheckCircle2 size={12} strokeWidth={2.25} aria-hidden />
|
<CheckCircle2 size={12} strokeWidth={2.25} aria-hidden />
|
||||||
{answerLabel}
|
{answerLabel}
|
||||||
</span>
|
</span>
|
||||||
<Link href={`/u/${answer.user.id}`} className="shrink-0" aria-label={name}>
|
{author ? (
|
||||||
<Avatar name={name} src={answer.user.avatar || undefined} size={32} />
|
<>
|
||||||
</Link>
|
<Link href={`/u/${author.id}`} className="shrink-0" aria-label={name}>
|
||||||
<div className="min-w-0 flex flex-col gap-0.5">
|
<Avatar name={name} src={author.avatar || undefined} size={32} />
|
||||||
<Link
|
</Link>
|
||||||
href={`/u/${answer.user.id}`}
|
<div className="min-w-0 flex flex-col gap-0.5">
|
||||||
className="truncate text-[13.5px] font-semibold hover:text-[var(--accent)] transition-colors"
|
<Link
|
||||||
style={{ color: "var(--ink)" }}
|
href={`/u/${author.id}`}
|
||||||
>
|
className="truncate text-[13.5px] font-semibold hover:text-[var(--accent)] transition-colors"
|
||||||
{name}
|
style={{ color: "var(--ink)" }}
|
||||||
</Link>
|
>
|
||||||
<p className="meta text-[12px] flex items-center gap-1.5 flex-wrap !mb-0">
|
{name}
|
||||||
<time dateTime={answer.created_at}>
|
</Link>
|
||||||
{new Date(answer.created_at).toLocaleString("zh-CN")}
|
<p className="meta text-[12px] flex items-center gap-1.5 flex-wrap !mb-0">
|
||||||
</time>
|
<time dateTime={answer.created_at}>
|
||||||
{answer.floor > 0 && (
|
{new Date(answer.created_at).toLocaleString("zh-CN")}
|
||||||
<>
|
</time>
|
||||||
<span aria-hidden>·</span>
|
{answer.floor > 0 && (
|
||||||
<a
|
<>
|
||||||
href={`#comment-${answer.id}`}
|
<span aria-hidden>·</span>
|
||||||
className="hover:underline"
|
<a
|
||||||
style={{ color: "var(--accent)" }}
|
href={`#comment-${answer.id}`}
|
||||||
>
|
className="hover:underline"
|
||||||
#{answer.floor}
|
style={{ color: "var(--accent)" }}
|
||||||
</a>
|
>
|
||||||
</>
|
#{answer.floor}
|
||||||
)}
|
</a>
|
||||||
</p>
|
</>
|
||||||
</div>
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="min-w-0 flex flex-col gap-0.5">
|
||||||
|
<p className="meta text-[12px] flex items-center gap-1.5 flex-wrap !mb-0">
|
||||||
|
<time dateTime={answer.created_at}>
|
||||||
|
{new Date(answer.created_at).toLocaleString("zh-CN")}
|
||||||
|
</time>
|
||||||
|
{answer.floor > 0 && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<a
|
||||||
|
href={`#comment-${answer.id}`}
|
||||||
|
className="hover:underline"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
#{answer.floor}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="j13-accepted-answer__body">
|
<div className="j13-accepted-answer__body">
|
||||||
{answer.deleted ? (
|
{answer.deleted ? (
|
||||||
<p className="meta text-[14px]" style={{ color: "var(--ink-3)" }}>
|
<>
|
||||||
该答案已被删除
|
<p className="meta text-[14px]" style={{ color: "var(--ink-3)" }}>
|
||||||
</p>
|
该答案已被删除
|
||||||
|
</p>
|
||||||
|
{answer.content ? (
|
||||||
|
<div className="mt-2">
|
||||||
|
<MarkdownBody content={answer.content} className="prose-content prose-comment" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<MarkdownBody content={answer.content} className="prose-content prose-comment" />
|
<MarkdownBody content={answer.content} className="prose-content prose-comment" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -75,30 +75,18 @@ function findComment(nodes: CommentNode[], id: number): CommentNode | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 马赛克头像:保留轮廓,不可辨认 */
|
/** 匿名马赛克头像:不含真实 src / 姓名,避免进 DOM */
|
||||||
function MosaicAvatar({
|
function MosaicAvatar({ size }: { size: number }) {
|
||||||
name,
|
|
||||||
src,
|
|
||||||
size,
|
|
||||||
}: {
|
|
||||||
name: string;
|
|
||||||
src?: string;
|
|
||||||
size: number;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<span className="cmt-mosaic-avatar" style={{ width: size, height: size }} aria-hidden>
|
<span className="cmt-mosaic-avatar" style={{ width: size, height: size }} aria-hidden>
|
||||||
<Avatar name={name || "用户"} src={src || undefined} size={size} />
|
<span className="cmt-mosaic-avatar-fill" />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 马赛克昵称 */
|
/** 匿名马赛克昵称条:固定宽度,不含真实文本 */
|
||||||
function MosaicName({ text, className = "" }: { text: string; className?: string }) {
|
function MosaicName() {
|
||||||
return (
|
return <span className="cmt-mosaic-name" aria-hidden />;
|
||||||
<span className={`cmt-mosaic-name ${className}`} style={{ color: "var(--ink-2)" }} aria-hidden>
|
|
||||||
{text || "用户"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CommentSection({
|
export default function CommentSection({
|
||||||
@@ -205,7 +193,9 @@ export default function CommentSection({
|
|||||||
|
|
||||||
const deleteTarget = deleteTargetId != null ? findComment(comments, deleteTargetId) : null;
|
const deleteTarget = deleteTargetId != null ? findComment(comments, deleteTargetId) : null;
|
||||||
const deleteMode: "self" | "staff" =
|
const deleteMode: "self" | "staff" =
|
||||||
deleteTarget && user && user.id === deleteTarget.user_id ? "self" : "staff";
|
deleteTarget && user && user.id === (deleteTarget.user?.id ?? deleteTarget.user_id)
|
||||||
|
? "self"
|
||||||
|
: "staff";
|
||||||
const purgeTarget = purgeTargetId != null ? findComment(comments, purgeTargetId) : null;
|
const purgeTarget = purgeTargetId != null ? findComment(comments, purgeTargetId) : null;
|
||||||
const purgeReplyCount = purgeTarget ? countReplies(purgeTarget) : 0;
|
const purgeReplyCount = purgeTarget ? countReplies(purgeTarget) : 0;
|
||||||
|
|
||||||
@@ -333,7 +323,7 @@ export default function CommentSection({
|
|||||||
const canRestore = (comment: CommentNode) => {
|
const canRestore = (comment: CommentNode) => {
|
||||||
if (!comment.deleted || !user) return false;
|
if (!comment.deleted || !user) return false;
|
||||||
if (canModerateBoard(user, boardId)) return true;
|
if (canModerateBoard(user, boardId)) return true;
|
||||||
return user.id === comment.user_id && !comment.staff_deleted;
|
return user.id === (comment.user?.id ?? comment.user_id) && !comment.staff_deleted;
|
||||||
};
|
};
|
||||||
|
|
||||||
const canApprove = (comment: CommentNode) =>
|
const canApprove = (comment: CommentNode) =>
|
||||||
@@ -351,7 +341,7 @@ export default function CommentSection({
|
|||||||
|
|
||||||
const openReply = (c: CommentNode) => {
|
const openReply = (c: CommentNode) => {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setReplyTarget({ id: c.id, nickname: c.user.nickname || c.user.username });
|
setReplyTarget({ id: c.id, nickname: c.user?.nickname || c.user?.username || "用户" });
|
||||||
setReplyContent("");
|
setReplyContent("");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -490,16 +480,19 @@ export default function CommentSection({
|
|||||||
const replyCount = countReplies(node);
|
const replyCount = countReplies(node);
|
||||||
const isFloor = depth === 0;
|
const isFloor = depth === 0;
|
||||||
const isExpanded = isFloor ? expandedFloors.has(c.id) : true;
|
const isExpanded = isFloor ? expandedFloors.has(c.id) : true;
|
||||||
const parentAuthor = parent?.user?.nickname || parent?.user?.username;
|
const parentAuthor = parent?.user
|
||||||
|
? parent.user.nickname || parent.user.username
|
||||||
|
: parent?.deleted
|
||||||
|
? "已删除"
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// 软删占位:自删不打码;管理删打码 + 管理员主页链接;版主可见原文
|
// 软删占位:自删不打码;管理删对公众匿名马赛克(身份以 API 是否返回 user 为准)
|
||||||
if (c.deleted) {
|
if (c.deleted) {
|
||||||
const displayName = c.user?.nickname || c.user?.username || "用户";
|
const author = c.user;
|
||||||
|
const displayName = author?.nickname || author?.username || "用户";
|
||||||
const typeLabel = postDeleteTypeLabel(c.delete_type);
|
const typeLabel = postDeleteTypeLabel(c.delete_type);
|
||||||
const isMod = !!user && canModerateBoard(user, boardId);
|
const isMod = !!user && canModerateBoard(user, boardId);
|
||||||
const isCommenter = !!user && user.id === c.user_id;
|
const showMosaic = !!c.staff_deleted && !author;
|
||||||
// 管理删:仅普通访客打码;管理员与评论者本人看真实身份
|
|
||||||
const showMosaic = !!c.staff_deleted && !isMod && !isCommenter;
|
|
||||||
const selfReasonForStaff = !c.staff_deleted && !!c.delete_reason && isMod;
|
const selfReasonForStaff = !c.staff_deleted && !!c.delete_reason && isMod;
|
||||||
const staffPublicMeta = c.staff_deleted && (!!typeLabel || !!c.delete_reason);
|
const staffPublicMeta = c.staff_deleted && (!!typeLabel || !!c.delete_reason);
|
||||||
const deleter = c.deleted_by_user;
|
const deleter = c.deleted_by_user;
|
||||||
@@ -508,21 +501,23 @@ export default function CommentSection({
|
|||||||
: "";
|
: "";
|
||||||
|
|
||||||
const identity = showMosaic ? (
|
const identity = showMosaic ? (
|
||||||
<MosaicAvatar name={displayName} src={c.user?.avatar} size={isFloor ? 38 : 28} />
|
<MosaicAvatar size={isFloor ? 38 : 28} />
|
||||||
) : (
|
) : author ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/u/${c.user_id}`}
|
href={`/u/${author.id}`}
|
||||||
className={isFloor ? "shrink-0 mt-0.5" : "shrink-0"}
|
className={isFloor ? "shrink-0 mt-0.5" : "shrink-0"}
|
||||||
aria-label={displayName}
|
aria-label={displayName}
|
||||||
>
|
>
|
||||||
<Avatar name={displayName} src={c.user?.avatar || undefined} size={isFloor ? 38 : 28} />
|
<Avatar name={displayName} src={author.avatar || undefined} size={isFloor ? 38 : 28} />
|
||||||
</Link>
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Avatar name={displayName} size={isFloor ? 38 : 28} />
|
||||||
);
|
);
|
||||||
|
|
||||||
const nameRow = showMosaic ? (
|
const nameRow = showMosaic ? (
|
||||||
<MosaicName text={displayName} className={isFloor ? "text-sm" : "cmt-reply-name"} />
|
<MosaicName />
|
||||||
) : (
|
) : author ? (
|
||||||
<Link href={`/u/${c.user_id}`} className="group">
|
<Link href={`/u/${author.id}`} className="group">
|
||||||
<span
|
<span
|
||||||
className={`truncate group-hover:text-[var(--accent)] transition-colors ${
|
className={`truncate group-hover:text-[var(--accent)] transition-colors ${
|
||||||
isFloor ? "text-sm" : "cmt-reply-name"
|
isFloor ? "text-sm" : "cmt-reply-name"
|
||||||
@@ -532,6 +527,13 @@ export default function CommentSection({
|
|||||||
{displayName}
|
{displayName}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={`truncate ${isFloor ? "text-sm" : "cmt-reply-name"}`}
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
const tombstoneBody = (
|
const tombstoneBody = (
|
||||||
@@ -565,9 +567,11 @@ export default function CommentSection({
|
|||||||
<p className="cmt-tombstone-reason !mt-1">{c.delete_reason}</p>
|
<p className="cmt-tombstone-reason !mt-1">{c.delete_reason}</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{isMod && c.content ? (
|
{c.content ? (
|
||||||
<div className="cmt-tombstone-orig">
|
<div className="cmt-tombstone-orig">
|
||||||
<span className="cmt-tombstone-audit-label">原文 · 仅管理可见</span>
|
<span className="cmt-tombstone-audit-label">
|
||||||
|
{isMod && user?.id !== author?.id ? "原文 · 仅管理可见" : "原文 · 仅你可见"}
|
||||||
|
</span>
|
||||||
<div className="mt-1.5">
|
<div className="mt-1.5">
|
||||||
<MarkdownBodyClient content={c.content} className="prose-content prose-comment" />
|
<MarkdownBodyClient content={c.content} className="prose-content prose-comment" />
|
||||||
</div>
|
</div>
|
||||||
@@ -692,22 +696,33 @@ export default function CommentSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const author = c.user;
|
||||||
|
const authorName = author?.nickname || author?.username || "用户";
|
||||||
|
|
||||||
if (!isFloor) {
|
if (!isFloor) {
|
||||||
// 子回复:紧凑行式布局(抖音风格)
|
// 子回复:紧凑行式布局(抖音风格)
|
||||||
const isReplyToReply = depth >= 2;
|
const isReplyToReply = depth >= 2;
|
||||||
return (
|
return (
|
||||||
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24">
|
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24">
|
||||||
<div className="flex gap-2.5">
|
<div className="flex gap-2.5">
|
||||||
<Link href={`/u/${c.user.id}`} className="shrink-0" aria-label={c.user.nickname || c.user.username}>
|
{author ? (
|
||||||
<Avatar name={c.user.nickname || c.user.username} src={c.user.avatar || undefined} size={28} />
|
<Link href={`/u/${author.id}`} className="shrink-0" aria-label={authorName}>
|
||||||
</Link>
|
<Avatar name={authorName} src={author.avatar || undefined} size={28} />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Avatar name={authorName} size={28} />
|
||||||
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
<Link href={`/u/${c.user.id}`} className="group shrink-0">
|
{author ? (
|
||||||
<span className="cmt-reply-name group-hover:text-[var(--accent)] transition-colors">
|
<Link href={`/u/${author.id}`} className="group shrink-0">
|
||||||
{c.user.nickname || c.user.username}
|
<span className="cmt-reply-name group-hover:text-[var(--accent)] transition-colors">
|
||||||
</span>
|
{authorName}
|
||||||
</Link>
|
</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="cmt-reply-name">{authorName}</span>
|
||||||
|
)}
|
||||||
{c.status === "pending" && <span className="badge-pending shrink-0 !text-[10px] !py-0.5 !px-1.5">待审</span>}
|
{c.status === "pending" && <span className="badge-pending shrink-0 !text-[10px] !py-0.5 !px-1.5">待审</span>}
|
||||||
{acceptedCommentId === c.id && (
|
{acceptedCommentId === c.id && (
|
||||||
<span className="badge-accepted shrink-0 !text-[10px] !py-0.5 !px-1.5">已采纳</span>
|
<span className="badge-accepted shrink-0 !text-[10px] !py-0.5 !px-1.5">已采纳</span>
|
||||||
@@ -734,7 +749,7 @@ export default function CommentSection({
|
|||||||
className={`cmt-reply-btn transition-colors ${
|
className={`cmt-reply-btn transition-colors ${
|
||||||
replyTarget?.id === c.id ? "!text-[var(--accent)]" : ""
|
replyTarget?.id === c.id ? "!text-[var(--accent)]" : ""
|
||||||
}`}
|
}`}
|
||||||
aria-label={`回复 ${c.user.nickname || c.user.username}`}
|
aria-label={`回复 ${authorName}`}
|
||||||
data-tip="回复"
|
data-tip="回复"
|
||||||
>
|
>
|
||||||
回复
|
回复
|
||||||
@@ -745,7 +760,7 @@ export default function CommentSection({
|
|||||||
type="button"
|
type="button"
|
||||||
className="cmt-reply-btn !text-[var(--accent)]"
|
className="cmt-reply-btn !text-[var(--accent)]"
|
||||||
disabled={acceptingId === c.id}
|
disabled={acceptingId === c.id}
|
||||||
onClick={() => void handleAccept(c.id, c.user.id)}
|
onClick={() => author && void handleAccept(c.id, author.id)}
|
||||||
data-tip={acceptMode === "question" ? "采纳为最佳答案" : "采纳并发放悬赏积分"}
|
data-tip={acceptMode === "question" ? "采纳为最佳答案" : "采纳并发放悬赏积分"}
|
||||||
>
|
>
|
||||||
{acceptingId === c.id ? "采纳中…" : acceptMode === "question" ? "采纳答案" : "采纳"}
|
{acceptingId === c.id ? "采纳中…" : acceptMode === "question" ? "采纳答案" : "采纳"}
|
||||||
@@ -848,16 +863,26 @@ export default function CommentSection({
|
|||||||
style={{ borderColor: "var(--line)" }}
|
style={{ borderColor: "var(--line)" }}
|
||||||
>
|
>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Link href={`/u/${c.user.id}`} className="shrink-0 mt-0.5" aria-label={c.user.nickname || c.user.username}>
|
{author ? (
|
||||||
<Avatar name={c.user.nickname || c.user.username} src={c.user.avatar || undefined} size={38} />
|
<Link href={`/u/${author.id}`} className="shrink-0 mt-0.5" aria-label={authorName}>
|
||||||
</Link>
|
<Avatar name={authorName} src={author.avatar || undefined} size={38} />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Avatar name={authorName} size={38} className="mt-0.5" />
|
||||||
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2.5 mb-1.5 flex-wrap">
|
<div className="flex items-center gap-2.5 mb-1.5 flex-wrap">
|
||||||
<Link href={`/u/${c.user.id}`} className="group">
|
{author ? (
|
||||||
<span className="truncate group-hover:text-[var(--accent)] transition-colors text-sm" style={{ color: "var(--ink)" }}>
|
<Link href={`/u/${author.id}`} className="group">
|
||||||
{c.user.nickname || c.user.username}
|
<span className="truncate group-hover:text-[var(--accent)] transition-colors text-sm" style={{ color: "var(--ink)" }}>
|
||||||
|
{authorName}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="truncate text-sm" style={{ color: "var(--ink)" }}>
|
||||||
|
{authorName}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
)}
|
||||||
{c.status === "pending" && <span className="badge-pending shrink-0">待审</span>}
|
{c.status === "pending" && <span className="badge-pending shrink-0">待审</span>}
|
||||||
{acceptedCommentId === c.id && (
|
{acceptedCommentId === c.id && (
|
||||||
<span className="badge-accepted shrink-0">已采纳</span>
|
<span className="badge-accepted shrink-0">已采纳</span>
|
||||||
@@ -882,7 +907,7 @@ export default function CommentSection({
|
|||||||
className={`cmt-reply-btn transition-colors ${
|
className={`cmt-reply-btn transition-colors ${
|
||||||
replyTarget?.id === c.id ? "!text-[var(--accent)]" : ""
|
replyTarget?.id === c.id ? "!text-[var(--accent)]" : ""
|
||||||
}`}
|
}`}
|
||||||
aria-label={`回复 ${c.user.nickname || c.user.username}`}
|
aria-label={`回复 ${authorName}`}
|
||||||
data-tip="回复"
|
data-tip="回复"
|
||||||
>
|
>
|
||||||
回复
|
回复
|
||||||
@@ -893,7 +918,7 @@ export default function CommentSection({
|
|||||||
type="button"
|
type="button"
|
||||||
className="cmt-reply-btn !text-[var(--accent)]"
|
className="cmt-reply-btn !text-[var(--accent)]"
|
||||||
disabled={acceptingId === c.id}
|
disabled={acceptingId === c.id}
|
||||||
onClick={() => void handleAccept(c.id, c.user.id)}
|
onClick={() => author && void handleAccept(c.id, author.id)}
|
||||||
data-tip={acceptMode === "question" ? "采纳为最佳答案" : "采纳并发放悬赏积分"}
|
data-tip={acceptMode === "question" ? "采纳为最佳答案" : "采纳并发放悬赏积分"}
|
||||||
>
|
>
|
||||||
{acceptingId === c.id ? "采纳中…" : acceptMode === "question" ? "采纳答案" : "采纳"}
|
{acceptingId === c.id ? "采纳中…" : acceptMode === "question" ? "采纳答案" : "采纳"}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default function DeleteCommentModal({
|
|||||||
title={isStaff ? "删除评论" : "删除我的评论"}
|
title={isStaff ? "删除评论" : "删除我的评论"}
|
||||||
description={
|
description={
|
||||||
isStaff
|
isStaff
|
||||||
? "评论区保留马赛克占位;读者可见删除类型,「其他」时还会展示补充理由。子回复不受影响。"
|
? "评论区保留匿名马赛克占位,读者看不到评论者头像与名称;可见删除类型,「其他」时还会展示补充理由。子回复不受影响。"
|
||||||
: "评论区保留马赛克占位,读者仅见「评论者已删除此评论」。你填写的理由仅管理员可见。"
|
: "评论区保留马赛克占位,读者仅见「评论者已删除此评论」。你填写的理由仅管理员可见。"
|
||||||
}
|
}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
|||||||
@@ -241,7 +241,8 @@ export interface AcceptedAnswer {
|
|||||||
content: string;
|
content: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
deleted?: boolean;
|
deleted?: boolean;
|
||||||
user: {
|
/** 管理删对公众省略 */
|
||||||
|
user?: {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
@@ -337,7 +338,8 @@ export interface LotteryState {
|
|||||||
export interface Comment {
|
export interface Comment {
|
||||||
id: number;
|
id: number;
|
||||||
post_id: number;
|
post_id: number;
|
||||||
user_id: number;
|
/** 管理删对公众省略 */
|
||||||
|
user_id?: number;
|
||||||
parent_id?: number | null; // 父评论 ID,null = 主评论(楼层)
|
parent_id?: number | null; // 父评论 ID,null = 主评论(楼层)
|
||||||
root_id?: number | null; // 所属楼层 ID,子评论必填
|
root_id?: number | null; // 所属楼层 ID,子评论必填
|
||||||
depth?: number; // 层级:主评论 0,子评论 = 父 + 1
|
depth?: number; // 层级:主评论 0,子评论 = 父 + 1
|
||||||
@@ -347,7 +349,8 @@ export interface Comment {
|
|||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
/** 相对创建已编辑(服务端判定) */
|
/** 相对创建已编辑(服务端判定) */
|
||||||
edited?: boolean;
|
edited?: boolean;
|
||||||
user: User;
|
/** 管理删对公众省略;版主与评论者本人仍返回 */
|
||||||
|
user?: User;
|
||||||
/** 软删占位 */
|
/** 软删占位 */
|
||||||
deleted?: boolean;
|
deleted?: boolean;
|
||||||
/** 管理删除(DeletedBy≠评论者) */
|
/** 管理删除(DeletedBy≠评论者) */
|
||||||
|
|||||||
Reference in New Issue
Block a user