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
|
// SetSessionCookie 写入会话 ID Cookie
|
||||||
// rememberMe=true → Cookie 持久化(30天),关浏览器后仍保持登录
|
// rememberMe=true → Cookie maxAge=30天,关浏览器后仍保持登录
|
||||||
// rememberMe=false → Cookie session 模式(maxAge=0),关浏览器即清除
|
// rememberMe=false → Cookie maxAge=空闲超时(默认2h),与Redis TTL一致,关浏览器后自动清除
|
||||||
func SetSessionCookie(c *gin.Context, sid string, rememberMe bool, cfg *config.Config) {
|
func SetSessionCookie(c *gin.Context, sid string, rememberMe bool, cfg *config.Config) {
|
||||||
secure := cfg.Server.Mode != "debug"
|
secure := cfg.Server.Mode != "debug"
|
||||||
c.SetSameSite(http.SameSiteLaxMode)
|
c.SetSameSite(http.SameSiteLaxMode)
|
||||||
@ -25,7 +25,7 @@ func SetSessionCookie(c *gin.Context, sid string, rememberMe bool, cfg *config.C
|
|||||||
if rememberMe {
|
if rememberMe {
|
||||||
maxAge = cfg.Session.RememberTimeout * 60 // 分钟 → 秒
|
maxAge = cfg.Session.RememberTimeout * 60 // 分钟 → 秒
|
||||||
} else {
|
} 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)
|
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
|
// SearchUsers GET /api/users/search?q=keyword
|
||||||
func (ctrl *CommentController) SearchUsers(c *gin.Context) {
|
func (ctrl *CommentController) SearchUsers(c *gin.Context) {
|
||||||
_, _, ok := common.GetGinUser(c)
|
uid, _, ok := common.GetGinUser(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
return
|
return
|
||||||
@ -53,7 +53,7 @@ func (ctrl *CommentController) SearchUsers(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
users, err := ctrl.commentService.SearchUsers(q)
|
users, err := ctrl.commentService.SearchUsers(q, uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Error(c, http.StatusInternalServerError, "搜索用户失败")
|
common.Error(c, http.StatusInternalServerError, "搜索用户失败")
|
||||||
return
|
return
|
||||||
|
|||||||
@ -115,7 +115,7 @@ type commentUseCase interface {
|
|||||||
Delete(commentID uint, requesterID uint, isAdmin bool) error
|
Delete(commentID uint, requesterID uint, isAdmin bool) error
|
||||||
ListRootComments(postID uint, page, pageSize int) ([]model.Comment, int64, error)
|
ListRootComments(postID uint, page, pageSize int) ([]model.Comment, int64, error)
|
||||||
ListReplies(rootID uint) ([]model.Comment, 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 个方法)
|
// reactionUseCase ReactionController 对 ReactionService 的最小依赖(ISP:4 个方法)
|
||||||
|
|||||||
@ -25,16 +25,23 @@ 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字段,批量查询填充)
|
// Mentions 有效@提及映射 original_username -> {uid, 当前显示名}(非DB字段,批量查询填充)
|
||||||
// 前端据此判断 @username 是否为有效提及并生成链接
|
// key=创建时的原始@用户名,value={uid,当前显示名},用户改名后链接仍有效且显示新名
|
||||||
Mentions map[string]uint `gorm:"-" json:"mentions,omitempty"`
|
Mentions map[string]MentionInfo `gorm:"-" json:"mentions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommentMention @提及映射(绑定UID,不是username,支持改名后自动更新显示名)
|
// CommentMention @提及映射(绑定UID + 原始用户名,支持改名后自动更新显示名)
|
||||||
type CommentMention struct {
|
type CommentMention struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
CommentID uint `gorm:"uniqueIndex:idx_comment_uid,priority:1;not null" json:"comment_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"`
|
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 @搜索用户结果
|
// UserSearchResult @搜索用户结果
|
||||||
|
|||||||
@ -13,6 +13,7 @@ const (
|
|||||||
NotifyCommentReply = "comment_reply"
|
NotifyCommentReply = "comment_reply"
|
||||||
NotifyLikeAggregated = "like_aggregated" // 每日聚合点赞通知
|
NotifyLikeAggregated = "like_aggregated" // 每日聚合点赞通知
|
||||||
NotifyFollow = "follow" // 关注通知
|
NotifyFollow = "follow" // 关注通知
|
||||||
|
NotifyCommentMention = "comment_mention" // 评论@提及通知
|
||||||
)
|
)
|
||||||
|
|
||||||
// Notification 消息/通知模型
|
// Notification 消息/通知模型
|
||||||
@ -39,6 +40,7 @@ var NotifyTypeNames = map[string]string{
|
|||||||
NotifyLevelUp: "等级提升",
|
NotifyLevelUp: "等级提升",
|
||||||
NotifyComment: "评论",
|
NotifyComment: "评论",
|
||||||
NotifyCommentReply: "回复",
|
NotifyCommentReply: "回复",
|
||||||
|
NotifyCommentMention: "@ 提及",
|
||||||
NotifyLikeAggregated: "点赞",
|
NotifyLikeAggregated: "点赞",
|
||||||
NotifyFollow: "关注",
|
NotifyFollow: "关注",
|
||||||
}
|
}
|
||||||
@ -54,6 +56,7 @@ var NotifyCategory = map[string]string{
|
|||||||
NotifyLevelUp: "system",
|
NotifyLevelUp: "system",
|
||||||
NotifyComment: "mention",
|
NotifyComment: "mention",
|
||||||
NotifyCommentReply: "mention",
|
NotifyCommentReply: "mention",
|
||||||
|
NotifyCommentMention: "mention",
|
||||||
NotifyLikeAggregated: "like",
|
NotifyLikeAggregated: "like",
|
||||||
NotifyFollow: "follow",
|
NotifyFollow: "follow",
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,8 +17,9 @@ func (r *CommentRepo) FindMentionsByComment(commentID uint) ([]model.CommentMent
|
|||||||
return list, err
|
return list, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadMentionsForComments 批量加载多条评论的有效@提及映射(username -> uid)
|
// LoadMentionsForComments 批量加载多条评论的有效@提及映射
|
||||||
// 传入的 comments 会被原地修改,填充 Mentions 字段
|
// key=创建时的原始@用户名, value={uid, 用户当前显示名}
|
||||||
|
// 用户改名后:原始名匹配body中的旧@文本,显示名随users表更新
|
||||||
func (r *CommentRepo) LoadMentionsForComments(comments []model.Comment) error {
|
func (r *CommentRepo) LoadMentionsForComments(comments []model.Comment) error {
|
||||||
if len(comments) == 0 {
|
if len(comments) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@ -29,15 +30,15 @@ func (r *CommentRepo) LoadMentionsForComments(comments []model.Comment) error {
|
|||||||
commentIDs[i] = c.ID
|
commentIDs[i] = c.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询 comment_mentions JOIN users,拿到 username 和 uid
|
|
||||||
type mentionRow struct {
|
type mentionRow struct {
|
||||||
CommentID uint
|
CommentID uint
|
||||||
UID uint
|
UID uint
|
||||||
Username string
|
OriginalUsername string
|
||||||
|
CurrentName string
|
||||||
}
|
}
|
||||||
var rows []mentionRow
|
var rows []mentionRow
|
||||||
err := r.db.Table("comment_mentions").
|
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").
|
Joins("LEFT JOIN users ON users.id = comment_mentions.uid").
|
||||||
Where("comment_mentions.comment_id IN ?", commentIDs).
|
Where("comment_mentions.comment_id IN ?", commentIDs).
|
||||||
Find(&rows).Error
|
Find(&rows).Error
|
||||||
@ -45,13 +46,16 @@ func (r *CommentRepo) LoadMentionsForComments(comments []model.Comment) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按 comment_id 分组
|
// 按 comment_id 分组,key = original_username, value = MentionInfo
|
||||||
mentionMap := make(map[uint]map[string]uint)
|
mentionMap := make(map[uint]map[string]model.MentionInfo)
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
if mentionMap[row.CommentID] == nil {
|
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 {
|
for i := range comments {
|
||||||
@ -63,7 +67,8 @@ func (r *CommentRepo) LoadMentionsForComments(comments []model.Comment) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SearchUsersByLevel 搜索指定等级及以上用户(用于@艾特),返回 uid/username/avatar/exp
|
// 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
|
UID uint
|
||||||
Username string
|
Username string
|
||||||
Avatar string
|
Avatar string
|
||||||
@ -79,12 +84,17 @@ func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel int, limit int
|
|||||||
|
|
||||||
threshold := model.LevelThresholds[minLevel]
|
threshold := model.LevelThresholds[minLevel]
|
||||||
|
|
||||||
err := r.db.Table("users").
|
q := r.db.Table("users AS u").
|
||||||
Select("id, username, avatar, exp").
|
Select("u.id AS uid, u.username, u.avatar, u.exp").
|
||||||
Where("username LIKE ? AND exp >= ? AND deleted_at IS NULL", "%"+keyword+"%", threshold).
|
Where("u.username LIKE ? AND u.exp >= ? AND u.deleted_at IS NULL", "%"+keyword+"%", threshold).
|
||||||
Order("exp DESC").
|
Order("u.exp DESC")
|
||||||
Limit(limit).
|
|
||||||
Find(&list).Error
|
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 {
|
var res []struct {
|
||||||
UID uint
|
UID uint
|
||||||
|
|||||||
@ -136,10 +136,14 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- 关注 API ---
|
// --- 关注 API ---
|
||||||
// 公开(未登录返回 false)
|
// 公开(Optional 认证以获取当前用户关注状态,未登录返回 false)
|
||||||
r.GET("/api/users/:uid/follow-status", d.followCtrl.GetStatus)
|
followPublic := r.Group("/api/users/:uid")
|
||||||
r.GET("/api/users/:uid/followers", d.followCtrl.Followers)
|
followPublic.Use(d.authMdw.Optional())
|
||||||
r.GET("/api/users/:uid/following", d.followCtrl.Following)
|
{
|
||||||
|
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 := r.Group("/api/users/:uid")
|
||||||
followAPI.Use(d.authMdw.Required())
|
followAPI.Use(d.authMdw.Required())
|
||||||
|
|||||||
@ -55,15 +55,15 @@ func (s *CommentService) CreateRoot(userID uint, postID uint, body string) (*mod
|
|||||||
// 文章评论数+1
|
// 文章评论数+1
|
||||||
_ = s.repo.IncrCommentsCount(postID)
|
_ = s.repo.IncrCommentsCount(postID)
|
||||||
|
|
||||||
// 解析 @ 提及
|
// 解析 @ 提及 + 发送通知
|
||||||
s.parseMentions(comment.ID, body)
|
|
||||||
|
|
||||||
// 通知文章作者(评论者 ≠ 作者),RelatedID 存 postID 以便消息页生成跳转链接
|
|
||||||
if s.notifier != nil && userID != postAuthorID {
|
|
||||||
commenterName, _ := s.repo.GetUsernameByID(userID)
|
commenterName, _ := s.repo.GetUsernameByID(userID)
|
||||||
if commenterName == "" {
|
if commenterName == "" {
|
||||||
commenterName = "用户"
|
commenterName = "用户"
|
||||||
}
|
}
|
||||||
|
s.parseMentions(comment.ID, postID, userID, commenterName, body)
|
||||||
|
|
||||||
|
// 通知文章作者(评论者 ≠ 作者),RelatedID 存 postID 以便消息页生成跳转链接
|
||||||
|
if s.notifier != nil && userID != postAuthorID {
|
||||||
s.notifier.Create(postAuthorID, model.NotifyComment,
|
s.notifier.Create(postAuthorID, model.NotifyComment,
|
||||||
"新评论",
|
"新评论",
|
||||||
fmt.Sprintf("%s 评论了你的文章", commenterName),
|
fmt.Sprintf("%s 评论了你的文章", commenterName),
|
||||||
@ -103,15 +103,15 @@ func (s *CommentService) CreateReply(userID uint, parentID uint, body string) (*
|
|||||||
// 文章评论数+1
|
// 文章评论数+1
|
||||||
_ = s.repo.IncrCommentsCount(parent.PostID)
|
_ = s.repo.IncrCommentsCount(parent.PostID)
|
||||||
|
|
||||||
// 解析 @ 提及
|
// 解析 @ 提及 + 发送通知
|
||||||
s.parseMentions(comment.ID, body)
|
|
||||||
|
|
||||||
// 通知被回复者(不自己回复自己),RelatedID 存 postID 以便消息页生成跳转链接
|
|
||||||
if s.notifier != nil && parent.UserID != userID {
|
|
||||||
commenterName, _ := s.repo.GetUsernameByID(userID)
|
commenterName, _ := s.repo.GetUsernameByID(userID)
|
||||||
if commenterName == "" {
|
if commenterName == "" {
|
||||||
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,
|
s.notifier.Create(parent.UserID, model.NotifyCommentReply,
|
||||||
"新回复",
|
"新回复",
|
||||||
fmt.Sprintf("%s 回复了你的评论", commenterName),
|
fmt.Sprintf("%s 回复了你的评论", commenterName),
|
||||||
@ -190,9 +190,9 @@ func (s *CommentService) ListAllComments(keyword string, showDeleted bool, page,
|
|||||||
return rows, total, err
|
return rows, total, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchUsers 搜索LV2+用户(用于@艾特悬浮窗)
|
// SearchUsers 搜索LV2+用户(用于@艾特悬浮窗),已关注优先,最多5条
|
||||||
func (s *CommentService) SearchUsers(keyword string) ([]model.UserSearchResult, error) {
|
func (s *CommentService) SearchUsers(keyword string, followerUID uint) ([]model.UserSearchResult, error) {
|
||||||
rows, err := s.repo.SearchUsersByLevel(keyword, 2, 10)
|
rows, err := s.repo.SearchUsersByLevel(keyword, 2, 5, followerUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("搜索用户失败: %w", err)
|
return nil, fmt.Errorf("搜索用户失败: %w", err)
|
||||||
}
|
}
|
||||||
@ -209,22 +209,36 @@ func (s *CommentService) SearchUsers(keyword string) ([]model.UserSearchResult,
|
|||||||
return users, nil
|
return users, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseMentions 解析@用户并存储到 comment_mentions 表
|
// parseMentions 解析@用户,存储到 comment_mentions 表,并向被@用户发送通知
|
||||||
func (s *CommentService) parseMentions(commentID uint, body string) {
|
func (s *CommentService) parseMentions(commentID uint, postID uint, commenterID uint, commenterName string, body string) {
|
||||||
matches := mentionPattern.FindAllStringSubmatch(body, -1)
|
matches := mentionPattern.FindAllStringSubmatch(body, -1)
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
|
notified := make(map[uint]bool) // 防止重复通知同一用户
|
||||||
|
|
||||||
for _, m := range matches {
|
for _, m := range matches {
|
||||||
username := m[1]
|
username := m[1]
|
||||||
if seen[username] {
|
if seen[username] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[username] = true
|
seen[username] = true
|
||||||
rows, _ := s.repo.SearchUsersByLevel(username, 2, 1)
|
rows, _ := s.repo.SearchUsersByLevel(username, 2, 1, 0)
|
||||||
if len(rows) > 0 {
|
if len(rows) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mentionedUID := rows[0].UID
|
||||||
_ = s.repo.CreateMention(&model.CommentMention{
|
_ = s.repo.CreateMention(&model.CommentMention{
|
||||||
CommentID: commentID,
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,7 +49,7 @@ func categoryTypes(category string) []string {
|
|||||||
case "system":
|
case "system":
|
||||||
return []string{model.NotifyAuditApproved, model.NotifyAuditRejected, model.NotifyPostApproved, model.NotifyPostRejected, model.NotifyPostLocked, model.NotifyPostUnlocked, model.NotifyLevelUp}
|
return []string{model.NotifyAuditApproved, model.NotifyAuditRejected, model.NotifyPostApproved, model.NotifyPostRejected, model.NotifyPostLocked, model.NotifyPostUnlocked, model.NotifyLevelUp}
|
||||||
case "mention":
|
case "mention":
|
||||||
return []string{model.NotifyComment, model.NotifyCommentReply}
|
return []string{model.NotifyComment, model.NotifyCommentReply, model.NotifyCommentMention}
|
||||||
case "like":
|
case "like":
|
||||||
return []string{model.NotifyLikeAggregated}
|
return []string{model.NotifyLikeAggregated}
|
||||||
case "follow":
|
case "follow":
|
||||||
|
|||||||
@ -155,7 +155,7 @@ type commentStore interface {
|
|||||||
CreateMention(mention *model.CommentMention) error
|
CreateMention(mention *model.CommentMention) error
|
||||||
FindPostIDByComment(commentID uint) (uint, error)
|
FindPostIDByComment(commentID uint) (uint, error)
|
||||||
FindPostAuthorID(postID 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
|
UID uint
|
||||||
Username string
|
Username string
|
||||||
Avatar string
|
Avatar string
|
||||||
|
|||||||
@ -97,8 +97,16 @@
|
|||||||
{{else}}
|
{{else}}
|
||||||
{{$link = printf "/posts/%d#comments" (derefUint $m.RelatedID)}}
|
{{$link = printf "/posts/%d#comments" (derefUint $m.RelatedID)}}
|
||||||
{{end}}
|
{{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")}}
|
{{else if or (eq $m.NotifyType "audit_approved") (eq $m.NotifyType "audit_rejected")}}
|
||||||
{{$link = "/settings"}}
|
{{$link = "/settings"}}
|
||||||
|
{{else if eq $m.NotifyType "follow"}}
|
||||||
|
{{$link = printf "/space/%d" (derefUint $m.RelatedID)}}
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if $link}}
|
{{if $link}}
|
||||||
<a href="{{$link}}" class="msg-card-link">
|
<a href="{{$link}}" class="msg-card-link">
|
||||||
|
|||||||
@ -35,10 +35,8 @@
|
|||||||
|
|
||||||
<!-- 右:操作按钮 -->
|
<!-- 右:操作按钮 -->
|
||||||
<div class="space-header-actions">
|
<div class="space-header-actions">
|
||||||
{{if .IsOwnSpace}}
|
<button id="space-follow-btn" class="space-action-btn primary" data-uid="{{.SpaceUser.ID}}" data-current-uid="{{.CurrentUID}}">+ 关注</button>
|
||||||
<!-- 本人无关注按钮 -->
|
{{if not .IsOwnSpace}}
|
||||||
{{else}}
|
|
||||||
<button id="space-follow-btn" class="space-action-btn primary" data-uid="{{.SpaceUser.ID}}">+ 关注</button>
|
|
||||||
<button class="space-action-btn">发消息</button>
|
<button class="space-action-btn">发消息</button>
|
||||||
<button class="space-action-btn icon-only" title="更多">⋮</button>
|
<button class="space-action-btn icon-only" title="更多">⋮</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
@ -576,10 +574,60 @@
|
|||||||
|
|
||||||
{{template "layout/footer.html" .}}
|
{{template "layout/footer.html" .}}
|
||||||
|
|
||||||
<!-- 访客空间:关注按钮交互 -->
|
<!-- 关注按钮交互 -->
|
||||||
{{if and (not .IsOwnSpace) (or (eq .ActiveTab "following") (eq .ActiveTab "followers"))}}
|
<!-- 顶部关注按钮(所有标签页通用) -->
|
||||||
<script nonce="{{.CSPNonce}}">
|
<script nonce="{{.CSPNonce}}">
|
||||||
(function() {
|
(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');
|
var cards = document.querySelectorAll('.space-user-card');
|
||||||
if (!cards.length) return;
|
if (!cards.length) return;
|
||||||
|
|
||||||
@ -605,7 +653,7 @@
|
|||||||
if (state === 'following' || state === 'mutual') btn.classList.add('followed');
|
if (state === 'following' || state === 'mutual') btn.classList.add('followed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 批量获取并更新每个卡片关注状态
|
// 批量获取并更新每个卡片关注状态(仅非自己)
|
||||||
cards.forEach(function(card) {
|
cards.forEach(function(card) {
|
||||||
var btn = card.querySelector('.space-follow-btn');
|
var btn = card.querySelector('.space-follow-btn');
|
||||||
if (!btn || btn.disabled) return;
|
if (!btn || btn.disabled) return;
|
||||||
@ -618,6 +666,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
btn.addEventListener('click', function() {
|
btn.addEventListener('click', function() {
|
||||||
|
if (currentUID && parseInt(targetUid) === currentUID) {
|
||||||
|
showToast('不能关注自己');
|
||||||
|
return;
|
||||||
|
}
|
||||||
btn.classList.add('waiting');
|
btn.classList.add('waiting');
|
||||||
var xhr = new XMLHttpRequest();
|
var xhr = new XMLHttpRequest();
|
||||||
xhr.open('POST', '/api/users/' + targetUid + '/follow', true);
|
xhr.open('POST', '/api/users/' + targetUid + '/follow', true);
|
||||||
@ -632,44 +684,6 @@
|
|||||||
xhr.send();
|
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>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
@ -695,7 +709,7 @@ function createFolder() {
|
|||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({name: name, is_public: false})
|
body: JSON.stringify({name: name, is_public: false})
|
||||||
}).then(function(r) { return r.json(); }).then(function(d) {
|
}).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'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({field: field, value: value})
|
body: JSON.stringify({field: field, value: value})
|
||||||
}).then(function(r){return r.json()}).then(function(d){
|
}).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));
|
}.bind(this));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -73,7 +73,7 @@ function submitPost(id) {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
location.reload();
|
location.reload();
|
||||||
} else {
|
} else {
|
||||||
alert(data.message || '提交失败');
|
showToast(data.message || '提交失败', 'error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -94,7 +94,7 @@ function deletePost(id) {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
location.reload();
|
location.reload();
|
||||||
} else {
|
} else {
|
||||||
alert(data.message || '删除失败');
|
showToast(data.message || '删除失败', 'error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -94,7 +94,7 @@ function deletePost(id) {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
location.reload();
|
location.reload();
|
||||||
} else {
|
} else {
|
||||||
alert(data.message || '删除失败');
|
showToast(data.message || '删除失败', 'error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1096,6 +1096,12 @@
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* @提及通知进入:持久半透明高亮 */
|
||||||
|
.comment-card.highlight-mention,
|
||||||
|
.comment-reply-card.highlight-mention {
|
||||||
|
background: rgba(255, 248, 225, 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.comment-header {
|
.comment-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@ -1368,24 +1374,3 @@
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
padding-top: 6px;
|
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 currentPage = 1;
|
||||||
var totalPages = 1;
|
var totalPages = 1;
|
||||||
|
|
||||||
// 渲染@提及:有效提及渲染为蓝色链接,无效提及保持纯文本
|
// 渲染@提及:有效提及渲染为蓝色链接(显示当前用户名),无效提及保持纯文本
|
||||||
|
// mentions: { original_username: {uid, name} } — name为当前显示名,用户改名后自动更新
|
||||||
function renderMentions(body, mentions) {
|
function renderMentions(body, mentions) {
|
||||||
body = escapeHtml(body);
|
body = escapeHtml(body);
|
||||||
if (!mentions || Object.keys(mentions).length === 0) {
|
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, '<span class="reply-to">@$1</span>');
|
||||||
}
|
}
|
||||||
return body.replace(/@(\S{1,16})/g, function(match, username) {
|
return body.replace(/@(\S{1,16})/g, function(match, username) {
|
||||||
var uid = mentions[username];
|
var info = mentions[username];
|
||||||
if (uid) {
|
if (info && info.uid) {
|
||||||
return '<a class="mention-link" href="/space/' + uid + '">@' + escapeHtml(username) + '</a>';
|
return '<a class="mention-link" href="/space/' + info.uid + '">@' + escapeHtml(info.name) + '</a>';
|
||||||
}
|
}
|
||||||
return match;
|
return match;
|
||||||
});
|
});
|
||||||
@ -201,7 +201,7 @@
|
|||||||
var input = document.getElementById('replyInput-' + id);
|
var input = document.getElementById('replyInput-' + id);
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
var body = input.value.trim();
|
var body = input.value.trim();
|
||||||
if (!body) { alert('请输入回复内容'); return; }
|
if (!body) { showToast('请输入回复内容', 'warning'); return; }
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
api('POST', '/api/comments/' + id + '/reply', { body: body })
|
api('POST', '/api/comments/' + id + '/reply', { body: body })
|
||||||
@ -236,12 +236,12 @@
|
|||||||
}
|
}
|
||||||
updateTotalCount(1);
|
updateTotalCount(1);
|
||||||
} else {
|
} else {
|
||||||
alert(d.message || '回复失败');
|
showToast(d.message || '回复失败', 'error');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function() {
|
.catch(function() {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
alert('请求失败');
|
showToast('请求失败', 'error');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -296,9 +296,9 @@
|
|||||||
|
|
||||||
clearTimeout(searchTimer);
|
clearTimeout(searchTimer);
|
||||||
|
|
||||||
// 仅输入 @ 时,显示空白下拉
|
// 仅输入 @ 时,显示提示
|
||||||
if (query.length === 0) {
|
if (query.length === 0) {
|
||||||
showMention(input, [], '');
|
showEmptyHint(input);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -366,6 +366,23 @@
|
|||||||
dropdown.style.left = Math.min(pos.left, window.innerWidth - 230) + 'px';
|
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() {
|
function hideMention() {
|
||||||
mentionDropdown.style.display = 'none';
|
mentionDropdown.style.display = 'none';
|
||||||
mentionDropdown.innerHTML = '';
|
mentionDropdown.innerHTML = '';
|
||||||
@ -389,7 +406,7 @@
|
|||||||
if (commentSubmitBtn) {
|
if (commentSubmitBtn) {
|
||||||
commentSubmitBtn.addEventListener('click', function() {
|
commentSubmitBtn.addEventListener('click', function() {
|
||||||
var body = commentInput.value.trim();
|
var body = commentInput.value.trim();
|
||||||
if (!body) { alert('请输入评论内容'); return; }
|
if (!body) { showToast('请输入评论内容', 'warning'); return; }
|
||||||
|
|
||||||
commentSubmitBtn.disabled = true;
|
commentSubmitBtn.disabled = true;
|
||||||
api('POST', '/api/posts/' + postId + '/comments', { body: body })
|
api('POST', '/api/posts/' + postId + '/comments', { body: body })
|
||||||
@ -402,12 +419,12 @@
|
|||||||
loadComments(currentPage);
|
loadComments(currentPage);
|
||||||
updateTotalCount(1);
|
updateTotalCount(1);
|
||||||
} else {
|
} else {
|
||||||
alert(d.message || '发表失败');
|
showToast(d.message || '发表失败', 'error');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function() {
|
.catch(function() {
|
||||||
commentSubmitBtn.disabled = false;
|
commentSubmitBtn.disabled = false;
|
||||||
alert('请求失败');
|
showToast('请求失败', 'error');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -466,14 +483,14 @@
|
|||||||
input.focus();
|
input.focus();
|
||||||
input.selectionStart = input.selectionEnd = start + imgUrl.length;
|
input.selectionStart = input.selectionEnd = start + imgUrl.length;
|
||||||
} else {
|
} else {
|
||||||
alert(d.message || '上传失败');
|
showToast(d.message || '上传失败', 'error');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function() {
|
.catch(function() {
|
||||||
uploadLabel.style.pointerEvents = 'auto';
|
uploadLabel.style.pointerEvents = 'auto';
|
||||||
uploadLabel.style.opacity = '1';
|
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> 图片';
|
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 = '';
|
imageUploadInput.value = '';
|
||||||
@ -481,21 +498,45 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ====== 评论高亮 ======
|
// ====== 评论高亮 ======
|
||||||
function highlightComment(commentId) {
|
function highlightComment(commentId, persistent) {
|
||||||
var card = commentsList.querySelector('.comment-card[data-id="' + commentId + '"]') ||
|
var card = commentsList.querySelector('.comment-card[data-id="' + commentId + '"]') ||
|
||||||
commentsList.querySelector('.comment-reply-card[data-id="' + commentId + '"]');
|
commentsList.querySelector('.comment-reply-card[data-id="' + commentId + '"]');
|
||||||
if (card) {
|
if (card) {
|
||||||
commentsList.insertBefore(card, commentsList.firstChild);
|
commentsList.insertBefore(card, commentsList.firstChild);
|
||||||
card.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
card.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
if (persistent) {
|
||||||
|
card.classList.add('highlight-mention');
|
||||||
|
} else {
|
||||||
card.style.background = '#fff8e1';
|
card.style.background = '#fff8e1';
|
||||||
card.style.transition = 'background 1.5s';
|
card.style.transition = 'background 1.5s';
|
||||||
setTimeout(function() { card.style.background = ''; }, 2000);
|
setTimeout(function() { card.style.background = ''; }, 2000);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var hash = window.location.hash;
|
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+)$/);
|
var commentMatch = hash.match(/^#comment-(\d+)$/);
|
||||||
if (commentMatch) {
|
if (commentMatch) {
|
||||||
var targetCommentId = commentMatch[1];
|
var targetCommentId = commentMatch[1];
|
||||||
|
|||||||
@ -12,18 +12,6 @@
|
|||||||
var isAuthor = btn.dataset.uid && btn.dataset.authorUid &&
|
var isAuthor = btn.dataset.uid && btn.dataset.authorUid &&
|
||||||
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() {
|
function showOverlay() {
|
||||||
overlay.style.display = 'flex';
|
overlay.style.display = 'flex';
|
||||||
}
|
}
|
||||||
@ -55,7 +43,7 @@
|
|||||||
|
|
||||||
btn.addEventListener('click', function() {
|
btn.addEventListener('click', function() {
|
||||||
if (isAuthor) {
|
if (isAuthor) {
|
||||||
showToast('无法给自己赋能');
|
showToast('无法给自己赋能', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
@ -64,7 +52,7 @@
|
|||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
if (!d.success || !d.data) {
|
if (!d.success || !d.data) {
|
||||||
showToast('获取状态失败,请稍后重试');
|
showToast('获取状态失败,请稍后重试', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (d.data.daily_exp_cap_reached) {
|
if (d.data.daily_exp_cap_reached) {
|
||||||
@ -72,7 +60,7 @@
|
|||||||
}
|
}
|
||||||
var total = d.data.post_energize_total || 0;
|
var total = d.data.post_energize_total || 0;
|
||||||
if (total >= 20) {
|
if (total >= 20) {
|
||||||
showToast('对该文章赋能已达上限');
|
showToast('对该文章赋能已达上限', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (total >= 10) {
|
if (total >= 10) {
|
||||||
@ -82,7 +70,7 @@
|
|||||||
})
|
})
|
||||||
.catch(function() {
|
.catch(function() {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
showToast('获取状态失败,请稍后重试');
|
showToast('获取状态失败,请稍后重试', 'error');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -106,16 +94,16 @@
|
|||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
if (d.success) {
|
if (d.success) {
|
||||||
alert(d.data.message || '赋能成功!');
|
showToast(d.data && d.data.message ? d.data.message : '赋能成功!', 'success');
|
||||||
hideOverlay();
|
hideOverlay();
|
||||||
} else {
|
} else {
|
||||||
alert(d.message || '赋能失败');
|
showToast(d.message || '赋能失败', 'error');
|
||||||
lightBtn.disabled = false;
|
lightBtn.disabled = false;
|
||||||
heavyBtn.disabled = false;
|
heavyBtn.disabled = false;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function() {
|
.catch(function() {
|
||||||
alert('请求失败');
|
showToast('请求失败', 'error');
|
||||||
lightBtn.disabled = false;
|
lightBtn.disabled = false;
|
||||||
heavyBtn.disabled = false;
|
heavyBtn.disabled = false;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -117,7 +117,7 @@
|
|||||||
updateBtn(true);
|
updateBtn(true);
|
||||||
hideModal();
|
hideModal();
|
||||||
} else {
|
} else {
|
||||||
alert(d.message || '操作失败');
|
showToast(d.message || '操作失败', 'error');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function() { loading = false; });
|
.catch(function() { loading = false; });
|
||||||
@ -137,7 +137,7 @@
|
|||||||
if (favCountEl) favCountEl.textContent = Math.max(0, (parseInt(favCountEl.textContent) || 0) - 1);
|
if (favCountEl) favCountEl.textContent = Math.max(0, (parseInt(favCountEl.textContent) || 0) - 1);
|
||||||
hideModal();
|
hideModal();
|
||||||
} else {
|
} else {
|
||||||
alert(d.message || '操作失败');
|
showToast(d.message || '操作失败', 'error');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function() { loading = false; });
|
.catch(function() { loading = false; });
|
||||||
@ -155,7 +155,7 @@
|
|||||||
loading = false;
|
loading = false;
|
||||||
newBtn.disabled = false;
|
newBtn.disabled = false;
|
||||||
newBtn.textContent = '创建并收藏';
|
newBtn.textContent = '创建并收藏';
|
||||||
alert(d.message || '创建失败');
|
showToast(d.message || '创建失败', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var newId = d.data && d.data.id;
|
var newId = d.data && d.data.id;
|
||||||
@ -170,7 +170,7 @@
|
|||||||
if (favCountEl) favCountEl.textContent = (parseInt(favCountEl.textContent) || 0) + 1;
|
if (favCountEl) favCountEl.textContent = (parseInt(favCountEl.textContent) || 0) + 1;
|
||||||
hideModal();
|
hideModal();
|
||||||
} else {
|
} else {
|
||||||
alert(d2.message || '操作失败');
|
showToast(d2.message || '操作失败', 'error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@ -212,8 +212,8 @@
|
|||||||
if (newBtn && newInput) {
|
if (newBtn && newInput) {
|
||||||
newBtn.addEventListener('click', function() {
|
newBtn.addEventListener('click', function() {
|
||||||
var name = newInput.value.trim();
|
var name = newInput.value.trim();
|
||||||
if (!name) { alert('请输入收藏夹名称'); return; }
|
if (!name) { showToast('请输入收藏夹名称', 'warning'); return; }
|
||||||
if (name.length > 50) { alert('收藏夹名称不能超过 50 个字符'); return; }
|
if (name.length > 50) { showToast('收藏夹名称不能超过 50 个字符', 'warning'); return; }
|
||||||
createAndAdd(name);
|
createAndAdd(name);
|
||||||
});
|
});
|
||||||
newInput.addEventListener('keydown', function(e) {
|
newInput.addEventListener('keydown', function(e) {
|
||||||
|
|||||||
@ -41,7 +41,7 @@
|
|||||||
if (d.success) {
|
if (d.success) {
|
||||||
updateUI(d.data.liked ? 'liked' : 'none', d.data.likes_count);
|
updateUI(d.data.liked ? 'liked' : 'none', d.data.likes_count);
|
||||||
} else {
|
} else {
|
||||||
alert(d.message || '操作失败');
|
showToast(d.message || '操作失败', 'error');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function() { likeBtn.disabled = false; });
|
.catch(function() { likeBtn.disabled = false; });
|
||||||
@ -63,7 +63,7 @@
|
|||||||
if (d.success) {
|
if (d.success) {
|
||||||
updateUI(d.data.disliked ? 'disliked' : 'none');
|
updateUI(d.data.disliked ? 'disliked' : 'none');
|
||||||
} else {
|
} else {
|
||||||
alert(d.message || '操作失败');
|
showToast(d.message || '操作失败', 'error');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function() { dislikeBtn.disabled = false; });
|
.catch(function() { dislikeBtn.disabled = false; });
|
||||||
|
|||||||
@ -41,9 +41,9 @@
|
|||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
if (d.success) { window.location.reload(); }
|
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 {
|
try {
|
||||||
const postId = document.getElementById('postId')?.value
|
const postId = document.getElementById('postId')?.value
|
||||||
const title = document.getElementById('postTitle')?.value.trim()
|
const title = document.getElementById('postTitle')?.value.trim()
|
||||||
if (!title) { alert('请输入标题'); return }
|
if (!title) { showToast('请输入标题', 'warning'); return }
|
||||||
|
|
||||||
const body = vditor ? vditor.getValue() : ''
|
const body = vditor ? vditor.getValue() : ''
|
||||||
if (!body.trim()) { alert('请输入正文'); return }
|
if (!body.trim()) { showToast('请输入正文', 'warning'); return }
|
||||||
|
|
||||||
const isEdit = !!postId
|
const isEdit = !!postId
|
||||||
const url = isEdit ? '/api/studio/posts/' + postId : '/api/studio/posts'
|
const url = isEdit ? '/api/studio/posts/' + postId : '/api/studio/posts'
|
||||||
@ -197,10 +197,10 @@ async function handleSubmit(e) {
|
|||||||
window.location.href = '/studio/posts'
|
window.location.href = '/studio/posts'
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
alert(data.message || '操作失败')
|
showToast(data.message || '操作失败', 'error')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('请求失败,请重试')
|
showToast('请求失败,请重试', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
submitting = false
|
submitting = false
|
||||||
if (submitBtn) {
|
if (submitBtn) {
|
||||||
|
|||||||
@ -22,8 +22,8 @@
|
|||||||
// 审核通过
|
// 审核通过
|
||||||
handleClick('.post-approve', '确定审核通过此文章吗?', function(id) {
|
handleClick('.post-approve', '确定审核通过此文章吗?', function(id) {
|
||||||
api.post('/api/admin/posts/' + id + '/approve')
|
api.post('/api/admin/posts/' + id + '/approve')
|
||||||
.then(function(d) { if (d.success) refreshPage(); else alert(d.message); })
|
.then(function(d) { if (d.success) refreshPage(); else showToast(d.message || '操作失败', 'error'); })
|
||||||
.catch(function() { alert('操作失败'); });
|
.catch(function() { showToast('操作失败', 'error'); });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 退回
|
// 退回
|
||||||
@ -31,8 +31,8 @@
|
|||||||
showPrompt('请输入退回理由(必填):').then(function(reason) {
|
showPrompt('请输入退回理由(必填):').then(function(reason) {
|
||||||
if (!reason || !reason.trim()) return;
|
if (!reason || !reason.trim()) return;
|
||||||
api.post('/api/admin/posts/' + id + '/reject', { reason: reason.trim() })
|
api.post('/api/admin/posts/' + id + '/reject', { reason: reason.trim() })
|
||||||
.then(function(d) { if (d.success) refreshPage(); else alert(d.message); })
|
.then(function(d) { if (d.success) refreshPage(); else showToast(d.message || '操作失败', 'error'); })
|
||||||
.catch(function() { alert('操作失败'); });
|
.catch(function() { showToast('操作失败', 'error'); });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -41,15 +41,15 @@
|
|||||||
showPrompt('请输入锁定理由(必填):').then(function(reason) {
|
showPrompt('请输入锁定理由(必填):').then(function(reason) {
|
||||||
if (!reason || !reason.trim()) return;
|
if (!reason || !reason.trim()) return;
|
||||||
api.post('/api/admin/posts/' + id + '/lock', { reason: reason.trim() })
|
api.post('/api/admin/posts/' + id + '/lock', { reason: reason.trim() })
|
||||||
.then(function(d) { if (d.success) refreshPage(); else alert(d.message); })
|
.then(function(d) { if (d.success) refreshPage(); else showToast(d.message || '操作失败', 'error'); })
|
||||||
.catch(function() { alert('操作失败'); });
|
.catch(function() { showToast('操作失败', 'error'); });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 解锁
|
// 解锁
|
||||||
handleClick('.post-unlock', '确定解锁此文章吗?', function(id) {
|
handleClick('.post-unlock', '确定解锁此文章吗?', function(id) {
|
||||||
api.post('/api/admin/posts/' + id + '/unlock')
|
api.post('/api/admin/posts/' + id + '/unlock')
|
||||||
.then(function(d) { if (d.success) refreshPage(); else alert(d.message); })
|
.then(function(d) { if (d.success) refreshPage(); else showToast(d.message || '操作失败', 'error'); })
|
||||||
.catch(function() { alert('操作失败'); });
|
.catch(function() { showToast('操作失败', 'error'); });
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user