65ce2664ee
ci-pangolin / Lint — shellcheck (pull_request) Successful in 13s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 25s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 23s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 34s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 21s
ci-pangolin / Flutter — analyze + test (pull_request) Successful in 34s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 8s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 8s
ci-pangolin / Go — build + test (pull_request) Failing after 13s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 10s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 4m34s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 19s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
180 lines
7.3 KiB
React
180 lines
7.3 KiB
React
/**
|
||
* Header.jsx — 交互式顶栏(React island,@astrojs/react 渲染 + 水合)。
|
||
* 迁自 design/ui_kits/website/index.html 的 <header> + site.js 的菜单/滚动毛玻璃逻辑。
|
||
* 语言切换由原型的 JS 文本替换改为「路由跳转」(zh=/, en=/en/),单显不并排(铁律 6)。
|
||
*/
|
||
import { useEffect, useRef, useState } from 'react';
|
||
import { Download, Menu } from 'lucide-react';
|
||
import { SITE } from '../config/site';
|
||
import { isLoggedIn, getStoredEmail, clearStoredSession } from '../lib/authState';
|
||
|
||
function Mark() {
|
||
return (
|
||
<svg width="30" height="30" viewBox="0 0 96 96" fill="none" aria-hidden="true">
|
||
<g transform="translate(96,0) scale(-1,1)">
|
||
<path
|
||
d="M16 59 Q17 51 26 50 Q34 31 45 30 Q55 30 62 37 Q69 41 75 45 Q85 49 89 44 Q92 50 84 52 Q75 53 67 53 Q66 62 60 62 L56 62 Q54 55 49 55 Q47 62 37 62 L33 62 Q31 56 26 56 Q20 59 16 59 Z"
|
||
fill="#B96A3D"
|
||
/>
|
||
<g stroke="#FAF3ED" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" fill="none" opacity="0.85">
|
||
<path d="M26 50 Q34 43 42 50" />
|
||
<path d="M36 48 Q44 41 52 48" />
|
||
<path d="M46 48 Q54 41 62 49" />
|
||
</g>
|
||
<circle cx="23" cy="55" r="2.2" fill="#FAF3ED" />
|
||
</g>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
export default function Header({ lang = 'zh', t = {} }) {
|
||
const [open, setOpen] = useState(false);
|
||
const [scrolled, setScrolled] = useState(false);
|
||
const [langOpen, setLangOpen] = useState(false);
|
||
const [userOpen, setUserOpen] = useState(false);
|
||
const [loggedIn, setLoggedIn] = useState(false);
|
||
const [email, setEmail] = useState('');
|
||
const langRef = useRef(null);
|
||
const userRef = useRef(null);
|
||
|
||
// 登录后回跳主页:用户中心带 ?redirect=/(配合 usercenter 登录成功后回跳)。
|
||
const loginHref = `${SITE.usercenter}?redirect=/`;
|
||
// 用户名截断显示:优先邮箱 @ 前部分,缺失时回退通用词。
|
||
const displayName = (email && email.split('@')[0]) || 'Account';
|
||
|
||
// 与用户中心同源:登录后 localStorage 存 pg_uc_refresh(+ pg_uc_email)→ 显示用户名下拉。
|
||
// 判断逻辑单一实现见 lib/authState.js(AnnouncementBar/SignupForm/RegisterCard/InviteCard 同源复用)。
|
||
useEffect(() => {
|
||
setLoggedIn(isLoggedIn());
|
||
setEmail(getStoredEmail());
|
||
}, []);
|
||
|
||
// 清登录态并跳转(切换用户 = 回登录页;退出 = 回主页)。
|
||
const onSwitch = () => { clearStoredSession(); window.location.href = loginHref; };
|
||
const onLogout = () => { clearStoredSession(); window.location.href = '/'; };
|
||
|
||
useEffect(() => {
|
||
if (!langOpen) return;
|
||
const onDoc = (e) => { if (langRef.current && !langRef.current.contains(e.target)) setLangOpen(false); };
|
||
const onKey = (e) => { if (e.key === 'Escape') setLangOpen(false); };
|
||
document.addEventListener('mousedown', onDoc);
|
||
document.addEventListener('keydown', onKey);
|
||
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };
|
||
}, [langOpen]);
|
||
|
||
useEffect(() => {
|
||
if (!userOpen) return;
|
||
const onDoc = (e) => { if (userRef.current && !userRef.current.contains(e.target)) setUserOpen(false); };
|
||
const onKey = (e) => { if (e.key === 'Escape') setUserOpen(false); };
|
||
document.addEventListener('mousedown', onDoc);
|
||
document.addEventListener('keydown', onKey);
|
||
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };
|
||
}, [userOpen]);
|
||
|
||
useEffect(() => {
|
||
const onScroll = () => setScrolled(window.scrollY > 8);
|
||
onScroll();
|
||
window.addEventListener('scroll', onScroll, { passive: true });
|
||
return () => window.removeEventListener('scroll', onScroll);
|
||
}, []);
|
||
|
||
const nav = [
|
||
['#product', t.product],
|
||
['#pricing', t.pricing],
|
||
['#download', t.download],
|
||
['#docs', t.docs],
|
||
];
|
||
|
||
// 6 语言:默认 en=/,其余 /<lang>/。段控横排会挤,用原生下拉。
|
||
const langs = [
|
||
['en', 'English'], ['zh', '中文'], ['ja', '日本語'],
|
||
['ko', '한국어'], ['ru', 'Русский'], ['es', 'Español'],
|
||
];
|
||
const langHref = (l) => (l === 'en' ? '/' : `/${l}/`);
|
||
|
||
return (
|
||
<header class={`hdr${scrolled ? ' scrolled' : ''}`}>
|
||
<div class="wrap row">
|
||
<a class="brand" href="#top">
|
||
<Mark />
|
||
<span class="nm">{t.brand || 'Pangolin'}</span>
|
||
</a>
|
||
<nav class="nav">
|
||
{nav.map(([href, label]) => (
|
||
<a key={href} href={href}>{label}</a>
|
||
))}
|
||
</nav>
|
||
<div class="right">
|
||
<div ref={langRef} class="langwrap">
|
||
<button
|
||
type="button"
|
||
class="langsel"
|
||
onClick={() => setLangOpen((o) => !o)}
|
||
aria-haspopup="listbox"
|
||
aria-expanded={langOpen}
|
||
aria-label="Language"
|
||
>
|
||
<span>{(langs.find(([c]) => c === lang) || ['', 'English'])[1]}</span>
|
||
<svg class="caret" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
|
||
</svg>
|
||
</button>
|
||
{langOpen && (
|
||
<div class="langmenu" role="listbox">
|
||
{langs.map(([code, label]) => {
|
||
const on = code === lang;
|
||
return (
|
||
<a key={code} role="option" aria-selected={on} href={langHref(code)}>
|
||
{label}
|
||
</a>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{loggedIn ? (
|
||
<div ref={userRef} class="langwrap usermenu">
|
||
<button
|
||
type="button"
|
||
class="langsel userbtn"
|
||
onClick={() => setUserOpen((o) => !o)}
|
||
aria-haspopup="menu"
|
||
aria-expanded={userOpen}
|
||
>
|
||
<span class="uname">{displayName}</span>
|
||
<svg class="caret" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
|
||
</svg>
|
||
</button>
|
||
{userOpen && (
|
||
<div class="langmenu" role="menu">
|
||
<a role="menuitem" href={SITE.usercenter}>{t.mcenter}</a>
|
||
<button role="menuitem" type="button" onClick={onSwitch}>{t.mswitch}</button>
|
||
<button role="menuitem" type="button" onClick={onLogout}>{t.mlogout}</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<a class="linklogin" href={loginHref}>{t.login}</a>
|
||
)}
|
||
<a class="btn btn-primary" href="#download">
|
||
<Download />
|
||
<span>{t.get}</span>
|
||
</a>
|
||
<button class="menu-btn" onClick={() => setOpen((v) => !v)} aria-label="menu" aria-expanded={open}>
|
||
<Menu />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class={`mnav${open ? ' open' : ''}`} id="mnav">
|
||
{nav.map(([href, label]) => (
|
||
<a key={href} href={href} onClick={() => setOpen(false)}>{label}</a>
|
||
))}
|
||
<a class="btn btn-primary" href="#signup" onClick={() => setOpen(false)}>
|
||
<span>{t.suBtn}</span>
|
||
</a>
|
||
</div>
|
||
</header>
|
||
);
|
||
}
|