# 投票系统重构 · 完成报告 **完成日期**: 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 - ✅ `/me` HTTP 307(未登录预期 redirect) - ✅ Hero 进度三态视觉对齐 Countdown compact 风格 - ✅ ArtistCard / RankingRow 已投灰按钮 + ✓ 角标 - ✅ VoteModal 待投态完整 UI - ✅ Zustand persist `cyber-star-vote` localStorage 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 不再用倒计时,新规则不限时 | --- ## 六、已知风险 1. **localStorage 可清除绕过前端校验**:用户开 devtools 清 `cyber-star-vote` 后能再次投票。本地 UI 看起来又能投,但若后端 unique 约束已加,真正 POST 会 409;前端可监听到 409 后纠正本地 store(改 hooks 里 catch 即可)。**临时风险窗口在 后端未加 unique 之前**。 2. **/me 页 server-only 渲染依赖 next-auth cookie**:headless 测试不便。建议后续把 /me 客户端 hydrate 拆出 `/api/me` JSON 获取,或加 dev-only mock session(`NEXTAUTH_MOCK_USER=1` 环境变量)便于以后 CI 自动化截图。 3. **VoteModal 已投态/满额态在生产路径走不到**:用户实际操作不会看到这两态(useVoteAction.openVote() 已 toast 屏蔽)。代码层面保留是为了防止父组件忘传防护时仍能优雅展示。 4. **artist.votes 计数本地化**:当前 store 用 INITIAL_ARTISTS(mock data)+ votedArtists 回放,不同步服务端真实总票数。Top12 排名是基于"我自己投过的票数"。**正式生产前必须接入 /api/artists/list 获取实时聚合票数**。 5. **HeroVoteProgress 在 SSR/CSR 闪烁**:zustand persist 客户端 hydrate 需要 1 帧,极短时间内显示"应援进度 0/12"(SSR 默认值),然后跳到真实数值。当前用 useSession status 加默认 0 兜底,影响在低端机上可能可见。如需消除,可在 Provider 加 onFinishHydration 后再渲染 children。 --- ## 七、回滚预案 如果上线后出问题需要快速回滚: ```bash # 撤销所有改动(本次 commit 之前的状态) git checkout -- \ 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 上的视频/图片