Compare commits

...

9 Commits
v0.3.0 ... main

Author SHA1 Message Date
iye
9a122ffa27 feat(brand): update CSG logo and site title
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m20s
2026-06-02 11:00:14 +08:00
zyc
85cf284848 chore(otp): raise per-IP send-otp limit from 5 to 100 / 5min
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m59s
放宽同一出口 IP 5 分钟内可发送的验证码次数,避免办公网 / 校园网 / NAT
下多个真实用户互相挤掉配额。单手机号 60s 限频不变。

注意:当前 REDIS_URL 未配置,限流走进程内 Map,多副本部署时该阈值
按 pod 各自计数,实际放大为 N × 100。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:54:56 +08:00
iye
9772ba88ae fix(auth): 僵尸 JWT session 兜底 —— /api/me 返回 NOT_FOUND/UNAUTHORIZED 时自动登出
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m54s
NextAuth 用 JWT 策略,cookie 签名不会因 DB user 被删而失效,
导致 dev 清数据后浏览器仍显示"已登录"但拉不到任何数据(假登录)。

useSyncMe 现在识别 /api/me 的 401/NOT_FOUND/UNAUTHORIZED 三种信号,
命中后调用 signOut({ redirect: false }) + reset(),把 UI 切回未登录态。

生产环境不会清 user,主要受益是 dev/staging 重置数据后无需手动清浏览器 cookie。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 17:05:50 +08:00
iye
8b99c2f091 feat: 跨设备同步 + Logo v3 + 导航合并 + 窄屏适配 (v0.3.4)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m44s
- useSyncMe: 登录后拉 /api/me 用 votedArtists 覆盖本地 store,登出清本地
- Providers: SyncMeBridge 接入 SessionProvider
- Logo v3 替换登录页/弹窗/Footer 的旧 <Logo> 组件
- Footer 删除 logo,简化为版权行
- Navigation 删除 mobile 第二行 NavLinks,合并到第一行
- NavLinks 统一布局,响应式 gap+字号(gap-5 sm:gap-8 / text-[13px] sm:text-sm)
- HeroVoteProgress 窄屏(< md)隐藏 12 格点,只留文字
- scripts/screenshot-narrow.mjs 验证脚本(可配宽高)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 14:56:42 +08:00
iye
5c009f38cd fix(vote): 投票后立即 refresh ranking,不等 30s 轮询
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m48s
- useVoteAction 加 onVoteSuccess 回调,服务端 200 立即触发
- page.tsx + ranking/page.tsx 传 live.refresh
- 顺手把 fire-and-forget 改成 await + 失败回滚 + /api/me 跨设备对齐
- store 新增 rollbackVote + hydrateFromServer 两个 action

体感:本地 30s → 700ms,生产 30s → 150ms

bump to v0.3.3

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 14:25:40 +08:00
iye
51009616a1 fix(home): Top12 出道位接 /api/ranking 显示真实票数
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 6m29s
- 首页 Top12Bar 之前只读 zustand store(初始 0 票),未登录访客永远看到"Awaiting Votes"
- 现在和 /ranking 页一样用 useRanking 30s 轮询,merge 服务端票数到本地 store(取 max)
- 与本地乐观投票兼容,投票后立即可见

bump to v0.3.2

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 14:03:51 +08:00
iye
e05f63b94f chore(scripts): 补提交 reset-vote-data.mjs(清 DB 工具)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m24s
清空 votes / fan_supports / daily_quota / sign_ins / risk_logs / snapshots
并 reset artists.vote_count=0 / current_rank=null。保留 users / artists / activity_config。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:55:55 +08:00
iye
80c37923d4 chore(release): v0.3.1 - 13 号虞浓氛围图 2 替换
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m6s
- TOS 缓存版本 7 → 8 触发 CDN 刷新
- CHANGELOG 加 v0.3.1 条目
- package.json 0.3.0 → 0.3.1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:00:35 +08:00
iye
338549ee27 docs(changelog): 每个版本附 commit hash + Gitea diff 链接
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m58s
- 修正 v0.1.0 tag 错位:从 8a83815(bootstrap) 改到 d5ed43a(2026-05-12 收尾)
- 每个版本补"Tag 打在哪 / 核心 commit / 完整 diff"三项链接
- 解决"点 v0.3.0 tag 进去看到的是 changelog diff,不是真正改动"的问题

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 20:24:19 +08:00
28 changed files with 674 additions and 110 deletions

View File

