新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
619 lines
23 KiB
TypeScript
619 lines
23 KiB
TypeScript
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 { userPath, leaderboardPath, type UrlStyle } from "@/lib/urlStyle";
|
||
import Avatar from "@/components/Avatar";
|
||
import LevelBadge from "@/components/LevelBadge";
|
||
import MobileRailDrawers from "@/components/MobileRailDrawers";
|
||
|
||
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(urlStyle: UrlStyle, 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();
|
||
const base = leaderboardPath(urlStyle);
|
||
return s ? `${base}?${s}` : base;
|
||
}
|
||
|
||
/** 右侧分数标签:总榜-全部=累计积分,周/月-全部=贡献值,细分=对应指标名 */
|
||
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(leaderboardPath(settings.url_style))),
|
||
};
|
||
}
|
||
|
||
/** 金银铜固定金属色(装饰用,不随明暗主题翻转) */
|
||
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, urlStyle }: {
|
||
e: LeaderboardEntry;
|
||
place: 1 | 2 | 3;
|
||
period: LeaderboardPeriod;
|
||
metric: LeaderboardMetric;
|
||
urlStyle: UrlStyle;
|
||
}) {
|
||
const m = METAL[place];
|
||
const first = place === 1;
|
||
return (
|
||
<Link
|
||
href={userPath(urlStyle, 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, urlStyle }: {
|
||
e: LeaderboardEntry;
|
||
period: LeaderboardPeriod;
|
||
metric: LeaderboardMetric;
|
||
urlStyle: UrlStyle;
|
||
}) {
|
||
return (
|
||
<Link
|
||
href={userPath(urlStyle, 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 urlStyle = settings.url_style;
|
||
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 (
|
||
<>
|
||
{/* 移动端:右栏(我的排名 + 贡献值规则)收进右抽屉,lg 起隐藏 */}
|
||
<MobileRailDrawers
|
||
right={{
|
||
label: "排名与规则",
|
||
content: (
|
||
<>
|
||
<MyRankPanel me={me} authed={authed} period={period} metric={metric} />
|
||
<RulesPanel s={settings} />
|
||
</>
|
||
),
|
||
}}
|
||
/>
|
||
|
||
<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(urlStyle, 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(urlStyle, 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} urlStyle={urlStyle} />}
|
||
{podium[0] && <PodiumCard e={podium[0]} place={1} period={period} metric={metric} urlStyle={urlStyle} />}
|
||
{podium[2] && <PodiumCard e={podium[2]} place={3} period={period} metric={metric} urlStyle={urlStyle} />}
|
||
</div>
|
||
|
||
{rest.length > 0 && (
|
||
<div className="panel overflow-hidden mt-4">
|
||
{rest.map((e) => (
|
||
<RankRow key={e.id} e={e} period={period} metric={metric} urlStyle={urlStyle} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* 右栏:我的排名 + 贡献值规则(移动端收进右抽屉) */}
|
||
<aside className="hidden lg:block space-y-4 lg:sticky lg:top-[76px]">
|
||
<MyRankPanel me={me} authed={authed} period={period} metric={metric} />
|
||
<RulesPanel s={settings} />
|
||
</aside>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|