5b89de656e
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 25s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 7s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 26s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 11s
ci-pangolin / Go — build + test (pull_request) Successful in 15s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 26s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Successful in 10s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Successful in 4m36s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Successful in 20s
ci-pangolin / Lint — shellcheck (pull_request) Failing after 10m3s
Deploy Site / deploy-site (push) Failing after 13m20s
ci-pangolin / Flutter — analyze + test (pull_request) Failing after 14m42s
ci-pangolin / OpenAPI Sync Check (pull_request) Failing after 14m52s
用户中心: - 统一浅色:theme.tsx 无偏好时不再跟随系统 prefers-color-scheme:dark(登录页变 黑真因),恒浅色;保留手动切换。 - 登录回跳:UserCenter 加 safeRedirect 白名单,登录成功按 ?redirect= 回来源页 (官网带 /,登录后回主页),无则进 overview。 - logo locale:新增 i18n brandName(zh 穿山甲/其余 Pangolin),Login/UserCenter /Subscription 三处引用。 - 存 pg_uc_email(getMe 时)/ clearSession 删,供官网读用户名。 官网(全走 CSS class 合 CSP,无内联 style): - logo locale:i18n nav.brand(zh 穿山甲/其余 Pangolin),Header+Footer。 - 登录态头部显示用户名(读同源 pg_uc_email)+ 下拉菜单(进入用户中心/切换用户/ 退出登录,复用 .langmenu 风格);未登录 Log in 带 ?redirect=/ 回跳。 - Docs 四卡片补真内容页(en+zh:quickstart/faq/protocol/privacy + Doc.astro 布局),卡片改回 <a href>;Protocol 改正 sing-box+REALITY(去 WireGuard)。 验证:同源闸绿 · 两端 build 过 · redline 0 · Header 零内联 style · 无 WireGuard。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
188 lines
7.4 KiB
React
188 lines
7.4 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';
|
||
|
||
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)→ 显示用户名下拉。
|
||
useEffect(() => {
|
||
try {
|
||
setLoggedIn(!!localStorage.getItem('pg_uc_refresh'));
|
||
setEmail(localStorage.getItem('pg_uc_email') || '');
|
||
} catch {
|
||
setLoggedIn(false);
|
||
}
|
||
}, []);
|
||
|
||
// 清登录态并跳转(切换用户 = 回登录页;退出 = 回主页)。
|
||
const clearSession = () => {
|
||
try {
|
||
localStorage.removeItem('pg_uc_refresh');
|
||
localStorage.removeItem('pg_uc_email');
|
||
} catch { /* ignore */ }
|
||
};
|
||
const onSwitch = () => { clearSession(); window.location.href = loginHref; };
|
||
const onLogout = () => { clearSession(); 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>
|
||
);
|
||
}
|