UI-UX/src/components/ranking/RankingRow.tsx
iye 71a2672ff6 fix(data,ranking,ui): real dynamic ranking + data sync hardening
数据准确性
- 票数初始为 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>
2026-05-13 13:56:42 +08:00

123 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import Link from "next/link";
import { TrendingUp, AlertTriangle } from "lucide-react";
import type { Artist } 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;
}
function formatVotes(v: number): string {
if (v >= 10_000) return `${(v / 10_000).toFixed(1)}w`;
return v.toLocaleString();
}
export default function RankingRow({
artist,
gapAbove,
gapToDebut,
isRescue = false,
onVote,
}: RankingRowProps) {
// 「真正进 Top12」必须有票 —— 0 票时编号兜底不算
const inTop12 = artist.rank <= 12 && artist.votes > 0;
return (
<div
className={cn(
"grid grid-cols-[56px_48px_1fr_72px_96px_72px] sm:grid-cols-[72px_56px_1fr_96px_120px_88px] items-center gap-2 sm:gap-4 px-3 sm:px-4 py-2.5 border-b border-white/[0.05] transition-all",
inTop12
? "bg-white/[0.02] hover:bg-purple-500/[0.06]"
: "opacity-[0.78] hover:opacity-100 hover:bg-white/[0.03]",
)}
>
{/* 排名 */}
<div
className={cn(
"font-display text-base sm:text-lg tabular-nums text-center",
inTop12 ? "text-purple-300" : "text-white/55",
)}
>
#{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",
inTop12 ? "border-purple-500/70" : "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="ml-2 text-white/45 font-normal text-[11px]">
· {artist.enName}
</span>
</div>
</Link>
{/* 票数 */}
<div className="text-right">
<span
className={cn(
"font-display text-sm tabular-nums",
inTop12 ? "text-purple-300" : "text-white/65",
)}
>
{formatVotes(artist.votes)} <span className="text-[10px] opacity-70"></span>
</span>
</div>
{/* 距上一名 / 差出道线 */}
<div className="text-right hidden sm:block">
{isRescue && gapToDebut != null ? (
<div className="inline-flex items-center gap-1 text-pink-400 text-xs">
<AlertTriangle size={11} />
<span className="tabular-nums">+{gapToDebut.toLocaleString()}</span>
</div>
) : gapAbove != null && artist.rank > 1 ? (
<div className="inline-flex items-center gap-1 text-white/45 text-xs 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="h-8 rounded-lg font-body font-semibold text-xs bg-grad-purple text-white shadow-[0_0_12px_rgba(139,92,246,0.35)] hover:brightness-110"
>
</button>
</div>
);
}