feat: 实现关注系统 + 通知侧边栏分类 TAB + 点赞聚合通知

- 新增关注系统:UserFollow 模型、FollowService(toggle/status/列表隐私控制)
- User 新增 FollowersCount/FollowingCount/FollowListPublic/NotifyPrefs 字段
- Space 页面增加四态关注按钮(关注/已关注/回关/已互粉)+ 粉丝/关注数链接
- 新增 /space/:uid/followers 和 /space/:uid/following 列表页
- 新增 NotifyFollow/NotifyLikeAggregated 通知类型
- ReactionService 点赞时写入 daily_like_summary,访问时生成聚合通知
- FollowService 关注时触发 NotifyFollow 通知
- 消息中心侧边栏升级为分类 TAB(全部/系统通知/@艾特/点赞/关注)
- NotificationService 新增 ListByCategory 按分类分页查询
This commit is contained in:
2026-06-01 16:30:46 +08:00
parent 2b47380ac5
commit 680df7371a
26 changed files with 1251 additions and 23 deletions

View File

@ -27,7 +27,7 @@ func main() {
if err != nil {
log.Fatalf("连接数据库失败: %v", err)
}
if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}, &model.CommunityFund{}, &model.FundLog{}, &model.PostLike{}, &model.PostDislike{}); err != nil {
if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}, &model.CommunityFund{}, &model.FundLog{}, &model.PostLike{}, &model.PostDislike{}, &model.UserFollow{}, &model.DailyLikeSummary{}); err != nil {
log.Fatalf("数据库迁移失败: %v", err)
}

View File

@ -0,0 +1,188 @@
package controller
import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"github.com/gin-gonic/gin"
)
// FollowController 关注 API 控制器
type FollowController struct {
followSvc followUseCase
}
// NewFollowController 构造函数
func NewFollowController(followSvc followUseCase) *FollowController {
return &FollowController{followSvc: followSvc}
}
// Toggle 切换关注 POST /api/users/:uid/follow
func (fc *FollowController) Toggle(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
}
isFollowing, err := fc.followSvc.Toggle(uid, uint(targetUID))
if err != nil {
if err.Error() == "不能关注自己" {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
common.Error(c, http.StatusInternalServerError, "操作失败")
return
}
// 获取最新状态
status, _ := fc.followSvc.GetStatus(uid, uint(targetUID))
common.Ok(c, gin.H{
"is_following": isFollowing,
"status": status,
})
}
// GetStatus 查询关注关系 GET /api/users/:uid/follow-status
func (fc *FollowController) GetStatus(c *gin.Context) {
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
}
uid, _, _ := common.GetGinUser(c) // 允许未登录(返回全是 false
status, err := fc.followSvc.GetStatus(uid, uint(targetUID))
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
}
common.Ok(c, status)
}
// Followers 粉丝列表 GET /api/users/:uid/followers
func (fc *FollowController) Followers(c *gin.Context) {
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
uid, _, _ := common.GetGinUser(c) // 允许未登录
result, err := fc.followSvc.ListFollowers(uint(targetUID), uid, page, pageSize)
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
}
common.Ok(c, result)
}
// Following 关注列表 GET /api/users/:uid/following
func (fc *FollowController) Following(c *gin.Context) {
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
uid, _, _ := common.GetGinUser(c)
result, err := fc.followSvc.ListFollowing(uint(targetUID), uid, page, pageSize)
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
}
common.Ok(c, result)
}
// FollowPageController 关注/粉丝页面控制器
type FollowPageController struct {
followSvc followUseCase
}
// NewFollowPageController 构造函数
func NewFollowPageController(followSvc followUseCase) *FollowPageController {
return &FollowPageController{followSvc: followSvc}
}
// FollowersPage 粉丝列表页面
func (fpc *FollowPageController) FollowersPage(c *gin.Context) {
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
uid, _, _ := common.GetGinUser(c)
result, err := fpc.followSvc.ListFollowers(uint(targetUID), uid, page, 20)
if err != nil {
result = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0}
}
c.HTML(http.StatusOK, "follow/followers.html", common.BuildPageData(c, gin.H{
"Title": "粉丝列表",
"ExtraCSS": "/static/css/follow.css",
"Result": result,
"TargetUID": targetUID,
"Page": result.Page,
"TotalPages": result.TotalPages,
"HasPrev": result.Page > 1,
"HasNext": result.Page < result.TotalPages,
"PrevPage": result.Page - 1,
"NextPage": result.Page + 1,
}))
}
// FollowingPage 关注列表页面
func (fpc *FollowPageController) FollowingPage(c *gin.Context) {
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
uid, _, _ := common.GetGinUser(c)
result, err := fpc.followSvc.ListFollowing(uint(targetUID), uid, page, 20)
if err != nil {
result = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0}
}
c.HTML(http.StatusOK, "follow/following.html", common.BuildPageData(c, gin.H{
"Title": "关注列表",
"ExtraCSS": "/static/css/follow.css",
"Result": result,
"TargetUID": targetUID,
"Page": result.Page,
"TotalPages": result.TotalPages,
"HasPrev": result.Page > 1,
"HasNext": result.Page < result.TotalPages,
"PrevPage": result.Page - 1,
"NextPage": result.Page + 1,
}))
}

View File

@ -111,3 +111,11 @@ type reactionUseCase interface {
GetReaction(userID, postID uint) (service.ReactionType, error)
GetPostLikesCount(postID uint) (int, error)
}
// followUseCase FollowController 对 FollowService 的最小依赖ISP4 个方法)
type followUseCase interface {
Toggle(followerID, followeeID uint) (bool, error)
GetStatus(currentUserID, targetUserID uint) (*service.FollowStatus, error)
ListFollowers(userID, currentUserID uint, page, pageSize int) (*service.FollowListResult, error)
ListFollowing(userID, currentUserID uint, page, pageSize int) (*service.FollowListResult, error)
}

