feat: @提及功能优化 + 默认头像改用SVG图标

- 评论区有效@提及渲染为蓝色链接跳转/space/{uid},无效@保持纯文本
- 输入@触发悬浮窗,根据输入实时搜索用户(含头像+等级)
- 无匹配时显示未搜索到用户,选中后插入为@用户名 格式(尾部空格)
- 悬浮窗定位于输入光标上方,不遮挡输入内容
- 后端Comment新增Mentions字段,批量加载提及映射供前端判断
- SearchUsers/parseMentions 统一限制LV2+用户
- 所有默认头像由首字符文字改为统一SVG人形图标(nav/home/space/mention等7处)
This commit is contained in:
2026-06-03 00:49:24 +08:00
parent 2d05da6709
commit 4ac4f64f33
12 changed files with 249 additions and 57 deletions

View File

@ -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 后台评论列表行

View File

@ -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
}

View File

@ -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,

View File

@ -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)
}

View File

@ -65,9 +65,8 @@
{{if .IsLoggedIn}}
{{if .Avatar}}
<img src="{{.Avatar}}" alt="{{.Username}}" class="user-avatar-img">
<div class="user-avatar-fallback" style="display:none">{{substr .Username 0 1}}</div>
{{else}}
<div class="user-avatar-fallback">{{substr .Username 0 1}}</div>
<div class="user-avatar-fallback"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg></div>
{{end}}
<h3 class="user-card-name">{{.Username}}</h3>
<p class="user-card-desc">欢迎加入 MetaLab</p>

View File

@ -21,9 +21,8 @@
<a href="/space" class="nav-avatar-link">
{{if .Avatar}}
<img src="{{.Avatar}}" alt="" class="nav-avatar">
<span class="nav-avatar nav-avatar-default" style="display:none">{{substr .Username 0 1}}</span>
{{else}}
<span class="nav-avatar nav-avatar-default">{{substr .Username 0 1}}</span>
<span class="nav-avatar nav-avatar-default"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg></span>
{{end}}
</a>
<div class="nav-dropdown">
@ -31,9 +30,8 @@
<div class="nav-dropdown-avatar">
{{if .Avatar}}
<img src="{{.Avatar}}" alt="">
<span style="display:none">{{substr .Username 0 1}}</span>
{{else}}
<span>{{substr .Username 0 1}}</span>
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
{{end}}
</div>
<div class="nav-dropdown-meta">

View File

@ -22,7 +22,7 @@
{{if .SpaceUser.Avatar}}
<img src="{{.SpaceUser.Avatar}}" alt="{{.SpaceUser.Username}}" class="space-avatar-img">
{{else}}
<div class="space-avatar-placeholder">{{slice .SpaceUser.Username 0 1}}</div>
<div class="space-avatar-placeholder"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg></div>
{{end}}
</a>
<div class="space-header-info">
@ -225,7 +225,7 @@
{{if $item.FolloweeAvatar}}
<img src="{{$item.FolloweeAvatar}}" alt="{{$item.FolloweeUsername}}" class="space-user-avatar-img">
{{else}}
<div class="space-user-avatar-placeholder">{{slice $item.FolloweeUsername 0 1}}</div>
<div class="space-user-avatar-placeholder"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg></div>
{{end}}
</a>
<div class="space-user-info">
@ -293,7 +293,7 @@
{{if $item.FollowerAvatar}}
<img src="{{$item.FollowerAvatar}}" alt="{{$item.FollowerUsername}}" class="space-user-avatar-img">
{{else}}
<div class="space-user-avatar-placeholder">{{slice $item.FollowerUsername 0 1}}</div>
<div class="space-user-avatar-placeholder"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg></div>
{{end}}
</a>
<div class="space-user-info">

View File

