feat: 优化首页帖子列表并新增右侧栏最新注册组件

首页列表精简 meta 与统计展示,板块色标前移并淡化;有回复时显示最后回复人。右侧栏新增最新注册(4 列头像网格),友链改为标签块并排换行。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-27 18:50:09 +08:00
parent 2208af7070
commit 7cc574f9a1
17 changed files with 594 additions and 113 deletions

View File

@@ -9,16 +9,16 @@ func TestNormalizeAsideWidgetsPreservesOrder(t *testing.T) {
{ID: AsideWidgetRecentComments, Enabled: false},
}
out := NormalizeAsideWidgets(in)
if len(out) != 3 {
t.Fatalf("want 3 widgets, got %d", len(out))
if len(out) != 4 {
t.Fatalf("want 4 widgets, got %d", len(out))
}
want := []string{AsideWidgetFriendLinks, AsideWidgetTagCloud, AsideWidgetRecentComments}
want := []string{AsideWidgetFriendLinks, AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers}
for i, id := range want {
if out[i].ID != id {
t.Fatalf("index %d: want %s, got %s", i, id, out[i].ID)
}
}
if !out[0].Enabled || !out[1].Enabled || out[2].Enabled {
if !out[0].Enabled || !out[1].Enabled || out[2].Enabled || out[3].Enabled {
t.Fatalf("enabled flags mismatch: %+v", out)
}
}
@@ -28,6 +28,7 @@ func TestAsideBoolsFromWidgets(t *testing.T) {
{ID: AsideWidgetRecentComments, Enabled: true},
{ID: AsideWidgetFriendLinks, Enabled: false},
{ID: AsideWidgetTagCloud, Enabled: true},
{ID: AsideWidgetRecentUsers, Enabled: true},
}
bools := asideBoolsFromWidgets(widgets)
if !bools.tagCloud || !bools.recentComments || bools.friendLinks {

View File

@@ -56,8 +56,16 @@ type PostListQuery struct {
// PostListItem 帖子列表项(含评论数等扩展字段)
type PostListItem struct {
model.Post
CommentCount int `json:"comment_count"`
LastReplyAt *time.Time `json:"last_reply_at,omitempty"`
CommentCount int `json:"comment_count"`
LastReplyAt *time.Time `json:"last_reply_at,omitempty"`
LastReplyUser *model.User `json:"last_reply_user,omitempty"`
LastReplyGuestNick string `json:"last_reply_guest_nick,omitempty"`
}
type lastReplyInfo struct {
At *time.Time
User *model.User
GuestNick string
}
func (s *PostService) ListItems(q PostListQuery) ([]PostListItem, int64, error) {
@@ -73,13 +81,16 @@ func (s *PostService) ListItems(q PostListQuery) ([]PostListItem, int64, error)
ids[i] = p.ID
}
countMap := s.commentCountMap(ids)
replyMap := s.lastReplyMap(ids)
replyMap := s.lastReplyInfoMap(ids)
items := make([]PostListItem, len(posts))
for i, p := range posts {
info := replyMap[p.ID]
items[i] = PostListItem{
Post: p,
CommentCount: countMap[p.ID],
LastReplyAt: replyMap[p.ID],
Post: p,
CommentCount: countMap[p.ID],
LastReplyAt: info.At,
LastReplyUser: info.User,
LastReplyGuestNick: info.GuestNick,
}
}
return items, total, nil
@@ -101,44 +112,50 @@ func (s *PostService) commentCountMap(postIDs []uint) map[uint]int {
return m
}
func (s *PostService) lastReplyMap(postIDs []uint) map[uint]*time.Time {
type row struct {
PostID uint
LastReply string
func (s *PostService) lastReplyInfoMap(postIDs []uint) map[uint]lastReplyInfo {
m := make(map[uint]lastReplyInfo, len(postIDs))
if len(postIDs) == 0 {
return m
}
var rows []row
type idRow struct {
PostID uint
MaxID uint
}
var idRows []idRow
model.DB.Model(&model.Comment{}).
Select("post_id, MAX(created_at) as last_reply").
Select("post_id, MAX(id) as max_id").
Where("post_id IN ? AND status = ?", postIDs, model.ContentStatusPublished).
Group("post_id").
Scan(&rows)
m := make(map[uint]*time.Time, len(rows))
for _, r := range rows {
if t, ok := parseSQLiteTime(r.LastReply); ok {
m[r.PostID] = &t
Scan(&idRows)
if len(idRows) == 0 {
return m
}
commentIDs := make([]uint, len(idRows))
for i, r := range idRows {
commentIDs[i] = r.MaxID
}
var comments []model.Comment
if err := model.DB.Preload("User").Where("id IN ?", commentIDs).Find(&comments).Error; err != nil {
return m
}
for i := range comments {
c := &comments[i]
info := lastReplyInfo{At: &c.CreatedAt}
if c.UserID > 0 && c.User.ID > 0 {
u := c.User
info.User = &u
} else {
nick := strings.TrimSpace(c.GuestNick)
if nick == "" {
nick = "游客"
}
info.GuestNick = nick
}
m[c.PostID] = info
}
return m
}
// parseSQLiteTime 解析 SQLite 聚合查询返回的时间字符串
func parseSQLiteTime(s string) (time.Time, bool) {
if s == "" {
return time.Time{}, false
}
for _, layout := range []string{
"2006-01-02 15:04:05.999999999-07:00",
time.RFC3339Nano,
time.RFC3339,
"2006-01-02 15:04:05",
} {
if t, err := time.Parse(layout, s); err == nil {
return t, true
}
}
return time.Time{}, false
}
// HotPosts 近期活跃讨论(近 7 日有公开回复,按最后回复时间倒序)
func (s *PostService) HotPosts(limit int) ([]PostListItem, error) {
if limit <= 0 {
@@ -170,13 +187,16 @@ func (s *PostService) HotPosts(limit int) ([]PostListItem, error) {
ids[i] = p.ID
}
countMap := s.commentCountMap(ids)
replyMap := s.lastReplyMap(ids)
replyMap := s.lastReplyInfoMap(ids)
items := make([]PostListItem, len(posts))
for i, p := range posts {
info := replyMap[p.ID]
items[i] = PostListItem{
Post: p,
CommentCount: countMap[p.ID],
LastReplyAt: replyMap[p.ID],
Post: p,
CommentCount: countMap[p.ID],
LastReplyAt: info.At,
LastReplyUser: info.User,
LastReplyGuestNick: info.GuestNick,
}
}
return items, nil

View File

@@ -144,12 +144,14 @@ type AsideWidget struct {
const (
AsideWidgetTagCloud = "tag_cloud"
AsideWidgetRecentComments = "recent_comments"
AsideWidgetRecentUsers = "recent_users"
AsideWidgetFriendLinks = "friend_links"
)
var asideWidgetDefaultOrder = []string{
AsideWidgetTagCloud,
AsideWidgetRecentComments,
AsideWidgetRecentUsers,
AsideWidgetFriendLinks,
}
@@ -742,7 +744,7 @@ func asideBoolsFromWidgets(widgets []AsideWidget) asideWidgetBools {
func isValidAsideWidgetID(id string) bool {
switch id {
case AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetFriendLinks:
case AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers, AsideWidgetFriendLinks:
return true
default:
return false

View File

@@ -123,6 +123,44 @@ func (s *UserService) SearchUsersBrief(keyword string, limit int) ([]model.User,
return users, nil
}
// RecentUserItem 右栏「最新注册」条目
type RecentUserItem struct {
ID uint `json:"id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
CreatedAt string `json:"created_at"`
}
// ListRecentRegistered 前台最新注册用户(排除封禁)
func (s *UserService) ListRecentRegistered(limit int) ([]RecentUserItem, error) {
if limit < 1 {
limit = 8
}
var users []model.User
err := model.DB.Select("id", "username", "nickname", "avatar", "created_at").
Where("banned = ?", false).
Order("created_at DESC, id DESC").
Limit(limit).
Find(&users).Error
if err != nil {
return nil, err
}
out := make([]RecentUserItem, 0, len(users))
for _, u := range users {
nick := strings.TrimSpace(u.Nickname)
if nick == "" {
nick = u.Username
}
out = append(out, RecentUserItem{
ID: u.ID,
Nickname: nick,
Avatar: u.Avatar,
CreatedAt: u.CreatedAt.UTC().Format(time.RFC3339),
})
}
return out, nil
}
// UpdateNickname 修改昵称
func (s *UserService) UpdateNickname(userID uint, nickname string) error {
nickname = strings.TrimSpace(nickname)