View File

@ -10,9 +10,10 @@ import (
"github.com/gin-gonic/gin"
)
// notifProvider MessageController 所需的通知服务接口ISP4 个方法)
// notifProvider MessageController 所需的通知服务接口ISP5 个方法)
type notifProvider interface {
List(userID uint, page, pageSize int) (*model.NotificationListResult, error)
ListByCategory(userID uint, category string, page, pageSize int) (*model.NotificationListResult, error)
CountUnread(userID uint) (int64, error)
MarkRead(id, userID uint) error
MarkAllRead(userID uint) error

View File

@ -10,7 +10,7 @@ import (
"github.com/gin-gonic/gin"
)
// MessagesPage 消息中心页面需登录noindex
// MessagesPage 消息中心页面需登录noindex,支持分类 TAB
func (mc *MessageController) MessagesPage(c *gin.Context) {
uidVal, exists := c.Get("uid")
if !exists {
@ -19,6 +19,8 @@ func (mc *MessageController) MessagesPage(c *gin.Context) {
}
uid := uidVal.(uint)
tab := c.DefaultQuery("tab", "all")
// 从 query 取分页参数
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
@ -26,7 +28,15 @@ func (mc *MessageController) MessagesPage(c *gin.Context) {
}
pageSize := 20
result, err := mc.notifService.List(uid, page, pageSize)
var result *model.NotificationListResult
var err error
if tab == "all" {
result, err = mc.notifService.List(uid, page, pageSize)
} else {
result, err = mc.notifService.ListByCategory(uid, tab, page, pageSize)
}
if err != nil {
result = &model.NotificationListResult{Items: []model.Notification{}, Total: 0, Page: 1}
}
@ -50,5 +60,6 @@ func (mc *MessageController) MessagesPage(c *gin.Context) {
"UnreadCount": result.Unread,
"NotifyTypeNames": model.NotifyTypeNames,
"AuditTypeNames": model.AuditTypeNames,
"CurrentTab": tab,
}))
}

View File

