Compare commits

..

10 Commits

Author SHA1 Message Date
8bf6ac9e51 feat: @提及发送通知 + 通知点击定位高亮
- 新增 NotifyCommentMention 通知类型,归属 mention 分类 TAB
- parseMentions 中向被@用户发送通知(排除自己和重复通知)
- 消息中心 mention 通知可点击跳转至文章页面
- 通过 mention 通知进入时,来源评论置顶并持久半透明高亮
- 普通评论链接保持原有短暂高亮行为不变
2026-06-03 13:21:27 +08:00
2ea9788ec7 fix: 关注状态 API 添加 Optional 认证中间件
- /api/users/:uid/follow-status 等公开 GET 接口缺少 Optional 认证
- 导致 GetGinUser 无法获取当前用户 uid,始终返回 i_follow=false
- 关注按钮加载时无法显示正确初始状态(始终显示"关注")
- 三条 GET 接口统一加入 authMdw.Optional() 组
- 不影响未登录用户(未登录依然返回 false)
2026-06-03 13:11:56 +08:00
be729c29a1 fix: 关注通知添加用户空间链接跳转
- 消息中心关注通知卡片 now 可点击跳转至关注者空间 (/space/:uid)
2026-06-03 13:06:44 +08:00
584d7fd146 fix: 修复 /space 关注按钮在默认标签页无法点击
- 将顶部关注按钮 JS 从 following/followers 标签页限定块中提取到全局脚本
- 按钮在所有标签页均可点击(之前仅在 following/followers 标签页有效)
- 本人点击关注时 toast 提示"不能关注自己",与赋能按钮行为一致
- 未登录用户点击关注跳转登录页
- 移除标签页限定块中的重复顶部按钮代码
2026-06-03 13:00:12 +08:00
6c9a4414cb refactor: 全局移除浏览器 alert 弹窗,统一使用页面内 showToast 通知
- 替换全部 11 个文件中 36 处 alert() 为 showToast()
- API 错误/异常使用 showToast(msg, 'error'),3秒自动消失
- 表单校验提示使用 showToast(msg, 'warning'),3秒自动消失
- 成功确认使用 showToast(msg, 'success'),3秒自动消失
- 移除 post-energize.js 中与全局 showToast 冲突的本地实现
- 删除 posts.css 中已失去引用的 .energize-toast 样式
- 保留 showConfirm() 自定义确认弹窗(需用户确认的操作)
2026-06-03 01:25:12 +08:00
864a488ee0 feat: @搜索优先展示已关注用户,结果限制最多5条
- SearchUsersByLevel SQL新增LEFT JOIN user_follows,按已关注优先+exp降序
- SearchUsers/followerUID参数贯穿repo→service→controller
- 继续输入时前端自动重新搜索新关键词
2026-06-03 01:15:38 +08:00
fb9724faed fix: space关注按钮移除隐藏逻辑,自关注改为toast提示
- 自己的space也显示关注按钮,不再隐藏
- 点击关注自己时toast'不能关注自己',不发请求
- 自己的space下,发消息/更多按钮保持隐藏
2026-06-03 01:13:01 +08:00
58aabb9bf0 fix: 临时登录cookie改用maxAge=IdleTimeout替代session模式
- rememberMe=false时maxAge从0改为IdleTimeout秒数(默认2h),与Redis TTL一致
- 解决服务器重启后部分浏览器(尤其隐私窗口)将session cookie视为失效的问题
- 关浏览器后依然自动清除(maxAge到期 + Redis TTL双保险)
2026-06-03 01:07:47 +08:00
ae15025b1d feat: @提及支持改名后链接跟随UID + 显示名自动更新
- comment_mentions新增original_username列,创建时记录原始@用户名
- Mentions map改为{original_username: {uid, name}}结构,name为当前显示名
- 用户改名后:旧@文本仍能匹配original_username,链接跟随uid不变,显示名自动更新
- 前端renderMentions适配新结构
2026-06-03 01:00:44 +08:00
be8ce04334 fix: 修复@提及uid始终为0的BUG + 空@提示优化
- SearchUsersByLevel SQL id列别名改为uid,修复GORM字段名映射导致uid=0
- @空输入时悬浮窗显示'输入以查找用户'而非空白
- 清理历史脏数据(uid=0的mention记录)
2026-06-03 00:55:51 +08:00
22 changed files with 267 additions and 193 deletions

