From 5b89de656e94f16fb22b947ff07083291dbc1457 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Wed, 8 Jul 2026 09:52:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=205=20=E9=A1=B9=20UI=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20=E2=80=94=20=E7=BB=9F=E4=B8=80=E6=B5=85=E8=89=B2/?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=9B=9E=E8=B7=B3/logo=20locale/=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=90=8D=E4=B8=8B=E6=8B=89/Docs=20=E7=9C=9F=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户中心: - 统一浅色: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 布局),卡片改回 ;Protocol 改正 sing-box+REALITY(去 WireGuard)。 验证:同源闸绿 · 两端 build 过 · redline 0 · Header 零内联 style · 无 WireGuard。 Co-Authored-By: Claude Opus 4.8 --- web/usercenter/components/Login.tsx | 2 +- web/usercenter/components/Subscription.tsx | 2 +- web/usercenter/components/UserCenter.tsx | 24 +++- web/usercenter/lib/api/http.ts | 7 +- web/usercenter/lib/api/mock.ts | 3 +- web/usercenter/lib/api/session.ts | 12 ++ web/usercenter/lib/i18n.ts | 1 + web/usercenter/lib/theme.tsx | 4 +- web/website/src/components/Docs.astro | 23 ++-- web/website/src/components/Footer.astro | 2 +- web/website/src/components/Header.jsx | 56 ++++++++- web/website/src/i18n/strings.ts | 8 +- web/website/src/layouts/Doc.astro | 77 ++++++++++++ web/website/src/layouts/Site.astro | 6 +- web/website/src/pages/docs/faq.astro | 26 ++++ web/website/src/pages/docs/privacy.astro | 27 ++++ web/website/src/pages/docs/protocol.astro | 22 ++++ web/website/src/pages/docs/quickstart.astro | 22 ++++ web/website/src/pages/zh/docs/faq.astro | 26 ++++ web/website/src/pages/zh/docs/privacy.astro | 27 ++++ web/website/src/pages/zh/docs/protocol.astro | 22 ++++ .../src/pages/zh/docs/quickstart.astro | 22 ++++ web/website/src/styles/site-extra.css | 119 ++++++++++++++++++ 23 files changed, 516 insertions(+), 24 deletions(-) create mode 100644 web/website/src/layouts/Doc.astro create mode 100644 web/website/src/pages/docs/faq.astro create mode 100644 web/website/src/pages/docs/privacy.astro create mode 100644 web/website/src/pages/docs/protocol.astro create mode 100644 web/website/src/pages/docs/quickstart.astro create mode 100644 web/website/src/pages/zh/docs/faq.astro create mode 100644 web/website/src/pages/zh/docs/privacy.astro create mode 100644 web/website/src/pages/zh/docs/protocol.astro create mode 100644 web/website/src/pages/zh/docs/quickstart.astro diff --git a/web/usercenter/components/Login.tsx b/web/usercenter/components/Login.tsx index 8a55b65..c3fdbb8 100644 --- a/web/usercenter/components/Login.tsx +++ b/web/usercenter/components/Login.tsx @@ -75,7 +75,7 @@ export default function Login({ onDone }: { onDone: () => void }) {
-
穿山甲
+
{t('brandName')}
PANGOLIN
diff --git a/web/usercenter/components/Subscription.tsx b/web/usercenter/components/Subscription.tsx index b2edb13..f361c8e 100644 --- a/web/usercenter/components/Subscription.tsx +++ b/web/usercenter/components/Subscription.tsx @@ -18,7 +18,7 @@ export default function Subscription({ t, mobile }: { t: TFn; mobile: boolean }) }, [api]); const clients = [ - { name: '穿山甲 App', sub: 'iOS / Android / 桌面', icon: 'shield-check', accent: true }, + { name: `${t('brandName')} App`, sub: 'iOS / Android / 桌面', icon: 'shield-check', accent: true }, { name: 'Shadowrocket', sub: 'iOS', icon: 'external-link' }, { name: 'Clash Verge', sub: 'Windows / macOS', icon: 'external-link' }, { name: 'v2rayN', sub: 'Windows', icon: 'external-link' }, diff --git a/web/usercenter/components/UserCenter.tsx b/web/usercenter/components/UserCenter.tsx index 7bda309..5346906 100644 --- a/web/usercenter/components/UserCenter.tsx +++ b/web/usercenter/components/UserCenter.tsx @@ -18,6 +18,14 @@ import type { Me } from '../lib/api/types'; type View = 'overview' | 'sub' | 'redeem' | 'invite' | 'settings'; const ORDER: View[] = ['overview', 'sub', 'redeem', 'invite', 'settings']; +/** redirect 白名单:仅接受单个 '/' 开头、且不以 '//' 或反斜杠开头的本站相对 + * 路径(防 open redirect,与 app/sso/page.tsx::safeRedirect 一致);否则返回 null。 */ +function safeRedirect(raw: string | null): string | null { + if (!raw) return null; + if (raw.charAt(0) !== '/' || raw.charAt(1) === '/' || raw.indexOf('\\') >= 0) return null; + return raw; +} + function useIsMobile() { const [m, setM] = useState(false); useEffect(() => { @@ -100,10 +108,22 @@ export default function UserCenter() { setView('overview'); } + // 登录成功回调:若 URL 带合法 ?redirect=<本站相对路径>(如官网带 ?redirect=/ 过来), + // 回跳来源页;否则进用户中心概览。 + function onLoginDone() { + const redirect = safeRedirect(new URLSearchParams(window.location.search).get('redirect')); + if (redirect) { + window.location.replace(redirect); + return; + } + setAuthed(true); + setView('overview'); + } + // 静态导出无服务端会话:首屏(!ready)与未登录一律直接渲染登录页,避免出现空白 // 背景(慢网络下用户会看到"空的")。已登录用户(有 refresh)会话续期完成后再切面板。 if (!ready || !authed) { - return { setAuthed(true); setView('overview'); }} />; + return ; } const nav: [View, string, string][] = [ @@ -148,7 +168,7 @@ export default function UserCenter() {
- 穿山甲 + {t('brandName')}
{!mobile && } {mobile &&
} diff --git a/web/usercenter/lib/api/http.ts b/web/usercenter/lib/api/http.ts index a730b67..9ee4254 100644 --- a/web/usercenter/lib/api/http.ts +++ b/web/usercenter/lib/api/http.ts @@ -17,6 +17,7 @@ import { clearSession, getAccessToken, getRefreshToken, + setEmail, setSession, } from './session'; @@ -171,7 +172,11 @@ export class HttpClient implements ApiClient { return session; } - getMe = async (): Promise => mapMe(await this.request('/v1/me')); + getMe = async (): Promise => { + const me = mapMe(await this.request('/v1/me')); + setEmail(me.email); // 同源官网读取显示用户名;clearSession/logout 时删除 + return me; + }; getSubscription = () => this.request('/v1/me/subscription'); resetSubscription = () => this.request('/v1/me/subscription/reset', { method: 'POST' }); listDevices = async (): Promise => { diff --git a/web/usercenter/lib/api/mock.ts b/web/usercenter/lib/api/mock.ts index c033958..d636456 100644 --- a/web/usercenter/lib/api/mock.ts +++ b/web/usercenter/lib/api/mock.ts @@ -10,7 +10,7 @@ import { SubscriptionInfo, TotpSetup, } from './types'; -import { setSession, clearSession } from './session'; +import { setSession, clearSession, setEmail } from './session'; const delay = (ms = 420) => new Promise((r) => setTimeout(r, ms)); @@ -88,6 +88,7 @@ export class MockClient implements ApiClient { async getMe(): Promise { await delay(260); + setEmail('me@pangolin.vpn'); // 同源官网读取显示用户名;clearSession/logout 时删除 return { email: 'me@pangolin.vpn', plan: 'pro', diff --git a/web/usercenter/lib/api/session.ts b/web/usercenter/lib/api/session.ts index a0b0f59..afd3c8b 100644 --- a/web/usercenter/lib/api/session.ts +++ b/web/usercenter/lib/api/session.ts @@ -5,6 +5,17 @@ import type { Session } from './types'; const REFRESH_KEY = 'pg_uc_refresh'; +// 登录用户邮箱:同源官网(pangolin website)读取以显示用户名。仅邮箱、非敏感凭证。 +const EMAIL_KEY = 'pg_uc_email'; + +export function setEmail(email: string): void { + if (typeof window === 'undefined' || !email) return; + try { + window.localStorage.setItem(EMAIL_KEY, email); + } catch { + /* ignore quota / privacy mode */ + } +} let accessToken: string | null = null; let accessExpiresAt = 0; @@ -44,6 +55,7 @@ export function clearSession(): void { if (typeof window !== 'undefined') { try { window.localStorage.removeItem(REFRESH_KEY); + window.localStorage.removeItem(EMAIL_KEY); } catch { /* ignore */ } diff --git a/web/usercenter/lib/i18n.ts b/web/usercenter/lib/i18n.ts index b7f17de..91f3bfe 100644 --- a/web/usercenter/lib/i18n.ts +++ b/web/usercenter/lib/i18n.ts @@ -13,6 +13,7 @@ export const STRINGS: Record = { navSettings: { zh: '设置', en: 'Settings', ja: '設定', ko: '설정', ru: 'Настройки', es: 'Ajustes' }, signOut: { zh: '退出', en: 'Sign out', ja: 'ログアウト', ko: '로그아웃', ru: 'Выйти', es: 'Cerrar sesión' }, backHome: { zh: '返回主页', en: 'Home', ja: 'ホーム', ko: '홈', ru: 'На главную', es: 'Inicio' }, + brandName: { zh: '穿山甲', en: 'Pangolin', ja: 'Pangolin', ko: 'Pangolin', ru: 'Pangolin', es: 'Pangolin' }, /* login */ loginTitle: { zh: '登录用户中心', en: 'Log in to your account', ja: 'アカウントにログイン', ko: '계정에 로그인', ru: 'Вход в аккаунт', es: 'Inicia sesión en tu cuenta' }, diff --git a/web/usercenter/lib/theme.tsx b/web/usercenter/lib/theme.tsx index 3f2c6a9..fc17d0d 100644 --- a/web/usercenter/lib/theme.tsx +++ b/web/usercenter/lib/theme.tsx @@ -27,7 +27,9 @@ export function UIProvider({ children }: { children: React.ReactNode }) { const l = window.localStorage.getItem(LANG_KEY) as Lang | null; const t = window.localStorage.getItem(THEME_KEY) as Theme | null; if (l && (['zh', 'en', 'ja', 'ko', 'ru', 'es'] as Lang[]).includes(l)) setLangState(l); - const initial: Theme = t === 'dark' || t === 'light' ? t : window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + // 默认恒浅色:只有用户手动切换过(localStorage 有显式偏好)才用保存值, + // 不跟随系统 prefers-color-scheme(避免系统暗色把登录页/用户中心染黑)。 + const initial: Theme = t === 'dark' || t === 'light' ? t : 'light'; setThemeState(initial); } catch { /* ignore */ diff --git a/web/website/src/components/Docs.astro b/web/website/src/components/Docs.astro index d9a9418..b827009 100644 --- a/web/website/src/components/Docs.astro +++ b/web/website/src/components/Docs.astro @@ -1,15 +1,18 @@ --- import Icon from './Icon.astro'; -import type { T } from '../i18n/strings'; +import type { T, Lang } from '../i18n/strings'; -interface Props { t: T } -const { t } = Astro.props; +interface Props { t: T; lang: Lang } +const { t, lang } = Astro.props; + +// 中文走 /zh/docs/*,其余语言暂共用英文文档(/docs/*)。 +const base = lang === 'zh' ? '/zh/docs' : '/docs'; const docs = [ - { icon: 'rocket', t: 'docs.1t', d: 'docs.1d' }, - { icon: 'circle-help', t: 'docs.2t', d: 'docs.2d' }, - { icon: 'shield-check', t: 'docs.3t', d: 'docs.3d' }, - { icon: 'lock', t: 'docs.4t', d: 'docs.4d' }, + { icon: 'rocket', t: 'docs.1t', d: 'docs.1d', slug: 'quickstart' }, + { icon: 'circle-help', t: 'docs.2t', d: 'docs.2d', slug: 'faq' }, + { icon: 'shield-check', t: 'docs.3t', d: 'docs.3d', slug: 'protocol' }, + { icon: 'lock', t: 'docs.4t', d: 'docs.4d', slug: 'privacy' }, ]; ---
@@ -20,13 +23,13 @@ const docs = [
diff --git a/web/website/src/components/Footer.astro b/web/website/src/components/Footer.astro index 24c53f2..8ca6b4a 100644 --- a/web/website/src/components/Footer.astro +++ b/web/website/src/components/Footer.astro @@ -12,7 +12,7 @@ const { t } = Astro.props;
- 穿山甲 + {t('nav.brand')}

{t('ft.tag')}

diff --git a/web/website/src/components/Header.jsx b/web/website/src/components/Header.jsx index cdf9dc2..d7ecbd0 100644 --- a/web/website/src/components/Header.jsx +++ b/web/website/src/components/Header.jsx @@ -30,18 +30,37 @@ 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); - // 与用户中心同源:登录后 localStorage 存 pg_uc_refresh → 显示「用户中心」入口。 + // 登录后回跳主页:用户中心带 ?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); }; @@ -51,6 +70,15 @@ export default function Header({ lang = 'zh', t = {} }) { 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(); @@ -77,7 +105,7 @@ export default function Header({ lang = 'zh', t = {} }) {
- 穿山甲 + {t.brand || 'Pangolin'}
{loggedIn ? ( - {t.center} +
+ + {userOpen && ( + + )} +
) : ( - {t.login} + {t.login} )} diff --git a/web/website/src/i18n/strings.ts b/web/website/src/i18n/strings.ts index 42d0909..56fccd4 100644 --- a/web/website/src/i18n/strings.ts +++ b/web/website/src/i18n/strings.ts @@ -40,6 +40,12 @@ export const STRINGS: Record> = { 'nav.login': { zh: '登录', en: 'Log in', ja: 'ログイン', ko: '로그인', ru: 'Войти', es: 'Iniciar sesión' }, 'nav.center': { zh: '用户中心', en: 'Account', ja: 'アカウント', ko: '계정', ru: 'Личный кабинет', es: 'Mi cuenta' }, 'nav.get': { zh: '立即下载', en: 'Get the app', ja: 'アプリを入手', ko: '앱 받기', ru: 'Получить приложение', es: 'Obtener la app' }, + // 品牌字标:中文显「穿山甲」,其余语言统一显「Pangolin」(英文版 logo 本地化)。 + 'nav.brand': { zh: '穿山甲', en: 'Pangolin', ja: 'Pangolin', ko: 'Pangolin', ru: 'Pangolin', es: 'Pangolin' }, + // 登录态用户下拉菜单项(3 项 × 6 语)。 + 'menu.center': { zh: '进入用户中心', en: 'Open account center', ja: 'アカウントセンターへ', ko: '계정 센터 열기', ru: 'Личный кабинет', es: 'Ir a mi cuenta' }, + 'menu.switch': { zh: '切换用户', en: 'Switch account', ja: 'アカウントを切替', ko: '계정 전환', ru: 'Сменить аккаунт', es: 'Cambiar de cuenta' }, + 'menu.logout': { zh: '退出登录', en: 'Log out', ja: 'ログアウト', ko: '로그아웃', ru: 'Выйти', es: 'Cerrar sesión' }, 'hero.eyebrow': { zh: '极速 · 稳定 · 省心', en: 'Fast · Stable · Effortless', ja: '高速 · 安定 · 快適', ko: '빠름 · 안정 · 간편', ru: 'Быстро · Стабильно · Без забот', es: 'Rápido · Estable · Sin complicaciones' }, 'hero.h1': { zh: '极速畅连,\n网络如丝顺滑', en: 'Faster, smoother,\neverywhere', ja: 'もっと速く、もっと滑らかに、\nどこでも', ko: '더 빠르고 더 매끄럽게,\n어디서나', ru: 'Быстрее, плавнее,\nвезде', es: 'Más rápido, más fluido,\nen todas partes' }, @@ -140,7 +146,7 @@ export const STRINGS: Record> = { 'docs.2t': { zh: '常见问题', en: 'FAQ', ja: 'よくある質問', ko: '자주 묻는 질문', ru: 'Вопросы и ответы', es: 'Preguntas frecuentes' }, 'docs.2d': { zh: '连接、计费、设备与兑换码的常见疑问。', en: 'Connection, billing, devices and redeem codes.', ja: '接続・請求・デバイス・引き換えコードのよくある疑問。', ko: '연결, 결제, 기기, 등록 코드에 대한 궁금증.', ru: 'Подключение, оплата, устройства и коды активации.', es: 'Conexión, facturación, dispositivos y códigos de canje.' }, 'docs.3t': { zh: '协议与安全', en: 'Protocol & security', ja: 'プロトコルとセキュリティ', ko: '프로토콜 & 보안', ru: 'Протокол и безопасность', es: 'Protocolo y seguridad' }, - 'docs.3d': { zh: 'WireGuard、加密方式与无日志架构说明。', en: 'WireGuard, encryption and our no-logs architecture.', ja: 'WireGuard、暗号化方式、ノーログ設計の解説。', ko: 'WireGuard, 암호화 방식, 노로그 아키텍처 설명.', ru: 'WireGuard, шифрование и наша архитектура без логов.', es: 'WireGuard, cifrado y nuestra arquitectura sin registros.' }, + 'docs.3d': { zh: 'sing-box + REALITY、加密方式与无日志架构说明。', en: 'sing-box + REALITY, encryption and our no-logs architecture.', ja: 'sing-box + REALITY、暗号化方式、ノーログ設計の解説。', ko: 'sing-box + REALITY, 암호화 방식, 노로그 아키텍처 설명.', ru: 'sing-box + REALITY, шифрование и наша архитектура без логов.', es: 'sing-box + REALITY, cifrado y nuestra arquitectura sin registros.' }, 'docs.4t': { zh: '隐私政策', en: 'Privacy policy', ja: 'プライバシーポリシー', ko: '개인정보 처리방침', ru: 'Политика конфиденциальности', es: 'Política de privacidad' }, 'docs.4d': { zh: '我们收集什么、不收集什么,一目了然。', en: 'Exactly what we collect — and what we never do.', ja: '収集するもの・しないものを明確に。', ko: '무엇을 수집하고 무엇을 수집하지 않는지 한눈에.', ru: 'Что мы собираем — и чего не собираем никогда.', es: 'Exactamente qué recopilamos y qué nunca hacemos.' }, 'docs.read': { zh: '阅读', en: 'Read', ja: '読む', ko: '읽기', ru: 'Читать', es: 'Leer' }, diff --git a/web/website/src/layouts/Doc.astro b/web/website/src/layouts/Doc.astro new file mode 100644 index 0000000..99e6736 --- /dev/null +++ b/web/website/src/layouts/Doc.astro @@ -0,0 +1,77 @@ +--- +/** + * Doc.astro — 文档正文页布局(/docs/* 与 /zh/docs/*)。 + * 复用官网 Header/Footer 与全站样式(tokens.gen / website / site-extra), + * 中间是 .doc-page > .doc-article 可读正文容器。构建期单显一种语言。 + */ +import '@fontsource/sora/500.css'; +import '@fontsource/sora/600.css'; +import '@fontsource/sora/700.css'; +import '@fontsource/manrope/400.css'; +import '@fontsource/manrope/500.css'; +import '@fontsource/manrope/600.css'; +import '@fontsource/manrope/700.css'; +import '@fontsource/noto-sans-sc/400.css'; +import '@fontsource/noto-sans-sc/500.css'; +import '@fontsource/noto-sans-sc/700.css'; +import '@fontsource/jetbrains-mono/400.css'; +import '@fontsource/jetbrains-mono/500.css'; + +import '../styles/tokens.gen.css'; +import '../styles/website.css'; +import '../styles/site-extra.css'; + +import { createT, type Lang } from '../i18n/strings'; +import Header from '../components/Header.jsx'; +import Footer from '../components/Footer.astro'; + +interface Props { lang: Lang; title: string; desc?: string } +const { lang, title, desc } = Astro.props; +const t = createT(lang); + +const HTML_LANG: Record = { zh: 'zh-CN', en: 'en', ja: 'ja', ko: 'ko', ru: 'ru', es: 'es' }; + +const headerT = { + product: t('nav.product'), + pricing: t('nav.pricing'), + download: t('nav.download'), + docs: t('nav.docs'), + blog: t('nav.blog'), + login: t('nav.login'), + center: t('nav.center'), + brand: t('nav.brand'), + mcenter: t('menu.center'), + mswitch: t('menu.switch'), + mlogout: t('menu.logout'), + get: t('nav.get'), + suBtn: t('su.btn'), +}; + +// 导航锚点回主页(文档页无同页锚点):把 #x 改为主页前缀。 +const home = lang === 'zh' ? '/zh/' : '/'; +const backHref = `${home}#docs`; +const metaTitle = `${title} · ${t('nav.brand')}`; +--- + + + + + + {metaTitle} + {desc && } + + + + +
+
+ +
+