数据准确性 - 票数初始为 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>
144 lines
5.2 KiB
TypeScript
144 lines
5.2 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo } from "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 LiveBadge from "@/components/LiveBadge";
|
||
import { sortArtists } from "@/lib/mock-data";
|
||
import { useVoteStore } from "@/lib/store";
|
||
import { useVoteAction } from "@/hooks/useVoteAction";
|
||
import { useRanking } from "@/hooks/useRanking";
|
||
import type { Artist } from "@/types/artist";
|
||
|
||
export default function RankingPage() {
|
||
const storeArtists = useVoteStore((s) => s.artists);
|
||
const { target, remaining, dailyQuota, openVote, closeVote, confirmVote } =
|
||
useVoteAction();
|
||
|
||
const live = useRanking({ pollInterval: 30_000 });
|
||
|
||
// 数据同步:本地乐观投票 + 服务端最新票数取 max(避免 API 落后覆盖本地新票,
|
||
// 也避免本地缺其他用户的票数)。合并后按 votes desc + no asc 重新排序并赋 rank。
|
||
const sorted = useMemo<Artist[]>(() => {
|
||
const apiVotes = new Map<string, number>();
|
||
if (live.data?.list) {
|
||
for (const row of live.data.list) apiVotes.set(row.id, row.voteCount);
|
||
}
|
||
const merged = storeArtists.map((a) => {
|
||
const apiV = apiVotes.get(a.id) ?? 0;
|
||
return apiV > a.votes ? { ...a, votes: apiV } : a;
|
||
});
|
||
const ranked = sortArtists(merged, "votes");
|
||
// 重新按合并后的排名赋 rank(store 自带的 rank 仅来自本地 vote 后的 rank())
|
||
return ranked.map((a, i) => ({ ...a, rank: i + 1 }));
|
||
}, [storeArtists, live.data]);
|
||
|
||
// 仅有票的人能"进榜单",0 票不参与排名兜底
|
||
const ranked = sorted.filter((a) => a.votes > 0);
|
||
const zeros = sorted.filter((a) => a.votes === 0);
|
||
// 只要有 1 人有票就显示领奖台(#2/#3 缺位会自动以"虚位以待"占位);
|
||
// 至少 12 人有票才有"出道线 / 复活位"概念
|
||
const podiumReady = ranked.length >= 1;
|
||
const debutReady = ranked.length >= 12;
|
||
|
||
const top3 = podiumReady ? ranked.slice(0, 3) : [];
|
||
|
||
// 出道线上方(top3 之后到出道线之间)
|
||
// - Top12 满员:经典 9 位(rank 4-12)
|
||
// - 已有领奖台但 Top12 未满:podium 之后的所有有票
|
||
// - 连领奖台都没满:所有有票的人都从这里开始
|
||
const aboveLine = debutReady
|
||
? ranked.slice(3, 12)
|
||
: podiumReady
|
||
? ranked.slice(3)
|
||
: ranked;
|
||
|
||
// 出道线下方
|
||
// - Top12 满员:ranked[12..] + 全部 0 票
|
||
// - 否则:仅 0 票
|
||
const belowLine = debutReady ? [...ranked.slice(12), ...zeros] : zeros;
|
||
|
||
const debutCutoff = debutReady ? ranked[11].votes : 0;
|
||
|
||
return (
|
||
<>
|
||
<div className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-10">
|
||
{/* Top 3 领奖台 */}
|
||
<Top3Podium top3={top3} />
|
||
|
||
{/* 实时刷新标识 */}
|
||
<div className="flex justify-end mt-4 mb-2">
|
||
<LiveBadge updatedAt={live.lastUpdated} paused={!!live.error} />
|
||
</div>
|
||
|
||
{/* 列表头部 */}
|
||
<div className="mt-6 rounded-2xl border border-white/[0.06] bg-base/60 backdrop-blur-sm overflow-hidden">
|
||
<div className="hidden sm:grid grid-cols-[72px_56px_1fr_96px_120px_88px] gap-4 px-4 py-3 text-[10px] tracking-widest uppercase text-white/45 font-label border-b border-white/[0.06]">
|
||
<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>
|
||
|
||
<div>
|
||
{aboveLine.map((a, idx) => {
|
||
const prev =
|
||
idx === 0
|
||
? podiumReady
|
||
? top3[2]
|
||
: undefined
|
||
: aboveLine[idx - 1];
|
||
const gap = prev ? prev.votes - a.votes : undefined;
|
||
return (
|
||
<RankingRow
|
||
key={a.id}
|
||
artist={a}
|
||
gapAbove={gap}
|
||
onVote={openVote}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* 仅 Top12 满员才显示"出道线"分隔 */}
|
||
{debutReady && <DebutLineDivider />}
|
||
|
||
<div>
|
||
{belowLine.map((a, idx) => {
|
||
const prev =
|
||
idx === 0 ? aboveLine[aboveLine.length - 1] : belowLine[idx - 1];
|
||
const gap = prev ? prev.votes - a.votes : undefined;
|
||
// 仅 Top12 满员时第一位才是"复活位"
|
||
const isRescue = debutReady && idx === 0;
|
||
const gapToDebut = isRescue ? debutCutoff - a.votes + 1 : undefined;
|
||
return (
|
||
<RankingRow
|
||
key={a.id}
|
||
artist={a}
|
||
gapAbove={gap}
|
||
gapToDebut={gapToDebut}
|
||
isRescue={isRescue}
|
||
onVote={openVote}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<VoteModal
|
||
artist={target}
|
||
remaining={remaining}
|
||
dailyQuota={dailyQuota}
|
||
onClose={closeVote}
|
||
onConfirm={confirmVote}
|
||
/>
|
||
</>
|
||
);
|
||
}
|