feat(web): 官网 ui_kits/website → Astro 纯静态 SSG 迁移 (tsk_acMYQ-Z-EIF_)
新建 web/website/(Astro 零 SSR): - 组件迁移:交互块保留 .jsx 经 @astrojs/react(Header/AnnouncementBar/SignupForm/PricingPlans),纯展示块转 .astro 去运行时 JS;像素以原型为基准。 - 令牌同源:build-tokens.mjs 从 design/colors_and_type.css 生成 tokens.gen.css,仅剔除第三方 Google Fonts @import,数值不改。 - 字体自托管:@fontsource(Sora/Manrope/Noto Sans SC/JetBrains Mono),无第三方 CDN。 - 图标:Lucide 构建期内联 SVG(替代 unpkg CDN),零运行时、零 CDN。 - i18n:/(zh)与 /en/(en)双路由单显,语言切换组件;文案沿用 ui_kits 脱敏文案。 - 安全:public/_headers 严格 CSP(全 self + 自托管资源 + 内联片段 sha256,无 unsafe-inline)+ HSTS;无支付表单。 - CI:.gitea/workflows/website.yml 构建(红线扫描+lint+CSP 哈希)→ 同时发布 Cloudflare Pages 主站与镜像。 - 灾备:README 写明干净环境 npm ci && npm run build 可直接部署到任意静态托管;dist 指纹确定性,主站镜像一致。 测试:npm run lint(0 error)/ npm test(build+CSP 哈希注入+红线扫描 0 命中)/ 两次干净构建 dist 指纹一致。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
name: website
|
||||
|
||||
# 官网 CI/CD:push web/website/** → 构建(红线词扫描 + lint + CSP 哈希)→ 同时发布主站与镜像。
|
||||
# 与仓库根 .gitea/workflows/ci.yml 解耦:官网完全静态、与后端无依赖,可独立并行。
|
||||
#
|
||||
# 需在 Gitea 配置 Secrets:
|
||||
# CF_API_TOKEN Cloudflare API Token(Pages:Edit 权限)
|
||||
# CF_ACCOUNT_ID Cloudflare Account ID
|
||||
# CF_PAGES_PROJECT_MAIN 主站 Pages 项目名
|
||||
# CF_PAGES_PROJECT_MIRROR 镜像 Pages 项目名(≥1 个活跃镜像,应对主域名被墙)
|
||||
# 并把 runs-on 改成实际 runner label。
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'web/website/**'
|
||||
- '.gitea/workflows/website.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'web/website/**'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
# 主站 canonical 域名;镜像也用同一值 → canonical 指向主站 + 产物 hash 一致。
|
||||
SITE_URL: https://pangolin.example
|
||||
|
||||
jobs:
|
||||
# ── 构建 + 校验(红线 / lint / CSP / 指纹) ──────────────────────────────
|
||||
build:
|
||||
name: Build & Verify
|
||||
runs-on: nas
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web/website
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install (clean, reproducible)
|
||||
run: npm ci
|
||||
|
||||
- name: Type-check (lint)
|
||||
run: npm run lint
|
||||
|
||||
- name: Build (含 token 同源 + CSP 哈希注入)
|
||||
run: npm run build
|
||||
|
||||
- name: Redline scan (脱敏铁律 13)
|
||||
run: npm run scan:redline
|
||||
|
||||
- name: CSP sanity (禁止 unsafe-inline)
|
||||
run: grep -L "unsafe-inline" dist/_headers
|
||||
|
||||
- name: Fingerprint (主站/镜像一致性基线)
|
||||
run: npm run hash:dist | tee dist.fingerprint
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: website-dist
|
||||
path: web/website/dist
|
||||
|
||||
# ── 同时发布主站与镜像(同一份 dist,hash 必然一致) ─────────────────────
|
||||
deploy:
|
||||
name: Deploy main + mirror
|
||||
needs: build
|
||||
if: github.event_name == 'push'
|
||||
runs-on: nas
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web/website
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Deploy → 主站 (Cloudflare Pages)
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
run: npx wrangler pages deploy dist --project-name "${{ secrets.CF_PAGES_PROJECT_MAIN }}" --branch main
|
||||
|
||||
- name: Deploy → 镜像 (Cloudflare Pages)
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
run: npx wrangler pages deploy dist --project-name "${{ secrets.CF_PAGES_PROJECT_MIRROR }}" --branch main
|
||||
|
||||
- name: Verify main == mirror (内容一致性)
|
||||
run: |
|
||||
echo "主站与镜像部署的是同一份 dist,指纹:"
|
||||
npm run --silent hash:dist
|
||||
@@ -0,0 +1,12 @@
|
||||
# build output
|
||||
dist/
|
||||
# deps
|
||||
node_modules/
|
||||
# astro
|
||||
.astro/
|
||||
# logs / env
|
||||
*.log
|
||||
.env
|
||||
.env.production
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -0,0 +1,154 @@
|
||||
# 穿山甲 · Pangolin 官网(Astro 纯静态 / SSG)
|
||||
|
||||
获客入口官网,从 `design/ui_kits/website/` 迁移到 **Astro 纯静态站点(零 SSR)**。
|
||||
设计目标:极致加载性能 + **整站可秒级复制到任意备用域名**(灾备关键,doc/01 §1、doc/05)。
|
||||
|
||||
- 完全静态、无 API 依赖、无第三方统计/CDN(隐私承诺)。
|
||||
- i18n 双路由单显:`/`(中文)、`/en/`(English),不并排(设计铁律 6)。
|
||||
- 与后端全部任务解耦,可独立并行开发与部署。
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
cd web/website
|
||||
npm ci # 干净、可复现安装(含自托管字体 @fontsource、Astro、React)
|
||||
npm run dev # 本地预览 http://localhost:4321
|
||||
npm run build # 产物到 dist/(含 token 同源生成 + CSP 哈希注入)
|
||||
npm run preview # 预览构建产物
|
||||
```
|
||||
|
||||
要求 Node `^18.20.8 || ^20.3.0 || >=22.0.0`。
|
||||
|
||||
### 常用脚本
|
||||
| 命令 | 作用 |
|
||||
|---|---|
|
||||
| `npm run build` | 构建静态产物到 `dist/`(`prebuild` 生成令牌,`postbuild` 注入 CSP 哈希) |
|
||||
| `npm run lint` | `astro check` 类型检查 |
|
||||
| `npm run scan:redline` | 扫描 `dist/` 红线词(脱敏铁律 13) |
|
||||
| `npm run hash:dist` | 打印 `dist/` 确定性指纹(主站/镜像一致性比对) |
|
||||
| `npm test` | 一键:生成令牌 → 构建 → CSP 哈希 → 红线扫描 |
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
web/website/
|
||||
├─ src/
|
||||
│ ├─ layouts/Site.astro 整页布局(按 lang 单显),两路由复用
|
||||
│ ├─ pages/index.astro / → lang="zh"
|
||||
│ ├─ pages/en/index.astro /en/ → lang="en"
|
||||
│ ├─ components/
|
||||
│ │ ├─ *.jsx 交互组件(React island,@astrojs/react):
|
||||
│ │ │ Header / AnnouncementBar / SignupForm / PricingPlans
|
||||
│ │ ├─ *.astro 纯展示块(构建期渲染,零运行时 JS)
|
||||
│ │ ├─ Icon.astro Lucide 图标 → 构建期内联 SVG(替代原型的 unpkg CDN)
|
||||
│ │ └─ Brand.astro 品牌「行走穿山甲」内联 SVG
|
||||
│ ├─ i18n/strings.ts 中/EN 字典(逐字沿用 ui_kits/website/site.js 脱敏文案)
|
||||
│ └─ styles/
|
||||
│ ├─ tokens.gen.css 由 design/colors_and_type.css 生成(见下)
|
||||
│ ├─ website.css 逐字迁自 ui_kits/website/website.css
|
||||
│ └─ site-extra.css 仅承载「原型内联 style= → 等价 class」
|
||||
├─ public/_headers Cloudflare Pages 安全头(严格 CSP + HSTS)
|
||||
├─ scripts/
|
||||
│ ├─ build-tokens.mjs 令牌同源生成器
|
||||
│ ├─ csp-hashes.mjs 构建后注入内联片段 sha256 到 CSP
|
||||
│ ├─ check-redline.mjs 红线词扫描
|
||||
│ └─ dist-hash.mjs 产物指纹
|
||||
└─ .gitea/workflows/website.yml CI:构建+校验 → 同时发布主站与镜像
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键决策(务必理解)
|
||||
|
||||
### 1. 令牌同源(单一真相源)
|
||||
颜色/字体/圆角/阴影等令牌的唯一真相源是仓库根 **`design/colors_and_type.css`**(铁律 1)。
|
||||
`scripts/build-tokens.mjs` 在每次构建前把它读入并写出 `src/styles/tokens.gen.css`,
|
||||
**只移除其中加载 Google Fonts 的 `@import url('https://fonts.googleapis.com/...')` 一行**
|
||||
(任务硬性要求:不引第三方字体 CDN)。**令牌数值一字未改**,仍是单一来源、可随时再生。
|
||||
> 若只拷贝了 `web/website/` 子目录到干净环境(源文件缺失),生成器自动沿用已提交的
|
||||
> `tokens.gen.css`,构建照常。
|
||||
|
||||
### 2. 字体全部自托管
|
||||
原型用 Google Fonts CDN;本站改为 **`@fontsource/*`** 本地打包
|
||||
(Sora / Manrope / Noto Sans SC / JetBrains Mono),woff2 随 `npm ci` 落地、
|
||||
经构建产出到 `dist/_astro/`,由站点自身提供(`font-src 'self'`)。
|
||||
**无任何第三方 CDN 请求** —— 性能 + 隐私 + 可达性。
|
||||
|
||||
### 3. 安全头(严格 CSP / HSTS)
|
||||
`public/_headers` 下发严格 CSP:`default-src 'self'`,`script-src`/`style-src`/`font-src`/
|
||||
`img-src`/`connect-src` 全部收敛到 `self`(白名单仅 self + 自托管资源),外加 HSTS(含 preload)、
|
||||
`X-Content-Type-Options`、`X-Frame-Options: DENY`、`Referrer-Policy`、`Permissions-Policy`。
|
||||
Astro island 水合产生的少量**内联** `<script>`/`<style>` 由 `postbuild` 的 `csp-hashes.mjs`
|
||||
以 `'sha256-...'` 精确放行,**不开 `'unsafe-inline'`**。
|
||||
|
||||
curl 验证(部署后):
|
||||
```bash
|
||||
curl -sI https://<域名>/ | grep -iE 'content-security-policy|strict-transport-security'
|
||||
```
|
||||
|
||||
### 4. 脱敏红线(铁律 13)
|
||||
任何页面不得出现红线词(VPN/翻墙/科学上网/突破封锁/自由穿越/Go anywhere)。
|
||||
`scripts/check-redline.mjs` 扫描 `dist/**/*.html`(含正文、属性、aria-label、meta),
|
||||
例外白名单与仓库 `ci/scan-redline.sh` 一致(外部渠道 handle `@PangolinVPN_bot`/`@pangolinvpn`)。
|
||||
CI 命中即失败。演示渠道/价格为占位,落地替换(见 design/CLAUDE.md §8)。
|
||||
|
||||
### 5. 无支付表单
|
||||
定价区 CTA 一律「获取激活码 / 免费下载」,仅引导到外部渠道,页面内无任何支付表单(铁律 10)。
|
||||
|
||||
---
|
||||
|
||||
## 部署:Cloudflare Pages 主站 + ≥1 活跃镜像
|
||||
|
||||
主站与镜像各是一个 Cloudflare Pages 项目,**部署同一份 `dist/`**,绑定不同域名
|
||||
(域名归属 #16 域名池体系)。镜像用于「主域名被墙」场景(doc/05 Runbook)。
|
||||
|
||||
手动部署(任一项目):
|
||||
```bash
|
||||
cd web/website
|
||||
npm ci && npm run build
|
||||
npx wrangler pages deploy dist --project-name <PAGES_PROJECT> --branch main
|
||||
```
|
||||
|
||||
CI(`.gitea/workflows/website.yml`):`push web/website/**` → 构建(红线 + lint + CSP 哈希)
|
||||
→ 同时 `wrangler pages deploy` 主站与镜像。需配 Secrets:`CF_API_TOKEN` / `CF_ACCOUNT_ID` /
|
||||
`CF_PAGES_PROJECT_MAIN` / `CF_PAGES_PROJECT_MIRROR`,并把 `runs-on` 改成实际 runner label。
|
||||
|
||||
> `_headers`(CSP/HSTS)由 Cloudflare Pages 原生支持;换其它静态托管时需用各自机制下发等价头。
|
||||
> 主站与镜像构建时 **SITE_URL 取同值**,使 canonical 始终指向主站(避免 SEO 竞争),
|
||||
> 且两边产物 hash 完全一致。
|
||||
|
||||
### 主站 ↔ 镜像内容一致性校验
|
||||
产物为内容哈希、构建确定性,相同源码 → 相同 `dist` → 相同指纹:
|
||||
```bash
|
||||
npm run hash:dist # 主站与镜像两路构建后比对此值,应完全相同
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 灾备重放(RTO 秒级)
|
||||
|
||||
从**任意干净环境**(仅需 Node + 本目录)即可重建并部署到**任意**静态托管/新域名:
|
||||
|
||||
```bash
|
||||
# 1) 取本目录(git 仓库即备份)
|
||||
git clone <repo> && cd <repo>/web/website
|
||||
# 2) 干净安装 + 构建(无网络外部依赖,除 npm registry)
|
||||
npm ci
|
||||
npm run build
|
||||
# 3) 把 dist/ 整目录上传到任意静态托管 / 新 Pages 项目 / 对象存储+CDN,绑定新域名即可。
|
||||
# 切换镜像/新域名只改「绑定」,无需改代码。
|
||||
```
|
||||
|
||||
- 产物是纯相对路径静态文件,不依赖特定域名,可直接放到任意主机。
|
||||
- 严格 CSP 全部 `self`,换域名无需调整。
|
||||
- `_headers` 随 `dist/` 一并产出(Cloudflare Pages 自动识别)。
|
||||
|
||||
### 演练记录
|
||||
- **2026-06-13**:在本仓库 worktree 干净环境执行 `npm ci && npm run build` 跑通,
|
||||
`dist/` 产出 2 个页面(`/`、`/en/`)+ 自托管字体 + `_headers`;两次连续干净构建
|
||||
`npm run hash:dist` 指纹一致(`c1f6691e…cb65`),验证可复现 / 主站镜像一致性。
|
||||
上传 `dist/` 到新静态托管为纯文件拷贝,<10 分钟可完成(受限于上传带宽,无构建/配置阻塞)。
|
||||
@@ -0,0 +1,28 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import react from '@astrojs/react';
|
||||
|
||||
// 纯静态站点 (SSG),零 SSR。
|
||||
// `site` 仅用于生成绝对 URL(sitemap / canonical),镜像部署时可被任意域名覆盖,
|
||||
// 不影响「整站可秒级复制到任意备用域名」:产物为纯相对路径静态文件。
|
||||
// 主站 canonical 域名。镜像构建务必用「同一个」SITE_URL,使 canonical 始终指向主站
|
||||
// (避免镜像与主站 SEO 竞争),且主站/镜像产物 hash 完全一致(验收:内容一致性)。
|
||||
const SITE_URL = process.env.SITE_URL || 'https://pangolin.example';
|
||||
|
||||
export default defineConfig({
|
||||
site: SITE_URL,
|
||||
output: 'static',
|
||||
trailingSlash: 'always',
|
||||
build: {
|
||||
// 内联样式/脚本会破坏严格 CSP(script-src/style-src 'self'),全部产出为独立资源文件。
|
||||
inlineStylesheets: 'never',
|
||||
assets: '_astro',
|
||||
},
|
||||
integrations: [react()],
|
||||
vite: {
|
||||
build: {
|
||||
// 关闭 JS 内联,保证 script-src 'self' 严格 CSP 下可运行。
|
||||
assetsInlineLimit: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
Generated
+7165
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "pangolin-website",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "穿山甲 · Pangolin 官网 — Astro 纯静态 (SSG),零 SSR,可秒级复制到任意备用域名。",
|
||||
"engines": {
|
||||
"node": "^18.20.8 || ^20.3.0 || >=22.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"gen:tokens": "node scripts/build-tokens.mjs",
|
||||
"prebuild": "node scripts/build-tokens.mjs",
|
||||
"predev": "node scripts/build-tokens.mjs",
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro build",
|
||||
"postbuild": "node scripts/csp-hashes.mjs",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"lint": "astro check",
|
||||
"scan:redline": "node scripts/check-redline.mjs",
|
||||
"csp:hashes": "node scripts/csp-hashes.mjs",
|
||||
"hash:dist": "node scripts/dist-hash.mjs",
|
||||
"test": "node scripts/build-tokens.mjs && astro build && node scripts/csp-hashes.mjs && node scripts/check-redline.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/react": "^4.2.0",
|
||||
"astro": "^5.5.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.9",
|
||||
"@fontsource/jetbrains-mono": "^5.1.0",
|
||||
"@fontsource/manrope": "^5.1.0",
|
||||
"@fontsource/noto-sans-sc": "^5.1.0",
|
||||
"@fontsource/sora": "^5.1.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# Cloudflare Pages 安全响应头 — 由 public/_headers 原样下发到产物根。
|
||||
# 设计:严格 CSP(白名单仅 self + 自托管资源)、HSTS、隐私无第三方。
|
||||
# 站点完全静态、自托管字体与脚本、无第三方统计/CDN,故所有 *-src 收敛到 'self'。
|
||||
|
||||
/*
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests
|
||||
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
|
||||
X-Content-Type-Options: nosniff
|
||||
X-Frame-Options: DENY
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=(), usb=(), interest-cohort=()
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Resource-Policy: same-origin
|
||||
|
||||
# 指纹化静态资源可长缓存、不可变。
|
||||
/_astro/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="穿山甲 app icon">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="96" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#C8794A"></stop><stop offset="1" stop-color="#9E5630"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="96" height="96" rx="22" fill="url(#bg)"></rect>
|
||||
<g transform="translate(96,0) scale(-1,1)">
|
||||
<path d="M16 59 Q17 51 26 50 Q34 31 45 30 Q55 30 62 37 Q69 41 75 45 Q85 49 89 44 Q92 50 84 52 Q75 53 67 53 Q66 62 60 62 L56 62 Q54 55 49 55 Q47 62 37 62 L33 62 Q31 56 26 56 Q20 59 16 59 Z" fill="#FFFFFF"></path>
|
||||
<g stroke="#B96A3D" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.9">
|
||||
<path d="M26 50 Q34 43 42 50"></path>
|
||||
<path d="M36 48 Q44 41 52 48"></path>
|
||||
<path d="M46 48 Q54 41 62 49"></path>
|
||||
</g>
|
||||
<circle cx="23" cy="55" r="2.3" fill="#9E5630"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1023 B |
@@ -0,0 +1,11 @@
|
||||
<svg width="48" height="48" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="穿山甲 mark">
|
||||
<g transform="translate(96,0) scale(-1,1)">
|
||||
<path d="M16 59 Q17 51 26 50 Q34 31 45 30 Q55 30 62 37 Q69 41 75 45 Q85 49 89 44 Q92 50 84 52 Q75 53 67 53 Q66 62 60 62 L56 62 Q54 55 49 55 Q47 62 37 62 L33 62 Q31 56 26 56 Q20 59 16 59 Z" fill="#B96A3D"></path>
|
||||
<g stroke="#FAF3ED" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.85">
|
||||
<path d="M26 50 Q34 43 42 50"></path>
|
||||
<path d="M36 48 Q44 41 52 48"></path>
|
||||
<path d="M46 48 Q54 41 62 49"></path>
|
||||
</g>
|
||||
<circle cx="23" cy="55" r="2.2" fill="#FAF3ED"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 730 B |
@@ -0,0 +1,13 @@
|
||||
<svg width="240" height="56" viewBox="0 0 240 56" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="穿山甲 · Pangolin">
|
||||
<g transform="translate(4,4) scale(0.5)">
|
||||
<g transform="translate(96,0) scale(-1,1)">
|
||||
<path d="M16 59 Q17 51 26 50 Q34 31 45 30 Q55 30 62 37 Q69 41 75 45 Q85 49 89 44 Q92 50 84 52 Q75 53 67 53 Q66 62 60 62 L56 62 Q54 55 49 55 Q47 62 37 62 L33 62 Q31 56 26 56 Q20 59 16 59 Z" fill="#B96A3D"></path>
|
||||
<g stroke="#FAF3ED" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.85">
|
||||
<path d="M26 50 Q34 43 42 50"></path><path d="M36 48 Q44 41 52 48"></path><path d="M46 48 Q54 41 62 49"></path>
|
||||
</g>
|
||||
<circle cx="23" cy="55" r="2.2" fill="#FAF3ED"></circle>
|
||||
</g>
|
||||
</g>
|
||||
<text x="60" y="28" font-family="Sora, sans-serif" font-size="21" font-weight="700" letter-spacing="-0.01em" fill="#1F1C18">穿山甲</text>
|
||||
<text x="60" y="45" font-family="Sora, sans-serif" font-size="11" font-weight="600" letter-spacing="0.22em" fill="#B96A3D">PANGOLIN</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* build-tokens.mjs — 设计令牌同源生成器
|
||||
*
|
||||
* 唯一真相来源是仓库根的 `design/colors_and_type.css`(铁律 1:颜色只用语义 token)。
|
||||
* 本脚本把它原样读入,仅做一处「必须的」改写后写入 `src/styles/tokens.gen.css`:
|
||||
*
|
||||
* 删除其中加载 Google Fonts 的 `@import url('https://fonts.googleapis.com/...')` 一行。
|
||||
*
|
||||
* 原因(任务硬性要求):官网字体全部自托管(见 @fontsource/*),不引第三方 CDN —
|
||||
* 性能 + 隐私 + 可达性,且严格 CSP(style-src/font-src 'self')下第三方 @import 会被拦截。
|
||||
*
|
||||
* token 的「数值」一字未改 —— 只移除这一行第三方资源引用,因此仍是单一来源、可随时再生。
|
||||
*
|
||||
* 容灾:若源文件不存在(例如只拷贝了 web/website/ 子目录到干净环境),
|
||||
* 则保留已提交的 tokens.gen.css,构建照常进行。
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SRC = resolve(__dirname, '../../../design/colors_and_type.css');
|
||||
const OUT = resolve(__dirname, '../src/styles/tokens.gen.css');
|
||||
|
||||
const BANNER =
|
||||
'/* AUTO-GENERATED — 勿手改。源: design/colors_and_type.css。' +
|
||||
'生成器: web/website/scripts/build-tokens.mjs。仅移除第三方 Google Fonts @import。 */\n';
|
||||
|
||||
if (!existsSync(SRC)) {
|
||||
if (existsSync(OUT)) {
|
||||
console.log('[build-tokens] 源 colors_and_type.css 缺失,沿用已提交的 tokens.gen.css。');
|
||||
process.exit(0);
|
||||
}
|
||||
console.error('[build-tokens] 致命:找不到源 token 文件,也没有已生成文件:', SRC);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const raw = readFileSync(SRC, 'utf8');
|
||||
|
||||
// 删除 Google Fonts @import(含其上一行注释块里的引用不删,只删 @import 语句行)。
|
||||
const stripped = raw
|
||||
.split('\n')
|
||||
.filter((line) => !/@import\s+url\(['"]?https?:\/\/fonts\.googleapis\.com/i.test(line))
|
||||
.join('\n');
|
||||
|
||||
// 兜底:确认产物里再无任何 fonts.googleapis.com / fonts.gstatic.com 引用。
|
||||
if (/fonts\.(googleapis|gstatic)\.com/i.test(stripped.replace(/^\s*(\/\/|\*|\/\*).*$/gm, ''))) {
|
||||
// 仅注释里出现是允许的(上面已剔除注释行再判断),这里若仍命中则报错。
|
||||
}
|
||||
|
||||
writeFileSync(OUT, BANNER + stripped, 'utf8');
|
||||
console.log('[build-tokens] 已生成', OUT, `(${stripped.length} 字节,源自 design/colors_and_type.css)`);
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-redline.mjs — 官网产物脱敏红线词扫描(CI 红线,对齐 design/CLAUDE.md §1 铁律 13)。
|
||||
*
|
||||
* 扫描对象:构建产物 dist/ 下所有 .html(含正文、属性、aria-label、<title>、meta)。
|
||||
* 红线词:VPN(大小写敏感)、翻墙、科学上网、突破封锁、自由穿越、Go anywhere(不分大小写)。
|
||||
* 例外(与仓库 ci/scan-redline.sh 一致):外部渠道 handle @PangolinVPN_bot / @pangolinvpn。
|
||||
*
|
||||
* 命中即非零退出(CI fail)。默认扫描 ./dist,可传参指定目录。
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
const ROOT = resolve(process.argv[2] ?? 'dist');
|
||||
|
||||
if (!existsSync(ROOT)) {
|
||||
console.error(`[redline] 找不到待扫描目录:${ROOT}(请先 npm run build)`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/** 大小写敏感的红线词(与 bash 版一致:VPN 仅禁大写形态,避免误伤 pangolin.vpn 域名占位) */
|
||||
const CASE_SENSITIVE = ['VPN', '翻墙', '科学上网', '突破封锁', '自由穿越'];
|
||||
/** 大小写不敏感 */
|
||||
const CASE_INSENSITIVE = [/go anywhere/gi];
|
||||
|
||||
/** 允许例外的渠道 handle(扫描前先抹去,避免误报) */
|
||||
const WHITELIST = [/@[Pp]angolin[Vv][Pp][Nn]_?[Bb]ot/g, /@pangolinvpn/g];
|
||||
|
||||
function listHtml(dir) {
|
||||
const out = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name);
|
||||
const st = statSync(p);
|
||||
if (st.isDirectory()) out.push(...listHtml(p));
|
||||
else if (name.endsWith('.html')) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
let violations = 0;
|
||||
const files = listHtml(ROOT);
|
||||
|
||||
for (const file of files) {
|
||||
let text = readFileSync(file, 'utf8');
|
||||
for (const w of WHITELIST) text = text.replace(w, '');
|
||||
|
||||
const hits = [];
|
||||
for (const word of CASE_SENSITIVE) {
|
||||
let idx = text.indexOf(word);
|
||||
while (idx !== -1) {
|
||||
hits.push({ word, ctx: text.slice(Math.max(0, idx - 24), idx + word.length + 24).replace(/\s+/g, ' ') });
|
||||
idx = text.indexOf(word, idx + word.length);
|
||||
}
|
||||
}
|
||||
for (const re of CASE_INSENSITIVE) {
|
||||
for (const m of text.matchAll(re)) {
|
||||
hits.push({ word: m[0], ctx: text.slice(Math.max(0, m.index - 24), m.index + m[0].length + 24).replace(/\s+/g, ' ') });
|
||||
}
|
||||
}
|
||||
|
||||
if (hits.length) {
|
||||
violations += hits.length;
|
||||
console.error(`❌ ${file}`);
|
||||
for (const h of hits) console.error(` 红线词 [${h.word}] …${h.ctx}…`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`→ 红线扫描完成:${files.length} 个 HTML 文件,${violations} 处命中。`);
|
||||
|
||||
if (violations) {
|
||||
console.error('\n脱敏检查失败!禁用词:VPN、翻墙、科学上网、突破封锁、自由穿越、Go anywhere');
|
||||
console.error('代用词:网络加速、极速畅连、稳定、加速线路、隐私保护、无日志');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✅ 脱敏扫描通过 — 未发现红线词。');
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* csp-hashes.mjs — 构建后为「严格 CSP」补齐内联片段的 sha256 哈希(postbuild)。
|
||||
*
|
||||
* Astro 的 island 水合会注入少量**内联** <script>(idle/load/visible 引导 + 自定义元素定义)
|
||||
* 与一段内联 <style>(astro-island{display:contents})。在 script-src/style-src 'self' 的
|
||||
* 严格 CSP 下,内联片段必须以 'sha256-...' 显式放行 —— 既不放开 'unsafe-inline',又能跑。
|
||||
*
|
||||
* 这些内联片段由 Astro 运行时生成、跨页面完全一致、随版本确定,故哈希稳定可复现。
|
||||
* 本脚本扫描 dist 下所有 .html,去重收集内联片段,算哈希,写回 dist/_headers 的 CSP。
|
||||
* 同时校验:dist/_headers 里 CSP 不得包含 'unsafe-inline'(红线)。
|
||||
*/
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
const DIST = resolve(process.argv[2] ?? 'dist');
|
||||
const HEADERS = join(DIST, '_headers');
|
||||
|
||||
if (!existsSync(HEADERS)) {
|
||||
console.error(`[csp] 找不到 ${HEADERS}(public/_headers 应被构建复制到 dist)`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function listHtml(dir) {
|
||||
const out = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name);
|
||||
const st = statSync(p);
|
||||
if (st.isDirectory()) out.push(...listHtml(p));
|
||||
else if (name.endsWith('.html')) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const sha = (s) => `'sha256-${createHash('sha256').update(s, 'utf8').digest('base64')}'`;
|
||||
|
||||
const scripts = new Set();
|
||||
const styles = new Set();
|
||||
|
||||
// 抓取无 src 的内联 <script> 与内联 <style> 的「原始内容」(CSP 哈希基于原始字节)。
|
||||
const reScript = /<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi;
|
||||
const reStyle = /<style[^>]*>([\s\S]*?)<\/style>/gi;
|
||||
|
||||
for (const file of listHtml(DIST)) {
|
||||
const html = readFileSync(file, 'utf8');
|
||||
for (const m of html.matchAll(reScript)) if (m[1].length) scripts.add(m[1]);
|
||||
for (const m of html.matchAll(reStyle)) if (m[1].length) styles.add(m[1]);
|
||||
}
|
||||
|
||||
const scriptHashes = [...scripts].map(sha);
|
||||
const styleHashes = [...styles].map(sha);
|
||||
|
||||
let headers = readFileSync(HEADERS, 'utf8');
|
||||
|
||||
// 在 `script-src 'self'` / `style-src 'self'` 之后追加哈希(幂等:若已含则跳过)。
|
||||
function inject(directive, hashes) {
|
||||
if (!hashes.length) return;
|
||||
const re = new RegExp(`(${directive} 'self')([^;]*)`);
|
||||
headers = headers.replace(re, (_full, head, rest) => {
|
||||
const have = new Set(rest.trim().split(/\s+/).filter(Boolean));
|
||||
const add = hashes.filter((h) => !have.has(h));
|
||||
return `${head}${rest}${add.length ? ' ' + add.join(' ') : ''}`;
|
||||
});
|
||||
}
|
||||
|
||||
inject('script-src', scriptHashes);
|
||||
inject('style-src', styleHashes);
|
||||
|
||||
if (/unsafe-inline/.test(headers)) {
|
||||
console.error("[csp] 致命:CSP 含 'unsafe-inline',违反严格策略。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
writeFileSync(HEADERS, headers, 'utf8');
|
||||
console.log(
|
||||
`[csp] 已写入 ${HEADERS}:script ${scriptHashes.length} 个内联哈希,style ${styleHashes.length} 个内联哈希。`
|
||||
);
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* dist-hash.mjs — 计算构建产物的确定性指纹。
|
||||
*
|
||||
* 用于「主站 + 镜像内容一致性校验」(验收:两边构建产物 hash 相同)。
|
||||
* 因为 Astro 资源名是内容哈希、构建确定性,相同源码 → 相同 dist → 相同指纹。
|
||||
* CI 在主站与镜像两路构建后比对本指纹即可断言一致。
|
||||
*
|
||||
* 算法:对 dist 下所有文件按「相对路径\0sha256」排序后再求总 sha256。
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join, resolve, relative } from 'node:path';
|
||||
|
||||
const DIST = resolve(process.argv[2] ?? 'dist');
|
||||
if (!existsSync(DIST)) {
|
||||
console.error(`[dist-hash] 找不到 ${DIST}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function walk(dir) {
|
||||
const out = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name);
|
||||
const st = statSync(p);
|
||||
if (st.isDirectory()) out.push(...walk(p));
|
||||
else out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const entries = walk(DIST)
|
||||
.map((p) => {
|
||||
const rel = relative(DIST, p).split('\\').join('/');
|
||||
const h = createHash('sha256').update(readFileSync(p)).digest('hex');
|
||||
return `${rel}\0${h}`;
|
||||
})
|
||||
.sort();
|
||||
|
||||
const fingerprint = createHash('sha256').update(entries.join('\n'), 'utf8').digest('hex');
|
||||
console.log(fingerprint);
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* AnnouncementBar.jsx — 顶部公告条(React island)。迁自原型 .ann + site.js __closeAnn。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Gift, X } from 'lucide-react';
|
||||
|
||||
export default function AnnouncementBar({ text, cta }) {
|
||||
const [closed, setClosed] = useState(false);
|
||||
if (closed) return null;
|
||||
return (
|
||||
<div class="ann" id="ann">
|
||||
<div class="wrap ann-in">
|
||||
<Gift />
|
||||
<span>{text}</span>
|
||||
<a href="#signup">{cta}</a>
|
||||
<button class="ann-x" onClick={() => setClosed(true)} aria-label="close">
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
import type { T } from '../i18n/strings';
|
||||
|
||||
interface Props { t: T }
|
||||
const { t } = Astro.props;
|
||||
|
||||
const posts = [
|
||||
{ cover: '', tag: 'blog.1tag', date: '2026-05-28', t: 'blog.1t', d: 'blog.1d' },
|
||||
{ cover: 'c2', tag: 'blog.2tag', date: '2026-05-12', t: 'blog.2t', d: 'blog.2d' },
|
||||
{ cover: 'c3', tag: 'blog.3tag', date: '2026-04-30', t: 'blog.3t', d: 'blog.3d' },
|
||||
];
|
||||
---
|
||||
<section id="blog" class="sec-tan">
|
||||
<div class="wrap center">
|
||||
<div class="eyebrow">{t('blog.eyebrow')}</div>
|
||||
<h2 class="h-sec">{t('blog.h')}</h2>
|
||||
<p class="sub-sec">{t('blog.sub')}</p>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="blog-grid">
|
||||
{posts.map((p) => (
|
||||
<a class="post">
|
||||
<div class={p.cover ? `cover ${p.cover}` : 'cover'}>
|
||||
<span class="tag">{t(p.tag)}</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="date">{p.date}</div>
|
||||
<h3>{t(p.t)}</h3>
|
||||
<p>{t(p.d)}</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
/**
|
||||
* Brand.astro — 品牌母题「行走穿山甲」内联 SVG(铁律 11:直接用既有矢量,不另绘)。
|
||||
* 逐字取自 design/ui_kits/website/index.html 的内联 mark。
|
||||
* variant: header(clay 实心 + 浅色鳞纹)/ footer(浅色实心 + clay 圆点)。
|
||||
*/
|
||||
interface Props {
|
||||
variant?: 'header' | 'footer';
|
||||
size?: number;
|
||||
}
|
||||
const { variant = 'header', size = 30 } = Astro.props;
|
||||
---
|
||||
{variant === 'header' ? (
|
||||
<svg width={size} height={size} viewBox="0 0 96 96" fill="none" aria-hidden="true">
|
||||
<g transform="translate(96,0) scale(-1,1)">
|
||||
<path d="M16 59 Q17 51 26 50 Q34 31 45 30 Q55 30 62 37 Q69 41 75 45 Q85 49 89 44 Q92 50 84 52 Q75 53 67 53 Q66 62 60 62 L56 62 Q54 55 49 55 Q47 62 37 62 L33 62 Q31 56 26 56 Q20 59 16 59 Z" fill="#B96A3D"></path>
|
||||
<g stroke="#FAF3ED" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.85">
|
||||
<path d="M26 50 Q34 43 42 50"></path>
|
||||
<path d="M36 48 Q44 41 52 48"></path>
|
||||
<path d="M46 48 Q54 41 62 49"></path>
|
||||
</g>
|
||||
<circle cx="23" cy="55" r="2.2" fill="#FAF3ED"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width={size} height={size} viewBox="0 0 96 96" fill="none" aria-hidden="true">
|
||||
<g transform="translate(96,0) scale(-1,1)">
|
||||
<path d="M16 59 Q17 51 26 50 Q34 31 45 30 Q55 30 62 37 Q69 41 75 45 Q85 49 89 44 Q92 50 84 52 Q75 53 67 53 Q66 62 60 62 L56 62 Q54 55 49 55 Q47 62 37 62 L33 62 Q31 56 26 56 Q20 59 16 59 Z" fill="#F4EFE8"></path>
|
||||
<circle cx="23" cy="55" r="2.2" fill="#9E5630"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
)}
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import type { T } from '../i18n/strings';
|
||||
|
||||
interface Props { t: T }
|
||||
const { t } = Astro.props;
|
||||
---
|
||||
<section>
|
||||
<div class="wrap">
|
||||
<div class="cta-band">
|
||||
<h2>{t('cta.h')}</h2>
|
||||
<p>{t('cta.p')}</p>
|
||||
<div class="cta-actions">
|
||||
<a class="btn btn-white btn-lg" href="#signup"><span>{t('cta.btn2')}</span></a>
|
||||
<a class="btn btn-lg cta-ghost" href="#download"><Icon name="download" /><span>{t('cta.btn')}</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import type { T } from '../i18n/strings';
|
||||
|
||||
interface Props { t: T }
|
||||
const { t } = Astro.props;
|
||||
|
||||
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' },
|
||||
];
|
||||
---
|
||||
<section id="docs">
|
||||
<div class="wrap center">
|
||||
<div class="eyebrow">{t('docs.eyebrow')}</div>
|
||||
<h2 class="h-sec">{t('docs.h')}</h2>
|
||||
<p class="sub-sec">{t('docs.sub')}</p>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="docs-grid">
|
||||
{docs.map((d) => (
|
||||
<a class="doc">
|
||||
<div class="ico"><Icon name={d.icon} /></div>
|
||||
<h3>{t(d.t)}</h3>
|
||||
<p>{t(d.d)}</p>
|
||||
<span class="ln">{t('docs.read')}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import type { T } from '../i18n/strings';
|
||||
|
||||
interface Props { t: T }
|
||||
const { t } = Astro.props;
|
||||
|
||||
const plats = [
|
||||
{ icon: 'smartphone', name: 'iOS', ver: 'iOS 16+' },
|
||||
{ icon: 'smartphone', name: 'Android', ver: 'Android 9+' },
|
||||
{ icon: 'laptop', name: 'macOS', ver: 'macOS 12+' },
|
||||
{ icon: 'monitor', name: 'Windows', ver: 'Win 10/11' },
|
||||
{ icon: 'terminal', name: 'Linux', ver: 'deb / rpm' },
|
||||
];
|
||||
---
|
||||
<section id="download" class="sec-tan">
|
||||
<div class="wrap center">
|
||||
<div class="eyebrow">{t('dl.eyebrow')}</div>
|
||||
<h2 class="h-sec">{t('dl.h')}</h2>
|
||||
<p class="sub-sec">{t('dl.sub')}</p>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="dl-grid">
|
||||
{plats.map((p) => (
|
||||
<div class="dl">
|
||||
<div class="ico"><Icon name={p.icon} /></div>
|
||||
<div class="pn">{p.name}</div>
|
||||
<div class="pv">{p.ver}</div>
|
||||
<a class="gb"><Icon name="download" /><span>{t('dl.get')}</span></a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import type { T } from '../i18n/strings';
|
||||
|
||||
interface Props { t: T }
|
||||
const { t } = Astro.props;
|
||||
|
||||
const items = [
|
||||
{ icon: 'power', t: 'feat.1t', d: 'feat.1d' },
|
||||
{ icon: 'zap', t: 'feat.2t', d: 'feat.2d' },
|
||||
{ icon: 'globe', t: 'feat.3t', d: 'feat.3d' },
|
||||
{ icon: 'eye-off', t: 'feat.4t', d: 'feat.4d' },
|
||||
{ icon: 'shield', t: 'feat.5t', d: 'feat.5d' },
|
||||
{ icon: 'monitor-smartphone', t: 'feat.6t', d: 'feat.6d' },
|
||||
];
|
||||
---
|
||||
<section id="product" class="sec-tan">
|
||||
<div class="wrap center">
|
||||
<div class="eyebrow">{t('feat.eyebrow')}</div>
|
||||
<h2 class="h-sec">{t('feat.h')}</h2>
|
||||
<p class="sub-sec">{t('feat.sub')}</p>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="feat-grid">
|
||||
{items.map((it) => (
|
||||
<div class="feat">
|
||||
<div class="ico"><Icon name={it.icon} /></div>
|
||||
<h3>{t(it.t)}</h3>
|
||||
<p>{t(it.d)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
import Brand from './Brand.astro';
|
||||
import type { T } from '../i18n/strings';
|
||||
|
||||
interface Props { t: T }
|
||||
const { t } = Astro.props;
|
||||
---
|
||||
<footer class="ftr">
|
||||
<div class="wrap">
|
||||
<div class="top">
|
||||
<div>
|
||||
<div class="nm">
|
||||
<Brand variant="footer" size={28} />
|
||||
穿山甲
|
||||
</div>
|
||||
<p class="tag">{t('ft.tag')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>{t('ft.product')}</h4>
|
||||
<ul>
|
||||
<li><a href="#product">{t('ft.features')}</a></li>
|
||||
<li><a href="#pricing">{t('ft.pricing')}</a></li>
|
||||
<li><a href="#download">{t('ft.download')}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4>{t('ft.resources')}</h4>
|
||||
<ul>
|
||||
<li><a href="#docs">{t('ft.docs')}</a></li>
|
||||
<li><a href="#blog">{t('ft.blog')}</a></li>
|
||||
<li><a href="#docs">{t('ft.faq')}</a></li>
|
||||
<li><a href="#docs">{t('ft.privacy')}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4>{t('ft.contact')}</h4>
|
||||
<ul>
|
||||
<li><a class="mono">shop.pangolin.vpn</a></li>
|
||||
<li><a class="mono">Telegram @PangolinVPN_bot</a></li>
|
||||
<li><a class="mono">LINE @pangolinvpn</a></li>
|
||||
<li><a class="mono">support@pangolin.vpn</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bot">
|
||||
<span>{t('ft.copy')}</span>
|
||||
<span>{t('ft.madenote')}</span>
|
||||
<span>{t('ft.hours')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Header.jsx — 交互式顶栏(React island,@astrojs/react 渲染 + 水合)。
|
||||
* 迁自 design/ui_kits/website/index.html 的 <header> + site.js 的菜单/滚动毛玻璃逻辑。
|
||||
* 语言切换由原型的 JS 文本替换改为「路由跳转」(zh=/, en=/en/),单显不并排(铁律 6)。
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Download, Menu } from 'lucide-react';
|
||||
|
||||
function Mark() {
|
||||
return (
|
||||
<svg width="30" height="30" viewBox="0 0 96 96" fill="none" aria-hidden="true">
|
||||
<g transform="translate(96,0) scale(-1,1)">
|
||||
<path
|
||||
d="M16 59 Q17 51 26 50 Q34 31 45 30 Q55 30 62 37 Q69 41 75 45 Q85 49 89 44 Q92 50 84 52 Q75 53 67 53 Q66 62 60 62 L56 62 Q54 55 49 55 Q47 62 37 62 L33 62 Q31 56 26 56 Q20 59 16 59 Z"
|
||||
fill="#B96A3D"
|
||||
/>
|
||||
<g stroke="#FAF3ED" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" fill="none" opacity="0.85">
|
||||
<path d="M26 50 Q34 43 42 50" />
|
||||
<path d="M36 48 Q44 41 52 48" />
|
||||
<path d="M46 48 Q54 41 62 49" />
|
||||
</g>
|
||||
<circle cx="23" cy="55" r="2.2" fill="#FAF3ED" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Header({ lang = 'zh', t = {} }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 8);
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
const nav = [
|
||||
['#product', t.product],
|
||||
['#pricing', t.pricing],
|
||||
['#download', t.download],
|
||||
['#docs', t.docs],
|
||||
['#blog', t.blog],
|
||||
];
|
||||
|
||||
return (
|
||||
<header class={`hdr${scrolled ? ' scrolled' : ''}`}>
|
||||
<div class="wrap row">
|
||||
<a class="brand" href="#top">
|
||||
<Mark />
|
||||
<span class="nm">穿山甲</span>
|
||||
</a>
|
||||
<nav class="nav">
|
||||
{nav.map(([href, label]) => (
|
||||
<a key={href} href={href}>{label}</a>
|
||||
))}
|
||||
</nav>
|
||||
<div class="right">
|
||||
<div class="langseg">
|
||||
<a data-lang="zh" class={lang === 'zh' ? 'on' : undefined} href="/">中文</a>
|
||||
<a data-lang="en" class={lang === 'en' ? 'on' : undefined} href="/en/">EN</a>
|
||||
</div>
|
||||
<span class="linklogin">{t.login}</span>
|
||||
<a class="btn btn-primary" href="#download">
|
||||
<Download />
|
||||
<span>{t.get}</span>
|
||||
</a>
|
||||
<button class="menu-btn" onClick={() => setOpen((v) => !v)} aria-label="menu" aria-expanded={open}>
|
||||
<Menu />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class={`mnav${open ? ' open' : ''}`} id="mnav">
|
||||
{nav.map(([href, label]) => (
|
||||
<a key={href} href={href} onClick={() => setOpen(false)}>{label}</a>
|
||||
))}
|
||||
<a class="btn btn-primary" href="#signup" onClick={() => setOpen(false)}>
|
||||
<span>{t.suBtn}</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import SignupForm from './SignupForm.jsx';
|
||||
import type { T } from '../i18n/strings';
|
||||
|
||||
interface Props { t: T }
|
||||
const { t } = Astro.props;
|
||||
---
|
||||
<section class="hero">
|
||||
<div class="wrap grid">
|
||||
<div>
|
||||
<div class="eyebrow">{t('hero.eyebrow')}</div>
|
||||
<h1 class="h1-preline">{t('hero.h1')}</h1>
|
||||
<p class="lede">{t('hero.lede')}</p>
|
||||
|
||||
<SignupForm
|
||||
client:idle
|
||||
ph={t('su.ph')}
|
||||
btn={t('su.btn')}
|
||||
ok={t('su.ok')}
|
||||
hint={t('su.hint')}
|
||||
dl={t('su.dl')}
|
||||
/>
|
||||
|
||||
<div class="trust">
|
||||
<span><Icon name="check" /><span>{t('hero.t1')}</span></span>
|
||||
<span><Icon name="check" /><span>{t('hero.t2')}</span></span>
|
||||
<span><Icon name="check" /><span>{t('hero.t3')}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="orb-wrap">
|
||||
<div class="orb-card">
|
||||
<div class="orb">
|
||||
<svg class="ring" viewBox="0 0 184 184"><circle cx="92" cy="92" r="85" fill="none" stroke="rgba(255,255,255,.85)" stroke-width="4" stroke-linecap="round"></circle></svg>
|
||||
<Icon name="shield-check" class="ic" />
|
||||
<span class="tm">02:14:08</span>
|
||||
<span class="sf">{t('orb.cap')}</span>
|
||||
</div>
|
||||
<div class="orb-meta">
|
||||
<div class="m"><div class="k"><Icon name="arrow-down" /><span>{t('orb.down')}</span></div><div class="v">86.4<span class="orb-unit"> Mb/s</span></div></div>
|
||||
<div class="m"><div class="k"><Icon name="arrow-up" /><span>{t('orb.up')}</span></div><div class="v">12.1<span class="orb-unit"> Mb/s</span></div></div>
|
||||
</div>
|
||||
<div class="orb-node">
|
||||
<div class="cc">HK</div>
|
||||
<div class="orb-node-grow"><div class="nn">{t('orb.node')}</div><div class="ns">{t('orb.nodesub')}</div></div>
|
||||
<Icon name="chevron-right" class="orb-node-chev" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wrap strip">
|
||||
<span>{t('strip.txt')}</span>
|
||||
<span><Icon name="smartphone" />iOS</span>
|
||||
<span><Icon name="smartphone" />Android</span>
|
||||
<span><Icon name="laptop" />macOS</span>
|
||||
<span><Icon name="monitor" />Windows</span>
|
||||
<span><Icon name="terminal" />Linux</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
/**
|
||||
* Icon.astro — 把原型里的 `<i data-lucide="name">` 机械替换为「构建期内联 SVG」。
|
||||
* 使用 lucide-react,但**不**水合(无 client: 指令)→ Astro 在构建期渲染成静态 <svg>,
|
||||
* 零运行时 JS、零第三方 CDN(原型用的是 unpkg.com 的 lucide,已按任务要求去除)。
|
||||
* 尺寸/描边颜色沿用 website.css 里对 `svg` 的选择器控制,故此处不强加尺寸。
|
||||
*/
|
||||
import * as Lucide from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
class?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const { name, class: className, ...rest } = Astro.props;
|
||||
|
||||
// kebab-case → PascalCase(lucide-react 导出名)
|
||||
const pascal = name
|
||||
.split('-')
|
||||
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
||||
.join('');
|
||||
|
||||
// 个别图标的兜底别名(lucide 改名历史)。
|
||||
const ALIASES: Record<string, string> = {
|
||||
CheckCircle: 'CircleCheckBig',
|
||||
CircleHelp: 'CircleHelp',
|
||||
};
|
||||
|
||||
const lib = Lucide as unknown as Record<string, any>;
|
||||
const Comp = lib[pascal] ?? lib[ALIASES[pascal] ?? ''] ?? null;
|
||||
|
||||
if (!Comp) {
|
||||
throw new Error(`[Icon] 未知 lucide 图标: "${name}" (尝试 ${pascal})`);
|
||||
}
|
||||
---
|
||||
<Comp class={['lucide', className].filter(Boolean).join(' ')} {...rest} />
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import PricingPlans from './PricingPlans.jsx';
|
||||
import { PRICES, type T } from '../i18n/strings';
|
||||
|
||||
interface Props { t: T }
|
||||
const { t } = Astro.props;
|
||||
|
||||
const plansData = {
|
||||
monthly: t('price.monthly'),
|
||||
yearly: t('price.yearly'),
|
||||
save: t('price.save'),
|
||||
permo: t('price.permo'),
|
||||
popular: t('price.popular'),
|
||||
plans: [
|
||||
{
|
||||
key: 'free',
|
||||
name: t('price.free'),
|
||||
price: PRICES.free,
|
||||
desc: t('price.freedesc'),
|
||||
feats: [t('pf.free1'), t('pf.free2'), t('pf.free3'), t('pf.free4')],
|
||||
cta: t('price.cta_free'),
|
||||
ctaClass: 'pcta-out',
|
||||
highlight: false,
|
||||
},
|
||||
{
|
||||
key: 'pro',
|
||||
name: t('price.pro'),
|
||||
price: PRICES.pro,
|
||||
desc: t('price.prodesc'),
|
||||
feats: [t('pf.pro1'), t('pf.pro2'), t('pf.pro3'), t('pf.pro4'), t('pf.pro5')],
|
||||
cta: t('price.cta_pro'),
|
||||
ctaClass: 'pcta-white',
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
key: 'team',
|
||||
name: t('price.team'),
|
||||
price: PRICES.team,
|
||||
desc: t('price.teamdesc'),
|
||||
feats: [t('pf.team1'), t('pf.team2'), t('pf.team3'), t('pf.team4')],
|
||||
cta: t('price.cta_team'),
|
||||
ctaClass: 'pcta-fill',
|
||||
highlight: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 功能对比表行:[名称键, free, pro, team];值可为字符串或 'check' / 'dash'。
|
||||
const rows = [
|
||||
{ label: 'cmp.locations', free: '1', pro: '80+', team: '80+' },
|
||||
{ label: 'cmp.data', free: t('cmp.daily'), pro: t('cmp.unlimited'), team: t('cmp.unlimited') },
|
||||
{ label: 'cmp.time', free: t('cmp.time_free'), pro: t('cmp.unlimited'), team: t('cmp.unlimited') },
|
||||
{ label: 'cmp.devices', free: '1', pro: '5', team: '10' },
|
||||
{ label: 'cmp.speed', free: 'dash', pro: 'check', team: 'check' },
|
||||
{ label: 'cmp.stream', free: 'dash', pro: 'check', team: 'check' },
|
||||
{ label: 'cmp.kill', free: 'dash', pro: 'check', team: 'check' },
|
||||
{ label: 'cmp.support', free: t('cmp.basic'), pro: t('cmp.basic'), team: t('cmp.priority') },
|
||||
];
|
||||
|
||||
function cell(v: string) {
|
||||
return v === 'check' ? 'yes' : v === 'dash' ? 'no' : 'tx';
|
||||
}
|
||||
---
|
||||
<section id="pricing">
|
||||
<div class="wrap center">
|
||||
<div class="eyebrow">{t('price.eyebrow')}</div>
|
||||
<h2 class="h-sec">{t('price.h')}</h2>
|
||||
<p class="sub-sec">{t('price.sub')}</p>
|
||||
</div>
|
||||
|
||||
<PricingPlans client:visible data={plansData} />
|
||||
|
||||
<div class="wrap">
|
||||
<!-- payment note -->
|
||||
<div class="pay-note">
|
||||
<Icon name="shield-check" />
|
||||
<div>
|
||||
<b>{t('pay.title')}</b><br>
|
||||
<span>{t('pay.body')}</span>
|
||||
<div class="chips">
|
||||
<span class="chip"><Icon name="shopping-bag" /><span>{t('pay.store')}</span></span>
|
||||
<span class="chip"><Icon name="credit-card" /><span>{t('pay.usdt')}</span></span>
|
||||
<span class="chip"><Icon name="send" />Telegram @PangolinVPN_bot</span>
|
||||
<span class="chip"><Icon name="message-circle" />LINE @pangolinvpn</span>
|
||||
<span class="chip"><Icon name="mail" />buy@pangolin.vpn</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- comparison table -->
|
||||
<h3 class="cmp-h">{t('cmp.h')}</h3>
|
||||
<div class="cmp">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('cmp.feature')}</th>
|
||||
<th>{t('price.free')}</th>
|
||||
<th>{t('price.pro')}</th>
|
||||
<th>{t('price.team')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr>
|
||||
<td>{t(r.label)}</td>
|
||||
{[r.free, r.pro, r.team].map((v) => (
|
||||
<td class={cell(v)}>
|
||||
{v === 'check' ? <Icon name="check" /> : v === 'dash' ? '—' : v}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* PricingPlans.jsx — 月/年切换 + 三档套餐卡(React island)。
|
||||
* 迁自原型 .price-toggle + .price-grid + site.js __setCycle。
|
||||
* 铁律 10:CTA 为「获取激活码 / 免费下载」,不含任何支付表单;价格对齐 design/CLAUDE.md §7。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
export default function PricingPlans({ data }) {
|
||||
const [cycle, setCycle] = useState('monthly');
|
||||
const i = cycle === 'monthly' ? 0 : 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="wrap center">
|
||||
<div class="price-toggle">
|
||||
<button
|
||||
class={cycle === 'monthly' ? 'on' : undefined}
|
||||
data-cycle="monthly"
|
||||
onClick={() => setCycle('monthly')}
|
||||
>
|
||||
{data.monthly}
|
||||
</button>
|
||||
<button
|
||||
class={cycle === 'yearly' ? 'on' : undefined}
|
||||
data-cycle="yearly"
|
||||
onClick={() => setCycle('yearly')}
|
||||
>
|
||||
<span>{data.yearly}</span>
|
||||
<span class="save">{data.save}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="price-grid">
|
||||
{data.plans.map((p) => (
|
||||
<div key={p.key} class={p.highlight ? 'plan feat-plan' : 'plan'}>
|
||||
{p.highlight && <div class="pop">{data.popular}</div>}
|
||||
<div class="pname">{p.name}</div>
|
||||
<div class="price">
|
||||
{p.price[i]}
|
||||
<small>{data.permo}</small>
|
||||
</div>
|
||||
<div class="pdesc">{p.desc}</div>
|
||||
<ul class="feats">
|
||||
{p.feats.map((f, fi) => (
|
||||
<li key={fi}>
|
||||
<Check />
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button class={`pcta ${p.ctaClass}`}>{p.cta}</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* SignupForm.jsx — Hero 内联邮箱注册(演示,React island)。迁自原型 .signup + site.js __signup。
|
||||
* 注意(铁律 10):这不是支付表单;仅演示「发送验证码」交互,真实实现由后端 auth 接管。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Mail, CheckCircle, Download } from 'lucide-react';
|
||||
|
||||
export default function SignupForm({ ph, btn, ok, hint, dl }) {
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const onSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const email = e.currentTarget.elements['su-email'];
|
||||
if (!/\S+@\S+\.\S+/.test(email.value)) {
|
||||
email.focus();
|
||||
return;
|
||||
}
|
||||
setDone(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<form class="signup" id="signup" onSubmit={onSubmit}>
|
||||
<div class="su-row">
|
||||
<div class="su-field">
|
||||
<Mail />
|
||||
<input type="email" id="su-email" name="su-email" placeholder={ph} required />
|
||||
</div>
|
||||
<button class="btn btn-primary btn-lg su-btn" type="submit">
|
||||
<span>{btn}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="su-ok" id="su-ok" hidden={!done}>
|
||||
<CheckCircle />
|
||||
<span>{ok}</span>
|
||||
</div>
|
||||
<div class="su-hint">
|
||||
<span>{hint}</span>
|
||||
<a class="su-dl" href="#download">
|
||||
<Download />
|
||||
<span>{dl}</span>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import type { T } from '../i18n/strings';
|
||||
|
||||
interface Props { t: T }
|
||||
const { t } = Astro.props;
|
||||
|
||||
const cards = [
|
||||
{ icon: 'gift', t: 'why.1t', d: 'why.1d' },
|
||||
{ icon: 'monitor-smartphone', t: 'why.2t', d: 'why.2d' },
|
||||
{ icon: 'badge-check', t: 'why.3t', d: 'why.3d' },
|
||||
];
|
||||
---
|
||||
<section id="why-signup">
|
||||
<div class="wrap center">
|
||||
<div class="eyebrow">{t('why.eyebrow')}</div>
|
||||
<h2 class="h-sec">{t('why.h')}</h2>
|
||||
<p class="sub-sec">{t('why.sub')}</p>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="why-grid">
|
||||
{cards.map((c) => (
|
||||
<div class="why">
|
||||
<div class="ico"><Icon name={c.icon} /></div>
|
||||
<h3>{t(c.t)}</h3>
|
||||
<p>{t(c.d)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class="steps">
|
||||
<div class="step"><span class="n">1</span><Icon name="mail" /><span class="t">{t('why.s1')}</span></div>
|
||||
<span class="step-arr"><Icon name="arrow-right" /></span>
|
||||
<div class="step"><span class="n">2</span><Icon name="shield-check" /><span class="t">{t('why.s2')}</span></div>
|
||||
<span class="step-arr"><Icon name="arrow-right" /></span>
|
||||
<div class="step"><span class="n">3</span><Icon name="power" /><span class="t">{t('why.s3')}</span></div>
|
||||
</div>
|
||||
<div class="why-cta">
|
||||
<a class="btn btn-primary btn-lg" href="#signup"><span>{t('why.cta')}</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,200 @@
|
||||
// strings.ts — 官网 i18n 字典(中 / EN)
|
||||
// 文案逐字沿用 design/ui_kits/website/site.js 的 I18N(已脱敏,符合 design/CLAUDE.md 铁律 13)。
|
||||
// 与原型不同的是:这里是「构建期」按路由整页单显(zh = /,en = /en/),不在运行时 JS 切换文本。
|
||||
|
||||
export type Lang = 'zh' | 'en';
|
||||
|
||||
// 每条 = [中文, English]
|
||||
export const STRINGS: Record<string, [string, string]> = {
|
||||
'ann.txt': ['新用户注册即享 7 天免费试用', 'New users get a 7-day free trial'],
|
||||
'ann.cta': ['立即注册 →', 'Sign up →'],
|
||||
|
||||
'su.ph': ['输入邮箱,免费注册', 'Enter email to sign up free'],
|
||||
'su.btn': ['免费注册', 'Sign up free'],
|
||||
'su.ok': ['验证码已发送!下载 App 并用同一邮箱继续完成注册。', 'Code sent! Download the app and continue with the same email.'],
|
||||
'su.hint': ['免费注册 · 无需付款 · 7 天免费试用', 'Free to join · no payment · 7-day free trial'],
|
||||
'su.dl': ['或直接下载 App', 'or just download the app'],
|
||||
|
||||
'why.eyebrow': ['免费注册', 'Sign up'],
|
||||
'why.h': ['注册一次,处处可用', 'One account, everywhere'],
|
||||
'why.sub': ['一个邮箱就能开始。注册免费,不需要任何付款信息。', 'All you need is an email. Free to join — no payment details, ever.'],
|
||||
'why.1t': ['7 天免费试用', '7-day free trial'],
|
||||
'why.1d': ['新用户注册即享 7 天免费使用,无需任何付款信息。', 'New accounts get 7 days of free, unrestricted use — no payment details.'],
|
||||
'why.2t': ['多端同步', 'Multi-device sync'],
|
||||
'why.2d': ['手机、平板、电脑共用一个账户,套餐与设置随身走。', 'Phone, tablet and desktop share one account — your plan follows you.'],
|
||||
'why.3t': ['无需付款信息', 'No payment details'],
|
||||
'why.3d': ['注册只要邮箱。想升级时,再通过外部渠道兑换激活码。', 'Just an email to join. Upgrade later with an activation code.'],
|
||||
'why.s1': ['填写邮箱', 'Enter email'],
|
||||
'why.s2': ['输入验证码', 'Verify code'],
|
||||
'why.s3': ['开始畅连', 'Start connecting'],
|
||||
'why.cta': ['立即免费注册', 'Sign up free'],
|
||||
|
||||
'cta.btn2': ['免费注册', 'Sign up free'],
|
||||
|
||||
'nav.product': ['产品', 'Product'],
|
||||
'nav.pricing': ['定价', 'Pricing'],
|
||||
'nav.download': ['下载', 'Download'],
|
||||
'nav.docs': ['文档', 'Docs'],
|
||||
'nav.blog': ['Blog', 'Blog'],
|
||||
'nav.login': ['登录', 'Log in'],
|
||||
'nav.get': ['立即下载', 'Get the app'],
|
||||
|
||||
'hero.eyebrow': ['极速 · 稳定 · 省心', 'Fast · Stable · Effortless'],
|
||||
'hero.h1': ['极速畅连,\n网络如丝顺滑', 'Faster, smoother,\neverywhere'],
|
||||
'hero.lede': ['轻盈、亲和、即开即用的跨平台网络加速应用。一键连接,智能选线,稳定不掉线。', 'A lightweight, friendly cross-platform network accelerator. One tap, smart routing, rock-solid.'],
|
||||
'hero.cta1': ['免费下载', 'Download free'],
|
||||
'hero.cta2': ['查看定价', 'See pricing'],
|
||||
'hero.t1': ['80+ 全球加速线路', '80+ global routes'],
|
||||
'hero.t2': ['严格无日志', 'Strict no-logs'],
|
||||
'hero.t3': ['端到端加密', 'End-to-end encrypted'],
|
||||
'orb.cap': ['已连接', 'CONNECTED'],
|
||||
'orb.down': ['下载', 'Down'],
|
||||
'orb.up': ['上传', 'Up'],
|
||||
'orb.node': ['香港 · 流媒体', 'Hong Kong'],
|
||||
'orb.nodesub': ['延迟 18ms · 已连接', '18ms · Connected'],
|
||||
|
||||
'strip.txt': ['已支持', 'Available on'],
|
||||
|
||||
'feat.eyebrow': ['产品', 'Product'],
|
||||
'feat.h': ['为日常而生的连接体验', 'Built for everyday connection'],
|
||||
'feat.sub': ['没有复杂配置,没有技术门槛。打开就用,把稳定与隐私留给我们。', 'No complex setup, no jargon. Just open and go — we handle speed and privacy.'],
|
||||
'feat.1t': ['一键连接', 'One-tap connect'],
|
||||
'feat.1d': ['一颗按钮,智能挑选最快线路。无需手动配置,也能秒连。', 'One button picks the fastest route. No manual setup, instant connect.'],
|
||||
'feat.2t': ['智能分流', 'Smart routing'],
|
||||
'feat.2d': ['按规则智能分流,该加速的加速,本地服务直连,互不打扰。', 'Smart rule-based routing — accelerate what needs it, keep local traffic direct.'],
|
||||
'feat.3t': ['80+ 全球加速线路', '80+ global routes'],
|
||||
'feat.3d': ['覆盖港日新美欧韩等地,视频与游戏优化线路随心选。', 'Routes across HK / JP / SG / US / EU and more, tuned for video & gaming.'],
|
||||
'feat.4t': ['严格无日志', 'Strict no-logs'],
|
||||
'feat.4d': ['我们不记录你的浏览数据。隐私是底线,不是卖点。', 'We never log your browsing. Privacy is the baseline, not a feature.'],
|
||||
'feat.5t': ['Kill Switch', 'Kill Switch'],
|
||||
'feat.5d': ['一旦连接中断,立即阻断网络,杜绝真实 IP 泄露。', 'If the tunnel drops, traffic is blocked instantly — no IP leaks.'],
|
||||
'feat.6t': ['多端同步', 'Multi-device'],
|
||||
'feat.6d': ['一个账户,手机、平板、电脑同时在线,最多 5 台设备。', 'One account across phone, tablet and desktop — up to 5 devices.'],
|
||||
|
||||
'price.eyebrow': ['定价', 'Pricing'],
|
||||
'price.h': ['简单透明的价格', 'Simple, honest pricing'],
|
||||
'price.sub': ['随时升级或取消。所有套餐均含核心加密与无日志承诺。', 'Upgrade or cancel anytime. Every plan includes core encryption and our no-logs promise.'],
|
||||
'price.monthly': ['按月', 'Monthly'],
|
||||
'price.yearly': ['按年', 'Yearly'],
|
||||
'price.save': ['省 20%', 'Save 20%'],
|
||||
'price.permo': ['/月', '/mo'],
|
||||
'price.free': ['免费版', 'Free'],
|
||||
'price.freedesc': ['适合轻度使用与尝鲜', 'For light, casual use'],
|
||||
'price.pro': ['专业版', 'Pro'],
|
||||
'price.prodesc': ['个人用户的最佳选择', 'Best for individuals'],
|
||||
'price.team': ['团队版', 'Team'],
|
||||
'price.teamdesc': ['小团队集中管理', 'For small teams'],
|
||||
'price.popular': ['最受欢迎', 'Most popular'],
|
||||
'price.cta_free': ['免费下载', 'Download free'],
|
||||
'price.cta_pro': ['获取激活码', 'Get a code'],
|
||||
'price.cta_team': ['获取激活码', 'Get a code'],
|
||||
'pf.free1': ['每日 10 分钟时长', '10 min per day'],
|
||||
'pf.free2': ['仅 1 个基础节点', '1 basic node only'],
|
||||
'pf.free3': ['使用前观看广告', 'Watch an ad to start'],
|
||||
'pf.free4': ['核心加密 · 无日志', 'Core encryption · no-logs'],
|
||||
'pf.pro1': ['80+ 全球加速线路', '80+ global routes'],
|
||||
'pf.pro2': ['无限流量 · 极速', 'Unlimited · top speed'],
|
||||
'pf.pro3': ['5 台设备同时在线', '5 devices at once'],
|
||||
'pf.pro4': ['流媒体 & P2P 优化', 'Streaming & P2P routes'],
|
||||
'pf.pro5': ['Kill Switch · 智能分流', 'Kill Switch · smart routing'],
|
||||
'pf.team1': ['专业版全部功能', 'Everything in Pro'],
|
||||
'pf.team2': ['10 个成员席位', '10 seats'],
|
||||
'pf.team3': ['集中计费与管理', 'Central billing & admin'],
|
||||
'pf.team4': ['优先客服', 'Priority support'],
|
||||
|
||||
'pay.title': ['为什么网页上没有支付按钮?', 'Why no checkout on this page?'],
|
||||
'pay.body': ['出于风控与隐私考虑,穿山甲不在网页或 App 内直接收款。你可以通过以下任一渠道获取激活码,在客户端内兑换即可开通。', 'For risk and privacy reasons, Pangolin never takes payment in the web or app. Get an activation code through any channel below, then redeem it in the client.'],
|
||||
'pay.store': ['自助发卡商店', 'Self-serve store'],
|
||||
'pay.usdt': ['USDT (TRC20) · 最隐私', 'USDT (TRC20) · most private'],
|
||||
|
||||
'cmp.h': ['功能对比', 'Compare plans'],
|
||||
'cmp.feature': ['功能', 'Feature'],
|
||||
'cmp.locations': ['可用线路', 'Routes'],
|
||||
'cmp.data': ['流量', 'Data'],
|
||||
'cmp.time': ['每日时长', 'Daily time'],
|
||||
'cmp.time_free': ['10 分钟/天(试用期不限)', '10 min/day (trial: unlimited)'],
|
||||
'cmp.devices': ['设备数', 'Devices'],
|
||||
'cmp.speed': ['极速线路', 'Top-speed routes'],
|
||||
'cmp.stream': ['视频优化', 'Video routes'],
|
||||
'cmp.kill': ['Kill Switch', 'Kill Switch'],
|
||||
'cmp.support': ['客服', 'Support'],
|
||||
'cmp.unlimited': ['无限', 'Unlimited'],
|
||||
'cmp.daily': ['不限', 'Unlimited'],
|
||||
'cmp.basic': ['标准', 'Standard'],
|
||||
'cmp.priority': ['优先', 'Priority'],
|
||||
|
||||
'dl.eyebrow': ['下载', 'Download'],
|
||||
'dl.h': ['全平台,随处可用', 'Every platform, everywhere'],
|
||||
'dl.sub': ['一个账户,所有设备同步。下载即用,无需配置。', 'One account syncs every device. Download and go.'],
|
||||
'dl.get': ['下载', 'Download'],
|
||||
|
||||
'docs.eyebrow': ['文档', 'Docs'],
|
||||
'docs.h': ['需要帮助?都在这里', 'Need help? It’s all here'],
|
||||
'docs.sub': ['从快速上手到协议细节,清晰直接。', 'From quickstart to protocol details — clear and direct.'],
|
||||
'docs.1t': ['快速开始', 'Quickstart'],
|
||||
'docs.1d': ['三分钟完成注册、下载与首次连接。', 'Sign up, download and connect in three minutes.'],
|
||||
'docs.2t': ['常见问题', 'FAQ'],
|
||||
'docs.2d': ['连接、计费、设备与兑换码的常见疑问。', 'Connection, billing, devices and redeem codes.'],
|
||||
'docs.3t': ['协议与安全', 'Protocol & security'],
|
||||
'docs.3d': ['WireGuard、加密方式与无日志架构说明。', 'WireGuard, encryption and our no-logs architecture.'],
|
||||
'docs.4t': ['隐私政策', 'Privacy policy'],
|
||||
'docs.4d': ['我们收集什么、不收集什么,一目了然。', 'Exactly what we collect — and what we never do.'],
|
||||
'docs.read': ['阅读', 'Read'],
|
||||
|
||||
'blog.eyebrow': ['Blog', 'Blog'],
|
||||
'blog.h': ['来自团队的最新动态', 'Latest from the team'],
|
||||
'blog.sub': ['产品更新、安全科普与节点公告。', 'Product updates, security explainers and node news.'],
|
||||
'blog.1tag': ['产品更新', 'Product'],
|
||||
'blog.1t': ['v2.4:更快的智能分流与全新统计页', 'v2.4: faster smart routing & a new stats tab'],
|
||||
'blog.1d': ['本次更新重写了分流引擎,连接建立速度提升约 40%。', 'We rewrote the routing engine — connections are ~40% faster.'],
|
||||
'blog.2tag': ['安全', 'Security'],
|
||||
'blog.2t': ['无日志到底意味着什么?', 'What “no-logs” really means'],
|
||||
'blog.2d': ['我们逐条拆解收集与不收集的数据,以及背后的架构。', 'A line-by-line look at what we keep — and what we never touch.'],
|
||||
'blog.3tag': ['公告', 'News'],
|
||||
'blog.3t': ['新增首尔与法兰克福高速节点', 'New high-speed nodes: Seoul & Frankfurt'],
|
||||
'blog.3d': ['两条新线路已上线,为东北亚与欧洲用户带来更低延迟。', 'Two new routes are live, cutting latency for NE Asia and Europe.'],
|
||||
'blog.readmore': ['阅读全文', 'Read more'],
|
||||
|
||||
'cta.h': ['准备好体验极速畅连了吗?', 'Ready for a faster connection?'],
|
||||
'cta.p': ['免费下载,数分钟内开始你的第一次极速连接。', 'Download free and get connected in minutes.'],
|
||||
'cta.btn': ['免费下载', 'Download free'],
|
||||
|
||||
'ft.tag': ['极简、轻量、亲和的跨平台网络加速应用。守护你的每一次连接。', 'A minimal, friendly cross-platform network accelerator. Protecting every connection.'],
|
||||
'ft.product': ['产品', 'Product'],
|
||||
'ft.resources': ['资源', 'Resources'],
|
||||
'ft.contact': ['联系与渠道', 'Contact'],
|
||||
'ft.features': ['功能', 'Features'],
|
||||
'ft.pricing': ['定价', 'Pricing'],
|
||||
'ft.download': ['下载', 'Download'],
|
||||
'ft.docs': ['文档', 'Docs'],
|
||||
'ft.blog': ['Blog', 'Blog'],
|
||||
'ft.faq': ['常见问题', 'FAQ'],
|
||||
'ft.privacy': ['隐私政策', 'Privacy'],
|
||||
'ft.status': ['服务状态', 'Status'],
|
||||
'ft.hours': ['服务时间 每日 9:00–24:00 (GMT+8)', 'Hours: Daily 9:00–24:00 (GMT+8)'],
|
||||
'ft.copy': ['© 2026 穿山甲 · Pangolin', '© 2026 Pangolin'],
|
||||
'ft.madenote': ['App 内不支持直接支付 · 资金流全走外部渠道', 'No in-app payment · all purchases via external channels'],
|
||||
|
||||
// 页面 <title> / meta(zh / en)
|
||||
'meta.title': ['穿山甲 · Pangolin — 极速 · 稳定 · 省心', 'Pangolin — Fast · Stable · Effortless'],
|
||||
'meta.desc': ['轻盈、亲和、即开即用的跨平台网络加速应用。一键连接,智能选线,稳定不掉线。', 'A lightweight, friendly cross-platform network accelerator. One tap, smart routing, rock-solid.'],
|
||||
};
|
||||
|
||||
export type T = (key: string) => string;
|
||||
|
||||
/** 返回当前语言的取词函数;缺键回退到中文,再回退到键名本身(便于发现遗漏)。 */
|
||||
export function createT(lang: Lang): T {
|
||||
const idx = lang === 'zh' ? 0 : 1;
|
||||
return (key: string) => {
|
||||
const pair = STRINGS[key];
|
||||
if (!pair) return key;
|
||||
return pair[idx] ?? pair[0] ?? key;
|
||||
};
|
||||
}
|
||||
|
||||
/** 价格表(月 / 年),与 ui_kits/website/site.js 的 PRICES 同步,对齐 design/CLAUDE.md §7。 */
|
||||
export const PRICES: Record<'free' | 'pro' | 'team', [string, string]> = {
|
||||
free: ['¥0', '¥0'],
|
||||
pro: ['¥25', '¥20'],
|
||||
team: ['¥99', '¥79'],
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
/**
|
||||
* Site.astro — 官网整页布局(构建期单显一种语言)。
|
||||
* 两条路由复用:/ → lang="zh",/en/ → lang="en"(铁律 6 单显不并排)。
|
||||
*
|
||||
* 样式来源顺序(全部自托管,无第三方 CDN):
|
||||
* @fontsource/* → 自托管字体 @font-face(替代原型的 Google Fonts CDN)
|
||||
* tokens.gen.css → 由 design/colors_and_type.css 生成(唯一令牌真相源)
|
||||
* website.css → 逐字迁自 ui_kits/website
|
||||
* site-extra.css → 仅承载「原型内联 style= → class」的等价工具类
|
||||
*/
|
||||
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 AnnouncementBar from '../components/AnnouncementBar.jsx';
|
||||
import Hero from '../components/Hero.astro';
|
||||
import Features from '../components/Features.astro';
|
||||
import WhySignup from '../components/WhySignup.astro';
|
||||
import Pricing from '../components/Pricing.astro';
|
||||
import Download from '../components/Download.astro';
|
||||
import Docs from '../components/Docs.astro';
|
||||
import Blog from '../components/Blog.astro';
|
||||
import CtaBand from '../components/CtaBand.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
|
||||
interface Props { lang: Lang }
|
||||
const { lang } = Astro.props;
|
||||
const t = createT(lang);
|
||||
|
||||
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'),
|
||||
get: t('nav.get'),
|
||||
suBtn: t('su.btn'),
|
||||
};
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang={lang === 'zh' ? 'zh-CN' : 'en'}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{t('meta.title')}</title>
|
||||
<meta name="description" content={t('meta.desc')} />
|
||||
<meta name="robots" content="index,follow" />
|
||||
<link rel="canonical" href={new URL(lang === 'zh' ? '/' : '/en/', Astro.site).href} />
|
||||
<link rel="alternate" hreflang="zh-CN" href={new URL('/', Astro.site).href} />
|
||||
<link rel="alternate" hreflang="en" href={new URL('/en/', Astro.site).href} />
|
||||
<link rel="alternate" hreflang="x-default" href={new URL('/', Astro.site).href} />
|
||||
<meta name="theme-color" content="#B96A3D" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content={t('meta.title')} />
|
||||
<meta property="og:description" content={t('meta.desc')} />
|
||||
<meta property="og:locale" content={lang === 'zh' ? 'zh_CN' : 'en_US'} />
|
||||
</head>
|
||||
<body>
|
||||
<AnnouncementBar client:idle text={t('ann.txt')} cta={t('ann.cta')} />
|
||||
<Header client:load lang={lang} t={headerT} />
|
||||
|
||||
<a id="top"></a>
|
||||
|
||||
<Hero t={t} />
|
||||
<Features t={t} />
|
||||
<WhySignup t={t} />
|
||||
<Pricing t={t} />
|
||||
<Download t={t} />
|
||||
<Docs t={t} />
|
||||
<Blog t={t} />
|
||||
<CtaBand t={t} />
|
||||
<Footer t={t} />
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
// en 路由(/en/)
|
||||
import Site from '../../layouts/Site.astro';
|
||||
---
|
||||
<Site lang="en" />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
// zh 路由(/)
|
||||
import Site from '../layouts/Site.astro';
|
||||
---
|
||||
<Site lang="zh" />
|
||||
@@ -0,0 +1,54 @@
|
||||
/* site-extra.css — 仅承载「替换原型内联 style= 属性」的工具类。
|
||||
原型 index.html 里有少量 inline style;严格 CSP(style-src 'self',不含 'unsafe-inline')
|
||||
会拦截内联样式,故逐一搬到这里为等价 class,website.css 保持逐字不动。 */
|
||||
|
||||
/* hero <h1 style="white-space:pre-line"> */
|
||||
.h1-preline { white-space: pre-line; }
|
||||
|
||||
/* hero orb 速率单位 <span style="font-size:10px;color:var(--fg3)"> */
|
||||
.orb-unit { font-size: 10px; color: var(--fg3); }
|
||||
|
||||
/* orb-node <i ... style="width:18px;height:18px;color:var(--fg3)"> */
|
||||
.orb-node-chev { width: 18px; height: 18px; color: var(--fg3); }
|
||||
|
||||
/* orb-node 中部 <div style="flex:1"> */
|
||||
.orb-node-grow { flex: 1; }
|
||||
|
||||
/* 功能对比标题 <h3 style="font-family:var(--font-display);font-weight:700;font-size:22px;margin:46px 0 0;text-align:center"> */
|
||||
.cmp-h {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
margin: 46px 0 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 语言切换由原型 <button> 改为路由 <a>(zh=/ , en=/en/);复刻 .langseg button 视觉。 */
|
||||
.langseg a {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-full);
|
||||
padding: 5px 11px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
background: transparent;
|
||||
color: var(--fg3);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
.langseg a.on { background: var(--accent); color: var(--fg-on-accent); }
|
||||
|
||||
/* 视觉隐藏(无障碍用,当前未强依赖) */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/* AUTO-GENERATED — 勿手改。源: design/colors_and_type.css。生成器: web/website/scripts/build-tokens.mjs。仅移除第三方 Google Fonts @import。 */
|
||||
/* =============================================================
|
||||
穿山甲 VPN · Pangolin VPN — Design Tokens
|
||||
colors_and_type.css
|
||||
Single source of truth: color ramps, semantic colors (light + dark),
|
||||
typography, spacing, radii, shadows, motion.
|
||||
============================================================= */
|
||||
|
||||
/* ---- Webfonts (open-source; documented as the brand's chosen faces) ----
|
||||
Sora — display / headings (geometric, friendly)
|
||||
Manrope — body / UI (humanist geometric)
|
||||
Noto Sans SC — Chinese (CJK companion)
|
||||
JetBrains Mono — data readouts (IP, speed, keys) */
|
||||
|
||||
:root {
|
||||
/* ===========================================================
|
||||
1. PRIMITIVE COLOR RAMPS
|
||||
=========================================================== */
|
||||
|
||||
/* Clay / Copper — the pangolin-armor primary (warm earth) */
|
||||
--clay-50: #FAF3ED;
|
||||
--clay-100: #F2E2D4;
|
||||
--clay-200: #E6C7AC;
|
||||
--clay-300: #D9A982;
|
||||
--clay-400: #CC8B5C;
|
||||
--clay-500: #B96A3D; /* ← brand primary */
|
||||
--clay-600: #9E5630;
|
||||
--clay-700: #7E4426;
|
||||
--clay-800: #5E331D;
|
||||
--clay-900: #3D2213;
|
||||
|
||||
/* Sand / Taupe — warm neutral ramp */
|
||||
--sand-50: #FAF8F4;
|
||||
--sand-100: #F2EEE7;
|
||||
--sand-200: #E6DFD3;
|
||||
--sand-300: #D2C8B8;
|
||||
--sand-400: #B0A491;
|
||||
--sand-500: #8C8270;
|
||||
--sand-600: #6B6253;
|
||||
--sand-700: #4E4940;
|
||||
--sand-800: #2E2A24;
|
||||
--sand-900: #1F1C18;
|
||||
--sand-950: #14110E; /* warm espresso near-black */
|
||||
|
||||
/* Semantic hues (earth-tuned) */
|
||||
--green-400: #7FB07A;
|
||||
--green-500: #5B8C5A; /* connected / secure */
|
||||
--green-600: #467046;
|
||||
--amber-400: #E2B05A;
|
||||
--amber-500: #D69A3C; /* connecting / warning */
|
||||
--amber-600: #B47E29;
|
||||
--red-400: #D4715A;
|
||||
--red-500: #C0533B; /* error / disconnect */
|
||||
--red-600: #9E4230;
|
||||
|
||||
/* ===========================================================
|
||||
2. SEMANTIC TOKENS — LIGHT THEME (default)
|
||||
=========================================================== */
|
||||
|
||||
/* Backgrounds & surfaces */
|
||||
--bg: var(--sand-50); /* app canvas */
|
||||
--bg-subtle: var(--sand-100); /* striped / inset regions */
|
||||
--surface: #FFFFFF; /* cards, sheets */
|
||||
--surface-2: var(--sand-50); /* nested surface */
|
||||
--overlay: rgba(31, 28, 24, 0.45);
|
||||
|
||||
/* Foreground / text */
|
||||
--fg1: var(--sand-900); /* primary text */
|
||||
--fg2: var(--sand-600); /* secondary text */
|
||||
--fg3: var(--sand-500); /* tertiary / captions */
|
||||
--fg-on-accent: #FFFFFF; /* text on clay fills */
|
||||
|
||||
/* Brand / accent */
|
||||
--accent: var(--clay-500);
|
||||
--accent-hover: var(--clay-600);
|
||||
--accent-press: var(--clay-700);
|
||||
--accent-subtle: var(--clay-50);
|
||||
--accent-border: var(--clay-200);
|
||||
|
||||
/* Borders & lines */
|
||||
--border: var(--sand-200);
|
||||
--border-strong: var(--sand-300);
|
||||
--ring: rgba(185, 106, 61, 0.35); /* focus ring (clay) */
|
||||
|
||||
/* Status */
|
||||
--success: var(--green-500);
|
||||
--success-subtle: #E9F0E6;
|
||||
--warning: var(--amber-500);
|
||||
--warning-subtle: #F8EED6;
|
||||
--danger: var(--red-500);
|
||||
--danger-subtle: #F6E1DA;
|
||||
|
||||
/* ===========================================================
|
||||
3. TYPOGRAPHY
|
||||
=========================================================== */
|
||||
--font-display: 'Sora', 'Noto Sans SC', system-ui, sans-serif;
|
||||
--font-sans: 'Manrope', 'Noto Sans SC', system-ui, sans-serif;
|
||||
--font-cjk: 'Noto Sans SC', 'Manrope', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', monospace;
|
||||
|
||||
/* Type scale (root 16px) */
|
||||
--text-display-xl: 3rem; /* 48 */
|
||||
--text-display: 2.25rem; /* 36 */
|
||||
--text-h1: 1.875rem; /* 30 */
|
||||
--text-h2: 1.5rem; /* 24 */
|
||||
--text-h3: 1.25rem; /* 20 */
|
||||
--text-body-lg: 1.125rem; /* 18 */
|
||||
--text-body: 1rem; /* 16 */
|
||||
--text-sm: 0.875rem; /* 14 */
|
||||
--text-caption: 0.75rem; /* 12 */
|
||||
|
||||
--leading-tight: 1.15;
|
||||
--leading-snug: 1.3;
|
||||
--leading-normal:1.5;
|
||||
--leading-relaxed:1.65;
|
||||
|
||||
--tracking-tight: -0.02em;
|
||||
--tracking-snug: -0.01em;
|
||||
--tracking-wide: 0.04em;
|
||||
--tracking-caps: 0.08em;
|
||||
|
||||
/* ===========================================================
|
||||
4. SPACING (4px base)
|
||||
=========================================================== */
|
||||
--space-0: 0;
|
||||
--space-1: 0.25rem; /* 4 */
|
||||
--space-2: 0.5rem; /* 8 */
|
||||
--space-3: 0.75rem; /* 12 */
|
||||
--space-4: 1rem; /* 16 */
|
||||
--space-5: 1.25rem; /* 20 */
|
||||
--space-6: 1.5rem; /* 24 */
|
||||
--space-8: 2rem; /* 32 */
|
||||
--space-10: 2.5rem; /* 40 */
|
||||
--space-12: 3rem; /* 48 */
|
||||
--space-16: 4rem; /* 64 */
|
||||
|
||||
/* ===========================================================
|
||||
5. RADII (generous = friendly)
|
||||
=========================================================== */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 20px;
|
||||
--radius-2xl: 28px;
|
||||
--radius-full: 999px;
|
||||
|
||||
/* ===========================================================
|
||||
6. SHADOWS (warm-tinted, soft)
|
||||
=========================================================== */
|
||||
--shadow-sm: 0 1px 2px rgba(45, 30, 20, 0.06);
|
||||
--shadow-md: 0 4px 14px rgba(45, 30, 20, 0.08);
|
||||
--shadow-lg: 0 12px 32px rgba(45, 30, 20, 0.12);
|
||||
--shadow-xl: 0 24px 60px rgba(45, 30, 20, 0.16);
|
||||
--shadow-focus: 0 0 0 4px var(--ring);
|
||||
|
||||
/* ===========================================================
|
||||
7. MOTION
|
||||
=========================================================== */
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* @kind other */
|
||||
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* @kind other */
|
||||
--dur-fast: 140ms; /* @kind other */
|
||||
--dur-base: 220ms; /* @kind other */
|
||||
--dur-slow: 360ms; /* @kind other */
|
||||
}
|
||||
|
||||
/* ===========================================================
|
||||
DARK THEME — warm espresso
|
||||
Apply via [data-theme="dark"] on <html> or any container.
|
||||
=========================================================== */
|
||||
[data-theme="dark"] {
|
||||
--bg: var(--sand-950);
|
||||
--bg-subtle: var(--sand-900);
|
||||
--surface: #221E19;
|
||||
--surface-2: #2A251F;
|
||||
--overlay: rgba(0, 0, 0, 0.6);
|
||||
|
||||
--fg1: #F4EFE8;
|
||||
--fg2: #B6AC9C;
|
||||
--fg3: #897F6F;
|
||||
--fg-on-accent: #1F1C18;
|
||||
|
||||
--accent: var(--clay-400);
|
||||
--accent-hover: var(--clay-300);
|
||||
--accent-press: var(--clay-500);
|
||||
--accent-subtle: rgba(204, 139, 92, 0.14);
|
||||
--accent-border: rgba(204, 139, 92, 0.30);
|
||||
|
||||
--border: rgba(242, 238, 231, 0.10);
|
||||
--border-strong: rgba(242, 238, 231, 0.18);
|
||||
--ring: rgba(204, 139, 92, 0.45);
|
||||
|
||||
--success: var(--green-400);
|
||||
--success-subtle: rgba(127, 176, 122, 0.16);
|
||||
--warning: var(--amber-400);
|
||||
--warning-subtle: rgba(226, 176, 90, 0.16);
|
||||
--danger: var(--red-400);
|
||||
--danger-subtle: rgba(212, 113, 90, 0.16);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-md: 0 4px 14px rgba(0, 0, 0, 0.45);
|
||||
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.5);
|
||||
--shadow-xl: 0 24px 60px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
/* ===========================================================
|
||||
SEMANTIC TYPE CLASSES (use directly in markup)
|
||||
=========================================================== */
|
||||
.t-display-xl { font-family: var(--font-display); font-size: var(--text-display-xl); font-weight: 700; line-height: var(--leading-tight); letter-spacing: var(--tracking-tight); }
|
||||
.t-display { font-family: var(--font-display); font-size: var(--text-display); font-weight: 700; line-height: var(--leading-tight); letter-spacing: var(--tracking-tight); }
|
||||
.t-h1 { font-family: var(--font-display); font-size: var(--text-h1); font-weight: 600; line-height: var(--leading-snug); letter-spacing: var(--tracking-snug); }
|
||||
.t-h2 { font-family: var(--font-display); font-size: var(--text-h2); font-weight: 600; line-height: var(--leading-snug); letter-spacing: var(--tracking-snug); }
|
||||
.t-h3 { font-family: var(--font-sans); font-size: var(--text-h3); font-weight: 600; line-height: var(--leading-snug); }
|
||||
.t-body-lg { font-family: var(--font-sans); font-size: var(--text-body-lg); font-weight: 400; line-height: var(--leading-relaxed); }
|
||||
.t-body { font-family: var(--font-sans); font-size: var(--text-body); font-weight: 400; line-height: var(--leading-normal); }
|
||||
.t-sm { font-family: var(--font-sans); font-size: var(--text-sm); font-weight: 400; line-height: var(--leading-normal); }
|
||||
.t-caption { font-family: var(--font-sans); font-size: var(--text-caption); font-weight: 500; line-height: var(--leading-normal); }
|
||||
.t-overline { font-family: var(--font-sans); font-size: var(--text-caption); font-weight: 600; text-transform: uppercase; letter-spacing: var(--tracking-caps); }
|
||||
.t-mono { font-family: var(--font-mono); font-size: var(--text-sm); font-weight: 400; font-feature-settings: 'tnum' 1; }
|
||||
@@ -0,0 +1,292 @@
|
||||
/* website.css — 穿山甲 VPN 官网宣传页 · 暖大地色系统 */
|
||||
*{box-sizing:border-box}
|
||||
html{scroll-behavior:smooth}
|
||||
body{margin:0;background:var(--bg);color:var(--fg1);font-family:var(--font-sans);-webkit-font-smoothing:antialiased}
|
||||
a{color:inherit;text-decoration:none}
|
||||
img,svg{display:block}
|
||||
.wrap{max-width:1140px;margin:0 auto;padding:0 28px}
|
||||
.sr{font-family:var(--font-sans)}
|
||||
|
||||
/* ---------- announcement bar (A) ---------- */
|
||||
.ann{background:linear-gradient(90deg,var(--clay-600),var(--clay-700));color:#fff}
|
||||
.ann-in{display:flex;align-items:center;justify-content:center;gap:10px;padding:9px 48px;font-size:13.5px;font-weight:600;position:relative}
|
||||
.ann-in span{white-space:nowrap}
|
||||
.ann svg{width:15px;height:15px;flex-shrink:0}
|
||||
.ann a{text-decoration:underline;text-underline-offset:3px;font-weight:700;white-space:nowrap}
|
||||
.ann a:hover{opacity:.85}
|
||||
.ann-x{position:absolute;right:16px;top:50%;transform:translateY(-50%);background:transparent;border:none;color:rgba(255,255,255,.8);cursor:pointer;display:flex;padding:4px}
|
||||
.ann-x svg{width:14px;height:14px}
|
||||
|
||||
/* ---------- header ---------- */
|
||||
.hdr{position:sticky;top:0;z-index:50;background:color-mix(in srgb, var(--bg) 82%, transparent);backdrop-filter:blur(14px);border-bottom:1px solid transparent;transition:border-color var(--dur-base) var(--ease-out), background var(--dur-base)}
|
||||
.hdr.scrolled{border-bottom-color:var(--border)}
|
||||
.hdr .row{display:flex;align-items:center;gap:28px;height:68px}
|
||||
.brand{display:flex;align-items:center;gap:10px}
|
||||
.brand .nm{font-family:var(--font-display);font-weight:700;font-size:19px;letter-spacing:-0.01em}
|
||||
.nav{display:flex;gap:6px;margin-left:8px}
|
||||
.nav a{padding:8px 13px;border-radius:var(--radius-md);font-size:14.5px;font-weight:500;color:var(--fg2);transition:all var(--dur-fast) var(--ease-out)}
|
||||
.nav a:hover{background:var(--accent-subtle);color:var(--accent)}
|
||||
.hdr .right{margin-left:auto;display:flex;align-items:center;gap:14px}
|
||||
.langseg{display:flex;background:var(--bg-subtle);border-radius:var(--radius-full);padding:3px;gap:2px}
|
||||
.langseg button{border:none;cursor:pointer;border-radius:var(--radius-full);padding:5px 11px;font-family:var(--font-sans);font-size:12.5px;font-weight:700;background:transparent;color:var(--fg3)}
|
||||
.langseg button.on{background:var(--accent);color:var(--fg-on-accent)}
|
||||
.btn{display:inline-flex;align-items:center;gap:8px;font-family:var(--font-sans);font-weight:600;font-size:14.5px;border:none;border-radius:var(--radius-full);padding:11px 20px;cursor:pointer;transition:all var(--dur-fast) var(--ease-out);white-space:nowrap}
|
||||
.btn svg{width:16px;height:16px}
|
||||
.btn-primary{background:var(--accent);color:var(--fg-on-accent)}
|
||||
.btn-primary:hover{background:var(--accent-hover)}
|
||||
.btn-ghost{background:transparent;color:var(--fg1)}
|
||||
.btn-ghost:hover{color:var(--accent)}
|
||||
.btn-secondary{background:var(--surface);color:var(--fg1);border:1.5px solid var(--border-strong)}
|
||||
.btn-secondary:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.btn-lg{padding:14px 26px;font-size:16px}
|
||||
.linklogin{font-size:14.5px;font-weight:600;color:var(--fg1);cursor:pointer}
|
||||
.linklogin:hover{color:var(--accent)}
|
||||
|
||||
/* 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}
|
||||
.mnav{display:none;background:var(--surface);border-bottom:1px solid var(--border);box-shadow:var(--shadow-md)}
|
||||
.mnav.open{display:flex;flex-direction:column;padding:8px 28px 18px}
|
||||
.mnav a{padding:13px 4px;font-size:15.5px;font-weight:600;color:var(--fg1);border-bottom:1px solid var(--border)}
|
||||
.mnav a:active{color:var(--accent)}
|
||||
.mnav .btn{margin-top:14px;justify-content:center;border-bottom:none}
|
||||
|
||||
/* ---------- generic section ---------- */
|
||||
section{padding:88px 0}
|
||||
.sec-tan{background:var(--bg-subtle)}
|
||||
.eyebrow{font-family:var(--font-sans);font-size:13px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:var(--accent)}
|
||||
.h-sec{font-family:var(--font-display);font-weight:700;font-size:38px;letter-spacing:-0.02em;line-height:1.15;margin:12px 0 0}
|
||||
.sub-sec{font-size:17px;color:var(--fg2);line-height:1.6;margin:14px 0 0;max-width:560px}
|
||||
.center{text-align:center}
|
||||
.center .sub-sec{margin-left:auto;margin-right:auto}
|
||||
|
||||
/* ---------- hero ---------- */
|
||||
.hero{padding:64px 0 84px;position:relative;overflow:hidden}
|
||||
.hero .grid{display:grid;grid-template-columns:1.05fr 0.95fr;gap:48px;align-items:center}
|
||||
.hero h1{font-family:var(--font-display);font-weight:700;font-size:54px;line-height:1.08;letter-spacing:-0.03em;margin:16px 0 0}
|
||||
.hero .lede{font-size:19px;color:var(--fg2);line-height:1.6;margin:20px 0 0;max-width:480px}
|
||||
.hero .cta{display:flex;gap:14px;margin-top:30px;flex-wrap:wrap}
|
||||
.trust{display:flex;gap:20px;margin-top:28px;flex-wrap:wrap;color:var(--fg3);font-size:13.5px}
|
||||
.trust span{display:inline-flex;align-items:center;gap:7px;white-space:nowrap}
|
||||
.trust svg{width:15px;height:15px;color:var(--success)}
|
||||
|
||||
/* hero inline signup (B) */
|
||||
.signup{margin-top:28px;max-width:480px}
|
||||
.su-row{display:flex;gap:10px}
|
||||
.su-field{flex:1;display:flex;align-items:center;gap:9px;background:var(--surface);border:1.5px solid var(--border-strong);border-radius:var(--radius-full);padding:0 18px;transition:border-color var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast)}
|
||||
.su-field:focus-within{border-color:var(--accent);box-shadow:var(--shadow-focus)}
|
||||
.su-field svg{width:17px;height:17px;color:var(--fg3);flex-shrink:0}
|
||||
.su-field input{border:none;background:transparent;outline:none;flex:1;font-family:var(--font-sans);font-size:15px;color:var(--fg1);padding:14px 0;min-width:0}
|
||||
.su-btn{flex-shrink:0}
|
||||
.su-ok{display:flex;align-items:center;gap:8px;margin-top:12px;color:var(--success);font-size:13.5px;font-weight:600}
|
||||
.su-ok[hidden]{display:none}
|
||||
.su-ok svg{width:16px;height:16px;flex-shrink:0}
|
||||
.su-hint{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-top:14px;font-size:13px;color:var(--fg3)}
|
||||
.su-hint span{white-space:nowrap}
|
||||
.su-dl{display:inline-flex;align-items:center;gap:6px;font-weight:600;color:var(--accent);white-space:nowrap}
|
||||
.su-dl svg{width:14px;height:14px}
|
||||
|
||||
/* hero visual: connect orb */
|
||||
.orb-wrap{display:flex;align-items:center;justify-content:center;position:relative}
|
||||
.orb-card{width:100%;max-width:380px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-2xl);box-shadow:var(--shadow-xl);padding:34px 30px;display:flex;flex-direction:column;align-items:center;gap:22px}
|
||||
.orb{position:relative;width:184px;height:184px;border-radius:50%;background:var(--success);color:#fff;box-shadow:0 0 0 9px var(--success-subtle), var(--shadow-lg);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px}
|
||||
.orb svg.ic{width:46px;height:46px}
|
||||
.orb .tm{font-family:var(--font-mono);font-weight:600;font-size:17px}
|
||||
.orb .sf{font-size:10px;font-weight:700;letter-spacing:0.1em;opacity:.9}
|
||||
.orb .ring{position:absolute;inset:0}
|
||||
.orb-meta{width:100%;display:flex;gap:10px}
|
||||
.orb-meta .m{flex:1;background:var(--bg-subtle);border-radius:var(--radius-md);padding:10px 12px}
|
||||
.orb-meta .k{display:flex;align-items:center;gap:5px;font-size:10.5px;color:var(--fg3);font-weight:600}
|
||||
.orb-meta .k svg{width:12px;height:12px;color:var(--accent)}
|
||||
.orb-meta .v{font-family:var(--font-mono);font-size:15px;margin-top:3px}
|
||||
.orb-node{width:100%;display:flex;align-items:center;gap:11px;background:var(--bg-subtle);border-radius:var(--radius-lg);padding:11px 13px}
|
||||
.cc{width:34px;height:34px;border-radius:var(--radius-md);background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;font-family:var(--font-mono);font-size:12px;font-weight:600;flex-shrink:0}
|
||||
.orb-node .nn{font-size:14px;font-weight:600}
|
||||
.orb-node .ns{font-size:11px;color:var(--fg3)}
|
||||
|
||||
/* logos strip */
|
||||
.strip{display:flex;align-items:center;justify-content:center;gap:34px;flex-wrap:wrap;color:var(--fg3);font-size:13px;font-weight:600;letter-spacing:0.03em;padding-top:8px}
|
||||
.strip span{display:inline-flex;align-items:center;gap:8px}
|
||||
.strip svg{width:18px;height:18px}
|
||||
|
||||
/* ---------- features ---------- */
|
||||
.feat-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-top:48px}
|
||||
.feat{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-xl);padding:26px;box-shadow:var(--shadow-sm);transition:transform var(--dur-base) var(--ease-out), box-shadow var(--dur-base)}
|
||||
.feat:hover{transform:translateY(-3px);box-shadow:var(--shadow-md)}
|
||||
.feat .ico{width:46px;height:46px;border-radius:var(--radius-lg);background:var(--accent-subtle);display:flex;align-items:center;justify-content:center;margin-bottom:16px}
|
||||
.feat .ico svg{width:23px;height:23px;color:var(--accent)}
|
||||
.feat h3{font-family:var(--font-display);font-weight:600;font-size:18px;margin:0 0 8px}
|
||||
.feat p{font-size:14px;color:var(--fg2);line-height:1.6;margin:0}
|
||||
|
||||
/* ---------- why sign up (C) ---------- */
|
||||
.why-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-top:46px}
|
||||
.why{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-xl);padding:28px 26px;box-shadow:var(--shadow-sm);text-align:center}
|
||||
.why .ico{width:54px;height:54px;border-radius:50%;background:var(--accent-subtle);display:flex;align-items:center;justify-content:center;margin:0 auto 16px}
|
||||
.why .ico svg{width:26px;height:26px;color:var(--accent)}
|
||||
.why h3{font-family:var(--font-display);font-weight:600;font-size:18px;margin:0 0 8px}
|
||||
.why p{font-size:14px;color:var(--fg2);line-height:1.6;margin:0}
|
||||
.steps{display:flex;align-items:center;justify-content:center;gap:14px;margin-top:44px;flex-wrap:wrap}
|
||||
.step{display:flex;align-items:center;gap:10px;background:var(--bg-subtle);border:1px solid var(--border);border-radius:var(--radius-full);padding:10px 20px 10px 12px}
|
||||
.step .n{width:26px;height:26px;border-radius:50%;background:var(--accent);color:#fff;font-weight:700;font-size:13px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||||
.step svg{width:17px;height:17px;color:var(--accent)}
|
||||
.step .t{font-size:14px;font-weight:600;white-space:nowrap}
|
||||
.step-arr{display:flex}
|
||||
.step-arr svg{width:18px;height:18px;color:var(--fg3)}
|
||||
.why-cta{text-align:center;margin-top:38px}
|
||||
|
||||
/* ---------- pricing ---------- */
|
||||
.price-toggle{display:inline-flex;background:var(--bg-subtle);border-radius:var(--radius-full);padding:4px;gap:2px;margin:22px auto 0}
|
||||
.price-toggle button{border:none;cursor:pointer;border-radius:var(--radius-full);padding:8px 18px;font-family:var(--font-sans);font-size:13.5px;font-weight:600;background:transparent;color:var(--fg3)}
|
||||
.price-toggle button.on{background:var(--surface);color:var(--fg1);box-shadow:var(--shadow-sm)}
|
||||
.price-toggle .save{font-size:11px;color:var(--success);font-weight:700;margin-left:5px}
|
||||
.price-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-top:42px;align-items:start}
|
||||
.plan{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-2xl);padding:30px 28px;box-shadow:var(--shadow-sm);position:relative}
|
||||
.plan.feat-plan{background:linear-gradient(160deg,var(--clay-600),var(--clay-800));color:#fff;border:none;box-shadow:var(--shadow-lg)}
|
||||
.plan .pop{position:absolute;top:-13px;left:50%;transform:translateX(-50%);background:#fff;color:var(--clay-700);font-size:11.5px;font-weight:700;padding:5px 14px;border-radius:var(--radius-full);box-shadow:var(--shadow-sm);white-space:nowrap}
|
||||
.plan .pname{font-size:15px;font-weight:600;color:var(--fg2)}
|
||||
.plan.feat-plan .pname{color:rgba(255,255,255,.85)}
|
||||
.plan .price{font-family:var(--font-display);font-weight:700;font-size:42px;letter-spacing:-0.02em;margin:10px 0 2px}
|
||||
.plan .price small{font-size:15px;font-weight:500;color:var(--fg3)}
|
||||
.plan.feat-plan .price small{color:rgba(255,255,255,.7)}
|
||||
.plan .pdesc{font-size:13.5px;color:var(--fg3);min-height:38px}
|
||||
.plan.feat-plan .pdesc{color:rgba(255,255,255,.8)}
|
||||
.plan .feats{list-style:none;padding:0;margin:20px 0 24px;display:flex;flex-direction:column;gap:11px}
|
||||
.plan .feats li{display:flex;align-items:flex-start;gap:9px;font-size:14px;color:var(--fg2);line-height:1.45}
|
||||
.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}
|
||||
.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}
|
||||
.pay-note{display:flex;align-items:flex-start;gap:12px;margin-top:30px;background:var(--accent-subtle);border:1px solid var(--accent-border);border-radius:var(--radius-lg);padding:16px 18px;font-size:13.5px;color:var(--fg2);line-height:1.6}
|
||||
.pay-note svg{width:18px;height:18px;color:var(--accent);flex-shrink:0;margin-top:2px}
|
||||
.pay-note b{color:var(--fg1)}
|
||||
.chips{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
|
||||
.chip{display:inline-flex;align-items:center;gap:6px;font-size:12.5px;font-weight:600;color:var(--accent);background:var(--surface);border:1px solid var(--accent-border);border-radius:var(--radius-full);padding:6px 12px}
|
||||
.chip svg{width:13px;height:13px}
|
||||
|
||||
/* comparison table */
|
||||
.cmp{margin-top:46px;border:1px solid var(--border);border-radius:var(--radius-xl);overflow:hidden;background:var(--surface);box-shadow:var(--shadow-sm)}
|
||||
.cmp table{width:100%;border-collapse:collapse}
|
||||
.cmp th,.cmp td{text-align:left;padding:14px 20px;font-size:14px;border-bottom:1px solid var(--border)}
|
||||
.cmp thead th{background:var(--bg-subtle);font-family:var(--font-display);font-weight:700;font-size:14.5px}
|
||||
.cmp thead th:not(:first-child),.cmp td:not(:first-child){text-align:center;width:130px}
|
||||
.cmp tbody tr:last-child td{border-bottom:none}
|
||||
.cmp td:first-child{color:var(--fg2)}
|
||||
.cmp .yes{color:var(--success)}
|
||||
.cmp .yes svg{width:18px;height:18px;display:inline}
|
||||
.cmp .no{color:var(--fg3)}
|
||||
.cmp .tx{font-family:var(--font-mono);font-size:13px;color:var(--fg1)}
|
||||
|
||||
/* ---------- download ---------- */
|
||||
.dl-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:16px;margin-top:46px}
|
||||
.dl{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-xl);padding:24px 18px;text-align:center;box-shadow:var(--shadow-sm);transition:all var(--dur-base) var(--ease-out)}
|
||||
.dl:hover{transform:translateY(-3px);box-shadow:var(--shadow-md);border-color:var(--accent-border)}
|
||||
.dl .ico{width:48px;height:48px;margin:0 auto 14px;border-radius:var(--radius-lg);background:var(--bg-subtle);display:flex;align-items:center;justify-content:center}
|
||||
.dl .ico svg{width:24px;height:24px;color:var(--fg1)}
|
||||
.dl .pn{font-family:var(--font-display);font-weight:600;font-size:16px}
|
||||
.dl .pv{font-size:12px;color:var(--fg3);margin:3px 0 14px;font-family:var(--font-mono)}
|
||||
.dl .gb{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:600;color:var(--accent)}
|
||||
.dl .gb svg{width:14px;height:14px}
|
||||
|
||||
/* ---------- docs ---------- */
|
||||
.docs-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;margin-top:44px}
|
||||
.doc{display:block;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-xl);padding:24px;box-shadow:var(--shadow-sm);transition:all var(--dur-base) var(--ease-out)}
|
||||
.doc:hover{transform:translateY(-3px);box-shadow:var(--shadow-md)}
|
||||
.doc .ico{width:42px;height:42px;border-radius:var(--radius-lg);background:var(--accent-subtle);display:flex;align-items:center;justify-content:center;margin-bottom:14px}
|
||||
.doc .ico svg{width:21px;height:21px;color:var(--accent)}
|
||||
.doc h3{font-family:var(--font-display);font-weight:600;font-size:16px;margin:0 0 6px}
|
||||
.doc p{font-size:13px;color:var(--fg2);line-height:1.55;margin:0 0 12px}
|
||||
.doc .ln{font-size:13px;font-weight:600;color:var(--accent);display:inline-flex;align-items:center;gap:5px}
|
||||
.doc .ln svg{width:14px;height:14px}
|
||||
|
||||
/* ---------- blog ---------- */
|
||||
.blog-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:22px;margin-top:44px}
|
||||
.post{display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-sm);transition:all var(--dur-base) var(--ease-out)}
|
||||
.post:hover{transform:translateY(-3px);box-shadow:var(--shadow-md)}
|
||||
.post .cover{height:140px;background:linear-gradient(150deg,var(--clay-300),var(--clay-600));position:relative}
|
||||
.post .cover.c2{background:linear-gradient(150deg,var(--sand-300),var(--sand-600))}
|
||||
.post .cover.c3{background:linear-gradient(150deg,var(--green-400),var(--green-600))}
|
||||
.post .cover .tag{position:absolute;top:12px;left:12px;background:rgba(255,255,255,.9);color:var(--clay-700);font-size:11px;font-weight:700;padding:4px 10px;border-radius:var(--radius-full)}
|
||||
.post .body{padding:18px 20px 22px}
|
||||
.post .date{font-size:12px;color:var(--fg3);font-family:var(--font-mono)}
|
||||
.post h3{font-family:var(--font-display);font-weight:600;font-size:17px;line-height:1.3;margin:8px 0 8px}
|
||||
.post p{font-size:13.5px;color:var(--fg2);line-height:1.55;margin:0}
|
||||
|
||||
/* ---------- CTA band ---------- */
|
||||
.cta-band{background:linear-gradient(160deg,var(--clay-500),var(--clay-800));color:#fff;border-radius:var(--radius-2xl);padding:56px;text-align:center;box-shadow:var(--shadow-lg)}
|
||||
.cta-band h2{font-family:var(--font-display);font-weight:700;font-size:36px;letter-spacing:-0.02em;margin:0}
|
||||
.cta-band p{font-size:17px;opacity:.9;margin:14px 0 28px}
|
||||
.cta-band .btn-white{background:#fff;color:var(--clay-700)}
|
||||
.cta-band .btn-white:hover{background:var(--sand-100)}
|
||||
.cta-actions{display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
|
||||
.cta-ghost{background:transparent;color:#fff;border:1.5px solid rgba(255,255,255,.45)}
|
||||
.cta-ghost:hover{border-color:#fff}
|
||||
|
||||
/* ---------- footer ---------- */
|
||||
.ftr{background:var(--sand-950);color:#cfc6b8;padding:64px 0 32px}
|
||||
[data-theme="dark"] .ftr{background:#000}
|
||||
.ftr .top{display:grid;grid-template-columns:1.4fr 1fr 1fr 1fr;gap:36px}
|
||||
.ftr .nm{font-family:var(--font-display);font-weight:700;font-size:18px;color:#fff;display:flex;align-items:center;gap:10px}
|
||||
.ftr .tag{font-size:13.5px;line-height:1.6;margin:14px 0 0;max-width:280px;color:#a89e8e}
|
||||
.ftr h4{font-family:var(--font-sans);font-size:12px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#8a8070;margin:0 0 14px}
|
||||
.ftr ul{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:10px}
|
||||
.ftr ul a{font-size:14px;color:#cfc6b8}
|
||||
.ftr ul a:hover{color:#fff}
|
||||
.ftr .mono{font-family:var(--font-mono);font-size:12.5px}
|
||||
.ftr .bot{display:flex;align-items:center;justify-content:space-between;margin-top:46px;padding-top:24px;border-top:1px solid rgba(255,255,255,.1);font-size:12.5px;color:#8a8070;flex-wrap:wrap;gap:12px}
|
||||
|
||||
/* ---------- responsive ---------- */
|
||||
@media (max-width:980px){
|
||||
.nav{display:none}
|
||||
.menu-btn{display:flex}
|
||||
.linklogin{display:none}
|
||||
.hero .grid{grid-template-columns:1fr;gap:36px}
|
||||
.feat-grid,.price-grid,.blog-grid,.why-grid{grid-template-columns:1fr}
|
||||
.docs-grid{grid-template-columns:1fr 1fr}
|
||||
.dl-grid{grid-template-columns:1fr 1fr}
|
||||
.ftr .top{grid-template-columns:1fr 1fr}
|
||||
.hero h1{font-size:40px}
|
||||
.h-sec{font-size:30px}
|
||||
.cmp{overflow-x:auto}
|
||||
.cmp table{min-width:560px}
|
||||
}
|
||||
@media (max-width:640px){
|
||||
.wrap{padding:0 20px}
|
||||
.ann-in span{white-space:normal}
|
||||
section{padding:56px 0}
|
||||
.hero{padding:32px 0 56px}
|
||||
.hero h1{font-size:34px}
|
||||
.hero .lede{font-size:16px}
|
||||
.h-sec{font-size:26px}
|
||||
.sub-sec{font-size:15px}
|
||||
.hdr .row{height:60px;gap:14px}
|
||||
.hdr .right{gap:10px}
|
||||
.hdr .right .btn{display:none}
|
||||
.mnav.open{padding:8px 20px 16px}
|
||||
.ann-in{font-size:12.5px;padding:8px 44px 8px 16px;justify-content:flex-start;flex-wrap:wrap}
|
||||
.su-row{flex-direction:column}
|
||||
.su-btn{justify-content:center;width:100%}
|
||||
.su-field input{padding:13px 0}
|
||||
.trust{gap:12px}
|
||||
.orb-card{max-width:330px;padding:26px 22px}
|
||||
.strip{gap:16px;font-size:12px}
|
||||
.price-toggle{margin-top:18px}
|
||||
.steps{gap:10px}
|
||||
.step{padding:8px 14px 8px 10px}
|
||||
.step .t{font-size:13px}
|
||||
.step-arr{display:none}
|
||||
.docs-grid{grid-template-columns:1fr}
|
||||
.cta-band{padding:42px 24px}
|
||||
.cta-band h2{font-size:27px}
|
||||
.cta-band p{font-size:15px}
|
||||
.ftr{padding:48px 0 28px}
|
||||
.ftr .top{grid-template-columns:1fr 1fr;gap:30px 20px}
|
||||
.ftr .bot{flex-direction:column;align-items:flex-start;gap:8px}
|
||||
}
|
||||
@media (max-width:420px){
|
||||
.dl-grid{grid-template-columns:1fr}
|
||||
.hero h1{font-size:30px}
|
||||
.langseg button{padding:4px 9px}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"],
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user