Compare commits
10 Commits
4ac4f64f33
...
8bf6ac9e51
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bf6ac9e51 | |||
| 2ea9788ec7 | |||
| be729c29a1 | |||
| 584d7fd146 | |||
| 6c9a4414cb | |||
| 864a488ee0 | |||
| fb9724faed | |||
| 58aabb9bf0 | |||
| ae15025b1d | |||
| be8ce04334 |
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 的最小依赖(ISP:4 个方法)
|
||||
|
||||
@ -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"`
|
||||
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 @搜索用户结果
|
||||
|
||||
@ -11,8 +11,9 @@ const (
|
||||
NotifyLevelUp = "level_up"
|
||||
NotifyComment = "comment"
|
||||
NotifyCommentReply = "comment_reply"
|
||||
NotifyLikeAggregated = "like_aggregated" // 每日聚合点赞通知
|
||||
NotifyFollow = "follow" // 关注通知
|
||||
NotifyLikeAggregated = "like_aggregated" // 每日聚合点赞通知
|
||||
NotifyFollow = "follow" // 关注通知
|
||||
NotifyCommentMention = "comment_mention" // 评论@提及通知
|
||||
)
|
||||
|
||||
// Notification 消息/通知模型
|
||||
@ -39,6 +40,7 @@ var NotifyTypeNames = map[string]string{
|
||||
NotifyLevelUp: "等级提升",
|
||||
NotifyComment: "评论",
|
||||
NotifyCommentReply: "回复",
|
||||
NotifyCommentMention: "@ 提及",
|
||||
NotifyLikeAggregated: "点赞",
|
||||
NotifyFollow: "关注",
|
||||
}
|
||||
@ -54,6 +56,7 @@ var NotifyCategory = map[string]string{
|
||||
NotifyLevelUp: "system",
|
||||
NotifyComment: "mention",
|
||||
NotifyCommentReply: "mention",
|
||||
NotifyCommentMention: "mention",
|
||||
NotifyLikeAggregated: "like",
|
||||
NotifyFollow: "follow",
|
||||
}
|
||||
|
||||
@ -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
|
||||
CommentID uint
|
||||
UID uint
|
||||
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
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -55,15 +55,15 @@ func (s *CommentService) CreateRoot(userID uint, postID uint, body string) (*mod
|
||||
// 文章评论数+1
|
||||
_ = s.repo.IncrCommentsCount(postID)
|
||||
|
||||
// 解析 @ 提及
|
||||
s.parseMentions(comment.ID, body)
|
||||
// 解析 @ 提及 + 发送通知
|
||||
commenterName, _ := s.repo.GetUsernameByID(userID)
|
||||
if commenterName == "" {
|
||||
commenterName = "用户"
|
||||
}
|
||||
s.parseMentions(comment.ID, postID, userID, commenterName, body)
|
||||
|
||||
// 通知文章作者(评论者 ≠ 作者),RelatedID 存 postID 以便消息页生成跳转链接
|
||||
if s.notifier != nil && userID != postAuthorID {
|
||||
commenterName, _ := s.repo.GetUsernameByID(userID)
|
||||
if commenterName == "" {
|
||||
commenterName = "用户"
|
||||
}
|
||||
s.notifier.Create(postAuthorID, model.NotifyComment,
|
||||
"新评论",
|
||||
fmt.Sprintf("%s 评论了你的文章", commenterName),
|
||||
@ -103,15 +103,15 @@ func (s *CommentService) CreateReply(userID uint, parentID uint, body string) (*
|
||||
// 文章评论数+1
|
||||
_ = s.repo.IncrCommentsCount(parent.PostID)
|
||||
|
||||
// 解析 @ 提及
|
||||
s.parseMentions(comment.ID, body)
|
||||
// 解析 @ 提及 + 发送通知
|
||||
commenterName, _ := s.repo.GetUsernameByID(userID)
|
||||
if commenterName == "" {
|
||||
commenterName = "用户"
|
||||
}
|
||||
s.parseMentions(comment.ID, parent.PostID, userID, commenterName, body)
|
||||
|
||||
// 通知被回复者(不自己回复自己),RelatedID 存 postID 以便消息页生成跳转链接
|
||||
if s.notifier != nil && parent.UserID != userID {
|
||||
commenterName, _ := s.repo.GetUsernameByID(userID)
|
||||
if commenterName == "" {
|
||||
commenterName = "用户"
|
||||
}
|
||||
s.notifier.Create(parent.UserID, model.NotifyCommentReply,
|
||||
"新回复",
|
||||
fmt.Sprintf("%s 回复了你的评论", commenterName),
|
||||
@ -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 {
|
||||
_ = s.repo.CreateMention(&model.CommentMention{
|
||||
CommentID: commentID,
|
||||
UID: rows[0].UID,
|
||||
})
|
||||
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: 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -94,7 +94,7 @@ function deletePost(id) {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.message || '删除失败');
|
||||
showToast(data.message || '删除失败', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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' });
|
||||
card.style.background = '#fff8e1';
|
||||
card.style.transition = 'background 1.5s';
|
||||
setTimeout(function() { card.style.background = ''; }, 2000);
|
||||
if (persistent) {
|
||||
card.classList.add('highlight-mention');
|
||||
} else {
|
||||
card.style.background = '#fff8e1';
|
||||
card.style.transition = 'background 1.5s';
|
||||
setTimeout(function() { card.style.background = ''; }, 2000);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
var hash = window.location.hash;
|
||||
|
||||
// @提及通知进入:置顶 + 持久高亮
|
||||
var mentionMatch = hash.match(/^#mention-(\d+)$/);
|
||||
if (mentionMatch) {
|
||||
var mentionCommentId = mentionMatch[1];
|
||||
var mentionInterval = setInterval(function() {
|
||||
if (highlightComment(mentionCommentId, true)) {
|
||||
clearInterval(mentionInterval);
|
||||
}
|
||||
}, 300);
|
||||
setTimeout(function() {
|
||||
clearInterval(mentionInterval);
|
||||
if (!commentsList.querySelector('.comment-card[data-id="' + mentionCommentId + '"]') &&
|
||||
!commentsList.querySelector('.comment-reply-card[data-id="' + mentionCommentId + '"]')) {
|
||||
showToast('该评论已不存在');
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 普通评论/回复链接:短暂高亮
|
||||
var commentMatch = hash.match(/^#comment-(\d+)$/);
|
||||
if (commentMatch) {
|
||||
var targetCommentId = commentMatch[1];
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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; });
|
||||
|
||||
@ -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'); });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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'); });
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user