@ -0,0 +1,14 @@
package model
import "time"
// DailyLikeSummary 每日点赞汇聚(用于聚合点赞通知)
type DailyLikeSummary struct {
ID uint `gorm:"primarykey" json:"id"`
AuthorUID uint `gorm:"uniqueIndex:idx_author_date;not null" json:"author_uid"` // 被点赞文章的作者
Date string `gorm:"type:varchar(10);uniqueIndex:idx_author_date;not null" json:"date"` // 日期 YYYY-MM-DDAsia/Shanghai
LikerUIDs string `gorm:"type:text" json:"liker_uids"` // 点赞者 UID 列表(追加式,逗号分隔)
Notified bool `gorm:"default:false" json:"notified"` // 是否已发送聚合通知
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@ -9,6 +9,8 @@ const (
NotifyLevelUp = "level_up"
NotifyComment = "comment"
NotifyCommentReply = "comment_reply"
NotifyLikeAggregated = "like_aggregated" // 每日聚合点赞通知
NotifyFollow = "follow" // 关注通知
)
// Notification 消息/通知模型
@ -33,6 +35,21 @@ var NotifyTypeNames = map[string]string{
NotifyLevelUp: "等级提升",
NotifyComment: "评论",
NotifyCommentReply: "回复",
NotifyLikeAggregated: "点赞",
NotifyFollow: "关注",
}
// NotifyCategory 通知类型所属分类 TAB
var NotifyCategory = map[string]string{
NotifyAuditApproved: "system",
NotifyAuditRejected: "system",
NotifyPostApproved: "system",
NotifyPostRejected: "system",
NotifyLevelUp: "system",
NotifyComment: "mention",
NotifyCommentReply: "mention",
NotifyLikeAggregated: "like",
NotifyFollow: "follow",
}
// NotificationListResult 消息列表查询结果

View File

@ -19,6 +19,14 @@ type User struct {
Exp int `gorm:"default:0" json:"exp"` // 经验值(只增不减)
Energy int `gorm:"default:0" json:"energy"` // 域能余额可为负数乘10存储
// 关注系统
FollowersCount int `gorm:"default:0" json:"followers_count"` // 粉丝数(冗余计数器)
FollowingCount int `gorm:"default:0" json:"following_count"` // 关注数(冗余计数器)
FollowListPublic bool `gorm:"default:true" json:"follow_list_public"` // 关注/粉丝列表是否公开
// 通知偏好 (JSON string)
NotifyPrefs string `gorm:"type:text;default:'{\"comment\":true,\"comment_reply\":true,\"like_aggregated\":true,\"follow\":true}'" json:"-"`
// 安全与审计
RegIP string `gorm:"type:varchar(45);default:''" json:"-"` // 注册 IP
LastLoginIP string `gorm:"type:varchar(45);default:''" json:"-"` // 最后登录 IP

View File

@ -0,0 +1,14 @@
package model
import "time"
// UserFollow 关注关系
type UserFollow struct {
FollowerID uint `gorm:"primaryKey;not null" json:"follower_id"`
FolloweeID uint `gorm:"primaryKey;not null" json:"followee_id"`
CreatedAt time.Time `json:"created_at"`
// 联表查询填充(非数据库字段)
FollowerUsername string `gorm:"-:migration;<-:false;column:follower_username" json:"follower_username,omitempty"`
FolloweeUsername string `gorm:"-:migration;<-:false;column:followee_username" json:"followee_username,omitempty"`
}

View File

@ -0,0 +1,112 @@
package repository
import (
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"gorm.io/gorm"
)
// FollowRepo 关注数据访问
type FollowRepo struct {
db *gorm.DB
}
// NewFollowRepo 构造函数
func NewFollowRepo(db *gorm.DB) *FollowRepo {
return &FollowRepo{db: db}
}
// WithTx 基于事务连接创建 FollowRepo
func (r *FollowRepo) WithTx(tx *gorm.DB) service.FollowStore {
return &FollowRepo{db: tx}
}
// Create 创建关注
func (r *FollowRepo) Create(follow *model.UserFollow) error {
return r.db.Create(follow).Error
}
// Delete 取消关注
func (r *FollowRepo) Delete(followerID, followeeID uint) error {
return r.db.Where("follower_id = ? AND followee_id = ?", followerID, followeeID).
Delete(&model.UserFollow{}).Error
}
// Exists 检查是否已关注
func (r *FollowRepo) Exists(followerID, followeeID uint) (bool, error) {
var count int64
err := r.db.Model(&model.UserFollow{}).
Where("follower_id = ? AND followee_id = ?", followerID, followeeID).
Count(&count).Error
return count > 0, err
}
// CountFollowers 粉丝数
func (r *FollowRepo) CountFollowers(userID uint) (int64, error) {
var count int64
err := r.db.Model(&model.UserFollow{}).Where("followee_id = ?", userID).Count(&count).Error
return count, err
}
// CountFollowing 关注数
func (r *FollowRepo) CountFollowing(userID uint) (int64, error) {
var count int64
err := r.db.Model(&model.UserFollow{}).Where("follower_id = ?", userID).Count(&count).Error
return count, err
}
// ListFollowers 粉丝列表分页JOIN users 获取用户名)
func (r *FollowRepo) ListFollowers(userID uint, offset, limit int) ([]model.UserFollow, int64, error) {
var total int64
if err := r.db.Model(&model.UserFollow{}).Where("followee_id = ?", userID).Count(&total).Error; err != nil {
return nil, 0, err
}
var items []model.UserFollow
err := r.db.Table("user_follows").
Select("user_follows.*, u.username AS follower_username").
Joins("INNER JOIN users u ON u.id = user_follows.follower_id").
Where("user_follows.followee_id = ?", userID).
Order("user_follows.created_at DESC").
Offset(offset).Limit(limit).
Scan(&items).Error
return items, total, err
}
// ListFollowing 关注列表分页JOIN users 获取用户名)
func (r *FollowRepo) ListFollowing(userID uint, offset, limit int) ([]model.UserFollow, int64, error) {
var total int64
if err := r.db.Model(&model.UserFollow{}).Where("follower_id = ?", userID).Count(&total).Error; err != nil {
return nil, 0, err
}
var items []model.UserFollow
err := r.db.Table("user_follows").
Select("user_follows.*, u.username AS followee_username").
Joins("INNER JOIN users u ON u.id = user_follows.followee_id").
Where("user_follows.follower_id = ?", userID).
Order("user_follows.created_at DESC").
Offset(offset).Limit(limit).
Scan(&items).Error
return items, total, err
}
// IncrFollowersCount 原子增减粉丝数
func (r *FollowRepo) IncrFollowersCount(userID uint, delta int) error {
return r.db.Model(&model.User{}).
Where("id = ?", userID).
UpdateColumn("followers_count", gorm.Expr("followers_count + ?", delta)).Error
}
// IncrFollowingCount 原子增减关注数
func (r *FollowRepo) IncrFollowingCount(userID uint, delta int) error {
return r.db.Model(&model.User{}).
Where("id = ?", userID).
UpdateColumn("following_count", gorm.Expr("following_count + ?", delta)).Error
}
// GetFollowListPublic 查询用户关注列表公开性
func (r *FollowRepo) GetFollowListPublic(userID uint) (bool, error) {
var user model.User
err := r.db.Select("follow_list_public").First(&user, userID).Error
return user.FollowListPublic, err
}

View File

@ -60,3 +60,48 @@ func (r *NotificationRepo) MarkAllRead(userID uint) error {
Where("user_id = ? AND is_read = false", userID).
Update("is_read", true).Error
}
// ListByUserAndType 按类型分页查询用户消息
func (r *NotificationRepo) ListByUserAndType(userID uint, notifyType string, offset, limit int) ([]model.Notification, error) {
var list []model.Notification
err := r.db.Where("user_id = ? AND notify_type = ?", userID, notifyType).
Order("created_at DESC").
Offset(offset).Limit(limit).
Find(&list).Error
return list, err
}
// CountByUserAndType 按类型统计用户消息数
func (r *NotificationRepo) CountByUserAndType(userID uint, notifyType string) (int64, error) {
var count int64
err := r.db.Model(&model.Notification{}).Where("user_id = ? AND notify_type = ?", userID, notifyType).Count(&count).Error
return count, err
}
// CountUnreadByType 按类型统计用户未读消息数
func (r *NotificationRepo) CountUnreadByType(userID uint, notifyType string) (int64, error) {
var count int64
err := r.db.Model(&model.Notification{}).
Where("user_id = ? AND is_read = false AND notify_type = ?", userID, notifyType).
Count(&count).Error
return count, err
}
// GetUnnotifiedDailyLikeSummaries 获取未通知的每日点赞汇总
func (r *NotificationRepo) GetUnnotifiedDailyLikeSummaries(userID uint) ([]model.DailyLikeSummary, error) {
var summaries []model.DailyLikeSummary
err := r.db.Where("author_uid = ? AND notified = ?", userID, false).Find(&summaries).Error
return summaries, err
}
// MarkDailyLikeSummaryNotified 标记汇总已通知
func (r *NotificationRepo) MarkDailyLikeSummaryNotified(id uint) error {
return r.db.Model(&model.DailyLikeSummary{}).Where("id = ?", id).Update("notified", true).Error
}
// GetUsernameByID 根据用户ID获取用户名
func (r *NotificationRepo) GetUsernameByID(userID uint) (string, error) {
var user model.User
err := r.db.Select("username").First(&user, userID).Error
return user.Username, err
}

View File

@ -75,3 +75,47 @@ func (r *ReactionRepo) IncrPostDislikes(postID uint, delta int) error {
Where("id = ?", postID).
UpdateColumn("dislikes_count", gorm.Expr("dislikes_count + ?", delta)).Error
}
// GetPostAuthorID 查询文章作者ID
func (r *ReactionRepo) GetPostAuthorID(postID uint) (uint, error) {
var post model.Post
err := r.db.Select("user_id").First(&post, postID).Error
return post.UserID, err
}
// UpsertDailyLikeSummary 追加或创建每日点赞汇总
func (r *ReactionRepo) UpsertDailyLikeSummary(authorUID uint, date string, likerUID string) error {
var existing model.DailyLikeSummary
err := r.db.Where("author_uid = ? AND date = ?", authorUID, date).First(&existing).Error
if err == gorm.ErrRecordNotFound {
return r.db.Create(&model.DailyLikeSummary{
AuthorUID: authorUID,
Date: date,
LikerUIDs: likerUID,
Notified: false,
}).Error
}
if err != nil {
return err
}
// 追加 likerUID
newUids := existing.LikerUIDs
if newUids == "" {
newUids = likerUID
} else {
newUids = newUids + "," + likerUID
}
return r.db.Model(&existing).Update("liker_uids", newUids).Error
}
// GetUnnotifiedSummaries 查询未通知的每日点赞汇总
func (r *ReactionRepo) GetUnnotifiedSummaries(userID uint) ([]model.DailyLikeSummary, error) {
var summaries []model.DailyLikeSummary
err := r.db.Where("author_uid = ? AND notified = ?", userID, false).Find(&summaries).Error
return summaries, err
}
// MarkSummaryNotified 标记汇总已通知
func (r *ReactionRepo) MarkSummaryNotified(id uint) error {
return r.db.Model(&model.DailyLikeSummary{}).Where("id = ?", id).Update("notified", true).Error
}

View File

@ -131,4 +131,18 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
reactionAPI.POST("/like", d.reactionCtrl.ToggleLike)
reactionAPI.POST("/dislike", d.reactionCtrl.ToggleDislike)
}
// --- 关注 API ---
// 公开(未登录返回 false
r.GET("/api/users/:uid/follow-status", d.followCtrl.GetStatus)
r.GET("/api/users/:uid/followers", d.followCtrl.Followers)
r.GET("/api/users/:uid/following", d.followCtrl.Following)
// 需登录:切换关注
followAPI := r.Group("/api/users/:uid")
followAPI.Use(d.authMdw.Required())
followAPI.Use(d.authMdw.BannedWriteGuard())
followAPI.Use(middleware.CSRF(cfg))
{
followAPI.POST("/follow", d.followCtrl.Toggle)
}
}

