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"` ReplyToName string `gorm:"-:migration;<-:false" json:"reply_to_name,omitempty"`
// RepliesCount 回复数非DB字段查询填充 // RepliesCount 回复数非DB字段查询填充
RepliesCount int `gorm:"-:migration;<-:false" json:"replies_count,omitempty"` 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支持改名后自动更新显示名 // CommentMention @提及映射绑定UID不是username支持改名后自动更新显示名
@ -39,6 +42,7 @@ type UserSearchResult struct {
UID uint `json:"uid"` UID uint `json:"uid"`
Username string `json:"username"` Username string `json:"username"`
Level int `json:"level"` Level int `json:"level"`
Avatar string `json:"avatar"`
} }
// AdminCommentRow 后台评论列表行 // AdminCommentRow 后台评论列表行

View File

@ -17,15 +17,62 @@ func (r *CommentRepo) FindMentionsByComment(commentID uint) ([]model.CommentMent
return list, err 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 { func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel int, limit int) ([]struct {
UID uint UID uint
Username string Username string
Avatar string
Exp int Exp int
}, error) { }, error) {
type result struct { type result struct {
UID uint UID uint
Username string Username string
Avatar string
Exp int Exp int
} }
var list []result var list []result
@ -33,7 +80,7 @@ func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel int, limit int
threshold := model.LevelThresholds[minLevel] threshold := model.LevelThresholds[minLevel]
err := r.db.Table("users"). 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). Where("username LIKE ? AND exp >= ? AND deleted_at IS NULL", "%"+keyword+"%", threshold).
Order("exp DESC"). Order("exp DESC").
Limit(limit). Limit(limit).
@ -42,14 +89,16 @@ func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel int, limit int
var res []struct { var res []struct {
UID uint UID uint
Username string Username string
Avatar string
Exp int Exp int
} }
for _, u := range list { for _, u := range list {
res = append(res, struct { res = append(res, struct {
UID uint UID uint
Username string Username string
Avatar string
Exp int 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 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) { func (s *CommentService) ListRootComments(postID uint, page, pageSize int) ([]model.Comment, int64, error) {
p := common.Pagination{Page: page, PageSize: pageSize} p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination() 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 获取某条顶级评论的全部回复 // ListReplies 获取某条顶级评论的全部回复
@ -164,6 +172,10 @@ func (s *CommentService) ListReplies(rootID uint) ([]model.Comment, error) {
if list == nil { if list == nil {
list = []model.Comment{} list = []model.Comment{}
} }
// 批量加载有效@提及映射
if len(list) > 0 {
_ = s.repo.LoadMentionsForComments(list)
}
return list, nil return list, nil
} }
@ -178,9 +190,9 @@ func (s *CommentService) ListAllComments(keyword string, showDeleted bool, page,
return rows, total, err return rows, total, err
} }
// SearchUsers 搜索LV3+用户(用于@艾特) // SearchUsers 搜索LV2+用户(用于@艾特悬浮窗
func (s *CommentService) SearchUsers(keyword string) ([]model.UserSearchResult, error) { 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 { if err != nil {
return nil, fmt.Errorf("搜索用户失败: %w", err) return nil, fmt.Errorf("搜索用户失败: %w", err)
} }
@ -191,6 +203,7 @@ func (s *CommentService) SearchUsers(keyword string) ([]model.UserSearchResult,
UID: row.UID, UID: row.UID,
Username: row.Username, Username: row.Username,
Level: model.GetLevelByExp(row.Exp), Level: model.GetLevelByExp(row.Exp),
Avatar: row.Avatar,
} }
} }
return users, nil return users, nil
@ -206,7 +219,7 @@ func (s *CommentService) parseMentions(commentID uint, body string) {
continue continue
} }
seen[username] = true seen[username] = true
rows, _ := s.repo.SearchUsersByLevel(username, 0, 1) rows, _ := s.repo.SearchUsersByLevel(username, 2, 1)
if len(rows) > 0 { if len(rows) > 0 {
_ = s.repo.CreateMention(&model.CommentMention{ _ = s.repo.CreateMention(&model.CommentMention{
CommentID: commentID, CommentID: commentID,

View File

@ -158,9 +158,11 @@ type commentStore interface {
SearchUsersByLevel(keyword string, minLevel, limit int) ([]struct { SearchUsersByLevel(keyword string, minLevel, limit int) ([]struct {
UID uint UID uint
Username string Username string
Avatar string
Exp int Exp int
}, error) }, error)
GetUsernameByID(userID uint) (string, error) GetUsernameByID(userID uint) (string, error)
LoadMentionsForComments(comments []model.Comment) error
ListAllComments(keyword string, showDeleted bool, offset, limit int) ([]model.AdminCommentRow, int64, error) ListAllComments(keyword string, showDeleted bool, offset, limit int) ([]model.AdminCommentRow, int64, error)
} }

View File

@ -65,9 +65,8 @@
{{if .IsLoggedIn}} {{if .IsLoggedIn}}
{{if .Avatar}} {{if .Avatar}}
<img src="{{.Avatar}}" alt="{{.Username}}" class="user-avatar-img"> <img src="{{.Avatar}}" alt="{{.Username}}" class="user-avatar-img">
<div class="user-avatar-fallback" style="display:none">{{substr .Username 0 1}}</div>
{{else}} {{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}} {{end}}
<h3 class="user-card-name">{{.Username}}</h3> <h3 class="user-card-name">{{.Username}}</h3>
<p class="user-card-desc">欢迎加入 MetaLab</p> <p class="user-card-desc">欢迎加入 MetaLab</p>

View File

@ -21,9 +21,8 @@
<a href="/space" class="nav-avatar-link"> <a href="/space" class="nav-avatar-link">
{{if .Avatar}} {{if .Avatar}}
<img src="{{.Avatar}}" alt="" class="nav-avatar"> <img src="{{.Avatar}}" alt="" class="nav-avatar">
<span class="nav-avatar nav-avatar-default" style="display:none">{{substr .Username 0 1}}</span>
{{else}} {{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}} {{end}}
</a> </a>
<div class="nav-dropdown"> <div class="nav-dropdown">
@ -31,9 +30,8 @@
<div class="nav-dropdown-avatar"> <div class="nav-dropdown-avatar">
{{if .Avatar}} {{if .Avatar}}
<img src="{{.Avatar}}" alt=""> <img src="{{.Avatar}}" alt="">
<span style="display:none">{{substr .Username 0 1}}</span>
{{else}} {{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}} {{end}}
</div> </div>
<div class="nav-dropdown-meta"> <div class="nav-dropdown-meta">

View File

@ -22,7 +22,7 @@
{{if .SpaceUser.Avatar}} {{if .SpaceUser.Avatar}}
<img src="{{.SpaceUser.Avatar}}" alt="{{.SpaceUser.Username}}" class="space-avatar-img"> <img src="{{.SpaceUser.Avatar}}" alt="{{.SpaceUser.Username}}" class="space-avatar-img">
{{else}} {{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}} {{end}}
</a> </a>
<div class="space-header-info"> <div class="space-header-info">
@ -225,7 +225,7 @@
{{if $item.FolloweeAvatar}} {{if $item.FolloweeAvatar}}
<img src="{{$item.FolloweeAvatar}}" alt="{{$item.FolloweeUsername}}" class="space-user-avatar-img"> <img src="{{$item.FolloweeAvatar}}" alt="{{$item.FolloweeUsername}}" class="space-user-avatar-img">
{{else}} {{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}} {{end}}
</a> </a>
<div class="space-user-info"> <div class="space-user-info">
@ -293,7 +293,7 @@
{{if $item.FollowerAvatar}} {{if $item.FollowerAvatar}}
<img src="{{$item.FollowerAvatar}}" alt="{{$item.FollowerUsername}}" class="space-user-avatar-img"> <img src="{{$item.FollowerAvatar}}" alt="{{$item.FollowerUsername}}" class="space-user-avatar-img">
{{else}} {{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}} {{end}}
</a> </a>
<div class="space-user-info"> <div class="space-user-info">

View File

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

View File

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

View File

@ -1127,6 +1127,18 @@
margin-right: 6px; margin-right: 6px;
} }
/* 有效@提及链接 */
.mention-link {
color: #6366f1;
text-decoration: none;
font-weight: 500;
}
.mention-link:hover {
text-decoration: underline;
color: #4f46e5;
}
.comment-actions { .comment-actions {
display: flex; display: flex;
gap: 16px; gap: 16px;
@ -1238,6 +1250,9 @@
} }
.mention-item { .mention-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px; padding: 8px 14px;
font-size: 13px; font-size: 13px;
cursor: pointer; cursor: pointer;
@ -1250,14 +1265,47 @@
color: #6366f1; color: #6366f1;
} }
.mention-item .mention-username { .mention-item-avatar {
font-weight: 500; 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; color: #9ca3af;
font-size: 11px; 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; width: 56px; height: 56px;
border-radius: 50%; border-radius: 50%;
background: rgba(255,255,255,.25); background: rgba(255,255,255,.25);
color: #fff; color: rgba(255,255,255,.75);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 1.4rem;
font-weight: 700;
border: 2px solid rgba(255,255,255,.4); border: 2px solid rgba(255,255,255,.4);
} }
.space-avatar-placeholder svg {
width: 55%;
height: 55%;
}
.space-header-info { .space-header-info {
color: #fff; color: #fff;
@ -377,9 +379,12 @@
} }
.space-user-avatar-placeholder { .space-user-avatar-placeholder {
width: 48px; height: 48px; border-radius: 50%; 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; 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; } .space-user-info { flex: 1; min-width: 0; }

View File

@ -14,13 +14,28 @@
var currentPage = 1; var currentPage = 1;
var totalPages = 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) { function buildCommentCard(c) {
var time = c.created_at ? formatTime(c.created_at) : ''; var time = c.created_at ? formatTime(c.created_at) : '';
var author = c.author_name ? escapeHtml(c.author_name) : '用户已注销'; var author = c.author_name ? escapeHtml(c.author_name) : '用户已注销';
var body = c.body || ''; var body = c.body || '';
body = escapeHtml(body); body = renderMentions(body, c.mentions);
body = body.replace(/@(\S{1,16})/g, '<span class="reply-to">@$1</span>');
var html = '<div class="comment-card" data-id="' + c.id + '">' + var html = '<div class="comment-card" data-id="' + c.id + '">' +
'<div class="comment-header">' + '<div class="comment-header">' +
@ -53,8 +68,8 @@
var time = r.created_at ? formatTime(r.created_at) : ''; var time = r.created_at ? formatTime(r.created_at) : '';
var author = r.author_name ? escapeHtml(r.author_name) : '用户已注销'; var author = r.author_name ? escapeHtml(r.author_name) : '用户已注销';
var body = r.body || ''; 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 = ''; var prefix = '';
if (r.reply_to_name) { if (r.reply_to_name) {
@ -236,17 +251,42 @@
var mentionPos = 0; var mentionPos = 0;
var searchTimer = null; 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) { function bindMention(input) {
input.addEventListener('input', function() { input.addEventListener('input', function() {
var val = input.value; var val = input.value;
var cursor = input.selectionStart; var cursor = input.selectionStart;
var atPos = val.lastIndexOf('@', cursor - 1); var atPos = val.lastIndexOf('@', cursor - 1);
// 检查 @ 前是否为空格或行首(合法位置)
if (atPos === -1 || (atPos > 0 && val[atPos - 1] !== ' ' && val[atPos - 1] !== '\n')) { if (atPos === -1 || (atPos > 0 && val[atPos - 1] !== ' ' && val[atPos - 1] !== '\n')) {
hideMention(); hideMention();
return; return;
} }
var query = val.substring(atPos + 1, cursor); var query = val.substring(atPos + 1, cursor);
if (query.length === 0 || query.includes(' ')) { // 查询中包含空格 -> 隐藏下拉
if (query.includes(' ') || query.includes('\n')) {
hideMention(); hideMention();
return; return;
} }
@ -255,6 +295,14 @@
mentionPos = atPos; mentionPos = atPos;
clearTimeout(searchTimer); clearTimeout(searchTimer);
// 仅输入 @ 时,显示空白下拉
if (query.length === 0) {
showMention(input, [], '');
return;
}
// 有查询内容时,延时搜索
searchTimer = setTimeout(function() { searchTimer = setTimeout(function() {
fetch('/api/users/search?q=' + encodeURIComponent(query)) fetch('/api/users/search?q=' + encodeURIComponent(query))
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
@ -262,7 +310,7 @@
if (d.success && d.data && d.data.length > 0) { if (d.success && d.data && d.data.length > 0) {
showMention(input, d.data, query); showMention(input, d.data, query);
} else { } else {
hideMention(); showMention(input, [], query);
} }
}) })
.catch(function() { hideMention(); }); .catch(function() { hideMention(); });
@ -275,19 +323,28 @@
} }
function showMention(input, users, query) { function showMention(input, users, query) {
var rect = input.getBoundingClientRect();
var dropdown = mentionDropdown; var dropdown = mentionDropdown;
dropdown.innerHTML = ''; 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++) { for (var i = 0; i < users.length; i++) {
(function(u) { (function(u) {
var item = document.createElement('div'); var item = document.createElement('div');
item.className = 'mention-item'; 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) { item.addEventListener('mousedown', function(e) {
e.preventDefault(); e.preventDefault();
selectMention(u); 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() { function hideMention() {
mentionDropdown.style.display = 'none'; mentionDropdown.style.display = 'none';
mentionDropdown.innerHTML = ''; mentionDropdown.innerHTML = '';