数据准确性 - 票数初始为 0,不再用 Math.sqrt 公式造假票 - 排序 tiebreaker 统一为 votes desc + no asc,确保稳定 - store.rank() 与 sortArtists() 行为对齐 Top12 / 出道位 - Top12Bar 仅收纳真正有票的人(votes > 0),0 票时显示"出道位尚未产生"空态 - ArtistCard / RankingRow / SearchModal / MyFanSupport / RankCard 的 inTop12 高亮全部加 votes > 0 守卫 - ArtistFilters 新增"实时排名 / 编号顺序"分段切换 + 首页 sortKey 状态 领奖台 (Top3Podium) - 1 人有票即可显示领奖台(此前要求 >= 3 人才显示) - 缺位的 #2/#3 用"虚位以待"占位卡片填充,与正式卡片同 3:4 比例对齐 - 全员 0 票时三张全部显示虚位以待 - 卡片间距拉大到 gap-8 sm:gap-12 排行榜页 (/ranking) - API 票数与本地乐观投票取 max() 合并,避免 API 落后覆盖本地新票 - 合并后重新排序与赋 rank - 仅 >= 12 人有票才显示出道线分隔与复活位标记 - 复活位 gapToDebut 计算修正 跨日额度 - selectRemaining 按 quotaDate 判断是否跨日,跨日自动回满额(此前会卡在昨日剩余值) 搜索 (SearchModal) - 改为订阅 store 拿活的票数,投票后立即反映 - 默认"热门 Top12"只在真正有票时显示,否则降级为"推荐艺人 · 编号顺序" - 票数显示统一走 formatVotes(0 票不再显示 0.0w) 人物详情 - 36 人真实数据接入,移除全部静态数据(slogan/birthday/cv/themeColor) - 接入人物小传 docx 数据(年龄/身高/性格/口头禅/技能/赛道/座右铭/长简介) - 视频区与版心同宽 + 首帧自动作为封面 + 整区点击播放/暂停 + 可拖拽进度条 - 表演图片改为三张氛围图竖向 3:4,左对齐 - 移除分享按钮,投票按钮全宽 个人页 (/me) - 移除等级/邀请好友/签到/编辑资料等静态数据 - 退出登录按钮在移动端 icon-only 显示(此前 sm:hidden 导致移动端无法登出) - 我的应援 list 基于真实 myVotesByArtist 派生,凯之类的投票真正同步过去 导航 - 余票徽章未登录态显示 0/0,已登录显示 N/10 - 登录/注册按钮样式与登录后头像胶囊保持一致(紫色实心) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
148 lines
5.7 KiB
TypeScript
148 lines
5.7 KiB
TypeScript
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[];
|
||
}
|
||
|
||
function formatVotes(v: number): string {
|
||
if (v >= 10_000) return `${(v / 10_000).toFixed(1)}w`;
|
||
return v.toLocaleString();
|
||
}
|
||
|
||
export default function Top3Podium({ top3 }: Top3PodiumProps) {
|
||
const [first, second, third] = top3;
|
||
|
||
// 0 票时按编号兜底排出来的"伪冠军"不算 —— 这些位置当作缺位处理
|
||
const champ = first && first.votes > 0 ? first : undefined;
|
||
const runnerUp = second && second.votes > 0 ? second : undefined;
|
||
const thirdPlace = third && third.votes > 0 ? third : undefined;
|
||
|
||
// 视觉顺序:第二 / 第一(中央) / 第三 · 缺位 = 虚位以待
|
||
const order: Array<{ artist: Artist | undefined; rank: 1 | 2 | 3 }> = [
|
||
{ artist: runnerUp, rank: 2 },
|
||
{ artist: champ, rank: 1 },
|
||
{ artist: thirdPlace, rank: 3 },
|
||
];
|
||
|
||
const lead = champ && runnerUp ? champ.votes - runnerUp.votes : null;
|
||
|
||
return (
|
||
// 冠军卡片更宽更高(1.3fr),亚季军等宽(1fr),三张底部对齐;
|
||
// 整个组合最大宽度受限并横向居中,避免在 1500 版心下占满铺张。
|
||
<div className="grid grid-cols-[1fr_1.3fr_1fr] items-end gap-8 sm:gap-12 pt-12 sm:pt-14 max-w-[860px] mx-auto">
|
||
{order.map(({ artist, rank }) => {
|
||
if (!artist) {
|
||
return <EmptySlot key={`empty-${rank}`} rank={rank} />;
|
||
}
|
||
const isFirst = rank === 1;
|
||
|
||
return (
|
||
<Link
|
||
key={artist.id}
|
||
href={`/artist/${artist.id}`}
|
||
className="group relative block"
|
||
>
|
||
{/* 顶部奖牌 SVG · 悬浮在卡片顶边上方 */}
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img
|
||
src={`/rank-${rank}.svg`}
|
||
alt={`第 ${rank} 名`}
|
||
className={cn(
|
||
"absolute left-1/2 -translate-x-1/2 z-20 select-none pointer-events-none drop-shadow-[0_4px_12px_rgba(0,0,0,0.5)]",
|
||
isFirst
|
||
? "w-16 sm:w-20 -top-9 sm:-top-11"
|
||
: "w-14 sm:w-16 -top-7 sm:-top-9",
|
||
)}
|
||
draggable={false}
|
||
/>
|
||
|
||
{/* 卡片容器(3:4 比例) · #1 金色渐变描边 + 金色辉光;#2 #3 紫色描边 */}
|
||
<div
|
||
className={cn(
|
||
"relative aspect-[3/4] rounded-xl overflow-hidden transition-all",
|
||
isFirst
|
||
? "p-[2px] shadow-[0_8px_36px_rgba(0,0,0,0.55),0_0_36px_rgba(255,200,120,0.20)]"
|
||
: "border border-purple-500/40 shadow-[0_8px_32px_rgba(0,0,0,0.55),0_0_24px_rgba(139,92,246,0.18)] hover:border-purple-400/60",
|
||
)}
|
||
style={
|
||
isFirst
|
||
? {
|
||
background:
|
||
"linear-gradient(180deg, #FFDAA8 0%, #A88351 100%)",
|
||
}
|
||
: undefined
|
||
}
|
||
>
|
||
{/* 内层 · #1 用 2px 内填让金色渐变作为描边露出 */}
|
||
<div
|
||
className={cn(
|
||
"relative w-full h-full overflow-hidden bg-deepest",
|
||
isFirst ? "rounded-[10px]" : "rounded-[11px]",
|
||
)}
|
||
>
|
||
{/* 立绘填满卡片 */}
|
||
<ArtistPortrait
|
||
artist={artist}
|
||
rounded="rounded-none"
|
||
className="absolute inset-0"
|
||
/>
|
||
|
||
{/* 底部渐隐 + 信息层 */}
|
||
<div className="absolute inset-x-0 bottom-0 px-3 pb-3 pt-10 bg-gradient-to-t from-black/90 via-black/65 to-transparent text-center">
|
||
<div
|
||
className="text-sm sm:text-base font-semibold truncate"
|
||
style={{ color: "#ffffff" }}
|
||
>
|
||
{artist.name}
|
||
</div>
|
||
<div className="mt-0.5 font-display tabular-nums text-base sm:text-lg text-pink-400">
|
||
{formatVotes(artist.votes)}{" "}
|
||
<span className="text-xs text-pink-300/80">票</span>
|
||
</div>
|
||
{isFirst && lead != null && lead > 0 && (
|
||
<div className="mt-1 text-[11px] text-white/60 tabular-nums">
|
||
领先 +{lead.toLocaleString()}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 虚位以待 · 任意 rank 缺位时的占位卡片,与正式卡片同 3:4 比例对齐底边 */
|
||
function EmptySlot({ rank }: { rank: 1 | 2 | 3 }) {
|
||
const isFirst = rank === 1;
|
||
return (
|
||
<div className="relative">
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img
|
||
src={`/rank-${rank}.svg`}
|
||
alt={`第 ${rank} 名`}
|
||
className={cn(
|
||
"absolute left-1/2 -translate-x-1/2 z-20 select-none pointer-events-none opacity-40 drop-shadow-[0_4px_12px_rgba(0,0,0,0.5)]",
|
||
isFirst
|
||
? "w-16 sm:w-20 -top-9 sm:-top-11"
|
||
: "w-14 sm:w-16 -top-7 sm:-top-9",
|
||
)}
|
||
draggable={false}
|
||
/>
|
||
<div className="relative aspect-[3/4] rounded-xl border border-dashed border-white/12 bg-white/[0.02] flex items-center justify-center">
|
||
<div className="text-center px-3">
|
||
<p className="font-label text-[10px] tracking-widest uppercase text-white/35">
|
||
Vacant
|
||
</p>
|
||
<p className="mt-1 text-xs text-white/55">虚位以待</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|