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/.gitea/workflows/deploy-client.yml b/.gitea/workflows/deploy-client.yml index 45bcc62..39b7688 100644 --- a/.gitea/workflows/deploy-client.yml +++ b/.gitea/workflows/deploy-client.yml @@ -62,6 +62,14 @@ jobs: GOPROXY: https://goproxy.cn,direct PUB_HOSTED_URL: https://pub.flutter-io.cn FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn + # 境内镜像:GitHub release 资产在国内被 GFW 限速 → windows runner(LAN 内)下 + # sing-box.exe / wintun.zip 超时。改从 NAS Gitea generic 包镜像拉, + # fetch-desktop-bin.sh 命中镜像后照样验 SHA256,失败则回退官方源。 + # ⚠️ 基址含 sing-box 版本目录(v1.13.12)——升级 app/kernel/VERSION 的 + # SINGBOX_VERSION 时,须把新版 zip 重新 PUT 到对应版本目录并同步改这里。 + DESKTOP_BIN_MIRROR: http://192.168.3.200:3000/api/packages/wangjia/generic/desktop-bin/v1.13.12 + # 包默认可匿名读,token 非必需;带上以防将来把包设为私有(未设/为空则匿名 GET)。 + DESKTOP_BIN_MIRROR_TOKEN: ${{ secrets.FORGEJO_TOKEN }} steps: - name: Checkout uses: actions/checkout@v4 diff --git a/app/kernel/fetch-desktop-bin.sh b/app/kernel/fetch-desktop-bin.sh index 3a35a87..1e9194a 100644 --- a/app/kernel/fetch-desktop-bin.sh +++ b/app/kernel/fetch-desktop-bin.sh @@ -20,6 +20,15 @@ # 提取对应 arch 的 wintun.dll 到产物目录。 # # 幂等: 产物已存在且 SHA256 校验通过则跳过下载(传 --force 强制重下) +# +# 境内镜像(可选,解决 CI 从 GitHub release 被 GFW 限速的问题): +# DESKTOP_BIN_MIRROR 镜像基址;设了则 archive 与 wintun.zip 先试 +# ${DESKTOP_BIN_MIRROR}/<文件名>,命中即用,失败/未设 +# 则回退官方 GitHub / wintun.net。镜像下载的文件照样走 +# 下面的 SHA256 校验(防投毒/损坏)。 +# 例:http://192.168.3.200:3000/api/packages/wangjia/generic/desktop-bin/v1.13.12 +# DESKTOP_BIN_MIRROR_TOKEN 可选;镜像需鉴权时作 `Authorization: token <值>`。 +# 不设则匿名 GET(NAS Gitea generic 包默认可匿名读)。 set -euo pipefail @@ -116,6 +125,36 @@ _sha256() { fi } +# ── 下载助手:镜像优先,回退官方源 ───────────────────────────────────────────── +# 用法: _download_with_mirror <文件名> <官方回退URL> <输出路径> +# 若设了 DESKTOP_BIN_MIRROR:先试 ${DESKTOP_BIN_MIRROR}/<文件名>(带可选 token +# header),命中即返回;未设 / 镜像失败则回退官方 <回退URL>。 +# SHA256 校验由调用方在下载后统一执行——镜像来的文件同样要过校验。 +_download_with_mirror() { + local filename="$1" fallback_url="$2" out="$3" + + if [[ -n "${DESKTOP_BIN_MIRROR:-}" ]]; then + local mirror_url="${DESKTOP_BIN_MIRROR%/}/${filename}" + local -a auth=() + if [[ -n "${DESKTOP_BIN_MIRROR_TOKEN:-}" ]]; then + auth=(-H "Authorization: token ${DESKTOP_BIN_MIRROR_TOKEN}") + fi + printf '==> [mirror] 尝试 %s…\n' "${mirror_url}" + # ${auth[@]+...} 兜住空数组 + set -u:macOS bash 3.2 下 "${auth[@]}" 在数组 + # 为空时会报 "unbound variable",此写法数组空则整体展开为空。 + if curl -fSL --retry 5 --retry-delay 3 --retry-all-errors --connect-timeout 15 \ + ${auth[@]+"${auth[@]}"} -o "${out}" "${mirror_url}"; then + printf ' ✓ [mirror] 命中\n' + return 0 + fi + printf ' ! [mirror] 未命中,回退官方源\n' >&2 + fi + + printf '==> 下载 %s…\n' "${fallback_url}" + curl -fSL --retry 8 --retry-delay 5 --retry-connrefused --retry-all-errors --connect-timeout 20 \ + -o "${out}" "${fallback_url}" +} + # ── 幂等检查(已有产物则跳过;--force 强制重下)──────────────────────────────── if [[ "${FORCE}" == false && -f "${OUT_BIN}" ]]; then printf '✓ %s 已存在,跳过下载\n' "${OUT_BIN}" @@ -138,11 +177,8 @@ if [[ -z "${EXPECTED_HASH}" ]]; then exit 1 fi -# ── 下载二进制压缩包 ────────────────────────────────────────────────────────── -printf '==> 下载 %s…\n' "${ARCHIVE_FILE}" -curl -fSL --retry 8 --retry-delay 5 --retry-connrefused --retry-all-errors --connect-timeout 20 \ - -o "${ARCHIVE_CACHE}" \ - "${ARCHIVE_URL}" +# ── 下载二进制压缩包(镜像优先,回退 GitHub Release)────────────────────────── +_download_with_mirror "${ARCHIVE_FILE}" "${ARCHIVE_URL}" "${ARCHIVE_CACHE}" # ── SHA256 校验(对压缩包,比对内置 pin)────────────────────────────────────── printf '==> 校验 SHA256…\n' @@ -197,9 +233,8 @@ if [[ "${TARGET_OS}" == "windows" ]]; then if [[ "${FORCE}" == false && -f "${WINTUN_OUT}" ]]; then printf '✓ wintun.dll 已存在,跳过(传 --force 重新下载)\n' else - curl -fSL --retry 8 --retry-delay 5 --retry-connrefused --retry-all-errors --connect-timeout 20 \ - -o "${WINTUN_ZIP_CACHE}" \ - "${WINTUN_URL}" + # 镜像优先,回退 wintun.net + _download_with_mirror "${WINTUN_ZIP_NAME}" "${WINTUN_URL}" "${WINTUN_ZIP_CACHE}" printf '==> 校验 wintun.zip SHA256…\n' WINTUN_ACTUAL="$(_sha256 "${WINTUN_ZIP_CACHE}")" diff --git a/client/test/widget/stats_device_filter_test.dart b/client/test/widget/stats_device_filter_test.dart index 9d3e2d6..2c15056 100644 --- a/client/test/widget/stats_device_filter_test.dart +++ b/client/test/widget/stats_device_filter_test.dart @@ -6,7 +6,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; -import 'package:pangolin_vpn/l10n/strings_zh.dart'; import 'package:pangolin_vpn/pangolin_theme.dart'; import 'package:pangolin_vpn/screens/stats_page.dart'; import 'package:pangolin_vpn/services/api_client.dart'; @@ -38,7 +37,6 @@ class _LoggedIn implements TokenStore { void main() { setUpAll(disableGoogleFontsFetching); - const t = StringsZh(); testWidgets('选设备 → /v1/usage 带 device=', (tester) async { await tester.binding.setSurfaceSize(const Size(900, 1200)); 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/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) } } 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/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 5e8df3a..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,10 +168,14 @@ export default function UserCenter() {
- 穿山甲 + {t('brandName')}
{!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/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 6fda3a8..91f3bfe 100644 --- a/web/usercenter/lib/i18n.ts +++ b/web/usercenter/lib/i18n.ts @@ -12,6 +12,8 @@ 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' }, + 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 = [
- {/* 文档页未就绪:卡片暂作信息展示(非链接),写好真实文档后改回 + 恢复「阅读」。 */} {docs.map((d) => ( - + {t('docs.read')} + ))}
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 3848d52..d7ecbd0 100644 --- a/web/website/src/components/Header.jsx +++ b/web/website/src/components/Header.jsx @@ -30,7 +30,36 @@ 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; @@ -41,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(); @@ -67,7 +105,7 @@ export default function Header({ lang = 'zh', t = {} }) {
- 穿山甲 + {t.brand || 'Pangolin'}
-
+
{langOpen && ( -
+
{langs.map(([code, label]) => { const on = code === lang; return ( - + {label} ); @@ -105,7 +140,31 @@ export default function Header({ lang = 'zh', t = {} }) {
)}
- {t.login} + {loggedIn ? ( +
+ + {userOpen && ( + + )} +
+ ) : ( + {t.login} + )} {t.get} diff --git a/web/website/src/i18n/strings.ts b/web/website/src/i18n/strings.ts index 7630648..56fccd4 100644 --- a/web/website/src/i18n/strings.ts +++ b/web/website/src/i18n/strings.ts @@ -38,7 +38,14 @@ 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' }, + // 品牌字标:中文显「穿山甲」,其余语言统一显「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' }, @@ -139,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 && } + + + + +
+
+ +
+