@ -13,10 +13,122 @@ CYBER STAR(虚拟明星 Top 12 出道选拔)更新日志。新条目写在最上
2. 本文件加新条目(顶部) 2. 本文件加新条目(顶部)
3. `git tag v<x.y.z>`(可选 push 到远程,触发 CI release 流程) 3. `git tag v<x.y.z>`(可选 push 到远程,触发 CI release 流程)
每个版本下方都写两块 commit 信息:
- **Tag**: tag 实际打在哪条 commit(可点击直达 Gitea)
- **核心 commit**: 该版本最重要的功能/改动 commit hash
- **完整 diff**: 与上一个版本的 compare 链接
---
## v0.3.4 · 2026-05-18 · 跨设备同步 + Logo v3 + 导航合并 + 窄屏适配
**Commit 信息**
- 完整 diff: [v0.3.3...v0.3.4](https://gitea.airlabs.art/zyc/UI-UX/compare/v0.3.3...v0.3.4)
**改了什么(用户视角)**
- 跨设备投票状态自动对齐:A 设备投了 5 票,B 设备登录后立刻看到 5 票已投(原来只看 localStorage)
- 登录页 / 登录弹窗 / Footer 替换为新版金属质感 logo(`logo-v3.png`),Footer 顺手去掉 logo 行
- 导航栏窄屏不再"双行":"首页 / 排行榜 / 我的"合并到第一行,与右侧搜索/badge/auth 同行
- 窄屏(< 768px)hero 右上角应援进度只显示文字,隐藏 12 格点 让左侧 "TOP 12 · CYBER STAR" eyebrow 有空间不挤撞
**技术点**
- `src/hooks/useSyncMe.ts`(新): 监听 session 变化 → 拉 `/api/me``hydrateFromServer(votedArtists)` 覆盖本地 store;登出清本地避免上一个用户残留
- `src/components/Providers.tsx`: `<SyncMeBridge/>` 在 SessionProvider 内部启动 useSyncMe
- `src/components/Navigation.tsx`: 删除 mobile 第二行 NavLinks,nav `gap-4 sm:gap-8` 响应式
- `src/components/NavLinks.tsx`: 删除 mobile/desktop 双分支,统一用 `gap-5 sm:gap-8 text-[13px] sm:text-sm`
- `src/components/HeroVoteProgress.tsx`: 12 格点容器加 `hidden md:inline-flex`,< 768px 隐藏
- `public/logo-v3.png`(新): 金属质感 logo,替换原 `<Logo>` 组件
**实测三种屏宽**(脚本 `scripts/screenshot-narrow.mjs`)
| 屏宽 | nav | hero eyebrow | hero progress 宽 |
|------|-----|--------------|------------------|
| 1500px | 单行 ✓ | 不撞 ✓ | 252(完整 12 点)|
| 740px | 单行 ✓ | 不撞 ✓ | 135(隐藏点)|
| 360px | 单行 ✓(NavLinks 140px 塞下)| eyebrow 320px 太长仍占满 ⚠️ | 135 |
**风险 / 已知问题**
- 360px 极窄屏 hero eyebrow 自身已占 320px,即使 progress 缩到 135 仍会横向重叠。下次单独修(eyebrow 极窄屏可简化为 "TOP 12" 或 hidden sm:block)
---
## v0.3.3 · 2026-05-15 · 修复:投票后票数 +1 和 Top12 排位要等 30s
**Commit 信息**
- 完整 diff: [v0.3.2...v0.3.3](https://gitea.airlabs.art/zyc/UI-UX/compare/v0.3.2...v0.3.3)
**改了什么**
- 投票成功后,首页 Top12 + 排行榜立即拉新数据,不必等下次 30s 轮询
- 服务端拒绝投票时(如其他设备已投过),本地立即回滚乐观更新 + 用 /api/me 重新对齐
- 网络异常时回滚 + 报错,避免本地状态与服务端不一致
**根因(诊断详见 v0.3.2 后的对话)**
- `useRanking` 30s 轮询拉服务端票数,merge 时取 max(serverVotes, storeVotes)
- 用户投票后,storeVotes 立即 +1,但 serverVotes 还是 30s 前的旧值,且更大
- max 永远取 server → 本地 +1 被压住 → 票数和排位最多延迟 30s 才更新
**技术修复**
- `useVoteAction``onVoteSuccess` 回调,服务端 200 后立即触发
- `page.tsx` + `ranking/page.tsx``live.refresh` 传进去
- 顺手把 fire-and-forget 改成 await 服务端,失败时 `rollbackVote` + `refetchMe(/api/me)` 跨设备对齐
- store 新增 `rollbackVote(id)` + `hydrateFromServer(ids[])` 两个 action
**体感对比**
| 场景 | 本地 dev(打公网 RDS) | 生产(同区 K3s + RDS) |
|------|---------------------|---------------------|
| 改前 | 0~30 秒后才看到票数 +1 | 同上 |
| 改后 | ~700-1000 ms(vote API 写完 + ranking refresh) | ~150 ms |
**风险 / 已知问题**
- ArtistDetailContent 显示的 artist.votes 仍从 store 来,store 里这个字段是"本地用户投过几次"不是服务端真实票。详情页票数显示问题留下次单独修。
---
## v0.3.2 · 2026-05-15 · 修复:首页 Top12 出道位空着,排行榜却有数据
**Commit 信息**
- 完整 diff: [v0.3.1...v0.3.2](https://gitea.airlabs.art/zyc/UI-UX/compare/v0.3.1...v0.3.2)
**改了什么**
- 首页 Top12 出道位现在能显示真实票数(此前一直显示"Awaiting Votes")
**根因**
- 首页 Top12Bar 之前只读前端 zustand store 的 `artists`,store 初始来自 mock-data(36 人全部 0 票),只有**当前浏览器用户自己投票时**才会更新
- 排行榜 `/ranking``/api/ranking` 直接读 DB,所以有真实票数
- 未登录访客访问首页 → store 全 0 票 → `top12 = filter(votes > 0)` 空 → 显示"Awaiting Votes"
**技术修复**
- `src/app/page.tsx``useRanking({ pollInterval: 30_000 })`,30 秒轮询 `/api/ranking`
- 用 `useMemo` 合并 `storeArtists`(本地乐观投票)+ API 票数(取 max),重新排序赋 rank
- 与 `/ranking` 页面用同样的 merge 策略,保证两处票数视图一致
**风险 / 已知问题**
- 首页和排行榜各自跑一个 `useRanking` 实例,会产生两个独立轮询请求。后续可以提到 layout 层共享,但当前 36 行查询很轻,先这样
---
## v0.3.1 · 2026-05-15 · 13 号(虞浓)氛围图 2 替换
**Commit 信息**
- Tag 打在: 本次 release commit(`git log -1` 看)
- 完整 diff: [v0.3.0...v0.3.1](https://gitea.airlabs.art/zyc/UI-UX/compare/v0.3.0...v0.3.1)
**改了什么**
- 13 号虞浓的氛围图 2 换成新版本(`portraits/013-2.webp`,1440×2560,153KB)
**技术点**
- TOS cache version `7``8`(`src/lib/tos.ts`),浏览器 + CDN 立即拿到新图,不必等 TTL
- 转换脚本 `_tmp_webp_convert/round3.mjs`,sharp quality 82
--- ---
## v0.3.0 · 2026-05-15 · 投票模型大改:每日额度 → 终身 12 票 ## v0.3.0 · 2026-05-15 · 投票模型大改:每日额度 → 终身 12 票
**Commit 信息**
- Tag 打在: [`93c3abe`](https://gitea.airlabs.art/zyc/UI-UX/commit/93c3abe) `chore(release): v0.3.0 + 建立 CHANGELOG + 追溯版本号`
- 核心 commit: [`10878dd`](https://gitea.airlabs.art/zyc/UI-UX/commit/10878dd) `feat(vote): 重构投票模型为终身 12 票 + 每艺人 1 票`
- 完整 diff: [v0.2.2...v0.3.0](https://gitea.airlabs.art/zyc/UI-UX/compare/v0.2.2...v0.3.0)
**改了什么** **改了什么**
- 每个用户改为终身 12 票,每位艺人最多 1 票,投出不可撤销 - 每个用户改为终身 12 票,每位艺人最多 1 票,投出不可撤销
- 取消活动时间窗(不再限定结束日期) - 取消活动时间窗(不再限定结束日期)
@ -43,6 +155,10 @@ CYBER STAR(虚拟明星 Top 12 出道选拔)更新日志。新条目写在最上
## v0.2.2 · 2026-05-15 · 内容批次 v2 修正 + 单人视频 ## v0.2.2 · 2026-05-15 · 内容批次 v2 修正 + 单人视频
**Commit 信息**
- Tag 打在: [`8d8451b`](https://gitea.airlabs.art/zyc/UI-UX/commit/8d8451b) `chore(tos): bump cache version 6 → 7 for 014-2 recrop`
- 完整 diff: [v0.2.1...v0.2.2](https://gitea.airlabs.art/zyc/UI-UX/compare/v0.2.1...v0.2.2)
**改了什么** **改了什么**
- 多位艺人立绘 / 头像 / 氛围图替换(003 / 006 / 007 / 010 / 014 / 017 / 019 / 027 / 033 等) - 多位艺人立绘 / 头像 / 氛围图替换(003 / 006 / 007 / 010 / 014 / 017 / 019 / 027 / 033 等)
- 单人表演视频更新(5 个艺人) - 单人表演视频更新(5 个艺人)
@ -57,6 +173,10 @@ CYBER STAR(虚拟明星 Top 12 出道选拔)更新日志。新条目写在最上
## v0.2.1 · 2026-05-14 · UI 视觉打磨 ## v0.2.1 · 2026-05-14 · UI 视觉打磨
**Commit 信息**
- Tag 打在: [`7168e50`](https://gitea.airlabs.art/zyc/UI-UX/commit/7168e50) `fix: prod login + env-file driven config + scroll-snap bounce`
- 完整 diff: [v0.2.0...v0.2.1](https://gitea.airlabs.art/zyc/UI-UX/compare/v0.2.0...v0.2.1)
**改了什么** **改了什么**
- 导航栏滚动到 Hero 下方时自动从透明变毛玻璃 - 导航栏滚动到 Hero 下方时自动从透明变毛玻璃
- 加浮动返回按钮 + 滚动位置 per-tab 记忆 - 加浮动返回按钮 + 滚动位置 per-tab 记忆
@ -70,6 +190,11 @@ CYBER STAR(虚拟明星 Top 12 出道选拔)更新日志。新条目写在最上
## v0.2.0 · 2026-05-13 · 真实持久化 + CI/CD 打通 ## v0.2.0 · 2026-05-13 · 真实持久化 + CI/CD 打通
**Commit 信息**
- Tag 打在: [`1073262`](https://gitea.airlabs.art/zyc/UI-UX/commit/1073262) `ci(secret): inject Aliyun SMS credentials into cyberstar-env`
- 核心 commit: [`a9f4799`](https://gitea.airlabs.art/zyc/UI-UX/commit/a9f4799) `feat(db): wire real persistence for votes / users / quota / supports`
- 完整 diff: [v0.1.0...v0.2.0](https://gitea.airlabs.art/zyc/UI-UX/compare/v0.1.0...v0.2.0)
**改了什么** **改了什么**
- 投票 / 用户 / 应援 / 额度全部走真实数据库(火山引擎 RDS),不再 mock - 投票 / 用户 / 应援 / 额度全部走真实数据库(火山引擎 RDS),不再 mock
- 阿里云短信 OTP 登录上线(国内手机号) - 阿里云短信 OTP 登录上线(国内手机号)
@ -86,6 +211,11 @@ CYBER STAR(虚拟明星 Top 12 出道选拔)更新日志。新条目写在最上
## v0.1.0 · 2026-05-12 · 项目骨架与全部页面初版 ## v0.1.0 · 2026-05-12 · 项目骨架与全部页面初版
**Commit 信息**
- Tag 打在: [`d5ed43a`](https://gitea.airlabs.art/zyc/UI-UX/commit/d5ed43a) `feat(ui): design overhaul, global login modal, design spec`
- 核心 commit: [`8a83815`](https://gitea.airlabs.art/zyc/UI-UX/commit/8a83815) `chore: bootstrap Next.js 16 + Tailwind v4 + TypeScript baseline`
- 范围: 项目第一个 commit 到 d5ed43a(共 16 个 commit)
**改了什么** **改了什么**
- Next.js 16 + Tailwind v4 + TypeScript 项目搭建 - Next.js 16 + Tailwind v4 + TypeScript 项目搭建
- 紫色品牌设计系统(Megrim / Audiowide / Cinzel / Inter 字体) - 紫色品牌设计系统(Megrim / Audiowide / Cinzel / Inter 字体)
@ -105,4 +235,4 @@ CYBER STAR(虚拟明星 Top 12 出道选拔)更新日志。新条目写在最上
--- ---
_格式参考 [Keep a Changelog](https://keepachangelog.com/),宽松,优先记"改了什么"和"风险",不强求语义化本号_ _格式参考 [Keep a Changelog](https://keepachangelog.com/),宽松版_

View File

@ -1,6 +1,6 @@
{ {
"name": "cyber-star", "name": "cyber-star",
"version": "0.3.0", "version": "0.3.4",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",

BIN
public/logo-v3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
public/logo-v4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@ -0,0 +1,59 @@
// 清空投票相关测试数据,准备重新测试。
//
// 清:votes / fan_supports / daily_quota / sign_ins / risk_logs / ranking_snapshots
// reset:artists.vote_count = 0, artists.current_rank = null
// 保留:users / artists 配置 / activity_config / invitations
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient({ log: ["error"] });
console.log("=== 清空投票测试数据 ===\n");
// 跑前 snapshot
const before = {
votes: await prisma.vote.count(),
fanSupports: await prisma.fanSupport.count(),
dailyQuotas: await prisma.dailyQuota.count(),
signIns: await prisma.signIn.count(),
riskLogs: await prisma.riskLog.count(),
snapshots: await prisma.rankingSnapshot.count(),
artistsWithVotes: await prisma.artist.count({ where: { voteCount: { gt: 0 } } }),
};
console.log("清前:", before);
await prisma.$transaction(
async (tx) => {
await tx.vote.deleteMany({});
await tx.fanSupport.deleteMany({});
await tx.dailyQuota.deleteMany({});
await tx.signIn.deleteMany({});
await tx.riskLog.deleteMany({});
await tx.rankingSnapshot.deleteMany({});
// reset artists 缓存
await tx.artist.updateMany({
data: { voteCount: 0, currentRank: null },
});
},
{ timeout: 30000 },
);
const after = {
votes: await prisma.vote.count(),
fanSupports: await prisma.fanSupport.count(),
dailyQuotas: await prisma.dailyQuota.count(),
signIns: await prisma.signIn.count(),
riskLogs: await prisma.riskLog.count(),
snapshots: await prisma.rankingSnapshot.count(),
artistsWithVotes: await prisma.artist.count({ where: { voteCount: { gt: 0 } } }),
};
console.log("\n清后:", after);
// 同时确认保留了什么
const preserved = {
users: await prisma.user.count(),
artists: await prisma.artist.count(),
activityConfig: await prisma.activityConfig.count(),
};
console.log("保留:", preserved);
await prisma.$disconnect();
console.log("\n✓ 完成");

View File

@ -0,0 +1,86 @@
import { spawn } from "node:child_process";
import { writeFile } 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-narrow-shot";
const OUT = process.env.SHOT_OUT || "d:/ClaudeProjects/虚拟明星/UI-UX/docs/screenshots/nav-overlap-narrow.png";
const WIDTH = Number(process.env.SHOT_WIDTH || 740);
const HEIGHT = Number(process.env.SHOT_HEIGHT || 1000);
const proc = spawn(CHROME, [
`--headless=new`, `--disable-gpu`, `--remote-debugging-port=${PORT}`,
`--user-data-dir=${PROFILE}`, `--hide-scrollbars`, `--no-first-run`, `about:blank`,
], { stdio: "ignore", detached: true });
proc.unref();
for (let i = 0; i < 30; i++) {
try { if ((await fetch(`http://127.0.0.1:${PORT}/json/version`)).ok) break; } catch {}
await wait(300);
}
const t = await (await fetch(`http://127.0.0.1:${PORT}/json/new?about:blank`, { method: "PUT" })).json();
const ws = new WebSocket(t.webSocketDebuggerUrl);
await new Promise(r => ws.addEventListener("open", () => r(), { once: true }));
let id = 0; const pending = new Map();
ws.addEventListener("message", (e) => {
const m = JSON.parse(e.data);
if (m.id && pending.has(m.id)) { const { resolve, reject } = pending.get(m.id); pending.delete(m.id);
if (m.error) reject(new Error(m.error.message)); else resolve(m.result); }
});
const cmd = (method, params = {}) => new Promise((resolve, reject) => {
pending.set(++id, { resolve, reject });
ws.send(JSON.stringify({ id, method, params }));
});
await cmd("Emulation.setDeviceMetricsOverride", {
width: WIDTH, height: HEIGHT, deviceScaleFactor: 1, mobile: false,
});
await cmd("Page.navigate", { url: "http://localhost:3000" });
await wait(3500);
await cmd("Runtime.evaluate", {
expression: `document.querySelectorAll('video').forEach(v => { try { v.pause(); } catch{} });`,
});
await wait(500);
const r = await cmd("Page.captureScreenshot", { format: "png" });
await writeFile(OUT, Buffer.from(r.data, "base64"));
// 同时取关键元素的位置信息
const layout = await cmd("Runtime.evaluate", {
returnByValue: true,
expression: `(() => {
const get = sel => {
const el = document.querySelector(sel);
if (!el) return null;
const r = el.getBoundingClientRect();
return { top: Math.round(r.top), left: Math.round(r.left), width: Math.round(r.width), height: Math.round(r.height), text: el.textContent?.trim().slice(0, 30) ?? '' };
};
return {
headerNav: get('header > nav'),
mobileNavLinks: get('header ul.md\\\\:hidden') || get('header [class*="md:hidden"] ul') || (() => {
// mobile NavLinks 在 header 下方第二行,找 header 内最后一个 ul
const uls = document.querySelectorAll('header ul');
const last = uls[uls.length - 1];
if (!last) return null;
const r = last.getBoundingClientRect();
return { top: Math.round(r.top), left: Math.round(r.left), width: Math.round(r.width), height: Math.round(r.height), text: last.textContent?.trim().slice(0, 30) ?? '' };
})(),
heroEyebrow: (() => {
const els = Array.from(document.querySelectorAll('p'));
const m = els.find(p => /Top 12.*Cyber Star/i.test(p.textContent || ''));
if (!m) return null;
const r = m.getBoundingClientRect();
return { top: Math.round(r.top), left: Math.round(r.left), width: Math.round(r.width), height: Math.round(r.height), text: m.textContent?.trim().slice(0, 40) ?? '' };
})(),
heroProgress: get('[data-hero-vote-progress]'),
};
})()`,
});
console.log("\n=== 关键元素位置(viewport 坐标)===");
for (const [k, v] of Object.entries(layout.result.value)) {
if (v) console.log(` ${k.padEnd(16)} top=${String(v.top).padStart(3)}px left=${String(v.left).padStart(3)}px w=${v.width} h=${v.height} "${v.text}"`);
else console.log(` ${k.padEnd(16)} (未找到)`);
}
console.log(`\n${OUT}`);
ws.close();
try { process.kill(proc.pid); } catch {}
spawn("taskkill", ["/F", "/PID", String(proc.pid), "/T"], { stdio: "ignore" });

76
scripts/verify-top12.mjs Normal file
View File

@ -0,0 +1,76 @@
import { spawn } from "node:child_process";
import { writeFile } 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-top12-verify";
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`,
`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) break;
} catch {}
await wait(300);
}
const t = await (await fetch(`http://127.0.0.1:${PORT}/json/new?about:blank`, { method: "PUT" })).json();
const ws = new WebSocket(t.webSocketDebuggerUrl);
await new Promise((r) => ws.addEventListener("open", () => r(), { once: true }));
let id = 0;
const pending = new Map();
ws.addEventListener("message", (e) => {
const m = JSON.parse(e.data);
if (m.id && pending.has(m.id)) {
const { resolve, reject } = pending.get(m.id);
pending.delete(m.id);
if (m.error) reject(new Error(m.error.message));
else resolve(m.result);
}
});
const cmd = (method, params = {}) =>
new Promise((resolve, reject) => {
pending.set(++id, { resolve, reject });
ws.send(JSON.stringify({ id, method, params }));
});
await cmd("Emulation.setDeviceMetricsOverride", {
width: 1500, height: 900, deviceScaleFactor: 1, mobile: false,
});
await cmd("Page.navigate", { url: "http://localhost:3000" });
await wait(3000);
await cmd("Runtime.evaluate", {
expression: `document.querySelectorAll('video').forEach(v => { try { v.pause(); } catch{} });`,
});
// 滚到 Top12 区
await cmd("Runtime.evaluate", {
expression: `window.scrollTo(0, window.innerHeight);`,
});
await wait(1500);
// 多等一下 useRanking 拉到数据
await wait(2500);
const r = await cmd("Page.captureScreenshot", { format: "png" });
await writeFile(
"d:/ClaudeProjects/虚拟明星/UI-UX/docs/screenshots/top12-fix-verify.png",
Buffer.from(r.data, "base64"),
);
console.log("✓ saved");
ws.close();
try { process.kill(proc.pid); } catch {}
spawn("taskkill", ["/F", "/PID", String(proc.pid), "/T"], { stdio: "ignore" });

View File

@ -14,7 +14,7 @@ const Body = z.object({
/** /**
* POST /api/auth/send-otp * POST /api/auth/send-otp
* · 60s / IP 5 5 * · 60s / IP 5 100
*/ */
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
@ -31,7 +31,7 @@ export async function POST(req: NextRequest) {
} }
const ip = await getClientIp(); const ip = await getClientIp();
if (ip) { if (ip) {
const ipRl = await rateLimit(`otp:ip:${ip}`, 300, 5); const ipRl = await rateLimit(`otp:ip:${ip}`, 300, 100);
if (!ipRl.allowed) return ERR.RATE_LIMITED(); if (!ipRl.allowed) return ERR.RATE_LIMITED();
} }

View File

@ -16,10 +16,10 @@ export async function generateMetadata({
}: ArtistPageProps): Promise<Metadata> { }: ArtistPageProps): Promise<Metadata> {
const { id } = await params; const { id } = await params;
const artist = getArtist(id); const artist = getArtist(id);
if (!artist) return { title: "艺人不存在 · CYBER STAR" }; if (!artist) return { title: "艺人不存在 · 银河初星计划 C . S . G" };
return { return {
title: `${artist.name} · ${artist.enName} · CYBER STAR`, title: `${artist.name} · ${artist.enName} · 银河初星计划 C . S . G`,
description: artist.bio.slice(0, 120), description: artist.bio.slice(0, 120),
}; };
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

@ -33,15 +33,27 @@ const inter = Inter({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "CYBER ✦ STAR · 虚拟偶像 Top12 出道企划", title: "银河初星计划 C . S . G",
description: description:
"36 位虚拟偶像候选人,由你投票决出最终出道 Top12。Cyber Star · Virtual Idol Debut Project.", "36 位虚拟偶像候选人,由你投票决出最终出道 Top12。银河初星计划 C . S . G。",
keywords: ["虚拟偶像", "出道", "投票", "Top12", "Cyber Star", "Virtual Idol"], keywords: [
"银河初星计划",
"C . S . G",
"虚拟偶像",
"出道",
"投票",
"Top12",
"Virtual Idol",
],
openGraph: { openGraph: {
title: "CYBER ✦ STAR", title: "银河初星计划 C . S . G",
description: "虚拟偶像 Top12 出道企划", description: "虚拟偶像 Top12 出道企划",
type: "website", type: "website",
}, },
icons: {
icon: "/favicon.ico?v=4",
shortcut: "/favicon.ico?v=4",
},
}; };
export default function RootLayout({ export default function RootLayout({

View File

@ -5,7 +5,6 @@ import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { Phone, KeyRound, Loader2 } from "lucide-react"; import { Phone, KeyRound, Loader2 } from "lucide-react";
import Logo from "@/components/Logo";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
@ -92,8 +91,15 @@ export default function LoginForm() {
<div className="min-h-[calc(100vh-128px)] flex items-center justify-center px-4 py-10"> <div className="min-h-[calc(100vh-128px)] flex items-center justify-center px-4 py-10">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
{/* Logo */} {/* Logo */}
<div className="text-center mb-8"> <div className="flex flex-col items-center mb-8">
<Logo size="lg" href={null} /> <img
src="/logo-v4.png?v=4"
alt="银河初星计划 C . S . G"
decoding="async"
draggable={false}
className="block select-none h-20 sm:h-24 w-auto"
style={{ background: "transparent" }}
/>
<p className="font-label text-[10px] tracking-[0.4em] uppercase text-purple-300/80 mt-3"> <p className="font-label text-[10px] tracking-[0.4em] uppercase text-purple-300/80 mt-3">
Sign in to Vote Sign in to Vote
</p> </p>

View File

@ -2,7 +2,7 @@ import { Suspense } from "react";
import LoginForm from "./LoginForm"; import LoginForm from "./LoginForm";
export const metadata = { export const metadata = {
title: "登录 · CYBER STAR", title: "登录 · 银河初星计划 C . S . G",
}; };
export default function LoginPage() { export default function LoginPage() {

View File

@ -4,7 +4,7 @@ import { auth } from "@/lib/auth";
import MeContent from "./MeContent"; import MeContent from "./MeContent";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "个人中心 · CYBER STAR", title: "个人中心 · 银河初星计划 C . S . G",
}; };
export default async function MePage() { export default async function MePage() {

View File

@ -10,15 +10,22 @@ import VoteModal from "@/components/VoteModal";
import { sortArtists, type SortKey } from "@/lib/mock-data"; import { sortArtists, type SortKey } from "@/lib/mock-data";
import { useVoteStore } from "@/lib/store"; import { useVoteStore } from "@/lib/store";
import { useVoteAction } from "@/hooks/useVoteAction"; import { useVoteAction } from "@/hooks/useVoteAction";
import { useRanking } from "@/hooks/useRanking";
import { useScrollRestore } from "@/hooks/useScrollRestore"; import { useScrollRestore } from "@/hooks/useScrollRestore";
import { useUIStore } from "@/lib/ui-store"; import { useUIStore } from "@/lib/ui-store";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { tosUrl } from "@/lib/tos"; import { tosUrl } from "@/lib/tos";
import type { Artist } from "@/types/artist";
export default function Home() { export default function Home() {
const artists = useVoteStore((s) => s.artists); const storeArtists = useVoteStore((s) => s.artists);
// 30s 轮询 /api/ranking 拿服务端真实票数
const live = useRanking({ pollInterval: 30_000 });
// 投票成功后立即 refresh 排名,不等下次轮询(解决"票数 +1 / Top12 排位延迟"问题)
const { target, remaining, totalQuota, openVote, closeVote, confirmVote } = const { target, remaining, totalQuota, openVote, closeVote, confirmVote } =
useVoteAction(); useVoteAction({ onVoteSuccess: live.refresh });
const [tagFilter, setTagFilter] = useState<TagFilter>("all"); const [tagFilter, setTagFilter] = useState<TagFilter>("all");
const [sortKey, setSortKey] = useState<SortKey>("votes"); const [sortKey, setSortKey] = useState<SortKey>("votes");
@ -29,6 +36,21 @@ export default function Home() {
// 首页滚动位置 per-tab 记忆:从艺人详情点 ← 返回时恢复到上次浏览位置 // 首页滚动位置 per-tab 记忆:从艺人详情点 ← 返回时恢复到上次浏览位置
useScrollRestore("home"); useScrollRestore("home");
// 数据同步:本地乐观投票 + 服务端票数取 max,避免本地 store 只看到自己投的票。
// 与 /ranking 页面同一策略,首页 Top12 + 候选区都基于这份合并数据。
const artists = useMemo<Artist[]>(() => {
const apiVotes = new Map<string, number>();
if (live.data?.list) {
for (const row of live.data.list) apiVotes.set(row.id, row.voteCount);
}
const merged = storeArtists.map((a) => {
const apiV = apiVotes.get(a.id) ?? 0;
return apiV > a.votes ? { ...a, votes: apiV } : a;
});
const ranked = sortArtists(merged, "votes");
return ranked.map((a, i) => ({ ...a, rank: i + 1 }));
}, [storeArtists, live.data]);
const visibleArtists = useMemo(() => { const visibleArtists = useMemo(() => {
let list = [...artists]; let list = [...artists];
if (tagFilter !== "all") { if (tagFilter !== "all") {

View File

@ -14,11 +14,13 @@ import type { Artist } from "@/types/artist";
export default function RankingPage() { export default function RankingPage() {
const storeArtists = useVoteStore((s) => s.artists); const storeArtists = useVoteStore((s) => s.artists);
const { target, remaining, totalQuota, openVote, closeVote, confirmVote } =
useVoteAction();
const live = useRanking({ pollInterval: 30_000 }); const live = useRanking({ pollInterval: 30_000 });
// 投票成功后立即 refresh 排名,不等下次轮询
const { target, remaining, totalQuota, openVote, closeVote, confirmVote } =
useVoteAction({ onVoteSuccess: live.refresh });
// 数据同步:本地乐观投票 + 服务端最新票数取 max避免 API 落后覆盖本地新票, // 数据同步:本地乐观投票 + 服务端最新票数取 max避免 API 落后覆盖本地新票,
// 也避免本地缺其他用户的票数)。合并后按 votes desc + no asc 重新排序并赋 rank。 // 也避免本地缺其他用户的票数)。合并后按 votes desc + no asc 重新排序并赋 rank。
const sorted = useMemo<Artist[]>(() => { const sorted = useMemo<Artist[]>(() => {

View File

@ -1,13 +1,10 @@
import Logo from "./Logo";
export default function Footer() { export default function Footer() {
const year = new Date().getFullYear(); const year = new Date().getFullYear();
return ( return (
<footer className="border-t border-white/[0.06] bg-deep mt-16"> <footer className="border-t border-white/[0.06] bg-deep mt-16">
<div className="max-w-[1500px] mx-auto px-6 sm:px-8 h-16 flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-6 text-center"> <div className="max-w-[1500px] mx-auto px-6 sm:px-8 h-16 flex items-center justify-center text-center">
<Logo size="sm" href={null} />
<p className="text-[11px] text-white/35 tracking-[0.05em]"> <p className="text-[11px] text-white/35 tracking-[0.05em]">
© {year} CYBER STAR · All Rights Reserved © {year} C . S . G · All Rights Reserved
</p> </p>
</div> </div>
</footer> </footer>

View File

@ -90,8 +90,8 @@ export default function HeroBanner({
<div className="absolute inset-0 max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8"> <div className="absolute inset-0 max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8">
{/* Eyebrow 左上 · 紧贴导航下方 */} {/* Eyebrow 左上 · 紧贴导航下方 */}
<div className="absolute top-[6.5rem] sm:top-[7.5rem] left-4 sm:left-6 lg:left-8 z-10"> <div className="absolute top-[6.5rem] sm:top-[7.5rem] left-4 sm:left-6 lg:left-8 z-10">
<p className="font-label text-[10px] sm:text-xs tracking-[0.4em] uppercase text-purple-200/90"> <p className="font-label text-[10px] sm:text-xs tracking-[0.28em] uppercase text-purple-200/90">
Top 12 · Cyber Star Debut Survival C . S . G
</p> </p>
</div> </div>

View File

@ -60,10 +60,10 @@ export default function HeroVoteProgress({ className }: { className?: string })
</span> </span>
)} )}
{/* 12 格点亮式进度条 */} {/* 12 格点亮式进度条 —— 窄屏隐藏,避免与左侧 Eyebrow 横向挤撞;>= md 才显示 */}
<span <span
aria-hidden aria-hidden
className="inline-flex items-center gap-[3px] ml-0.5" className="hidden md:inline-flex items-center gap-[3px] ml-0.5"
> >
{Array.from({ length: TOTAL_VOTE_QUOTA }).map((_, i) => { {Array.from({ length: TOTAL_VOTE_QUOTA }).map((_, i) => {
const lit = i < filled; const lit = i < filled;

View File

@ -8,7 +8,7 @@ interface LogoProps {
className?: string; className?: string;
} }
// 高度由 size 控制,宽度按 logo.png 实际比例(约 5.41:1单行 CYBER STAR + 星环)自适应 // 高度由 size 控制,宽度按 logo-v4.png 实际比例自适应
const HEIGHT_PX: Record<LogoSize, number> = { const HEIGHT_PX: Record<LogoSize, number> = {
sm: 24, sm: 24,
md: 44, md: 44,
@ -25,11 +25,11 @@ export default function Logo({
// 用原生 <img> 绕开 Next/Image 的格式转换 —— 某些环境下 sharp 把透明 PNG // 用原生 <img> 绕开 Next/Image 的格式转换 —— 某些环境下 sharp 把透明 PNG
// 转 webp/avif 时会铺白底,导致 logo 在深色 nav 上出现白色矩形。 // 转 webp/avif 时会铺白底,导致 logo 在深色 nav 上出现白色矩形。
// ?v=2 缓存破坏:logo 改版时 +1,浏览器立刻拉新版而不读老缓存。 // ?v=4 缓存破坏:logo 改版时 +1,浏览器立刻拉新版而不读老缓存。
const inner = ( const inner = (
<img <img
src="/logo.png?v=3" src="/logo-v4.png?v=4"
alt="CYBER STAR" alt="银河初星计划 C . S . G"
decoding="async" decoding="async"
draggable={false} draggable={false}
style={{ style={{
@ -46,7 +46,7 @@ export default function Logo({
<Link <Link
href={href} href={href}
className="inline-flex items-center hover:opacity-90 transition-opacity" className="inline-flex items-center hover:opacity-90 transition-opacity"
aria-label="CYBER STAR · 首页" aria-label="银河初星计划 C . S . G · 首页"
style={{ background: "transparent" }} style={{ background: "transparent" }}
> >
{inner} {inner}

View File

@ -19,10 +19,9 @@ const NAV_ITEMS: Array<{
interface NavLinksProps { interface NavLinksProps {
className?: string; className?: string;
mobile?: boolean;
} }
export default function NavLinks({ className, mobile = false }: NavLinksProps) { export default function NavLinks({ className }: NavLinksProps) {
const pathname = usePathname(); const pathname = usePathname();
const { status } = useSession(); const { status } = useSession();
const openLogin = useLoginModalStore((s) => s.show); const openLogin = useLoginModalStore((s) => s.show);
@ -43,39 +42,11 @@ export default function NavLinks({ className, mobile = false }: NavLinksProps) {
} }
}; };
if (mobile) {
return ( return (
<ul <ul
className={cn( className={cn(
"flex items-center gap-6 px-6 py-2.5 text-[13px] tracking-[0.1em] whitespace-nowrap", // 单一布局:窄屏 gap-5 text-[13px],sm 以上 gap-8 text-sm,装饰一致
className, "flex items-center gap-5 sm:gap-8 text-[13px] sm:text-sm tracking-[0.1em] whitespace-nowrap",
)}
>
{NAV_ITEMS.map((item) => {
const active = isActive(item.href);
return (
<li key={item.href}>
<Link
href={item.href}
onClick={(e) => handleClick(e, item)}
className={cn(
"transition-colors",
active ? "text-purple-300" : "text-white/55",
)}
>
{item.label}
</Link>
</li>
);
})}
</ul>
);
}
return (
<ul
className={cn(
"items-center gap-8 text-sm tracking-[0.1em]",
className, className,
)} )}
> >

View File

@ -2,6 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import Logo from "./Logo";
import NavLinks from "./NavLinks"; import NavLinks from "./NavLinks";
import SearchTrigger from "./SearchTrigger"; import SearchTrigger from "./SearchTrigger";
import AuthMenu from "./auth/AuthMenu"; import AuthMenu from "./auth/AuthMenu";
@ -27,6 +28,7 @@ export default function Navigation() {
// nav 关掉自己的玻璃,避免双重 backdrop-filter 在 y=80 处出现拼接线。 // nav 关掉自己的玻璃,避免双重 backdrop-filter 在 y=80 处出现拼接线。
const filterStuck = useUIStore((s) => s.filterStuck); const filterStuck = useUIStore((s) => s.filterStuck);
const glassOff = isTransparent || filterStuck; const glassOff = isTransparent || filterStuck;
const showLogo = pathname !== "/";
// 维护一个站内导航计数器(per-tab),供 FloatingBackButton 判断 router.back() 是否安全 // 维护一个站内导航计数器(per-tab),供 FloatingBackButton 判断 router.back() 是否安全
useEffect(() => { useEffect(() => {
@ -80,28 +82,26 @@ export default function Navigation() {
glassOff ? "opacity-0" : "opacity-100", glassOff ? "opacity-0" : "opacity-100",
)} )}
/> />
<nav className="relative max-w-[1500px] mx-auto h-20 px-4 sm:px-6 lg:px-8 flex items-center gap-8"> <nav className="relative max-w-[1500px] mx-auto h-20 px-4 sm:px-6 lg:px-8 flex items-center gap-4 sm:gap-8">
{/* 左侧:首页 / 排行榜 / 我的(logo 已移除) */} {showLogo && (
<NavLinks className="hidden md:flex" /> <div className="hidden sm:flex shrink-0 items-center">
<Logo
size="md"
className="drop-shadow-[0_0_14px_rgba(139,92,246,0.55)]"
/>
</div>
)}
{/* 左侧:首页 / 排行榜 / 我的 */}
<NavLinks />
{/* 右侧:搜索 + 今日余票 + 登录/注册 (或 头像+下拉) */} {/* 右侧:搜索 + 今日余票 + 登录/注册 (或 头像+下拉) */}
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-2 sm:gap-3">
<SearchTrigger /> <SearchTrigger />
<RemainingVotesBadge /> <RemainingVotesBadge />
<AuthMenu /> <AuthMenu />
</div> </div>
</nav> </nav>
{/* 移动端:单独一行 nav links · 顶部分割线只在玻璃态显示 */}
<NavLinks
className={cn(
"relative md:hidden overflow-x-auto transition-colors duration-300",
glassOff
? "border-t border-transparent"
: "border-t border-white/[0.05]",
)}
mobile
/>
</header> </header>
); );
} }

View File

@ -3,15 +3,27 @@
import { SessionProvider } from "next-auth/react"; import { SessionProvider } from "next-auth/react";
import { Toaster } from "react-hot-toast"; import { Toaster } from "react-hot-toast";
import GlobalLoginModal from "@/components/auth/GlobalLoginModal"; import GlobalLoginModal from "@/components/auth/GlobalLoginModal";
import { useSyncMe } from "@/hooks/useSyncMe";
/**
* /api/me vote store
* SessionProvider useSession
*/
function SyncMeBridge() {
useSyncMe();
return null;
}
/** /**
* Provider * Provider
* - SessionProvider: client useSession() * - SessionProvider: client useSession()
* - SyncMeBridge: 登录后用 /api/me ()
* - Toaster: 全站 toast * - Toaster: 全站 toast
*/ */
export default function Providers({ children }: { children: React.ReactNode }) { export default function Providers({ children }: { children: React.ReactNode }) {
return ( return (
<SessionProvider> <SessionProvider>
<SyncMeBridge />
{children} {children}
<GlobalLoginModal /> <GlobalLoginModal />
<Toaster <Toaster

View File

@ -6,7 +6,6 @@ import { AnimatePresence, motion } from "framer-motion";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { X, Phone, KeyRound, Loader2 } from "lucide-react"; import { X, Phone, KeyRound, Loader2 } from "lucide-react";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button";
import Logo from "@/components/Logo";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
interface LoginModalProps { interface LoginModalProps {
@ -172,10 +171,15 @@ export default function LoginModal({ open, onClose, onSuccess }: LoginModalProps
<X size={18} /> <X size={18} />
</button> </button>
<div className="text-center mb-6"> <div className="flex flex-col items-center mb-6">
<div className="inline-block"> <img
<Logo size="md" href={null} /> src="/logo-v4.png?v=4"
</div> alt="银河初星计划 C . S . G"
decoding="async"
draggable={false}
className="block select-none h-16 sm:h-20 w-auto"
style={{ background: "transparent" }}
/>
<p <p
id="login-modal-title" id="login-modal-title"
className="font-label text-[10px] tracking-[0.4em] uppercase text-purple-300/80 mt-3" className="font-label text-[10px] tracking-[0.4em] uppercase text-purple-300/80 mt-3"

82
src/hooks/useSyncMe.ts Normal file
View File

@ -0,0 +1,82 @@
"use client";
import { useEffect, useRef } from "react";
import { useSession, signOut } from "next-auth/react";
import { useVoteStore } from "@/lib/store";
/**
* /api/me vote store
*
* - status === "authenticated" /api/me, votedArtists
* - status === "unauthenticated" ()
* - (uid )
*
* session 兜底:NextAuth JWT ,cookie DB user
* /api/me 401() NOT_FOUND(DB user ) ,
* signOut() cookie "假登录"()
*
* localStorage ,
*/
export function useSyncMe() {
const { data, status } = useSession();
const hydrateFromServer = useVoteStore((s) => s.hydrateFromServer);
const reset = useVoteStore((s) => s.reset);
const lastSyncedUidRef = useRef<string | null>(null);
const sessionUser = data?.user as { id?: string } | undefined;
const uid = sessionUser?.id ?? null;
useEffect(() => {
if (status === "loading") return;
if (status === "unauthenticated") {
if (lastSyncedUidRef.current !== null) {
reset();
lastSyncedUidRef.current = null;
}
return;
}
// authenticated
if (!uid) return;
if (lastSyncedUidRef.current === uid) return;
let cancelled = false;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 8000);
fetch("/api/me", {
credentials: "include",
signal: ctrl.signal,
})
.then(async (r) => {
if (cancelled) return;
const res = await r.json().catch(() => null);
if (r.ok && res?.ok && Array.isArray(res.data?.votedArtists)) {
hydrateFromServer(res.data.votedArtists as string[]);
lastSyncedUidRef.current = uid;
return;
}
// 僵尸 session:JWT 还有效,但 DB 里 user 已不存在(或鉴权失效)。
// 直接登出清 cookie,UI 状态切换为未登录,避免"显示已登录但拉不到数据"。
const code: string | undefined = res?.error?.code;
if (r.status === 401 || code === "UNAUTHORIZED" || code === "NOT_FOUND") {
signOut({ redirect: false });
reset();
lastSyncedUidRef.current = null;
}
})
.catch(() => {
// 网络失败容忍 —— 下次 status 变化或手动刷新会再试
})
.finally(() => clearTimeout(timer));
return () => {
cancelled = true;
ctrl.abort();
clearTimeout(timer);
};
}, [status, uid, hydrateFromServer, reset]);
}

View File

@ -26,6 +26,30 @@ interface UseVoteActionResult {
confirmVote: (artist: Artist) => Promise<void>; confirmVote: (artist: Artist) => Promise<void>;
} }
interface UseVoteActionOpts {
/**
* 200 useRanking.refresh(),
* Top12 / , 30s
* ()
*/
onVoteSuccess?: () => void;
}
/** 服务端拉一次 /api/me,把权威态灌进本地 store。用于跨设备状态对齐。 */
async function refetchMe(
hydrateFromServer: (ids: string[]) => void,
): Promise<void> {
try {
const res = await fetch("/api/me", { credentials: "include" });
const data = await res.json();
if (data?.ok && Array.isArray(data.data?.votedArtists)) {
hydrateFromServer(data.data.votedArtists as string[]);
}
} catch {
// 失败容忍 —— 下次页面交互或登录态变化会再同步
}
}
/** /**
* *
* *
@ -34,11 +58,16 @@ interface UseVoteActionResult {
* - toast * - toast
* - toast , * - toast ,
* - 12 toast , * - 12 toast ,
* - 弹窗确认后:本地 store + API(fire-and-forget) * - 弹窗确认后:乐观更新本地 + await
* - + /api/me ()
* - API 200 opts.onVoteSuccess() refresh,
* "票数 +1 / Top12 排位要等 30s"
*/ */
export function useVoteAction(): UseVoteActionResult { export function useVoteAction(opts: UseVoteActionOpts = {}): UseVoteActionResult {
const { status } = useSession(); const { status } = useSession();
const recordVote = useVoteStore((s) => s.vote); const recordVote = useVoteStore((s) => s.vote);
const rollbackVote = useVoteStore((s) => s.rollbackVote);
const hydrateFromServer = useVoteStore((s) => s.hydrateFromServer);
const remaining = useVoteStore(selectRemaining); const remaining = useVoteStore(selectRemaining);
const votedArtists = useVoteStore((s) => s.votedArtists); const votedArtists = useVoteStore((s) => s.votedArtists);
const openLogin = useLoginModalStore((s) => s.show); const openLogin = useLoginModalStore((s) => s.show);
@ -69,7 +98,7 @@ export function useVoteAction(): UseVoteActionResult {
const confirmVote = useCallback( const confirmVote = useCallback(
async (artist: Artist) => { async (artist: Artist) => {
// 1. 本地 store 记录(含已投/已满校验) // 1. 乐观更新本地(含已投/已满兜底校验)
const result = recordVote(artist.id); const result = recordVote(artist.id);
if (!result.ok) { if (!result.ok) {
if (result.reason === "already") { if (result.reason === "already") {
@ -81,30 +110,67 @@ export function useVoteAction(): UseVoteActionResult {
return; return;
} }
// 投票成功:计算投票后状态,判断是否是最后一票 // 乐观成功提示;若服务端拒绝再 dismiss + 报错
const remainingAfter = remaining - 1; const remainingAfter = remaining - 1;
if (remainingAfter === 0) { const successMsg =
toast.success(`完成!你的 12 票已全部投出 ✦`, { duration: 4000 }); remainingAfter === 0
} else { ? `完成!你的 12 票已全部投出 ✦`
toast.success(`已为 ${artist.name} 投票 · 剩余 ${remainingAfter}`); : `已为 ${artist.name} 投票 · 剩余 ${remainingAfter}`;
} const successToastId = toast.success(successMsg, {
duration: remainingAfter === 0 ? 4000 : 2800,
});
setTarget(null); setTarget(null);
// 2. 后台 fire-and-forget 调用真实 API(5 秒超时,失败静默忽略) // 2. await 服务端真实写入。失败 → 回滚 + 用 /api/me 对齐
// 注意:旧 API 仍接收 count 参数,这里固定传 1。后端逻辑 unique 约束
// 等后续提交单独迁移,现阶段前端 store 已保证不会重投。
const ctrl = new AbortController(); const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 5000); const timer = setTimeout(() => ctrl.abort(), 8000);
fetch("/api/vote", { try {
const res = await fetch("/api/vote", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ artistId: artist.id, count: 1 }), body: JSON.stringify({ artistId: artist.id, count: 1 }),
signal: ctrl.signal, signal: ctrl.signal,
}) credentials: "include",
.catch(() => {}) });
.finally(() => clearTimeout(timer)); const data = await res.json().catch(() => null);
if (res.ok && data?.ok) {
// API 200 → 通知调用方(如首页)立即 refresh 排名,不等 30s 轮询
opts.onVoteSuccess?.();
return;
}
// 服务端拒绝 → 回滚乐观更新
rollbackVote(artist.id);
toast.dismiss(successToastId);
const code: string | undefined = data?.error?.code;
if (code === "ALREADY_VOTED") {
toast.error("你已在其他设备为该艺人投过票");
} else if (code === "QUOTA_EXHAUSTED") {
toast.error("你的 12 票已在其他设备投完");
} else if (code === "UNAUTHORIZED") {
toast.error("登录已失效,请重新登录");
} else if (code === "ACTIVITY_OFF") {
toast.error("投票活动暂未开放");
} else if (code === "RATE_LIMITED") {
toast.error("操作太快了,请稍后再试");
} else {
toast.error(data?.error?.message ?? "投票失败,请重试");
}
// 跨设备状态对齐:服务端永远是真相源
await refetchMe(hydrateFromServer);
} catch {
// 网络错误 / 超时 → 回滚 + 不强拉 /api/me(网络问题大概率也拉不到)
rollbackVote(artist.id);
toast.dismiss(successToastId);
toast.error("网络异常,投票未生效");
} finally {
clearTimeout(timer);
}
}, },
[recordVote, remaining], [recordVote, rollbackVote, hydrateFromServer, remaining, opts],
); );
return { return {

View File

@ -30,7 +30,14 @@ interface VoteStore {
* - { ok: true } * - { ok: true }
*/ */
vote: (artistId: string) => { ok: boolean; reason?: "already" | "exhausted" }; vote: (artistId: string) => { ok: boolean; reason?: "already" | "exhausted" };
/** 重置(开发时用 / 测试用) */ /**
* 服务端权威态覆盖本地态:登录后从 /api/me votedArtists ,
* / localStorage
*/
hydrateFromServer: (votedArtists: string[]) => void;
/** 服务端拒绝时回滚单次投票(乐观更新的兜底) */
rollbackVote: (artistId: string) => void;
/** 重置(开发时用 / 测试用 / 登出时清理) */
reset: () => void; reset: () => void;
} }
@ -65,6 +72,37 @@ export const useVoteStore = create<VoteStore>()(
}); });
return { ok: true }; return { ok: true };
}, },
hydrateFromServer: (votedArtists) => {
// 用服务端返回的 votedArtists 重建 artists 票数(回放 mock baseline)
const counts = new Map<string, number>();
for (const id of votedArtists) {
counts.set(id, (counts.get(id) ?? 0) + 1);
}
const rebuilt = INITIAL_ARTISTS.map((a) => ({
...a,
votes: a.votes + (counts.get(a.id) ?? 0),
}));
set({
artists: rank(rebuilt),
votedArtists,
});
},
rollbackVote: (artistId) => {
const state = get();
const idx = state.votedArtists.lastIndexOf(artistId);
if (idx === -1) return;
const nextVoted = [
...state.votedArtists.slice(0, idx),
...state.votedArtists.slice(idx + 1),
];
const updated = state.artists.map((a) =>
a.id === artistId ? { ...a, votes: Math.max(0, a.votes - 1) } : a,
);
set({
artists: rank(updated),
votedArtists: nextVoted,
});
},
reset: () => reset: () =>
set({ set({
artists: INITIAL_ARTISTS, artists: INITIAL_ARTISTS,
@ -75,7 +113,8 @@ export const useVoteStore = create<VoteStore>()(
name: "cyber-star-vote", name: "cyber-star-vote",
// 仅持久化 votedArtists —— artists 票数/排名是派生数据, // 仅持久化 votedArtists —— artists 票数/排名是派生数据,
// 刷新后重新从初始数据 + votedArtists 重建。 // 刷新后重新从初始数据 + votedArtists 重建。
// 注意:当前 mock 阶段 artists 只反映本地投票,不同步服务端 —— 等后端接入再调整。 // localStorage 仅作本设备缓存,加速首屏渲染;真相源是 /api/me
// —— useSyncMe 会在登录/切换用户后用服务端数据覆盖本地。
partialize: (state) => ({ votedArtists: state.votedArtists }), partialize: (state) => ({ votedArtists: state.votedArtists }),
// rehydrate 时把 votedArtists 数据"回放"到 artists 票数上,保持视图一致 // rehydrate 时把 votedArtists 数据"回放"到 artists 票数上,保持视图一致
onRehydrateStorage: () => (state) => { onRehydrateStorage: () => (state) => {

View File

@ -14,7 +14,7 @@
* , TTL invalidate * , TTL invalidate
*/ */
const TOS_BASE = (process.env.NEXT_PUBLIC_TOS_DOMAIN ?? "").replace(/\/+$/, ""); const TOS_BASE = (process.env.NEXT_PUBLIC_TOS_DOMAIN ?? "").replace(/\/+$/, "");
const TOS_VERSION = "7"; const TOS_VERSION = "8";
export function tosUrl(path: string): string { export function tosUrl(path: string): string {
const clean = path.replace(/^\/+/, ""); const clean = path.replace(/^\/+/, "");