feat(ranking): /ranking page with Top3 podium, Top4-12 list, debut line divider and rescue zone

This commit is contained in:
iye 2026-05-12 09:43:41 +08:00
parent 5f06b5122b
commit e7166ecf81
4 changed files with 390 additions and 0 deletions

124
src/app/ranking/page.tsx Normal file
View File

@ -0,0 +1,124 @@
"use client";
import { useMemo, useState } from "react";
import { Sparkles } from "lucide-react";
import Top3Podium from "@/components/ranking/Top3Podium";
import RankingRow from "@/components/ranking/RankingRow";
import DebutLineDivider from "@/components/ranking/DebutLineDivider";
import VoteModal from "@/components/VoteModal";
import Countdown from "@/components/ui/Countdown";
import { ARTISTS, getActivityEndTime, sortArtists } from "@/lib/mock-data";
import type { Artist } from "@/types/artist";
export default function RankingPage() {
const [voteTarget, setVoteTarget] = useState<Artist | null>(null);
const sorted = useMemo(() => sortArtists(ARTISTS, "votes"), []);
const endTime = useMemo(() => getActivityEndTime(), []);
const top3 = sorted.slice(0, 3);
const top4to12 = sorted.slice(3, 12);
const candidates = sorted.slice(12);
// 计算 #13 与第 12 名的差距,用于"救援投票"
const debutCutoff = sorted[11]?.votes ?? 0;
const handleVote = async (a: Artist, count: number) => {
await new Promise((r) => setTimeout(r, 400));
console.log(`Vote: ${a.name} × ${count}`);
setVoteTarget(null);
};
return (
<>
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
{/* 头部 */}
<div className="text-center mb-10">
<p className="font-label text-[10px] sm:text-xs tracking-[0.4em] uppercase text-purple-300 mb-2">
Live Ranking · 2026
</p>
<h1 className="font-logo text-4xl sm:text-5xl tracking-[0.3em] uppercase glow-text-purple mb-3 inline-flex items-baseline">
Top
<span className="text-purple-300 mx-2 text-2xl sm:text-3xl"></span>
35
</h1>
<p className="text-white/55 text-sm mb-5">35 · </p>
<div className="flex justify-center">
<Countdown endTime={endTime} compact />
</div>
</div>
{/* Top3 领奖台 */}
<Top3Podium top3={top3} />
{/* Top 4-12 标题 */}
<div className="flex items-center gap-2 mt-10 mb-4">
<Sparkles size={14} className="text-purple-300" />
<h2 className="font-display text-sm tracking-[0.25em] text-white uppercase">
· Top 4 ~ 12
</h2>
</div>
{/* 表头 */}
<div className="hidden sm:grid grid-cols-[64px_64px_1fr_100px_140px_110px] gap-4 px-3 py-2 text-[10px] tracking-widest uppercase text-white/40 font-label">
<span className="text-center"></span>
<span></span>
<span></span>
<span className="text-right"></span>
<span className="text-right"></span>
<span className="text-center"></span>
</div>
{/* Top4-12 行 */}
<div className="space-y-2">
{top4to12.map((a, idx) => {
const prev = idx === 0 ? top3[2] : top4to12[idx - 1];
const gap = prev ? prev.votes - a.votes : undefined;
return (
<RankingRow
key={a.id}
artist={a}
gapAbove={gap}
onVote={setVoteTarget}
/>
);
})}
</div>
{/* 出道线 */}
<DebutLineDivider />
{/* 候补区 */}
<div className="space-y-2">
{candidates.map((a, idx) => {
const prev = idx === 0 ? top4to12[top4to12.length - 1] : candidates[idx - 1];
const gap = prev ? prev.votes - a.votes : undefined;
const isRescue = idx === 0; // 第 13 位
const gapToDebut = isRescue ? debutCutoff - a.votes + 1 : undefined;
return (
<RankingRow
key={a.id}
artist={a}
gapAbove={gap}
gapToDebut={gapToDebut}
isRescue={isRescue}
onVote={setVoteTarget}
/>
);
})}
</div>
{/* 底部提示 */}
<p className="text-xs text-white/35 text-center mt-10">
·
</p>
</div>
<VoteModal
artist={voteTarget}
onClose={() => setVoteTarget(null)}
onConfirm={handleVote}
/>
</>
);
}

View File

