Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
前端: - 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>
222 lines
8.3 KiB
Markdown
222 lines
8.3 KiB
Markdown
# 投票系统重构 · 完整方案
|
||
|
||
## 核心规则变更
|
||
|
||
| 维度 | 旧 | 新 |
|
||
|---|---|---|
|
||
| 单用户额度 | 每日 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`
|