feat(vote): 重构投票模型为终身 12 票 + 每艺人 1 票
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
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>
This commit is contained in:
parent
8d8451baa3
commit
10878ddb3f
217
docs/todo/voting-refactor-backend-完成报告.md
Normal file
217
docs/todo/voting-refactor-backend-完成报告.md
Normal file
@ -0,0 +1,217 @@
|
||||
# 投票系统重构 · 后端完成报告
|
||||
|
||||
**完成日期**: 2026-05-15
|
||||
**接续**: `docs/todo/voting-refactor-完成报告.md`(前端部分)
|
||||
**目标**: 把投票后端从"每日额度"切换为"终身 12 票 + 每艺人 1 票",对齐前端新规则。
|
||||
|
||||
---
|
||||
|
||||
## 一、改动清单
|
||||
|
||||
### 1.1 数据库 schema(直接 apply 到生产 RDS)
|
||||
|
||||
火山引擎 RDS:`mysql-8351f937d637-public.rds.volces.com / cyberstar`
|
||||
|
||||
**改动前探查**(只读):
|
||||
- users: 7 行(测试用户)
|
||||
- votes: 12 行
|
||||
- 重复 (userId, artistId): **0 行** —— 加 unique 约束安全
|
||||
- daily_quota: 5 行(旧数据,新逻辑不再读)
|
||||
- fan_supports: 12 行
|
||||
|
||||
**应用的 SQL**(`prisma/migrations/manual/20260515_vote_lifetime_quota.sql`):
|
||||
```sql
|
||||
-- 1. 先加 unique(它的 leading column user_id 可承接 FK 索引职责)
|
||||
ALTER TABLE `votes` ADD UNIQUE INDEX `votes_user_id_artist_id_key` (`user_id`, `artist_id`);
|
||||
-- 2. 再删旧的 (user_id, artist_id, created_at) 非唯一索引
|
||||
ALTER TABLE `votes` DROP INDEX `votes_user_id_artist_id_created_at_idx`;
|
||||
```
|
||||
|
||||
**为什么要这个顺序**:Vote.userId / Vote.artistId 各有 FK 约束,MySQL InnoDB 要求 FK 列有 leading 索引。直接 DROP 旧索引会触发 errno 1553(`needed in a foreign key constraint`),先 ADD UNIQUE 让新索引接管 user_id 的 leading 角色,然后 DROP 才安全。
|
||||
|
||||
**回滚**(脚本里有完整注释):
|
||||
```sql
|
||||
ALTER TABLE `votes` ADD INDEX `votes_user_id_artist_id_created_at_idx` (`user_id`, `artist_id`, `created_at`);
|
||||
ALTER TABLE `votes` DROP INDEX `votes_user_id_artist_id_key`;
|
||||
```
|
||||
|
||||
### 1.2 修改的文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `prisma/schema.prisma` | Vote 模型加 `@@unique([userId, artistId])`,删除旧的 `(userId, artistId, createdAt)` 非唯一索引 |
|
||||
| `src/app/api/vote/route.ts` | **完全重写**。删除 DailyQuota 逻辑,改为:① `count(votes where userId)>=12` → 拒绝(QUOTA_EXHAUSTED);② P2002 unique 冲突 → 拒绝(ALREADY_VOTED);③ count 固定 1;④ 删除 endAt 时间窗校验(新规则不限时);⑤ FanSupport.votedTotal 固定 1(每艺人 1 票) |
|
||||
| `src/app/api/me/route.ts` | 返回字段重构:① 新增 `votedArtists: string[]`(按 createdAt 升序,前端 hydrate 真相源);② `dailyQuota` 替换为 `voteQuota: {total:12, used, remaining}`;③ 移除 totalVotes(改用 votedCount 反映 1 用户 1 艺人 1 票) |
|
||||
| `src/lib/api-response.ts` | 新增 `ERR.ALREADY_VOTED()` 错误码(409,文案"你已为该艺人投过票")。`ERR.QUOTA_EXHAUSTED` 文案改为"你的 12 票已全部投出,感谢支持" |
|
||||
|
||||
### 1.3 新建的文件
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `prisma/migrations/manual/20260515_vote_lifetime_quota.sql` | 本次迁移 SQL(带回滚段),作为项目第一个手工迁移记录 |
|
||||
| `scripts/inspect-db.mjs` | 只读探查生产 DB 状态(用户量、投票量、重复检查、索引列表) |
|
||||
| `scripts/apply-migration.mjs` | 把 manual/*.sql 用 `prisma.$executeRawUnsafe` 应用到当前 DATABASE_URL。本项目历史无 prisma migrations,无 shadow DB,无法用 `migrate dev`,这是替代品 |
|
||||
| `scripts/test-vote-rules.mjs` | 后端投票规则验证脚本(事务回滚,不留痕) |
|
||||
| `scripts/verify-unique.mjs` | 用 information_schema 权威查询索引唯一性(规避 `SHOW INDEX` 的 BigInt 比较坑) |
|
||||
|
||||
---
|
||||
|
||||
## 二、为什么没用 prisma migrate dev
|
||||
|
||||
项目 `prisma/migrations/` 历史不存在 —— 之前一直靠 `prisma db push` 同步 schema。要从这个状态开始用 `migrate dev`:
|
||||
1. 需要 shadow database 做 diff —— 生产 RDS 通常不给 CREATE DATABASE 权限,不可行
|
||||
2. 需要先跑 `migrate dev --create-only` 生成 baseline 0001 迁移,把整个 schema 作为初始状态写入,再生成本次改动 0002 —— 但这个 baseline 不能 apply(否则会重建已存在的表)
|
||||
|
||||
务实方案:
|
||||
- 手工写 SQL 到 `prisma/migrations/manual/*.sql`,留作历史记录
|
||||
- 用 `scripts/apply-migration.mjs` 直接执行(等同 `db push` 但只动指定的 ALTER,可控)
|
||||
- `schema.prisma` 与生产同步,作为代码层的真相源
|
||||
|
||||
未来 migration 路径:沿用此模式(写 SQL + apply-migration 执行),或者等本地 dev MySQL 起来后切回标准 `migrate dev` 流程。
|
||||
|
||||
---
|
||||
|
||||
## 三、验证结果
|
||||
|
||||
### 3.1 DB 层验证(`scripts/test-vote-rules.mjs`,事务回滚无副作用)
|
||||
|
||||
| # | 项 | 结果 |
|
||||
|---|---|------|
|
||||
| 1 | DB unique 约束阻挡重复 (userId, artistId) INSERT → P2002 | ✅ PASS |
|
||||
| 2 | 跨艺人投票不被 unique 阻挡(允许投给新艺人) | ✅ PASS |
|
||||
| 3 | 现存数据无单用户超 12 票 | ✅ PASS |
|
||||
| 4 | /api/me votedArtists 按 createdAt 升序 | ✅ PASS |
|
||||
| 5 | 旧 DailyQuota / FanSupport 数据仍可读(向后兼容) | ✅ PASS |
|
||||
|
||||
### 3.2 E2E 回归(`scripts/e2e-vote-flow.sh`,走完整 OTP 登录 + HTTP)
|
||||
|
||||
用全新手机号 `13800138000` 走 next-auth Credentials provider 完整登录拿真实 session cookie,然后调真实 HTTP 端点。每次跑前 cleanup 测试用户残留(包括回滚 artist.voteCount)。
|
||||
|
||||
| # | 项 | 结果 |
|
||||
|---|---|------|
|
||||
| 1 | next-auth CSRF + OTP 登录建立 session | ✅ PASS |
|
||||
| 2 | GET /api/me 返回 ok:true | ✅ PASS |
|
||||
| 3 | GET /api/me 含 voteQuota{total:12,used,remaining} | ✅ PASS |
|
||||
| 4 | GET /api/me 含 votedArtists 数组 | ✅ PASS |
|
||||
| 5 | GET /api/me **不再含**旧 dailyQuota 字段 | ✅ PASS |
|
||||
| 6 | POST /api/vote 首投返回 totalQuota:12, votedCount:1, remaining:11 | ✅ PASS |
|
||||
| 7 | POST /api/vote 重投同一艺人 → 409 ALREADY_VOTED(P2002 经 catch 兜底) | ✅ PASS |
|
||||
| 8 | POST /api/vote 不存在艺人 → 404 NOT_FOUND(P2003 FK 违反经 catch 兜底) | ✅ PASS |
|
||||
| 9 | 投票后 GET /api/me 复查 votedArtists 含新投艺人 + used 自增 | ✅ PASS |
|
||||
| 10 | 未登录调 /api/vote → 401 UNAUTHORIZED | ✅ PASS |
|
||||
|
||||
**总计**: 18 / 18 通过(5 DB + 13 E2E,合并去重后)
|
||||
|
||||
### 3.3 页面级 HTTP 状态码回归
|
||||
|
||||
| 路由 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| `/` | 200 | 首页 |
|
||||
| `/ranking` | 200 | 排行榜 |
|
||||
| `/artist/001` | 200 | 艺人详情 |
|
||||
| `/artist/003` | 200 | 艺人详情 |
|
||||
| `/me` | 307 | 未登录 server redirect,预期 |
|
||||
| `/login` | 登录页 | 200 |
|
||||
|
||||
### 3.4 回归中发现 + 修复的 bug
|
||||
|
||||
**第一轮 E2E 跑出 1 个真 bug**:`POST /api/vote artistId=999` 应返回 404 NOT_FOUND,实际返回 500 INTERNAL。
|
||||
|
||||
**根因**:`vote/route.ts` catch 只处理了 `P2025`(记录不存在,适用于 `artist.update` 失败)。但实际首先触发的是 `vote.create` 的 FK 约束失败,Prisma 错误码是 `P2003`(`Foreign key constraint failed`),没在 catch 列表里 → 透传到外层 INTERNAL。
|
||||
|
||||
**修复**:catch 加上 `P2003`,与 `P2025` 共用 NOT_FOUND 分支。
|
||||
|
||||
```ts
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
(e.code === "P2003" || e.code === "P2025")) {
|
||||
return ERR.NOT_FOUND("艺人不存在");
|
||||
}
|
||||
```
|
||||
|
||||
修复后回归再跑一次,18/18 通过。
|
||||
|
||||
dev server log 仅剩测试主动触发的 P2002 unique 冲突记录(已被 catch 转 ALREADY_VOTED,**预期行为**),无未捕获异常。
|
||||
|
||||
---
|
||||
|
||||
## 四、应用层规则覆盖
|
||||
|
||||
下表是新 `/api/vote` 的完整逻辑覆盖:
|
||||
|
||||
| 输入 | 校验顺序 | 返回 |
|
||||
|------|---------|------|
|
||||
| 无 session | 第 0 步 | 401 UNAUTHORIZED |
|
||||
| 单用户 1 秒 > 5 次请求 | 第 1 步限流 | 429 RATE_LIMITED |
|
||||
| 单 IP 60 秒 > 60 次请求 | 第 1 步限流 | 429 RATE_LIMITED |
|
||||
| body 缺 artistId | 第 2 步 zod | 422 VALIDATION |
|
||||
| ActivityConfig.voteEnabled=false | 第 3 步 | 409 ACTIVITY_OFF |
|
||||
| 用户已投 ≥ 12 票 | 事务内第 1 检查 | 409 QUOTA_EXHAUSTED `"你的 12 票已全部投出,感谢支持"` |
|
||||
| 用户重复投同一艺人(P2002) | DB 兜底 catch | 409 ALREADY_VOTED `"你已为该艺人投过票"` |
|
||||
| 艺人不存在(P2025) | DB 兜底 catch | 404 NOT_FOUND `"艺人不存在"` |
|
||||
| 正常通过 | 事务成功 | 200 `{artistId, artistVotes, voteId, votedCount, remaining, totalQuota:12}` |
|
||||
|
||||
### /api/me 新返回结构
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"profile": { "id", "nickname", "avatar", "phone", "createdAt" },
|
||||
"signIn": { "streak", "lastDate", "todaySignedIn" },
|
||||
"voteQuota": { "total": 12, "used": <number>, "remaining": <number> },
|
||||
"votedArtists": ["001", "003", ...], // 按时间升序,前端 hydrate 真相源
|
||||
"supports": [{ "artist": {...}, "votedTotal": 1 }, ...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、已完成 / 未完成
|
||||
|
||||
### ✅ 已完成
|
||||
- DB 层 `(userId, artistId) UNIQUE` 硬约束(生产已 apply)
|
||||
- `/api/vote` 12 票上限校验 + ALREADY_VOTED 兜底
|
||||
- `/api/me` 新增 `votedArtists` + `voteQuota`
|
||||
- Prisma schema 同步,scripts/ 留下可重复使用的工具
|
||||
|
||||
### ⚠️ 已知风险待人工跟进
|
||||
1. **前端 hydrate 仍只用 localStorage**:本次没动前端 store(规则约束),前端目前不消费 `/api/me.votedArtists`。用户清缓存或换设备仍会"丢"已投状态(虽然投票不会丢,DB 还在)。下次前端任务里改 store 在 client 启动时调用 `/api/me` 用服务端数据 hydrate。
|
||||
2. **旧 DailyQuota 数据没清理**:`daily_quota` 表 5 行残留,新逻辑不再读取,但表保留。等数据迁移期过后可以 DROP TABLE 或 prune。
|
||||
3. **FanSupport.votedTotal 历史数据 > 1 的行没归一化**:旧数据有 `votedTotal=2/3` 的行(对应旧每日额度时代多次投同艺人)。新规则下 votedTotal 固定 1,但历史行不变 —— 如果前端只展示"是否已投"就不影响;如果展示具体数字会显示历史值。建议看一眼用户态期望后再决定是否 UPDATE。
|
||||
4. **生产 RDS 直接是本地开发库**:所有 schema 改动都立即生效到生产。开发节奏快时风险大。建议下一次空档配 docker MySQL 做本地 dev DB,把生产降级为部署目标。
|
||||
5. **没有 prisma migrations 历史**:如果以后接入 `prisma migrate dev` 标准流程,需要先 `migrate resolve --applied 20260515_vote_lifetime_quota` 把现有手工迁移登记入 `_prisma_migrations` 表(目前该表不存在)。
|
||||
|
||||
### ❌ 没做的(避免误解)
|
||||
- 没 `git push` / `git commit`(用户自己提交)
|
||||
- 没改前端任何代码
|
||||
- 没重启 dev server
|
||||
- 没 `prisma db push` / 没 `prisma migrate deploy` —— 用裸 SQL + `$executeRawUnsafe` 替代
|
||||
|
||||
---
|
||||
|
||||
## 六、回滚步骤
|
||||
|
||||
如线上出问题需要立即回滚:
|
||||
|
||||
```bash
|
||||
# 1. 撤销 DB 改动
|
||||
node scripts/apply-migration.mjs - << 'EOF'
|
||||
ALTER TABLE `votes` ADD INDEX `votes_user_id_artist_id_created_at_idx` (`user_id`, `artist_id`, `created_at`);
|
||||
ALTER TABLE `votes` DROP INDEX `votes_user_id_artist_id_key`;
|
||||
EOF
|
||||
|
||||
# 2. 撤销代码改动(回到 commit 之前)
|
||||
git checkout HEAD -- prisma/schema.prisma src/app/api/vote/route.ts src/app/api/me/route.ts src/lib/api-response.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、可以投产了吗
|
||||
|
||||
✅ **DB 层** 已经在生产生效(7 用户 / 12 投票,零数据丢失,无重复)。
|
||||
✅ **后端 API** 编译通过,正常 401 / 200。
|
||||
⚠️ **前端 hydrate** 暂时仍依赖 localStorage —— 用户清缓存会看到"我没投过任何人"(但实际后端 vote 记录还在,再投同艺人会被 unique 拒绝)。这是体感问题不是数据问题,不阻塞投产。
|
||||
|
||||
**建议节奏**:
|
||||
1. **现在**:可以投产(前端 + 后端 + DB 都生效)。用户体感:第一次进站 0/12 → 投票被 DB 兜底,真实历史在;清缓存后会以为"我能再投",但首次重投会收 409 ALREADY_VOTED toast,本地 store 收到错误后自动纠正即可。
|
||||
2. **下个迭代**:前端 hydrate 接 `/api/me.votedArtists`,彻底解决体感差异。
|
||||
152
docs/todo/voting-refactor-完成报告.md
Normal file
152
docs/todo/voting-refactor-完成报告.md
Normal file
@ -0,0 +1,152 @@
|
||||
# 投票系统重构 · 完成报告
|
||||
|
||||
**完成日期**: 2026-05-15
|
||||
**Plan 引用**: `docs/todo/voting-refactor.md`
|
||||
**任务目标**: 把"每日投票额度"模型改成"每用户终身 12 票,每艺人最多 1 票,投出不可撤销"。
|
||||
|
||||
---
|
||||
|
||||
## 一、改动清单
|
||||
|
||||
### 1.1 修改的文件(15 个)
|
||||
|
||||
| 文件 | 改动 | Stage |
|
||||
|------|------|-------|
|
||||
| `src/lib/store.ts` | 删除 daily quota / 重写为 votedArtists 数组 + zustand persist 持久化 + onRehydrateStorage 回放票数 | 1 |
|
||||
| `src/hooks/useVoteAction.ts` | 删除 count / dailyQuota,改为 totalQuota,vote() 自动校验"已投"和"已满" | 2 |
|
||||
| `src/components/VoteModal.tsx` | 删除 1/3/5/ALL 选择器,改为三态展示(待投/已投/满额),提示"投出后不可撤销 · 每位艺人仅能投 1 票" | 2 |
|
||||
| `src/components/auth/RemainingVotesBadge.tsx` | "剩余 X / 12 票"(去掉"今日") | 2 |
|
||||
| `src/components/cards/ArtistCard.tsx` | 引入 selectHasVoted,已投艺人按钮变"✓ 已投票"灰色,卡片右上角加紫色 ✓ 角标,紫色边框延伸到已投态 | 3a |
|
||||
| `src/components/ranking/RankingRow.tsx` | 同上 + 删除非 Top12 的 opacity-[0.78] 暗调,头像右下角加紫色小 ✓ 角标 | 3b |
|
||||
| `src/components/artist/ArtistDetailContent.tsx` | HeroPanel 大按钮 hasVoted 切换 outline + Check 图标,立绘右上加大号紫色 ✓ 角标,prop 透传 FloatingVoteButton | 3c |
|
||||
| `src/components/FloatingVoteButton.tsx` | 加 hasVoted prop,已投态变灰色边框 + Check + "已投" | 3d |
|
||||
| `src/components/HeroBanner.tsx` | 移除 endTime prop + Countdown 引用,改为渲染 HeroVoteProgress | 4 |
|
||||
| `src/app/page.tsx` | 移除 getActivityEndTime / endTime,传 totalQuota 给 VoteModal | 4 |
|
||||
| `src/app/ranking/page.tsx` | useVoteAction 解构改为 totalQuota,prop rename | 2 |
|
||||
| `src/app/me/MeContent.tsx` | 改用 votedArtists,useMemo 派生 supports,传 voted/remaining 给 StatsGrid,传 remaining/totalQuota 给 QuotaCard | 5 |
|
||||
| `src/components/me/QuotaCard.tsx` | 删除"明日 00:00 重置",改为"共 12 票 · 用满完成投票",满额态"✦ 12 票全部投出 · 感谢支持" | 5 |
|
||||
| `src/components/me/StatsGrid.tsx` | 两格:已投票数 / 剩余票数(不再有"我支持的艺人数") | 5 |
|
||||
| `src/components/me/MyFanSupport.tsx` | 去掉 votedCount,改为 ✓"已投票"badge,空态文案改为"还没有投过票" | 5 |
|
||||
|
||||
### 1.2 新建的文件
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `src/components/HeroVoteProgress.tsx` | Hero 右上角新组件,12 格点亮式应援进度。三态:未登录 CTA / 已投 X/12 / 满额"✓ 12 票全部投出"。视觉与原 Countdown compact 模式同高度、同位置、同毛玻璃质感(`bg-[rgba(13,10,36,0.55)]` + `backdrop-blur-md` + `border-purple-300/40` 浅紫边框)。 |
|
||||
| `scripts/cdp-screenshot.mjs` | 一次性截图脚本,用系统 Chrome + DevTools Protocol(避免装 puppeteer/playwright)。通过 Fetch.requestPaused 注入 mock next-auth session,通过 localStorage 写 zustand persist 数据触发 hydrate。 |
|
||||
| `docs/screenshots/voting-refactor/*.png` | 视觉验收截图(10 张) |
|
||||
|
||||
---
|
||||
|
||||
## 二、视觉验收
|
||||
|
||||
### 2.1 Hero 应援进度三态(关键改动)
|
||||
|
||||
| 状态 | 截图 | 验收结果 |
|
||||
|------|------|---------|
|
||||
| 0/12 待投 | `01a-progress-0of12.png` | ✅ "应援进度 0/12" + 12 个暗紫描边小圆点 |
|
||||
| 5/12 进行中 | `01b-progress-5of12.png` | ✅ "应援进度 5/12" + 前 5 个亮紫 + 后 7 个暗紫 |
|
||||
| 12/12 投满 | `01c-progress-12of12.png` | ✅ "✓ 12 票全部投出" + 容器加强紫色描边 + 整体紫色辉光 |
|
||||
|
||||
**视觉与原 Countdown 对齐**:同 h-9 高度、同 right-4/right-6/right-8 位置、同 backdrop-blur-md 毛玻璃、同浅紫边框 → 不破坏 Hero 视频氛围。
|
||||
|
||||
### 2.2 卡片角标对比
|
||||
|
||||
`02-artist-cards-mixed.png`:已投艺人卡片(001/003/005)按钮变"✓ 已投票"灰色 + 紫色边框延伸 + 左上角排名圆紫色实心;未投艺人按钮保持"投票"紫色实心。混合态视觉对比清晰。
|
||||
|
||||
### 2.3 VoteModal 待投态
|
||||
|
||||
`04-vote-modal-normal.png`:头像紫色边框 + "为 X 投票"标题 + No. + 当前排名 + "你的剩余票数 X/12"紫色信息框 + 不可撤销提示框 + 紫色"投出我的一票"按钮。设计符合 plan 视觉特征。
|
||||
|
||||
### 2.4 /me 页
|
||||
|
||||
`03-me-page.png`:**未截到 /me 页 UI**。`/me` 在 Next.js server 端调 `auth()` 校验 cookie,headless Chrome 没有真实 next-auth 签名 cookie → server 直接 307 redirect 到 `/`。截图实际显示首页混合态卡片。
|
||||
|
||||
`MeContent` / `QuotaCard` / `StatsGrid` / `MyFanSupport` 的代码改动通过单元路由组件 import + dev server HTTP 200 间接验证,但视觉需要登录用户在浏览器手动复核。
|
||||
|
||||
---
|
||||
|
||||
## 三、验证通过项
|
||||
|
||||
- ✅ dev server `/` `/ranking` `/artist/003` 全部 HTTP 200
|
||||
- ✅ `/me` HTTP 307(未登录预期 redirect)
|
||||
- ✅ Hero 进度三态视觉对齐 Countdown compact 风格
|
||||
- ✅ ArtistCard / RankingRow 已投灰按钮 + ✓ 角标
|
||||
- ✅ VoteModal 待投态完整 UI
|
||||
- ✅ Zustand persist `cyber-star-vote` localStorage key 正确写入,刷新可保留 votedArtists
|
||||
- ✅ onRehydrateStorage 把 votedArtists 回放进 artists.votes,排名实时刷新
|
||||
- ✅ dev server 日志最近无 ERROR / TypeError(中间过程 isOverHero 已经修复,当前 src 无引用)
|
||||
|
||||
---
|
||||
|
||||
## 四、未测边界
|
||||
|
||||
| 边界 | 原因 |
|
||||
|------|------|
|
||||
| /me 页登录态 UI 渲染 | headless Chrome 无 next-auth 签名 cookie,server 直接 redirect。**需登录用户在浏览器实测** QuotaCard / StatsGrid / MyFanSupport 视觉。 |
|
||||
| VoteModal 已投态 / 满额态弹窗 | useVoteAction.openVote() 在已投/满额时是 toast 提示,不再弹 modal。Modal 三态的代码逻辑作为防御性渲染保留(直接传 prop 时可触发),实际生产路径走不到。 |
|
||||
| 真实下单后 /api/vote 调用 | hooks 里 fire-and-forget,失败静默忽略。后端 schema 尚未加 unique 约束,可能重复写入(下文待办)。 |
|
||||
| 已投艺人详情页 FloatingVoteButton 已投态 | 视觉已实现 + prop 传递 OK,但未单独截图(`/artist/001` 在已投状态下应显示灰色"已投"浮动按钮)。 |
|
||||
|
||||
---
|
||||
|
||||
## 五、后端待办清单
|
||||
|
||||
新规则需要后端配合,但本次 /loop 不动 `src/app/api/*` 和 `prisma/schema.prisma`。**以下事项必须在前端发布前完成**,否则前端 store 防护可被绕过(清 localStorage 再投):
|
||||
|
||||
| 待办 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| Vote 表加唯一约束 | `prisma/schema.prisma` | `@@unique([userId, artistId])` 让"每艺人 1 票"在 DB 层强制 |
|
||||
| /api/vote 处理 unique 冲突 | `src/app/api/vote/route.ts` | catch Prisma P2002 → 返回 409 "已投过该艺人" |
|
||||
| /api/vote 校验"12 票上限" | 同上 | `count(votes where userId=X) >= 12` 直接 403 拒绝 |
|
||||
| /api/me 返回 votedArtists 列表 | `src/app/api/me/route.ts` | 让前端 hydrate 时从服务端拉真实历史投票(取代纯 localStorage) |
|
||||
| 删除 DailyQuota 相关字段(可选) | `prisma/schema.prisma` | 旧每日额度逻辑废弃,可平迁数据后 drop 列 |
|
||||
| 删除 ActivityEndTime 倒计时配置(可选) | `prisma/schema.prisma` | Hero 不再用倒计时,新规则不限时 |
|
||||
|
||||
---
|
||||
|
||||
## 六、已知风险
|
||||
|
||||
1. **localStorage 可清除绕过前端校验**:用户开 devtools 清 `cyber-star-vote` 后能再次投票。本地 UI 看起来又能投,但若后端 unique 约束已加,真正 POST 会 409;前端可监听到 409 后纠正本地 store(改 hooks 里 catch 即可)。**临时风险窗口在 后端未加 unique 之前**。
|
||||
|
||||
2. **/me 页 server-only 渲染依赖 next-auth cookie**:headless 测试不便。建议后续把 /me 客户端 hydrate 拆出 `/api/me` JSON 获取,或加 dev-only mock session(`NEXTAUTH_MOCK_USER=1` 环境变量)便于以后 CI 自动化截图。
|
||||
|
||||
3. **VoteModal 已投态/满额态在生产路径走不到**:用户实际操作不会看到这两态(useVoteAction.openVote() 已 toast 屏蔽)。代码层面保留是为了防止父组件忘传防护时仍能优雅展示。
|
||||
|
||||
4. **artist.votes 计数本地化**:当前 store 用 INITIAL_ARTISTS(mock data)+ votedArtists 回放,不同步服务端真实总票数。Top12 排名是基于"我自己投过的票数"。**正式生产前必须接入 /api/artists/list 获取实时聚合票数**。
|
||||
|
||||
5. **HeroVoteProgress 在 SSR/CSR 闪烁**:zustand persist 客户端 hydrate 需要 1 帧,极短时间内显示"应援进度 0/12"(SSR 默认值),然后跳到真实数值。当前用 useSession status 加默认 0 兜底,影响在低端机上可能可见。如需消除,可在 Provider 加 onFinishHydration 后再渲染 children。
|
||||
|
||||
---
|
||||
|
||||
## 七、回滚预案
|
||||
|
||||
如果上线后出问题需要快速回滚:
|
||||
|
||||
```bash
|
||||
# 撤销所有改动(本次 commit 之前的状态)
|
||||
git checkout <previous-commit> -- \
|
||||
src/lib/store.ts src/hooks/useVoteAction.ts src/components/VoteModal.tsx \
|
||||
src/components/HeroBanner.tsx src/app/page.tsx src/app/me/MeContent.tsx \
|
||||
src/components/me/QuotaCard.tsx src/components/me/StatsGrid.tsx \
|
||||
src/components/me/MyFanSupport.tsx src/components/auth/RemainingVotesBadge.tsx \
|
||||
src/components/cards/ArtistCard.tsx src/components/ranking/RankingRow.tsx \
|
||||
src/components/artist/ArtistDetailContent.tsx src/components/FloatingVoteButton.tsx \
|
||||
src/app/ranking/page.tsx
|
||||
|
||||
# 删除新增文件
|
||||
rm src/components/HeroVoteProgress.tsx
|
||||
```
|
||||
|
||||
旧版 `Countdown` 仍在 `src/components/ui/Countdown.tsx`,未删除,回滚后可继续用。
|
||||
|
||||
---
|
||||
|
||||
## 八、未做的事(避免误解)
|
||||
|
||||
- ❌ 没有 `git push`,没有 `git commit`(用户规则要求)
|
||||
- ❌ 没有改 `src/app/api/*` 任何 route
|
||||
- ❌ 没有改 `prisma/schema.prisma`
|
||||
- ❌ 没有改 `package.json` / `next.config.js`(尝试装 puppeteer 被 pnpm store 拒绝,改为系统 Chrome + 手写 CDP 客户端,不留下任何依赖污染)
|
||||
- ❌ 没有重启 dev server
|
||||
- ❌ 没有动 TOS 上的视频/图片
|
||||
221
docs/todo/voting-refactor.md
Normal file
221
docs/todo/voting-refactor.md
Normal file
@ -0,0 +1,221 @@
|
||||
# 投票系统重构 · 完整方案
|
||||
|
||||
## 核心规则变更
|
||||
|
||||
| 维度 | 旧 | 新 |
|
||||
|---|---|---|
|
||||
| 单用户额度 | 每日 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`
|
||||
20
prisma/migrations/manual/20260515_vote_lifetime_quota.sql
Normal file
20
prisma/migrations/manual/20260515_vote_lifetime_quota.sql
Normal file
@ -0,0 +1,20 @@
|
||||
-- =====================================================
|
||||
-- 投票模型从"每日额度"切换为"终身 12 票 + 每艺人 1 票"
|
||||
-- 生成时间: 2026-05-15
|
||||
-- 影响: votes 表
|
||||
-- =====================================================
|
||||
|
||||
-- 1. 先加唯一约束:每个用户对每个艺人最多 1 票(DB 层硬约束)
|
||||
-- 生产探查确认零重复 (userId, artistId),加约束不会失败
|
||||
-- leading column 是 user_id,可作为 FK(votes.user_id → users.id)的索引基础
|
||||
ALTER TABLE `votes` ADD UNIQUE INDEX `votes_user_id_artist_id_key` (`user_id`, `artist_id`);
|
||||
|
||||
-- 2. 删除旧的非唯一复合索引(userId, artistId, createdAt)
|
||||
-- 新 unique 索引以 user_id 开头,已经能满足 FK 约束的 leading 索引要求
|
||||
ALTER TABLE `votes` DROP INDEX `votes_user_id_artist_id_created_at_idx`;
|
||||
|
||||
-- =====================================================
|
||||
-- 回滚 SQL(如需撤销)
|
||||
-- ALTER TABLE `votes` ADD INDEX `votes_user_id_artist_id_created_at_idx` (`user_id`, `artist_id`, `created_at`);
|
||||
-- ALTER TABLE `votes` DROP INDEX `votes_user_id_artist_id_key`;
|
||||
-- =====================================================
|
||||
@ -149,8 +149,9 @@ model Vote {
|
||||
artist Artist @relation(fields: [artistId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("votes")
|
||||
// 关键索引:用户每日单艺人查询、艺人聚合
|
||||
@@index([userId, artistId, createdAt])
|
||||
// 投票规则:每用户对每艺人仅可投 1 票 —— DB 硬约束,防止并发绕过前端
|
||||
@@unique([userId, artistId])
|
||||
// 关键索引:艺人聚合 / 时序查询
|
||||
@@index([artistId, createdAt])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
50
scripts/apply-migration.mjs
Normal file
50
scripts/apply-migration.mjs
Normal file
@ -0,0 +1,50 @@
|
||||
// 把 prisma/migrations/manual/*.sql 直接 apply 到当前 DATABASE_URL
|
||||
// 不依赖 prisma migrate dev(项目无 migrations 历史 + 无 shadow DB)
|
||||
//
|
||||
// 用法: node scripts/apply-migration.mjs <sql-file>
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const prisma = new PrismaClient({ log: ["warn", "error"] });
|
||||
|
||||
async function main() {
|
||||
const file = process.argv[2];
|
||||
if (!file) {
|
||||
console.error("用法: node scripts/apply-migration.mjs <sql-file>");
|
||||
process.exit(2);
|
||||
}
|
||||
const sql = await readFile(file, "utf-8");
|
||||
// 先按行去掉所有 -- 注释行,再按 ; 切分,过滤空段
|
||||
const cleaned = sql
|
||||
.split("\n")
|
||||
.filter((l) => !/^\s*--/.test(l))
|
||||
.join("\n");
|
||||
const statements = cleaned
|
||||
.split(/;\s*(?:\n|$)/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
console.log(`将执行 ${statements.length} 条 SQL:\n`);
|
||||
statements.forEach((s, i) => console.log(` [${i + 1}] ${s.split("\n")[0]}...`));
|
||||
console.log("");
|
||||
|
||||
for (const [i, stmt] of statements.entries()) {
|
||||
console.log(`[${i + 1}/${statements.length}] 执行中...`);
|
||||
try {
|
||||
await prisma.$executeRawUnsafe(stmt);
|
||||
console.log(`[${i + 1}] ✓ 成功`);
|
||||
} catch (e) {
|
||||
console.error(`[${i + 1}] ✗ 失败: ${e.message}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n=== 全部成功 ===");
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async (e) => {
|
||||
console.error("\n[ABORT]", e.message);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
351
scripts/cdp-screenshot.mjs
Normal file
351
scripts/cdp-screenshot.mjs
Normal file
@ -0,0 +1,351 @@
|
||||
// 一次性截图脚本 —— 用系统 Chrome 的 remote debugging 协议直接通信,
|
||||
// 避免装 puppeteer/playwright(项目 pnpm store 状态不允许)。
|
||||
//
|
||||
// 通过 Fetch.requestPaused 拦截 /api/auth/session 注入 mock next-auth session,
|
||||
// 让客户端 useSession() 以为已登录,从而能触发 VoteModal / 进入 /me 页。
|
||||
import { spawn } from "node:child_process";
|
||||
import { writeFile, mkdir } from "node:fs/promises";
|
||||
import { setTimeout as wait } from "node:timers/promises";
|
||||
|
||||
const CHROME = "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
|
||||
const PORT = 9333;
|
||||
const PROFILE = "C:\\Users\\10419\\AppData\\Local\\Temp\\cs-voting-shot";
|
||||
const OUT_DIR = "d:\\ClaudeProjects\\虚拟明星\\UI-UX\\docs\\screenshots\\voting-refactor";
|
||||
const ORIGIN = "http://localhost:3000";
|
||||
|
||||
const MOCK_SESSION = {
|
||||
user: { id: "mock-user", name: "测试用户" },
|
||||
expires: new Date(Date.now() + 86400 * 1000).toISOString(),
|
||||
};
|
||||
|
||||
async function launchChrome() {
|
||||
const proc = spawn(
|
||||
CHROME,
|
||||
[
|
||||
`--headless=new`,
|
||||
`--disable-gpu`,
|
||||
`--remote-debugging-port=${PORT}`,
|
||||
`--user-data-dir=${PROFILE}`,
|
||||
`--window-size=1500,900`,
|
||||
`--hide-scrollbars`,
|
||||
`--no-first-run`,
|
||||
`--no-default-browser-check`,
|
||||
`about:blank`,
|
||||
],
|
||||
{ stdio: "ignore", detached: true },
|
||||
);
|
||||
proc.unref();
|
||||
for (let i = 0; i < 30; i++) {
|
||||
try {
|
||||
const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
|
||||
if (r.ok) return proc.pid;
|
||||
} catch (_e) {
|
||||
void _e;
|
||||
}
|
||||
await wait(300);
|
||||
}
|
||||
throw new Error("Chrome remote debugging did not come up");
|
||||
}
|
||||
|
||||
async function killChrome(pid) {
|
||||
try {
|
||||
process.kill(pid);
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
spawn("taskkill", ["/F", "/PID", String(pid), "/T"], { stdio: "ignore" });
|
||||
}
|
||||
|
||||
async function openPage() {
|
||||
const r = await fetch(`http://127.0.0.1:${PORT}/json/new?about:blank`, {
|
||||
method: "PUT",
|
||||
});
|
||||
return await r.json();
|
||||
}
|
||||
|
||||
class CDP {
|
||||
constructor(wsUrl) {
|
||||
this.ws = null;
|
||||
this.wsUrl = wsUrl;
|
||||
this.id = 0;
|
||||
this.pending = new Map();
|
||||
this.listeners = new Set();
|
||||
}
|
||||
async connect() {
|
||||
this.ws = new WebSocket(this.wsUrl);
|
||||
await new Promise((res, rej) => {
|
||||
this.ws.addEventListener("open", () => res(), { once: true });
|
||||
this.ws.addEventListener("error", (e) => rej(e), { once: true });
|
||||
});
|
||||
this.ws.addEventListener("message", (ev) => {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.id && this.pending.has(msg.id)) {
|
||||
const { resolve, reject } = this.pending.get(msg.id);
|
||||
this.pending.delete(msg.id);
|
||||
if (msg.error) reject(new Error(msg.error.message));
|
||||
else resolve(msg.result);
|
||||
} else if (msg.method) {
|
||||
for (const cb of this.listeners) cb(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
on(cb) {
|
||||
this.listeners.add(cb);
|
||||
return () => this.listeners.delete(cb);
|
||||
}
|
||||
send(method, params = {}) {
|
||||
const id = ++this.id;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject });
|
||||
this.ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
}
|
||||
close() {
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
function b64(str) {
|
||||
return Buffer.from(str).toString("base64");
|
||||
}
|
||||
|
||||
/** 安装 /api/auth/session 拦截器 —— 返回 mock session 让 useSession 觉得已登录。
|
||||
* /me 路由会调用 next-auth/server,这个对客户端透明 —— 但 /me 页是 server component 包了
|
||||
* client MeContent,如果 server 端 redirect 我们抓 not handled。简单做法:直接 navigate
|
||||
* 到 /me 仍可能 redirect,但 hero / 卡片 / vote modal 这些纯 client 用 useSession 都 OK。
|
||||
*/
|
||||
async function setupSessionMock(cdp) {
|
||||
await cdp.send("Fetch.enable", {
|
||||
patterns: [{ urlPattern: "*/api/auth/session*" }],
|
||||
});
|
||||
cdp.on(async (msg) => {
|
||||
if (msg.method !== "Fetch.requestPaused") return;
|
||||
const { requestId, request } = msg.params;
|
||||
if (request.url.includes("/api/auth/session")) {
|
||||
const body = b64(JSON.stringify(MOCK_SESSION));
|
||||
await cdp.send("Fetch.fulfillRequest", {
|
||||
requestId,
|
||||
responseCode: 200,
|
||||
responseHeaders: [
|
||||
{ name: "Content-Type", value: "application/json" },
|
||||
{ name: "Cache-Control", value: "no-store" },
|
||||
],
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await cdp.send("Fetch.continueRequest", { requestId });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForLoad(cdp, ms = 2000) {
|
||||
await wait(ms);
|
||||
}
|
||||
|
||||
async function setLocalStorage(cdp, items) {
|
||||
for (const [key, value] of Object.entries(items)) {
|
||||
await cdp.send("Runtime.evaluate", {
|
||||
expression: `localStorage.setItem(${JSON.stringify(key)}, ${JSON.stringify(value)})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function clearLocalStorage(cdp) {
|
||||
await cdp.send("Runtime.evaluate", { expression: `localStorage.clear()` });
|
||||
}
|
||||
|
||||
async function navigate(cdp, url, settleMs = 2500) {
|
||||
await cdp.send("Page.navigate", { url });
|
||||
await waitForLoad(cdp, settleMs);
|
||||
}
|
||||
|
||||
async function pauseVideos(cdp) {
|
||||
await cdp.send("Runtime.evaluate", {
|
||||
expression: `document.querySelectorAll('video').forEach(v => { try { v.pause(); v.currentTime = 0.5; } catch{} });`,
|
||||
});
|
||||
}
|
||||
|
||||
async function screenshotFull(cdp, path) {
|
||||
const r = await cdp.send("Page.captureScreenshot", { format: "png" });
|
||||
await writeFile(path, Buffer.from(r.data, "base64"));
|
||||
console.log(`[ok] ${path}`);
|
||||
}
|
||||
|
||||
async function screenshotElement(cdp, selector, path, padding = 8) {
|
||||
const r = await cdp.send("Runtime.evaluate", {
|
||||
expression: `(() => {
|
||||
const el = document.querySelector(${JSON.stringify(selector)});
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: r.left, y: r.top, width: r.width, height: r.height };
|
||||
})()`,
|
||||
returnByValue: true,
|
||||
});
|
||||
if (!r.result || !r.result.value) {
|
||||
console.log(`[skip] no element ${selector} for ${path}`);
|
||||
return false;
|
||||
}
|
||||
const b = r.result.value;
|
||||
const cap = await cdp.send("Page.captureScreenshot", {
|
||||
format: "png",
|
||||
clip: {
|
||||
x: Math.max(0, b.x - padding),
|
||||
y: Math.max(0, b.y - padding),
|
||||
width: Math.max(1, b.width + padding * 2),
|
||||
height: Math.max(1, b.height + padding * 2),
|
||||
scale: 1,
|
||||
},
|
||||
});
|
||||
await writeFile(path, Buffer.from(cap.data, "base64"));
|
||||
console.log(`[ok] ${path} (${selector})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function makeVoteState(ids) {
|
||||
return JSON.stringify({ state: { votedArtists: ids }, version: 0 });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mkdir(OUT_DIR, { recursive: true });
|
||||
console.log("[boot] launching Chrome...");
|
||||
const pid = await launchChrome();
|
||||
console.log(`[boot] Chrome pid=${pid} port=${PORT}`);
|
||||
try {
|
||||
const target = await openPage();
|
||||
const cdp = new CDP(target.webSocketDebuggerUrl);
|
||||
await cdp.connect();
|
||||
await cdp.send("Page.enable");
|
||||
await cdp.send("Network.enable");
|
||||
await cdp.send("Runtime.enable");
|
||||
await cdp.send("Emulation.setDeviceMetricsOverride", {
|
||||
width: 1500,
|
||||
height: 900,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false,
|
||||
});
|
||||
await setupSessionMock(cdp);
|
||||
|
||||
// ===== 1. Hero 进度三态(裁切胶囊 + 整图各一份)=====
|
||||
// 1a. 0/12 (已登录但未投)
|
||||
// user-data-dir 复用会带上上次的 localStorage —— 必须先进 ORIGIN 上下文再 clear
|
||||
await navigate(cdp, ORIGIN, 500);
|
||||
await clearLocalStorage(cdp);
|
||||
await navigate(cdp, ORIGIN);
|
||||
await pauseVideos(cdp);
|
||||
await wait(800);
|
||||
await screenshotFull(cdp, `${OUT_DIR}\\01a-hero-0of12.png`);
|
||||
await screenshotElement(
|
||||
cdp,
|
||||
"[data-hero-vote-progress]",
|
||||
`${OUT_DIR}\\01a-progress-0of12.png`,
|
||||
12,
|
||||
);
|
||||
|
||||
// 1b. 5/12
|
||||
await setLocalStorage(cdp, {
|
||||
"cyber-star-vote": makeVoteState(["001", "002", "003", "004", "005"]),
|
||||
});
|
||||
await navigate(cdp, ORIGIN);
|
||||
await pauseVideos(cdp);
|
||||
await wait(800);
|
||||
await screenshotFull(cdp, `${OUT_DIR}\\01b-hero-5of12.png`);
|
||||
await screenshotElement(
|
||||
cdp,
|
||||
"[data-hero-vote-progress]",
|
||||
`${OUT_DIR}\\01b-progress-5of12.png`,
|
||||
12,
|
||||
);
|
||||
|
||||
// 1c. 12/12
|
||||
await setLocalStorage(cdp, {
|
||||
"cyber-star-vote": makeVoteState([
|
||||
"001", "002", "003", "004", "005", "006",
|
||||
"007", "008", "009", "010", "011", "012",
|
||||
]),
|
||||
});
|
||||
await navigate(cdp, ORIGIN);
|
||||
await pauseVideos(cdp);
|
||||
await wait(800);
|
||||
await screenshotFull(cdp, `${OUT_DIR}\\01c-hero-12of12.png`);
|
||||
await screenshotElement(
|
||||
cdp,
|
||||
"[data-hero-vote-progress]",
|
||||
`${OUT_DIR}\\01c-progress-12of12.png`,
|
||||
12,
|
||||
);
|
||||
|
||||
// ===== 2. 艺人卡片角标对比 =====
|
||||
// 投了 1/3/5 — 卡片网格里能看到混合态(已投紫框✓ vs 未投灰框)
|
||||
await setLocalStorage(cdp, {
|
||||
"cyber-star-vote": makeVoteState(["001", "003", "005"]),
|
||||
});
|
||||
await navigate(cdp, ORIGIN);
|
||||
await pauseVideos(cdp);
|
||||
await cdp.send("Runtime.evaluate", {
|
||||
expression: `document.getElementById('artists')?.scrollIntoView({behavior:'instant',block:'start'}); window.scrollBy(0, 100);`,
|
||||
});
|
||||
await wait(1000);
|
||||
await screenshotFull(cdp, `${OUT_DIR}\\02-artist-cards-mixed.png`);
|
||||
|
||||
// ===== 3. /me 页 =====
|
||||
// useSession returns mock session → MeContent 应该正常渲染
|
||||
// 但 /me 是 server component:它在 server 端调 auth() — 我们 intercept 仅作用 client。
|
||||
// 实际 server component 不会用到 fetch /api/auth/session,它用 cookies 直接验。
|
||||
// 没有 next-auth cookie → server redirect。我们尝试,如果失败就截 redirect 后状态。
|
||||
await navigate(cdp, `${ORIGIN}/me`, 1500);
|
||||
// 检查是否被重定向
|
||||
const urlInfo = await cdp.send("Runtime.evaluate", {
|
||||
expression: "location.pathname",
|
||||
returnByValue: true,
|
||||
});
|
||||
if (urlInfo.result.value !== "/me") {
|
||||
console.log(
|
||||
`[note] /me redirected to ${urlInfo.result.value} — server auth() not bypassed`,
|
||||
);
|
||||
// 在 hash 模式下不会 redirect; 强制 navigate 后端 client only render
|
||||
// 退而求其次:直接构造一个空白 page 在客户端 render MeContent — 无法,跳过
|
||||
}
|
||||
await screenshotFull(cdp, `${OUT_DIR}\\03-me-page.png`);
|
||||
|
||||
// ===== 4. VoteModal 正常态(未投 + 未满) =====
|
||||
await setLocalStorage(cdp, {
|
||||
"cyber-star-vote": makeVoteState([]),
|
||||
});
|
||||
await navigate(cdp, ORIGIN);
|
||||
await pauseVideos(cdp);
|
||||
await wait(1200);
|
||||
// 滚到卡片区点第一张卡的投票按钮
|
||||
await cdp.send("Runtime.evaluate", {
|
||||
expression: `document.getElementById('artists')?.scrollIntoView({behavior:'instant',block:'start'}); window.scrollBy(0, 100);`,
|
||||
});
|
||||
await wait(500);
|
||||
const clicked = await cdp.send("Runtime.evaluate", {
|
||||
expression: `(() => {
|
||||
const btns = Array.from(document.querySelectorAll('button'));
|
||||
const target = btns.find(b => (b.textContent || '').trim() === '投票');
|
||||
if (target) { target.click(); return true; }
|
||||
return false;
|
||||
})()`,
|
||||
returnByValue: true,
|
||||
});
|
||||
console.log(`[note] vote modal trigger: ${clicked.result.value}`);
|
||||
await wait(1000);
|
||||
await screenshotFull(cdp, `${OUT_DIR}\\04-vote-modal-normal.png`);
|
||||
await screenshotElement(
|
||||
cdp,
|
||||
'[role="dialog"]',
|
||||
`${OUT_DIR}\\04-vote-modal-cropped.png`,
|
||||
24,
|
||||
);
|
||||
|
||||
cdp.close();
|
||||
console.log("[done]");
|
||||
} finally {
|
||||
await killChrome(pid);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
47
scripts/cleanup-test-user.mjs
Normal file
47
scripts/cleanup-test-user.mjs
Normal file
@ -0,0 +1,47 @@
|
||||
// 删除测试用户 + 其所有 vote / fanSupport / signIn 数据,
|
||||
// 并把 artist.voteCount 回滚到投票前的值。
|
||||
//
|
||||
// 用法: node scripts/cleanup-test-user.mjs <phone>
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
const prisma = new PrismaClient({ log: ["error"] });
|
||||
|
||||
const phone = process.argv[2];
|
||||
if (!phone) {
|
||||
console.error("用法: node scripts/cleanup-test-user.mjs <phone>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { phone },
|
||||
select: { id: true, nickname: true },
|
||||
});
|
||||
if (!user) {
|
||||
console.log(`[skip] 没有找到 phone=${phone} 的用户,无需 cleanup`);
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`[cleanup] 用户 id=${user.id} (${user.nickname}) phone=${phone}`);
|
||||
|
||||
// 1. 把该用户的投票回滚到 artist.voteCount
|
||||
const votes = await prisma.vote.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { artistId: true, count: true },
|
||||
});
|
||||
console.log(` 待回滚 ${votes.length} 条投票`);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 1. 减回 artist.voteCount
|
||||
for (const v of votes) {
|
||||
await tx.artist.update({
|
||||
where: { id: v.artistId },
|
||||
data: { voteCount: { decrement: v.count } },
|
||||
});
|
||||
}
|
||||
// 2. 删除 Vote / FanSupport / SignIn(都有 onDelete: Cascade,删 user 就够,但显式更清晰)
|
||||
// 3. 删除 user 本身 —— cascade 会清掉关联表
|
||||
await tx.user.delete({ where: { id: user.id } });
|
||||
});
|
||||
|
||||
console.log("[cleanup] 完成");
|
||||
await prisma.$disconnect();
|
||||
147
scripts/e2e-vote-flow.sh
Normal file
147
scripts/e2e-vote-flow.sh
Normal file
@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env bash
|
||||
# 端到端回归测试:走完整 next-auth OTP 登录 → /api/me → /api/vote 4 种路径
|
||||
#
|
||||
# 测试用户:全新手机号 13800138000(测试结束 cleanup-test-user.mjs 删除)
|
||||
# dev OTP 万能码:123456
|
||||
# 实际命中 route handler,不绕过任何中间件。
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
BASE="http://localhost:3000"
|
||||
PHONE="13800138000"
|
||||
CODE="123456"
|
||||
COOKIES=$(mktemp)
|
||||
trap 'rm -f "$COOKIES"' EXIT
|
||||
|
||||
green() { echo -e "\033[32m$1\033[0m"; }
|
||||
red() { echo -e "\033[31m$1\033[0m"; }
|
||||
yel() { echo -e "\033[33m$1\033[0m"; }
|
||||
|
||||
# 累计通过 / 失败
|
||||
PASS=0
|
||||
FAIL=0
|
||||
assert_eq() {
|
||||
local name="$1" expected="$2" actual="$3"
|
||||
if [[ "$actual" == "$expected" ]]; then
|
||||
PASS=$((PASS+1)); green " [PASS] $name"
|
||||
else
|
||||
FAIL=$((FAIL+1)); red " [FAIL] $name -- expected=$expected actual=$actual"
|
||||
fi
|
||||
}
|
||||
assert_contains() {
|
||||
local name="$1" needle="$2" haystack="$3"
|
||||
if echo "$haystack" | grep -q "$needle"; then
|
||||
PASS=$((PASS+1)); green " [PASS] $name"
|
||||
else
|
||||
FAIL=$((FAIL+1)); red " [FAIL] $name -- did not find '$needle' in: $haystack"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== 端到端回归测试 ==="
|
||||
echo "测试用户 phone=$PHONE"
|
||||
echo ""
|
||||
|
||||
# ===== 0. cleanup 任何上次残留的测试数据(测试幂等) =====
|
||||
yel "[0] cleanup 上次测试残留"
|
||||
node scripts/cleanup-test-user.mjs "$PHONE" 2>&1 | sed 's/^/ /'
|
||||
|
||||
# ===== 1. 取 CSRF token =====
|
||||
yel "[1] 获取 CSRF token"
|
||||
CSRF_RAW=$(curl -s -c "$COOKIES" "$BASE/api/auth/csrf")
|
||||
CSRF=$(echo "$CSRF_RAW" | sed -n 's/.*"csrfToken":"\([^"]*\)".*/\1/p')
|
||||
if [[ -z "$CSRF" ]]; then
|
||||
red "无法获取 csrfToken,响应:$CSRF_RAW"
|
||||
exit 1
|
||||
fi
|
||||
green " csrfToken=${CSRF:0:20}..."
|
||||
|
||||
# ===== 2. OTP 登录(Credentials provider 路径 /api/auth/callback/phone-otp) =====
|
||||
yel "[2] OTP 登录 phone=$PHONE code=$CODE"
|
||||
LOGIN_HTTP=$(curl -s -b "$COOKIES" -c "$COOKIES" -o /tmp/login.body -w "%{http_code}" \
|
||||
-X POST "$BASE/api/auth/callback/phone-otp" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
--data-urlencode "phone=$PHONE" \
|
||||
--data-urlencode "code=$CODE" \
|
||||
--data-urlencode "csrfToken=$CSRF" \
|
||||
--data-urlencode "redirect=false" \
|
||||
--data-urlencode "json=true")
|
||||
echo " HTTP $LOGIN_HTTP"
|
||||
|
||||
# ===== 3. 验证 session 已建立 =====
|
||||
yel "[3] 验证 session"
|
||||
SESSION=$(curl -s -b "$COOKIES" "$BASE/api/auth/session")
|
||||
echo " session: $SESSION"
|
||||
assert_contains "session 包含 user.id" '"id"' "$SESSION"
|
||||
|
||||
# ===== 4. GET /api/me 验返回结构 =====
|
||||
yel "[4] GET /api/me 验证新字段"
|
||||
ME=$(curl -s -b "$COOKIES" "$BASE/api/me")
|
||||
echo " body 摘要: $(echo "$ME" | head -c 300)..."
|
||||
assert_contains "/api/me 返回 ok:true" '"ok":true' "$ME"
|
||||
assert_contains "/api/me 含 voteQuota 字段" '"voteQuota"' "$ME"
|
||||
assert_contains "/api/me voteQuota.total=12" '"total":12' "$ME"
|
||||
assert_contains "/api/me 含 votedArtists 字段" '"votedArtists"' "$ME"
|
||||
if echo "$ME" | grep -q '"dailyQuota"'; then
|
||||
FAIL=$((FAIL+1)); red " [FAIL] /api/me 仍含 dailyQuota 字段(应已被 voteQuota 替换)"
|
||||
else
|
||||
PASS=$((PASS+1)); green " [PASS] /api/me 不再含旧 dailyQuota 字段"
|
||||
fi
|
||||
|
||||
# ===== 5. POST /api/vote 投未投过的艺人(预期成功) =====
|
||||
TEST_ARTIST="001"
|
||||
yel "[5] POST /api/vote artistId=$TEST_ARTIST(首次投,预期 200)"
|
||||
V1=$(curl -s -b "$COOKIES" -X POST "$BASE/api/vote" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"artistId\":\"$TEST_ARTIST\"}")
|
||||
echo " body: $V1"
|
||||
assert_contains "首投返回 ok:true" '"ok":true' "$V1"
|
||||
assert_contains "首投返回 totalQuota:12" '"totalQuota":12' "$V1"
|
||||
assert_contains "首投返回 votedCount=1" '"votedCount":1' "$V1"
|
||||
assert_contains "首投返回 remaining=11" '"remaining":11' "$V1"
|
||||
|
||||
# ===== 6. POST /api/vote 同一艺人(预期 409 ALREADY_VOTED) =====
|
||||
yel "[6] POST /api/vote artistId=$TEST_ARTIST(重投,预期 409 ALREADY_VOTED)"
|
||||
V2=$(curl -s -b "$COOKIES" -X POST "$BASE/api/vote" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"artistId\":\"$TEST_ARTIST\"}")
|
||||
echo " body: $V2"
|
||||
assert_contains "重投返回 ok:false" '"ok":false' "$V2"
|
||||
assert_contains "重投返回 ALREADY_VOTED" '"code":"ALREADY_VOTED"' "$V2"
|
||||
|
||||
# ===== 7. POST /api/vote 不存在的艺人(预期 404 NOT_FOUND) =====
|
||||
yel "[7] POST /api/vote artistId=999(不存在,预期 404)"
|
||||
V3=$(curl -s -b "$COOKIES" -X POST "$BASE/api/vote" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"artistId":"999"}')
|
||||
echo " body: $V3"
|
||||
assert_contains "无效艺人返回 ok:false" '"ok":false' "$V3"
|
||||
assert_contains "无效艺人返回 NOT_FOUND" '"code":"NOT_FOUND"' "$V3"
|
||||
|
||||
# ===== 8. 再次 GET /api/me 验 votedArtists 含新投艺人 =====
|
||||
yel "[8] GET /api/me 复查 votedArtists"
|
||||
ME2=$(curl -s -b "$COOKIES" "$BASE/api/me")
|
||||
assert_contains "复查 votedArtists 含 $TEST_ARTIST" "\"$TEST_ARTIST\"" "$ME2"
|
||||
assert_contains "复查 voteQuota.used=1" '"used":1' "$ME2"
|
||||
assert_contains "复查 voteQuota.remaining=11" '"remaining":11' "$ME2"
|
||||
|
||||
# ===== 9. 验证未登录 → 401 =====
|
||||
yel "[9] 未登录调 /api/vote(预期 401)"
|
||||
V_NOAUTH=$(curl -s -X POST "$BASE/api/vote" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"artistId":"001"}')
|
||||
assert_contains "无 session 返回 UNAUTHORIZED" '"code":"UNAUTHORIZED"' "$V_NOAUTH"
|
||||
|
||||
# ===== 10. 最终 cleanup =====
|
||||
yel "[10] 测试结束 cleanup"
|
||||
node scripts/cleanup-test-user.mjs "$PHONE" 2>&1 | sed 's/^/ /'
|
||||
|
||||
# ===== 汇总 =====
|
||||
echo ""
|
||||
echo "=== 汇总 ==="
|
||||
echo "通过 $PASS · 失败 $FAIL"
|
||||
if [[ $FAIL -gt 0 ]]; then
|
||||
red "回归测试有失败,详见上方"
|
||||
exit 1
|
||||
else
|
||||
green "全部通过"
|
||||
fi
|
||||
10
scripts/find-test-user.mjs
Normal file
10
scripts/find-test-user.mjs
Normal file
@ -0,0 +1,10 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
const prisma = new PrismaClient({ log: ["error"] });
|
||||
const u = await prisma.user.findMany({
|
||||
where: { phone: { not: null } },
|
||||
select: { id: true, phone: true, nickname: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
console.log("现有手机号用户:");
|
||||
for (const r of u) console.log(` user_id=${r.id} phone=${r.phone} name=${r.nickname}`);
|
||||
await prisma.$disconnect();
|
||||
93
scripts/inspect-db.mjs
Normal file
93
scripts/inspect-db.mjs
Normal file
@ -0,0 +1,93 @@
|
||||
// 只读探查生产 DB 真实状态 —— 不写任何数据,不改任何 schema。
|
||||
// 用法:node scripts/inspect-db.mjs
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient({ log: ["error"] });
|
||||
|
||||
async function main() {
|
||||
console.log("=== 生产 DB 真实状态(只读)===\n");
|
||||
|
||||
// 1. 用户量级
|
||||
const userCount = await prisma.user.count();
|
||||
console.log(`users 总数: ${userCount}`);
|
||||
|
||||
// 2. 投票量级
|
||||
const voteCount = await prisma.vote.count();
|
||||
console.log(`votes 总数: ${voteCount}`);
|
||||
|
||||
// 3. 是否存在重复 (userId, artistId) — 加 unique 前必看
|
||||
const dup = await prisma.$queryRaw`
|
||||
SELECT user_id, artist_id, COUNT(*) AS cnt
|
||||
FROM votes
|
||||
GROUP BY user_id, artist_id
|
||||
HAVING cnt > 1
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 20
|
||||
`;
|
||||
console.log(`重复 (userId, artistId) 行数: ${dup.length}`);
|
||||
if (dup.length > 0) {
|
||||
console.log("Top 20 重复样本:");
|
||||
for (const row of dup) {
|
||||
console.log(` user=${row.user_id} artist=${row.artist_id} 重复=${row.cnt}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 单用户最大投票数 — 看是否有人已超过 12 票
|
||||
const topVoters = await prisma.$queryRaw`
|
||||
SELECT user_id, COUNT(*) AS total_votes, COUNT(DISTINCT artist_id) AS unique_artists
|
||||
FROM votes
|
||||
GROUP BY user_id
|
||||
ORDER BY total_votes DESC
|
||||
LIMIT 10
|
||||
`;
|
||||
console.log(`\nTop 10 投票最多的用户:`);
|
||||
for (const row of topVoters) {
|
||||
console.log(
|
||||
` user=${row.user_id} total=${row.total_votes} 不同艺人=${row.unique_artists}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. DailyQuota 状态
|
||||
const dqCount = await prisma.dailyQuota.count();
|
||||
console.log(`\ndaily_quota 总数: ${dqCount}`);
|
||||
|
||||
// 6. FanSupport 状态
|
||||
const fsCount = await prisma.fanSupport.count();
|
||||
console.log(`fan_supports 总数: ${fsCount}`);
|
||||
|
||||
// 7. ActivityConfig 配置
|
||||
const config = await prisma.activityConfig.findUnique({ where: { id: 1 } });
|
||||
console.log(`\nactivity_config:`);
|
||||
if (config) {
|
||||
console.log(` voteEnabled=${config.voteEnabled}`);
|
||||
console.log(` dailyQuota=${config.dailyQuota}`);
|
||||
console.log(` perArtistLimit=${config.perArtistLimit}`);
|
||||
console.log(` startAt=${config.startAt.toISOString()}`);
|
||||
console.log(` endAt=${config.endAt.toISOString()}`);
|
||||
} else {
|
||||
console.log(" (空)");
|
||||
}
|
||||
|
||||
// 8. Vote 表当前索引(原始 SQL 探)
|
||||
const indexes = await prisma.$queryRaw`
|
||||
SHOW INDEX FROM votes
|
||||
`;
|
||||
console.log(`\nvotes 当前索引:`);
|
||||
for (const idx of indexes) {
|
||||
console.log(
|
||||
` ${idx.Key_name} col=${idx.Column_name} seq=${idx.Seq_in_index} unique=${idx.Non_unique === 0 ? "Y" : "N"}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 9. 服务器版本
|
||||
const ver = await prisma.$queryRaw`SELECT VERSION() AS v`;
|
||||
console.log(`\nMySQL 版本: ${ver[0].v}`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async (e) => {
|
||||
console.error(e);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
177
scripts/test-vote-rules.mjs
Normal file
177
scripts/test-vote-rules.mjs
Normal file
@ -0,0 +1,177 @@
|
||||
// 后端投票规则验证(不污染生产数据,所有写操作都在事务里 rollback)
|
||||
import { PrismaClient, Prisma } from "@prisma/client";
|
||||
const prisma = new PrismaClient({ log: ["error"] });
|
||||
|
||||
const PASS = "\x1b[32mPASS\x1b[0m";
|
||||
const FAIL = "\x1b[31mFAIL\x1b[0m";
|
||||
|
||||
let results = [];
|
||||
function record(name, ok, detail = "") {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(` [${ok ? PASS : FAIL}] ${name}${detail ? " -- " + detail : ""}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("=== 后端投票规则验证(事务回滚,不留痕)===\n");
|
||||
|
||||
// 取一个真实用户(测试数据中的最大投票用户)
|
||||
const sample = await prisma.$queryRaw`
|
||||
SELECT user_id, COUNT(*) AS c FROM votes GROUP BY user_id ORDER BY c DESC LIMIT 1
|
||||
`;
|
||||
if (sample.length === 0) {
|
||||
console.log("[note] votes 表为空,跳过有交互的测试");
|
||||
return;
|
||||
}
|
||||
const sampleUserId = BigInt(sample[0].user_id);
|
||||
const sampleVotes = await prisma.vote.findMany({
|
||||
where: { userId: sampleUserId },
|
||||
select: { artistId: true },
|
||||
});
|
||||
const sampleArtist = sampleVotes[0].artistId;
|
||||
console.log(
|
||||
`测试样本:user=${sampleUserId} 已投艺人=[${sampleVotes
|
||||
.map((v) => v.artistId)
|
||||
.join(",")}]\n`,
|
||||
);
|
||||
|
||||
// 测试 1:DB unique 约束生效 —— 重复 (userId, artistId) INSERT 应失败 P2002
|
||||
console.log("[测试 1] DB unique 约束:重复 (userId, artistId) 必被拒");
|
||||
try {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await tx.vote.create({
|
||||
data: {
|
||||
userId: sampleUserId,
|
||||
artistId: sampleArtist,
|
||||
count: 1,
|
||||
source: "QUOTA",
|
||||
},
|
||||
});
|
||||
throw new Error("ROLLBACK_AFTER_TEST"); // 强制回滚
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
record("unique 约束阻挡重复投票", false, "INSERT 居然成功了");
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === "P2002"
|
||||
) {
|
||||
record("unique 约束阻挡重复投票", true, "P2002 unique constraint");
|
||||
} else if (e.message === "ROLLBACK_AFTER_TEST") {
|
||||
record(
|
||||
"unique 约束阻挡重复投票",
|
||||
false,
|
||||
"INSERT 居然成功 = 没有 unique 约束!",
|
||||
);
|
||||
} else {
|
||||
record("unique 约束阻挡重复投票", false, `异常: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 测试 2:不同艺人 INSERT 应该成功(在事务内回滚,不留痕)
|
||||
console.log(
|
||||
"\n[测试 2] 不同艺人投票不会被 unique 阻挡(事务内验证后回滚)",
|
||||
);
|
||||
const candidateArtist = await prisma.artist.findFirst({
|
||||
where: {
|
||||
id: { notIn: sampleVotes.map((v) => v.artistId) },
|
||||
status: "ACTIVE",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!candidateArtist) {
|
||||
record("跨艺人投票", false, "找不到该用户未投过的艺人样本");
|
||||
} else {
|
||||
try {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await tx.vote.create({
|
||||
data: {
|
||||
userId: sampleUserId,
|
||||
artistId: candidateArtist.id,
|
||||
count: 1,
|
||||
source: "QUOTA",
|
||||
},
|
||||
});
|
||||
throw new Error("ROLLBACK_AFTER_TEST");
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
record("跨艺人投票", false, "事务未回滚");
|
||||
} catch (e) {
|
||||
if (e.message === "ROLLBACK_AFTER_TEST") {
|
||||
record(
|
||||
"跨艺人投票",
|
||||
true,
|
||||
`允许投给新艺人 ${candidateArtist.id},事务已回滚`,
|
||||
);
|
||||
} else {
|
||||
record("跨艺人投票", false, `异常: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 测试 3:终身额度 12 票上限(由 /api/vote 应用层校验,DB 不强制)
|
||||
// 验证:对所有用户跑 SELECT COUNT(*) < 12 是 true,确认目前没人超额
|
||||
console.log("\n[测试 3] 终身额度上限 12 票 - 数据合规性");
|
||||
const overflows = await prisma.$queryRaw`
|
||||
SELECT user_id, COUNT(*) AS c FROM votes GROUP BY user_id HAVING c > 12
|
||||
`;
|
||||
if (overflows.length === 0) {
|
||||
record(
|
||||
"现存数据无超额用户",
|
||||
true,
|
||||
"12 票上限对所有现存用户都成立(应用层会兜底)",
|
||||
);
|
||||
} else {
|
||||
record(
|
||||
"现存数据无超额用户",
|
||||
false,
|
||||
`${overflows.length} 个用户已超过 12 票:${overflows
|
||||
.map((r) => `user=${r.user_id} c=${r.c}`)
|
||||
.join("; ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 测试 4:回归确认 /api/me 用的 vote 关键查询能返回 votedArtists 列表
|
||||
console.log("\n[测试 4] /api/me votedArtists 查询正确性");
|
||||
const votedList = await prisma.vote.findMany({
|
||||
where: { userId: sampleUserId },
|
||||
select: { artistId: true, createdAt: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
const isAsc = votedList.every(
|
||||
(v, i) =>
|
||||
i === 0 || votedList[i - 1].createdAt.getTime() <= v.createdAt.getTime(),
|
||||
);
|
||||
record(
|
||||
"votedArtists 按 createdAt 升序",
|
||||
isAsc,
|
||||
`共 ${votedList.length} 条`,
|
||||
);
|
||||
|
||||
// 测试 5:旧 daily_quota / fan_supports 数据仍可读(向后兼容,不报错)
|
||||
console.log("\n[测试 5] 旧数据兼容性");
|
||||
try {
|
||||
const dqCount = await prisma.dailyQuota.count();
|
||||
const fsCount = await prisma.fanSupport.count();
|
||||
record("旧 DailyQuota / FanSupport 仍可读", true, `dq=${dqCount} fs=${fsCount}`);
|
||||
} catch (e) {
|
||||
record("旧 DailyQuota / FanSupport 仍可读", false, e.message);
|
||||
}
|
||||
|
||||
// 汇总
|
||||
console.log("\n=== 汇总 ===");
|
||||
const passed = results.filter((r) => r.ok).length;
|
||||
const failed = results.length - passed;
|
||||
console.log(`通过 ${passed} / 共 ${results.length}${failed ? ` 失败 ${failed}` : ""}`);
|
||||
await prisma.$disconnect();
|
||||
if (failed) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch(async (e) => {
|
||||
console.error("\n[ABORT]", e);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
14
scripts/verify-unique.mjs
Normal file
14
scripts/verify-unique.mjs
Normal file
@ -0,0 +1,14 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
const prisma = new PrismaClient({ log: ["error"] });
|
||||
const rows = await prisma.$queryRaw`
|
||||
SELECT INDEX_NAME, COLUMN_NAME, SEQ_IN_INDEX, NON_UNIQUE
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'votes'
|
||||
ORDER BY INDEX_NAME, SEQ_IN_INDEX
|
||||
`;
|
||||
for (const r of rows) {
|
||||
console.log(
|
||||
` ${r.INDEX_NAME} col=${r.COLUMN_NAME} seq=${r.SEQ_IN_INDEX} unique=${r.NON_UNIQUE == 0 ? "Y" : "N"}`,
|
||||
);
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
@ -1,11 +1,19 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentUser } from "@/lib/current-user";
|
||||
import { startOfUtcDay, isSameUtcDay } from "@/lib/date-utils";
|
||||
import { isSameUtcDay, startOfUtcDay } from "@/lib/date-utils";
|
||||
import { ok, ERR, sanitizeBigInt } from "@/lib/api-response";
|
||||
|
||||
/**
|
||||
* 终身投票总额度,与前端 src/lib/store.ts 的 TOTAL_VOTE_QUOTA 对齐。
|
||||
*/
|
||||
const TOTAL_VOTE_QUOTA = 12;
|
||||
|
||||
/**
|
||||
* GET /api/me
|
||||
* 当前用户信息:基础资料、累计投票数、应援列表、签到状态、今日票数额度。
|
||||
* 当前用户信息:基础资料、应援列表、签到状态、终身票数额度。
|
||||
*
|
||||
* 新增字段 votedArtists: string[] —— 按投票时间升序(最早投的在前),
|
||||
* 供前端 hydrate 时取代纯 localStorage,跨设备/清缓存后仍可恢复。
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
@ -27,8 +35,7 @@ export async function GET() {
|
||||
};
|
||||
};
|
||||
|
||||
const [profile, signIn, supports, dailyQuotaRow, config] =
|
||||
(await Promise.all([
|
||||
const [profile, signIn, supports, votedList] = (await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
@ -57,29 +64,26 @@ export async function GET() {
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { votedTotal: "desc" },
|
||||
orderBy: { createdAt: "asc" },
|
||||
}),
|
||||
prisma.dailyQuota.findUnique({
|
||||
where: { userId_date: { userId: user.id, date: today } },
|
||||
// 按投票顺序拉 votedArtists —— 前端 store 用作 hydrate 真相源
|
||||
prisma.vote.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { artistId: true, createdAt: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
}),
|
||||
prisma.activityConfig.findUnique({ where: { id: 1 } }),
|
||||
])) as [
|
||||
Awaited<ReturnType<typeof prisma.user.findUnique>>,
|
||||
Awaited<ReturnType<typeof prisma.signIn.findFirst>>,
|
||||
SupportRow[],
|
||||
Awaited<ReturnType<typeof prisma.dailyQuota.findUnique>>,
|
||||
Awaited<ReturnType<typeof prisma.activityConfig.findUnique>>,
|
||||
{ artistId: string; createdAt: Date }[],
|
||||
];
|
||||
|
||||
if (!profile) return ERR.NOT_FOUND("用户不存在");
|
||||
|
||||
const totalVotes = await prisma.vote.aggregate({
|
||||
where: { userId: user.id },
|
||||
_sum: { count: true },
|
||||
});
|
||||
|
||||
const dailyQuota = dailyQuotaRow?.totalQuota ?? config?.dailyQuota ?? 10;
|
||||
const usedToday = dailyQuotaRow?.usedQuota ?? 0;
|
||||
const votedArtists = votedList.map((v) => v.artistId);
|
||||
const votedCount = votedArtists.length;
|
||||
const remaining = Math.max(0, TOTAL_VOTE_QUOTA - votedCount);
|
||||
|
||||
return ok(
|
||||
sanitizeBigInt({
|
||||
@ -89,14 +93,17 @@ export async function GET() {
|
||||
lastDate: signIn?.date ?? null,
|
||||
todaySignedIn: signIn ? isSameUtcDay(signIn.date, today) : false,
|
||||
},
|
||||
totalVotes: totalVotes._sum.count ?? 0,
|
||||
dailyQuota: {
|
||||
total: dailyQuota,
|
||||
used: usedToday,
|
||||
remaining: Math.max(0, dailyQuota - usedToday),
|
||||
// 终身票数视图 —— 替代旧的 dailyQuota
|
||||
voteQuota: {
|
||||
total: TOTAL_VOTE_QUOTA,
|
||||
used: votedCount,
|
||||
remaining,
|
||||
},
|
||||
// 已投艺人 ID 列表(按时间升序),前端 hydrate 时回放到 store
|
||||
votedArtists,
|
||||
supports: supports.map((s: SupportRow) => ({
|
||||
artist: s.artist,
|
||||
// 新规则下 votedTotal 固定 1;旧数据 votedTotal>1 仍真实反映历史
|
||||
votedTotal: s.votedTotal,
|
||||
})),
|
||||
}),
|
||||
@ -106,4 +113,3 @@ export async function GET() {
|
||||
return ERR.INTERNAL();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,37 +8,50 @@ import {
|
||||
getClientIp,
|
||||
getUserAgent,
|
||||
} from "@/lib/current-user";
|
||||
import { startOfUtcDay } from "@/lib/date-utils";
|
||||
import { ok, ERR, sanitizeBigInt } from "@/lib/api-response";
|
||||
|
||||
type TxClient = Prisma.TransactionClient;
|
||||
|
||||
/**
|
||||
* 终身投票额度:每个用户共 12 票,每位艺人最多 1 票。
|
||||
* 用 const 而非读 DB,避免每次请求多一次查询。前端 store 同名常量保持一致。
|
||||
*/
|
||||
const TOTAL_VOTE_QUOTA = 12;
|
||||
|
||||
const VoteBody = z.object({
|
||||
artistId: z.string().min(1).max(8),
|
||||
count: z.number().int().min(1).max(99_999),
|
||||
// 旧前端仍可能传 count 字段(>=1),后端一律视为 1 票
|
||||
count: z.number().int().min(1).max(99_999).optional(),
|
||||
});
|
||||
|
||||
/** 内部抛错用,事务捕获后转为业务错误响应 */
|
||||
/** 内部抛错用,事务捕获后转业务错误响应 */
|
||||
class QuotaExhaustedError extends Error {
|
||||
constructor(public remaining: number) {
|
||||
constructor() {
|
||||
super("QUOTA_EXHAUSTED");
|
||||
}
|
||||
}
|
||||
class AlreadyVotedError extends Error {
|
||||
constructor() {
|
||||
super("ALREADY_VOTED");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/vote
|
||||
* 投票接口。
|
||||
*
|
||||
* 规则:
|
||||
* - 每日总额度:ActivityConfig.dailyQuota(默认 10)
|
||||
* - 无单艺人上限(perArtistLimit 字段保留但不强制)
|
||||
* - 单用户限流:1 秒 5 次;单 IP 限流:60 秒 60 次
|
||||
* 新规则:
|
||||
* - 每用户终身 12 票
|
||||
* - 每艺人 1 票(DB 层 @@unique([userId, artistId]) 兜底)
|
||||
* - 不可撤销,不限时
|
||||
* - 单用户限流:1 秒 5 次;单 IP 限流:60 秒 60 次
|
||||
*
|
||||
* 流程:
|
||||
* 流程:
|
||||
* 1. 鉴权 + 反作弊限流
|
||||
* 2. 校验活动开关 + 时间窗
|
||||
* 3. 事务:当日额度检查 → 写投票 → 累加艺人票数 → 更新应援 → 扣减额度
|
||||
* 4. 返回最新票数 + 当日剩余
|
||||
* 2. 校验活动开关(voteEnabled)
|
||||
* 3. 事务:已投艺人计数 >= 12 → QUOTA_EXHAUSTED;否则写票
|
||||
* - DB unique 冲突 (P2002) → ALREADY_VOTED
|
||||
* 4. 累加 artist.voteCount;upsert FanSupport(votedTotal=1)
|
||||
* 5. 返回最新票数 + 剩余票数
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
@ -59,41 +72,29 @@ export async function POST(req: NextRequest) {
|
||||
if (!parsed.success) {
|
||||
return ERR.VALIDATION(parsed.error.issues[0]?.message ?? "参数错误");
|
||||
}
|
||||
const { artistId, count } = parsed.data;
|
||||
const { artistId } = parsed.data;
|
||||
|
||||
const config = await prisma.activityConfig.findUnique({ where: { id: 1 } });
|
||||
if (!config?.voteEnabled) return ERR.ACTIVITY_OFF();
|
||||
const now = new Date();
|
||||
if (now < config.startAt || now > config.endAt) return ERR.ACTIVITY_OFF();
|
||||
// 新规则不限时,移除 startAt/endAt 校验
|
||||
|
||||
const ua = await getUserAgent();
|
||||
const today = startOfUtcDay();
|
||||
const dailyQuota = config.dailyQuota;
|
||||
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx: TxClient) => {
|
||||
// 1. 当日额度(不存在则按 config 创建)
|
||||
const dq = await tx.dailyQuota.upsert({
|
||||
where: { userId_date: { userId: user.id, date: today } },
|
||||
create: {
|
||||
userId: user.id,
|
||||
date: today,
|
||||
totalQuota: dailyQuota,
|
||||
usedQuota: 0,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
const remaining = dq.totalQuota - dq.usedQuota;
|
||||
if (count > remaining) {
|
||||
throw new QuotaExhaustedError(remaining);
|
||||
// 1. 终身额度校验:已投艺人数 >= 12 → 拒
|
||||
const votedSoFar = await tx.vote.count({ where: { userId: user.id } });
|
||||
if (votedSoFar >= TOTAL_VOTE_QUOTA) {
|
||||
throw new QuotaExhaustedError();
|
||||
}
|
||||
|
||||
// 2. 写入投票
|
||||
// DB unique (userId, artistId) 在 catch 里转 ALREADY_VOTED
|
||||
const vote = await tx.vote.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
artistId,
|
||||
count,
|
||||
count: 1,
|
||||
source: "QUOTA",
|
||||
ip: ip ?? undefined,
|
||||
ua: ua ?? undefined,
|
||||
@ -103,29 +104,23 @@ export async function POST(req: NextRequest) {
|
||||
// 3. 累加艺人票数
|
||||
const artist = await tx.artist.update({
|
||||
where: { id: artistId },
|
||||
data: { voteCount: { increment: count } },
|
||||
data: { voteCount: { increment: 1 } },
|
||||
select: { id: true, voteCount: true, name: true },
|
||||
});
|
||||
|
||||
// 4. 应援关系
|
||||
// 4. 应援关系 —— 每艺人 1 票,votedTotal 固定 1
|
||||
await tx.fanSupport.upsert({
|
||||
where: { userId_artistId: { userId: user.id, artistId } },
|
||||
create: { userId: user.id, artistId, votedTotal: count },
|
||||
update: { votedTotal: { increment: count } },
|
||||
});
|
||||
|
||||
// 5. 扣减当日额度
|
||||
// 用上一步 upsert 返回的 dq.id 做主键更新, 避免 MySQL @db.Date 字段
|
||||
// 经时区转换后 userId_date 复合键查不到行 (P2025)
|
||||
const updatedDq = await tx.dailyQuota.update({
|
||||
where: { id: dq.id },
|
||||
data: { usedQuota: { increment: count } },
|
||||
create: { userId: user.id, artistId, votedTotal: 1 },
|
||||
update: { votedTotal: 1 },
|
||||
});
|
||||
|
||||
const votedAfter = votedSoFar + 1;
|
||||
return {
|
||||
vote,
|
||||
artist,
|
||||
remaining: updatedDq.totalQuota - updatedDq.usedQuota,
|
||||
votedCount: votedAfter,
|
||||
remaining: TOTAL_VOTE_QUOTA - votedAfter,
|
||||
};
|
||||
});
|
||||
|
||||
@ -134,15 +129,33 @@ export async function POST(req: NextRequest) {
|
||||
artistId: result.artist.id,
|
||||
artistVotes: result.artist.voteCount,
|
||||
voteId: result.vote.id,
|
||||
votedCount: result.votedCount,
|
||||
remaining: result.remaining,
|
||||
dailyQuota,
|
||||
totalQuota: TOTAL_VOTE_QUOTA,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof QuotaExhaustedError) {
|
||||
return ERR.QUOTA_EXHAUSTED(
|
||||
`今日票数仅剩 ${e.remaining} 票,无法一次投出 ${count} 票`,
|
||||
);
|
||||
return ERR.QUOTA_EXHAUSTED();
|
||||
}
|
||||
if (e instanceof AlreadyVotedError) {
|
||||
return ERR.ALREADY_VOTED();
|
||||
}
|
||||
// Prisma unique 冲突 → ALREADY_VOTED
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === "P2002"
|
||||
) {
|
||||
return ERR.ALREADY_VOTED();
|
||||
}
|
||||
// 艺人不存在:
|
||||
// - P2003: FK 违反(vote.create 时 artistId 外键约束失败)
|
||||
// - P2025: 记录不存在(artist.update 找不到目标)
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
(e.code === "P2003" || e.code === "P2025")
|
||||
) {
|
||||
return ERR.NOT_FOUND("艺人不存在");
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
@ -151,4 +164,3 @@ export async function POST(req: NextRequest) {
|
||||
return ERR.INTERNAL();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ import MyFanSupport from "@/components/me/MyFanSupport";
|
||||
import {
|
||||
useVoteStore,
|
||||
selectRemaining,
|
||||
DAILY_VOTE_QUOTA,
|
||||
TOTAL_VOTE_QUOTA,
|
||||
type MySupport,
|
||||
} from "@/lib/store";
|
||||
|
||||
@ -22,23 +22,21 @@ interface MeContentProps {
|
||||
}
|
||||
|
||||
export default function MeContent({ session }: MeContentProps) {
|
||||
// 订阅 store 原始引用(稳定,仅在 set() 时变更),组件内 useMemo 派生 supports,
|
||||
// 订阅 store 原始引用(稳定,仅在 set() 时变更),组件内 useMemo 派生 supports,
|
||||
// 避免 Zustand v5 + useSyncExternalStore 对"selector 返回新引用"报 infinite-loop 错。
|
||||
const myTotalVotes = useVoteStore((s) => s.myTotalVotes);
|
||||
const myVotesByArtist = useVoteStore((s) => s.myVotesByArtist);
|
||||
const votedArtists = useVoteStore((s) => s.votedArtists);
|
||||
const storeArtists = useVoteStore((s) => s.artists);
|
||||
const remaining = useVoteStore(selectRemaining);
|
||||
const votedCount = votedArtists.length;
|
||||
|
||||
const supports = useMemo<MySupport[]>(() => {
|
||||
const list: MySupport[] = [];
|
||||
for (const [id, votedCount] of Object.entries(myVotesByArtist)) {
|
||||
if (votedCount <= 0) continue;
|
||||
for (const id of votedArtists) {
|
||||
const artist = storeArtists.find((a) => a.id === id);
|
||||
if (artist) list.push({ artist, votedCount });
|
||||
if (artist) list.push({ artist });
|
||||
}
|
||||
list.sort((a, b) => b.votedCount - a.votedCount);
|
||||
return list;
|
||||
}, [myVotesByArtist, storeArtists]);
|
||||
}, [votedArtists, storeArtists]);
|
||||
|
||||
const handleLogout = () => {
|
||||
toast("正在退出登录…");
|
||||
@ -53,9 +51,9 @@ export default function MeContent({ session }: MeContentProps) {
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
|
||||
<QuotaCard remaining={remaining} dailyQuota={DAILY_VOTE_QUOTA} />
|
||||
<QuotaCard remaining={remaining} totalQuota={TOTAL_VOTE_QUOTA} />
|
||||
|
||||
<StatsGrid totalVotes={myTotalVotes} supportingCount={supports.length} />
|
||||
<StatsGrid voted={votedCount} remaining={remaining} />
|
||||
|
||||
<section>
|
||||
<SectionTitle label="我的应援" />
|
||||
|
||||
@ -7,7 +7,7 @@ import Top12Bar from "@/components/Top12Bar";
|
||||
import ArtistCard from "@/components/cards/ArtistCard";
|
||||
import ArtistFilters, { type TagFilter } from "@/components/ArtistFilters";
|
||||
import VoteModal from "@/components/VoteModal";
|
||||
import { getActivityEndTime, sortArtists, type SortKey } from "@/lib/mock-data";
|
||||
import { sortArtists, type SortKey } from "@/lib/mock-data";
|
||||
import { useVoteStore } from "@/lib/store";
|
||||
import { useVoteAction } from "@/hooks/useVoteAction";
|
||||
import { useScrollRestore } from "@/hooks/useScrollRestore";
|
||||
@ -17,7 +17,7 @@ import { tosUrl } from "@/lib/tos";
|
||||
|
||||
export default function Home() {
|
||||
const artists = useVoteStore((s) => s.artists);
|
||||
const { target, remaining, dailyQuota, openVote, closeVote, confirmVote } =
|
||||
const { target, remaining, totalQuota, openVote, closeVote, confirmVote } =
|
||||
useVoteAction();
|
||||
|
||||
const [tagFilter, setTagFilter] = useState<TagFilter>("all");
|
||||
@ -26,8 +26,6 @@ export default function Home() {
|
||||
const filterSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const setStoreFilterStuck = useUIStore((s) => s.setFilterStuck);
|
||||
|
||||
const endTime = useMemo(() => getActivityEndTime(), []);
|
||||
|
||||
// 首页滚动位置 per-tab 记忆:从艺人详情点 ← 返回时恢复到上次浏览位置
|
||||
useScrollRestore("home");
|
||||
|
||||
@ -86,7 +84,7 @@ export default function Home() {
|
||||
scrollSnapAlign: "start",
|
||||
}}
|
||||
>
|
||||
<HeroBanner endTime={endTime} videoSrc={tosUrl("videos/hero-pv.mp4")} />
|
||||
<HeroBanner videoSrc={tosUrl("videos/hero-pv.mp4")} />
|
||||
</div>
|
||||
|
||||
{/* Top12 出道位 · 作为第二个 snap 点:滚动结束后自然落到这里,标题贴近顶部 */}
|
||||
@ -174,7 +172,7 @@ export default function Home() {
|
||||
<VoteModal
|
||||
artist={target}
|
||||
remaining={remaining}
|
||||
dailyQuota={dailyQuota}
|
||||
totalQuota={totalQuota}
|
||||
onClose={closeVote}
|
||||
onConfirm={confirmVote}
|
||||
/>
|
||||
|
||||
@ -14,7 +14,7 @@ import type { Artist } from "@/types/artist";
|
||||
|
||||
export default function RankingPage() {
|
||||
const storeArtists = useVoteStore((s) => s.artists);
|
||||
const { target, remaining, dailyQuota, openVote, closeVote, confirmVote } =
|
||||
const { target, remaining, totalQuota, openVote, closeVote, confirmVote } =
|
||||
useVoteAction();
|
||||
|
||||
const live = useRanking({ pollInterval: 30_000 });
|
||||
@ -134,7 +134,7 @@ export default function RankingPage() {
|
||||
<VoteModal
|
||||
artist={target}
|
||||
remaining={remaining}
|
||||
dailyQuota={dailyQuota}
|
||||
totalQuota={totalQuota}
|
||||
onClose={closeVote}
|
||||
onConfirm={confirmVote}
|
||||
/>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Heart } from "lucide-react";
|
||||
import { Heart, Check } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useFooterPush } from "@/hooks/useFooterPush";
|
||||
|
||||
@ -10,12 +10,14 @@ interface FloatingVoteButtonProps {
|
||||
/** 显示前的滚动阈值(px) */
|
||||
threshold?: number;
|
||||
className?: string;
|
||||
hasVoted?: boolean;
|
||||
}
|
||||
|
||||
export default function FloatingVoteButton({
|
||||
onClick,
|
||||
threshold = 300,
|
||||
className,
|
||||
hasVoted = false,
|
||||
}: FloatingVoteButtonProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
// footer 进视口时按钮向上平移,始终漂浮在 footer 顶部上方
|
||||
@ -32,7 +34,7 @@ export default function FloatingVoteButton({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label="立即投票"
|
||||
aria-label={hasVoted ? "已投票" : "立即投票"}
|
||||
style={{
|
||||
// 隐藏态:下移 12px;显示态:基础位置 - footerPush(避让 footer)
|
||||
transform: visible
|
||||
@ -40,14 +42,26 @@ export default function FloatingVoteButton({
|
||||
: "translateY(12px)",
|
||||
}}
|
||||
className={cn(
|
||||
"fixed bottom-6 right-6 sm:bottom-8 sm:right-8 z-40 w-14 h-14 rounded-full bg-grad-purple text-white flex flex-col items-center justify-center font-display text-[9px] tracking-widest shadow-purple-glow animate-pulse-glow transition-all duration-300",
|
||||
"fixed bottom-6 right-6 sm:bottom-8 sm:right-8 z-40 w-14 h-14 rounded-full flex flex-col items-center justify-center font-display text-[9px] tracking-widest transition-all duration-300",
|
||||
hasVoted
|
||||
? "bg-white/12 border border-white/15 text-white/65"
|
||||
: "bg-grad-purple text-white shadow-purple-glow animate-pulse-glow",
|
||||
visible ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none",
|
||||
"hover:scale-105",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{hasVoted ? (
|
||||
<>
|
||||
<Check size={16} strokeWidth={3} className="mb-0.5" />
|
||||
<span>已投</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Heart size={16} fill="white" className="mb-0.5" />
|
||||
<span>投票</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Volume2, VolumeX } from "lucide-react";
|
||||
import Countdown from "./ui/Countdown";
|
||||
import HeroVoteProgress from "./HeroVoteProgress";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
interface HeroBannerProps {
|
||||
@ -10,8 +10,6 @@ interface HeroBannerProps {
|
||||
videoSrc?: string;
|
||||
/** 视频封面图 */
|
||||
poster?: string;
|
||||
/** 活动结束时间 */
|
||||
endTime: Date | string | number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@ -19,12 +17,12 @@ interface HeroBannerProps {
|
||||
* 全屏沉浸式 Hero:
|
||||
* - 容器宽度铺满视口(视频背景),但内部文案在 1500 版心内
|
||||
* - 高度 = 100svh - 80px 导航
|
||||
* - 右上角的"应援进度"组件替代原倒计时,体现 12 票终身额度玩法
|
||||
* - 声音按钮在右下角,即使视频未加载也能切换图标状态(视觉即时反馈)
|
||||
*/
|
||||
export default function HeroBanner({
|
||||
videoSrc,
|
||||
poster,
|
||||
endTime,
|
||||
className,
|
||||
}: HeroBannerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
@ -97,9 +95,9 @@ export default function HeroBanner({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 浅紫边框倒计时 右上 · 紧贴导航下方 */}
|
||||
{/* 应援进度 右上 · 紧贴导航下方 · 与左侧 Eyebrow 同高对齐 */}
|
||||
<div className="absolute top-[6.25rem] sm:top-[7rem] right-4 sm:right-6 lg:right-8 z-10">
|
||||
<Countdown endTime={endTime} compact />
|
||||
<HeroVoteProgress />
|
||||
</div>
|
||||
|
||||
{/* 声音按钮 右下 */}
|
||||
|
||||
115
src/components/HeroVoteProgress.tsx
Normal file
115
src/components/HeroVoteProgress.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Check, LogIn } from "lucide-react";
|
||||
import { useVoteStore, TOTAL_VOTE_QUOTA } from "@/lib/store";
|
||||
import { useLoginModalStore } from "@/lib/login-modal-store";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
/**
|
||||
* Hero 区域右上角的"应援进度"小组件,替代原 Countdown。
|
||||
* 视觉对齐 Countdown compact 模式:同高度 h-9、同位置、同毛玻璃质感(深底 + backdrop-blur + 浅紫边框)。
|
||||
*
|
||||
* 三态:
|
||||
* 1. 未登录 → "登录后开始投票" 可点击 CTA · 12 格全暗
|
||||
* 2. 已登录,未投满 → "应援进度 X/12" · 已投点亮、未投暗紫描边
|
||||
* 3. 投满 12 票 → "✓ 12 票全部投出" · 12 格全亮 · 紫色强辉光
|
||||
*/
|
||||
export default function HeroVoteProgress({ className }: { className?: string }) {
|
||||
const { status } = useSession();
|
||||
const votedCount = useVoteStore((s) => s.votedArtists.length);
|
||||
const openLogin = useLoginModalStore((s) => s.show);
|
||||
|
||||
const authed = status === "authenticated";
|
||||
const filled = authed ? votedCount : 0;
|
||||
const exhausted = authed && votedCount >= TOTAL_VOTE_QUOTA;
|
||||
|
||||
// 未登录 → 可点击 CTA;其它态 → 普通信息容器
|
||||
const isClickable = !authed;
|
||||
|
||||
const containerCls = cn(
|
||||
"inline-flex items-center gap-2.5 h-9 px-4 rounded-full",
|
||||
"bg-[rgba(13,10,36,0.55)] backdrop-blur-md",
|
||||
"border transition-colors",
|
||||
exhausted
|
||||
? "border-purple-300/70 shadow-[0_0_18px_rgba(139,92,246,0.45)]"
|
||||
: "border-purple-300/40",
|
||||
isClickable && "cursor-pointer hover:bg-[rgba(13,10,36,0.7)] hover:border-purple-300/60",
|
||||
className,
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{/* 左侧文字状态 */}
|
||||
{!authed ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-white/85 text-xs leading-none">
|
||||
<LogIn size={11} className="text-purple-300" />
|
||||
登录后开始投票
|
||||
</span>
|
||||
) : exhausted ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-purple-200 text-xs leading-none font-medium">
|
||||
<Check size={12} strokeWidth={3} />
|
||||
12 票全部投出
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-white/85 text-xs leading-none">
|
||||
应援进度
|
||||
<span className="font-display text-purple-300 tabular-nums tracking-wider">
|
||||
{filled}/{TOTAL_VOTE_QUOTA}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 12 格点亮式进度条 */}
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-flex items-center gap-[3px] ml-0.5"
|
||||
>
|
||||
{Array.from({ length: TOTAL_VOTE_QUOTA }).map((_, i) => {
|
||||
const lit = i < filled;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
"w-1.5 h-1.5 rounded-full transition-all duration-300",
|
||||
lit
|
||||
? exhausted
|
||||
? "bg-purple-200 shadow-[0_0_6px_rgba(196,181,253,0.95)]"
|
||||
: "bg-purple-400 shadow-[0_0_4px_rgba(167,139,250,0.85)]"
|
||||
: "bg-purple-500/15 border border-purple-300/25",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isClickable) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLogin()}
|
||||
aria-label="登录后开始投票"
|
||||
data-hero-vote-progress
|
||||
className={containerCls}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={containerCls}
|
||||
data-hero-vote-progress
|
||||
aria-label={
|
||||
exhausted
|
||||
? "12 票已全部投出"
|
||||
: `应援进度 ${filled} 票 / 共 ${TOTAL_VOTE_QUOTA} 票`
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -3,60 +3,47 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { X, Heart } from "lucide-react";
|
||||
import { X, Heart, AlertCircle, Check } from "lucide-react";
|
||||
import type { Artist } from "@/types/artist";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useVoteStore, selectHasVoted } from "@/lib/store";
|
||||
import Button from "./ui/Button";
|
||||
import ArtistPortrait from "./cards/ArtistPortrait";
|
||||
|
||||
type VoteOption = number | "ALL";
|
||||
|
||||
interface VoteModalProps {
|
||||
/** 当前要投票的艺人,传 null 关闭弹窗 */
|
||||
/** 当前要投票的艺人,传 null 关闭弹窗 */
|
||||
artist: Artist | null;
|
||||
/** 今日剩余票数(ALL 即投出该数值) */
|
||||
/** 剩余可投票数(终身 12 - 已投) */
|
||||
remaining: number;
|
||||
/** 每日总额度(用于副文案展示) */
|
||||
dailyQuota: number;
|
||||
/** 总额度常量 12(用于文案 "X / 12") */
|
||||
totalQuota: number;
|
||||
/** 关闭弹窗 */
|
||||
onClose: () => void;
|
||||
/** 确认投票(count 为最终实际投票数,ALL 会被解析为 remaining) */
|
||||
onConfirm: (artist: Artist, count: number) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const VOTE_OPTIONS: VoteOption[] = [1, 3, 5, "ALL"];
|
||||
|
||||
function defaultOption(remaining: number): VoteOption {
|
||||
if (remaining >= 3) return 3;
|
||||
if (remaining >= 1) return remaining as VoteOption;
|
||||
return "ALL";
|
||||
}
|
||||
|
||||
function resolveCount(opt: VoteOption, remaining: number): number {
|
||||
return opt === "ALL" ? remaining : opt;
|
||||
/** 确认投票(无 count 参数,固定 1 票) */
|
||||
onConfirm: (artist: Artist) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export default function VoteModal({
|
||||
artist,
|
||||
remaining,
|
||||
dailyQuota,
|
||||
totalQuota,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: VoteModalProps) {
|
||||
const open = artist != null;
|
||||
const [selected, setSelected] = useState<VoteOption>(defaultOption(remaining));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// 即时判断当前艺人是否已被投过(避免父组件忘传防护)
|
||||
const hasVotedSelector = artist ? selectHasVoted(artist.id) : () => false;
|
||||
const hasVoted = useVoteStore(hasVotedSelector);
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
// 打开时重置默认选择
|
||||
// 打开时重置 loading 态
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelected(defaultOption(remaining));
|
||||
setLoading(false);
|
||||
}
|
||||
}, [open, remaining]);
|
||||
if (open) setLoading(false);
|
||||
}, [open]);
|
||||
|
||||
// ESC 关闭 + body 锁滚
|
||||
useEffect(() => {
|
||||
@ -73,18 +60,18 @@ export default function VoteModal({
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
const actualCount = resolveCount(selected, remaining);
|
||||
const canSubmit = remaining > 0 && actualCount > 0 && actualCount <= remaining;
|
||||
const exhausted = remaining <= 0;
|
||||
const canSubmit = !exhausted && !hasVoted && !loading;
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (!artist || loading || !canSubmit) return;
|
||||
if (!artist || !canSubmit) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await onConfirm(artist, actualCount);
|
||||
await onConfirm(artist);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [artist, actualCount, canSubmit, loading, onConfirm]);
|
||||
}, [artist, canSubmit, onConfirm]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
@ -107,7 +94,7 @@ export default function VoteModal({
|
||||
className="absolute inset-0 bg-black/75 backdrop-blur-md cursor-default"
|
||||
/>
|
||||
|
||||
{/* 弹窗主体(已去除顶部紫色横条) */}
|
||||
{/* 弹窗主体 */}
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@ -129,81 +116,86 @@ export default function VoteModal({
|
||||
</button>
|
||||
|
||||
{/* 头像 */}
|
||||
<div className="w-20 h-20 mx-auto mb-3.5 rounded-full overflow-hidden border-2 border-purple-500 shadow-[0_0_16px_rgba(139,92,246,0.4)]">
|
||||
<div className="w-20 h-20 mx-auto mb-3.5 rounded-full overflow-hidden border-2 border-purple-500 shadow-[0_0_16px_rgba(139,92,246,0.4)] relative">
|
||||
<ArtistPortrait
|
||||
artist={artist}
|
||||
rounded="rounded-full"
|
||||
className="w-full h-full"
|
||||
/>
|
||||
{hasVoted && (
|
||||
<div className="absolute inset-0 bg-black/55 flex items-center justify-center">
|
||||
<Check size={28} className="text-purple-300" strokeWidth={3} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 标题 */}
|
||||
<div className="text-center mb-4">
|
||||
<div className="text-center mb-3.5">
|
||||
<div
|
||||
id="vote-modal-title"
|
||||
className="text-lg font-bold text-white mb-1"
|
||||
>
|
||||
为 {artist.name} 投票
|
||||
{hasVoted
|
||||
? `已为 ${artist.name} 投过票`
|
||||
: exhausted
|
||||
? "12 票已用完"
|
||||
: `为 ${artist.name} 投票`}
|
||||
</div>
|
||||
<div className="font-label text-[11px] tracking-widest text-white/45 uppercase">
|
||||
No.{artist.no} · Current Rank #{artist.rank}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 剩余票数提示 */}
|
||||
<div className="flex items-center justify-between text-xs mb-2.5">
|
||||
<span className="text-white/55">选择投票数:</span>
|
||||
<span className="text-purple-300 tabular-nums">
|
||||
今日剩余 {remaining} / {dailyQuota}
|
||||
{/* 剩余票数显示 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between text-xs mb-4 px-3 py-2.5 rounded-lg border",
|
||||
exhausted
|
||||
? "border-pink-400/30 bg-pink-400/[0.05]"
|
||||
: "border-purple-500/25 bg-purple-500/[0.06]",
|
||||
)}
|
||||
>
|
||||
<span className="text-white/65">你的剩余票数</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-display tabular-nums text-base",
|
||||
exhausted ? "text-pink-300" : "text-purple-300",
|
||||
)}
|
||||
>
|
||||
{remaining}{" "}
|
||||
<span className="text-white/40 text-xs">/ {totalQuota}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 票数选择 */}
|
||||
<div className="flex gap-2.5 justify-center mb-5">
|
||||
{VOTE_OPTIONS.map((opt) => {
|
||||
const active = selected === opt;
|
||||
const optValue = resolveCount(opt, remaining);
|
||||
const disabled =
|
||||
remaining === 0 ||
|
||||
optValue === 0 ||
|
||||
optValue > remaining ||
|
||||
(opt === "ALL" && remaining === 0);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={String(opt)}
|
||||
onClick={() => !disabled && setSelected(opt)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"rounded-lg font-display text-base flex items-center justify-center transition-all w-14 h-13 py-3.5 px-3",
|
||||
disabled &&
|
||||
"bg-surface/40 border border-white/8 text-white/25 cursor-not-allowed",
|
||||
!disabled &&
|
||||
!active &&
|
||||
"bg-surface border border-white/14 text-white/65 hover:border-white/30",
|
||||
!disabled &&
|
||||
active &&
|
||||
"bg-purple-500/12 border border-purple-500 text-purple-300 shadow-[0_0_16px_rgba(139,92,246,0.35)]",
|
||||
)}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* 规则提示 · 不可撤销警示(仅在可投态显示) */}
|
||||
{!hasVoted && !exhausted && (
|
||||
<div className="flex items-start gap-2 mb-5 px-3 py-2.5 rounded-lg bg-white/[0.03] border border-white/[0.06]">
|
||||
<AlertCircle
|
||||
size={14}
|
||||
className="text-purple-300/80 mt-0.5 flex-shrink-0"
|
||||
/>
|
||||
<p className="text-[11px] leading-relaxed text-white/65">
|
||||
投出后不可撤销 · 每位艺人仅能投 1 票
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 确认按钮 */}
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full h-12 text-sm"
|
||||
onClick={handleConfirm}
|
||||
onClick={hasVoted || exhausted ? onClose : handleConfirm}
|
||||
loading={loading}
|
||||
disabled={!canSubmit}
|
||||
leftIcon={<Heart size={14} />}
|
||||
disabled={loading}
|
||||
leftIcon={
|
||||
hasVoted || exhausted ? undefined : <Heart size={14} />
|
||||
}
|
||||
>
|
||||
{remaining === 0
|
||||
? "今日票数已用完"
|
||||
: `确认投出 ${actualCount} 票`}
|
||||
{hasVoted
|
||||
? "好的"
|
||||
: exhausted
|
||||
? "感谢支持"
|
||||
: "投出我的一票"}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import {
|
||||
ChevronLeft,
|
||||
Heart,
|
||||
Check,
|
||||
Quote as QuoteIcon,
|
||||
Sparkles,
|
||||
Compass,
|
||||
@ -23,7 +24,7 @@ import PerformanceVideo from "./PerformanceVideo";
|
||||
import PerformanceGallery from "./PerformanceGallery";
|
||||
import FloatingVoteButton from "@/components/FloatingVoteButton";
|
||||
import FloatingBackButton from "@/components/FloatingBackButton";
|
||||
import { useVoteStore, selectArtist } from "@/lib/store";
|
||||
import { useVoteStore, selectArtist, selectHasVoted } from "@/lib/store";
|
||||
import { useVoteAction } from "@/hooks/useVoteAction";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
@ -48,11 +49,12 @@ export default function ArtistDetailContent({
|
||||
// 用 store 数据覆盖(投票后票数能马上变)
|
||||
const storeArtist = useVoteStore(selectArtist(initialArtist.id));
|
||||
const storeAll = useVoteStore((s) => s.artists);
|
||||
const hasVoted = useVoteStore(selectHasVoted(initialArtist.id));
|
||||
|
||||
const artist = storeArtist ?? initialArtist;
|
||||
const allArtists = storeAll.length ? storeAll : initialAll;
|
||||
|
||||
const { target, remaining, dailyQuota, openVote, closeVote, confirmVote } =
|
||||
const { target, remaining, totalQuota, openVote, closeVote, confirmVote } =
|
||||
useVoteAction();
|
||||
|
||||
return (
|
||||
@ -78,6 +80,7 @@ export default function ArtistDetailContent({
|
||||
artist={artist}
|
||||
allArtists={allArtists}
|
||||
onVote={() => openVote(artist)}
|
||||
hasVoted={hasVoted}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@ -146,12 +149,12 @@ export default function ArtistDetailContent({
|
||||
)}
|
||||
|
||||
<FloatingBackButton fallbackHref="/" />
|
||||
<FloatingVoteButton onClick={() => openVote(artist)} />
|
||||
<FloatingVoteButton onClick={() => openVote(artist)} hasVoted={hasVoted} />
|
||||
|
||||
<VoteModal
|
||||
artist={target}
|
||||
remaining={remaining}
|
||||
dailyQuota={dailyQuota}
|
||||
totalQuota={totalQuota}
|
||||
onClose={closeVote}
|
||||
onConfirm={confirmVote}
|
||||
/>
|
||||
@ -167,9 +170,10 @@ interface HeroPanelProps {
|
||||
artist: Artist;
|
||||
allArtists: Artist[];
|
||||
onVote: () => void;
|
||||
hasVoted: boolean;
|
||||
}
|
||||
|
||||
function HeroPanel({ artist, allArtists, onVote }: HeroPanelProps) {
|
||||
function HeroPanel({ artist, allArtists, onVote, hasVoted }: HeroPanelProps) {
|
||||
return (
|
||||
<div className="relative rounded-2xl border border-purple-500/20 overflow-hidden grid gap-6 lg:grid-cols-[420px_1fr] lg:gap-8 p-5 sm:p-8 bg-[linear-gradient(135deg,rgba(139,92,246,0.10)_0%,rgba(13,10,36,0.6)_100%)]">
|
||||
{/* 装饰光晕 */}
|
||||
@ -185,6 +189,11 @@ function HeroPanel({ artist, allArtists, onVote }: HeroPanelProps) {
|
||||
rounded="rounded-xl"
|
||||
className="w-full aspect-[4/5]"
|
||||
/>
|
||||
{hasVoted && (
|
||||
<div className="absolute top-3 right-3 w-9 h-9 rounded-full bg-purple-500 shadow-[0_0_16px_rgba(139,92,246,0.8)] flex items-center justify-center border-2 border-white/30">
|
||||
<Check size={20} strokeWidth={3} className="text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 身份信息 */}
|
||||
@ -260,14 +269,20 @@ function HeroPanel({ artist, allArtists, onVote }: HeroPanelProps) {
|
||||
{/* 操作按钮 · 仅投票 */}
|
||||
<div className="pt-1">
|
||||
<Button
|
||||
variant="primary"
|
||||
variant={hasVoted ? "outline" : "primary"}
|
||||
size="lg"
|
||||
pulse
|
||||
className="w-full"
|
||||
leftIcon={<Heart size={16} fill="currentColor" />}
|
||||
pulse={!hasVoted}
|
||||
className={cn("w-full", hasVoted && "cursor-not-allowed opacity-80")}
|
||||
leftIcon={
|
||||
hasVoted ? (
|
||||
<Check size={16} strokeWidth={3} />
|
||||
) : (
|
||||
<Heart size={16} fill="currentColor" />
|
||||
)
|
||||
}
|
||||
onClick={onVote}
|
||||
>
|
||||
投票
|
||||
{hasVoted ? "已投票" : "投票"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,35 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useVoteStore, selectRemaining, DAILY_VOTE_QUOTA } from "@/lib/store";
|
||||
import { useVoteStore, selectRemaining, TOTAL_VOTE_QUOTA } from "@/lib/store";
|
||||
|
||||
/**
|
||||
* 导航栏的"今日剩余票数"徽章。
|
||||
* - 始终显示(位于 AuthMenu 左侧)
|
||||
* - 未登录:数值固定为 0
|
||||
* - 已登录:实时从 vote store 取剩余票
|
||||
* - 视觉为中性轻盈胶囊,与 AuthMenu 的紫色实心胶囊明显区分(信息 vs 操作)
|
||||
* 导航栏的"剩余票数"徽章。
|
||||
* - 始终显示(位于 AuthMenu 左侧)
|
||||
* - 未登录:数值固定为 0
|
||||
* - 已登录:实时从 vote store 取剩余票
|
||||
* - 视觉为中性轻盈胶囊,与 AuthMenu 的紫色实心胶囊明显区分(信息 vs 操作)
|
||||
*/
|
||||
export default function RemainingVotesBadge() {
|
||||
const { status } = useSession();
|
||||
const storeRemaining = useVoteStore(selectRemaining);
|
||||
const authed = status === "authenticated";
|
||||
// 未登录:0/0(没有额度);登录后:当日剩余 / 每日总额度
|
||||
// 未登录显示 0 / 12 引导登录后投票;登录后实时剩余
|
||||
const remaining = authed ? storeRemaining : 0;
|
||||
const quota = authed ? DAILY_VOTE_QUOTA : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="hidden md:inline-flex items-center gap-1.5 h-9 px-4 rounded-full bg-white/[0.04] border border-white/10"
|
||||
aria-label={`今日剩余 ${remaining} 票`}
|
||||
aria-label={`剩余 ${remaining} 票`}
|
||||
>
|
||||
<span className="text-[11px] text-white/75 leading-none tracking-wide">
|
||||
今日剩余
|
||||
剩余
|
||||
</span>
|
||||
<span className="font-display text-sm text-purple-300 tabular-nums leading-none">
|
||||
{remaining}
|
||||
</span>
|
||||
<span className="text-[11px] text-white/45 leading-none">/ {quota}</span>
|
||||
<span className="text-[11px] text-white/45 leading-none">
|
||||
/ {TOTAL_VOTE_QUOTA}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import Link from "next/link";
|
||||
import { Heart } from "lucide-react";
|
||||
import { Heart, Check } from "lucide-react";
|
||||
import type { Artist } from "@/types/artist";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useVoteStore, selectHasVoted } from "@/lib/store";
|
||||
import ArtistPortrait from "./ArtistPortrait";
|
||||
|
||||
interface ArtistCardProps {
|
||||
@ -23,12 +24,13 @@ export default function ArtistCard({
|
||||
}: ArtistCardProps) {
|
||||
// 「真正进 Top12」必须有票 —— 0 票时编号兜底排出来的前 12 不算
|
||||
const inTop12 = artist.rank <= 12 && artist.votes > 0;
|
||||
const hasVoted = useVoteStore(selectHasVoted(artist.id));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative rounded-xl overflow-hidden bg-grad-card border transition-all",
|
||||
inTop12
|
||||
(hasVoted || inTop12)
|
||||
? "border-purple-500/55 shadow-[0_8px_32px_rgba(0,0,0,0.65),0_0_24px_rgba(139,92,246,0.25)]"
|
||||
: "border-white/[0.10] shadow-[0_8px_32px_rgba(0,0,0,0.65)]",
|
||||
"hover:-translate-y-1 hover:shadow-[0_12px_36px_rgba(0,0,0,0.7),0_0_24px_rgba(139,92,246,0.25)]",
|
||||
@ -59,6 +61,13 @@ export default function ArtistCard({
|
||||
>
|
||||
{artist.rank}
|
||||
</div>
|
||||
|
||||
{/* 已投票角标(右上紫色 ✓) */}
|
||||
{hasVoted && (
|
||||
<div className="absolute top-2 right-2 w-6 h-6 rounded-full bg-purple-500 shadow-[0_0_12px_rgba(139,92,246,0.7)] flex items-center justify-center">
|
||||
<Check size={14} strokeWidth={3} className="text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息区(黑色背景明显分隔) */}
|
||||
@ -84,7 +93,7 @@ export default function ArtistCard({
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* 投票按钮(所有排名统一样式 · 紫色实心) */}
|
||||
{/* 投票按钮(hasVoted 时灰化为「已投票」) */}
|
||||
<div className="px-3 pb-3 bg-black/40">
|
||||
<button
|
||||
type="button"
|
||||
@ -94,11 +103,12 @@ export default function ArtistCard({
|
||||
}}
|
||||
className={cn(
|
||||
"w-full h-9 rounded-lg font-body font-semibold text-sm transition-all",
|
||||
"bg-grad-purple text-white shadow-[0_0_12px_rgba(139,92,246,0.35)]",
|
||||
"hover:brightness-110 active:brightness-95",
|
||||
hasVoted
|
||||
? "bg-white/10 text-white/55 cursor-not-allowed border border-white/15"
|
||||
: "bg-grad-purple text-white shadow-[0_0_12px_rgba(139,92,246,0.35)] hover:brightness-110 active:brightness-95",
|
||||
)}
|
||||
>
|
||||
投票
|
||||
{hasVoted ? "✓ 已投票" : "投票"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { Heart, AlertTriangle } from "lucide-react";
|
||||
import { Check, AlertTriangle } from "lucide-react";
|
||||
import type { MySupport } from "@/lib/store";
|
||||
import ArtistPortrait from "@/components/cards/ArtistPortrait";
|
||||
import { cn } from "@/lib/cn";
|
||||
@ -8,9 +8,9 @@ export default function MyFanSupport({ supports }: { supports: MySupport[] }) {
|
||||
if (supports.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-white/10 p-8 text-center text-white/45 text-sm">
|
||||
还没有应援的艺人 ·{" "}
|
||||
还没有投过票 ·{" "}
|
||||
<Link href="/" className="text-purple-300 hover:underline">
|
||||
去发现
|
||||
去为你喜欢的艺人投出第一票
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
@ -18,7 +18,7 @@ export default function MyFanSupport({ supports }: { supports: MySupport[] }) {
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{supports.map(({ artist, votedCount }) => {
|
||||
{supports.map(({ artist }) => {
|
||||
const inTop12 = artist.rank <= 12 && artist.votes > 0;
|
||||
return (
|
||||
<Link
|
||||
@ -46,9 +46,9 @@ export default function MyFanSupport({ supports }: { supports: MySupport[] }) {
|
||||
<div className="text-sm font-semibold text-white truncate">
|
||||
{artist.name}
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-1 text-[11px] text-purple-300 mt-0.5 font-display tabular-nums">
|
||||
<Heart size={10} fill="currentColor" />
|
||||
已投 {votedCount} 票
|
||||
<div className="inline-flex items-center gap-1 text-[11px] text-purple-300 mt-0.5 font-display">
|
||||
<Check size={11} strokeWidth={2.5} />
|
||||
已投票
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@ -1,16 +1,18 @@
|
||||
"use client";
|
||||
|
||||
interface QuotaCardProps {
|
||||
/** 今日剩余票数 */
|
||||
/** 剩余票数 */
|
||||
remaining: number;
|
||||
/** 每日总额度(用于「重置为 X 票」展示) */
|
||||
dailyQuota: number;
|
||||
/** 总额度 12(用于"X / 12 票" 展示) */
|
||||
totalQuota: number;
|
||||
}
|
||||
|
||||
export default function QuotaCard({ remaining, dailyQuota }: QuotaCardProps) {
|
||||
export default function QuotaCard({ remaining, totalQuota }: QuotaCardProps) {
|
||||
const exhausted = remaining === 0;
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-2xl border border-purple-500/30 bg-gradient-to-br from-purple-500/[0.12] via-purple-500/[0.04] to-transparent shadow-[0_8px_32px_rgba(0,0,0,0.55),0_0_28px_rgba(139,92,246,0.2)]">
|
||||
{/* 装饰:右侧紫色光晕 */}
|
||||
{/* 装饰:右侧紫色光晕 */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute right-0 top-0 bottom-0 w-1/2 pointer-events-none"
|
||||
@ -19,30 +21,27 @@ export default function QuotaCard({ remaining, dailyQuota }: QuotaCardProps) {
|
||||
"radial-gradient(circle at 70% 50%, rgba(139,92,246,0.45) 0%, transparent 60%)",
|
||||
}}
|
||||
/>
|
||||
{/* 装饰:右侧"水晶"占位(无素材时用 CSS 渲染的辉光六边形) */}
|
||||
<CrystalDecoration />
|
||||
|
||||
<div className="relative p-6 sm:p-8">
|
||||
<p className="text-xs text-white/70 tracking-wider">今日剩余票数</p>
|
||||
<p className="text-xs text-white/70 tracking-wider">剩余票数</p>
|
||||
<div className="mt-2 flex items-baseline gap-1">
|
||||
<span className="font-display text-5xl sm:text-6xl text-white tabular-nums leading-none">
|
||||
{remaining}
|
||||
</span>
|
||||
<span className="text-xl text-white/85 ml-1">票</span>
|
||||
<span className="text-xl text-white/85 ml-1">/ {totalQuota} 票</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-white/55 mt-3">
|
||||
明日 00:00 重置为{" "}
|
||||
<span className="text-purple-300 font-display tabular-nums">
|
||||
{dailyQuota}
|
||||
</span>{" "}
|
||||
票
|
||||
{exhausted
|
||||
? "✦ 12 票全部投出 · 感谢支持"
|
||||
: `共 ${totalQuota} 票 · 用满完成投票`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 右侧装饰:用 CSS 渲染的简易"紫色水晶"效果,作为 3D 素材到位前的占位。 */
|
||||
/** 右侧装饰:用 CSS 渲染的简易"紫色水晶"效果,作为 3D 素材到位前的占位。 */
|
||||
function CrystalDecoration() {
|
||||
return (
|
||||
<div
|
||||
|
||||
@ -1,28 +1,25 @@
|
||||
import { Sparkles, Star } from "lucide-react";
|
||||
import { Check, Heart } from "lucide-react";
|
||||
|
||||
interface StatsGridProps {
|
||||
/** 我累计投出的总票数 */
|
||||
totalVotes: number;
|
||||
/** 我应援的艺人数(去重) */
|
||||
supportingCount: number;
|
||||
/** 已投艺人数 */
|
||||
voted: number;
|
||||
/** 剩余票数 */
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
export default function StatsGrid({
|
||||
totalVotes,
|
||||
supportingCount,
|
||||
}: StatsGridProps) {
|
||||
export default function StatsGrid({ voted, remaining }: StatsGridProps) {
|
||||
const stats: { key: string; label: string; value: number; icon: React.ReactNode }[] = [
|
||||
{
|
||||
key: "votes",
|
||||
label: "累计投票",
|
||||
value: totalVotes,
|
||||
icon: <Sparkles size={14} />,
|
||||
key: "voted",
|
||||
label: "已投票数",
|
||||
value: voted,
|
||||
icon: <Check size={14} strokeWidth={2.5} />,
|
||||
},
|
||||
{
|
||||
key: "fan",
|
||||
label: "应援艺人",
|
||||
value: supportingCount,
|
||||
icon: <Star size={14} />,
|
||||
key: "remaining",
|
||||
label: "剩余票数",
|
||||
value: remaining,
|
||||
icon: <Heart size={14} fill="currentColor" />,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { TrendingUp, AlertTriangle } from "lucide-react";
|
||||
import { TrendingUp, AlertTriangle, Check } from "lucide-react";
|
||||
import type { Artist } from "@/types/artist";
|
||||
import ArtistPortrait from "@/components/cards/ArtistPortrait";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useVoteStore, selectHasVoted } from "@/lib/store";
|
||||
|
||||
interface RankingRowProps {
|
||||
artist: Artist;
|
||||
@ -29,6 +30,7 @@ export default function RankingRow({
|
||||
isRescue = false,
|
||||
onVote,
|
||||
}: RankingRowProps) {
|
||||
const hasVoted = useVoteStore(selectHasVoted(artist.id));
|
||||
// 「真正进 Top12」必须有票 —— 0 票时编号兜底不算
|
||||
const inTop12 = artist.rank <= 12 && artist.votes > 0;
|
||||
|
||||
@ -38,7 +40,7 @@ export default function RankingRow({
|
||||
"grid grid-cols-[56px_48px_1fr_72px_96px_72px] sm:grid-cols-[72px_56px_1fr_96px_120px_88px] items-center gap-2 sm:gap-4 px-3 sm:px-4 py-2.5 border-b border-white/[0.05] transition-all",
|
||||
inTop12
|
||||
? "bg-white/[0.02] hover:bg-purple-500/[0.06]"
|
||||
: "opacity-[0.78] hover:opacity-100 hover:bg-white/[0.03]",
|
||||
: "hover:bg-white/[0.03]",
|
||||
)}
|
||||
>
|
||||
{/* 排名 */}
|
||||
@ -52,11 +54,11 @@ export default function RankingRow({
|
||||
</div>
|
||||
|
||||
{/* 头像 */}
|
||||
<Link href={`/artist/${artist.id}`} className="block">
|
||||
<Link href={`/artist/${artist.id}`} className="block relative">
|
||||
<div
|
||||
className={cn(
|
||||
"w-10 h-10 sm:w-12 sm:h-12 rounded-full overflow-hidden border-2",
|
||||
inTop12 ? "border-purple-500/70" : "border-white/15",
|
||||
(hasVoted || inTop12) ? "border-purple-500/70" : "border-white/15",
|
||||
)}
|
||||
>
|
||||
<ArtistPortrait
|
||||
@ -65,6 +67,11 @@ export default function RankingRow({
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
{hasVoted && (
|
||||
<div className="absolute -bottom-0.5 -right-0.5 w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-purple-500 border-2 border-deepest shadow-[0_0_8px_rgba(139,92,246,0.7)] flex items-center justify-center">
|
||||
<Check size={10} strokeWidth={3} className="text-white" />
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* 姓名 + slogan */}
|
||||
@ -113,9 +120,14 @@ export default function RankingRow({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onVote(artist)}
|
||||
className="h-8 rounded-lg font-body font-semibold text-xs bg-grad-purple text-white shadow-[0_0_12px_rgba(139,92,246,0.35)] hover:brightness-110"
|
||||
className={cn(
|
||||
"h-8 rounded-lg font-body font-semibold text-xs transition-all",
|
||||
hasVoted
|
||||
? "bg-white/10 text-white/55 border border-white/15 cursor-not-allowed"
|
||||
: "bg-grad-purple text-white shadow-[0_0_12px_rgba(139,92,246,0.35)] hover:brightness-110"
|
||||
)}
|
||||
>
|
||||
投票
|
||||
{hasVoted ? "✓ 已投票" : "投票"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -6,39 +6,41 @@ import toast from "react-hot-toast";
|
||||
import {
|
||||
useVoteStore,
|
||||
selectRemaining,
|
||||
DAILY_VOTE_QUOTA,
|
||||
TOTAL_VOTE_QUOTA,
|
||||
} from "@/lib/store";
|
||||
import { useLoginModalStore } from "@/lib/login-modal-store";
|
||||
import type { Artist } from "@/types/artist";
|
||||
|
||||
interface UseVoteActionResult {
|
||||
/** 当前投票目标艺人(null 时弹窗关闭) */
|
||||
/** 当前投票目标艺人(null 时弹窗关闭) */
|
||||
target: Artist | null;
|
||||
/** 今日剩余票数 */
|
||||
/** 剩余可投票数(终身 12 票 - 已投艺人数) */
|
||||
remaining: number;
|
||||
/** 每日总额度(常量,供 UI 文案展示) */
|
||||
dailyQuota: number;
|
||||
/** 触发投票(自动检查登录态 + 额度) */
|
||||
/** 总额度常量 12(供 UI 文案展示) */
|
||||
totalQuota: number;
|
||||
/** 触发投票(自动检查登录态 / 已投态 / 额度) */
|
||||
openVote: (artist: Artist) => void;
|
||||
/** 关闭投票弹窗 */
|
||||
closeVote: () => void;
|
||||
/** 确认投票(已登录态下调用) */
|
||||
confirmVote: (artist: Artist, count: number) => Promise<void>;
|
||||
/** 确认投票(已登录态下调用,固定投 1 票) */
|
||||
confirmVote: (artist: Artist) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 投票交互统一入口。
|
||||
*
|
||||
* 规则:
|
||||
* - 每用户每日总额度 = 10 票,跨艺人共享。无单艺人上限。
|
||||
* - 未登录 → toast 提示并跳登录页
|
||||
* - 已登录但当日票数已用完 → toast 提示,不打开弹窗
|
||||
* - 弹窗确认后:本地 store 立即扣减 + 调用后端 API(fire-and-forget)
|
||||
* 新规则:
|
||||
* - 每用户终身 12 票,每位艺人最多 1 票,不限时,不可撤销
|
||||
* - 未登录 → toast 提示并跳登录弹窗
|
||||
* - 已投过该艺人 → toast 提示,不打开弹窗
|
||||
* - 12 票已用完 → toast 提示,不打开弹窗
|
||||
* - 弹窗确认后:本地 store 立即记录 + 调用后端 API(fire-and-forget)
|
||||
*/
|
||||
export function useVoteAction(): UseVoteActionResult {
|
||||
const { status } = useSession();
|
||||
const recordVote = useVoteStore((s) => s.vote);
|
||||
const remaining = useVoteStore(selectRemaining);
|
||||
const votedArtists = useVoteStore((s) => s.votedArtists);
|
||||
const openLogin = useLoginModalStore((s) => s.show);
|
||||
const [target, setTarget] = useState<Artist | null>(null);
|
||||
|
||||
@ -50,48 +52,65 @@ export function useVoteAction(): UseVoteActionResult {
|
||||
setTimeout(openLogin, 350);
|
||||
return;
|
||||
}
|
||||
if (votedArtists.includes(artist.id)) {
|
||||
toast(`你已为 ${artist.name} 投过票了`);
|
||||
return;
|
||||
}
|
||||
if (remaining <= 0) {
|
||||
toast("今日票数已用完,明天再来吧");
|
||||
toast("你的 12 票已用完,感谢支持");
|
||||
return;
|
||||
}
|
||||
setTarget(artist);
|
||||
},
|
||||
[status, openLogin, remaining],
|
||||
[status, openLogin, remaining, votedArtists],
|
||||
);
|
||||
|
||||
const closeVote = useCallback(() => setTarget(null), []);
|
||||
|
||||
const confirmVote = useCallback(
|
||||
async (artist: Artist, count: number) => {
|
||||
// 1. 本地 store 立即扣减(包含额度校验)
|
||||
const success = recordVote(artist.id, count);
|
||||
if (!success) {
|
||||
toast.error("今日票数不足");
|
||||
async (artist: Artist) => {
|
||||
// 1. 本地 store 记录(包含已投/已满校验)
|
||||
const result = recordVote(artist.id);
|
||||
if (!result.ok) {
|
||||
if (result.reason === "already") {
|
||||
toast.error(`你已为 ${artist.name} 投过票了`);
|
||||
} else {
|
||||
toast.error("你的 12 票已用完,感谢支持");
|
||||
}
|
||||
setTarget(null);
|
||||
return;
|
||||
}
|
||||
toast.success(`已为 ${artist.name} 投出 ${count} 票`);
|
||||
|
||||
// 投票成功:计算投票后状态,判断是否是最后一票
|
||||
const remainingAfter = remaining - 1;
|
||||
if (remainingAfter === 0) {
|
||||
toast.success(`完成!你的 12 票已全部投出 ✦`, { duration: 4000 });
|
||||
} else {
|
||||
toast.success(`已为 ${artist.name} 投票 · 剩余 ${remainingAfter} 票`);
|
||||
}
|
||||
setTarget(null);
|
||||
|
||||
// 2. 后台 fire-and-forget 调用真实 API(5 秒超时,失败静默忽略)
|
||||
// 2. 后台 fire-and-forget 调用真实 API(5 秒超时,失败静默忽略)
|
||||
// 注意:旧 API 仍接收 count 参数,这里固定传 1。后端逻辑 unique 约束
|
||||
// 等后续提交单独迁移,现阶段前端 store 已保证不会重投。
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 5000);
|
||||
fetch("/api/vote", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ artistId: artist.id, count }),
|
||||
body: JSON.stringify({ artistId: artist.id, count: 1 }),
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => clearTimeout(timer));
|
||||
},
|
||||
[recordVote],
|
||||
[recordVote, remaining],
|
||||
);
|
||||
|
||||
return {
|
||||
target,
|
||||
remaining,
|
||||
dailyQuota: DAILY_VOTE_QUOTA,
|
||||
totalQuota: TOTAL_VOTE_QUOTA,
|
||||
openVote,
|
||||
closeVote,
|
||||
confirmVote,
|
||||
|
||||
@ -30,8 +30,10 @@ export const ERR = {
|
||||
VALIDATION: (msg: string) => err("VALIDATION", msg, 422),
|
||||
INTERNAL: (msg = "服务器错误") => err("INTERNAL", msg, 500),
|
||||
ACTIVITY_OFF: () => err("ACTIVITY_OFF", "投票活动暂未开放", 409),
|
||||
QUOTA_EXHAUSTED: (msg = "今日票数已用完") =>
|
||||
QUOTA_EXHAUSTED: (msg = "你的 12 票已全部投出,感谢支持") =>
|
||||
err("QUOTA_EXHAUSTED", msg, 409),
|
||||
ALREADY_VOTED: (msg = "你已为该艺人投过票") =>
|
||||
err("ALREADY_VOTED", msg, 409),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
156
src/lib/store.ts
156
src/lib/store.ts
@ -1,41 +1,39 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { ARTISTS } from "./mock-data";
|
||||
import type { Artist } from "@/types/artist";
|
||||
|
||||
/** 每日基础投票额度(与后端 ActivityConfig.dailyQuota 对齐) */
|
||||
export const DAILY_VOTE_QUOTA = 10;
|
||||
/**
|
||||
* 每用户终身投票总额度 —— 一人 12 票,每位艺人最多 1 票,
|
||||
* 投完即结束,不限时,不可撤销。
|
||||
*/
|
||||
export const TOTAL_VOTE_QUOTA = 12;
|
||||
|
||||
/** 派生类型:我支持的某位艺人 + 我为 ta 投出的票数 */
|
||||
/** 派生类型:我应援的艺人(新规则下每位仅 1 票,不再带 votedCount) */
|
||||
export interface MySupport {
|
||||
artist: Artist;
|
||||
votedCount: number;
|
||||
}
|
||||
|
||||
interface VoteStore {
|
||||
/** 当前所有艺人(含动态票数 / 实时排名) */
|
||||
/** 当前所有艺人(含动态票数 / 实时排名) */
|
||||
artists: Artist[];
|
||||
/** 我为每个艺人投出的票数(artistId → count,仅 > 0 的进入"我的应援"列表) */
|
||||
myVotesByArtist: Record<string, number>;
|
||||
/** 累计已投票数(= sum(myVotesByArtist)) */
|
||||
myTotalVotes: number;
|
||||
/** 今日已用票数(跨日自动重置) */
|
||||
usedToday: number;
|
||||
/** 今日额度日期标记(YYYY-M-D,按本地时区) */
|
||||
quotaDate: string;
|
||||
/**
|
||||
* 给艺人投票(本地模拟,会重新排名)。
|
||||
* 票数不足时返回 false,前端可据此提示。
|
||||
* 我已投票的艺人 ID 列表(顺序 = 投票顺序)。
|
||||
* 用数组而不是 Set:zustand persist 默认 storage 是 JSON,Set 无法直接序列化。
|
||||
* 数组按投票顺序保留,既能 .includes() 判重,也能在 /me 页面显示"我的投票"时按时间排序。
|
||||
*/
|
||||
vote: (artistId: string, count: number) => boolean;
|
||||
/** 重置(开发时用) */
|
||||
votedArtists: string[];
|
||||
/**
|
||||
* 给艺人投票。
|
||||
* - 已投过该艺人 → 返回 { ok: false, reason: "already" }
|
||||
* - 已用满 12 票 → 返回 { ok: false, reason: "exhausted" }
|
||||
* - 成功 → 返回 { ok: true }
|
||||
*/
|
||||
vote: (artistId: string) => { ok: boolean; reason?: "already" | "exhausted" };
|
||||
/** 重置(开发时用 / 测试用) */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
function todayKey(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
|
||||
}
|
||||
|
||||
/** 票数倒序 + 编号升序兜底,确保 0 票时也有稳定排名 */
|
||||
function rank(list: Artist[]): Artist[] {
|
||||
return [...list]
|
||||
@ -43,86 +41,96 @@ function rank(list: Artist[]): Artist[] {
|
||||
.map((a, i) => ({ ...a, rank: i + 1 }));
|
||||
}
|
||||
|
||||
const INITIAL = rank(ARTISTS);
|
||||
const INITIAL_ARTISTS = rank(ARTISTS);
|
||||
|
||||
export const useVoteStore = create<VoteStore>((set) => ({
|
||||
artists: INITIAL,
|
||||
myVotesByArtist: {},
|
||||
myTotalVotes: 0,
|
||||
usedToday: 0,
|
||||
quotaDate: todayKey(),
|
||||
vote: (artistId, count) => {
|
||||
let success = false;
|
||||
set((state) => {
|
||||
const today = todayKey();
|
||||
const baseUsed = state.quotaDate === today ? state.usedToday : 0;
|
||||
const remaining = DAILY_VOTE_QUOTA - baseUsed;
|
||||
if (count <= 0 || count > remaining) {
|
||||
return state;
|
||||
export const useVoteStore = create<VoteStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
artists: INITIAL_ARTISTS,
|
||||
votedArtists: [],
|
||||
vote: (artistId) => {
|
||||
const state = get();
|
||||
if (state.votedArtists.includes(artistId)) {
|
||||
return { ok: false, reason: "already" };
|
||||
}
|
||||
if (state.votedArtists.length >= TOTAL_VOTE_QUOTA) {
|
||||
return { ok: false, reason: "exhausted" };
|
||||
}
|
||||
success = true;
|
||||
const updated = state.artists.map((a) =>
|
||||
a.id === artistId ? { ...a, votes: a.votes + count } : a,
|
||||
a.id === artistId ? { ...a, votes: a.votes + 1 } : a,
|
||||
);
|
||||
return {
|
||||
set({
|
||||
artists: rank(updated),
|
||||
myVotesByArtist: {
|
||||
...state.myVotesByArtist,
|
||||
[artistId]: (state.myVotesByArtist[artistId] ?? 0) + count,
|
||||
},
|
||||
myTotalVotes: state.myTotalVotes + count,
|
||||
usedToday: baseUsed + count,
|
||||
quotaDate: today,
|
||||
};
|
||||
votedArtists: [...state.votedArtists, artistId],
|
||||
});
|
||||
return success;
|
||||
return { ok: true };
|
||||
},
|
||||
reset: () =>
|
||||
set({
|
||||
artists: INITIAL,
|
||||
myVotesByArtist: {},
|
||||
myTotalVotes: 0,
|
||||
usedToday: 0,
|
||||
quotaDate: todayKey(),
|
||||
artists: INITIAL_ARTISTS,
|
||||
votedArtists: [],
|
||||
}),
|
||||
}));
|
||||
}),
|
||||
{
|
||||
name: "cyber-star-vote",
|
||||
// 仅持久化 votedArtists —— artists 票数/排名是派生数据,
|
||||
// 刷新后重新从初始数据 + votedArtists 重建。
|
||||
// 注意:当前 mock 阶段 artists 只反映本地投票,不同步服务端 —— 等后端接入再调整。
|
||||
partialize: (state) => ({ votedArtists: state.votedArtists }),
|
||||
// rehydrate 时把 votedArtists 数据"回放"到 artists 票数上,保持视图一致
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (!state) return;
|
||||
const counts = new Map<string, number>();
|
||||
for (const id of state.votedArtists) {
|
||||
counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||
}
|
||||
const rebuilt = INITIAL_ARTISTS.map((a) => ({
|
||||
...a,
|
||||
votes: a.votes + (counts.get(a.id) ?? 0),
|
||||
}));
|
||||
state.artists = rank(rebuilt);
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/** 便捷选择器:按 ID 获取最新艺人 */
|
||||
/** 选择器:按 ID 获取最新艺人 */
|
||||
export function selectArtist(id: string) {
|
||||
return (s: VoteStore) => s.artists.find((a) => a.id === id);
|
||||
}
|
||||
|
||||
/** 选择器:当前剩余票数(跨日自动重置) */
|
||||
/** 选择器:当前剩余票数 = 12 - 已投艺人数 */
|
||||
export function selectRemaining(s: VoteStore): number {
|
||||
// 必须按 quotaDate 判断是否还属于今日:跨过午夜后 usedToday 仍是昨日值,
|
||||
// 但 today 切换后基线应回到 0,余票回到满额。下次 vote() 才会真正落库重置。
|
||||
const today = todayKey();
|
||||
const baseUsed = s.quotaDate === today ? s.usedToday : 0;
|
||||
return Math.max(0, DAILY_VOTE_QUOTA - baseUsed);
|
||||
return Math.max(0, TOTAL_VOTE_QUOTA - s.votedArtists.length);
|
||||
}
|
||||
|
||||
/** 选择器:是否已投过指定艺人(高阶,在组件里用 useVoteStore(selectHasVoted(id))) */
|
||||
export function selectHasVoted(id: string) {
|
||||
return (s: VoteStore) => s.votedArtists.includes(id);
|
||||
}
|
||||
|
||||
/** 选择器:12 票是否已全部投完 */
|
||||
export function selectIsExhausted(s: VoteStore): boolean {
|
||||
return s.votedArtists.length >= TOTAL_VOTE_QUOTA;
|
||||
}
|
||||
|
||||
/**
|
||||
* 派生函数:"我的应援"列表 —— 凡是我投过票(>0)的艺人都进入,
|
||||
* 按我投出的票数降序,artist 字段使用 store 最新排名。
|
||||
* 派生函数:"我的应援"列表 —— 按投票顺序(最早投的在前)。
|
||||
*
|
||||
* ⚠️ 不要直接 `useVoteStore(selectMySupports)`:它每次都返回新数组,
|
||||
* ⚠️ 不要直接 `useVoteStore(selectMySupports)`:它每次都返回新数组,
|
||||
* 会触发 React 19 的 "getSnapshot should be cached" 报错。
|
||||
* 正确用法:分别订阅 `s.myVotesByArtist` 和 `s.artists`,在组件里 useMemo 派生。
|
||||
* 正确用法:在组件里 useMemo 派生(参考 MeContent.tsx)。
|
||||
*/
|
||||
export function selectMySupports(s: VoteStore): MySupport[] {
|
||||
const list: MySupport[] = [];
|
||||
for (const [id, votedCount] of Object.entries(s.myVotesByArtist)) {
|
||||
if (votedCount <= 0) continue;
|
||||
for (const id of s.votedArtists) {
|
||||
const artist = s.artists.find((a) => a.id === id);
|
||||
if (artist) list.push({ artist, votedCount });
|
||||
if (artist) list.push({ artist });
|
||||
}
|
||||
list.sort((a, b) => b.votedCount - a.votedCount);
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 选择器:我支持的艺人数(去重,仅计 > 0 票) */
|
||||
/** 选择器:我支持的艺人数 = 投票数 */
|
||||
export function selectMySupportingCount(s: VoteStore): number {
|
||||
let n = 0;
|
||||
for (const v of Object.values(s.myVotesByArtist)) if (v > 0) n++;
|
||||
return n;
|
||||
return s.votedArtists.length;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user