diff --git a/backend/handler/comment.go b/backend/handler/comment.go index 8bdfcf2..22876a3 100644 --- a/backend/handler/comment.go +++ b/backend/handler/comment.go @@ -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"` diff --git a/backend/handler/favorite.go b/backend/handler/favorite.go new file mode 100644 index 0000000..7ff11e3 --- /dev/null +++ b/backend/handler/favorite.go @@ -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, + }) +} diff --git a/backend/handler/follow.go b/backend/handler/follow.go new file mode 100644 index 0000000..d364702 --- /dev/null +++ b/backend/handler/follow.go @@ -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, + }) +} diff --git a/backend/handler/handlers.go b/backend/handler/handlers.go index 9e19b5d..bf2e9b4 100644 --- a/backend/handler/handlers.go +++ b/backend/handler/handlers.go @@ -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 diff --git a/backend/handler/post.go b/backend/handler/post.go index 1e8bdf1..9920117 100644 --- a/backend/handler/post.go +++ b/backend/handler/post.go @@ -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}) diff --git a/backend/handler/user.go b/backend/handler/user.go index 73f9320..d031f19 100644 --- a/backend/handler/user.go +++ b/backend/handler/user.go @@ -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) diff --git a/backend/model/db.go b/backend/model/db.go index b3bf353..d5e1688 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -57,7 +57,7 @@ func InitDB(dsn string) error { if err := db.AutoMigrate( &TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{}, - &User{}, &Board{}, &Post{}, &PostEditHistory{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{}, + &User{}, &Board{}, &Post{}, &PostEditHistory{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Favorite{}, &Follow{}, &Notification{}, &Checkin{}, &Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &VisitEvent{}, &Attachment{}, &UserBoard{}, &LoginLog{}, &ChatRoom{}, &ChatRoomMember{}, &ChatMessage{}, &PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{}, diff --git a/backend/model/models.go b/backend/model/models.go index 2db9dde..5402af0 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -432,6 +432,22 @@ type Like struct { CreatedAt time.Time `json:"created_at"` } +// Favorite 收藏记录(联合唯一索引防止重复收藏;无计数列,按钮仅表示状态) +type Favorite struct { + ID uint `gorm:"primaryKey" json:"id"` + PostID uint `gorm:"uniqueIndex:idx_fav_post_user;not null" json:"post_id"` + UserID uint `gorm:"uniqueIndex:idx_fav_post_user;not null" json:"user_id"` + CreatedAt time.Time `json:"created_at"` +} + +// Follow 关注关系(FollowerID 关注了 FollowingID;联合唯一防止重复关注) +type Follow struct { + ID uint `gorm:"primaryKey" json:"id"` + FollowerID uint `gorm:"uniqueIndex:idx_follow_pair,priority:1;index;not null" json:"follower_id"` + FollowingID uint `gorm:"uniqueIndex:idx_follow_pair,priority:2;index;not null" json:"following_id"` + CreatedAt time.Time `json:"created_at"` +} + // Checkin 每日签到记录(用户+日期联合唯一,防止重复签到) type Checkin struct { ID uint `gorm:"primaryKey" json:"id"` @@ -453,6 +469,7 @@ const ( NotificationTypeBadge = "badge" // 获得管理员颁发的徽章 NotificationTypeDeleted = "deleted" // 帖子被管理员删除 NotificationTypeNecroReply = "necro_reply" // 回复旧帖提醒(系统,ActorID=0) + NotificationTypeFollow = "follow" // 关注了你 ) // Notification 站内通知 diff --git a/backend/router/router.go b/backend/router/router.go index 2d83b02..042b5b2 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -66,6 +66,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { commentSvc := service.NewCommentService(model.DB).WithSetting(settingSvc) likeSvc := service.NewLikeService(model.DB) notifSvc := service.NewNotificationService(model.DB) + favoriteSvc := service.NewFavoriteService(model.DB).WithPosts(postSvc) + followSvc := service.NewFollowService(model.DB).WithNotification(notifSvc) overviewSvc := service.NewOverviewService(model.DB) checkinSvc := service.NewCheckinService(model.DB).WithSetting(settingSvc) announcementSvc := service.NewAnnouncementService(model.DB) @@ -108,6 +110,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { Post: postSvc, Comment: commentSvc, Like: likeSvc, + Favorite: favoriteSvc, + Follow: followSvc, Notification: notifSvc, OverviewSvc: overviewSvc, Checkin: checkinSvc, @@ -170,8 +174,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { pubAPI.POST("/posts/:id/unlock-password", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateHidePassword), h.UnlockPostPassword) pubAPI.GET("/posts/:id/attachments/:aid/download", h.DownloadPostAttachment) pubAPI.GET("/posts/:id/comments", h.PostComments) + pubAPI.GET("/posts/:id/comments/:cid", h.CommentLocation) pubAPI.GET("/users/:id", h.UserProfile) pubAPI.GET("/users/:id/comments", h.UserComments) + pubAPI.GET("/users/:id/favorites", h.UserFavorites) + pubAPI.GET("/users/:id/following", h.UserFollowing) pubAPI.GET("/announcements", h.AnnouncementsList) pubAPI.GET("/announcements/:id", h.AnnouncementDetail) pubAPI.GET("/pages", h.SitePagesList) @@ -220,6 +227,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { api.PUT("/posts/:id/recommend", h.ToggleRecommend) api.PUT("/posts/:id/lock", h.TogglePostLock) api.POST("/posts/:id/like", h.ToggleLike) + api.POST("/posts/:id/favorite", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateInteract)), h.ToggleFavorite) + api.POST("/users/:id/follow", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateInteract)), h.ToggleFollow) api.POST("/posts/:id/comments", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.CreateComment) api.PUT("/posts/:id/comments/:cid", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.UpdateComment) api.DELETE("/posts/:id/comments/:cid", h.DeleteComment) diff --git a/backend/service/favorite.go b/backend/service/favorite.go new file mode 100644 index 0000000..f80d390 --- /dev/null +++ b/backend/service/favorite.go @@ -0,0 +1,104 @@ +package service + +import ( + "errors" + + "github.com/freefire/jiang13-bbs/model" + "gorm.io/gorm" +) + +// FavoriteService 收藏服务:收藏公开可见,公开列表只出 published 帖 +type FavoriteService struct { + db *gorm.DB + posts *PostService +} + +func NewFavoriteService(db *gorm.DB) *FavoriteService { + return &FavoriteService{db: db} +} + +// WithPosts 注入帖子服务(列表复用 PostListItem 组装与最后回复填充) +func (s *FavoriteService) WithPosts(p *PostService) *FavoriteService { + s.posts = p + return s +} + +// Toggle 切换收藏状态:已收藏则取消,未收藏则收藏。返回 (favorited, error) +func (s *FavoriteService) Toggle(postID, userID uint) (bool, error) { + var post model.Post + if err := s.db.First(&post, postID).Error; err != nil { + return false, errors.New("帖子不存在") + } + + var fav model.Favorite + err := s.db.Where("post_id = ? AND user_id = ?", postID, userID).First(&fav).Error + if err == nil { + if err := s.db.Delete(&fav).Error; err != nil { + return true, err + } + return false, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return false, err + } + if err := s.db.Create(&model.Favorite{PostID: postID, UserID: userID}).Error; err != nil { + return false, err + } + return true, nil +} + +// HasFavorited 判断用户是否已收藏某帖子 +func (s *FavoriteService) HasFavorited(postID, userID uint) bool { + var count int64 + s.db.Model(&model.Favorite{}).Where("post_id = ? AND user_id = ?", postID, userID).Count(&count) + return count > 0 +} + +// BatchHasFavorited 批量查询用户对多个帖子的收藏状态,返回 postID -> bool +func (s *FavoriteService) BatchHasFavorited(postIDs []uint, userID uint) map[uint]bool { + result := make(map[uint]bool) + if len(postIDs) == 0 || userID == 0 { + return result + } + var favs []model.Favorite + s.db.Where("post_id IN ? AND user_id = ?", postIDs, userID).Find(&favs) + for _, f := range favs { + result[f.PostID] = true + } + return result +} + +// List 用户收藏的帖子列表(公开可见,仅 published;按收藏时间倒序) +func (s *FavoriteService) List(userID uint, page, size int) ([]PostListItem, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 || size > 50 { + size = 20 + } + query := s.db.Model(&model.Post{}). + Joins("JOIN favorites ON favorites.post_id = posts.id"). + Where("favorites.user_id = ? AND posts.status = ?", userID, model.ContentStatusPublished) + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + var posts []model.Post + offset := (page - 1) * size + if err := query.Select("posts.*").Order("favorites.created_at DESC").Offset(offset).Limit(size). + Preload("Board").Preload("User").Preload("User.Badges.Badge").Find(&posts).Error; err != nil { + return nil, 0, err + } + items := toPostListItems(posts) + if s.posts != nil { + s.posts.fillLastReply(items) + } + return items, total, nil +} + +// CountByUser 用户收藏数量 +func (s *FavoriteService) CountByUser(userID uint) int64 { + var count int64 + s.db.Model(&model.Favorite{}).Where("user_id = ?", userID).Count(&count) + return count +} diff --git a/backend/service/follow.go b/backend/service/follow.go new file mode 100644 index 0000000..4362342 --- /dev/null +++ b/backend/service/follow.go @@ -0,0 +1,104 @@ +package service + +import ( + "errors" + + "github.com/freefire/jiang13-bbs/model" + "gorm.io/gorm" +) + +// FollowService 关注服务(仅用户;关注时给对方发通知) +type FollowService struct { + db *gorm.DB + notif *NotificationService +} + +func NewFollowService(db *gorm.DB) *FollowService { + return &FollowService{db: db} +} + +// WithNotification 注入通知服务(关注成功时落库一条 follow 通知) +func (s *FollowService) WithNotification(n *NotificationService) *FollowService { + s.notif = n + return s +} + +// Toggle 切换关注状态:已关注则取关,未关注则关注。返回 (following, error) +func (s *FollowService) Toggle(followerID, targetID uint) (bool, error) { + if followerID == targetID { + return false, errors.New("不能关注自己") + } + var target model.User + if err := s.db.First(&target, targetID).Error; err != nil { + return false, errors.New("用户不存在") + } + if target.Banned { + return false, errors.New("该用户已被封禁") + } + + var follow model.Follow + err := s.db.Where("follower_id = ? AND following_id = ?", followerID, targetID).First(&follow).Error + if err == nil { + if err := s.db.Delete(&follow).Error; err != nil { + return true, err + } + return false, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return false, err + } + if err := s.db.Create(&model.Follow{FollowerID: followerID, FollowingID: targetID}).Error; err != nil { + return false, err + } + if s.notif != nil { + s.notif.Create(targetID, followerID, model.NotificationTypeFollow, 0, 0, "") + } + return true, nil +} + +// IsFollowing 判断 followerID 是否已关注 targetID +func (s *FollowService) IsFollowing(followerID, targetID uint) bool { + if followerID == 0 || targetID == 0 || followerID == targetID { + return false + } + var count int64 + s.db.Model(&model.Follow{}).Where("follower_id = ? AND following_id = ?", followerID, targetID).Count(&count) + return count > 0 +} + +// ListFollowing 用户关注的人列表(公开;按关注时间倒序) +func (s *FollowService) ListFollowing(userID uint, page, size int) ([]model.User, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 || size > 50 { + size = 20 + } + query := s.db.Model(&model.User{}). + Joins("JOIN follows ON follows.following_id = users.id"). + Where("follows.follower_id = ?", userID) + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + var users []model.User + offset := (page - 1) * size + if err := query.Select("users.*").Order("follows.created_at DESC").Offset(offset).Limit(size).Find(&users).Error; err != nil { + return nil, 0, err + } + return users, total, nil +} + +// CountFollowing 关注数量 +func (s *FollowService) CountFollowing(userID uint) int64 { + var count int64 + s.db.Model(&model.Follow{}).Where("follower_id = ?", userID).Count(&count) + return count +} + +// CountFollowers 粉丝数量 +func (s *FollowService) CountFollowers(userID uint) int64 { + var count int64 + s.db.Model(&model.Follow{}).Where("following_id = ?", userID).Count(&count) + return count +} diff --git a/backend/service/overview.go b/backend/service/overview.go index db67099..9bbd1b8 100644 --- a/backend/service/overview.go +++ b/backend/service/overview.go @@ -91,13 +91,14 @@ type OverviewData struct { // PublicAdItem 侧栏广告条目 type PublicAdItem struct { - ID uint `json:"id"` - Kind string `json:"kind"` - LinkURL string `json:"link_url"` - ImageURL string `json:"image_url,omitempty"` - Title string `json:"title,omitempty"` - TextColor string `json:"text_color,omitempty"` - BgColor string `json:"bg_color,omitempty"` + ID uint `json:"id"` + Kind string `json:"kind"` + LinkURL string `json:"link_url"` + ImageURL string `json:"image_url,omitempty"` + Title string `json:"title,omitempty"` + TextColor string `json:"text_color,omitempty"` + BgColor string `json:"bg_color,omitempty"` + EndsAt *time.Time `json:"ends_at,omitempty"` } // PublicSponsorItem 侧栏赞助商 @@ -332,6 +333,7 @@ LIMIT 5` ID: a.ID, Kind: a.Kind, LinkURL: a.LinkURL, ImageURL: a.ImageURL, Title: a.Title, TextColor: a.TextColor, BgColor: a.BgColor, + EndsAt: a.EndsAt, }) } } diff --git a/backend/service/post.go b/backend/service/post.go index a786b48..7254055 100644 --- a/backend/service/post.go +++ b/backend/service/post.go @@ -270,16 +270,31 @@ func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) { return items, total, nil } +// normalizeUserPostSort 用户主页帖子排序口径(非法值回落 new) +func normalizeUserPostSort(sort string) string { + switch sort { + case "hot", "essence": + return sort + default: + return "new" + } +} + // ListByUser 获取指定用户发布的帖子。 // viewer 为本人或全站可审者时并入 pending;板块管理员仅并入其授权板的 pending。 -func (s *PostService) ListByUser(userID, viewerID uint, actor *Actor, page, size int) ([]PostListItem, int64, error) { +// sort: new=最新(默认,置顶优先)、hot=热门(点赞+评论加权)、essence=精华(仅 recommended)。 +func (s *PostService) ListByUser(userID, viewerID uint, actor *Actor, page, size int, sort string) ([]PostListItem, int64, error) { if page < 1 { page = 1 } if size < 1 || size > 50 { size = 20 } + sort = normalizeUserPostSort(sort) query := s.db.Model(&model.Post{}).Where("user_id = ?", userID) + if sort == "essence" { + query = query.Where("recommended = ?", true) + } if viewerID == userID || (actor != nil && model.RoleLevel(actor.Role) >= model.RoleLevel(model.RoleAdmin)) { query = query.Where("status IN ?", []string{model.ContentStatusPublished, model.ContentStatusPending}) } else if actor != nil && actor.Role == model.RoleBoardAdmin && len(actor.BoardIDs) > 0 { @@ -296,7 +311,11 @@ func (s *PostService) ListByUser(userID, viewerID uint, actor *Actor, page, size } var posts []model.Post offset := (page - 1) * size - if err := query.Order("pinned DESC, created_at DESC").Offset(offset).Limit(size). + order := "pinned DESC, created_at DESC" + if sort == "hot" { + order = "like_count + comment_count DESC, view_count DESC, created_at DESC" + } + if err := query.Order(order).Offset(offset).Limit(size). Preload("Board").Preload("User").Preload("User.Badges.Badge").Find(&posts).Error; err != nil { return nil, 0, err } @@ -457,7 +476,8 @@ type PostDetail struct { ViewCount int `json:"view_count"` CommentCount int `json:"comment_count"` Liked bool `json:"liked"` - Edited bool `json:"edited"` // 是否存在编辑历史快照(决定"更新于/编辑历史"入口展示) + Favorited bool `json:"favorited"` // 当前查看者是否已收藏(详情接口实时填充) + Edited bool `json:"edited"` // 是否存在编辑历史快照(决定"更新于/编辑历史"入口展示) // 最后一条已发布评论时间;null=无回复(旧帖判定回落 created_at) LastReplyAt *time.Time `json:"last_reply_at,omitempty"` // 旧帖回复确认提示:按查看者实时计算,命中才返回;前端存在即弹确认框 diff --git a/frontend/app/admin/content/ContentAdmin.tsx b/frontend/app/admin/content/ContentAdmin.tsx index ad59f5b..2942782 100644 --- a/frontend/app/admin/content/ContentAdmin.tsx +++ b/frontend/app/admin/content/ContentAdmin.tsx @@ -689,7 +689,7 @@ export default function ContentAdmin({ c.post_title ) : ( diff --git a/frontend/app/admin/users/UsersAdmin.tsx b/frontend/app/admin/users/UsersAdmin.tsx index 4af3f7b..fa8300c 100644 --- a/frontend/app/admin/users/UsersAdmin.tsx +++ b/frontend/app/admin/users/UsersAdmin.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; +import { boardColor } from "@/components/BoardIcon"; import { ArrowUpDown, ChevronDown, @@ -352,6 +353,12 @@ export default function UsersAdmin({ return m; }, [boards]); + const boardColorMap = useMemo(() => { + const m = new Map(); + boards.forEach((b) => m.set(b.id, boardColor(b))); + return m; + }, [boards]); + useEffect(() => { apiListBoards() .then((r) => setBoards(r.boards ?? [])) @@ -755,7 +762,7 @@ export default function UsersAdmin({ case "role": return (
- +
); case "status": @@ -945,7 +952,7 @@ export default function UsersAdmin({ {u.nickname} {u.role !== ROLES.USER && ( - + )} {isSelf && ( 帖子 #{c.post_id} ) : ( diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 430f5f5..f76fc77 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -364,7 +364,7 @@ html[data-lv-fx="off"] .lv-text { line-height: 1.4; letter-spacing: 0.01em; pointer-events: none; - white-space: normal; + white-space: pre-line; overflow-wrap: anywhere; color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, var(--panel)); @@ -7252,6 +7252,27 @@ a.j13-mention-token:hover { transform: none; } + /* 自助推广:购买广告位胶囊按钮 */ + .j13-ad-buy-btn { + display: inline-flex; + align-items: center; + padding: 2px 11px; + border-radius: 999px; + font-size: 12px; + font-weight: 500; + line-height: 1.5; + white-space: nowrap; + color: var(--accent); + background: color-mix(in srgb, var(--accent-soft) 70%, var(--panel)); + border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--line)); + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; + } + .j13-ad-buy-btn:hover { + background: var(--accent); + color: var(--accent-on); + border-color: var(--accent); + } + /* 自助推广侧栏占位:空槽可点去购买 */ .j13-ad-slot { display: flex; @@ -9112,6 +9133,16 @@ html.j13-reduce-chrome .j13-att-card.is-fresh { } .j13-fade-up { animation: j13-fade-up 0.2s ease-out both; } +/* 深链/目录跳转落点高亮:先铺主题色浅底再渐隐(lib/flash.ts 挂载,超时自动摘除) */ +@keyframes j13-flash-fade { + from { background-color: var(--accent-soft); } + to { background-color: transparent; } +} +.j13-flash-target { + animation: j13-flash-fade 1.8s ease-out both; + border-radius: 10px; +} + /* 跳到主内容 */ .skip-link { position: fixed; diff --git a/frontend/app/notifications/NotificationsClient.tsx b/frontend/app/notifications/NotificationsClient.tsx index 6903b43..b2c924c 100644 --- a/frontend/app/notifications/NotificationsClient.tsx +++ b/frontend/app/notifications/NotificationsClient.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; -import { Heart, MessageCircle, CheckCheck, Bell, CheckCircle2, XCircle, AtSign, ClipboardList, Award, AlertTriangle } from "lucide-react"; +import { Heart, MessageCircle, CheckCheck, Bell, CheckCircle2, XCircle, AtSign, ClipboardList, Award, AlertTriangle, UserPlus } from "lucide-react"; import { apiMarkRead, apiMarkAllRead, type NotificationsResponse, type NotificationItem } from "@/lib/api"; /** @@ -20,13 +20,18 @@ export default function NotificationsClient({ initialData }: { initialData: Noti list.map((item) => (item.id === n.id ? { ...item, is_read: true } : item)) ); } - // @提及通知跳到群聊房间;徽章通知无关联帖子(仅标已读);其余跳到帖子/评论 + // @提及通知跳到群聊房间;徽章通知无关联帖子(仅标已读);关注通知跳到关注者主页;其余跳到帖子/评论 if (n.type === "mention" && n.room_id) { router.push(`/chat/${n.room_id}`); } else if (n.type === "pending_review") { router.push(n.post_id ? `/post/${n.post_id}` : "/admin/content?status=pending"); } else if (n.type === "badge") { return; + } else if (n.type === "follow") { + router.push(`/u/${n.actor_id}`); + } else if (n.comment_id > 0) { + // 评论/回复/点赞等:直达评论所在楼层(跨页由帖子页深链定位换页) + router.push(`/post/${n.post_id}#comment-${n.comment_id}`); } else { router.push(`/post/${n.post_id}`); } @@ -92,7 +97,9 @@ export default function NotificationsClient({ initialData }: { initialData: Noti ? : n.type === "badge" ? - : ; + : n.type === "follow" + ? + : ; const iconColor = n.type === "approved" ? "var(--ok)" : n.type === "rejected" || n.type === "deleted" || n.type === "necro_reply" @@ -162,7 +169,9 @@ export default function NotificationsClient({ initialData }: { initialData: Noti : n.type === "deleted" ? "删除了你的帖子" : n.type === "badge" - ? "向你颁布了徽章" + ? "向你颁布了徽章" + : n.type === "follow" + ? "关注了你" : "点赞了你的帖子"} diff --git a/frontend/app/points/PointsClient.tsx b/frontend/app/points/PointsClient.tsx index e4f3b02..213df3e 100644 --- a/frontend/app/points/PointsClient.tsx +++ b/frontend/app/points/PointsClient.tsx @@ -1,8 +1,6 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import Link from "next/link"; -import { ChevronRight } from "lucide-react"; import { apiGetPoints, apiGetPointsLedger, @@ -42,14 +40,6 @@ export default function PointsClient() { return (
- -

可用余额

; + boardColors?: Record; }) { const u = profile.user; const signature = (u.signature || "").trim(); @@ -192,7 +199,9 @@ function AuthorCardStacked({ {(u.badges ?? []).slice(0, 2).map((ub) => ( ))} - {u.role !== "user" && } + {u.role !== "user" && ( + + )}

@{u.username} @@ -307,13 +316,22 @@ export default async function PostDetailPage({ params, searchParams }: PageProps }; const meFirst = await getMeCached(cookie || undefined); const canFetchComments = allowComments && (!commentsRequireLogin || !!meFirst.user); - const [commentsData, profile, relatedData, sidebarData] = await Promise.all([ + const [commentsData, profile, relatedData, sidebarData, boardsData] = await Promise.all([ canFetchComments ? fetchComments(id, commentPage, cookie) : Promise.resolve(emptyComments), fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null), fetchPosts(1, 8, post.board_id, "recommended", "", false, cookie).catch(() => null), // 侧栏布局配置失败不阻塞页面,回落默认布局 fetchSidebar(cookie).catch(() => null), + // 板块名/配色:板块管理员徽章展示「{板块名}板块管理员」并对齐板块色 + getBoardsCached().catch(() => null), ]); + // 板块 id → 名称 / 配色下标(与板块列表、头像底色同一套 av-N 色板) + const boardNames: Record = {}; + const boardColors: Record = {}; + for (const b of boardsData?.boards ?? []) { + boardNames[b.id] = b.name; + boardColors[b.id] = boardColor(b); + } const sidebar = sidebarData ?? { config: defaultSidebarConfig(), widgets: [] }; const me = meFirst; const comments = commentsData.comments; @@ -336,6 +354,8 @@ export default async function PostDetailPage({ params, searchParams }: PageProps authorProfile: profile ?? undefined, authorPosts, toc, + boardNames, + boardColors, }; // 三档网格退化:双栏 / 仅左 / 仅右 / 单栏 const railGridClass = !hasLeftRail && !hasRightRail @@ -462,6 +482,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps postId={id} liked={post.liked} likeCount={post.like_count} + favorited={post.favorited === true} allowComments={allowComments} /> @@ -550,6 +571,23 @@ export default async function PostDetailPage({ params, searchParams }: PageProps > {post.user.nickname} + {/* 等级 + 管理员颁布徽章 + 站点角色徽章收拢为一组(详情接口已 Preload,随 post.user 返回) */} +

+ + {(post.user.badges ?? []).slice(0, 2).map((ub) => ( + + ))} + {post.user.role !== "user" && ( + + )} +
{/* 管理操作已收进单个下拉按钮;普通读者此处不渲染内容 */} ) : null} - {/* 文末点赞:分隔线后居中,唯一的点赞入口(作者行不再放置) */} + {/* 文末点赞/收藏:分隔线后居中,唯一的互动入口(作者行不再放置) */}
+
{/* 窄屏作者卡(lg 起由左栏承载,避免重复展示) */} {profile && (
- +
)} diff --git a/frontend/app/u/[id]/UserProfileContent.tsx b/frontend/app/u/[id]/UserProfileContent.tsx index 3c6fed0..98b15fe 100644 --- a/frontend/app/u/[id]/UserProfileContent.tsx +++ b/frontend/app/u/[id]/UserProfileContent.tsx @@ -1,9 +1,17 @@ import Link from "next/link"; -import { FileText, MessageCircle, AlertCircle } from "lucide-react"; +import { + FileText, MessageCircle, AlertCircle, Bookmark, UserPlus, +} from "lucide-react"; import Pagination from "@/components/Pagination"; +import PostRow from "@/components/PostRow"; import { IconComment, IconEye, IconHeart } from "@/components/Icons"; +import Avatar from "@/components/Avatar"; +import LevelBadge from "@/components/LevelBadge"; +import FollowButton from "@/components/FollowButton"; import { formatRelative } from "@/lib/format"; -import type { UserProfile, UserCommentsResponse } from "@/lib/api"; +import type { + UserProfile, UserCommentsResponse, UserFavoritesResponse, UserFollowingResponse, PostListItem, +} from "@/lib/api"; /** 本页卡片统一阴影;用 color-mix 让暗色主题自动加深,避免写死浅色阴影 */ const CARD_SHADOW = "0 1px 3px color-mix(in srgb, #000 18%, transparent)"; @@ -11,10 +19,125 @@ const CARD_SHADOW = "0 1px 3px color-mix(in srgb, #000 18%, transparent)"; const ITEM_BASE = "group block rounded-xl px-6 py-5 border transition-[background-color,box-shadow,border-color] duration-150"; +const SORTS: { key: string; label: string }[] = [ + { key: "new", label: "最新" }, + { key: "hot", label: "热门" }, + { key: "essence", label: "精华" }, +]; + +/** 帖子卡(发帖列表与收藏列表共用) */ +function PostCardItem({ post, allowComments }: { post: PostListItem; allowComments?: boolean }) { + const pinned = post.pinned > 0; + return ( + +
+ {pinned && ( + + 置顶 + + )} +

+ {post.title} +

+ {post.recommended && 推荐} +
+
+ + {post.board.name} + + {formatRelative(post.created_at)} + {allowComments ? ( + + + + {post.comment_count} + 评论 + + + ) : null} + + + + {post.view_count} + 浏览 + + + + + + {post.like_count} + 点赞 + + +
+ + ); +} + +/** 列表区块统一空态 */ +function EmptyState({ icon, text }: { icon: React.ReactNode; text: string }) { + return ( +
+ {icon} +

{text}

+
+ ); +} + +/** 列表加载失败统一态 */ +function ErrorState({ retryHref }: { retryHref: string }) { + return ( +
+ +

加载失败

+

网络异常,请稍后重试

+ 重新加载 +
+ ); +} + /** * 用户主页内容区(纯服务端渲染): - * tab 与分页全部走 URL(?tab=comments&page=N),刷新/后退/分享链接均保持一致。 - * Tab 切换栏已移至 page.tsx 统一渲染(含 settings / security),此组件仅负责 posts / comments 列表。 + * tab 与分页全部走 URL(?tab=xxx&page=N&sort=xxx),刷新/后退/分享链接均保持一致。 + * Tab 切换栏已移至 page.tsx 统一渲染(含 settings / security),此组件仅负责列表类 tab。 */ export default function UserProfileContent({ userId, @@ -22,153 +145,187 @@ export default function UserProfileContent({ tab, commentData, commentError, + favoriteData, + favoriteError, + followingData, + followingError, page, + sort = "new", + isOwner, allowComments = true, }: { userId: string; profile: UserProfile; - tab: "posts" | "comments"; + tab: "posts" | "comments" | "favorites" | "following"; commentData: UserCommentsResponse | null; commentError: boolean; + favoriteData: UserFavoritesResponse | null; + favoriteError: boolean; + followingData: UserFollowingResponse | null; + followingError: boolean; page: number; + sort?: string; + isOwner: boolean; allowComments?: boolean; }) { + const displayName = profile.user.nickname || profile.user.username; + const paginationQuery: Record = + tab === "comments" ? { tab: "comments" } + : tab === "favorites" ? { tab: "favorites" } + : tab === "following" ? { tab: "following" } + : sort !== "new" ? { sort } + : {}; + return (
{tab === "posts" ? (
+ {/* 标题 + 排序 chips(最新/热门/精华),与参考图一致 */} +
+

+ {isOwner ? "我的帖子" : `${displayName} 的帖子`} +

+
+ {SORTS.map((s) => ( + + {s.label} + + ))} +
+
{Array.isArray(profile.posts) && profile.posts.length > 0 ? ( -
- {profile.posts.map((post) => { - const pinned = post.pinned > 0; - return ( - -
- {pinned && ( - - 置顶 - - )} -

- {post.title} -

- {post.recommended && 推荐} -
-
- - {post.board.name} - - {formatRelative(post.created_at)} - {allowComments ? ( - - - - {post.comment_count} - 评论 - - - ) : null} - - - - {post.view_count} - 浏览 - - - - - - {post.like_count} - 点赞 - - -
- - ); - })} + /* 与首页信息流一致的行式列表 */ +
+ {profile.posts.map((post, i) => ( + + ))}
) : ( -
- -

暂无发帖

-
+ } + text="暂无发帖" + /> )}
+ ) : tab === "favorites" ? ( + favoriteError ? ( + + ) : favoriteData && favoriteData.posts.length > 0 ? ( +
+
+

+ {isOwner ? "我的收藏" : `${displayName} 的收藏`} +

+
+
+ {favoriteData.posts.map((post) => ( + + ))} +
+ +
+ ) : ( + } + text="暂无收藏" + /> + ) + ) : tab === "following" ? ( + followingError ? ( + + ) : followingData && followingData.users.length > 0 ? ( +
+
+

+ {isOwner ? "我的关注" : `${displayName} 的关注`} +

+
+
+ {followingData.users.map((fu) => ( +
+ + +
+
+ + {fu.nickname || fu.username} + + +
+

+ @{fu.username} + {fu.signature ? ` · ${fu.signature}` : ""} +

+
+ + {isOwner && } +
+ ))} +
+ +
+ ) : ( + } + text="暂无关注" + /> + ) ) : commentError ? ( -
- -

评论加载失败

-

网络异常,请稍后重试

- 重新加载 -
+ ) : commentData && (commentData.comments?.length ?? 0) > 0 ? (
-
+
{(commentData.comments ?? []).map((c) => ( -
- 评论于《{c.post_title}》 +
+ + + 评论于《{c.post_title}》 +

{c.content}

-
+
{formatRelative(c.created_at)}
@@ -179,21 +336,14 @@ export default function UserProfileContent({ page={commentData.page} size={20} basePath={`/u/${userId}`} - query={{ tab: "comments" }} + query={paginationQuery} />
) : ( -
- -

暂无评论

-
+ } + text="暂无评论" + /> )}
); diff --git a/frontend/app/u/[id]/page.tsx b/frontend/app/u/[id]/page.tsx index 766033a..c998e13 100644 --- a/frontend/app/u/[id]/page.tsx +++ b/frontend/app/u/[id]/page.tsx @@ -3,14 +3,16 @@ import { cookies, headers } from "next/headers"; import { Metadata } from "next"; import { FileText, MessageCircle, Flame, Calendar, Hash, Shield, - Settings, KeyRound, Info, PenLine, + Settings, KeyRound, Info, PenLine, Bookmark, UserPlus, Mail, } from "lucide-react"; import { fetchUserProfile, fetchUserComments, fetchLoginDevices, + fetchUserFavorites, fetchUserFollowing, type UserProfile, type UserCommentsResponse, type LoginDevice, + type UserFavoritesResponse, type UserFollowingResponse, } from "@/lib/api"; import { authCookieHeader, sessionCookieHeader } from "@/lib/cookies"; -import { getMeCached, getPublicSettingsCached } from "@/lib/serverData"; +import { getMeCached, getPublicSettingsCached, getBoardsCached } from "@/lib/serverData"; import UserProfileContent from "./UserProfileContent"; import ProfileForm from "./ProfileForm"; import PasswordForm from "./PasswordForm"; @@ -19,8 +21,10 @@ import ProfileHeroAvatar from "./ProfileHeroAvatar"; import RoleBadge from "@/components/RoleBadge"; import LevelBadge from "@/components/LevelBadge"; import BadgeChip from "@/components/BadgeChip"; +import { boardColor } from "@/components/BoardIcon"; import { roleMeta } from "@/lib/roles"; import StartDMButton from "./StartDMButton"; +import FollowButton from "@/components/FollowButton"; import StaffUserMenu from "@/components/StaffUserMenu"; import { requestCanonical } from "@/lib/canonicalMeta"; import { absoluteCanonical, canonicalPage } from "@/lib/siteUrl"; @@ -29,19 +33,23 @@ import JsonLd from "@/components/JsonLd"; interface UserPageProps { params: Promise<{ id: string }>; - searchParams: Promise<{ page?: string; tab?: string }>; + searchParams: Promise<{ page?: string; tab?: string; sort?: string }>; } -export type UserTab = "posts" | "comments" | "settings" | "security"; +export type UserTab = "posts" | "comments" | "favorites" | "following" | "settings" | "security"; + +function parseTab(rawTab?: string): UserTab { + return rawTab === "comments" ? "comments" : + rawTab === "favorites" ? "favorites" : + rawTab === "following" ? "following" : + rawTab === "settings" ? "settings" : + rawTab === "security" ? "security" : "posts"; +} export async function generateMetadata({ params, searchParams }: UserPageProps): Promise { const { id } = await params; const sp = await searchParams; - const rawTab = sp.tab; - const tab: UserTab = - rawTab === "comments" ? "comments" : - rawTab === "settings" ? "settings" : - rawTab === "security" ? "security" : "posts"; + const tab = parseTab(sp.tab); try { const data = await fetchUserProfile(id, 1); const allowComments = (await getPublicSettingsCached()).allow_comments !== false; @@ -55,6 +63,9 @@ export async function generateMetadata({ params, searchParams }: UserPageProps): } const query: Record = {}; if (allowComments && tab === "comments") query.tab = "comments"; + if (tab === "favorites") query.tab = "favorites"; + if (tab === "following") query.tab = "following"; + if (tab === "posts" && (sp.sort === "hot" || sp.sort === "essence")) query.sort = sp.sort; const page = canonicalPage(sp.page); if (page) query.page = page; return { @@ -75,11 +86,8 @@ export default async function UserPage({ params, searchParams }: UserPageProps) const { id } = await params; const sp = await searchParams; const page = Math.max(1, parseInt(sp.page || "1", 10) || 1); - const rawTab = sp.tab; - const tab: UserTab = - rawTab === "comments" ? "comments" : - rawTab === "settings" ? "settings" : - rawTab === "security" ? "security" : "posts"; + const tab = parseTab(sp.tab); + const sort = sp.sort === "hot" || sp.sort === "essence" ? sp.sort : "new"; const cookie = authCookieHeader(await cookies()); const me = await getMeCached(cookie || undefined); @@ -92,7 +100,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps) let data: UserProfile; try { // 带 cookie:超管/站长才能拿到 banned 字段 - data = await fetchUserProfile(id, page, cookie || undefined); + data = await fetchUserProfile(id, page, cookie || undefined, sort); } catch { return (
@@ -107,6 +115,18 @@ export default async function UserPage({ params, searchParams }: UserPageProps) ); } + // 板块 id → 名称/配色:板块管理员徽章展示「{板块名}板块管理员」并对齐板块色 + const boardNames: Record = {}; + const boardColors: Record = {}; + try { + for (const b of (await getBoardsCached()).boards) { + boardNames[b.id] = b.name; + boardColors[b.id] = boardColor(b); + } + } catch { + /* 板块列表失败时回落通用角色徽章 */ + } + // 判断是否本人(决定能否看到 settings / security tab) const isOwner = me.user?.id === data.user.id; const currentUser = me.user; @@ -119,12 +139,34 @@ export default async function UserPage({ params, searchParams }: UserPageProps) let commentError = false; if (canReadComments && effectiveTab === "comments") { try { - commentData = await fetchUserComments(id, page); + commentData = await fetchUserComments(id, page, cookie || undefined); } catch { commentError = true; } } + // 收藏 tab:SSR 直出对应分页(公开可见) + let favoriteData: UserFavoritesResponse | null = null; + let favoriteError = false; + if (effectiveTab === "favorites") { + try { + favoriteData = await fetchUserFavorites(id, page); + } catch { + favoriteError = true; + } + } + + // 关注 tab:SSR 直出对应分页(公开可见) + let followingData: UserFollowingResponse | null = null; + let followingError = false; + if (effectiveTab === "following") { + try { + followingData = await fetchUserFollowing(id, page); + } catch { + followingError = true; + } + } + // 安全设置:仍有效的登录会话(仅本人) // SSR 直连后端时必须转发 refresh cookie + 浏览器 UA/IP,才能标「当前在线」并避免把 Node fetch 当成一台设备 let loginDevices: LoginDevice[] = []; @@ -148,20 +190,29 @@ export default async function UserPage({ params, searchParams }: UserPageProps) const showPoints = data.stats.points > 0 || isOwner; const hasHeroActions = isOwner || !!currentUser; - // 导航项(桌面左栏 / 移动 chips 共用定义) - const navItems: { key: UserTab; label: string; count?: number; icon: React.ReactNode; ownerOnly?: boolean }[] = [ + // 导航项(桌面左栏 / 移动 chips 共用定义):「个人主页」+「设置」两组,与参考图一致 + const mainNavItems: { key: UserTab; label: string; count?: number; icon: React.ReactNode }[] = [ { key: "posts", label: "发帖", count: data.stats.post_count, icon: }, ...(canReadComments - ? [{ key: "comments" as const, label: "评论", count: data.stats.comment_count, icon: }] + ? [{ key: "comments" as const, label: "回帖", count: data.stats.comment_count, icon: }] : []), - { key: "settings", label: "资料设置", icon: , ownerOnly: true }, - { key: "security", label: "安全设置", icon: , ownerOnly: true }, + { key: "favorites", label: "收藏", count: data.stats.favorite_count, icon: }, + { key: "following", label: "关注", count: data.stats.following_count, icon: }, ]; - const visibleNav = navItems.filter((t) => !t.ownerOnly || isOwner); + const settingsNavItems: { key: UserTab; label: string; count?: number; icon: React.ReactNode }[] = isOwner + ? [ + { key: "settings", label: "资料设置", icon: }, + { key: "security", label: "安全设置", icon: }, + ] + : []; + const visibleNav = [...mainNavItems, ...settingsNavItems]; const tabHref = (t: UserTab) => (t === "posts" ? `/u/${id}` : `/u/${id}?tab=${t}`); const profileQuery: Record = {}; if (canReadComments && effectiveTab === "comments") profileQuery.tab = "comments"; + if (effectiveTab === "favorites") profileQuery.tab = "favorites"; + if (effectiveTab === "following") profileQuery.tab = "following"; + if (effectiveTab === "posts" && sort !== "new") profileQuery.sort = sort; if (page > 1) profileQuery.page = page; const profileLd = effectiveTab === "settings" || effectiveTab === "security" @@ -220,6 +271,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
) : currentUser ? (
+ {u.role !== "user" && ( - + )} {u.banned === true && (
- {visibleNav.map((t) => ( + {mainNavItems.map((t) => ( ))}
+ {settingsNavItems.length > 0 && ( + <> +

+ 设置 +

+
+ {settingsNavItems.map((t) => ( + + {t.icon} + {t.label} + + ))} +
+ + )} {/* 中栏:Tab 内容 */}
- {effectiveTab === "posts" || effectiveTab === "comments" ? ( + {effectiveTab === "posts" || + effectiveTab === "comments" || + effectiveTab === "favorites" || + effectiveTab === "following" ? ( ) : effectiveTab === "settings" && isOwner && currentUser ? ( @@ -421,35 +510,45 @@ export default async function UserPage({ params, searchParams }: UserPageProps)

账号信息

-
-
-

用户名

-

+

+
+

用户名

+

{u.username}

-
-

+

+

注册时间

-

+

{joinDate}

-
-

+

+

角色

-

+

{roleMeta(u.role).label}

-
-

用户 ID

-

+

+

用户 ID

+

#{u.id}

+ {u.email && ( +
+

+ 邮箱 +

+

+ {u.email} +

+
+ )}
diff --git a/frontend/components/AdText.tsx b/frontend/components/AdText.tsx index f9bb82e..a9210ff 100644 --- a/frontend/components/AdText.tsx +++ b/frontend/components/AdText.tsx @@ -1,12 +1,15 @@ "use client"; import { useEffect, useRef, useState, type CSSProperties } from "react"; +import { AD_TIP_DELAY_MS, adHoverTip } from "@/lib/ads"; type AdTextProps = { title: string; textColor?: string; bgColor?: string; href?: string; + /** 广告到期时间(ISO 字符串):有值时气泡首行展示,无值时仅显示免责声明 */ + endsAt?: string | null; className?: string; }; @@ -14,7 +17,14 @@ type AdTextProps = { * 文字广告:仅当文字宽度超出容器时才启用跑马灯滚动, * 能完整显示时居中静止。结构与样式复用侧栏 .j13-ad-text。 */ -export function AdText({ title, textColor, bgColor, href, className = "" }: AdTextProps) { +export function AdText({ + title, + textColor, + bgColor, + href, + endsAt, + className = "", +}: AdTextProps) { const trackRef = useRef(null); const [overflow, setOverflow] = useState(false); @@ -35,6 +45,9 @@ export function AdText({ title, textColor, bgColor, href, className = "" }: AdTe const cls = `j13-ad-text${overflow ? " is-overflow" : ""}${className ? ` ${className}` : ""}`; const style: CSSProperties = { color: textColor, background: bgColor }; + // 气泡首行:到期时间;第二行:免责声明 + const tip = adHoverTip(endsAt); + const inner = ( {inner} ); } return ( - + {inner} ); diff --git a/frontend/components/BadgeChip.tsx b/frontend/components/BadgeChip.tsx index 775c9f4..5060b97 100644 --- a/frontend/components/BadgeChip.tsx +++ b/frontend/components/BadgeChip.tsx @@ -22,11 +22,11 @@ export default function BadgeChip({ return ( - + {badge.name} ); diff --git a/frontend/components/CommentSection.tsx b/frontend/components/CommentSection.tsx index 14c9474..e19b312 100644 --- a/frontend/components/CommentSection.tsx +++ b/frontend/components/CommentSection.tsx @@ -39,6 +39,8 @@ import MarkdownEditor from "@/components/MarkdownEditor"; import ConfirmDialog from "@/components/ConfirmDialog"; import DeleteCommentModal, { type DeleteCommentPayload } from "@/components/DeleteCommentModal"; import EditHistoryModal from "@/components/EditHistoryModal"; +import { scrollFlashTo } from "@/lib/flash"; +import { fetchCommentLocation } from "@/lib/api"; interface CommentSectionProps { postId: string; @@ -288,6 +290,59 @@ export default function CommentSection({ return () => window.removeEventListener("hashchange", focusComposer); }, [user, commentsOpen]); + // 深链定位:#comment-{id} → 滚动 + 主题色浅底高亮渐隐。 + // 目标是折叠楼层里的子回复时先展开所属楼层;不在当前页时查楼层号换页后重试。 + // 楼层号锚点(#楼)、「回复 @xx」、采纳答案卡等页内跳转经 hashchange 同样获得高亮。 + const locatedRef = useRef(null); // 已跨页尝试过的评论 ID,防循环 + useEffect(() => { + if (!commentsOpen) return; + let cancelled = false; + const locate = async () => { + const m = window.location.hash.match(/^#comment-(\d+)$/); + if (!m) return; + const id = Number(m[1]); + const el = document.getElementById(`comment-${id}`); + if (el) { + scrollFlashTo(el); + return; + } + // 不在 DOM:先看是否在本页数据里(折叠的子回复)→ 展开所属楼层后定位 + const node = findComment(comments, id); + if (node) { + const rootId = node.root_id ?? node.parent_id ?? node.id; + setExpandedFloors((prev) => { + if (prev.has(rootId)) return prev; + const next = new Set(prev); + next.add(rootId); + return next; + }); + window.setTimeout(() => { + if (!cancelled) scrollFlashTo(document.getElementById(`comment-${id}`)); + }, 160); + return; + } + // 不在本页:查楼层号 → 换页(comments 变化后本 effect 再次运行并定位) + if (locatedRef.current === id) return; // 已尝试仍找不到(如被拒/未公开),放弃 + locatedRef.current = id; + try { + const floor = await fetchCommentLocation(postId, id); + if (cancelled || floor <= 0) return; + const destPage = Math.max(1, Math.ceil(floor / size)); + if (destPage === page) return; // 同页却无节点(异常数据),放弃 + router.replace(`/post/${postId}?page=${destPage}#comment-${id}`, { scroll: false }); + } catch { + /* 定位失败静默:停留在当前页 */ + } + }; + locate(); + window.addEventListener("hashchange", locate); + return () => { + cancelled = true; + window.removeEventListener("hashchange", locate); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [comments, commentsOpen, page, postId, size]); + const submitFloor = async () => { if (!commentsOpen || !content.trim() || submitLock.current || floorUploading) return; submitLock.current = true; diff --git a/frontend/components/FavoriteButton.tsx b/frontend/components/FavoriteButton.tsx new file mode 100644 index 0000000..5f38fa5 --- /dev/null +++ b/frontend/components/FavoriteButton.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter, usePathname } from "next/navigation"; +import { Bookmark } from "lucide-react"; +import { apiToggleFavorite } from "@/lib/api"; +import { toast } from "@/lib/toast"; + +interface FavoriteButtonProps { + postId: string; + favorited: boolean; + variant?: "default" | "compact" | "article"; +} + +export default function FavoriteButton({ + postId, + favorited: initialFavorited, + variant = "default", +}: FavoriteButtonProps) { + const router = useRouter(); + const pathname = usePathname(); + const [favorited, setFavorited] = useState(initialFavorited); + const [loading, setLoading] = useState(false); + + // router.refresh()(登录/登出后)服务端会重新下发 favorited 态,同步到本地 + useEffect(() => { + setFavorited(initialFavorited); + }, [initialFavorited]); + + const handleClick = async () => { + if (loading) return; + setLoading(true); + const wasFavorited = favorited; + setFavorited(!wasFavorited); + try { + const res = await apiToggleFavorite(postId); + if (res.error === "未登录") { + router.push(`/login?redirect=${encodeURIComponent(pathname)}`); + return; + } + if (typeof res.favorited === "boolean") { + setFavorited(res.favorited); + } else { + throw new Error(res.error || "FAIL"); + } + } catch { + setFavorited(wasFavorited); + toast("操作失败,请稍后重试"); + } finally { + setLoading(false); + } + }; + + const sizeCls = + variant === "compact" + ? "px-2.5 py-1.5 gap-1 text-[12.5px]" + : variant === "article" + ? "px-7 py-3 gap-2 text-[15px] min-w-[132px]" + : "px-4 py-2 gap-1.5 text-sm"; + const iconSize = variant === "compact" ? 13 : variant === "article" ? 18 : 15; + + return ( + + ); +} diff --git a/frontend/components/FollowButton.tsx b/frontend/components/FollowButton.tsx new file mode 100644 index 0000000..9c99ea4 --- /dev/null +++ b/frontend/components/FollowButton.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter, usePathname } from "next/navigation"; +import { UserPlus, UserCheck } from "lucide-react"; +import { apiToggleFollow } from "@/lib/api"; +import { toast } from "@/lib/toast"; + +interface FollowButtonProps { + userId: string | number; + following: boolean; + size?: "default" | "sm"; +} + +export default function FollowButton({ + userId, + following: initialFollowing, + size = "default", +}: FollowButtonProps) { + const router = useRouter(); + const pathname = usePathname(); + const [following, setFollowing] = useState(initialFollowing); + const [loading, setLoading] = useState(false); + + // router.refresh()(登录/登出后)服务端会重新下发关注态,同步到本地 + useEffect(() => { + setFollowing(initialFollowing); + }, [initialFollowing]); + + const handleClick = async () => { + if (loading) return; + setLoading(true); + const wasFollowing = following; + setFollowing(!wasFollowing); + try { + const res = await apiToggleFollow(userId); + if (res.error === "未登录") { + router.push(`/login?redirect=${encodeURIComponent(pathname)}`); + return; + } + if (typeof res.following === "boolean") { + setFollowing(res.following); + // 关注数变化后刷新页面统计(导航计数、Hero 统计) + router.refresh(); + } else { + throw new Error(res.error || "FAIL"); + } + } catch { + setFollowing(wasFollowing); + toast("操作失败,请稍后重试"); + } finally { + setLoading(false); + } + }; + + const sizeCls = size === "sm" ? "px-2.5 py-1 gap-1 text-[12px]" : "btn-sm"; + const iconSize = size === "sm" ? 12 : 14; + + return ( + + ); +} diff --git a/frontend/components/MobilePostBar.tsx b/frontend/components/MobilePostBar.tsx index 2ff10cd..826ecdf 100644 --- a/frontend/components/MobilePostBar.tsx +++ b/frontend/components/MobilePostBar.tsx @@ -2,21 +2,24 @@ import { MessageSquare } from "lucide-react"; import LikeButton from "./LikeButton"; +import FavoriteButton from "./FavoriteButton"; import { scrollBehavior } from "@/lib/motion"; /** * 帖子详情移动端浮动操作条(lg 以下显示): - * 长文读到中途也能点赞 / 跳评论框;回顶由全局 BackToTop 承担。 + * 长文读到中途也能点赞 / 收藏 / 跳评论框;回顶由全局 BackToTop 承担。 */ export default function MobilePostBar({ postId, liked, likeCount, + favorited = false, allowComments = true, }: { postId: string; liked: boolean; likeCount: number; + favorited?: boolean; allowComments?: boolean; }) { const scrollToComments = () => { @@ -52,6 +55,7 @@ export default function MobilePostBar({
+
); diff --git a/frontend/components/NotificationBell.tsx b/frontend/components/NotificationBell.tsx index a63182c..0af4ef8 100644 --- a/frontend/components/NotificationBell.tsx +++ b/frontend/components/NotificationBell.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; -import { Bell, CheckCheck, Heart, MessageCircle, CheckCircle2, XCircle, AtSign, ClipboardList, Award, AlertTriangle } from "lucide-react"; +import { Bell, CheckCheck, Heart, MessageCircle, CheckCircle2, XCircle, AtSign, ClipboardList, Award, AlertTriangle, UserPlus } from "lucide-react"; import { apiFetchNotifications, apiUnreadCount, @@ -99,13 +99,18 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread? ); } setOpen(false); - // @提及通知跳到群聊房间;徽章通知无关联帖子(仅标已读);其余跳到帖子/评论 + // @提及通知跳到群聊房间;徽章通知无关联帖子(仅标已读);关注通知跳到关注者主页;其余跳到帖子/评论 if (n.type === "mention" && n.room_id) { router.push(`/chat/${n.room_id}`); } else if (n.type === "pending_review") { router.push(n.post_id ? `/post/${n.post_id}` : "/admin/content?status=pending"); } else if (n.type === "badge") { return; + } else if (n.type === "follow") { + router.push(`/u/${n.actor_id}`); + } else if (n.comment_id > 0) { + // 评论/回复/点赞等:直达评论所在楼层(跨页由帖子页深链定位换页) + router.push(`/post/${n.post_id}#comment-${n.comment_id}`); } else { router.push(`/post/${n.post_id}`); } @@ -138,6 +143,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread? if (n.type === "deleted") return "删除了你的帖子"; if (n.type === "badge") return "向你颁布了徽章"; if (n.type === "necro_reply") return "旧帖回复提醒"; + if (n.type === "follow") return "关注了你"; return "点赞了你的帖子"; }; @@ -161,6 +167,8 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread? return { color: "var(--accent)", bg: "var(--accent-soft)", node: }; if (n.type === "badge") return { color: "var(--gold)", bg: "var(--gold-soft)", node: }; + if (n.type === "follow") + return { color: "var(--accent)", bg: "var(--accent-soft)", node: }; return { color: "var(--accent)", bg: "var(--accent-soft)", node: }; }; diff --git a/frontend/components/PostRow.tsx b/frontend/components/PostRow.tsx index d4f974d..e6cf028 100644 --- a/frontend/components/PostRow.tsx +++ b/frontend/components/PostRow.tsx @@ -6,7 +6,6 @@ import { getPublicSettingsCached } from "@/lib/serverData"; import { normalizePostType, postTypeLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta"; import LikeButton from "./LikeButton"; import Avatar from "./Avatar"; -import BadgeChip from "./BadgeChip"; import { IconComment, IconEye } from "./Icons"; /** @@ -104,9 +103,6 @@ export default async function PostRow({ > {authorName} - {(post.user.badges ?? []).slice(0, 2).map((ub) => ( - - ))} · {formatRelative(post.created_at)} {replied && ( diff --git a/frontend/components/PostToc.tsx b/frontend/components/PostToc.tsx index 432dd66..acd14cc 100644 --- a/frontend/components/PostToc.tsx +++ b/frontend/components/PostToc.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { scrollBehavior } from "@/lib/motion"; +import { flashElement } from "@/lib/flash"; export interface TocItem { level: 2 | 3; @@ -53,6 +54,7 @@ export default function PostToc({ items }: { items: TocItem[] }) { const target = document.getElementById(id); if (!target) return; target.scrollIntoView({ behavior: scrollBehavior(), block: "start" }); + flashElement(target); // 主题色浅底高亮渐隐,标记落点 setActive(id); }; diff --git a/frontend/components/RoleBadge.tsx b/frontend/components/RoleBadge.tsx index d4ab638..ee11475 100644 --- a/frontend/components/RoleBadge.tsx +++ b/frontend/components/RoleBadge.tsx @@ -2,6 +2,7 @@ import { Crown, BadgeCheck, ShieldCheck, Shield, Users, type LucideIcon } from "lucide-react"; import { roleMeta, type RoleIconKey } from "@/lib/roles"; +import { BOARD_COLOR_COUNT } from "@/components/BoardIcon"; // 角色图标集中映射(图标库不含 ShieldStar,超管使用 BadgeCheck 表达"全权限认证") const ICONS: Record = { @@ -12,59 +13,92 @@ const ICONS: Record = { user: Users, }; +/** 已知板块信息:名称 + 配色下标(0..15) */ +type BoardRef = { name: string; colorIdx: number | null }; + /** - * 角色徽章:图标 + 文案,配色走角色元数据中的 CSS token。 - * - board_admin 额外展示授权板块数(或板块名) - * - variant=plain 时仅图标+文字不着底色(用于表头/文本行) + * 角色徽章:图标 + 文案。 + * - 普通角色:配色走角色元数据中的 CSS token + * - board_admin 提供板块名时逐板块渲染「{板块名} · 板块管理员」,配色对齐板块色 + * (av-N,深浅色自适应);truncate 窄列场景折叠为单枚「{首板块}等 N 个板块 · 板块管理员」 + * - board_admin 未提供板块名时退回「板块管理员 · N 个板块」 */ export default function RoleBadge({ role, boardIds, boardNames, - size = 11, + boardColors, + size = 12, className = "", title, truncate = false, }: { role?: string | null; boardIds?: number[] | null; - /** id -> 名称映射;提供时板块管理员展示板块名(多选时折叠为 N 个板块) */ + /** id -> 名称映射;提供时板块管理员按板块逐枚展示 */ boardNames?: Map | Record; + /** id -> 配色下标(boardColor 计算);提供时徽章配色对齐板块色 */ + boardColors?: Map | Record; size?: number; className?: string; title?: string; - /** 窄列场景:限制最大宽度并省略过长文案 */ + /** 窄列场景:限制最大宽度并省略过长文案;多板块折叠为单枚 */ truncate?: boolean; }) { const meta = roleMeta(role); const Icon = ICONS[meta.icon]; - let label = meta.label; - if (role === "board_admin") { - if (boardNames) { - const names = (boardIds ?? []) - .map((id) => (boardNames instanceof Map ? boardNames.get(id) : boardNames[id])) - .filter(Boolean) as string[]; - if (names.length === 1) { - label = `${meta.label} · ${names[0]}`; - } else if (names.length > 1) { - label = `${meta.label} · ${names.length} 个板块`; - } - } else if (boardIds && boardIds.length > 0) { - label = `${meta.label} · ${boardIds.length} 个板块`; - } - } - - return ( + // 单枚渲染:有板块配色下标时用 av-N 类(自带深浅色两套值),否则角色配色 token + const renderBadge = (label: string, boardColorIdx: number | null, key?: number) => ( {truncate ? {label} : label} ); + + if (role === "board_admin" && boardNames) { + const boards = (boardIds ?? []) + .map((id): BoardRef | null => { + const name = boardNames instanceof Map ? boardNames.get(id) : boardNames[id]; + if (!name) return null; + const c = boardColors + ? boardColors instanceof Map + ? boardColors.get(id) + : boardColors[id] + : undefined; + return { name, colorIdx: typeof c === "number" ? c % BOARD_COLOR_COUNT : null }; + }) + .filter((b): b is BoardRef => b !== null); + + if (boards.length > 0) { + if (truncate) { + // 窄列:折叠为单枚,避免多枚徽章撑破布局 + const first = boards[0]; + const label = + boards.length === 1 + ? `${first.name} · ${meta.label}` + : `${first.name} 等 ${boards.length} 个板块 · ${meta.label}`; + return renderBadge(label, first.colorIdx); + } + return <>{boards.map((b, i) => renderBadge(`${b.name} · ${meta.label}`, b.colorIdx, i))}; + } + // 板块名缺失(板块被删等):回落通用渲染 + } + + let label = meta.label; + if (role === "board_admin" && boardIds && boardIds.length > 0) { + label = `${meta.label} · ${boardIds.length} 个板块`; + } + return renderBadge(label, null); } diff --git a/frontend/components/TipHost.tsx b/frontend/components/TipHost.tsx index dbe186a..48e4776 100644 --- a/frontend/components/TipHost.tsx +++ b/frontend/components/TipHost.tsx @@ -136,7 +136,17 @@ export default function TipHost() { const activeEl = useRef(null); useEffect(() => { + let pendingTimer: number | null = null; + + const clearPending = () => { + if (pendingTimer !== null) { + window.clearTimeout(pendingTimer); + pendingTimer = null; + } + }; + const hide = () => { + clearPending(); activeEl.current = null; setTip(null); }; @@ -160,8 +170,27 @@ export default function TipHost() { hide(); return; } - activeEl.current = host; - placeTip(host, text); + // 同一目标已在显示:直接复位位置,不重新计时 + if (activeEl.current === host && pendingTimer === null) { + placeTip(host, text); + return; + } + // data-tip-delay="毫秒":停留一会再显示,期间移出即取消 + const delay = Number(host.getAttribute("data-tip-delay") || 0); + if (!Number.isFinite(delay) || delay <= 0) { + clearPending(); + activeEl.current = host; + placeTip(host, text); + return; + } + clearPending(); + pendingTimer = window.setTimeout(() => { + pendingTimer = null; + const focused = host.contains(document.activeElement); + if (!host.matches(":hover") && !focused) return; + activeEl.current = host; + placeTip(host, text); + }, delay); }; const onOver = (e: Event) => { @@ -202,6 +231,7 @@ export default function TipHost() { window.addEventListener("resize", onScrollOrResize); return () => { + clearPending(); document.removeEventListener("mouseover", onOver); document.removeEventListener("mouseout", onOut); document.removeEventListener("focusin", onOver); diff --git a/frontend/components/home/CommunityPanels.tsx b/frontend/components/home/CommunityPanels.tsx index 237fda7..b8c1fc8 100644 --- a/frontend/components/home/CommunityPanels.tsx +++ b/frontend/components/home/CommunityPanels.tsx @@ -34,7 +34,13 @@ import { FRIEND_LINKS } from "@/lib/friendLinks"; import { formatCompact, formatShortDate } from "@/lib/format"; import { pageGlyph } from "@/lib/pageGlyph"; import { AboutSiteHeader } from "@/components/site-doc"; -import { AD_IMAGE_HEIGHT, AD_IMAGE_SIZE_LABEL, AD_IMAGE_WIDTH } from "@/lib/ads"; +import { + AD_IMAGE_HEIGHT, + AD_IMAGE_SIZE_LABEL, + AD_IMAGE_WIDTH, + AD_TIP_DELAY_MS, + adHoverTip, +} from "@/lib/ads"; /* ---------- 站点数据(2×2 bento 统计瓦片) ---------- */ @@ -464,10 +470,7 @@ export function AdsPanel({

{title || "自助推广"} - + 购买广告位

@@ -482,6 +485,8 @@ export function AdsPanel({ target="_blank" rel="noopener noreferrer sponsored" className="j13-ad-image-link" + data-tip={adHoverTip(ad.ends_at)} + data-tip-delay={AD_TIP_DELAY_MS} > {/* eslint-disable-next-line @next/next/no-img-element */} ) : ( diff --git a/frontend/components/post/PostRailBlocks.tsx b/frontend/components/post/PostRailBlocks.tsx index f8c6139..429f6a4 100644 --- a/frontend/components/post/PostRailBlocks.tsx +++ b/frontend/components/post/PostRailBlocks.tsx @@ -5,6 +5,7 @@ import BoardIcon, { boardColor } from "@/components/BoardIcon"; import Avatar from "@/components/Avatar"; import RoleBadge from "@/components/RoleBadge"; import LevelBadge from "@/components/LevelBadge"; +import BadgeChip from "@/components/BadgeChip"; // formatYMD 帖子详情页左栏「作者其他帖」日期 function formatYMD(dateStr: string) { @@ -56,9 +57,14 @@ export function BoardCard({ board }: { board: Board }) { export function AuthorRailCard({ profile, allowComments, + boardNames, + boardColors, }: { profile: UserProfile; allowComments: boolean; + /** 板块 id → 名称/配色:板块管理员徽章展示「{板块名}板块管理员」并对齐板块色 */ + boardNames?: Record; + boardColors?: Record; }) { const u = profile.user; const signature = (u.signature || "").trim(); @@ -89,13 +95,23 @@ export function AuthorRailCard({

@{u.username}

- {u.role !== "user" && ( -
- -
- )}
+ {/* 徽章行:站点角色 + 管理员颁布徽章统一收拢一处,悬浮显示颁布时间;窄栏最多 4 枚 */} + {(u.role !== "user" || (u.badges ?? []).length > 0) && ( +
+ {u.role !== "user" && ( + + )} + {(u.badges ?? []).slice(0, 4).map((ub) => ( + + ))} +
+ )} {/* 有签名则展示;无则留空,不放占位文案 */} {signature ? (

) : null} + {/* 数据面板:圆角浅底 + 竖线分隔(参考作者卡布局,配色走站点令牌) */}

{stats.map((s, i) => (
0 ? { borderLeft: "1px solid var(--line)" } : undefined} >

{s.value} @@ -128,12 +147,17 @@ export function AuthorRailCard({

))}
+ {/* 连签胶囊:金色调软底,与站点 gold 令牌一致 */} {streak > 0 && (

- 连签 {streak} 天 + 连签 {streak} 天

)} diff --git a/frontend/components/sidebar/registry.tsx b/frontend/components/sidebar/registry.tsx index 813393f..17c7e23 100644 --- a/frontend/components/sidebar/registry.tsx +++ b/frontend/components/sidebar/registry.tsx @@ -78,6 +78,9 @@ export interface SidebarBlockProps { authorProfile?: UserProfile; authorPosts?: PostListItem[]; toc?: TocItem[]; + // 板块 id → 名称/配色下标:板块管理员徽章展示「{板块名}板块管理员」并对齐板块色 + boardNames?: Record; + boardColors?: Record; } // 内置块注册表:key → 组件。数据未就绪或未知 key 返回 null(渲染层静默跳过)。 @@ -143,7 +146,12 @@ export function renderSidebarBlock(key: string, p: SidebarBlockProps) { return p.postBoard ? : null; case "post_author": return p.authorProfile ? ( - + ) : null; case "post_author_posts": return p.authorPosts ? : null; diff --git a/frontend/lib/ads.ts b/frontend/lib/ads.ts index 0d2eeb6..93116c0 100644 --- a/frontend/lib/ads.ts +++ b/frontend/lib/ads.ts @@ -6,3 +6,22 @@ export const AD_TEXT_COL_WIDTH = 121; /** 购买页建议文案 */ export const AD_IMAGE_SIZE_LABEL = `${AD_IMAGE_WIDTH}×${AD_IMAGE_HEIGHT}`; export const AD_IMAGE_SIZE_LABEL_2X = `${AD_IMAGE_WIDTH * 2}×${AD_IMAGE_HEIGHT * 2}`; + +/** 广告悬停提示第二行:统一免责声明 */ +export const AD_TIP_DISCLAIMER = "斟酌选择,本站不做担保。"; +/** 广告悬停提示延迟弹出时长(毫秒):停留一会再显示 */ +export const AD_TIP_DELAY_MS = 600; + +/** 到期时间展示格式:2026-10-01 23:59 */ +export function formatAdEndsAt(value: string): string { + const d = new Date(value); + if (Number.isNaN(d.getTime())) return ""; + const p = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; +} + +/** 广告悬停提示:首行到期时间(可选),第二行免责声明(\n 由气泡 pre-line 换行) */ +export function adHoverTip(endsAt?: string | null): string { + const t = endsAt ? formatAdEndsAt(endsAt) : ""; + return t ? `到期时间:${t}\n${AD_TIP_DISCLAIMER}` : AD_TIP_DISCLAIMER; +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 49fda3b..a0f5da7 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -295,6 +295,7 @@ export interface PublicAdItem { title?: string; text_color?: string; bg_color?: string; + ends_at?: string | null; } export interface PublicSponsorItem { @@ -392,6 +393,8 @@ export interface Post { view_count: number; comment_count: number; liked: boolean; + /** 详情接口返回:当前查看者是否已收藏 */ + favorited?: boolean; /** 详情接口返回:是否存在编辑历史快照(列表接口无此字段) */ edited?: boolean; created_at: string; @@ -584,12 +587,18 @@ export interface UserProfile { total_points?: number; // 徽章墙(全量,含授予时间) badges?: UserBadgeView[]; + /** 访客(登录态)与该用户的关注关系;本人访问恒 false */ + is_following?: boolean; + /** 仅本人访问时下发的打码邮箱(j***@163.com) */ + email?: string; }; stats: { post_count: number; comment_count: number; points: number; streak: number; + favorite_count: number; + following_count: number; }; posts: PostListItem[]; posts_total: number; @@ -612,11 +621,34 @@ export interface UserCommentsResponse { size: number; } +export interface UserFavoritesResponse { + posts: PostListItem[]; + total: number; + page: number; + size: number; +} + +export interface UserFollowingItem { + id: number; + username: string; + nickname: string; + avatar: string; + level?: number; + signature?: string; +} + +export interface UserFollowingResponse { + users: UserFollowingItem[]; + total: number; + page: number; + size: number; +} + export interface NotificationItem { id: number; user_id: number; actor_id: number; - type: "comment" | "reply" | "like" | "approved" | "rejected" | "mention" | "pending_review" | "deleted" | "badge" | "necro_reply"; + type: "comment" | "reply" | "like" | "approved" | "rejected" | "mention" | "pending_review" | "deleted" | "badge" | "necro_reply" | "follow"; post_id: number; comment_id: number; room_id?: number; @@ -1286,8 +1318,25 @@ export async function fetchComments( return res.json(); } -export async function fetchUserProfile(id: string, page = 1, cookieHeader?: string): Promise { - const params = new URLSearchParams({ page: String(page), size: "20" }); +// 评论定位:返回楼层号(与楼层分页口径一致),0 = 不存在。 +// 通知/主页评论深链 #comment-{id} 跨页时用它换到正确页码。 +export async function fetchCommentLocation( + postId: string | number, + commentId: number +): Promise { + const res = await fetch(`${API_BASE}/api/posts/${postId}/comments/${commentId}`); + if (!res.ok) return 0; + const data = (await res.json()) as { floor?: number }; + return data.floor || 0; +} + +export async function fetchUserProfile( + id: string, + page = 1, + cookieHeader?: string, + sort: string = "new" +): Promise { + const params = new URLSearchParams({ page: String(page), size: "20", sort }); const res = await fetch(`${API_BASE}/api/users/${id}?${params}`, ssrInit(cookieHeaders(cookieHeader))); if (res.status === 404) throw new Error("用户不存在"); if (!res.ok) throw new Error("获取用户资料失败"); @@ -1307,9 +1356,11 @@ export async function fetchMe(cookieHeader?: string): Promise { } } -export async function fetchUserComments(id: string, page = 1): Promise { +// cookieHeader:SSR 时必须转发访问者 cookie——后端开启「评论需登录」时, +// 匿名请求会拿到空列表(表现为所有人主页回帖都显示暂无评论) +export async function fetchUserComments(id: string, page = 1, cookieHeader?: string): Promise { const params = new URLSearchParams({ page: String(page), size: "20" }); - const res = await fetch(`${API_BASE}/api/users/${id}/comments?${params}`, ssrInit()); + const res = await fetch(`${API_BASE}/api/users/${id}/comments?${params}`, ssrInit(cookieHeaders(cookieHeader))); if (res.status === 404) throw new Error("用户不存在"); if (!res.ok) throw new Error("获取评论失败"); const data = (await res.json()) as UserCommentsResponse; @@ -1317,6 +1368,24 @@ export async function fetchUserComments(id: string, page = 1): Promise { + const params = new URLSearchParams({ page: String(page), size: "20" }); + const res = await fetch(`${API_BASE}/api/users/${id}/favorites?${params}`, ssrInit()); + if (res.status === 404) throw new Error("用户不存在"); + if (!res.ok) throw new Error("获取收藏失败"); + const data = (await res.json()) as UserFavoritesResponse; + return { ...data, posts: data.posts ?? [] }; +} + +export async function fetchUserFollowing(id: string, page = 1): Promise { + const params = new URLSearchParams({ page: String(page), size: "20" }); + const res = await fetch(`${API_BASE}/api/users/${id}/following?${params}`, ssrInit()); + if (res.status === 404) throw new Error("用户不存在"); + if (!res.ok) throw new Error("获取关注列表失败"); + const data = (await res.json()) as UserFollowingResponse; + return { ...data, users: data.users ?? [] }; +} + // SSR 直出通知列表(仅登录用户;401 由页面层转为登录引导) export async function fetchNotifications(page = 1, cookieHeader?: string): Promise { const params = new URLSearchParams({ page: String(page), size: "20" }); @@ -2242,6 +2311,22 @@ export async function apiToggleLike(postId: string) { return res.json(); } +export async function apiToggleFavorite(postId: string) { + const res = await fetchWithRefresh(`/api/posts/${postId}/favorite`, { + method: "POST", + headers: clientHeaders(), + }); + return res.json(); +} + +export async function apiToggleFollow(userId: string | number) { + const res = await fetchWithRefresh(`/api/users/${userId}/follow`, { + method: "POST", + headers: clientHeaders(), + }); + return res.json(); +} + export async function apiDeleteComment( postId: string, commentId: number, diff --git a/frontend/lib/flash.ts b/frontend/lib/flash.ts new file mode 100644 index 0000000..3ecdc93 --- /dev/null +++ b/frontend/lib/flash.ts @@ -0,0 +1,34 @@ +/** + * 跳转目标高亮:目标区域先铺主题色浅底(--accent-soft)再渐隐。 + * 供评论深链定位与文章目录树跳转共用;样式见 globals.css 的 .j13-flash-target。 + */ +import { scrollBehavior } from "@/lib/motion"; + +const FLASH_MS = 1800; +const CLEANUP_MS = FLASH_MS + 300; + +const timers = new WeakMap>(); + +export function flashElement(el: HTMLElement | null | undefined) { + if (!el) return; + el.classList.remove("j13-flash-target"); + // 强制重排,支持连续触发同一元素 + void el.offsetWidth; + el.classList.add("j13-flash-target"); + const prev = timers.get(el); + if (prev) clearTimeout(prev); + timers.set( + el, + setTimeout(() => { + el.classList.remove("j13-flash-target"); + timers.delete(el); + }, CLEANUP_MS) + ); +} + +/** 滚动到目标(尊重平滑滚动开关与 scroll-margin)并高亮渐隐 */ +export function scrollFlashTo(el: HTMLElement | null | undefined, block: ScrollLogicalPosition = "start") { + if (!el) return; + el.scrollIntoView({ behavior: scrollBehavior(), block }); + flashElement(el); +} diff --git a/frontend/lib/serverData.ts b/frontend/lib/serverData.ts index 332b6e0..e8ddf8d 100644 --- a/frontend/lib/serverData.ts +++ b/frontend/lib/serverData.ts @@ -1,5 +1,5 @@ import { cache } from "react"; -import { fetchMe, fetchPublicSettings, fetchSiteState } from "./api"; +import { fetchBoards, fetchMe, fetchPublicSettings, fetchSiteState } from "./api"; // 同一 SSR 请求内去重:layout 与页面可能同时需要当前用户, // 相同 Cookie 头参数下只产生一次 /api/me 调用。 @@ -10,3 +10,6 @@ export const getPublicSettingsCached = cache(() => fetchPublicSettings()); // 同请求内去重:注册/找回密码页读取开放状态与提示文案 export const getSiteStateCached = cache(() => fetchSiteState()); + +// 同请求内去重:帖子详情/用户主页渲染板块管理员徽章需板块名与配色 +export const getBoardsCached = cache(() => fetchBoards());