From e7166ecf81cf71b50112cd9295e23b4796591f7d Mon Sep 17 00:00:00 2001 From: iye <1713042409@qq.com> Date: Tue, 12 May 2026 09:43:41 +0800 Subject: [PATCH] feat(ranking): /ranking page with Top3 podium, Top4-12 list, debut line divider and rescue zone --- src/app/ranking/page.tsx | 124 +++++++++++++++++++ src/components/ranking/DebutLineDivider.tsx | 25 ++++ src/components/ranking/RankingRow.tsx | 130 ++++++++++++++++++++ src/components/ranking/Top3Podium.tsx | 111 +++++++++++++++++ 4 files changed, 390 insertions(+) create mode 100644 src/app/ranking/page.tsx create mode 100644 src/components/ranking/DebutLineDivider.tsx create mode 100644 src/components/ranking/RankingRow.tsx create mode 100644 src/components/ranking/Top3Podium.tsx diff --git a/src/app/ranking/page.tsx b/src/app/ranking/page.tsx new file mode 100644 index 0000000..a78cd8d --- /dev/null +++ b/src/app/ranking/page.tsx @@ -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(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 ( + <> +
+ {/* 头部 */} +
+

+ Live Ranking · 2026 +

+

+ Top + + 35 +

+

35 位候选人 · 实时排名

+
+ +
+
+ + {/* Top3 领奖台 */} + + + {/* Top 4-12 标题 */} +
+ +

+ 出道位 · Top 4 ~ 12 +

+
+ + {/* 表头 */} +
+ 排名 + 头像 + 艺人 + 票数 + 距上一名 + 操作 +
+ + {/* Top4-12 行 */} +
+ {top4to12.map((a, idx) => { + const prev = idx === 0 ? top3[2] : top4to12[idx - 1]; + const gap = prev ? prev.votes - a.votes : undefined; + return ( + + ); + })} +
+ + {/* 出道线 */} + + + {/* 候补区 */} +
+ {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 ( + + ); + })} +
+ + {/* 底部提示 */} +

+ 排名每分钟更新一次 · 投票后立即生效 +

+
+ + setVoteTarget(null)} + onConfirm={handleVote} + /> + + ); +} diff --git a/src/components/ranking/DebutLineDivider.tsx b/src/components/ranking/DebutLineDivider.tsx new file mode 100644 index 0000000..142b49c --- /dev/null +++ b/src/components/ranking/DebutLineDivider.tsx @@ -0,0 +1,25 @@ +export default function DebutLineDivider() { + return ( +
+ {/* 横线 */} +
+ + {/* 中央徽章 */} +
+ + + Debut Line · 出道线 + + +
+
+ ); +} diff --git a/src/components/ranking/RankingRow.tsx b/src/components/ranking/RankingRow.tsx new file mode 100644 index 0000000..9fcb760 --- /dev/null +++ b/src/components/ranking/RankingRow.tsx @@ -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 ( +
+ {/* 排名 */} +
+ #{artist.rank} +
+ + {/* 头像 */} + +
+ +
+ + + {/* 姓名 + slogan */} + +
+ {artist.name}{" "} + + · {artist.enName} + +
+
+ {artist.slogan} +
+ + + {/* 票数 */} +
+
+ {(artist.votes / 10000).toFixed(1)}w +
+
+
+ + {/* 差距 */} +
+ {isRescue && gapToDebut != null ? ( +
+ + 差 +{gapToDebut.toLocaleString()} 进出道位 +
+ ) : gapAbove != null && artist.rank > 1 ? ( +
+ − + {gapAbove.toLocaleString()} +
+ ) : ( + + )} +
+ + {/* 投票按钮 */} + +
+ ); +} diff --git a/src/components/ranking/Top3Podium.tsx b/src/components/ranking/Top3Podium.tsx new file mode 100644 index 0000000..46e578e --- /dev/null +++ b/src/components/ranking/Top3Podium.tsx @@ -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 ( +
+ {order.map(({ artist, rank }) => { + const style = STYLES[rank]; + return ( + +
+ {style.label} + {style.rank} +
+
+ +
+
+ {artist.name} +
+
+ {artist.enName} +
+
+ {(artist.votes / 10000).toFixed(1)}w +
+
+ + ); + })} +
+ ); +}