数据准确性 - 票数初始为 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>
140 lines
3.4 KiB
TypeScript
140 lines
3.4 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
export interface RankedArtist {
|
|
id: string;
|
|
no: string;
|
|
name: string;
|
|
enName: string;
|
|
avatar: string | null;
|
|
portrait: string | null;
|
|
voteCount: number;
|
|
currentRank: number | null;
|
|
rank: number;
|
|
}
|
|
|
|
export interface RankingData {
|
|
list: RankedArtist[];
|
|
top3: RankedArtist[];
|
|
top12: RankedArtist[];
|
|
candidates: RankedArtist[];
|
|
generatedAt: string;
|
|
}
|
|
|
|
interface UseRankingOptions {
|
|
/** 轮询间隔(毫秒),默认 30s */
|
|
pollInterval?: number;
|
|
/** 是否启用轮询,默认 true */
|
|
enabled?: boolean;
|
|
}
|
|
|
|
interface UseRankingResult {
|
|
data: RankingData | null;
|
|
loading: boolean;
|
|
error: string | null;
|
|
/** 上次更新时间(用于 "Live · 5s ago" 显示) */
|
|
lastUpdated: Date | null;
|
|
/** 手动刷新 */
|
|
refresh: () => void;
|
|
}
|
|
|
|
/**
|
|
* 实时排名 Hook · 客户端轮询 /api/ranking
|
|
*
|
|
* 用法:
|
|
* const { data, lastUpdated } = useRanking({ pollInterval: 30_000 });
|
|
*
|
|
* 当页面隐藏时自动暂停轮询,可见时恢复(节省流量)。
|
|
*/
|
|
export function useRanking(options: UseRankingOptions = {}): UseRankingResult {
|
|
const { pollInterval = 30_000, enabled = true } = options;
|
|
|
|
const [data, setData] = useState<RankingData | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
|
const [tick, setTick] = useState(0);
|
|
|
|
const aborterRef = useRef<AbortController | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
|
|
let cancelled = false;
|
|
aborterRef.current?.abort();
|
|
const aborter = new AbortController();
|
|
aborterRef.current = aborter;
|
|
|
|
const fetchData = async () => {
|
|
try {
|
|
const res = await fetch("/api/ranking", {
|
|
signal: aborter.signal,
|
|
cache: "no-store",
|
|
});
|
|
const body = await res.json();
|
|
if (cancelled) return;
|
|
if (!body.ok) throw new Error(body.error?.message ?? "fetch failed");
|
|
setData(body.data as RankingData);
|
|
setLastUpdated(new Date());
|
|
setError(null);
|
|
} catch (e) {
|
|
if (cancelled || aborter.signal.aborted) return;
|
|
if (e instanceof Error && e.name === "AbortError") return;
|
|
setError(e instanceof Error ? e.message : "未知错误");
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchData();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
aborter.abort();
|
|
};
|
|
}, [enabled, tick]);
|
|
|
|
// 定时器:可见性感知
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
|
|
let timerId: ReturnType<typeof setInterval> | null = null;
|
|
|
|
const start = () => {
|
|
if (timerId) return;
|
|
timerId = setInterval(() => setTick((t) => t + 1), pollInterval);
|
|
};
|
|
const stop = () => {
|
|
if (timerId) {
|
|
clearInterval(timerId);
|
|
timerId = null;
|
|
}
|
|
};
|
|
const onVisibility = () => {
|
|
if (document.hidden) stop();
|
|
else {
|
|
// 页面可见时立即拉一次最新数据
|
|
setTick((t) => t + 1);
|
|
start();
|
|
}
|
|
};
|
|
|
|
start();
|
|
document.addEventListener("visibilitychange", onVisibility);
|
|
|
|
return () => {
|
|
stop();
|
|
document.removeEventListener("visibilitychange", onVisibility);
|
|
};
|
|
}, [enabled, pollInterval]);
|
|
|
|
return {
|
|
data,
|
|
loading,
|
|
error,
|
|
lastUpdated,
|
|
refresh: () => setTick((t) => t + 1),
|
|
};
|
|
}
|