@ -230,11 +230,13 @@ header {
align-items: center;
justify-content: center;
background: var(--color-accent);
color: #fff;
font-size: .85rem;
font-weight: 600;
color: rgba(255,255,255,.75);
object-fit: unset;
}
.nav-avatar-default svg {
width: 55%;
height: 55%;
}
.nav-avatar-wrapper:hover .nav-avatar {
border-color: var(--color-accent);
@ -279,9 +281,11 @@ header {
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 1.1rem;
font-weight: 600;
color: rgba(255,255,255,.75);
}
.nav-dropdown-avatar svg {
width: 52%;
height: 52%;
}
.nav-dropdown-avatar img {

View File

@ -239,14 +239,15 @@
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, var(--color-accent), #2980b9);
color: #fff;
color: rgba(255,255,255,.75);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto .75rem;
font-size: 1.6rem;
font-weight: 700;
text-transform: uppercase;
}
.user-avatar-fallback svg {
width: 55%;
height: 55%;
}
.guest-avatar {

View File

@ -1127,6 +1127,18 @@
margin-right: 6px;
}
/* 有效@提及链接 */
.mention-link {
color: #6366f1;
text-decoration: none;
font-weight: 500;
}
.mention-link:hover {
text-decoration: underline;
color: #4f46e5;
}
.comment-actions {
display: flex;
gap: 16px;
@ -1238,6 +1250,9 @@
}
.mention-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
font-size: 13px;
cursor: pointer;
@ -1250,14 +1265,47 @@
color: #6366f1;
}
.mention-item .mention-username {
font-weight: 500;
.mention-item-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
background: #f3f4f6;
}
.mention-item .mention-uid {
.mention-item-avatar-placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: #e5e7eb;
flex-shrink: 0;
color: #9ca3af;
}
.mention-item-avatar-placeholder svg {
width: 60%;
height: 60%;
}
.mention-item .mention-username {
font-weight: 500;
flex: 1;
}
.mention-item .mention-level {
color: #9ca3af;
font-size: 11px;
margin-left: 8px;
flex-shrink: 0;
}
.mention-no-result {
padding: 12px 14px;
font-size: 13px;
color: #9ca3af;
text-align: center;
}
/* ========== 右下角悬浮操作栏 ========== */

View File

@ -75,14 +75,16 @@
width: 56px; height: 56px;
border-radius: 50%;
background: rgba(255,255,255,.25);
color: #fff;
color: rgba(255,255,255,.75);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
font-weight: 700;
border: 2px solid rgba(255,255,255,.4);
}
.space-avatar-placeholder svg {
width: 55%;
height: 55%;
}
.space-header-info {
color: #fff;
@ -377,9 +379,12 @@
}
.space-user-avatar-placeholder {
width: 48px; height: 48px; border-radius: 50%;
background: var(--color-accent); color: #fff;
background: var(--color-accent); color: rgba(255,255,255,.75);
display: flex; align-items: center; justify-content: center;
font-size: 1.15rem; font-weight: 700;
}
.space-user-avatar-placeholder svg {
width: 55%;
height: 55%;
}
.space-user-info { flex: 1; min-width: 0; }

View File

