feat(ranking): /ranking page with Top3 podium, Top4-12 list, debut line divider and rescue zone
This commit is contained in:
parent
5f06b5122b
commit
e7166ecf81
124
src/app/ranking/page.tsx
Normal file
124
src/app/ranking/page.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
src/components/ranking/DebutLineDivider.tsx
Normal file
25
src/components/ranking/DebutLineDivider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
src/components/ranking/RankingRow.tsx
Normal file
130
src/components/ranking/RankingRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
src/components/ranking/Top3Podium.tsx
Normal file
111
src/components/ranking/Top3Podium.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user