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)
|
| 框架 | Next.js 16(App Router)· React 19 · TypeScript |
|
||||||
- **部署**: 火山引擎 ECS + TOS
|
| 样式 | 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
|
```bash
|
||||||
pnpm install
|
pnpm install # 安装依赖(自动 prisma generate)
|
||||||
pnpm dev # 开发服务器 → http://localhost:3000
|
cp .env.example .env # 配置环境变量(首次)
|
||||||
pnpm build # 生产构建
|
pnpm dev # http://localhost:3000
|
||||||
pnpm start # 生产服务器
|
|
||||||
pnpm lint # 代码检查
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
未配置数据库时,前端页面会回退到 `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/
|
src/
|
||||||
app/ # Next.js App Router 页面
|
app/
|
||||||
layout.tsx # 根布局(含氛围装饰层)
|
layout.tsx # 根布局(含氛围装饰层)
|
||||||
page.tsx # 首页
|
page.tsx # 首页(Hero PV + Top12 + 35 卡片)
|
||||||
globals.css # 全局样式 + 设计令牌
|
artist/[id]/ # 艺人详情页
|
||||||
components/ # UI 组件
|
ranking/ # 排行榜(Top3 podium + 出道线 + 候补区)
|
||||||
lib/ # 工具函数 / Prisma client
|
me/ # 个人中心
|
||||||
prisma/ # 数据库 schema
|
login/ # 登录页(手机号 OTP)
|
||||||
public/ # 静态资源
|
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`
|
- 视觉规范:`../视觉规范.html` · v1.1 紫调主导
|
||||||
- 需求文档:`../需求分析文档.md`
|
- 需求文档:`../需求分析文档.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 DebutLineDivider from "@/components/ranking/DebutLineDivider";
|
||||||
import VoteModal from "@/components/VoteModal";
|
import VoteModal from "@/components/VoteModal";
|
||||||
import Countdown from "@/components/ui/Countdown";
|
import Countdown from "@/components/ui/Countdown";
|
||||||
|
import LiveBadge from "@/components/LiveBadge";
|
||||||
import { ARTISTS, getActivityEndTime, sortArtists } from "@/lib/mock-data";
|
import { ARTISTS, getActivityEndTime, sortArtists } from "@/lib/mock-data";
|
||||||
|
import { useRanking } from "@/hooks/useRanking";
|
||||||
import type { Artist } from "@/types/artist";
|
import type { Artist } from "@/types/artist";
|
||||||
|
|
||||||
export default function RankingPage() {
|
export default function RankingPage() {
|
||||||
const [voteTarget, setVoteTarget] = useState<Artist | null>(null);
|
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 endTime = useMemo(() => getActivityEndTime(), []);
|
||||||
|
|
||||||
const top3 = sorted.slice(0, 3);
|
const top3 = sorted.slice(0, 3);
|
||||||
@ -43,8 +59,9 @@ export default function RankingPage() {
|
|||||||
35
|
35
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-white/55 text-sm mb-5">35 位候选人 · 实时排名</p>
|
<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 />
|
<Countdown endTime={endTime} compact />
|
||||||
|
<LiveBadge updatedAt={live.lastUpdated} paused={!!live.error} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -110,7 +127,7 @@ export default function RankingPage() {
|
|||||||
|
|
||||||
{/* 底部提示 */}
|
{/* 底部提示 */}
|
||||||
<p className="text-xs text-white/35 text-center mt-10">
|
<p className="text-xs text-white/35 text-center mt-10">
|
||||||
排名每分钟更新一次 · 投票后立即生效
|
排名每 30 秒自动刷新 · 投票后立即生效
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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