View File

@ -15,8 +15,8 @@ const (
)
// SetSessionCookie 写入会话 ID Cookie
// rememberMe=true → Cookie 持久化(30天,关浏览器后仍保持登录
// rememberMe=false → Cookie session 模式maxAge=0,关浏览器清除
// rememberMe=true → Cookie maxAge=30天关浏览器后仍保持登录
// rememberMe=false → Cookie maxAge=空闲超时默认2h与Redis TTL一致,关浏览器后自动清除
func SetSessionCookie(c *gin.Context, sid string, rememberMe bool, cfg *config.Config) {
secure := cfg.Server.Mode != "debug"
c.SetSameSite(http.SameSiteLaxMode)
@ -25,7 +25,7 @@ func SetSessionCookie(c *gin.Context, sid string, rememberMe bool, cfg *config.C
if rememberMe {
maxAge = cfg.Session.RememberTimeout * 60 // 分钟 → 秒
} else {
maxAge = 0 // session cookie
maxAge = cfg.Session.IdleTimeout * 60 // 分钟 → 秒,与 Redis TTL 一致
}
log.Printf("[SetSessionCookie] sid=%s rememberMe=%v maxAge=%ds secure=%v", sid[:16]+"...", rememberMe, maxAge, secure)

View File

@ -41,7 +41,7 @@ func (ctrl *CommentController) Delete(c *gin.Context) {
// SearchUsers GET /api/users/search?q=keyword
func (ctrl *CommentController) SearchUsers(c *gin.Context) {
_, _, ok := common.GetGinUser(c)
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
@ -53,7 +53,7 @@ func (ctrl *CommentController) SearchUsers(c *gin.Context) {
return
}
users, err := ctrl.commentService.SearchUsers(q)
users, err := ctrl.commentService.SearchUsers(q, uid)
if err != nil {
common.Error(c, http.StatusInternalServerError, "搜索用户失败")
return

View File

@ -115,7 +115,7 @@ type commentUseCase interface {
Delete(commentID uint, requesterID uint, isAdmin bool) error
ListRootComments(postID uint, page, pageSize int) ([]model.Comment, int64, error)
ListReplies(rootID uint) ([]model.Comment, error)
SearchUsers(keyword string) ([]model.UserSearchResult, error)
SearchUsers(keyword string, followerUID uint) ([]model.UserSearchResult, error)
}
// reactionUseCase ReactionController 对 ReactionService 的最小依赖ISP4 个方法)

View File

@ -25,16 +25,23 @@ 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"`
// Mentions 有效@提及映射 original_username -> {uid, 当前显示名}非DB字段批量查询填充
// key=创建时的原始@用户名value={uid,当前显示名},用户改名后链接仍有效且显示新名
Mentions map[string]MentionInfo `gorm:"-" json:"mentions,omitempty"`
}
// CommentMention @提及映射绑定UID不是username,支持改名后自动更新显示名)
// CommentMention @提及映射绑定UID + 原始用户名,支持改名后自动更新显示名)
type CommentMention struct {
ID uint `gorm:"primarykey" json:"id"`
CommentID uint `gorm:"uniqueIndex:idx_comment_uid,priority:1;not null" json:"comment_id"`
UID uint `gorm:"uniqueIndex:idx_comment_uid,priority:2;not null" json:"uid"`
OriginalUsername string `gorm:"type:varchar(64);not null;default:''" json:"original_username"`
}
// MentionInfo @提及显示信息(前端渲染用)
type MentionInfo struct {
UID uint `json:"uid"`
Name string `json:"name"` // 当前显示名
}
// UserSearchResult @搜索用户结果

View File

@ -13,6 +13,7 @@ const (
NotifyCommentReply = "comment_reply"
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

@ -17,8 +17,9 @@ func (r *CommentRepo) FindMentionsByComment(commentID uint) ([]model.CommentMent
return list, err
}
// LoadMentionsForComments 批量加载多条评论的有效@提及映射username -> uid
// 传入的 comments 会被原地修改,填充 Mentions 字段
// LoadMentionsForComments 批量加载多条评论的有效@提及映射
// key=创建时的原始@用户名, value={uid, 用户当前显示名}
// 用户改名后原始名匹配body中的旧@文本显示名随users表更新
func (r *CommentRepo) LoadMentionsForComments(comments []model.Comment) error {
if len(comments) == 0 {
return nil
@ -29,15 +30,15 @@ func (r *CommentRepo) LoadMentionsForComments(comments []model.Comment) error {
commentIDs[i] = c.ID
}
// 查询 comment_mentions JOIN users拿到 username 和 uid
type mentionRow struct {
CommentID uint
UID uint
Username string
OriginalUsername string
CurrentName string
}
var rows []mentionRow
err := r.db.Table("comment_mentions").
Select("comment_mentions.comment_id, comment_mentions.uid, users.username").
Select("comment_mentions.comment_id, comment_mentions.uid, comment_mentions.original_username, users.username AS current_name").
Joins("LEFT JOIN users ON users.id = comment_mentions.uid").
Where("comment_mentions.comment_id IN ?", commentIDs).
Find(&rows).Error
@ -45,13 +46,16 @@ func (r *CommentRepo) LoadMentionsForComments(comments []model.Comment) error {
return err
}
// 按 comment_id 分组
mentionMap := make(map[uint]map[string]uint)
// 按 comment_id 分组key = original_username, value = MentionInfo
mentionMap := make(map[uint]map[string]model.MentionInfo)
for _, row := range rows {
if mentionMap[row.CommentID] == nil {
mentionMap[row.CommentID] = make(map[string]uint)
mentionMap[row.CommentID] = make(map[string]model.MentionInfo)
}
mentionMap[row.CommentID][row.OriginalUsername] = model.MentionInfo{
UID: row.UID,
Name: row.CurrentName,
}
mentionMap[row.CommentID][row.Username] = row.UID
}
for i := range comments {
@ -63,7 +67,8 @@ func (r *CommentRepo) LoadMentionsForComments(comments []model.Comment) error {
}
// SearchUsersByLevel 搜索指定等级及以上用户(用于@艾特),返回 uid/username/avatar/exp
func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel int, limit int) ([]struct {
// followerUID > 0 时按已关注优先排序其次按exp降序
func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel, limit int, followerUID uint) ([]struct {
UID uint
Username string
Avatar string
@ -79,12 +84,17 @@ func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel int, limit int
threshold := model.LevelThresholds[minLevel]
err := r.db.Table("users").
Select("id, username, avatar, exp").
Where("username LIKE ? AND exp >= ? AND deleted_at IS NULL", "%"+keyword+"%", threshold).
Order("exp DESC").
Limit(limit).
Find(&list).Error
q := r.db.Table("users AS u").
Select("u.id AS uid, u.username, u.avatar, u.exp").
Where("u.username LIKE ? AND u.exp >= ? AND u.deleted_at IS NULL", "%"+keyword+"%", threshold).
Order("u.exp DESC")
if followerUID > 0 {
q = q.Joins("LEFT JOIN user_follows f ON f.followee_id = u.id AND f.follower_id = ?", followerUID).
Order("(f.follower_id IS NOT NULL) DESC, u.exp DESC")
}
err := q.Limit(limit).Find(&list).Error
var res []struct {
UID uint

View File

@ -136,10 +136,14 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
}
// --- 关注 API ---
// 公开(未登录返回 false
r.GET("/api/users/:uid/follow-status", d.followCtrl.GetStatus)
r.GET("/api/users/:uid/followers", d.followCtrl.Followers)
r.GET("/api/users/:uid/following", d.followCtrl.Following)
// 公开(Optional 认证以获取当前用户关注状态,未登录返回 false
followPublic := r.Group("/api/users/:uid")
followPublic.Use(d.authMdw.Optional())
{
followPublic.GET("/follow-status", d.followCtrl.GetStatus)
followPublic.GET("/followers", d.followCtrl.Followers)
followPublic.GET("/following", d.followCtrl.Following)
}
// 需登录:切换关注
followAPI := r.Group("/api/users/:uid")
followAPI.Use(d.authMdw.Required())

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)
// 通知文章作者(评论者 ≠ 作者RelatedID 存 postID 以便消息页生成跳转链接
if s.notifier != nil && userID != postAuthorID {
// 解析 @ 提及 + 发送通知
commenterName, _ := s.repo.GetUsernameByID(userID)
if commenterName == "" {
commenterName = "用户"
}
s.parseMentions(comment.ID, postID, userID, commenterName, body)
// 通知文章作者(评论者 ≠ 作者RelatedID 存 postID 以便消息页生成跳转链接
if s.notifier != nil && userID != postAuthorID {
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)
// 通知被回复者不自己回复自己RelatedID 存 postID 以便消息页生成跳转链接
if s.notifier != nil && parent.UserID != userID {
// 解析 @ 提及 + 发送通知
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 {
s.notifier.Create(parent.UserID, model.NotifyCommentReply,
"新回复",
fmt.Sprintf("%s 回复了你的评论", commenterName),
@ -190,9 +190,9 @@ func (s *CommentService) ListAllComments(keyword string, showDeleted bool, page,
return rows, total, err
}
// SearchUsers 搜索LV2+用户(用于@艾特悬浮窗)
func (s *CommentService) SearchUsers(keyword string) ([]model.UserSearchResult, error) {
rows, err := s.repo.SearchUsersByLevel(keyword, 2, 10)
// SearchUsers 搜索LV2+用户(用于@艾特悬浮窗)已关注优先最多5条
func (s *CommentService) SearchUsers(keyword string, followerUID uint) ([]model.UserSearchResult, error) {
rows, err := s.repo.SearchUsersByLevel(keyword, 2, 5, followerUID)
if err != nil {
return nil, fmt.Errorf("搜索用户失败: %w", err)
}
@ -209,22 +209,36 @@ func (s *CommentService) SearchUsers(keyword string) ([]model.UserSearchResult,
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] {
continue
}
seen[username] = true
rows, _ := s.repo.SearchUsersByLevel(username, 2, 1)
if len(rows) > 0 {
rows, _ := s.repo.SearchUsersByLevel(username, 2, 1, 0)
if len(rows) == 0 {
continue
}
mentionedUID := rows[0].UID
_ = s.repo.CreateMention(&model.CommentMention{
CommentID: commentID,
UID: rows[0].UID,
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

@ -155,7 +155,7 @@ type commentStore interface {
CreateMention(mention *model.CommentMention) error
FindPostIDByComment(commentID uint) (uint, error)
FindPostAuthorID(postID uint) (uint, error)
SearchUsersByLevel(keyword string, minLevel, limit int) ([]struct {
SearchUsersByLevel(keyword string, minLevel, limit int, followerUID uint) ([]struct {
UID uint
Username string
Avatar string

View File

@ -97,8 +97,16 @@
{{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"}}
{{$link = printf "/space/%d" (derefUint $m.RelatedID)}}
{{end}}
{{if $link}}
<a href="{{$link}}" class="msg-card-link">

View File

@ -35,10 +35,8 @@
<!-- 右:操作按钮 -->
<div class="space-header-actions">
{{if .IsOwnSpace}}
<!-- 本人无关注按钮 -->
{{else}}
<button id="space-follow-btn" class="space-action-btn primary" data-uid="{{.SpaceUser.ID}}">+ 关注</button>
<button id="space-follow-btn" class="space-action-btn primary" data-uid="{{.SpaceUser.ID}}" data-current-uid="{{.CurrentUID}}">+ 关注</button>
{{if not .IsOwnSpace}}
<button class="space-action-btn">发消息</button>
<button class="space-action-btn icon-only" title="更多"></button>
{{end}}
@ -576,10 +574,60 @@
{{template "layout/footer.html" .}}
<!-- 访客空间:关注按钮交互 -->
{{if and (not .IsOwnSpace) (or (eq .ActiveTab "following") (eq .ActiveTab "followers"))}}
<!-- 关注按钮交互 -->
<!-- 顶部关注按钮(所有标签页通用) -->
<script nonce="{{.CSPNonce}}">
(function() {
var topFollowBtn = document.getElementById('space-follow-btn');
if (!topFollowBtn) return;
var topUid = topFollowBtn.getAttribute('data-uid');
var currentUID = parseInt(topFollowBtn.getAttribute('data-current-uid')) || 0;
if (currentUID && parseInt(topUid) !== currentUID) {
fetch('/api/users/' + topUid + '/follow-status')
.then(function(res) { return res.json(); })
.then(function(data) {
if (data && data.success && data.data) {
var s = data.data;
topFollowBtn.textContent = (s.i_follow ? (s.they_follow ? '已互粉' : '已关注') : (s.they_follow ? '回关' : '+ 关注'));
if (s.i_follow) topFollowBtn.classList.add('followed');
}
});
}
topFollowBtn.addEventListener('click', function() {
if (currentUID && parseInt(topUid) === currentUID) {
showToast('不能关注自己', 'warning');
return;
}
if (!currentUID) {
window.location.href = '/auth/login?redirect=/space/' + topUid;
return;
}
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/users/' + topUid + '/follow', true);
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
try {
var resp = JSON.parse(xhr.responseText);
if (resp.data && resp.data.status) {
var st = resp.data.status;
topFollowBtn.textContent = (st.i_follow ? (st.they_follow ? '已互粉' : '已关注') : (st.they_follow ? '回关' : '+ 关注'));
topFollowBtn.classList.toggle('followed', st.i_follow);
}
} catch(e){}
}
};
xhr.send();
});
})();
</script>
{{if or (eq .ActiveTab "following") (eq .ActiveTab "followers")}}
<script nonce="{{.CSPNonce}}">
(function() {
var currentUID = {{.CurrentUID}} || 0;
var cards = document.querySelectorAll('.space-user-card');
if (!cards.length) return;
@ -605,7 +653,7 @@
if (state === 'following' || state === 'mutual') btn.classList.add('followed');
}
// 批量获取并更新每个卡片关注状态
// 批量获取并更新每个卡片关注状态(仅非自己)
cards.forEach(function(card) {
var btn = card.querySelector('.space-follow-btn');
if (!btn || btn.disabled) return;
@ -618,6 +666,10 @@
});
btn.addEventListener('click', function() {
if (currentUID && parseInt(targetUid) === currentUID) {
showToast('不能关注自己');
return;
}
btn.classList.add('waiting');
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/users/' + targetUid + '/follow', true);
@ -632,44 +684,6 @@
xhr.send();
});
});
// 顶部关注按钮
var topFollowBtn = document.getElementById('space-follow-btn');
if (topFollowBtn) {
var topUid = topFollowBtn.getAttribute('data-uid');
fetch('/api/users/' + topUid + '/follow-status')
.then(function(res) { return res.json(); })
.then(function(data) {
if (data && data.success && data.data) {
var s = data.data;
topFollowBtn.textContent = (s.i_follow ? (s.they_follow ? '已互粉' : '已关注') : (s.they_follow ? '回关' : '+ 关注'));
if (s.i_follow) topFollowBtn.classList.add('followed');
}
});
topFollowBtn.addEventListener('click', function() {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/users/' + topUid + '/follow', true);
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
try {
var resp = JSON.parse(xhr.responseText);
if (resp.data && resp.data.status) {
var st = resp.data.status;
topFollowBtn.textContent = (st.i_follow ? (st.they_follow ? '已互粉' : '已关注') : (st.they_follow ? '回关' : '+ 关注'));
topFollowBtn.classList.toggle('followed', st.i_follow);
// 同时更新下方卡片中的对应按钮
cards.forEach(function(c) {
if (c.getAttribute('data-uid') === topUid) {
var cb = c.querySelector('.space-follow-btn'); if(cb) updateBtn(cb, st);
}
});
}
} catch(e){}
}
};
xhr.send();
});
}
})();
</script>
{{end}}
@ -695,7 +709,7 @@ function createFolder() {
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name, is_public: false})
}).then(function(r) { return r.json(); }).then(function(d) {
if (d.success) { location.reload(); } else { alert(d.message || '创建失败'); }
if (d.success) { location.reload(); } else { showToast(d.message || '创建失败', 'error'); }
});
}
@ -733,7 +747,7 @@ document.querySelectorAll('#privacy-follow-list, #privacy-follower-list').forEac
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({field: field, value: value})
}).then(function(r){return r.json()}).then(function(d){
if(!d.success) { this.checked=!value; alert(d.message||'保存失败'); }
if(!d.success) { this.checked=!value; showToast(d.message||'保存失败', 'error'); }
}.bind(this));
});
});

View File

@ -73,7 +73,7 @@ function submitPost(id) {
if (data.success) {
location.reload();
} else {
alert(data.message || '提交失败');
showToast(data.message || '提交失败', 'error');
}
});
});
@ -94,7 +94,7 @@ function deletePost(id) {
if (data.success) {
location.reload();
} else {
alert(data.message || '删除失败');
showToast(data.message || '删除失败', 'error');
}
});
});

View File

@ -94,7 +94,7 @@ function deletePost(id) {
if (data.success) {
location.reload();
} else {
alert(data.message || '删除失败');
showToast(data.message || '删除失败', 'error');
}
});
});

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;
@ -1368,24 +1374,3 @@
margin-top: 2px;
padding-top: 6px;
}
/* 赋能 Toast */
.energize-toast {
position: fixed;
top: 80px;
left: 50%;
transform: translateX(-50%);
background: #1f2937;
color: #fff;
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
opacity: 0;
transition: opacity .3s;
z-index: 10000;
pointer-events: none;
}
.energize-toast.show {
opacity: 1;
}

