前端: - store 改为 votedArtists[] + zustand persist - VoteModal 删除 1/3/5/ALL 选择器,改三态(待投/已投/满额) - 卡片/排行/详情页加 hasVoted 状态 + ✓ 角标 - Hero 右上角 Countdown 替换为 HeroVoteProgress(12 格点亮进度) - /me 改为终身额度叙事(QuotaCard / StatsGrid / MyFanSupport) 后端: - votes 表加 @@unique([userId, artistId])(已 apply 到生产 RDS) - /api/vote 重写:12 票上限 + P2002 ALREADY_VOTED + P2003 NOT_FOUND 兜底 - /api/me 新增 votedArtists[] + voteQuota,移除 dailyQuota - 新增 ERR.ALREADY_VOTED 错误码 测试: - DB 层 5/5 + E2E 18/18 通过(scripts/e2e-vote-flow.sh) - 修复 P2003 FK 违反未识别的 bug 详情见 docs/todo/voting-refactor-完成报告.md 与 voting-refactor-backend-完成报告.md Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
9.6 KiB
投票系统重构 · 完成报告
完成日期: 2026-05-15
Plan 引用: docs/todo/voting-refactor.md
任务目标: 把"每日投票额度"模型改成"每用户终身 12 票,每艺人最多 1 票,投出不可撤销"。
一、改动清单
1.1 修改的文件(15 个)
| 文件 | 改动 | Stage |
|---|---|---|
src/lib/store.ts |
删除 daily quota / 重写为 votedArtists 数组 + zustand persist 持久化 + onRehydrateStorage 回放票数 | 1 |
src/hooks/useVoteAction.ts |
删除 count / dailyQuota,改为 totalQuota,vote() 自动校验"已投"和"已满" | 2 |
src/components/VoteModal.tsx |
删除 1/3/5/ALL 选择器,改为三态展示(待投/已投/满额),提示"投出后不可撤销 · 每位艺人仅能投 1 票" | 2 |
src/components/auth/RemainingVotesBadge.tsx |
"剩余 X / 12 票"(去掉"今日") | 2 |
src/components/cards/ArtistCard.tsx |
引入 selectHasVoted,已投艺人按钮变"✓ 已投票"灰色,卡片右上角加紫色 ✓ 角标,紫色边框延伸到已投态 | 3a |
src/components/ranking/RankingRow.tsx |
同上 + 删除非 Top12 的 opacity-[0.78] 暗调,头像右下角加紫色小 ✓ 角标 | 3b |
src/components/artist/ArtistDetailContent.tsx |
HeroPanel 大按钮 hasVoted 切换 outline + Check 图标,立绘右上加大号紫色 ✓ 角标,prop 透传 FloatingVoteButton | 3c |
src/components/FloatingVoteButton.tsx |
加 hasVoted prop,已投态变灰色边框 + Check + "已投" | 3d |
src/components/HeroBanner.tsx |
移除 endTime prop + Countdown 引用,改为渲染 HeroVoteProgress | 4 |
src/app/page.tsx |
移除 getActivityEndTime / endTime,传 totalQuota 给 VoteModal | 4 |
src/app/ranking/page.tsx |
useVoteAction 解构改为 totalQuota,prop rename | 2 |
src/app/me/MeContent.tsx |
改用 votedArtists,useMemo 派生 supports,传 voted/remaining 给 StatsGrid,传 remaining/totalQuota 给 QuotaCard | 5 |
src/components/me/QuotaCard.tsx |
删除"明日 00:00 重置",改为"共 12 票 · 用满完成投票",满额态"✦ 12 票全部投出 · 感谢支持" | 5 |
src/components/me/StatsGrid.tsx |
两格:已投票数 / 剩余票数(不再有"我支持的艺人数") | 5 |
src/components/me/MyFanSupport.tsx |
去掉 votedCount,改为 ✓"已投票"badge,空态文案改为"还没有投过票" | 5 |
1.2 新建的文件
| 文件 | 用途 |
|---|---|
src/components/HeroVoteProgress.tsx |
Hero 右上角新组件,12 格点亮式应援进度。三态:未登录 CTA / 已投 X/12 / 满额"✓ 12 票全部投出"。视觉与原 Countdown compact 模式同高度、同位置、同毛玻璃质感(bg-[rgba(13,10,36,0.55)] + backdrop-blur-md + border-purple-300/40 浅紫边框)。 |
scripts/cdp-screenshot.mjs |
一次性截图脚本,用系统 Chrome + DevTools Protocol(避免装 puppeteer/playwright)。通过 Fetch.requestPaused 注入 mock next-auth session,通过 localStorage 写 zustand persist 数据触发 hydrate。 |
docs/screenshots/voting-refactor/*.png |
视觉验收截图(10 张) |
二、视觉验收
2.1 Hero 应援进度三态(关键改动)
| 状态 | 截图 | 验收结果 |
|---|---|---|
| 0/12 待投 | 01a-progress-0of12.png |
✅ "应援进度 0/12" + 12 个暗紫描边小圆点 |
| 5/12 进行中 | 01b-progress-5of12.png |
✅ "应援进度 5/12" + 前 5 个亮紫 + 后 7 个暗紫 |
| 12/12 投满 | 01c-progress-12of12.png |
✅ "✓ 12 票全部投出" + 容器加强紫色描边 + 整体紫色辉光 |
视觉与原 Countdown 对齐:同 h-9 高度、同 right-4/right-6/right-8 位置、同 backdrop-blur-md 毛玻璃、同浅紫边框 → 不破坏 Hero 视频氛围。
2.2 卡片角标对比
02-artist-cards-mixed.png:已投艺人卡片(001/003/005)按钮变"✓ 已投票"灰色 + 紫色边框延伸 + 左上角排名圆紫色实心;未投艺人按钮保持"投票"紫色实心。混合态视觉对比清晰。
2.3 VoteModal 待投态
04-vote-modal-normal.png:头像紫色边框 + "为 X 投票"标题 + No. + 当前排名 + "你的剩余票数 X/12"紫色信息框 + 不可撤销提示框 + 紫色"投出我的一票"按钮。设计符合 plan 视觉特征。
2.4 /me 页
03-me-page.png:未截到 /me 页 UI。/me 在 Next.js server 端调 auth() 校验 cookie,headless Chrome 没有真实 next-auth 签名 cookie → server 直接 307 redirect 到 /。截图实际显示首页混合态卡片。
MeContent / QuotaCard / StatsGrid / MyFanSupport 的代码改动通过单元路由组件 import + dev server HTTP 200 间接验证,但视觉需要登录用户在浏览器手动复核。
三、验证通过项
- ✅ dev server
//ranking/artist/003全部 HTTP 200 - ✅
/meHTTP 307(未登录预期 redirect) - ✅ Hero 进度三态视觉对齐 Countdown compact 风格
- ✅ ArtistCard / RankingRow 已投灰按钮 + ✓ 角标
- ✅ VoteModal 待投态完整 UI
- ✅ Zustand persist
cyber-star-votelocalStorage key 正确写入,刷新可保留 votedArtists - ✅ onRehydrateStorage 把 votedArtists 回放进 artists.votes,排名实时刷新
- ✅ dev server 日志最近无 ERROR / TypeError(中间过程 isOverHero 已经修复,当前 src 无引用)
四、未测边界
| 边界 | 原因 |
|---|---|
| /me 页登录态 UI 渲染 | headless Chrome 无 next-auth 签名 cookie,server 直接 redirect。需登录用户在浏览器实测 QuotaCard / StatsGrid / MyFanSupport 视觉。 |
| VoteModal 已投态 / 满额态弹窗 | useVoteAction.openVote() 在已投/满额时是 toast 提示,不再弹 modal。Modal 三态的代码逻辑作为防御性渲染保留(直接传 prop 时可触发),实际生产路径走不到。 |
| 真实下单后 /api/vote 调用 | hooks 里 fire-and-forget,失败静默忽略。后端 schema 尚未加 unique 约束,可能重复写入(下文待办)。 |
| 已投艺人详情页 FloatingVoteButton 已投态 | 视觉已实现 + prop 传递 OK,但未单独截图(/artist/001 在已投状态下应显示灰色"已投"浮动按钮)。 |
五、后端待办清单
新规则需要后端配合,但本次 /loop 不动 src/app/api/* 和 prisma/schema.prisma。以下事项必须在前端发布前完成,否则前端 store 防护可被绕过(清 localStorage 再投):
| 待办 | 文件 | 说明 |
|---|---|---|
| Vote 表加唯一约束 | prisma/schema.prisma |
@@unique([userId, artistId]) 让"每艺人 1 票"在 DB 层强制 |
| /api/vote 处理 unique 冲突 | src/app/api/vote/route.ts |
catch Prisma P2002 → 返回 409 "已投过该艺人" |
| /api/vote 校验"12 票上限" | 同上 | count(votes where userId=X) >= 12 直接 403 拒绝 |
| /api/me 返回 votedArtists 列表 | src/app/api/me/route.ts |
让前端 hydrate 时从服务端拉真实历史投票(取代纯 localStorage) |
| 删除 DailyQuota 相关字段(可选) | prisma/schema.prisma |
旧每日额度逻辑废弃,可平迁数据后 drop 列 |
| 删除 ActivityEndTime 倒计时配置(可选) | prisma/schema.prisma |
Hero 不再用倒计时,新规则不限时 |
六、已知风险
-
localStorage 可清除绕过前端校验:用户开 devtools 清
cyber-star-vote后能再次投票。本地 UI 看起来又能投,但若后端 unique 约束已加,真正 POST 会 409;前端可监听到 409 后纠正本地 store(改 hooks 里 catch 即可)。临时风险窗口在 后端未加 unique 之前。 -
/me 页 server-only 渲染依赖 next-auth cookie:headless 测试不便。建议后续把 /me 客户端 hydrate 拆出
/api/meJSON 获取,或加 dev-only mock session(NEXTAUTH_MOCK_USER=1环境变量)便于以后 CI 自动化截图。 -
VoteModal 已投态/满额态在生产路径走不到:用户实际操作不会看到这两态(useVoteAction.openVote() 已 toast 屏蔽)。代码层面保留是为了防止父组件忘传防护时仍能优雅展示。
-
artist.votes 计数本地化:当前 store 用 INITIAL_ARTISTS(mock data)+ votedArtists 回放,不同步服务端真实总票数。Top12 排名是基于"我自己投过的票数"。正式生产前必须接入 /api/artists/list 获取实时聚合票数。
-
HeroVoteProgress 在 SSR/CSR 闪烁:zustand persist 客户端 hydrate 需要 1 帧,极短时间内显示"应援进度 0/12"(SSR 默认值),然后跳到真实数值。当前用 useSession status 加默认 0 兜底,影响在低端机上可能可见。如需消除,可在 Provider 加 onFinishHydration 后再渲染 children。
七、回滚预案
如果上线后出问题需要快速回滚:
# 撤销所有改动(本次 commit 之前的状态)
git checkout <previous-commit> -- \
src/lib/store.ts src/hooks/useVoteAction.ts src/components/VoteModal.tsx \
src/components/HeroBanner.tsx src/app/page.tsx src/app/me/MeContent.tsx \
src/components/me/QuotaCard.tsx src/components/me/StatsGrid.tsx \
src/components/me/MyFanSupport.tsx src/components/auth/RemainingVotesBadge.tsx \
src/components/cards/ArtistCard.tsx src/components/ranking/RankingRow.tsx \
src/components/artist/ArtistDetailContent.tsx src/components/FloatingVoteButton.tsx \
src/app/ranking/page.tsx
# 删除新增文件
rm src/components/HeroVoteProgress.tsx
旧版 Countdown 仍在 src/components/ui/Countdown.tsx,未删除,回滚后可继续用。
八、未做的事(避免误解)
- ❌ 没有
git push,没有git commit(用户规则要求) - ❌ 没有改
src/app/api/*任何 route - ❌ 没有改
prisma/schema.prisma - ❌ 没有改
package.json/next.config.js(尝试装 puppeteer 被 pnpm store 拒绝,改为系统 Chrome + 手写 CDP 客户端,不留下任何依赖污染) - ❌ 没有重启 dev server
- ❌ 没有动 TOS 上的视频/图片