# 投票系统重构 · 完整方案 ## 核心规则变更 | 维度 | 旧 | 新 | |---|---|---| | 单用户额度 | 每日 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` → `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` - 删右上角倒计时整块 - 替换为 `` (位置不变, 右上角) - 不要删 Countdown.tsx 文件(可能其他场景用) ### `src/app/page.tsx` - 删 `import { getActivityEndTime }` 和 `const endTime` 行 - `` 调用去掉 `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`