View File

@ -14,17 +14,17 @@
var currentPage = 1;
var totalPages = 1;
// 渲染@提及:有效提及渲染为蓝色链接,无效提及保持纯文本
// 渲染@提及:有效提及渲染为蓝色链接(显示当前用户名),无效提及保持纯文本
// mentions: { original_username: {uid, name} } — name为当前显示名用户改名后自动更新
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>';
var info = mentions[username];
if (info && info.uid) {
return '<a class="mention-link" href="/space/' + info.uid + '">@' + escapeHtml(info.name) + '</a>';
}
return match;
});
@ -201,7 +201,7 @@
var input = document.getElementById('replyInput-' + id);
if (!input) return;
var body = input.value.trim();
if (!body) { alert('请输入回复内容'); return; }
if (!body) { showToast('请输入回复内容', 'warning'); return; }
btn.disabled = true;
api('POST', '/api/comments/' + id + '/reply', { body: body })
@ -236,12 +236,12 @@
}
updateTotalCount(1);
} else {
alert(d.message || '回复失败');
showToast(d.message || '回复失败', 'error');
}
})
.catch(function() {
btn.disabled = false;
alert('请求失败');
showToast('请求失败', 'error');
});
});
}
@ -296,9 +296,9 @@
clearTimeout(searchTimer);
// 仅输入 @ 时,显示空白下拉
// 仅输入 @ 时,显示提示
if (query.length === 0) {
showMention(input, [], '');
showEmptyHint(input);
return;
}
@ -366,6 +366,23 @@
dropdown.style.left = Math.min(pos.left, window.innerWidth - 230) + 'px';
}
function showEmptyHint(input) {
var dropdown = mentionDropdown;
dropdown.innerHTML = '';
var hint = document.createElement('div');
hint.className = 'mention-no-result';
hint.textContent = '输入以查找用户';
dropdown.appendChild(hint);
var cursorIdx = input.selectionStart;
var pos = getCursorPixelPos(input, cursorIdx);
dropdown.style.display = 'block';
dropdown.style.width = '220px';
var dropHeight = dropdown.offsetHeight || 36;
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 = '';
@ -389,7 +406,7 @@
if (commentSubmitBtn) {
commentSubmitBtn.addEventListener('click', function() {
var body = commentInput.value.trim();
if (!body) { alert('请输入评论内容'); return; }
if (!body) { showToast('请输入评论内容', 'warning'); return; }
commentSubmitBtn.disabled = true;
api('POST', '/api/posts/' + postId + '/comments', { body: body })
@ -402,12 +419,12 @@
loadComments(currentPage);
updateTotalCount(1);
} else {
alert(d.message || '发表失败');
showToast(d.message || '发表失败', 'error');
}
})
.catch(function() {
commentSubmitBtn.disabled = false;
alert('请求失败');
showToast('请求失败', 'error');
});
});
@ -466,14 +483,14 @@
input.focus();
input.selectionStart = input.selectionEnd = start + imgUrl.length;
} else {
alert(d.message || '上传失败');
showToast(d.message || '上传失败', 'error');
}
})
.catch(function() {
uploadLabel.style.pointerEvents = 'auto';
uploadLabel.style.opacity = '1';
uploadLabel.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> 图片';
alert('上传失败');
showToast('上传失败', 'error');
});
imageUploadInput.value = '';
@ -481,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' });
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];

