feat: 新增排行榜模块

- 新增 leaderboard 服务与 handler、前端排行榜页面
- 路由与首页入口适配
This commit is contained in:
2026-09-25 18:28:12 +08:00
parent 4ae57d9392
commit cab8230803
9 changed files with 1112 additions and 2 deletions

View File

@@ -37,6 +37,7 @@ type Handlers struct {
Ads *service.AdService Ads *service.AdService
Sidebar *service.SidebarService Sidebar *service.SidebarService
Badge *service.BadgeService Badge *service.BadgeService
LeaderboardSvc *service.LeaderboardService
} }
// resolvePublishStatus 决定新帖/新评的初始状态: // resolvePublishStatus 决定新帖/新评的初始状态:

View File

@@ -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})
}

View File

@@ -75,6 +75,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
uploadSvc.WithOperations(ops) uploadSvc.WithOperations(ops)
postFileSvc.WithOperations(ops) postFileSvc.WithOperations(ops)
pointsSvc := service.NewPointsService(model.DB) pointsSvc := service.NewPointsService(model.DB)
leaderboardSvc := service.NewLeaderboardService(model.DB)
adminUserSvc := service.NewAdminUserService(model.DB) adminUserSvc := service.NewAdminUserService(model.DB)
legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads")) legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
moderationSvc := service.NewModerationService(model.DB, notifSvc) moderationSvc := service.NewModerationService(model.DB, notifSvc)
@@ -126,6 +127,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
Ads: adSvc, Ads: adSvc,
Sidebar: service.NewSidebarService(model.DB), Sidebar: service.NewSidebarService(model.DB),
Badge: badgeSvc, Badge: badgeSvc,
LeaderboardSvc: leaderboardSvc,
} }
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用) // 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
notifSvc.OnNotifyNew = func(userID uint) { 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", h.SitePagesList)
pubAPI.GET("/pages/:slug", h.SitePageDetail) pubAPI.GET("/pages/:slug", h.SitePageDetail)
pubAPI.GET("/settings", h.PublicSettings) pubAPI.GET("/settings", h.PublicSettings)
pubAPI.GET("/leaderboard", h.Leaderboard)
pubAPI.GET("/leaderboard/me", h.LeaderboardMe)
pubAPI.GET("/site-state", h.SiteState) pubAPI.GET("/site-state", h.SiteState)
pubAPI.GET("/media/:object", h.PublicObject) pubAPI.GET("/media/:object", h.PublicObject)
pubAPI.POST("/auth/code", middleware.CSRFMiddleware(), h.SendEmailCode) pubAPI.POST("/auth/code", middleware.CSRFMiddleware(), h.SendEmailCode)

View File

@@ -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
}

View File

@@ -66,7 +66,7 @@
/* 工作台可点块悬停底:左栏、帖子行、右栏公告/热门/友链/关于本站、目录等共用。 /* 工作台可点块悬停底:左栏、帖子行、右栏公告/热门/友链/关于本站、目录等共用。
改这一处即可全局换悬停色;选中态仍走 --nav-active-*。按钮/菜单等控件不要绑这个。 */ 改这一处即可全局换悬停色;选中态仍走 --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-bg: var(--accent-soft);
--nav-active-ink: var(--accent); --nav-active-ink: var(--accent);
@@ -183,7 +183,7 @@
--stock-badge-low-bg: var(--tint); --stock-badge-low-bg: var(--tint);
--stock-badge-low-fg: var(--ink-3); --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-bg: var(--accent-soft);
--nav-active-ink: #a9b8ff; --nav-active-ink: #a9b8ff;

View File

