# 投票系统重构 · 完成报告

**完成日期**: 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 <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 上的视频/图片
