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>
8.3 KiB
8.3 KiB
投票系统重构 · 完整方案
核心规则变更
| 维度 | 旧 | 新 |
|---|---|---|
| 单用户额度 | 每日 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字段, 只留artistvote(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} 投过票"+ abortopenVoteremaining <= 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/selectedstate - 删 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 → 文案
✓ 已投票,disabledprop, 紫色淡化 - 头像下方加 ✓ 角标(同 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
- 删
endTimeprop 和接口字段 - 删
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 · 验证
浏览器手动验证
- 首页 Hero 区域显示 HeroVoteProgress, 12 格状态正确
- 点首页某艺人 "投票" → 弹窗显示新文案, 点 "投出我的一票" → toast 成功 + Hero 进度 +1
- 再点同一艺人 → toast "你已为 TA 投过票", 弹窗不打开
- 进入 /me 页 → QuotaCard 不显示 "明日重置", StatsGrid 显示新统计, MyFanSupport 显示 ✓ 徽章
- 投满 12 票 → 触发里程碑 toast, 所有未投艺人按钮变灰禁用
- 浏览器硬刷新 → votedArtists 从 localStorage 恢复, 已投态保持
- 排行榜页 → 已投艺人按钮变灰, 未投可点
- 详情页 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, 列:
- 改了哪些文件(完整列表)
- 新建了哪些文件
- 哪些功能验证通过, 哪些边界没测
- 后续 backend 改动清单(schema unique 约束 / API 检查重投 / API 检查总额度)
- 已知风险或 TODO
硬禁区(loop 必须遵守)
- 不要 git push 任何分支
- 不要动
src/app/api/*任何 API route - 不要动
prisma/schema.prisma - 不要重启 dev server(3000 已经在跑, hot-reload 就行)
- 不要动 TOS 上传 / 视频文件 / 任何资源文件
- 不要碰
package.json/next.config.js