Compare commits

..

No commits in common. "main" and "v0.3.3" have entirely different histories.
main ... v0.3.3

21 changed files with 80 additions and 282 deletions

View File

@ -20,38 +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 信息**

View File

@ -1,6 +1,6 @@
{
"name": "cyber-star",
"version": "0.3.4",
"version": "0.3.3",
"private": true,
"scripts": {
"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

View File

@ -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" });

View File

@ -14,7 +14,7 @@ const Body = z.object({
/**
* POST /api/auth/send-otp
* · 60s / IP 5 100
* · 60s / IP 5 5
*/
export async function POST(req: NextRequest) {
try {
@ -31,7 +31,7 @@ export async function POST(req: NextRequest) {
}
const ip = await getClientIp();
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();
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

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

View File

@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { signIn } from "next-auth/react";
import { Phone, KeyRound, Loader2 } from "lucide-react";
import Logo from "@/components/Logo";
import Button from "@/components/ui/Button";
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="w-full max-w-md">
{/* Logo */}
<div className="flex flex-col items-center mb-8">
<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" }}
/>
<div className="text-center mb-8">
<Logo size="lg" href={null} />
<p className="font-label text-[10px] tracking-[0.4em] uppercase text-purple-300/80 mt-3">
Sign in to Vote
</p>

View File

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

View File

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

View File

@ -1,10 +1,13 @@
import Logo from "./Logo";
export default function Footer() {
const year = new Date().getFullYear();
return (
<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]">
© {year} C . S . G · All Rights Reserved
© {year} CYBER STAR · All Rights Reserved
</p>
</div>
</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">
{/* Eyebrow 左上 · 紧贴导航下方 */}
<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">
C . S . G
<p className="font-label text-[10px] sm:text-xs tracking-[0.4em] uppercase text-purple-200/90">
Top 12 · Cyber Star Debut Survival
</p>
</div>

View File

@ -60,10 +60,10 @@ export default function HeroVoteProgress({ className }: { className?: string })
</span>
)}
{/* 12 格点亮式进度条 —— 窄屏隐藏,避免与左侧 Eyebrow 横向挤撞;>= md 才显示 */}
{/* 12 格点亮式进度条 */}
<span
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) => {
const lit = i < filled;

View File

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

View File

@ -19,9 +19,10 @@ const NAV_ITEMS: Array<{
interface NavLinksProps {
className?: string;
mobile?: boolean;
}
export default function NavLinks({ className }: NavLinksProps) {
export default function NavLinks({ className, mobile = false }: NavLinksProps) {
const pathname = usePathname();
const { status } = useSession();
const openLogin = useLoginModalStore((s) => s.show);
@ -42,11 +43,39 @@ export default function NavLinks({ className }: NavLinksProps) {
}
};
if (mobile) {
return (
<ul
className={cn(
"flex items-center gap-6 px-6 py-2.5 text-[13px] 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(
// 单一布局:窄屏 gap-5 text-[13px],sm 以上 gap-8 text-sm,装饰一致
"flex items-center gap-5 sm:gap-8 text-[13px] sm:text-sm tracking-[0.1em] whitespace-nowrap",
"items-center gap-8 text-sm tracking-[0.1em]",
className,
)}
>

View File

@ -2,7 +2,6 @@
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import Logo from "./Logo";
import NavLinks from "./NavLinks";
import SearchTrigger from "./SearchTrigger";
import AuthMenu from "./auth/AuthMenu";
@ -28,7 +27,6 @@ export default function Navigation() {
// nav 关掉自己的玻璃,避免双重 backdrop-filter 在 y=80 处出现拼接线。
const filterStuck = useUIStore((s) => s.filterStuck);
const glassOff = isTransparent || filterStuck;
const showLogo = pathname !== "/";
// 维护一个站内导航计数器(per-tab),供 FloatingBackButton 判断 router.back() 是否安全
useEffect(() => {
@ -82,26 +80,28 @@ export default function Navigation() {
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">
{showLogo && (
<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 />
<nav className="relative max-w-[1500px] mx-auto h-20 px-4 sm:px-6 lg:px-8 flex items-center gap-8">
{/* 左侧:首页 / 排行榜 / 我的(logo 已移除) */}
<NavLinks className="hidden md:flex" />
{/* 右侧:搜索 + 今日余票 + 登录/注册 (或 头像+下拉) */}
<div className="ml-auto flex items-center gap-2 sm:gap-3">
<div className="ml-auto flex items-center gap-3">
<SearchTrigger />
<RemainingVotesBadge />
<AuthMenu />
</div>
</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>
);
}

View File

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

View File

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

View File

@ -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]);
}