feat: @提及功能优化 + 默认头像改用SVG图标
- 评论区有效@提及渲染为蓝色链接跳转/space/{uid},无效@保持纯文本
- 输入@触发悬浮窗,根据输入实时搜索用户(含头像+等级)
- 无匹配时显示未搜索到用户,选中后插入为@用户名 格式(尾部空格)
- 悬浮窗定位于输入光标上方,不遮挡输入内容
- 后端Comment新增Mentions字段,批量加载提及映射供前端判断
- SearchUsers/parseMentions 统一限制LV2+用户
- 所有默认头像由首字符文字改为统一SVG人形图标(nav/home/space/mention等7处)
This commit is contained in:
@ -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 后台评论列表行
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== 右下角悬浮操作栏 ========== */
|
/* ========== 右下角悬浮操作栏 ========== */
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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 = '';
|
||||||
|
|||||||
Reference in New Issue
Block a user