@ -14,13 +14,28 @@
var currentPage = 1;
var totalPages = 1;
// 渲染@提及:有效提及渲染为蓝色链接,无效提及保持纯文本
function renderMentions(body, mentions) {
body = escapeHtml(body);
if (!mentions || Object.keys(mentions).length === 0) {
// 无 mentions 数据时仍高亮@,但不加链接(降级处理)
return body.replace(/@(\S{1,16})/g, '<span class="reply-to">@$1</span>');
}
return body.replace(/@(\S{1,16})/g, function(match, username) {
var uid = mentions[username];
if (uid) {
return '<a class="mention-link" href="/space/' + uid + '">@' + escapeHtml(username) + '</a>';
}
return match;
});
}
function buildCommentCard(c) {
var time = c.created_at ? formatTime(c.created_at) : '';
var author = c.author_name ? escapeHtml(c.author_name) : '用户已注销';
var body = c.body || '';
body = escapeHtml(body);
body = body.replace(/@(\S{1,16})/g, '<span class="reply-to">@$1</span>');
body = renderMentions(body, c.mentions);
var html = '<div class="comment-card" data-id="' + c.id + '">' +
'<div class="comment-header">' +
@ -53,8 +68,8 @@
var time = r.created_at ? formatTime(r.created_at) : '';
var author = r.author_name ? escapeHtml(r.author_name) : '用户已注销';
var body = r.body || '';
body = escapeHtml(body);
body = body.replace(/@(\S{1,16})/g, '<span class="reply-to">@$1</span>');
body = renderMentions(body, r.mentions);
var prefix = '';
if (r.reply_to_name) {
@ -236,17 +251,42 @@
var mentionPos = 0;
var searchTimer = null;
// 计算 textarea 中光标的近似像素位置(相对视口)
function getCursorPixelPos(textarea, cursorIdx) {
var text = textarea.value.substring(0, cursorIdx);
var lines = text.split('\n');
var lineCount = lines.length;
var lastLine = lines[lineCount - 1];
var style = window.getComputedStyle(textarea);
var lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.5 || 21;
var paddingTop = parseFloat(style.paddingTop) || 0;
var paddingLeft = parseFloat(style.paddingLeft) || 0;
// 近似字符宽度 (中文字符约 2x 英文字符)
var charWidth = parseFloat(style.fontSize) * 0.6 || 8.4;
var rect = textarea.getBoundingClientRect();
var top = rect.top + paddingTop + (lineCount - 1) * lineHeight + lineHeight;
var left = rect.left + paddingLeft + lastLine.length * charWidth;
return { top: top, left: left, bottom: rect.bottom };
}
function bindMention(input) {
input.addEventListener('input', function() {
var val = input.value;
var cursor = input.selectionStart;
var atPos = val.lastIndexOf('@', cursor - 1);
// 检查 @ 前是否为空格或行首(合法位置)
if (atPos === -1 || (atPos > 0 && val[atPos - 1] !== ' ' && val[atPos - 1] !== '\n')) {
hideMention();
return;
}
var query = val.substring(atPos + 1, cursor);
if (query.length === 0 || query.includes(' ')) {
// 查询中包含空格 -> 隐藏下拉
if (query.includes(' ') || query.includes('\n')) {
hideMention();
return;
}
@ -255,6 +295,14 @@
mentionPos = atPos;
clearTimeout(searchTimer);
// 仅输入 @ 时,显示空白下拉
if (query.length === 0) {
showMention(input, [], '');
return;
}
// 有查询内容时,延时搜索
searchTimer = setTimeout(function() {
fetch('/api/users/search?q=' + encodeURIComponent(query))
.then(function(r) { return r.json(); })
@ -262,7 +310,7 @@
if (d.success && d.data && d.data.length > 0) {
showMention(input, d.data, query);
} else {
hideMention();
showMention(input, [], query);
}
})
.catch(function() { hideMention(); });
@ -275,19 +323,28 @@
}
function showMention(input, users, query) {
var rect = input.getBoundingClientRect();
var dropdown = mentionDropdown;
dropdown.innerHTML = '';
dropdown.style.display = 'block';
dropdown.style.top = (rect.bottom + window.scrollY + 4) + 'px';
dropdown.style.left = (rect.left + window.scrollX) + 'px';
dropdown.style.width = Math.max(rect.width, 200) + 'px';
if (users.length === 0) {
// 无匹配结果:显示提示(仅在有查询时)
if (query.length > 0) {
var emptyItem = document.createElement('div');
emptyItem.className = 'mention-no-result';
emptyItem.textContent = '未搜索到用户';
dropdown.appendChild(emptyItem);
}
} else {
for (var i = 0; i < users.length; i++) {
(function(u) {
var item = document.createElement('div');
item.className = 'mention-item';
item.innerHTML = '<span class="mention-username">' + escapeHtml(u.username) + '</span><span class="mention-uid">' + escapeHtml(u.uid) + '</span>';
var avatarHtml = u.avatar
? '<img class="mention-item-avatar" src="' + escapeHtml(u.avatar) + '" alt="" />'
: '<span class="mention-item-avatar mention-item-avatar-placeholder"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg></span>';
item.innerHTML = avatarHtml +
'<span class="mention-username">' + escapeHtml(u.username) + '</span>' +
'<span class="mention-level">LV' + u.level + '</span>';
item.addEventListener('mousedown', function(e) {
e.preventDefault();
selectMention(u);
@ -297,6 +354,18 @@
}
}
// 计算光标位置并定位(先渲染内容,再用 offsetHeight 定位)
var cursorIdx = input.selectionStart;
var pos = getCursorPixelPos(input, cursorIdx);
dropdown.style.display = 'block';
dropdown.style.width = '220px';
// 悬浮在光标上方(不遮挡输入内容)
var dropHeight = dropdown.offsetHeight || 60;
dropdown.style.top = (pos.top - dropHeight - 4 + window.scrollY) + 'px';
dropdown.style.left = Math.min(pos.left, window.innerWidth - 230) + 'px';
}
function hideMention() {
mentionDropdown.style.display = 'none';
mentionDropdown.innerHTML = '';