@@ -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<Metadata> {
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 (
<span
className="absolute -top-3 left-1/2 -translate-x-1/2 z-10 w-8 h-8 rounded-full flex items-center justify-center"
style={{
background: m.soft,
color: m.fg,
border: "1px solid var(--panel)",
boxShadow: `0 0 0 1px color-mix(in srgb, ${m.fg} 35%, transparent)`,
}}
>
{place === 1 ? <Crown size={15} /> : <Medal size={15} />}
</span>
);
}
/** 领奖台卡片:渐变金属环头像 + 名字/等级 + 分数大数字 */
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 (
<Link
href={`/u/${e.id}`}
className={`group relative flex flex-col items-center text-center rounded-[22px] transition-transform hover:-translate-y-0.5 ${
first ? "pt-7 pb-6 px-3 sm:px-5" : "pt-6 pb-5 px-2 sm:px-4"
}`}
style={{
background: "var(--panel)",
border: first
? "1px solid color-mix(in srgb, var(--accent) 45%, var(--line))"
: "1px solid var(--line)",
boxShadow: first
? "0 16px 36px -20px color-mix(in srgb, var(--accent) 55%, transparent)"
: "none",
}}
>
<RankMedal place={place} />
{/* 两层均用 flex 包裹(inline 行盒 leading 会把环撑成竖椭圆):金属环 + 白色内垫 + 头像贴合 */}
<span
className={`rounded-full mb-3 flex ${first ? "p-[4px]" : "p-[3px]"}`}
style={{ background: m.ring }}
>
<span className="rounded-full flex p-[3px]" style={{ background: "var(--panel)" }}>
<Avatar
name={e.nickname || e.username}
src={e.avatar || undefined}
size={first ? 66 : 50}
/>
</span>
</span>
<p
className={`max-w-full truncate font-bold ${first ? "text-[15px] sm:text-[16px]" : "text-[14px]"}`}
style={{ color: "var(--ink)" }}
>
{e.nickname || e.username}
</p>
<div className="mt-1.5 flex items-center gap-1.5">
<LevelBadge level={e.level} />
<span className="meta text-[11px] hidden sm:inline">@{e.username}</span>
</div>
<p
className={`mt-3 font-extrabold tabular-nums ${first ? "text-[26px] sm:text-[30px]" : "text-[20px] sm:text-[22px]"}`}
style={{ color: first ? "var(--accent)" : "var(--ink)" }}
>
{e.score.toLocaleString()}
</p>
<p className="meta text-[11px]">{scoreLabel(metric, period)}</p>
<p className="meta text-[11px] mt-2 flex items-center gap-2">
<span className="inline-flex items-center gap-1">
<FileText size={11} /> {e.post_count}
</span>
<span aria-hidden>·</span>
<span className="inline-flex items-center gap-1">
<MessageSquare size={11} /> {e.comment_count}
</span>
</p>
</Link>
);
}
/** 4 名起的列表行 */
function RankRow({ e, period, metric }: {
e: LeaderboardEntry;
period: LeaderboardPeriod;
metric: LeaderboardMetric;
}) {
return (
<Link
href={`/u/${e.id}`}
className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3.5 transition-colors hover:bg-[var(--panel-2)]"
style={{ borderTop: "1px solid var(--line)" }}
>
<span
className="w-7 text-center text-[15px] font-bold tabular-nums shrink-0"
style={{ color: e.rank <= 10 ? "var(--accent)" : "var(--ink-3)" }}
>
{e.rank}
</span>
<Avatar name={e.nickname || e.username} src={e.avatar || undefined} size={40} />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5 min-w-0">
<span className="text-[14.5px] font-semibold truncate" style={{ color: "var(--ink)" }}>
{e.nickname || e.username}
</span>
<LevelBadge level={e.level} />
</span>
<span className="meta text-[11.5px] block truncate">@{e.username}</span>
</span>
<span className="hidden md:flex items-center gap-3 text-[12px] meta shrink-0 tabular-nums">
<span className="inline-flex items-center gap-1">
<FileText size={12} /> {e.post_count}
</span>
<span className="inline-flex items-center gap-1">
<MessageSquare size={12} /> {e.comment_count}
</span>
<span className="inline-flex items-center gap-1">
<ThumbsUp size={12} /> {e.like_count}
</span>
{e.answer_count > 0 && (
<span className="inline-flex items-center gap-1">
<BadgeCheck size={12} /> {e.answer_count}
</span>
)}
</span>
<span className="text-right shrink-0 w-[74px] sm:w-[88px]">
<span
className="block text-[16px] sm:text-[17px] font-extrabold tabular-nums"
style={{ color: "var(--ink)" }}
>
{e.score.toLocaleString()}
</span>
<span className="meta text-[10.5px] block">{scoreLabel(metric, period)}</span>
</span>
</Link>
);
}
/** 右栏:我的排名(登录用户带本周/本月名次变化) */
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 (
<section className="panel p-5" aria-label="我的排名">
<header className="flex items-center justify-between gap-2">
<h2 className="text-[15px] font-bold flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
<Trophy size={15} style={{ color: "var(--accent)" }} /> 我的排名
</h2>
{me && me.rank > 0 && period !== "all" && (
me.has_change ? (
me.change > 0 ? (
<span
className="inline-flex items-center gap-0.5 text-[12px] font-semibold"
style={{ color: "var(--ok)" }}
title={`${periodLabel}上升 ${me.change} 名`}
>
<TrendingUp size={13} /> {periodLabel}上升 {me.change} 名
</span>
) : me.change < 0 ? (
<span
className="inline-flex items-center gap-0.5 text-[12px] font-semibold"
style={{ color: "var(--danger)" }}
title={`${periodLabel}下降 ${-me.change} 名`}
>
<TrendingDown size={13} /> {periodLabel}下降 {-me.change} 名
</span>
) : (
<span className="meta text-[12px]">{periodLabel}持平</span>
)
) : (
<span className="meta text-[12px]">{periodLabel}新上榜</span>
)
)}
</header>
{!authed ? (
<div className="text-center py-7">
<span
className="inline-flex w-12 h-12 rounded-full items-center justify-center mb-3"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<Trophy size={22} />
</span>
<p className="text-[14px] font-semibold" style={{ color: "var(--ink)" }}>
登录后查看我的排名
</p>
<p className="meta text-[12px] mt-1">参与发帖、回帖与签到即可上榜</p>
<Link
href={`/login?redirect=${encodeURIComponent("/leaderboard")}`}
className="btn btn-primary mt-4"
>
立即登录
</Link>
</div>
) : me && me.rank > 0 ? (
<div className="mt-4 flex items-center gap-4">
<div className="shrink-0">
<p
className="text-[40px] leading-none font-extrabold tabular-nums"
style={{ color: me.rank <= 3 ? "var(--accent)" : "var(--ink)" }}
>
<span className="text-[18px] align-top font-bold" style={{ color: "var(--ink-3)" }}>#</span>
{me.rank}
</p>
</div>
<div className="min-w-0">
<p className="text-[13px] font-semibold" style={{ color: "var(--ink)" }}>
{label} {me.score.toLocaleString()}
</p>
<p className="meta text-[12px] mt-0.5">
{period === "all" ? "总榜" : periodLabel} · 共 {me.total} 人上榜
</p>
</div>
</div>
) : (
<div className="mt-4 text-center py-3">
<p className="text-[15px] font-bold" style={{ color: "var(--ink)" }}>
暂未上榜
</p>
<p className="meta text-[12px] mt-1.5">
{period === "all" ? "签到或发帖积累积分即可上榜" : `${periodLabel}内参与互动即可上榜`}
</p>
<Link href="/compose" className="btn btn-primary mt-4">
<PenSquare size={14} /> 写新帖
</Link>
</div>
)}
</section>
);
}
/** 右栏:贡献值规则(数据来自公开设置的积分规则 8 键) */
function RulesPanel({ s }: { s: PublicSettings }) {
const rows: { icon: React.ReactNode; label: string; value: string; note?: string }[] = [
{
icon: <CalendarCheck size={14} />,
label: "每日签到",
value: `+${s.points_checkin_base}`,
note: `连续每 ${s.points_streak_every_days} 天额外 +${s.points_streak_bonus}`,
},
{
icon: <FileText size={14} />,
label: "发表主题",
value: `+${s.points_post_reward}`,
note: `每日上限 ${s.points_post_daily_cap} 篇`,
},
{
icon: <MessageSquare size={14} />,
label: "发表回复",
value: `+${s.points_reply_reward}`,
note: `每日上限 ${s.points_reply_daily_cap} 条`,
},
{
icon: <ThumbsUp size={14} />,
label: "帖子被推荐",
value: `+${s.points_recommend_reward}`,
note: "每帖仅计一次",
},
{
icon: <BadgeCheck size={14} />,
label: "回答被采纳",
value: "悬赏另计",
note: "悬赏积分由提问者设置",
},
];
return (
<section className="panel p-5" aria-label="贡献值规则">
<h2 className="text-[15px] font-bold flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
<Medal size={15} style={{ color: "var(--accent)" }} /> 贡献值规则
</h2>
<ul className="mt-3">
{rows.map((r) => (
<li
key={r.label}
className="flex items-center gap-2.5 py-2.5"
style={{ borderTop: "1px solid var(--line)" }}
>
<span
className="w-7 h-7 rounded-full shrink-0 inline-flex items-center justify-center"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
{r.icon}
</span>
<span className="min-w-0 flex-1">
<span className="block text-[13px] font-semibold" style={{ color: "var(--ink)" }}>
{r.label}
</span>
{r.note && <span className="meta text-[11px] block truncate">{r.note}</span>}
</span>
<span
className="text-[14px] font-extrabold tabular-nums shrink-0"
style={{ color: "var(--accent)" }}
>
{r.value}
</span>
</li>
))}
</ul>
<p className="meta text-[11.5px] mt-3 leading-relaxed">
累计积分即贡献值,决定等级与总榜排名;消费仅扣余额,不减少累计值。
</p>
</section>
);
}
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 (
<div className="grid gap-5 lg:gap-6 lg:grid-cols-[minmax(0,1fr)_320px] items-start">
{/* 左栏:贡献排行榜 */}
<div className="min-w-0">
{/* Hero:渐变洗底 + 光斑装饰 */}
<section
className="relative overflow-hidden rounded-[28px] px-6 py-8 sm:px-10 sm:py-10 text-center"
style={{
background:
"radial-gradient(120% 160% at 12% -10%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 55%)," +
"radial-gradient(90% 140% at 90% -20%, color-mix(in srgb, var(--accent) 8%, transparent), transparent 50%)",
border: "1px solid var(--line)",
}}
>
<div
aria-hidden
className="absolute -top-16 -right-10 w-56 h-56 rounded-full pointer-events-none"
style={{
background: "color-mix(in srgb, var(--accent) 14%, transparent)",
filter: "blur(48px)",
}}
/>
<div className="relative">
<h1
className="text-[26px] sm:text-[32px] leading-tight font-extrabold tracking-tight"
style={{ color: "var(--ink)" }}
>
贡献排行榜
</h1>
<p className="meta mt-2 text-[13.5px]">每一分积累,都是社区前行的足迹</p>
{entries.length > 0 && (
<div className="mt-4 flex items-center justify-center gap-5 sm:gap-8">
<div>
<p className="text-[18px] font-extrabold tabular-nums" style={{ color: "var(--ink)" }}>
{entries.length}
</p>
<p className="meta text-[11px]">上榜用户</p>
</div>
<div aria-hidden className="w-px h-7" style={{ background: "var(--line)" }} />
<div>
<p className="text-[18px] font-extrabold tabular-nums" style={{ color: "var(--ink)" }}>
{totalScore.toLocaleString()}
</p>
<p className="meta text-[11px]">
{period === "all" && metric === "all" ? "累计贡献积分" : "贡献合计"}
</p>
</div>
<div aria-hidden className="w-px h-7 hidden sm:block" style={{ background: "var(--line)" }} />
<div className="hidden sm:block max-w-[150px]">
<p className="text-[18px] font-extrabold truncate" style={{ color: "var(--ink)" }}>
{podium[0]?.nickname || podium[0]?.username || "—"}
</p>
<p className="meta text-[11px]">当前榜首</p>
</div>
</div>
)}
</div>
</section>
{/* 周期 Tab(本周/本月/总榜)+ 维度细分(全部/发帖/回复/获赞/最佳答案) */}
<div className="mt-6 flex flex-col items-center gap-3">
<div
className="inline-flex p-1 rounded-full"
style={{ border: "1px solid var(--line)", background: "var(--panel)" }}
role="navigation"
aria-label="榜单周期"
>
{PERIODS.map((p) => (
<Link
key={p.key}
href={lbHref(p.key, metric)}
className="px-5 sm:px-6 py-2 rounded-full text-[14px] font-semibold transition-colors"
style={
period === p.key
? { background: "var(--accent)", color: "#fff" }
: { color: "var(--ink-2)" }
}
aria-current={period === p.key ? "page" : undefined}
>
{p.label}
</Link>
))}
</div>
<div
className="flex flex-wrap justify-center gap-1.5"
role="navigation"
aria-label="榜单维度"
>
{METRICS.map((m) => {
const active = metric === m.key;
return (
<Link
key={m.key}
href={lbHref(period, m.key)}
className="px-3 py-1.5 rounded-full text-[12.5px] font-medium transition-colors"
style={
active
? {
background: "color-mix(in srgb, var(--accent) 12%, transparent)",
color: "var(--accent)",
boxShadow: "inset 0 0 0 1px color-mix(in srgb, var(--accent) 40%, transparent)",
}
: { color: "var(--ink-2)" }
}
aria-current={active ? "page" : undefined}
>
{m.label}
</Link>
);
})}
</div>
</div>
{entries.length === 0 ? (
<div className="text-center py-16 px-6">
<span
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<Trophy size={26} />
</span>
<p className="font-bold text-lg" style={{ color: "var(--ink)" }}>
榜单虚位以待
</p>
<p className="meta mt-1.5">
{period === "all" ? "签到、发帖、回帖都能积累积分上榜" : "这个周期还没有动态,快来抢占先机"}
</p>
<Link href="/compose" className="btn btn-primary mt-6">
<PenSquare size={15} /> 写新帖
</Link>
</div>
) : (
<>
{/* 领奖台:DOM 顺序 2-1-3,冠军居中放大 */}
<div className="mt-7 grid grid-cols-3 gap-2.5 sm:gap-4 items-start">
{podium[1] && <PodiumCard e={podium[1]} place={2} period={period} metric={metric} />}
{podium[0] && <PodiumCard e={podium[0]} place={1} period={period} metric={metric} />}
{podium[2] && <PodiumCard e={podium[2]} place={3} period={period} metric={metric} />}
</div>
{rest.length > 0 && (
<div className="panel overflow-hidden mt-4">
{rest.map((e) => (
<RankRow key={e.id} e={e} period={period} metric={metric} />
))}
</div>
)}
</>
)}
</div>
{/* 右栏:我的排名 + 贡献值规则 */}
<aside className="space-y-4 lg:sticky lg:top-[76px]">
<MyRankPanel me={me} authed={authed} period={period} metric={metric} />
<RulesPanel s={settings} />
</aside>
</div>
);
}

