feat: @提及发送通知 + 通知点击定位高亮

- 新增 NotifyCommentMention 通知类型,归属 mention 分类 TAB
- parseMentions 中向被@用户发送通知(排除自己和重复通知)
- 消息中心 mention 通知可点击跳转至文章页面
- 通过 mention 通知进入时,来源评论置顶并持久半透明高亮
- 普通评论链接保持原有短暂高亮行为不变
This commit is contained in:
2026-06-03 13:21:27 +08:00
parent 2ea9788ec7
commit 8bf6ac9e51
6 changed files with 79 additions and 27 deletions

View File

@ -11,8 +11,9 @@ const (
NotifyLevelUp = "level_up"
NotifyComment = "comment"
NotifyCommentReply = "comment_reply"
NotifyLikeAggregated = "like_aggregated" // 每日聚合点赞通知
NotifyFollow = "follow" // 关注通知
NotifyLikeAggregated = "like_aggregated" // 每日聚合点赞通知
NotifyFollow = "follow" // 关注通知
NotifyCommentMention = "comment_mention" // 评论@提及通知
)
// Notification 消息/通知模型
@ -39,6 +40,7 @@ var NotifyTypeNames = map[string]string{
NotifyLevelUp: "等级提升",
NotifyComment: "评论",
NotifyCommentReply: "回复",
NotifyCommentMention: "@ 提及",
NotifyLikeAggregated: "点赞",
NotifyFollow: "关注",
}
@ -54,6 +56,7 @@ var NotifyCategory = map[string]string{
NotifyLevelUp: "system",
NotifyComment: "mention",
NotifyCommentReply: "mention",
NotifyCommentMention: "mention",
NotifyLikeAggregated: "like",
NotifyFollow: "follow",
}

View File

