From cab8230803eee5ed5cd2715302b3c35c93de7413 Mon Sep 17 00:00:00 2001 From: freefire Date: Fri, 25 Sep 2026 18:28:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=8E=92=E8=A1=8C?= =?UTF-8?q?=E6=A6=9C=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 leaderboard 服务与 handler、前端排行榜页面 - 路由与首页入口适配 --- backend/handler/handlers.go | 1 + backend/handler/leaderboard.go | 71 ++++ backend/router/router.go | 4 + backend/service/leaderboard.go | 345 +++++++++++++++++ frontend/app/globals.css | 4 +- frontend/app/leaderboard/page.tsx | 597 ++++++++++++++++++++++++++++++ frontend/app/sitemap.ts | 6 + frontend/components/Header.tsx | 13 + frontend/lib/api.ts | 73 ++++ 9 files changed, 1112 insertions(+), 2 deletions(-) create mode 100644 backend/handler/leaderboard.go create mode 100644 backend/service/leaderboard.go create mode 100644 frontend/app/leaderboard/page.tsx diff --git a/backend/handler/handlers.go b/backend/handler/handlers.go index 04d3317..9e19b5d 100644 --- a/backend/handler/handlers.go +++ b/backend/handler/handlers.go @@ -37,6 +37,7 @@ type Handlers struct { Ads *service.AdService Sidebar *service.SidebarService Badge *service.BadgeService + LeaderboardSvc *service.LeaderboardService } // resolvePublishStatus 决定新帖/新评的初始状态: diff --git a/backend/handler/leaderboard.go b/backend/handler/leaderboard.go new file mode 100644 index 0000000..b2e4a09 --- /dev/null +++ b/backend/handler/leaderboard.go @@ -0,0 +1,71 @@ +package handler + +import ( + "net/http" + "strconv" + + "github.com/freefire/jiang13-bbs/service" + "github.com/gin-gonic/gin" +) + +// 榜单参数白名单 +var ( + lbPeriods = map[string]bool{service.PeriodWeek: true, service.PeriodMonth: true, service.PeriodAll: true} + lbMetrics = map[string]bool{service.MetricAll: true, service.MetricPost: true, + service.MetricComment: true, service.MetricLike: true, service.MetricAnswer: true} +) + +// lbParams 解析并校验 period/metric,非法值回退默认并标记 400 +func lbParams(c *gin.Context) (period, metric string, bad bool) { + period = c.DefaultQuery("period", service.PeriodAll) + metric = c.DefaultQuery("metric", service.MetricAll) + if !lbPeriods[period] || !lbMetrics[metric] { + return "", "", true + } + return period, metric, false +} + +// Leaderboard 用户贡献榜:period=week|month|all × metric=all|post|comment|like|answer(公开接口) +func (h *Handlers) Leaderboard(c *gin.Context) { + period, metric, bad := lbParams(c) + if bad { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的榜单参数"}) + return + } + limit := 50 + if v := c.Query("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + limit = n + } + } + if limit > 100 { + limit = 100 + } + res, err := h.LeaderboardSvc.Board(period, metric, limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取排行榜失败"}) + return + } + c.JSON(http.StatusOK, res) +} + +// LeaderboardMe 当前用户在指定榜单的排名(可选登录;游客返回 me:null) +func (h *Handlers) LeaderboardMe(c *gin.Context) { + period, metric, bad := lbParams(c) + if bad { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的榜单参数"}) + return + } + if v, ok := c.Get("user"); ok { + if claims, ok := v.(*service.UserClaims); ok && claims.ID > 0 { + res, err := h.LeaderboardSvc.MyRank(period, metric, claims.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取排名失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"me": res}) + return + } + } + c.JSON(http.StatusOK, gin.H{"me": nil}) +} diff --git a/backend/router/router.go b/backend/router/router.go index 6f05676..31a16a2 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -75,6 +75,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { uploadSvc.WithOperations(ops) postFileSvc.WithOperations(ops) pointsSvc := service.NewPointsService(model.DB) + leaderboardSvc := service.NewLeaderboardService(model.DB) adminUserSvc := service.NewAdminUserService(model.DB) legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads")) moderationSvc := service.NewModerationService(model.DB, notifSvc) @@ -126,6 +127,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { Ads: adSvc, Sidebar: service.NewSidebarService(model.DB), Badge: badgeSvc, + LeaderboardSvc: leaderboardSvc, } // 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用) notifSvc.OnNotifyNew = func(userID uint) { @@ -175,6 +177,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { pubAPI.GET("/pages", h.SitePagesList) pubAPI.GET("/pages/:slug", h.SitePageDetail) pubAPI.GET("/settings", h.PublicSettings) + pubAPI.GET("/leaderboard", h.Leaderboard) + pubAPI.GET("/leaderboard/me", h.LeaderboardMe) pubAPI.GET("/site-state", h.SiteState) pubAPI.GET("/media/:object", h.PublicObject) pubAPI.POST("/auth/code", middleware.CSRFMiddleware(), h.SendEmailCode) diff --git a/backend/service/leaderboard.go b/backend/service/leaderboard.go new file mode 100644 index 0000000..c1a8ae5 --- /dev/null +++ b/backend/service/leaderboard.go @@ -0,0 +1,345 @@ +package service + +import ( + "sort" + "time" + + "github.com/freefire/jiang13-bbs/model" + "gorm.io/gorm" +) + +// 榜单周期与统计维度(handler 侧做白名单校验) +const ( + PeriodWeek = "week" + PeriodMonth = "month" + PeriodAll = "all" + + MetricAll = "all" // 综合:周/月=四项合计,总榜=累计积分 + MetricPost = "post" // 发帖最多 + MetricComment = "comment" // 回复最多 + MetricLike = "like" // 获赞最多 + MetricAnswer = "answer" // 最佳答案(回答被采纳) +) + +// LeaderboardEntry 排行榜单条(领奖台/列表通用) +type LeaderboardEntry struct { + Rank int `json:"rank"` + ID uint `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + TotalPoints int `json:"total_points"` // 累计获得积分(经验,定级依据) + Points int `json:"points"` // 当前积分余额 + Level int `json:"level"` // AfterFind 按 TotalPoints 计算 + PostCount int64 `json:"post_count"` + CommentCount int64 `json:"comment_count"` + LikeCount int64 `json:"like_count"` // 其 published 帖获赞之和 + AnswerCount int64 `json:"answer_count"` + Score int64 `json:"score"` // 排名依据:见 scoreOf +} + +// LeaderboardResult 榜单响应 +type LeaderboardResult struct { + Period string `json:"period"` // week | month | all + Metric string `json:"metric"` // all | post | comment | like | answer + Entries []LeaderboardEntry `json:"entries"` +} + +// MyRank 当前用户在指定榜单的排名(含与上一周期的名次对比) +type MyRank struct { + Period string `json:"period"` + Metric string `json:"metric"` + Rank int `json:"rank"` // 1 起;0 = 未上榜 + Total int `json:"total"` // 上榜总人数 + Score int64 `json:"score"` // 当前得分 + Change int `json:"change"` // 名次变化:正=上升,负=下降,0=持平 + HasChange bool `json:"has_change"` +} + +// LeaderboardService 用户贡献榜 +type LeaderboardService struct { + db *gorm.DB +} + +func NewLeaderboardService(db *gorm.DB) *LeaderboardService { + return &LeaderboardService{db: db} +} + +// statsRow 用户内容统计聚合行 +type statsRow struct { + UserID uint + PostCount int64 + CommentCount int64 + LikeCount int64 + AnswerCount int64 +} + +// contentStats 聚合发帖/回帖/获赞(posts+comments+post 获赞三臂 UNION ALL 单查询)。 +// from/to 任一非 nil 时各臂加时间窗;SQL 范式与 overview.go 活跃用户聚合一致。 +func (s *LeaderboardService) contentStats(from, to *time.Time) (map[uint]statsRow, error) { + clause := "" + var timeArgs []interface{} + if from != nil { + clause += " AND created_at >= ?" + timeArgs = append(timeArgs, *from) + } + if to != nil { + clause += " AND created_at < ?" + timeArgs = append(timeArgs, *to) + } + postArm := "SELECT user_id, 1 AS is_post, 0 AS like_sum FROM posts WHERE status = ?" + clause + " AND deleted_at IS NULL" + commentArm := "SELECT user_id, 0 AS is_post, 0 AS like_sum FROM comments WHERE status = ?" + clause + " AND deleted_at IS NULL" + likeArm := "SELECT user_id, 0 AS is_post, like_count AS like_sum FROM posts WHERE status = ?" + clause + " AND deleted_at IS NULL" + // 占位符顺序与三臂拼接顺序严格对应 + args := []interface{}{} + for i := 0; i < 3; i++ { + args = append(args, model.ContentStatusPublished) + args = append(args, timeArgs...) + } + sql := "SELECT a.user_id AS user_id, SUM(a.is_post) AS post_count, SUM(1 - a.is_post) AS comment_count, SUM(a.like_sum) AS like_count" + + " FROM (" + postArm + " UNION ALL " + commentArm + " UNION ALL " + likeArm + ") 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" + args = append(args, false) + + var rows []statsRow + if err := s.db.Raw(sql, args...).Scan(&rows).Error; err != nil { + return nil, err + } + out := make(map[uint]statsRow, len(rows)) + for _, r := range rows { + out[r.UserID] = r + } + return out, nil +} + +// answerStats 最佳答案统计:问答/悬赏帖中其评论被采纳(type_meta.accepted_comment_id)的条数, +// 按帖子发布时间窗计;type_meta 为 JSON 字符串,空值/缺失统一落 0(JOIN 不到即不计) +func (s *LeaderboardService) answerStats(from, to *time.Time) (map[uint]int64, error) { + clause := "" + var timeArgs []interface{} + if from != nil { + clause += " AND p.created_at >= ?" + timeArgs = append(timeArgs, *from) + } + if to != nil { + clause += " AND p.created_at < ?" + timeArgs = append(timeArgs, *to) + } + accepted := "COALESCE(NULLIF(NULLIF(p.type_meta, '')::jsonb ->> 'accepted_comment_id', ''), '0')::bigint" + sql := "SELECT c.user_id AS user_id, COUNT(*) AS answer_count FROM posts p" + + " JOIN comments c ON c.id = " + accepted + + " JOIN users u ON u.id = c.user_id" + + " WHERE p.post_type IN (?, ?) AND p.status = ? AND p.deleted_at IS NULL" + + " AND " + accepted + " > 0 AND c.deleted_at IS NULL AND u.banned = ? AND u.deleted_at IS NULL" + clause + + " GROUP BY c.user_id" + args := []interface{}{model.PostTypeQuestion, model.PostTypeBounty, model.ContentStatusPublished, false} + args = append(args, timeArgs...) + + var rows []struct { + UserID uint + AnswerCount int64 + } + if err := s.db.Raw(sql, args...).Scan(&rows).Error; err != nil { + return nil, err + } + out := make(map[uint]int64, len(rows)) + for _, r := range rows { + out[r.UserID] = r.AnswerCount + } + return out, nil +} + +// statsFor 内容统计 + 最佳答案合并 +func (s *LeaderboardService) statsFor(from, to *time.Time) (map[uint]statsRow, error) { + stats, err := s.contentStats(from, to) + if err != nil { + return nil, err + } + answers, err := s.answerStats(from, to) + if err != nil { + return nil, err + } + for id, n := range answers { + r := stats[id] + r.AnswerCount = n + stats[id] = r + } + return stats, nil +} + +// scoreOf 指标 → 排名得分;综合 = 四项合计(总榜-全部走累计积分口径,见 boardBetween) +func scoreOf(metric string, st statsRow) int64 { + switch metric { + case MetricPost: + return st.PostCount + case MetricComment: + return st.CommentCount + case MetricLike: + return st.LikeCount + case MetricAnswer: + return st.AnswerCount + default: + return st.PostCount + st.CommentCount + st.LikeCount + st.AnswerCount + } +} + +// entryFromUser 由用户行构造榜单条目(不含统计) +func entryFromUser(rank int, u model.User) LeaderboardEntry { + return LeaderboardEntry{ + Rank: rank, + ID: u.ID, + Username: u.Username, + Nickname: u.Nickname, + Avatar: u.Avatar, + TotalPoints: u.TotalPoints, + Points: u.Points, + Level: u.Level, + } +} + +// periodBounds 周期 → [from, to) 时间窗与上一周期窗口;总榜两值均 nil +func periodBounds(period string, now time.Time) (from, to, prevFrom, prevTo *time.Time) { + switch period { + case PeriodWeek: + w := startOfWeek(now) + pw := w.AddDate(0, 0, -7) + return &w, nil, &pw, &w + case PeriodMonth: + m := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + pm := m.AddDate(0, -1, 0) + return &m, nil, &pm, &m + default: + return nil, nil, nil, nil + } +} + +// boardBetween 指定时间窗的全量榜单(未截断,Rank 未编号)。 +// 「总榜+全部」走累计积分口径(total_points),其余走统计聚合口径。 +func (s *LeaderboardService) boardBetween(from, to *time.Time, period, metric string) ([]LeaderboardEntry, error) { + if metric == MetricAll && from == nil { + var users []model.User + if err := s.db. + Where("banned = ? AND total_points > 0", false). + Order("total_points DESC, id ASC"). + Find(&users).Error; err != nil { + return nil, err + } + stats, err := s.statsFor(nil, nil) + if err != nil { + return nil, err + } + entries := make([]LeaderboardEntry, 0, len(users)) + for _, u := range users { + e := entryFromUser(0, u) + e.Score = int64(u.TotalPoints) + if r, ok := stats[u.ID]; ok { + e.PostCount, e.CommentCount, e.LikeCount, e.AnswerCount = + r.PostCount, r.CommentCount, r.LikeCount, r.AnswerCount + } + entries = append(entries, e) + } + return entries, nil + } + + stats, err := s.statsFor(from, to) + if err != nil { + return nil, err + } + entries := make([]LeaderboardEntry, 0, len(stats)) + for id, r := range stats { + score := scoreOf(metric, r) + if score <= 0 { + continue + } + entries = append(entries, LeaderboardEntry{ID: id, Score: score, PostCount: r.PostCount, + CommentCount: r.CommentCount, LikeCount: r.LikeCount, AnswerCount: r.AnswerCount}) + } + if len(entries) == 0 { + return entries, nil + } + ids := make([]uint, 0, len(entries)) + for _, e := range entries { + ids = append(ids, e.ID) + } + var users []model.User + if err := s.db.Where("id IN ?", ids).Find(&users).Error; err != nil { + return nil, err + } + info := make(map[uint]model.User, len(users)) + for _, u := range users { + info[u.ID] = u + } + full := make([]LeaderboardEntry, 0, len(entries)) + for _, e := range entries { + u, ok := info[e.ID] + if !ok { + continue + } + ne := entryFromUser(0, u) + ne.PostCount, ne.CommentCount, ne.LikeCount, ne.AnswerCount = + e.PostCount, e.CommentCount, e.LikeCount, e.AnswerCount + ne.Score = e.Score + full = append(full, ne) + } + sort.Slice(full, func(i, j int) bool { + if full[i].Score != full[j].Score { + return full[i].Score > full[j].Score + } + if full[i].TotalPoints != full[j].TotalPoints { + return full[i].TotalPoints > full[j].TotalPoints + } + return full[i].ID < full[j].ID + }) + return full, nil +} + +// Board 指定周期×维度的 Top N 榜单 +func (s *LeaderboardService) Board(period, metric string, limit int) (*LeaderboardResult, error) { + from, to, _, _ := periodBounds(period, time.Now()) + full, err := s.boardBetween(from, to, period, metric) + if err != nil { + return nil, err + } + if len(full) > limit { + full = full[:limit] + } + for i := range full { + full[i].Rank = i + 1 + } + return &LeaderboardResult{Period: period, Metric: metric, Entries: full}, nil +} + +// MyRank 当前用户在指定榜单的排名;周/月榜带与上一周期的名次对比(正=上升) +func (s *LeaderboardService) MyRank(period, metric string, userID uint) (*MyRank, error) { + from, to, prevFrom, prevTo := periodBounds(period, time.Now()) + full, err := s.boardBetween(from, to, period, metric) + if err != nil { + return nil, err + } + res := &MyRank{Period: period, Metric: metric, Total: len(full)} + for i, e := range full { + if e.ID == userID { + res.Rank = i + 1 + res.Score = e.Score + break + } + } + if period == PeriodWeek || period == PeriodMonth { + prev, err := s.boardBetween(prevFrom, prevTo, period, metric) + if err != nil { + return nil, err + } + var prevRank int + for i, e := range prev { + if e.ID == userID { + prevRank = i + 1 + break + } + } + if res.Rank > 0 && prevRank > 0 { + res.HasChange = true + res.Change = prevRank - res.Rank + } + } + return res, nil +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 0a423f8..23f1642 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -66,7 +66,7 @@ /* 工作台可点块悬停底:左栏、帖子行、右栏公告/热门/友链/关于本站、目录等共用。 改这一处即可全局换悬停色;选中态仍走 --nav-active-*。按钮/菜单等控件不要绑这个。 */ - --item-hover-bg: var(--accent-soft); + --item-hover-bg: color-mix(in srgb, var(--accent-soft) 50%, transparent); /* 工作台语义:选中用主题色淡底 */ --nav-active-bg: var(--accent-soft); --nav-active-ink: var(--accent); @@ -183,7 +183,7 @@ --stock-badge-low-bg: var(--tint); --stock-badge-low-fg: var(--ink-3); - --item-hover-bg: var(--accent-soft); + --item-hover-bg: color-mix(in srgb, var(--accent-soft) 50%, transparent); /* 工作台语义:选中用主题色淡底 */ --nav-active-bg: var(--accent-soft); --nav-active-ink: #a9b8ff; diff --git a/frontend/app/leaderboard/page.tsx b/frontend/app/leaderboard/page.tsx new file mode 100644 index 0000000..9b1d35f --- /dev/null +++ b/frontend/app/leaderboard/page.tsx @@ -0,0 +1,597 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { cookies } from "next/headers"; +import { + Trophy, + Crown, + Medal, + FileText, + MessageSquare, + ThumbsUp, + BadgeCheck, + CalendarCheck, + PenSquare, + TrendingUp, + TrendingDown, +} from "lucide-react"; +import { + fetchLeaderboard, + fetchMyRank, + type LeaderboardEntry, + type LeaderboardPeriod, + type LeaderboardMetric, + type LeaderboardMe, + type PublicSettings, +} from "@/lib/api"; +import { getMeCached, getPublicSettingsCached } from "@/lib/serverData"; +import { authCookieHeader } from "@/lib/cookies"; +import { requestCanonical } from "@/lib/canonicalMeta"; +import Avatar from "@/components/Avatar"; +import LevelBadge from "@/components/LevelBadge"; + +interface PageProps { + searchParams: Promise<{ period?: string; metric?: string }>; +} + +const PERIODS: { key: LeaderboardPeriod; label: string }[] = [ + { key: "week", label: "本周" }, + { key: "month", label: "本月" }, + { key: "all", label: "总榜" }, +]; + +const METRICS: { key: LeaderboardMetric; label: string }[] = [ + { key: "all", label: "全部" }, + { key: "post", label: "发帖最多" }, + { key: "comment", label: "回复最多" }, + { key: "like", label: "获赞最多" }, + { key: "answer", label: "最佳答案" }, +]; + +function normPeriod(v?: string): LeaderboardPeriod { + return v === "week" || v === "month" || v === "all" ? v : "all"; +} +function normMetric(v?: string): LeaderboardMetric { + return v === "post" || v === "comment" || v === "like" || v === "answer" || v === "all" ? v : "all"; +} + +/** 榜单链接:默认参数省略,保持 URL 干净 */ +function lbHref(period: LeaderboardPeriod, metric: LeaderboardMetric): string { + const q = new URLSearchParams(); + if (period !== "all") q.set("period", period); + if (metric !== "all") q.set("metric", metric); + const s = q.toString(); + return s ? `/leaderboard?${s}` : "/leaderboard"; +} + +/** 右侧分数标签:总榜-全部=累计积分,周/月-全部=贡献值,细分=对应指标名 */ +function scoreLabel(metric: LeaderboardMetric, period: LeaderboardPeriod): string { + if (metric === "all") return period === "all" ? "累计积分" : "贡献值"; + return METRICS.find((m) => m.key === metric)?.label.replace("最多", "") ?? "贡献值"; +} + +export async function generateMetadata({ searchParams }: PageProps): Promise { + await searchParams; + const settings = await getPublicSettingsCached(); + return { + title: "排行榜", + description: settings.site_description, + ...(await requestCanonical("/leaderboard")), + }; +} + +/** 金银铜固定金属色(装饰用,不随明暗主题翻转) */ +const METAL: Record<1 | 2 | 3, { ring: string; fg: string; soft: string }> = { + 1: { + ring: "linear-gradient(135deg, #ffe9a8, #f0c964 45%, #c9973f)", + fg: "#a97f1e", + soft: "rgba(212, 160, 23, 0.14)", + }, + 2: { + ring: "linear-gradient(135deg, #f1f4f8, #c3cbd6 45%, #96a0ad)", + fg: "#6b7686", + soft: "rgba(125, 135, 148, 0.16)", + }, + 3: { + ring: "linear-gradient(135deg, #f3cdae, #d69a68 45%, #b0763f)", + fg: "#8f5f2e", + soft: "rgba(176, 118, 63, 0.15)", + }, +}; + +function RankMedal({ place }: { place: 1 | 2 | 3 }) { + const m = METAL[place]; + return ( + + {place === 1 ? : } + + ); +} + +/** 领奖台卡片:渐变金属环头像 + 名字/等级 + 分数大数字 */ +function PodiumCard({ e, place, period, metric }: { + e: LeaderboardEntry; + place: 1 | 2 | 3; + period: LeaderboardPeriod; + metric: LeaderboardMetric; +}) { + const m = METAL[place]; + const first = place === 1; + return ( + + + {/* 两层均用 flex 包裹(inline 行盒 leading 会把环撑成竖椭圆):金属环 + 白色内垫 + 头像贴合 */} + + + + + +