@ -0,0 +1,25 @@
export default function DebutLineDivider() {
return (
<div className="my-6 relative flex items-center justify-center" role="separator">
{/* 横线 */}
<div
aria-hidden
className="absolute inset-x-0 h-px"
style={{
background:
"linear-gradient(90deg, transparent 0%, rgba(236,72,153,0.6) 50%, transparent 100%)",
boxShadow: "0 0 12px rgba(236,72,153,0.4)",
}}
/>
{/* 中央徽章 */}
<div className="relative bg-[var(--color-deepest)] px-5 py-2 rounded-full border border-pink-500/45 inline-flex items-center gap-2 backdrop-blur-md">
<span className="text-pink-400 text-xs"></span>
<span className="font-label text-[10px] sm:text-[11px] tracking-[0.3em] uppercase text-pink-400">
Debut Line · 线
</span>
<span className="text-pink-400 text-xs"></span>
</div>
</div>
);
}

View File

@ -0,0 +1,130 @@
"use client";
import Link from "next/link";
import { TrendingUp, AlertTriangle } from "lucide-react";
import type { Artist } from "@/types/artist";
import { getRankCategory } from "@/types/artist";
import ArtistPortrait from "@/components/cards/ArtistPortrait";
import { cn } from "@/lib/cn";
interface RankingRowProps {
artist: Artist;
/** 与上一名差距(票数) */
gapAbove?: number;
/** 与出道线差距(票数):候补区第一位用于"差 X 票进出道位" */
gapToDebut?: number;
/** 是否为出道线下第一位(用于"救援投票" */
isRescue?: boolean;
onVote: (a: Artist) => void;
}
export default function RankingRow({
artist,
gapAbove,
gapToDebut,
isRescue = false,
onVote,
}: RankingRowProps) {
const cat = getRankCategory(artist.rank);
const inTop12 = artist.rank <= 12;
return (
<div
className={cn(
"grid grid-cols-[44px_56px_1fr_84px_120px_90px] sm:grid-cols-[64px_64px_1fr_100px_140px_110px] items-center gap-2 sm:gap-4 px-3 py-2.5 rounded-xl border transition-all",
inTop12
? "bg-purple-500/[0.05] border-purple-500/30 hover:bg-purple-500/[0.08]"
: isRescue
? "bg-pink-500/[0.04] border-pink-500/30 hover:bg-pink-500/[0.06]"
: "bg-surface/40 border-white/[0.06] hover:bg-surface/60",
)}
>
{/* 排名 */}
<div
className={cn(
"font-display text-base sm:text-lg tabular-nums text-center",
inTop12 ? "text-purple-300 glow-text-purple" : "text-white/70",
)}
>
#{artist.rank}
</div>
{/* 头像 */}
<Link href={`/artist/${artist.id}`} className="block">
<div
className={cn(
"w-10 h-10 sm:w-12 sm:h-12 rounded-full overflow-hidden border-2",
cat === "gold" && "border-[#fcd34d] shadow-[0_0_12px_rgba(252,211,77,0.45)]",
cat === "silver" && "border-[#c4ccd8]",
cat === "bronze" && "border-[#cd7f32]",
cat === "top12" && "border-purple-500/70",
cat === "candidate" && "border-white/15",
)}
>
<ArtistPortrait
artist={artist}
rounded="rounded-full"
className="w-full h-full"
/>
</div>
</Link>
{/* 姓名 + slogan */}
<Link
href={`/artist/${artist.id}`}
className="min-w-0 hover:text-purple-300 transition-colors"
>
<div className="text-sm text-white font-semibold truncate">
{artist.name}{" "}
<span className="text-white/55 font-normal text-xs">
· {artist.enName}
</span>
</div>
<div className="text-[10px] sm:text-[11px] text-white/40 truncate">
{artist.slogan}
</div>
</Link>
{/* 票数 */}
<div className="text-right">
<div className="font-display text-sm text-purple-300 tabular-nums">
{(artist.votes / 10000).toFixed(1)}w
</div>
<div className="text-[10px] text-white/40"></div>
</div>
{/* 差距 */}
<div className="text-right hidden sm:block">
{isRescue && gapToDebut != null ? (
<div className="inline-flex items-center gap-1 text-pink-400 text-xs font-display">
<AlertTriangle size={11} />
+{gapToDebut.toLocaleString()}
</div>
) : gapAbove != null && artist.rank > 1 ? (
<div className="inline-flex items-center gap-1 text-white/45 text-xs font-display tabular-nums">
<TrendingUp size={11} className="text-orange-400" />
{gapAbove.toLocaleString()}
</div>
) : (
<span className="text-white/30 text-xs"></span>
)}
</div>
{/* 投票按钮 */}
<button
type="button"
onClick={() => onVote(artist)}
className={cn(
"h-8 rounded-full font-display text-[10px] tracking-widest uppercase transition-all px-3",
isRescue
? "bg-pink-500 text-white shadow-[0_0_14px_rgba(236,72,153,0.45)] hover:brightness-110"
: inTop12
? "bg-grad-purple text-white shadow-[0_0_12px_rgba(139,92,246,0.35)] hover:brightness-110"
: "bg-elevated border border-white/15 text-white/70 hover:text-white",
)}
>
{isRescue ? "救援投票" : "Vote"}
</button>
</div>
);
}

