feat: 完成社区首页功能迭代,新增首页聚合与工作台组件
本次提交实现了完整的社区首页功能: 1. 新增首页聚合接口与服务,包含社区统计、热门帖、活跃用户与板块数据 2. 实现帖子推荐排序、SSR缓存与点赞态同步逻辑 3. 重构导航与布局系统,新增工作台侧边栏与移动端适配 4. 优化主题色与UI样式,统一设计系统风格 5. 修复登录注册页自动跳转与组件状态同步问题
This commit is contained in:
@@ -14,4 +14,5 @@ type Handlers struct {
|
||||
Comment *service.CommentService
|
||||
Like *service.LikeService
|
||||
Notification *service.NotificationService
|
||||
OverviewSvc *service.OverviewService
|
||||
}
|
||||
|
||||
34
backend/handler/overview.go
Normal file
34
backend/handler/overview.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Overview 首页聚合数据:统计、热门榜、活跃用户、板块计数
|
||||
func (h *Handlers) Overview(c *gin.Context) {
|
||||
data, err := h.OverviewSvc.Get()
|
||||
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(data.Hot))
|
||||
for _, p := range data.Hot {
|
||||
ids = append(ids, p.ID)
|
||||
}
|
||||
likedMap := h.Like.BatchHasLiked(ids, claims.ID)
|
||||
for i := range data.Hot {
|
||||
data.Hot[i].Liked = likedMap[data.Hot[i].ID]
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"stats": data.Stats,
|
||||
"hot": data.Hot,
|
||||
"active_users": data.ActiveUsers,
|
||||
"boards": data.Boards,
|
||||
})
|
||||
}
|
||||
@@ -16,13 +16,15 @@ func (h *Handlers) Posts(c *gin.Context) {
|
||||
boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64)
|
||||
sort := c.DefaultQuery("sort", "latest")
|
||||
keyword := c.Query("keyword")
|
||||
recommended := c.Query("recommended") == "true"
|
||||
|
||||
items, total, err := h.Post.List(service.PostListQuery{
|
||||
BoardID: uint(boardID),
|
||||
Page: page,
|
||||
Size: size,
|
||||
Sort: sort,
|
||||
Keyword: keyword,
|
||||
BoardID: uint(boardID),
|
||||
Page: page,
|
||||
Size: size,
|
||||
Sort: sort,
|
||||
Keyword: keyword,
|
||||
Recommended: recommended,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
|
||||
@@ -43,6 +43,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
commentSvc := service.NewCommentService(model.DB)
|
||||
likeSvc := service.NewLikeService(model.DB)
|
||||
notifSvc := service.NewNotificationService(model.DB)
|
||||
overviewSvc := service.NewOverviewService(model.DB)
|
||||
limiter := service.DefaultRateLimiter()
|
||||
|
||||
h := &handler.Handlers{
|
||||
@@ -53,6 +54,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
Comment: commentSvc,
|
||||
Like: likeSvc,
|
||||
Notification: notifSvc,
|
||||
OverviewSvc: overviewSvc,
|
||||
}
|
||||
|
||||
authMW := middleware.NewAuthMiddleware(authSvc)
|
||||
@@ -67,6 +69,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
{
|
||||
pubAPI.GET("/me", h.Me)
|
||||
pubAPI.GET("/boards", h.Boards)
|
||||
pubAPI.GET("/overview", h.Overview)
|
||||
pubAPI.GET("/posts", h.Posts)
|
||||
pubAPI.GET("/posts/:id", h.PostDetail)
|
||||
pubAPI.GET("/posts/:id/comments", h.PostComments)
|
||||
|
||||
167
backend/service/overview.go
Normal file
167
backend/service/overview.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OverviewService 首页聚合服务:一次请求返回统计、热门榜、活跃用户、板块计数
|
||||
type OverviewService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewOverviewService(db *gorm.DB) *OverviewService {
|
||||
return &OverviewService{db: db}
|
||||
}
|
||||
|
||||
// OverviewStats 社区整体统计
|
||||
type OverviewStats struct {
|
||||
Posts int64 `json:"posts"`
|
||||
Users int64 `json:"users"`
|
||||
Comments int64 `json:"comments"`
|
||||
TodayPosts int64 `json:"today_posts"`
|
||||
}
|
||||
|
||||
// ActiveUser 近 14 天活跃用户
|
||||
type ActiveUser struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
ActivityCount int64 `json:"activity_count"`
|
||||
}
|
||||
|
||||
// BoardCount 板块及其已发布帖子计数(Board 字段被 encoding/json 展平)
|
||||
type BoardCount struct {
|
||||
model.Board
|
||||
PostCount int64 `json:"post_count"`
|
||||
}
|
||||
|
||||
// OverviewData 首页聚合数据
|
||||
type OverviewData struct {
|
||||
Stats OverviewStats `json:"stats"`
|
||||
Hot []PostListItem `json:"hot"`
|
||||
ActiveUsers []ActiveUser `json:"active_users"`
|
||||
Boards []BoardCount `json:"boards"`
|
||||
}
|
||||
|
||||
const activeUserWindow = 14 * 24 * time.Hour
|
||||
|
||||
// Get 聚合首页数据。hot 的 liked 态由 handler 层按当前用户填充。
|
||||
func (s *OverviewService) Get() (*OverviewData, error) {
|
||||
data := &OverviewData{
|
||||
Hot: []PostListItem{},
|
||||
ActiveUsers: []ActiveUser{},
|
||||
Boards: []BoardCount{},
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
since := now.Add(-activeUserWindow)
|
||||
|
||||
// 统计
|
||||
postQ := s.db.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished)
|
||||
if err := postQ.Count(&data.Stats.Posts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Model(&model.User{}).Count(&data.Stats.Users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Model(&model.Comment{}).Where("status = ?", model.ContentStatusPublished).Count(&data.Stats.Comments).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Model(&model.Post{}).
|
||||
Where("status = ? AND created_at >= ?", model.ContentStatusPublished, dayStart).
|
||||
Count(&data.Stats.TodayPosts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 热门榜:非置顶,按 赞 + 评论×2
|
||||
var hotPosts []model.Post
|
||||
if err := s.db.Where("status = ? AND pinned = 0", model.ContentStatusPublished).
|
||||
Order("(like_count + comment_count * 2) DESC, created_at DESC").
|
||||
Limit(5).Preload("Board").Preload("User").Find(&hotPosts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data.Hot = toPostListItems(hotPosts)
|
||||
|
||||
// 活跃用户:近 14 天发帖+评论合并计数,JOIN 用户过滤封禁/软删
|
||||
type activityRow struct {
|
||||
UserID uint
|
||||
ActivityCount int64
|
||||
}
|
||||
var rows []activityRow
|
||||
activitySQL := `
|
||||
SELECT a.user_id AS user_id, COUNT(*) AS activity_count
|
||||
FROM (
|
||||
SELECT user_id FROM posts WHERE status = ? AND created_at >= ? AND deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT user_id FROM comments WHERE status = ? AND created_at >= ? AND deleted_at IS NULL
|
||||
) AS a
|
||||
JOIN users AS u ON u.id = a.user_id
|
||||
WHERE u.banned = ? AND u.deleted_at IS NULL
|
||||
GROUP BY a.user_id
|
||||
ORDER BY activity_count DESC, a.user_id ASC
|
||||
LIMIT 5`
|
||||
if err := s.db.Raw(activitySQL,
|
||||
model.ContentStatusPublished, since,
|
||||
model.ContentStatusPublished, since,
|
||||
false).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
ids := make([]uint, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
ids = append(ids, r.UserID)
|
||||
}
|
||||
var users []model.User
|
||||
if err := s.db.Where("id IN ?", ids).Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userMap := make(map[uint]model.User, len(users))
|
||||
for _, u := range users {
|
||||
userMap[u.ID] = u
|
||||
}
|
||||
for _, r := range rows {
|
||||
u, ok := userMap[r.UserID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
data.ActiveUsers = append(data.ActiveUsers, ActiveUser{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Nickname: u.Nickname,
|
||||
Avatar: u.Avatar,
|
||||
ActivityCount: r.ActivityCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 板块 + 各自帖子计数(一条 GROUP BY)
|
||||
var boards []model.Board
|
||||
if err := s.db.Order("sort_order ASC, id ASC").Find(&boards).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type countRow struct {
|
||||
BoardID uint
|
||||
Cnt int64
|
||||
}
|
||||
var counts []countRow
|
||||
if err := s.db.Model(&model.Post{}).
|
||||
Select("board_id, COUNT(*) AS cnt").
|
||||
Where("status = ?", model.ContentStatusPublished).
|
||||
Group("board_id").Scan(&counts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
countMap := make(map[uint]int64, len(counts))
|
||||
for _, c := range counts {
|
||||
countMap[c.BoardID] = c.Cnt
|
||||
}
|
||||
for _, b := range boards {
|
||||
data.Boards = append(data.Boards, BoardCount{Board: b, PostCount: countMap[b.ID]})
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
@@ -20,11 +20,27 @@ func NewPostService(db *gorm.DB) *PostService {
|
||||
|
||||
// PostListQuery 帖子列表查询参数
|
||||
type PostListQuery struct {
|
||||
BoardID uint
|
||||
Page int
|
||||
Size int
|
||||
Sort string // latest | hot
|
||||
Keyword string // 搜索关键词
|
||||
BoardID uint
|
||||
Page int
|
||||
Size int
|
||||
Sort string // latest | hot
|
||||
Keyword string // 搜索关键词
|
||||
Recommended bool // 仅精华帖
|
||||
}
|
||||
|
||||
// toPostListItems 将 Post 模型批量转为不含正文的列表项
|
||||
func toPostListItems(posts []model.Post) []PostListItem {
|
||||
items := make([]PostListItem, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
items = append(items, PostListItem{
|
||||
ID: p.ID, BoardID: p.BoardID, UserID: p.UserID,
|
||||
Title: p.Title, Tags: p.Tags, PostType: p.PostType,
|
||||
Pinned: p.Pinned, Recommended: p.Recommended, LikeCount: p.LikeCount, ViewCount: p.ViewCount,
|
||||
CommentCount: p.CommentCount, CreatedAt: p.CreatedAt,
|
||||
Board: p.Board, User: p.User,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// PostListItem 帖子列表项(不含正文)
|
||||
@@ -63,6 +79,9 @@ func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) {
|
||||
kw := "%" + q.Keyword + "%"
|
||||
query = query.Where("title ILIKE ? OR content ILIKE ? OR tags ILIKE ?", kw, kw, kw)
|
||||
}
|
||||
if q.Recommended {
|
||||
query = query.Where("recommended = ?", true)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
@@ -71,7 +90,12 @@ func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) {
|
||||
|
||||
var posts []model.Post
|
||||
offset := (q.Page - 1) * q.Size
|
||||
order := "pinned DESC, created_at DESC"
|
||||
// latest:按"最后活跃时间"排序——新帖与新评论都会浮到顶部。
|
||||
// 取发帖时间与该帖最新一条已发布评论时间的较大值,相关子查询走 comments.post_id 索引
|
||||
order := `pinned DESC, GREATEST(created_at, COALESCE((
|
||||
SELECT MAX(c.created_at) FROM comments c
|
||||
WHERE c.post_id = posts.id AND c.status = 'published' AND c.deleted_at IS NULL
|
||||
), created_at)) DESC`
|
||||
if q.Sort == "hot" {
|
||||
order = "pinned DESC, (like_count + comment_count * 2) DESC, created_at DESC"
|
||||
}
|
||||
@@ -80,17 +104,7 @@ func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
items := make([]PostListItem, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
items = append(items, PostListItem{
|
||||
ID: p.ID, BoardID: p.BoardID, UserID: p.UserID,
|
||||
Title: p.Title, Tags: p.Tags, PostType: p.PostType,
|
||||
Pinned: p.Pinned, Recommended: p.Recommended, LikeCount: p.LikeCount, ViewCount: p.ViewCount,
|
||||
CommentCount: p.CommentCount, CreatedAt: p.CreatedAt,
|
||||
Board: p.Board, User: p.User,
|
||||
})
|
||||
}
|
||||
return items, total, nil
|
||||
return toPostListItems(posts), total, nil
|
||||
}
|
||||
|
||||
// ListByUser 获取指定用户发布的帖子(公开已发布)
|
||||
@@ -112,17 +126,7 @@ func (s *PostService) ListByUser(userID uint, page, size int) ([]PostListItem, i
|
||||
Preload("Board").Preload("User").Find(&posts).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items := make([]PostListItem, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
items = append(items, PostListItem{
|
||||
ID: p.ID, BoardID: p.BoardID, UserID: p.UserID,
|
||||
Title: p.Title, Tags: p.Tags, PostType: p.PostType,
|
||||
Pinned: p.Pinned, Recommended: p.Recommended, LikeCount: p.LikeCount, ViewCount: p.ViewCount,
|
||||
CommentCount: p.CommentCount, CreatedAt: p.CreatedAt,
|
||||
Board: p.Board, User: p.User,
|
||||
})
|
||||
}
|
||||
return items, total, nil
|
||||
return toPostListItems(posts), total, nil
|
||||
}
|
||||
|
||||
// GetAuthorID 获取帖子作者 ID(不增加浏览量)
|
||||
|
||||
Reference in New Issue
Block a user