+ {e.nickname || e.username} +

+
+ + @{e.username} +
+

+ {e.score.toLocaleString()} +

+

{scoreLabel(metric, period)}

+

+ + {e.post_count} + + · + + {e.comment_count} + +

+ + ); +} + +/** 4 名起的列表行 */ +function RankRow({ e, period, metric }: { + e: LeaderboardEntry; + period: LeaderboardPeriod; + metric: LeaderboardMetric; +}) { + return ( + + + {e.rank} + + + + + + {e.nickname || e.username} + + + + @{e.username} + + + + {e.post_count} + + + {e.comment_count} + + + {e.like_count} + + {e.answer_count > 0 && ( + + {e.answer_count} + + )} + + + + {e.score.toLocaleString()} + + {scoreLabel(metric, period)} + + + ); +} + +/** 右栏:我的排名(登录用户带本周/本月名次变化) */ +function MyRankPanel({ me, authed, period, metric }: { + me: LeaderboardMe | null; + authed: boolean; + period: LeaderboardPeriod; + metric: LeaderboardMetric; +}) { + const periodLabel = period === "month" ? "本月" : "本周"; + const label = scoreLabel(metric, period); + return ( +
+
+

+ 我的排名 +

+ {me && me.rank > 0 && period !== "all" && ( + me.has_change ? ( + me.change > 0 ? ( + + {periodLabel}上升 {me.change} 名 + + ) : me.change < 0 ? ( + + {periodLabel}下降 {-me.change} 名 + + ) : ( + {periodLabel}持平 + ) + ) : ( + {periodLabel}新上榜 + ) + )} +
+ + {!authed ? ( +
+ + + +

+ 登录后查看我的排名 +

+

参与发帖、回帖与签到即可上榜

+ + 立即登录 + +
+ ) : me && me.rank > 0 ? ( +
+
+

+ # + {me.rank} +

+
+
+

+ {label} {me.score.toLocaleString()} +

+

+ {period === "all" ? "总榜" : periodLabel} · 共 {me.total} 人上榜 +

+
+
+ ) : ( +
+

+ 暂未上榜 +

+

+ {period === "all" ? "签到或发帖积累积分即可上榜" : `${periodLabel}内参与互动即可上榜`} +

+ + 写新帖 + +
+ )} +
+ ); +} + +/** 右栏:贡献值规则(数据来自公开设置的积分规则 8 键) */ +function RulesPanel({ s }: { s: PublicSettings }) { + const rows: { icon: React.ReactNode; label: string; value: string; note?: string }[] = [ + { + icon: , + label: "每日签到", + value: `+${s.points_checkin_base}`, + note: `连续每 ${s.points_streak_every_days} 天额外 +${s.points_streak_bonus}`, + }, + { + icon: , + label: "发表主题", + value: `+${s.points_post_reward}`, + note: `每日上限 ${s.points_post_daily_cap} 篇`, + }, + { + icon: , + label: "发表回复", + value: `+${s.points_reply_reward}`, + note: `每日上限 ${s.points_reply_daily_cap} 条`, + }, + { + icon: , + label: "帖子被推荐", + value: `+${s.points_recommend_reward}`, + note: "每帖仅计一次", + }, + { + icon: , + label: "回答被采纳", + value: "悬赏另计", + note: "悬赏积分由提问者设置", + }, + ]; + return ( +
+

+ 贡献值规则 +

+
    + {rows.map((r) => ( +
  • + + {r.icon} + + + + {r.label} + + {r.note && {r.note}} + + + {r.value} + +
  • + ))} +
+

+ 累计积分即贡献值,决定等级与总榜排名;消费仅扣余额,不减少累计值。 +

+
+ ); +} + +export default async function LeaderboardPage({ searchParams }: PageProps) { + const sp = await searchParams; + const period = normPeriod(sp.period); + const metric = normMetric(sp.metric); + const cookie = authCookieHeader(await cookies()); + const settings = await getPublicSettingsCached(); + const [meUser] = await Promise.all([getMeCached(cookie || undefined)]); + const authed = !!meUser; + + // 接口失败降级为空榜,不让页面 500 + let entries: LeaderboardEntry[] = []; + try { + const res = await fetchLeaderboard(period, metric, cookie || undefined); + entries = res.entries ?? []; + } catch { + /* 降级空榜 */ + } + const me = authed ? await fetchMyRank(period, metric, cookie || undefined) : null; + + const podium = entries.slice(0, 3); + const rest = entries.slice(3); + const totalScore = entries.reduce((acc, e) => acc + e.score, 0); + + return ( +
+ {/* 左栏:贡献排行榜 */} +
+ {/* Hero:渐变洗底 + 光斑装饰 */} +
+
+
+

+ 贡献排行榜 +

+

每一分积累,都是社区前行的足迹

+ {entries.length > 0 && ( +
+
+

+ {entries.length} +

+

上榜用户

+
+
+
+

+ {totalScore.toLocaleString()} +

+

+ {period === "all" && metric === "all" ? "累计贡献积分" : "贡献合计"} +

+
+
+
+

+ {podium[0]?.nickname || podium[0]?.username || "—"} +

+

当前榜首

+
+
+ )} +
+
+ + {/* 周期 Tab(本周/本月/总榜)+ 维度细分(全部/发帖/回复/获赞/最佳答案) */} +
+
+ {PERIODS.map((p) => ( + + {p.label} + + ))} +
+
+ {METRICS.map((m) => { + const active = metric === m.key; + return ( + + {m.label} + + ); + })} +
+
+ + {entries.length === 0 ? ( +
+ + + +

+ 榜单虚位以待 +

+

+ {period === "all" ? "签到、发帖、回帖都能积累积分上榜" : "这个周期还没有动态,快来抢占先机"} +

+ + 写新帖 + +
+ ) : ( + <> + {/* 领奖台:DOM 顺序 2-1-3,冠军居中放大 */} +
+ {podium[1] && } + {podium[0] && } + {podium[2] && } +
+ + {rest.length > 0 && ( +
+ {rest.map((e) => ( + + ))} +
+ )} + + )} +
+ + {/* 右栏:我的排名 + 贡献值规则 */} + +
+ ); +} diff --git a/frontend/app/sitemap.ts b/frontend/app/sitemap.ts index 3ffe9d7..26854da 100644 --- a/frontend/app/sitemap.ts +++ b/frontend/app/sitemap.ts @@ -24,6 +24,12 @@ export default async function sitemap(): Promise { changeFrequency: "daily", priority: 0.7, }, + { + url: url("/leaderboard"), + lastModified: new Date(), + changeFrequency: "daily", + priority: 0.5, + }, { url: url("/about"), lastModified: new Date(), diff --git a/frontend/components/Header.tsx b/frontend/components/Header.tsx index ee511d8..70e86c7 100644 --- a/frontend/components/Header.tsx +++ b/frontend/components/Header.tsx @@ -14,6 +14,7 @@ import { Settings, Bell, Home, + Trophy, User as UserIcon, ChevronDown, ShieldCheck, @@ -241,6 +242,13 @@ export default function Header({ }> 首页 + } + > + 排行榜 + @@ -366,6 +374,11 @@ export default function Header({
首页 + + + 排行榜 + + {user && !hideComposeNav && 发帖} {user && allowMessages && ( diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 9a17e45..8ea5f37 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -759,6 +759,79 @@ export async function fetchBoardSidebar( return res.json(); } +/** 榜单周期:本周 | 本月 | 总榜 */ +export type LeaderboardPeriod = "week" | "month" | "all"; +/** 榜单维度:全部(综合)| 发帖最多 | 回复最多 | 获赞最多 | 最佳答案 */ +export type LeaderboardMetric = "all" | "post" | "comment" | "like" | "answer"; + +/** 排行榜单条(领奖台/列表通用;level 由后端按累计积分计算) */ +export interface LeaderboardEntry { + rank: number; + id: number; + username: string; + nickname: string; + avatar: string; + total_points: number; + points: number; + level: number; + post_count: number; + comment_count: number; + like_count: number; + answer_count: number; + score: number; +} + +/** /api/leaderboard 响应 */ +export interface LeaderboardResponse { + period: LeaderboardPeriod; + metric: LeaderboardMetric; + entries: LeaderboardEntry[]; +} + +/** 我的排名(rank=0 未上榜;change 正=上升负=下降,has_change=false 表示无上一周期对比) */ +export interface LeaderboardMe { + period: LeaderboardPeriod; + metric: LeaderboardMetric; + rank: number; + total: number; + score: number; + change: number; + has_change: boolean; +} + +/** SSR 排行榜;失败时抛错,由页面降级为空榜 */ +export async function fetchLeaderboard( + period: LeaderboardPeriod, + metric: LeaderboardMetric, + cookieHeader?: string +): Promise { + const res = await fetch( + `${API_BASE}/api/leaderboard?period=${period}&metric=${metric}`, + ssrInit(cookieHeaders(cookieHeader)) + ); + if (!res.ok) throw new Error("获取排行榜失败"); + return res.json(); +} + +/** SSR 我的排名;游客/失败返回 null(不抛错) */ +export async function fetchMyRank( + period: LeaderboardPeriod, + metric: LeaderboardMetric, + cookieHeader?: string +): Promise { + try { + const res = await fetch( + `${API_BASE}/api/leaderboard/me?period=${period}&metric=${metric}`, + ssrInit(cookieHeaders(cookieHeader)) + ); + if (!res.ok) return null; + const data = (await res.json()) as { me: LeaderboardMe | null }; + return data.me ?? null; + } catch { + return null; + } +} + export async function fetchBoards(): Promise<{ boards: BoardWithCount[] }> { const res = await fetch(`${API_BASE}/api/boards`, ssrInit()); if (!res.ok) throw new Error("获取板块失败");