View File

@@ -24,6 +24,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: "daily", changeFrequency: "daily",
priority: 0.7, priority: 0.7,
}, },
{
url: url("/leaderboard"),
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.5,
},
{ {
url: url("/about"), url: url("/about"),
lastModified: new Date(), lastModified: new Date(),

View File

@@ -14,6 +14,7 @@ import {
Settings, Settings,
Bell, Bell,
Home, Home,
Trophy,
User as UserIcon, User as UserIcon,
ChevronDown, ChevronDown,
ShieldCheck, ShieldCheck,
@@ -241,6 +242,13 @@ export default function Header({
<NavPill href="/" active={pathname === "/"} icon={<Home size={15} />}> <NavPill href="/" active={pathname === "/"} icon={<Home size={15} />}>
首页 首页
</NavPill> </NavPill>
<NavPill
href="/leaderboard"
active={pathname?.startsWith("/leaderboard") ?? false}
icon={<Trophy size={15} />}
>
排行榜
</NavPill>
</nav> </nav>
</div> </div>
@@ -366,6 +374,11 @@ export default function Header({
</form> </form>
<div className="flex flex-col"> <div className="flex flex-col">
<MobileLink href="/" active={pathname === "/"}>首页</MobileLink> <MobileLink href="/" active={pathname === "/"}>首页</MobileLink>
<MobileLink href="/leaderboard" active={pathname?.startsWith("/leaderboard") ?? false}>
<span className="inline-flex items-center gap-2">
<Trophy size={15} /> 排行榜
</span>
</MobileLink>
{user && !hideComposeNav && <MobileLink href="/compose">发帖</MobileLink>} {user && !hideComposeNav && <MobileLink href="/compose">发帖</MobileLink>}
{user && allowMessages && ( {user && allowMessages && (
<MobileLink href="/chat" active={pathname?.startsWith("/chat") ?? false}> <MobileLink href="/chat" active={pathname?.startsWith("/chat") ?? false}>

View File

@@ -759,6 +759,79 @@ export async function fetchBoardSidebar(
return res.json(); 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<LeaderboardResponse> {
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<LeaderboardMe | null> {
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[] }> { export async function fetchBoards(): Promise<{ boards: BoardWithCount[] }> {
const res = await fetch(`${API_BASE}/api/boards`, ssrInit()); const res = await fetch(`${API_BASE}/api/boards`, ssrInit());
if (!res.ok) throw new Error("获取板块失败"); if (!res.ok) throw new Error("获取板块失败");