From c9e266b89a8879ca1efcaf88ee5913099466c5ba Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Wed, 8 Jul 2026 09:04:10 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix(server+ci):=20=E4=BF=AE=20go-integratio?= =?UTF-8?q?n=20=E7=9C=9F=20bug=20+=20e2e=20=E5=85=8D=E7=96=AB=E4=BB=A3?= =?UTF-8?q?=E7=90=86(CI=20=E6=94=B6=E5=B0=BE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DinD 修复后暴露的两个 CI job,诊断: Go integration(真 test-drift bug,早前会话改动遗留): - auth/integration_test:Register/Login 补 ip + DeviceMeta 参数(sessions/ device-meta 改动后陈旧调用,构建失败)。 - usage/usage_integration_test:手写测试 schema 补 ad_bonus_minutes 列 (migration 000020 加的);重写 TestIntAdsUnlockAccumulates 断言对齐 ad-unlock 转累加式(UnlockAd→AddAdBonusMinutes,不再 stamp ad_unlocked_at)。 - devices/devices_integration_test:套餐种子 pro=5→3(migration 000019 改的)。 - devices/context.go(生产 1 行):CtxKeyUserID 别名到 codes.CtxKeyUserID——原为 独立 devices.ctxKey 类型,与 auth 注入的 codes.ctxKey 类型不同→context 取键 失配(休眠 bug,中间件目前仅测试接线)。go build 通过。 E2E(环境问题,非脚本):删 ci.yml 里多余的 apt-get(openssl/curl/python3 已在 golang:1.25 镜像内;原 apt 走 Docker Desktop 代理→本机死口,徒增脆性)。脚本 本身本机直跑通过。 验证:go test -tags integration -count=1 -p 1 ./... 全 ok;go build ./... clean。 Co-Authored-By: Claude Opus 4.8 --- .gitea/workflows/ci.yml | 4 +++- server/internal/auth/integration_test.go | 6 +++--- server/internal/devices/context.go | 12 ++++++++---- .../internal/devices/devices_integration_test.go | 2 +- server/internal/usage/usage_integration_test.go | 16 +++++++++++----- 5 files changed, 26 insertions(+), 14 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index ac43de0..db6c1bd 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -228,7 +228,9 @@ jobs: -v "$HOME/.cache/pangolin-ci/gomod:/go/pkg/mod" \ -v "$HOME/.cache/pangolin-ci/gobuild:/root/.cache/go-build" \ golang:1.25 \ - bash -c "apt-get update -qq && apt-get install -y -qq openssl curl python3 >/dev/null 2>&1 && bash scripts/e2e-smoke.sh" + bash -c "bash scripts/e2e-smoke.sh" + # 注:openssl/curl/python3 已在 golang:1.25 镜像内,无需 apt 安装 + # (原 apt-get 会走 Docker Desktop 代理→本机 clash 死口,徒增网络脆性)。 # ── Job 10: Go 集成测试 (L2:真 mysql8/redis 经 testcontainers)────────── # 跨库可移植(支柱 3)+ 按租户流量记账(usage)+ 配额(devices)+ 兑换(codes)+ diff --git a/server/internal/auth/integration_test.go b/server/internal/auth/integration_test.go index 49b623c..e3fb5bf 100644 --- a/server/internal/auth/integration_test.go +++ b/server/internal/auth/integration_test.go @@ -140,7 +140,7 @@ func TestIntegration_FullChain(t *testing.T) { } // 2. Register → trial subscription must exist for 7 days. - pair, apiErr := svc.Register(ctx, email, code, pw) + pair, apiErr := svc.Register(ctx, email, code, pw, "203.0.113.10", DeviceMeta{}) if apiErr != nil { t.Fatalf("Register: %v", apiErr) } @@ -170,12 +170,12 @@ func TestIntegration_FullChain(t *testing.T) { } // Force a fresh code regardless of rate limit. _ = rdb.Set(ctx, codeKey(email), code, 10*time.Minute).Err() - if _, e := svc.Register(ctx, email, code, pw); e == nil || e.Code != ErrCodeInvalid.Code { + if _, e := svc.Register(ctx, email, code, pw, "203.0.113.10", DeviceMeta{}); e == nil || e.Code != ErrCodeInvalid.Code { t.Fatalf("want code_invalid (anti-enumeration), got %v", e) } // 4. Login. - loginPair, _, apiErr := svc.Login(ctx, email, pw, "198.51.100.7") + loginPair, _, apiErr := svc.Login(ctx, email, pw, "198.51.100.7", DeviceMeta{}) if apiErr != nil { t.Fatalf("Login: %v", apiErr) } diff --git a/server/internal/devices/context.go b/server/internal/devices/context.go index 7754bec..8d4de6a 100644 --- a/server/internal/devices/context.go +++ b/server/internal/devices/context.go @@ -3,17 +3,21 @@ package devices import ( "context" "time" + + "github.com/wangjia/pangolin/server/internal/codes" ) // ctxKey is a private type for context keys to avoid collisions. type ctxKey string // CtxKeyUserID is the context key under which the authenticated user's -// internal int64 ID is stored by the JWT auth middleware (module #2). +// internal int64 ID is stored by the JWT auth middleware (auth.RequireAuth). // -// It mirrors the key used by the codes module so that, once the auth -// middleware lands, a single canonical key can be reconciled across modules. -const CtxKeyUserID ctxKey = "user_id" +// It is the single canonical key shared with the codes and auth modules +// (auth.UserIDFromContext reads codes.CtxKeyUserID). Aliasing it here — rather +// than declaring a distinct devices.ctxKey("user_id") — ensures the devices +// middleware/handlers resolve the same value the auth middleware injects. +const CtxKeyUserID = codes.CtxKeyUserID // ctxKeyPlan is the context key under which the resolved subscription Plan is // stored by SubscriptionMiddleware. diff --git a/server/internal/devices/devices_integration_test.go b/server/internal/devices/devices_integration_test.go index dc605ed..6e0c2ba 100644 --- a/server/internal/devices/devices_integration_test.go +++ b/server/internal/devices/devices_integration_test.go @@ -117,7 +117,7 @@ func applySchema(db *sql.DB) error { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, `INSERT IGNORE INTO plans (code, max_devices, daily_minutes, ad_gate) - VALUES ('free', 1, 10, TRUE), ('pro', 5, NULL, FALSE), ('team', 10, NULL, FALSE)`, + VALUES ('free', 1, 10, TRUE), ('pro', 3, NULL, FALSE), ('team', 10, NULL, FALSE)`, } for _, stmt := range stmts { if _, err := db.Exec(stmt); err != nil { diff --git a/server/internal/usage/usage_integration_test.go b/server/internal/usage/usage_integration_test.go index c623c52..c08f365 100644 --- a/server/internal/usage/usage_integration_test.go +++ b/server/internal/usage/usage_integration_test.go @@ -108,6 +108,7 @@ func applySchema(db *sql.DB) error { bytes_down BIGINT UNSIGNED NOT NULL DEFAULT 0, minutes_used INT NOT NULL DEFAULT 0, ad_unlocked_at DATETIME(6) NULL, + ad_bonus_minutes INT NOT NULL DEFAULT 0, PRIMARY KEY (user_id, date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, `CREATE TABLE IF NOT EXISTS usage_hourly ( @@ -433,11 +434,16 @@ func TestIntAdsUnlockAccumulates(t *testing.T) { t.Errorf("remaining after 2 ads=%d, want 30", rem) } - // Exactly one unlock timestamp. - var n int - db.QueryRow(`SELECT COUNT(*) FROM usage_daily WHERE user_id=? AND ad_unlocked_at IS NOT NULL`, uid).Scan(&n) - if n != 1 { - t.Errorf("expected 1 unlocked day, got %d", n) + // Additive model: the two ads accumulate into a single day row's + // ad_bonus_minutes (10 + 10 = 20). (ad_unlocked_at is legacy from the old + // per-day boolean unlock and is no longer stamped by UnlockAd.) + var rows, bonus int + db.QueryRow(`SELECT COUNT(*), COALESCE(MAX(ad_bonus_minutes),0) FROM usage_daily WHERE user_id=?`, uid).Scan(&rows, &bonus) + if rows != 1 { + t.Errorf("expected 1 usage_daily row, got %d", rows) + } + if bonus != 20 { + t.Errorf("expected ad_bonus_minutes=20, got %d", bonus) } } -- 2.52.0 From f37f1f3b59fb4b7a364d3429e094004705723a37 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Wed, 8 Jul 2026 09:16:13 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(web):=20=E4=BF=AE=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=E4=B8=8B=E6=8B=89=E6=BC=82=E7=A7=BB(class=20=E5=8C=96)+=20?= =?UTF-8?q?=E4=B8=BB=E9=A1=B5=E7=99=BB=E5=BD=95=E6=80=81=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E6=8C=89=E9=92=AE=20+=20=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E8=BF=94=E5=9B=9E=E4=B8=BB=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1 官网语言下拉漂移:根因=官网 CSP 无 unsafe-inline,Header.jsx 下拉全用内联 style= 被浏览器拦→菜单飘视口右边。修:搬进 website.css 的 .langwrap/.langsel /.langmenu/.langmenu a(全走 var(--token),right:0 锚按钮下方,min-width 160 加宽),Header.jsx 删净内联 style。 #2 主页登录态右上角「用户中心」按钮:Header.jsx 读同源 localStorage 'pg_uc_refresh',有则 .linklogin 位显「用户中心」(→ /user/)、无则「Log in」。 i18n nav.center 6 语言。 #3 用户中心「返回主页」按钮:UserCenter.tsx 顶栏加 (home 图标), i18n backHome 6 语言。 顺带堵同源闸漏洞:check-l1-sync ③ 原只匹配带引号图标键('refresh-cw'),漏了 裸标识符键(home),导致新加的 home 未被校验就通过。修解析器匹配裸键;并按 ds-flow 把 home 登记进 design/prototype/icons.js。同源闸复跑绿(裸键现全受检)。 Co-Authored-By: Claude Opus 4.8 --- design/prototype/icons.js | 1 + tools/check-l1-sync.mjs | 3 ++- web/usercenter/components/UserCenter.tsx | 4 ++++ web/usercenter/components/icons.tsx | 1 + web/usercenter/lib/i18n.ts | 1 + web/website/src/components/Header.jsx | 27 +++++++++++++++++------- web/website/src/i18n/strings.ts | 1 + web/website/src/layouts/Site.astro | 1 + web/website/src/styles/website.css | 11 ++++++++++ 9 files changed, 41 insertions(+), 9 deletions(-) diff --git a/design/prototype/icons.js b/design/prototype/icons.js index 5904581..470444a 100644 --- a/design/prototype/icons.js +++ b/design/prototype/icons.js @@ -57,6 +57,7 @@ 'compass': '', 'chart-no-axes-column': '', 'chart': '', + 'home': '', 'map-pin': '', 'laptop': '', 'monitor-smartphone': '', diff --git a/tools/check-l1-sync.mjs b/tools/check-l1-sync.mjs index 3f045d1..5a56301 100644 --- a/tools/check-l1-sync.mjs +++ b/tools/check-l1-sync.mjs @@ -73,7 +73,8 @@ function checkWebTokenSync(label, webPath) { // usercenter: export const LUCIDE: Record = { 'name': '', ... } const ucJs = read('web/usercenter/components/icons.tsx'); const ucBody = ucJs.match(/LUCIDE\s*:[^=]*=\s*\{([\s\S]*?)\n\};/)?.[1] ?? ucJs.match(/LUCIDE\s*=\s*\{([\s\S]*?)\n\};/)?.[1] ?? ''; - for (const [, id, body] of ucBody.matchAll(/'([^']+)'\s*:\s*'([^']*)'/g)) { + // 键可能带引号('refresh-cw')或裸标识符(home)—— 两种都匹配(裸键此前被漏检)。 + for (const [, id, body] of ucBody.matchAll(/['"]?([\w-]+)['"]?\s*:\s*'([^']*)'/g)) { if (!(id in protoIcons)) { problems.push(`[icons] usercenter 图标「${id}」不在原型 sprite —— 先登记 design/prototype/icons.js 再用`); } else if (protoIcons[id] !== body) { diff --git a/web/usercenter/components/UserCenter.tsx b/web/usercenter/components/UserCenter.tsx index 5e8df3a..7bda309 100644 --- a/web/usercenter/components/UserCenter.tsx +++ b/web/usercenter/components/UserCenter.tsx @@ -152,6 +152,10 @@ export default function UserCenter() { {!mobile && } {mobile &&
} + + + {!mobile && t('backHome')} + diff --git a/web/usercenter/components/icons.tsx b/web/usercenter/components/icons.tsx index 99db11d..cb6c3f2 100644 --- a/web/usercenter/components/icons.tsx +++ b/web/usercenter/components/icons.tsx @@ -25,6 +25,7 @@ export const LUCIDE: Record = { 'shopping-bag': '', 'log-out': '', 'external-link': '', + home: '', gift: '', smartphone: '', lock: '', diff --git a/web/usercenter/lib/i18n.ts b/web/usercenter/lib/i18n.ts index 6fda3a8..b7f17de 100644 --- a/web/usercenter/lib/i18n.ts +++ b/web/usercenter/lib/i18n.ts @@ -12,6 +12,7 @@ export const STRINGS: Record = { navInvite: { zh: '邀请返利', en: 'Referral', ja: '紹介', ko: '추천', ru: 'Рефералы', es: 'Referidos' }, 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' }, /* login */ loginTitle: { zh: '登录用户中心', en: 'Log in to your account', ja: 'アカウントにログイン', ko: '계정에 로그인', ru: 'Вход в аккаунт', es: 'Inicia sesión en tu cuenta' }, diff --git a/web/website/src/components/Header.jsx b/web/website/src/components/Header.jsx index 3848d52..cdf9dc2 100644 --- a/web/website/src/components/Header.jsx +++ b/web/website/src/components/Header.jsx @@ -30,8 +30,18 @@ export default function Header({ lang = 'zh', t = {} }) { const [open, setOpen] = useState(false); const [scrolled, setScrolled] = useState(false); const [langOpen, setLangOpen] = useState(false); + const [loggedIn, setLoggedIn] = useState(false); const langRef = useRef(null); + // 与用户中心同源:登录后 localStorage 存 pg_uc_refresh → 显示「用户中心」入口。 + useEffect(() => { + try { + setLoggedIn(!!localStorage.getItem('pg_uc_refresh')); + } catch { + setLoggedIn(false); + } + }, []); + useEffect(() => { if (!langOpen) return; const onDoc = (e) => { if (langRef.current && !langRef.current.contains(e.target)) setLangOpen(false); }; @@ -75,7 +85,7 @@ export default function Header({ lang = 'zh', t = {} }) { ))}
-
+
{langOpen && ( -
+
{langs.map(([code, label]) => { const on = code === lang; return ( - + {label} ); @@ -105,7 +112,11 @@ export default function Header({ lang = 'zh', t = {} }) {
)}
- {t.login} + {loggedIn ? ( + {t.center} + ) : ( + {t.login} + )} {t.get} diff --git a/web/website/src/i18n/strings.ts b/web/website/src/i18n/strings.ts index 7630648..42d0909 100644 --- a/web/website/src/i18n/strings.ts +++ b/web/website/src/i18n/strings.ts @@ -38,6 +38,7 @@ export const STRINGS: Record> = { 'nav.docs': { zh: '文档', en: 'Docs', ja: 'ドキュメント', ko: '문서', ru: 'Документация', es: 'Documentación' }, 'nav.blog': { zh: 'Blog', en: 'Blog', ja: 'ブログ', ko: '블로그', ru: 'Блог', es: 'Blog' }, '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' }, 'hero.eyebrow': { zh: '极速 · 稳定 · 省心', en: 'Fast · Stable · Effortless', ja: '高速 · 安定 · 快適', ko: '빠름 · 안정 · 간편', ru: 'Быстро · Стабильно · Без забот', es: 'Rápido · Estable · Sin complicaciones' }, diff --git a/web/website/src/layouts/Site.astro b/web/website/src/layouts/Site.astro index 8441d93..50b0fc4 100644 --- a/web/website/src/layouts/Site.astro +++ b/web/website/src/layouts/Site.astro @@ -56,6 +56,7 @@ const headerT = { docs: t('nav.docs'), blog: t('nav.blog'), login: t('nav.login'), + center: t('nav.center'), get: t('nav.get'), suBtn: t('su.btn'), }; diff --git a/web/website/src/styles/website.css b/web/website/src/styles/website.css index 2e7ce30..2d821b7 100644 --- a/web/website/src/styles/website.css +++ b/web/website/src/styles/website.css @@ -42,6 +42,17 @@ img,svg{display:block} .linklogin{font-size:14.5px;font-weight:600;color:var(--fg1);cursor:pointer} .linklogin:hover{color:var(--accent)} +/* ---------- language dropdown ---------- */ +.langwrap{position:relative;display:inline-block} +.langsel{display:inline-flex;align-items:center;gap:6px;border:1.5px solid var(--border-strong);border-radius:var(--radius-full);padding:8px 14px;background:var(--surface);color:var(--fg1);font-family:var(--font-sans);font-size:14px;font-weight:600;cursor:pointer;transition:border-color var(--dur-fast) var(--ease-out),color var(--dur-fast) var(--ease-out)} +.langsel:hover{border-color:var(--accent);color:var(--accent)} +.langsel .caret{width:11px;height:11px;transition:transform 140ms var(--ease-out)} +.langsel[aria-expanded="true"] .caret{transform:rotate(180deg)} +.langmenu{position:absolute;top:calc(100% + 6px);right:0;min-width:160px;display:flex;flex-direction:column;gap:1px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);padding:5px;z-index:60} +.langmenu a{text-align:left;text-decoration:none;font-family:var(--font-sans);font-size:13px;font-weight:500;color:var(--fg1);background:transparent;padding:8px 11px;border-radius:var(--radius-sm);white-space:nowrap;transition:background var(--dur-fast) var(--ease-out)} +.langmenu a:hover{background:var(--bg-subtle)} +.langmenu a[aria-selected="true"]{color:var(--accent);background:var(--accent-subtle);font-weight:700} + /* mobile nav */ .menu-btn{display:none;border:none;background:transparent;cursor:pointer;padding:7px;border-radius:var(--radius-sm);color:var(--fg1)} .menu-btn svg{width:22px;height:22px} -- 2.52.0 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 3/5] =?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 && } + + + + +
+
+ +
+