diff --git a/README.md b/README.md index 53b897e..03411ff 100644 --- a/README.md +++ b/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 紫色侧栏面板(不是普通按钮)— 视觉锚点 +- 背景始终在深紫黑系,星点 + 紫雾装饰层 +- 投票交互必须有动画仪式感 diff --git a/src/app/ranking/page.tsx b/src/app/ranking/page.tsx index a78cd8d..bbe42a7 100644 --- a/src/app/ranking/page.tsx +++ b/src/app/ranking/page.tsx @@ -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(null); - const sorted = useMemo(() => sortArtists(ARTISTS, "votes"), []); + // 实时排名(API 可用时生效;失败则继续用 mock) + const live = useRanking({ pollInterval: 30_000 }); + + const sorted = useMemo(() => { + 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

35 位候选人 · 实时排名

-
+
+
@@ -110,7 +127,7 @@ export default function RankingPage() { {/* 底部提示 */}

- 排名每分钟更新一次 · 投票后立即生效 + 排名每 30 秒自动刷新 · 投票后立即生效

diff --git a/src/components/LiveBadge.tsx b/src/components/LiveBadge.tsx new file mode 100644 index 0000000..296f3f7 --- /dev/null +++ b/src/components/LiveBadge.tsx @@ -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 ( + + + {paused ? "Paused" : "Live"} + · + {rel} + + ); +} + +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(); +} diff --git a/src/hooks/useRanking.ts b/src/hooks/useRanking.ts new file mode 100644 index 0000000..db2a976 --- /dev/null +++ b/src/hooks/useRanking.ts @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(null); + const [tick, setTick] = useState(0); + + const aborterRef = useRef(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 | 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), + }; +}