@ -55,15 +55,15 @@ func (s *CommentService) CreateRoot(userID uint, postID uint, body string) (*mod
// 文章评论数+1
_ = s.repo.IncrCommentsCount(postID)
// 解析 @ 提及
s.parseMentions(comment.ID, body)
// 解析 @ 提及 + 发送通知
commenterName, _ := s.repo.GetUsernameByID(userID)
if commenterName == "" {
commenterName = "用户"
}
s.parseMentions(comment.ID, postID, userID, commenterName, body)
// 通知文章作者(评论者 ≠ 作者RelatedID 存 postID 以便消息页生成跳转链接
if s.notifier != nil && userID != postAuthorID {
commenterName, _ := s.repo.GetUsernameByID(userID)
if commenterName == "" {
commenterName = "用户"
}
s.notifier.Create(postAuthorID, model.NotifyComment,
"新评论",
fmt.Sprintf("%s 评论了你的文章", commenterName),
@ -103,15 +103,15 @@ func (s *CommentService) CreateReply(userID uint, parentID uint, body string) (*
// 文章评论数+1
_ = s.repo.IncrCommentsCount(parent.PostID)
// 解析 @ 提及
s.parseMentions(comment.ID, body)
// 解析 @ 提及 + 发送通知
commenterName, _ := s.repo.GetUsernameByID(userID)
if commenterName == "" {
commenterName = "用户"
}
s.parseMentions(comment.ID, parent.PostID, userID, commenterName, body)
// 通知被回复者不自己回复自己RelatedID 存 postID 以便消息页生成跳转链接
if s.notifier != nil && parent.UserID != userID {
commenterName, _ := s.repo.GetUsernameByID(userID)
if commenterName == "" {
commenterName = "用户"
}
s.notifier.Create(parent.UserID, model.NotifyCommentReply,
"新回复",
fmt.Sprintf("%s 回复了你的评论", commenterName),
@ -209,10 +209,12 @@ func (s *CommentService) SearchUsers(keyword string, followerUID uint) ([]model.
return users, nil
}
// parseMentions 解析@用户存储到 comment_mentions 表
func (s *CommentService) parseMentions(commentID uint, body string) {
// parseMentions 解析@用户存储到 comment_mentions 表,并向被@用户发送通知
func (s *CommentService) parseMentions(commentID uint, postID uint, commenterID uint, commenterName string, body string) {
matches := mentionPattern.FindAllStringSubmatch(body, -1)
seen := make(map[string]bool)
notified := make(map[uint]bool) // 防止重复通知同一用户
for _, m := range matches {
username := m[1]
if seen[username] {
@ -220,12 +222,23 @@ func (s *CommentService) parseMentions(commentID uint, body string) {
}
seen[username] = true
rows, _ := s.repo.SearchUsersByLevel(username, 2, 1, 0)
if len(rows) > 0 {
_ = s.repo.CreateMention(&model.CommentMention{
CommentID: commentID,
UID: rows[0].UID,
OriginalUsername: username,
})
if len(rows) == 0 {
continue
}
mentionedUID := rows[0].UID
_ = s.repo.CreateMention(&model.CommentMention{
CommentID: commentID,
UID: mentionedUID,
OriginalUsername: username,
})
// 向被@用户发送通知(排除自己@自己、重复通知)
if s.notifier != nil && mentionedUID != commenterID && !notified[mentionedUID] {
notified[mentionedUID] = true
s.notifier.Create(mentionedUID, model.NotifyCommentMention,
"新提及",
fmt.Sprintf("%s 在评论中 @ 了你", commenterName),
&postID, &commentID)
}
}
}

View File

@ -49,7 +49,7 @@ func categoryTypes(category string) []string {
case "system":
return []string{model.NotifyAuditApproved, model.NotifyAuditRejected, model.NotifyPostApproved, model.NotifyPostRejected, model.NotifyPostLocked, model.NotifyPostUnlocked, model.NotifyLevelUp}
case "mention":
return []string{model.NotifyComment, model.NotifyCommentReply}
return []string{model.NotifyComment, model.NotifyCommentReply, model.NotifyCommentMention}
case "like":
return []string{model.NotifyLikeAggregated}
case "follow":

View File

@ -97,6 +97,12 @@
{{else}}
{{$link = printf "/posts/%d#comments" (derefUint $m.RelatedID)}}
{{end}}
{{else if eq $m.NotifyType "comment_mention"}}
{{if $m.CommentID}}
{{$link = printf "/posts/%d#mention-%d" (derefUint $m.RelatedID) (derefUint $m.CommentID)}}
{{else}}
{{$link = printf "/posts/%d#comments" (derefUint $m.RelatedID)}}
{{end}}
{{else if or (eq $m.NotifyType "audit_approved") (eq $m.NotifyType "audit_rejected")}}
{{$link = "/settings"}}
{{else if eq $m.NotifyType "follow"}}

View File

@ -1096,6 +1096,12 @@
border-bottom: none;
}
/* @提及通知进入:持久半透明高亮 */
.comment-card.highlight-mention,
.comment-reply-card.highlight-mention {
background: rgba(255, 248, 225, 0.5) !important;
}
.comment-header {
display: flex;
justify-content: space-between;

View File

@ -498,21 +498,45 @@
}
// ====== 评论高亮 ======
function highlightComment(commentId) {
function highlightComment(commentId, persistent) {
var card = commentsList.querySelector('.comment-card[data-id="' + commentId + '"]') ||
commentsList.querySelector('.comment-reply-card[data-id="' + commentId + '"]');
if (card) {
commentsList.insertBefore(card, commentsList.firstChild);
card.scrollIntoView({ behavior: 'smooth', block: 'start' });
card.style.background = '#fff8e1';
card.style.transition = 'background 1.5s';
setTimeout(function() { card.style.background = ''; }, 2000);
if (persistent) {
card.classList.add('highlight-mention');
} else {
card.style.background = '#fff8e1';
card.style.transition = 'background 1.5s';
setTimeout(function() { card.style.background = ''; }, 2000);
}
return true;
}
return false;
}
var hash = window.location.hash;
// @提及通知进入:置顶 + 持久高亮
var mentionMatch = hash.match(/^#mention-(\d+)$/);
if (mentionMatch) {
var mentionCommentId = mentionMatch[1];
var mentionInterval = setInterval(function() {
if (highlightComment(mentionCommentId, true)) {
clearInterval(mentionInterval);
}
}, 300);
setTimeout(function() {
clearInterval(mentionInterval);
if (!commentsList.querySelector('.comment-card[data-id="' + mentionCommentId + '"]') &&
!commentsList.querySelector('.comment-reply-card[data-id="' + mentionCommentId + '"]')) {
showToast('该评论已不存在');
}
}, 3000);
}
// 普通评论/回复链接:短暂高亮
var commentMatch = hash.match(/^#comment-(\d+)$/);
if (commentMatch) {
var targetCommentId = commentMatch[1];