From 4ac4f64f33e3a90a40520fb8143891a6a5e42a11 Mon Sep 17 00:00:00 2001 From: Victor_Jay Date: Wed, 3 Jun 2026 00:49:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20@=E6=8F=90=E5=8F=8A=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E4=BC=98=E5=8C=96=20+=20=E9=BB=98=E8=AE=A4=E5=A4=B4=E5=83=8F?= =?UTF-8?q?=E6=94=B9=E7=94=A8SVG=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 评论区有效@提及渲染为蓝色链接跳转/space/{uid},无效@保持纯文本 - 输入@触发悬浮窗,根据输入实时搜索用户(含头像+等级) - 无匹配时显示未搜索到用户,选中后插入为@用户名 格式(尾部空格) - 悬浮窗定位于输入光标上方,不遮挡输入内容 - 后端Comment新增Mentions字段,批量加载提及映射供前端判断 - SearchUsers/parseMentions 统一限制LV2+用户 - 所有默认头像由首字符文字改为统一SVG人形图标(nav/home/space/mention等7处) --- internal/model/comment.go | 4 + internal/repository/comment_mention_repo.go | 55 ++++++++- internal/service/comment_service.go | 21 +++- internal/service/repository.go | 2 + templates/MetaLab-2026/html/home/index.html | 3 +- templates/MetaLab-2026/html/layout/nav.html | 6 +- templates/MetaLab-2026/html/space/index.html | 6 +- templates/MetaLab-2026/static/css/common.css | 16 ++- templates/MetaLab-2026/static/css/home.css | 9 +- templates/MetaLab-2026/static/css/posts.css | 56 ++++++++- templates/MetaLab-2026/static/css/space.css | 15 ++- .../MetaLab-2026/static/js/post-comments.js | 113 ++++++++++++++---- 12 files changed, 249 insertions(+), 57 deletions(-) diff --git a/internal/model/comment.go b/internal/model/comment.go index 6beeebb..082bb47 100644 --- a/internal/model/comment.go +++ b/internal/model/comment.go @@ -25,6 +25,9 @@ type Comment struct { ReplyToName string `gorm:"-:migration;<-:false" json:"reply_to_name,omitempty"` // RepliesCount 回复数(非DB字段,查询填充) RepliesCount int `gorm:"-:migration;<-:false" json:"replies_count,omitempty"` + // Mentions 有效@提及映射 username -> uid(非DB字段,批量查询填充) + // 前端据此判断 @username 是否为有效提及并生成链接 + Mentions map[string]uint `gorm:"-" json:"mentions,omitempty"` } // CommentMention @提及映射(绑定UID,不是username,支持改名后自动更新显示名) @@ -39,6 +42,7 @@ type UserSearchResult struct { UID uint `json:"uid"` Username string `json:"username"` Level int `json:"level"` + Avatar string `json:"avatar"` } // AdminCommentRow 后台评论列表行 diff --git a/internal/repository/comment_mention_repo.go b/internal/repository/comment_mention_repo.go index f8ee0ab..d7f63b5 100644 --- a/internal/repository/comment_mention_repo.go +++ b/internal/repository/comment_mention_repo.go @@ -17,15 +17,62 @@ func (r *CommentRepo) FindMentionsByComment(commentID uint) ([]model.CommentMent return list, err } -// SearchUsersByLevel 搜索指定等级及以上用户(用于@艾特),返回 uid/username/exp +// LoadMentionsForComments 批量加载多条评论的有效@提及映射(username -> uid) +// 传入的 comments 会被原地修改,填充 Mentions 字段 +func (r *CommentRepo) LoadMentionsForComments(comments []model.Comment) error { + if len(comments) == 0 { + return nil + } + + commentIDs := make([]uint, len(comments)) + for i, c := range comments { + commentIDs[i] = c.ID + } + + // 查询 comment_mentions JOIN users,拿到 username 和 uid + type mentionRow struct { + CommentID uint + UID uint + Username string + } + var rows []mentionRow + err := r.db.Table("comment_mentions"). + Select("comment_mentions.comment_id, comment_mentions.uid, users.username"). + Joins("LEFT JOIN users ON users.id = comment_mentions.uid"). + Where("comment_mentions.comment_id IN ?", commentIDs). + Find(&rows).Error + if err != nil { + return err + } + + // 按 comment_id 分组 + mentionMap := make(map[uint]map[string]uint) + for _, row := range rows { + if mentionMap[row.CommentID] == nil { + mentionMap[row.CommentID] = make(map[string]uint) + } + mentionMap[row.CommentID][row.Username] = row.UID + } + + for i := range comments { + if m, ok := mentionMap[comments[i].ID]; ok { + comments[i].Mentions = m + } + } + return nil +} + +// SearchUsersByLevel 搜索指定等级及以上用户(用于@艾特),返回 uid/username/avatar/exp func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel int, limit int) ([]struct { UID uint Username string + Avatar string Exp int }, error) { type result struct { UID uint Username string + Avatar string Exp int } var list []result @@ -33,7 +80,7 @@ func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel int, limit int threshold := model.LevelThresholds[minLevel] err := r.db.Table("users"). - Select("id, username, exp"). + Select("id, username, avatar, exp"). Where("username LIKE ? AND exp >= ? AND deleted_at IS NULL", "%"+keyword+"%", threshold). Order("exp DESC"). Limit(limit). @@ -42,14 +89,16 @@ func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel int, limit int var res []struct { UID uint Username string + Avatar string Exp int } for _, u := range list { res = append(res, struct { UID uint Username string + Avatar string Exp int - }{UID: u.UID, Username: u.Username, Exp: u.Exp}) + }{UID: u.UID, Username: u.Username, Avatar: u.Avatar, Exp: u.Exp}) } return res, err } diff --git a/internal/service/comment_service.go b/internal/service/comment_service.go index 7006571..387b073 100644 --- a/internal/service/comment_service.go +++ b/internal/service/comment_service.go @@ -152,7 +152,15 @@ func (s *CommentService) Delete(commentID uint, requesterID uint, isAdmin bool) func (s *CommentService) ListRootComments(postID uint, page, pageSize int) ([]model.Comment, int64, error) { p := common.Pagination{Page: page, PageSize: pageSize} p.DefaultPagination() - return s.repo.FindRootComments(postID, p.Offset(), p.PageSize) + comments, total, err := s.repo.FindRootComments(postID, p.Offset(), p.PageSize) + if err != nil { + return nil, 0, err + } + // 批量加载有效@提及映射,前端据此决定是否为链接 + if len(comments) > 0 { + _ = s.repo.LoadMentionsForComments(comments) + } + return comments, total, nil } // ListReplies 获取某条顶级评论的全部回复 @@ -164,6 +172,10 @@ func (s *CommentService) ListReplies(rootID uint) ([]model.Comment, error) { if list == nil { list = []model.Comment{} } + // 批量加载有效@提及映射 + if len(list) > 0 { + _ = s.repo.LoadMentionsForComments(list) + } return list, nil } @@ -178,9 +190,9 @@ func (s *CommentService) ListAllComments(keyword string, showDeleted bool, page, return rows, total, err } -// SearchUsers 搜索LV3+用户(用于@艾特) +// SearchUsers 搜索LV2+用户(用于@艾特悬浮窗) func (s *CommentService) SearchUsers(keyword string) ([]model.UserSearchResult, error) { - rows, err := s.repo.SearchUsersByLevel(keyword, 3, 10) + rows, err := s.repo.SearchUsersByLevel(keyword, 2, 10) if err != nil { return nil, fmt.Errorf("搜索用户失败: %w", err) } @@ -191,6 +203,7 @@ func (s *CommentService) SearchUsers(keyword string) ([]model.UserSearchResult, UID: row.UID, Username: row.Username, Level: model.GetLevelByExp(row.Exp), + Avatar: row.Avatar, } } return users, nil @@ -206,7 +219,7 @@ func (s *CommentService) parseMentions(commentID uint, body string) { continue } seen[username] = true - rows, _ := s.repo.SearchUsersByLevel(username, 0, 1) + rows, _ := s.repo.SearchUsersByLevel(username, 2, 1) if len(rows) > 0 { _ = s.repo.CreateMention(&model.CommentMention{ CommentID: commentID, diff --git a/internal/service/repository.go b/internal/service/repository.go index 90e98c8..f0314da 100644 --- a/internal/service/repository.go +++ b/internal/service/repository.go @@ -158,9 +158,11 @@ type commentStore interface { SearchUsersByLevel(keyword string, minLevel, limit int) ([]struct { UID uint Username string + Avatar string Exp int }, error) GetUsernameByID(userID uint) (string, error) + LoadMentionsForComments(comments []model.Comment) error ListAllComments(keyword string, showDeleted bool, offset, limit int) ([]model.AdminCommentRow, int64, error) } diff --git a/templates/MetaLab-2026/html/home/index.html b/templates/MetaLab-2026/html/home/index.html index 5ff5b69..65c113f 100644 --- a/templates/MetaLab-2026/html/home/index.html +++ b/templates/MetaLab-2026/html/home/index.html @@ -65,9 +65,8 @@ {{if .IsLoggedIn}} {{if .Avatar}} {{.Username}} - {{else}} -
{{substr .Username 0 1}}
+
{{end}}

{{.Username}}

欢迎加入 MetaLab

diff --git a/templates/MetaLab-2026/html/layout/nav.html b/templates/MetaLab-2026/html/layout/nav.html index eed73db..b48dabe 100644 --- a/templates/MetaLab-2026/html/layout/nav.html +++ b/templates/MetaLab-2026/html/layout/nav.html @@ -21,9 +21,8 @@ {{if .Avatar}} - {{else}} - {{substr .Username 0 1}} + {{end}}