View File

@ -12,18 +12,6 @@
var isAuthor = btn.dataset.uid && btn.dataset.authorUid &&
btn.dataset.uid === btn.dataset.authorUid;
function showToast(msg) {
var toast = document.createElement('div');
toast.className = 'energize-toast';
toast.textContent = msg;
document.body.appendChild(toast);
setTimeout(function() { toast.classList.add('show'); }, 10);
setTimeout(function() {
toast.classList.remove('show');
setTimeout(function() { toast.remove(); }, 300);
}, 2000);
}
function showOverlay() {
overlay.style.display = 'flex';
}
@ -55,7 +43,7 @@
btn.addEventListener('click', function() {
if (isAuthor) {
showToast('无法给自己赋能');
showToast('无法给自己赋能', 'warning');
return;
}
btn.disabled = true;
@ -64,7 +52,7 @@
.then(function(d) {
btn.disabled = false;
if (!d.success || !d.data) {
showToast('获取状态失败,请稍后重试');
showToast('获取状态失败,请稍后重试', 'error');
return;
}
if (d.data.daily_exp_cap_reached) {
@ -72,7 +60,7 @@
}
var total = d.data.post_energize_total || 0;
if (total >= 20) {
showToast('对该文章赋能已达上限');
showToast('对该文章赋能已达上限', 'warning');
return;
}
if (total >= 10) {
@ -82,7 +70,7 @@
})
.catch(function() {
btn.disabled = false;
showToast('获取状态失败,请稍后重试');
showToast('获取状态失败,请稍后重试', 'error');
});
});
@ -106,16 +94,16 @@
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) {
alert(d.data.message || '赋能成功!');
showToast(d.data && d.data.message ? d.data.message : '赋能成功!', 'success');
hideOverlay();
} else {
alert(d.message || '赋能失败');
showToast(d.message || '赋能失败', 'error');
lightBtn.disabled = false;
heavyBtn.disabled = false;
}
})
.catch(function() {
alert('请求失败');
showToast('请求失败', 'error');
lightBtn.disabled = false;
heavyBtn.disabled = false;
});

