Files
pangolin/web/website/src/components/Header.jsx
T
wangjia 386e0bce90
Deploy Site / deploy-site (push) Successful in 4m31s
fix(web): 语言下拉换自定义菜单(原生 select 弹层被系统定位/漂移)
原生 <select> 的选项弹层由 OS 定位(macOS 锚在选中项上、整体偏移=漂移),CSS 控
不了。改成自控下拉:按钮(复用药丸样式)+ 绝对定位菜单(top:100%,right:0)+ 点外/
Esc 关闭 + 选中高亮。用户中心 LangSeg 与官网 Header 同款;官网选项用 <a href> 导航
(SEO 友好)、用户中心用 setLang。两端 build 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 22:42:00 +08:00

129 lines
5.5 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 langRef = useRef(null);
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(() => {
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">穿山甲</span>
</a>
<nav class="nav">
{nav.map(([href, label]) => (
<a key={href} href={href}>{label}</a>
))}
</nav>
<div class="right">
<div ref={langRef} style={{ position: 'relative', display: 'inline-block' }}>
<button
type="button"
class="langsel"
onClick={() => setLangOpen((o) => !o)}
aria-haspopup="listbox"
aria-expanded={langOpen}
aria-label="Language"
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
>
<span>{(langs.find(([c]) => c === lang) || ['', 'English'])[1]}</span>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" aria-hidden="true"
style={{ transform: langOpen ? 'rotate(180deg)' : 'none', transition: 'transform 140ms' }}>
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{langOpen && (
<div role="listbox" style={{ position: 'absolute', top: 'calc(100% + 6px)', right: 0, minWidth: 132, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 12, boxShadow: '0 10px 30px rgba(20, 12, 6, 0.22)', padding: 5, zIndex: 60, display: 'flex', flexDirection: 'column', gap: 1 }}>
{langs.map(([code, label]) => {
const on = code === lang;
return (
<a key={code} role="option" aria-selected={on} href={langHref(code)}
style={{ textAlign: 'left', textDecoration: 'none', background: on ? 'var(--accent-subtle, var(--bg-subtle))' : 'transparent', color: on ? 'var(--accent)' : 'var(--fg1)', fontWeight: on ? 700 : 500, fontFamily: 'var(--font-sans)', fontSize: 13, padding: '8px 11px', borderRadius: 8, whiteSpace: 'nowrap' }}>
{label}
</a>
);
})}
</div>
)}
</div>
<a class="linklogin" href={SITE.usercenter}>{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>
);
}