Compare commits
No commits in common. "main" and "v0.3.2" have entirely different histories.
64
CHANGELOG.md
64
CHANGELOG.md
@ -20,70 +20,6 @@ CYBER STAR(虚拟明星 Top 12 出道选拔)更新日志。新条目写在最上
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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 出道位空着,排行榜却有数据
|
## v0.3.2 · 2026-05-15 · 修复:首页 Top12 出道位空着,排行榜却有数据
|
||||||
|
|
||||||
**Commit 信息**
|
**Commit 信息**
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cyber-star",
|
"name": "cyber-star",
|
||||||
"version": "0.3.4",
|
"version": "0.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 MiB |
@ -1,86 +0,0 @@
|
|||||||
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" });
|
|
||||||
@ -14,7 +14,7 @@ const Body = z.object({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/auth/send-otp
|
* POST /api/auth/send-otp
|
||||||
* 发送短信验证码 · 单手机号 60s 限频 / 单 IP 5 分钟 100 次
|
* 发送短信验证码 · 单手机号 60s 限频 / 单 IP 5 分钟 5 次
|
||||||
*/
|
*/
|
||||||
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, 100);
|
const ipRl = await rateLimit(`otp:ip:${ip}`, 300, 5);
|
||||||
if (!ipRl.allowed) return ERR.RATE_LIMITED();
|
if (!ipRl.allowed) return ERR.RATE_LIMITED();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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: "艺人不存在 · 银河初星计划 C . S . G" };
|
if (!artist) return { title: "艺人不存在 · CYBER STAR" };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `${artist.name} · ${artist.enName} · 银河初星计划 C . S . G`,
|
title: `${artist.name} · ${artist.enName} · CYBER STAR`,
|
||||||
description: artist.bio.slice(0, 120),
|
description: artist.bio.slice(0, 120),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 25 KiB |
@ -33,27 +33,15 @@ const inter = Inter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "银河初星计划 C . S . G",
|
title: "CYBER ✦ STAR · 虚拟偶像 Top12 出道企划",
|
||||||
description:
|
description:
|
||||||
"36 位虚拟偶像候选人,由你投票决出最终出道 Top12。银河初星计划 C . S . G。",
|
"36 位虚拟偶像候选人,由你投票决出最终出道 Top12。Cyber Star · Virtual Idol Debut Project.",
|
||||||
keywords: [
|
keywords: ["虚拟偶像", "出道", "投票", "Top12", "Cyber Star", "Virtual Idol"],
|
||||||
"银河初星计划",
|
|
||||||
"C . S . G",
|
|
||||||
"虚拟偶像",
|
|
||||||
"出道",
|
|
||||||
"投票",
|
|
||||||
"Top12",
|
|
||||||
"Virtual Idol",
|
|
||||||
],
|
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "银河初星计划 C . S . G",
|
title: "CYBER ✦ STAR",
|
||||||
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({
|
||||||
|
|||||||
@ -5,6 +5,7 @@ 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";
|
||||||
|
|
||||||
@ -91,15 +92,8 @@ 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="flex flex-col items-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<img
|
<Logo size="lg" href={null} />
|
||||||
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>
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Suspense } from "react";
|
|||||||
import LoginForm from "./LoginForm";
|
import LoginForm from "./LoginForm";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "登录 · 银河初星计划 C . S . G",
|
title: "登录 · CYBER STAR",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
|||||||
@ -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: "个人中心 · 银河初星计划 C . S . G",
|
title: "个人中心 · CYBER STAR",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function MePage() {
|
export default async function MePage() {
|
||||||
|
|||||||
@ -19,14 +19,12 @@ import type { Artist } from "@/types/artist";
|
|||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const storeArtists = useVoteStore((s) => s.artists);
|
const storeArtists = useVoteStore((s) => s.artists);
|
||||||
|
const { target, remaining, totalQuota, openVote, closeVote, confirmVote } =
|
||||||
|
useVoteAction();
|
||||||
|
|
||||||
// 30s 轮询 /api/ranking 拿服务端真实票数
|
// 30s 轮询 /api/ranking 拿服务端真实票数
|
||||||
const live = useRanking({ pollInterval: 30_000 });
|
const live = useRanking({ pollInterval: 30_000 });
|
||||||
|
|
||||||
// 投票成功后立即 refresh 排名,不等下次轮询(解决"票数 +1 / Top12 排位延迟"问题)
|
|
||||||
const { target, remaining, totalQuota, openVote, closeVote, confirmVote } =
|
|
||||||
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");
|
||||||
const [filterStuck, setFilterStuck] = useState(false);
|
const [filterStuck, setFilterStuck] = useState(false);
|
||||||
|
|||||||
@ -14,13 +14,11 @@ 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[]>(() => {
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
|
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 items-center justify-center text-center">
|
<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">
|
||||||
|
<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} 银河初星计划 C . S . G · All Rights Reserved
|
© {year} CYBER STAR · All Rights Reserved
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@ -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.28em] uppercase text-purple-200/90">
|
<p className="font-label text-[10px] sm:text-xs tracking-[0.4em] uppercase text-purple-200/90">
|
||||||
银河初星计划 C . S . G
|
Top 12 · Cyber Star Debut Survival
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -60,10 +60,10 @@ export default function HeroVoteProgress({ className }: { className?: string })
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 12 格点亮式进度条 —— 窄屏隐藏,避免与左侧 Eyebrow 横向挤撞;>= md 才显示 */}
|
{/* 12 格点亮式进度条 */}
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="hidden md:inline-flex items-center gap-[3px] ml-0.5"
|
className="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;
|
||||||
|
|||||||
@ -8,7 +8,7 @@ interface LogoProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 高度由 size 控制,宽度按 logo-v4.png 实际比例自适应
|
// 高度由 size 控制,宽度按 logo.png 实际比例(约 5.41:1,单行 CYBER STAR + 星环)自适应
|
||||||
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=4 缓存破坏:logo 改版时 +1,浏览器立刻拉新版而不读老缓存。
|
// ?v=2 缓存破坏:logo 改版时 +1,浏览器立刻拉新版而不读老缓存。
|
||||||
const inner = (
|
const inner = (
|
||||||
<img
|
<img
|
||||||
src="/logo-v4.png?v=4"
|
src="/logo.png?v=3"
|
||||||
alt="银河初星计划 C . S . G"
|
alt="CYBER STAR"
|
||||||
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="银河初星计划 C . S . G · 首页"
|
aria-label="CYBER STAR · 首页"
|
||||||
style={{ background: "transparent" }}
|
style={{ background: "transparent" }}
|
||||||
>
|
>
|
||||||
{inner}
|
{inner}
|
||||||
|
|||||||
@ -19,9 +19,10 @@ const NAV_ITEMS: Array<{
|
|||||||
|
|
||||||
interface NavLinksProps {
|
interface NavLinksProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
mobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NavLinks({ className }: NavLinksProps) {
|
export default function NavLinks({ className, mobile = false }: 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);
|
||||||
@ -42,11 +43,39 @@ export default function NavLinks({ className }: NavLinksProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (mobile) {
|
||||||
return (
|
return (
|
||||||
<ul
|
<ul
|
||||||
className={cn(
|
className={cn(
|
||||||
// 单一布局:窄屏 gap-5 text-[13px],sm 以上 gap-8 text-sm,装饰一致
|
"flex items-center gap-6 px-6 py-2.5 text-[13px] tracking-[0.1em] whitespace-nowrap",
|
||||||
"flex items-center gap-5 sm:gap-8 text-[13px] sm:text-sm tracking-[0.1em] whitespace-nowrap",
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{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,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
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";
|
||||||
@ -28,7 +27,6 @@ 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(() => {
|
||||||
@ -82,26 +80,28 @@ 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-4 sm:gap-8">
|
<nav className="relative max-w-[1500px] mx-auto h-20 px-4 sm:px-6 lg:px-8 flex items-center gap-8">
|
||||||
{showLogo && (
|
{/* 左侧:首页 / 排行榜 / 我的(logo 已移除) */}
|
||||||
<div className="hidden sm:flex shrink-0 items-center">
|
<NavLinks className="hidden md:flex" />
|
||||||
<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-2 sm:gap-3">
|
<div className="ml-auto flex items-center 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,27 +3,15 @@
|
|||||||
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
|
||||||
|
|||||||
@ -6,6 +6,7 @@ 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 {
|
||||||
@ -171,15 +172,10 @@ export default function LoginModal({ open, onClose, onSuccess }: LoginModalProps
|
|||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex flex-col items-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<img
|
<div className="inline-block">
|
||||||
src="/logo-v4.png?v=4"
|
<Logo size="md" href={null} />
|
||||||
alt="银河初星计划 C . S . G"
|
</div>
|
||||||
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"
|
||||||
|
|||||||
@ -1,82 +0,0 @@
|
|||||||
"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]);
|
|
||||||
}
|
|
||||||
@ -26,30 +26,6 @@ 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 {
|
|
||||||
// 失败容忍 —— 下次页面交互或登录态变化会再同步
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 投票交互统一入口。
|
* 投票交互统一入口。
|
||||||
*
|
*
|
||||||
@ -58,16 +34,11 @@ async function refetchMe(
|
|||||||
* - 未登录 → toast 提示并跳登录弹窗
|
* - 未登录 → toast 提示并跳登录弹窗
|
||||||
* - 已投过该艺人 → toast 提示,不打开弹窗
|
* - 已投过该艺人 → toast 提示,不打开弹窗
|
||||||
* - 12 票已用完 → toast 提示,不打开弹窗
|
* - 12 票已用完 → toast 提示,不打开弹窗
|
||||||
* - 弹窗确认后:乐观更新本地 + await 服务端写入
|
* - 弹窗确认后:本地 store 立即记录 + 调用后端 API(fire-and-forget)
|
||||||
* - 服务端拒绝 → 回滚本地 + 用 /api/me 重新对齐(跨设备真相源)
|
|
||||||
* - API 200 后触发 opts.onVoteSuccess() —— 让排名相关页面立即 refresh,
|
|
||||||
* 解决"票数 +1 / Top12 排位要等 30s"的体感问题
|
|
||||||
*/
|
*/
|
||||||
export function useVoteAction(opts: UseVoteActionOpts = {}): UseVoteActionResult {
|
export function useVoteAction(): 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);
|
||||||
@ -98,7 +69,7 @@ export function useVoteAction(opts: UseVoteActionOpts = {}): UseVoteActionResult
|
|||||||
|
|
||||||
const confirmVote = useCallback(
|
const confirmVote = useCallback(
|
||||||
async (artist: Artist) => {
|
async (artist: Artist) => {
|
||||||
// 1. 乐观更新本地(含已投/已满兜底校验)
|
// 1. 本地 store 记录(包含已投/已满校验)
|
||||||
const result = recordVote(artist.id);
|
const result = recordVote(artist.id);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
if (result.reason === "already") {
|
if (result.reason === "already") {
|
||||||
@ -110,67 +81,30 @@ export function useVoteAction(opts: UseVoteActionOpts = {}): UseVoteActionResult
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 乐观成功提示;若服务端拒绝再 dismiss + 报错
|
// 投票成功:计算投票后状态,判断是否是最后一票
|
||||||
const remainingAfter = remaining - 1;
|
const remainingAfter = remaining - 1;
|
||||||
const successMsg =
|
if (remainingAfter === 0) {
|
||||||
remainingAfter === 0
|
toast.success(`完成!你的 12 票已全部投出 ✦`, { duration: 4000 });
|
||||||
? `完成!你的 12 票已全部投出 ✦`
|
} else {
|
||||||
: `已为 ${artist.name} 投票 · 剩余 ${remainingAfter} 票`;
|
toast.success(`已为 ${artist.name} 投票 · 剩余 ${remainingAfter} 票`);
|
||||||
const successToastId = toast.success(successMsg, {
|
}
|
||||||
duration: remainingAfter === 0 ? 4000 : 2800,
|
|
||||||
});
|
|
||||||
setTarget(null);
|
setTarget(null);
|
||||||
|
|
||||||
// 2. await 服务端真实写入。失败 → 回滚 + 用 /api/me 对齐
|
// 2. 后台 fire-and-forget 调用真实 API(5 秒超时,失败静默忽略)
|
||||||
|
// 注意:旧 API 仍接收 count 参数,这里固定传 1。后端逻辑 unique 约束
|
||||||
|
// 等后续提交单独迁移,现阶段前端 store 已保证不会重投。
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const timer = setTimeout(() => ctrl.abort(), 8000);
|
const timer = setTimeout(() => ctrl.abort(), 5000);
|
||||||
try {
|
fetch("/api/vote", {
|
||||||
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(() => {})
|
||||||
const data = await res.json().catch(() => null);
|
.finally(() => clearTimeout(timer));
|
||||||
|
|
||||||
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, rollbackVote, hydrateFromServer, remaining, opts],
|
[recordVote, remaining],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -30,14 +30,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,37 +65,6 @@ 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,
|
||||||
@ -113,8 +75,7 @@ export const useVoteStore = create<VoteStore>()(
|
|||||||
name: "cyber-star-vote",
|
name: "cyber-star-vote",
|
||||||
// 仅持久化 votedArtists —— artists 票数/排名是派生数据,
|
// 仅持久化 votedArtists —— artists 票数/排名是派生数据,
|
||||||
// 刷新后重新从初始数据 + votedArtists 重建。
|
// 刷新后重新从初始数据 + votedArtists 重建。
|
||||||
// localStorage 仅作本设备缓存,加速首屏渲染;真相源是 /api/me
|
// 注意:当前 mock 阶段 artists 只反映本地投票,不同步服务端 —— 等后端接入再调整。
|
||||||
// —— useSyncMe 会在登录/切换用户后用服务端数据覆盖本地。
|
|
||||||
partialize: (state) => ({ votedArtists: state.votedArtists }),
|
partialize: (state) => ({ votedArtists: state.votedArtists }),
|
||||||
// rehydrate 时把 votedArtists 数据"回放"到 artists 票数上,保持视图一致
|
// rehydrate 时把 votedArtists 数据"回放"到 artists 票数上,保持视图一致
|
||||||
onRehydrateStorage: () => (state) => {
|
onRehydrateStorage: () => (state) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user