UI-UX/src/app/ranking/page.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

144 lines
5.2 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 { 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");
// 重新按合并后的排名赋 rankstore 自带的 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}
/>
</>
);
}