feat: 跨设备同步 + Logo v3 + 导航合并 + 窄屏适配 (v0.3.4)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m44s
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>
This commit is contained in:
parent
5c009f38cd
commit
8b99c2f091
32
CHANGELOG.md
32
CHANGELOG.md
@ -20,6 +20,38 @@ 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 信息**
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cyber-star",
|
||||
"version": "0.3.3",
|
||||
"version": "0.3.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
BIN
public/logo-v3.png
Normal file
BIN
public/logo-v3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
86
scripts/screenshot-narrow.mjs
Normal file
86
scripts/screenshot-narrow.mjs
Normal 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" });
|
||||
@ -5,7 +5,6 @@ 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";
|
||||
|
||||
@ -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="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<Logo size="lg" href={null} />
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<img
|
||||
src="/logo-v3.png"
|
||||
alt="CYBER STAR"
|
||||
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">
|
||||
Sign in to Vote
|
||||
</p>
|
||||
|
||||
@ -1,11 +1,8 @@
|
||||
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 flex-col sm:flex-row items-center justify-center gap-3 sm:gap-6 text-center">
|
||||
<Logo size="sm" href={null} />
|
||||
<div className="max-w-[1500px] mx-auto px-6 sm:px-8 h-16 flex items-center justify-center text-center">
|
||||
<p className="text-[11px] text-white/35 tracking-[0.05em]">
|
||||
© {year} CYBER STAR · All Rights Reserved
|
||||
</p>
|
||||
|
||||
@ -60,10 +60,10 @@ export default function HeroVoteProgress({ className }: { className?: string })
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 12 格点亮式进度条 */}
|
||||
{/* 12 格点亮式进度条 —— 窄屏隐藏,避免与左侧 Eyebrow 横向挤撞;>= md 才显示 */}
|
||||
<span
|
||||
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) => {
|
||||
const lit = i < filled;
|
||||
|
||||
@ -19,10 +19,9 @@ const NAV_ITEMS: Array<{
|
||||
|
||||
interface NavLinksProps {
|
||||
className?: string;
|
||||
mobile?: boolean;
|
||||
}
|
||||
|
||||
export default function NavLinks({ className, mobile = false }: NavLinksProps) {
|
||||
export default function NavLinks({ className }: NavLinksProps) {
|
||||
const pathname = usePathname();
|
||||
const { status } = useSession();
|
||||
const openLogin = useLoginModalStore((s) => s.show);
|
||||
@ -43,39 +42,11 @@ export default function NavLinks({ className, mobile = false }: 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(
|
||||
"items-center gap-8 text-sm tracking-[0.1em]",
|
||||
// 单一布局:窄屏 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",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@ -80,28 +80,17 @@ 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-8">
|
||||
{/* 左侧:首页 / 排行榜 / 我的(logo 已移除) */}
|
||||
<NavLinks className="hidden md:flex" />
|
||||
<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 已移除,所有屏宽都显示在第一行) */}
|
||||
<NavLinks />
|
||||
|
||||
{/* 右侧:搜索 + 今日余票 + 登录/注册 (或 头像+下拉) */}
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<div className="ml-auto flex items-center gap-2 sm: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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,15 +3,27 @@
|
||||
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
|
||||
|
||||
@ -6,7 +6,6 @@ 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 {
|
||||
@ -172,10 +171,15 @@ export default function LoginModal({ open, onClose, onSuccess }: LoginModalProps
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<div className="text-center mb-6">
|
||||
<div className="inline-block">
|
||||
<Logo size="md" href={null} />
|
||||
</div>
|
||||
<div className="flex flex-col items-center mb-6">
|
||||
<img
|
||||
src="/logo-v3.png"
|
||||
alt="CYBER STAR"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
className="block select-none h-16 sm:h-20 w-auto"
|
||||
style={{ background: "transparent" }}
|
||||
/>
|
||||
<p
|
||||
id="login-modal-title"
|
||||
className="font-label text-[10px] tracking-[0.4em] uppercase text-purple-300/80 mt-3"
|
||||
|
||||
67
src/hooks/useSyncMe.ts
Normal file
67
src/hooks/useSyncMe.ts
Normal file
@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useVoteStore } from "@/lib/store";
|
||||
|
||||
/**
|
||||
* 把服务端 /api/me 的真相态同步进本地 vote store。
|
||||
*
|
||||
* - status === "authenticated" → 拉一次 /api/me,用 votedArtists 覆盖本地
|
||||
* - status === "unauthenticated" → 清本地(避免上一个用户的票残留给下一个登录者)
|
||||
* - 切换用户(uid 变化) → 重新拉一次
|
||||
*
|
||||
* 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((r) => r.json())
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res?.ok && Array.isArray(res.data?.votedArtists)) {
|
||||
hydrateFromServer(res.data.votedArtists as string[]);
|
||||
lastSyncedUidRef.current = uid;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 网络失败容忍 —— 下次 status 变化或手动刷新会再试
|
||||
})
|
||||
.finally(() => clearTimeout(timer));
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
ctrl.abort();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [status, uid, hydrateFromServer, reset]);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user