Compare commits

..

17 Commits

Author SHA1 Message Date
wangjia 9a8fec2e9c fix(usercenter): 加 no-FOUC 内联脚本,消除刷新时明暗主题整页配色闪烁
layout 之前把 data-theme 硬编码 light、挂载后才由 UIProvider effect 应用保存的主题,
暗色偏好用户每次刷新先见浅色再翻暗色 = 整页配色重绘(叠加内容加载,观感"刷两次")。

照官网 Site.astro 做法:<head> 内联脚本在首屏渲染前读 localStorage(pg_uc_theme/
pg_uc_lang)设好 data-theme 与 html lang。/user/* CSP 允许 unsafe-inline 脚本,直接内联;
脚本为编译期常量、无插值、只读 localStorage,无 XSS 风险。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 15:04:23 +08:00
wangjia 101b31e073 fix(usercenter): 消除刷新"重绘两次" —— 引导期直接渲染外壳骨架,去掉多余首帧
上一版为防水合不一致加了 !mounted 中性首帧,反而制造了额外一段:一次 Cmd+R 走
裸 spinner(mounted=false)→ 应用外壳(乐观)→ 内容区(overview),三段重绘 =
用户看到的"刷新两次(先主显示区、后整页)"。

改:去掉 mounted gate;引导期(!ready)一律渲染 appShell(spinner)——外壳骨架
(顶栏/导航就位、内容区加载态)。因 render 期不读 localStorage/hasRefresh,SSR 与
客户端首帧一致、无水合不一致,静态 HTML 首帧即外壳。引导完成后只把内容区从 spinner
换成真实视图,顶栏/导航原地不动 → 刷新只有内容区一次替换,不再整页重绘。

代价:未登录用户刷新 /user/ 会先见外壳骨架再落登录页(极短、且随即要登录),
可接受;已登录用户(刷新的绝大多数场景)全程外壳稳定、无闪。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 15:01:29 +08:00
wangjia 11ad888ee7 fix(usercenter): 刷新页面即被登出 —— refresh 改用请求体传 token(修致命鉴权 bug)
根因:服务端 POST /v1/auth/refresh 从**请求体** {refresh_token} 读取(handler.go
Refresh + 原生 Flutter auth_api.dart 均如此),但 web 客户端 refresh() 误把 refresh
token 放进 X-Refresh-Token **请求头**(只有 /auth/logout 读该头)。服务端拿到空 body
→ 400 auth.invalid_request → 客户端 catch → clearSession → **每次刷新页面即被登出**。

线上实测证实:POST 带头无体 → invalid_request(空);带体 {refresh_token} → invalid_token
(正常解析)。故服务端读体、web 发头 = 必然掉线。

改:web refresh() 改用 body:{ refresh_token } 传参(与原生客户端、服务端一致);
无需改/重部署服务端。logout 仍走 X-Refresh-Token 头(服务端 logout 读头,匹配)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:52:21 +08:00
wangjia 53d8ded7f6 fix(usercenter): 刷新 /user/ 不再整页重绘(引导期乐观渲染外壳)
上一版把首帧从登录页改成居中 Loading,消除了闪登录页,但用户仍感刷两次:
静态导出首帧无法知登录态 → 先渲染裸 Loading(无顶栏/导航)→ 再出现完整外壳
→ 再拉 /me 填内容。顶栏在裸 Loading 到外壳间消失再出现,观感=整页重绘两次。

改:抽出可复用 appShell(content);mount 后引导仍进行(!ready)时,若本地有 refresh
token,乐观渲染完整外壳(顶栏/导航就位,仅内容区转圈),没 token 才直接登录页。
于是顶栏/导航从乐观态到已登录态始终在位,只有内容区替换 → 不再整页重绘。

SSR 安全:!mounted(SSR + 首次客户端渲染)仍渲染中性 spinner(不读 localStorage),
与服务端 HTML 一致防水合不一致;mount 后才据 hasRefresh() 决定外壳/登录页。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:43:24 +08:00
wangjia e3e1d43d5d fix(usercenter): 刷新 /user/ 不再先闪登录页(引导期一律加载态)
根因:静态导出 SSR 首屏读不到 localStorage(hasRefresh 服务端恒 false),旧守卫
!ready&&hasRefresh() 在此时为 false → 首屏渲染成登录页 → 刷新 /user/ 浏览器先显示
登录页 HTML,再水合+续期跳回面板(闪现);且 render 期调 hasRefresh 造成 SSR/客户端
不一致加剧闪现。

修:引导未完成(!ready)一律显示加载态(不读 localStorage、不渲染登录页),SSR 与
客户端首屏一致;引导完再定:已登录→面板、未登录/失败→登录页。刷新不再闪登录页。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:28:17 +08:00
wangjia 09b1944376 fix(usercenter): 续期失败时清本地会话,修主页↔登录页来回刷
根因:引导时 api.refresh() 失败(refresh token 过期/被撤销/Redis 丢 JTI 白名单)
只 setAuthed(false),没清 localStorage 的 pg_uc_refresh/pg_uc_email → 官网仍据此
显示'用户中心' → 点进 /user/ 又续期失败回登录页 → 来回刷。

修:catch 里加 clearSession() 清掉残留 token,官网随即回到'Log in',状态一致、
不再来回刷。(登录态正常保持 30 天=refresh token TTL;失败多因会话真失效。)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:23:35 +08:00
wangjia e0912dd819 fix(website): 定价卡片 3 个按钮补链接(原纯 button 无响应)
PricingPlans.jsx 的 pcta 是纯 <button> 无 onClick/href → 点击无反应。改为
<a href>:Free「Download free」→ #download 下载区;Pro/Team「Get a code」→
#get-code(给渠道区 .pay-note 加 id,滚到购码渠道:自助店/USDT/Telegram/LINE/
邮箱)。.pcta 补 display:block/text-align:center/text-decoration:none 让 <a>
渲染同按钮。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 13:09:29 +08:00
wangjia f4c5f93370 fix(website): 主题切换按钮缩小,与用户中心一致(透明底/无框/17px 图标)
原 38×38 圆框 + surface 底太大;改为透明底、无边框、padding 6、图标 17px,
和用户中心顶栏主题按钮同款尺寸。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 12:13:01 +08:00
wangjia 6dcf582225 feat(website): 官网加明/暗主题切换(默认浅色,无 FOUC)
- Header 加 Moon/Sun 切换按钮(.themetoggle class,走语义 token,无内联 style),
  切 document.documentElement.dataset.theme + 存 localStorage pg_site_theme,
  挂载读回同步;移动端也能切。
- Site.astro + Doc.astro <head> 加 <script is:inline> 无 FOUC 初始化(首屏前读
  localStorage 设 data-theme;postbuild csp-hashes 已加 script hash 放行)。
- 暗色机制:tokens.gen.css 已含 [data-theme=dark] 块,官网 127 处语义 token 自动
  适配;23 处 light 专属原色(公告条/CTA/主推套餐卡 clay 品牌面 + 恒暗页脚)均
  有意固定,保留。
- 审计:所有元素走唯一真相源(check-ds/check-l1-sync/check_ds_code 三闸全绿)。

验证:check-l1-sync ✓ · build ✓(CSP hash 覆盖 FOUC 脚本)· redline 0 · Header
零内联 style。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:58:35 +08:00
wangjia 9ed7588ec0 fix(usercenter): 删冗余 Preferences 卡片 + 修登录态进用户中心闪登录页
1. Settings 页删掉 Preferences 卡片(语言/主题切换)——顶栏已有语言下拉 + 主题
   切换按钮,重复。连带删只被它用的 Preferences/Row/Seg 函数 + useUI 导入。
2. 修 bug:从官网登录态点"用户中心"进 /user/ 会先闪登录页再跳面板。根因:渲染
   守卫在 !ready(会话续期中)时也直接渲染 Login。改为:本地有 refresh token 且
   续期未完成时显示加载态(非登录页),续期成功→面板、失败/无 token→登录页。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:22:11 +08:00
wangjia 87647cae34 fix(usercenter): Subscription/Redeem/Referral/Settings 布局宽度对齐 Overview
这 4 页根布局各自设了更窄的 maxWidth(720/680),而 Overview 无 maxWidth 填满
1000px 居中容器 → 这几页内容贴左、右侧留白、与 Overview 不一致。去掉各自的
maxWidth,统一填满主容器(maxWidth:1000 margin:0 auto),宽度与 Overview 一致、
居中对齐。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:13:31 +08:00
wangjia 620d8148f3 fix(usercenter/overview): 4 处格式调整
1. 顶栏 Sign out 按钮加 whiteSpace:nowrap,不再折成两行。
2. 近 7 日柱状图数字保留 2 位有效数字(Number(v.toPrecision(2))),不再显示
   9.285392755642533 这种长数。
3. 英文星期标签 M/T/W/T/F/S/S → Mon/Tue/Wed/Thu/Fri/Sat/Sun(消歧义;中文
   一~日 本就清晰不改)。
4. 图表卡与 Quick actions 卡 alignItems start→stretch,两卡等高、上下对齐。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:09:51 +08:00
wangjia 37258e1cb2 fix(client/test): 删未用变量修 flutter analyze(CI Flutter job 全绿)
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 24s
ci-pangolin / Flutter — analyze + test (pull_request) Successful in 33s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 28s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 10s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 24s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 12s
ci-pangolin / Go — build + test (pull_request) Successful in 7s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Successful in 8s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Successful in 4m33s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Successful in 18s
ci-pangolin / OpenAPI Sync Check (pull_request) Failing after 10m21s
ci-pangolin / Lint — shellcheck (pull_request) Failing after 10m33s
stats_device_filter_test.dart 的 const t = StringsZh() 声明后未使用 →
unused_local_variable warning → flutter analyze --no-fatal-infos exit 1 →
CI Flutter job 一直红(既存,与 i18n 修复无关)。删该变量 + 随之无用的
strings_zh import。

容器复验:analyze exit 0(0 warning)· test +97 过 · 覆盖率 38.5%≥28%。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 10:11:32 +08:00
wangjia 38be40a107 fix(client/ci): sing-box/wintun 境内镜像到 NAS(修 Windows 构建 GFW 失败)
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 25s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 22s
ci-pangolin / Lint — shellcheck (pull_request) Successful in 8s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 30s
ci-pangolin / Flutter — analyze + test (pull_request) Failing after 18s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 4s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 4s
ci-pangolin / Go — build + test (pull_request) Successful in 14s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Successful in 16s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Successful in 4m52s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Successful in 23s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Failing after 13m12s
根因:Windows CI 从 GitHub release 下 sing-box 压缩包被 GFW 限速超时(github.com
网页可达但 release 资产 CDN 被掐),build-windows 卡在 fetch-desktop-bin 早期。

修:
- 二进制镜像到 NAS Gitea generic 包(v1.13.12/sing-box-*-windows-amd64.zip +
  wintun-0.14.1.zip),上传前后均 sha256 校验。
- fetch-desktop-bin.sh 加 DESKTOP_BIN_MIRROR(+可选 _TOKEN):设了先试镜像、
  命中即用、未设/失败回退 GitHub/wintun.net;镜像文件照走现有 sha pin 校验(防
  投毒/损坏)。修一处 macOS bash3.2 空数组 set -u unbound 坑。
- deploy-client.yml build-windows env 指向 NAS 镜像基址 + 复用现有 FORGEJO_TOKEN
  secret(匿名 GET 也行,token 仅备将来私有)。无新 secret、无硬编码、无入库二进制。

回归(mac bash3.2):无 mirror 走 GitHub ✓ / mirror 走 NAS 命中 ✓ / bogus mirror
回退 ✓。Windows 端到端待下个 client-v* tag 触发 CI 观察。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 09:53:38 +08:00
wangjia 5b89de656e feat(web): 5 项 UI 修复 — 统一浅色/登录回跳/logo locale/用户名下拉/Docs 真页
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>
2026-07-08 09:52:04 +08:00
wangjia f37f1f3b59 feat(web): 修语言下拉漂移(class 化)+ 主页登录态用户中心按钮 + 用户中心返回主页
ci-pangolin / Lint — shellcheck (pull_request) Successful in 10s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 29s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 32s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 21s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 24s
ci-pangolin / Flutter — analyze + test (pull_request) Failing after 21s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 10s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 8s
ci-pangolin / Go — build + test (pull_request) Successful in 10s
Deploy Site / deploy-site (push) Successful in 2m33s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 4m33s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Successful in 19s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 13m16s
#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 顶栏加 <a href="/">(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 <noreply@anthropic.com>
2026-07-08 09:16:13 +08:00
wangjia c9e266b89a fix(server+ci): 修 go-integration 真 bug + e2e 免疫代理(CI 收尾)
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 23s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 20s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 20s
ci-pangolin / Lint — shellcheck (pull_request) Successful in 7s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 33s
ci-pangolin / Flutter — analyze + test (pull_request) Failing after 16s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 4s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 5s
ci-pangolin / Go — build + test (pull_request) Successful in 8s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Successful in 8s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Successful in 4m33s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Successful in 19s
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 <noreply@anthropic.com>
2026-07-08 09:04:10 +08:00
42 changed files with 714 additions and 126 deletions
+3 -1
View File
@@ -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)+
+8
View File
@@ -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
+43 -8
View File
@@ -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 <值>`。
# 不设则匿名 GETNAS 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 -umacOS 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}")"
@@ -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=<uuid>', (tester) async {
await tester.binding.setSurfaceSize(const Size(900, 1200));
+1
View File
@@ -57,6 +57,7 @@
'compass': '<path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"/><circle cx="12" cy="12" r="10"/>',
'chart-no-axes-column': '<line x1="18" x2="18" y1="20" y2="10"/><line x1="12" x2="12" y1="20" y2="4"/><line x1="6" x2="6" y1="20" y2="14"/>',
'chart': '<line x1="18" x2="18" y1="20" y2="10"/><line x1="12" x2="12" y1="20" y2="4"/><line x1="6" x2="6" y1="20" y2="14"/>',
'home': '<path d="M3 9.5 12 3l9 6.5"/><path d="M5 10v10a1 1 0 0 0 1 1h3v-6h6v6h3a1 1 0 0 0 1-1V10"/>',
'map-pin': '<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>',
'laptop': '<path d="M20 16V7a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v9m16 0H4m16 0 1.28 2.55a1 1 0 0 1-.9 1.45H3.62a1 1 0 0 1-.9-1.45L4 16"/>',
'monitor-smartphone': '<path d="M18 8V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h8"/><path d="M10 19v-3.96 3.15"/><path d="M7 19h5"/><rect width="6" height="10" x="16" y="12" rx="2"/>',
+3 -3
View File
@@ -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)
}
+8 -4
View File
@@ -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.
@@ -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 {
@@ -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 -1
View File
@@ -73,7 +73,8 @@ function checkWebTokenSync(label, webPath) {
// usercenter: export const LUCIDE: Record<string,string> = { 'name': '<path/>', ... }
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) {
+9
View File
@@ -20,6 +20,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<html lang="en" data-theme="light" suppressHydrationWarning>
<head>
{/* 首屏渲染前据 localStorage 设好 data-theme/lang,避免明暗主题(整页配色)在
挂载后才应用造成的 FOUC 闪烁。/user/* CSP 允许 'unsafe-inline' 脚本,故直接内联。
与官网 Site.astro 的 no-FOUC 脚本同法(键名用用户中心的 pg_uc_theme/pg_uc_lang)。 */}
<script
dangerouslySetInnerHTML={{
__html:
"(function(){try{var t=localStorage.getItem('pg_uc_theme');if(t==='dark'||t==='light')document.documentElement.dataset.theme=t;var l=localStorage.getItem('pg_uc_lang');if(l)document.documentElement.lang=l;}catch(e){}})()",
}}
/>
{/* 设计令牌单一真相源,原样链入(SRI 由构建期注入) */}
{/* eslint-disable-next-line @next/next/no-css-tags */}
<link rel="stylesheet" href={`${BASE_PATH}/colors_and_type.css`} />
+1 -1
View File
@@ -25,7 +25,7 @@ export default function Invite({ t }: { t: TFn }) {
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: 680 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 25, fontWeight: 700, color: 'var(--fg1)' }}>{t('inviteTitle')}</div>
<div style={{ fontSize: 13, color: 'var(--fg3)', marginTop: 5, lineHeight: 1.6 }}>{t('inviteSub')}</div>
+1 -1
View File
@@ -75,7 +75,7 @@ export default function Login({ onDone }: { onDone: () => void }) {
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Mark size={32} />
<div>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 17, color: 'var(--fg1)', lineHeight: 1 }}>穿</div>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 17, color: 'var(--fg1)', lineHeight: 1 }}>{t('brandName')}</div>
<div style={{ fontSize: 8.5, fontWeight: 600, letterSpacing: '0.2em', color: 'var(--accent)', marginTop: 3 }}>PANGOLIN</div>
</div>
</div>
+3 -3
View File
@@ -24,7 +24,7 @@ export default function Overview({
const free = me.plan === 'free';
const vals = me.weeklyGB;
const max = Math.max(...vals, 0.1);
const labels = lang === 'zh' ? ['一', '二', '三', '四', '五', '六', '日'] : ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
const labels = lang === 'zh' ? ['一', '二', '三', '四', '五', '六', '日'] : ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const stats: [string, string, string, string][] = [
['clock', t('quotaToday'), free ? String(me.quotaTodayMin ?? 0) : '∞', free ? 'min' : ''],
@@ -76,13 +76,13 @@ export default function Overview({
</div>
{/* usage chart + quick actions */}
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : '1.6fr 1fr', gap: 14, alignItems: 'start' }}>
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : '1.6fr 1fr', gap: 14, alignItems: 'stretch' }}>
<div style={{ ...card, padding: '18px 22px' }}>
<div style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--fg1)', marginBottom: 16 }}>{t('usageTitle')}</div>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 14, height: 110 }}>
{vals.map((v, i) => (
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 9.5, color: 'var(--fg3)' }}>{v}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 9.5, color: 'var(--fg3)' }}>{Number(v.toPrecision(2))}</div>
<div style={{ width: '100%', maxWidth: 30, height: `${(v / max) * 76}px`, background: 'var(--accent)', borderRadius: '5px 5px 0 0', opacity: 0.85 }} />
<div style={{ fontSize: 10.5, color: 'var(--fg3)' }}>{labels[i]}</div>
</div>
+1 -1
View File
@@ -39,7 +39,7 @@ export default function Redeem({ t, lang, onRedeemed }: { t: TFn; lang: Lang; on
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: 680 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 25, fontWeight: 700, color: 'var(--fg1)' }}>{t('navRedeem')}</div>
<div style={{ ...card, padding: '20px 22px' }}>
<div style={{ fontSize: 14.5, fontWeight: 700, color: 'var(--fg1)', marginBottom: 12 }}>{t('redeemTitle')}</div>
+1 -54
View File
@@ -5,7 +5,6 @@ import React, { useEffect, useState } from 'react';
import { Icon } from './icons';
import { card, input } from './shared';
import { ErrorLine } from './Login';
import { useUI } from '../lib/theme';
import type { TFn, Lang } from '../lib/i18n';
import { getClient } from '../lib/api/client';
import { bilingual } from '../lib/api/errors';
@@ -13,9 +12,8 @@ import type { Device, TotpSetup } from '../lib/api/types';
export default function Settings({ t, lang, totpEnabled, onTotpChange }: { t: TFn; lang: Lang; totpEnabled: boolean; onTotpChange: () => void }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: 680 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 25, fontWeight: 700, color: 'var(--fg1)' }}>{t('settingsTitle')}</div>
<Preferences t={t} />
<TotpSection t={t} lang={lang} enabled={totpEnabled} onChange={onTotpChange} />
<Devices t={t} lang={lang} />
</div>
@@ -24,57 +22,6 @@ export default function Settings({ t, lang, totpEnabled, onTotpChange }: { t: TF
const sectionTitle: React.CSSProperties = { fontSize: 14.5, fontWeight: 700, color: 'var(--fg1)' };
function Preferences({ t }: { t: TFn }) {
const { lang, setLang, theme, setTheme } = useUI();
return (
<div style={{ ...card, padding: '18px 20px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={sectionTitle}>{t('prefTitle')}</div>
<Row label={t('prefLang')}>
<Seg
value={lang}
options={[['zh', '中文'], ['en', 'EN']]}
onPick={(v) => setLang(v as Lang)}
/>
</Row>
<Row label={t('prefTheme')}>
<Seg
value={theme}
options={[['light', t('themeLight')], ['dark', t('themeDark')]]}
icons={{ light: 'sun', dark: 'moon' }}
onPick={(v) => setTheme(v as 'light' | 'dark')}
/>
</Row>
</div>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<span style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--fg2)' }}>{label}</span>
{children}
</div>
);
}
function Seg({ value, options, onPick, icons }: { value: string; options: [string, string][]; onPick: (v: string) => void; icons?: Record<string, string> }) {
return (
<div style={{ display: 'flex', background: 'var(--bg-subtle)', borderRadius: 999, padding: 3, gap: 2 }}>
{options.map(([v, l]) => (
<button
key={v}
onClick={() => onPick(v)}
aria-pressed={value === v}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, border: 'none', cursor: 'pointer', borderRadius: 999, padding: '6px 14px', fontFamily: 'var(--font-sans)', fontSize: 12.5, fontWeight: 700, background: value === v ? 'var(--accent)' : 'transparent', color: value === v ? 'var(--fg-on-accent)' : 'var(--fg3)' }}
>
{icons && <Icon name={icons[v]} size={14} color={value === v ? 'var(--fg-on-accent)' : 'var(--fg3)'} />}
{l}
</button>
))}
</div>
);
}
/* ───────── TOTP 2FA ───────── */
function TotpSection({ t, lang, enabled, onChange }: { t: TFn; lang: Lang; enabled: boolean; onChange: () => void }) {
const api = getClient();
+2 -2
View File
@@ -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' },
@@ -48,7 +48,7 @@ export default function Subscription({ t, mobile }: { t: TFn; mobile: boolean })
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: 720 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 25, fontWeight: 700, color: 'var(--fg1)' }}>{t('subTitle')}</div>
<div style={{ fontSize: 13, color: 'var(--fg3)', marginTop: 5, lineHeight: 1.6 }}>{t('subDesc')}</div>
+45 -9
View File
@@ -12,12 +12,20 @@ import Settings from './Settings';
import { useUI } from '../lib/theme';
import { makeT } from '../lib/i18n';
import { apiMode, getClient } from '../lib/api/client';
import { hasRefresh } from '../lib/api/session';
import { hasRefresh, clearSession } from '../lib/api/session';
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(() => {
@@ -57,6 +65,10 @@ export default function UserCenter() {
await api.refresh();
if (alive) setAuthed(true);
} catch {
// 续期失败(refresh token 过期/被撤销/Redis 丢 JTI):清掉本地会话,
// 否则 pg_uc_refresh 残留 → 官网仍显示"用户中心" → 点进来又续期失败 →
// 来回刷登录页。清了官网会回到"Log in",状态一致。
clearSession();
if (alive) setAuthed(false);
}
}
@@ -100,10 +112,16 @@ export default function UserCenter() {
setView('overview');
}
// 静态导出无服务端会话:首屏(!ready)与未登录一律直接渲染登录页,避免出现空白
// 背景(慢网络下用户会看到"空的")。已登录用户(有 refresh)会话续期完成后再切面板
if (!ready || !authed) {
return <Login onDone={() => { setAuthed(true); 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');
}
const nav: [View, string, string][] = [
@@ -141,22 +159,29 @@ export default function UserCenter() {
</button>
));
return (
// 应用外壳(顶栏 + 导航 + 内容区);content 由调用方决定 —— 已就绪传真实视图,
// 引导期传加载占位。关键:外壳在「引导中(乐观)」与「已登录」两态都渲染,导航/顶栏
// 始终在位,只有内容区替换 → 刷新时不再"整页重绘",消除"刷两次"的观感。
const appShell = (content: React.ReactNode) => (
<div style={{ minHeight: '100vh', background: 'var(--bg)', fontFamily: 'var(--font-sans)' }}>
{/* top bar */}
<div style={{ position: 'sticky', top: 0, zIndex: 10, background: 'color-mix(in srgb, var(--bg) 85%, transparent)', backdropFilter: 'blur(12px)', borderBottom: '1px solid var(--border)' }}>
<div style={{ maxWidth: 1000, margin: '0 auto', padding: mobile ? '0 16px' : '0 24px', height: mobile ? 54 : 60, display: 'flex', alignItems: 'center', gap: mobile ? 12 : 22 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
<Mark size={26} />
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 16.5, color: 'var(--fg1)' }}>穿</span>
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 16.5, color: 'var(--fg1)' }}>{t('brandName')}</span>
</div>
{!mobile && <nav style={{ display: 'flex', gap: 4, flex: 1 }}>{navBtns}</nav>}
{mobile && <div style={{ flex: 1 }} />}
<a href="/" title={t('backHome')} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, textDecoration: 'none', color: 'var(--fg2)', fontSize: 13, fontWeight: 600, padding: 6 }}>
<Icon name="home" size={15} color="var(--fg3)" />
{!mobile && t('backHome')}
</a>
<button onClick={toggleTheme} aria-label="theme" title="theme" style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--fg2)', padding: 6, display: 'flex' }}>
<Icon name={theme === 'dark' ? 'sun' : 'moon'} size={17} color="var(--fg3)" />
</button>
<LangSeg lang={lang} setLang={setLang} />
<button onClick={signOut} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--fg2)', fontSize: 13, fontWeight: 600, padding: 6 }}>
<button onClick={signOut} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--fg2)', fontSize: 13, fontWeight: 600, padding: 6, whiteSpace: 'nowrap' }}>
<Icon name="log-out" size={15} color="var(--fg3)" />
{!mobile && t('signOut')}
</button>
@@ -169,9 +194,20 @@ export default function UserCenter() {
style={{ maxWidth: 1000, margin: '0 auto', padding: mobile ? '20px 16px 40px' : '30px 24px 48px' }}
>
<div key={view} style={{ animation: dir !== 0 ? `uc-in-${dir === 1 ? 'l' : 'r'} 200ms var(--ease-out)` : 'none' }}>
{main}
{content}
</div>
</div>
</div>
);
const spinner = <div style={{ minHeight: '40vh', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--fg3)', fontSize: 14 }}>{t('loading')}</div>;
// 引导(会话续期)期间一律渲染**应用外壳骨架**(顶栏/导航 + 内容区加载态)。关键:
// · SSR 与客户端首帧渲染同一份外壳(不在 render 期读 localStorage/hasRefresh)→ 无水合
// 不一致,首帧(静态 HTML)即外壳。
// · 引导完成后:已登录→外壳 + 真实视图(**只换内容区**,顶栏/导航原地不动),未登录→登录页。
// 于是刷新时不再"裸屏 spinner → 外壳 → 内容"多段重绘,只有内容区一次替换。
if (!ready) return appShell(spinner);
if (!authed) return <Login onDone={onLoginDone} />;
return appShell(main);
}
+1
View File
@@ -25,6 +25,7 @@ export const LUCIDE: Record<string, string> = {
'shopping-bag': '<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/>',
'log-out': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
'external-link': '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
home: '<path d="M3 9.5 12 3l9 6.5"/><path d="M5 10v10a1 1 0 0 0 1 1h3v-6h6v6h3a1 1 0 0 0 1-1V10"/>',
gift: '<rect x="3" y="8" width="18" height="4" rx="1"/><path d="M12 8v13"/><path d="M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7"/><path d="M7.5 8a2.5 2.5 0 0 1 0-5A4.8 8 0 0 1 12 8a4.8 8 0 0 1 4.5-5 2.5 2.5 0 0 1 0 5"/>',
smartphone: '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
+10 -2
View File
@@ -17,6 +17,7 @@ import {
clearSession,
getAccessToken,
getRefreshToken,
setEmail,
setSession,
} from './session';
@@ -160,18 +161,25 @@ export class HttpClient implements ApiClient {
async refresh(): Promise<Session> {
const rt = getRefreshToken();
if (!rt) throw new ApiError({ code: 'unauthorized', message_zh: '登录已失效', message_en: 'Session expired' });
// 服务端 /v1/auth/refresh 从 **请求体** {refresh_token} 读取(与原生 Flutter 客户端
// auth_api.dart 一致);此处曾误用 X-Refresh-Token 头(仅 /auth/logout 读头),导致
// 服务端拿到空 token → 400 invalid_request → 每次刷新页面即被登出。改回 body。
const r = await this.request<RawTokenPair>('/v1/auth/refresh', {
method: 'POST',
auth: false,
allowRefresh: false,
refreshToken: rt,
body: { refresh_token: rt },
});
const session = mapSession(r);
setSession(session);
return session;
}
getMe = async (): Promise<Me> => mapMe(await this.request<RawMe>('/v1/me'));
getMe = async (): Promise<Me> => {
const me = mapMe(await this.request<RawMe>('/v1/me'));
setEmail(me.email); // 同源官网读取显示用户名;clearSession/logout 时删除
return me;
};
getSubscription = () => this.request<SubscriptionInfo>('/v1/me/subscription');
resetSubscription = () => this.request<SubscriptionInfo>('/v1/me/subscription/reset', { method: 'POST' });
listDevices = async (): Promise<Device[]> => {
+2 -1
View File
@@ -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<Me> {
await delay(260);
setEmail('me@pangolin.vpn'); // 同源官网读取显示用户名;clearSession/logout 时删除
return {
email: 'me@pangolin.vpn',
plan: 'pro',
+12
View File
@@ -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 */
}
+2
View File
@@ -12,6 +12,8 @@ export const STRINGS: Record<string, Entry> = {
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' },
+3 -1
View File
@@ -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 */
+13 -10
View File
@@ -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' },
];
---
<section id="docs">
@@ -20,13 +23,13 @@ const docs = [
</div>
<div class="wrap">
<div class="docs-grid">
{/* 文档页未就绪:卡片暂作信息展示(非链接),写好真实文档后改回 <a href> + 恢复「阅读」。 */}
{docs.map((d) => (
<div class="doc">
<a class="doc" href={`${base}/${d.slug}/`}>
<div class="ico"><Icon name={d.icon} /></div>
<h3>{t(d.t)}</h3>
<p>{t(d.d)}</p>
</div>
<span class="ln">{t('docs.read')}<Icon name="arrow-right" /></span>
</a>
))}
</div>
</div>
+1 -1
View File
@@ -12,7 +12,7 @@ const { t } = Astro.props;
<div>
<div class="nm">
<Brand variant="footer" size={28} />
穿山甲
{t('nav.brand')}
</div>
<p class="tag">{t('ft.tag')}</p>
</div>
+97 -10
View File
@@ -4,7 +4,7 @@
* 语言切换由原型的 JS 文本替换改为「路由跳转」(zh=/, en=/en/),单显不并排(铁律 6)。
*/
import { useEffect, useRef, useState } from 'react';
import { Download, Menu } from 'lucide-react';
import { Download, Menu, Sun, Moon } from 'lucide-react';
import { SITE } from '../config/site';
function Mark() {
@@ -30,7 +30,56 @@ 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('');
// 明/暗主题:默认浅色(不跟随系统),持久化 localStorage key pg_site_theme。
const [theme, setTheme] = useState('light');
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 = '/'; };
// 挂载时读 localStorage 同步按钮态(首屏无 FOUC 脚本已在 <head> 设好 data-theme)。
useEffect(() => {
try {
const saved = localStorage.getItem('pg_site_theme');
setTheme(saved === 'dark' ? 'dark' : 'light');
} catch { /* ignore */ }
}, []);
// 切换主题:写 <html data-theme> + localStorage,图标随态变。
const toggleTheme = () => {
setTheme((prev) => {
const next = prev === 'dark' ? 'light' : 'dark';
try { localStorage.setItem('pg_site_theme', next); } catch { /* ignore */ }
document.documentElement.dataset.theme = next;
return next;
});
};
useEffect(() => {
if (!langOpen) return;
@@ -41,6 +90,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 +125,7 @@ export default function Header({ lang = 'zh', t = {} }) {
<div class="wrap row">
<a class="brand" href="#top">
<Mark />
<span class="nm">穿山甲</span>
<span class="nm">{t.brand || 'Pangolin'}</span>
</a>
<nav class="nav">
{nav.map(([href, label]) => (
@@ -75,7 +133,15 @@ export default function Header({ lang = 'zh', t = {} }) {
))}
</nav>
<div class="right">
<div ref={langRef} style={{ position: 'relative', display: 'inline-block' }}>
<button
type="button"
class="themetoggle"
onClick={toggleTheme}
aria-label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
>
{theme === 'dark' ? <Sun /> : <Moon />}
</button>
<div ref={langRef} class="langwrap">
<button
type="button"
class="langsel"
@@ -83,21 +149,18 @@ export default function Header({ lang = 'zh', t = {} }) {
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' }}>
<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 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 }}>
<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)}
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' }}>
<a key={code} role="option" aria-selected={on} href={langHref(code)}>
{label}
</a>
);
@@ -105,7 +168,31 @@ export default function Header({ lang = 'zh', t = {} }) {
</div>
)}
</div>
<a class="linklogin" href={SITE.usercenter}>{t.login}</a>
{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>
+4 -1
View File
@@ -23,6 +23,7 @@ const plansData = {
feats: [t('pf.free1'), t('pf.free2'), t('pf.free3'), t('pf.free4')],
cta: t('price.cta_free'),
ctaClass: 'pcta-out',
href: '#download',
highlight: false,
},
{
@@ -33,6 +34,7 @@ const plansData = {
feats: [t('pf.pro1'), t('pf.pro2'), t('pf.pro3'), t('pf.pro4'), t('pf.pro5')],
cta: t('price.cta_pro'),
ctaClass: 'pcta-white',
href: '#get-code',
highlight: true,
},
{
@@ -43,6 +45,7 @@ const plansData = {
feats: [t('pf.team1'), t('pf.team2'), t('pf.team3'), t('pf.team4')],
cta: t('price.cta_team'),
ctaClass: 'pcta-fill',
href: '#get-code',
highlight: false,
},
],
@@ -75,7 +78,7 @@ function cell(v: string) {
<div class="wrap">
<!-- payment note -->
<div class="pay-note">
<div class="pay-note" id="get-code">
<Icon name="shield-check" />
<div>
<b>{t('pay.title')}</b><br>
+1 -1
View File
@@ -50,7 +50,7 @@ export default function PricingPlans({ data }) {
</li>
))}
</ul>
<button class={`pcta ${p.ctaClass}`}>{p.cta}</button>
<a href={p.href || '#get-code'} class={`pcta ${p.ctaClass}`}>{p.cta}</a>
</div>
))}
</div>
+8 -1
View File
@@ -38,7 +38,14 @@ export const STRINGS: Record<string, Record<Lang, string>> = {
'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<string, Record<Lang, string>> = {
'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' },
+79
View File
@@ -0,0 +1,79 @@
---
/**
* 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<Lang, string> = { 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')}`;
---
<!doctype html>
<html lang={HTML_LANG[lang]}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{/* 首屏渲染前设好 data-theme,避免明/暗切换闪烁(FOUC)。postbuild 会给此内联脚本加 CSP hash。 */}
<script is:inline>(function(){try{var t=localStorage.getItem('pg_site_theme');if(t==='dark'||t==='light')document.documentElement.dataset.theme=t;}catch(e){}})()</script>
<title>{metaTitle}</title>
{desc && <meta name="description" content={desc} />}
<meta name="robots" content="index,follow" />
<meta name="theme-color" content="#B96A3D" />
</head>
<body>
<Header client:load lang={lang} t={headerT} />
<main class="doc-page">
<div class="wrap">
<article class="doc-article">
<slot />
<a class="doc-back" href={backHref}>&larr; {t('nav.docs')}</a>
</article>
</div>
</main>
<Footer t={t} />
</body>
</html>
+8 -1
View File
@@ -56,6 +56,11 @@ const headerT = {
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'),
};
@@ -65,6 +70,8 @@ const headerT = {
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{/* 首屏渲染前设好 data-theme,避免明/暗切换闪烁(FOUC)。postbuild 会给此内联脚本加 CSP hash。 */}
<script is:inline>(function(){try{var t=localStorage.getItem('pg_site_theme');if(t==='dark'||t==='light')document.documentElement.dataset.theme=t;}catch(e){}})()</script>
<title>{t('meta.title')}</title>
<meta name="description" content={t('meta.desc')} />
<meta name="robots" content="index,follow" />
@@ -90,7 +97,7 @@ const headerT = {
<WhySignup t={t} />
<Pricing t={t} lang={lang} />
<Download t={t} />
<Docs t={t} />
<Docs t={t} lang={lang} />
<CtaBand t={t} />
<Footer t={t} />
</body>
+26
View File
@@ -0,0 +1,26 @@
---
import Doc from '../../layouts/Doc.astro';
---
<Doc lang="en" title="FAQ" desc="Common questions about accounts, plans, devices and activation codes.">
<div class="doc-eyebrow">Docs</div>
<h1>Frequently asked questions</h1>
<p class="doc-lede">Short answers to the questions we hear most about accounts, plans, devices and codes.</p>
<h2>How do I redeem an activation code?</h2>
<p>Open the app, go to the account or subscription screen, choose <strong>Redeem code</strong>, paste the code and confirm. Your plan upgrades immediately — no restart needed. Codes are obtained through external channels; there is no checkout on the website or in the app.</p>
<h2>Which platforms are supported?</h2>
<p>Windows, macOS, Android and iOS. One account works across all of them, and your plan and settings follow you between devices.</p>
<h2>What are the free plan limits?</h2>
<p>The free plan gives you 10 minutes of connection time per day on a single basic route, and asks you to watch a short ad before each session. Core encryption and our no-logs promise are included on every plan, free or paid. During the 7-day trial the time limit is lifted.</p>
<h2>How many devices can I use?</h2>
<p>Up to 5 devices on one account at the same time on the Pro plan. Sign in with the same email on each device to keep everything in sync.</p>
<h2>I forgot my password — what now?</h2>
<p>Accounts sign in by email verification code, so there is no fixed password to forget. Just request a fresh code at login and enter it to get back in.</p>
<h2>Why is there no payment button on the site?</h2>
<p>For risk and privacy reasons Pangolin never processes payments in the web or app. You get an activation code through an external channel and redeem it in the client — that keeps the payment flow entirely off our platform.</p>
</Doc>
+27
View File
@@ -0,0 +1,27 @@
---
import Doc from '../../layouts/Doc.astro';
---
<Doc lang="en" title="Privacy policy" desc="Exactly what we collect, what we never collect, and how it is stored.">
<div class="doc-eyebrow">Docs</div>
<h1>Privacy policy</h1>
<p class="doc-lede">Privacy protection is the baseline of this product, not a marketing line. Here is exactly what we do and don't collect, in plain language.</p>
<h2>What we collect</h2>
<p>We keep only the operational minimum needed to run your account and the service:</p>
<ul>
<li><strong>Account email</strong> — used to sign in, deliver verification codes and tie your plan to you.</li>
<li><strong>Device identifier</strong> — an anonymous ID used to enforce the device limit and sync your plan across devices.</li>
<li><strong>Usage statistics</strong> — coarse figures such as connection time and data volume, used for billing limits and capacity planning.</li>
</ul>
<h2>What we never collect</h2>
<p>We do not log the content of your traffic, the sites or apps you reach, DNS queries, or any browsing history. There is no per-request connection log tied to what you do online. Because we never gather this data, there is nothing of that kind to disclose or lose.</p>
<h2>How it is stored</h2>
<p>The limited data above is stored on our own infrastructure, encrypted in transit, and retained only as long as it is needed to operate your account and the service. Payments happen entirely through external channels, so no payment card details ever touch our systems.</p>
<div class="doc-card">
<h3>Questions?</h3>
<p>Reach out through any of the channels listed in the footer and we'll help clarify how your data is handled.</p>
</div>
</Doc>
+22
View File
@@ -0,0 +1,22 @@
---
import Doc from '../../layouts/Doc.astro';
---
<Doc lang="en" title="Protocol & security" desc="Our data plane runs on sing-box with the REALITY transport, plus a strict no-logs architecture.">
<div class="doc-eyebrow">Docs</div>
<h1>Protocol &amp; security</h1>
<p class="doc-lede">How we move your traffic quickly while keeping it private — the transport, the encryption, and the no-logs architecture behind it.</p>
<h2>Data plane: sing-box + REALITY</h2>
<p>Our data plane is built on <strong>sing-box</strong> and uses the <strong>REALITY</strong> transport. REALITY performs a genuine TLS handshake against a real destination, so accelerated traffic blends in with ordinary encrypted web traffic instead of standing out. The result is a connection that stays fast and reliable on demanding networks.</p>
<h2>End-to-end encryption</h2>
<p>Every session is encrypted from your device to the node. Encryption is the baseline for all traffic on every plan — it is not an add-on. Keys are negotiated per session, and the client configuration is rendered and delivered by our control plane rather than assembled on the device.</p>
<h2>No-logs architecture</h2>
<p>We do not record what you browse. Nodes forward traffic without keeping content or connection logs, and the system is designed so there is simply nothing sensitive to hand over. What we do keep is the operational minimum needed to run the service — see the <a href="/docs/privacy/">Privacy policy</a> for the exact list.</p>
<div class="doc-card">
<h3>Kill switch</h3>
<p>If the tunnel ever drops, the client blocks traffic instantly so your real IP is never exposed while the connection re-establishes.</p>
</div>
</Doc>
@@ -0,0 +1,22 @@
---
import Doc from '../../layouts/Doc.astro';
---
<Doc lang="en" title="Quickstart" desc="Sign up, download and make your first connection in three minutes.">
<div class="doc-eyebrow">Docs</div>
<h1>Quickstart</h1>
<p class="doc-lede">Get from zero to your first fast, stable connection in about three minutes — three steps, no configuration.</p>
<h2>1. Create an account</h2>
<p>Enter your email on the homepage or in the app and confirm the verification code we send you. Registration is free and needs nothing but an email — no payment details, ever. New accounts include a 7-day free trial with full access.</p>
<h2>2. Download the client</h2>
<p>Grab the app for your platform from the <a href="/#download">Download</a> section — Windows, macOS, Android and iOS are supported. One account syncs across every device, up to 5 at once.</p>
<h2>3. Log in and connect</h2>
<p>Open the app, sign in with the same email, and tap the connect button. The app smart-picks the fastest route for you; there is nothing to configure. When you see <strong>Connected</strong>, you're on an accelerated line.</p>
<div class="doc-card">
<h3>Upgrading later</h3>
<p>For privacy and risk reasons we never take payment inside the web or app. When you want more, obtain an activation code through an external channel and redeem it in the client to unlock unlimited data and top-speed routes.</p>
</div>
</Doc>
+26
View File
@@ -0,0 +1,26 @@
---
import Doc from '../../../layouts/Doc.astro';
---
<Doc lang="zh" title="常见问题" desc="关于账户、套餐、设备与激活码的常见疑问。">
<div class="doc-eyebrow">文档</div>
<h1>常见问题</h1>
<p class="doc-lede">关于账户、套餐、设备与兑换码,最常被问到的几个问题,简明作答。</p>
<h2>如何兑换激活码?</h2>
<p>打开 App,进入账户或订阅页,选择<strong>兑换激活码</strong>,粘贴激活码并确认,套餐即刻升级,无需重启。激活码通过外部渠道获取;网页与 App 内均不设收银台。</p>
<h2>支持哪些平台?</h2>
<p>Windows、macOS、Android 与 iOS。一个账户全平台通用,套餐与设置在各设备间同步。</p>
<h2>免费版有哪些限制?</h2>
<p>免费版每天可连接 10 分钟,仅含 1 个基础节点,且每次连接前需观看一段短广告。核心加密与无日志承诺在所有套餐(含免费版)中一视同仁。7 天试用期内不受时长限制。</p>
<h2>可以用几台设备?</h2>
<p>专业版一个账户最多 5 台设备同时在线。各设备用同一邮箱登录即可保持同步。</p>
<h2>忘记密码了怎么办?</h2>
<p>账户采用邮箱验证码登录,没有固定密码需要记忆。登录时重新获取一次验证码、输入即可进入。</p>
<h2>为什么网页上没有支付按钮?</h2>
<p>出于风控与隐私考虑,穿山甲不在网页或 App 内直接收款。你通过外部渠道获取激活码、在客户端内兑换 —— 资金流全程不经过我们的平台。</p>
</Doc>
@@ -0,0 +1,27 @@
---
import Doc from '../../../layouts/Doc.astro';
---
<Doc lang="zh" title="隐私政策" desc="我们收集什么、绝不收集什么,以及如何存储。">
<div class="doc-eyebrow">文档</div>
<h1>隐私政策</h1>
<p class="doc-lede">隐私保护是这款产品的底线,而非营销话术。以下用大白话说清我们收集与绝不收集的内容。</p>
<h2>我们收集什么</h2>
<p>我们只保留运行账户与服务所必需的最小信息:</p>
<ul>
<li><strong>账户邮箱</strong> —— 用于登录、发送验证码,以及将套餐与你绑定。</li>
<li><strong>设备标识</strong> —— 一个匿名 ID,用于限制设备数量、在多设备间同步套餐。</li>
<li><strong>用量统计</strong> —— 连接时长、流量等粗粒度数据,用于计费限额与容量规划。</li>
</ul>
<h2>我们绝不收集什么</h2>
<p>我们不记录你的流量内容、访问的网站或 App、DNS 查询,也不保留任何浏览历史;不存在与你上网行为绑定的逐条连接日志。因为我们从一开始就不采集这类数据,所以也没有这类数据可供交出或泄露。</p>
<h2>如何存储</h2>
<p>上述有限数据存放在我们自有的基础设施上,传输过程加密,且仅在运行账户与服务所需的期限内保留。收款全部经外部渠道完成,任何银行卡信息都不会触及我们的系统。</p>
<div class="doc-card">
<h3>还有疑问?</h3>
<p>通过页脚列出的任一渠道联系我们,我们会进一步说明你的数据是如何被处理的。</p>
</div>
</Doc>
@@ -0,0 +1,22 @@
---
import Doc from '../../../layouts/Doc.astro';
---
<Doc lang="zh" title="协议与安全" desc="数据面基于 sing-box + REALITY,配合严格无日志架构。">
<div class="doc-eyebrow">文档</div>
<h1>协议与安全</h1>
<p class="doc-lede">我们如何在保持极速的同时守护隐私 —— 传输方式、加密机制,以及背后的无日志架构。</p>
<h2>数据面:sing-box + REALITY</h2>
<p>我们的数据面基于 <strong>sing-box</strong>,传输采用 <strong>REALITY</strong>。REALITY 会与真实站点完成一次真正的 TLS 握手,使加速流量与普通加密网页流量融为一体、不易被区分,从而在苛刻网络下依然快速稳定。</p>
<h2>端到端加密</h2>
<p>每一次会话都从你的设备到节点全程加密。加密是所有套餐、所有流量的底线,而非附加项。密钥按会话协商,客户端配置由控制面渲染下发,而非在设备本地拼装。</p>
<h2>无日志架构</h2>
<p>我们不记录你浏览了什么。节点只做流量转发,不保留内容或连接日志;整套系统的设计初衷,就是让敏感数据「压根不存在、无从交出」。我们仅保留运行服务所必需的最小运营数据 —— 具体清单见<a href="/zh/docs/privacy/">隐私政策</a>。</p>
<div class="doc-card">
<h3>Kill Switch</h3>
<p>一旦隧道中断,客户端立即阻断网络,在连接重建期间杜绝真实 IP 泄露。</p>
</div>
</Doc>
@@ -0,0 +1,22 @@
---
import Doc from '../../../layouts/Doc.astro';
---
<Doc lang="zh" title="快速开始" desc="三分钟完成注册、下载与首次连接。">
<div class="doc-eyebrow">文档</div>
<h1>快速开始</h1>
<p class="doc-lede">三步走,约三分钟即可完成第一次极速、稳定的连接,全程无需任何配置。</p>
<h2>1. 注册账户</h2>
<p>在主页或 App 内填写邮箱,输入收到的验证码即可完成注册。注册免费,只要一个邮箱,无需任何付款信息。新账户还附赠 7 天免费试用,功能不设限。</p>
<h2>2. 下载客户端</h2>
<p>在<a href="/zh/#download">下载</a>区选择对应平台的安装包 —— 支持 Windows、macOS、Android 与 iOS。一个账户多端同步,最多 5 台设备同时在线。</p>
<h2>3. 登录并连接</h2>
<p>打开 App,用同一邮箱登录,点一下连接按钮即可。客户端会智能挑选最快线路,无需手动设置。当界面显示<strong>已连接</strong>,你就已经在加速线路上了。</p>
<div class="doc-card">
<h3>之后如何升级</h3>
<p>出于风控与隐私考虑,我们不在网页或 App 内直接收款。想要更多时,通过外部渠道获取激活码,在客户端内兑换即可解锁无限流量与极速线路。</p>
</div>
</Doc>
+119
View File
@@ -59,6 +59,125 @@
box-shadow: 0 1px 4px rgba(45, 30, 20, 0.08);
}
/* 登录态用户下拉菜单:复用 .langwrap/.langsel/.langmenu 视觉,追加用户名截断
与菜单内 <button>(切换用户 / 退出登录需 JS,非纯链接)的等价样式。 */
.usermenu .userbtn {
max-width: 168px;
}
.usermenu .uname {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.usermenu .langmenu button {
width: 100%;
text-align: left;
border: none;
cursor: pointer;
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);
}
.usermenu .langmenu button:hover {
background: var(--bg-subtle);
}
/* 文档正文页(/docs/*):承载标题层级 / 段落 / 列表 / 卡片的可读排版。 */
.doc-page {
padding: 56px 0 88px;
}
.doc-article {
max-width: 760px;
}
.doc-article .doc-eyebrow {
font-family: var(--font-sans);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
}
.doc-article h1 {
font-family: var(--font-display);
font-weight: 700;
font-size: 38px;
letter-spacing: -0.02em;
line-height: 1.15;
margin: 12px 0 0;
}
.doc-article .doc-lede {
font-size: 17px;
color: var(--fg2);
line-height: 1.6;
margin: 14px 0 0;
}
.doc-article h2 {
font-family: var(--font-display);
font-weight: 700;
font-size: 22px;
letter-spacing: -0.01em;
margin: 40px 0 0;
}
.doc-article h3 {
font-family: var(--font-display);
font-weight: 600;
font-size: 17px;
margin: 26px 0 0;
}
.doc-article p {
font-size: 15.5px;
color: var(--fg2);
line-height: 1.7;
margin: 12px 0 0;
}
.doc-article ul,
.doc-article ol {
margin: 12px 0 0;
padding-left: 22px;
color: var(--fg2);
}
.doc-article li {
font-size: 15.5px;
line-height: 1.7;
margin: 6px 0 0;
}
.doc-article a {
color: var(--accent);
font-weight: 600;
text-decoration: underline;
text-underline-offset: 3px;
}
.doc-article strong {
color: var(--fg1);
font-weight: 700;
}
.doc-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: 22px 24px;
box-shadow: var(--shadow-sm);
margin: 26px 0 0;
}
.doc-card h3 {
margin-top: 0;
}
.doc-back {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 44px;
font-size: 14px;
font-weight: 600;
color: var(--accent);
}
/* 视觉隐藏(无障碍用,当前未强依赖) */
.visually-hidden {
position: absolute;
+17 -1
View File
@@ -42,6 +42,22 @@ img,svg{display:block}
.linklogin{font-size:14.5px;font-weight:600;color:var(--fg1);cursor:pointer}
.linklogin:hover{color:var(--accent)}
/* 明/暗主题切换(顶栏图标按钮,随 token 自适配) */
.themetoggle{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;border:none;background:transparent;color:var(--fg2);cursor:pointer;padding:6px;transition:color var(--dur-fast) var(--ease-out)}
.themetoggle:hover{color:var(--accent)}
.themetoggle svg{width:17px;height:17px}
/* ---------- 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}
@@ -155,7 +171,7 @@ section{padding:88px 0}
.plan.feat-plan .feats li{color:rgba(255,255,255,.92)}
.plan .feats svg{width:16px;height:16px;color:var(--success);flex-shrink:0;margin-top:1px}
.plan.feat-plan .feats svg{color:#fff}
.plan .pcta{width:100%;border:none;border-radius:var(--radius-full);padding:13px;font-family:var(--font-sans);font-weight:700;font-size:14.5px;cursor:pointer}
.plan .pcta{display:block;width:100%;box-sizing:border-box;text-align:center;text-decoration:none;border:none;border-radius:var(--radius-full);padding:13px;font-family:var(--font-sans);font-weight:700;font-size:14.5px;cursor:pointer}
.pcta-fill{background:var(--accent);color:#fff}
.pcta-white{background:#fff;color:var(--clay-700)}
.pcta-out{background:transparent;color:var(--fg2);border:1.5px solid var(--border-strong)!important}