feat: 帖子收藏与用户关注

- 新增收藏功能:favorite 服务与 handler、FavoriteButton 组件
- 新增关注功能:follow 服务与 handler、FollowButton 组件
- 用户主页适配关注/粉丝与收藏展示
- flash 消息工具、广告与侧边栏组件微调
This commit is contained in:
2026-09-26 03:02:40 +08:00
parent 31f5360c26
commit d7c6b3a961
40 changed files with 1528 additions and 268 deletions

View File

@@ -82,6 +82,38 @@ func (h *Handlers) PostComments(c *gin.Context) {
})
}
// CommentLocation 评论定位:返回该评论的楼层号(含软删占位,与楼层分页口径一致),
// 供通知/主页评论深链 #comment-{id} 跨页时换到正确页码。0 楼 = 评论不存在。
func (h *Handlers) CommentLocation(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"})
return
}
// 待审/被拒帖子的评论不对公众开放
var viewerID uint
var loadActor func() *service.Actor
if claims := middleware.CurrentUser(c); claims != nil {
viewerID = claims.ID
loadActor = h.actorLoader(claims.ID)
}
if err := h.Post.EnsurePostVisible(uint(id), viewerID, loadActor); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
floor := h.Comment.FloorNumber(uint(id), uint(cid))
if floor <= 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "评论不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"floor": floor})
}
// CreateCommentRequest 评论请求
type CreateCommentRequest struct {
Content string `json:"content" binding:"required,min=1,max=5000"`

View File

@@ -0,0 +1,69 @@
package handler
import (
"net/http"
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/gin-gonic/gin"
)
// ToggleFavorite 切换当前登录用户对帖子的收藏状态
func (h *Handlers) ToggleFavorite(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
return
}
favorited, err := h.Favorite.Toggle(uint(id), claims.ID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"favorited": favorited})
}
// UserFavorites 获取用户收藏的帖子列表(公开可见,仅 published)
func (h *Handlers) UserFavorites(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的用户 ID"})
return
}
if _, err := h.Auth.GetUserByID(uint(id)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
posts, total, err := h.Favorite.List(uint(id), page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 填充点赞状态(仅登录用户)
if claims := middleware.CurrentUser(c); claims != nil {
ids := make([]uint, 0, len(posts))
for _, p := range posts {
ids = append(ids, p.ID)
}
likedMap := h.Like.BatchHasLiked(ids, claims.ID)
for i := range posts {
posts[i].Liked = likedMap[posts[i].ID]
}
}
c.JSON(http.StatusOK, gin.H{
"posts": nonNilSlice(posts),
"total": total,
"page": page,
"size": size,
})
}

58
backend/handler/follow.go Normal file
View File

@@ -0,0 +1,58 @@
package handler
import (
"net/http"
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/gin-gonic/gin"
)
// ToggleFollow 切换当前登录用户对目标用户的关注状态
func (h *Handlers) ToggleFollow(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的用户 ID"})
return
}
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
return
}
following, err := h.Follow.Toggle(claims.ID, uint(id))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"following": following})
}
// UserFollowing 获取用户关注的人列表(公开)
func (h *Handlers) UserFollowing(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的用户 ID"})
return
}
if _, err := h.Auth.GetUserByID(uint(id)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
users, total, err := h.Follow.ListFollowing(uint(id), page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"users": nonNilSlice(users),
"total": total,
"page": page,
"size": size,
})
}

View File

@@ -18,6 +18,8 @@ type Handlers struct {
Post *service.PostService
Comment *service.CommentService
Like *service.LikeService
Favorite *service.FavoriteService
Follow *service.FollowService
Notification *service.NotificationService
OverviewSvc *service.OverviewService
Checkin *service.CheckinService

View File

@@ -88,6 +88,7 @@ func (h *Handlers) PostDetail(c *gin.Context) {
}
if claims != nil {
post.Liked = h.Like.HasLiked(post.ID, claims.ID)
post.Favorited = h.Favorite.HasFavorited(post.ID, claims.ID)
}
h.attachNecroReplyHint(claims, post)
c.JSON(http.StatusOK, gin.H{"post": post})
@@ -275,6 +276,7 @@ func (h *Handlers) UnlockPostPassword(c *gin.Context) {
}
if claims != nil {
post.Liked = h.Like.HasLiked(post.ID, claims.ID)
post.Favorited = h.Favorite.HasFavorited(post.ID, claims.ID)
}
h.attachNecroReplyHint(claims, post)
c.JSON(http.StatusOK, gin.H{"post": post})

View File

@@ -62,6 +62,7 @@ func (h *Handlers) UserProfile(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
sort := c.DefaultQuery("sort", "new")
var viewerID uint
var actor *service.Actor
@@ -72,7 +73,7 @@ func (h *Handlers) UserProfile(c *gin.Context) {
}
}
posts, postsTotal, err := h.Post.ListByUser(user.ID, viewerID, actor, page, size)
posts, postsTotal, err := h.Post.ListByUser(user.ID, viewerID, actor, page, size, sort)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -91,6 +92,9 @@ func (h *Handlers) UserProfile(c *gin.Context) {
postCount, _ := h.Post.CountByUser(user.ID)
commentCount, _ := h.Comment.CountByUser(user.ID)
favoriteCount := h.Favorite.CountByUser(user.ID)
followingCount := h.Follow.CountFollowing(user.ID)
isFollowing := viewerID > 0 && viewerID != user.ID && h.Follow.IsFollowing(viewerID, user.ID)
// 签到积分概览(失败不阻塞资料返回)
pointsTotal, streak := 0, 0
@@ -124,6 +128,12 @@ func (h *Handlers) UserProfile(c *gin.Context) {
if actor != nil && actor.HasPerm(service.PermUsers) {
userPayload["banned"] = user.Banned
}
// 访客与该用户的关注关系(关注按钮初始态)
userPayload["is_following"] = isFollowing
// 邮箱仅本人可见,打码展示(j***@163.com)
if viewerID == user.ID && user.Email != "" {
userPayload["email"] = maskEmail(user.Email)
}
// 徽章墙(失败不阻塞资料返回)
if h.Badge != nil {
if badges, err := h.Badge.ForUser(user.ID); err == nil && badges != nil {
@@ -134,10 +144,12 @@ func (h *Handlers) UserProfile(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"user": userPayload,
"stats": gin.H{
"post_count": postCount,
"comment_count": commentCount,
"points": pointsTotal,
"streak": streak,
"post_count": postCount,
"comment_count": commentCount,
"points": pointsTotal,
"streak": streak,
"favorite_count": favoriteCount,
"following_count": followingCount,
},
"posts": nonNilSlice(posts),
"posts_total": postsTotal,
@@ -146,6 +158,21 @@ func (h *Handlers) UserProfile(c *gin.Context) {
})
}
// maskEmail 邮箱打码:保留首字符与 @ 后域名(j***@163.com)
func maskEmail(email string) string {
at := -1
for i, r := range email {
if r == '@' {
at = i
break
}
}
if at <= 0 {
return "***"
}
return email[:1] + "***" + email[at:]
}
// UserComments 获取用户发表的评论列表(分页,含帖子标题)
func (h *Handlers) UserComments(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)