View File

@ -117,7 +117,7 @@
updateBtn(true);
hideModal();
} else {
alert(d.message || '操作失败');
showToast(d.message || '操作失败', 'error');
}
})
.catch(function() { loading = false; });
@ -137,7 +137,7 @@
if (favCountEl) favCountEl.textContent = Math.max(0, (parseInt(favCountEl.textContent) || 0) - 1);
hideModal();
} else {
alert(d.message || '操作失败');
showToast(d.message || '操作失败', 'error');
}
})
.catch(function() { loading = false; });
@ -155,7 +155,7 @@
loading = false;
newBtn.disabled = false;
newBtn.textContent = '创建并收藏';
alert(d.message || '创建失败');
showToast(d.message || '创建失败', 'error');
return;
}
var newId = d.data && d.data.id;
@ -170,7 +170,7 @@
if (favCountEl) favCountEl.textContent = (parseInt(favCountEl.textContent) || 0) + 1;
hideModal();
} else {
alert(d2.message || '操作失败');
showToast(d2.message || '操作失败', 'error');
}
});
})
@ -212,8 +212,8 @@
if (newBtn && newInput) {
newBtn.addEventListener('click', function() {
var name = newInput.value.trim();
if (!name) { alert('请输入收藏夹名称'); return; }
if (name.length > 50) { alert('收藏夹名称不能超过 50 个字符'); return; }
if (!name) { showToast('请输入收藏夹名称', 'warning'); return; }
if (name.length > 50) { showToast('收藏夹名称不能超过 50 个字符', 'warning'); return; }
createAndAdd(name);
});
newInput.addEventListener('keydown', function(e) {

View File

@ -41,7 +41,7 @@
if (d.success) {
updateUI(d.data.liked ? 'liked' : 'none', d.data.likes_count);
} else {
alert(d.message || '操作失败');
showToast(d.message || '操作失败', 'error');
}
})
.catch(function() { likeBtn.disabled = false; });
@ -63,7 +63,7 @@
if (d.success) {
updateUI(d.data.disliked ? 'disliked' : 'none');
} else {
alert(d.message || '操作失败');
showToast(d.message || '操作失败', 'error');
}
})
.catch(function() { dislikeBtn.disabled = false; });