View File

@ -38,6 +38,8 @@ type dependencies struct {
commentService *service.CommentService
adminCommentCtrl *adminCtrl.AdminCommentController
reactionCtrl *controller.ReactionController
followCtrl *controller.FollowController
followPageCtrl *controller.FollowPageController
}
func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) (

View File

@ -74,6 +74,13 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
reactionService := service.NewReactionService(db, reactionRepo)
reactionCtrl := controller.NewReactionController(reactionService)
// 关注系统
followRepo := repository.NewFollowRepo(db)
followService := service.NewFollowService(db, followRepo)
followService.SetNotifier(notificationService) // 关注通知
followCtrl := controller.NewFollowController(followService)
followPageCtrl := controller.NewFollowPageController(followService)
// 用户空间
spaceService := service.NewSpaceService(userRepo, postRepo)
spaceCtrl := controller.NewSpaceController(spaceService)
@ -96,5 +103,7 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
commentService: commentService,
adminCommentCtrl: adminCommentCtrl,
reactionCtrl: reactionCtrl,
followCtrl: followCtrl,
followPageCtrl: followPageCtrl,
}
}

View File

@ -45,6 +45,10 @@ func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
pages.GET("/space", d.spaceCtrl.MySpace)
pages.GET("/space/:uid", d.spaceCtrl.ShowSpace)
// 关注/粉丝列表页
pages.GET("/space/:uid/followers", d.followPageCtrl.FollowersPage)
pages.GET("/space/:uid/following", d.followPageCtrl.FollowingPage)
// 帖子页面
pages.GET("/posts", d.postCtrl.ListPage)
pages.GET("/posts/:id", d.postCtrl.ShowPage)

View File

