UI-UX/docs/todo/voting-refactor.md
iye 10878ddb3f
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
feat(vote): 重构投票模型为终身 12 票 + 每艺人 1 票
前端:
- 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>
2026-05-15 20:14:57 +08:00

222 lines
8.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 投票系统重构 · 完整方案
## 核心规则变更
| 维度 | 旧 | 新 |
|---|---|---|
| 单用户额度 | 每日 10/12 票(跨日重置) | **终身 12 票**(永不重置) |
| 单艺人上限 | 不限 | **每位艺人最多 1 票** |
| 单次投票数 | 可选 1/3/5/ALL | **固定 1 票** |
| 时间窗 | 有 startAt/endAt 倒计时 | **无时间限制** |
| 用户最终行为 | 累积投出 N 票 | **必须选出 12 个不同艺人** |
| 可撤销 | — | **不可撤销** |
## 设计决策(已拍板默认值)
- **A. Hero 进度条**: 12 格点亮式(已投亮紫, 未投暗紫边框, 横向排开)
- **B. VoteModal 确认按钮文案**: `投出我的一票`
- **C. 已投艺人卡片角标**: 右上角紫色 ✓ 圆角标(20px), 阴影柔和
- **D. 持久化**: zustand persist + localStorage(仅 `votedArtists` 字段)
## 本 loop 执行范围
**只动前端**(Store + Hooks + 组件 + 新组件)。
**后端 API/Schema 不动** —— 风险大需要单独迁移, 留给后续 commit。
前端 Store 强制阻止重复投票, API 即使旧的也能正常工作。
---
## 阶段 1 · Store 重构(单文件改重)
### `src/lib/store.ts`
-`DAILY_VOTE_QUOTA = 10` 常量
- 新增 `export const TOTAL_VOTE_QUOTA = 12` 常量
-`usedToday`, `quotaDate` 字段
- `myVotesByArtist: Record<string, number>``votedArtists: string[]`
- `MySupport` 接口删 `votedCount` 字段, 只留 `artist`
- `vote(id, count)``vote(id): { ok: boolean; reason?: "already" | "exhausted" }`
- `selectRemaining` = `TOTAL_VOTE_QUOTA - votedArtists.length`
- 新增 `selectHasVoted(id)` 高阶函数, 返回 `(s: VoteStore) => boolean`
- 新增 `selectIsExhausted(s) => votedArtists.length >= 12`
-`todayKey()` 函数
- 重写 `selectMySupports` 基于 `votedArtists` 派生
- **加 persist 中间件**:
- `import { persist } from "zustand/middleware"`
- 仅持久化 `votedArtists`(partialize)
- storage key: `cyber-star-vote`
---
## 阶段 2 · 交互闭环
### `src/hooks/useVoteAction.ts`
- import 改用 `TOTAL_VOTE_QUOTA` + `selectHasVoted`
- 接口 `confirmVote(artist, count)``confirmVote(artist)`(去 count)
- `openVote` 新增检查: `selectHasVoted(artist.id)` → toast `"你已为 ${artist.name} 投过票"` + abort
- `openVote` `remaining <= 0` 时 toast 改 `"你的 12 票已用完, 感谢支持"`
- `confirmVote` 调用 store.vote 不传 count
- API POST body 去掉 count(`{ artistId }`)
- 成功 toast: `"已为 ${artist.name} 投票 · 剩余 X 票"`
- 第 12 票投完瞬间额外 toast: `"完成!你的 12 票已全部投出"`(里程碑)
### `src/components/VoteModal.tsx`
-`VOTE_OPTIONS` 数组 / `defaultOption` / `resolveCount` / `selected` state
- 删 1/3/5/ALL 选择 grid 整块
- props 去掉 `remaining` 依赖(保留显示用), `dailyQuota` 字段名保留语义改为 totalQuota
- 标题保留 "为 X 投票"
- 副标题保留 `No.${no} · Current Rank #${rank}`
- **新增警示文案**: `"投出后不可撤销, 每位艺人仅能投 1 票"`
- 显示 "剩余 X / 12 票"
- 确认按钮: **`投出我的一票`** (loading 时显示 spinner)
- 已投态(打开时若 hasVoted=true): 标题改 "你已为 TA 投过票了" + 关闭按钮唯一
- 用完态(remaining=0): 标题改 "12 票已用完 · 感谢支持"
---
## 阶段 3 · 已投态 UI(可 sub-agent 并行 3-4 个文件)
### `src/components/cards/ArtistCard.tsx`
- import `selectHasVoted`
- `const hasVoted = useVoteStore(selectHasVoted(artist.id))`
- 投票按钮已投态: `✓ 已投票`, 灰禁用 (`bg-elevated text-white/45 border border-white/10`)
- 卡片右上角加紫色 ✓ 角标(20×20px, `bg-purple-500 rounded-full shadow-purple-glow`), `absolute top-2 right-2 z-10`
### `src/components/ranking/RankingRow.tsx`
- import `selectHasVoted`
- 同样的 hasVoted 判断
- 按钮已投态: `✓ 已投`, 灰禁用
- **顺便修复**: 移除非 Top12 的 `opacity-[0.78]` 暗化(用户已要求过)
### `src/components/artist/ArtistDetailContent.tsx`
- HeroPanel 内的投票按钮: hasVoted → 文案 `✓ 已投票`, `disabled` prop, 紫色淡化
- 头像下方加 ✓ 角标(同 ArtistCard 风格)
### `src/components/FloatingVoteButton.tsx`
- props 新增 `disabled?: boolean`
- disabled=true: Heart 改 Check, 文案改 `已投`, 样式灰
- 调用方 ArtistDetailContent: 透传 `disabled={hasVoted}`
---
## 阶段 4 · 新建 HeroVoteProgress 替换倒计时
### 新建 `src/components/HeroVoteProgress.tsx`
视觉: 12 个小圆点 / 方块横排, 已投亮紫(`bg-purple-500 shadow-purple-glow`), 未投暗紫(`bg-purple-500/15 border border-purple-500/40`)。
```
你的投票 · 5 / 12
● ● ● ● ● ○ ○ ○ ○ ○ ○ ○
```
状态机:
- 未登录: `登录后开始投票` + 点击触发 LoginModal(用 useLoginModalStore)
- 已投 0/12: 显示标题 "你的投票 · 0 / 12" + 12 格全暗
- 投票中(1-11): 标题 + 进度格子 + 副文案 "还可投 X 位艺人"
- 已投满 12/12: 标题 + 12 格全亮 + ✓ 图标 + 文案 "12 票全部投出 · 感谢支持"
样式约束:
- 紧凑模式, 高度 ≤ 80px(替换原 Countdown 位置)
- 半透明背景 + backdrop-blur(与原 Countdown 视觉一致)
- 文字白色, 进度紫色
### `src/components/HeroBanner.tsx`
-`endTime` prop 和接口字段
-`import Countdown`
- 删右上角倒计时整块
- 替换为 `<HeroVoteProgress />` (位置不变, 右上角)
- 不要删 Countdown.tsx 文件(可能其他场景用)
### `src/app/page.tsx`
-`import { getActivityEndTime }``const endTime`
- `<HeroBanner>` 调用去掉 `endTime` 属性
---
## 阶段 5 · /me 页 + Badge
### `src/components/auth/RemainingVotesBadge.tsx`
- 文案 `今日剩余 X / 10``剩余 X / 12`
- 注释 "今日剩余票数" → "剩余票数"
- 未登录显示 `0 / 12`
### `src/components/me/QuotaCard.tsx`
- 标题 `今日剩余票数``剩余票数`
-`明日 00:00 重置为 X 票` 整行
- 副文案改 `共 12 票 · 用满完成投票`(已投完则 `12 票全部投出 · 感谢支持`)
- props `dailyQuota` 保留(语义改为 totalQuota, 不重命名避免大面积改动)
### `src/components/me/StatsGrid.tsx`
- 新规则下 "累计投票" 和 "应援艺人" 永远相等 → 删一个
- 改为单 stat 组合: `已投 X / 12 票` + `剩余 12-X 票`(两格)
- 图标 Sparkles → Check(已投), Star → Heart(剩余)
### `src/components/me/MyFanSupport.tsx`
- 移除 `votedCount` 引用
- "已投 X 票" → 紫色 ✓ 徽章 + 文案 "已投票"
- 空态文案: `还没有应援的艺人``还没有投过票 · 去为你喜欢的艺人投出第一票`
### `src/app/me/MeContent.tsx`
- `myVotesByArtist` 引用全改 `votedArtists`
- supports useMemo 派生逻辑改为遍历 `votedArtists`
- `myTotalVotes``votedArtists.length` 或直接删, 用 `.length`
- 传 StatsGrid 的 props 改: `voted={N}` `remaining={12-N}`
---
## 阶段 6 · 验证
### 浏览器手动验证
1. 首页 Hero 区域显示 HeroVoteProgress, 12 格状态正确
2. 点首页某艺人 "投票" → 弹窗显示新文案, 点 "投出我的一票" → toast 成功 + Hero 进度 +1
3. 再点同一艺人 → toast "你已为 TA 投过票", 弹窗不打开
4. 进入 /me 页 → QuotaCard 不显示 "明日重置", StatsGrid 显示新统计, MyFanSupport 显示 ✓ 徽章
5. 投满 12 票 → 触发里程碑 toast, 所有未投艺人按钮变灰禁用
6. 浏览器硬刷新 → votedArtists 从 localStorage 恢复, 已投态保持
7. 排行榜页 → 已投艺人按钮变灰, 未投可点
8. 详情页 HeroPanel 按钮 + FloatingVoteButton 都正确响应 hasVoted
### 编译验证
- dev server 已在 3000 跑着, 改一个文件就 hot-reload 一次, 不要重启
- 检查 dev server log 没有红色 ERROR
- 没有任何 TypeScript 错误(`grep -E "Type error|TS\d+" dev-server.log`)
---
## 阶段 7 · 收尾报告
完成后写报告 `docs/todo/voting-refactor-完成报告.md`, 列:
1. 改了哪些文件(完整列表)
2. 新建了哪些文件
3. 哪些功能验证通过, 哪些边界没测
4. 后续 backend 改动清单(schema unique 约束 / API 检查重投 / API 检查总额度)
5. 已知风险或 TODO
---
## 硬禁区(loop 必须遵守)
- 不要 git push 任何分支
- 不要动 `src/app/api/*` 任何 API route
- 不要动 `prisma/schema.prisma`
- 不要重启 dev server(3000 已经在跑, hot-reload 就行)
- 不要动 TOS 上传 / 视频文件 / 任何资源文件
- 不要碰 `package.json` / `next.config.js`