View File

@ -41,9 +41,9 @@
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) { window.location.reload(); }
else { alert(d.message || '提交失败'); }
else { showToast(d.message || '提交失败', 'error'); }
})
.catch(function() { alert('请求失败'); });
.catch(function() { showToast('请求失败', 'error'); });
});
});
}

View File

@ -169,10 +169,10 @@ async function handleSubmit(e) {
try {
const postId = document.getElementById('postId')?.value
const title = document.getElementById('postTitle')?.value.trim()
if (!title) { alert('请输入标题'); return }
if (!title) { showToast('请输入标题', 'warning'); return }
const body = vditor ? vditor.getValue() : ''
if (!body.trim()) { alert('请输入正文'); return }
if (!body.trim()) { showToast('请输入正文', 'warning'); return }
const isEdit = !!postId
const url = isEdit ? '/api/studio/posts/' + postId : '/api/studio/posts'
@ -197,10 +197,10 @@ async function handleSubmit(e) {
window.location.href = '/studio/posts'
}
} else {
alert(data.message || '操作失败')
showToast(data.message || '操作失败', 'error')
}
} catch (err) {
alert('请求失败,请重试')
showToast('请求失败,请重试', 'error')
} finally {
submitting = false
if (submitBtn) {

View File

@ -22,8 +22,8 @@
// 审核通过
handleClick('.post-approve', '确定审核通过此文章吗?', function(id) {
api.post('/api/admin/posts/' + id + '/approve')
.then(function(d) { if (d.success) refreshPage(); else alert(d.message); })
.catch(function() { alert('操作失败'); });
.then(function(d) { if (d.success) refreshPage(); else showToast(d.message || '操作失败', 'error'); })
.catch(function() { showToast('操作失败', 'error'); });
});
// 退回
@ -31,8 +31,8 @@
showPrompt('请输入退回理由(必填):').then(function(reason) {
if (!reason || !reason.trim()) return;
api.post('/api/admin/posts/' + id + '/reject', { reason: reason.trim() })
.then(function(d) { if (d.success) refreshPage(); else alert(d.message); })
.catch(function() { alert('操作失败'); });
.then(function(d) { if (d.success) refreshPage(); else showToast(d.message || '操作失败', 'error'); })
.catch(function() { showToast('操作失败', 'error'); });
});
});
@ -41,15 +41,15 @@
showPrompt('请输入锁定理由(必填):').then(function(reason) {
if (!reason || !reason.trim()) return;
api.post('/api/admin/posts/' + id + '/lock', { reason: reason.trim() })
.then(function(d) { if (d.success) refreshPage(); else alert(d.message); })
.catch(function() { alert('操作失败'); });
.then(function(d) { if (d.success) refreshPage(); else showToast(d.message || '操作失败', 'error'); })
.catch(function() { showToast('操作失败', 'error'); });
});
});
// 解锁
handleClick('.post-unlock', '确定解锁此文章吗?', function(id) {
api.post('/api/admin/posts/' + id + '/unlock')
.then(function(d) { if (d.success) refreshPage(); else alert(d.message); })
.catch(function() { alert('操作失败'); });
.then(function(d) { if (d.success) refreshPage(); else showToast(d.message || '操作失败', 'error'); })
.catch(function() { showToast('操作失败', 'error'); });
});
})();