View File

@ -0,0 +1,111 @@
import Link from "next/link";
import type { Artist } from "@/types/artist";
import ArtistPortrait from "@/components/cards/ArtistPortrait";
import { cn } from "@/lib/cn";
interface Top3PodiumProps {
top3: Artist[];
}
const STYLES = {
1: {
label: "🥇",
rank: "#1",
color: "text-[#fcd34d]",
border: "border-[#fcd34d]",
glow: "shadow-[0_0_28px_rgba(252,211,77,0.45)]",
bg: "bg-gradient-to-b from-[#fcd34d22] to-transparent",
scale: "lg:scale-110",
size: "w-28 h-28 sm:w-32 sm:h-32",
},
2: {
label: "🥈",
rank: "#2",
color: "text-[#c4ccd8]",
border: "border-[#c4ccd8]",
glow: "shadow-[0_0_20px_rgba(196,204,216,0.3)]",
bg: "bg-gradient-to-b from-[#c4ccd822] to-transparent",
scale: "",
size: "w-24 h-24 sm:w-28 sm:h-28",
},
3: {
label: "🥉",
rank: "#3",
color: "text-[#cd7f32]",
border: "border-[#cd7f32]",
glow: "shadow-[0_0_20px_rgba(205,127,50,0.3)]",
bg: "bg-gradient-to-b from-[#cd7f3222] to-transparent",
scale: "",
size: "w-24 h-24 sm:w-28 sm:h-28",
},
} as const;
export default function Top3Podium({ top3 }: Top3PodiumProps) {
const [first, second, third] = top3;
if (!first || !second || !third) return null;
// 视觉上:第二名 · 第一名 · 第三名 的顺序排列
const order: Array<{ artist: Artist; rank: 1 | 2 | 3 }> = [
{ artist: second, rank: 2 },
{ artist: first, rank: 1 },
{ artist: third, rank: 3 },
];
return (
<div className="grid grid-cols-3 gap-3 sm:gap-6 items-end py-6 sm:py-10 px-2 sm:px-4 rounded-2xl border border-white/[0.06] bg-gradient-to-b from-purple-500/[0.05] to-transparent">
{order.map(({ artist, rank }) => {
const style = STYLES[rank];
return (
<Link
key={artist.id}
href={`/artist/${artist.id}`}
className={cn(
"group flex flex-col items-center text-center transition-transform",
style.scale,
"hover:-translate-y-1",
)}
>
<div
className={cn(
"font-display text-xl sm:text-2xl mb-2",
style.color,
)}
>
<span className="mr-1">{style.label}</span>
{style.rank}
</div>
<div
className={cn(
"rounded-full overflow-hidden border-[3px] mb-3",
style.size,
style.border,
style.glow,
)}
>
<ArtistPortrait
artist={artist}
rounded="rounded-full"
className="w-full h-full"
/>
</div>
<div className="text-sm sm:text-base font-semibold text-white truncate max-w-full">
{artist.name}
</div>
<div className="font-display text-[10px] sm:text-xs tracking-widest text-purple-300/70 uppercase mt-0.5">
{artist.enName}
</div>
<div
className={cn(
"font-display text-sm sm:text-lg tabular-nums mt-2",
style.color,
)}
>
{(artist.votes / 10000).toFixed(1)}w
</div>
<div className="text-[10px] text-white/40"></div>
</Link>
);
})}
</div>
);
}