feat(live): real-time ranking polling hook + LiveBadge, ranking page falls back to mock when API unavailable
This commit is contained in:
parent
b7fbd5ac53
commit
854a162109
131
README.md
131
README.md
@ -4,37 +4,128 @@
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架**: Next.js 16 (App Router) + React 19 + TypeScript
|
||||
- **样式**: Tailwind CSS v4
|
||||
- **数据库**: MySQL (Prisma ORM)
|
||||
- **部署**: 火山引擎 ECS + TOS
|
||||
| 层 | 选型 |
|
||||
|---|---|
|
||||
| 框架 | Next.js 16(App Router)· React 19 · TypeScript |
|
||||
| 样式 | Tailwind CSS v4(CSS-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 Studio(GUI)|
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
prisma/
|
||||
schema.prisma # 数据库 schema(10 个模型)
|
||||
seed.ts # 初始化 35 位艺人
|
||||
|
||||
src/
|
||||
app/ # Next.js App Router 页面
|
||||
layout.tsx # 根布局(含氛围装饰层)
|
||||
page.tsx # 首页
|
||||
globals.css # 全局样式 + 设计令牌
|
||||
components/ # UI 组件
|
||||
lib/ # 工具函数 / Prisma client
|
||||
prisma/ # 数据库 schema
|
||||
public/ # 静态资源
|
||||
app/
|
||||
layout.tsx # 根布局(含氛围装饰层)
|
||||
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 紫色侧栏面板(不是普通按钮)— 视觉锚点
|
||||
- 背景始终在深紫黑系,星点 + 紫雾装饰层
|
||||
- 投票交互必须有动画仪式感
|
||||
|
||||
@ -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 数据合并 mock(API 只返回基础字段,详情仍从 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>
|
||||
|
||||
|
||||
62
src/components/LiveBadge.tsx
Normal file
62
src/components/LiveBadge.tsx
Normal 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
141
src/hooks/useRanking.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user