feat(live): real-time ranking polling hook + LiveBadge, ranking page falls back to mock when API unavailable

This commit is contained in:
iye 2026-05-12 10:06:16 +08:00
parent b7fbd5ac53
commit 854a162109
4 changed files with 334 additions and 23 deletions

129
README.md
View File

@ -4,37 +4,128 @@
## 技术栈
- **框架**: Next.js 16 (App Router) + React 19 + TypeScript
- **样式**: Tailwind CSS v4
- **数据库**: MySQL (Prisma ORM)
- **部署**: 火山引擎 ECS + TOS
| 层 | 选型 |
|---|---|
| 框架 | Next.js 16App Router· React 19 · TypeScript |
| 样式 | Tailwind CSS v4CSS-first `@theme`)· Framer Motion |
| 字体 | Megrim / Audiowide / Cinzel / Inter全部 SIL OFL 商用免费)|
| 数据库 | MySQL 8 · Prisma 6 ORM |
| 缓存 / 限流 | Redis`ioredis`,未配置时降级内存)|
| 身份 | Auth.js v5手机号 OTP · 可扩展微信 / QQ|
| 校验 | Zod |
| 部署 | 火山引擎 ECS · 火山引擎 TOS对象存储|
## 本地开发
## 本地启动
```bash
pnpm install
pnpm dev # 开发服务器 → http://localhost:3000
pnpm build # 生产构建
pnpm start # 生产服务器
pnpm lint # 代码检查
pnpm install # 安装依赖(自动 prisma generate
cp .env.example .env # 配置环境变量(首次)
pnpm dev # http://localhost:3000
```
未配置数据库时,前端页面会回退到 `src/lib/mock-data.ts` 的 35 位艺人 mock 数据UI 完全可用。
## 数据库初始化(部署时)
```bash
# 1. 创建 MySQL 库 + 在 .env 设置 DATABASE_URL
# 2. 推送 schema 到数据库
pnpm db:push
# 3. 灌入 35 位艺人 + 活动配置
pnpm db:seed
# 可视化管理(可选)
pnpm db:studio
```
## 关键脚本
| 命令 | 说明 |
|---|---|
| `pnpm dev` | 开发服务器Turbopack|
| `pnpm build` | 生产构建(先 prisma generate|
| `pnpm start` | 生产服务器 |
| `pnpm lint` | ESLint |
| `pnpm db:push` | 把 schema 推到数据库 |
| `pnpm db:migrate` | 生成迁移脚本 |
| `pnpm db:seed` | 灌入种子数据 |
| `pnpm db:studio` | Prisma StudioGUI|
## 项目结构
```
prisma/
schema.prisma # 数据库 schema10 个模型)
seed.ts # 初始化 35 位艺人
src/
app/ # Next.js App Router 页面
app/
layout.tsx # 根布局(含氛围装饰层)
page.tsx # 首页
globals.css # 全局样式 + 设计令牌
components/ # UI 组件
lib/ # 工具函数 / Prisma client
prisma/ # 数据库 schema
public/ # 静态资源
page.tsx # 首页Hero PV + Top12 + 35 卡片)
artist/[id]/ # 艺人详情页
ranking/ # 排行榜Top3 podium + 出道线 + 候补区)
me/ # 个人中心
login/ # 登录页(手机号 OTP
api/
artists/ # GET 艺人列表 / 单个详情
ranking/ # GET 实时排名
vote/ # POST 投票(含风控限流 + 事务)
me/ # GET 当前用户 / POST 签到
auth/ # NextAuth handlers + send-otp
components/
Logo / Navigation / Footer / NavLinks
HeroBanner / Top12Bar / ArtistFilters
VoteModal / FloatingVoteButton / LiveBadge
ui/ # Button / Countdown
cards/ # ArtistCard / ArtistPortrait
artist/ # 详情页组件(视频 / 画廊 / 排名卡等)
ranking/ # 排行榜组件
me/ # 个人中心组件
hooks/
useRanking.ts # 实时排名轮询 hook
lib/
prisma / redis / rate-limit
auth / current-user / api-response
cn / mock-data / mock-user
types/
artist.ts
```
## 设计参考
## 设计资料
- 交互原型:`../交互原型线框图.html`
- 视觉规范:`../视觉规范.html`
- 视觉规范:`../视觉规范.html` · v1.1 紫调主导
- 需求文档:`../需求分析文档.md`
- 参考视觉:`../参考图.png`
## 团队需配置的外部服务
| 服务 | 用途 | 环境变量 |
|---|---|---|
| **MySQL火山 RDS** | 主数据库 | `DATABASE_URL` |
| **Redis火山实例** | 限流 / OTP 缓存 / 实时聚合 | `REDIS_URL` |
| **TOS火山对象存储** | 立绘 / 视频 / 头像 | `TOS_*` |
| **短信网关** | 手机号 OTP | `SMS_*` |
| **微信开放平台** | 微信扫码登录 | `WECHAT_APP_ID` `WECHAT_APP_SECRET` |
| **hCaptcha** | 反作弊验证码 | `HCAPTCHA_*` |
详见 `.env.example`。所有这些都用 TODO 注释标记在代码中,**可灰度配置 —— 没配置时功能自动降级**。
## 开发态便利
- **登录** `/login` 页面下,开发环境接受万能验证码 `123456`
- **mock 用户** API 调用前自动落到 `cs_user_id` cookie可在开发面板设置
- **mock 数据** :未配 DB 时前端自动使用 `mock-data.ts` 的 35 位艺人
- **实时排名** API 不可用时静默回退到 mock 数据
## 设计原则
参考 `../视觉规范.html` 的 16 个章节,核心:
- 紫罗兰为主调色(不是蓝),承担 CTA / 激活态 / 描边
- 装饰星标 ✦ 用于 Logo 中间,体现品牌签
- TOP12 头像方形圆角(不是圆形)— "明星卡片"质感
- VOTE NOW 紫色侧栏面板(不是普通按钮)— 视觉锚点
- 背景始终在深紫黑系,星点 + 紫雾装饰层
- 投票交互必须有动画仪式感

View File

@ -7,13 +7,29 @@ import RankingRow from "@/components/ranking/RankingRow";
import DebutLineDivider from "@/components/ranking/DebutLineDivider";
import VoteModal from "@/components/VoteModal";
import Countdown from "@/components/ui/Countdown";
import LiveBadge from "@/components/LiveBadge";
import { ARTISTS, getActivityEndTime, sortArtists } from "@/lib/mock-data";
import { useRanking } from "@/hooks/useRanking";
import type { Artist } from "@/types/artist";
export default function RankingPage() {
const [voteTarget, setVoteTarget] = useState<Artist | null>(null);
const sorted = useMemo(() => sortArtists(ARTISTS, "votes"), []);
// 实时排名API 可用时生效;失败则继续用 mock
const live = useRanking({ pollInterval: 30_000 });
const sorted = useMemo<Artist[]>(() => {
if (live.data?.list && live.data.list.length > 0) {
// 用 API 数据合并 mockAPI 只返回基础字段,详情仍从 mock 取)
return live.data.list.map((row) => {
const base = ARTISTS.find((a) => a.id === row.id);
if (!base) return row as unknown as Artist;
return { ...base, votes: row.voteCount, rank: row.rank };
});
}
return sortArtists(ARTISTS, "votes");
}, [live.data]);
const endTime = useMemo(() => getActivityEndTime(), []);
const top3 = sorted.slice(0, 3);
@ -43,8 +59,9 @@ export default function RankingPage() {
35
</h1>
<p className="text-white/55 text-sm mb-5">35 · </p>
<div className="flex justify-center">
<div className="flex flex-wrap justify-center items-center gap-3">
<Countdown endTime={endTime} compact />
<LiveBadge updatedAt={live.lastUpdated} paused={!!live.error} />
</div>
</div>
@ -110,7 +127,7 @@ export default function RankingPage() {
{/* 底部提示 */}
<p className="text-xs text-white/35 text-center mt-10">
·
30 ·
</p>
</div>

View File

@ -0,0 +1,62 @@
"use client";
import { useEffect, useState } from "react";
import { cn } from "@/lib/cn";
interface LiveBadgeProps {
/** 最后更新时间 */
updatedAt: Date | null;
/** 是否处于加载 / 暂停态 */
paused?: boolean;
className?: string;
}
/**
*
* "LIVE · 5s ago"
*/
export default function LiveBadge({
updatedAt,
paused = false,
className,
}: LiveBadgeProps) {
const [, force] = useState(0);
// 每秒刷新一次相对时间显示
useEffect(() => {
const id = setInterval(() => force((x) => x + 1), 1000);
return () => clearInterval(id);
}, []);
const rel = updatedAt ? formatRelative(updatedAt) : "正在连接…";
return (
<span
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white/[0.04] border border-white/10 text-[10px] tracking-widest font-label uppercase",
paused ? "text-white/45" : "text-purple-300",
className,
)}
>
<span
className={cn(
"w-1.5 h-1.5 rounded-full",
paused
? "bg-white/40"
: "bg-purple-400 animate-pulse-glow shadow-[0_0_6px_rgba(167,139,250,0.8)]",
)}
/>
{paused ? "Paused" : "Live"}
<span className="text-white/35 normal-case tracking-normal">·</span>
<span className="text-white/55 normal-case tracking-normal">{rel}</span>
</span>
);
}
function formatRelative(d: Date): string {
const sec = Math.floor((Date.now() - d.getTime()) / 1000);
if (sec < 5) return "just now";
if (sec < 60) return `${sec}s ago`;
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
return d.toLocaleTimeString();
}

141
src/hooks/useRanking.ts Normal file
View File

@ -0,0 +1,141 @@
"use client";
import { useEffect, useRef, useState } from "react";
export interface RankedArtist {
id: string;
no: string;
name: string;
enName: string;
slogan: string;
themeColor: 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),
};
}