Files
jiang13-bbs/frontend/app/leaderboard/page.tsx
freefire cab8230803 feat: 新增排行榜模块
- 新增 leaderboard 服务与 handler、前端排行榜页面
- 路由与首页入口适配
2026-09-25 18:28:12 +08:00

598 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}