@ -0,0 +1,213 @@
package service
import (
"fmt"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"gorm.io/gorm"
)
// FollowStatus 关注关系状态
type FollowStatus struct {
IFollow bool `json:"i_follow"`
TheyFollow bool `json:"they_follow"`
}
// FollowListResult 关注/粉丝列表结果
type FollowListResult struct {
Items []model.UserFollow `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
TotalPages int `json:"total_pages"`
ListPublic bool `json:"list_public"`
Accessible bool `json:"accessible"`
}
// followNotifier 关注通知接口ISP
type followNotifier interface {
NotifyFollow(followerID, followeeID uint)
}
// FollowService 关注业务逻辑
type FollowService struct {
db *gorm.DB
repo FollowStore
notifier followNotifier
}
// NewFollowService 构造函数
func NewFollowService(db *gorm.DB, repo FollowStore) *FollowService {
return &FollowService{db: db, repo: repo}
}
// SetNotifier 注入通知服务
func (s *FollowService) SetNotifier(n followNotifier) {
s.notifier = n
}
// Toggle 关注/取消关注返回操作后的关注状态true=已关注)
func (s *FollowService) Toggle(followerID, followeeID uint) (bool, error) {
if followerID == followeeID {
return false, fmt.Errorf("不能关注自己")
}
var isFollowing bool
err := s.db.Transaction(func(tx *gorm.DB) error {
txRepo := s.repo.WithTx(tx)
exists, err := txRepo.Exists(followerID, followeeID)
if err != nil {
return fmt.Errorf("查询关注状态失败: %w", err)
}
if exists {
if err := txRepo.Delete(followerID, followeeID); err != nil {
return fmt.Errorf("取消关注失败: %w", err)
}
if err := txRepo.IncrFollowingCount(followerID, -1); err != nil {
return fmt.Errorf("更新关注数失败: %w", err)
}
if err := txRepo.IncrFollowersCount(followeeID, -1); err != nil {
return fmt.Errorf("更新粉丝数失败: %w", err)
}
isFollowing = false
} else {
follow := &model.UserFollow{
FollowerID: followerID,
FolloweeID: followeeID,
}
if err := txRepo.Create(follow); err != nil {
return fmt.Errorf("关注失败: %w", err)
}
if err := txRepo.IncrFollowingCount(followerID, 1); err != nil {
return fmt.Errorf("更新关注数失败: %w", err)
}
if err := txRepo.IncrFollowersCount(followeeID, 1); err != nil {
return fmt.Errorf("更新粉丝数失败: %w", err)
}
isFollowing = true
}
return nil
})
if err == nil && isFollowing {
s.sendFollowNotification(followerID, followeeID)
}
return isFollowing, err
}
// sendFollowNotification 发送关注通知(非关键路径)
func (s *FollowService) sendFollowNotification(followerID, followeeID uint) {
if s.notifier != nil && followerID != followeeID {
s.notifier.NotifyFollow(followerID, followeeID)
}
}
// GetStatus 查询当前用户对目标用户的关注关系
func (s *FollowService) GetStatus(currentUserID, targetUserID uint) (*FollowStatus, error) {
if currentUserID == 0 {
return &FollowStatus{}, nil
}
iFollow, err := s.repo.Exists(currentUserID, targetUserID)
if err != nil {
return nil, fmt.Errorf("查询我是否关注: %w", err)
}
theyFollow, err := s.repo.Exists(targetUserID, currentUserID)
if err != nil {
return nil, fmt.Errorf("查询对方是否关注: %w", err)
}
return &FollowStatus{
IFollow: iFollow,
TheyFollow: theyFollow,
}, nil
}
// ListFollowers 粉丝列表(含隐私控制)
func (s *FollowService) ListFollowers(userID, currentUserID uint, page, pageSize int) (*FollowListResult, error) {
listPublic, err := s.repo.GetFollowListPublic(userID)
if err != nil {
return nil, fmt.Errorf("查询公开设置: %w", err)
}
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
accessible := listPublic || currentUserID == userID
var items []model.UserFollow
var total int64
if accessible {
items, total, err = s.repo.ListFollowers(userID, p.Offset(), p.PageSize)
if err != nil {
return nil, fmt.Errorf("查询粉丝列表: %w", err)
}
} else {
total, err = s.repo.CountFollowers(userID)
if err != nil {
return nil, fmt.Errorf("查询粉丝数: %w", err)
}
items = []model.UserFollow{}
}
if items == nil {
items = []model.UserFollow{}
}
return &FollowListResult{
Items: items,
Total: total,
Page: p.Page,
TotalPages: common.PageCount(total, p.PageSize),
ListPublic: listPublic,
Accessible: accessible,
}, nil
}
// ListFollowing 关注列表(含隐私控制)
func (s *FollowService) ListFollowing(userID, currentUserID uint, page, pageSize int) (*FollowListResult, error) {
listPublic, err := s.repo.GetFollowListPublic(userID)
if err != nil {
return nil, fmt.Errorf("查询公开设置: %w", err)
}
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
accessible := listPublic || currentUserID == userID
var items []model.UserFollow
var total int64
if accessible {
items, total, err = s.repo.ListFollowing(userID, p.Offset(), p.PageSize)
if err != nil {
return nil, fmt.Errorf("查询关注列表: %w", err)
}
} else {
total, err = s.repo.CountFollowing(userID)
if err != nil {
return nil, fmt.Errorf("查询关注数: %w", err)
}
items = []model.UserFollow{}
}
if items == nil {
items = []model.UserFollow{}
}
return &FollowListResult{
Items: items,
Total: total,
Page: p.Page,
TotalPages: common.PageCount(total, p.PageSize),
ListPublic: listPublic,
Accessible: accessible,
}, nil
}

View File

@ -1,6 +1,9 @@
package service
import (
"fmt"
"sort"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
)
@ -9,10 +12,16 @@ import (
type notifRepo interface {
Create(n *model.Notification) error
ListByUser(userID uint, offset, limit int) ([]model.Notification, error)
ListByUserAndType(userID uint, notifyType string, offset, limit int) ([]model.Notification, error)
CountByUser(userID uint) (int64, error)
CountByUserAndType(userID uint, notifyType string) (int64, error)
CountUnread(userID uint) (int64, error)
CountUnreadByType(userID uint, notifyType string) (int64, error)
MarkRead(id, userID uint) error
MarkAllRead(userID uint) error
GetUnnotifiedDailyLikeSummaries(userID uint) ([]model.DailyLikeSummary, error)
MarkDailyLikeSummaryNotified(id uint) error
GetUsernameByID(userID uint) (string, error)
}
// NotificationService 消息/通知业务逻辑
@ -39,8 +48,11 @@ func (s *NotificationService) Create(userID uint, notifyType, title, content str
return s.repo.Create(n)
}
// List 分页查询用户消息列表
// List 分页查询用户消息列表(触发聚合通知检查)
func (s *NotificationService) List(userID uint, page, pageSize int) (*model.NotificationListResult, error) {
// 自动触发点赞聚合通知生成
s.checkAndNotifyAggregation(userID)
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
@ -69,6 +81,123 @@ func (s *NotificationService) List(userID uint, page, pageSize int) (*model.Noti
}, nil
}
// ListByCategory 按通知分类分页查询(供消息页分类 TAB 使用)
func (s *NotificationService) ListByCategory(userID uint, category string, page, pageSize int) (*model.NotificationListResult, error) {
s.checkAndNotifyAggregation(userID)
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
// 分类→通知类型映射
notifyTypes := categoryTypes(category)
var combinedItems []model.Notification
var total int64
for _, nt := range notifyTypes {
c, _ := s.repo.CountByUserAndType(userID, nt)
total += c
}
allItems := make([]model.Notification, 0)
for _, nt := range notifyTypes {
items, err := s.repo.ListByUserAndType(userID, nt, 0, 10000) // 大 limit在内存里分页
if err != nil {
continue
}
allItems = append(allItems, items...)
}
// 按 created_at 降序排序
sortNotifications(allItems)
// 分页
start := p.Offset()
end := start + p.PageSize
if start > len(allItems) {
start = len(allItems)
}
if end > len(allItems) {
end = len(allItems)
}
if start < end {
combinedItems = allItems[start:end]
}
if combinedItems == nil {
combinedItems = []model.Notification{}
}
totalPages := int((total + int64(p.PageSize) - 1) / int64(p.PageSize))
unread, _ := s.CountUnread(userID)
return &model.NotificationListResult{
Items: combinedItems,
Total: total,
Page: p.Page,
TotalPages: totalPages,
Unread: unread,
}, nil
}
// checkAndNotifyAggregation 检查并生成点赞聚合通知
func (s *NotificationService) checkAndNotifyAggregation(userID uint) {
summaries, err := s.repo.GetUnnotifiedDailyLikeSummaries(userID)
if err != nil || len(summaries) == 0 {
return
}
for _, sm := range summaries {
count := countUIDs(sm.LikerUIDs)
var content string
if count <= 3 {
content = fmt.Sprintf("你的文章被 %d 人点赞了", count)
} else {
content = fmt.Sprintf("你的文章被 %d 人点赞了", count)
}
_ = s.Create(userID, model.NotifyLikeAggregated,
"点赞汇总通知",
content,
nil, nil)
_ = s.repo.MarkDailyLikeSummaryNotified(sm.ID)
}
}
func countUIDs(uids string) int {
if uids == "" {
return 0
}
count := 1
for _, c := range uids {
if c == ',' {
count++
}
}
return count
}
// categoryTypes 分类→通知类型列表
func categoryTypes(category string) []string {
switch category {
case "system":
return []string{model.NotifyAuditApproved, model.NotifyAuditRejected, model.NotifyPostApproved, model.NotifyPostRejected, model.NotifyLevelUp}
case "mention":
return []string{model.NotifyComment, model.NotifyCommentReply}
case "like":
return []string{model.NotifyLikeAggregated}
case "follow":
return []string{model.NotifyFollow}
default:
return []string{}
}
}
// sortNotifications 按 CreatedAt 降序排序
func sortNotifications(items []model.Notification) {
sort.Slice(items, func(i, j int) bool {
return items[i].CreatedAt.After(items[j].CreatedAt)
})
}
// CountUnread 统计未读消息数
func (s *NotificationService) CountUnread(userID uint) (int64, error) {
return s.repo.CountUnread(userID)
@ -128,3 +257,25 @@ func (s *NotificationService) NotifyPostRejected(userID uint, postTitle, reason
}
_ = s.Create(userID, model.NotifyPostRejected, title, content, &postID, nil)
}
// NotifyFollow 关注通知followerID 关注了 followeeID
func (s *NotificationService) NotifyFollow(followerID, followeeID uint) {
// 获取关注者用户名
followerName := s.getUsername(followerID)
if followerName == "" {
followerName = "一位用户"
}
title := "关注通知"
content := followerName + " 关注了你"
relID := followerID
_ = s.Create(followeeID, model.NotifyFollow, title, content, &relID, nil)
}
// getUsername 获取用户名(辅助方法)
func (s *NotificationService) getUsername(userID uint) string {
name, err := s.repo.GetUsernameByID(userID)
if err != nil || name == "" {
return "一位用户"
}
return name
}

View File

@ -2,6 +2,7 @@ package service
import (
"fmt"
"time"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
@ -75,9 +76,29 @@ func (s *ReactionService) ToggleLike(userID, postID uint) (bool, error) {
return nil
})
if err == nil && isLiked {
// 新赞 → 记录到每日点赞汇总(非关键路径)
s.recordLikeForAggregation(postID, userID)
}
return isLiked, err
}
// recordLikeForAggregation 记录点赞到每日汇总(非关键路径,失败不阻塞)
func (s *ReactionService) recordLikeForAggregation(postID uint, likerID uint) {
authorID, err := s.repo.GetPostAuthorID(postID)
if err != nil {
return
}
// 不记录自己赞自己
if authorID == likerID {
return
}
date := time.Now().Format("2006-01-02")
likerUID := fmt.Sprintf("%d", likerID)
_ = s.repo.UpsertDailyLikeSummary(authorID, date, likerUID)
}
// ToggleDislike 切换踩(含赞互斥逻辑)
// 返回:当前是否已踩
func (s *ReactionService) ToggleDislike(userID, postID uint) (bool, error) {

View File

@ -164,5 +164,24 @@ type ReactionStore interface {
DeleteDislike(userID, postID uint) error
IncrPostLikes(postID uint, delta int) error
IncrPostDislikes(postID uint, delta int) error
GetPostAuthorID(postID uint) (uint, error)
UpsertDailyLikeSummary(authorUID uint, date string, likerUID string) error
GetUnnotifiedSummaries(userID uint) ([]model.DailyLikeSummary, error)
MarkSummaryNotified(id uint) error
WithTx(tx *gorm.DB) ReactionStore
}
// FollowStore FollowService 所需的最小仓储接口ISP
type FollowStore interface {
Create(follow *model.UserFollow) error
Delete(followerID, followeeID uint) error
Exists(followerID, followeeID uint) (bool, error)
CountFollowers(userID uint) (int64, error)
CountFollowing(userID uint) (int64, error)
ListFollowers(userID uint, offset, limit int) ([]model.UserFollow, int64, error)
ListFollowing(userID uint, offset, limit int) ([]model.UserFollow, int64, error)
IncrFollowersCount(userID uint, delta int) error
IncrFollowingCount(userID uint, delta int) error
GetFollowListPublic(userID uint) (bool, error)
WithTx(tx *gorm.DB) FollowStore
}

View File

@ -0,0 +1,47 @@
{{template "layout/header.html" .}}
<meta name="robots" content="noindex,nofollow">
<body>
{{template "layout/nav.html" .}}
<div class="follow-list-page">
<h1>粉丝列表</h1>
{{if not .Result.Accessible}}
<div class="follow-list-private">
<p>该用户设置了关注列表不公开</p>
</div>
{{else if eq (len .Result.Items) 0}}
<div class="follow-list-empty">
<p>暂无粉丝</p>
</div>
{{else}}
{{range .Result.Items}}
<div class="follow-list-item">
<a href="/space/{{.FollowerID}}" class="follow-list-user">
<div class="follow-list-avatar">
{{slice .FollowerUsername 0 1}}
</div>
<span class="follow-list-name">{{.FollowerUsername}}</span>
</a>
</div>
{{end}}
{{if gt .TotalPages 1}}
<div class="pagination">
{{if .HasPrev}}
<a href="?page={{.PrevPage}}" class="page-link">上一页</a>
{{end}}
<span class="page-info">第 {{.Page}} / {{.TotalPages}} 页</span>
{{if .HasNext}}
<a href="?page={{.NextPage}}" class="page-link">下一页</a>
{{end}}
</div>
{{end}}
{{end}}
</div>
{{template "layout/footer.html" .}}
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
</body>
</html>

View File

@ -0,0 +1,47 @@
{{template "layout/header.html" .}}
<meta name="robots" content="noindex,nofollow">
<body>
{{template "layout/nav.html" .}}
<div class="follow-list-page">
<h1>关注列表</h1>
{{if not .Result.Accessible}}
<div class="follow-list-private">
<p>该用户设置了关注列表不公开</p>
</div>
{{else if eq (len .Result.Items) 0}}
<div class="follow-list-empty">
<p>暂未关注任何人</p>
</div>
{{else}}
{{range .Result.Items}}
<div class="follow-list-item">
<a href="/space/{{.FolloweeID}}" class="follow-list-user">
<div class="follow-list-avatar">
{{slice .FolloweeUsername 0 1}}
</div>
<span class="follow-list-name">{{.FolloweeUsername}}</span>
</a>
</div>
{{end}}
{{if gt .TotalPages 1}}
<div class="pagination">
{{if .HasPrev}}
<a href="?page={{.PrevPage}}" class="page-link">上一页</a>
{{end}}
<span class="page-info">第 {{.Page}} / {{.TotalPages}} 页</span>
{{if .HasNext}}
<a href="?page={{.NextPage}}" class="page-link">下一页</a>
{{end}}
</div>
{{end}}
{{end}}
</div>
{{template "layout/footer.html" .}}
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
</body>
</html>

View File

@ -10,13 +10,29 @@
<h2>消息中心</h2>
</div>
<nav class="msg-sidebar-nav">
<span class="msg-sidebar-item active">
<a href="/messages?tab=all" class="msg-sidebar-item{{if or (eq .CurrentTab "all") (eq .CurrentTab "")}} active{{end}}">
<svg class="msg-sidebar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
全部消息
{{if .UnreadCount}}
<span class="msg-sidebar-count">{{.UnreadCount}}</span>
{{end}}
</span>
</a>
<a href="/messages?tab=system" class="msg-sidebar-item{{if eq .CurrentTab "system"}} active{{end}}">
<svg class="msg-sidebar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
系统通知
</a>
<a href="/messages?tab=mention" class="msg-sidebar-item{{if eq .CurrentTab "mention"}} active{{end}}">
<svg class="msg-sidebar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
@艾特我的
</a>
<a href="/messages?tab=like" class="msg-sidebar-item{{if eq .CurrentTab "like"}} active{{end}}">
<svg class="msg-sidebar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
点赞通知
</a>
<a href="/messages?tab=follow" class="msg-sidebar-item{{if eq .CurrentTab "follow"}} active{{end}}">
<svg class="msg-sidebar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
关注通知
</a>
</nav>
</aside>
@ -78,13 +94,13 @@
{{if .TotalPages}}
<div class="msg-pagination">
{{if .HasPrev}}
<a href="/messages?page={{.PrevPage}}" class="msg-page-btn">上一页</a>
<a href="/messages?tab={{.CurrentTab}}&page={{.PrevPage}}" class="msg-page-btn">上一页</a>
{{else}}
<span class="msg-page-btn disabled">上一页</span>
{{end}}
<span class="msg-page-info">{{.Page}} / {{.TotalPages}}</span>
{{if .HasNext}}
<a href="/messages?page={{.NextPage}}" class="msg-page-btn">下一页</a>
<a href="/messages?tab={{.CurrentTab}}&page={{.NextPage}}" class="msg-page-btn">下一页</a>
{{else}}
<span class="msg-page-btn disabled">下一页</span>
{{end}}

View File

@ -30,6 +30,15 @@
<span class="space-divider">·</span>
<span class="space-joined">加入于 {{.SpaceUser.CreatedAt.Format "2006-01-02"}}</span>
</div>
<div class="follow-links">
<a href="/space/{{.SpaceUser.ID}}/following"><strong>{{.SpaceUser.FollowingCount}}</strong> 关注</a>
<a href="/space/{{.SpaceUser.ID}}/followers"><strong>{{.SpaceUser.FollowersCount}}</strong> 粉丝</a>
</div>
{{if not .IsOwnSpace}}
<div class="follow-btn-wrapper">
<button id="followBtn" class="follow-btn" data-uid="{{.SpaceUser.ID}}">关注</button>
</div>
{{end}}
</div>
</div>
</div>
@ -89,5 +98,75 @@
{{template "layout/footer.html" .}}
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
{{if not .IsOwnSpace}}
<script>
(function() {
var followBtn = document.getElementById('followBtn');
if (!followBtn) return;
var targetUid = followBtn.getAttribute('data-uid');
var csrfMeta = document.querySelector('meta[name="csrf-token"]');
var csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
// 四个按钮状态文案
var labels = {
none: '关注',
following: '已关注',
followBack: '回关',
mutual: '已互粉'
};
var classes = {
none: '',
following: 'following',
followBack: '',
mutual: 'mutual'
};
function buttonState(status) {
if (status.i_follow && status.they_follow) return 'mutual';
if (status.i_follow) return 'following';
if (status.they_follow) return 'followBack';
return 'none';
}
function updateBtn(status) {
var state = buttonState(status);
followBtn.textContent = labels[state];
followBtn.className = 'follow-btn ' + (classes[state] || '');
}
// 获取初始状态
fetch('/api/users/' + targetUid + '/follow-status')
.then(function(res) { return res.json(); })
.then(function(data) {
if (data && data.success && data.data) {
updateBtn(data.data);
}
});
// 点击切换
followBtn.addEventListener('click', function() {
followBtn.classList.add('waiting');
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/users/' + targetUid + '/follow', true);
xhr.setRequestHeader('X-CSRF-Token', csrfToken);
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
followBtn.classList.remove('waiting');
if (xhr.status === 200) {
try {
var resp = JSON.parse(xhr.responseText);
if (resp.data && resp.data.status) {
updateBtn(resp.data.status);
}
} catch(e) {}
}
}
};
xhr.send();
});
})();
</script>
{{end}}
</body>
</html>

View File

@ -0,0 +1,142 @@
/* 关注按钮 */
.follow-btn-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
}
.follow-btn {
display: inline-flex;
align-items: center;
gap: .35rem;
padding: .4rem 1rem;
border: 1.5px solid var(--color-accent);
border-radius: 6px;
font-size: .85rem;
font-weight: 600;
cursor: pointer;
transition: all .15s ease;
background: transparent;
color: var(--color-accent);
}
.follow-btn:hover {
background: var(--color-accent);
color: #fff;
}
.follow-btn.following {
background: var(--color-accent);
color: #fff;
}
.follow-btn.following:hover {
background: var(--color-danger, #e74c3c);
border-color: var(--color-danger, #e74c3c);
}
.follow-btn.mutual {
background: var(--color-accent);
color: #fff;
}
.follow-btn.mutual:hover {
background: var(--color-danger, #e74c3c);
border-color: var(--color-danger, #e74c3c);
}
.follow-btn.waiting {
opacity: .6;
pointer-events: none;
}
/* 关注/粉丝链接 */
.follow-links {
display: flex;
gap: 1rem;
margin-top: .35rem;
}
.follow-links a {
color: var(--color-secondary);
font-size: .85rem;
text-decoration: none;
transition: color .15s;
}
.follow-links a:hover {
color: var(--color-accent);
}
.follow-links strong {
color: var(--color-text);
font-weight: 600;
}
/* 用户列表(粉丝/关注页) */
.follow-list-page {
max-width: 700px;
margin: 2rem auto;
padding: 0 1rem;
}
.follow-list-page h1 {
font-size: 1.4rem;
margin-bottom: 1.5rem;
}
.follow-list-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: .8rem 0;
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.follow-list-item:last-child {
border-bottom: none;
}
.follow-list-user {
display: flex;
align-items: center;
gap: .75rem;
text-decoration: none;
color: var(--color-text);
}
.follow-list-user:hover {
color: var(--color-accent);
}
.follow-list-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--color-accent);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 1rem;
flex-shrink: 0;
}
.follow-list-name {
font-weight: 500;
}
.follow-list-empty {
text-align: center;
color: var(--color-secondary);
padding: 3rem 0;
}
/* 隐私提示 */
.follow-list-private {
text-align: center;
color: var(--color-secondary);
padding: 3rem 0;
}

View File

@ -59,6 +59,8 @@ body {
color: var(--color-secondary);
font-weight: 500;
transition: all .15s ease;
text-decoration: none;
cursor: pointer;
}
.msg-sidebar-item:hover {