Compare commits

19 Commits

Author SHA1 Message Date
mini
5c4b29804e Merge pull request 'fix: remove iShare references from frontend' (#4) from fix/remove-ishare-refs into main
Some checks failed
continuous-integration/drone/push Build is passing
CI / test (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled
2026-04-23 13:08:44 +08:00
mini
623a7518b2 fix(docs): remove iShare mention (puro 独立运营定位)
- docs.sections.getKey.note 键从 zh/en 删除
- DocsView 对应 <p class="note"> 段删掉
- 全仓再次 grep 确认无其他 ishare/iShare 引用
2026-04-23 13:08:43 +08:00
mini
291e3bfe43 Merge pull request 'fix/refactor: PortalLayout + nav unify' (#3) from fix/nav-layout-shift into main
Some checks failed
continuous-integration/drone/push Build is passing
CI / test (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled
2026-04-23 12:55:26 +08:00
mini
e7f3fe5b4d refactor(portal): extract PortalLayout so Nav/Footer persist across routes
Some checks failed
CI / test (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled
将 Landing/Docs/Pricing 的 Nav + Footer 提取到共享 PortalLayout。
Router 改为嵌套结构,路由切换时 router-view 内容变化,Nav 本身
不重挂载,消除切页时的 UI 抖动(真·SPA 行为)。

- new: components/layout/PortalLayout.vue(Nav + router-view + Footer)
- router: /、/docs、/pricing 作为 PortalLayout 的子路由
- i18n: 新增 portal.nav.* 命名空间;删除重复的 docs.nav.* / pricing.nav.* / landing.nav.*
- router: scrollBehavior 支持 hash 锚点跳转(offset 80px 绕开 sticky nav)
- router-link 使用 active-class/exact-active-class prop 替代硬编码 class="active"
2026-04-23 12:52:07 +08:00
mini
779005e1cd fix(portal-nav): unify signup CTA + replace anchor href=# with router-link
Some checks failed
CI / test (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled
- docs.nav.signup 从 '注册' 改为 '免费试用 →',与 Landing/Pricing 一致
- en.ts docs.nav.products 'Product' → 'Products'(复数统一)
- en.ts docs.nav.signup 'Sign up' → 'Free trial →'
- PricingView 定价链接改用 <router-link to="/pricing" class="active">,消除 href=# 触发的 scroll + URL 变动

修复导航栏在切换页面时按钮宽度变化导致的布局抖动问题。
2026-04-23 11:51:00 +08:00
mini
77bb69b2c5 Merge pull request 'feat: PURO portal i18n (zh/en) + Pricing page' (#2) from feat/portal-i18n-pricing into main
Some checks failed
continuous-integration/drone/push Build is passing
CI / test (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled
2026-04-21 02:05:56 +08:00
mini
b989c50317 feat(pricing): add PricingView + calculator with bilingual i18n
Some checks failed
CI / test (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / golangci-lint (pull_request) Has been cancelled
Security Scan / backend-security (pull_request) Has been cancelled
Security Scan / frontend-security (pull_request) Has been cancelled
Port Pricing.html verbatim to Vue: hero with preview pill, 4-tier grid,
custom-amount slider, PricingCalculator subcomponent, works-everywhere
grid, FAQ accordions, final CTA. Full zh/en pricing namespace (~200 keys
each). SOON chip on Scale priority feature; zero-log FAQ uses inline
parenthetical. Drop $5 bonus line; Enterprise → mailto, Binding/tiers →
/register, docs link → /register/docs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 01:56:18 +08:00
mini
13bdd8f892 fix(docs): restore dashboard link in models.note (was dropped during i18n) 2026-04-21 01:44:04 +08:00
mini
73b3980711 feat(portal): i18n-ify DocsView + auth narrative panels
Extract all Chinese from DocsView.vue into docs.* namespace and add
auth.narrative.* sub-namespace for LoginView/RegisterView narrative slots.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 01:42:32 +08:00
mini
fc7e27671d feat(landing): extract i18n keys + add English translations
Replaces all rendered Chinese strings in LandingView with $t() calls and
<i18n-t> interpolation components; adds landing namespace (62 leaf keys) to
both zh.ts and en.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 01:30:23 +08:00
mini
6328881801 feat(portal): mount PuroLocaleSwitcher in Landing/Docs/AuthLayout 2026-04-20 21:36:41 +08:00
mini
e711a20373 feat(i18n): add PuroLocaleSwitcher for portal pages 2026-04-20 21:24:51 +08:00
mini
49ee2cba8a fix(docs): DocsView fidelity port (plan A)
Some checks failed
continuous-integration/drone/push Build is passing
CI / test (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled
Visual alignment with zip Docs.html (excluding 3-col layout):
- SVG hexagon logo (replaces ⬢ emoji)
- h2 cyan accent bar (::before 3px left strip)
- Models section: replace <ul> with structured table
  (provider badges with brand-color dots, OK/BETA status chips)
- Wrap all code blocks in .code-panel with:
  - traffic-light header + filename tab
  - 复制 button with clipboard API + 已复制 feedback

Kept intentionally different per Stage 1 decisions:
- Section 1 uses 'contact admin@puro.im' (not OAuth self-serve)
- Nav omits pricing / design-system links
- Codex CLI section preserved (Vue-only)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:19:46 +08:00
mini
e843a7aef8 fix: fidelity port of Landing/Login/Register from design zip
Some checks failed
continuous-integration/drone/push Build is passing
CI / test (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled
Merges 3 port commits bringing Vue views closer to Claude Design source:
- 04676563 LoginView: N/kicker/route-demo/n-bottom/SVG hex
- 9f78b70a RegisterView: N/kicker/steps panel/pw-strength/confirm-pw/terms
- 4cf68404 LandingView: badge/pills/9 bullets/SVG logos/traffic+tabs/donut/sidebar
2026-04-19 22:58:29 +08:00
mini
4cf6840479 fix(landing): LandingView fidelity port from design zip
A-group deltas restored (excluding Stage 1 decisions — no Pricing/FAQ/
CTA banner, kept existing Hero CTA copy):

- Nav + footer brand: SVG hexagon replaces ⬢ emoji
- Hero: add NEW badge in eyebrow; inline pills around OpenAI/Anthropic
- Section kickers: monospace // providers / // capabilities / etc
- Features: restored title "付一次订阅,用起一整个模型池" + subtitle
  + 9 bullet items (3 per card, dashed-border lists)
- Model wall: SVG letter-logos + green status chips (was plain dots)
- Code demo: traffic-light + tab header in each code-block
- Dashboard mockup: added sidebar nav + donut chart (chart-grid 2fr:1fr)
- Footer: Chinese product tagline; all-systems-operational indicator

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:58:07 +08:00
mini
9f78b70a87 fix(auth): RegisterView fidelity port from design zip
Port A-group deltas from design zip (excluding bonus/pricing which
are explicitly out of scope):
- Narrative: N (not 5) 个订阅; add '// 5 分钟开始用' n-kicker;
  SVG hexagon logo (was emoji); n-bottom live status bar
- Add 3-step onboarding panel (创建账户 → 绑定订阅 → 生成 key)
  in narrative, active-step highlighted
- Add password strength meter (4 bars + text label 弱/中/强/极强)
- Add confirm-password field with live // matched/mismatch hint
- Add Terms & Privacy consent checkbox (submit gated)
- New i18n keys: confirmPasswordLabel/Placeholder, passwordsDoNotMatch

All existing Vue logic preserved (OAuth/Turnstile/verify code/
invitation+promo codes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:53:40 +08:00
mini
046765632d fix(auth): LoginView fidelity port from design zip
- Narrative: N (not 5) 个订阅, matching design intent
- Add '// 你的订阅,已经付过钱了' n-kicker above headline
- Port route-demo panel (POST /v1/chat/completions → pool → 200 OK)
- Port n-bottom live status bar (green dot + ai.puro.im operational)
- Replace ⬢ emoji with inline SVG hexagon (crisp at all sizes)

All Vue auth logic preserved: OAuth sections, Turnstile, 2FA modal,
forgot-password, form validation, v-model bindings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:45:54 +08:00
mini
93481b8c45 fix(auth): restore dark split-layout visuals on /login /register
Some checks failed
continuous-integration/drone/push Build is passing
CI / test (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled
Three regressions from Task 7-9 caused /login /register to render broken:
- bg-glow not rendering: puro.css scopes .bg-glow to .puro-page,
  AuthLayout isn't inside one. Fix: duplicate bg-glow rules into
  AuthLayout scoped CSS keyed on .auth-shell-split.
- .auth-main had no background: right side showed naked body bg.
  Fix: .auth-shell-split now sets var(--bg-0) for whole shell.
- Heading/label colors used text-gray-900 light-mode classes,
  invisible on dark bg. Fix: switch to explicit text-slate-50/400,
  and :deep() override for form inputs via AuthLayout split scope.

Legacy (non-split) mode unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:23:11 +08:00
mini
6291dc40d0 fix(auth): restore dark split-layout visuals on /login /register
Three regressions from Task 7-9 caused /login /register to render broken:
- bg-glow not rendering: puro.css scopes .bg-glow to .puro-page,
  AuthLayout isn't inside one. Fix: duplicate bg-glow rules into
  AuthLayout scoped CSS keyed on .auth-shell-split.
- .auth-main had no background: right side showed naked body bg.
  Fix: .auth-shell-split now sets var(--bg-0) for whole shell.
- Heading/label colors used text-gray-900 light-mode classes,
  invisible on dark bg. Fix: switch to explicit text-slate-50/400,
  and :deep() override for form inputs via AuthLayout split scope.

Legacy (non-split) mode unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:22:25 +08:00
17 changed files with 5881 additions and 271 deletions

168
LOCAL_SETUP_NOTES.md Normal file
View File

@@ -0,0 +1,168 @@
# Sub2API 本地开发环境搭建记录
> 2026-04-19 @ macOS (darwin/arm64)
## 一、环境依赖
| 项 | 版本 | 说明 |
|---|---|---|
| Go | 1.24.3(本机)→ 1.26.2auto via GOTOOLCHAIN | go.mod 要求 1.26.2,靠 Go 1.21+ 的 GOTOOLCHAIN 机制自动下载 |
| Node | v24.13.0 | ≥18 即可 |
| pnpm | v10.33.0 | 现装:`npm install -g pnpm` |
| Docker | OrbStack | 已跑 mysql8@3306,端口冲突规避见下 |
| 端口占用 | 8080 / 5433 / 6380 均空闲 | 5432/6379 留给可能的其他 PG/Redis |
## 二、部署步骤(实际执行)
### 1. 拉源码
```bash
git clone https://github.com/Wei-Shaw/sub2api.git /Users/mini/Work/dev/sub2api
```
### 2. 起依赖容器(非默认端口)
```bash
docker run -d --name sub2api-pg \
-e POSTGRES_PASSWORD=devpass -e POSTGRES_DB=sub2api \
-p 5433:5432 postgres:15
docker run -d --name sub2api-redis \
-p 6380:6379 redis:7
```
### 3. 安装 pnpm首次
```bash
npm install -g pnpm
```
### 4. 构建前端
```bash
cd /Users/mini/Work/dev/sub2api/frontend
pnpm install # ~9 秒
pnpm run build # ~8 秒,产物输出到 ../backend/internal/web/dist/
```
### 5. 构建后端(⚠️ 必带 `-tags embed`
```bash
cd /Users/mini/Work/dev/sub2api/backend
go build -tags embed -o sub2api ./cmd/server
# 产物105MB Mach-O 64-bit arm64
```
### 6. 生成 config.yaml
```bash
cp /Users/mini/Work/dev/sub2api/deploy/config.example.yaml \
/Users/mini/Work/dev/sub2api/backend/config.yaml
```
修改四处(见下方问题点 Issue #1 说明 sslmode
| 字段 | 原值 | 改为 |
|---|---|---|
| `database.port` | 5432 | **5433** |
| `database.password` | `"your_secure_password_here"` | `"devpass"` |
| `database.sslmode` | `"prefer"` | **`"disable"`** |
| `redis.port` | 6379 | **6380** |
| `jwt.secret` | `"change-this-..."` | `openssl rand -hex 32` 产出的 64 位 hex |
### 7. 启动 + 验证
```bash
cd /Users/mini/Work/dev/sub2api/backend
nohup ./sub2api > /tmp/sub2api.log 2>&1 &
# 等 5-10 秒让服务完成 pricing 数据下载
curl -si http://localhost:8080 | head
# 期望HTTP/1.1 200 OKHTML 含 <title>Sub2API - AI API Gateway</title>
```
---
## 三、遇到的问题与解法
### Issue #1 — `sslmode: "prefer"` 启动失败
**现象**:后端启动立即退出,日志:
```
Failed to initialize application: acquire migrations lock:
pq: unsupported sslmode "prefer"; only "require" (default),
"verify-full", "verify-ca", and "disable" supported
```
**根因**`config.example.yaml` 默认的 `sslmode: "prefer"` 是 libpqC 驱动的模式Go 的 `lib/pq` 不支持。
**解法**:本地 Docker Postgres 没配 SSL改成 `disable`
```yaml
database:
sslmode: "disable"
```
生产若走带 SSL 的 PG`require``verify-full`
---
### Issue #2 — go.mod 要求 Go 1.26.2,本机只有 1.24.3
**现象**:首次 `go build` 触发:
```
go: downloading go1.26.2 (darwin/arm64)
```
**根因**`backend/go.mod` 第一行 `go 1.26.2` 写死。
**解法****无需手动升级 Go**。Go 1.21+ 的 GOTOOLCHAIN 机制会自动下载指定版本并透明切换。首次 build 比较慢(下载 toolchain + 全部依赖),后续会缓存。
---
### Issue #3 — frontend 构建产物路径是相对路径到 backend
**现象**`pnpm run build` 的日志显示产物写到 `../backend/internal/web/dist/`
**说明****这是预期行为**。Vite 配置把输出指向 backend 的 embed 目录,配合 `go build -tags embed` 把 dist 打进 Go 二进制。所以:
- 每次改前端代码都要重新 `pnpm run build` 然后 `go build -tags embed`
- 如果 `go build` 时忘了 `-tags embed`,后端启动后访问 `/` 会 404
---
### Issue #4 — 日志文件 `/app/data/logs/sub2api.log` 写入失败
**现象**:启动日志里有 WARN
```
日志文件输出初始化失败,降级为仅标准输出
path=/app/data/logs/sub2api.log err=mkdir /app: read-only file system
```
**根因**:默认配置指向容器内路径 `/app/data/logs/`,本地裸跑在 macOS 上 `/app` 不可写。
**影响**:无功能影响,只是降级到 stdout。我们用 `nohup ./sub2api > /tmp/sub2api.log 2>&1 &` 已经把 stdout 重定向了,日志照样完整。
**若要消除 WARN**:修改 config 里 `logging.file_path`(或等同字段)指向本地可写路径,如 `/tmp/sub2api/logs/sub2api.log`,并 `mkdir -p` 目录。
---
## 四、当前状态
```
Backend PID: 26921
HTTP: 200 @ http://localhost:8080
页面: Sub2API - AI API GatewaySetup Wizard 入口)
Pricing: 已下载 177 个模型价格
Containers: sub2api-pg (Up), sub2api-redis (Up)
```
---
## 五、下次重启命令
```bash
# 启动依赖(容器如果 stopped
docker start sub2api-pg sub2api-redis
# 启动后端
cd /Users/mini/Work/dev/sub2api/backend
nohup ./sub2api > /tmp/sub2api.log 2>&1 &
# 停止
pkill -f "/Users/mini/Work/dev/sub2api/backend/sub2api"
docker stop sub2api-pg sub2api-redis
```
## 六、清理重来
```bash
pkill -f "/Users/mini/Work/dev/sub2api/backend/sub2api"
docker rm -f sub2api-pg sub2api-redis
rm -rf /Users/mini/Work/dev/sub2api
```

1064
backend/config.prod.yaml Normal file

File diff suppressed because it is too large Load Diff

BIN
backend/sub2api-linux Executable file

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,445 @@
# PURO AI Fidelity Delta Report
> Compare: `docs/design-drafts/v2/{Landing,Login,Register}.html` vs `frontend/src/views/{landing,auth}/*.vue`
> Generated: 2026-04-19
---
## 1. LandingView
**Zip:** `docs/design-drafts/v2/Landing.html`
**Vue:** `frontend/src/views/landing/LandingView.vue`
---
### Copy / Text Deltas
| # | Zip (HTML) | Vue (SFC) | Severity | Notes |
|---|---|---|---|---|
| L-T1 | Hero eyebrow: `<span class="badge">NEW</span> 统一接入多个 AI 平台 · 零改动切换` | Hero eyebrow: `ChatGPT Plus · Claude Pro · Codex · Gemini` (plain pill, no badge) | **important** | Zip has a highlighted NEW badge + tagline; Vue shows only a product-list pill with no "NEW" call-out and no `统一接入…` text |
| L-T2 | Hero sub: `聚合成统一 API零改动接入 <span class="pill">OpenAI</span> / <span class="pill">Anthropic</span> SDK` — pill is a code-styled inline token | Vue: `聚合成统一 API零改动接入 OpenAI / Anthropic SDK` — plain text, no pill styling | **cosmetic** | Inline `<span class="pill">` with monospace border lost |
| L-T3 | Hero CTA buttons: `立即开始 →` (primary) + `查看文档` (ghost) | Hero CTA buttons: `登录 →` (primary) + `联系咨询` (ghost) | **important** | Primary button label changed from "立即开始" to "登录"; secondary changed from "查看文档" to "联系咨询". Zip routes unauthenticated users to Register first; Vue routes to Login |
| L-T4 | Hero micro-text: `无需信用卡 · 用你已有的订阅 · 5 分钟跑通` (three dot-separated items, each a `<span>`) | Hero micro-text: `已验证可用 Codex CLI · Claude Code · curl · 服务器出口新加坡` (single plain string) | **important** | Entirely different social-proof claims; zip emphasises zero-friction onboarding, Vue emphasises tech compatibility |
| L-T5 | Model-wall section kicker: `// providers` | Vue model-wall section kicker: `支持的 AI 平台` (Chinese, no `//` prefix) | **cosmetic** | Zip uses monospace `// providers` pattern; Vue switches to Chinese heading |
| L-T6 | Model-wall subtitle: `通过 OAuth 直接复用你的订阅,无需申请官方 API key` | Vue sub: `无需申请官方 API key也无需切换账号` | **cosmetic** | Minor copy rewrite; same meaning |
| L-T7 | Features section title: `付一次订阅,<br>用起一整个模型池` | Vue section title: `一套 key三件武器` | **important** | Substantially different headline; zip's "pay once" framing vs Vue's "one key, three weapons" |
| L-T8 | Features section sub: `把散落在各个平台的订阅,整合成开发者真正能用的基础设施` | Vue: *(sub entirely absent)* | **important** | Vue omits the features section subtitle entirely |
| L-T9 | Feature card 2 copy: `某个 ChatGPT Plus 触发限流,自动 failover 到下一个。重启、刷新 token 全自动。` | Vue: `某个上游触发限流 / 冷却时流量切到下一个健康账号token 刷新全自动。` | **cosmetic** | Light rewrite, same meaning |
| L-T10 | Feature bullets (all 3 cards): 3-bullet lists under a dashed-border divider | Vue: *bullets entirely absent* from all feature cards | **important** | All 9 feature bullet items (`OpenAI Responses API 兼容`, `限流/5xx 自动 failover`, etc.) are missing |
| L-T11 | Code demo section kicker: `// integration` | Vue: `快速接入` | **cosmetic** | |
| L-T12 | Dashboard section sub (long): `不像第三方 API 池子那种"扣了多少不告诉你"。你能看到每次调用:扣了哪个账号、跑了哪个模型、用了多少 tokens、花了多少钱、上游响应几秒。` | Vue: `不像第三方 API 池子那种"扣了多少不告诉你"——扣哪个账号、跑哪个模型、用了多少 tokens、上游响应几秒一目了然。` | **cosmetic** | Condensed; "花了多少钱" detail removed from Vue version |
| L-T13 | CTA banner: `把订阅变成 API — 5 分钟` + `绑定第一个账号,生成 sk- key把 base_url 指过来。就这些。` | *CTA banner entirely absent from Vue* | **important** | The mid-page conversion CTA section is completely missing from Vue |
| L-T14 | Pricing section (full 3-tier grid + `pricing-more-link`) | *Pricing section entirely absent from Vue* | **important** | Full pricing section with 3 tiers ($9.9 / $29.9 / $99), all tier copy, and "查看完整定价" link is missing |
| L-T15 | FAQ section (6 expandable `<details>` + "查看全部 10 个问题" link) | *FAQ section entirely absent from Vue* | **important** | All FAQ content missing |
| L-T16 | Footer brand tagline: `把多个 AI 订阅聚合成统一 API。让「已经付过钱」的订阅真正为你工作。` | Vue: `Self-hosted on puro.im` | **important** | Zip has Chinese product tagline; Vue shows English self-hosted note |
| L-T17 | Footer meta: `© 2026 puro.im · Built with ♥ in Shanghai` | Vue: `© 2026 puro.im · MIT License\nfork of Wei-Shaw/sub2api` | **cosmetic** | Different but both acceptable |
| L-T18 | Footer "产品" column: 文档 / 定价 / FAQ / 功能 | Vue "产品" column: 文档 / 更新日志 | **important** | Vue removes 定价/FAQ/功能 links |
| L-T19 | Footer "账户" column: 登录 / 注册 / Dashboard / 绑定订阅 | Vue footer has "联系" column (admin@puro.im / git.puro.im) instead of "账户" column | **important** | Entire "账户" column replaced with "联系" column |
| L-T20 | Footer system-status line: `all systems operational` (with green dot) | *Absent from Vue footer* | **cosmetic** | |
| L-T21 | Stat card labels (dashboard): `Requests · 24h`, `Tokens · 24h`, `Avg latency`, `Est. savings` | Vue: `今日请求`, `输入 Tokens`, `输出 Tokens`, `今日费用` | **important** | Different stat names and dimensions (4th stat is "savings" in zip vs "cost" in Vue; zip has 1 tokens stat vs Vue has 2 separate I/O token stats) |
| L-T22 | Log table columns: Time / Provider / Model / Tokens / Cost / Latency / Status | Vue: 时间 / 模型 / 上游 / 状态 / 用量 (5 cols, Chinese, Cost and Latency absent) | **important** | Zip has 7-column table with Cost + Latency; Vue drops those two columns |
---
### Structure Deltas
| # | What zip has | Vue status | Severity |
|---|---|---|---|
| L-S1 | Nav links include `#pricing` and `#faq` anchor links | Vue nav only has `#features` + `/docs` (no pricing/faq) | **important** |
| L-S2 | Hero: `<span class="hero-micro">` with 3 `<span>` children and `.dot` separators | Vue: single plain string in `.hero-micro`, no separators | **cosmetic** |
| L-S3 | Model cards: icon logo (`<div class="model-logo">` + inline SVG) + name + tag stacked vertically with green status chip | Vue: colored dot + name + meta in a row (horizontal), no status chip, no SVG logos | **important** |
| L-S4 | Code demo: single-column with TWO separate `code-frame` panels, each with traffic-light dots + tabs + `● edited 2s ago` / `zsh · puro ≈ 210ms` labels | Vue: two-column `code-demo` grid with simplified `code-block` panels (no tab row, no traffic dots) | **important** |
| L-S5 | Dashboard: browser-chrome header (`dash-head` with url bar `ai.puro.im/dashboard` + lock icon + `me@puro` label) | Vue: minimal `dash-header` with title + three dots, no URL bar | **important** |
| L-S6 | Dashboard: left sidebar (`dash-side`) with WORKSPACE and SETTINGS nav groups | Vue: sidebar entirely absent | **important** |
| L-S7 | Dashboard chart area: `chart-grid` (2fr + 1fr) with line chart SVG + donut chart SVG side-by-side | Vue: single `chart-card` with a simplified `<polyline>` chart only, no donut | **important** |
| L-S8 | Donut chart: full SVG with 4 arc segments (Claude 48% / GPT 32% / Gemini 14% / Codex 6%) + legend | Vue: absent | **important** |
| L-S9 | CTA banner section (mid-page, after dashboard) | Vue: absent | **important** |
| L-S10 | Pricing section (3-tier grid) | Vue: absent | **important** |
| L-S11 | FAQ section (`<details>` accordion, 6 items) | Vue: absent | **important** |
| L-S12 | Footer: 4-column grid (2fr brand + 3×1fr cols) with `footer-brand` description paragraph | Vue: 4-column grid present but content differs (see L-T16/T18/T19) | **cosmetic** (structure match, content differs) |
---
### Visuals / SVG
| # | Delta | Severity |
|---|---|---|
| L-V1 | Nav brand: inline SVG hexagon (`<path d="M12 2L21 7V17L12 22L3 17V7L12 2Z">` + inner fill path) | Vue: `⬢` Unicode emoji | **important** — SVG renders crisp at all sizes; emoji rendering varies by OS/font |
| L-V2 | Footer brand: same inline SVG hexagon | Vue: `⬢` emoji | **important** |
| L-V3 | Model-card logos: unique inline SVG per provider (Claude chevron, GPT circle, Codex bracket, Gemini star, "..." dots) | Vue: colored 10px `<div class="model-dot">` only | **important** |
| L-V4 | Dashboard line-chart: SVG with gradient fill area, cyan/purple/amber stroke lines, x-axis time labels (`00:00``now`) | Vue: simplified `<polyline>` without fill area or x-axis labels | **important** |
| L-V5 | Hero eyebrow: `.badge` span with cyan tinted background for "NEW" | Vue: no badge element | **important** |
| L-V6 | Pricing tier-flag chips (STARTER / ◆ 推荐 / ⚡ 限时) with color variants (`muted` / default / `amber`) | Vue: absent (no pricing section) | n/a (section missing) |
---
### Missing CSS (zip has, Vue scoped missing)
| # | Missing rule / component | Impact |
|---|---|---|
| L-C1 | `.model-logo` (icon container with border + bg), `.status-chip` (green dot + glow pulse), `.model-card.disabled` opacity | Model wall looks flat vs. designed appearance |
| L-C2 | `.hero-eyebrow .badge` (cyan tinted pill) | Missing "NEW" badge styling |
| L-C3 | `.hero-micro .dot` (4px separator dots) | Separator dots between hero micro items |
| L-C4 | `.code-tabs`, `.tab`, `.tab.active`, `.tab-dot`, `.traffic` | Full code-frame tab-bar styling missing |
| L-C5 | `.dash-head .url` (address-bar mockup with lock icon pseudo) | Dashboard browser-chrome bar |
| L-C6 | `.dash-side`, `.side-group`, `.side-label`, `.side-item`, `.side-item.active` | Sidebar nav entirely unstyled |
| L-C7 | `.chart-grid` 2:1 layout, `.chart-title .legend`, `.sw` (legend swatch) | Chart section layout and legend |
| L-C8 | `.cta-banner` and all sub-rules | Mid-page CTA banner |
| L-C9 | `.pricing-grid-landing`, `.tier-l`, `.tier-l-flag`, `.tier-l-price`, `.tier-l-credit`, `.tier-l-discount`, `.tier-l-feats`, `.pricing-more-link` | Entire pricing card system |
| L-C10 | `.faq-l` + `summary`, `::after`, `[open]` variants, `.faq-answer` | FAQ accordion |
| L-C11 | `.section-kicker` with `// ` monospace prefix convention (zip uses `letter-spacing: 0.15em`) | Vue uses 0.12em and different style |
---
### Missing Scripts / Interactivity
| # | What zip has | Vue status | Severity |
|---|---|---|
| L-I1 | Code-frame tab switching: clicking `.tab` elements switches displayed code block | Vue code demo has no tab interaction (static 2 panels) | **interactive** |
---
---
## 2. LoginView
**Zip:** `docs/design-drafts/v2/Login.html`
**Vue:** `frontend/src/views/auth/LoginView.vue`
---
### Copy / Text Deltas
| # | Zip (HTML) | Vue (SFC) | Severity | Notes |
|---|---|---|---|---|
| LN-T1 | Narrative headline: `<span class="amber">N</span> 个订阅 → <span class="cyan">1</span> 个 key` (N is amber-colored variable) | Vue: `<span class="num-5">5</span> 个订阅 → <span class="num-1">1</span> 个 key` | **important** | Zip uses abstract "N" (variable quantity); Vue hardcodes "5". This was the specific discrepancy the user noticed |
| LN-T2 | Narrative kicker: `// 你的订阅,已经付过钱了` | Vue: absent (no kicker element in narrative) | **important** | |
| LN-T3 | n-sub text: `省去切换账号的繁琐,` / `省去为多个高昂订阅重复买单。` / `PURO(纯粹)—— 让 AI 调用回归本质。` | Vue: `省去切换账号的繁琐,` / `省去为多个高昂订阅重复买单。` / `PURO纯粹—— 让 AI 调用回归本质。` (via `auth-narrative-tagline`) | **cosmetic** | Full-width vs half-width punctuation (`` vs `,`, `` vs `()`); same meaning otherwise |
| LN-T4 | Form title: `登录` (h1) + sub: `用你的 PURO AI 账户继续` | Vue: `{{ t('auth.puroLoginTitle') }}` + `{{ t('auth.puroLoginSub') }}` — actual rendered text depends on i18n key values | **important** | Need to verify i18n values match; design source has specific Chinese copy |
| LN-T5 | "Remember me" checkbox: `记住我` | Vue: absent (no remember-me UI) | **cosmetic** | |
| LN-T6 | "Forgot password" link: `忘记密码?` (always visible, inline with remember-me) | Vue: only shown `v-if="passwordResetEnabled && !backendModeEnabled"` | **cosmetic** | Conditioned; zip always shows it |
| LN-T7 | Submit button text: `登录 →` | Vue: i18n key `t('auth.signIn')` / `t('auth.signingIn')` | **cosmetic** | Likely matches; verify arrow `→` is included |
| LN-T8 | LinuxDO button: `使用 LinuxDO 登录` with custom orange gradient `.linuxdo-ico` "L" badge | Vue: delegated to `<LinuxDoOAuthSection>` component | **cosmetic** | Functionally equivalent; visual parity depends on component implementation |
| LN-T9 | Footer link: `没有账户?<a>注册</a>` | Vue: i18n `t('auth.dontHaveAccount')` + `t('auth.signUp')` | **cosmetic** | Verify i18n values |
| LN-T10 | Legal line: `登录即表示你同意 <a>服务条款</a> 与 <a>隐私政策</a>` | Vue: **absent** | **important** | No terms/privacy consent text in Vue login form |
---
### Structure Deltas
| # | What zip has | Vue status | Severity |
|---|---|---|
| LN-S1 | `route-demo` block: live routing demo panel showing `POST /v1/chat/completions`, `model: claude-sonnet-4-5`, `route → claude-pool-03`, `status 200 · 213ms · 42 tok` | Vue: **entirely absent** | **important** |
| LN-S2 | `n-bottom` bar: provider list (`Claude · ChatGPT · Codex · Gemini`) + `live · ai.puro.im · operational` green-dot status | Vue: replaced by simpler `auth-narrative-foot` = `Claude · ChatGPT · Codex · Gemini` only; no live status | **important** |
| LN-S3 | `back-home` link (`← 返回首页`) absolute-positioned top-right of form panel | Vue: absent | **cosmetic** |
| LN-S4 | Password field: eye toggle with two SVG states (open / closed-with-slash), both embedded inline | Vue: `<Icon name="eye">` / `<Icon name="eyeOff">` (component-based) | **cosmetic** — functionally equivalent |
| LN-S5 | `remember-me` checkbox with custom styled box + check mark | Vue: absent | **cosmetic** |
| LN-S6 | Login form `autocomplete="off" novalidate` attribute | Vue: `autocomplete` per-field, no `novalidate` (uses Vue validation) | **cosmetic** |
---
### Visuals / SVG
| # | Delta | Severity |
|---|---|---|
| LN-V1 | Brand in narrative: inline SVG hexagon (same double-path as Landing) | Vue: `⬢` emoji | **important** |
| LN-V2 | `route-demo` styling: pill-inline badges with cyan/amber/green border-tinted backgrounds, green dot-pulse status | Vue: absent (whole block missing) | n/a (structure missing) |
| LN-V3 | `n-bottom .live .dot` — 5px green dot with `box-shadow` glow | Vue: absent | **important** |
| LN-V4 | Narrative background: `narrative::before` pseudo with dual radial-gradient overlay (cyan + purple) | Vue: handled by `AuthLayout` — parity depends on that component | unknown — needs AuthLayout check |
---
### Missing CSS (zip has, Vue scoped missing)
| # | Missing rule | Impact |
|---|---|---|
| LN-C1 | `.route-demo` and all its `.row`, `.k`, `.v`, `.pill-inline` variants | Route demo panel |
| LN-C2 | `.n-bottom` and `.n-bottom .live`, `.n-bottom .sep` | Bottom status bar |
| LN-C3 | `.n-kicker` | Kicker line above headline |
| LN-C4 | `.check` custom checkbox (`.box`, `:checked ~ .box::after`) | Remember-me checkbox |
| LN-C5 | `.legal` legal notice line (monospace, dashed link underlines) | Terms/privacy link |
| LN-C6 | `.back-home` | Return-to-home link |
| LN-C7 | `@media (max-width: 900px)` hides `.route-demo` and `.n-bottom` | Responsive rule |
---
### Missing Scripts / Interactivity
| # | Zip has | Vue status | Severity |
|---|---|---|
| LN-I1 | Password eye-toggle: vanilla JS swaps `pw.type` + toggles two SVG icons by `display` | Vue: reactive `showPassword` ref + Icon component. Functionally equivalent | **none** — Vue solution is better |
| LN-I2 | Login submit: loading spinner class, success state (`✓ 登录成功` label text change + green background), redirect to Dashboard.html | Vue: `isLoading` spinner, redirect via `router.push('/dashboard')`; no "✓ 登录成功" button text mutation | **cosmetic** — success toast used instead |
| LN-I3 | Basic non-empty check only (`if (!form.email.value || !form.password.value)`) | Vue: full regex email validation + min-length password validation + Turnstile + 2FA flow | **none** — Vue is more complete |
---
---
## 3. RegisterView
**Zip:** `docs/design-drafts/v2/Register.html`
**Vue:** `frontend/src/views/auth/RegisterView.vue`
---
### Copy / Text Deltas
| # | Zip (HTML) | Vue (SFC) | Severity | Notes |
|---|---|---|---|---|
| R-T1 | Narrative kicker: `// 5 分钟开始用` | Vue: absent (no kicker) | **important** | |
| R-T2 | Narrative headline: `<span class="amber">N</span> 个订阅 → <span class="cyan">1</span> 个 key` | Vue: `<span class="num-5">5</span> 个订阅 → <span class="num-1">1</span> 个 key` | **important** | Same as Login: "N" vs hardcoded "5" |
| R-T3 | Form title: `创建账户` (h1) + sub: `注册即送 <b style="color:var(--cyan)">$5</b> 测试积分` | Vue: `{{ t('auth.puroRegisterTitle') }}` + `{{ t('auth.puroRegisterSub') }}` | **important** | The "$5 bonus credit" hook in the sub-title is a key conversion element; Vue moves it to i18n |
| R-T4 | Password placeholder: `至少 8 位,含字母与数字` | Vue: `{{ t('auth.createPasswordPlaceholder') }}` | **cosmetic** | Verify i18n value matches |
| R-T5 | `bonus-note` block: green-bordered callout `+$5 完成注册即送 $5 测试积分 —— 够你跑几万次 Claude 请求。` | Vue: **absent** | **important** | High-conversion element entirely missing |
| R-T6 | Steps panel title: `// 下一步` with 3 numbered onboarding steps (创建账户 → 绑定订阅 → 生成 key) | Vue: **absent** | **important** | The onboarding journey explainer is missing |
| R-T7 | `n-bottom` provider list + `live · ai.puro.im · operational` | Vue: `auth-narrative-foot` = `Claude · ChatGPT · Codex · Gemini` only | **important** | Live status indicator missing |
| R-T8 | `back-home` link: `← 返回首页` | Vue: absent | **cosmetic** | |
| R-T9 | Footer: `已有账户?<a>登录</a>` | Vue: i18n `t('auth.alreadyHaveAccount')` + `t('auth.signIn')` | **cosmetic** | Verify i18n values |
---
### Structure Deltas
| # | What zip has | Vue status | Severity |
|---|---|---|
| R-S1 | Onboarding `.steps` panel (3-step card in the narrative: 1. Create account, 2. Bind subscription, 3. Generate key) | Vue: **entirely absent** | **important** |
| R-S2 | Email field: inline validation green checkmark (`valid-ico` SVG) appears when email format is valid | Vue: shows `input-error-text` below field on error; no inline success icon on valid state during typing | **cosmetic** |
| R-S3 | Password field: 4-bar strength meter (`pw-strength` div) + text label (`// strength · 弱/中/强/极强`) | Vue: no strength meter; only a static `input-hint` paragraph | **important** |
| R-S4 | Confirm-password field with: `valid-ico` checkmark + `match-hint` (`// matched` / `// passwords do not match`) | Vue: no confirm-password field at all | **important** |
| R-S5 | Terms checkbox (`我已阅读并同意 服务条款 与 隐私政策`) — submit button disabled until checked | Vue: no terms checkbox | **important** |
| R-S6 | `bonus-note` callout block | Vue: absent | **important** |
| R-S7 | Password confirm field activates submit only when: email valid + score ≥ 2 + passwords match + terms checked | Vue: password min-length 6, no confirm field, no terms gate | **important** |
| R-S8 | Vue-only: invitation code field (conditional on `invitationCodeEnabled`) | Zip: absent | n/a (Vue enhancement) |
| R-S9 | Vue-only: promo code field (conditional on `promoCodeEnabled`) | Zip: absent | n/a (Vue enhancement) |
| R-S10 | Vue-only: Turnstile CAPTCHA widget | Zip: absent | n/a (Vue enhancement) |
---
### Visuals / SVG
| # | Delta | Severity |
|---|---|---|
| R-V1 | Brand in narrative: inline SVG hexagon | Vue: `⬢` emoji | **important** |
| R-V2 | Steps panel: numbered circles (`step-num`) with cyan border and active fill | Vue: absent | **important** |
| R-V3 | `bonus-note` callout: green border `rgba(52,211,153,0.2)` + `+$5` chip in green background | Vue: absent | **important** |
| R-V4 | Password strength bars: 4 `<span class="bar">` elements that fill red/amber/cyan/green based on `data-score` | Vue: absent | **important** |
| R-V5 | Match-hint monospace text (`// matched` in green / `// passwords do not match` in red) | Vue: absent | **important** |
| R-V6 | `valid-ico` green SVG checkmark inside input-wrap when field is valid | Vue: absent | **cosmetic** |
---
### Missing CSS (zip has, Vue scoped missing)
| # | Missing rule | Impact |
|---|---|---|
| R-C1 | `.steps`, `.steps-title`, `.step`, `.step-num`, `.step.active .step-num`, `.step-text .k` | Onboarding steps panel |
| R-C2 | `.pw-strength[data-score]` + `.bar` + 4 score-tinted variants | Password strength meter |
| R-C3 | `.pw-hint[data-score]` + `.val` color variants | Strength label |
| R-C4 | `.match-hint`, `.match-hint.mismatch`, `.match-hint.ok` | Confirm-password match feedback |
| R-C5 | `.valid-ico` + `.input-wrap input.ok ~ .valid-ico` | Inline valid checkmark |
| R-C6 | `.bonus-note` + `.bonus-note .emoji` | Registration bonus callout |
| R-C7 | `.n-bottom` + live-dot styles (same as Login) | Bottom status bar |
---
### Missing Scripts / Interactivity
| # | Zip has | Vue status | Severity |
|---|---|---|
| R-I1 | Password strength scorer (`scorePw()`) — counts: ≥8 chars, mixed case, digit, special/long; maps score 0-4 to `['—','弱','中','强','极强']` | Vue: no strength meter, only min-length check | **interactive** |
| R-I2 | Real-time confirm-password match checker with `// matched` / `// passwords do not match` feedback | Vue: no confirm-password field | **interactive** |
| R-I3 | Email inline validation (adds `.ok` class → shows green checkmark) | Vue: error shown on submit, not live | **interactive** |
| R-I4 | Submit button gated on: email `.ok` + `score >= 2` + passwords match + terms checkbox checked | Vue: submit gated only on loading + optional Turnstile | **interactive** |
| R-I5 | Success animation: button text changes to `✓ 注册成功,正在跳转...` + green background, then redirects | Vue: toast notification + router.push — no button animation | **cosmetic** |
---
---
## 4. DocsView
**Zip:** `docs/design-drafts/v2/Docs.html`
**Vue:** `frontend/src/views/docs/DocsView.vue`
---
### Copy / Text Deltas
| # | Zip (HTML) | Vue (SFC) | Severity | Notes |
|---|---|---|---|---|
| D-T1 | Page title / h1: `快速开始` + lede: `PURO AI 提供一个统一的 OpenAI 兼容端点 —— 你已有的 SDK 代码只需要改 base_url 和 api_key 两行…整个过程通常不超过 5 分钟。` | Vue h1: `快速接入 PURO AI` + subtitle: `三步走:拿 key → 配 base_url → 发请求` | **important** | Zip's lede is a full paragraph explaining the product value and 5-minute setup claim; Vue's subtitle is a terse 3-step summary. Different framing. |
| D-T2 | Step-card labels: `STEP 01 / 绑定订阅`, `STEP 02 / 创建 API Key`, `STEP 03 / 切换 base_url` with description paragraphs | Vue: no step-card grid at all | **important** | The `quick-grid` 3-card visual summary is entirely absent from Vue |
| D-T3 | Section "① 绑定你的订阅": full paragraph about Dashboard → OAuth flow + KMS callout `凭证通过 AES-256 加密存储在隔离的 KMS 中…` | Vue: Section "1. 获取 API key": `当前 PURO AI 不开放自助注册付费。联系管理员获取admin@puro.im` + note `未来通过 iShare 入口开放订阅购买。` | **important** | Completely different content: zip describes the self-serve OAuth flow; Vue describes an invite-only alpha state. Vue reflects current operational reality; zip is the target product state. |
| D-T4 | Section "② 创建 API Key": instructions for `Dashboard → API Keys → 创建 Key`, advice to create per-client keys for safe revocation | Vue: no dedicated "create API key" section | **important** | Zip's key-management guidance (per-client keys, revoke without affecting others) is absent from Vue |
| D-T5 | Section "③ 发送第一个请求": prose introducing OpenAI + Anthropic format support, code-panel with Python/Node/cURL/Anthropic SDK tabs | Vue: "2. Codex CLI 接入", "3. Claude Code 接入", "4. curl 直连测试" — three separate sections each with a plain `<pre>` block | **important** | Zip organises code samples under one tabbed panel; Vue splits them into three separate sections with different tool focus (Codex CLI is a Vue-only section) |
| D-T6 | Codex CLI section: **absent from zip** | Vue: Section "2. Codex CLI 接入" with `~/.codex/config.toml` and `~/.codex/auth.json` config samples | n/a (Vue enhancement) | Vue-only section targeting Codex CLI users; not in zip |
| D-T7 | Models table: `claude-sonnet-4-5`, `claude-opus-4`, `claude-haiku-4-5`, `gpt-5`, `gpt-5-codex`, `gemini-2.5-pro`, `gemini-2.5-flash` with PROVIDER / 池 / 上下文 / 状态 columns | Vue model-list: `gpt-5.4`, `gpt-5.4-codex`, `claude-opus-4-7`, `claude-sonnet-4-6`, `gemini-2.5-pro`, `gemini-2.5-flash` (plain `<ul>`) | **important** | Model names differ (e.g. `claude-sonnet-4-5` vs `claude-sonnet-4-6`; `gpt-5` vs `gpt-5.4`); zip has `claude-haiku-4-5` which Vue omits; zip has rich table structure vs Vue's plain list |
| D-T8 | Section "支持的 base_url": three URLs (`/v1`, `/anthropic`, `/google`) with explanations + amber callout about unified key across all formats | Vue: no dedicated base_url section; curl examples hard-code `/responses` and `/v1/messages` endpoints | **important** | The `/anthropic` and `/google` base_url variants and the "one key for all formats" callout are absent from Vue |
| D-T9 | Section "下一步": integration links for Claude Code, Cursor, Cline/Roo Code, Continue | Vue: no "next steps" section | **important** | Onward-journey links that guide users to integration docs are absent |
| D-T10 | Breadcrumb trail: `Docs / Getting Started / 快速开始` (monospace, with cyan `.current` span) | Vue: absent | **cosmetic** | |
| D-T11 | Nav "active" link: `文档` has `.active` class in zip nav | Vue nav: no `.active` state on current-page link | **cosmetic** | |
| D-T12 | Nav links: 产品 / 定价 / 文档 / 设计系统 | Vue nav links: 首页 / `#codex` / `#claude-code` / `#curl` (in-page anchors) | **important** | Zip nav is a proper site-level navigation; Vue nav links to in-page anchors only and removes Pricing + Design System entries |
| D-T13 | Nav CTA: `Dashboard` (ghost) + `开始使用` (primary) | Vue nav CTA: `登录 →` (primary only, no ghost button) | **important** | Zip has two CTA buttons (authenticated + unauthenticated states); Vue has one |
| D-T14 | Left sidebar brand-line: `// docs · v1` (monospace kicker) | Vue: no sidebar at all | **important** | The `// docs · v1` kicker text is a brand-pattern element; entire sidebar is missing |
| D-T15 | Right TOC label: `On this page` (monospace uppercase) with 6 anchor links | Vue: no right-column TOC | **important** | |
| D-T16 | Page-footer nav links: `← 上一页 / 产品概念` and `下一页 → / 绑定你的订阅` | Vue: no page-footer nav | **important** | Prev/next doc navigation absent |
| D-T17 | Section "6. 问题反馈" + callout with admin@puro.im | Vue has equivalent "6. 问题反馈" section (same email) | — | This matches; no delta |
| D-T18 | Callout (cyan) security note with SVG lock icon: `凭证通过 AES-256 加密存储在隔离的 KMS 中…` | Vue callout: plain `<a>` email link inside div, no icon | **important** | Zip callout has inline SVG icon + richer copy; Vue callout is a minimal email link only |
| D-T19 | Callout (amber, warning) key portability note: `一个 sk-puro-* 可以同时用于三种 base_url…` | Vue: no amber/warning callout variant | **important** | Amber callout variant used in zip for advisory messages is entirely absent from Vue |
---
### Structure Deltas
| # | What zip has | Vue status | Severity |
|---|---|---|---|
| D-S1 | 3-column grid layout: `docs-layout` (240px left nav + 1fr main + 220px right TOC) | Vue: single-column `.container` with centred `.docs-body`; no sidebar, no TOC column | **important** |
| D-S2 | Sticky left sidebar (`docs-nav`) with `brand-line`, search `<input>`, 4 nav-section groups (Getting Started / API Reference / Integrations / Advanced) | Vue: absent | **important** |
| D-S3 | Sticky right TOC (`docs-toc`) with `On this page` label + 6 anchor links (some `.sub` indented) | Vue: absent | **important** |
| D-S4 | Breadcrumb bar (`docs-crumbs`) above h1 | Vue: absent | **cosmetic** |
| D-S5 | Quick-start step-card grid (`quick-grid`) — 3 cards with monospace STEP 01/02/03 labels | Vue: absent | **important** |
| D-S6 | Code panel (`code-panel`) with `panel-tabs` bar (Python / Node.js / cURL / Anthropic SDK tabs) + `copy-code` button | Vue: plain `<pre class="mono">` blocks with no chrome, no tab switching, no copy button | **important** |
| D-S7 | Models table (`models-table`) — `<table>` with thead/tbody, provider badge spans (`.provider.claude/gpt/gemini` + `.dot`), status badges (`.badge.green/.amber`) | Vue: `<ul class="model-list">` plain list | **important** |
| D-S8 | Two distinct callout variants: default cyan and `.amber` warning, each with inline SVG icon (`<svg>` lock / triangle) | Vue: single callout style with no icon and no amber variant | **important** |
| D-S9 | Page-footer prev/next nav (`page-foot` with two `foot-link` cards) | Vue: absent | **important** |
| D-S10 | Nav `docs-top` with `backdrop-filter: blur(12px)` sticky bar + brand SVG hex | Vue: plain `.nav` (no `docs-top` class, no sticky backdrop, `⬢` emoji brand) | **important** |
| D-S11 | Left nav search input (`nav-search` with SVG magnifier pseudo-element) | Vue: absent | **cosmetic** |
| D-S12 | `h2` headings with `::before` 3px cyan vertical bar accent | Vue `h2`: `border-bottom: 1px solid var(--border)` only — no left-bar accent | **important** |
| D-S13 | `<section id="models">` — Vue-only "5. 支持的模型" section maps to zip's models table | Equivalent content present but rendered as list vs table (see D-T7) | — |
| D-S14 | Vue-only: `docs-hero` centered section above `docs-body` | Zip: no separate hero section; content starts directly with breadcrumbs + h1 inside `docs-body` | n/a (Vue adds hero) |
---
### Visuals / SVG
| # | Delta | Severity |
|---|---|---|
| D-V1 | Nav brand: inline SVG hexagon (same double-path as Landing/Login/Register: outer stroke + inner filled path) | Vue: `⬢` Unicode emoji | **important** |
| D-V2 | Left nav SVG search icon (inline `url("data:image/svg+xml...")` magnifier in `::before` pseudo) | Vue: no sidebar/search | n/a (structure missing) |
| D-V3 | `h2::before` pseudo-element: 3px × 22px cyan vertical accent bar | Vue `h2`: plain bottom border only | **important** |
| D-V4 | Code syntax token spans: `.com` (grey italic comments), `.kw` (pink keywords), `.str` (green strings), `.fn` (amber functions), `.prop` (blue properties), `.num` (orange numbers) | Vue pre blocks: `.str` (cyan), `.kw` (amber), `.cm` (grey) — only 3 token types, different colour mapping | **cosmetic** |
| D-V5 | Models table: `.provider` span with 6px `.dot` circle (colour per provider) + "Claude / ChatGPT / Gemini" text | Vue model list: no provider badge, provider shown as inline text only | **important** |
| D-V6 | Models table: `.badge.green` ("OK") and `.badge.amber` ("BETA") status chips | Vue: no status badges | **important** |
| D-V7 | Callout icons: inline SVG lock (14×14) for cyan callout; inline SVG triangle-warning for amber callout | Vue: no icon in callouts | **important** |
| D-V8 | Quick-grid cards: `::before` / hover `translateY(-2px)` lift animation | Vue: no step cards | n/a (structure missing) |
| D-V9 | `docs-nav-item.active`: cyan text + `rgba(34,211,238,0.06)` tinted background | Vue: no sidebar nav | n/a (structure missing) |
| D-V10 | `docs-toc a.active`: cyan text + left `2px solid var(--cyan)` border + `rgba(34,211,238,0.03)` bg | Vue: no TOC | n/a (structure missing) |
---
### Missing CSS (zip has, Vue scoped missing)
| # | Missing rule / component | Impact |
|---|---|---|
| D-C1 | `.docs-layout` 3-column grid, `.docs-nav`, `.docs-nav .brand-line`, `.docs-nav-section`, `.docs-nav-label`, `.docs-nav-item`, `.docs-nav-item.active` | Entire left sidebar nav system |
| D-C2 | `.docs-nav .nav-search` + `input` + `::before` SVG pseudo | Sidebar search bar |
| D-C3 | `.docs-toc`, `.docs-toc-label`, `.docs-toc a`, `.docs-toc a.active`, `.docs-toc a.sub` | Right-column TOC |
| D-C4 | `.docs-crumbs`, `.docs-crumbs .sep`, `.docs-crumbs .current` | Breadcrumb bar |
| D-C5 | `.docs-top` (sticky backdrop-blur nav variant) | Docs-specific nav bar |
| D-C6 | `.docs-body h2::before` (3px cyan left-bar accent pseudo-element) | h2 section accent bar; Vue uses bottom-border instead |
| D-C7 | `.code-panel`, `.code-panel .panel-tabs`, `.tabs-inner button`, `.tabs-inner button.active`, `.copy-code`, `.copy-code:hover` | Tabbed code panel chrome |
| D-C8 | `.code-panel pre .com`, `.kw`, `.str`, `.fn`, `.prop`, `.num` (6 token colour rules) | Full syntax highlighting palette (Vue has only 3 token types) |
| D-C9 | `.quick-grid`, `.quick-card`, `.quick-card:hover`, `.quick-card .num`, `.quick-card h4`, `.quick-card p` | Step-card quick-start grid |
| D-C10 | `.models-table`, `.models-table th`, `.models-table td`, `.models-table tr:hover`, `.models-table .mono` | Styled models table |
| D-C11 | `.callout .icon` (flex + cyan colour), `.callout.amber`, `.callout.amber .icon` | Callout icon + amber variant |
| D-C12 | `.docs-body h2` flex + `gap: 12px` + `align-items: center` | h2 layout for left-bar + text alignment |
| D-C13 | `.page-foot`, `.foot-link`, `.foot-link:hover`, `.foot-link .dir`, `.foot-link .title`, `.foot-link.next` | Prev/next page footer nav |
| D-C14 | `.tabs`, `.tab`, `.tab:hover`, `.tab.active` (standalone tab-bar component) | Tab bar used in docs layout |
---
### Missing Scripts / Interactivity
| # | What zip has | Vue status | Severity |
|---|---|---|
| D-I1 | Code-panel tab switching: clicking a `button` inside `.tabs-inner` shows/hides the corresponding `<pre>` block and toggles `.active` on the tab | Vue: all code blocks are always visible in separate `<section>` elements; no tab switching | **interactive** |
| D-I2 | Copy-to-clipboard: clicking `.copy-code` copies the current tab's `<pre>` text content, momentarily changes label to `✓ 복사됨` (or similar confirmation) | Vue: no copy button on any code block | **interactive** |
| D-I3 | TOC active-link tracking: scrolling the page updates which `.docs-toc a` has `.active` class (IntersectionObserver or scroll listener implied) | Vue: no TOC | n/a (structure missing) |
| D-I4 | Sidebar nav active-item tracking: clicked nav item gets `.active` class with cyan highlight | Vue: no sidebar | n/a (structure missing) |
---
### Recommended Fix List
| Priority | ID | Fix | Notes |
|---|---|---|---|
| 1 | D-S1 / D-S2 / D-S3 | Implement 3-column `docs-layout` with sticky left sidebar and right TOC | Structural foundation; all other doc-nav deltas depend on this. Estimate: 34h |
| 2 | D-S6 / D-I1 / D-I2 | Replace plain `<pre>` blocks with `.code-panel` component: tab bar (Python/Node/cURL/Anthropic SDK), copy-to-clipboard button, traffic-light dots | Highest developer-credibility element on the page. Port from zip JS or use Vue `ref`+`v-show`. Estimate: 2h |
| 3 | D-S7 / D-V5 / D-V6 | Replace `<ul class="model-list">` with `<table class="models-table">` including provider badge spans and OK/BETA status chips | Table format communicates provider, pool, context and status at a glance. Estimate: 1h |
| 4 | D-V1 / D-V3 | Replace `⬢` emoji brand with inline SVG hexagon (same as Landing fix); add `h2::before` cyan accent bar via CSS | Both are cosmetic but high-frequency — visible on every section heading. Estimate: 0.5h |
| 5 | D-T3 / D-T8 | Rewrite Section 1 to reflect OAuth subscription-binding flow (once feature exists); add `支持的 base_url` section with three endpoint variants and amber "one key for all formats" callout | Content alignment with zip product vision. May be deferred until backend supports self-serve OAuth. Estimate: 1h when ready |
| 6 | D-T9 / D-S9 | Add "下一步" integration-links section + `page-foot` prev/next navigation | Keeps users moving through the docs funnel. Estimate: 0.5h |
| 7 | D-S5 / D-C9 | Add `quick-grid` three-step card summary below the lede | Visual orientation aid above the long-form content. Estimate: 0.5h |
| 8 | D-V7 / D-C11 | Add SVG icon support to callout component (lock icon for info, triangle for amber/warning), add `.callout.amber` variant | Required to port the base_url portability warning. Estimate: 0.5h |
---
## Summary
### Total Deltas Found
| Category | Important | Cosmetic | Interactive |
|---|---|---|---|
| LandingView | 15 | 7 | 1 |
| LoginView | 5 | 9 | 0 (Vue is better) |
| RegisterView | 9 | 6 | 4 |
| DocsView | 16 | 3 | 2 |
| **Total** | **45** | **25** | **7** |
**Grand total: 77 deltas** (45 important + 25 cosmetic + 7 interactive)
---
### Top 10 Most Impactful User-Visible Deltas
1. **L-S9 / L-S10 / L-S11 — Landing: CTA banner + Pricing section + FAQ entirely missing**
`LandingView.vue` — These three sections are the primary conversion funnel below the fold. The design shows a compelling mid-page CTA (`把订阅变成 API — 5 分钟`), a full 3-tier pricing grid, and 6 FAQ items. All are absent. Without them, the landing page ends abruptly after the dashboard mockup.
2. **LN-T1 / R-T2 — Login + Register narrative: "N" hardcoded as "5"**
`LoginView.vue` lines 8-9, `RegisterView.vue` lines 8-9 — Zip uses abstract variable `N` (amber) to convey "however many subscriptions you have". Vue hardcodes `5`, which is factually wrong for users with 14 or more than 5 subscriptions.
3. **L-S6 / L-S7 — Dashboard mockup: no sidebar, no donut chart**
`LandingView.vue` — The zip's dashboard mockup has a sidebar nav (7 menu items) and a 2-panel chart area (line chart + model-distribution donut). Vue shows only a line chart and no sidebar. This is visible in the hero social-proof section used to sell the product.
4. **R-S3 / R-S4 / R-S5 — Register: no password strength meter, no confirm-password, no terms checkbox**
`RegisterView.vue` — Three mutually reinforcing UX elements from the zip are absent. The strength meter educates users; the confirm field prevents typos; the terms gate is a legal/trust signal. Combined, their absence degrades register-page quality significantly.
5. **R-T5 / R-S6 — Register: `+$5 bonus-note` callout missing**
`RegisterView.vue` — The green-bordered "完成注册即送 $5 测试积分 —— 够你跑几万次 Claude 请求" callout is a direct conversion driver. It's visible below the submit button in the zip; entirely absent in Vue.
6. **R-S1 — Register: onboarding steps panel missing**
`RegisterView.vue` — The narrative left-panel in zip shows a 3-step numbered checklist (create account → bind subscription → generate key). This explains what happens *after* registration and reduces drop-off. Vue shows a generic headline only.
7. **L-V1 / LN-V1 / R-V1 — SVG hexagon logo replaced by ⬢ emoji everywhere**
`LandingView.vue`, `LoginView.vue`, `RegisterView.vue` — The double-path SVG hex (`stroke` outline + inner `fill`) renders as a crisp cyan icon. The `⬢` Unicode character renders inconsistently across macOS/Windows/mobile and lacks the inner fill detail. Affects brand recognition.
8. **L-S3 / L-V3 — Model wall: SVG logos replaced by color dots**
`LandingView.vue` — Each model card in the zip has a distinct inline SVG icon (Claude chevron, GPT circle-crosshair, Codex bracket, Gemini star, dots). Vue renders only 10px color dots. On a section designed to communicate provider breadth, icon identity matters.
9. **LN-S1 — Login: route-demo panel entirely absent**
`LoginView.vue` — The live-routing demonstration block (`POST /v1/chat/completions → claude-pool-03 → 200 OK · 213ms`) is the most concrete product proof-point on the login page. It communicates technical credibility to developer users seeing the login page for the first time. Entirely missing from Vue.
10. **L-T7 / L-T8 / L-T10 — Landing features: different headline, no sub, no bullets**
`LandingView.vue` — Section headline changed from `付一次订阅,用起一整个模型池` to `一套 key三件武器`. The subtitle and all 9 feature bullet items (API compatibility claims, failover details, export options) are absent. Together these make the features section significantly less informative.
---
### Estimated Work
| Scope | Est. hours |
|---|---|
| Fix "N vs 5" in Login + Register narrative (L-T1 / R-T2) | 0.25h |
| Restore SVG hexagon logo across all 3 views | 0.5h |
| Restore Landing: CTA banner + Pricing section + FAQ | 46h |
| Restore Landing: model-card SVG logos + status chips | 1.5h |
| Restore Landing: code-frame tabs + traffic lights | 1h |
| Restore Landing: dashboard sidebar + donut chart | 2h |
| Restore Login: route-demo panel + n-bottom status bar | 1.5h |
| Restore Login: remember-me checkbox + legal notice | 0.5h |
| Restore Register: strength meter + confirm field + terms checkbox | 2h |
| Restore Register: steps panel + bonus-note callout | 1h |
| CSS parity (Landing/Login/Register) | 23h |
| Docs: 3-column layout + sticky sidebar + right TOC | 34h |
| Docs: tabbed code-panel + copy-to-clipboard | 2h |
| Docs: models table with provider/status badges | 1h |
| Docs: SVG hex + h2 accent bar + callout icon/amber variant | 1h |
| Docs: base_url section + page-footer nav + quick-grid | 1h |
| **Total (all important + interactive deltas)** | **~2530h** |
| **Top 5 only (Landing CTA/pricing/FAQ + Docs layout + code-panel)** | **~1214h** |

View File

@@ -2,6 +2,10 @@
<div class="auth-shell" :class="{ 'auth-shell-split': hasNarrative }">
<div class="bg-glow soft"></div>
<div v-if="hasNarrative" class="auth-locale-corner">
<PuroLocaleSwitcher />
</div>
<!-- LEFT: Narrative (split mode only, hidden on mobile) -->
<aside v-if="hasNarrative" class="auth-narrative">
<slot name="narrative"></slot>
@@ -42,6 +46,7 @@
import { computed, onMounted, useSlots } from 'vue'
import { useAppStore } from '@/stores'
import { sanitizeUrl } from '@/utils/url'
import PuroLocaleSwitcher from '@/components/puro/PuroLocaleSwitcher.vue'
const appStore = useAppStore()
@@ -73,6 +78,16 @@ onMounted(() => {
.auth-shell-split {
display: grid;
grid-template-columns: 1fr 1fr;
background: var(--bg-0);
color: var(--text-0);
font-family: var(--font-sans);
min-height: 100vh;
}
.auth-shell-split .auth-locale-corner {
position: absolute;
top: 24px;
right: 24px;
z-index: 20;
}
@media (max-width: 900px) {
.auth-shell-split {
@@ -106,6 +121,63 @@ onMounted(() => {
max-width: 420px;
}
/* bg-glow primitives for split mode (not in .puro-page ancestor, so puro.css rules don't apply) */
.auth-shell-split .bg-glow {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
overflow: hidden;
}
.auth-shell-split .bg-glow::before,
.auth-shell-split .bg-glow::after {
content: "";
position: absolute;
width: 900px;
height: 900px;
border-radius: 50%;
filter: blur(120px);
opacity: 0.15;
}
.auth-shell-split .bg-glow::before {
background: radial-gradient(circle, #22d3ee 0%, transparent 60%);
top: -300px;
left: -200px;
}
.auth-shell-split .bg-glow::after {
background: radial-gradient(circle, #a855f7 0%, transparent 60%);
top: 200px;
right: -300px;
}
/* Right-side form area (split mode) inherits --text-0 for headings */
.auth-shell-split .auth-main {
position: relative;
z-index: 2;
}
/* In split mode, force Tailwind's light-mode text grays to light colors for dark shell readability.
* Uses :deep() to reach slotted LoginView/RegisterView content. */
.auth-shell-split :deep(.input-label),
.auth-shell-split :deep(label) {
color: var(--text-1) !important;
}
.auth-shell-split :deep(.input) {
background: var(--bg-1) !important;
border-color: var(--border) !important;
color: var(--text-0) !important;
}
.auth-shell-split :deep(.input::placeholder) {
color: var(--text-3) !important;
}
.auth-shell-split :deep(.btn-primary) {
/* keep existing — should still look good */
}
.auth-shell-split :deep(.text-gray-500),
.auth-shell-split :deep(.text-gray-400) {
color: var(--text-2) !important;
}
/* Legacy-mode (no narrative slot) background — keep existing gradient decorative look */
.auth-shell:not(.auth-shell-split) {
display: flex;

View File

@@ -0,0 +1,129 @@
<template>
<div class="puro-page">
<div class="bg-glow"></div>
<div class="grain"></div>
<nav class="nav">
<div class="container nav-inner">
<router-link to="/" class="brand">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 2L21 7V17L12 22L3 17V7L12 2Z" fill="rgba(34, 211, 238, 0.08)"/>
</svg>
<span>PURO AI</span>
</router-link>
<div class="nav-links">
<router-link to="/" active-class="active" exact-active-class="active">{{ $t('portal.nav.products') }}</router-link>
<router-link to="/pricing" active-class="active" exact-active-class="active">{{ $t('portal.nav.pricing') }}</router-link>
<router-link to="/docs" active-class="active" exact-active-class="active">{{ $t('portal.nav.docs') }}</router-link>
</div>
<div class="nav-cta">
<PuroLocaleSwitcher />
<router-link to="/login" class="btn btn-ghost">{{ $t('portal.nav.login') }}</router-link>
<router-link to="/register" class="btn btn-primary">{{ $t('portal.nav.signup') }}</router-link>
</div>
</div>
</nav>
<router-view />
<footer class="puro-footer">
<div class="container footer-grid">
<div class="footer-brand">
<div class="brand">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 2L21 7V17L12 22L3 17V7L12 2Z" fill="rgba(34, 211, 238, 0.08)"/>
</svg>
<span>PURO AI</span>
</div>
<p class="footer-tagline">{{ $t('landing.footer.tagline1') }}<br>{{ $t('landing.footer.tagline2') }}</p>
<p class="footer-meta">© 2026 puro.im · MIT License<br>fork of Wei-Shaw/sub2api</p>
<div class="footer-status"><span class="dot-green"></span>all systems operational</div>
</div>
<div class="footer-col">
<div class="footer-col-title">{{ $t('landing.footer.colProducts') }}</div>
<router-link to="/docs">{{ $t('landing.footer.linkDocs') }}</router-link>
<router-link to="/pricing">{{ $t('portal.nav.pricing') }}</router-link>
<a href="https://git.puro.im/purovps/sub2api/commits/branch/main" target="_blank" rel="noopener noreferrer">{{ $t('landing.footer.linkChangelog') }}</a>
</div>
<div class="footer-col">
<div class="footer-col-title">{{ $t('landing.footer.colAccount') }}</div>
<router-link to="/login">{{ $t('landing.footer.linkLogin') }}</router-link>
<router-link to="/register">{{ $t('landing.footer.linkRegister') }}</router-link>
<router-link to="/dashboard">Dashboard</router-link>
</div>
<div class="footer-col">
<div class="footer-col-title">{{ $t('landing.footer.colContact') }}</div>
<a href="mailto:admin@puro.im">admin@puro.im</a>
<a href="https://git.puro.im" target="_blank" rel="noopener noreferrer">git.puro.im</a>
<a href="https://git.puro.im/purovps/sub2api" target="_blank" rel="noopener noreferrer">GitHub </a>
</div>
</div>
</footer>
</div>
</template>
<script setup lang="ts">
import PuroLocaleSwitcher from '@/components/puro/PuroLocaleSwitcher.vue'
</script>
<style scoped>
.puro-page {
min-height: 100vh;
background: var(--bg-0);
color: var(--text-0);
font-family: var(--font-sans);
position: relative;
overflow-x: hidden;
}
.puro-footer {
border-top: 1px solid var(--border);
padding: 48px 0 32px;
background: rgba(2, 6, 23, 0.4);
position: relative;
z-index: 2;
}
.footer-grid {
display: grid;
grid-template-columns: 1.3fr 1fr 1fr 1fr;
gap: 48px;
}
@media (max-width: 720px) { .footer-grid { grid-template-columns: 1fr 1fr; } }
.footer-brand .brand { margin-bottom: 12px; }
.footer-tagline { color: var(--text-2); font-size: 13px; line-height: 1.6; margin-bottom: 8px; max-width: 280px; }
.footer-meta { color: var(--text-3); font-size: 12px; line-height: 1.7; margin-bottom: 12px; }
.footer-status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-2);
background: rgba(15, 23, 42, 0.6);
border: 1px solid var(--border);
border-radius: 4px;
}
.dot-green {
display: inline-block;
width: 6px; height: 6px;
border-radius: 50%;
background: var(--green, #34d399);
box-shadow: 0 0 6px rgba(52,211,153,0.6);
}
.footer-col-title {
color: var(--text-0);
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 12px;
}
.footer-col a {
display: block;
color: var(--text-2);
font-size: 13px;
padding: 4px 0;
}
.footer-col a:hover { color: var(--cyan); }
</style>

View File

@@ -0,0 +1,117 @@
<template>
<div class="calc">
<div class="calc-left">
<div class="section-kicker">{{ $t('pricing.calc.kicker') }}</div>
<h3>{{ $t('pricing.calc.title') }}</h3>
<p class="sub">{{ $t('pricing.calc.sub') }}</p>
<div class="calc-controls">
<div class="slider-row">
<div class="s-top">
<span>{{ $t('pricing.calc.reqLabel') }}</span>
<span class="val">{{ reqValFormatted }}</span>
</div>
<input type="range" min="500" max="50000" step="500" v-model.number="req">
</div>
<div class="slider-row">
<div class="s-top">
<span>{{ $t('pricing.calc.tokLabel') }}</span>
<span class="val">{{ tokValFormatted }}</span>
</div>
<input type="range" min="500" max="10000" step="500" v-model.number="tok">
</div>
<div class="slider-row">
<div class="s-top">
<span>{{ $t('pricing.calc.mixLabel') }}</span>
<span class="val">{{ mix }}%</span>
</div>
<input type="range" min="0" max="100" step="10" v-model.number="mix">
</div>
</div>
</div>
<div class="calc-right">
<div class="breakdown">
<div class="line"><span class="lab">{{ $t('pricing.calc.monthlyTok') }}</span><span class="v">{{ monthlyTokFmt }}</span></div>
<div class="line"><span class="lab">{{ $t('pricing.calc.officialCost') }}</span><span class="v">${{ officialCostFmt }}</span></div>
<div class="line"><span class="lab">{{ $t('pricing.calc.puroCost') }}</span><span class="v">${{ puroCostFmt }}</span></div>
<div class="line savings"><span class="lab">{{ $t('pricing.calc.savings') }}</span><span class="v">${{ saveFmt }} · {{ savePct }}%</span></div>
</div>
<div class="total-line">
<div>
<div class="lab">{{ $t('pricing.calc.recLabel') }}</div>
<div class="rec-note">{{ recNote }}</div>
</div>
<div class="big"><span class="curr">$</span>{{ recAmt }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const req = ref(5000)
const tok = ref(3000)
const mix = ref(50)
const reqValFormatted = computed(() => req.value.toLocaleString())
const tokValFormatted = computed(() => tok.value.toLocaleString())
const monthlyTok = computed(() => req.value * tok.value * 30)
const official = computed(() => {
const avg = (mix.value / 100) * 6 + (1 - mix.value / 100) * 3
return (monthlyTok.value / 1e6) * avg
})
const puro = computed(() => official.value * 0.3)
const save = computed(() => official.value - puro.value)
const savePct = computed(() => Math.round((save.value / official.value) * 100))
function fmtNum(n: number): string {
if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B'
if (n >= 1e6) return (n / 1e6).toFixed(0) + 'M'
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'
return String(n)
}
function fmtMoney(n: number): string {
return Math.round(n).toLocaleString('en-US')
}
const monthlyTokFmt = computed(() => fmtNum(monthlyTok.value))
const officialCostFmt = computed(() => fmtMoney(official.value))
const puroCostFmt = computed(() => fmtMoney(puro.value))
const saveFmt = computed(() => fmtMoney(save.value))
const recAmt = computed(() => Math.ceil(puro.value))
const recNote = computed(() => {
if (puro.value < 30) return t('pricing.calc.recStarter')
if (puro.value < 80) return t('pricing.calc.recPro')
return t('pricing.calc.recScale')
})
</script>
<style scoped>
.calc { border: 1px solid var(--border); border-radius: var(--r-xl); background: radial-gradient(600px 300px at 0% 0%, rgba(34,211,238,0.06), transparent 60%), radial-gradient(600px 300px at 100% 100%, rgba(168,85,247,0.06), transparent 60%), rgba(15, 23, 42, 0.4); padding: 32px 36px; display: grid; grid-template-columns: 1fr 1fr; gap: 40px; align-items: center; }
.calc-left h3 { font-size: 22px; font-weight: 700; letter-spacing: -0.01em; margin-bottom: 8px; }
.calc-left .sub { color: var(--text-2); font-size: 14px; line-height: 1.55; margin-bottom: 22px; }
.calc-controls { display: flex; flex-direction: column; gap: 14px; }
.slider-row .s-top { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 6px; align-items: baseline; }
.slider-row .s-top .val { font-family: var(--font-mono); font-weight: 700; color: var(--cyan); }
.slider-row input[type=range] { -webkit-appearance: none; width: 100%; height: 4px; background: var(--border); border-radius: 2px; outline: none; }
.slider-row input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: var(--cyan); cursor: pointer; box-shadow: 0 0 0 4px rgba(34,211,238,0.15); }
.calc-right { background: rgba(2, 6, 23, 0.6); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 28px; }
.calc-right .breakdown { display: flex; flex-direction: column; gap: 10px; margin-bottom: 18px; }
.calc-right .line { display: flex; justify-content: space-between; font-size: 13px; }
.calc-right .line .lab { color: var(--text-2); }
.calc-right .line .v { font-family: var(--font-mono); color: var(--text-0); }
.calc-right .line.savings .v { color: var(--green); }
.calc-right .total-line { padding-top: 14px; border-top: 1px dashed var(--border); display: flex; justify-content: space-between; align-items: baseline; }
.calc-right .total-line .lab { font-size: 12px; color: var(--text-3); font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.14em; }
.calc-right .rec-note { font-size: 12px; color: var(--text-3); margin-top: 4px; }
.calc-right .total-line .big { font-family: var(--font-mono); font-size: 28px; font-weight: 800; color: var(--cyan); letter-spacing: -0.02em; }
.calc-right .total-line .big .curr { font-size: 14px; color: var(--text-3); font-weight: 500; margin-right: 2px; }
@media (max-width: 960px) {
.calc { grid-template-columns: 1fr; }
}
</style>

View File

@@ -0,0 +1,150 @@
<template>
<div class="puro-locale" ref="dropdownRef">
<button
class="puro-locale-btn"
type="button"
:disabled="switching"
:aria-expanded="isOpen"
:title="currentLocale?.name"
@click="toggleDropdown"
>
<span class="puro-locale-code">{{ currentLocale?.code.toUpperCase() }}</span>
<svg class="puro-locale-chev" :class="{ open: isOpen }" viewBox="0 0 12 12" width="10" height="10" aria-hidden="true">
<path d="M2 4l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<transition name="puro-locale-pop">
<div v-if="isOpen" class="puro-locale-menu" role="listbox">
<button
v-for="option in availableLocales"
:key="option.code"
type="button"
role="option"
:aria-selected="option.code === currentLocaleCode"
class="puro-locale-option"
:class="{ active: option.code === currentLocaleCode }"
:disabled="switching"
@click="selectLocale(option.code)"
>
<span class="puro-locale-option-code">{{ option.code.toUpperCase() }}</span>
<span class="puro-locale-option-name">{{ option.name }}</span>
<span v-if="option.code === currentLocaleCode" class="puro-locale-option-dot" aria-hidden="true"></span>
</button>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { setLocale, availableLocales } from '@/i18n'
const { locale } = useI18n()
const isOpen = ref(false)
const dropdownRef = ref<HTMLElement | null>(null)
const switching = ref(false)
const currentLocaleCode = computed(() => locale.value)
const currentLocale = computed(() => availableLocales.find((l) => l.code === locale.value))
function toggleDropdown() {
isOpen.value = !isOpen.value
}
async function selectLocale(code: string) {
if (switching.value || code === currentLocaleCode.value) {
isOpen.value = false
return
}
switching.value = true
try {
await setLocale(code)
isOpen.value = false
} finally {
switching.value = false
}
}
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
isOpen.value = false
}
}
onMounted(() => document.addEventListener('click', handleClickOutside))
onBeforeUnmount(() => document.removeEventListener('click', handleClickOutside))
</script>
<style scoped>
.puro-locale { position: relative; display: inline-flex; }
.puro-locale-btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 10px;
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 12px; font-weight: 500;
color: var(--text-1, #cbd5e1);
background: rgba(2, 6, 23, 0.4);
border: 1px solid var(--border, rgba(148,163,184,0.18));
border-radius: 8px;
cursor: pointer;
transition: border-color .15s, color .15s, background .15s;
}
.puro-locale-btn:hover:not(:disabled) {
border-color: var(--border-2, rgba(148,163,184,0.32));
color: var(--cyan, #22d3ee);
}
.puro-locale-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.puro-locale-code { letter-spacing: 0.08em; }
.puro-locale-chev { color: var(--text-3, #64748b); transition: transform .15s; }
.puro-locale-chev.open { transform: rotate(180deg); color: var(--cyan, #22d3ee); }
.puro-locale-menu {
position: absolute; top: calc(100% + 6px); right: 0;
min-width: 140px;
background: rgba(15, 23, 42, 0.95);
border: 1px solid var(--border, rgba(148,163,184,0.18));
border-radius: 10px;
padding: 4px;
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
backdrop-filter: blur(8px);
z-index: 50;
}
.puro-locale-option {
width: 100%;
display: flex; align-items: center; gap: 10px;
padding: 8px 10px;
background: transparent; border: none; border-radius: 6px;
font-size: 13px; color: var(--text-1, #cbd5e1);
cursor: pointer;
text-align: left;
transition: background .12s, color .12s;
}
.puro-locale-option:hover:not(:disabled) {
background: rgba(34, 211, 238, 0.08);
color: var(--text-0, #f8fafc);
}
.puro-locale-option.active {
color: var(--cyan, #22d3ee);
}
.puro-locale-option-code {
font-family: var(--font-mono);
font-size: 11px; letter-spacing: 0.08em;
color: var(--text-3, #64748b);
}
.puro-locale-option.active .puro-locale-option-code { color: var(--cyan, #22d3ee); }
.puro-locale-option-name { flex: 1; }
.puro-locale-option-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--cyan, #22d3ee);
box-shadow: 0 0 0 3px rgba(34,211,238,0.2);
}
.puro-locale-pop-enter-active,
.puro-locale-pop-leave-active { transition: opacity .12s, transform .12s; }
.puro-locale-pop-enter-from,
.puro-locale-pop-leave-to { opacity: 0; transform: translateY(-4px) scale(0.97); }
</style>

View File

@@ -499,7 +499,39 @@ export default {
invalidResetLink: 'Invalid Reset Link',
invalidResetLinkHint: 'This password reset link is invalid or has expired. Please request a new one.',
requestNewResetLink: 'Request New Reset Link',
invalidOrExpiredToken: 'The password reset link is invalid or has expired. Please request a new one.'
invalidOrExpiredToken: 'The password reset link is invalid or has expired. Please request a new one.',
narrative: {
common: {
statusLive: 'live',
},
login: {
kicker: '// you already paid for your subscriptions',
headlineN: 'N',
headlineOne: '1',
headlineSep: 'subscriptions →',
headlineSuffix: 'key',
sub1: 'No more juggling accounts,',
sub2: 'no more paying twice for overlapping subscriptions.',
tagline: 'PURO — AI calls, back to basics.',
},
register: {
kicker: '// up and running in 5 minutes',
headlineN: 'N',
headlineOne: '1',
headlineSep: 'subscriptions →',
headlineSuffix: 'key',
sub1: 'No more juggling accounts,',
sub2: 'no more paying twice for overlapping subscriptions.',
tagline: 'PURO — AI calls, back to basics.',
stepsTitle: '// next steps',
step1Title: 'Create account',
step1Desc: 'Email + password, or LinuxDO OAuth',
step2Title: 'Connect subscription',
step2Desc: 'OAuth into your existing Claude Pro / ChatGPT Plus',
step3Title: 'Get your API key',
step3Desc: 'Grab your sk-puro-… and swap the SDK\'s base_url',
},
},
},
// Dashboard
@@ -5598,4 +5630,336 @@ export default {
},
},
portal: {
nav: {
products: 'Products',
pricing: 'Pricing',
docs: 'Docs',
login: 'Sign in',
signup: 'Free trial →',
},
},
landing: {
hero: {
badgeNew: 'NEW',
eyebrow: 'Unified access to multiple AI platforms · Zero code change',
title1: 'Your AI subscriptions,',
title2: 'are already paid for.',
sub1: 'Claude Pro · ChatGPT Plus · Codex · Gemini subscriptions',
sub2: 'Aggregated into one unified API — drop-in {openai} / {anthropic} SDK',
ctaLogin: 'Sign in →',
ctaContact: 'Contact us',
micro: 'Verified with Codex CLI · Claude Code · curl · Server egress: Singapore',
},
models: {
kicker: '// providers',
title: 'Reuse your subscriptions directly via OAuth',
sub: 'No official API key needed. No account switching.',
more: 'More',
morePlanned: 'Planned',
},
features: {
kicker: '// capabilities',
title1: 'One subscription,',
title2: 'one unified model pool',
sub: 'Consolidate subscriptions scattered across platforms into infrastructure developers can actually use',
f1Title: 'One key for all models',
f1Desc: 'No more requesting API keys or configuring base_url per provider. One {sk} routes to Claude / GPT / Gemini, auto-dispatched by model to the right account pool.',
f1b1: 'OpenAI Responses API compatible',
f1b2: 'Anthropic Messages API compatible',
f1b3: 'Smart model → provider routing',
f2Title: 'Highly available account pool',
f2Desc: 'Multi-account auto-scheduling with failover. When an upstream hits rate limits or cooldown, traffic switches to the next healthy account — token refresh is fully automatic.',
f2b1: 'Rate-limit / 5xx auto failover',
f2b2: 'OAuth token auto-refresh',
f2b3: 'Weighted round-robin · least connections',
f3Title: 'Usage dashboard',
f3Desc: 'Tokens, cost, upstream account, and latency visualized per request. Model distribution pie + trend curve + top rankings.',
f3b1: 'Per-request audit log',
f3b2: 'Multi-dimension tokens / cost stats',
f3b3: 'Export CSV / Webhook integration',
},
codeDemo: {
kicker: '// integration',
title: 'Change base_url. That\'s it.',
sub: 'Compatible with OpenAI / Anthropic / Gemini SDK — {highlight}',
subHighlight: 'zero code changes',
foot: 'Supports OpenAI Responses API · Anthropic Messages API · Gemini generateContent · Streaming SSE &amp; WebSocket',
},
dashboard: {
kicker: '// observability',
title: 'Every request, fully visible',
sub: 'Unlike opaque third-party API pools — see exactly which account was charged, which model ran, how many tokens were used, and upstream response time at a glance.',
statToday: "Today's requests",
statTokensIn: 'Input tokens',
statTokensOut: 'Output tokens',
statCost: "Today's cost",
chartTrend: 'Usage trend — last 30 days',
tableTime: 'Time',
tableModel: 'Model',
tableUpstream: 'Upstream',
tableStatus: 'Status',
tableUsage: 'Usage',
},
footer: {
tagline1: 'Aggregate multiple AI subscriptions into one unified API.',
tagline2: 'Put your already-paid subscriptions to work.',
colProducts: 'Products',
colAccount: 'Account',
colContact: 'Contact',
linkDocs: 'Docs',
linkFeatures: 'Features',
linkChangelog: 'Changelog',
linkLogin: 'Sign in',
linkRegister: 'Register',
},
},
pricing: {
hero: {
kicker: '// pricing · top up · pay as you go · never expires',
previewPill: '// preview · final pricing TBD at launch',
title1: 'Top up once,',
titleAccent: 'works across',
title2: 'all platforms',
sub: 'One credit balance works across Claude / ChatGPT / Gemini pools. We turn your subscription quota into real API credits — {discount} cheaper than official APIs.',
subDiscount: 'up to 70%',
underline: 'Credits never expire · Alipay / WeChat / USDT supported · No hidden fees',
},
soonChip: 'SOON',
tiers: {
starter: {
flag: 'STARTER',
tierLabel: 'tier · 01',
headline: 'Dip your toes in, get connected',
credit: 'Top up ${creditAmount} → get {creditBonus} credits',
creditAmount: '9.9',
creditBonus: '$12',
discountTag: 'vs. official API · from 5× cheaper',
cta: 'Top up →',
features: {
allModels: 'All models / all pools',
oneKey: 'API key',
rpm60: '60 RPM rate limit',
log7: 'Basic logs (7-day retention)',
noBYOS: 'Bring your own subscription',
noTeam: 'Team / multi-user collaboration',
},
},
pro: {
flag: '◆ RECOMMENDED',
tierLabel: 'tier · 02',
headline: 'Power users · Best value',
credit: 'Top up ${creditAmount} → get {creditBonus} credits',
creditAmount: '29.9',
creditBonus: '$45',
discountTag: 'vs. official API · 37× cheaper',
cta: 'Buy Pro →',
features: {
allModels: 'All models / all pools',
threeKeys: 'API keys · separate budgets',
rpm120: '120 RPM rate limit',
log30: 'Call logs (30-day retention)',
byos: 'Bring your own subscription (unlimited)',
failover: 'Multi-account failover scheduling',
},
},
scale: {
flag: '⚡ LIMITED +100%',
tierLabel: 'tier · 03',
headline: 'Small teams / long-haul projects',
credit: 'Top up ${creditAmount} → get {creditBonus} credits',
creditAmount: '99',
creditBonus: '$198',
discountTag: 'vs. official API · 25× cheaper',
cta: 'Top up →',
features: {
proAll: 'All Pro capabilities',
tenKeys: 'API keys · separate budgets',
rpm300: '300 RPM rate limit',
log90: 'Call logs (90-day retention)',
priorityCount: '',
priority: 'Priority-weighted scheduling',
community: 'Slack / Discord community support',
},
},
custom: {
flag: 'CUSTOM',
tierLabel: 'tier · 04',
headline: 'Custom amount · top up on demand',
creditPrefix: 'Get approx.',
bonusPrefix: '+',
discountTag: 'Discount tier matched automatically by amount',
cta: 'Custom amount →',
features: {
neverExpire: 'Credits never expire',
proAll: 'All Pro capabilities',
tiered: 'Tiered +21% ~ +100%',
payment: 'Alipay / WeChat / USDT',
preview: 'Drag slider to preview bonus',
},
},
},
custom: {
enterprise: {
title: 'Enterprise · Custom plans',
desc: 'Dedicated subscription pool, SLA, compliance audit, private deployment, invoice billing. Starting at >$500/mo.',
cta: 'Contact us →',
},
binding: {
title: 'Already subscribed? Connect it.',
desc: 'Have Claude Max / ChatGPT Pro? Register free, bind your subscription, and only pay for PURO routing — {price} per request.',
price: '$0.0008/request',
cta: 'Connect my subscription →',
},
},
calc: {
kicker: '// cost estimator',
previewPill: '// estimated · for reference only',
title: 'How much could you save?',
sub: 'Estimate your monthly spend on PURO vs. official APIs based on your usage. Numbers update as you move the sliders.',
reqLabel: 'Daily requests',
tokLabel: 'Avg tokens per request',
mixLabel: 'Claude share',
monthlyTok: 'Monthly token usage',
officialCost: 'Official API cost',
puroCost: 'PURO cost (incl. +50% bonus)',
savings: 'Savings',
recLabel: 'Suggested top-up',
recStarter: '≈ Starter tier covers it',
recPro: '≈ Pro tier · 1 month',
recScale: '≈ Scale tier · 1 month',
},
works: {
kicker: '// works everywhere',
title: 'One key, every tool',
sub: 'Any tool that supports a custom {baseUrl} or the OpenAI / Anthropic API works with PURO out of the box.',
baseUrl: 'base_url',
tools: {
claudeCode: 'Claude Code',
cursor: 'Cursor',
cline: 'Cline',
rooCode: 'Roo Code',
continueTag: 'Continue',
openaiSdk: 'OpenAI SDK',
anthropicSdk: 'Anthropic SDK',
openWebui: 'Open WebUI',
langchain: 'LangChain',
llamaIndex: 'LlamaIndex',
zed: 'Zed',
more: 'More…',
},
tags: {
claudeCode: 'ANTHROPIC_BASE_URL',
cursor: 'Custom model',
cline: 'OpenAI compat.',
rooCode: 'OpenAI compat.',
continueTag: 'config.yaml',
openaiSdk: 'Python / Node',
anthropicSdk: 'Native Claude',
openWebui: 'Custom base',
langchain: 'LLM node',
llamaIndex: 'Model router',
zed: 'Assistant',
more: '60+ tools',
},
},
faq: {
kicker: '// frequently asked',
title: 'You might be wondering',
noAnswer: "Can't find an answer? {contact} · We usually reply within 2 hours.",
contact: 'Email us ↗',
q1: 'How is PURO different from an API relay or proxy?',
a1: 'A relay just forwards official API requests — the price depends on how much balance you prepay. PURO is different: we let you turn your existing Claude Pro / ChatGPT Plus subscription into an API. The $20/month you\'re already paying no longer has to live in the official chat UI — it feeds Cursor, Claude Code, and any SDK through a unified API. We also offer a pay-per-use official API fallback pool, and you can mix both modes freely.',
q2: 'Will running API calls through my subscription get me banned?',
a2: 'We automatically pace requests per subscription and failover to other pool members if rate limits trigger. In practice, PURO\'s call pattern is less likely to flag risk controls than copy-pasting large conversations in the official client. When you bind multiple subscriptions, each account\'s RPM stays well within safe thresholds. All credentials are AES-256 encrypted, and requests never transit third-party infrastructure.',
q3: 'Do credits expire? Can I get a refund?',
a3Part1: 'Credits never expire.',
a3Part2: "You can save them up and use them months later. Full refund within 7 days of first top-up if no calls were made; after that, 85% of remaining credits are refunded. See our",
a3Link: 'refund policy',
a3Part3: '.',
q4: 'What payment methods are supported?',
a4: 'Domestic (CN): Alipay · WeChat Pay. International: Stripe credit card · USDT (TRC20 / ERC20) · PayPal. Enterprise top-ups support invoice and bank transfer with CNY receipts.',
q5: 'How many subscriptions can one PURO account bind?',
a5StarterLabel: 'Starter tier:',
a5Starter: 'Binding your own subscriptions is not supported',
a5ProLabel: 'Pro tier and above:',
a5Pro: 'Unlimited — you can bind 10 ChatGPT Plus + 3 Claude Pro accounts and schedule them all together',
a5EnterpriseLabel: 'Enterprise:',
a5Enterprise: 'Supports cross-team shared pools with org-level isolation',
q6: 'What happens if a subscription hits its rate limit?',
a6: "PURO's scheduler marks the throttled subscription as cooling and temporarily removes it from the pool. The same request is immediately failed over to another healthy subscription — callers typically experience no interruption. You can see each subscription's current status and remaining quota in the Dashboard.",
q7: 'How precise is billing? What if I go over my limit?',
a7: 'Billed per actual token count × model rate, accurate to 4 decimal places. Each API key can have an independent monthly budget cap — once hit, requests return 402 Payment Required and no further charges accumulate. The same 402 applies when your account balance is exhausted. Dashboard sends 80% / 95% reminder emails.',
q8: 'Will my data be used for training?',
a8Part1: 'No.',
a8Part2: 'All requests are used solely for routing — no content is stored or persisted (only metadata like model, token count, and latency is retained for billing and logs). Pro tier and above can optionally enable "zero-log mode" (planned), where we record nothing, not even request IDs.',
q9: 'Can I self-host PURO?',
a9: 'Enterprise tier supports Docker / K8s private deployment with separate control plane and data plane. Licensed as an annual subscription with upgrades and technical support included.',
a9Link: 'Contact us →',
q10: 'What models are supported? Will new models be added?',
a10: 'Currently covers Claude (Sonnet 4.5 / Opus 4 / Haiku 4.5), ChatGPT (GPT-5 / GPT-5 Codex / GPT-4.1), Gemini (2.5 Pro / 2.5 Flash). When official providers release new models, we typically go live within',
a10Link: 'docs',
a10Part2: '. Full model list available in the docs.',
},
finalCta: {
kicker: '// ready to start',
title: 'Get your first sk-puro-* key in 5 minutes',
subtitle: 'Connect your first subscription and you\'re ready.',
ctaPrimary: 'Sign up free →',
ctaDocs: 'View docs',
},
},
docs: {
hero: {
title: 'Quickstart — PURO AI',
subtitle: 'Three steps: get a key → set base_url → send a request',
},
sections: {
getKey: {
heading: '1. Get your API key',
desc: 'PURO AI is currently invite-only. Contact the admin to get access:',
},
codex: {
heading: '2. Codex CLI setup',
configIntro: 'Edit ~/.codex/config.toml:',
authIntro: 'Then ~/.codex/auth.json:',
verifyIntro: 'Verify:',
copy: 'Copy',
copied: 'Copied',
},
claudeCode: {
heading: '3. Claude Code setup',
configIntro: 'Edit ~/.claude/settings.json:',
note: 'Claude Code calls the Anthropic-compatible API via the /v1/messages endpoint.',
copy: 'Copy',
copied: 'Copied',
},
curl: {
heading: '4. curl quick test',
openaiIntro: 'OpenAI Responses API:',
anthropicIntro: 'Anthropic Messages API:',
copy: 'Copy',
copied: 'Copied',
},
models: {
heading: '5. Available models',
colModel: 'Model',
colPlatform: 'Platform / source',
colContext: 'Context',
colStatus: 'Status',
codexDedicated: 'OpenAI Codex dedicated',
note: 'Pricing tracks {repo} live. Full list available in the {dashboard} after signing in.',
noteRepo: 'model-price-repo',
noteDashboard: 'dashboard',
},
feedback: {
heading: '6. Feedback',
desc: 'Run into an issue or want a new platform added:',
},
},
},
}

View File

@@ -504,6 +504,39 @@ export default {
puroLoginSub: '用你的 PURO AI 账户继续',
puroRegisterTitle: '创建账户',
puroRegisterSub: '5 分钟开始用 PURO AI',
confirmPasswordLabel: '确认密码',
narrative: {
common: {
statusLive: 'live',
},
login: {
kicker: '// 你的订阅,已经付过钱了',
headlineN: 'N',
headlineOne: '1',
headlineSep: '个订阅 →',
headlineSuffix: '个 key',
sub1: '省去切换账号的繁琐,',
sub2: '省去为多个高昂订阅重复买单。',
tagline: 'PURO纯粹—— 让 AI 调用回归本质。',
},
register: {
kicker: '// 5 分钟开始用',
headlineN: 'N',
headlineOne: '1',
headlineSep: '个订阅 →',
headlineSuffix: '个 key',
sub1: '省去切换账号的繁琐,',
sub2: '省去为多个高昂订阅重复买单。',
tagline: 'PURO纯粹—— 让 AI 调用回归本质。',
stepsTitle: '// 下一步',
step1Title: '创建账户',
step1Desc: '邮箱 + 密码,或用 LinuxDO OAuth',
step2Title: '绑定订阅',
step2Desc: 'OAuth 接入你现有的 Claude Pro / ChatGPT Plus',
step3Title: '生成 key',
step3Desc: '拿到 sk-puro-…,换掉 SDK 的 base_url',
},
},
},
// Dashboard
@@ -5790,4 +5823,336 @@ export default {
},
},
portal: {
nav: {
products: '产品',
pricing: '定价',
docs: '文档',
login: '登录',
signup: '免费试用 →',
},
},
landing: {
hero: {
badgeNew: 'NEW',
eyebrow: '统一接入多个 AI 平台 · 零改动切换',
title1: '你的 AI 订阅,',
title2: '已经付过钱了。',
sub1: 'Claude Pro · ChatGPT Plus · Codex · Gemini 订阅',
sub2: '聚合成统一 API零改动接入 {openai} / {anthropic} SDK',
ctaLogin: '登录 →',
ctaContact: '联系咨询',
micro: '已验证可用 Codex CLI · Claude Code · curl · 服务器出口新加坡',
},
models: {
kicker: '// providers',
title: '通过 OAuth 直接复用你的订阅',
sub: '无需申请官方 API key也无需切换账号',
more: '更多',
morePlanned: '规划中',
},
features: {
kicker: '// capabilities',
title1: '付一次订阅,',
title2: '用起一整个模型池',
sub: '把散落在各个平台的订阅,整合成开发者真正能用的基础设施',
f1Title: '一个 key 接所有模型',
f1Desc: '不再为每个 provider 申请 API key、配置 base_url。统一 {sk} 走 Claude / GPT / Gemini按 model 自动路由到对应账号池。',
f1b1: 'OpenAI Responses API 兼容',
f1b2: 'Anthropic Messages API 兼容',
f1b3: '智能 model → provider 路由',
f2Title: '账号池高可用',
f2Desc: '支持多账号自动调度与 failover。某个上游触发限流 / 冷却时流量切到下一个健康账号token 刷新全自动。',
f2b1: '限流/5xx 自动 failover',
f2b2: 'OAuth token 自动刷新',
f2b3: '加权轮询 · 最少连接',
f3Title: '用量看板',
f3Desc: '每条请求的 tokens、费用、上游账号、延迟全可视化。模型分布饼图 + 趋势曲线 + Top 排行。',
f3b1: '逐请求审计日志',
f3b2: '多维度 tokens / cost 统计',
f3b3: '导出 CSV / 接 Webhook',
},
codeDemo: {
kicker: '// integration',
title: '把 base_url 一改,就能用',
sub: '兼容 OpenAI / Anthropic / Gemini SDK{highlight}',
subHighlight: '零代码改动',
foot: '支持 OpenAI Responses API · Anthropic Messages API · Gemini generateContent · 流式 SSE &amp; WebSocket',
},
dashboard: {
kicker: '// observability',
title: '每条请求都看得见',
sub: '不像第三方 API 池子那种"扣了多少不告诉你"——扣哪个账号、跑哪个模型、用了多少 tokens、上游响应几秒一目了然。',
statToday: '今日请求',
statTokensIn: '输入 Tokens',
statTokensOut: '输出 Tokens',
statCost: '今日费用',
chartTrend: '近 30 天用量趋势',
tableTime: '时间',
tableModel: '模型',
tableUpstream: '上游',
tableStatus: '状态',
tableUsage: '用量',
},
footer: {
tagline1: '把多个 AI 订阅聚合成统一 API。',
tagline2: '让「已经付过钱」的订阅真正为你工作。',
colProducts: '产品',
colAccount: '账户',
colContact: '联系',
linkDocs: '文档',
linkFeatures: '功能',
linkChangelog: '更新日志',
linkLogin: '登录',
linkRegister: '注册',
},
},
pricing: {
hero: {
kicker: '// pricing · 充多少 · 用多少 · 永不过期',
previewPill: '// preview · 最终定价以开售为准',
title1: '一次充值,',
titleAccent: '全平台',
title2: '通用',
sub: '同一份积分可以用在 Claude / ChatGPT / Gemini 任意池上。我们把你的订阅额度变成真正的 API 余额 —— 相比官方 API 便宜 {discount}。',
subDiscount: '至多 70%',
underline: '余额永不过期 · 支持支付宝 / 微信 / USDT · 无隐藏订阅费',
},
soonChip: 'SOON',
tiers: {
starter: {
flag: 'STARTER',
tierLabel: 'tier · 01',
headline: '先尝尝鲜,跑通接入',
credit: '充 ${creditAmount} → 得 {creditBonus} 积分',
creditAmount: '9.9',
creditBonus: '$12',
discountTag: '相当于官方 API · 0.5 折起',
cta: '充值 →',
features: {
allModels: '可用所有模型 / 所有池',
oneKey: '个 API Key',
rpm60: '60 RPM 速率限制',
log7: '基础日志(7 天保留)',
noBYOS: '自带订阅接入',
noTeam: '团队 / 多人协作',
},
},
pro: {
flag: '◆ 推荐',
tierLabel: 'tier · 02',
headline: '个人重度用户 · 最划算',
credit: '充 ${creditAmount} → 得 {creditBonus} 积分',
creditAmount: '29.9',
creditBonus: '$45',
discountTag: '相当于官方 API · 3-7 折',
cta: '立即充值 →',
features: {
allModels: '可用所有模型 / 所有池',
threeKeys: '个 API Key · 独立预算',
rpm120: '120 RPM 速率限制',
log30: '调用日志(30 天保留)',
byos: '自带订阅接入(无限个)',
failover: '多账号 failover 调度',
},
},
scale: {
flag: '⚡ 限时 +100%',
tierLabel: 'tier · 03',
headline: '小团队 / 长跑项目',
credit: '充 ${creditAmount} → 得 {creditBonus} 积分',
creditAmount: '99',
creditBonus: '$198',
discountTag: '相当于官方 API · 2-5 折',
cta: '充值 →',
features: {
proAll: '所有 Pro 能力',
tenKeys: '个 API Key · 独立预算',
rpm300: '300 RPM 速率限制',
log90: '调用日志(90 天保留)',
priorityCount: '',
priority: '请求优先级加权调度',
community: 'Slack / Discord 群组支持',
},
},
custom: {
flag: 'CUSTOM',
tierLabel: 'tier · 04',
headline: '自定义金额 · 按需充值',
creditPrefix: '得约',
bonusPrefix: '+',
discountTag: '根据金额阶梯自动匹配折扣',
cta: '定制充值 →',
features: {
neverExpire: '积分永不过期',
proAll: 'Pro 全部能力',
tiered: '阶梯 +21% ~ +100%',
payment: '支付宝 / 微信 / USDT',
preview: '拖动滑块预览赠送',
},
},
},
custom: {
enterprise: {
title: 'Enterprise · 企业定制',
desc: '专属订阅池、SLA、合规审计、私有化部署、发票结算。规模 >$500/月起可申请。',
cta: '联系商务 →',
},
binding: {
title: '已有订阅?直接接入',
desc: '有 Claude Max / ChatGPT Pro?免费注册后绑定,只为 PURO 路由费买单 —— 按次 {price}。',
price: '$0.0008/request',
cta: '接入我的订阅 →',
},
},
calc: {
kicker: '// cost estimator',
previewPill: '// estimated · 以实际计费为准',
title: '算算你能省多少?',
sub: '按你的使用场景,对比 PURO 和官方 API 的月度花费差。数字会根据你选的场景自动更新。',
reqLabel: '日均请求数',
tokLabel: '平均每请求 tokens',
mixLabel: 'Claude 占比',
monthlyTok: '月度 tokens 消耗',
officialCost: '官方 API 价格',
puroCost: 'PURO 价格(含 +50% 赠送)',
savings: '节省',
recLabel: '建议充值',
recStarter: '≈ Starter 档够用',
recPro: '≈ Pro 档 1 个月',
recScale: '≈ Scale 档 · 1 个月',
},
works: {
kicker: '// works everywhere',
title: '一个 key,所有工具通用',
sub: '只要支持自定义 {baseUrl} 或 OpenAI / Anthropic API,都能直接接入 PURO。',
baseUrl: 'base_url',
tools: {
claudeCode: 'Claude Code',
cursor: 'Cursor',
cline: 'Cline',
rooCode: 'Roo Code',
continueTag: 'Continue',
openaiSdk: 'OpenAI SDK',
anthropicSdk: 'Anthropic SDK',
openWebui: 'Open WebUI',
langchain: 'LangChain',
llamaIndex: 'LlamaIndex',
zed: 'Zed',
more: '更多…',
},
tags: {
claudeCode: 'ANTHROPIC_BASE_URL',
cursor: '自定义模型',
cline: 'OpenAI 兼容',
rooCode: 'OpenAI 兼容',
continueTag: 'config.yaml',
openaiSdk: 'Python / Node',
anthropicSdk: '原生 Claude',
openWebui: '自定义 base',
langchain: 'LLM 节点',
llamaIndex: '模型路由',
zed: 'Assistant',
more: '60+ 工具',
},
},
faq: {
kicker: '// frequently asked',
title: '你可能想问的',
noAnswer: '没找到答案?{contact} · 通常 2 小时内回复。',
contact: '发邮件给我们 ↗',
q1: 'PURO 和 API 中转站 / API 代理有什么不同?',
a1: '中转站只是把官方 API 请求转一手,价格取决于你预付多少 balance。PURO 的不同是 —— 我们让你把已有的 Claude Pro / ChatGPT Plus 订阅变成 API。你原本就在付的 $20/月,不再只能在官网聊天里用,而是通过统一 API 喂给 Cursor、Claude Code、任何 SDK。同时我们也提供按量充值的官方 API 备用池,两种模式可以混用。',
q2: '用订阅跑 API 会不会被封号?',
a2: '我们会自动控制每个订阅的请求节奏,并在触发限流时把请求 failover 到池子里的其他订阅。实际上 PURO 的调用模式比你在官方客户端直接复制粘贴大段对话更不容易触发风控。你绑定多个订阅时,单个账号的 RPM 会被压到足够安全的阈值内。另外所有凭证用 AES-256 加密存储,请求链路不经过第三方。',
q3: '积分会过期吗?可以退款吗?',
a3Part1: '积分永不过期。',
a3Part2: '你可以攒着慢慢用 —— 包括几个月都不用。首次充值 7 天内未产生任何调用可全额退款,之后按剩余积分 85% 比例退。详见',
a3Link: '退款政策',
a3Part3: '。',
q4: '支持哪些支付方式?',
a4: '国内:支付宝 · 微信支付。国际Stripe 信用卡 · USDT (TRC20 / ERC20) · PayPal。企业充值支持 Invoice 对公打款,人民币开票。',
q5: '一个 PURO 账号可以绑定多少个订阅?',
a5StarterLabel: 'Starter 档:',
a5Starter: '不支持绑定自带订阅',
a5ProLabel: 'Pro 档及以上:',
a5Pro: '无限制,你可以把 10 个 ChatGPT Plus + 3 个 Claude Pro 一起绑上去,统一调度',
a5EnterpriseLabel: 'Enterprise',
a5Enterprise: '支持跨团队共享池,按组织维度隔离',
q6: '如果某个订阅触发限流了会怎样?',
a6: 'PURO 的调度器会把受限的订阅自动标记为 cooling 状态,暂时从池子里摘除。同一请求会立刻被 failover 到池内其他健康订阅上 —— 调用方通常感受不到中断。你可以在 Dashboard 看到每个订阅的当前状态和剩余配额。',
q7: '计费精度?超量会怎么办?',
a7: '按实际 token 数 + 模型单价计费,精度到 4 位小数。每个 API Key 可设置独立月度预算,达到后返回 402 Payment Required不会继续扣费。账户总余额不足时同样会返回 402且 Dashboard 有 80% / 95% 两级提醒邮件。',
q8: '数据会被用于训练吗?',
a8Part1: '不会。',
a8Part2: '所有请求仅用于路由转发,不入库、不留存内容(仅保留元数据如模型、token 数、延迟,用于计费和日志)。Pro 档及以上可选开启"零日志模式"(规划中),我们连请求 ID 都不记录。',
q9: '可以私有化部署吗?',
a9: 'Enterprise 档支持 Docker / K8s 私有化部署,控制面和数据面可以分开。授权按年订阅,包含升级和技术支持。',
a9Link: '联系商务 →',
q10: '支持哪些模型?会跟进新模型吗?',
a10: '当前覆盖 Claude(Sonnet 4.5 / Opus 4 / Haiku 4.5)、ChatGPT(GPT-5 / GPT-5 Codex / GPT-4.1)、Gemini(2.5 Pro / 2.5 Flash)。每当官方发布新模型,我们通常在 24 小时内上线。完整模型列表见',
a10Link: '文档',
a10Part2: '。',
},
finalCta: {
kicker: '// ready to start',
title: '5 分钟,拿到你第一个 sk-puro-* key',
subtitle: '绑定你的第一个订阅即可开始。',
ctaPrimary: '免费注册 →',
ctaDocs: '查看文档',
},
},
docs: {
hero: {
title: '快速接入 PURO AI',
subtitle: '三步走:拿 key → 配 base_url → 发请求',
},
sections: {
getKey: {
heading: '1. 获取 API key',
desc: '当前 PURO AI 不开放自助注册付费。联系管理员获取:',
},
codex: {
heading: '2. Codex CLI 接入',
configIntro: '修改 ~/.codex/config.toml',
authIntro: '然后 ~/.codex/auth.json',
verifyIntro: '验证:',
copy: '复制',
copied: '已复制',
},
claudeCode: {
heading: '3. Claude Code 接入',
configIntro: '修改 ~/.claude/settings.json',
note: 'Claude Code 通过 /v1/messages endpoint 调用 Anthropic 兼容 API。',
copy: '复制',
copied: '已复制',
},
curl: {
heading: '4. curl 直连测试',
openaiIntro: 'OpenAI Responses API',
anthropicIntro: 'Anthropic Messages API',
copy: '复制',
copied: '已复制',
},
models: {
heading: '5. 支持的模型',
colModel: '模型',
colPlatform: '平台 / 来源',
colContext: '上下文',
colStatus: '状态',
codexDedicated: 'OpenAI Codex 专用',
note: '后端 pricing 表实时跟进 {repo},完整清单登录后在 {dashboard} 查看。',
noteRepo: 'model-price-repo',
noteDashboard: '控制台',
},
feedback: {
heading: '6. 问题反馈',
desc: '遇到问题或希望补接某个平台:',
},
},
},
}

View File

@@ -120,26 +120,38 @@ const routes: RouteRecordRaw[] = [
title: 'Key Usage',
}
},
{
path: '/docs',
name: 'Docs',
component: () => import('@/views/docs/DocsView.vue'),
meta: {
requiresAuth: false,
title: 'PURO AI · 文档'
}
},
// ==================== User Routes ====================
// ==================== Portal Routes (shared PortalLayout) ====================
{
path: '/',
name: 'Landing',
component: () => import('@/views/landing/LandingView.vue'),
meta: {
requiresAuth: false,
title: 'PURO AI — 你的 AI 订阅,已经付过钱了',
redirectIfAuth: '/dashboard'
}
component: () => import('@/components/layout/PortalLayout.vue'),
children: [
{
path: '',
name: 'Landing',
component: () => import('@/views/landing/LandingView.vue'),
meta: {
requiresAuth: false,
title: 'PURO AI — 你的 AI 订阅,已经付过钱了',
redirectIfAuth: '/dashboard'
}
},
{
path: 'docs',
name: 'Docs',
component: () => import('@/views/docs/DocsView.vue'),
meta: {
requiresAuth: false,
title: 'PURO AI · 文档'
}
},
{
path: 'pricing',
name: 'pricing',
component: () => import('@/views/pricing/PricingView.vue'),
meta: { requiresAuth: false, title: 'Pricing · PURO AI' }
},
]
},
{
path: '/dashboard',
@@ -515,11 +527,15 @@ const routes: RouteRecordRaw[] = [
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
scrollBehavior(_to, _from, savedPosition) {
scrollBehavior(to, _from, savedPosition) {
// Scroll to saved position when using browser back/forward
if (savedPosition) {
return savedPosition
}
// Scroll to hash target (anchor link) — offset by sticky nav height
if (to.hash) {
return { el: to.hash, behavior: 'smooth', top: 80 }
}
// Scroll to top for new routes
return { top: 0 }
}

View File

@@ -2,27 +2,52 @@
<AuthLayout>
<template #narrative>
<div class="auth-narrative-inner">
<div class="brand"><span class="hex"></span><span>PURO AI</span></div>
<div class="auth-narrative-hero">
<div class="auth-narrative-headline">
<span class="num-5">5</span> 个订阅<br>
<span class="num-1">1</span> key
<router-link to="/" class="brand">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none">
<path d="M12 2L21 7V17L12 22L3 17V7L12 2Z" stroke="currentColor" stroke-width="1.8" fill="rgba(34, 211, 238, 0.08)"/>
<path d="M12 7L17 9.5V14.5L12 17L7 14.5V9.5L12 7Z" fill="currentColor"/>
</svg>
<span>PURO AI</span>
</router-link>
<div>
<div class="n-kicker">{{ t('auth.narrative.login.kicker') }}</div>
<div class="auth-narrative-headline" style="margin-top: 12px;">
<span class="num-n">{{ t('auth.narrative.login.headlineN') }}</span>
{{ ' ' + t('auth.narrative.login.headlineSep') + ' ' }}
<span class="num-1">{{ t('auth.narrative.login.headlineOne') }}</span>
{{ ' ' + t('auth.narrative.login.headlineSuffix') }}
</div>
<p class="auth-narrative-sub">
省去切换账号的繁琐<br>
省去为多个高昂订阅重复买单<br>
<span class="auth-narrative-tagline">PURO纯粹 AI 调用回归本质</span>
{{ t('auth.narrative.login.sub1') }}<br>
{{ t('auth.narrative.login.sub2') }}<br>
<span class="auth-narrative-tagline">{{ t('auth.narrative.login.tagline') }}</span>
</p>
</div>
<div class="auth-narrative-foot">Claude · ChatGPT · Codex · Gemini</div>
<div class="route-demo">
<div class="row"><span class="k">POST</span><span class="v">/v1/chat/completions</span></div>
<div class="row"><span class="k">model</span><span class="pill-inline">claude-sonnet-4-5</span></div>
<div class="row"><span class="k">route </span><span class="pill-inline amber">claude-pool-03</span></div>
<div class="row"><span class="k">status</span><span><span class="dot-g"></span><span style="color:var(--green)">200</span><span style="color:var(--text-3); margin:0 6px;">·</span>213ms<span style="color:var(--text-3); margin:0 6px;">·</span>42 tok</span></div>
</div>
<div class="auth-narrative-foot n-bottom">
<span>Claude</span><span class="sep">·</span>
<span>ChatGPT</span><span class="sep">·</span>
<span>Codex</span><span class="sep">·</span>
<span>Gemini</span>
<span class="sep">|</span>
<span class="live"><span class="dot"></span>ai.puro.im · operational</span>
</div>
</div>
</template>
<div class="space-y-6">
<!-- Title -->
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('auth.puroLoginTitle') }}</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">{{ t('auth.puroLoginSub') }}</p>
<h2 class="text-2xl font-bold text-slate-50">{{ t('auth.puroLoginTitle') }}</h2>
<p class="mt-2 text-sm text-slate-400">{{ t('auth.puroLoginSub') }}</p>
</div>
<div v-if="!backendModeEnabled && (linuxdoOAuthEnabled || oidcOAuthEnabled)" class="space-y-4">
@@ -464,15 +489,26 @@ function handle2FACancel(): void {
color: var(--text-0);
}
.brand {
display: flex;
display: inline-flex;
align-items: center;
gap: 8px;
gap: 10px;
font-weight: 700;
font-size: 18px;
font-size: 15px;
letter-spacing: -0.01em;
color: var(--text-0);
text-decoration: none;
}
.brand .hex {
.brand svg {
color: var(--cyan);
font-size: 24px;
flex-shrink: 0;
}
.n-kicker {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.1em;
color: var(--cyan);
margin-bottom: 0;
}
.auth-narrative-headline {
@@ -482,7 +518,7 @@ function handle2FACancel(): void {
letter-spacing: -0.03em;
margin-bottom: 24px;
}
.auth-narrative-headline .num-5 { color: var(--amber); }
.auth-narrative-headline .num-n { color: var(--amber); }
.auth-narrative-headline .num-1 { color: var(--cyan); }
.auth-narrative-sub {
font-size: 15px;
@@ -495,9 +531,71 @@ function handle2FACancel(): void {
font-size: 12px;
color: var(--text-3);
}
/* Route demo panel */
.route-demo {
font-family: var(--font-mono);
font-size: 12px;
background: rgba(2, 6, 23, 0.6);
border: 1px solid var(--border);
border-radius: var(--r-md, 8px);
padding: 18px 22px;
max-width: 440px;
}
.route-demo .row {
display: flex;
gap: 20px;
padding: 4px 0;
align-items: center;
}
.route-demo .k { color: var(--text-3); min-width: 70px; }
.route-demo .v { color: var(--text-0); }
.route-demo .pill-inline {
padding: 2px 8px;
border-radius: 4px;
background: rgba(34, 211, 238, 0.08);
border: 1px solid rgba(34, 211, 238, 0.25);
color: var(--cyan);
}
.route-demo .pill-inline.amber {
background: rgba(251, 191, 36, 0.08);
border-color: rgba(251, 191, 36, 0.25);
color: var(--amber);
}
.route-demo .dot-g {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--green);
display: inline-block;
margin-right: 6px;
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15);
}
/* Bottom status bar */
.auth-narrative-foot {
font-size: 12px;
color: var(--text-3);
font-family: var(--font-mono);
}
.n-bottom {
display: flex;
gap: 14px;
align-items: center;
flex-wrap: wrap;
}
.n-bottom .sep { color: var(--border-2, rgba(255,255,255,0.12)); }
.n-bottom .live {
color: var(--green, #34d399);
display: inline-flex;
align-items: center;
gap: 6px;
}
.n-bottom .live .dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--green, #34d399);
box-shadow: 0 0 6px var(--green, #34d399);
}
</style>

View File

@@ -2,27 +2,61 @@
<AuthLayout>
<template #narrative>
<div class="auth-narrative-inner">
<div class="brand"><span class="hex"></span><span>PURO AI</span></div>
<div class="auth-narrative-hero">
<div class="auth-narrative-headline">
<span class="num-5">5</span> 个订阅<br>
<span class="num-1">1</span> key
<router-link to="/" class="brand">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none">
<path d="M12 2L21 7V17L12 22L3 17V7L12 2Z" stroke="currentColor" stroke-width="1.8" fill="rgba(34, 211, 238, 0.08)"/>
<path d="M12 7L17 9.5V14.5L12 17L7 14.5V9.5L12 7Z" fill="currentColor"/>
</svg>
<span>PURO AI</span>
</router-link>
<div>
<div class="n-kicker">{{ t('auth.narrative.register.kicker') }}</div>
<div class="auth-narrative-headline" style="margin-top: 12px;">
<span class="num-n">{{ t('auth.narrative.register.headlineN') }}</span>
{{ ' ' + t('auth.narrative.register.headlineSep') + ' ' }}
<span class="num-1">{{ t('auth.narrative.register.headlineOne') }}</span>
{{ ' ' + t('auth.narrative.register.headlineSuffix') }}
</div>
<p class="auth-narrative-sub">
省去切换账号的繁琐<br>
省去为多个高昂订阅重复买单<br>
<span class="auth-narrative-tagline">PURO纯粹 AI 调用回归本质</span>
{{ t('auth.narrative.register.sub1') }}<br>
{{ t('auth.narrative.register.sub2') }}<br>
<span class="auth-narrative-tagline">{{ t('auth.narrative.register.tagline') }}</span>
</p>
</div>
<div class="auth-narrative-foot">Claude · ChatGPT · Codex · Gemini</div>
<div class="steps">
<div class="steps-title">{{ t('auth.narrative.register.stepsTitle') }}</div>
<div class="step active">
<div class="step-num">1</div>
<div class="step-text"><b>{{ t('auth.narrative.register.step1Title') }}</b> · {{ t('auth.narrative.register.step1Desc') }}</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-text"><b>{{ t('auth.narrative.register.step2Title') }}</b> · {{ t('auth.narrative.register.step2Desc') }}</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-text"><b>{{ t('auth.narrative.register.step3Title') }}</b> · {{ t('auth.narrative.register.step3Desc') }}</div>
</div>
</div>
<div class="auth-narrative-foot n-bottom">
<span>Claude</span><span class="sep">·</span>
<span>ChatGPT</span><span class="sep">·</span>
<span>Codex</span><span class="sep">·</span>
<span>Gemini</span>
<span class="sep">|</span>
<span class="live"><span class="dot"></span>ai.puro.im · operational</span>
</div>
</div>
</template>
<div class="space-y-6">
<!-- Title -->
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('auth.puroRegisterTitle') }}</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">{{ t('auth.puroRegisterSub') }}</p>
<h2 class="text-2xl font-bold text-slate-50">{{ t('auth.puroRegisterTitle') }}</h2>
<p class="mt-2 text-sm text-slate-400">{{ t('auth.puroRegisterSub') }}</p>
</div>
<div v-if="linuxdoOAuthEnabled || oidcOAuthEnabled" class="space-y-4">
@@ -125,6 +159,38 @@
<p v-else class="input-hint">
{{ t('auth.passwordHint') }}
</p>
<!-- Password strength meter -->
<div class="pw-strength" :data-score="pwScore">
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
</div>
<div class="pw-hint" :data-score="pwScore">
<span class="k">// strength · </span>
<span class="val">{{ pwScoreLabel }}</span>
</div>
</div>
<!-- Confirm Password Input -->
<div class="form-field">
<label class="input-label">{{ t('auth.confirmPasswordLabel') }}</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<Icon name="lock" size="md" class="text-gray-400 dark:text-dark-500" />
</div>
<input
v-model="formData.confirmPassword"
type="password"
class="input pl-11"
:placeholder="t('auth.confirmPasswordPlaceholder')"
required
autocomplete="new-password"
/>
</div>
<div class="match-hint" v-if="formData.confirmPassword" :class="pwMatchClass">
<span class="k">// </span><span class="val">{{ pwMatchLabel }}</span>
</div>
</div>
<!-- Invitation Code Input (Required when enabled) -->
@@ -231,6 +297,14 @@
</transition>
</div>
<!-- Terms Checkbox -->
<div class="terms-check">
<label>
<input type="checkbox" v-model="termsAccepted" class="check" />
<span>我已阅读并同意 <a href="#" class="underline">服务条款</a> <a href="#" class="underline">隐私政策</a></span>
</label>
</div>
<!-- Turnstile Widget -->
<div v-if="turnstileEnabled && turnstileSiteKey">
<TurnstileWidget
@@ -265,7 +339,7 @@
<!-- Submit Button -->
<button
type="submit"
:disabled="isLoading || (turnstileEnabled && !turnstileToken)"
:disabled="isLoading || (turnstileEnabled && !turnstileToken) || !termsAccepted"
class="btn btn-primary w-full"
>
<svg
@@ -316,7 +390,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
@@ -347,6 +421,7 @@ const isLoading = ref<boolean>(false)
const settingsLoaded = ref<boolean>(false)
const errorMessage = ref<string>('')
const showPassword = ref<boolean>(false)
const termsAccepted = ref<boolean>(false)
// Public settings
const registrationEnabled = ref<boolean>(true)
@@ -387,6 +462,7 @@ let invitationValidateTimeout: ReturnType<typeof setTimeout> | null = null
const formData = reactive({
email: '',
password: '',
confirmPassword: '',
promo_code: '',
invitation_code: ''
})
@@ -398,6 +474,28 @@ const errors = reactive({
invitation_code: ''
})
// ==================== Password Strength ====================
const pwScore = computed(() => {
const p = formData.password
if (!p) return 0
let s = 0
if (p.length >= 8) s++
if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s++
if (/[0-9]/.test(p)) s++
if (/[^A-Za-z0-9]/.test(p) || p.length >= 12) s++
return Math.min(s, 4)
})
const pwScoreLabel = computed(() => ['—', '弱', '中', '强', '极强'][pwScore.value])
// ==================== Confirm Password Match ====================
const pwMatch = computed(() =>
!formData.confirmPassword ? null : formData.password === formData.confirmPassword
)
const pwMatchClass = computed(() => pwMatch.value === true ? 'ok' : pwMatch.value === false ? 'mismatch' : '')
const pwMatchLabel = computed(() => pwMatch.value === true ? 'matched' : pwMatch.value === false ? 'passwords do not match' : '')
// ==================== Lifecycle ====================
onMounted(async () => {
@@ -648,6 +746,12 @@ function validateForm(): boolean {
isValid = false
}
// Confirm password validation
if (formData.password && formData.confirmPassword && formData.password !== formData.confirmPassword) {
errorMessage.value = t('auth.passwordsDoNotMatch')
isValid = false
}
// Invitation code validation (required when enabled)
if (invitationCodeEnabled.value) {
if (!formData.invitation_code.trim()) {
@@ -676,6 +780,12 @@ async function handleRegister(): Promise<void> {
return
}
// Check passwords match
if (formData.confirmPassword && formData.password !== formData.confirmPassword) {
errorMessage.value = t('auth.passwordsDoNotMatch')
return
}
// Check promo code validation status
if (formData.promo_code.trim()) {
// If promo code is being validated, wait
@@ -791,16 +901,28 @@ async function handleRegister(): Promise<void> {
justify-content: space-between;
color: var(--text-0);
}
.brand {
display: flex;
display: inline-flex;
align-items: center;
gap: 8px;
gap: 10px;
font-weight: 700;
font-size: 18px;
font-size: 15px;
letter-spacing: -0.01em;
color: var(--text-0);
text-decoration: none;
}
.brand .hex {
.brand svg {
color: var(--cyan);
font-size: 24px;
flex-shrink: 0;
}
.n-kicker {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.1em;
color: var(--cyan);
margin-bottom: 0;
}
.auth-narrative-headline {
@@ -810,7 +932,7 @@ async function handleRegister(): Promise<void> {
letter-spacing: -0.03em;
margin-bottom: 24px;
}
.auth-narrative-headline .num-5 { color: var(--amber); }
.auth-narrative-headline .num-n { color: var(--amber); }
.auth-narrative-headline .num-1 { color: var(--cyan); }
.auth-narrative-sub {
font-size: 15px;
@@ -823,9 +945,162 @@ async function handleRegister(): Promise<void> {
font-size: 12px;
color: var(--text-3);
}
/* 3-step onboarding panel */
.steps {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px 22px;
background: rgba(2, 6, 23, 0.5);
border: 1px solid var(--border);
border-radius: var(--r-md, 8px);
max-width: 440px;
}
.steps-title {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-3);
letter-spacing: 0.14em;
}
.step {
display: flex;
align-items: flex-start;
gap: 14px;
font-size: 13px;
}
.step-num {
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(34, 211, 238, 0.1);
border: 1px solid rgba(34, 211, 238, 0.3);
color: var(--cyan);
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.step.active .step-num {
background: var(--cyan);
color: #042f2e;
border-color: var(--cyan);
}
.step-text {
color: var(--text-1);
line-height: 1.5;
padding-top: 2px;
}
.step-text b {
color: var(--text-0);
font-weight: 600;
}
.step-text .k {
font-family: var(--font-mono);
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border-2);
padding: 1px 6px;
border-radius: 3px;
font-size: 11.5px;
color: var(--cyan);
}
/* Bottom status bar */
.auth-narrative-foot {
font-size: 12px;
color: var(--text-3);
font-family: var(--font-mono);
}
.n-bottom {
display: flex;
gap: 14px;
align-items: center;
flex-wrap: wrap;
}
.n-bottom .sep { color: var(--border-2, rgba(255, 255, 255, 0.12)); }
.n-bottom .live {
color: var(--green, #34d399);
display: inline-flex;
align-items: center;
gap: 6px;
}
.n-bottom .live .dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--green, #34d399);
box-shadow: 0 0 6px var(--green, #34d399);
}
/* Password strength meter */
.pw-strength {
display: flex;
gap: 4px;
margin-top: 8px;
}
.pw-strength .bar {
flex: 1;
height: 3px;
background: var(--border);
border-radius: 2px;
transition: background 0.2s;
}
.pw-strength[data-score="1"] .bar:nth-child(-n+1),
.pw-strength[data-score="2"] .bar:nth-child(-n+2),
.pw-strength[data-score="3"] .bar:nth-child(-n+3),
.pw-strength[data-score="4"] .bar:nth-child(-n+4) {
background: var(--strength-color, #f87171);
}
.pw-strength[data-score="1"] { --strength-color: #f87171; }
.pw-strength[data-score="2"] { --strength-color: #fbbf24; }
.pw-strength[data-score="3"] { --strength-color: #22d3ee; }
.pw-strength[data-score="4"] { --strength-color: #34d399; }
.pw-hint {
font-family: var(--font-mono);
font-size: 11px;
margin-top: 6px;
}
.pw-hint .k { color: var(--text-3); }
.pw-hint .val { color: var(--strength-color, var(--text-2)); }
.pw-hint[data-score="1"] { --strength-color: #f87171; }
.pw-hint[data-score="2"] { --strength-color: #fbbf24; }
.pw-hint[data-score="3"] { --strength-color: #22d3ee; }
.pw-hint[data-score="4"] { --strength-color: #34d399; }
/* Confirm password match hint */
.match-hint {
font-family: var(--font-mono);
font-size: 11px;
margin-top: 6px;
}
.match-hint.ok { color: #34d399; }
.match-hint.mismatch { color: #f87171; }
.match-hint .k { color: var(--text-3); }
/* Terms checkbox */
.terms-check {
margin-top: 16px;
font-size: 13px;
color: var(--text-2);
}
.terms-check label {
display: flex;
align-items: flex-start;
gap: 10px;
cursor: pointer;
}
.terms-check a {
color: var(--cyan, #22d3ee);
text-decoration: underline;
text-underline-offset: 2px;
}
.terms-check input[type="checkbox"] {
accent-color: var(--cyan, #22d3ee);
margin-top: 3px;
flex-shrink: 0;
}
</style>

View File

@@ -1,41 +1,39 @@
<template>
<div class="puro-page">
<div class="bg-glow soft"></div>
<nav class="nav">
<div class="container nav-inner">
<router-link to="/" class="brand"><span class="hex"></span><span>PURO AI</span></router-link>
<div class="nav-links">
<router-link to="/">首页</router-link>
<a href="#codex">Codex</a>
<a href="#claude-code">Claude Code</a>
<a href="#curl">curl</a>
</div>
<div class="nav-cta">
<router-link to="/login" class="btn btn-primary">登录 </router-link>
</div>
</div>
</nav>
<div>
<section class="docs-hero container">
<h1>快速接入 PURO AI</h1>
<p class="subtitle">三步走 key base_url 发请求</p>
<h1>{{ $t('docs.hero.title') }}</h1>
<p class="subtitle">{{ $t('docs.hero.subtitle') }}</p>
</section>
<div class="container docs-body">
<section id="get-key" class="docs-section">
<h2>1. 获取 API key</h2>
<p>当前 PURO AI 不开放自助注册付费联系管理员获取</p>
<h2>{{ $t('docs.sections.getKey.heading') }}</h2>
<p>{{ $t('docs.sections.getKey.desc') }}</p>
<div class="callout">
<a href="mailto:admin@puro.im">admin@puro.im</a>
</div>
<p class="note">未来通过 iShare 入口开放订阅购买</p>
</section>
<section id="codex" class="docs-section">
<h2>2. Codex CLI 接入</h2>
<p>修改 <code class="mono">~/.codex/config.toml</code></p>
<pre class="mono"><code>model_provider = <span class="str">"OpenAI"</span>
<h2>{{ $t('docs.sections.codex.heading') }}</h2>
<p>{{ $t('docs.sections.codex.configIntro') }}</p>
<div class="code-panel">
<div class="code-head">
<div class="traffic">
<span></span><span></span><span></span>
</div>
<div class="code-tabs">
<span class="tab active">~/.codex/config.toml</span>
</div>
<button class="code-copy" @click="copyCode($event)" type="button">
<svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor">
<path d="M4 1.5h5a.5.5 0 0 1 .5.5v1h1V2a1.5 1.5 0 0 0-1.5-1.5H4A1.5 1.5 0 0 0 2.5 2v8A1.5 1.5 0 0 0 4 11.5h1v-1H4a.5.5 0 0 1-.5-.5V2a.5.5 0 0 1 .5-.5z"/>
<path d="M7 4.5A1.5 1.5 0 0 1 8.5 3h5A1.5 1.5 0 0 1 15 4.5v9a1.5 1.5 0 0 1-1.5 1.5h-5A1.5 1.5 0 0 1 7 13.5v-9z"/>
</svg>
{{ $t('docs.sections.codex.copy') }}
</button>
</div>
<pre class="mono"><code>model_provider = <span class="str">"OpenAI"</span>
model = <span class="str">"gpt-5.4"</span>
wire_api = <span class="str">"responses"</span>
@@ -44,55 +42,186 @@ name = <span class="str">"OpenAI"</span>
base_url = <span class="str">"https://ai.puro.im"</span>
wire_api = <span class="str">"responses"</span>
requires_openai_auth = <span class="kw">true</span></code></pre>
<p>然后 <code class="mono">~/.codex/auth.json</code></p>
<pre class="mono"><code>{
</div>
<p>{{ $t('docs.sections.codex.authIntro') }}</p>
<div class="code-panel">
<div class="code-head">
<div class="traffic">
<span></span><span></span><span></span>
</div>
<div class="code-tabs">
<span class="tab active">~/.codex/auth.json</span>
</div>
<button class="code-copy" @click="copyCode($event)" type="button">
<svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor">
<path d="M4 1.5h5a.5.5 0 0 1 .5.5v1h1V2a1.5 1.5 0 0 0-1.5-1.5H4A1.5 1.5 0 0 0 2.5 2v8A1.5 1.5 0 0 0 4 11.5h1v-1H4a.5.5 0 0 1-.5-.5V2a.5.5 0 0 1 .5-.5z"/>
<path d="M7 4.5A1.5 1.5 0 0 1 8.5 3h5A1.5 1.5 0 0 1 15 4.5v9a1.5 1.5 0 0 1-1.5 1.5h-5A1.5 1.5 0 0 1 7 13.5v-9z"/>
</svg>
{{ $t('docs.sections.codex.copy') }}
</button>
</div>
<pre class="mono"><code>{
<span class="str">"OPENAI_API_KEY"</span>: <span class="str">"sk-xxxxxxxxxxxxxxxx"</span>
}</code></pre>
<p>验证</p>
<pre class="mono"><code><span class="cm">$</span> codex exec --sandbox read-only <span class="str">"say hi"</span></code></pre>
</div>
<p>{{ $t('docs.sections.codex.verifyIntro') }}</p>
<div class="code-panel">
<div class="code-head">
<div class="traffic">
<span></span><span></span><span></span>
</div>
<div class="code-tabs">
<span class="tab active">shell</span>
</div>
<button class="code-copy" @click="copyCode($event)" type="button">
<svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor">
<path d="M4 1.5h5a.5.5 0 0 1 .5.5v1h1V2a1.5 1.5 0 0 0-1.5-1.5H4A1.5 1.5 0 0 0 2.5 2v8A1.5 1.5 0 0 0 4 11.5h1v-1H4a.5.5 0 0 1-.5-.5V2a.5.5 0 0 1 .5-.5z"/>
<path d="M7 4.5A1.5 1.5 0 0 1 8.5 3h5A1.5 1.5 0 0 1 15 4.5v9a1.5 1.5 0 0 1-1.5 1.5h-5A1.5 1.5 0 0 1 7 13.5v-9z"/>
</svg>
{{ $t('docs.sections.codex.copy') }}
</button>
</div>
<pre class="mono"><code><span class="cm">$</span> codex exec --sandbox read-only <span class="str">"say hi"</span></code></pre>
</div>
</section>
<section id="claude-code" class="docs-section">
<h2>3. Claude Code 接入</h2>
<p>修改 <code class="mono">~/.claude/settings.json</code></p>
<pre class="mono"><code>{
<h2>{{ $t('docs.sections.claudeCode.heading') }}</h2>
<p>{{ $t('docs.sections.claudeCode.configIntro') }}</p>
<div class="code-panel">
<div class="code-head">
<div class="traffic">
<span></span><span></span><span></span>
</div>
<div class="code-tabs">
<span class="tab active">~/.claude/settings.json</span>
</div>
<button class="code-copy" @click="copyCode($event)" type="button">
<svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor">
<path d="M4 1.5h5a.5.5 0 0 1 .5.5v1h1V2a1.5 1.5 0 0 0-1.5-1.5H4A1.5 1.5 0 0 0 2.5 2v8A1.5 1.5 0 0 0 4 11.5h1v-1H4a.5.5 0 0 1-.5-.5V2a.5.5 0 0 1 .5-.5z"/>
<path d="M7 4.5A1.5 1.5 0 0 1 8.5 3h5A1.5 1.5 0 0 1 15 4.5v9a1.5 1.5 0 0 1-1.5 1.5h-5A1.5 1.5 0 0 1 7 13.5v-9z"/>
</svg>
{{ $t('docs.sections.claudeCode.copy') }}
</button>
</div>
<pre class="mono"><code>{
<span class="str">"base_url"</span>: <span class="str">"https://ai.puro.im"</span>,
<span class="str">"api_key"</span>: <span class="str">"sk-xxxxxxxxxxxxxxxx"</span>
}</code></pre>
<p class="note">Claude Code 通过 <code class="mono">/v1/messages</code> endpoint 调用 Anthropic 兼容 API</p>
</div>
<p class="note">{{ $t('docs.sections.claudeCode.note') }}</p>
</section>
<section id="curl" class="docs-section">
<h2>4. curl 直连测试</h2>
<p>OpenAI Responses API</p>
<pre class="mono"><code><span class="cm">$</span> curl https://ai.puro.im/responses \
<h2>{{ $t('docs.sections.curl.heading') }}</h2>
<p>{{ $t('docs.sections.curl.openaiIntro') }}</p>
<div class="code-panel">
<div class="code-head">
<div class="traffic">
<span></span><span></span><span></span>
</div>
<div class="code-tabs">
<span class="tab active">curl</span>
</div>
<button class="code-copy" @click="copyCode($event)" type="button">
<svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor">
<path d="M4 1.5h5a.5.5 0 0 1 .5.5v1h1V2a1.5 1.5 0 0 0-1.5-1.5H4A1.5 1.5 0 0 0 2.5 2v8A1.5 1.5 0 0 0 4 11.5h1v-1H4a.5.5 0 0 1-.5-.5V2a.5.5 0 0 1 .5-.5z"/>
<path d="M7 4.5A1.5 1.5 0 0 1 8.5 3h5A1.5 1.5 0 0 1 15 4.5v9a1.5 1.5 0 0 1-1.5 1.5h-5A1.5 1.5 0 0 1 7 13.5v-9z"/>
</svg>
{{ $t('docs.sections.curl.copy') }}
</button>
</div>
<pre class="mono"><code><span class="cm">$</span> curl https://ai.puro.im/responses \
-H <span class="str">"Authorization: Bearer sk-xxx"</span> \
-H <span class="str">"Content-Type: application/json"</span> \
-d <span class="str">'{"model":"gpt-5.4","input":"hello"}'</span></code></pre>
<p>Anthropic Messages API</p>
<pre class="mono"><code><span class="cm">$</span> curl https://ai.puro.im/v1/messages \
</div>
<p>{{ $t('docs.sections.curl.anthropicIntro') }}</p>
<div class="code-panel">
<div class="code-head">
<div class="traffic">
<span></span><span></span><span></span>
</div>
<div class="code-tabs">
<span class="tab active">curl</span>
</div>
<button class="code-copy" @click="copyCode($event)" type="button">
<svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor">
<path d="M4 1.5h5a.5.5 0 0 1 .5.5v1h1V2a1.5 1.5 0 0 0-1.5-1.5H4A1.5 1.5 0 0 0 2.5 2v8A1.5 1.5 0 0 0 4 11.5h1v-1H4a.5.5 0 0 1-.5-.5V2a.5.5 0 0 1 .5-.5z"/>
<path d="M7 4.5A1.5 1.5 0 0 1 8.5 3h5A1.5 1.5 0 0 1 15 4.5v9a1.5 1.5 0 0 1-1.5 1.5h-5A1.5 1.5 0 0 1 7 13.5v-9z"/>
</svg>
{{ $t('docs.sections.curl.copy') }}
</button>
</div>
<pre class="mono"><code><span class="cm">$</span> curl https://ai.puro.im/v1/messages \
-H <span class="str">"Authorization: Bearer sk-xxx"</span> \
-H <span class="str">"Content-Type: application/json"</span> \
-H <span class="str">"anthropic-version: 2023-06-01"</span> \
-d <span class="str">'{"model":"claude-opus-4-7","max_tokens":100,"messages":[{"role":"user","content":"hi"}]}'</span></code></pre>
</div>
</section>
<section id="models" class="docs-section">
<h2>5. 支持的模型</h2>
<ul class="model-list">
<li><code class="mono">gpt-5.4</code> · OpenAIvia ChatGPT Plus / Codex OAuth</li>
<li><code class="mono">gpt-5.4-codex</code> · OpenAI Codex 专用</li>
<li><code class="mono">claude-opus-4-7</code> · Anthropicvia Claude Pro / Max OAuth</li>
<li><code class="mono">claude-sonnet-4-6</code> · Anthropic</li>
<li><code class="mono">gemini-2.5-pro</code> · Googlevia Code Assist OAuth</li>
<li><code class="mono">gemini-2.5-flash</code> · Google</li>
</ul>
<p class="note">后端 pricing 表实时跟进 <code class="mono">model-price-repo</code>完整清单登录后在 <router-link to="/dashboard">控制台</router-link> 查看</p>
<h2>{{ $t('docs.sections.models.heading') }}</h2>
<div class="table-wrap">
<table class="models-table mono">
<thead>
<tr>
<th>{{ $t('docs.sections.models.colModel') }}</th>
<th>{{ $t('docs.sections.models.colPlatform') }}</th>
<th>{{ $t('docs.sections.models.colContext') }}</th>
<th>{{ $t('docs.sections.models.colStatus') }}</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>gpt-5.4</code></td>
<td><span class="provider gpt"><span class="dot"></span>OpenAIChatGPT Plus / Codex OAuth</span></td>
<td>272K</td>
<td><span class="badge-ok">OK</span></td>
</tr>
<tr>
<td><code>gpt-5.4-codex</code></td>
<td><span class="provider gpt"><span class="dot"></span>{{ $t('docs.sections.models.codexDedicated') }}</span></td>
<td>272K</td>
<td><span class="badge-ok">OK</span></td>
</tr>
<tr>
<td><code>claude-opus-4-7</code></td>
<td><span class="provider claude"><span class="dot"></span>AnthropicClaude Pro / Max OAuth</span></td>
<td>200K</td>
<td><span class="badge-ok">OK</span></td>
</tr>
<tr>
<td><code>claude-sonnet-4-6</code></td>
<td><span class="provider claude"><span class="dot"></span>Anthropic</span></td>
<td>200K</td>
<td><span class="badge-ok">OK</span></td>
</tr>
<tr>
<td><code>gemini-2.5-pro</code></td>
<td><span class="provider gemini"><span class="dot"></span>GoogleCode Assist OAuth</span></td>
<td>1M</td>
<td><span class="badge-beta">BETA</span></td>
</tr>
<tr>
<td><code>gemini-2.5-flash</code></td>
<td><span class="provider gemini"><span class="dot"></span>Google</span></td>
<td>1M</td>
<td><span class="badge-beta">BETA</span></td>
</tr>
</tbody>
</table>
</div>
<i18n-t tag="p" class="note" keypath="docs.sections.models.note">
<template #repo><code class="mono">{{ $t('docs.sections.models.noteRepo') }}</code></template>
<template #dashboard><router-link to="/dashboard">{{ $t('docs.sections.models.noteDashboard') }}</router-link></template>
</i18n-t>
</section>
<section id="feedback" class="docs-section">
<h2>6. 问题反馈</h2>
<p>遇到问题或希望补接某个平台</p>
<h2>{{ $t('docs.sections.feedback.heading') }}</h2>
<p>{{ $t('docs.sections.feedback.desc') }}</p>
<div class="callout">
<a href="mailto:admin@puro.im">admin@puro.im</a>
</div>
@@ -102,8 +231,28 @@ requires_openai_auth = <span class="kw">true</span></code></pre>
</template>
<script setup lang="ts">
// DocsView — public quickstart documentation
// Route: /docs (no auth required)
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
async function copyCode(ev: MouseEvent) {
const button = ev.currentTarget as HTMLButtonElement
const panel = button.closest('.code-panel')
const codeEl = panel?.querySelector('pre code') as HTMLElement | null
if (!codeEl) return
try {
await navigator.clipboard.writeText(codeEl.innerText)
const original = button.innerHTML
button.innerHTML = `<svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor"><path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/></svg> ${t('docs.sections.codex.copied')}`
button.classList.add('copied')
setTimeout(() => {
button.innerHTML = original
button.classList.remove('copied')
}, 1500)
} catch (e) {
console.warn('Clipboard copy failed', e)
}
}
</script>
<style scoped>
@@ -119,6 +268,9 @@ requires_openai_auth = <span class="kw">true</span></code></pre>
position: relative;
}
/* nav brand SVG */
.brand svg { color: var(--cyan); flex-shrink: 0; }
.docs-hero {
padding: 80px 24px 40px;
text-align: center;
@@ -139,6 +291,8 @@ requires_openai_auth = <span class="kw">true</span></code></pre>
margin: 48px 0;
scroll-margin-top: 80px;
}
/* h2 with cyan left-accent bar */
.docs-section h2 {
font-size: 22px;
font-weight: 700;
@@ -146,7 +300,20 @@ requires_openai_auth = <span class="kw">true</span></code></pre>
color: var(--text-0);
border-bottom: 1px solid var(--border);
padding-bottom: 8px;
padding-left: 14px;
position: relative;
}
.docs-section h2::before {
content: '';
position: absolute;
left: 0;
top: 4px;
bottom: 10px;
width: 3px;
background: var(--cyan);
border-radius: 2px;
}
.docs-section p {
color: var(--text-1);
font-size: 14px;
@@ -164,20 +331,92 @@ requires_openai_auth = <span class="kw">true</span></code></pre>
font-size: 13px;
color: var(--cyan);
}
.docs-section pre.mono {
background: var(--bg-code);
/* code panel */
.code-panel {
border: 1px solid var(--border);
border-radius: var(--r-md);
background: var(--bg-code);
overflow: hidden;
margin: 12px 0;
}
.code-head {
display: flex;
align-items: center;
padding: 10px 14px;
gap: 12px;
background: var(--bg-1);
border-bottom: 1px solid var(--border);
}
.traffic { display: flex; gap: 6px; flex-shrink: 0; }
.traffic span {
width: 10px; height: 10px; border-radius: 50%;
}
.traffic span:nth-child(1) { background: #f87171; }
.traffic span:nth-child(2) { background: #fbbf24; }
.traffic span:nth-child(3) { background: #34d399; }
.code-tabs {
display: flex;
gap: 6px;
flex: 1;
min-width: 0;
}
.code-tabs .tab {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-2);
padding: 2px 10px;
border: 1px solid var(--border);
border-radius: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 240px;
}
.code-tabs .tab.active {
color: var(--cyan);
background: rgba(34,211,238,0.1);
border-color: rgba(34,211,238,0.3);
}
.code-copy {
background: transparent;
border: 1px solid var(--border);
border-radius: 4px;
padding: 4px 8px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-3);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
transition: color 0.15s, border-color 0.15s;
}
.code-copy:hover {
color: var(--cyan);
border-color: rgba(34,211,238,0.3);
}
.code-copy.copied {
color: var(--green, #34d399);
border-color: rgba(52,211,153,0.3);
}
.code-copy svg { flex-shrink: 0; }
.code-panel pre.mono {
margin: 0;
border: none;
border-radius: 0;
background: var(--bg-code);
padding: 16px;
font-size: 13px;
line-height: 1.6;
color: var(--text-1);
overflow-x: auto;
margin: 12px 0;
}
.docs-section pre .str { color: var(--cyan); }
.docs-section pre .kw { color: var(--amber); }
.docs-section pre .cm { color: var(--text-3); }
.code-panel pre .str { color: var(--cyan); }
.code-panel pre .kw { color: var(--amber); }
.code-panel pre .cm { color: var(--text-3); }
.callout {
padding: 16px 20px;
@@ -193,14 +432,80 @@ requires_openai_auth = <span class="kw">true</span></code></pre>
font-size: 14px;
}
.model-list { list-style: none; padding: 0; }
.model-list li {
padding: 8px 0;
color: var(--text-1);
font-size: 14px;
border-bottom: 1px dashed var(--border);
/* models table */
.table-wrap {
border: 1px solid var(--border);
border-radius: var(--r-md);
overflow: hidden;
margin: 12px 0;
}
.models-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.models-table thead {
background: var(--bg-1);
}
.models-table th {
text-align: left;
padding: 10px 14px;
color: var(--text-3);
font-weight: 500;
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
border-bottom: 1px solid var(--border);
}
.models-table td {
padding: 12px 14px;
color: var(--text-1);
border-bottom: 1px solid rgba(30,41,59,0.5);
}
.models-table tbody tr:last-child td { border-bottom: none; }
.models-table code {
color: var(--cyan);
background: transparent;
padding: 0;
font-family: var(--font-mono);
font-size: 12px;
}
.models-table .provider {
display: inline-flex;
align-items: center;
gap: 6px;
}
.models-table .provider .dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.models-table .provider.gpt .dot { background: var(--p-gpt, #10a37f); }
.models-table .provider.claude .dot { background: var(--p-claude, #d97757); }
.models-table .provider.gemini .dot { background: var(--p-gemini, #4285f4); }
.badge-ok {
display: inline-block;
padding: 2px 8px;
font-size: 10px;
font-weight: 600;
font-family: var(--font-mono);
color: var(--green, #34d399);
background: rgba(52,211,153,0.1);
border: 1px solid rgba(52,211,153,0.3);
border-radius: 4px;
}
.badge-beta {
display: inline-block;
padding: 2px 8px;
font-size: 10px;
font-weight: 600;
font-family: var(--font-mono);
color: var(--amber, #fbbf24);
background: rgba(251,191,36,0.1);
border: 1px solid rgba(251,191,36,0.3);
border-radius: 4px;
}
.model-list li:last-child { border-bottom: none; }
/* container override (puro.css has 1100px/32px; we want narrower for docs readability) */
.container {

View File

@@ -1,89 +1,86 @@
<template>
<div class="puro-page">
<div class="bg-glow"></div>
<div class="grain"></div>
<!-- NAV -->
<nav class="nav">
<div class="container nav-inner">
<router-link to="/" class="brand">
<span class="hex"></span>
<span>PURO AI</span>
</router-link>
<div class="nav-links">
<a href="#features">产品</a>
<a href="/docs">文档</a>
</div>
<div class="nav-cta">
<router-link to="/login" class="btn btn-ghost">登录</router-link>
<router-link to="/register" class="btn btn-primary">免费试用 </router-link>
</div>
</div>
</nav>
<div>
<!-- HERO -->
<section class="hero container">
<div class="hero-eyebrow">
<span class="pill">ChatGPT Plus · Claude Pro · Codex · Gemini</span>
<span class="badge">{{ $t('landing.hero.badgeNew') }}</span>
<span>{{ $t('landing.hero.eyebrow') }}</span>
</div>
<h1 class="hero-title">
你的 AI 订阅<br>
<span class="text-puro-cyan">已经付过钱了</span>
{{ $t('landing.hero.title1') }}<br>
<span class="text-puro-cyan">{{ $t('landing.hero.title2') }}</span>
</h1>
<p class="hero-sub">
Claude Pro · ChatGPT Plus · Codex · Gemini 订阅<br>
聚合成统一 API零改动接入 OpenAI / Anthropic SDK
{{ $t('landing.hero.sub1') }}<br>
<i18n-t keypath="landing.hero.sub2" tag="span">
<template #openai><span class="pill-inline">OpenAI</span></template>
<template #anthropic><span class="pill-inline">Anthropic</span></template>
</i18n-t>
</p>
<div class="hero-cta">
<router-link to="/login" class="btn btn-primary btn-lg">登录 </router-link>
<a href="mailto:admin@puro.im" class="btn btn-ghost btn-lg">联系咨询</a>
<router-link to="/login" class="btn btn-primary btn-lg">{{ $t('landing.hero.ctaLogin') }}</router-link>
<a href="mailto:admin@puro.im" class="btn btn-ghost btn-lg">{{ $t('landing.hero.ctaContact') }}</a>
</div>
<div class="hero-micro">
已验证可用 Codex CLI · Claude Code · curl · 服务器出口新加坡
{{ $t('landing.hero.micro') }}
</div>
</section>
<!-- 模型墙 -->
<section class="block container" id="models">
<div class="section-header">
<div class="section-kicker">支持的 AI 平台</div>
<h2 class="section-title">通过 OAuth 直接复用你的订阅</h2>
<p class="section-sub">无需申请官方 API key也无需切换账号</p>
<div class="section-kicker">{{ $t('landing.models.kicker') }}</div>
<h2 class="section-title">{{ $t('landing.models.title') }}</h2>
<p class="section-sub">{{ $t('landing.models.sub') }}</p>
</div>
<div class="model-wall">
<div class="model-card">
<div class="model-dot" style="background: var(--p-claude)"></div>
<div class="model-logo">
<svg width="22" height="22" viewBox="0 0 24 24" fill="#d97757"><path d="M4.5 19L12 4l7.5 15H16l-4-8.5L8 19H4.5z"/></svg>
</div>
<div>
<div class="model-name">Claude Pro / Max</div>
<div class="model-meta">Anthropic OAuth</div>
</div>
<div class="status-chip"><span class="dot"></span>online</div>
</div>
<div class="model-card">
<div class="model-dot" style="background: var(--p-gpt)"></div>
<div class="model-logo">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#10a37f" stroke-width="1.6"><circle cx="12" cy="12" r="8"/><path d="M12 4v16M4 12h16"/></svg>
</div>
<div>
<div class="model-name">ChatGPT Plus / Pro</div>
<div class="model-meta">OpenAI OAuth</div>
</div>
<div class="status-chip"><span class="dot"></span>online</div>
</div>
<div class="model-card">
<div class="model-dot" style="background: var(--p-codex)"></div>
<div class="model-logo">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#f8fafc" stroke-width="1.6"><rect x="4" y="4" width="16" height="16" rx="3" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M8 10l-2 2 2 2M16 10l2 2-2 2M14 8l-4 8" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round"/></svg>
</div>
<div>
<div class="model-name">Codex CLI</div>
<div class="model-meta">OpenAI OAuth</div>
</div>
<div class="status-chip"><span class="dot"></span>online</div>
</div>
<div class="model-card">
<div class="model-dot" style="background: var(--p-gemini)"></div>
<div class="model-logo">
<svg width="22" height="22" viewBox="0 0 24 24" fill="#4285f4"><path d="M12 2L14 10L22 12L14 14L12 22L10 14L2 12L10 10Z"/></svg>
</div>
<div>
<div class="model-name">Gemini Code Assist</div>
<div class="model-meta">Google OAuth</div>
</div>
<div class="status-chip"><span class="dot"></span>online</div>
</div>
<div class="model-card is-muted">
<div class="model-dot" style="background: var(--text-3)"></div>
<div class="model-logo">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.6" stroke-linecap="round"><circle cx="5" cy="12" r="1.5" fill="#94a3b8"/><circle cx="12" cy="12" r="1.5" fill="#94a3b8"/><circle cx="19" cy="12" r="1.5" fill="#94a3b8"/></svg>
</div>
<div>
<div class="model-name">更多</div>
<div class="model-meta">规划中</div>
<div class="model-name">{{ $t('landing.models.more') }}</div>
<div class="model-meta">{{ $t('landing.models.morePlanned') }}</div>
</div>
</div>
</div>
@@ -92,24 +89,44 @@
<!-- 三特性 -->
<section class="block container" id="features">
<div class="section-header">
<div class="section-kicker">核心特性</div>
<h2 class="section-title">一套 key三件武器</h2>
<div class="section-kicker">{{ $t('landing.features.kicker') }}</div>
<h2 class="section-title">{{ $t('landing.features.title1') }}<br>{{ $t('landing.features.title2') }}</h2>
<p class="section-sub">{{ $t('landing.features.sub') }}</p>
</div>
<div class="features">
<div class="feature">
<div class="feature-icon"></div>
<h3>一个 key 接所有模型</h3>
<p>不再为每个 provider 申请 API key配置 base_url统一 <code class="mono">sk-</code> Claude / GPT / Gemini model 自动路由到对应账号池</p>
<h3>{{ $t('landing.features.f1Title') }}</h3>
<p>
<i18n-t keypath="landing.features.f1Desc" tag="span">
<template #sk><code class="mono">sk-</code></template>
</i18n-t>
</p>
<ul class="feature-bullets">
<li>{{ $t('landing.features.f1b1') }}</li>
<li>{{ $t('landing.features.f1b2') }}</li>
<li>{{ $t('landing.features.f1b3') }}</li>
</ul>
</div>
<div class="feature">
<div class="feature-icon">🔄</div>
<h3>账号池高可用</h3>
<p>支持多账号自动调度与 failover某个上游触发限流 / 冷却时流量切到下一个健康账号token 刷新全自动</p>
<h3>{{ $t('landing.features.f2Title') }}</h3>
<p>{{ $t('landing.features.f2Desc') }}</p>
<ul class="feature-bullets">
<li>{{ $t('landing.features.f2b1') }}</li>
<li>{{ $t('landing.features.f2b2') }}</li>
<li>{{ $t('landing.features.f2b3') }}</li>
</ul>
</div>
<div class="feature">
<div class="feature-icon">📊</div>
<h3>用量看板</h3>
<p>每条请求的 tokens费用上游账号延迟全可视化模型分布饼图 + 趋势曲线 + Top 排行</p>
<h3>{{ $t('landing.features.f3Title') }}</h3>
<p>{{ $t('landing.features.f3Desc') }}</p>
<ul class="feature-bullets">
<li>{{ $t('landing.features.f3b1') }}</li>
<li>{{ $t('landing.features.f3b2') }}</li>
<li>{{ $t('landing.features.f3b3') }}</li>
</ul>
</div>
</div>
</section>
@@ -117,103 +134,155 @@
<!-- Code Demo -->
<section class="block container" id="code">
<div class="section-header">
<div class="section-kicker">快速接入</div>
<h2 class="section-title"> base_url 一改就能用</h2>
<p class="section-sub">兼容 OpenAI / Anthropic / Gemini SDK<span class="text-puro-cyan">零代码改动</span></p>
<div class="section-kicker">{{ $t('landing.codeDemo.kicker') }}</div>
<h2 class="section-title">{{ $t('landing.codeDemo.title') }}</h2>
<p class="section-sub">
<i18n-t keypath="landing.codeDemo.sub" tag="span">
<template #highlight><span class="text-puro-cyan">{{ $t('landing.codeDemo.subHighlight') }}</span></template>
</i18n-t>
</p>
</div>
<div class="code-demo">
<div class="code-block">
<div class="code-title mono">~/.codex/config.toml</div>
<div class="code-head">
<div class="traffic">
<span></span><span></span><span></span>
</div>
<div class="code-tabs">
<span class="tab active">~/.codex/config.toml</span>
</div>
<span class="meta">edited 2s ago</span>
</div>
<pre class="mono"><code><span class="cm">[model_providers.OpenAI]</span>
base_url = <span class="str">"https://ai.puro.im"</span>
wire_api = <span class="str">"responses"</span>
requires_openai_auth = <span class="kw">true</span></code></pre>
</div>
<div class="code-block">
<div class="code-title mono">curl</div>
<div class="code-head">
<div class="traffic">
<span></span><span></span><span></span>
</div>
<div class="code-tabs">
<span class="tab active">curl.sh</span>
</div>
<span class="meta">zsh · puro 210ms</span>
</div>
<pre class="mono"><code><span class="cm">$</span> curl https://ai.puro.im/responses \
-H <span class="str">"Authorization: Bearer sk-xxx"</span> \
-d <span class="str">'{"model":"gpt-5.4","input":"hello"}'</span></code></pre>
</div>
</div>
<div class="code-foot">支持 OpenAI Responses API · Anthropic Messages API · Gemini generateContent · 流式 SSE &amp; WebSocket</div>
<div class="code-foot">{{ $t('landing.codeDemo.foot') }}</div>
</section>
<!-- Dashboard mockup -->
<section class="block container" id="dashboard">
<div class="section-header">
<div class="section-kicker">用量透明</div>
<h2 class="section-title">每条请求都看得见</h2>
<p class="section-sub">不像第三方 API 池子那种"扣了多少不告诉你"扣哪个账号跑哪个模型用了多少 tokens上游响应几秒一目了然</p>
<div class="section-kicker">{{ $t('landing.dashboard.kicker') }}</div>
<h2 class="section-title">{{ $t('landing.dashboard.title') }}</h2>
<p class="section-sub">{{ $t('landing.dashboard.sub') }}</p>
</div>
<div class="dash-mock">
<div class="dash-header">
<span class="dash-title">Dashboard · 预览</span>
<div class="dash-dots"><span></span><span></span><span></span></div>
<!-- browser chrome header -->
<div class="dash-chrome">
<div class="traffic">
<span></span><span></span><span></span>
</div>
<div class="url-bar">ai.puro.im/dashboard</div>
<span class="dash-user mono">me@puro</span>
</div>
<div class="dash-body">
<div class="stat-row">
<div class="stat"><div class="stat-label">今日请求</div><div class="stat-value">1,842</div><div class="stat-delta">+12.3%</div></div>
<div class="stat"><div class="stat-label">输入 Tokens</div><div class="stat-value">2.1M</div><div class="stat-delta">+8.1%</div></div>
<div class="stat"><div class="stat-label">输出 Tokens</div><div class="stat-value">485K</div><div class="stat-delta">+15.6%</div></div>
<div class="stat"><div class="stat-label">今日费用</div><div class="stat-value">$1.23</div><div class="stat-delta down">-4.2%</div></div>
<div class="dash-layout">
<!-- sidebar -->
<aside class="dash-side">
<div class="side-group">
<div class="side-label">WORKSPACE</div>
<div class="side-item active"><span class="ico">📊</span> Dashboard</div>
<div class="side-item"><span class="ico">🔑</span> API Keys</div>
<div class="side-item"><span class="ico">📜</span> Logs</div>
<div class="side-item"><span class="ico">🔌</span> Accounts<span class="side-count">12</span></div>
</div>
<div class="side-group">
<div class="side-label">SETTINGS</div>
<div class="side-item"><span class="ico">👥</span> Team</div>
<div class="side-item"><span class="ico">💳</span> Billing</div>
<div class="side-item"><span class="ico">👤</span> Profile</div>
</div>
</aside>
<!-- main content -->
<div class="dash-main">
<div class="stat-row">
<div class="stat"><div class="stat-label">{{ $t('landing.dashboard.statToday') }}</div><div class="stat-value">1,842</div><div class="stat-delta">+12.3%</div></div>
<div class="stat"><div class="stat-label">{{ $t('landing.dashboard.statTokensIn') }}</div><div class="stat-value">2.1M</div><div class="stat-delta">+8.1%</div></div>
<div class="stat"><div class="stat-label">{{ $t('landing.dashboard.statTokensOut') }}</div><div class="stat-value">485K</div><div class="stat-delta">+15.6%</div></div>
<div class="stat"><div class="stat-label">{{ $t('landing.dashboard.statCost') }}</div><div class="stat-value">$1.23</div><div class="stat-delta down">-4.2%</div></div>
</div>
<div class="chart-grid">
<div class="chart-card">
<div class="chart-title">
{{ $t('landing.dashboard.chartTrend') }}
<div class="chart-legend">
<span><span class="sw" style="background: var(--cyan)"></span> Claude</span>
<span><span class="sw" style="background: #a855f7"></span> GPT</span>
<span><span class="sw" style="background: var(--amber)"></span> Gemini</span>
</div>
</div>
<svg viewBox="0 0 500 140" class="chart-svg">
<defs>
<linearGradient id="gc" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#22d3ee" stop-opacity="0.25"/>
<stop offset="100%" stop-color="#22d3ee" stop-opacity="0"/>
</linearGradient>
</defs>
<g stroke="#1e293b" stroke-width="1">
<line x1="0" y1="30" x2="500" y2="30"/>
<line x1="0" y1="70" x2="500" y2="70"/>
<line x1="0" y1="110" x2="500" y2="110"/>
</g>
<path d="M0,100 L40,85 L80,90 L120,65 L160,75 L200,50 L240,58 L280,38 L320,45 L360,25 L400,38 L440,28 L500,18 L500,140 L0,140 Z" fill="url(#gc)"/>
<path d="M0,100 L40,85 L80,90 L120,65 L160,75 L200,50 L240,58 L280,38 L320,45 L360,25 L400,38 L440,28 L500,18" stroke="#22d3ee" stroke-width="2" fill="none"/>
<path d="M0,115 L40,108 L80,100 L120,108 L160,92 L200,96 L240,75 L280,83 L320,65 L360,72 L400,56 L440,62 L500,46" stroke="#a855f7" stroke-width="2" fill="none"/>
</svg>
</div>
<div class="chart-card donut-card">
<div class="chart-title">Model distribution <span class="chart-sub">· 24h</span></div>
<div class="donut-wrap">
<svg viewBox="0 0 42 42" class="donut-svg">
<circle cx="21" cy="21" r="15.915" fill="transparent" stroke="#1e293b" stroke-width="6"/>
<circle cx="21" cy="21" r="15.915" fill="transparent" stroke="#22d3ee" stroke-width="6" stroke-dasharray="48 52" stroke-dashoffset="0"/>
<circle cx="21" cy="21" r="15.915" fill="transparent" stroke="#a855f7" stroke-width="6" stroke-dasharray="32 68" stroke-dashoffset="-48"/>
<circle cx="21" cy="21" r="15.915" fill="transparent" stroke="#fbbf24" stroke-width="6" stroke-dasharray="14 86" stroke-dashoffset="-80"/>
<circle cx="21" cy="21" r="15.915" fill="transparent" stroke="#64748b" stroke-width="6" stroke-dasharray="6 94" stroke-dashoffset="-94"/>
</svg>
<div class="donut-legend">
<div class="donut-row"><span><span class="sw" style="background:#22d3ee"></span>Claude</span><span class="pct">48%</span></div>
<div class="donut-row"><span><span class="sw" style="background:#a855f7"></span>GPT</span><span class="pct">32%</span></div>
<div class="donut-row"><span><span class="sw" style="background:#fbbf24"></span>Gemini</span><span class="pct">14%</span></div>
<div class="donut-row"><span><span class="sw" style="background:#64748b"></span>Codex</span><span class="pct">6%</span></div>
</div>
</div>
</div>
</div>
<table class="log-table mono">
<thead>
<tr><th>{{ $t('landing.dashboard.tableTime') }}</th><th>{{ $t('landing.dashboard.tableModel') }}</th><th>{{ $t('landing.dashboard.tableUpstream') }}</th><th>{{ $t('landing.dashboard.tableStatus') }}</th><th>{{ $t('landing.dashboard.tableUsage') }}</th></tr>
</thead>
<tbody>
<tr><td>12:34:07</td><td>gpt-5.4</td><td><span class="provider gpt"><span class="dot"></span>ChatGPT #1</span></td><td class="status-200">200</td><td>2,341</td></tr>
<tr><td>12:34:02</td><td>claude-opus-4-7</td><td><span class="provider claude"><span class="dot"></span>Claude #2</span></td><td class="status-200">200</td><td>5,102</td></tr>
<tr><td>12:33:58</td><td>gemini-2.5-pro</td><td><span class="provider gemini"><span class="dot"></span>Gemini #1</span></td><td class="status-200">200</td><td>843</td></tr>
<tr><td>12:33:41</td><td>gpt-5.4</td><td><span class="provider gpt"><span class="dot"></span>ChatGPT #2</span></td><td class="status-429">429</td><td></td></tr>
</tbody>
</table>
</div>
<div class="chart-card">
<div class="chart-title"> 30 天用量趋势</div>
<svg viewBox="0 0 600 120" class="chart-svg">
<polyline points="0,90 40,80 80,70 120,65 160,60 200,50 240,55 280,45 320,40 360,35 400,30 440,25 480,20 520,25 560,15 600,10"
fill="none" stroke="#22d3ee" stroke-width="2"/>
<polyline points="0,100 40,95 80,90 120,88 160,85 200,82 240,80 280,78 320,75 360,73 400,70 440,68 480,65 520,63 560,60 600,58"
fill="none" stroke="#a855f7" stroke-width="2" stroke-dasharray="4 4"/>
</svg>
</div>
<table class="log-table mono">
<thead>
<tr><th>时间</th><th>模型</th><th>上游</th><th>状态</th><th>用量</th></tr>
</thead>
<tbody>
<tr><td>12:34:07</td><td>gpt-5.4</td><td><span class="provider gpt"><span class="dot"></span>ChatGPT #1</span></td><td class="status-200">200</td><td>2,341</td></tr>
<tr><td>12:34:02</td><td>claude-opus-4-7</td><td><span class="provider claude"><span class="dot"></span>Claude #2</span></td><td class="status-200">200</td><td>5,102</td></tr>
<tr><td>12:33:58</td><td>gemini-2.5-pro</td><td><span class="provider gemini"><span class="dot"></span>Gemini #1</span></td><td class="status-200">200</td><td>843</td></tr>
<tr><td>12:33:41</td><td>gpt-5.4</td><td><span class="provider gpt"><span class="dot"></span>ChatGPT #2</span></td><td class="status-429">429</td><td></td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- Footer -->
<footer class="puro-footer">
<div class="container footer-grid">
<div class="footer-brand">
<div class="brand"><span class="hex"></span><span>PURO AI</span></div>
<p class="footer-tagline">Self-hosted on puro.im</p>
<p class="footer-meta">© 2026 puro.im · MIT License<br>fork of Wei-Shaw/sub2api</p>
</div>
<div class="footer-col">
<div class="footer-col-title">产品</div>
<a href="/docs">文档</a>
<a href="https://git.puro.im/purovps/sub2api/commits/branch/main" target="_blank" rel="noopener noreferrer">更新日志</a>
</div>
<div class="footer-col">
<div class="footer-col-title">资源</div>
<a href="https://git.puro.im/purovps/sub2api" target="_blank" rel="noopener noreferrer">GitHub</a>
<a href="/docs#codex">Codex 配置示例</a>
<a href="https://status.puro.im" target="_blank" rel="noopener noreferrer">API 状态</a>
</div>
<div class="footer-col">
<div class="footer-col-title">联系</div>
<a href="mailto:admin@puro.im">admin@puro.im</a>
<a href="https://git.puro.im" target="_blank" rel="noopener noreferrer">git.puro.im</a>
</div>
</div>
</footer>
</div>
</template>
<script setup lang="ts">
// LandingView — public marketing landing page for PURO AI
// Rendered at `/` when user is unauthenticated (see router/index.ts)
</script>
<style scoped>
@@ -240,7 +309,30 @@ requires_openai_auth = <span class="kw">true</span></code></pre>
position: relative;
z-index: 2;
}
.hero-eyebrow { margin-bottom: 24px; }
.hero-eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 6px;
border: 1px solid var(--border-2);
border-radius: 100px;
font-size: 12px;
color: var(--text-1);
margin-bottom: 32px;
background: rgba(15, 23, 42, 0.6);
}
.hero-eyebrow .badge {
display: inline-block;
padding: 2px 8px;
font-size: 10px;
font-weight: 700;
font-family: var(--font-mono);
color: var(--bg-0);
background: var(--cyan);
border-radius: 4px;
letter-spacing: 0.1em;
margin-right: 4px;
}
.hero-title {
font-size: clamp(36px, 5.5vw, 64px);
font-weight: 800;
@@ -255,6 +347,17 @@ requires_openai_auth = <span class="kw">true</span></code></pre>
max-width: 640px;
margin: 0 auto 36px;
}
.hero-sub .pill-inline {
display: inline-block;
padding: 1px 8px;
font-family: var(--font-mono);
font-size: 13px;
border: 1px solid var(--border-2);
border-radius: 4px;
background: rgba(15, 23, 42, 0.6);
color: var(--text-1);
margin: 0 2px;
}
.hero-cta {
display: flex;
gap: 12px;
@@ -286,7 +389,6 @@ requires_openai_auth = <span class="kw">true</span></code></pre>
font-size: 12px;
font-weight: 600;
color: var(--cyan);
text-transform: uppercase;
letter-spacing: 0.12em;
margin-bottom: 12px;
font-family: var(--font-mono);
@@ -299,6 +401,9 @@ requires_openai_auth = <span class="kw">true</span></code></pre>
}
.section-sub { color: var(--text-2); font-size: 15px; }
/* brand SVG */
.brand svg { color: var(--cyan); flex-shrink: 0; }
/* model wall */
.model-wall {
display: grid;
@@ -314,11 +419,39 @@ requires_openai_auth = <span class="kw">true</span></code></pre>
align-items: center;
gap: 12px;
}
.model-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.model-logo {
width: 36px;
height: 36px;
border-radius: var(--r-md);
border: 1px solid var(--border-2);
background: var(--bg-2);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--cyan);
}
.model-name { font-weight: 600; font-size: 14px; }
.model-meta { font-size: 11px; color: var(--text-3); font-family: var(--font-mono); margin-top: 2px; }
.model-card.is-muted { opacity: 0.5; }
.model-card.is-muted .model-name { color: var(--text-2); }
.model-card .status-chip {
margin-left: auto;
font-family: var(--font-mono);
font-size: 10px;
color: var(--green, #34d399);
display: flex;
align-items: center;
gap: 5px;
}
.model-card .status-chip .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--green, #34d399);
box-shadow: 0 0 6px rgba(52,211,153,0.6);
}
.model-card.is-muted .status-chip { display: none; }
/* features */
.features {
@@ -336,6 +469,27 @@ requires_openai_auth = <span class="kw">true</span></code></pre>
.feature h3 { font-size: 18px; font-weight: 700; margin-bottom: 10px; }
.feature p { color: var(--text-2); font-size: 14px; line-height: 1.6; }
.feature code { color: var(--cyan); font-size: 13px; }
.feature-bullets {
list-style: none;
padding: 0;
margin-top: 16px;
padding-top: 14px;
border-top: 1px dashed var(--border);
}
.feature-bullets li {
padding: 6px 0;
color: var(--text-2);
font-size: 12px;
font-family: var(--font-mono);
display: flex;
align-items: center;
gap: 8px;
}
.feature-bullets li::before {
content: '→';
color: var(--cyan);
font-weight: 600;
}
/* code demo */
.code-demo {
@@ -350,13 +504,40 @@ requires_openai_auth = <span class="kw">true</span></code></pre>
background: var(--bg-code);
overflow: hidden;
}
.code-title {
padding: 10px 16px;
.code-head {
display: flex;
align-items: center;
padding: 10px 14px;
gap: 12px;
background: var(--bg-1);
font-size: 11px;
color: var(--text-3);
border-bottom: 1px solid var(--border);
}
.traffic { display: flex; gap: 6px; }
.traffic span {
width: 10px; height: 10px; border-radius: 50%;
}
.traffic span:nth-child(1) { background: #f87171; }
.traffic span:nth-child(2) { background: #fbbf24; }
.traffic span:nth-child(3) { background: #34d399; }
.code-tabs .tab {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-2);
padding: 2px 10px;
border: 1px solid var(--border);
border-radius: 4px;
}
.code-tabs .tab.active {
color: var(--cyan);
background: rgba(34,211,238,0.1);
border-color: rgba(34,211,238,0.3);
}
.code-head .meta {
margin-left: auto;
font-size: 11px;
font-family: var(--font-mono);
color: var(--text-3);
}
.code-block pre {
padding: 16px;
font-size: 13px;
@@ -383,17 +564,72 @@ requires_openai_auth = <span class="kw">true</span></code></pre>
overflow: hidden;
box-shadow: 0 40px 80px -40px rgba(0,0,0,0.8);
}
.dash-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
/* browser chrome */
.dash-chrome {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: rgba(15, 23, 42, 0.7);
}
.dash-title { font-size: 12px; color: var(--text-2); font-family: var(--font-mono); }
.dash-dots { display: flex; gap: 6px; }
.dash-dots span { width: 10px; height: 10px; border-radius: 50%; background: var(--border-2); }
.dash-body { padding: 20px; }
.url-bar {
flex: 1;
padding: 5px 12px;
background: var(--bg-0);
border: 1px solid var(--border);
border-radius: 6px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-2);
}
.dash-user { font-size: 11px; color: var(--text-3); }
/* dashboard layout */
.dash-layout {
display: grid;
grid-template-columns: 180px 1fr;
min-height: 460px;
}
@media (max-width: 720px) {
.dash-layout { grid-template-columns: 1fr; }
.dash-side { display: none; }
}
.dash-side {
border-right: 1px solid var(--border);
padding: 16px 10px;
background: rgba(2, 6, 23, 0.4);
}
.side-group { margin-bottom: 20px; }
.side-label {
font-size: 10px;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.12em;
padding: 0 8px 6px;
font-family: var(--font-mono);
}
.side-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
font-size: 12px;
color: var(--text-2);
cursor: pointer;
}
.side-item.active { background: rgba(34, 211, 238, 0.08); color: var(--cyan); }
.side-item .ico { font-size: 12px; }
.side-count {
margin-left: auto;
font-size: 10px;
color: var(--text-3);
font-family: var(--font-mono);
}
.dash-main { padding: 20px; }
.stat-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
@media (max-width: 720px) { .stat-row { grid-template-columns: repeat(2, 1fr); } }
.stat {
@@ -407,15 +643,70 @@ requires_openai_auth = <span class="kw">true</span></code></pre>
.stat-delta { font-size: 11px; color: var(--green); margin-top: 4px; font-family: var(--font-mono); }
.stat-delta.down { color: var(--red); }
/* chart grid: 2fr line + 1fr donut */
.chart-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 14px;
margin-bottom: 20px;
}
@media (max-width: 720px) { .chart-grid { grid-template-columns: 1fr; } }
.chart-card {
border: 1px solid var(--border);
border-radius: var(--r-md);
background: rgba(15,23,42,0.6);
padding: 16px;
margin-bottom: 20px;
}
.chart-title { font-size: 12px; color: var(--text-2); margin-bottom: 12px; }
.chart-svg { width: 100%; height: 120px; display: block; }
.chart-title {
font-size: 12px;
font-weight: 600;
color: var(--text-2);
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: space-between;
}
.chart-sub { font-weight: 400; color: var(--text-3); }
.chart-legend {
display: flex;
gap: 10px;
font-size: 11px;
color: var(--text-3);
font-weight: 400;
}
.chart-legend span { display: inline-flex; align-items: center; gap: 4px; }
.sw { display: inline-block; width: 8px; height: 8px; border-radius: 2px; }
.chart-svg { width: 100%; height: 140px; display: block; }
/* donut chart */
.donut-card .chart-title { margin-bottom: 8px; }
.donut-wrap {
display: flex;
align-items: center;
gap: 16px;
}
.donut-svg {
width: 100px;
height: 100px;
transform: rotate(-90deg);
flex-shrink: 0;
}
.donut-legend {
flex: 1;
display: flex;
flex-direction: column;
gap: 7px;
font-size: 11px;
font-family: var(--font-mono);
}
.donut-row {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--text-1);
}
.donut-row span:first-child { display: flex; align-items: center; }
.donut-row .pct { color: var(--text-3); }
/* Nav sticky + responsive tweaks (augments puro.css defaults)
* Note: puro.css defines .puro-page .nav (z-index 50, blur 16px, gap 28px)
@@ -443,10 +734,6 @@ requires_openai_auth = <span class="kw">true</span></code></pre>
font-weight: 700;
font-size: 16px;
}
.brand .hex {
color: var(--cyan);
font-size: 20px;
}
.nav-links {
display: flex;
font-size: 14px;
@@ -475,12 +762,30 @@ requires_openai_auth = <span class="kw">true</span></code></pre>
}
@media (max-width: 720px) { .footer-grid { grid-template-columns: 1fr 1fr; } }
.footer-brand .brand { margin-bottom: 12px; }
.footer-tagline { color: var(--text-2); font-size: 13px; margin-bottom: 8px; }
.footer-meta { color: var(--text-3); font-size: 12px; line-height: 1.7; }
.footer-tagline { color: var(--text-2); font-size: 13px; line-height: 1.6; margin-bottom: 8px; max-width: 280px; }
.footer-meta { color: var(--text-3); font-size: 12px; line-height: 1.7; margin-bottom: 12px; }
.footer-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-3);
}
.dot-green {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--green, #34d399);
box-shadow: 0 0 6px rgba(52,211,153,0.6);
}
.footer-col-title {
color: var(--text-0);
font-size: 13px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 12px;
}
.footer-col a {
@@ -490,15 +795,4 @@ requires_openai_auth = <span class="kw">true</span></code></pre>
padding: 4px 0;
}
.footer-col a:hover { color: var(--cyan); }
/* pill */
.pill {
display: inline-block;
padding: 6px 14px;
border: 1px solid var(--border-2);
border-radius: 999px;
font-size: 12px;
color: var(--text-2);
background: rgba(15,23,42,0.6);
}
</style>

View File

@@ -0,0 +1,486 @@
<template>
<div>
<!-- HERO -->
<section class="hero">
<div class="section-kicker" style="margin-bottom:14px;">{{ $t('pricing.hero.kicker') }}</div>
<div class="preview-pill">{{ $t('pricing.hero.previewPill') }}</div>
<h1>{{ $t('pricing.hero.title1') }}<span class="accent">{{ $t('pricing.hero.titleAccent') }}</span>{{ $t('pricing.hero.title2') }}</h1>
<p class="sub">
<i18n-t keypath="pricing.hero.sub" tag="span">
<template #discount><b class="text-cyan">{{ $t('pricing.hero.subDiscount') }}</b></template>
</i18n-t>
</p>
<div class="underline">
<span class="dot"></span>
{{ $t('pricing.hero.underline') }}
</div>
</section>
<!-- PRICING GRID -->
<div class="pricing-wrap">
<div class="pricing-grid">
<!-- STARTER -->
<div class="tier">
<span class="flag muted">{{ $t('pricing.tiers.starter.flag') }}</span>
<div class="tier-name">{{ $t('pricing.tiers.starter.tierLabel') }}</div>
<div class="tier-headline">{{ $t('pricing.tiers.starter.headline') }}</div>
<div class="price-row"><span class="price"><span class="curr">$</span>{{ $t('pricing.tiers.starter.creditAmount') }}</span></div>
<div class="credit-line">
<i18n-t keypath="pricing.tiers.starter.credit" tag="span">
<template #creditAmount>{{ $t('pricing.tiers.starter.creditAmount') }}</template>
<template #creditBonus><b>{{ $t('pricing.tiers.starter.creditBonus') }}</b> <span class="bonus">+21%</span></template>
</i18n-t>
</div>
<span class="discount-tag">{{ $t('pricing.tiers.starter.discountTag') }}</span>
<hr/>
<div class="feats">
<div class="feat"><span class="tick"></span>{{ $t('pricing.tiers.starter.features.allModels') }}</div>
<div class="feat"><span class="tick"></span><b>1</b> {{ $t('pricing.tiers.starter.features.oneKey') }}</div>
<div class="feat"><span class="tick"></span>{{ $t('pricing.tiers.starter.features.rpm60') }}</div>
<div class="feat"><span class="tick"></span>{{ $t('pricing.tiers.starter.features.log7') }}</div>
<div class="feat muted"><span class="tick"></span>{{ $t('pricing.tiers.starter.features.noBYOS') }}</div>
<div class="feat muted"><span class="tick"></span>{{ $t('pricing.tiers.starter.features.noTeam') }}</div>
</div>
<router-link to="/register" class="btn btn-ghost btn-lg tier-cta">{{ $t('pricing.tiers.starter.cta') }}</router-link>
</div>
<!-- PRO -->
<div class="tier popular">
<span class="flag">{{ $t('pricing.tiers.pro.flag') }}</span>
<div class="tier-name">{{ $t('pricing.tiers.pro.tierLabel') }}</div>
<div class="tier-headline">{{ $t('pricing.tiers.pro.headline') }}</div>
<div class="price-row"><span class="price"><span class="curr">$</span>{{ $t('pricing.tiers.pro.creditAmount') }}</span></div>
<div class="credit-line">
<i18n-t keypath="pricing.tiers.pro.credit" tag="span">
<template #creditAmount>{{ $t('pricing.tiers.pro.creditAmount') }}</template>
<template #creditBonus><b>{{ $t('pricing.tiers.pro.creditBonus') }}</b> <span class="bonus">+50%</span></template>
</i18n-t>
</div>
<span class="discount-tag">{{ $t('pricing.tiers.pro.discountTag') }}</span>
<hr/>
<div class="feats">
<div class="feat"><span class="tick"></span>{{ $t('pricing.tiers.pro.features.allModels') }}</div>
<div class="feat"><span class="tick"></span><b>3</b> {{ $t('pricing.tiers.pro.features.threeKeys') }}</div>
<div class="feat"><span class="tick"></span>{{ $t('pricing.tiers.pro.features.rpm120') }}</div>
<div class="feat"><span class="tick"></span>{{ $t('pricing.tiers.pro.features.log30') }}</div>
<div class="feat"><span class="tick"></span>{{ $t('pricing.tiers.pro.features.byos') }}</div>
<div class="feat"><span class="tick"></span>{{ $t('pricing.tiers.pro.features.failover') }}</div>
</div>
<router-link to="/register" class="btn btn-primary btn-lg tier-cta">{{ $t('pricing.tiers.pro.cta') }}</router-link>
</div>
<!-- SCALE -->
<div class="tier">
<span class="flag amber">{{ $t('pricing.tiers.scale.flag') }}</span>
<div class="tier-name">{{ $t('pricing.tiers.scale.tierLabel') }}</div>
<div class="tier-headline">{{ $t('pricing.tiers.scale.headline') }}</div>
<div class="price-row"><span class="price"><span class="curr">$</span>{{ $t('pricing.tiers.scale.creditAmount') }}</span></div>
<div class="credit-line">
<i18n-t keypath="pricing.tiers.scale.credit" tag="span">
<template #creditAmount>{{ $t('pricing.tiers.scale.creditAmount') }}</template>
<template #creditBonus><b>{{ $t('pricing.tiers.scale.creditBonus') }}</b> <span class="bonus">+100%</span></template>
</i18n-t>
</div>
<span class="discount-tag">{{ $t('pricing.tiers.scale.discountTag') }}</span>
<hr/>
<div class="feats">
<div class="feat"><span class="tick"></span>{{ $t('pricing.tiers.scale.features.proAll') }}</div>
<div class="feat"><span class="tick"></span><b>10</b> {{ $t('pricing.tiers.scale.features.tenKeys') }}</div>
<div class="feat"><span class="tick"></span>{{ $t('pricing.tiers.scale.features.rpm300') }}</div>
<div class="feat"><span class="tick"></span>{{ $t('pricing.tiers.scale.features.log90') }}</div>
<div class="feat"><span class="tick"></span>{{ $t('pricing.tiers.scale.features.priority') }}<span class="soon-chip">{{ $t('pricing.soonChip') }}</span></div>
<div class="feat"><span class="tick"></span>{{ $t('pricing.tiers.scale.features.community') }}</div>
</div>
<router-link to="/register" class="btn btn-ghost btn-lg tier-cta">{{ $t('pricing.tiers.scale.cta') }}</router-link>
</div>
<!-- CUSTOM -->
<div class="tier">
<span class="flag muted">{{ $t('pricing.tiers.custom.flag') }}</span>
<div class="tier-name">{{ $t('pricing.tiers.custom.tierLabel') }}</div>
<div class="tier-headline">{{ $t('pricing.tiers.custom.headline') }}</div>
<div class="price-row"><span class="price"><span class="curr">$</span>{{ customAmt }}</span></div>
<div class="credit-line">{{ $t('pricing.tiers.custom.creditPrefix') }} <b>${{ customCredit }}</b> <span class="bonus">{{ $t('pricing.tiers.custom.bonusPrefix') }}{{ customBonus }}%</span></div>
<input type="range" min="10" max="500" value="50" step="10" v-model.number="customAmt" style="-webkit-appearance:none; width:100%; height:4px; background:var(--border); border-radius:2px; margin-bottom:12px;">
<span class="discount-tag">{{ $t('pricing.tiers.custom.discountTag') }}</span>
<hr/>
<div class="feats">
<div class="feat"><span class="tick"></span>{{ $t('pricing.tiers.custom.features.neverExpire') }}</div>
<div class="feat"><span class="tick"></span>{{ $t('pricing.tiers.custom.features.proAll') }}</div>
<div class="feat"><span class="tick"></span>{{ $t('pricing.tiers.custom.features.tiered') }}</div>
<div class="feat"><span class="tick"></span>{{ $t('pricing.tiers.custom.features.payment') }}</div>
<div class="feat muted"><span class="tick"></span>{{ $t('pricing.tiers.custom.features.preview') }}</div>
</div>
<router-link to="/register" class="btn btn-ghost btn-lg tier-cta">{{ $t('pricing.tiers.custom.cta') }}</router-link>
</div>
</div>
<!-- CUSTOM ROW -->
<div class="custom-row">
<div class="custom-card">
<div class="icon purple">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 20h20"/>
<path d="M4 20V10l8-6 8 6v10"/>
<path d="M9 20v-7h6v7"/>
</svg>
</div>
<div style="flex:1;">
<h3>{{ $t('pricing.custom.enterprise.title') }}</h3>
<p>{{ $t('pricing.custom.enterprise.desc') }}</p>
</div>
<a href="mailto:contact@puro.im" class="btn btn-ghost">{{ $t('pricing.custom.enterprise.cta') }}</a>
</div>
<div class="custom-card">
<div class="icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg>
</div>
<div style="flex:1;">
<h3>{{ $t('pricing.custom.binding.title') }}</h3>
<p>
<i18n-t keypath="pricing.custom.binding.desc" tag="span">
<template #price><code class="pill">{{ $t('pricing.custom.binding.price') }}</code></template>
</i18n-t>
</p>
</div>
<router-link to="/register" class="btn btn-ghost">{{ $t('pricing.custom.binding.cta') }}</router-link>
</div>
</div>
</div>
<!-- CALCULATOR -->
<section class="calc-section">
<div class="calc-preview-pill">{{ $t('pricing.calc.previewPill') }}</div>
<PricingCalculator />
</section>
<!-- WORKS EVERYWHERE -->
<section class="works">
<div class="section-head">
<div class="kicker">{{ $t('pricing.works.kicker') }}</div>
<h2>{{ $t('pricing.works.title') }}</h2>
<p>
<i18n-t keypath="pricing.works.sub" tag="span">
<template #baseUrl><code class="pill">{{ $t('pricing.works.baseUrl') }}</code></template>
</i18n-t>
</p>
</div>
<div class="tools-grid">
<div class="tool">
<div class="logo"><svg width="22" height="22" viewBox="0 0 24 24" fill="#d97757"><path d="M4.5 19L12 4l7.5 15H16l-4-8.5L8 19H4.5z"/></svg></div>
<div class="name">{{ $t('pricing.works.tools.claudeCode') }}</div>
<div class="tag">{{ $t('pricing.works.tags.claudeCode') }}</div>
</div>
<div class="tool">
<div class="logo"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 18L18 6M8 6h10v10"/></svg></div>
<div class="name">{{ $t('pricing.works.tools.cursor') }}</div>
<div class="tag">{{ $t('pricing.works.tags.cursor') }}</div>
</div>
<div class="tool">
<div class="logo"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></div>
<div class="name">{{ $t('pricing.works.tools.cline') }}</div>
<div class="tag">{{ $t('pricing.works.tags.cline') }}</div>
</div>
<div class="tool">
<div class="logo"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8"/><path d="m8 12 2 2 6-6"/></svg></div>
<div class="name">{{ $t('pricing.works.tools.rooCode') }}</div>
<div class="tag">{{ $t('pricing.works.tags.rooCode') }}</div>
</div>
<div class="tool">
<div class="logo"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 12h4l3-8 4 16 3-8h4"/></svg></div>
<div class="name">{{ $t('pricing.works.tools.continueTag') }}</div>
<div class="tag">{{ $t('pricing.works.tags.continueTag') }}</div>
</div>
<div class="tool">
<div class="logo"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M12 3v18M3 12h18"/></svg></div>
<div class="name">{{ $t('pricing.works.tools.openaiSdk') }}</div>
<div class="tag">{{ $t('pricing.works.tags.openaiSdk') }}</div>
</div>
<div class="tool">
<div class="logo"><svg width="22" height="22" viewBox="0 0 24 24" fill="#d97757"><path d="M4.5 19L12 4l7.5 15H16l-4-8.5L8 19H4.5z"/></svg></div>
<div class="name">{{ $t('pricing.works.tools.anthropicSdk') }}</div>
<div class="tag">{{ $t('pricing.works.tags.anthropicSdk') }}</div>
</div>
<div class="tool">
<div class="logo"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 10h18M8 5v14"/></svg></div>
<div class="name">{{ $t('pricing.works.tools.openWebui') }}</div>
<div class="tag">{{ $t('pricing.works.tags.openWebui') }}</div>
</div>
<div class="tool">
<div class="logo"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 2L2 7v10l10 5 10-5V7z"/><path d="M12 22V12M2 7l10 5 10-5"/></svg></div>
<div class="name">{{ $t('pricing.works.tools.langchain') }}</div>
<div class="tag">{{ $t('pricing.works.tags.langchain') }}</div>
</div>
<div class="tool">
<div class="logo"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M6 9v3l6 3M18 9v3l-6 3"/></svg></div>
<div class="name">{{ $t('pricing.works.tools.llamaIndex') }}</div>
<div class="tag">{{ $t('pricing.works.tags.llamaIndex') }}</div>
</div>
<div class="tool">
<div class="logo"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M9 9h6v6H9z"/></svg></div>
<div class="name">{{ $t('pricing.works.tools.zed') }}</div>
<div class="tag">{{ $t('pricing.works.tags.zed') }}</div>
</div>
<div class="tool">
<div class="logo"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z"/></svg></div>
<div class="name">{{ $t('pricing.works.tools.more') }}</div>
<div class="tag">{{ $t('pricing.works.tags.more') }}</div>
</div>
</div>
</section>
<!-- FAQ -->
<section class="faq-section">
<div class="section-head">
<div class="kicker">{{ $t('pricing.faq.kicker') }}</div>
<h2>{{ $t('pricing.faq.title') }}</h2>
<p>
<i18n-t keypath="pricing.faq.noAnswer" tag="span">
<template #contact><a href="mailto:contact@puro.im" style="color:var(--cyan)">{{ $t('pricing.faq.contact') }}</a></template>
</i18n-t>
</p>
</div>
<details class="faq" open>
<summary><span class="num">01</span>{{ $t('pricing.faq.q1') }}</summary>
<div class="answer">{{ $t('pricing.faq.a1') }}</div>
</details>
<details class="faq">
<summary><span class="num">02</span>{{ $t('pricing.faq.q2') }}</summary>
<div class="answer">
{{ $t('pricing.faq.a2') }}
</div>
</details>
<details class="faq">
<summary><span class="num">03</span>{{ $t('pricing.faq.q3') }}</summary>
<div class="answer">
<b>{{ $t('pricing.faq.a3Part1') }}</b>
{{ $t('pricing.faq.a3Part2') }} <a href="#">{{ $t('pricing.faq.a3Link') }}</a>{{ $t('pricing.faq.a3Part3') }}
</div>
</details>
<details class="faq">
<summary><span class="num">04</span>{{ $t('pricing.faq.q4') }}</summary>
<div class="answer">{{ $t('pricing.faq.a4') }}</div>
</details>
<details class="faq">
<summary><span class="num">05</span>{{ $t('pricing.faq.q5') }}</summary>
<div class="answer">
<ul>
<li><b>{{ $t('pricing.faq.a5StarterLabel') }}</b> {{ $t('pricing.faq.a5Starter') }}</li>
<li><b>{{ $t('pricing.faq.a5ProLabel') }}</b> {{ $t('pricing.faq.a5Pro') }}</li>
<li><b>{{ $t('pricing.faq.a5EnterpriseLabel') }}</b> {{ $t('pricing.faq.a5Enterprise') }}</li>
</ul>
</div>
</details>
<details class="faq">
<summary><span class="num">06</span>{{ $t('pricing.faq.q6') }}</summary>
<div class="answer">
{{ $t('pricing.faq.a6') }}
</div>
</details>
<details class="faq">
<summary><span class="num">07</span>{{ $t('pricing.faq.q7') }}</summary>
<div class="answer">{{ $t('pricing.faq.a7') }}</div>
</details>
<details class="faq">
<summary><span class="num">08</span>{{ $t('pricing.faq.q8') }}</summary>
<div class="answer">
<b>{{ $t('pricing.faq.a8Part1') }}</b> {{ $t('pricing.faq.a8Part2') }}
</div>
</details>
<details class="faq">
<summary><span class="num">09</span>{{ $t('pricing.faq.q9') }}</summary>
<div class="answer">
{{ $t('pricing.faq.a9') }} <a href="mailto:contact@puro.im">{{ $t('pricing.faq.a9Link') }}</a>
</div>
</details>
<details class="faq">
<summary><span class="num">10</span>{{ $t('pricing.faq.q10') }}</summary>
<div class="answer">
{{ $t('pricing.faq.a10') }} 24 {{ $t('pricing.faq.a10Part2') }} <a href="/docs">{{ $t('pricing.faq.a10Link') }}</a>.
</div>
</details>
</section>
<!-- FINAL CTA -->
<section class="final-cta">
<div class="final-cta-inner">
<div class="section-kicker" style="margin-bottom:12px;">{{ $t('pricing.finalCta.kicker') }}</div>
<h2>{{ $t('pricing.finalCta.title') }}</h2>
<p>{{ $t('pricing.finalCta.subtitle') }}</p>
<div style="display:inline-flex; gap:12px;">
<router-link to="/register" class="btn btn-primary btn-lg">{{ $t('pricing.finalCta.ctaPrimary') }}</router-link>
<router-link to="/docs" class="btn btn-ghost btn-lg">{{ $t('pricing.finalCta.ctaDocs') }}</router-link>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import PricingCalculator from '@/components/puro/PricingCalculator.vue'
const customAmt = ref(50)
const customBonus = computed(() => {
const v = customAmt.value
if (v >= 500) return 120
if (v >= 200) return 110
if (v >= 99) return 100
if (v >= 50) return 70
if (v >= 30) return 50
if (v >= 20) return 35
return 21
})
const customCredit = computed(() => Math.round(customAmt.value * (1 + customBonus.value / 100)))
</script>
<style scoped>
.hero { max-width: 1180px; margin: 0 auto; padding: 80px 32px 40px; text-align: center; }
.hero h1 { font-size: 54px; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 18px; }
.hero h1 .accent { color: var(--cyan); }
.hero .sub { color: var(--text-2); font-size: 17px; max-width: 620px; margin: 0 auto 14px; line-height: 1.6; }
.hero .underline { display: inline-flex; align-items: center; gap: 8px; font-family: var(--font-mono); font-size: 12px; color: var(--text-3); padding: 6px 14px; background: rgba(2, 6, 23, 0.5); border: 1px solid var(--border); border-radius: 100px; }
.hero .underline .dot { width: 6px; height: 6px; background: var(--green); border-radius: 50%; box-shadow: 0 0 0 3px rgba(52,211,153,0.15); }
.pricing-wrap { max-width: 1180px; margin: 0 auto; padding: 20px 32px 40px; }
.pricing-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
.tier { position: relative; border: 1px solid var(--border); border-radius: var(--r-xl); background: rgba(15, 23, 42, 0.5); padding: 28px 24px; display: flex; flex-direction: column; transition: all .2s; }
.tier:hover { border-color: var(--border-2); transform: translateY(-3px); }
.tier.popular { border-color: rgba(34, 211, 238, 0.4); background: radial-gradient(500px 300px at 50% 0%, rgba(34,211,238,0.08), transparent 60%), rgba(15, 23, 42, 0.7); box-shadow: 0 0 0 1px rgba(34,211,238,0.15), 0 20px 40px -20px rgba(34,211,238,0.2); transform: translateY(-6px); }
.tier.popular:hover { transform: translateY(-9px); }
.tier .flag { position: absolute; top: -11px; left: 50%; transform: translateX(-50%); font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.14em; padding: 4px 12px; border-radius: 100px; background: var(--cyan); color: #042f2e; font-weight: 700; white-space: nowrap; }
.tier .flag.amber { background: var(--amber); color: #422006; }
.tier .flag.muted { background: rgba(100, 116, 139, 0.2); color: var(--text-2); border: 1px solid var(--border); }
.tier-name { font-size: 13px; font-family: var(--font-mono); letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-3); margin-bottom: 8px; }
.tier-headline { font-size: 16px; font-weight: 600; color: var(--text-0); margin-bottom: 22px; line-height: 1.35; min-height: 44px; }
.price-row { display: flex; align-items: baseline; gap: 4px; margin-bottom: 4px; }
.price { font-family: var(--font-mono); font-size: 42px; font-weight: 800; letter-spacing: -0.03em; color: var(--text-0); }
.tier.popular .price { color: var(--cyan); }
.price .curr { font-size: 18px; font-weight: 600; color: var(--text-3); margin-right: 2px; vertical-align: super; }
.credit-line { font-family: var(--font-mono); font-size: 12px; color: var(--cyan); margin-bottom: 14px; }
.credit-line .arrow { margin: 0 6px; color: var(--text-3); }
.credit-line .bonus { padding: 2px 8px; background: rgba(34,211,238,0.08); border: 1px solid rgba(34,211,238,0.25); border-radius: 4px; font-weight: 600; margin-left: 6px; }
.discount-tag { display: inline-block; font-family: var(--font-mono); font-size: 11px; color: var(--amber); background: rgba(251,191,36,0.08); border: 1px solid rgba(251,191,36,0.25); border-radius: 4px; padding: 3px 8px; margin-bottom: 18px; }
.tier hr { border: none; border-top: 1px dashed var(--border); margin: 4px 0 18px; }
.feat { display: flex; gap: 10px; align-items: flex-start; font-size: 13px; color: var(--text-1); padding: 4px 0; line-height: 1.55; }
.feat .tick { color: var(--cyan); flex-shrink: 0; margin-top: 2px; }
.feat.muted { color: var(--text-3); }
.feat.muted .tick { color: var(--text-3); }
.feat b { color: var(--text-0); font-weight: 600; }
.feats { display: flex; flex-direction: column; gap: 2px; margin-bottom: 24px; flex: 1; }
.tier-cta { width: 100%; justify-content: center; }
.custom-row { margin-top: 12px; display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.custom-card { padding: 24px; border: 1px solid var(--border); border-radius: var(--r-xl); background: linear-gradient(135deg, rgba(168,85,247,0.05), transparent 50%), rgba(15, 23, 42, 0.4); display: flex; align-items: center; gap: 22px; }
.custom-card .icon { width: 48px; height: 48px; border-radius: 10px; background: rgba(34,211,238,0.1); color: var(--cyan); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.custom-card .icon.purple { background: rgba(168,85,247,0.1); color: var(--purple); }
.custom-card h3 { font-size: 16px; font-weight: 600; margin-bottom: 4px; }
.custom-card p { font-size: 13px; color: var(--text-2); line-height: 1.5; }
.custom-card .btn { margin-left: auto; flex-shrink: 0; }
.works { max-width: 1180px; margin: 0 auto; padding: 80px 32px 40px; }
.section-head { text-align: center; margin-bottom: 32px; }
.section-head .kicker { font-family: var(--font-mono); font-size: 12px; color: var(--cyan); letter-spacing: 0.14em; margin-bottom: 10px; }
.section-head h2 { font-size: 32px; font-weight: 700; letter-spacing: -0.02em; margin-bottom: 8px; }
.section-head p { color: var(--text-2); font-size: 15px; max-width: 560px; margin: 0 auto; line-height: 1.55; }
.tools-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; }
.tool { padding: 18px 14px; border: 1px solid var(--border); border-radius: var(--r-md); background: rgba(15, 23, 42, 0.4); text-align: center; transition: all .15s; }
.tool:hover { border-color: var(--border-2); background: rgba(15, 23, 42, 0.7); }
.tool .logo { width: 28px; height: 28px; margin: 0 auto 10px; display: flex; align-items: center; justify-content: center; color: var(--text-1); }
.tool .name { font-size: 12px; font-weight: 500; color: var(--text-1); }
.tool .tag { font-size: 10px; color: var(--text-3); margin-top: 2px; font-family: var(--font-mono); }
.calc-section { max-width: 1180px; margin: 0 auto; padding: 40px 32px; }
.faq-section { max-width: 880px; margin: 0 auto; padding: 60px 32px 100px; }
.faq { border: 1px solid var(--border); border-radius: var(--r-md); background: rgba(15, 23, 42, 0.4); margin-bottom: 8px; overflow: hidden; transition: all .15s; }
.faq:hover { border-color: var(--border-2); }
.faq summary { padding: 18px 22px; cursor: pointer; list-style: none; display: flex; align-items: center; gap: 14px; font-size: 15px; font-weight: 500; color: var(--text-0); position: relative; }
.faq summary::-webkit-details-marker { display: none; }
.faq summary::after { content: "+"; margin-left: auto; font-family: var(--font-mono); font-size: 18px; color: var(--text-3); transition: transform .2s; }
.faq[open] summary::after { content: ""; color: var(--cyan); }
.faq summary .num { font-family: var(--font-mono); font-size: 11px; color: var(--cyan); letter-spacing: 0.1em; min-width: 26px; }
.faq .answer { padding: 0 22px 20px 62px; color: var(--text-2); font-size: 14px; line-height: 1.7; }
.faq .answer code { font-family: var(--font-mono); background: rgba(2, 6, 23, 0.6); padding: 1px 6px; border-radius: 3px; color: var(--cyan); font-size: 12.5px; }
.faq .answer a { color: var(--cyan); }
.faq .answer ul { padding-left: 20px; margin-top: 8px; }
.final-cta { max-width: 1180px; margin: 40px auto 80px; padding: 0 32px; }
.final-cta-inner { padding: 48px; border: 1px solid var(--border); border-radius: var(--r-xl); background: radial-gradient(800px 400px at 50% 0%, rgba(34,211,238,0.08), transparent 60%), rgba(15, 23, 42, 0.6); text-align: center; }
.final-cta-inner h2 { font-size: 32px; font-weight: 800; letter-spacing: -0.02em; margin-bottom: 10px; }
.final-cta-inner p { color: var(--text-2); font-size: 15px; margin-bottom: 26px; }
.preview-pill {
display: inline-block;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
color: var(--amber, #fbbf24);
background: rgba(251, 191, 36, 0.08);
border: 1px solid rgba(251, 191, 36, 0.25);
border-radius: 100px;
padding: 4px 12px;
margin-bottom: 14px;
}
.calc-preview-pill {
display: inline-block;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
color: var(--amber, #fbbf24);
background: rgba(251, 191, 36, 0.08);
border: 1px solid rgba(251, 191, 36, 0.25);
border-radius: 100px;
padding: 4px 12px;
margin-bottom: 14px;
}
.soon-chip {
display: inline-block;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.1em;
color: var(--amber, #fbbf24);
background: rgba(251, 191, 36, 0.1);
border: 1px solid rgba(251, 191, 36, 0.3);
border-radius: 3px;
padding: 1px 5px;
margin-left: 6px;
vertical-align: middle;
}
/* pill inline code */
.pill { font-family: var(--font-mono); background: rgba(2, 6, 23, 0.6); padding: 1px 6px; border-radius: 3px; color: var(--cyan); font-size: 12.5px; }
/* nav brand */
.brand { display: flex; align-items: center; gap: 8px; text-decoration: none; color: var(--text-0); font-weight: 700; font-size: 15px; }
.hex { width: 24px; height: 24px; color: var(--cyan); }
.nav-links .active { color: var(--cyan); }
@media (max-width: 960px) {
.pricing-grid { grid-template-columns: 1fr 1fr; }
.custom-row { grid-template-columns: 1fr; }
.tools-grid { grid-template-columns: repeat(3, 1fr); }
}
</style>