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

8.3 KiB
Raw Blame History

投票系统重构 · 完整方案

核心规则变更

维度
单用户额度 每日 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
  • myTotalVotesvotedArtists.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