Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38d18adefd | ||
|
|
ee2f54602e | ||
|
|
808b9dd8ad | ||
|
|
c3fd4a5c0f | ||
|
|
92ffcb81e4 | ||
|
|
8be8788382 | ||
|
|
2b6b5fc6be | ||
|
|
5c4b29804e | ||
|
|
623a7518b2 | ||
|
|
291e3bfe43 | ||
|
|
e7f3fe5b4d | ||
|
|
779005e1cd | ||
|
|
77bb69b2c5 | ||
|
|
b989c50317 | ||
|
|
13bdd8f892 | ||
|
|
73b3980711 | ||
|
|
fc7e27671d | ||
|
|
6328881801 | ||
|
|
e711a20373 | ||
|
|
49ee2cba8a | ||
|
|
e843a7aef8 | ||
|
|
4cf6840479 | ||
|
|
9f78b70a87 | ||
|
|
046765632d | ||
|
|
93481b8c45 | ||
|
|
6291dc40d0 | ||
| 355370ad2a | |||
|
|
829f101100 | ||
|
|
0fceb100e0 | ||
|
|
7dc8062988 | ||
|
|
f17a88c171 | ||
|
|
91b9ae7e21 | ||
|
|
9ee99d17fd | ||
|
|
284b5129ac | ||
|
|
4832534232 | ||
|
|
4e675d70c1 | ||
|
|
c099cd5d97 | ||
|
|
158f2a8d53 | ||
|
|
cfcdd988db | ||
|
|
9dae8724e3 | ||
|
|
064a4b7614 | ||
|
|
41664efede | ||
|
|
1d7e75b82e | ||
|
|
d941550bf6 | ||
|
|
3a16b3ecde | ||
|
|
02173c8d7e | ||
|
|
332d46cde7 | ||
|
|
ac3417a964 | ||
|
|
9c34e9619c | ||
|
|
f431b2e2ff |
5
.ci/Dockerfile
Normal file
5
.ci/Dockerfile
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
|
WORKDIR /app
|
||||||
|
COPY sub2api-linux /app/sub2api
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/app/sub2api"]
|
||||||
13
.ci/README.md
Normal file
13
.ci/README.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# ai.puro.im CI artifacts
|
||||||
|
|
||||||
|
Drone CI (see `.drone.yml`) builds a statically-linked `sub2api-linux` binary and bakes it into this distroless image.
|
||||||
|
|
||||||
|
Host-side state (NOT in repo):
|
||||||
|
- `/opt/sub2api/docker-compose.yml` — sub2api + sub2api-pg + sub2api-redis services + PG password
|
||||||
|
- `/opt/sub2api/app-data/config.yaml` — wizard-generated runtime config
|
||||||
|
- `/opt/sub2api/{pg-data,redis-data,app-data,logs}` — persistent volumes
|
||||||
|
|
||||||
|
Deploy flow:
|
||||||
|
1. Drone builds frontend (pnpm) + backend (go, linux/amd64)
|
||||||
|
2. CI copies `backend/sub2api-linux` + `.ci/Dockerfile` to `/opt/sub2api/`
|
||||||
|
3. CI runs `docker compose up -d --build sub2api` — rebuilds only sub2api service, leaves PG/Redis untouched
|
||||||
80
.drone.yml
Normal file
80
.drone.yml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: default
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
branch: [main]
|
||||||
|
event: [push]
|
||||||
|
# 仅当 commit 修改的文件全部在以下路径下时跳过构建
|
||||||
|
# (只要有任何一个文件在排除路径之外,构建照常触发)
|
||||||
|
paths:
|
||||||
|
exclude:
|
||||||
|
- tools/**
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: build-frontend
|
||||||
|
image: node:18-alpine
|
||||||
|
commands:
|
||||||
|
- corepack enable
|
||||||
|
- corepack prepare pnpm@10.33.0 --activate
|
||||||
|
- cd frontend
|
||||||
|
- pnpm install --frozen-lockfile
|
||||||
|
- pnpm run build
|
||||||
|
volumes:
|
||||||
|
- name: pnpm-store
|
||||||
|
path: /root/.local/share/pnpm/store
|
||||||
|
|
||||||
|
- name: build-backend
|
||||||
|
image: golang:1.23-alpine
|
||||||
|
environment:
|
||||||
|
CGO_ENABLED: "0"
|
||||||
|
GOOS: linux
|
||||||
|
GOARCH: amd64
|
||||||
|
GOTOOLCHAIN: auto
|
||||||
|
GOFLAGS: "-buildvcs=false"
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache git
|
||||||
|
- cd backend
|
||||||
|
- go build -tags embed -ldflags='-s -w' -o sub2api-linux ./cmd/server
|
||||||
|
volumes:
|
||||||
|
- name: go-cache
|
||||||
|
path: /root/.cache/go-build
|
||||||
|
- name: go-mod
|
||||||
|
path: /go/pkg/mod
|
||||||
|
depends_on:
|
||||||
|
- build-frontend
|
||||||
|
|
||||||
|
- name: deploy
|
||||||
|
image: docker:cli
|
||||||
|
commands:
|
||||||
|
- cp backend/sub2api-linux /opt/sub2api/sub2api-linux
|
||||||
|
- cp .ci/Dockerfile /opt/sub2api/Dockerfile
|
||||||
|
- cd /opt/sub2api && docker compose up -d --build sub2api
|
||||||
|
- sleep 8
|
||||||
|
- docker ps --filter 'name=^sub2api$' --filter 'status=running' --format '{{.Names}}' | grep -qx sub2api
|
||||||
|
- docker inspect sub2api --format='{{.State.Health.Status}} {{.State.Status}}' 2>/dev/null || true
|
||||||
|
- echo "deploy ok"
|
||||||
|
volumes:
|
||||||
|
- name: docker-sock
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
- name: opt-sub2api
|
||||||
|
path: /opt/sub2api
|
||||||
|
depends_on:
|
||||||
|
- build-backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- name: pnpm-store
|
||||||
|
host:
|
||||||
|
path: /opt/drone/cache/pnpm-store
|
||||||
|
- name: go-cache
|
||||||
|
host:
|
||||||
|
path: /opt/drone/cache/go-build
|
||||||
|
- name: go-mod
|
||||||
|
host:
|
||||||
|
path: /opt/drone/cache/go-mod
|
||||||
|
- name: docker-sock
|
||||||
|
host:
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
- name: opt-sub2api
|
||||||
|
host:
|
||||||
|
path: /opt/sub2api
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -130,7 +130,10 @@ vite.config.js
|
|||||||
docs/*
|
docs/*
|
||||||
!docs/PAYMENT.md
|
!docs/PAYMENT.md
|
||||||
!docs/PAYMENT_CN.md
|
!docs/PAYMENT_CN.md
|
||||||
|
!docs/superpowers/
|
||||||
|
!docs/design-drafts/
|
||||||
!docs/ADMIN_PAYMENT_INTEGRATION_API.md
|
!docs/ADMIN_PAYMENT_INTEGRATION_API.md
|
||||||
|
.superpowers/
|
||||||
.serena/
|
.serena/
|
||||||
.codex/
|
.codex/
|
||||||
frontend/coverage/
|
frontend/coverage/
|
||||||
|
|||||||
168
LOCAL_SETUP_NOTES.md
Normal file
168
LOCAL_SETUP_NOTES.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# Sub2API 本地开发环境搭建记录
|
||||||
|
|
||||||
|
> 2026-04-19 @ macOS (darwin/arm64)
|
||||||
|
|
||||||
|
## 一、环境依赖
|
||||||
|
|
||||||
|
| 项 | 版本 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| Go | 1.24.3(本机)→ 1.26.2(auto 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 OK,HTML 含 <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"` 是 libpq(C 驱动)的模式,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 Gateway(Setup 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
|
||||||
|
```
|
||||||
541
VPS_DEPLOYMENT_NOTES.md
Normal file
541
VPS_DEPLOYMENT_NOTES.md
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
# Sub2API VPS 部署 + CI/CD 记录
|
||||||
|
|
||||||
|
> 2026-04-19 @ `ai.puro.im` · 217.216.32.230 · Ubuntu 22.04 x86_64
|
||||||
|
|
||||||
|
本文档对应 `LOCAL_SETUP_NOTES.md`(Mac 本地开发环境)的线上版本,覆盖:从零到站点可访问、接入 Codex CLI、建立 Drone CI 自动部署的完整流程。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概览
|
||||||
|
|
||||||
|
```
|
||||||
|
外网
|
||||||
|
│ HTTPS (Let's Encrypt, Caddy auto-TLS)
|
||||||
|
▼
|
||||||
|
ai.puro.im :443
|
||||||
|
│
|
||||||
|
┌──────┴──────┐
|
||||||
|
│ Caddy │ host, /etc/caddy/conf.d/sub2api.conf
|
||||||
|
│ reverse_ │ → 127.0.0.1:8081
|
||||||
|
│ proxy │
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
docker port map 127.0.0.1:8081 → 容器 8080
|
||||||
|
│
|
||||||
|
┌────────▼──────────────┐
|
||||||
|
│ sub2api-net (compose) │
|
||||||
|
│ sub2api (Go) │ /opt/sub2api/ (持久化)
|
||||||
|
│ sub2api-pg (PG15) │ ├─ app-data/ (config, .installed, pricing, logs)
|
||||||
|
│ sub2api-redis │ ├─ pg-data/
|
||||||
|
│ (redis:7-alpine) │ └─ redis-data/
|
||||||
|
└───────────────────────┘
|
||||||
|
│ VPS 出口
|
||||||
|
▼
|
||||||
|
api.anthropic.com / chatgpt.com / api.openai.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### CI 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
Mac (本地仓库) ─ git push gitea main ─▶ Gitea (git.puro.im) ──webhook──▶ Drone (devops.puro.im)
|
||||||
|
│
|
||||||
|
┌───────────┴────────────┐
|
||||||
|
│ build-frontend (pnpm) │
|
||||||
|
│ build-backend (go) │
|
||||||
|
│ deploy │
|
||||||
|
│ - cp bin + Dockerfile│
|
||||||
|
│ - docker compose up │
|
||||||
|
│ --build sub2api │
|
||||||
|
└────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、VPS 环境
|
||||||
|
|
||||||
|
| 项 | 值 | 备注 |
|
||||||
|
|---|---|---|
|
||||||
|
| 主机 | 217.216.32.230(新加坡) | `ssh vps` |
|
||||||
|
| OS | Ubuntu 22.04 LTS x86_64 | |
|
||||||
|
| Caddy | host 安装,`/etc/caddy/conf.d/*.conf` auto-include | 已跑着 puro.im / git.puro.im / devops.puro.im / erp.puro.im / ... |
|
||||||
|
| Docker | host 装 + `devops-net` 网络 | 各应用容器都在 devops-net 里 |
|
||||||
|
| Gitea | `git.puro.im:2222` (SSH), 3000 (HTTP) | purovps/sub2api 仓库 |
|
||||||
|
| Drone | `devops.puro.im`(OAuth via Gitea) | drone-server + drone-runner-docker |
|
||||||
|
| Sub2API 专用 docker-compose | `sub2api-net` 独立 bridge 网络(**不加 devops-net**) | 隔离 PG/Redis 生命周期 |
|
||||||
|
| DNS | Cloudflare **DNS only**(非代理模式) | `ai.puro.im` A 记录 → VPS IP |
|
||||||
|
| 端口冲突 | 宿主 `:8080` 被 drone-server 占 | Sub2API 映射到宿主 `127.0.0.1:8081` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、初次部署流程(手工路径,首次跑通)
|
||||||
|
|
||||||
|
> CI 跑通后这段主要作为"首次引导 / 灾难恢复参考"。日常改代码走 CI(下一节)。
|
||||||
|
|
||||||
|
### 1. 本机交叉编译 linux/amd64 二进制
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/mini/Work/dev/sub2api/backend
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||||
|
go build -tags embed -ldflags='-s -w' -o sub2api-linux ./cmd/server
|
||||||
|
# 产物 ~75MB, static ELF x86-64
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:本机 macOS arm64,VPS 是 x86_64,**必须交叉编译**。`-tags embed` 把前端 dist 打进二进制(配合先跑 `pnpm run build`)。
|
||||||
|
|
||||||
|
### 2. VPS 建目录 + scp 文件
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh vps "mkdir -p /opt/sub2api/{app-data,pg-data,redis-data}"
|
||||||
|
|
||||||
|
# 准备 deploy 文件(本地 staging 目录)
|
||||||
|
cp backend/sub2api-linux /Users/mini/Work/dev/sub2api-deploy/
|
||||||
|
# Dockerfile + docker-compose.yml 见下节内容
|
||||||
|
|
||||||
|
scp /Users/mini/Work/dev/sub2api-deploy/{sub2api-linux,Dockerfile,docker-compose.yml} \
|
||||||
|
vps:/opt/sub2api/
|
||||||
|
|
||||||
|
# distroless:nonroot 需要 UID 65532 可写
|
||||||
|
ssh vps "chown -R 65532:65532 /opt/sub2api/app-data"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Dockerfile(`.ci/Dockerfile` 也是同一份)
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
|
WORKDIR /app
|
||||||
|
COPY sub2api-linux /app/sub2api
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/app/sub2api"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. docker-compose.yml
|
||||||
|
|
||||||
|
关键点:
|
||||||
|
- `sub2api` 监听 `127.0.0.1:8081:8080`(避开宿主 8080 被 drone 占)
|
||||||
|
- `/app/data` 整个挂到 `./app-data`(含 config.yaml / install.lock / pricing / logs)
|
||||||
|
- PG 密码通过 `POSTGRES_PASSWORD` env(不进仓库)
|
||||||
|
- 独立 `sub2api-net` bridge,不混 devops-net
|
||||||
|
|
||||||
|
生产文件位置:`/opt/sub2api/docker-compose.yml`(**不签入 git**,含 PG 密码)。模板结构:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15
|
||||||
|
container_name: sub2api-pg
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: sub2api
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: "<32-hex 强口令>"
|
||||||
|
volumes: [./pg-data:/var/lib/postgresql/data]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d sub2api"]
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [sub2api-net]
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: sub2api-redis
|
||||||
|
command: ["redis-server", "--appendonly", "yes"]
|
||||||
|
volumes: [./redis-data:/data]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [sub2api-net]
|
||||||
|
|
||||||
|
sub2api:
|
||||||
|
build: .
|
||||||
|
image: sub2api:local
|
||||||
|
container_name: sub2api
|
||||||
|
depends_on:
|
||||||
|
postgres: { condition: service_healthy }
|
||||||
|
redis: { condition: service_healthy }
|
||||||
|
volumes:
|
||||||
|
- ./app-data:/app/data
|
||||||
|
ports: ["127.0.0.1:8081:8080"]
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [sub2api-net]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
sub2api-net:
|
||||||
|
driver: bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 起 stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh vps "cd /opt/sub2api && docker compose up -d --build"
|
||||||
|
# 等 PG/Redis healthy 后 sub2api 才会起(depends_on + healthcheck 保证)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Caddy 反代
|
||||||
|
|
||||||
|
`/etc/caddy/conf.d/sub2api.conf`:
|
||||||
|
|
||||||
|
```caddy
|
||||||
|
ai.puro.im {
|
||||||
|
reverse_proxy 127.0.0.1:8081 {
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
}
|
||||||
|
encode gzip zstd
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/ai.puro.im.log
|
||||||
|
format json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh vps "systemctl reload caddy"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. 首次访问 → Setup Wizard
|
||||||
|
|
||||||
|
**不预先写 `/opt/sub2api/app-data/config.yaml`**——让 sub2api 以 wizard 模式启动,自己生成 config。走:
|
||||||
|
|
||||||
|
1. 浏览器开 `https://ai.puro.im` → 自动进 Setup Wizard
|
||||||
|
2. Step 1 数据库:host=`postgres` / port=`5432` / user=`postgres` / password=<compose 里那个> / dbname=`sub2api` / sslmode=`disable`
|
||||||
|
3. Step 2 Redis:host=`redis` / port=`6379` / password 空 / db=`0`
|
||||||
|
4. Step 3 Admin:邮箱 + ≥8 位密码(自己选好记下来)
|
||||||
|
5. Step 4 提交 → wizard 写 `app-data/config.yaml` + `app-data/.installed` + DB 创建 admin → 进程自杀、compose 重启后以正常模式跑
|
||||||
|
|
||||||
|
### 8. 加 OpenAI OAuth 账号(账号池)
|
||||||
|
|
||||||
|
从本机 `~/.codex/auth.json` 抽 `tokens.refresh_token`,粘到:
|
||||||
|
- 后台 → 账号管理 → 添加账号 → Platform: OpenAI / Type: OAuth → "粘贴 Refresh Token" → 验证
|
||||||
|
- 弹出 ChatGPT 邮箱+Plan 即成功
|
||||||
|
|
||||||
|
### 9. 三件必调(否则 503 "no available OpenAI accounts")
|
||||||
|
|
||||||
|
见 [§四 · 坑 3](#坑-3-新建-oauth-账号一上来无法调度),后台 UI 里:
|
||||||
|
- **开启调度开关**
|
||||||
|
- **去掉 auto_pause_on_expired**
|
||||||
|
- **清掉 expires_at**(或设远期)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、CI/CD 流水线(Drone)
|
||||||
|
|
||||||
|
### 仓库
|
||||||
|
|
||||||
|
| 位置 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| GitHub: `Wei-Shaw/sub2api` | 上游,origin remote |
|
||||||
|
| Gitea: `git.puro.im/purovps/sub2api` | 我们的 fork,**Drone trigger source**,remote 名 `gitea` |
|
||||||
|
|
||||||
|
本地开发流程:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 日常
|
||||||
|
git checkout main
|
||||||
|
# 改代码...
|
||||||
|
git commit
|
||||||
|
git push gitea main # 触发 CI
|
||||||
|
# 可选同步上游
|
||||||
|
git fetch origin
|
||||||
|
git merge origin/main # 手动决定是否合上游
|
||||||
|
```
|
||||||
|
|
||||||
|
### Drone 激活
|
||||||
|
|
||||||
|
首次激活:https://devops.puro.im → Sync → 点亮 `purovps/sub2api` → Settings → 开 **Trusted**(pipeline 要挂 host socket 和 `/opt/sub2api`)。
|
||||||
|
|
||||||
|
激活后 Gitea 会自动配 webhook(`POST https://devops.puro.im/hook`,事件 `push, pull_request, ...`)。
|
||||||
|
|
||||||
|
### `.drone.yml` 三段 pipeline
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: default
|
||||||
|
trigger: { branch: [main], event: [push] }
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: build-frontend # node:18-alpine + pnpm 10.33
|
||||||
|
# pnpm install --frozen-lockfile && pnpm run build
|
||||||
|
# 产物 → backend/internal/web/dist/
|
||||||
|
|
||||||
|
- name: build-backend # golang:1.23-alpine + GOTOOLCHAIN=auto
|
||||||
|
# 自动拉 Go 1.26.2 toolchain
|
||||||
|
# CGO_ENABLED=0 GOOS=linux GOARCH=amd64 → sub2api-linux
|
||||||
|
|
||||||
|
- name: deploy # docker:cli
|
||||||
|
# cp backend/sub2api-linux /opt/sub2api/sub2api-linux
|
||||||
|
# cp .ci/Dockerfile /opt/sub2api/Dockerfile
|
||||||
|
# cd /opt/sub2api && docker compose up -d --build sub2api
|
||||||
|
# 验证 sub2api 容器 running
|
||||||
|
```
|
||||||
|
|
||||||
|
host 挂载 3 类卷:
|
||||||
|
- `/var/run/docker.sock` — 用宿主 docker daemon 做构建+重启
|
||||||
|
- `/opt/sub2api` — 更新 binary/Dockerfile
|
||||||
|
- `/opt/drone/cache/{pnpm-store,go-build,go-mod}` — 加速后续构建
|
||||||
|
|
||||||
|
### 首次构建时长
|
||||||
|
|
||||||
|
| Step | 首次 | 缓存命中后 |
|
||||||
|
|---|---|---|
|
||||||
|
| build-frontend | ~90s | ~30s |
|
||||||
|
| build-backend | ~60s(+首次下 Go 1.26.2 toolchain) | ~40s |
|
||||||
|
| deploy | ~20s | ~20s |
|
||||||
|
| **合计** | **~3 min** | **~90s** |
|
||||||
|
|
||||||
|
### CI 不碰的内容(secrets / 数据)
|
||||||
|
|
||||||
|
| 文件/目录 | 位置 | 为什么 |
|
||||||
|
|---|---|---|
|
||||||
|
| `docker-compose.yml` | `/opt/sub2api/` | 含 PG 密码 |
|
||||||
|
| `app-data/config.yaml` | `/opt/sub2api/app-data/` | JWT secret / 运行态 |
|
||||||
|
| `pg-data/` | `/opt/sub2api/` | PG 数据 |
|
||||||
|
| `redis-data/` | `/opt/sub2api/` | Redis 数据 |
|
||||||
|
|
||||||
|
CI 只:
|
||||||
|
- 覆写 `/opt/sub2api/sub2api-linux`(二进制)
|
||||||
|
- 覆写 `/opt/sub2api/Dockerfile`
|
||||||
|
- `docker compose up -d --build sub2api` — 只重建 `sub2api` service(PG/Redis 不动)
|
||||||
|
|
||||||
|
### Skip CI
|
||||||
|
|
||||||
|
文档/无代码变动想跳过构建:commit 消息加 `[CI SKIP]`(Drone 官方约定)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、踩坑与解法
|
||||||
|
|
||||||
|
### 坑 1:Setup Wizard 默认写 `server.port: 443`
|
||||||
|
|
||||||
|
**现象**:Wizard 跑完 → 容器重启 → `127.0.0.1:8081` 无响应(app 启动了但监听在 container 内的 :443)。
|
||||||
|
|
||||||
|
**根因**:Wizard 默认把 `server.port` 写成 443(面向"容器直接暴露 HTTPS"的用法,不适合我们"容器内 8080 + Caddy 外 443"的模式)。
|
||||||
|
|
||||||
|
**解法**:
|
||||||
|
```bash
|
||||||
|
ssh vps "sed -i 's/port: 443/port: 8080/' /opt/sub2api/app-data/config.yaml && docker restart sub2api"
|
||||||
|
```
|
||||||
|
|
||||||
|
后续 CI 不重写 config.yaml,这个修复是一次性的。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 2:distroless:nonroot 日志目录权限
|
||||||
|
|
||||||
|
**现象**:容器启动日志刷 `write error: can't open new logfile: open /app/data/logs/sub2api.log: permission denied`。
|
||||||
|
|
||||||
|
**根因**:`gcr.io/distroless/static-debian12:nonroot` 进程 UID=65532,但 `/opt/sub2api/app-data/logs` 宿主目录 owner=root。
|
||||||
|
|
||||||
|
**解法**:
|
||||||
|
```bash
|
||||||
|
ssh vps "chown -R 65532:65532 /opt/sub2api/app-data"
|
||||||
|
```
|
||||||
|
|
||||||
|
一次性。CI 不碰 app-data,所以不复发。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 3:新建 OAuth 账号一上来"无法调度"
|
||||||
|
|
||||||
|
**现象**:refresh_token 粘好、验证通过、UI 显示"正常",curl `/responses` 报 `503 "Service temporarily unavailable"`,日志 `openai.account_select_failed: no available OpenAI accounts`。
|
||||||
|
|
||||||
|
**根因(三个叠加)**:
|
||||||
|
1. UI 默认 `schedulable=false`
|
||||||
|
2. Wizard/后端给 `expires_at` 写了 **access_token 的短期过期**(~7 分钟后),而 `auto_pause_on_expired=true` 默认开
|
||||||
|
3. 每次 sub2api 重启 → `[AccountExpiry] Auto paused 1 expired accounts` → schedulable 被打回 false
|
||||||
|
|
||||||
|
**解法**(SQL 或 UI 都可):
|
||||||
|
```sql
|
||||||
|
UPDATE accounts
|
||||||
|
SET schedulable=true, expires_at=NULL, auto_pause_on_expired=false
|
||||||
|
WHERE id=<n>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**已存 memory**:`feedback_sub2api_account_pitfalls.md`。长期方案:修 fork 里的账号创建逻辑,OAuth 账号的 `expires_at` 应指 refresh_token 过期或订阅到期,不是 access_token。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 4:`run_mode: simple` 隐藏 SaaS 菜单
|
||||||
|
|
||||||
|
**现象**:为跳过 `INSUFFICIENT_BALANCE` 临时切到 `run_mode: simple`,重登后台发现**用户管理 / 分组管理 / 渠道管理 / 订阅管理 / 兑换码 / 优惠码**全消失。
|
||||||
|
|
||||||
|
**根因**:**设计行为**,不是 bug。`simple` 模式是给 "单用户/团队内部工具" 用,刻意隐藏 SaaS 管理面板;前端 `stores/auth.ts` 里 `isSimpleMode = computed(() => runMode === 'simple')` 控制路由可见性。
|
||||||
|
|
||||||
|
**解法**:
|
||||||
|
- 如果需要管理 group/订阅/计费/兑换码 → 保持 `run_mode: standard`
|
||||||
|
- `INSUFFICIENT_BALANCE` 的替代方案:给 admin 塞大额 balance 即可(见坑 5)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 5:`standard` 模式 + admin balance=0 → 403
|
||||||
|
|
||||||
|
**现象**:切回 `standard` 后 curl `/responses` 报 `INSUFFICIENT_BALANCE`。
|
||||||
|
|
||||||
|
**根因**:中间件 `api_key_auth.go:198` 检查 `apiKey.User.Balance <= 0` → 403。admin 默认余额为 0。
|
||||||
|
|
||||||
|
**解法**:
|
||||||
|
```sql
|
||||||
|
UPDATE users SET balance = 1000000000 WHERE id=1;
|
||||||
|
```
|
||||||
|
|
||||||
|
10 亿够跑很久。接 iShare 后改用 iShare 订阅模式(sub2api 标记为 "subscription mode",走订阅限额而不查 balance)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 6:Redis L2 cache stale
|
||||||
|
|
||||||
|
**现象**:跑 [坑 5] 的 SQL 之后、docker restart sub2api 之后,curl 仍报 `INSUFFICIENT_BALANCE`。
|
||||||
|
|
||||||
|
**根因**:Sub2API 的 API key 鉴权有两级缓存:
|
||||||
|
- L1 进程内 LRU(TTL 15s)
|
||||||
|
- L2 Redis(key `sub2api:apikey:*`,**TTL 300s**)
|
||||||
|
|
||||||
|
docker restart 只清 L1,L2 是独立 Redis 容器,保留着旧的 `balance=0` 缓存条目。
|
||||||
|
|
||||||
|
**解法**:
|
||||||
|
```bash
|
||||||
|
ssh vps "docker exec sub2api-redis redis-cli FLUSHDB && docker restart sub2api"
|
||||||
|
```
|
||||||
|
|
||||||
|
**一般经验**:改 DB 里的 user / api_key / account 后需重启 sub2api **+** 清 Redis。只重启 sub2api 不够。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 7:`/setup/*` 路由按需注册
|
||||||
|
|
||||||
|
**现象**:首次手工写了 config.yaml 让容器跑起来,后来想走 Wizard 重新配置;前端进了 `/setup` 页,填完点"测试连接" → `Request failed with status code 404`。
|
||||||
|
|
||||||
|
**根因**:`cmd/server/main.go` 里只有 `NeedsSetup() == true` 分支(走 `runSetupServer()`)才 `setup.RegisterRoutes(r)`。config.yaml 已存在时走 `runMainServer()`,setup 路由不注册 → 前端 `/setup/test-db` → 404。
|
||||||
|
|
||||||
|
**解法**:让 `NeedsSetup()` 返回 true,即 `/app/data/config.yaml` **和** `/app/data/.installed` 都不存在。具体做法:
|
||||||
|
```bash
|
||||||
|
ssh vps "cd /opt/sub2api && mv app-data/config.yaml app-data/config.yaml.bak; \
|
||||||
|
mv app-data/.installed app-data/.installed.bak 2>/dev/null; \
|
||||||
|
docker restart sub2api"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、当前状态 snapshot(2026-04-19)
|
||||||
|
|
||||||
|
```
|
||||||
|
Domain https://ai.puro.im (HTTP/2, Let's Encrypt via Caddy)
|
||||||
|
VPS 217.216.32.230 (Singapore, Ubuntu 22.04)
|
||||||
|
Image sub2api:local (Drone CI-built, distroless + static Go binary)
|
||||||
|
|
||||||
|
Containers on VPS:
|
||||||
|
sub2api ← Drone 每次 push 重建
|
||||||
|
sub2api-pg postgres:15 (devops-net 无关,独立 sub2api-net)
|
||||||
|
sub2api-redis redis:7-alpine
|
||||||
|
|
||||||
|
Accounts:
|
||||||
|
admin@puro.im (id=1, role=admin, balance=1e9)
|
||||||
|
test_myopenai (OpenAI OAuth, group=test_codex, schedulable=true)
|
||||||
|
|
||||||
|
API keys:
|
||||||
|
sk-d2132de2f0b4c1ab64ef7241a16d254cab483f1f8afd47ad4a89e39cf6e2345a
|
||||||
|
(user=admin, group=test_codex)
|
||||||
|
|
||||||
|
Config:
|
||||||
|
run_mode: standard
|
||||||
|
server.port: 8080 (容器内)
|
||||||
|
database.host: postgres (compose DNS)
|
||||||
|
redis.host: redis (compose DNS)
|
||||||
|
|
||||||
|
CI:
|
||||||
|
Drone job trigger = git push gitea main
|
||||||
|
Gitea webhook → Drone webhook (active)
|
||||||
|
最近一次 build: commit 9c34e961 ✓ (前端 ~90s + 后端 ~60s + deploy ~20s)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、常用运维命令
|
||||||
|
|
||||||
|
### 日常
|
||||||
|
```bash
|
||||||
|
# 状态
|
||||||
|
ssh vps "cd /opt/sub2api && docker compose ps"
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
ssh vps "docker logs sub2api --tail 100 -f"
|
||||||
|
ssh vps "tail -f /var/log/caddy/ai.puro.im.log"
|
||||||
|
|
||||||
|
# 强制重启 sub2api(不动 PG/Redis)
|
||||||
|
ssh vps "docker restart sub2api"
|
||||||
|
|
||||||
|
# 重载 Caddy
|
||||||
|
ssh vps "systemctl reload caddy"
|
||||||
|
|
||||||
|
# 清 Redis 缓存(改 DB 后要做)
|
||||||
|
ssh vps "docker exec sub2api-redis redis-cli FLUSHDB"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 触发 CI 重建
|
||||||
|
```bash
|
||||||
|
git commit --allow-empty -m "ci: rebuild" && git push gitea main
|
||||||
|
# 跟 build 进度(可选)
|
||||||
|
ssh vps "docker ps --filter 'name=^drone-'"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据备份
|
||||||
|
```bash
|
||||||
|
# PG dump
|
||||||
|
ssh vps "docker exec sub2api-pg pg_dump -U postgres sub2api" \
|
||||||
|
> ~/backups/sub2api-$(date +%Y%m%d).sql
|
||||||
|
|
||||||
|
# 整个 /opt/sub2api/
|
||||||
|
ssh vps "tar -czf /tmp/sub2api-full-$(date +%Y%m%d).tgz -C /opt sub2api"
|
||||||
|
scp vps:/tmp/sub2api-full-*.tgz ~/backups/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查关键 DB 信息
|
||||||
|
```sql
|
||||||
|
-- 账号池
|
||||||
|
SELECT id, name, platform, type, status, schedulable, expires_at FROM accounts;
|
||||||
|
|
||||||
|
-- API keys
|
||||||
|
SELECT id, user_id, name, status, group_id, quota, quota_used FROM api_keys;
|
||||||
|
|
||||||
|
-- 用户 + 余额
|
||||||
|
SELECT id, email, role, balance FROM users;
|
||||||
|
|
||||||
|
-- 今日用量
|
||||||
|
SELECT model_name, SUM(total_tokens), SUM(total_cost)
|
||||||
|
FROM usage_logs
|
||||||
|
WHERE created_at > NOW() - INTERVAL '24 hours'
|
||||||
|
GROUP BY model_name
|
||||||
|
ORDER BY 2 DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、清理/从头重来
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 停 + 删所有容器
|
||||||
|
ssh vps "cd /opt/sub2api && docker compose down"
|
||||||
|
|
||||||
|
# 清数据(会彻底删 wizard 数据、用户、账号池!)
|
||||||
|
ssh vps "rm -rf /opt/sub2api/{app-data,pg-data,redis-data}"
|
||||||
|
ssh vps "mkdir -p /opt/sub2api/{app-data,pg-data,redis-data}"
|
||||||
|
ssh vps "chown -R 65532:65532 /opt/sub2api/app-data"
|
||||||
|
|
||||||
|
# 重新起(走 Wizard)
|
||||||
|
ssh vps "cd /opt/sub2api && docker compose up -d"
|
||||||
|
|
||||||
|
# Caddy 保留,不用动
|
||||||
|
```
|
||||||
|
|
||||||
|
**不清数据的"纯重建"**(CI 跑一遍等价):
|
||||||
|
```bash
|
||||||
|
git commit --allow-empty -m "ci: rebuild" && git push gitea main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 参考
|
||||||
|
|
||||||
|
- 本地开发:`LOCAL_SETUP_NOTES.md`
|
||||||
|
- CI 配置:`.drone.yml`、`.ci/Dockerfile`、`.ci/README.md`
|
||||||
|
- 上游:https://github.com/Wei-Shaw/sub2api
|
||||||
|
- Fork:https://git.puro.im/purovps/sub2api
|
||||||
1064
backend/config.prod.yaml
Normal file
1064
backend/config.prod.yaml
Normal file
File diff suppressed because it is too large
Load Diff
BIN
backend/sub2api-linux
Executable file
BIN
backend/sub2api-linux
Executable file
Binary file not shown.
1183
docs/design-drafts/Landing.html
Normal file
1183
docs/design-drafts/Landing.html
Normal file
File diff suppressed because it is too large
Load Diff
689
docs/design-drafts/Login.html
Normal file
689
docs/design-drafts/Login.html
Normal file
@@ -0,0 +1,689 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>登录 — PURO AI</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
:root {
|
||||||
|
--bg-0: #0a0e1a;
|
||||||
|
--bg-1: #0f172a;
|
||||||
|
--border: #1e293b;
|
||||||
|
--border-2: #334155;
|
||||||
|
--text-0: #f8fafc;
|
||||||
|
--text-1: #cbd5e1;
|
||||||
|
--text-2: #94a3b8;
|
||||||
|
--text-3: #64748b;
|
||||||
|
--cyan: #22d3ee;
|
||||||
|
--purple: #a855f7;
|
||||||
|
--amber: #fbbf24;
|
||||||
|
--red: #f87171;
|
||||||
|
--green: #34d399;
|
||||||
|
}
|
||||||
|
html, body {
|
||||||
|
background: var(--bg-0);
|
||||||
|
color: var(--text-0);
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.mono { font-family: 'JetBrains Mono', ui-monospace, monospace; }
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; }
|
||||||
|
|
||||||
|
/* ------ SPLIT LAYOUT ------ */
|
||||||
|
.split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------ LEFT (NARRATIVE) ------ */
|
||||||
|
.narrative {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 48px 56px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: linear-gradient(135deg, #0a0e1a 0%, #1e1b4b 100%);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.narrative::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -200px; left: -200px;
|
||||||
|
width: 700px; height: 700px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, #22d3ee 0%, transparent 60%);
|
||||||
|
filter: blur(100px);
|
||||||
|
opacity: 0.3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.narrative::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: -250px; right: -150px;
|
||||||
|
width: 700px; height: 700px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, #a855f7 0%, transparent 60%);
|
||||||
|
filter: blur(100px);
|
||||||
|
opacity: 0.3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
/* grid pattern */
|
||||||
|
.narrative-grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(148, 163, 184, 0.04) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(148, 163, 184, 0.04) 1px, transparent 1px);
|
||||||
|
background-size: 48px 48px;
|
||||||
|
mask-image: radial-gradient(ellipse at center, black 30%, transparent 80%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrative-inner {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-top { padding-top: 4px; }
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.hex {
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 28px;
|
||||||
|
margin: auto 0;
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
.n-kicker {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--cyan);
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.n-headline {
|
||||||
|
font-size: clamp(36px, 4.2vw, 52px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
.n-headline .amber {
|
||||||
|
color: var(--amber);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.n-headline .cyan {
|
||||||
|
color: var(--cyan);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.n-headline .arrow {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 14px;
|
||||||
|
color: var(--text-2);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.n-sub {
|
||||||
|
font-size: 17px;
|
||||||
|
color: var(--text-1);
|
||||||
|
line-height: 1.7;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.n-sub .line { display: block; }
|
||||||
|
.n-sub .puro {
|
||||||
|
color: var(--text-0);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.n-sub .mono-accent {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Visual demo block — a mock "route" diagram */
|
||||||
|
.route-demo {
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
max-width: 420px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.route-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.route-row .head {
|
||||||
|
width: 84px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
.route-row .val {
|
||||||
|
color: var(--text-0);
|
||||||
|
}
|
||||||
|
.route-row.status .val { color: var(--cyan); display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.route-row.status .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--cyan); box-shadow: 0 0 10px var(--cyan); }
|
||||||
|
.route-row .chip {
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: rgba(34, 211, 238, 0.08);
|
||||||
|
border: 1px solid rgba(34, 211, 238, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--cyan);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.route-row .chip.a { background: rgba(168, 85, 247, 0.08); border-color: rgba(168, 85, 247, 0.2); color: var(--purple); }
|
||||||
|
|
||||||
|
.n-bottom {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3);
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
padding-top: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.n-bottom .sep { color: var(--border-2); }
|
||||||
|
.n-bottom .live {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.n-bottom .live .dot {
|
||||||
|
width: 6px; height: 6px; border-radius: 50%; background: var(--green);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------ RIGHT (FORM) ------ */
|
||||||
|
.form-side {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px;
|
||||||
|
background: var(--bg-0);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.form-side::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 40%; left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 500px; height: 500px;
|
||||||
|
background: radial-gradient(circle, rgba(34, 211, 238, 0.06) 0%, transparent 60%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.form-card h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.form-card .sub {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-1);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.input-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.input-wrap .icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 14px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: var(--text-3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.input-wrap input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px 12px 42px;
|
||||||
|
background: var(--bg-1);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-0);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.input-wrap input::placeholder { color: var(--text-3); }
|
||||||
|
.input-wrap input:hover { border-color: #475569; }
|
||||||
|
.input-wrap input:focus {
|
||||||
|
border-color: var(--cyan);
|
||||||
|
box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.12);
|
||||||
|
background: rgba(15, 23, 42, 0.9);
|
||||||
|
}
|
||||||
|
.input-wrap input.error {
|
||||||
|
border-color: var(--red);
|
||||||
|
box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.12);
|
||||||
|
}
|
||||||
|
.input-wrap .eye {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
color: var(--text-3);
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: color .15s;
|
||||||
|
}
|
||||||
|
.input-wrap .eye:hover { color: var(--text-1); }
|
||||||
|
|
||||||
|
.field-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.field-meta .forgot {
|
||||||
|
color: var(--text-2);
|
||||||
|
transition: color .15s;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.field-meta .forgot:hover { color: var(--cyan); }
|
||||||
|
.field-error {
|
||||||
|
color: var(--red);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.field-hint {
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pw-strength {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.pw-strength .bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: background .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 13px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: all .15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--cyan);
|
||||||
|
color: #042f2e;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: #67e8f9; }
|
||||||
|
.btn-primary:active { transform: translateY(1px); }
|
||||||
|
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
color: var(--text-1);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover { border-color: #475569; color: var(--text-0); background: rgba(255,255,255,0.02); }
|
||||||
|
|
||||||
|
.btn-primary .spinner {
|
||||||
|
width: 14px; height: 14px; border: 2px solid rgba(0,0,0,0.2);
|
||||||
|
border-top-color: #042f2e;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin .7s linear infinite;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.btn-primary.loading .spinner { display: inline-block; }
|
||||||
|
.btn-primary.loading .label { opacity: 0.5; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin: 24px 0;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
}
|
||||||
|
.divider::before, .divider::after {
|
||||||
|
content: "";
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot {
|
||||||
|
margin-top: 28px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.foot a {
|
||||||
|
color: var(--cyan);
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color .15s;
|
||||||
|
}
|
||||||
|
.foot a:hover { color: #67e8f9; }
|
||||||
|
|
||||||
|
.legal {
|
||||||
|
margin-top: 18px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.legal a { color: var(--text-2); text-decoration: underline; text-decoration-color: var(--border-2); }
|
||||||
|
.legal a:hover { color: var(--text-0); }
|
||||||
|
|
||||||
|
/* checkbox */
|
||||||
|
.check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.check input { display: none; }
|
||||||
|
.check .box {
|
||||||
|
width: 14px; height: 14px;
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-1);
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.check input:checked + .box {
|
||||||
|
background: var(--cyan);
|
||||||
|
border-color: var(--cyan);
|
||||||
|
}
|
||||||
|
.check input:checked + .box::after {
|
||||||
|
content: "✓";
|
||||||
|
color: #042f2e;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-home {
|
||||||
|
position: absolute;
|
||||||
|
top: 24px;
|
||||||
|
right: 32px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3);
|
||||||
|
transition: color .15s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.back-home:hover { color: var(--text-0); }
|
||||||
|
|
||||||
|
/* LinuxDo logo */
|
||||||
|
.linuxdo-ico {
|
||||||
|
width: 16px; height: 16px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: linear-gradient(135deg, #f0a030, #f05050);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.split { grid-template-columns: 1fr; min-height: auto; }
|
||||||
|
.narrative {
|
||||||
|
padding: 24px 24px 32px;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
.narrative-inner { gap: 18px; display: block; }
|
||||||
|
.n-top { margin-bottom: 16px; }
|
||||||
|
.n-center { gap: 12px; margin: 0; }
|
||||||
|
.n-headline { font-size: 28px; }
|
||||||
|
.n-sub { font-size: 14px; }
|
||||||
|
.n-sub .line { display: inline; }
|
||||||
|
.route-demo, .n-bottom { display: none; }
|
||||||
|
.form-side { padding: 32px 24px; }
|
||||||
|
.back-home { top: 18px; right: 18px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="split">
|
||||||
|
<!-- LEFT: NARRATIVE -->
|
||||||
|
<div class="narrative">
|
||||||
|
<div class="narrative-grid"></div>
|
||||||
|
<div class="narrative-inner">
|
||||||
|
<div class="n-top">
|
||||||
|
<a href="Landing.html" class="brand">
|
||||||
|
<svg class="hex" viewBox="0 0 24 24" 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>
|
||||||
|
PURO AI
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="n-center">
|
||||||
|
<div class="n-kicker">// 你的订阅,已经付过钱了</div>
|
||||||
|
<h1 class="n-headline">
|
||||||
|
<span class="amber">N</span> 个订阅
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<span class="cyan">1</span> 个 key
|
||||||
|
</h1>
|
||||||
|
<div class="n-sub">
|
||||||
|
<span class="line">省去切换账号的繁琐,</span>
|
||||||
|
<span class="line">省去为多个高昂订阅重复买单。</span>
|
||||||
|
<span class="line"><span class="puro">PURO</span>(纯粹)—— 让 AI 调用回归本质。</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="route-demo">
|
||||||
|
<div class="route-row"><span class="head">POST</span> <span class="val">/v1/chat/completions</span></div>
|
||||||
|
<div class="route-row"><span class="head">model</span> <span class="chip">claude-sonnet-4-5</span></div>
|
||||||
|
<div class="route-row"><span class="head">route →</span> <span class="chip a">claude-pool-03</span></div>
|
||||||
|
<div class="route-row status"><span class="head">status</span> <span class="val"><span class="dot"></span>200 · 213ms · 42 tok</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="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" style="margin: 0 4px;">|</span>
|
||||||
|
<span class="live"><span class="dot"></span>ai.puro.im · operational</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT: FORM -->
|
||||||
|
<div class="form-side">
|
||||||
|
<a href="Landing.html" class="back-home">← 返回首页</a>
|
||||||
|
|
||||||
|
<form class="form-card" id="login-form" autocomplete="off" novalidate>
|
||||||
|
<h1>登录</h1>
|
||||||
|
<p class="sub">用你的 PURO AI 账户继续</p>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="email">邮箱</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="5" width="18" height="14" rx="2"/>
|
||||||
|
<path d="M3 7l9 6 9-6"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input type="email" id="email" name="email" placeholder="you@puro.im" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">密码</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="4" y="11" width="16" height="10" rx="2"/>
|
||||||
|
<path d="M8 11V7a4 4 0 0 1 8 0v4"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input type="password" id="password" name="password" placeholder="••••••••" required>
|
||||||
|
<button type="button" class="eye" id="toggle-pw" aria-label="切换显示密码">
|
||||||
|
<svg id="eye-open" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/>
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
</svg>
|
||||||
|
<svg id="eye-closed" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="display:none;">
|
||||||
|
<path d="M9.88 9.88A3 3 0 0 0 14.12 14.12M10.73 5.08A10.94 10.94 0 0 1 12 5c6.5 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68M6.61 6.61A13.53 13.53 0 0 0 2 12s3.5 7 10 7a9.77 9.77 0 0 0 5.39-1.61M2 2l20 20"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-meta">
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" checked>
|
||||||
|
<span class="box"></span>
|
||||||
|
记住我
|
||||||
|
</label>
|
||||||
|
<a href="#" class="forgot">忘记密码?</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" id="submit-btn">
|
||||||
|
<span class="spinner"></span>
|
||||||
|
<span class="label">登录 →</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="divider">OR</div>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-ghost">
|
||||||
|
<span class="linuxdo-ico">L</span>
|
||||||
|
使用 LinuxDO 登录
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="foot">
|
||||||
|
没有账户?<a href="Register.html">注册</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal">
|
||||||
|
登录即表示你同意 <a href="#">服务条款</a> 与 <a href="#">隐私政策</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// eye toggle
|
||||||
|
const pw = document.getElementById('password');
|
||||||
|
const toggle = document.getElementById('toggle-pw');
|
||||||
|
const eyeOpen = document.getElementById('eye-open');
|
||||||
|
const eyeClosed = document.getElementById('eye-closed');
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
const show = pw.type === 'password';
|
||||||
|
pw.type = show ? 'text' : 'password';
|
||||||
|
eyeOpen.style.display = show ? 'none' : 'block';
|
||||||
|
eyeClosed.style.display = show ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// submit (mocked)
|
||||||
|
const form = document.getElementById('login-form');
|
||||||
|
const btn = document.getElementById('submit-btn');
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.email.value || !form.password.value) return;
|
||||||
|
btn.classList.add('loading');
|
||||||
|
btn.disabled = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.classList.remove('loading');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.querySelector('.label').textContent = '✓ 登录成功';
|
||||||
|
btn.style.background = '#34d399';
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.querySelector('.label').textContent = '登录 →';
|
||||||
|
btn.style.background = '';
|
||||||
|
}, 1500);
|
||||||
|
}, 1200);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
734
docs/design-drafts/Register.html
Normal file
734
docs/design-drafts/Register.html
Normal file
@@ -0,0 +1,734 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>注册 — PURO AI</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
:root {
|
||||||
|
--bg-0: #0a0e1a;
|
||||||
|
--bg-1: #0f172a;
|
||||||
|
--border: #1e293b;
|
||||||
|
--border-2: #334155;
|
||||||
|
--text-0: #f8fafc;
|
||||||
|
--text-1: #cbd5e1;
|
||||||
|
--text-2: #94a3b8;
|
||||||
|
--text-3: #64748b;
|
||||||
|
--cyan: #22d3ee;
|
||||||
|
--purple: #a855f7;
|
||||||
|
--amber: #fbbf24;
|
||||||
|
--red: #f87171;
|
||||||
|
--green: #34d399;
|
||||||
|
}
|
||||||
|
html, body {
|
||||||
|
background: var(--bg-0);
|
||||||
|
color: var(--text-0);
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; }
|
||||||
|
|
||||||
|
.split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------ LEFT (NARRATIVE) ------ */
|
||||||
|
.narrative {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 48px 56px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: linear-gradient(135deg, #0a0e1a 0%, #1e1b4b 100%);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.narrative::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -200px; left: -200px;
|
||||||
|
width: 700px; height: 700px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, #a855f7 0%, transparent 60%);
|
||||||
|
filter: blur(100px);
|
||||||
|
opacity: 0.3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.narrative::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: -250px; right: -150px;
|
||||||
|
width: 700px; height: 700px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, #22d3ee 0%, transparent 60%);
|
||||||
|
filter: blur(100px);
|
||||||
|
opacity: 0.3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.narrative-grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(148, 163, 184, 0.04) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(148, 163, 184, 0.04) 1px, transparent 1px);
|
||||||
|
background-size: 48px 48px;
|
||||||
|
mask-image: radial-gradient(ellipse at center, black 30%, transparent 80%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrative-inner {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.hex {
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 28px;
|
||||||
|
margin: auto 0;
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
.n-kicker {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--cyan);
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.n-headline {
|
||||||
|
font-size: clamp(36px, 4.2vw, 52px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
.n-headline .amber { color: var(--amber); font-variant-numeric: tabular-nums; display: inline-block; }
|
||||||
|
.n-headline .cyan { color: var(--cyan); font-variant-numeric: tabular-nums; display: inline-block; }
|
||||||
|
.n-headline .arrow {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 14px;
|
||||||
|
color: var(--text-2);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.n-sub {
|
||||||
|
font-size: 17px;
|
||||||
|
color: var(--text-1);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.n-sub .line { display: block; }
|
||||||
|
.n-sub .puro { color: var(--text-0); font-weight: 600; }
|
||||||
|
|
||||||
|
/* Step list */
|
||||||
|
.steps {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
.steps-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
color: var(--text-3);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.step {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
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.25);
|
||||||
|
color: var(--cyan);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.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: 'JetBrains Mono', monospace;
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-bottom {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3);
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
padding-top: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.n-bottom .sep { color: var(--border-2); }
|
||||||
|
|
||||||
|
/* ------ RIGHT (FORM) ------ */
|
||||||
|
.form-side {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px;
|
||||||
|
background: var(--bg-0);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.form-side::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 40%; left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 500px; height: 500px;
|
||||||
|
background: radial-gradient(circle, rgba(168, 85, 247, 0.05) 0%, transparent 60%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.form-card h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.form-card .sub {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field { margin-bottom: 18px; }
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-1);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.input-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.input-wrap .icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 14px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: var(--text-3);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.input-wrap input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px 12px 42px;
|
||||||
|
background: var(--bg-1);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-0);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.input-wrap input::placeholder { color: var(--text-3); }
|
||||||
|
.input-wrap input:hover { border-color: #475569; }
|
||||||
|
.input-wrap input:focus {
|
||||||
|
border-color: var(--cyan);
|
||||||
|
box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.12);
|
||||||
|
background: rgba(15, 23, 42, 0.9);
|
||||||
|
}
|
||||||
|
.input-wrap input.ok { border-color: rgba(52, 211, 153, 0.4); }
|
||||||
|
.input-wrap input.ok:focus { box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.12); }
|
||||||
|
.input-wrap input.error { border-color: var(--red); }
|
||||||
|
.input-wrap .eye {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
color: var(--text-3);
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.input-wrap .eye:hover { color: var(--text-1); }
|
||||||
|
.input-wrap .valid-ico {
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
color: var(--green);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.input-wrap input.ok ~ .valid-ico { display: flex; }
|
||||||
|
|
||||||
|
.pw-strength {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.pw-strength .bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: background .2s;
|
||||||
|
}
|
||||||
|
.pw-strength[data-score="1"] .bar:nth-child(1) { background: var(--red); }
|
||||||
|
.pw-strength[data-score="2"] .bar:nth-child(-n+2) { background: var(--amber); }
|
||||||
|
.pw-strength[data-score="3"] .bar:nth-child(-n+3) { background: var(--cyan); }
|
||||||
|
.pw-strength[data-score="4"] .bar { background: var(--green); }
|
||||||
|
|
||||||
|
.pw-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
.pw-hint .label { color: var(--text-3); }
|
||||||
|
.pw-hint .val { color: var(--text-1); }
|
||||||
|
.pw-hint[data-score="1"] .val { color: var(--red); }
|
||||||
|
.pw-hint[data-score="2"] .val { color: var(--amber); }
|
||||||
|
.pw-hint[data-score="3"] .val { color: var(--cyan); }
|
||||||
|
.pw-hint[data-score="4"] .val { color: var(--green); }
|
||||||
|
|
||||||
|
.match-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--text-3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.match-hint.ok { color: var(--green); }
|
||||||
|
.match-hint.bad { color: var(--red); }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 13px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all .15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--cyan);
|
||||||
|
color: #042f2e;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: #67e8f9; }
|
||||||
|
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.btn-primary .spinner {
|
||||||
|
width: 14px; height: 14px; border: 2px solid rgba(0,0,0,0.2);
|
||||||
|
border-top-color: #042f2e;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin .7s linear infinite;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.btn-primary.loading .spinner { display: inline-block; }
|
||||||
|
.btn-primary.loading .label { opacity: 0.5; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
color: var(--text-1);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover { border-color: #475569; color: var(--text-0); background: rgba(255,255,255,0.02); }
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin: 24px 0;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
}
|
||||||
|
.divider::before, .divider::after {
|
||||||
|
content: "";
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot {
|
||||||
|
margin-top: 24px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.foot a {
|
||||||
|
color: var(--cyan);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.foot a:hover { color: #67e8f9; }
|
||||||
|
.legal {
|
||||||
|
margin-top: 16px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.legal a { color: var(--text-2); text-decoration: underline; text-decoration-color: var(--border-2); }
|
||||||
|
|
||||||
|
.check {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.check input { display: none; }
|
||||||
|
.check .box {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
width: 14px; height: 14px;
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-1);
|
||||||
|
}
|
||||||
|
.check input:checked + .box {
|
||||||
|
background: var(--cyan);
|
||||||
|
border-color: var(--cyan);
|
||||||
|
}
|
||||||
|
.check input:checked + .box::after {
|
||||||
|
content: "✓";
|
||||||
|
color: #042f2e;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.check a { color: var(--text-0); text-decoration: underline; text-decoration-color: var(--border-2); }
|
||||||
|
|
||||||
|
.back-home {
|
||||||
|
position: absolute;
|
||||||
|
top: 24px;
|
||||||
|
right: 32px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.back-home:hover { color: var(--text-0); }
|
||||||
|
|
||||||
|
.linuxdo-ico {
|
||||||
|
width: 16px; height: 16px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: linear-gradient(135deg, #f0a030, #f05050);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.split { grid-template-columns: 1fr; min-height: auto; }
|
||||||
|
.narrative {
|
||||||
|
padding: 24px 24px 32px;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.narrative-inner { gap: 18px; display: block; }
|
||||||
|
.n-center { gap: 12px; margin: 16px 0 0; }
|
||||||
|
.n-headline { font-size: 28px; }
|
||||||
|
.n-sub { font-size: 14px; }
|
||||||
|
.n-sub .line { display: inline; }
|
||||||
|
.steps, .n-bottom { display: none; }
|
||||||
|
.form-side { padding: 32px 24px; }
|
||||||
|
.back-home { top: 18px; right: 18px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="split">
|
||||||
|
<!-- LEFT: NARRATIVE -->
|
||||||
|
<div class="narrative">
|
||||||
|
<div class="narrative-grid"></div>
|
||||||
|
<div class="narrative-inner">
|
||||||
|
<div>
|
||||||
|
<a href="Landing.html" class="brand">
|
||||||
|
<svg class="hex" viewBox="0 0 24 24" 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>
|
||||||
|
PURO AI
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="n-center">
|
||||||
|
<div class="n-kicker">// 5 分钟开始用</div>
|
||||||
|
<h1 class="n-headline">
|
||||||
|
<span class="amber">N</span> 个订阅
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<span class="cyan">1</span> 个 key
|
||||||
|
</h1>
|
||||||
|
<div class="n-sub">
|
||||||
|
<span class="line">省去切换账号的繁琐,</span>
|
||||||
|
<span class="line">省去为多个高昂订阅重复买单。</span>
|
||||||
|
<span class="line"><span class="puro">PURO</span>(纯粹)—— 让 AI 调用回归本质。</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="steps">
|
||||||
|
<div class="steps-title">// 下一步</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-num">1</div>
|
||||||
|
<div class="step-text"><b>创建账户</b> · 邮箱 + 密码,或用 LinuxDO OAuth</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-num">2</div>
|
||||||
|
<div class="step-text"><b>绑定订阅</b> · OAuth 接入你现有的 Claude Pro / ChatGPT Plus</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-num">3</div>
|
||||||
|
<div class="step-text"><b>生成 key</b> · 拿到 <span class="k">sk-puro-…</span> 换掉 SDK 的 <span class="k">base_url</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="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" style="margin: 0 4px;">|</span>
|
||||||
|
<span>无需信用卡 · 永久免费 Hobby 套餐</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT: FORM -->
|
||||||
|
<div class="form-side">
|
||||||
|
<a href="Landing.html" class="back-home">← 返回首页</a>
|
||||||
|
|
||||||
|
<form class="form-card" id="reg-form" autocomplete="off" novalidate>
|
||||||
|
<h1>创建账户</h1>
|
||||||
|
<p class="sub">5 分钟开始用 PURO AI</p>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="email">邮箱</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="5" width="18" height="14" rx="2"/>
|
||||||
|
<path d="M3 7l9 6 9-6"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input type="email" id="email" name="email" placeholder="you@puro.im" required>
|
||||||
|
<span class="valid-ico">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M5 12l5 5L20 7"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">密码</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="4" y="11" width="16" height="10" rx="2"/>
|
||||||
|
<path d="M8 11V7a4 4 0 0 1 8 0v4"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input type="password" id="password" name="password" placeholder="至少 8 位,含字母与数字" required>
|
||||||
|
<button type="button" class="eye" id="toggle-pw" aria-label="切换显示密码">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/>
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="pw-strength" id="pw-strength" data-score="0">
|
||||||
|
<span class="bar"></span><span class="bar"></span><span class="bar"></span><span class="bar"></span>
|
||||||
|
</div>
|
||||||
|
<div class="pw-hint" id="pw-hint" data-score="0">
|
||||||
|
<span class="label">// strength</span>
|
||||||
|
<span class="val">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="password2">确认密码</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="4" y="11" width="16" height="10" rx="2"/>
|
||||||
|
<path d="M8 11V7a4 4 0 0 1 8 0v4"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input type="password" id="password2" name="password2" placeholder="再输入一次" required>
|
||||||
|
<span class="valid-ico">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M5 12l5 5L20 7"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="match-hint" id="match-hint"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" id="terms">
|
||||||
|
<span class="box"></span>
|
||||||
|
<span>我已阅读并同意 <a href="#">服务条款</a> 与 <a href="#">隐私政策</a></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" id="submit-btn" disabled>
|
||||||
|
<span class="spinner"></span>
|
||||||
|
<span class="label">创建账户 →</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="divider">OR</div>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-ghost">
|
||||||
|
<span class="linuxdo-ico">L</span>
|
||||||
|
使用 LinuxDO 注册
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="foot">
|
||||||
|
已有账户?<a href="Login.html">登录</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal">
|
||||||
|
注册即可获得 Hobby 套餐 · 无需信用卡
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const pw = document.getElementById('password');
|
||||||
|
const pw2 = document.getElementById('password2');
|
||||||
|
const email = document.getElementById('email');
|
||||||
|
const terms = document.getElementById('terms');
|
||||||
|
const btn = document.getElementById('submit-btn');
|
||||||
|
const strengthBars = document.getElementById('pw-strength');
|
||||||
|
const strengthHint = document.getElementById('pw-hint');
|
||||||
|
const matchHint = document.getElementById('match-hint');
|
||||||
|
const toggle = document.getElementById('toggle-pw');
|
||||||
|
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
pw.type = pw.type === 'password' ? 'text' : 'password';
|
||||||
|
});
|
||||||
|
|
||||||
|
const labels = ['—', '太弱', '一般', '良好', '强'];
|
||||||
|
function scorePw(v) {
|
||||||
|
let s = 0;
|
||||||
|
if (v.length >= 8) s++;
|
||||||
|
if (/[a-z]/.test(v) && /[A-Z]/.test(v)) s++;
|
||||||
|
if (/\d/.test(v)) s++;
|
||||||
|
if (/[^\w\s]/.test(v) || v.length >= 14) s++;
|
||||||
|
if (v.length === 0) s = 0;
|
||||||
|
return Math.min(s, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
const s = scorePw(pw.value);
|
||||||
|
strengthBars.dataset.score = s;
|
||||||
|
strengthHint.dataset.score = s;
|
||||||
|
strengthHint.querySelector('.val').textContent = labels[s];
|
||||||
|
|
||||||
|
// email validity
|
||||||
|
const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value);
|
||||||
|
email.classList.toggle('ok', emailOk);
|
||||||
|
|
||||||
|
// match
|
||||||
|
if (pw2.value.length === 0) {
|
||||||
|
matchHint.textContent = '';
|
||||||
|
matchHint.className = 'match-hint';
|
||||||
|
pw2.classList.remove('ok', 'error');
|
||||||
|
} else if (pw.value === pw2.value) {
|
||||||
|
matchHint.innerHTML = '<span>✓</span> 两次密码一致';
|
||||||
|
matchHint.className = 'match-hint ok';
|
||||||
|
pw2.classList.add('ok');
|
||||||
|
pw2.classList.remove('error');
|
||||||
|
} else {
|
||||||
|
matchHint.innerHTML = '<span>✗</span> 密码不一致';
|
||||||
|
matchHint.className = 'match-hint bad';
|
||||||
|
pw2.classList.add('error');
|
||||||
|
pw2.classList.remove('ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ready = emailOk && s >= 2 && pw.value === pw2.value && pw.value.length >= 8 && terms.checked;
|
||||||
|
btn.disabled = !ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
[email, pw, pw2, terms].forEach(el => el.addEventListener('input', update));
|
||||||
|
terms.addEventListener('change', update);
|
||||||
|
|
||||||
|
const form = document.getElementById('reg-form');
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (btn.disabled) return;
|
||||||
|
btn.classList.add('loading');
|
||||||
|
btn.disabled = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.classList.remove('loading');
|
||||||
|
btn.querySelector('.label').textContent = '✓ 账户已创建,跳转中…';
|
||||||
|
btn.style.background = '#34d399';
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
637
docs/design-drafts/v2/API Keys.html
Normal file
637
docs/design-drafts/v2/API Keys.html
Normal file
@@ -0,0 +1,637 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
|
<title>API Keys — PURO AI</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="puro.css">
|
||||||
|
<style>
|
||||||
|
.page-head {
|
||||||
|
display: flex; align-items:flex-end; justify-content:space-between;
|
||||||
|
margin-bottom: 28px; gap: 24px;
|
||||||
|
}
|
||||||
|
.page-head h1 { font-size: 28px; font-weight: 700; letter-spacing:-0.02em; margin-bottom:6px; }
|
||||||
|
.page-head .sub { color: var(--text-2); font-size: 14px; max-width: 560px; line-height: 1.55; }
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.summary .cell {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
}
|
||||||
|
.summary .cell-label {
|
||||||
|
font-size: 11px; color: var(--text-3);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.1em;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.summary .cell-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 20px; font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.summary .cell-value .unit { font-size: 12px; color: var(--text-3); margin-left: 4px; }
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex; gap: 10px; align-items: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.toolbar .search-box {
|
||||||
|
flex: 1; max-width: 340px; position: relative;
|
||||||
|
}
|
||||||
|
.toolbar .search-box input {
|
||||||
|
width: 100%; height: 36px;
|
||||||
|
padding: 0 12px 0 34px;
|
||||||
|
background: rgba(2, 6, 23, 0.5);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
color: var(--text-1); font-size: 13px;
|
||||||
|
outline: none; font-family: inherit;
|
||||||
|
}
|
||||||
|
.toolbar .search-box input:focus { border-color: var(--border-2); }
|
||||||
|
.toolbar .search-box::before {
|
||||||
|
content: ""; position: absolute; left: 12px; top: 50%;
|
||||||
|
width: 14px; height: 14px; transform: translateY(-50%);
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='11' cy='11' r='7'/><path d='m20 20-3.5-3.5'/></svg>");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
.filter-btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 0 12px; height: 36px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: rgba(2, 6, 23, 0.5);
|
||||||
|
color: var(--text-2); font-size: 13px;
|
||||||
|
transition: all .12s;
|
||||||
|
}
|
||||||
|
.filter-btn:hover { border-color: var(--border-2); color: var(--text-0); }
|
||||||
|
.filter-btn .dot-count {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 16px; height: 16px; border-radius: 50%;
|
||||||
|
background: var(--cyan); color: #042f2e;
|
||||||
|
font-size: 10px; font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* key card */
|
||||||
|
.key-list { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.key-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
padding: 18px 20px;
|
||||||
|
transition: all .12s;
|
||||||
|
}
|
||||||
|
.key-card:hover { border-color: var(--border-2); background: rgba(15, 23, 42, 0.7); }
|
||||||
|
.key-card.revoked { opacity: 0.6; }
|
||||||
|
|
||||||
|
.key-head {
|
||||||
|
display: flex; align-items: center; gap: 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.key-name {
|
||||||
|
font-size: 15px; font-weight: 600;
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
}
|
||||||
|
.key-meta {
|
||||||
|
font-family: var(--font-mono); font-size: 11px; color: var(--text-3);
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.key-meta .sep { color: var(--border-2); }
|
||||||
|
.key-actions { display: flex; gap: 4px; margin-left: 6px; }
|
||||||
|
.icon-act {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-3);
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
transition: all .12s;
|
||||||
|
}
|
||||||
|
.icon-act:hover { color: var(--text-0); background: rgba(255,255,255,0.04); }
|
||||||
|
.icon-act.danger:hover { color: var(--red); background: rgba(248,113,113,0.08); }
|
||||||
|
|
||||||
|
.key-value {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(2, 6, 23, 0.6);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-1);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.key-value .prefix { color: var(--cyan); }
|
||||||
|
.key-value .rest { letter-spacing: 0.05em; }
|
||||||
|
.key-value .reveal {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--text-3); font-size: 11px;
|
||||||
|
padding: 4px 10px; border-radius: 4px;
|
||||||
|
cursor: pointer; transition: all .12s;
|
||||||
|
}
|
||||||
|
.key-value .reveal:hover { color: var(--cyan); background: rgba(34,211,238,0.08); }
|
||||||
|
.key-value .copy-btn {
|
||||||
|
color: var(--text-3); font-size: 11px;
|
||||||
|
padding: 4px 10px; border-radius: 4px;
|
||||||
|
cursor: pointer; transition: all .12s;
|
||||||
|
}
|
||||||
|
.key-value .copy-btn:hover { color: var(--cyan); background: rgba(34,211,238,0.08); }
|
||||||
|
|
||||||
|
.key-scopes {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.scope {
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
}
|
||||||
|
.scope-label {
|
||||||
|
font-size: 10px; color: var(--text-3);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.12em;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.scope-val {
|
||||||
|
font-size: 12px; color: var(--text-1);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.scope-val.tags {
|
||||||
|
display: flex; gap: 4px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.mini-tag {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.mini-tag .dot { width: 5px; height: 5px; border-radius: 50%; }
|
||||||
|
.mini-tag.all { color: var(--cyan); border-color: rgba(34,211,238,0.25); background: rgba(34,211,238,0.06); }
|
||||||
|
.usage-bar {
|
||||||
|
height: 4px; background: var(--border);
|
||||||
|
border-radius: 2px; overflow: hidden;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.usage-bar span { display: block; height: 100%; background: var(--cyan); }
|
||||||
|
.usage-bar.warn span { background: var(--amber); }
|
||||||
|
|
||||||
|
/* ---------- modal ---------- */
|
||||||
|
.backdrop {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(2, 6, 23, 0.8);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
z-index: 100;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
.backdrop.open { display: flex; }
|
||||||
|
.modal {
|
||||||
|
width: 540px; max-width: 100%;
|
||||||
|
background: var(--bg-1);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: var(--r-xl);
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.modal-head {
|
||||||
|
padding: 22px 28px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex; align-items: center;
|
||||||
|
}
|
||||||
|
.modal-head h2 {
|
||||||
|
font-size: 18px; font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.modal-head .close {
|
||||||
|
margin-left: auto; padding: 6px;
|
||||||
|
color: var(--text-3); cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.modal-head .close:hover { color: var(--text-0); background: rgba(255,255,255,0.04); }
|
||||||
|
.modal-body { padding: 22px 28px; }
|
||||||
|
.modal-foot {
|
||||||
|
padding: 14px 28px 22px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex; gap: 10px; justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* newly-created key callout */
|
||||||
|
.new-key-callout {
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid rgba(52, 211, 153, 0.3);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: rgba(52, 211, 153, 0.05);
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.new-key-callout .header {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
font-size: 13px; font-weight: 600; color: var(--green);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.new-key-callout .warn {
|
||||||
|
font-size: 12px; color: var(--text-2); margin-top: 10px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* scope picker */
|
||||||
|
.scope-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.scope-opt {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: rgba(2, 6, 23, 0.4);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .12s;
|
||||||
|
}
|
||||||
|
.scope-opt:hover { border-color: var(--border-2); }
|
||||||
|
.scope-opt.active {
|
||||||
|
border-color: var(--cyan);
|
||||||
|
background: rgba(34,211,238,0.06);
|
||||||
|
}
|
||||||
|
.scope-opt .box {
|
||||||
|
width: 14px; height: 14px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.scope-opt.active .box {
|
||||||
|
background: var(--cyan); border-color: var(--cyan);
|
||||||
|
}
|
||||||
|
.scope-opt.active .box::after {
|
||||||
|
content: "✓"; color: #042f2e; font-size: 10px; font-weight: 700;
|
||||||
|
}
|
||||||
|
.scope-opt .label { font-size: 13px; color: var(--text-0); }
|
||||||
|
.scope-opt .desc { font-size: 11px; color: var(--text-3); font-family: var(--font-mono); margin-left: auto; }
|
||||||
|
|
||||||
|
/* topbar (reused) */
|
||||||
|
.topbar-user {
|
||||||
|
margin-left: auto; display: flex; gap: 10px; align-items: center;
|
||||||
|
}
|
||||||
|
.user-menu {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 4px 10px 4px 4px;
|
||||||
|
border-radius: 100px;
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.user-menu .name { font-size: 13px; font-weight: 500; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-glow soft"></div>
|
||||||
|
|
||||||
|
<div class="app-shell">
|
||||||
|
<!-- SIDEBAR (same as dashboard) -->
|
||||||
|
<aside class="app-side">
|
||||||
|
<a class="brand" href="Landing.html">
|
||||||
|
<svg class="hex" viewBox="0 0 24 24" 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>
|
||||||
|
PURO
|
||||||
|
</a>
|
||||||
|
<div class="side-group">
|
||||||
|
<div class="side-label">Workspace</div>
|
||||||
|
<a class="side-item" href="Dashboard.html"><span class="ico">◆</span>Dashboard</a>
|
||||||
|
<a class="side-item active" href="API Keys.html"><span class="ico">⌘</span>API Keys<span class="count">3</span></a>
|
||||||
|
<a class="side-item" href="Accounts.html"><span class="ico">⊡</span>订阅账号<span class="count">7</span></a>
|
||||||
|
<a class="side-item" href="#"><span class="ico">▤</span>调用日志</a>
|
||||||
|
<a class="side-item" href="#"><span class="ico">$</span>账单 & 充值</a>
|
||||||
|
</div>
|
||||||
|
<div class="side-group">
|
||||||
|
<div class="side-label">Resources</div>
|
||||||
|
<a class="side-item" href="Docs.html"><span class="ico">📖</span>文档</a>
|
||||||
|
<a class="side-item" href="Design System.html"><span class="ico">◆</span>Design System</a>
|
||||||
|
</div>
|
||||||
|
<div class="side-group" style="margin-top:auto; padding-top:20px; border-top:1px solid var(--border);">
|
||||||
|
<a class="side-item" href="#"><span class="ico">⚙</span>设置</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="app-main">
|
||||||
|
<header class="app-topbar">
|
||||||
|
<h1>API Keys</h1>
|
||||||
|
<div class="topbar-user">
|
||||||
|
<div class="user-menu"><span class="avatar">Z</span><span class="name">zane</span></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="app-content">
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>API Keys</h1>
|
||||||
|
<div class="sub">每个 key 是一张独立的"通行证",可以单独设置可用的订阅池、限速和预算,泄漏时可以直接吊销而不影响其他 key。</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-lg" onclick="document.getElementById('create-modal').classList.add('open')">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
创建 Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- summary -->
|
||||||
|
<div class="summary">
|
||||||
|
<div class="cell">
|
||||||
|
<div class="cell-label">活跃 Keys</div>
|
||||||
|
<div class="cell-value tabular">3<span class="unit">/ 10 上限</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<div class="cell-label">近 7 日调用</div>
|
||||||
|
<div class="cell-value tabular">89,402</div>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<div class="cell-label">近 7 日花费</div>
|
||||||
|
<div class="cell-value tabular">$24.18<span class="unit">USD</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<div class="cell-label">已吊销</div>
|
||||||
|
<div class="cell-value tabular text-3">2</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- toolbar -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="search-box">
|
||||||
|
<input placeholder="搜索 key 名称或前缀…">
|
||||||
|
</div>
|
||||||
|
<button class="filter-btn">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 3H2l8 9.46V19l4 2v-8.54z"/></svg>
|
||||||
|
筛选 <span class="dot-count">2</span>
|
||||||
|
</button>
|
||||||
|
<button class="filter-btn">
|
||||||
|
全部范围
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M6 9l6 6 6-6"/></svg>
|
||||||
|
</button>
|
||||||
|
<div style="margin-left:auto;"></div>
|
||||||
|
<button class="filter-btn">
|
||||||
|
显示吊销
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- list -->
|
||||||
|
<div class="key-list">
|
||||||
|
|
||||||
|
<div class="key-card">
|
||||||
|
<div class="key-head">
|
||||||
|
<div class="key-name">
|
||||||
|
production
|
||||||
|
<span class="badge green">ACTIVE</span>
|
||||||
|
</div>
|
||||||
|
<div class="key-meta">
|
||||||
|
<span>created 2026·03·14</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<span>last used 2m ago</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<span class="text-cyan">● in use</span>
|
||||||
|
</div>
|
||||||
|
<div class="key-actions">
|
||||||
|
<button class="icon-act" title="编辑">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 1 1 3 3L7 19l-4 1 1-4z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="icon-act" title="轮换">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.5 0 4.8 1 6.4 2.6L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="icon-act danger" title="吊销">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="key-value">
|
||||||
|
<span class="prefix">sk-puro-</span>
|
||||||
|
<span class="rest">••••••••••••••••••••••••4f82</span>
|
||||||
|
<span class="reveal">👁 显示</span>
|
||||||
|
<span class="copy-btn">复制</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="key-scopes">
|
||||||
|
<div class="scope">
|
||||||
|
<div class="scope-label">可用订阅池</div>
|
||||||
|
<div class="scope-val tags">
|
||||||
|
<span class="mini-tag"><span class="dot" style="background:#d97757"></span>claude · 2</span>
|
||||||
|
<span class="mini-tag"><span class="dot" style="background:#10a37f"></span>gpt · 2</span>
|
||||||
|
<span class="mini-tag"><span class="dot" style="background:#4285f4"></span>gemini · 1</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="scope">
|
||||||
|
<div class="scope-label">本月用量</div>
|
||||||
|
<div class="scope-val tabular">$14.82 <span class="text-3">/ $50</span></div>
|
||||||
|
<div class="usage-bar"><span style="width:30%"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="scope">
|
||||||
|
<div class="scope-label">速率限制</div>
|
||||||
|
<div class="scope-val">120 RPM</div>
|
||||||
|
</div>
|
||||||
|
<div class="scope">
|
||||||
|
<div class="scope-label">关联应用</div>
|
||||||
|
<div class="scope-val tags">
|
||||||
|
<span class="mini-tag">Claude Code</span>
|
||||||
|
<span class="mini-tag">Cursor</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="key-card">
|
||||||
|
<div class="key-head">
|
||||||
|
<div class="key-name">
|
||||||
|
staging
|
||||||
|
<span class="badge amber">RATE LIMITED</span>
|
||||||
|
</div>
|
||||||
|
<div class="key-meta">
|
||||||
|
<span>created 2026·04·02</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<span>last used 3h ago</span>
|
||||||
|
</div>
|
||||||
|
<div class="key-actions">
|
||||||
|
<button class="icon-act"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 1 1 3 3L7 19l-4 1 1-4z"/></svg></button>
|
||||||
|
<button class="icon-act"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.5 0 4.8 1 6.4 2.6L21 8"/><path d="M21 3v5h-5"/></svg></button>
|
||||||
|
<button class="icon-act danger"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="key-value">
|
||||||
|
<span class="prefix">sk-puro-</span>
|
||||||
|
<span class="rest">••••••••••••••••••••••••ae19</span>
|
||||||
|
<span class="reveal">👁 显示</span>
|
||||||
|
<span class="copy-btn">复制</span>
|
||||||
|
</div>
|
||||||
|
<div class="key-scopes">
|
||||||
|
<div class="scope">
|
||||||
|
<div class="scope-label">可用订阅池</div>
|
||||||
|
<div class="scope-val tags">
|
||||||
|
<span class="mini-tag all">all pools</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="scope">
|
||||||
|
<div class="scope-label">本月用量</div>
|
||||||
|
<div class="scope-val tabular">$8.24 <span class="text-3">/ $10</span></div>
|
||||||
|
<div class="usage-bar warn"><span style="width:82%"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="scope">
|
||||||
|
<div class="scope-label">速率限制</div>
|
||||||
|
<div class="scope-val">30 RPM</div>
|
||||||
|
</div>
|
||||||
|
<div class="scope">
|
||||||
|
<div class="scope-label">关联应用</div>
|
||||||
|
<div class="scope-val tags">
|
||||||
|
<span class="mini-tag">本地开发</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="key-card">
|
||||||
|
<div class="key-head">
|
||||||
|
<div class="key-name">
|
||||||
|
cli-personal
|
||||||
|
<span class="badge">ACTIVE</span>
|
||||||
|
</div>
|
||||||
|
<div class="key-meta">
|
||||||
|
<span>created 2026·04·11</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<span>last used 18h ago</span>
|
||||||
|
</div>
|
||||||
|
<div class="key-actions">
|
||||||
|
<button class="icon-act"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 1 1 3 3L7 19l-4 1 1-4z"/></svg></button>
|
||||||
|
<button class="icon-act"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.5 0 4.8 1 6.4 2.6L21 8"/><path d="M21 3v5h-5"/></svg></button>
|
||||||
|
<button class="icon-act danger"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="key-value">
|
||||||
|
<span class="prefix">sk-puro-</span>
|
||||||
|
<span class="rest">••••••••••••••••••••••••c3d1</span>
|
||||||
|
<span class="reveal">👁 显示</span>
|
||||||
|
<span class="copy-btn">复制</span>
|
||||||
|
</div>
|
||||||
|
<div class="key-scopes">
|
||||||
|
<div class="scope">
|
||||||
|
<div class="scope-label">可用订阅池</div>
|
||||||
|
<div class="scope-val tags">
|
||||||
|
<span class="mini-tag"><span class="dot" style="background:#d97757"></span>claude · 1</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="scope">
|
||||||
|
<div class="scope-label">本月用量</div>
|
||||||
|
<div class="scope-val tabular">$1.12 <span class="text-3">/ 无限制</span></div>
|
||||||
|
<div class="usage-bar"><span style="width:6%"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="scope">
|
||||||
|
<div class="scope-label">速率限制</div>
|
||||||
|
<div class="scope-val">60 RPM</div>
|
||||||
|
</div>
|
||||||
|
<div class="scope">
|
||||||
|
<div class="scope-label">关联应用</div>
|
||||||
|
<div class="scope-val tags">
|
||||||
|
<span class="mini-tag">Terminal</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- revoked -->
|
||||||
|
<div class="key-card revoked">
|
||||||
|
<div class="key-head">
|
||||||
|
<div class="key-name">
|
||||||
|
<span style="text-decoration:line-through; color:var(--text-2);">old-demo</span>
|
||||||
|
<span class="badge red">REVOKED</span>
|
||||||
|
</div>
|
||||||
|
<div class="key-meta">
|
||||||
|
<span>revoked 2026·03·02</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="key-value" style="opacity:0.7">
|
||||||
|
<span class="prefix">sk-puro-</span>
|
||||||
|
<span class="rest">••••••••••••••••••••••••0ab3</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CREATE MODAL -->
|
||||||
|
<div class="backdrop" id="create-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2>创建新的 API Key</h2>
|
||||||
|
<span class="close" onclick="document.getElementById('create-modal').classList.remove('open')">✕</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">Key 名称</label>
|
||||||
|
<input class="input" placeholder="e.g. production · staging · cursor-macbook">
|
||||||
|
<div class="field-hint">仅用于辨识,不会出现在请求中。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">可用订阅池</label>
|
||||||
|
<div class="scope-grid">
|
||||||
|
<div class="scope-opt active">
|
||||||
|
<span class="box"></span>
|
||||||
|
<span class="label">Claude 池</span>
|
||||||
|
<span class="desc">2 accounts</span>
|
||||||
|
</div>
|
||||||
|
<div class="scope-opt active">
|
||||||
|
<span class="box"></span>
|
||||||
|
<span class="label">GPT 池</span>
|
||||||
|
<span class="desc">2 accounts</span>
|
||||||
|
</div>
|
||||||
|
<div class="scope-opt">
|
||||||
|
<span class="box"></span>
|
||||||
|
<span class="label">Gemini 池</span>
|
||||||
|
<span class="desc">1 account</span>
|
||||||
|
</div>
|
||||||
|
<div class="scope-opt">
|
||||||
|
<span class="box"></span>
|
||||||
|
<span class="label">Codex 池</span>
|
||||||
|
<span class="desc">0 accounts</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:14px;">
|
||||||
|
<div class="field" style="margin-bottom:0">
|
||||||
|
<label class="field-label">月度预算 ($USD)</label>
|
||||||
|
<input class="input" placeholder="50" type="number">
|
||||||
|
<div class="field-hint">达到后自动停用,下月 1 号重置。</div>
|
||||||
|
</div>
|
||||||
|
<div class="field" style="margin-bottom:0">
|
||||||
|
<label class="field-label">速率限制 (RPM)</label>
|
||||||
|
<input class="input" placeholder="60" type="number">
|
||||||
|
<div class="field-hint">每分钟最大请求数。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<button class="btn btn-ghost" onclick="document.getElementById('create-modal').classList.remove('open')">取消</button>
|
||||||
|
<button class="btn btn-primary">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12l5 5L20 7"/></svg>
|
||||||
|
创建 Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
561
docs/design-drafts/v2/Binding.html
Normal file
561
docs/design-drafts/v2/Binding.html
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
|
<title>绑定订阅 — PURO AI</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="puro.css">
|
||||||
|
<style>
|
||||||
|
.wrap {
|
||||||
|
max-width: 840px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 48px 24px 80px;
|
||||||
|
}
|
||||||
|
.head { text-align: center; margin-bottom: 40px; }
|
||||||
|
.head h1 { font-size: 34px; font-weight: 700; letter-spacing: -0.02em; margin-bottom: 10px; }
|
||||||
|
.head p { color: var(--text-2); font-size: 15px; max-width: 560px; margin: 0 auto; line-height: 1.6; }
|
||||||
|
|
||||||
|
/* stepper */
|
||||||
|
.steps {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
gap: 0; margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
.step {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
.step .num {
|
||||||
|
width: 26px; height: 26px; border-radius: 50%;
|
||||||
|
background: rgba(2, 6, 23, 0.6);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-3);
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
font-family: var(--font-mono); font-size: 12px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.step.done .num { background: var(--cyan); color: #042f2e; border-color: var(--cyan); }
|
||||||
|
.step.active .num { border-color: var(--cyan); color: var(--cyan); background: rgba(34,211,238,0.08); }
|
||||||
|
.step .label { font-size: 13px; color: var(--text-3); }
|
||||||
|
.step.done .label, .step.active .label { color: var(--text-0); }
|
||||||
|
.step-line {
|
||||||
|
width: 80px; height: 1px; background: var(--border);
|
||||||
|
margin: 0 14px;
|
||||||
|
}
|
||||||
|
.step-line.done { background: var(--cyan); }
|
||||||
|
|
||||||
|
/* platform picker */
|
||||||
|
.platform-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.platform-card {
|
||||||
|
padding: 22px 20px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .15s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.platform-card:hover { border-color: var(--border-2); transform: translateY(-2px); }
|
||||||
|
.platform-card.selected {
|
||||||
|
border-color: var(--cyan);
|
||||||
|
background:
|
||||||
|
radial-gradient(400px 200px at 50% 0%, rgba(34,211,238,0.1), transparent 60%),
|
||||||
|
rgba(15, 23, 42, 0.8);
|
||||||
|
}
|
||||||
|
.platform-card.selected::after {
|
||||||
|
content: "✓"; position: absolute; top: 12px; right: 12px;
|
||||||
|
width: 20px; height: 20px; border-radius: 50%;
|
||||||
|
background: var(--cyan); color: #042f2e;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 12px; font-weight: 700;
|
||||||
|
}
|
||||||
|
.platform-logo {
|
||||||
|
width: 40px; height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.platform-logo.claude { background: rgba(217, 119, 87, 0.12); }
|
||||||
|
.platform-logo.gpt { background: rgba(16, 163, 127, 0.12); }
|
||||||
|
.platform-logo.gemini { background: rgba(66, 133, 244, 0.12); }
|
||||||
|
.platform-logo.codex { background: rgba(240, 160, 48, 0.12); }
|
||||||
|
|
||||||
|
.platform-name {
|
||||||
|
font-size: 15px; font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.platform-tier {
|
||||||
|
font-size: 11px; color: var(--text-3);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.platform-tiers {
|
||||||
|
display: flex; gap: 4px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.platform-tiers .pill {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* selected detail panel */
|
||||||
|
.detail-panel {
|
||||||
|
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%),
|
||||||
|
rgba(15, 23, 42, 0.4);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.detail-head {
|
||||||
|
padding: 22px 26px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.detail-head .platform-logo { margin: 0; }
|
||||||
|
.detail-head h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.detail-head .sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.detail-head .secure-tag {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 11px; font-family: var(--font-mono);
|
||||||
|
color: var(--green);
|
||||||
|
background: rgba(52,211,153,0.08);
|
||||||
|
border: 1px solid rgba(52,211,153,0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-body { padding: 24px 26px; }
|
||||||
|
|
||||||
|
.tier-picker {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
.tier-opt {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: rgba(2, 6, 23, 0.4);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: all .12s;
|
||||||
|
}
|
||||||
|
.tier-opt:hover { border-color: var(--border-2); }
|
||||||
|
.tier-opt.selected {
|
||||||
|
border-color: var(--cyan);
|
||||||
|
background: rgba(34,211,238,0.05);
|
||||||
|
}
|
||||||
|
.tier-opt .t-name { font-size: 13px; font-weight: 600; margin-bottom: 4px; }
|
||||||
|
.tier-opt .t-price {
|
||||||
|
font-family: var(--font-mono); font-size: 18px;
|
||||||
|
font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--cyan); margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.tier-opt .t-price .unit {
|
||||||
|
font-size: 11px; color: var(--text-3);
|
||||||
|
font-weight: 500; margin-left: 3px;
|
||||||
|
}
|
||||||
|
.tier-opt .t-desc {
|
||||||
|
font-size: 11px; color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* method cards */
|
||||||
|
.method-section h3 {
|
||||||
|
font-size: 13px; font-weight: 600;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
.method-section h3 .hint {
|
||||||
|
color: var(--text-3); font-weight: 400;
|
||||||
|
font-family: var(--font-mono); font-size: 11px;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.method-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.method-card {
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: rgba(2, 6, 23, 0.4);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .12s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.method-card:hover { border-color: var(--border-2); background: rgba(2, 6, 23, 0.6); }
|
||||||
|
.method-card.selected {
|
||||||
|
border-color: var(--cyan);
|
||||||
|
background: rgba(34, 211, 238, 0.04);
|
||||||
|
}
|
||||||
|
.method-card.selected::after {
|
||||||
|
content: ""; position: absolute; top: 12px; right: 12px;
|
||||||
|
width: 16px; height: 16px; border-radius: 50%;
|
||||||
|
background: var(--cyan);
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%23042f2e' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'><path d='M5 12l5 5L20 7'/></svg>");
|
||||||
|
background-repeat: no-repeat; background-position: center;
|
||||||
|
}
|
||||||
|
.method-title {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
font-size: 14px; font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.method-title .tag {
|
||||||
|
font-size: 10px; font-family: var(--font-mono);
|
||||||
|
padding: 1px 6px; border-radius: 3px;
|
||||||
|
background: rgba(52,211,153,0.12); color: var(--green);
|
||||||
|
}
|
||||||
|
.method-title .tag.beta { background: rgba(251,191,36,0.12); color: var(--amber); }
|
||||||
|
.method-desc { font-size: 12px; color: var(--text-2); line-height: 1.55; }
|
||||||
|
.method-steps {
|
||||||
|
font-family: var(--font-mono); font-size: 11px;
|
||||||
|
color: var(--text-3); margin-top: 10px;
|
||||||
|
display: flex; gap: 6px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.method-steps span::after {
|
||||||
|
content: "→"; margin-left: 6px; color: var(--border-2);
|
||||||
|
}
|
||||||
|
.method-steps span:last-child::after { display: none; }
|
||||||
|
|
||||||
|
/* OAuth preview */
|
||||||
|
.oauth-preview {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
background: rgba(2, 6, 23, 0.5);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
display: flex; gap: 16px; align-items: center;
|
||||||
|
}
|
||||||
|
.oauth-preview .flow {
|
||||||
|
flex: 1;
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
font-family: var(--font-mono); font-size: 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.oauth-preview .flow .arrow {
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
.oauth-preview .flow .node {
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.oauth-preview .flow .node.puro { color: var(--cyan); border-color: rgba(34,211,238,0.25); background: rgba(34,211,238,0.06); }
|
||||||
|
|
||||||
|
/* actions */
|
||||||
|
.actions {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
margin-top: 26px;
|
||||||
|
padding-top: 22px;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.actions .left {
|
||||||
|
font-size: 12px; color: var(--text-3);
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.actions .right { display: flex; gap: 10px; }
|
||||||
|
|
||||||
|
/* bound summary panel (step 3 preview) */
|
||||||
|
.bound-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.bound-item {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(2, 6, 23, 0.4);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.bound-item .tick {
|
||||||
|
width: 16px; height: 16px; border-radius: 50%;
|
||||||
|
background: var(--green);
|
||||||
|
color: #042f2e;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 10px; font-weight: 700;
|
||||||
|
}
|
||||||
|
.bound-item .name { flex: 1; color: var(--text-0); }
|
||||||
|
.bound-item .tag { color: var(--text-3); font-family: var(--font-mono); font-size: 11px; }
|
||||||
|
|
||||||
|
/* topbar user menu (minimal) */
|
||||||
|
.topbar-user {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
}
|
||||||
|
.user-menu {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 4px 10px 4px 4px;
|
||||||
|
border-radius: 100px;
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.user-menu .name { font-size: 13px; font-weight: 500; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-glow soft"></div>
|
||||||
|
|
||||||
|
<div class="app-shell">
|
||||||
|
<aside class="app-side">
|
||||||
|
<a class="brand" href="Landing.html">
|
||||||
|
<svg class="hex" viewBox="0 0 24 24" 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>
|
||||||
|
PURO
|
||||||
|
</a>
|
||||||
|
<div class="side-group">
|
||||||
|
<div class="side-label">Workspace</div>
|
||||||
|
<a class="side-item" href="Dashboard.html"><span class="ico">◆</span>Dashboard</a>
|
||||||
|
<a class="side-item" href="API Keys.html"><span class="ico">⌘</span>API Keys<span class="count">3</span></a>
|
||||||
|
<a class="side-item active" href="#"><span class="ico">⊡</span>订阅账号<span class="count">7</span></a>
|
||||||
|
<a class="side-item" href="#"><span class="ico">▤</span>调用日志</a>
|
||||||
|
<a class="side-item" href="#"><span class="ico">$</span>账单 & 充值</a>
|
||||||
|
</div>
|
||||||
|
<div class="side-group">
|
||||||
|
<div class="side-label">Resources</div>
|
||||||
|
<a class="side-item" href="Docs.html"><span class="ico">📖</span>文档</a>
|
||||||
|
<a class="side-item" href="Design System.html"><span class="ico">◆</span>Design System</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="app-main">
|
||||||
|
<header class="app-topbar">
|
||||||
|
<a href="Dashboard.html" style="color:var(--text-3); display:inline-flex; align-items:center; gap:6px; font-size:13px;">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
|
||||||
|
返回 Dashboard
|
||||||
|
</a>
|
||||||
|
<div class="topbar-user">
|
||||||
|
<div class="user-menu"><span class="avatar">Z</span><span class="name">zane</span></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="app-content">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="head">
|
||||||
|
<div class="section-kicker">// 绑定订阅 · 3 步完成</div>
|
||||||
|
<h1>把你已有的 AI 订阅,变成 API</h1>
|
||||||
|
<p>我们支持 OAuth 授权和 Cookie 托管两种方式接入 Claude / ChatGPT / Gemini。所有凭证使用 AES-256 加密存储,你可以随时一键解绑。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- stepper -->
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step done">
|
||||||
|
<span class="num">✓</span>
|
||||||
|
<span class="label">选择平台</span>
|
||||||
|
</div>
|
||||||
|
<div class="step-line done"></div>
|
||||||
|
<div class="step active">
|
||||||
|
<span class="num">2</span>
|
||||||
|
<span class="label">授权绑定</span>
|
||||||
|
</div>
|
||||||
|
<div class="step-line"></div>
|
||||||
|
<div class="step">
|
||||||
|
<span class="num">3</span>
|
||||||
|
<span class="label">完成 & 加入池</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- platform picker -->
|
||||||
|
<div class="platform-grid">
|
||||||
|
<div class="platform-card">
|
||||||
|
<div class="platform-logo claude">
|
||||||
|
<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="platform-name">Claude</div>
|
||||||
|
<div class="platform-tier">Anthropic · OAuth</div>
|
||||||
|
<div class="platform-tiers">
|
||||||
|
<span class="pill">Pro · $20</span>
|
||||||
|
<span class="pill">Max · $100</span>
|
||||||
|
<span class="pill">Team</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="platform-card selected">
|
||||||
|
<div class="platform-logo gpt">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#10a37f" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M12 3v18M3 12h18"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="platform-name">ChatGPT</div>
|
||||||
|
<div class="platform-tier">OpenAI · OAuth + Cookie</div>
|
||||||
|
<div class="platform-tiers">
|
||||||
|
<span class="pill">Plus · $20</span>
|
||||||
|
<span class="pill">Pro · $200</span>
|
||||||
|
<span class="pill">Team</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="platform-card">
|
||||||
|
<div class="platform-logo gemini">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="1.8"><path d="M12 2 L15 9 L22 10 L17 15 L19 22 L12 18 L5 22 L7 15 L2 10 L9 9 Z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="platform-name">Gemini</div>
|
||||||
|
<div class="platform-tier">Google · Cookie</div>
|
||||||
|
<div class="platform-tiers">
|
||||||
|
<span class="pill">Advanced · $20</span>
|
||||||
|
<span class="pill">Workspace</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- detail panel for selected platform (ChatGPT) -->
|
||||||
|
<div class="detail-panel">
|
||||||
|
<div class="detail-head">
|
||||||
|
<div class="platform-logo gpt">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#10a37f" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M12 3v18M3 12h18"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>绑定 ChatGPT 账号</h2>
|
||||||
|
<div class="sub">支持 Plus / Pro / Team · 接入后可用 gpt-5 / gpt-5-codex / gpt-4.1</div>
|
||||||
|
</div>
|
||||||
|
<span class="secure-tag">
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||||
|
AES-256 加密存储
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-body">
|
||||||
|
|
||||||
|
<!-- tier picker -->
|
||||||
|
<div class="ds-label" style="font-family:var(--font-mono); font-size:11px; color:var(--text-3); text-transform:uppercase; letter-spacing:0.14em; margin-bottom:10px;">
|
||||||
|
订阅档位
|
||||||
|
</div>
|
||||||
|
<div class="tier-picker">
|
||||||
|
<div class="tier-opt">
|
||||||
|
<div class="t-name">Plus</div>
|
||||||
|
<div class="t-price">$20<span class="unit">/月</span></div>
|
||||||
|
<div class="t-desc">~500k tokens · 约值 $0.08/k</div>
|
||||||
|
</div>
|
||||||
|
<div class="tier-opt selected">
|
||||||
|
<div class="t-name">Pro</div>
|
||||||
|
<div class="t-price">$200<span class="unit">/月</span></div>
|
||||||
|
<div class="t-desc">~5M tokens · 约值 $0.04/k</div>
|
||||||
|
</div>
|
||||||
|
<div class="tier-opt">
|
||||||
|
<div class="t-name">Team</div>
|
||||||
|
<div class="t-price">$30<span class="unit">/user</span></div>
|
||||||
|
<div class="t-desc">按席位池化,稳定性更高</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- methods -->
|
||||||
|
<div class="method-section">
|
||||||
|
<h3>绑定方式 <span class="hint">选择一种即可 · 可以在绑定后随时更换</span></h3>
|
||||||
|
<div class="method-grid">
|
||||||
|
<div class="method-card selected">
|
||||||
|
<div class="method-title">
|
||||||
|
OAuth 授权登录
|
||||||
|
<span class="tag">推荐</span>
|
||||||
|
</div>
|
||||||
|
<div class="method-desc">
|
||||||
|
跳转到 ChatGPT 登录页,登录后自动回跳。不经过我们的密码表单,最接近"官方授权"体验。
|
||||||
|
</div>
|
||||||
|
<div class="method-steps">
|
||||||
|
<span>点击跳转</span>
|
||||||
|
<span>ChatGPT 登录</span>
|
||||||
|
<span>授权回调</span>
|
||||||
|
<span>加入池</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="method-card">
|
||||||
|
<div class="method-title">
|
||||||
|
粘贴 Session Cookie
|
||||||
|
<span class="tag beta">兼容模式</span>
|
||||||
|
</div>
|
||||||
|
<div class="method-desc">
|
||||||
|
用浏览器扩展一键导出 <code class="mono" style="color:var(--cyan)">__Secure-next-auth.session-token</code> 并粘贴到这里。适合多账号批量绑定。
|
||||||
|
</div>
|
||||||
|
<div class="method-steps">
|
||||||
|
<span>安装扩展</span>
|
||||||
|
<span>登录 chatgpt.com</span>
|
||||||
|
<span>导出 cookie</span>
|
||||||
|
<span>粘贴绑定</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OAuth flow preview -->
|
||||||
|
<div class="oauth-preview">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#22d3ee" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0">
|
||||||
|
<rect width="18" height="11" x="3" y="11" rx="2"/>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
|
</svg>
|
||||||
|
<div class="flow">
|
||||||
|
<span class="node">你</span>
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<span class="node puro">PURO</span>
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<span class="node">chatgpt.com/oauth</span>
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<span class="node puro">PURO</span>
|
||||||
|
</div>
|
||||||
|
<div class="mono text-xs text-3">约 15 秒</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- bound preview (done state, hidden unless step 3) -->
|
||||||
|
<div class="method-section" style="margin-top:22px">
|
||||||
|
<h3>本次绑定预览 <span class="hint">授权成功后会自动加入池</span></h3>
|
||||||
|
<div class="bound-list">
|
||||||
|
<div class="bound-item">
|
||||||
|
<span class="tick">✓</span>
|
||||||
|
<span class="name">gpt-plus-7</span>
|
||||||
|
<span class="tag">加入 GPT 池</span>
|
||||||
|
</div>
|
||||||
|
<div class="bound-item">
|
||||||
|
<span class="tick">✓</span>
|
||||||
|
<span class="name">gpt-plus-8</span>
|
||||||
|
<span class="tag">加入 GPT 池</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- actions -->
|
||||||
|
<div class="actions">
|
||||||
|
<div class="left">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg>
|
||||||
|
凭证仅用于代理请求,不会用于训练或泄露给第三方。
|
||||||
|
</div>
|
||||||
|
<div class="right">
|
||||||
|
<a class="btn btn-ghost" href="Dashboard.html">稍后再说</a>
|
||||||
|
<button class="btn btn-primary btn-lg">
|
||||||
|
使用 OAuth 绑定
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17L17 7M7 7h10v10"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
770
docs/design-drafts/v2/Dashboard.html
Normal file
770
docs/design-drafts/v2/Dashboard.html
Normal file
@@ -0,0 +1,770 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
|
<title>Dashboard — PURO AI</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="puro.css">
|
||||||
|
<style>
|
||||||
|
/* ---- page-local ---- */
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
.page-head h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.page-head .sub { color: var(--text-2); font-size: 14px; }
|
||||||
|
|
||||||
|
/* ---- stat row ---- */
|
||||||
|
.stat-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.stat-value .unit { font-size: 14px; color: var(--text-3); margin-left: 4px; font-weight: 500; }
|
||||||
|
.stat-delta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--green);
|
||||||
|
margin-top: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
}
|
||||||
|
.stat-delta.down { color: var(--red); }
|
||||||
|
.stat-delta.neutral { color: var(--text-3); }
|
||||||
|
.stat-spark {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0; right: 0;
|
||||||
|
width: 100px; height: 40px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat.credit {
|
||||||
|
background:
|
||||||
|
radial-gradient(400px 200px at 100% 0%, rgba(34,211,238,0.1), transparent 60%),
|
||||||
|
rgba(15, 23, 42, 0.6);
|
||||||
|
border-color: rgba(34, 211, 238, 0.2);
|
||||||
|
}
|
||||||
|
.stat.credit .stat-value { color: var(--cyan); }
|
||||||
|
.stat.credit .topup {
|
||||||
|
position: absolute; top: 14px; right: 14px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--cyan);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 1px solid rgba(34, 211, 238, 0.25);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(34,211,238,0.05);
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.stat.credit .topup:hover { background: rgba(34,211,238,0.15); }
|
||||||
|
|
||||||
|
/* ---- chart + donut grid ---- */
|
||||||
|
.chart-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.chart-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.chart-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.chart-head .title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.chart-head .legend {
|
||||||
|
display: flex; gap: 14px; font-size: 11px; color: var(--text-3); font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.chart-head .legend span { display: inline-flex; align-items: center; gap: 5px; }
|
||||||
|
.chart-head .legend .sw { width: 8px; height: 8px; border-radius: 2px; }
|
||||||
|
|
||||||
|
.range-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
background: rgba(2, 6, 23, 0.5);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.range-switch button {
|
||||||
|
padding: 4px 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all .12s;
|
||||||
|
}
|
||||||
|
.range-switch button.active { background: rgba(34,211,238,0.12); color: var(--cyan); }
|
||||||
|
.range-switch button:hover:not(.active) { color: var(--text-1); }
|
||||||
|
|
||||||
|
/* ---- accounts + log ---- */
|
||||||
|
.two-col-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.4fr;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.panel-head {
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.panel-head h3 { font-size: 13px; font-weight: 600; }
|
||||||
|
.panel-head .meta {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
.panel-body { padding: 8px; }
|
||||||
|
|
||||||
|
/* account list row */
|
||||||
|
.acct-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 24px 1fr auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
transition: all .12s;
|
||||||
|
}
|
||||||
|
.acct-row:hover { background: rgba(255,255,255,0.02); }
|
||||||
|
.acct-logo {
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.acct-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-0);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.acct-name .tag {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.acct-meter {
|
||||||
|
width: 80px;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.acct-meter span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--green);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.acct-meter.warn span { background: var(--amber); }
|
||||||
|
.acct-meter.danger span { background: var(--red); }
|
||||||
|
|
||||||
|
.acct-status {
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--green);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 72px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.acct-status.warn { color: var(--amber); }
|
||||||
|
.acct-status.off { color: var(--text-3); }
|
||||||
|
|
||||||
|
.add-acct {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 6px 6px 6px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px dashed var(--border-2);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.add-acct:hover { border-color: var(--cyan); color: var(--cyan); background: rgba(34,211,238,0.04); }
|
||||||
|
|
||||||
|
/* log table overrides */
|
||||||
|
.log-wrap { overflow: hidden; }
|
||||||
|
.log-wrap table { width: 100%; }
|
||||||
|
|
||||||
|
/* endpoint bar */
|
||||||
|
.endpoint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(2, 6, 23, 0.6);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.endpoint .method {
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: rgba(34,211,238,0.1);
|
||||||
|
color: var(--cyan);
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.endpoint .url { color: var(--text-0); flex: 1; }
|
||||||
|
.endpoint .copy {
|
||||||
|
padding: 4px 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .12s;
|
||||||
|
}
|
||||||
|
.endpoint .copy:hover { color: var(--cyan); background: rgba(34,211,238,0.08); }
|
||||||
|
|
||||||
|
/* getting started banner */
|
||||||
|
.tips-banner {
|
||||||
|
padding: 14px 18px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border: 1px solid rgba(251, 191, 36, 0.22);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: rgba(251, 191, 36, 0.05);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
.tips-banner .spark { color: var(--amber); flex-shrink: 0; display: inline-flex; }
|
||||||
|
.tips-banner a { color: var(--amber); font-weight: 500; }
|
||||||
|
.tips-banner .dismiss { margin-left: auto; color: var(--text-3); cursor: pointer; padding: 4px; }
|
||||||
|
|
||||||
|
/* topbar custom */
|
||||||
|
.search {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 360px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.search input {
|
||||||
|
width: 100%;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 14px 0 36px;
|
||||||
|
background: rgba(2, 6, 23, 0.5);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
color: var(--text-1);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: all .12s;
|
||||||
|
}
|
||||||
|
.search input::placeholder { color: var(--text-3); }
|
||||||
|
.search input:focus { border-color: var(--border-2); background: rgba(2, 6, 23, 0.8); }
|
||||||
|
.search .search-ico {
|
||||||
|
position: absolute; left: 12px; top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--text-3);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.search .kbd {
|
||||||
|
position: absolute; right: 10px; top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-right {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.icon-btn {
|
||||||
|
width: 32px; height: 32px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
color: var(--text-2);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all .12s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.icon-btn:hover { color: var(--text-0); background: rgba(255,255,255,0.04); }
|
||||||
|
.icon-btn .pulse {
|
||||||
|
position: absolute; top: 8px; right: 8px;
|
||||||
|
width: 6px; height: 6px; border-radius: 50%;
|
||||||
|
background: var(--amber);
|
||||||
|
box-shadow: 0 0 0 3px rgba(251,191,36,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 10px 4px 4px;
|
||||||
|
border-radius: 100px;
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .12s;
|
||||||
|
}
|
||||||
|
.user-menu:hover { background: rgba(255,255,255,0.06); }
|
||||||
|
.user-menu .name { font-size: 13px; font-weight: 500; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="bg-glow soft"></div>
|
||||||
|
|
||||||
|
<div class="app-shell">
|
||||||
|
|
||||||
|
<!-- SIDEBAR -->
|
||||||
|
<aside class="app-side">
|
||||||
|
<a class="brand" href="Landing.html">
|
||||||
|
<svg class="hex" viewBox="0 0 24 24" 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>
|
||||||
|
PURO
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="side-group">
|
||||||
|
<div class="side-label">Workspace</div>
|
||||||
|
<a class="side-item active" href="Dashboard.html">
|
||||||
|
<span class="ico">
|
||||||
|
<svg width="15" height="15" 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="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
|
||||||
|
</span>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
<a class="side-item" href="API Keys.html">
|
||||||
|
<span class="ico">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="3.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/></svg>
|
||||||
|
</span>
|
||||||
|
API Keys
|
||||||
|
<span class="count">3</span>
|
||||||
|
</a>
|
||||||
|
<a class="side-item" href="Accounts.html">
|
||||||
|
<span class="ico">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-9"/><path d="M14 17H5"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/></svg>
|
||||||
|
</span>
|
||||||
|
订阅账号
|
||||||
|
<span class="count">7</span>
|
||||||
|
</a>
|
||||||
|
<a class="side-item" href="#">
|
||||||
|
<span class="ico">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h8"/></svg>
|
||||||
|
</span>
|
||||||
|
调用日志
|
||||||
|
</a>
|
||||||
|
<a class="side-item" href="#">
|
||||||
|
<span class="ico">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
|
||||||
|
</span>
|
||||||
|
账单 & 充值
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="side-group">
|
||||||
|
<div class="side-label">Resources</div>
|
||||||
|
<a class="side-item" href="Docs.html">
|
||||||
|
<span class="ico">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
||||||
|
</span>
|
||||||
|
文档
|
||||||
|
</a>
|
||||||
|
<a class="side-item" href="Design System.html">
|
||||||
|
<span class="ico">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L21 7V17L12 22L3 17V7L12 2Z"/></svg>
|
||||||
|
</span>
|
||||||
|
Design System
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="side-group" style="margin-top:auto; padding-top:20px; border-top:1px solid var(--border);">
|
||||||
|
<a class="side-item" href="#">
|
||||||
|
<span class="ico">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h0a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v0a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>
|
||||||
|
</span>
|
||||||
|
设置
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- MAIN -->
|
||||||
|
<div class="app-main">
|
||||||
|
<header class="app-topbar">
|
||||||
|
<div class="search">
|
||||||
|
<span class="search-ico">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
|
||||||
|
</span>
|
||||||
|
<input placeholder="搜索 key / 账号 / 日志…">
|
||||||
|
<span class="kbd"><kbd>⌘</kbd><kbd>K</kbd></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topbar-right">
|
||||||
|
<button class="icon-btn" aria-label="通知">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
|
||||||
|
<span class="pulse"></span>
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" aria-label="帮助">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="user-menu">
|
||||||
|
<span class="avatar">Z</span>
|
||||||
|
<span class="name">zane</span>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="app-content">
|
||||||
|
|
||||||
|
<!-- tips -->
|
||||||
|
<div class="tips-banner">
|
||||||
|
<span class="spark">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||||
|
</span>
|
||||||
|
你还有 <b class="text-0">2 个 Claude Pro</b> 订阅未绑定 —— 绑定后立即享受多账号 failover 和请求加权调度。
|
||||||
|
<a href="Binding.html">去绑定 →</a>
|
||||||
|
<span class="dismiss">✕</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- page head -->
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<div class="sub">欢迎回来 <b class="text-0">Zane</b> · workspace <code class="pill">zane-personal</code></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:10px;">
|
||||||
|
<div class="range-switch">
|
||||||
|
<button>1h</button>
|
||||||
|
<button>24h</button>
|
||||||
|
<button class="active">7d</button>
|
||||||
|
<button>30d</button>
|
||||||
|
<button>自定义</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
|
||||||
|
导出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- endpoint -->
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="method">POST</span>
|
||||||
|
<span class="url">https://ai.puro.im/v1/chat/completions</span>
|
||||||
|
<span class="copy">复制</span>
|
||||||
|
<span style="color:var(--text-3); margin: 0 4px;">·</span>
|
||||||
|
<span class="copy">查看文档</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- stats -->
|
||||||
|
<div class="stat-row">
|
||||||
|
<div class="stat credit">
|
||||||
|
<a class="topup" href="Pricing.html">充值 →</a>
|
||||||
|
<div class="stat-label">余额</div>
|
||||||
|
<div class="stat-value">$182.40<span class="unit">USD</span></div>
|
||||||
|
<div class="stat-delta neutral">按当前节奏约 <b class="text-1">23 天</b></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">今日请求</div>
|
||||||
|
<div class="stat-value tabular">12,847</div>
|
||||||
|
<div class="stat-delta">▲ 18.2% vs 昨日</div>
|
||||||
|
<svg class="stat-spark" viewBox="0 0 100 40" preserveAspectRatio="none">
|
||||||
|
<path d="M0,30 L10,28 L20,22 L30,25 L40,18 L50,20 L60,12 L70,15 L80,8 L90,12 L100,5" stroke="#22d3ee" stroke-width="1.5" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">消耗 Tokens</div>
|
||||||
|
<div class="stat-value tabular">4.82<span class="unit">M</span></div>
|
||||||
|
<div class="stat-delta">▲ 12.5%</div>
|
||||||
|
<svg class="stat-spark" viewBox="0 0 100 40" preserveAspectRatio="none">
|
||||||
|
<path d="M0,25 L10,20 L20,22 L30,15 L40,18 L50,10 L60,14 L70,8 L80,12 L90,6 L100,4" stroke="#a855f7" stroke-width="1.5" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">平均延迟</div>
|
||||||
|
<div class="stat-value tabular">214<span class="unit">ms</span></div>
|
||||||
|
<div class="stat-delta down">▲ 4.1% vs 昨日</div>
|
||||||
|
<svg class="stat-spark" viewBox="0 0 100 40" preserveAspectRatio="none">
|
||||||
|
<path d="M0,20 L10,22 L20,18 L30,24 L40,20 L50,22 L60,18 L70,24 L80,22 L90,26 L100,22" stroke="#fbbf24" stroke-width="1.5" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- chart + donut -->
|
||||||
|
<div class="chart-grid">
|
||||||
|
<div class="chart-card">
|
||||||
|
<div class="chart-head">
|
||||||
|
<span class="title">请求趋势</span>
|
||||||
|
<div class="legend">
|
||||||
|
<span><span class="sw" style="background:#22d3ee"></span>Claude</span>
|
||||||
|
<span><span class="sw" style="background:#a855f7"></span>GPT</span>
|
||||||
|
<span><span class="sw" style="background:#fbbf24"></span>Gemini</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg viewBox="0 0 500 180" style="width:100%; height:180px; display:block;">
|
||||||
|
<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="75" x2="500" y2="75"/>
|
||||||
|
<line x1="0" y1="120" x2="500" y2="120"/>
|
||||||
|
<line x1="0" y1="165" x2="500" y2="165"/>
|
||||||
|
</g>
|
||||||
|
<path d="M0,120 L40,100 L80,110 L120,80 L160,95 L200,60 L240,70 L280,45 L320,55 L360,30 L400,50 L440,35 L500,25 L500,180 L0,180 Z" fill="url(#gc)"/>
|
||||||
|
<path d="M0,120 L40,100 L80,110 L120,80 L160,95 L200,60 L240,70 L280,45 L320,55 L360,30 L400,50 L440,35 L500,25" stroke="#22d3ee" stroke-width="2" fill="none"/>
|
||||||
|
<path d="M0,140 L40,135 L80,120 L120,130 L160,110 L200,115 L240,90 L280,100 L320,80 L360,90 L400,70 L440,75 L500,55" stroke="#a855f7" stroke-width="2" fill="none"/>
|
||||||
|
<path d="M0,160 L40,155 L80,150 L120,148 L160,145 L200,140 L240,135 L280,130 L320,125 L360,122 L400,118 L440,112 L500,108" stroke="#fbbf24" stroke-width="2" fill="none"/>
|
||||||
|
<g font-family="JetBrains Mono" font-size="9" fill="#64748b">
|
||||||
|
<text x="0" y="178">Mon</text>
|
||||||
|
<text x="83" y="178">Tue</text>
|
||||||
|
<text x="166" y="178">Wed</text>
|
||||||
|
<text x="249" y="178">Thu</text>
|
||||||
|
<text x="332" y="178">Fri</text>
|
||||||
|
<text x="415" y="178">Sat</text>
|
||||||
|
<text x="475" y="178">Sun</text>
|
||||||
|
</g>
|
||||||
|
<!-- hover indicator -->
|
||||||
|
<line x1="360" y1="30" x2="360" y2="165" stroke="#334155" stroke-width="1" stroke-dasharray="2,3"/>
|
||||||
|
<circle cx="360" cy="30" r="3" fill="#22d3ee"/>
|
||||||
|
<g transform="translate(370, 18)">
|
||||||
|
<rect width="92" height="28" fill="#020617" stroke="#334155" rx="4"/>
|
||||||
|
<text x="8" y="12" font-family="JetBrains Mono" font-size="9" fill="#cbd5e1">Fri · 16:00</text>
|
||||||
|
<text x="8" y="23" font-family="JetBrains Mono" font-size="10" fill="#22d3ee">Claude 3,824</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-card">
|
||||||
|
<div class="chart-head">
|
||||||
|
<span class="title">模型分布</span>
|
||||||
|
<span class="mono text-xs text-3">7d</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:18px;">
|
||||||
|
<svg viewBox="0 0 42 42" style="width:120px; height:120px; transform:rotate(-90deg);">
|
||||||
|
<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 style="flex:1; display:flex; flex-direction:column; gap:8px; font-size:12px; font-family:var(--font-mono);">
|
||||||
|
<div style="display:flex; justify-content:space-between;"><span style="color:var(--text-1);"><span style="display:inline-block; width:8px; height:8px; background:#22d3ee; border-radius:2px; margin-right:6px;"></span>Claude</span><span style="color:var(--text-3);">48%</span></div>
|
||||||
|
<div style="display:flex; justify-content:space-between;"><span style="color:var(--text-1);"><span style="display:inline-block; width:8px; height:8px; background:#a855f7; border-radius:2px; margin-right:6px;"></span>GPT</span><span style="color:var(--text-3);">32%</span></div>
|
||||||
|
<div style="display:flex; justify-content:space-between;"><span style="color:var(--text-1);"><span style="display:inline-block; width:8px; height:8px; background:#fbbf24; border-radius:2px; margin-right:6px;"></span>Gemini</span><span style="color:var(--text-3);">14%</span></div>
|
||||||
|
<div style="display:flex; justify-content:space-between;"><span style="color:var(--text-1);"><span style="display:inline-block; width:8px; height:8px; background:#64748b; border-radius:2px; margin-right:6px;"></span>Codex</span><span style="color:var(--text-3);">6%</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- accounts + logs -->
|
||||||
|
<div class="two-col-grid">
|
||||||
|
<!-- accounts -->
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h3>订阅账号池</h3>
|
||||||
|
<a href="Accounts.html" class="meta" style="color:var(--cyan);">查看全部 →</a>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="acct-row">
|
||||||
|
<span class="acct-logo"><svg width="14" height="14" viewBox="0 0 24 24" fill="#d97757"><path d="M4.5 19L12 4l7.5 15H16l-4-8.5L8 19H4.5z"/></svg></span>
|
||||||
|
<div class="acct-name">claude-pool-01<span class="tag">Pro · OAuth</span></div>
|
||||||
|
<div class="acct-meter"><span style="width:68%"></span></div>
|
||||||
|
<span class="acct-status"><span class="status-chip" style="width:5px;height:5px;"></span>healthy</span>
|
||||||
|
</div>
|
||||||
|
<div class="acct-row">
|
||||||
|
<span class="acct-logo"><svg width="14" height="14" viewBox="0 0 24 24" fill="#d97757"><path d="M4.5 19L12 4l7.5 15H16l-4-8.5L8 19H4.5z"/></svg></span>
|
||||||
|
<div class="acct-name">claude-pool-02<span class="tag">Max · OAuth</span></div>
|
||||||
|
<div class="acct-meter"><span style="width:42%"></span></div>
|
||||||
|
<span class="acct-status"><span class="status-chip" style="width:5px;height:5px;"></span>healthy</span>
|
||||||
|
</div>
|
||||||
|
<div class="acct-row">
|
||||||
|
<span class="acct-logo"><svg width="14" height="14" 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></span>
|
||||||
|
<div class="acct-name">gpt-plus-7<span class="tag">Plus · OAuth</span></div>
|
||||||
|
<div class="acct-meter warn"><span style="width:88%"></span></div>
|
||||||
|
<span class="acct-status warn"><span class="status-chip amber" style="width:5px;height:5px;"></span>near limit</span>
|
||||||
|
</div>
|
||||||
|
<div class="acct-row">
|
||||||
|
<span class="acct-logo"><svg width="14" height="14" 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></span>
|
||||||
|
<div class="acct-name">gpt-plus-8<span class="tag">Pro · OAuth</span></div>
|
||||||
|
<div class="acct-meter"><span style="width:35%"></span></div>
|
||||||
|
<span class="acct-status"><span class="status-chip" style="width:5px;height:5px;"></span>healthy</span>
|
||||||
|
</div>
|
||||||
|
<div class="acct-row">
|
||||||
|
<span class="acct-logo"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="1.8"><path d="M12 2 L15 9 L22 10 L17 15 L19 22 L12 18 L5 22 L7 15 L2 10 L9 9 Z"/></svg></span>
|
||||||
|
<div class="acct-name">gemini-adv-01<span class="tag">Advanced</span></div>
|
||||||
|
<div class="acct-meter"><span style="width:12%"></span></div>
|
||||||
|
<span class="acct-status"><span class="status-chip" style="width:5px;height:5px;"></span>healthy</span>
|
||||||
|
</div>
|
||||||
|
<div class="acct-row">
|
||||||
|
<span class="acct-logo" style="opacity:0.5"><svg width="14" height="14" viewBox="0 0 24 24" fill="#d97757"><path d="M4.5 19L12 4l7.5 15H16l-4-8.5L8 19H4.5z"/></svg></span>
|
||||||
|
<div class="acct-name" style="opacity:0.7">claude-pool-03<span class="tag">Pro · expired</span></div>
|
||||||
|
<div class="acct-meter"><span style="width:0%"></span></div>
|
||||||
|
<span class="acct-status off">● offline</span>
|
||||||
|
</div>
|
||||||
|
<a class="add-acct" href="Binding.html">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
绑定新订阅
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- logs -->
|
||||||
|
<div class="panel log-wrap">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h3>最近请求</h3>
|
||||||
|
<span class="meta">live · 12 of 18,294</span>
|
||||||
|
</div>
|
||||||
|
<table class="tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="mono">TIME</th>
|
||||||
|
<th>ACCOUNT</th>
|
||||||
|
<th class="mono">MODEL</th>
|
||||||
|
<th class="mono">TOKENS</th>
|
||||||
|
<th class="mono">COST</th>
|
||||||
|
<th class="mono">STATUS</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="mono">13:42:18</td>
|
||||||
|
<td><span class="provider claude"><span class="dot"></span>claude-pool-01</span></td>
|
||||||
|
<td class="mono">sonnet-4-5</td>
|
||||||
|
<td class="mono tabular">2,847</td>
|
||||||
|
<td class="mono tabular">$0.042</td>
|
||||||
|
<td class="mono text-green">200 · 213ms</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="mono">13:42:11</td>
|
||||||
|
<td><span class="provider gpt"><span class="dot"></span>gpt-plus-7</span></td>
|
||||||
|
<td class="mono">gpt-5-codex</td>
|
||||||
|
<td class="mono tabular">1,204</td>
|
||||||
|
<td class="mono tabular">$0.018</td>
|
||||||
|
<td class="mono text-green">200 · 167ms</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="mono">13:42:03</td>
|
||||||
|
<td><span class="provider gemini"><span class="dot"></span>gemini-adv-01</span></td>
|
||||||
|
<td class="mono">gemini-2.5-pro</td>
|
||||||
|
<td class="mono tabular">4,102</td>
|
||||||
|
<td class="mono tabular">$0.000</td>
|
||||||
|
<td class="mono text-green">200 · 392ms</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="mono">13:41:58</td>
|
||||||
|
<td><span class="provider claude"><span class="dot"></span>claude-pool-02</span></td>
|
||||||
|
<td class="mono">sonnet-4-5</td>
|
||||||
|
<td class="mono tabular">6,318</td>
|
||||||
|
<td class="mono tabular">$0.095</td>
|
||||||
|
<td class="mono text-green">200 · 288ms</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="mono">13:41:49</td>
|
||||||
|
<td><span class="provider gpt"><span class="dot"></span>gpt-plus-7</span></td>
|
||||||
|
<td class="mono">gpt-5</td>
|
||||||
|
<td class="mono tabular">892</td>
|
||||||
|
<td class="mono tabular">$0.013</td>
|
||||||
|
<td class="mono text-amber">429 · retry</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="mono">13:41:42</td>
|
||||||
|
<td><span class="provider gpt"><span class="dot"></span>gpt-plus-8</span></td>
|
||||||
|
<td class="mono">gpt-5</td>
|
||||||
|
<td class="mono tabular">892</td>
|
||||||
|
<td class="mono tabular">$0.013</td>
|
||||||
|
<td class="mono text-green">200 · 198ms</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="mono">13:41:35</td>
|
||||||
|
<td><span class="provider claude"><span class="dot"></span>claude-pool-01</span></td>
|
||||||
|
<td class="mono">haiku-4-5</td>
|
||||||
|
<td class="mono tabular">512</td>
|
||||||
|
<td class="mono tabular">$0.004</td>
|
||||||
|
<td class="mono text-green">200 · 98ms</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="mono">13:41:28</td>
|
||||||
|
<td><span class="provider gemini"><span class="dot"></span>gemini-adv-01</span></td>
|
||||||
|
<td class="mono">gemini-2.5-flash</td>
|
||||||
|
<td class="mono tabular">1,824</td>
|
||||||
|
<td class="mono tabular">$0.000</td>
|
||||||
|
<td class="mono text-green">200 · 156ms</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
670
docs/design-drafts/v2/Design System.html
Normal file
670
docs/design-drafts/v2/Design System.html
Normal file
@@ -0,0 +1,670 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
|
<title>Design System — PURO AI</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="puro.css">
|
||||||
|
<style>
|
||||||
|
/* page-local helpers */
|
||||||
|
.ds-header {
|
||||||
|
padding: 72px 0 48px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 56px;
|
||||||
|
}
|
||||||
|
.ds-header h1 { font-size: 48px; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 12px; }
|
||||||
|
.ds-header p { color: var(--text-2); max-width: 600px; line-height: 1.6; }
|
||||||
|
.ds-header .meta {
|
||||||
|
display: flex; gap: 18px; margin-top: 24px;
|
||||||
|
font-family: var(--font-mono); font-size: 12px; color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-section { padding: 40px 0; border-bottom: 1px dashed var(--border); }
|
||||||
|
.ds-section:last-child { border-bottom: none; }
|
||||||
|
.ds-section > h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.ds-section > h2 .mono-num {
|
||||||
|
color: var(--cyan);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-right: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.ds-section > .desc {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* color swatches */
|
||||||
|
.swatch-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.sw {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
padding: 14px;
|
||||||
|
background: rgba(15, 23, 42, 0.4);
|
||||||
|
}
|
||||||
|
.sw .chip {
|
||||||
|
width: 100%;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
border: 1px solid rgba(255,255,255,0.04);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 0; background: transparent;
|
||||||
|
}
|
||||||
|
.sw .name { font-size: 12px; font-weight: 600; color: var(--text-0); }
|
||||||
|
.sw .hex { font-family: var(--font-mono); font-size: 11px; color: var(--text-3); margin-top: 2px; }
|
||||||
|
.sw .use { font-size: 11px; color: var(--text-3); margin-top: 4px; }
|
||||||
|
|
||||||
|
/* type scale */
|
||||||
|
.type-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
.type-row:last-child { border-bottom: none; }
|
||||||
|
.type-meta { font-family: var(--font-mono); font-size: 11px; color: var(--text-3); }
|
||||||
|
.type-meta b { color: var(--text-1); font-weight: 500; }
|
||||||
|
|
||||||
|
/* component examples */
|
||||||
|
.example {
|
||||||
|
background: rgba(2, 6, 23, 0.35);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
padding: 28px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.example.dense { padding: 20px; }
|
||||||
|
.example-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* spacing scale */
|
||||||
|
.space-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 90px 1fr 100px;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.space-row .bar { height: 10px; background: var(--cyan); border-radius: 2px; opacity: 0.7; }
|
||||||
|
.space-row .note { color: var(--text-3); text-align: right; }
|
||||||
|
|
||||||
|
/* radius scale */
|
||||||
|
.radius-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.radius-card {
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
text-align: center;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.radius-card .token { font-size: 11px; color: var(--text-3); margin-top: 4px; }
|
||||||
|
|
||||||
|
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.three-col { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
|
||||||
|
|
||||||
|
/* logo lockup block */
|
||||||
|
.logo-showcase {
|
||||||
|
padding: 56px 32px;
|
||||||
|
background:
|
||||||
|
radial-gradient(600px 300px at 20% 0%, rgba(34,211,238,0.12), transparent 60%),
|
||||||
|
radial-gradient(600px 300px at 80% 100%, rgba(168,85,247,0.12), transparent 60%),
|
||||||
|
var(--bg-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-xl);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.logo-showcase svg { width: 56px; height: 56px; color: var(--cyan); }
|
||||||
|
.logo-showcase .word {
|
||||||
|
font-size: 38px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.logo-showcase .word span { color: var(--cyan); }
|
||||||
|
|
||||||
|
/* TOC */
|
||||||
|
.toc {
|
||||||
|
position: sticky;
|
||||||
|
top: 24px;
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
.toc .toc-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.toc ol { list-style: none; counter-reset: toc; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.toc li { counter-increment: toc; }
|
||||||
|
.toc a {
|
||||||
|
display: block;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all .12s;
|
||||||
|
}
|
||||||
|
.toc a::before {
|
||||||
|
content: counter(toc, decimal-leading-zero);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
.toc a:hover { color: var(--text-0); background: rgba(255,255,255,0.02); }
|
||||||
|
|
||||||
|
.with-toc {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 200px 1fr;
|
||||||
|
gap: 48px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="bg-glow soft"></div>
|
||||||
|
<div class="grain"></div>
|
||||||
|
|
||||||
|
<nav class="nav">
|
||||||
|
<div class="container nav-inner">
|
||||||
|
<a class="brand" href="Landing.html">
|
||||||
|
<svg class="hex" viewBox="0 0 24 24" 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>
|
||||||
|
PURO AI
|
||||||
|
</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="Landing.html">Landing</a>
|
||||||
|
<a href="#" class="active">Design System</a>
|
||||||
|
<a href="Dashboard.html">Dashboard</a>
|
||||||
|
<a href="Docs.html">文档</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-cta">
|
||||||
|
<a href="Login.html" class="btn btn-ghost">登录</a>
|
||||||
|
<a href="Register.html" class="btn btn-primary">注册</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<!-- HEADER -->
|
||||||
|
<header class="ds-header">
|
||||||
|
<div class="section-kicker">// design system · v1.0</div>
|
||||||
|
<h1>PURO AI Design System</h1>
|
||||||
|
<p>一套用来构建 PURO AI 所有界面的原子 token 和组件。产品的视觉语言围绕「开发者工具 · 深色为主 · 青色作为行动色 · JetBrains Mono 强调技术感」展开。</p>
|
||||||
|
<div class="meta">
|
||||||
|
<span>tokens <b class="text-1">·</b> 29</span>
|
||||||
|
<span>components <b class="text-1">·</b> 22</span>
|
||||||
|
<span>last updated · 2026.04.19</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="with-toc">
|
||||||
|
<aside class="toc">
|
||||||
|
<div class="toc-label">Contents</div>
|
||||||
|
<ol>
|
||||||
|
<li><a href="#brand">Brand</a></li>
|
||||||
|
<li><a href="#colors">Colors</a></li>
|
||||||
|
<li><a href="#type">Typography</a></li>
|
||||||
|
<li><a href="#spacing">Spacing</a></li>
|
||||||
|
<li><a href="#radius">Radius</a></li>
|
||||||
|
<li><a href="#buttons">Buttons</a></li>
|
||||||
|
<li><a href="#badges">Badges</a></li>
|
||||||
|
<li><a href="#chips">Chips</a></li>
|
||||||
|
<li><a href="#forms">Forms</a></li>
|
||||||
|
<li><a href="#cards">Cards</a></li>
|
||||||
|
<li><a href="#tables">Tables</a></li>
|
||||||
|
<li><a href="#code">Code</a></li>
|
||||||
|
<li><a href="#nav">Nav</a></li>
|
||||||
|
</ol>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<!-- BRAND -->
|
||||||
|
<section class="ds-section" id="brand">
|
||||||
|
<h2><span class="mono-num">01</span>Brand Lockup</h2>
|
||||||
|
<p class="desc">六边形 + 内部实心菱形 — 代表"订阅被聚合成一个 key"。单色在小尺寸下使用,大尺寸下保留内描边增加分量。</p>
|
||||||
|
<div class="logo-showcase">
|
||||||
|
<svg viewBox="0 0 24 24" 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>
|
||||||
|
<div class="word">PURO<span>.</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- COLORS -->
|
||||||
|
<section class="ds-section" id="colors">
|
||||||
|
<h2><span class="mono-num">02</span>Colors</h2>
|
||||||
|
<p class="desc">所有颜色都以 CSS variables 定义在 <code class="pill">puro.css</code>。青色 (cyan) 是唯一的品牌色,其他颜色仅承担语义职责(success / warn / danger)。</p>
|
||||||
|
|
||||||
|
<div class="ds-label">Surfaces</div>
|
||||||
|
<div class="swatch-grid">
|
||||||
|
<div class="sw"><div class="chip" style="background:#0a0e1a"></div><div class="name">bg-0</div><div class="hex">#0a0e1a</div><div class="use">page</div></div>
|
||||||
|
<div class="sw"><div class="chip" style="background:#0f172a"></div><div class="name">bg-1</div><div class="hex">#0f172a</div><div class="use">raised</div></div>
|
||||||
|
<div class="sw"><div class="chip" style="background:#111827"></div><div class="name">bg-2</div><div class="hex">#111827</div><div class="use">card alt</div></div>
|
||||||
|
<div class="sw"><div class="chip" style="background:#020617"></div><div class="name">bg-code</div><div class="hex">#020617</div><div class="use">code</div></div>
|
||||||
|
<div class="sw"><div class="chip" style="background:#1e293b"></div><div class="name">border</div><div class="hex">#1e293b</div><div class="use">hairline</div></div>
|
||||||
|
<div class="sw"><div class="chip" style="background:#334155"></div><div class="name">border-2</div><div class="hex">#334155</div><div class="use">strong</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ds-label" style="margin-top:32px">Text</div>
|
||||||
|
<div class="swatch-grid">
|
||||||
|
<div class="sw"><div class="chip" style="background:#f8fafc"></div><div class="name">text-0</div><div class="hex">#f8fafc</div><div class="use">primary</div></div>
|
||||||
|
<div class="sw"><div class="chip" style="background:#cbd5e1"></div><div class="name">text-1</div><div class="hex">#cbd5e1</div><div class="use">body</div></div>
|
||||||
|
<div class="sw"><div class="chip" style="background:#94a3b8"></div><div class="name">text-2</div><div class="hex">#94a3b8</div><div class="use">muted</div></div>
|
||||||
|
<div class="sw"><div class="chip" style="background:#64748b"></div><div class="name">text-3</div><div class="hex">#64748b</div><div class="use">hint</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ds-label" style="margin-top:32px">Accents</div>
|
||||||
|
<div class="swatch-grid">
|
||||||
|
<div class="sw"><div class="chip" style="background:#22d3ee"></div><div class="name">cyan</div><div class="hex">#22d3ee</div><div class="use">primary / cta</div></div>
|
||||||
|
<div class="sw"><div class="chip" style="background:#a855f7"></div><div class="name">purple</div><div class="hex">#a855f7</div><div class="use">secondary glow</div></div>
|
||||||
|
<div class="sw"><div class="chip" style="background:#fbbf24"></div><div class="name">amber</div><div class="hex">#fbbf24</div><div class="use">warn / featured</div></div>
|
||||||
|
<div class="sw"><div class="chip" style="background:#34d399"></div><div class="name">green</div><div class="hex">#34d399</div><div class="use">success / 200</div></div>
|
||||||
|
<div class="sw"><div class="chip" style="background:#f87171"></div><div class="name">red</div><div class="hex">#f87171</div><div class="use">error / 5xx</div></div>
|
||||||
|
<div class="sw"><div class="chip" style="background:#fb923c"></div><div class="name">orange</div><div class="hex">#fb923c</div><div class="use">flag / highlight</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ds-label" style="margin-top:32px">Provider Brand Dots</div>
|
||||||
|
<div class="swatch-grid">
|
||||||
|
<div class="sw"><div class="chip" style="background:#d97757"></div><div class="name">claude</div><div class="hex">#d97757</div><div class="use">Anthropic</div></div>
|
||||||
|
<div class="sw"><div class="chip" style="background:#10a37f"></div><div class="name">gpt</div><div class="hex">#10a37f</div><div class="use">OpenAI</div></div>
|
||||||
|
<div class="sw"><div class="chip" style="background:#4285f4"></div><div class="name">gemini</div><div class="hex">#4285f4</div><div class="use">Google</div></div>
|
||||||
|
<div class="sw"><div class="chip" style="background:#f0a030"></div><div class="name">codex</div><div class="hex">#f0a030</div><div class="use">Codex</div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TYPOGRAPHY -->
|
||||||
|
<section class="ds-section" id="type">
|
||||||
|
<h2><span class="mono-num">03</span>Typography</h2>
|
||||||
|
<p class="desc">主字体 <b>Inter</b> · 等宽 <b>JetBrains Mono</b>。等宽仅用于代码、数据、时间戳、状态徽标,以强化开发者语境。</p>
|
||||||
|
|
||||||
|
<div class="type-row">
|
||||||
|
<div class="type-meta">display · 56/64 · 800</div>
|
||||||
|
<div style="font-size:56px; font-weight:800; letter-spacing:-0.03em; line-height:1.05;">你的 AI 订阅</div>
|
||||||
|
</div>
|
||||||
|
<div class="type-row">
|
||||||
|
<div class="type-meta">h1 · 40/48 · 700</div>
|
||||||
|
<div style="font-size:40px; font-weight:700; letter-spacing:-0.02em;">统一接入 API</div>
|
||||||
|
</div>
|
||||||
|
<div class="type-row">
|
||||||
|
<div class="type-meta">h2 · 28/36 · 700</div>
|
||||||
|
<div style="font-size:28px; font-weight:700; letter-spacing:-0.02em;">付一次,用一池</div>
|
||||||
|
</div>
|
||||||
|
<div class="type-row">
|
||||||
|
<div class="type-meta">h3 · 18/26 · 600</div>
|
||||||
|
<div style="font-size:18px; font-weight:600;">多账号自动调度</div>
|
||||||
|
</div>
|
||||||
|
<div class="type-row">
|
||||||
|
<div class="type-meta">body · 14/22 · 400</div>
|
||||||
|
<div style="font-size:14px;">OAuth 绑定账号,零改动切换 base_url,沿用你习惯的 SDK。</div>
|
||||||
|
</div>
|
||||||
|
<div class="type-row">
|
||||||
|
<div class="type-meta">caption · 12/18 · 400</div>
|
||||||
|
<div style="font-size:12px; color:var(--text-2);">某个 ChatGPT Plus 触发限流会自动 failover。</div>
|
||||||
|
</div>
|
||||||
|
<div class="type-row">
|
||||||
|
<div class="type-meta">mono · 13 · 500</div>
|
||||||
|
<div class="mono" style="font-size:13px; color:var(--cyan);">curl https://ai.puro.im/v1/chat/completions</div>
|
||||||
|
</div>
|
||||||
|
<div class="type-row">
|
||||||
|
<div class="type-meta">kicker · mono · 12 · caps</div>
|
||||||
|
<div class="section-kicker" style="margin:0">// section kicker</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SPACING -->
|
||||||
|
<section class="ds-section" id="spacing">
|
||||||
|
<h2><span class="mono-num">04</span>Spacing Scale</h2>
|
||||||
|
<p class="desc">4px 基线的 8 / 12 / 16 / 20 / 24 / 32 / 48 / 64 scale。页面垂直节奏用 32/48/64/96,卡片内部用 16/20/24。</p>
|
||||||
|
<div>
|
||||||
|
<div class="space-row"><span>4px</span><div class="bar" style="width:4px"></div><span class="note">gap-xs · pill 间隔</span></div>
|
||||||
|
<div class="space-row"><span>8px</span><div class="bar" style="width:8px"></div><span class="note">gap-sm · icon 内外距</span></div>
|
||||||
|
<div class="space-row"><span>12px</span><div class="bar" style="width:12px"></div><span class="note">gap · 卡片网格</span></div>
|
||||||
|
<div class="space-row"><span>16px</span><div class="bar" style="width:16px"></div><span class="note">stack-sm · 主要网格</span></div>
|
||||||
|
<div class="space-row"><span>20px</span><div class="bar" style="width:20px"></div><span class="note">form field</span></div>
|
||||||
|
<div class="space-row"><span>24px</span><div class="bar" style="width:24px"></div><span class="note">card padding</span></div>
|
||||||
|
<div class="space-row"><span>32px</span><div class="bar" style="width:32px"></div><span class="note">content padding</span></div>
|
||||||
|
<div class="space-row"><span>48px</span><div class="bar" style="width:48px"></div><span class="note">section break</span></div>
|
||||||
|
<div class="space-row"><span>64px</span><div class="bar" style="width:64px"></div><span class="note">section head gap</span></div>
|
||||||
|
<div class="space-row"><span>96px</span><div class="bar" style="width:96px"></div><span class="note">landing section</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- RADIUS -->
|
||||||
|
<section class="ds-section" id="radius">
|
||||||
|
<h2><span class="mono-num">05</span>Radius & Shadow</h2>
|
||||||
|
<div class="radius-grid">
|
||||||
|
<div class="radius-card" style="border-radius:6px">6px<div class="token">--r-sm</div></div>
|
||||||
|
<div class="radius-card" style="border-radius:8px">8px<div class="token">--r-md (button, input)</div></div>
|
||||||
|
<div class="radius-card" style="border-radius:12px">12px<div class="token">--r-lg (card)</div></div>
|
||||||
|
<div class="radius-card" style="border-radius:16px">16px<div class="token">--r-xl (hero card)</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ds-label" style="margin-top:28px">Elevation</div>
|
||||||
|
<div class="two-col">
|
||||||
|
<div class="card-raised" style="padding:24px; box-shadow:var(--shadow-lg);">
|
||||||
|
<div class="mono text-xs text-3">--shadow-lg</div>
|
||||||
|
<div class="text-sm text-1" style="margin-top:8px">卡片悬浮 · 代码面板</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-raised" style="padding:24px; box-shadow:var(--shadow-xl);">
|
||||||
|
<div class="mono text-xs text-3">--shadow-xl</div>
|
||||||
|
<div class="text-sm text-1" style="margin-top:8px">仪表盘大图 · 对话框</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- BUTTONS -->
|
||||||
|
<section class="ds-section" id="buttons">
|
||||||
|
<h2><span class="mono-num">06</span>Buttons</h2>
|
||||||
|
<p class="desc">唯一的主色按钮 Primary(青色),其余都是 Ghost/Subtle。没有多种 primary —— 让每个页面最重要的那个 CTA 足够显眼。</p>
|
||||||
|
|
||||||
|
<div class="ds-label">Variants</div>
|
||||||
|
<div class="example">
|
||||||
|
<div class="example-row">
|
||||||
|
<button class="btn btn-primary">立即开始 →</button>
|
||||||
|
<button class="btn btn-ghost">查看文档</button>
|
||||||
|
<button class="btn btn-subtle">跳过</button>
|
||||||
|
<button class="btn btn-danger">解绑账号</button>
|
||||||
|
<button class="btn btn-primary" disabled>禁用</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ds-label" style="margin-top:20px">Sizes</div>
|
||||||
|
<div class="example">
|
||||||
|
<div class="example-row">
|
||||||
|
<button class="btn btn-primary btn-lg">btn-lg · 注册</button>
|
||||||
|
<button class="btn btn-primary">btn · 默认</button>
|
||||||
|
<button class="btn btn-primary btn-sm">btn-sm · 复制</button>
|
||||||
|
<button class="btn btn-ghost btn-icon" aria-label="设置">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h0a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v0a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ds-label" style="margin-top:20px">Loading</div>
|
||||||
|
<div class="example">
|
||||||
|
<div class="example-row">
|
||||||
|
<button class="btn btn-primary loading"><span class="spinner"></span><span class="label">提交中</span></button>
|
||||||
|
<button class="btn btn-ghost loading"><span class="spinner" style="border-top-color:var(--text-0)"></span><span class="label">加载</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- BADGES -->
|
||||||
|
<section class="ds-section" id="badges">
|
||||||
|
<h2><span class="mono-num">07</span>Badges</h2>
|
||||||
|
<div class="example">
|
||||||
|
<div class="example-row">
|
||||||
|
<span class="badge">NEW</span>
|
||||||
|
<span class="badge purple">BETA</span>
|
||||||
|
<span class="badge amber">LIMITED</span>
|
||||||
|
<span class="badge green">ACTIVE</span>
|
||||||
|
<span class="badge red">EXPIRED</span>
|
||||||
|
<span class="badge muted">DRAFT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CHIPS -->
|
||||||
|
<section class="ds-section" id="chips">
|
||||||
|
<h2><span class="mono-num">08</span>Chips & Status</h2>
|
||||||
|
<p class="desc">chip 用于在代码块周围承载"路由/参数/标签"信息,status-chip 是一个绝对定位的单像素点,用于显示账号/节点在线状态。</p>
|
||||||
|
<div class="example">
|
||||||
|
<div class="example-row">
|
||||||
|
<span class="chip claude"><span class="dot"></span>claude-pool-03</span>
|
||||||
|
<span class="chip gpt"><span class="dot"></span>gpt-plus-7</span>
|
||||||
|
<span class="chip gemini"><span class="dot"></span>gemini-2</span>
|
||||||
|
<span class="chip codex"><span class="dot"></span>codex-pool-01</span>
|
||||||
|
<span class="chip"><span class="dot"></span>200 · 213ms</span>
|
||||||
|
</div>
|
||||||
|
<div class="example-row" style="margin-top:16px">
|
||||||
|
<span class="pill">OpenAI SDK</span>
|
||||||
|
<span class="pill">Anthropic SDK</span>
|
||||||
|
<span class="pill">/v1/chat/completions</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FORMS -->
|
||||||
|
<section class="ds-section" id="forms">
|
||||||
|
<h2><span class="mono-num">09</span>Form Fields</h2>
|
||||||
|
<div class="example">
|
||||||
|
<div class="two-col" style="gap:24px">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">邮箱</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/></svg>
|
||||||
|
</span>
|
||||||
|
<input class="input with-icon" type="email" placeholder="you@puro.im">
|
||||||
|
</div>
|
||||||
|
<div class="field-hint">默认 · 空态</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">API Key 名称</label>
|
||||||
|
<input class="input ok" type="text" value="production">
|
||||||
|
<div class="field-hint" style="color:var(--green)">✓ 可用</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">密码</label>
|
||||||
|
<input class="input error" type="password" value="12345">
|
||||||
|
<div class="field-error">密码至少 8 位,包含数字和字母</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">平台</label>
|
||||||
|
<select class="input">
|
||||||
|
<option>Claude Pro / Max</option>
|
||||||
|
<option>ChatGPT Plus / Pro</option>
|
||||||
|
<option>Gemini Advanced</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:18px; display:flex; gap:16px;">
|
||||||
|
<label class="check"><input type="checkbox" checked><span class="box"></span>接受服务协议</label>
|
||||||
|
<label class="check"><input type="checkbox"><span class="box"></span>订阅更新邮件</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CARDS -->
|
||||||
|
<section class="ds-section" id="cards">
|
||||||
|
<h2><span class="mono-num">10</span>Cards</h2>
|
||||||
|
<div class="three-col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="section-kicker" style="margin-bottom:8px">// default</div>
|
||||||
|
<div class="text-lg fw-600" style="margin-bottom:6px">标准卡片</div>
|
||||||
|
<div class="text-sm text-2">用于所有常规内容容器,12px 圆角 + 1px border。</div>
|
||||||
|
</div>
|
||||||
|
<div class="card card-interactive">
|
||||||
|
<div class="section-kicker" style="margin-bottom:8px">// hover</div>
|
||||||
|
<div class="text-lg fw-600" style="margin-bottom:6px">可交互卡片</div>
|
||||||
|
<div class="text-sm text-2">hover 时向上位移 2px,border 加深。</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-raised" style="padding:24px">
|
||||||
|
<div class="section-kicker" style="margin-bottom:8px">// raised</div>
|
||||||
|
<div class="text-lg fw-600" style="margin-bottom:6px">Raised 卡片</div>
|
||||||
|
<div class="text-sm text-2">不透明背景,用于浮层/仪表盘主体。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TABLES -->
|
||||||
|
<section class="ds-section" id="tables">
|
||||||
|
<h2><span class="mono-num">11</span>Tables</h2>
|
||||||
|
<p class="desc">主要用于请求日志、API Key 列表、计费记录。数字列一律等宽 tabular-nums。</p>
|
||||||
|
<div class="card" style="padding:0; overflow:hidden;">
|
||||||
|
<table class="tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="mono">TIME</th>
|
||||||
|
<th>ACCOUNT</th>
|
||||||
|
<th class="mono">MODEL</th>
|
||||||
|
<th class="mono">TOKENS</th>
|
||||||
|
<th class="mono">COST</th>
|
||||||
|
<th class="mono">STATUS</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="mono">13:42:18</td>
|
||||||
|
<td><span class="provider claude"><span class="dot"></span>claude-3</span></td>
|
||||||
|
<td class="mono">sonnet-4-5</td>
|
||||||
|
<td class="mono tabular">2,847</td>
|
||||||
|
<td class="mono tabular">$0.042</td>
|
||||||
|
<td class="mono text-green">200</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="mono">13:42:11</td>
|
||||||
|
<td><span class="provider gpt"><span class="dot"></span>gpt-plus-7</span></td>
|
||||||
|
<td class="mono">gpt-5-codex</td>
|
||||||
|
<td class="mono tabular">1,204</td>
|
||||||
|
<td class="mono tabular">$0.018</td>
|
||||||
|
<td class="mono text-green">200</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="mono">13:42:03</td>
|
||||||
|
<td><span class="provider gemini"><span class="dot"></span>gemini-2</span></td>
|
||||||
|
<td class="mono">gemini-2.5-pro</td>
|
||||||
|
<td class="mono tabular">4,102</td>
|
||||||
|
<td class="mono tabular">$0.000</td>
|
||||||
|
<td class="mono text-amber">429</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CODE -->
|
||||||
|
<section class="ds-section" id="code">
|
||||||
|
<h2><span class="mono-num">12</span>Code Frame</h2>
|
||||||
|
<div class="code-frame">
|
||||||
|
<div class="code-head">
|
||||||
|
<div class="traffic"><span></span><span></span><span></span></div>
|
||||||
|
<span class="mono text-xs text-3">zsh · puro ≈ 210ms</span>
|
||||||
|
</div>
|
||||||
|
<pre class="code-body"><div class="line"><span class="ln">1</span><span><span class="com"># OpenAI SDK · 零改动</span></span></div><div class="line"><span class="ln">2</span><span><span class="kw">from</span> openai <span class="kw">import</span> OpenAI</span></div><div class="line"><span class="ln">3</span><span> </span></div><div class="line"><span class="ln">4</span><span>client = <span class="fn">OpenAI</span>(</span></div><div class="line"><span class="ln">5</span><span> <span class="prop">base_url</span>=<span class="str">"https://ai.puro.im/v1"</span>,</span></div><div class="line"><span class="ln">6</span><span> <span class="prop">api_key</span>=<span class="str">"sk-puro-••••"</span>,</span></div><div class="line"><span class="ln">7</span><span>)</span></div></pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- NAV -->
|
||||||
|
<section class="ds-section" id="nav">
|
||||||
|
<h2><span class="mono-num">13</span>Navigation</h2>
|
||||||
|
<p class="desc">顶部导航 <code class="pill">.nav</code> 用于 marketing 页面,<code class="pill">.app-shell</code> 带侧边栏用于登录后的应用页面。</p>
|
||||||
|
<div class="ds-label">Top Nav (Marketing)</div>
|
||||||
|
<div class="example dense" style="padding:0">
|
||||||
|
<nav style="position:relative;">
|
||||||
|
<div class="container nav-inner" style="padding: 0 24px;">
|
||||||
|
<span class="brand">
|
||||||
|
<svg class="hex" viewBox="0 0 24 24" 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>
|
||||||
|
PURO AI
|
||||||
|
</span>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="#" class="active">产品</a>
|
||||||
|
<a href="#">定价</a>
|
||||||
|
<a href="#">文档</a>
|
||||||
|
<a href="#" class="disabled">Changelog</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-cta">
|
||||||
|
<a class="btn btn-ghost">登录</a>
|
||||||
|
<a class="btn btn-primary">开始使用</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ds-label" style="margin-top:24px">Side Nav (App)</div>
|
||||||
|
<div class="example dense" style="padding:0; overflow:hidden;">
|
||||||
|
<div style="display:grid; grid-template-columns:240px 1fr; min-height:280px;">
|
||||||
|
<div style="border-right:1px solid var(--border); padding:18px 14px; background:rgba(2,6,23,0.6);">
|
||||||
|
<div class="brand" style="padding:6px 10px 18px">
|
||||||
|
<svg class="hex" viewBox="0 0 24 24" 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>
|
||||||
|
PURO
|
||||||
|
</div>
|
||||||
|
<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<span class="count">3</span></div>
|
||||||
|
<div class="side-item"><span class="ico">⊡</span>订阅账号<span class="count">7</span></div>
|
||||||
|
<div class="side-item"><span class="ico">▤</span>调用日志</div>
|
||||||
|
<div class="side-item"><span class="ico">Ⅹ</span>账单</div>
|
||||||
|
</div>
|
||||||
|
<div class="side-group" style="margin-top:20px">
|
||||||
|
<div class="side-label">Settings</div>
|
||||||
|
<div class="side-item"><span class="ico">☰</span>账户</div>
|
||||||
|
<div class="side-item"><span class="ico">❖</span>团队</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:24px; display:flex; align-items:center; justify-content:center; color:var(--text-3);">
|
||||||
|
<div class="text-sm mono">app content →</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer style="margin-top:80px; padding:40px 0; border-top:1px solid var(--border); font-family:var(--font-mono); font-size:12px; color:var(--text-3); display:flex; justify-content:space-between;">
|
||||||
|
<span>PURO AI · Design System v1.0</span>
|
||||||
|
<span>© 2026 · built with puro.css</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
623
docs/design-drafts/v2/Docs.html
Normal file
623
docs/design-drafts/v2/Docs.html
Normal file
@@ -0,0 +1,623 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
|
<title>Docs — PURO AI</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="puro.css">
|
||||||
|
<style>
|
||||||
|
.docs-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 240px 1fr 220px;
|
||||||
|
max-width: 1440px;
|
||||||
|
margin: 0 auto;
|
||||||
|
gap: 48px;
|
||||||
|
padding: 40px 32px 80px;
|
||||||
|
}
|
||||||
|
/* left nav */
|
||||||
|
.docs-nav {
|
||||||
|
position: sticky; top: 32px; align-self: flex-start;
|
||||||
|
font-size: 13px;
|
||||||
|
max-height: calc(100vh - 64px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.docs-nav .brand-line {
|
||||||
|
padding: 4px 10px 24px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-3);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.docs-nav .nav-search {
|
||||||
|
margin-bottom: 18px; position: relative;
|
||||||
|
}
|
||||||
|
.docs-nav .nav-search input {
|
||||||
|
width: 100%; height: 32px;
|
||||||
|
padding: 0 10px 0 32px;
|
||||||
|
background: rgba(2, 6, 23, 0.5);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
color: var(--text-1); font-size: 12px;
|
||||||
|
outline: none; font-family: inherit;
|
||||||
|
}
|
||||||
|
.docs-nav .nav-search::before {
|
||||||
|
content: ""; position: absolute; left: 10px; top: 50%;
|
||||||
|
width: 12px; height: 12px; transform: translateY(-50%);
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'><circle cx='11' cy='11' r='7'/><path d='m20 20-3.5-3.5'/></svg>");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
.docs-nav-section {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.docs-nav-label {
|
||||||
|
font-family: var(--font-mono); font-size: 10px;
|
||||||
|
color: var(--text-3); letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0 10px 8px;
|
||||||
|
}
|
||||||
|
.docs-nav-item {
|
||||||
|
display: block;
|
||||||
|
padding: 5px 10px;
|
||||||
|
color: var(--text-2);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
transition: all .1s;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.docs-nav-item:hover { color: var(--text-0); background: rgba(255,255,255,0.02); }
|
||||||
|
.docs-nav-item.active {
|
||||||
|
color: var(--cyan);
|
||||||
|
background: rgba(34, 211, 238, 0.06);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* content */
|
||||||
|
.docs-body { min-width: 0; }
|
||||||
|
.docs-crumbs {
|
||||||
|
font-family: var(--font-mono); font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.docs-crumbs .sep { color: var(--border-2); margin: 0 6px; }
|
||||||
|
.docs-crumbs .current { color: var(--cyan); }
|
||||||
|
|
||||||
|
.docs-body h1 {
|
||||||
|
font-size: 38px; font-weight: 700;
|
||||||
|
letter-spacing: -0.02em; margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.docs-body .lede {
|
||||||
|
font-size: 16px; color: var(--text-2);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-body h2 {
|
||||||
|
font-size: 22px; font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 44px 0 12px;
|
||||||
|
padding-top: 14px;
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
}
|
||||||
|
.docs-body h2::before {
|
||||||
|
content: "";
|
||||||
|
width: 3px; height: 22px; background: var(--cyan);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.docs-body h3 {
|
||||||
|
font-size: 16px; font-weight: 600;
|
||||||
|
margin: 22px 0 8px;
|
||||||
|
}
|
||||||
|
.docs-body p {
|
||||||
|
color: var(--text-1);
|
||||||
|
line-height: 1.72;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.docs-body p code, .docs-body li code, .docs-body td code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: rgba(2, 6, 23, 0.6);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
.docs-body ul { margin-bottom: 14px; padding-left: 20px; }
|
||||||
|
.docs-body li {
|
||||||
|
color: var(--text-1); line-height: 1.7; font-size: 14px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* callout */
|
||||||
|
.callout {
|
||||||
|
padding: 14px 18px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--cyan);
|
||||||
|
border-radius: 0 var(--r-md) var(--r-md) 0;
|
||||||
|
background: rgba(34, 211, 238, 0.04);
|
||||||
|
margin: 18px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex; gap: 10px; align-items: flex-start;
|
||||||
|
}
|
||||||
|
.callout .icon { color: var(--cyan); flex-shrink: 0; margin-top: 2px; }
|
||||||
|
.callout.amber { border-left-color: var(--amber); background: rgba(251,191,36,0.04); }
|
||||||
|
.callout.amber .icon { color: var(--amber); }
|
||||||
|
|
||||||
|
/* tabs */
|
||||||
|
.tabs {
|
||||||
|
display: flex; gap: 2px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-3);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .12s;
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--text-1); }
|
||||||
|
.tab.active {
|
||||||
|
color: var(--cyan);
|
||||||
|
border-bottom-color: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* code frame in docs */
|
||||||
|
.code-panel {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--bg-code);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.code-panel .panel-tabs {
|
||||||
|
padding: 0 4px;
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex; align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.code-panel .panel-tabs .tabs-inner {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.code-panel .panel-tabs .tabs-inner button {
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-3);
|
||||||
|
transition: color .12s;
|
||||||
|
}
|
||||||
|
.code-panel .panel-tabs .tabs-inner button.active { color: var(--cyan); }
|
||||||
|
.code-panel .copy-code {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .12s;
|
||||||
|
}
|
||||||
|
.code-panel .copy-code:hover { color: var(--cyan); background: rgba(34,211,238,0.08); }
|
||||||
|
|
||||||
|
.code-panel pre {
|
||||||
|
padding: 16px 20px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--text-1);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.code-panel pre .com { color: #64748b; font-style: italic; }
|
||||||
|
.code-panel pre .kw { color: #f472b6; }
|
||||||
|
.code-panel pre .str { color: #86efac; }
|
||||||
|
.code-panel pre .fn { color: #fcd34d; }
|
||||||
|
.code-panel pre .prop { color: #93c5fd; }
|
||||||
|
.code-panel pre .num { color: #fb923c; }
|
||||||
|
|
||||||
|
/* quick start grid */
|
||||||
|
.quick-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.quick-card {
|
||||||
|
padding: 18px 20px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
transition: all .15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.quick-card:hover {
|
||||||
|
border-color: var(--cyan);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.quick-card .num {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--cyan);
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.quick-card h4 {
|
||||||
|
font-size: 14px; font-weight: 600; margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.quick-card p {
|
||||||
|
font-size: 12px !important; color: var(--text-3) !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* models table */
|
||||||
|
.models-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.models-table th, .models-table td {
|
||||||
|
padding: 10px 14px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.models-table th {
|
||||||
|
background: rgba(2, 6, 23, 0.5);
|
||||||
|
color: var(--text-3);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.models-table td { color: var(--text-1); }
|
||||||
|
.models-table tr:last-child td { border-bottom: none; }
|
||||||
|
.models-table tr:hover td { background: rgba(255,255,255,0.02); }
|
||||||
|
.models-table .mono { font-family: var(--font-mono); font-size: 12px; }
|
||||||
|
|
||||||
|
/* TOC on right */
|
||||||
|
.docs-toc {
|
||||||
|
position: sticky; top: 32px; align-self: flex-start;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.docs-toc-label {
|
||||||
|
font-family: var(--font-mono); font-size: 10px;
|
||||||
|
color: var(--text-3); letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.docs-toc a {
|
||||||
|
display: block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
color: var(--text-2);
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
transition: all .1s;
|
||||||
|
}
|
||||||
|
.docs-toc a:hover { color: var(--text-0); }
|
||||||
|
.docs-toc a.active {
|
||||||
|
color: var(--cyan);
|
||||||
|
border-left-color: var(--cyan);
|
||||||
|
background: rgba(34,211,238,0.03);
|
||||||
|
}
|
||||||
|
.docs-toc a.sub { padding-left: 20px; font-size: 11.5px; }
|
||||||
|
|
||||||
|
/* top nav (reuse landing nav) */
|
||||||
|
.docs-top {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: rgba(2, 6, 23, 0.7);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* footer nav */
|
||||||
|
.page-foot {
|
||||||
|
margin-top: 64px;
|
||||||
|
padding-top: 28px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex; justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.foot-link {
|
||||||
|
flex: 1;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
transition: all .12s;
|
||||||
|
}
|
||||||
|
.foot-link:hover { border-color: var(--cyan); }
|
||||||
|
.foot-link .dir {
|
||||||
|
font-family: var(--font-mono); font-size: 11px;
|
||||||
|
color: var(--text-3); margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.foot-link .title { font-size: 14px; font-weight: 600; color: var(--text-0); }
|
||||||
|
.foot-link.next { text-align: right; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-glow soft"></div>
|
||||||
|
|
||||||
|
<nav class="nav docs-top">
|
||||||
|
<div class="container nav-inner">
|
||||||
|
<a class="brand" href="Landing.html">
|
||||||
|
<svg class="hex" viewBox="0 0 24 24" 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>
|
||||||
|
PURO AI
|
||||||
|
</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="Landing.html">产品</a>
|
||||||
|
<a href="Pricing.html">定价</a>
|
||||||
|
<a href="#" class="active">文档</a>
|
||||||
|
<a href="Design System.html">设计系统</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-cta">
|
||||||
|
<a href="Dashboard.html" class="btn btn-ghost">Dashboard</a>
|
||||||
|
<a href="Register.html" class="btn btn-primary">开始使用</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="docs-layout">
|
||||||
|
<!-- LEFT NAV -->
|
||||||
|
<aside class="docs-nav">
|
||||||
|
<div class="brand-line">// docs · v1</div>
|
||||||
|
|
||||||
|
<div class="nav-search">
|
||||||
|
<input placeholder="搜索文档…">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="docs-nav-section">
|
||||||
|
<div class="docs-nav-label">Getting Started</div>
|
||||||
|
<a class="docs-nav-item active" href="#">快速开始</a>
|
||||||
|
<a class="docs-nav-item" href="#">产品概念</a>
|
||||||
|
<a class="docs-nav-item" href="#">绑定你的订阅</a>
|
||||||
|
<a class="docs-nav-item" href="#">创建 API Key</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="docs-nav-section">
|
||||||
|
<div class="docs-nav-label">API Reference</div>
|
||||||
|
<a class="docs-nav-item" href="#">Endpoints 总览</a>
|
||||||
|
<a class="docs-nav-item" href="#">/v1/chat/completions</a>
|
||||||
|
<a class="docs-nav-item" href="#">/v1/messages</a>
|
||||||
|
<a class="docs-nav-item" href="#">/v1/generateContent</a>
|
||||||
|
<a class="docs-nav-item" href="#">模型列表</a>
|
||||||
|
<a class="docs-nav-item" href="#">错误码</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="docs-nav-section">
|
||||||
|
<div class="docs-nav-label">Integrations</div>
|
||||||
|
<a class="docs-nav-item" href="#">Claude Code</a>
|
||||||
|
<a class="docs-nav-item" href="#">Cursor · Continue</a>
|
||||||
|
<a class="docs-nav-item" href="#">Cline · Roo Code</a>
|
||||||
|
<a class="docs-nav-item" href="#">OpenAI SDK</a>
|
||||||
|
<a class="docs-nav-item" href="#">Anthropic SDK</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="docs-nav-section">
|
||||||
|
<div class="docs-nav-label">Advanced</div>
|
||||||
|
<a class="docs-nav-item" href="#">调度与 failover</a>
|
||||||
|
<a class="docs-nav-item" href="#">速率限制与配额</a>
|
||||||
|
<a class="docs-nav-item" href="#">流式响应</a>
|
||||||
|
<a class="docs-nav-item" href="#">Function Calling</a>
|
||||||
|
<a class="docs-nav-item" href="#">数据隐私</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- MAIN -->
|
||||||
|
<main class="docs-body">
|
||||||
|
<div class="docs-crumbs">
|
||||||
|
Docs <span class="sep">/</span>
|
||||||
|
Getting Started <span class="sep">/</span>
|
||||||
|
<span class="current">快速开始</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>快速开始</h1>
|
||||||
|
<p class="lede">
|
||||||
|
PURO AI 提供一个统一的 OpenAI 兼容端点 —— 你已有的 SDK 代码只需要改 <code>base_url</code> 和 <code>api_key</code> 两行,就能用上你绑定的 Claude / ChatGPT / Gemini 订阅。整个过程通常不超过 5 分钟。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- quick links -->
|
||||||
|
<div class="quick-grid">
|
||||||
|
<div class="quick-card">
|
||||||
|
<div class="num">STEP 01</div>
|
||||||
|
<h4>绑定订阅</h4>
|
||||||
|
<p>授权你的 Claude / ChatGPT 账号加入池。</p>
|
||||||
|
</div>
|
||||||
|
<div class="quick-card">
|
||||||
|
<div class="num">STEP 02</div>
|
||||||
|
<h4>创建 API Key</h4>
|
||||||
|
<p>为每个客户端生成独立的 sk-puro-* key。</p>
|
||||||
|
</div>
|
||||||
|
<div class="quick-card">
|
||||||
|
<div class="num">STEP 03</div>
|
||||||
|
<h4>切换 base_url</h4>
|
||||||
|
<p>改两行代码,剩下和官方 SDK 一模一样。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="install">① 绑定你的订阅</h2>
|
||||||
|
<p>
|
||||||
|
进入 <code>Dashboard → 订阅账号 → 绑定新订阅</code>,选择平台后通过 OAuth 一键授权。每个订阅都会被加入对应的"池",同一池内的请求会自动做负载均衡、限流回退和故障转移。
|
||||||
|
</p>
|
||||||
|
<div class="callout">
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
凭证通过 AES-256 加密存储在隔离的 KMS 中。我们只会用它代理你发出的请求 —— 不会进入训练数据、不会做二次分发。详见 <a href="#">数据隐私</a>。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="key">② 创建 API Key</h2>
|
||||||
|
<p>
|
||||||
|
在 <code>Dashboard → API Keys → 创建 Key</code> 生成一个 <code>sk-puro-*</code> 的 key。建议每个客户端 / 环境单独一个 key,泄漏时可以直接吊销而不影响其他场景。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 id="request">③ 发送第一个请求</h2>
|
||||||
|
<p>PURO AI 同时兼容 OpenAI 和 Anthropic 的 API 格式。按你原来在用的 SDK 风格选择对应的代码示例即可:</p>
|
||||||
|
|
||||||
|
<div class="code-panel">
|
||||||
|
<div class="panel-tabs">
|
||||||
|
<div class="tabs-inner">
|
||||||
|
<button class="active">Python</button>
|
||||||
|
<button>Node.js</button>
|
||||||
|
<button>cURL</button>
|
||||||
|
<button>Anthropic SDK</button>
|
||||||
|
</div>
|
||||||
|
<span class="copy-code">⧉ 复制</span>
|
||||||
|
</div>
|
||||||
|
<pre><span class="com"># pip install openai</span>
|
||||||
|
<span class="kw">from</span> openai <span class="kw">import</span> OpenAI
|
||||||
|
|
||||||
|
client = <span class="fn">OpenAI</span>(
|
||||||
|
<span class="prop">base_url</span>=<span class="str">"https://ai.puro.im/v1"</span>,
|
||||||
|
<span class="prop">api_key</span>=<span class="str">"sk-puro-YOUR_KEY"</span>,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.chat.completions.<span class="fn">create</span>(
|
||||||
|
<span class="prop">model</span>=<span class="str">"claude-sonnet-4-5"</span>, <span class="com"># 可直接写任意平台的模型名</span>
|
||||||
|
<span class="prop">messages</span>=[
|
||||||
|
{<span class="prop">"role"</span>: <span class="str">"user"</span>, <span class="prop">"content"</span>: <span class="str">"hi, who am I talking to?"</span>}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
<span class="fn">print</span>(resp.choices[<span class="num">0</span>].message.content)
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>返回结构完全符合 OpenAI 格式,可以无缝对接任何基于 OpenAI SDK 的应用(Cursor / Continue / Cline / Roo Code / Open WebUI …)。</p>
|
||||||
|
|
||||||
|
<h2 id="models">可用模型</h2>
|
||||||
|
<p>绑定后,下列模型都可以直接用 model 字段调用 —— PURO 会根据模型自动路由到对应的订阅池。</p>
|
||||||
|
|
||||||
|
<table class="models-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>MODEL</th>
|
||||||
|
<th>PROVIDER</th>
|
||||||
|
<th>池</th>
|
||||||
|
<th>上下文</th>
|
||||||
|
<th>状态</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="mono text-cyan">claude-sonnet-4-5</td>
|
||||||
|
<td><span class="provider claude"><span class="dot"></span>Claude</span></td>
|
||||||
|
<td>Pro / Max</td>
|
||||||
|
<td class="mono">200k</td>
|
||||||
|
<td><span class="badge green">OK</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="mono text-cyan">claude-opus-4</td>
|
||||||
|
<td><span class="provider claude"><span class="dot"></span>Claude</span></td>
|
||||||
|
<td>Max</td>
|
||||||
|
<td class="mono">200k</td>
|
||||||
|
<td><span class="badge green">OK</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="mono text-cyan">claude-haiku-4-5</td>
|
||||||
|
<td><span class="provider claude"><span class="dot"></span>Claude</span></td>
|
||||||
|
<td>Pro / Max</td>
|
||||||
|
<td class="mono">200k</td>
|
||||||
|
<td><span class="badge green">OK</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="mono text-cyan">gpt-5</td>
|
||||||
|
<td><span class="provider gpt"><span class="dot"></span>ChatGPT</span></td>
|
||||||
|
<td>Plus / Pro</td>
|
||||||
|
<td class="mono">128k</td>
|
||||||
|
<td><span class="badge green">OK</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="mono text-cyan">gpt-5-codex</td>
|
||||||
|
<td><span class="provider gpt"><span class="dot"></span>ChatGPT</span></td>
|
||||||
|
<td>Plus / Pro</td>
|
||||||
|
<td class="mono">128k</td>
|
||||||
|
<td><span class="badge green">OK</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="mono text-cyan">gemini-2.5-pro</td>
|
||||||
|
<td><span class="provider gemini"><span class="dot"></span>Gemini</span></td>
|
||||||
|
<td>Advanced</td>
|
||||||
|
<td class="mono">1M</td>
|
||||||
|
<td><span class="badge amber">BETA</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="mono text-cyan">gemini-2.5-flash</td>
|
||||||
|
<td><span class="provider gemini"><span class="dot"></span>Gemini</span></td>
|
||||||
|
<td>Advanced</td>
|
||||||
|
<td class="mono">1M</td>
|
||||||
|
<td><span class="badge green">OK</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 id="base-urls">支持的 base_url</h2>
|
||||||
|
<p>每种格式都提供独立的 base_url —— 如果你在用原生 Anthropic / Google SDK,请选择对应格式以获得最完整的字段兼容:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><code>https://ai.puro.im/v1</code> — OpenAI 兼容格式(推荐,覆盖 95% 场景)</li>
|
||||||
|
<li><code>https://ai.puro.im/anthropic</code> — Anthropic Messages 格式(原生 Claude SDK)</li>
|
||||||
|
<li><code>https://ai.puro.im/google</code> — Google GenAI 格式(原生 Gemini SDK)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="callout amber">
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4M12 17h.01"/></svg>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
一个 <code>sk-puro-*</code> 可以同时用于三种 base_url —— 鉴权和计费是统一的,你不需要为不同 SDK 维护多个 key。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="next">下一步</h2>
|
||||||
|
<p>把 PURO 接入到你常用的工具:</p>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#">在 <b>Claude Code</b> 中使用 PURO</a> — <code>ANTHROPIC_BASE_URL</code> 环境变量一行搞定</li>
|
||||||
|
<li><a href="#">在 <b>Cursor</b> 中使用 PURO</a> — 自定义模型 + 自定义 base_url</li>
|
||||||
|
<li><a href="#">在 <b>Cline / Roo Code</b> 中使用 PURO</a> — 选择 "OpenAI compatible"</li>
|
||||||
|
<li><a href="#">在 <b>Continue</b> 中使用 PURO</a> — config.yaml 添加 provider</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- pager -->
|
||||||
|
<div class="page-foot">
|
||||||
|
<a class="foot-link" href="#">
|
||||||
|
<div class="dir">← 上一页</div>
|
||||||
|
<div class="title">产品概念</div>
|
||||||
|
</a>
|
||||||
|
<a class="foot-link next" href="#">
|
||||||
|
<div class="dir">下一页 →</div>
|
||||||
|
<div class="title">绑定你的订阅</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- RIGHT TOC -->
|
||||||
|
<aside class="docs-toc">
|
||||||
|
<div class="docs-toc-label">On this page</div>
|
||||||
|
<a href="#install" class="active">① 绑定订阅</a>
|
||||||
|
<a href="#key">② 创建 API Key</a>
|
||||||
|
<a href="#request">③ 第一个请求</a>
|
||||||
|
<a href="#models">可用模型</a>
|
||||||
|
<a href="#base-urls">支持的 base_url</a>
|
||||||
|
<a href="#next">下一步</a>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
222
docs/design-drafts/v2/HANDOFF.md
Normal file
222
docs/design-drafts/v2/HANDOFF.md
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
# PURO AI · 设计交付文档
|
||||||
|
|
||||||
|
> 把已有的 Claude / ChatGPT / Codex / Gemini 订阅聚合成统一 API · 让"已经付过钱的订阅"真正可编程
|
||||||
|
|
||||||
|
本文档面向**接手实现这套设计的工程团队 / Coding Agent**,说明每个 HTML 文件的用途、数据契约、交互逻辑,以及与后端的对接点。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 文件清单
|
||||||
|
|
||||||
|
| 文件 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `Landing.html` | 营销首页 | 未登录入口 · Hero / 模型墙 / 功能 / 代码示例 / Dashboard 预览 / Pricing / FAQ |
|
||||||
|
| `Pricing.html` | 定价页 | 完整定价 + 成本估算器 + 工具兼容墙 + 10 条 FAQ |
|
||||||
|
| `Docs.html` | 文档 | 快速开始 / 模型列表 / API 参考 / 各客户端配置 |
|
||||||
|
| `Login.html` | 登录 | 左侧叙事 + 右侧表单 · 支持 LinuxDO OAuth |
|
||||||
|
| `Register.html` | 注册 | 带密码强度 · 下一步引导 · 送 $5 |
|
||||||
|
| `Binding.html` | 订阅绑定 | 核心差异化流程 · OAuth 接入 Claude Pro / ChatGPT Plus |
|
||||||
|
| `Dashboard.html` | 控制台首页 | 余额 / 用量图表 / 近期请求 / 订阅池状态 |
|
||||||
|
| `API Keys.html` | Key 管理 | 创建 / 吊销 / 预算限制 / 模型白名单 |
|
||||||
|
| `Design System.html` | 设计系统 | 色板 / 字号 / 组件索引 |
|
||||||
|
| `puro.css` | 全站样式 | 所有页面共用的 tokens + primitives(.btn / .pill / .tag / .input 等) |
|
||||||
|
|
||||||
|
**所有页面必须首行 meta:** `<meta name="color-scheme" content="dark">` —— 防止浏览器 auto-darken 覆盖 cyan 按钮。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 设计 Tokens(见 `puro.css`)
|
||||||
|
|
||||||
|
```
|
||||||
|
--bg-0: #0a0e1a 页面底
|
||||||
|
--bg-1: #0f172a raised
|
||||||
|
--bg-2: #111827 card alt
|
||||||
|
--bg-code: #020617 代码面板
|
||||||
|
|
||||||
|
--border: #1e293b
|
||||||
|
--border-2:#334155
|
||||||
|
--border-3:#475569
|
||||||
|
|
||||||
|
--text-0: #f8fafc 主
|
||||||
|
--text-1: #cbd5e1 次
|
||||||
|
--text-2: #94a3b8 说明
|
||||||
|
--text-3: #64748b 弱
|
||||||
|
|
||||||
|
--cyan: #22d3ee 主强调(primary btn / 链接 / 数据点)
|
||||||
|
--cyan-2: #67e8f9 hover
|
||||||
|
--purple: #a855f7 次强调 / 装饰
|
||||||
|
--amber: #fbbf24 提醒 / 限时标签
|
||||||
|
--green: #34d399 成功 / 在线
|
||||||
|
--red: #f87171 错误 / 危险
|
||||||
|
```
|
||||||
|
|
||||||
|
字体:`Inter`(正文)+ `JetBrains Mono`(所有数值 / 代码 / 元信息)。
|
||||||
|
圆角:`--r-sm 6px / --r-md 8px / --r-lg 12px / --r-xl 16px`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 页面 → 后端契约
|
||||||
|
|
||||||
|
### 2.1 Landing · 无后端依赖(纯营销)
|
||||||
|
锚点:`#pricing` / `#faq` / `#features` / `#code` / `#dashboard`。注册 CTA → `Register.html`。
|
||||||
|
|
||||||
|
### 2.2 Register
|
||||||
|
提交表单需要 **后端返回**:
|
||||||
|
```
|
||||||
|
POST /auth/register
|
||||||
|
{ email, password, linuxdo_token? }
|
||||||
|
→ 200 { user_id, jwt, balance_credits: 5.00 /* $5 注册赠送 */ }
|
||||||
|
```
|
||||||
|
前端验证规则:
|
||||||
|
- 邮箱格式 `^[^\s@]+@[^\s@]+\.[^\s@]+$`
|
||||||
|
- 密码强度 ≥ 2(长度 ≥ 8 + 字母大小写混合即可通过)
|
||||||
|
- 两次密码一致 + 勾选 terms
|
||||||
|
成功后跳 `Binding.html`。
|
||||||
|
|
||||||
|
### 2.3 Login
|
||||||
|
```
|
||||||
|
POST /auth/login
|
||||||
|
{ email, password } 或 { linuxdo_oauth_code }
|
||||||
|
→ 200 { jwt, user, has_subscriptions: boolean }
|
||||||
|
```
|
||||||
|
若 `has_subscriptions === false`,引导去 `Binding.html`;否则去 `Dashboard.html`。
|
||||||
|
|
||||||
|
### 2.4 Binding(核心差异化)
|
||||||
|
OAuth 流程,每个平台:
|
||||||
|
```
|
||||||
|
POST /bindings/oauth/start
|
||||||
|
{ provider: 'claude' | 'chatgpt' | 'codex' | 'gemini' }
|
||||||
|
→ { auth_url, state }
|
||||||
|
```
|
||||||
|
前端打开 `auth_url` 新窗口;OAuth 回调:
|
||||||
|
```
|
||||||
|
GET /bindings/oauth/callback?code=...&state=...
|
||||||
|
→ 302 → /binding/success?provider=claude&account_id=...
|
||||||
|
```
|
||||||
|
绑定列表:
|
||||||
|
```
|
||||||
|
GET /bindings
|
||||||
|
→ [ { id, provider, account_email, plan, status: 'healthy'|'cooling'|'error', quota_remaining_pct, bound_at } ]
|
||||||
|
DELETE /bindings/:id 解绑
|
||||||
|
POST /bindings/:id/test 发一条测试请求验证凭证有效
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 Dashboard
|
||||||
|
```
|
||||||
|
GET /me/overview
|
||||||
|
→ {
|
||||||
|
balance: { credits: 45.23, bonus_credits: 12.00, expires_at: null },
|
||||||
|
usage_today: { requests: 1842, tokens_in: 2.1e6, tokens_out: 4.8e5, cost: 1.23 },
|
||||||
|
usage_30d: [{ date, cost, requests }, ...],
|
||||||
|
recent_requests: [{ ts, model, route_to, status, latency_ms, tokens, cost }, ...],
|
||||||
|
pool_status: [{ provider, accounts: [{ status, quota_pct }] }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
图表用 `usage_30d` 绘制折线(cyan 主线,purple 副线)。
|
||||||
|
|
||||||
|
### 2.6 API Keys
|
||||||
|
```
|
||||||
|
GET /api-keys
|
||||||
|
POST /api-keys { name, models?: string[], monthly_budget?: number }
|
||||||
|
DELETE /api-keys/:id
|
||||||
|
```
|
||||||
|
Key 前缀 `sk-puro-` + 32 字符。创建后仅显示一次完整值,之后只保留前 8 位。
|
||||||
|
|
||||||
|
### 2.7 Pricing
|
||||||
|
纯静态展示。充值 CTA 统一走:
|
||||||
|
```
|
||||||
|
POST /billing/topup
|
||||||
|
{ amount_usd, payment_method: 'alipay'|'wechat'|'usdt'|'stripe' }
|
||||||
|
→ { payment_url / qr_code, order_id }
|
||||||
|
```
|
||||||
|
阶梯赠送在前端计算并展示(见 `Pricing.html` 底部 script),**后端下单时再次校验**:
|
||||||
|
```
|
||||||
|
阶梯 = amount >= 500 ? 120%
|
||||||
|
: amount >= 200 ? 110%
|
||||||
|
: amount >= 99 ? 100%
|
||||||
|
: amount >= 50 ? 70%
|
||||||
|
: amount >= 30 ? 50%
|
||||||
|
: amount >= 20 ? 35%
|
||||||
|
: 21%
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 统一 API Gateway(产品核心)
|
||||||
|
|
||||||
|
所有用户集成的终点:
|
||||||
|
```
|
||||||
|
https://api.puro.im/v1/chat/completions # OpenAI 兼容
|
||||||
|
https://api.puro.im/v1/messages # Anthropic 兼容
|
||||||
|
https://api.puro.im/v1beta/models/:m:generateContent # Gemini 兼容
|
||||||
|
```
|
||||||
|
Header: `Authorization: Bearer sk-puro-xxx`
|
||||||
|
|
||||||
|
**调度逻辑(文档化在 `Docs.html`):**
|
||||||
|
1. 根据模型解析目标 provider
|
||||||
|
2. 从该 provider 的订阅池挑一个 `status=healthy` 的账号(权重:剩余配额 × 响应时延倒数)
|
||||||
|
3. 若 429 / 限流,标记 `cooling` 60-300s,failover 到下一个
|
||||||
|
4. 若该 provider 所有订阅都 cooling 且用户余额 > 0,fallback 到官方 API
|
||||||
|
5. 所有请求写入 `request_logs`(用户可见,保留周期按套餐)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 套餐与限制
|
||||||
|
|
||||||
|
| | Starter $9.9 | Pro $29.9 ⭐ | Scale $99 | Custom |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 赠送 | +21% | +50% | +100% | 阶梯 |
|
||||||
|
| API Keys | 1 | 3 | 10 | 按 Pro |
|
||||||
|
| RPM | 60 | 120 | 300 | 按 Pro |
|
||||||
|
| 日志保留 | 7d | 30d | 90d | 按 Pro |
|
||||||
|
| 自带订阅 | ❌ | ∞ | ∞ | ✅ |
|
||||||
|
| 多账号 failover | — | ✅ | ✅ + 优先级 | ✅ |
|
||||||
|
|
||||||
|
**Enterprise** 走 Sales 线:私有化 / SLA / Invoice,不在普通订单流里。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 交互细节(容易漏)
|
||||||
|
|
||||||
|
- **余额不足**:Gateway 返回 `402 Payment Required`,Dashboard 顶部红色 banner + 发邮件(80% / 95% 两档预警)
|
||||||
|
- **订阅 cooling** 时 Dashboard 订阅卡片上角 amber 闪烁点
|
||||||
|
- **API Key 创建**:弹窗必须强制用户复制一次,关闭弹窗后永远看不到完整值
|
||||||
|
- **密码强度**:评分 0-4,label 对应 `— / 弱 / 中 / 强 / 极强`,颜色 `border / red / amber / cyan / green`
|
||||||
|
- **登录成功**:按钮变 green 显示 `✓ 登录成功`,800ms 后 window.location 跳转
|
||||||
|
- **LinuxDO OAuth** 是 PURO 的目标用户群(开发者社区),作为次要登录按钮展示
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 响应式断点
|
||||||
|
|
||||||
|
- `<= 900px`:Login/Register 的 split 变单列,narrative 折叠
|
||||||
|
- `<= 820px`:Pricing grid 从 4 列 → 1 列
|
||||||
|
- `<= 820px`:Landing pricing-grid-landing 从 3 列 → 1 列
|
||||||
|
- `<= 960px`:Pricing 的 calculator 从 2 列 → 1 列
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 后端技术建议(非设计范畴,仅供参考)
|
||||||
|
|
||||||
|
- **Gateway 层**:Go / Rust 写高吞吐代理,HTTP/2 + streaming,每 provider 起独立 worker pool
|
||||||
|
- **调度**:Redis ZSET 存每个订阅的健康分 + cooling 过期时间
|
||||||
|
- **日志**:写 ClickHouse(列存 + 按日分区),Dashboard 直接查
|
||||||
|
- **凭证加密**:每个订阅凭证用 AES-256-GCM 加密,key 放 KMS;解密只在 gateway worker 内存里,绝不落日志
|
||||||
|
- **计费**:Pulsar / Redis Streams 实时扣费,最终一致性,异步对账
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 已知 TODO(设计层面暂不处理,开发阶段补)
|
||||||
|
|
||||||
|
- [ ] Dashboard 真实图表联动(目前是静态 SVG)
|
||||||
|
- [ ] Binding OAuth 回调 loading / 成功动效
|
||||||
|
- [ ] 邮件模板(注册验证 / 余额预警 / 充值成功)
|
||||||
|
- [ ] 忘记密码流程
|
||||||
|
- [ ] 团队 / 多人协作 UI(Enterprise 档需要)
|
||||||
|
- [ ] i18n:当前仅中文,英文版待出
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
生成时间:2026-04 · 设计稿版本:v1
|
||||||
|
|
||||||
|
联系 Sam / 产品负责人确认实现细节。
|
||||||
1423
docs/design-drafts/v2/Landing.html
Normal file
1423
docs/design-drafts/v2/Landing.html
Normal file
File diff suppressed because it is too large
Load Diff
351
docs/design-drafts/v2/Login.html
Normal file
351
docs/design-drafts/v2/Login.html
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
|
<title>登录 — PURO AI</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="puro.css">
|
||||||
|
<style>
|
||||||
|
body { min-height: 100vh; overflow-x: hidden; }
|
||||||
|
|
||||||
|
.split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ LEFT (NARRATIVE) ============ */
|
||||||
|
.narrative {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 48px 56px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
background: linear-gradient(135deg, rgba(34,211,238,0.04), transparent 60%), rgba(15, 23, 42, 0.3);
|
||||||
|
}
|
||||||
|
.narrative::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(600px 400px at 20% 20%, rgba(34,211,238,0.08), transparent 60%),
|
||||||
|
radial-gradient(500px 400px at 90% 80%, rgba(168,85,247,0.06), transparent 60%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.narrative-inner { position: relative; display: flex; flex-direction: column; gap: 28px; z-index: 1; }
|
||||||
|
|
||||||
|
.brand-top {
|
||||||
|
display: inline-flex; align-items: center; gap: 10px;
|
||||||
|
font-weight: 700; font-size: 15px; letter-spacing: -0.01em; color: var(--text-0);
|
||||||
|
}
|
||||||
|
.brand-top svg { width: 22px; height: 22px; color: var(--cyan); }
|
||||||
|
|
||||||
|
.n-kicker { font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.1em; color: var(--cyan); }
|
||||||
|
.n-headline {
|
||||||
|
font-size: 52px; font-weight: 800; letter-spacing: -0.03em; line-height: 1.05;
|
||||||
|
display: flex; align-items: baseline; flex-wrap: wrap; gap: 14px;
|
||||||
|
}
|
||||||
|
.n-headline .amber { color: var(--amber); }
|
||||||
|
.n-headline .cyan { color: var(--cyan); }
|
||||||
|
.n-headline .arrow { font-size: 38px; color: var(--text-3); font-weight: 400; }
|
||||||
|
.n-sub { color: var(--text-2); font-size: 15px; line-height: 1.75; max-width: 440px; }
|
||||||
|
.n-sub .line { display: block; }
|
||||||
|
.n-sub .puro { color: var(--text-0); font-weight: 600; }
|
||||||
|
|
||||||
|
.route-demo {
|
||||||
|
margin-top: 10px;
|
||||||
|
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);
|
||||||
|
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 .pill-inline.green { background: rgba(52,211,153,0.08); border-color: rgba(52,211,153,0.25); color: var(--green); }
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
.n-bottom {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
font-family: var(--font-mono); font-size: 11px; color: var(--text-3);
|
||||||
|
display: flex; gap: 14px; align-items: center; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.n-bottom .sep { color: var(--border-2); }
|
||||||
|
.n-bottom .live { color: var(--green); display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.n-bottom .live .dot { width: 5px; height: 5px; border-radius: 50%; background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||||
|
|
||||||
|
/* ============ RIGHT (FORM) ============ */
|
||||||
|
.form-side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 48px 56px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.back-home {
|
||||||
|
position: absolute; top: 32px; right: 32px;
|
||||||
|
font-family: var(--font-mono); font-size: 12px; color: var(--text-3);
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
}
|
||||||
|
.back-home:hover { color: var(--text-0); }
|
||||||
|
|
||||||
|
.form-card { width: 100%; max-width: 400px; }
|
||||||
|
.form-card h1 { font-size: 32px; font-weight: 700; letter-spacing: -0.02em; margin-bottom: 6px; }
|
||||||
|
.form-card .sub { color: var(--text-2); font-size: 14px; margin-bottom: 32px; }
|
||||||
|
|
||||||
|
.field { margin-bottom: 16px; }
|
||||||
|
.field label { display: block; font-size: 12px; color: var(--text-2); font-weight: 500; margin-bottom: 8px; letter-spacing: 0.01em; }
|
||||||
|
.input-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex; align-items: center;
|
||||||
|
background: rgba(2, 6, 23, 0.4);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.input-wrap:focus-within { border-color: var(--cyan); background: rgba(34, 211, 238, 0.04); box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.1); }
|
||||||
|
.input-wrap .icon { padding: 0 12px; color: var(--text-3); flex-shrink: 0; display: flex; }
|
||||||
|
.input-wrap input {
|
||||||
|
flex: 1; background: none; border: none; outline: none;
|
||||||
|
font-family: inherit; color: var(--text-0); font-size: 14px;
|
||||||
|
padding: 12px 14px 12px 0;
|
||||||
|
}
|
||||||
|
.input-wrap input::placeholder { color: var(--text-3); }
|
||||||
|
.eye { display: flex; padding: 10px 12px; color: var(--text-3); background: none; border: none; cursor: pointer; }
|
||||||
|
.eye:hover { color: var(--text-1); }
|
||||||
|
|
||||||
|
.field-meta { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
|
||||||
|
.check { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-2); cursor: pointer; user-select: none; }
|
||||||
|
.check input { position: absolute; opacity: 0; pointer-events: none; }
|
||||||
|
.check .box { width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--border-2); background: rgba(2, 6, 23, 0.4); position: relative; flex-shrink: 0; }
|
||||||
|
.check input:checked ~ .box { background: var(--cyan); border-color: var(--cyan); }
|
||||||
|
.check input:checked ~ .box::after {
|
||||||
|
content: ""; position: absolute; left: 4px; top: 1px;
|
||||||
|
width: 4px; height: 8px; border: solid #042f2e; border-width: 0 2px 2px 0;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
.forgot { font-size: 13px; color: var(--text-3); }
|
||||||
|
.forgot:hover { color: var(--cyan); }
|
||||||
|
|
||||||
|
/* Submit button — explicit styles to ensure rendering */
|
||||||
|
.submit-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 20px !important;
|
||||||
|
font-size: 14px !important;
|
||||||
|
margin-top: 8px;
|
||||||
|
position: relative;
|
||||||
|
background: var(--cyan) !important;
|
||||||
|
color: #042f2e !important;
|
||||||
|
border: 1px solid var(--cyan) !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.submit-btn:hover { background: var(--cyan-2) !important; }
|
||||||
|
.submit-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.submit-btn .spinner { width: 14px; height: 14px; border: 2px solid rgba(0,0,0,0.2); border-top-color: #042f2e; border-radius: 50%; animation: spin .7s linear infinite; display: none; }
|
||||||
|
.submit-btn.loading .spinner { display: inline-block; }
|
||||||
|
.submit-btn.loading .label { opacity: 0.5; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.ghost-btn { width: 100%; padding: 14px 20px !important; font-size: 14px !important; font-weight: 500 !important; }
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
display: flex; align-items: center; gap: 14px;
|
||||||
|
margin: 24px 0;
|
||||||
|
color: var(--text-3); font-size: 11px;
|
||||||
|
font-family: var(--font-mono); letter-spacing: 0.15em;
|
||||||
|
}
|
||||||
|
.divider::before, .divider::after { content: ""; flex: 1; height: 1px; background: var(--border); }
|
||||||
|
|
||||||
|
.linuxdo-ico {
|
||||||
|
width: 18px; height: 18px; border-radius: 3px;
|
||||||
|
background: linear-gradient(135deg, #f0a030, #f05050);
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 10px; font-weight: 800; color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot { margin-top: 24px; text-align: center; font-size: 13px; color: var(--text-2); }
|
||||||
|
.foot a { color: var(--cyan); font-weight: 500; }
|
||||||
|
.foot a:hover { color: var(--cyan-2); }
|
||||||
|
|
||||||
|
.legal { margin-top: 20px; text-align: center; font-size: 11px; color: var(--text-3); font-family: var(--font-mono); }
|
||||||
|
.legal a { color: var(--text-2); border-bottom: 1px dashed var(--border-2); }
|
||||||
|
.legal a:hover { color: var(--cyan); border-color: var(--cyan); }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.split { grid-template-columns: 1fr; }
|
||||||
|
.narrative { padding: 32px 28px; border-right: none; border-bottom: 1px solid var(--border); }
|
||||||
|
.n-headline { font-size: 36px; }
|
||||||
|
.route-demo, .n-bottom { display: none; }
|
||||||
|
.form-side { padding: 40px 28px; }
|
||||||
|
.back-home { top: 20px; right: 20px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-glow"></div>
|
||||||
|
<div class="grain"></div>
|
||||||
|
|
||||||
|
<div class="split">
|
||||||
|
<!-- LEFT: NARRATIVE -->
|
||||||
|
<section class="narrative">
|
||||||
|
<div class="narrative-inner">
|
||||||
|
<a href="Landing.html" class="brand-top">
|
||||||
|
<svg viewBox="0 0 24 24" 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>
|
||||||
|
PURO AI
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="n-kicker">// 你的订阅,已经付过钱了</div>
|
||||||
|
<h1 class="n-headline" style="margin-top: 12px;">
|
||||||
|
<span class="amber">N</span> 个订阅
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<span class="cyan">1</span> 个 key
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="n-sub">
|
||||||
|
<span class="line">省去切换账号的繁琐,</span>
|
||||||
|
<span class="line">省去为多个高昂订阅重复买单。</span>
|
||||||
|
<span class="line" style="margin-top: 8px;"><span class="puro">PURO</span>(纯粹)—— 让 AI 调用回归本质。</span>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<div class="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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- RIGHT: FORM -->
|
||||||
|
<section class="form-side">
|
||||||
|
<a href="Landing.html" class="back-home">← 返回首页</a>
|
||||||
|
|
||||||
|
<form class="form-card" id="login-form" autocomplete="off" novalidate>
|
||||||
|
<h1>登录</h1>
|
||||||
|
<p class="sub">用你的 PURO AI 账户继续</p>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="email">邮箱</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="5" width="18" height="14" rx="2"/>
|
||||||
|
<path d="M3 7l9 6 9-6"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input type="email" id="email" name="email" placeholder="you@puro.im" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">密码</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="4" y="11" width="16" height="10" rx="2"/>
|
||||||
|
<path d="M8 11V7a4 4 0 0 1 8 0v4"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input type="password" id="password" name="password" placeholder="••••••••" required>
|
||||||
|
<button type="button" class="eye" id="toggle-pw" aria-label="切换显示密码">
|
||||||
|
<svg id="eye-open" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/>
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
</svg>
|
||||||
|
<svg id="eye-closed" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="display:none;">
|
||||||
|
<path d="M9.88 9.88A3 3 0 0 0 14.12 14.12M10.73 5.08A10.94 10.94 0 0 1 12 5c6.5 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68M6.61 6.61A13.53 13.53 0 0 0 2 12s3.5 7 10 7a9.77 9.77 0 0 0 5.39-1.61M2 2l20 20"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-meta">
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" checked>
|
||||||
|
<span class="box"></span>
|
||||||
|
记住我
|
||||||
|
</label>
|
||||||
|
<a href="#" class="forgot">忘记密码?</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary submit-btn" id="submit-btn">
|
||||||
|
<span class="spinner"></span>
|
||||||
|
<span class="label">登录 →</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="divider">OR</div>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-ghost ghost-btn">
|
||||||
|
<span class="linuxdo-ico">L</span>
|
||||||
|
使用 LinuxDO 登录
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="foot">
|
||||||
|
没有账户?<a href="Register.html">注册</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legal">
|
||||||
|
登录即表示你同意 <a href="#">服务条款</a> 与 <a href="#">隐私政策</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const pw = document.getElementById('password');
|
||||||
|
const toggle = document.getElementById('toggle-pw');
|
||||||
|
const eyeOpen = document.getElementById('eye-open');
|
||||||
|
const eyeClosed = document.getElementById('eye-closed');
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
const show = pw.type === 'password';
|
||||||
|
pw.type = show ? 'text' : 'password';
|
||||||
|
eyeOpen.style.display = show ? 'none' : 'block';
|
||||||
|
eyeClosed.style.display = show ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = document.getElementById('login-form');
|
||||||
|
const btn = document.getElementById('submit-btn');
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.email.value || !form.password.value) return;
|
||||||
|
btn.classList.add('loading');
|
||||||
|
btn.disabled = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.classList.remove('loading');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.querySelector('.label').textContent = '✓ 登录成功';
|
||||||
|
btn.style.background = 'var(--green)';
|
||||||
|
setTimeout(() => { window.location.href = 'Dashboard.html'; }, 800);
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
472
docs/design-drafts/v2/Pricing.html
Normal file
472
docs/design-drafts/v2/Pricing.html
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
|
<title>Pricing — PURO AI</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="puro.css">
|
||||||
|
<style>
|
||||||
|
.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; }
|
||||||
|
.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; }
|
||||||
|
.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 .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; }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
@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); }
|
||||||
|
.calc { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-glow"></div>
|
||||||
|
<div class="grain"></div>
|
||||||
|
|
||||||
|
<nav class="nav">
|
||||||
|
<div class="container nav-inner">
|
||||||
|
<a class="brand" href="Landing.html">
|
||||||
|
<svg class="hex" viewBox="0 0 24 24" 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>
|
||||||
|
PURO AI
|
||||||
|
</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="Landing.html">产品</a>
|
||||||
|
<a href="#" class="active">定价</a>
|
||||||
|
<a href="Docs.html">文档</a>
|
||||||
|
<a href="Design System.html">设计系统</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-cta">
|
||||||
|
<a href="Login.html" class="btn btn-ghost">登录</a>
|
||||||
|
<a href="Register.html" class="btn btn-primary">开始使用</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
|
<div class="section-kicker" style="margin-bottom:14px;">// pricing · 充多少 · 用多少 · 永不过期</div>
|
||||||
|
<h1>一次充值,<span class="accent">全平台</span>通用</h1>
|
||||||
|
<p class="sub">
|
||||||
|
同一份积分可以用在 Claude / ChatGPT / Gemini 任意池上。我们把你的订阅额度变成真正的 API 余额 —— 相比官方 API 便宜 <b class="text-cyan">至多 70%</b>。
|
||||||
|
</p>
|
||||||
|
<div class="underline">
|
||||||
|
<span class="dot"></span>
|
||||||
|
余额永不过期 · 支持支付宝 / 微信 / USDT · 无隐藏订阅费
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="pricing-wrap">
|
||||||
|
<div class="pricing-grid">
|
||||||
|
|
||||||
|
<div class="tier">
|
||||||
|
<span class="flag muted">STARTER</span>
|
||||||
|
<div class="tier-name">tier · 01</div>
|
||||||
|
<div class="tier-headline">先尝尝鲜,跑通接入</div>
|
||||||
|
<div class="price-row"><span class="price"><span class="curr">$</span>9.9</span></div>
|
||||||
|
<div class="credit-line">充 $9.9 <span class="arrow">→</span> 得 <b>$12</b> 积分 <span class="bonus">+21%</span></div>
|
||||||
|
<span class="discount-tag">相当于官方 API · 0.5 折起</span>
|
||||||
|
<hr/>
|
||||||
|
<div class="feats">
|
||||||
|
<div class="feat"><span class="tick">✓</span>可用所有模型 / 所有池</div>
|
||||||
|
<div class="feat"><span class="tick">✓</span><b>1</b> 个 API Key</div>
|
||||||
|
<div class="feat"><span class="tick">✓</span>60 RPM 速率限制</div>
|
||||||
|
<div class="feat"><span class="tick">✓</span>基础日志(7 天保留)</div>
|
||||||
|
<div class="feat muted"><span class="tick">✗</span>自带订阅接入</div>
|
||||||
|
<div class="feat muted"><span class="tick">✗</span>团队 / 多人协作</div>
|
||||||
|
</div>
|
||||||
|
<a href="Register.html" class="btn btn-ghost btn-lg tier-cta">充值 →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tier popular">
|
||||||
|
<span class="flag">◆ 推荐</span>
|
||||||
|
<div class="tier-name">tier · 02</div>
|
||||||
|
<div class="tier-headline">个人重度用户 · 最划算</div>
|
||||||
|
<div class="price-row"><span class="price"><span class="curr">$</span>29.9</span></div>
|
||||||
|
<div class="credit-line">充 $29.9 <span class="arrow">→</span> 得 <b>$45</b> 积分 <span class="bonus">+50%</span></div>
|
||||||
|
<span class="discount-tag">相当于官方 API · 3-7 折</span>
|
||||||
|
<hr/>
|
||||||
|
<div class="feats">
|
||||||
|
<div class="feat"><span class="tick">✓</span>可用所有模型 / 所有池</div>
|
||||||
|
<div class="feat"><span class="tick">✓</span><b>3</b> 个 API Key · 独立预算</div>
|
||||||
|
<div class="feat"><span class="tick">✓</span>120 RPM 速率限制</div>
|
||||||
|
<div class="feat"><span class="tick">✓</span>调用日志(30 天保留)</div>
|
||||||
|
<div class="feat"><span class="tick">✓</span>自带订阅接入(无限个)</div>
|
||||||
|
<div class="feat"><span class="tick">✓</span>多账号 failover 调度</div>
|
||||||
|
</div>
|
||||||
|
<a href="Register.html" class="btn btn-primary btn-lg tier-cta">立即充值 →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tier">
|
||||||
|
<span class="flag amber">⚡ 限时 +100%</span>
|
||||||
|
<div class="tier-name">tier · 03</div>
|
||||||
|
<div class="tier-headline">小团队 / 长跑项目</div>
|
||||||
|
<div class="price-row"><span class="price"><span class="curr">$</span>99</span></div>
|
||||||
|
<div class="credit-line">充 $99 <span class="arrow">→</span> 得 <b>$198</b> 积分 <span class="bonus">+100%</span></div>
|
||||||
|
<span class="discount-tag">相当于官方 API · 2-5 折</span>
|
||||||
|
<hr/>
|
||||||
|
<div class="feats">
|
||||||
|
<div class="feat"><span class="tick">✓</span>所有 Pro 能力</div>
|
||||||
|
<div class="feat"><span class="tick">✓</span><b>10</b> 个 API Key · 独立预算</div>
|
||||||
|
<div class="feat"><span class="tick">✓</span>300 RPM 速率限制</div>
|
||||||
|
<div class="feat"><span class="tick">✓</span>调用日志(90 天保留)</div>
|
||||||
|
<div class="feat"><span class="tick">✓</span>请求优先级加权调度</div>
|
||||||
|
<div class="feat"><span class="tick">✓</span>Slack / Discord 群组支持</div>
|
||||||
|
</div>
|
||||||
|
<a href="Register.html" class="btn btn-ghost btn-lg tier-cta">充值 →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tier">
|
||||||
|
<span class="flag muted">CUSTOM</span>
|
||||||
|
<div class="tier-name">tier · 04</div>
|
||||||
|
<div class="tier-headline">自定义金额 · 按需充值</div>
|
||||||
|
<div class="price-row"><span class="price"><span class="curr">$</span><span id="custom-amt">50</span></span></div>
|
||||||
|
<div class="credit-line">得约 <b id="custom-credit">$78</b> 积分 <span class="bonus">+<span id="custom-bonus">56</span>%</span></div>
|
||||||
|
<input type="range" min="10" max="500" value="50" step="10" id="custom-slider" style="-webkit-appearance:none; width:100%; height:4px; background:var(--border); border-radius:2px; margin-bottom:12px;">
|
||||||
|
<span class="discount-tag">根据金额阶梯自动匹配折扣</span>
|
||||||
|
<hr/>
|
||||||
|
<div class="feats">
|
||||||
|
<div class="feat"><span class="tick">✓</span>积分永不过期</div>
|
||||||
|
<div class="feat"><span class="tick">✓</span>Pro 全部能力</div>
|
||||||
|
<div class="feat"><span class="tick">✓</span>阶梯 +21% ~ +100%</div>
|
||||||
|
<div class="feat"><span class="tick">✓</span>支付宝 / 微信 / USDT</div>
|
||||||
|
<div class="feat muted"><span class="tick">—</span>拖动滑块预览赠送</div>
|
||||||
|
</div>
|
||||||
|
<a href="Register.html" class="btn btn-ghost btn-lg tier-cta">定制充值 →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>Enterprise · 企业定制</h3>
|
||||||
|
<p>专属订阅池、SLA、合规审计、私有化部署、发票结算。规模 >$500/月起可申请。</p>
|
||||||
|
</div>
|
||||||
|
<a href="#" class="btn btn-ghost">联系商务 →</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>已有订阅?直接接入</h3>
|
||||||
|
<p>有 Claude Max / ChatGPT Pro?免费注册后绑定,只为 PURO 路由费买单 —— 按次 <code class="pill">$0.0008/request</code>。</p>
|
||||||
|
</div>
|
||||||
|
<a href="Binding.html" class="btn btn-ghost">接入我的订阅 →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="calc-section">
|
||||||
|
<div class="calc">
|
||||||
|
<div class="calc-left">
|
||||||
|
<div class="section-kicker" style="margin-bottom:10px">// cost estimator</div>
|
||||||
|
<h3>算算你能省多少?</h3>
|
||||||
|
<p class="sub">按你的使用场景,对比 PURO 和官方 API 的月度花费差。数字会根据你选的场景自动更新。</p>
|
||||||
|
<div class="calc-controls">
|
||||||
|
<div class="slider-row">
|
||||||
|
<div class="s-top"><span>日均请求数</span><span class="val" id="req-val">5,000</span></div>
|
||||||
|
<input type="range" min="500" max="50000" step="500" value="5000" id="req-slider">
|
||||||
|
</div>
|
||||||
|
<div class="slider-row">
|
||||||
|
<div class="s-top"><span>平均每请求 tokens</span><span class="val" id="tok-val">3,000</span></div>
|
||||||
|
<input type="range" min="500" max="10000" step="500" value="3000" id="tok-slider">
|
||||||
|
</div>
|
||||||
|
<div class="slider-row">
|
||||||
|
<div class="s-top"><span>Claude 占比</span><span class="val" id="mix-val">50%</span></div>
|
||||||
|
<input type="range" min="0" max="100" step="10" value="50" id="mix-slider">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="calc-right">
|
||||||
|
<div class="breakdown">
|
||||||
|
<div class="line"><span class="lab">月度 tokens 消耗</span><span class="v" id="total-tok">450M</span></div>
|
||||||
|
<div class="line"><span class="lab">官方 API 价格</span><span class="v" id="official-cost">$1,620</span></div>
|
||||||
|
<div class="line"><span class="lab">PURO 价格(含 +50% 赠送)</span><span class="v" id="puro-cost">$486</span></div>
|
||||||
|
<div class="line savings"><span class="lab">节省</span><span class="v" id="save-amt">$1,134 · 70%</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="total-line">
|
||||||
|
<div>
|
||||||
|
<div class="lab">建议充值</div>
|
||||||
|
<div style="font-size:12px; color:var(--text-3); margin-top:4px;" id="rec-note">≈ 3 天试用 + Pro 档充值</div>
|
||||||
|
</div>
|
||||||
|
<div class="big"><span class="curr">$</span><span id="rec-amt">486</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="works">
|
||||||
|
<div class="section-head">
|
||||||
|
<div class="kicker">// works everywhere</div>
|
||||||
|
<h2>一个 key,所有工具通用</h2>
|
||||||
|
<p>只要支持自定义 <code class="pill">base_url</code> 或 OpenAI / Anthropic API,都能直接接入 PURO。</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">Claude Code</div><div class="tag">ANTHROPIC_BASE_URL</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">Cursor</div><div class="tag">自定义模型</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">Cline</div><div class="tag">OpenAI 兼容</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">Roo Code</div><div class="tag">OpenAI 兼容</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">Continue</div><div class="tag">config.yaml</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">OpenAI SDK</div><div class="tag">Python / Node</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">Anthropic SDK</div><div class="tag">原生 Claude</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">Open WebUI</div><div class="tag">自定义 base</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">LangChain</div><div class="tag">LLM 节点</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">LlamaIndex</div><div class="tag">模型路由</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">Zed</div><div class="tag">Assistant</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">更多…</div><div class="tag">60+ 工具</div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="faq-section">
|
||||||
|
<div class="section-head">
|
||||||
|
<div class="kicker">// frequently asked</div>
|
||||||
|
<h2>你可能想问的</h2>
|
||||||
|
<p>没找到答案?<a href="#" style="color:var(--cyan)">发邮件给我们 ↗</a> · 通常 2 小时内回复。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="faq" open>
|
||||||
|
<summary><span class="num">01</span>PURO 和 API 中转站 / API 代理有什么不同?</summary>
|
||||||
|
<div class="answer">
|
||||||
|
中转站只是把官方 API 请求转一手,价格取决于你预付多少 balance。PURO 的不同是 —— 我们让你 <b>把已有的 Claude Pro / ChatGPT Plus 订阅变成 API</b>。
|
||||||
|
你原本就在付的 $20/月,不再只能在官网聊天里用,而是通过统一 API 喂给 Cursor、Claude Code、任何 SDK。
|
||||||
|
同时我们也提供按量充值的官方 API 备用池,两种模式可以混用。
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq">
|
||||||
|
<summary><span class="num">02</span>用订阅跑 API 会不会被封号?</summary>
|
||||||
|
<div class="answer">
|
||||||
|
我们会自动控制每个订阅的请求节奏,并在触发限流时把请求 failover 到池子里的其他订阅。实际上 PURO 的调用模式比你在官方客户端直接复制粘贴大段对话 <b>更不容易触发风控</b>。
|
||||||
|
你绑定多个订阅时,单个账号的 <code>RPM</code> 会被压到足够安全的阈值内。另外所有凭证用 AES-256 加密存储,请求链路不经过第三方。
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq">
|
||||||
|
<summary><span class="num">03</span>积分会过期吗?可以退款吗?</summary>
|
||||||
|
<div class="answer">
|
||||||
|
<b>积分永不过期。</b>你可以攒着慢慢用 —— 包括几个月都不用。首次充值 7 天内未产生任何调用可全额退款,之后按剩余积分 85% 比例退。详见 <a href="#">退款政策</a>。
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq">
|
||||||
|
<summary><span class="num">04</span>支持哪些支付方式?</summary>
|
||||||
|
<div class="answer">
|
||||||
|
国内:支付宝 · 微信支付。国际:Stripe 信用卡 · USDT (TRC20 / ERC20) · PayPal。企业充值支持 Invoice 对公打款,人民币开票。
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq">
|
||||||
|
<summary><span class="num">05</span>一个 PURO 账号可以绑定多少个订阅?</summary>
|
||||||
|
<div class="answer">
|
||||||
|
<ul>
|
||||||
|
<li>Starter 档:<b>不支持</b>绑定自带订阅</li>
|
||||||
|
<li>Pro 档及以上:<b>无限制</b>,你可以把 10 个 ChatGPT Plus + 3 个 Claude Pro 一起绑上去,统一调度</li>
|
||||||
|
<li>Enterprise:支持跨团队共享池,按组织维度隔离</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq">
|
||||||
|
<summary><span class="num">06</span>如果某个订阅触发限流了会怎样?</summary>
|
||||||
|
<div class="answer">
|
||||||
|
PURO 的调度器会把受限的订阅自动标记为 <code>cooling</code> 状态,暂时从池子里摘除。同一请求会立刻被 failover 到池内其他健康订阅上 —— 调用方通常 <b>感受不到中断</b>。你可以在 Dashboard 看到每个订阅的当前状态和剩余配额。
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq">
|
||||||
|
<summary><span class="num">07</span>计费精度?超量会怎么办?</summary>
|
||||||
|
<div class="answer">
|
||||||
|
按实际 token 数 + 模型单价计费,精度到 4 位小数。每个 API Key 可设置独立月度预算,达到后 <code>402 Payment Required</code>,不会继续扣费。账户总余额不足时同样会返回 <code>402</code>,且 Dashboard 有 80% / 95% 两级提醒邮件。
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq">
|
||||||
|
<summary><span class="num">08</span>数据会被用于训练吗?</summary>
|
||||||
|
<div class="answer">
|
||||||
|
<b>不会。</b>所有请求仅用于路由转发,不入库、不留存内容(仅保留元数据如模型、token 数、延迟,用于计费和日志)。Pro 档及以上可选开启"零日志模式",我们连请求 ID 都不记录。
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq">
|
||||||
|
<summary><span class="num">09</span>可以私有化部署吗?</summary>
|
||||||
|
<div class="answer">
|
||||||
|
Enterprise 档支持 Docker / K8s 私有化部署,控制面和数据面可以分开。授权按年订阅,包含升级和技术支持。<a href="#">联系商务 →</a>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq">
|
||||||
|
<summary><span class="num">10</span>支持哪些模型?会跟进新模型吗?</summary>
|
||||||
|
<div class="answer">
|
||||||
|
当前覆盖 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)。每当官方发布新模型,我们通常在 <b>24 小时内</b>上线。完整模型列表见 <a href="Docs.html">文档</a>。
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="final-cta">
|
||||||
|
<div class="final-cta-inner">
|
||||||
|
<div class="section-kicker" style="margin-bottom:12px;">// ready to start</div>
|
||||||
|
<h2>5 分钟,拿到你第一个 sk-puro-* key</h2>
|
||||||
|
<p>注册送 <b class="text-cyan">$5</b> 测试积分 · 绑定你的第一个订阅即可开始。</p>
|
||||||
|
<div style="display:inline-flex; gap:12px;">
|
||||||
|
<a href="Register.html" class="btn btn-primary btn-lg">免费注册 →</a>
|
||||||
|
<a href="Docs.html" class="btn btn-ghost btn-lg">查看文档</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const csl = document.getElementById('custom-slider');
|
||||||
|
const camt = document.getElementById('custom-amt');
|
||||||
|
const ccredit = document.getElementById('custom-credit');
|
||||||
|
const cbonus = document.getElementById('custom-bonus');
|
||||||
|
function updateCustom() {
|
||||||
|
const v = +csl.value;
|
||||||
|
camt.textContent = v;
|
||||||
|
let bonus = 21;
|
||||||
|
if (v >= 500) bonus = 120;
|
||||||
|
else if (v >= 200) bonus = 110;
|
||||||
|
else if (v >= 99) bonus = 100;
|
||||||
|
else if (v >= 50) bonus = 70;
|
||||||
|
else if (v >= 30) bonus = 50;
|
||||||
|
else if (v >= 20) bonus = 35;
|
||||||
|
const credit = Math.round(v * (1 + bonus/100));
|
||||||
|
ccredit.textContent = '$' + credit;
|
||||||
|
cbonus.textContent = bonus;
|
||||||
|
}
|
||||||
|
csl.addEventListener('input', updateCustom);
|
||||||
|
updateCustom();
|
||||||
|
|
||||||
|
const reqS = document.getElementById('req-slider');
|
||||||
|
const tokS = document.getElementById('tok-slider');
|
||||||
|
const mixS = document.getElementById('mix-slider');
|
||||||
|
function fmtMoney(n) { return '$' + n.toLocaleString('en-US', { maximumFractionDigits: 0 }); }
|
||||||
|
function fmtNum(n) {
|
||||||
|
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 n.toString();
|
||||||
|
}
|
||||||
|
function calc() {
|
||||||
|
const req = +reqS.value;
|
||||||
|
const tok = +tokS.value;
|
||||||
|
const mix = +mixS.value;
|
||||||
|
document.getElementById('req-val').textContent = req.toLocaleString();
|
||||||
|
document.getElementById('tok-val').textContent = tok.toLocaleString();
|
||||||
|
document.getElementById('mix-val').textContent = mix + '%';
|
||||||
|
const monthlyTok = req * tok * 30;
|
||||||
|
const avgOfficial = (mix/100) * 6 + (1 - mix/100) * 3;
|
||||||
|
const official = monthlyTok / 1e6 * avgOfficial;
|
||||||
|
const puro = official * 0.3;
|
||||||
|
const save = official - puro;
|
||||||
|
const savePct = Math.round((save / official) * 100);
|
||||||
|
document.getElementById('total-tok').textContent = fmtNum(monthlyTok);
|
||||||
|
document.getElementById('official-cost').textContent = fmtMoney(official);
|
||||||
|
document.getElementById('puro-cost').textContent = fmtMoney(puro);
|
||||||
|
document.getElementById('save-amt').textContent = fmtMoney(save) + ' · ' + savePct + '%';
|
||||||
|
document.getElementById('rec-amt').textContent = Math.ceil(puro);
|
||||||
|
const note = puro < 30 ? '≈ Starter 档够用' : puro < 80 ? '≈ Pro 档 1 个月' : '≈ Scale 档 · 1 个月';
|
||||||
|
document.getElementById('rec-note').textContent = note;
|
||||||
|
}
|
||||||
|
[reqS, tokS, mixS].forEach(s => s.addEventListener('input', calc));
|
||||||
|
calc();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
386
docs/design-drafts/v2/Register.html
Normal file
386
docs/design-drafts/v2/Register.html
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
|
<title>注册 — PURO AI</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="puro.css">
|
||||||
|
<style>
|
||||||
|
body { min-height: 100vh; overflow-x: hidden; }
|
||||||
|
|
||||||
|
.split { display: grid; grid-template-columns: 1fr 1fr; min-height: 100vh; position: relative; z-index: 1; }
|
||||||
|
|
||||||
|
/* ============ LEFT (NARRATIVE + STEPS) ============ */
|
||||||
|
.narrative {
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
padding: 48px 56px;
|
||||||
|
display: flex; flex-direction: column; justify-content: space-between;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
background: linear-gradient(135deg, rgba(34,211,238,0.04), transparent 60%), rgba(15, 23, 42, 0.3);
|
||||||
|
}
|
||||||
|
.narrative::before {
|
||||||
|
content: ""; position: absolute; inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(600px 400px at 20% 20%, rgba(34,211,238,0.08), transparent 60%),
|
||||||
|
radial-gradient(500px 400px at 90% 80%, rgba(168,85,247,0.06), transparent 60%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.narrative-inner { position: relative; display: flex; flex-direction: column; gap: 28px; z-index: 1; }
|
||||||
|
|
||||||
|
.brand-top { display: inline-flex; align-items: center; gap: 10px; font-weight: 700; font-size: 15px; letter-spacing: -0.01em; color: var(--text-0); }
|
||||||
|
.brand-top svg { width: 22px; height: 22px; color: var(--cyan); }
|
||||||
|
|
||||||
|
.n-kicker { font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.1em; color: var(--cyan); }
|
||||||
|
.n-headline { font-size: 52px; font-weight: 800; letter-spacing: -0.03em; line-height: 1.05; display: flex; align-items: baseline; flex-wrap: wrap; gap: 14px; }
|
||||||
|
.n-headline .amber { color: var(--amber); }
|
||||||
|
.n-headline .cyan { color: var(--cyan); }
|
||||||
|
.n-headline .arrow { font-size: 38px; color: var(--text-3); font-weight: 400; }
|
||||||
|
.n-sub { color: var(--text-2); font-size: 15px; line-height: 1.75; max-width: 440px; }
|
||||||
|
.n-sub .line { display: block; }
|
||||||
|
.n-sub .puro { color: var(--text-0); font-weight: 600; }
|
||||||
|
|
||||||
|
.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); 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); }
|
||||||
|
|
||||||
|
.n-bottom { position: relative; z-index: 1; font-family: var(--font-mono); font-size: 11px; color: var(--text-3); display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.n-bottom .sep { color: var(--border-2); }
|
||||||
|
.n-bottom .live { color: var(--green); display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.n-bottom .live .dot { width: 5px; height: 5px; border-radius: 50%; background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||||
|
|
||||||
|
/* ============ RIGHT (FORM) ============ */
|
||||||
|
.form-side { display: flex; flex-direction: column; justify-content: center; align-items: center; padding: 48px 56px; position: relative; }
|
||||||
|
.back-home { position: absolute; top: 32px; right: 32px; font-family: var(--font-mono); font-size: 12px; color: var(--text-3); display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.back-home:hover { color: var(--text-0); }
|
||||||
|
|
||||||
|
.form-card { width: 100%; max-width: 400px; }
|
||||||
|
.form-card h1 { font-size: 32px; font-weight: 700; letter-spacing: -0.02em; margin-bottom: 6px; }
|
||||||
|
.form-card .sub { color: var(--text-2); font-size: 14px; margin-bottom: 32px; }
|
||||||
|
|
||||||
|
.field { margin-bottom: 16px; }
|
||||||
|
.field label { display: block; font-size: 12px; color: var(--text-2); font-weight: 500; margin-bottom: 8px; }
|
||||||
|
.input-wrap { position: relative; display: flex; align-items: center; background: rgba(2, 6, 23, 0.4); border: 1px solid var(--border-2); border-radius: var(--r-md); transition: all .15s; }
|
||||||
|
.input-wrap:focus-within { border-color: var(--cyan); background: rgba(34, 211, 238, 0.04); box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.1); }
|
||||||
|
.input-wrap .icon { padding: 0 12px; color: var(--text-3); flex-shrink: 0; display: flex; }
|
||||||
|
.input-wrap input { flex: 1; background: none; border: none; outline: none; font-family: inherit; color: var(--text-0); font-size: 14px; padding: 12px 14px 12px 0; }
|
||||||
|
.input-wrap input::placeholder { color: var(--text-3); }
|
||||||
|
.eye { display: flex; padding: 10px 12px; color: var(--text-3); background: none; border: none; cursor: pointer; }
|
||||||
|
.eye:hover { color: var(--text-1); }
|
||||||
|
.valid-ico { display: none; color: var(--green); padding: 0 12px; }
|
||||||
|
.input-wrap input.ok ~ .valid-ico { display: flex; }
|
||||||
|
.input-wrap input.ok { padding-right: 0; }
|
||||||
|
.input-wrap input.ok + .eye { display: none; }
|
||||||
|
|
||||||
|
/* Password strength */
|
||||||
|
.pw-strength { display: flex; gap: 4px; margin-top: 8px; margin-bottom: 6px; }
|
||||||
|
.pw-strength .bar { flex: 1; height: 3px; background: var(--border); border-radius: 2px; transition: background .2s; }
|
||||||
|
.pw-strength[data-score="1"] .bar:nth-child(1) { background: var(--red); }
|
||||||
|
.pw-strength[data-score="2"] .bar:nth-child(-n+2) { background: var(--amber); }
|
||||||
|
.pw-strength[data-score="3"] .bar:nth-child(-n+3) { background: var(--cyan); }
|
||||||
|
.pw-strength[data-score="4"] .bar { background: var(--green); }
|
||||||
|
|
||||||
|
.pw-hint { display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 10px; color: var(--text-3); letter-spacing: 0.05em; }
|
||||||
|
.pw-hint .val { color: var(--text-3); }
|
||||||
|
.pw-hint[data-score="1"] .val { color: var(--red); }
|
||||||
|
.pw-hint[data-score="2"] .val { color: var(--amber); }
|
||||||
|
.pw-hint[data-score="3"] .val { color: var(--cyan); }
|
||||||
|
.pw-hint[data-score="4"] .val { color: var(--green); }
|
||||||
|
|
||||||
|
.match-hint { font-family: var(--font-mono); font-size: 11px; color: var(--text-3); margin-top: 6px; min-height: 14px; }
|
||||||
|
.match-hint.mismatch { color: var(--red); }
|
||||||
|
.match-hint.ok { color: var(--green); }
|
||||||
|
|
||||||
|
.check { display: inline-flex; align-items: flex-start; gap: 10px; font-size: 13px; color: var(--text-2); cursor: pointer; user-select: none; margin: 6px 0 20px; line-height: 1.5; }
|
||||||
|
.check input { position: absolute; opacity: 0; pointer-events: none; }
|
||||||
|
.check .box { width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--border-2); background: rgba(2, 6, 23, 0.4); position: relative; flex-shrink: 0; margin-top: 2px; }
|
||||||
|
.check input:checked ~ .box { background: var(--cyan); border-color: var(--cyan); }
|
||||||
|
.check input:checked ~ .box::after { content: ""; position: absolute; left: 4px; top: 1px; width: 4px; height: 8px; border: solid #042f2e; border-width: 0 2px 2px 0; transform: rotate(45deg); }
|
||||||
|
.check a { color: var(--text-1); border-bottom: 1px dashed var(--border-2); }
|
||||||
|
.check a:hover { color: var(--cyan); border-color: var(--cyan); }
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 20px !important;
|
||||||
|
font-size: 14px !important;
|
||||||
|
margin-top: 4px;
|
||||||
|
background: var(--cyan) !important;
|
||||||
|
color: #042f2e !important;
|
||||||
|
border: 1px solid var(--cyan) !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.submit-btn:hover { background: var(--cyan-2) !important; }
|
||||||
|
.submit-btn:disabled { opacity: 0.5; cursor: not-allowed; background: var(--border-2) !important; border-color: var(--border-2) !important; color: var(--text-3) !important; }
|
||||||
|
.submit-btn .spinner { width: 14px; height: 14px; border: 2px solid rgba(0,0,0,0.2); border-top-color: #042f2e; border-radius: 50%; animation: spin .7s linear infinite; display: none; }
|
||||||
|
.submit-btn.loading .spinner { display: inline-block; }
|
||||||
|
.submit-btn.loading .label { opacity: 0.5; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.ghost-btn { width: 100%; padding: 14px 20px !important; font-size: 14px !important; font-weight: 500 !important; }
|
||||||
|
|
||||||
|
.divider { display: flex; align-items: center; gap: 14px; margin: 24px 0; color: var(--text-3); font-size: 11px; font-family: var(--font-mono); letter-spacing: 0.15em; }
|
||||||
|
.divider::before, .divider::after { content: ""; flex: 1; height: 1px; background: var(--border); }
|
||||||
|
|
||||||
|
.linuxdo-ico { width: 18px; height: 18px; border-radius: 3px; background: linear-gradient(135deg, #f0a030, #f05050); display: inline-flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 800; color: white; }
|
||||||
|
|
||||||
|
.foot { margin-top: 24px; text-align: center; font-size: 13px; color: var(--text-2); }
|
||||||
|
.foot a { color: var(--cyan); font-weight: 500; }
|
||||||
|
|
||||||
|
.bonus-note { margin-top: 18px; padding: 12px 14px; background: rgba(52,211,153,0.06); border: 1px solid rgba(52,211,153,0.2); border-radius: var(--r-md); font-size: 12px; color: var(--text-1); display: flex; align-items: center; gap: 10px; }
|
||||||
|
.bonus-note .emoji { flex-shrink: 0; color: var(--green); font-weight: 700; font-family: var(--font-mono); padding: 3px 8px; background: rgba(52,211,153,0.15); border-radius: 4px; font-size: 11px; }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.split { grid-template-columns: 1fr; }
|
||||||
|
.narrative { padding: 32px 28px; border-right: none; border-bottom: 1px solid var(--border); }
|
||||||
|
.n-headline { font-size: 36px; }
|
||||||
|
.steps, .n-bottom { display: none; }
|
||||||
|
.form-side { padding: 40px 28px; }
|
||||||
|
.back-home { top: 20px; right: 20px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-glow"></div>
|
||||||
|
<div class="grain"></div>
|
||||||
|
|
||||||
|
<div class="split">
|
||||||
|
<section class="narrative">
|
||||||
|
<div class="narrative-inner">
|
||||||
|
<a href="Landing.html" class="brand-top">
|
||||||
|
<svg viewBox="0 0 24 24" 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>
|
||||||
|
PURO AI
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="n-kicker">// 5 分钟开始用</div>
|
||||||
|
<h1 class="n-headline" style="margin-top: 12px;">
|
||||||
|
<span class="amber">N</span> 个订阅
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<span class="cyan">1</span> 个 key
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="n-sub">
|
||||||
|
<span class="line">省去切换账号的繁琐,</span>
|
||||||
|
<span class="line">省去为多个高昂订阅重复买单。</span>
|
||||||
|
<span class="line" style="margin-top: 8px;"><span class="puro">PURO</span>(纯粹)—— 让 AI 调用回归本质。</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="steps">
|
||||||
|
<div class="steps-title">// 下一步</div>
|
||||||
|
<div class="step active">
|
||||||
|
<div class="step-num">1</div>
|
||||||
|
<div class="step-text"><b>创建账户</b> · 邮箱 + 密码,或用 LinuxDO OAuth</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-num">2</div>
|
||||||
|
<div class="step-text"><b>绑定订阅</b> · OAuth 接入你现有的 Claude Pro / ChatGPT Plus</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-num">3</div>
|
||||||
|
<div class="step-text"><b>生成 key</b> · 拿到 <span class="k">sk-puro-…</span>,换掉 SDK 的 <span class="k">base_url</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="form-side">
|
||||||
|
<a href="Landing.html" class="back-home">← 返回首页</a>
|
||||||
|
|
||||||
|
<form class="form-card" id="reg-form" autocomplete="off" novalidate>
|
||||||
|
<h1>创建账户</h1>
|
||||||
|
<p class="sub">注册即送 <b style="color:var(--cyan)">$5</b> 测试积分</p>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="email">邮箱</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="5" width="18" height="14" rx="2"/>
|
||||||
|
<path d="M3 7l9 6 9-6"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input type="email" id="email" name="email" placeholder="you@puro.im" required>
|
||||||
|
<span class="valid-ico">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M5 12l5 5L20 7"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">密码</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="4" y="11" width="16" height="10" rx="2"/>
|
||||||
|
<path d="M8 11V7a4 4 0 0 1 8 0v4"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input type="password" id="password" name="password" placeholder="至少 8 位,含字母与数字" required>
|
||||||
|
<button type="button" class="eye" id="toggle-pw" aria-label="切换显示密码">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/>
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="pw-strength" id="pw-strength" data-score="0">
|
||||||
|
<span class="bar"></span><span class="bar"></span><span class="bar"></span><span class="bar"></span>
|
||||||
|
</div>
|
||||||
|
<div class="pw-hint" id="pw-hint" data-score="0">
|
||||||
|
<span>// strength</span>
|
||||||
|
<span class="val">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="password2">确认密码</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="4" y="11" width="16" height="10" rx="2"/>
|
||||||
|
<path d="M8 11V7a4 4 0 0 1 8 0v4"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input type="password" id="password2" name="password2" placeholder="再输入一次" required>
|
||||||
|
<span class="valid-ico">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M5 12l5 5L20 7"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="match-hint" id="match-hint"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" id="terms">
|
||||||
|
<span class="box"></span>
|
||||||
|
<span>我已阅读并同意 <a href="#">服务条款</a> 与 <a href="#">隐私政策</a></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary submit-btn" id="submit-btn" disabled>
|
||||||
|
<span class="spinner"></span>
|
||||||
|
<span class="label">创建账户 →</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="divider">OR</div>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-ghost ghost-btn">
|
||||||
|
<span class="linuxdo-ico">L</span>
|
||||||
|
使用 LinuxDO 注册
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="bonus-note">
|
||||||
|
<span class="emoji">+$5</span>
|
||||||
|
<span>完成注册即送 <b style="color:var(--text-0)">$5</b> 测试积分 —— 够你跑几万次 Claude 请求。</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="foot">
|
||||||
|
已有账户?<a href="Login.html">登录</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const email = document.getElementById('email');
|
||||||
|
const pw = document.getElementById('password');
|
||||||
|
const pw2 = document.getElementById('password2');
|
||||||
|
const terms = document.getElementById('terms');
|
||||||
|
const btn = document.getElementById('submit-btn');
|
||||||
|
const strengthBars = document.getElementById('pw-strength');
|
||||||
|
const strengthHint = document.getElementById('pw-hint');
|
||||||
|
const matchHint = document.getElementById('match-hint');
|
||||||
|
const toggle = document.getElementById('toggle-pw');
|
||||||
|
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
const show = pw.type === 'password';
|
||||||
|
pw.type = show ? 'text' : 'password';
|
||||||
|
});
|
||||||
|
|
||||||
|
function scorePw(v) {
|
||||||
|
if (!v) return 0;
|
||||||
|
let s = 0;
|
||||||
|
if (v.length >= 8) s++;
|
||||||
|
if (/[A-Z]/.test(v) && /[a-z]/.test(v)) s++;
|
||||||
|
if (/\d/.test(v)) s++;
|
||||||
|
if (/[^A-Za-z0-9]/.test(v) || v.length >= 14) s++;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
const labels = ['—', '弱', '中', '强', '极强'];
|
||||||
|
|
||||||
|
function updateAll() {
|
||||||
|
const s = scorePw(pw.value);
|
||||||
|
strengthBars.dataset.score = s;
|
||||||
|
strengthHint.dataset.score = s;
|
||||||
|
strengthHint.querySelector('.val').textContent = labels[s];
|
||||||
|
|
||||||
|
// email validation
|
||||||
|
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value)) {
|
||||||
|
email.classList.add('ok');
|
||||||
|
} else {
|
||||||
|
email.classList.remove('ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
// password match
|
||||||
|
if (!pw2.value) {
|
||||||
|
matchHint.textContent = '';
|
||||||
|
matchHint.className = 'match-hint';
|
||||||
|
pw2.classList.remove('ok');
|
||||||
|
} else if (pw2.value === pw.value) {
|
||||||
|
matchHint.textContent = '// matched';
|
||||||
|
matchHint.className = 'match-hint ok';
|
||||||
|
pw2.classList.add('ok');
|
||||||
|
} else {
|
||||||
|
matchHint.textContent = '// passwords do not match';
|
||||||
|
matchHint.className = 'match-hint mismatch';
|
||||||
|
pw2.classList.remove('ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
// enable submit?
|
||||||
|
const valid = email.classList.contains('ok') && s >= 2 && pw.value === pw2.value && pw2.value && terms.checked;
|
||||||
|
btn.disabled = !valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
[email, pw, pw2].forEach(el => el.addEventListener('input', updateAll));
|
||||||
|
terms.addEventListener('change', updateAll);
|
||||||
|
|
||||||
|
const form = document.getElementById('reg-form');
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (btn.disabled) return;
|
||||||
|
btn.classList.add('loading');
|
||||||
|
btn.disabled = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.classList.remove('loading');
|
||||||
|
btn.querySelector('.label').textContent = '✓ 注册成功,正在跳转...';
|
||||||
|
btn.style.background = 'var(--green)';
|
||||||
|
setTimeout(() => { window.location.href = 'Binding.html'; }, 800);
|
||||||
|
}, 1200);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
726
docs/design-drafts/v2/puro.css
Normal file
726
docs/design-drafts/v2/puro.css
Normal file
@@ -0,0 +1,726 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
PURO AI — Design System
|
||||||
|
Shared tokens + primitive styles used across every page.
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
Usage: <link rel="stylesheet" href="puro.css">
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Surfaces */
|
||||||
|
--bg-0: #0a0e1a; /* page */
|
||||||
|
--bg-1: #0f172a; /* raised */
|
||||||
|
--bg-2: #111827; /* card alt */
|
||||||
|
--bg-code: #020617; /* code canvas */
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border: #1e293b;
|
||||||
|
--border-2: #334155;
|
||||||
|
--border-3: #475569;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text-0: #f8fafc; /* primary */
|
||||||
|
--text-1: #cbd5e1; /* body */
|
||||||
|
--text-2: #94a3b8; /* muted */
|
||||||
|
--text-3: #64748b; /* hint */
|
||||||
|
|
||||||
|
/* Accents */
|
||||||
|
--cyan: #22d3ee;
|
||||||
|
--cyan-2: #67e8f9;
|
||||||
|
--cyan-dim: #0891b2;
|
||||||
|
--purple: #a855f7;
|
||||||
|
--amber: #fbbf24;
|
||||||
|
--green: #34d399;
|
||||||
|
--red: #f87171;
|
||||||
|
--orange: #fb923c;
|
||||||
|
|
||||||
|
/* Provider brand dots */
|
||||||
|
--p-claude: #d97757;
|
||||||
|
--p-gpt: #10a37f;
|
||||||
|
--p-gemini: #4285f4;
|
||||||
|
--p-codex: #f0a030;
|
||||||
|
|
||||||
|
/* Radius */
|
||||||
|
--r-sm: 6px;
|
||||||
|
--r-md: 8px;
|
||||||
|
--r-lg: 12px;
|
||||||
|
--r-xl: 16px;
|
||||||
|
|
||||||
|
/* Shadow */
|
||||||
|
--shadow-lg: 0 30px 60px -30px rgba(0,0,0,0.6);
|
||||||
|
--shadow-xl: 0 40px 80px -40px rgba(0,0,0,0.8);
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
background: var(--bg-0);
|
||||||
|
color: var(--text-0);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-feature-settings: "cv11", "ss01", "ss03";
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
body { overflow-x: hidden; }
|
||||||
|
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; }
|
||||||
|
|
||||||
|
/* scrollbar — subtle */
|
||||||
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 6px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--border-3); }
|
||||||
|
|
||||||
|
.mono { font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
BACKGROUND EFFECTS
|
||||||
|
========================================================================== */
|
||||||
|
.bg-glow {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bg-glow::before,
|
||||||
|
.bg-glow::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 900px;
|
||||||
|
height: 900px;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(120px);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
.bg-glow::before {
|
||||||
|
background: radial-gradient(circle, #22d3ee 0%, transparent 60%);
|
||||||
|
top: -300px;
|
||||||
|
left: -200px;
|
||||||
|
}
|
||||||
|
.bg-glow::after {
|
||||||
|
background: radial-gradient(circle, #a855f7 0%, transparent 60%);
|
||||||
|
top: 200px;
|
||||||
|
right: -300px;
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
.bg-glow.soft::before, .bg-glow.soft::after { opacity: 0.15; }
|
||||||
|
|
||||||
|
.grain {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 0.4;
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence baseFrequency='0.9' numOctaves='2'/></filter><rect width='100%25' height='100%25' filter='url(%23n)' opacity='0.35'/></svg>");
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 32px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.container-wide { max-width: 1280px; }
|
||||||
|
.container-narrow { max-width: 860px; }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
NAV
|
||||||
|
========================================================================== */
|
||||||
|
.nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
background: rgba(10, 14, 26, 0.72);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.nav-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 64px;
|
||||||
|
gap: 48px;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.hex {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 28px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.nav-links a { transition: color .15s; }
|
||||||
|
.nav-links a:hover, .nav-links a.active { color: var(--text-0); }
|
||||||
|
.nav-links .disabled { color: var(--text-3); cursor: not-allowed; display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.nav-links .disabled::after {
|
||||||
|
content: "即将推出";
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
.nav-cta {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
BUTTONS
|
||||||
|
========================================================================== */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
transition: all .15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--cyan);
|
||||||
|
color: #042f2e;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: var(--cyan-2); }
|
||||||
|
.btn-primary:active { transform: translateY(1px); }
|
||||||
|
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
border-color: var(--border-2);
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover { border-color: var(--border-3); color: var(--text-0); background: rgba(255,255,255,0.02); }
|
||||||
|
|
||||||
|
.btn-subtle {
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
color: var(--text-1);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.btn-subtle:hover { background: rgba(255,255,255,0.08); color: var(--text-0); }
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: rgba(248, 113, 113, 0.1);
|
||||||
|
color: var(--red);
|
||||||
|
border-color: rgba(248, 113, 113, 0.25);
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background: rgba(248, 113, 113, 0.15); border-color: rgba(248, 113, 113, 0.4); }
|
||||||
|
|
||||||
|
.btn-lg { padding: 12px 20px; font-size: 14px; }
|
||||||
|
.btn-sm { padding: 5px 10px; font-size: 12px; }
|
||||||
|
.btn-icon { padding: 7px; aspect-ratio: 1; }
|
||||||
|
|
||||||
|
.btn .spinner {
|
||||||
|
width: 14px; height: 14px;
|
||||||
|
border: 2px solid rgba(0,0,0,0.2);
|
||||||
|
border-top-color: currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin .7s linear infinite;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.btn.loading .spinner { display: inline-block; }
|
||||||
|
.btn.loading .label { opacity: 0.5; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
BADGES / PILLS / CHIPS
|
||||||
|
========================================================================== */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
background: rgba(34, 211, 238, 0.1);
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
.badge.amber { background: rgba(251, 191, 36, 0.12); color: var(--amber); }
|
||||||
|
.badge.purple { background: rgba(168, 85, 247, 0.12); color: var(--purple); }
|
||||||
|
.badge.green { background: rgba(52, 211, 153, 0.12); color: var(--green); }
|
||||||
|
.badge.red { background: rgba(248, 113, 113, 0.12); color: var(--red); }
|
||||||
|
.badge.muted { background: rgba(255, 255, 255, 0.04); color: var(--text-2); border: 1px solid var(--border); }
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 100px;
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-1);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.chip .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--green); }
|
||||||
|
.chip.claude .dot { background: var(--p-claude); }
|
||||||
|
.chip.gpt .dot { background: var(--p-gpt); }
|
||||||
|
.chip.gemini .dot { background: var(--p-gemini); }
|
||||||
|
.chip.codex .dot { background: var(--p-codex); }
|
||||||
|
|
||||||
|
.dot-sep { width: 4px; height: 4px; border-radius: 50%; background: var(--text-3); display: inline-block; }
|
||||||
|
|
||||||
|
/* status chip (tiny dot absolute-positioned) */
|
||||||
|
.status-chip {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--green);
|
||||||
|
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.status-chip.dim { background: var(--text-3); box-shadow: none; }
|
||||||
|
.status-chip.amber { background: var(--amber); box-shadow: 0 0 0 3px rgba(251, 191, 36, 0.15); }
|
||||||
|
.status-chip.red { background: var(--red); box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.15); }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
CARDS / SURFACES
|
||||||
|
========================================================================== */
|
||||||
|
.card {
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.card-raised {
|
||||||
|
background: var(--bg-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
}
|
||||||
|
.card-interactive {
|
||||||
|
transition: all .2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.card-interactive:hover {
|
||||||
|
border-color: var(--border-2);
|
||||||
|
background: rgba(15, 23, 42, 0.85);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider { height: 1px; background: var(--border); margin: 24px 0; border: 0; }
|
||||||
|
.divider-dashed { border: 0; border-top: 1px dashed var(--border); margin: 20px 0; }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
FORMS
|
||||||
|
========================================================================== */
|
||||||
|
.field { margin-bottom: 18px; }
|
||||||
|
.field-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-1);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.field-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.field-error {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--red);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrap { position: relative; }
|
||||||
|
.input-wrap .icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 14px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--text-3);
|
||||||
|
pointer-events: none;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 14px;
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
color: var(--text-0);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.input.with-icon { padding-left: 40px; }
|
||||||
|
.input::placeholder { color: var(--text-3); }
|
||||||
|
.input:hover { border-color: var(--border-3); }
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--cyan);
|
||||||
|
box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.12);
|
||||||
|
background: rgba(15, 23, 42, 0.9);
|
||||||
|
}
|
||||||
|
.input.ok { border-color: rgba(52, 211, 153, 0.4); }
|
||||||
|
.input.ok:focus { box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.12); }
|
||||||
|
.input.error { border-color: var(--red); }
|
||||||
|
.input.error:focus { box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.12); }
|
||||||
|
|
||||||
|
textarea.input { height: auto; padding: 12px 14px; resize: vertical; line-height: 1.5; }
|
||||||
|
|
||||||
|
select.input {
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><path d='M6 9l6 6 6-6'/></svg>");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 14px center;
|
||||||
|
padding-right: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* checkbox */
|
||||||
|
.check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
.check input { display: none; }
|
||||||
|
.check .box {
|
||||||
|
width: 16px; height: 16px;
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-1);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all .15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.check input:checked + .box {
|
||||||
|
background: var(--cyan);
|
||||||
|
border-color: var(--cyan);
|
||||||
|
}
|
||||||
|
.check input:checked + .box::after {
|
||||||
|
content: "✓";
|
||||||
|
color: #042f2e;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
SECTION HEADINGS
|
||||||
|
========================================================================== */
|
||||||
|
.section-kicker {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--cyan);
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: clamp(28px, 3.5vw, 40px);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.section-sub {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
TABLES
|
||||||
|
========================================================================== */
|
||||||
|
.tbl {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.tbl th {
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
.tbl td {
|
||||||
|
padding: 14px;
|
||||||
|
border-bottom: 1px solid rgba(30, 41, 59, 0.5);
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
.tbl tr:last-child td { border-bottom: none; }
|
||||||
|
.tbl tr:hover td { background: rgba(15, 23, 42, 0.4); }
|
||||||
|
.tbl td.mono, .tbl th.mono { font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
CODE BLOCKS
|
||||||
|
========================================================================== */
|
||||||
|
.code-frame {
|
||||||
|
background: var(--bg-code);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
.code-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: rgba(15, 23, 42, 0.8);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.traffic {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.traffic span {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #475569;
|
||||||
|
}
|
||||||
|
.code-body {
|
||||||
|
padding: 22px 26px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--text-1);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.code-body .line { display: flex; gap: 20px; }
|
||||||
|
.ln { color: var(--text-3); user-select: none; min-width: 16px; text-align: right; opacity: 0.5; }
|
||||||
|
|
||||||
|
/* syntax */
|
||||||
|
.kw { color: #c084fc; }
|
||||||
|
.str { color: #86efac; }
|
||||||
|
.num { color: #fbbf24; }
|
||||||
|
.com { color: #64748b; font-style: italic; }
|
||||||
|
.fn { color: #22d3ee; }
|
||||||
|
.prop{ color: #f0abfc; }
|
||||||
|
.var-v { color: #f8fafc; }
|
||||||
|
.flag{ color: #fb923c; }
|
||||||
|
.bash-prompt { color: var(--cyan); user-select: none; }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
PROVIDER-BRAND HELPERS
|
||||||
|
========================================================================== */
|
||||||
|
.provider {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.provider .dot { width: 6px; height: 6px; border-radius: 50%; }
|
||||||
|
.provider.claude .dot { background: var(--p-claude); }
|
||||||
|
.provider.gpt .dot { background: var(--p-gpt); }
|
||||||
|
.provider.gemini .dot { background: var(--p-gemini); }
|
||||||
|
.provider.codex .dot { background: var(--p-codex); }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
UTILITIES
|
||||||
|
========================================================================== */
|
||||||
|
.stack-xs { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.stack-sm { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.stack-md { display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
.stack-lg { display: flex; flex-direction: column; gap: 32px; }
|
||||||
|
|
||||||
|
.row { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.row-sm { gap: 8px; }
|
||||||
|
.row-lg { gap: 20px; }
|
||||||
|
.row-between { justify-content: space-between; }
|
||||||
|
.row-center { justify-content: center; }
|
||||||
|
.row-wrap { flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.flex-1 { flex: 1; }
|
||||||
|
.ml-auto { margin-left: auto; }
|
||||||
|
.mt-auto { margin-top: auto; }
|
||||||
|
|
||||||
|
.text-0 { color: var(--text-0); }
|
||||||
|
.text-1 { color: var(--text-1); }
|
||||||
|
.text-2 { color: var(--text-2); }
|
||||||
|
.text-3 { color: var(--text-3); }
|
||||||
|
.text-cyan { color: var(--cyan); }
|
||||||
|
.text-purple { color: var(--purple); }
|
||||||
|
.text-amber { color: var(--amber); }
|
||||||
|
.text-green { color: var(--green); }
|
||||||
|
.text-red { color: var(--red); }
|
||||||
|
|
||||||
|
.text-xs { font-size: 11px; }
|
||||||
|
.text-sm { font-size: 13px; }
|
||||||
|
.text-md { font-size: 14px; }
|
||||||
|
.text-lg { font-size: 16px; }
|
||||||
|
.text-xl { font-size: 20px; }
|
||||||
|
.text-2xl { font-size: 28px; }
|
||||||
|
.text-3xl { font-size: 36px; }
|
||||||
|
|
||||||
|
.fw-400 { font-weight: 400; }
|
||||||
|
.fw-500 { font-weight: 500; }
|
||||||
|
.fw-600 { font-weight: 600; }
|
||||||
|
.fw-700 { font-weight: 700; }
|
||||||
|
.fw-800 { font-weight: 800; }
|
||||||
|
|
||||||
|
.tabular { font-variant-numeric: tabular-nums; }
|
||||||
|
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
APP SHELL (for dashboard-style pages)
|
||||||
|
========================================================================== */
|
||||||
|
.app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 240px 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.app-side {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
background: rgba(2, 6, 23, 0.6);
|
||||||
|
padding: 20px 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 28px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.app-side .brand { padding: 6px 10px 14px; }
|
||||||
|
.side-group { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.side-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
padding: 0 10px 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.side-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .12s;
|
||||||
|
}
|
||||||
|
.side-item:hover { color: var(--text-0); background: rgba(255,255,255,0.03); }
|
||||||
|
.side-item.active { background: rgba(34, 211, 238, 0.08); color: var(--cyan); }
|
||||||
|
.side-item .ico {
|
||||||
|
width: 16px; height: 16px; opacity: 0.8;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.side-item .count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.side-item.active .count { color: var(--cyan); }
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
min-width: 0; /* allow grid children to shrink */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.app-topbar {
|
||||||
|
height: 60px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 32px;
|
||||||
|
gap: 16px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: rgba(10, 14, 26, 0.75);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
.app-topbar h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.app-content {
|
||||||
|
padding: 32px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* user avatar pill */
|
||||||
|
.avatar {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #22d3ee, #a855f7);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #042f2e;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
KBD
|
||||||
|
========================================================================== */
|
||||||
|
kbd {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-1);
|
||||||
|
background: var(--bg-1);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
BIN
docs/design-drafts/v2/uploads/pasted-1776589344748-0.png
Normal file
BIN
docs/design-drafts/v2/uploads/pasted-1776589344748-0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 337 KiB |
BIN
docs/design-drafts/v2/uploads/pasted-1776589408607-0.png
Normal file
BIN
docs/design-drafts/v2/uploads/pasted-1776589408607-0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 183 KiB |
1746
docs/superpowers/plans/2026-04-19-puro-ai-landing-auth.md
Normal file
1746
docs/superpowers/plans/2026-04-19-puro-ai-landing-auth.md
Normal file
File diff suppressed because it is too large
Load Diff
1262
docs/superpowers/plans/2026-04-20-portal-i18n-pricing.md
Normal file
1262
docs/superpowers/plans/2026-04-20-portal-i18n-pricing.md
Normal file
File diff suppressed because it is too large
Load Diff
445
docs/superpowers/plans/fidelity-delta-report.md
Normal file
445
docs/superpowers/plans/fidelity-delta-report.md
Normal 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: 3–4h |
|
||||||
|
| 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 1–4 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 | 4–6h |
|
||||||
|
| 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) | 2–3h |
|
||||||
|
| Docs: 3-column layout + sticky sidebar + right TOC | 3–4h |
|
||||||
|
| 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)** | **~25–30h** |
|
||||||
|
| **Top 5 only (Landing CTA/pricing/FAQ + Docs layout + code-panel)** | **~12–14h** |
|
||||||
425
docs/superpowers/specs/2026-04-19-puro-ai-landing-auth-design.md
Normal file
425
docs/superpowers/specs/2026-04-19-puro-ai-landing-auth-design.md
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
# PURO AI · Landing + Auth 重设计(v2)
|
||||||
|
|
||||||
|
> 2026-04-19 · 分支 `feat/design-landing-auth` · **v2 更新:Claude Design 产出 10 页 + puro.css 后,定了路径 B 执行**
|
||||||
|
|
||||||
|
本文档是「PURO AI 公开页面重设计」的设计 spec:
|
||||||
|
- **Stage 1(完成)**:信息架构 + 中文文案 + 风格方向 + 布局选型
|
||||||
|
- **Stage 2(完成)**:在 claude.ai/design 出了 10 页视觉稿 + `puro.css` 设计系统,归档到 `docs/design-drafts/v2/`
|
||||||
|
- **Stage 3(本期范围)**:挑 **4 个页面** 落地 — Landing(精修版)、Login、Register、Docs(精简版)+ 设计 tokens 落地到 Tailwind
|
||||||
|
- **Stage 4**:merge → Drone CI → ai.puro.im 实机验证
|
||||||
|
|
||||||
|
## 本期范围决策(路径 B · 分层交付)
|
||||||
|
|
||||||
|
### 本期做(feat/design-landing-auth)
|
||||||
|
1. **puro.css 落地为 Tailwind config + global styles**(`tailwind.config.ts` 扩展 color/radius/font;`puro.css` 挪成 `frontend/src/assets/puro.css` 全局引入)
|
||||||
|
2. **Landing 页**(路由 `/` 未登录态)· 6 段结构,**精修版文案**(见第 3 节)
|
||||||
|
3. **Login / Register 页** 套新左右分栏 + 保留现有 OAuth/Turnstile/2FA 逻辑
|
||||||
|
4. **Docs 页精简版**(路由 `/docs`,公开访问)· 快速接入 + curl 示例 + 支持模型
|
||||||
|
|
||||||
|
### 二期做(另开 feat/design-dashboard 分支,不本期)
|
||||||
|
5. Dashboard 换皮(沿用 puro.css)
|
||||||
|
6. API Keys 管理页换皮
|
||||||
|
7. Design System 页(给团队内部看的)
|
||||||
|
|
||||||
|
### 永不做 / 远期再说
|
||||||
|
- **Binding 页**:Claude Design 预设用户自己 BYO-Subscription 绑定,但 Sub2API 是 admin 统一管账号池,概念不符
|
||||||
|
- **Pricing 页**:iShare 接管钱包/订阅后由 iShare 处理
|
||||||
|
- 注册送 $5 / 充值阶梯赠送等"赠送经济"特性
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目背景
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| **公开品牌名** | **PURO AI** |
|
||||||
|
| **内部代码名** | sub2api(Wei-Shaw/sub2api fork,不改) |
|
||||||
|
| **域名** | https://ai.puro.im |
|
||||||
|
| **现状** | 登录后是 Vue 3 + Tailwind 后台;无公开首页;登录页用浅色 `AuthLayout` |
|
||||||
|
| **目标受众** | 个人开发者 / 小团队 — 已有 ChatGPT Plus / Claude Pro / Codex / Gemini 订阅,想程序化调用而不付 API 费率 |
|
||||||
|
| **核心叙事** | "你的 AI 订阅,已经付过钱了"——把已付订阅复用为 API |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 风格方向
|
||||||
|
|
||||||
|
**暗黑科技**(Dark Tech)—— 对标 Linear / Vercel / Railway / Supabase / Cloudflare Workers。
|
||||||
|
|
||||||
|
### 配色(建议)
|
||||||
|
| 角色 | 色值 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| 主底 | `#0a0e1a` ~ `#0f172a` | 页面背景,slate-950 区间 |
|
||||||
|
| 卡片底 | `#0f172a` | 表单卡片、特性卡 |
|
||||||
|
| 边框 | `#334155` | 次要边框 |
|
||||||
|
| 主文 | `#f8fafc` | 标题 |
|
||||||
|
| 副文 | `#94a3b8` ~ `#cbd5e1` | 描述、菜单 |
|
||||||
|
| **主品牌色** | `#22d3ee`(cyan-400) | Logo、CTA、链接 |
|
||||||
|
| **辅品牌色** | `#a855f7`(purple-500) | 渐变叠加、装饰光晕 |
|
||||||
|
| 警示 | `#fbbf24`(amber-400) | "💡"标签、数字对比 |
|
||||||
|
|
||||||
|
### 视觉语汇
|
||||||
|
- 暗底上**圆形 radial gradient 光晕**(青/紫双色)
|
||||||
|
- 等宽字体(ui-monospace / SF Mono)用于 code demo
|
||||||
|
- 主体字体:sans-serif(Inter / SF Pro / 系统默认)
|
||||||
|
- 边框 1px 实线 / 关键分割用 dashed
|
||||||
|
- CTA 圆角 8px;卡片圆角 12px
|
||||||
|
- 不要拟物、不要软阴影、不要 Bootstrap 4 那种 gradient 按钮
|
||||||
|
|
||||||
|
### 排版氛围
|
||||||
|
- 大量留白
|
||||||
|
- 标题大、字距稍紧(letter-spacing -0.02em)
|
||||||
|
- 内容居中收敛(max-width ~1100px)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Landing 页(路由 `/`,未登录态)· 精修版
|
||||||
|
|
||||||
|
### 3.1 信息架构(6 段,剔除 Pricing/FAQ/CTA banner)
|
||||||
|
|
||||||
|
```
|
||||||
|
NAV · ⬢ PURO AI · 产品 · 文档 · [登录][免费试用 →]
|
||||||
|
① HERO · 主标 + 副标 + CTA×2 + 微文案
|
||||||
|
② 模型墙 · 4 个支持的 AI 平台
|
||||||
|
③ 三特性 · ⚡ 一个 key 多模型 · 🔄 账号池高可用 · 📊 用量看板
|
||||||
|
④ Code Demo · codex config 片段 + curl 示例
|
||||||
|
⑤ Dashboard · 真实 mockup 预览(不用截图了,设计稿里是 HTML 渲染的)
|
||||||
|
⑥ Footer · 4 列(品牌 / 产品 / 资源 / 联系)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 完整中文文案
|
||||||
|
|
||||||
|
#### NAV
|
||||||
|
- Logo: `⬢ PURO AI`
|
||||||
|
- 菜单: 产品 · 文档
|
||||||
|
- 右侧: `[登录]`(边框)`[免费试用 →]`(cyan 实底;Nav 里保留注册入口,但 Hero CTA 不走这里)
|
||||||
|
|
||||||
|
#### ① HERO
|
||||||
|
- **主标**: 你的 AI 订阅,**已经付过钱了。**
|
||||||
|
- **副标**: Claude Pro · ChatGPT Plus · Codex · Gemini 订阅<br>聚合成统一 API,零改动接入 OpenAI / Anthropic SDK
|
||||||
|
- **主 CTA**: `登录 →`(cyan 实底,已有账号用户直接进)
|
||||||
|
- **副 CTA**: `联系咨询`(边框,mailto:admin@puro.im 或未来跳 iShare 咨询页)
|
||||||
|
- **微文案**: 已验证可用 Codex CLI · Claude Code · curl · 服务器出口新加坡
|
||||||
|
|
||||||
|
#### ② 模型墙
|
||||||
|
- **小标题**: 支持的 AI 平台
|
||||||
|
- **副标**: 通过 OAuth 直接复用你的订阅,无需申请官方 API key
|
||||||
|
- **Logos**:
|
||||||
|
- ⚪ Claude Pro / Max
|
||||||
|
- 🟢 ChatGPT Plus / Pro
|
||||||
|
- 🟡 Codex CLI
|
||||||
|
- 🔵 Gemini Code Assist
|
||||||
|
- ⚫ 更多(规划中,灰显)
|
||||||
|
|
||||||
|
#### ③ 三大特性
|
||||||
|
| 图标 | 标题 | 描述 |
|
||||||
|
|---|---|---|
|
||||||
|
| ⚡ | 一个 key 接所有模型 | 不再为每个 provider 申请 API key、配置 base_url。统一 sk- 走 Claude / GPT / Gemini,按 model 自动路由到对应账号池。|
|
||||||
|
| 🔄 | 账号池高可用 | 支持多账号自动调度与 failover。某个上游触发限流 / 冷却时,流量切到下一个健康账号,token 刷新全自动。|
|
||||||
|
| 📊 | 用量看板 | 每条请求的 tokens、费用、上游账号、延迟全可视化。模型分布饼图 + 趋势曲线 + Top 排行。|
|
||||||
|
|
||||||
|
#### ④ Code Demo
|
||||||
|
- **标题**: 把 base_url 一改,就能用
|
||||||
|
- **副**: 兼容 OpenAI / Anthropic / Gemini SDK,**零代码改动**
|
||||||
|
- **代码块**:
|
||||||
|
```toml
|
||||||
|
# Codex CLI
|
||||||
|
# ~/.codex/config.toml
|
||||||
|
[model_providers.OpenAI]
|
||||||
|
base_url = "https://ai.puro.im"
|
||||||
|
wire_api = "responses"
|
||||||
|
```
|
||||||
|
```bash
|
||||||
|
# 或直接 curl
|
||||||
|
$ curl https://ai.puro.im/responses \
|
||||||
|
-H "Authorization: Bearer sk-xxx" \
|
||||||
|
-d '{"model":"gpt-5.4","input":"hello"}'
|
||||||
|
```
|
||||||
|
- **底注**: 支持 OpenAI Responses API · Anthropic Messages API · Gemini generateContent · 流式 SSE & WebSocket
|
||||||
|
|
||||||
|
#### ⑤ Dashboard
|
||||||
|
- **标题**: 每条请求都看得见
|
||||||
|
- **副**: 不像第三方 API 池子那种"扣了多少不告诉你"。你能看到每次调用:扣了哪个账号、跑了哪个模型、用了多少 tokens、花了多少钱、上游响应几秒。
|
||||||
|
- **图**: 使用 Claude Design v2 产出的 **纯 HTML 渲染 mockup**(stats grid + chart 卡片 + log table with provider dots),见 `docs/design-drafts/v2/Landing.html` 里 `#dashboard` 段。本期 Vue 翻译时保持静态数据,不对接真实 API。
|
||||||
|
|
||||||
|
#### ⑥ Footer
|
||||||
|
| 列 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 品牌 | ⬢ PURO AI<br>Self-hosted on puro.im<br>© 2026 puro.im · MIT License<br>fork of Wei-Shaw/sub2api |
|
||||||
|
| 产品 | 文档 · 更新日志 |
|
||||||
|
| 资源 | GitHub · API 状态 · Codex 配置示例 |
|
||||||
|
| 联系 | admin@puro.im · git.puro.im |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Auth 页(登录 / 注册)
|
||||||
|
|
||||||
|
### 4.1 布局选型:左右分栏
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────┬─────────────────────┐
|
||||||
|
│ 左:品牌叙事区 │ 右:表单区 │
|
||||||
|
│ - Logo │ - 标题(登录/注册) │
|
||||||
|
│ - 主标语(5→1 对比) │ - 副标 │
|
||||||
|
│ - 副文(双卖点排比) │ - email / password │
|
||||||
|
│ - 装饰光晕 cyan/purple │ - CTA │
|
||||||
|
│ - 底栏小字(支持平台) │ - 切换链接 │
|
||||||
|
└─────────────────────────┴─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
移动端:左侧叙事降级为顶部小 banner 或完全隐藏,单列表单。
|
||||||
|
|
||||||
|
### 4.2 左侧叙事文案
|
||||||
|
|
||||||
|
- **Logo**: `⬢ PURO AI`
|
||||||
|
- **主标语**:
|
||||||
|
> **5** 个订阅<br>
|
||||||
|
> → **1** 个 key
|
||||||
|
|
||||||
|
数字 `5` 用 amber/orange 强调;`1` 用主品牌色 cyan 强调。
|
||||||
|
- **副文**(三句排比):
|
||||||
|
> 省去切换账号的繁琐,<br>
|
||||||
|
> 省去为多个高昂订阅重复买单。<br>
|
||||||
|
> <small style="color:#64748b">PURO(纯粹)—— 让 AI 调用回归本质。</small>
|
||||||
|
- **底栏小字**: `Claude · ChatGPT · Codex · Gemini`
|
||||||
|
|
||||||
|
### 4.3 右侧表单
|
||||||
|
|
||||||
|
#### 登录页(`/login`)
|
||||||
|
- 标题: 登录
|
||||||
|
- 副: 用你的 PURO AI 账户继续
|
||||||
|
- 字段:
|
||||||
|
- 📧 邮箱(input, type=email, required)
|
||||||
|
- 🔒 密码(input, type=password, required, 带眼睛切换显示)
|
||||||
|
- 选项:
|
||||||
|
- 忘记密码?(router-link)
|
||||||
|
- Turnstile captcha(条件显示)
|
||||||
|
- CTA: `登录 →`
|
||||||
|
- 分隔: `或`
|
||||||
|
- OAuth 按钮(条件显示):
|
||||||
|
- 使用 LinuxDO 登录
|
||||||
|
- 使用 OIDC 登录
|
||||||
|
- 底部链接: 没有账户?**注册**
|
||||||
|
|
||||||
|
#### 注册页(`/register`)
|
||||||
|
- 标题: 创建账户
|
||||||
|
- 副: 5 分钟开始用 PURO AI
|
||||||
|
- 字段:
|
||||||
|
- 📧 邮箱
|
||||||
|
- 🔒 密码
|
||||||
|
- 🔒 确认密码
|
||||||
|
- (可选)邮箱验证码(条件显示,配置 `email_verify_required` 时)
|
||||||
|
- Turnstile captcha(条件)
|
||||||
|
- CTA: `创建账户 →`
|
||||||
|
- 底部链接: 已有账户?**登录**
|
||||||
|
|
||||||
|
#### 其他保留页(不重设计本期)
|
||||||
|
- `/forgot-password`
|
||||||
|
- `/reset-password`
|
||||||
|
- `/verify-email`
|
||||||
|
- OAuth 回调页
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.5 Docs 页(新增 · 本期做精简版)
|
||||||
|
|
||||||
|
路由 `/docs`,公开访问(不需登录)。沿用 puro.css 设计系统。
|
||||||
|
|
||||||
|
### 结构
|
||||||
|
|
||||||
|
```
|
||||||
|
NAV (复用)
|
||||||
|
── HERO · "快速接入 PURO AI"(简短)
|
||||||
|
── § 1 · 获取 API key(流程说明:联系 admin / iShare)
|
||||||
|
── § 2 · Codex CLI 接入(config.toml 示例)
|
||||||
|
── § 3 · Claude Code 接入(~/.claude/settings.json 例)
|
||||||
|
── § 4 · curl 测试(/responses + /v1/messages 两段)
|
||||||
|
── § 5 · 支持的模型(列表:gpt-5.4, claude-opus-4-7, gemini-2.5-pro 等)
|
||||||
|
── § 6 · 问题反馈 mailto:admin@puro.im
|
||||||
|
FOOTER (复用)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 文案原则
|
||||||
|
- 代码块用 JetBrains Mono,带语法高亮
|
||||||
|
- 每段开头 1-2 句说明,然后直接上代码,不啰嗦
|
||||||
|
- 所有 base_url 用真实值:`https://ai.puro.im`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 给 claude.ai/design 的 brief(Stage 2 输入,**已执行完成 · 历史参考**)
|
||||||
|
|
||||||
|
> ⚠️ **此节是喂给 Claude Design 的原始 brief**,Claude Design 已按此产出 10 个页面。Stage 2 之后**内容精修 + 范围剪裁**以第 3/4/4.5 节为准,本节仅作历史存档。
|
||||||
|
|
||||||
|
复制下方文字到 https://claude.ai/design:
|
||||||
|
|
||||||
|
````
|
||||||
|
我要做两个网页设计,请帮我生成高保真 HTML/React 视觉稿。
|
||||||
|
|
||||||
|
## 品牌
|
||||||
|
名字:PURO AI(拉丁语「纯粹」)
|
||||||
|
Logo:六边形 ⬢ + 文字
|
||||||
|
域名:ai.puro.im
|
||||||
|
定位:把多个 AI 订阅(Claude Pro / ChatGPT Plus / Codex / Gemini)聚合成统一 API
|
||||||
|
核心叙事:你的 AI 订阅,已经付过钱了
|
||||||
|
|
||||||
|
## 风格
|
||||||
|
暗黑科技风,对标 Linear / Vercel / Railway。
|
||||||
|
配色:
|
||||||
|
- 主底 #0a0e1a / #0f172a(slate-950 区间)
|
||||||
|
- 主品牌色 #22d3ee(cyan-400)
|
||||||
|
- 辅品牌色 #a855f7(purple-500)
|
||||||
|
- 强调色 #fbbf24(amber-400,仅用于数字对比)
|
||||||
|
- 主文 #f8fafc,副文 #94a3b8 ~ #cbd5e1
|
||||||
|
- 卡片/表单底 #0f172a,边框 #334155
|
||||||
|
|
||||||
|
视觉元素:
|
||||||
|
- 暗底上 radial gradient 光晕(青/紫双色,60% 透明度,blur)
|
||||||
|
- 大量留白,max-width 1100px
|
||||||
|
- 圆角:CTA 8px,卡片 12px
|
||||||
|
- 字体:Inter 或 SF Pro(sans-serif),代码用 ui-monospace
|
||||||
|
- 不要拟物、不要软阴影、不要 gradient 按钮
|
||||||
|
|
||||||
|
## 页面 1:Landing(路由 /,未登录)
|
||||||
|
6 个 section + 顶部 nav,全部中文。
|
||||||
|
|
||||||
|
NAV
|
||||||
|
- 左:⬢ PURO AI
|
||||||
|
- 中:产品、文档(定价灰显)
|
||||||
|
- 右:[登录](边框)[免费试用 →](cyan 实底)
|
||||||
|
|
||||||
|
① HERO(居中,垂直 padding 大)
|
||||||
|
主标:你的 AI 订阅,**已经付过钱了。**
|
||||||
|
("已经付过钱了" 用 cyan 高亮)
|
||||||
|
副标:Claude Pro · ChatGPT Plus · Codex · Gemini 订阅
|
||||||
|
聚合成统一 API,零改动接入 OpenAI / Anthropic SDK
|
||||||
|
CTA:[立即开始 →][查看文档]
|
||||||
|
微文案(小灰字):无需信用卡 · 用你已有的订阅 · 5 分钟跑通
|
||||||
|
|
||||||
|
② 模型墙
|
||||||
|
小标题:支持的 AI 平台
|
||||||
|
副:通过 OAuth 直接复用你的订阅,无需申请官方 API key
|
||||||
|
横排 5 个 logo 卡片:Claude Pro/Max · ChatGPT Plus/Pro · Codex CLI · Gemini Code Assist · 更多(灰显)
|
||||||
|
|
||||||
|
③ 三特性(3 列卡片)
|
||||||
|
卡片 1:⚡ 一个 key 接所有模型 / 不再为每个 provider 申请 API key、配置 base_url。统一 sk- 走 Claude / GPT / Gemini,按 model 自动路由到对应账号池。
|
||||||
|
卡片 2:🔄 账号池高可用 / 多账号自动调度。某个 ChatGPT Plus 触发限流,自动 failover 到下一个。重启、刷新 token 全自动。
|
||||||
|
卡片 3:📊 用量看板 / 每条请求的 tokens、费用、上游账号、延迟全可视化。模型分布饼图 + 趋势曲线 + Top 排行。
|
||||||
|
|
||||||
|
④ Code Demo
|
||||||
|
标题:把 base_url 一改,就能用
|
||||||
|
副:兼容 OpenAI / Anthropic / Gemini SDK,零代码改动
|
||||||
|
代码块(深色 terminal 配色,syntax highlight):
|
||||||
|
- 上方一段 toml(codex config)
|
||||||
|
- 下方一段 bash(curl 示例)
|
||||||
|
底注小字:支持 OpenAI Responses API · Anthropic Messages API · Gemini generateContent · 流式 SSE & WebSocket
|
||||||
|
|
||||||
|
⑤ Dashboard
|
||||||
|
标题:每条请求都看得见
|
||||||
|
副:不像第三方 API 池子那种"扣了多少不告诉你"。你能看到每次调用:扣了哪个账号、跑了哪个模型、用了多少 tokens、花了多少钱、上游响应几秒。
|
||||||
|
图:dashboard 截图占位(深色饼图 + 折线图 + 表格)
|
||||||
|
|
||||||
|
⑥ Footer
|
||||||
|
4 列:
|
||||||
|
- 品牌列:⬢ PURO AI / Self-hosted on puro.im / © 2026 puro.im · MIT / fork of Wei-Shaw/sub2api
|
||||||
|
- 产品列:文档 · 套餐(暂隐)· 更新日志
|
||||||
|
- 资源列:GitHub · API 状态 · Codex 配置示例
|
||||||
|
- 联系列:admin@puro.im · git.puro.im
|
||||||
|
|
||||||
|
## 页面 2:登录页(路由 /login)
|
||||||
|
左右分栏,桌面端 50/50 分。
|
||||||
|
|
||||||
|
左侧(叙事区):
|
||||||
|
- 顶部 Logo:⬢ PURO AI
|
||||||
|
- 中部主标:5 个订阅 → 1 个 key
|
||||||
|
(5 用 amber #fbbf24 强调,1 用 cyan #22d3ee 强调,字号 36-48px,weight 800)
|
||||||
|
- 主标下副文(三句排比):
|
||||||
|
省去切换账号的繁琐,
|
||||||
|
省去为多个高昂订阅重复买单。
|
||||||
|
PURO(纯粹)—— 让 AI 调用回归本质。
|
||||||
|
- 底部小字:Claude · ChatGPT · Codex · Gemini
|
||||||
|
- 背景:linear-gradient(135deg, #0a0e1a, #1e1b4b) + 角落 radial gradient 光晕(cyan + purple)
|
||||||
|
|
||||||
|
右侧(表单区):
|
||||||
|
- 标题:登录
|
||||||
|
- 副:用你的 PURO AI 账户继续
|
||||||
|
- 邮箱输入(带 📧 icon)
|
||||||
|
- 密码输入(带 🔒 icon + 眼睛切换显示)
|
||||||
|
- 行:忘记密码?(右对齐链接)
|
||||||
|
- 主 CTA:登录 →(cyan 实底)
|
||||||
|
- 分隔:—— 或 ——
|
||||||
|
- OAuth 按钮:使用 LinuxDO 登录(边框样式)
|
||||||
|
- 底部:没有账户?注册(链接)
|
||||||
|
|
||||||
|
移动端:左侧叙事区收为顶部小 banner(只保留 Logo + 短主标),表单全宽。
|
||||||
|
|
||||||
|
## 页面 3:注册页(路由 /register)
|
||||||
|
和登录页同布局,右侧表单字段:
|
||||||
|
- 标题:创建账户 / 副:5 分钟开始用 PURO AI
|
||||||
|
- 邮箱、密码、确认密码
|
||||||
|
- 主 CTA:创建账户 →
|
||||||
|
- 底部:已有账户?登录
|
||||||
|
|
||||||
|
请生成完整可预览的 HTML(含 inline CSS)或 React 组件。
|
||||||
|
````
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.5 Stage 2 产出清单(已完成,参考用)
|
||||||
|
|
||||||
|
`docs/design-drafts/v2/` 目录下:
|
||||||
|
|
||||||
|
| 文件 | 用途 | 本期用 |
|
||||||
|
|---|---|---|
|
||||||
|
| `puro.css` | 设计系统(tokens + primitives .btn/.card/.input 等) | ✅ 全站落地 |
|
||||||
|
| `Landing.html` | 营销首页(7 段,我们只取 6 段) | ✅ 参考翻译 |
|
||||||
|
| `Login.html` | 登录页 | ✅ 参考翻译 |
|
||||||
|
| `Register.html` | 注册页 | ✅ 参考翻译 |
|
||||||
|
| `Docs.html` | 文档页(精简版参考) | ✅ 参考翻译 |
|
||||||
|
| `Dashboard.html` | 控制台首页 | ⏳ 二期 |
|
||||||
|
| `API Keys.html` | Key 管理 | ⏳ 二期 |
|
||||||
|
| `Design System.html` | 设计系统展示页 | ⏳ 二期(给团队看)|
|
||||||
|
| `Binding.html` | 订阅绑定 | ❌ 不做(架构不符)|
|
||||||
|
| `Pricing.html` | 定价 | ❌ 不做(iShare 管) |
|
||||||
|
| `HANDOFF.md` | 交付文档(含后端契约,部分不适用) | 参考但不全部实现 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Stage 3 实施约束(给未来的我看)
|
||||||
|
|
||||||
|
技术栈(不改动):
|
||||||
|
- Vue 3.4+ Composition API + TypeScript
|
||||||
|
- Tailwind CSS(已配 dark mode、`primary-*` 色板)
|
||||||
|
- Vite 5
|
||||||
|
- Vue Router 4 / Pinia / vue-i18n
|
||||||
|
- 现有组件库(`@/components/common`、`@/components/layout/AuthLayout`)
|
||||||
|
|
||||||
|
实施要点:
|
||||||
|
1. **puro.css 落地**:
|
||||||
|
- CSS 变量(--bg-0 等)保留作为全局 tokens,挪到 `frontend/src/assets/puro.css`
|
||||||
|
- `main.ts` 里 `import './assets/puro.css'`
|
||||||
|
- 在 `tailwind.config.ts` 的 `theme.extend.colors` 里同步一份 cyan/purple/amber/provider 色值,方便 Vue 组件里用 Tailwind class
|
||||||
|
- primitives(.btn / .card / .input)可作为全局 class 直接用,也可以包 Vue 组件;**本期直接用 class,不抽组件**
|
||||||
|
2. **Landing 页是新页**:新建 `frontend/src/views/landing/HomeView.vue`;改 `router/index.ts`,未登录访问 `/` 显示 landing,已登录跳 `/dashboard`
|
||||||
|
3. **Auth 页改造**:改 `frontend/src/components/layout/AuthLayout.vue` 为左右分栏;改 `LoginView.vue` / `RegisterView.vue` 适配新 layout,**保留所有现有逻辑**(OAuth、Turnstile、2FA、表单校验)
|
||||||
|
4. **Docs 页是新页**:新建 `frontend/src/views/docs/DocsView.vue`,路由 `/docs`,public 可访问
|
||||||
|
5. **i18n**:新文案进 `frontend/src/i18n/locales/zh.ts`;本期只补中文(默认语言),英文 key 留空 / 复用现有
|
||||||
|
6. **Dashboard mockup in Landing**:直接按 Claude Design 的 HTML 翻成 Vue 静态标记(不对接真实数据,纯展示)
|
||||||
|
7. **不动的**:Setup Wizard / 后台所有页面 / API 层 / store
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 验收标准
|
||||||
|
|
||||||
|
本地 preview(http://127.0.0.1:4173)已过:
|
||||||
|
|
||||||
|
- [x] `puro.css` 已引入为全局样式,`--cyan: #22d3ee` 等变量在 DevTools :root 可见
|
||||||
|
- [x] 未登录访问 / → LandingView 呈现(route meta.redirectIfAuth=/dashboard 生效分支)
|
||||||
|
- [x] 已登录访问 / → guard 跳 /dashboard
|
||||||
|
- [x] Landing 6 个 section 内容全部呈现(Nav + Hero + Models + Features + Code Demo + Dashboard mockup + Footer),移动端可堆叠
|
||||||
|
- [x] Landing ⑤ Dashboard mockup 为静态 HTML + 内嵌 SVG,无后端依赖
|
||||||
|
- [x] /login 左右分栏布局,narrative "5→1" 文案 + 登录表单 / heading "登录"
|
||||||
|
- [x] /register 左右分栏,heading "创建账户" / "5 分钟开始用 PURO AI"
|
||||||
|
- [x] /docs 公开访问,含 curl / Codex config.toml / Claude Code settings.json 示例
|
||||||
|
- [x] 所有现有 auth 功能代码未改动(OAuth section / Turnstile / 2FA modal / password toggle 均在原位)
|
||||||
|
- [x] 后台 /dashboard 等页面使用 style.css 的 .btn/.input/.card(puro.css 已 scoped 到 .puro-page,不污染)
|
||||||
|
- [x] `pnpm run typecheck` 0 error,`pnpm run build` 成功
|
||||||
|
- [ ] CI 构建通过,部署后 ai.puro.im 加载正常(Task 13 完成)
|
||||||
165
docs/superpowers/work-logs/2026-04-23-portal-frontend-revamp.md
Normal file
165
docs/superpowers/work-logs/2026-04-23-portal-frontend-revamp.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# 门户前端调整工作记录
|
||||||
|
|
||||||
|
**时间窗口**:2026-04-19 ~ 2026-04-23
|
||||||
|
**范围**:sub2api 项目前端门户页(公开访问的 5 页:Landing / Docs / Login / Register / Pricing)
|
||||||
|
**目标**:完成多语言切换、新增 Pricing 页、消除导航栏抖动、清理对外不应展示的技术细节
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段一:门户多语言 + Pricing 页
|
||||||
|
|
||||||
|
**问题**:门户 4 页(Landing / Docs / Login-narrative / Register-narrative)当时是 fidelity-port 自 Claude Design zip 的,约 100+ 处中文硬编码在 template 里。Pricing 页未实现。i18n 框架(`vue-i18n`)虽然到位,但门户页基本没用上。
|
||||||
|
|
||||||
|
**做了什么**:
|
||||||
|
|
||||||
|
1. 新建 `components/puro/PuroLocaleSwitcher.vue` —— 深色科技风的语言切换器,复用 `setLocale()` 核心,与 puro.css 设计 token 对齐(区别于 admin 用的 `LocaleSwitcher.vue`)
|
||||||
|
2. 抽取 4 页所有中文到 i18n key(`landing.*`、`docs.*`、`auth.narrative.*` 命名空间),同步生成英文翻译;总计约 130 个 leaf key
|
||||||
|
3. 新增 `/pricing` 路由 + `views/pricing/PricingView.vue`(约 500 行)+ `components/puro/PricingCalculator.vue` 子组件
|
||||||
|
4. Pricing 页从一开始就 i18n-native(约 100 个 key),含 4 档定价 tier、cost calculator、12 工具 grid、10 FAQ、final CTA
|
||||||
|
5. 关键决策(写进 spec 锁定):
|
||||||
|
- 定价数字使用 zip 的 `$9.9 / $29.9 / $99` 作为占位,配 `// preview · 最终定价以开售为准` 标签
|
||||||
|
- 未实现的功能(priority scheduling、zero-log mode)配 `SOON` 灰标签,不删除展示
|
||||||
|
- Binding 卡片暂指 `/register`;联系商务用 `mailto:contact@puro.im`
|
||||||
|
- Cost calculator 算法照搬 zip JS(`puro = official × 0.3`),配 `// estimated · 以实际计费为准` 标签
|
||||||
|
- 移除 zip 中的 `注册送 $5 测试积分` 文案(Stage 1 决策)
|
||||||
|
|
||||||
|
**执行方式**:subagent-driven-development(Plan 文件 `docs/superpowers/plans/2026-04-20-portal-i18n-pricing.md`,10 个任务,3 个 subagent 实现 + 1 次回归修复)
|
||||||
|
|
||||||
|
**已合并 PR**:#2 → main `77bb69b2`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段二:Nav 抖动修复
|
||||||
|
|
||||||
|
**问题**:用户报告点击导航栏「产品/定价/文档」切换时元素位置发生轻微抖动。
|
||||||
|
|
||||||
|
**根因分析**:
|
||||||
|
|
||||||
|
第一直觉是 active class 引起的字体 weight 变化。grep CSS 后发现并非如此 —— `.active` 只改 color 不影响布局。
|
||||||
|
|
||||||
|
真正原因是:当前 Landing/Docs/Pricing 三个 View **各自在 template 里写了一份完整的 nav**(包括按钮文案)。Vue Router 切换路由时销毁整棵 view 组件树(含 nav),重新挂载下一个。虽然视觉上"看似同一个 nav",实际是三份独立拷贝被替换。
|
||||||
|
|
||||||
|
进一步发现两份拷贝间有差异:
|
||||||
|
- Docs 的注册 CTA 文案是 `注册`(2 字符);Landing/Pricing 是 `免费试用 →`(5+1 字符)—— 按钮宽度不同
|
||||||
|
- Docs 的英文 `Product`(单数)vs 其他两页的 `Products`(复数)
|
||||||
|
- Pricing 的「定价」用 `<a href="#" class="active">` —— 点击会 scroll to top 且 URL 改成 `/pricing#`
|
||||||
|
|
||||||
|
**做了什么**(分两步):
|
||||||
|
|
||||||
|
**步骤 1(短期)** —— 文案统一:把三份 nav 的内容对齐到完全一致
|
||||||
|
- `docs.nav.signup`: `注册` → `免费试用 →`
|
||||||
|
- `docs.nav.products` (en): `Product` → `Products`
|
||||||
|
- PricingView 的 `<a href="#">` → `<router-link to="/pricing">`
|
||||||
|
- commit `779005e1`
|
||||||
|
|
||||||
|
**步骤 2(结构性修复)** —— 抽 `PortalLayout`:
|
||||||
|
- 新建 `components/layout/PortalLayout.vue` —— 包含 nav + `<router-view />` + footer
|
||||||
|
- 路由改为嵌套结构:`/`、`/docs`、`/pricing` 作为 PortalLayout 的子路由
|
||||||
|
- 三个 View 删掉各自的 nav / footer / `bg-glow` / `.puro-page` 包装
|
||||||
|
- 新增 `portal.nav.*` i18n 命名空间,删除原来的 `landing.nav.*` / `docs.nav.*` / `pricing.nav.*` 三份重复
|
||||||
|
- `router-link` 用 `active-class="active"` prop 替代硬编码 class
|
||||||
|
- `scrollBehavior` 加上 hash 锚点跳转(`offset 80px` 绕 sticky nav)
|
||||||
|
- commit `e7f3fe5b`
|
||||||
|
|
||||||
|
**结果**:路由切换时 Nav 不再卸载重挂,真·SPA 行为。
|
||||||
|
|
||||||
|
**已合并 PR**:#3 → main `291e3bfe`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段三:对外内容清理
|
||||||
|
|
||||||
|
**Pass 1:删除 iShare 引用**
|
||||||
|
- 用户要求:项目目前定位是独立运营,不要在前端展示 iShare 相关字样
|
||||||
|
- 影响:`docs.sections.getKey.note` 一个 key("未来通过 iShare 入口开放订阅购买")+ DocsView 对应的 `<p class="note">`
|
||||||
|
- commit `623a7518`,PR #4 → main `5c4b2980`
|
||||||
|
|
||||||
|
**Pass 2:移除 footer 技术细节**
|
||||||
|
- 用户要求:不要透露项目技术开发细节
|
||||||
|
- 删除:`更新日志` 链接(指向 Gitea commits)、`git.puro.im` 链接、`GitHub ↗` 链接、`fork of Wei-Shaw/sub2api` 字样
|
||||||
|
- footer-meta 简化为 `© 2026 puro.im`
|
||||||
|
- 同步删除 `linkChangelog` i18n key
|
||||||
|
- commit `2b6b5fc6`,PR #5 → main `8be87883`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关键文件清单
|
||||||
|
|
||||||
|
新增:
|
||||||
|
- `frontend/src/components/puro/PuroLocaleSwitcher.vue`
|
||||||
|
- `frontend/src/components/puro/PricingCalculator.vue`
|
||||||
|
- `frontend/src/components/layout/PortalLayout.vue`
|
||||||
|
- `frontend/src/views/pricing/PricingView.vue`
|
||||||
|
|
||||||
|
修改(重点):
|
||||||
|
- `frontend/src/router/index.ts` —— 加 `/pricing`、嵌套路由、scrollBehavior
|
||||||
|
- `frontend/src/i18n/locales/{zh,en}.ts` —— 新增 portal/landing/docs/pricing/auth.narrative 命名空间
|
||||||
|
- `frontend/src/views/{landing,docs,pricing}/*View.vue` —— 全量 i18n 化 + 移除各自 nav/footer
|
||||||
|
- `frontend/src/views/auth/{Login,Register}View.vue` —— narrative slot i18n 化
|
||||||
|
- `frontend/src/components/layout/AuthLayout.vue` —— 添加右上角语言切换器槽位
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## commit / PR 索引
|
||||||
|
|
||||||
|
| 阶段 | commit | 描述 |
|
||||||
|
|---|---|---|
|
||||||
|
| 一 | `e711a203` | feat(i18n): add PuroLocaleSwitcher |
|
||||||
|
| 一 | `63288818` | mount switcher in Landing/Docs/AuthLayout |
|
||||||
|
| 一 | `fc7e2767` | landing i18n keys + EN |
|
||||||
|
| 一 | `73b39807` | docs + auth narrative i18n |
|
||||||
|
| 一 | `13bdd8f8` | restore dashboard link in models.note |
|
||||||
|
| 一 | `b989c503` | PricingView + calculator |
|
||||||
|
| 一 | `77bb69b2` | **Merge PR #2** → main |
|
||||||
|
| 二 | `779005e1` | unify signup CTA |
|
||||||
|
| 二 | `e7f3fe5b` | extract PortalLayout |
|
||||||
|
| 二 | `291e3bfe` | **Merge PR #3** → main |
|
||||||
|
| 三 | `623a7518` | remove iShare mention |
|
||||||
|
| 三 | `5c4b2980` | **Merge PR #4** → main |
|
||||||
|
| 三 | `2b6b5fc6` | footer remove tech details |
|
||||||
|
| 三 | `8be87883` | **Merge PR #5** → main |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 部署链路
|
||||||
|
|
||||||
|
每次 merge 到 main 后:
|
||||||
|
1. Gitea webhook 触发 Drone CI
|
||||||
|
2. Drone 运行 docker build + 推镜像
|
||||||
|
3. VPS 上 docker compose 拉取新镜像 + 重启
|
||||||
|
4. 总耗时约 3–5 分钟
|
||||||
|
|
||||||
|
每次部署完成的验证手段(curl):
|
||||||
|
1. 取 `https://ai.puro.im/` 主 HTML,对比 `/assets/index-*.js` 文件名 hash 是否变化(Vite 内容寻址)
|
||||||
|
2. fetch 新 chunk grep 关键字符串确认新代码上线
|
||||||
|
3. 5 个门户路由全部 200
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 遗留事项 / 下次可继续
|
||||||
|
|
||||||
|
**门户层面**
|
||||||
|
1. 浏览器实测 nav 持久化无抖动(用户已确认部署,肉眼验证待做)
|
||||||
|
2. 翻译效果打磨(用户浏览后发现的不自然处随时改)
|
||||||
|
3. Pricing 数字 / 功能列表定稿(从 preview 转正式)
|
||||||
|
4. 后端 `SOON` 标记的功能落地:zero-log mode、priority scheduling
|
||||||
|
5. Pricing FAQ 中的 24 小时上线新模型 等承诺类文案核实
|
||||||
|
|
||||||
|
**Admin / User 页面(之前搁置的两个决策)**
|
||||||
|
- 决策 1:Admin reskin 深度(A 全局换肤 / B 深度 port 两页 / C 全量重写)
|
||||||
|
- 决策 2:User-facing 页面 vs Admin 页面优先级
|
||||||
|
- 推荐路径:Phase 2.1 全局换肤 → 2.2 user pages → 2.3 admin pages
|
||||||
|
|
||||||
|
**仓库卫生**
|
||||||
|
- `backend/config.prod.yaml`、`backend/sub2api-linux`、`LOCAL_SETUP_NOTES.md` 在某次意外的 `git add -A` 中被提交到 main。
|
||||||
|
- 用户判断:因 Gitea 是私有的,先不处理;仅要求前端层面不展示敏感信息(已通过 footer 清理实现)。
|
||||||
|
- 建议:下次添加配置 / 二进制类文件前,往 `.gitignore` 补规则(`backend/config.*.yaml`、`backend/sub2api-*` 等),避免再次误提。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 经验教训
|
||||||
|
|
||||||
|
1. **不要 `git add -A`** —— 主 worktree 里有累积的本地文件(生产配置、二进制、笔记),一次性 stage 会把这些都带进 commit。后续都用 `git add <具体文件>`。
|
||||||
|
2. **设计稿里的 nav = 3 份独立 HTML** ≠ Vue 应该 3 份独立 template —— port 设计稿到 SPA 时,要把跨页共享的部分(nav、footer、layout shell)一开始就抽到 layout 组件。
|
||||||
|
3. **Subagent 报告"我做完了"≠ 真的做对了** —— `73b39807` 提交里 subagent 静默删掉了 `<router-link>` 退化成纯文本。事后 grep 读 commit diff 才发现。下次 subagent 处理 i18n 抽取时,prompt 里要明确"原始 HTML 标签如 `<a>`/`<code>`/`<router-link>` 必须保留,使用 `<i18n-t>` 命名 slot"。
|
||||||
|
4. **fidelity port 优先靠 `<i18n-t>` + 命名 slot,不要把带行内标签的句子拍平成纯文本** —— 这点已经写进下次的 i18n 提示模板中。
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/png" href="/logo.png" />
|
<link rel="icon" type="image/png" href="/logo.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
<title>Sub2API - AI API Gateway</title>
|
<title>Sub2API - AI API Gateway</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
729
frontend/src/assets/puro.css
Normal file
729
frontend/src/assets/puro.css
Normal file
@@ -0,0 +1,729 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
PURO AI — Design System
|
||||||
|
Shared tokens + primitive styles used across every page.
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
Usage: <link rel="stylesheet" href="puro.css">
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
.puro-page, .puro-page *, .puro-page *::before, .puro-page *::after { margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Surfaces */
|
||||||
|
--bg-0: #0a0e1a; /* page */
|
||||||
|
--bg-1: #0f172a; /* raised */
|
||||||
|
--bg-2: #111827; /* card alt */
|
||||||
|
--bg-code: #020617; /* code canvas */
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border: #1e293b;
|
||||||
|
--border-2: #334155;
|
||||||
|
--border-3: #475569;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text-0: #f8fafc; /* primary */
|
||||||
|
--text-1: #cbd5e1; /* body */
|
||||||
|
--text-2: #94a3b8; /* muted */
|
||||||
|
--text-3: #64748b; /* hint */
|
||||||
|
|
||||||
|
/* Accents */
|
||||||
|
--cyan: #22d3ee;
|
||||||
|
--cyan-2: #67e8f9;
|
||||||
|
--cyan-dim: #0891b2;
|
||||||
|
--purple: #a855f7;
|
||||||
|
--amber: #fbbf24;
|
||||||
|
--green: #34d399;
|
||||||
|
--red: #f87171;
|
||||||
|
--orange: #fb923c;
|
||||||
|
|
||||||
|
/* Provider brand dots */
|
||||||
|
--p-claude: #d97757;
|
||||||
|
--p-gpt: #10a37f;
|
||||||
|
--p-gemini: #4285f4;
|
||||||
|
--p-codex: #f0a030;
|
||||||
|
|
||||||
|
/* Radius */
|
||||||
|
--r-sm: 6px;
|
||||||
|
--r-md: 8px;
|
||||||
|
--r-lg: 12px;
|
||||||
|
--r-xl: 16px;
|
||||||
|
|
||||||
|
/* Shadow */
|
||||||
|
--shadow-lg: 0 30px 60px -30px rgba(0,0,0,0.6);
|
||||||
|
--shadow-xl: 0 40px 80px -40px rgba(0,0,0,0.8);
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
/* Merged from original html/body rules — applied to .puro-page container root */
|
||||||
|
.puro-page {
|
||||||
|
background: var(--bg-0);
|
||||||
|
color: var(--text-0);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-feature-settings: "cv11", "ss01", "ss03";
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.puro-page a { color: inherit; text-decoration: none; }
|
||||||
|
.puro-page button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; }
|
||||||
|
|
||||||
|
/* scrollbar — subtle */
|
||||||
|
.puro-page::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
.puro-page::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.puro-page::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 6px; }
|
||||||
|
.puro-page::-webkit-scrollbar-thumb:hover { background: var(--border-3); }
|
||||||
|
|
||||||
|
.puro-page .mono { font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
BACKGROUND EFFECTS
|
||||||
|
========================================================================== */
|
||||||
|
.puro-page .bg-glow {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.puro-page .bg-glow::before,
|
||||||
|
.puro-page .bg-glow::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 900px;
|
||||||
|
height: 900px;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(120px);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
.puro-page .bg-glow::before {
|
||||||
|
background: radial-gradient(circle, #22d3ee 0%, transparent 60%);
|
||||||
|
top: -300px;
|
||||||
|
left: -200px;
|
||||||
|
}
|
||||||
|
.puro-page .bg-glow::after {
|
||||||
|
background: radial-gradient(circle, #a855f7 0%, transparent 60%);
|
||||||
|
top: 200px;
|
||||||
|
right: -300px;
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
.puro-page .bg-glow.soft::before, .puro-page .bg-glow.soft::after { opacity: 0.15; }
|
||||||
|
|
||||||
|
.puro-page .grain {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 0.4;
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence baseFrequency='0.9' numOctaves='2'/></filter><rect width='100%25' height='100%25' filter='url(%23n)' opacity='0.35'/></svg>");
|
||||||
|
}
|
||||||
|
|
||||||
|
.puro-page .container {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 32px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.puro-page .container-wide { max-width: 1280px; }
|
||||||
|
.puro-page .container-narrow { max-width: 860px; }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
NAV
|
||||||
|
========================================================================== */
|
||||||
|
.puro-page .nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
background: rgba(10, 14, 26, 0.72);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.puro-page .nav-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 64px;
|
||||||
|
gap: 48px;
|
||||||
|
}
|
||||||
|
.puro-page .brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.puro-page .hex {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
.puro-page .nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 28px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.puro-page .nav-links a { transition: color .15s; }
|
||||||
|
.puro-page .nav-links a:hover, .puro-page .nav-links a.active { color: var(--text-0); }
|
||||||
|
.puro-page .nav-links .disabled { color: var(--text-3); cursor: not-allowed; display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.puro-page .nav-links .disabled::after {
|
||||||
|
content: "即将推出";
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
.puro-page .nav-cta {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
BUTTONS
|
||||||
|
========================================================================== */
|
||||||
|
.puro-page .btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
transition: all .15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.puro-page .btn-primary {
|
||||||
|
background: var(--cyan);
|
||||||
|
color: #042f2e;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.puro-page .btn-primary:hover { background: var(--cyan-2); }
|
||||||
|
.puro-page .btn-primary:active { transform: translateY(1px); }
|
||||||
|
.puro-page .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.puro-page .btn-ghost {
|
||||||
|
border-color: var(--border-2);
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
.puro-page .btn-ghost:hover { border-color: var(--border-3); color: var(--text-0); background: rgba(255,255,255,0.02); }
|
||||||
|
|
||||||
|
.puro-page .btn-subtle {
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
color: var(--text-1);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.puro-page .btn-subtle:hover { background: rgba(255,255,255,0.08); color: var(--text-0); }
|
||||||
|
|
||||||
|
.puro-page .btn-danger {
|
||||||
|
background: rgba(248, 113, 113, 0.1);
|
||||||
|
color: var(--red);
|
||||||
|
border-color: rgba(248, 113, 113, 0.25);
|
||||||
|
}
|
||||||
|
.puro-page .btn-danger:hover { background: rgba(248, 113, 113, 0.15); border-color: rgba(248, 113, 113, 0.4); }
|
||||||
|
|
||||||
|
.puro-page .btn-lg { padding: 12px 20px; font-size: 14px; }
|
||||||
|
.puro-page .btn-sm { padding: 5px 10px; font-size: 12px; }
|
||||||
|
.puro-page .btn-icon { padding: 7px; aspect-ratio: 1; }
|
||||||
|
|
||||||
|
.puro-page .btn .spinner {
|
||||||
|
width: 14px; height: 14px;
|
||||||
|
border: 2px solid rgba(0,0,0,0.2);
|
||||||
|
border-top-color: currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin .7s linear infinite;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.puro-page .btn.loading .spinner { display: inline-block; }
|
||||||
|
.puro-page .btn.loading .label { opacity: 0.5; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
BADGES / PILLS / CHIPS
|
||||||
|
========================================================================== */
|
||||||
|
.puro-page .badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
background: rgba(34, 211, 238, 0.1);
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
.puro-page .badge.amber { background: rgba(251, 191, 36, 0.12); color: var(--amber); }
|
||||||
|
.puro-page .badge.purple { background: rgba(168, 85, 247, 0.12); color: var(--purple); }
|
||||||
|
.puro-page .badge.green { background: rgba(52, 211, 153, 0.12); color: var(--green); }
|
||||||
|
.puro-page .badge.red { background: rgba(248, 113, 113, 0.12); color: var(--red); }
|
||||||
|
.puro-page .badge.muted { background: rgba(255, 255, 255, 0.04); color: var(--text-2); border: 1px solid var(--border); }
|
||||||
|
|
||||||
|
.puro-page .pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puro-page .chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 100px;
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-1);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.puro-page .chip .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--green); }
|
||||||
|
.puro-page .chip.claude .dot { background: var(--p-claude); }
|
||||||
|
.puro-page .chip.gpt .dot { background: var(--p-gpt); }
|
||||||
|
.puro-page .chip.gemini .dot { background: var(--p-gemini); }
|
||||||
|
.puro-page .chip.codex .dot { background: var(--p-codex); }
|
||||||
|
|
||||||
|
.puro-page .dot-sep { width: 4px; height: 4px; border-radius: 50%; background: var(--text-3); display: inline-block; }
|
||||||
|
|
||||||
|
/* status chip (tiny dot absolute-positioned) */
|
||||||
|
.puro-page .status-chip {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--green);
|
||||||
|
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.puro-page .status-chip.dim { background: var(--text-3); box-shadow: none; }
|
||||||
|
.puro-page .status-chip.amber { background: var(--amber); box-shadow: 0 0 0 3px rgba(251, 191, 36, 0.15); }
|
||||||
|
.puro-page .status-chip.red { background: var(--red); box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.15); }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
CARDS / SURFACES
|
||||||
|
========================================================================== */
|
||||||
|
.puro-page .card {
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.puro-page .card-raised {
|
||||||
|
background: var(--bg-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
}
|
||||||
|
.puro-page .card-interactive {
|
||||||
|
transition: all .2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.puro-page .card-interactive:hover {
|
||||||
|
border-color: var(--border-2);
|
||||||
|
background: rgba(15, 23, 42, 0.85);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puro-page .divider { height: 1px; background: var(--border); margin: 24px 0; border: 0; }
|
||||||
|
.puro-page .divider-dashed { border: 0; border-top: 1px dashed var(--border); margin: 20px 0; }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
FORMS
|
||||||
|
========================================================================== */
|
||||||
|
.puro-page .field { margin-bottom: 18px; }
|
||||||
|
.puro-page .field-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-1);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.puro-page .field-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.puro-page .field-error {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--red);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puro-page .input-wrap { position: relative; }
|
||||||
|
.puro-page .input-wrap .icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 14px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--text-3);
|
||||||
|
pointer-events: none;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.puro-page .input {
|
||||||
|
width: 100%;
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 14px;
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
color: var(--text-0);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.puro-page .input.with-icon { padding-left: 40px; }
|
||||||
|
.puro-page .input::placeholder { color: var(--text-3); }
|
||||||
|
.puro-page .input:hover { border-color: var(--border-3); }
|
||||||
|
.puro-page .input:focus {
|
||||||
|
border-color: var(--cyan);
|
||||||
|
box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.12);
|
||||||
|
background: rgba(15, 23, 42, 0.9);
|
||||||
|
}
|
||||||
|
.puro-page .input.ok { border-color: rgba(52, 211, 153, 0.4); }
|
||||||
|
.puro-page .input.ok:focus { box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.12); }
|
||||||
|
.puro-page .input.error { border-color: var(--red); }
|
||||||
|
.puro-page .input.error:focus { box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.12); }
|
||||||
|
|
||||||
|
.puro-page textarea.input { height: auto; padding: 12px 14px; resize: vertical; line-height: 1.5; }
|
||||||
|
|
||||||
|
.puro-page select.input {
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><path d='M6 9l6 6 6-6'/></svg>");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 14px center;
|
||||||
|
padding-right: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* checkbox */
|
||||||
|
.puro-page .check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
.puro-page .check input { display: none; }
|
||||||
|
.puro-page .check .box {
|
||||||
|
width: 16px; height: 16px;
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-1);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all .15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.puro-page .check input:checked + .box {
|
||||||
|
background: var(--cyan);
|
||||||
|
border-color: var(--cyan);
|
||||||
|
}
|
||||||
|
.puro-page .check input:checked + .box::after {
|
||||||
|
content: "✓";
|
||||||
|
color: #042f2e;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
SECTION HEADINGS
|
||||||
|
========================================================================== */
|
||||||
|
.puro-page .section-kicker {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--cyan);
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.puro-page .section-title {
|
||||||
|
font-size: clamp(28px, 3.5vw, 40px);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.puro-page .section-sub {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
TABLES
|
||||||
|
========================================================================== */
|
||||||
|
.puro-page .tbl {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.puro-page .tbl th {
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
.puro-page .tbl td {
|
||||||
|
padding: 14px;
|
||||||
|
border-bottom: 1px solid rgba(30, 41, 59, 0.5);
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
.puro-page .tbl tr:last-child td { border-bottom: none; }
|
||||||
|
.puro-page .tbl tr:hover td { background: rgba(15, 23, 42, 0.4); }
|
||||||
|
.puro-page .tbl td.mono, .puro-page .tbl th.mono { font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
CODE BLOCKS
|
||||||
|
========================================================================== */
|
||||||
|
.puro-page .code-frame {
|
||||||
|
background: var(--bg-code);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
.puro-page .code-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: rgba(15, 23, 42, 0.8);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.puro-page .traffic {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.puro-page .traffic span {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #475569;
|
||||||
|
}
|
||||||
|
.puro-page .code-body {
|
||||||
|
padding: 22px 26px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--text-1);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.puro-page .code-body .line { display: flex; gap: 20px; }
|
||||||
|
.puro-page .ln { color: var(--text-3); user-select: none; min-width: 16px; text-align: right; opacity: 0.5; }
|
||||||
|
|
||||||
|
/* syntax */
|
||||||
|
.puro-page .kw { color: #c084fc; }
|
||||||
|
.puro-page .str { color: #86efac; }
|
||||||
|
.puro-page .num { color: #fbbf24; }
|
||||||
|
.puro-page .com { color: #64748b; font-style: italic; }
|
||||||
|
.puro-page .fn { color: #22d3ee; }
|
||||||
|
.puro-page .prop { color: #f0abfc; }
|
||||||
|
.puro-page .var-v { color: #f8fafc; }
|
||||||
|
.puro-page .flag { color: #fb923c; }
|
||||||
|
.puro-page .bash-prompt { color: var(--cyan); user-select: none; }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
PROVIDER-BRAND HELPERS
|
||||||
|
========================================================================== */
|
||||||
|
.puro-page .provider {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.puro-page .provider .dot { width: 6px; height: 6px; border-radius: 50%; }
|
||||||
|
.puro-page .provider.claude .dot { background: var(--p-claude); }
|
||||||
|
.puro-page .provider.gpt .dot { background: var(--p-gpt); }
|
||||||
|
.puro-page .provider.gemini .dot { background: var(--p-gemini); }
|
||||||
|
.puro-page .provider.codex .dot { background: var(--p-codex); }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
UTILITIES
|
||||||
|
========================================================================== */
|
||||||
|
.puro-page .stack-xs { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.puro-page .stack-sm { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.puro-page .stack-md { display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
.puro-page .stack-lg { display: flex; flex-direction: column; gap: 32px; }
|
||||||
|
|
||||||
|
.puro-page .row { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.puro-page .row-sm { gap: 8px; }
|
||||||
|
.puro-page .row-lg { gap: 20px; }
|
||||||
|
.puro-page .row-between { justify-content: space-between; }
|
||||||
|
.puro-page .row-center { justify-content: center; }
|
||||||
|
.puro-page .row-wrap { flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.puro-page .flex-1 { flex: 1; }
|
||||||
|
.puro-page .ml-auto { margin-left: auto; }
|
||||||
|
.puro-page .mt-auto { margin-top: auto; }
|
||||||
|
|
||||||
|
.puro-page .text-0 { color: var(--text-0); }
|
||||||
|
.puro-page .text-1 { color: var(--text-1); }
|
||||||
|
.puro-page .text-2 { color: var(--text-2); }
|
||||||
|
.puro-page .text-3 { color: var(--text-3); }
|
||||||
|
.puro-page .text-cyan { color: var(--cyan); }
|
||||||
|
.puro-page .text-purple { color: var(--purple); }
|
||||||
|
.puro-page .text-amber { color: var(--amber); }
|
||||||
|
.puro-page .text-green { color: var(--green); }
|
||||||
|
.puro-page .text-red { color: var(--red); }
|
||||||
|
|
||||||
|
.puro-page .text-xs { font-size: 11px; }
|
||||||
|
.puro-page .text-sm { font-size: 13px; }
|
||||||
|
.puro-page .text-md { font-size: 14px; }
|
||||||
|
.puro-page .text-lg { font-size: 16px; }
|
||||||
|
.puro-page .text-xl { font-size: 20px; }
|
||||||
|
.puro-page .text-2xl { font-size: 28px; }
|
||||||
|
.puro-page .text-3xl { font-size: 36px; }
|
||||||
|
|
||||||
|
.puro-page .fw-400 { font-weight: 400; }
|
||||||
|
.puro-page .fw-500 { font-weight: 500; }
|
||||||
|
.puro-page .fw-600 { font-weight: 600; }
|
||||||
|
.puro-page .fw-700 { font-weight: 700; }
|
||||||
|
.puro-page .fw-800 { font-weight: 800; }
|
||||||
|
|
||||||
|
.puro-page .tabular { font-variant-numeric: tabular-nums; }
|
||||||
|
.puro-page .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
APP SHELL (for dashboard-style pages)
|
||||||
|
========================================================================== */
|
||||||
|
.puro-page .app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 240px 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.puro-page .app-side {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
background: rgba(2, 6, 23, 0.6);
|
||||||
|
padding: 20px 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 28px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.puro-page .app-side .brand { padding: 6px 10px 14px; }
|
||||||
|
.puro-page .side-group { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.puro-page .side-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
padding: 0 10px 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.puro-page .side-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .12s;
|
||||||
|
}
|
||||||
|
.puro-page .side-item:hover { color: var(--text-0); background: rgba(255,255,255,0.03); }
|
||||||
|
.puro-page .side-item.active { background: rgba(34, 211, 238, 0.08); color: var(--cyan); }
|
||||||
|
.puro-page .side-item .ico {
|
||||||
|
width: 16px; height: 16px; opacity: 0.8;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.puro-page .side-item .count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.puro-page .side-item.active .count { color: var(--cyan); }
|
||||||
|
|
||||||
|
.puro-page .app-main {
|
||||||
|
min-width: 0; /* allow grid children to shrink */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.puro-page .app-topbar {
|
||||||
|
height: 60px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 32px;
|
||||||
|
gap: 16px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: rgba(10, 14, 26, 0.75);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
.puro-page .app-topbar h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.puro-page .app-content {
|
||||||
|
padding: 32px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* user avatar pill */
|
||||||
|
.puro-page .avatar {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #22d3ee, #a855f7);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #042f2e;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
KBD
|
||||||
|
========================================================================== */
|
||||||
|
.puro-page kbd {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-1);
|
||||||
|
background: var(--bg-1);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
@@ -1,71 +1,52 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative flex min-h-screen items-center justify-center overflow-hidden p-4">
|
<div class="auth-shell" :class="{ 'auth-shell-split': hasNarrative }">
|
||||||
<!-- Background -->
|
<div class="bg-glow soft"></div>
|
||||||
<div
|
|
||||||
class="absolute inset-0 bg-gradient-to-br from-gray-50 via-primary-50/30 to-gray-100 dark:from-dark-950 dark:via-dark-900 dark:to-dark-950"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<!-- Decorative Elements -->
|
<div v-if="hasNarrative" class="auth-locale-corner">
|
||||||
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
<PuroLocaleSwitcher />
|
||||||
<!-- Gradient Orbs -->
|
|
||||||
<div
|
|
||||||
class="absolute -right-40 -top-40 h-80 w-80 rounded-full bg-primary-400/20 blur-3xl"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
class="absolute -bottom-40 -left-40 h-80 w-80 rounded-full bg-primary-500/15 blur-3xl"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
class="absolute left-1/2 top-1/2 h-96 w-96 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-300/10 blur-3xl"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<!-- Grid Pattern -->
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 bg-[linear-gradient(rgba(20,184,166,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(20,184,166,0.03)_1px,transparent_1px)] bg-[size:64px_64px]"
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content Container -->
|
<!-- LEFT: Narrative (split mode only, hidden on mobile) -->
|
||||||
<div class="relative z-10 w-full max-w-md">
|
<aside v-if="hasNarrative" class="auth-narrative">
|
||||||
<!-- Logo/Brand -->
|
<slot name="narrative"></slot>
|
||||||
<div class="mb-8 text-center">
|
</aside>
|
||||||
<!-- Custom Logo or Default Logo -->
|
|
||||||
<template v-if="settingsLoaded">
|
<!-- RIGHT: Form -->
|
||||||
|
<main class="auth-main">
|
||||||
|
<div class="auth-main-inner">
|
||||||
|
<!-- Legacy centered-card header (only when no narrative slot) -->
|
||||||
|
<div class="mb-8 text-center" v-if="!hasNarrative && settingsLoaded">
|
||||||
<div
|
<div
|
||||||
class="mb-4 inline-flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl shadow-lg shadow-primary-500/30"
|
class="mb-4 inline-flex h-14 w-14 items-center justify-center overflow-hidden rounded-2xl"
|
||||||
>
|
>
|
||||||
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
|
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-gradient mb-2 text-3xl font-bold">
|
<h1 class="text-2xl font-bold">{{ siteName }}</h1>
|
||||||
{{ siteName }}
|
<p class="text-sm text-gray-500 dark:text-dark-400" v-if="siteSubtitle">{{ siteSubtitle }}</p>
|
||||||
</h1>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-dark-400">
|
|
||||||
{{ siteSubtitle }}
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card Container -->
|
<!-- Form content -->
|
||||||
<div class="card-glass rounded-2xl p-8 shadow-glass">
|
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer Links -->
|
<!-- Footer link slot (e.g., "没有账户?注册") -->
|
||||||
<div class="mt-6 text-center text-sm">
|
<div class="mt-6 text-center text-sm">
|
||||||
<slot name="footer" />
|
<slot name="footer" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Copyright -->
|
<!-- Copyright (legacy mode only) -->
|
||||||
<div class="mt-8 text-center text-xs text-gray-400 dark:text-dark-500">
|
<div class="mt-8 text-center text-xs text-gray-400 dark:text-dark-500" v-if="!hasNarrative">
|
||||||
© {{ currentYear }} {{ siteName }}. All rights reserved.
|
© {{ currentYear }} {{ siteName }}. All rights reserved.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted, useSlots } from 'vue'
|
||||||
import { useAppStore } from '@/stores'
|
import { useAppStore } from '@/stores'
|
||||||
import { sanitizeUrl } from '@/utils/url'
|
import { sanitizeUrl } from '@/utils/url'
|
||||||
|
import PuroLocaleSwitcher from '@/components/puro/PuroLocaleSwitcher.vue'
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|
||||||
@@ -76,6 +57,9 @@ const settingsLoaded = computed(() => appStore.publicSettingsLoaded)
|
|||||||
|
|
||||||
const currentYear = computed(() => new Date().getFullYear())
|
const currentYear = computed(() => new Date().getFullYear())
|
||||||
|
|
||||||
|
const slots = useSlots()
|
||||||
|
const hasNarrative = computed(() => !!slots.narrative)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
appStore.fetchPublicSettings()
|
appStore.fetchPublicSettings()
|
||||||
})
|
})
|
||||||
@@ -85,4 +69,124 @@ onMounted(() => {
|
|||||||
.text-gradient {
|
.text-gradient {
|
||||||
@apply bg-gradient-to-r from-primary-600 to-primary-500 bg-clip-text text-transparent;
|
@apply bg-gradient-to-r from-primary-600 to-primary-500 bg-clip-text text-transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.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 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.auth-narrative {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.auth-narrative {
|
||||||
|
position: relative;
|
||||||
|
padding: 48px 56px;
|
||||||
|
background: linear-gradient(135deg, #0a0e1a 0%, #1e1b4b 100%);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--text-0);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
.auth-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 24px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.auth-main-inner {
|
||||||
|
width: 100%;
|
||||||
|
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;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: linear-gradient(to bottom right, #f9fafb, rgba(240,253,250,0.3), #f3f4f6);
|
||||||
|
}
|
||||||
|
:global(.dark) .auth-shell:not(.auth-shell-split) {
|
||||||
|
background: linear-gradient(to bottom right, #020617, #0f172a, #020617);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
126
frontend/src/components/layout/PortalLayout.vue
Normal file
126
frontend/src/components/layout/PortalLayout.vue
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<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</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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
117
frontend/src/components/puro/PricingCalculator.vue
Normal file
117
frontend/src/components/puro/PricingCalculator.vue
Normal 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>
|
||||||
150
frontend/src/components/puro/PuroLocaleSwitcher.vue
Normal file
150
frontend/src/components/puro/PuroLocaleSwitcher.vue
Normal 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>
|
||||||
@@ -559,7 +559,39 @@ export default {
|
|||||||
invalidResetLink: 'Invalid Reset Link',
|
invalidResetLink: 'Invalid Reset Link',
|
||||||
invalidResetLinkHint: 'This password reset link is invalid or has expired. Please request a new one.',
|
invalidResetLinkHint: 'This password reset link is invalid or has expired. Please request a new one.',
|
||||||
requestNewResetLink: 'Request New Reset Link',
|
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
|
// Dashboard
|
||||||
@@ -6373,4 +6405,346 @@ 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 & 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',
|
||||||
|
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 · 3–7× 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 · 2–5× 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.',
|
||||||
|
},
|
||||||
|
modelPricing: {
|
||||||
|
kicker: '// model pricing',
|
||||||
|
title: 'Per-model pricing · transparent',
|
||||||
|
sub: 'Token-based billing with discount included. No monthly fees, no minimum. Prices shown per 1M tokens.',
|
||||||
|
groupCoding: 'Coding models',
|
||||||
|
groupPerformance: 'Premium models',
|
||||||
|
groupBudget: 'Budget-friendly',
|
||||||
|
inputLabel: 'Input',
|
||||||
|
outputLabel: 'Output',
|
||||||
|
unit: '/ 1M tokens',
|
||||||
|
},
|
||||||
|
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:',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -558,7 +558,45 @@ export default {
|
|||||||
invalidResetLink: '无效的重置链接',
|
invalidResetLink: '无效的重置链接',
|
||||||
invalidResetLinkHint: '此密码重置链接无效或已过期。请重新请求一个新链接。',
|
invalidResetLinkHint: '此密码重置链接无效或已过期。请重新请求一个新链接。',
|
||||||
requestNewResetLink: '请求新的重置链接',
|
requestNewResetLink: '请求新的重置链接',
|
||||||
invalidOrExpiredToken: '密码重置链接无效或已过期。请重新请求一个新链接。'
|
invalidOrExpiredToken: '密码重置链接无效或已过期。请重新请求一个新链接。',
|
||||||
|
// PURO AI redesign
|
||||||
|
puroLoginTitle: '登录',
|
||||||
|
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
|
// Dashboard
|
||||||
@@ -6557,4 +6595,346 @@ 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 & 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: '功能',
|
||||||
|
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: '。',
|
||||||
|
},
|
||||||
|
modelPricing: {
|
||||||
|
kicker: '// model pricing',
|
||||||
|
title: '按模型分级·透明定价',
|
||||||
|
sub: '按 token 计费,价格已含折扣。无月费、无最低消费。下方数字为每百万 tokens 单价。',
|
||||||
|
groupCoding: '编程模型',
|
||||||
|
groupPerformance: '高性能模型',
|
||||||
|
groupBudget: '低价好用模型',
|
||||||
|
inputLabel: 'Input',
|
||||||
|
outputLabel: 'Output',
|
||||||
|
unit: '/ 百万 tokens',
|
||||||
|
},
|
||||||
|
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: '遇到问题或希望补接某个平台:',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import router from './router'
|
|||||||
import i18n, { initI18n } from './i18n'
|
import i18n, { initI18n } from './i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
|
import './assets/puro.css'
|
||||||
|
|
||||||
function initThemeClass() {
|
function initThemeClass() {
|
||||||
const savedTheme = localStorage.getItem('theme')
|
const savedTheme = localStorage.getItem('theme')
|
||||||
|
|||||||
@@ -144,10 +144,37 @@ const routes: RouteRecordRaw[] = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// ==================== User Routes ====================
|
// ==================== Portal Routes (shared PortalLayout) ====================
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
redirect: '/home'
|
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',
|
path: '/dashboard',
|
||||||
@@ -574,11 +601,15 @@ const routes: RouteRecordRaw[] = [
|
|||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes,
|
routes,
|
||||||
scrollBehavior(_to, _from, savedPosition) {
|
scrollBehavior(to, _from, savedPosition) {
|
||||||
// Scroll to saved position when using browser back/forward
|
// Scroll to saved position when using browser back/forward
|
||||||
if (savedPosition) {
|
if (savedPosition) {
|
||||||
return 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
|
// Scroll to top for new routes
|
||||||
return { top: 0 }
|
return { top: 0 }
|
||||||
}
|
}
|
||||||
@@ -631,6 +662,13 @@ router.beforeEach((to, _from, next) => {
|
|||||||
authInitialized = true
|
authInitialized = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auth-aware redirect for public pages that should bounce authenticated users elsewhere
|
||||||
|
// (e.g., Landing / at `/` redirects to /dashboard if user is logged in)
|
||||||
|
const redirectIfAuth = to.meta.redirectIfAuth
|
||||||
|
if (redirectIfAuth && authStore.isAuthenticated) {
|
||||||
|
return next(redirectIfAuth)
|
||||||
|
}
|
||||||
|
|
||||||
// Set page title
|
// Set page title
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
// For custom pages, use menu item label as document title
|
// For custom pages, use menu item label as document title
|
||||||
|
|||||||
6
frontend/src/router/meta.d.ts
vendored
6
frontend/src/router/meta.d.ts
vendored
@@ -7,6 +7,12 @@ import 'vue-router'
|
|||||||
|
|
||||||
declare module 'vue-router' {
|
declare module 'vue-router' {
|
||||||
interface RouteMeta {
|
interface RouteMeta {
|
||||||
|
/**
|
||||||
|
* If set, authenticated users visiting this route are redirected to this path.
|
||||||
|
* Used for public pages (e.g., Landing `/`) that should bounce logged-in users to the app.
|
||||||
|
*/
|
||||||
|
redirectIfAuth?: string
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this route requires authentication
|
* Whether this route requires authentication
|
||||||
* @default true
|
* @default true
|
||||||
|
|||||||
@@ -1,14 +1,53 @@
|
|||||||
<template>
|
<template>
|
||||||
<AuthLayout>
|
<AuthLayout>
|
||||||
|
<template #narrative>
|
||||||
|
<div class="auth-narrative-inner">
|
||||||
|
<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">
|
||||||
|
{{ 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="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">
|
<div class="space-y-6">
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
<h2 class="text-2xl font-bold text-slate-50">{{ t('auth.puroLoginTitle') }}</h2>
|
||||||
{{ t('auth.welcomeBack') }}
|
<p class="mt-2 text-sm text-slate-400">{{ t('auth.puroLoginSub') }}</p>
|
||||||
</h2>
|
|
||||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
|
||||||
{{ t('auth.signInToAccount') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!backendModeEnabled && (linuxdoOAuthEnabled || wechatOAuthEnabled || oidcOAuthEnabled)" class="space-y-4">
|
<div v-if="!backendModeEnabled && (linuxdoOAuthEnabled || wechatOAuthEnabled || oidcOAuthEnabled)" class="space-y-4">
|
||||||
@@ -434,4 +473,124 @@ function handle2FACancel(): void {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-8px);
|
transform: translateY(-8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-narrative-inner {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--text-0);
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--text-0);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.brand svg {
|
||||||
|
color: var(--cyan);
|
||||||
|
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 {
|
||||||
|
font-size: clamp(40px, 5vw, 64px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.auth-narrative-headline .num-n { color: var(--amber); }
|
||||||
|
.auth-narrative-headline .num-1 { color: var(--cyan); }
|
||||||
|
.auth-narrative-sub {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
.auth-narrative-tagline {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12px;
|
||||||
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,14 +1,62 @@
|
|||||||
<template>
|
<template>
|
||||||
<AuthLayout>
|
<AuthLayout>
|
||||||
|
<template #narrative>
|
||||||
|
<div class="auth-narrative-inner">
|
||||||
|
<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">
|
||||||
|
{{ 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="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">
|
<div class="space-y-6">
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
<h2 class="text-2xl font-bold text-slate-50">{{ t('auth.puroRegisterTitle') }}</h2>
|
||||||
{{ t('auth.createAccount') }}
|
<p class="mt-2 text-sm text-slate-400">{{ t('auth.puroRegisterSub') }}</p>
|
||||||
</h2>
|
|
||||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
|
||||||
{{ t('auth.signUpToStart', { siteName }) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="linuxdoOAuthEnabled || wechatOAuthEnabled || oidcOAuthEnabled" class="space-y-4">
|
<div v-if="linuxdoOAuthEnabled || wechatOAuthEnabled || oidcOAuthEnabled" class="space-y-4">
|
||||||
@@ -113,6 +161,38 @@
|
|||||||
<p class="input-hint">
|
<p class="input-hint">
|
||||||
{{ t('auth.passwordHint') }}
|
{{ t('auth.passwordHint') }}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Invitation Code Input (Required when enabled) -->
|
<!-- Invitation Code Input (Required when enabled) -->
|
||||||
@@ -210,6 +290,14 @@
|
|||||||
</transition>
|
</transition>
|
||||||
</div>
|
</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 -->
|
<!-- Turnstile Widget -->
|
||||||
<div v-if="turnstileEnabled && turnstileSiteKey">
|
<div v-if="turnstileEnabled && turnstileSiteKey">
|
||||||
<TurnstileWidget
|
<TurnstileWidget
|
||||||
@@ -224,7 +312,7 @@
|
|||||||
<!-- Submit Button -->
|
<!-- Submit Button -->
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="isLoading || (turnstileEnabled && !turnstileToken)"
|
:disabled="isLoading || (turnstileEnabled && !turnstileToken) || !termsAccepted"
|
||||||
class="btn btn-primary w-full"
|
class="btn btn-primary w-full"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -275,7 +363,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, reactive, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { AuthLayout } from '@/components/layout'
|
import { AuthLayout } from '@/components/layout'
|
||||||
@@ -317,6 +405,7 @@ const isLoading = ref<boolean>(false)
|
|||||||
const settingsLoaded = ref<boolean>(false)
|
const settingsLoaded = ref<boolean>(false)
|
||||||
const errorMessage = ref<string>('')
|
const errorMessage = ref<string>('')
|
||||||
const showPassword = ref<boolean>(false)
|
const showPassword = ref<boolean>(false)
|
||||||
|
const termsAccepted = ref<boolean>(false)
|
||||||
|
|
||||||
// Public settings
|
// Public settings
|
||||||
const registrationEnabled = ref<boolean>(true)
|
const registrationEnabled = ref<boolean>(true)
|
||||||
@@ -358,6 +447,7 @@ let invitationValidateTimeout: ReturnType<typeof setTimeout> | null = null
|
|||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
promo_code: '',
|
promo_code: '',
|
||||||
invitation_code: '',
|
invitation_code: '',
|
||||||
aff_code: ''
|
aff_code: ''
|
||||||
@@ -370,6 +460,30 @@ const errors = reactive({
|
|||||||
invitation_code: ''
|
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' : '')
|
||||||
|
|
||||||
|
// ==================== Validation Toast ====================
|
||||||
|
|
||||||
const validationToastMessage = computed(() =>
|
const validationToastMessage = computed(() =>
|
||||||
errors.email ||
|
errors.email ||
|
||||||
errors.password ||
|
errors.password ||
|
||||||
@@ -386,6 +500,8 @@ watch(validationToastMessage, (value, previousValue) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ==================== Affiliate Referral ====================
|
||||||
|
|
||||||
function syncAffiliateReferralCode(): string {
|
function syncAffiliateReferralCode(): string {
|
||||||
const code = resolveAffiliateReferralCode(route.query.aff, route.query.aff_code)
|
const code = resolveAffiliateReferralCode(route.query.aff, route.query.aff_code)
|
||||||
if (code) {
|
if (code) {
|
||||||
@@ -655,6 +771,12 @@ function validateForm(): boolean {
|
|||||||
isValid = false
|
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)
|
// Invitation code validation (required when enabled)
|
||||||
if (invitationCodeEnabled.value) {
|
if (invitationCodeEnabled.value) {
|
||||||
if (!formData.invitation_code.trim()) {
|
if (!formData.invitation_code.trim()) {
|
||||||
@@ -683,6 +805,12 @@ async function handleRegister(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check passwords match
|
||||||
|
if (formData.confirmPassword && formData.password !== formData.confirmPassword) {
|
||||||
|
errorMessage.value = t('auth.passwordsDoNotMatch')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Check promo code validation status
|
// Check promo code validation status
|
||||||
if (formData.promo_code.trim()) {
|
if (formData.promo_code.trim()) {
|
||||||
// If promo code is being validated, wait
|
// If promo code is being validated, wait
|
||||||
@@ -796,4 +924,216 @@ async function handleRegister(): Promise<void> {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-8px);
|
transform: translateY(-8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-narrative-inner {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--text-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--text-0);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.brand svg {
|
||||||
|
color: var(--cyan);
|
||||||
|
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 {
|
||||||
|
font-size: clamp(40px, 5vw, 64px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.auth-narrative-headline .num-n { color: var(--amber); }
|
||||||
|
.auth-narrative-headline .num-1 { color: var(--cyan); }
|
||||||
|
.auth-narrative-sub {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
.auth-narrative-tagline {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12px;
|
||||||
|
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>
|
</style>
|
||||||
|
|||||||
518
frontend/src/views/docs/DocsView.vue
Normal file
518
frontend/src/views/docs/DocsView.vue
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<section class="docs-hero container">
|
||||||
|
<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>{{ $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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="codex" class="docs-section">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
[model_providers.OpenAI]
|
||||||
|
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>
|
||||||
|
</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>
|
||||||
|
</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>{{ $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>
|
||||||
|
</div>
|
||||||
|
<p class="note">{{ $t('docs.sections.claudeCode.note') }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="curl" class="docs-section">
|
||||||
|
<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>
|
||||||
|
</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>{{ $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>OpenAI(ChatGPT 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>Anthropic(Claude 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>Google(Code 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>{{ $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>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
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>
|
||||||
|
/* =============================================================
|
||||||
|
* DocsView — component-local styles
|
||||||
|
* Globals from puro.css (scoped to .puro-page) provide:
|
||||||
|
* - .nav, .nav-inner, .brand, .hex, .nav-links, .nav-cta (nav base)
|
||||||
|
* - .mono, .btn, .btn-primary, .container (primitives)
|
||||||
|
* ============================================================= */
|
||||||
|
|
||||||
|
.puro-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* nav brand SVG */
|
||||||
|
.brand svg { color: var(--cyan); flex-shrink: 0; }
|
||||||
|
|
||||||
|
.docs-hero {
|
||||||
|
padding: 80px 24px 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.docs-hero h1 {
|
||||||
|
font-size: clamp(32px, 4vw, 48px);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.docs-hero .subtitle {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-body { padding-bottom: 80px; }
|
||||||
|
.docs-section {
|
||||||
|
margin: 48px 0;
|
||||||
|
scroll-margin-top: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* h2 with cyan left-accent bar */
|
||||||
|
.docs-section h2 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
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;
|
||||||
|
line-height: 1.8;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
.docs-section .note {
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.docs-section code.mono {
|
||||||
|
background: var(--bg-1);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
background: var(--bg-1);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-left: 3px solid var(--cyan);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
.callout a {
|
||||||
|
color: var(--cyan);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* container override (puro.css has 1100px/32px; we want narrower for docs readability) */
|
||||||
|
.container {
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
798
frontend/src/views/landing/LandingView.vue
Normal file
798
frontend/src/views/landing/LandingView.vue
Normal file
@@ -0,0 +1,798 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- HERO -->
|
||||||
|
<section class="hero container">
|
||||||
|
<div class="hero-eyebrow">
|
||||||
|
<span class="badge">{{ $t('landing.hero.badgeNew') }}</span>
|
||||||
|
<span>{{ $t('landing.hero.eyebrow') }}</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="hero-title">
|
||||||
|
{{ $t('landing.hero.title1') }}<br>
|
||||||
|
<span class="text-puro-cyan">{{ $t('landing.hero.title2') }}</span>
|
||||||
|
</h1>
|
||||||
|
<p class="hero-sub">
|
||||||
|
{{ $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">{{ $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">
|
||||||
|
{{ $t('landing.hero.micro') }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ② 模型墙 -->
|
||||||
|
<section class="block container" id="models">
|
||||||
|
<div class="section-header">
|
||||||
|
<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-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-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-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-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-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">{{ $t('landing.models.more') }}</div>
|
||||||
|
<div class="model-meta">{{ $t('landing.models.morePlanned') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ③ 三特性 -->
|
||||||
|
<section class="block container" id="features">
|
||||||
|
<div class="section-header">
|
||||||
|
<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>{{ $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>{{ $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>{{ $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>
|
||||||
|
|
||||||
|
<!-- ④ Code Demo -->
|
||||||
|
<section class="block container" id="code">
|
||||||
|
<div class="section-header">
|
||||||
|
<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-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-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">{{ $t('landing.codeDemo.foot') }}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ⑤ Dashboard mockup -->
|
||||||
|
<section class="block container" id="dashboard">
|
||||||
|
<div class="section-header">
|
||||||
|
<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">
|
||||||
|
<!-- 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-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>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* =============================================================
|
||||||
|
* LandingView — component-local styles
|
||||||
|
* Globals from puro.css (scoped to .puro-page) also provide:
|
||||||
|
* - .log-table, .provider.{claude,gpt,gemini}, .status-200/429 (dashboard mockup)
|
||||||
|
* - .nav, .nav-inner, .brand, .hex, .nav-links, .nav-cta (nav base)
|
||||||
|
* - .mono, .container, .btn, .btn-primary, .btn-ghost, .btn-lg (primitives)
|
||||||
|
* ============================================================= */
|
||||||
|
|
||||||
|
.puro-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-0);
|
||||||
|
color: var(--text-0);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
position: relative;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
text-align: center;
|
||||||
|
padding: 100px 24px 80px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.hero-sub {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--text-2);
|
||||||
|
line-height: 1.6;
|
||||||
|
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;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.hero-micro {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Note: these rules (.container / .section-*) intentionally override
|
||||||
|
* puro.css defaults with landing-page-specific values.
|
||||||
|
* puro.css has global defaults of: container max-width 1100px/padding 32px,
|
||||||
|
* section-title margin-bottom 16px, section-kicker letter-spacing 0.15em.
|
||||||
|
* Source-order ensures the scoped values below win. */
|
||||||
|
.container {
|
||||||
|
max-width: 1120px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.block { padding: 80px 24px; }
|
||||||
|
.section-header { text-align: center; margin-bottom: 40px; }
|
||||||
|
.section-kicker {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--cyan);
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: clamp(28px, 3.5vw, 40px);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.model-card {
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
background: var(--bg-1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.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 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.feature {
|
||||||
|
padding: 28px 24px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
background: var(--bg-1);
|
||||||
|
}
|
||||||
|
.feature-icon { font-size: 28px; margin-bottom: 14px; }
|
||||||
|
.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 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
@media (max-width: 820px) { .code-demo { grid-template-columns: 1fr; } }
|
||||||
|
.code-block {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
background: var(--bg-code);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.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; }
|
||||||
|
.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;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-1);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.cm { color: var(--text-3); }
|
||||||
|
.str { color: var(--cyan); }
|
||||||
|
.kw { color: var(--amber); }
|
||||||
|
.code-foot {
|
||||||
|
margin-top: 20px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* dash mockup */
|
||||||
|
.dash-mock {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-xl);
|
||||||
|
background: var(--bg-1);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 40px 80px -40px rgba(0,0,0,0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* browser chrome */
|
||||||
|
.dash-chrome {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: rgba(15, 23, 42, 0.7);
|
||||||
|
}
|
||||||
|
.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 {
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: rgba(15,23,42,0.6);
|
||||||
|
}
|
||||||
|
.stat-label { font-size: 10px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 6px; }
|
||||||
|
.stat-value { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; font-family: var(--font-mono); }
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.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)
|
||||||
|
* at higher specificity; rules here only add what puro.css lacks
|
||||||
|
* (our darker sticky bg + mobile hide-nav-links). */
|
||||||
|
.nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: rgba(10,14,26,0.75);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.nav-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 28px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
max-width: 1120px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.nav-links a:hover { color: var(--text-0); }
|
||||||
|
.nav-cta {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.nav-links { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.puro-footer {
|
||||||
|
margin-top: 80px;
|
||||||
|
padding: 60px 24px 40px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.footer-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr 1fr 1fr;
|
||||||
|
gap: 36px;
|
||||||
|
}
|
||||||
|
@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;
|
||||||
|
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: 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>
|
||||||
685
frontend/src/views/pricing/PricingView.vue
Normal file
685
frontend/src/views/pricing/PricingView.vue
Normal file
@@ -0,0 +1,685 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- MODEL PRICING TABLE -->
|
||||||
|
<section class="model-pricing-section" id="model-pricing">
|
||||||
|
<div class="section-head">
|
||||||
|
<div class="kicker">{{ $t('pricing.modelPricing.kicker') }}</div>
|
||||||
|
<h2>{{ $t('pricing.modelPricing.title') }}</h2>
|
||||||
|
<p>{{ $t('pricing.modelPricing.sub') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="group in modelGroups" :key="group.key" class="mp-group">
|
||||||
|
<h3 class="mp-group-title">{{ $t(group.titleKey) }}</h3>
|
||||||
|
<div class="mp-grid">
|
||||||
|
<div v-for="m in group.models" :key="m.name" class="mp-card">
|
||||||
|
<div class="mp-head">
|
||||||
|
<span class="mp-provider" :data-provider="m.provider">{{ m.provider }}</span>
|
||||||
|
<h4 class="mp-name">{{ m.name }}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="mp-prices">
|
||||||
|
<div class="mp-price-row">
|
||||||
|
<div class="mp-label">{{ $t('pricing.modelPricing.inputLabel') }}</div>
|
||||||
|
<div class="mp-value">
|
||||||
|
<span class="mp-amount">${{ m.input }}</span>
|
||||||
|
<span class="mp-discount">{{ m.discount }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mp-price-row">
|
||||||
|
<div class="mp-label">{{ $t('pricing.modelPricing.outputLabel') }}</div>
|
||||||
|
<div class="mp-value">
|
||||||
|
<span class="mp-amount">${{ m.output }}</span>
|
||||||
|
<span class="mp-discount">{{ m.discount }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mp-unit">{{ $t('pricing.modelPricing.unit') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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)))
|
||||||
|
|
||||||
|
const modelGroups = [
|
||||||
|
{
|
||||||
|
key: 'coding',
|
||||||
|
titleKey: 'pricing.modelPricing.groupCoding',
|
||||||
|
models: [
|
||||||
|
{ name: 'GPT-5.3 Codex', provider: 'OpenAI', input: '0.179', output: '1.435', discount: '-90%' },
|
||||||
|
{ name: 'GPT-5.2 Codex', provider: 'OpenAI', input: '0.690', output: '5.520', discount: '-60.6%' },
|
||||||
|
{ name: 'Claude Opus 4.6', provider: 'Anthropic', input: '0.950', output: '4.750', discount: '-81%' },
|
||||||
|
{ name: 'Claude Opus 4.5', provider: 'Anthropic', input: '0.950', output: '4.750', discount: '-81%' },
|
||||||
|
{ name: 'Claude Sonnet 4.5', provider: 'Anthropic', input: '0.570', output: '2.850', discount: '-81%' },
|
||||||
|
{ name: 'Claude Haiku 4.5', provider: 'Anthropic', input: '0.190', output: '0.950', discount: '-24%' },
|
||||||
|
{ name: 'Grok Code Fast 1', provider: 'xAI', input: '0.147', output: '1.104', discount: '-26.5%' },
|
||||||
|
{ name: 'MiniMax m2.1', provider: 'MiniMax', input: '0.230', output: '0.954', discount: '-14.8%' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'performance',
|
||||||
|
titleKey: 'pricing.modelPricing.groupPerformance',
|
||||||
|
models: [
|
||||||
|
{ name: 'Claude Opus 4.6', provider: 'Anthropic', input: '3.680', output: '18.400', discount: '-26.4%' },
|
||||||
|
{ name: 'Claude Opus 4.5', provider: 'Anthropic', input: '3.680', output: '18.400', discount: '-26.4%' },
|
||||||
|
{ name: 'Claude Sonnet 4.5', provider: 'Anthropic', input: '2.668', output: '13.340', discount: '-11.1%' },
|
||||||
|
{ name: 'GPT-5.2', provider: 'OpenAI', input: '0.690', output: '5.520', discount: '-60.6%' },
|
||||||
|
{ name: 'Gemini 3 Pro', provider: 'Google', input: '0.460', output: '2.760', discount: '-63.2%' },
|
||||||
|
{ name: 'DeepSeek v3.2', provider: 'DeepSeek', input: '0.207', output: '0.311', discount: '-26.1%' },
|
||||||
|
{ name: 'Grok 4.1 Fast', provider: 'xAI', input: '0.156', output: '0.391', discount: '-22%' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'budget',
|
||||||
|
titleKey: 'pricing.modelPricing.groupBudget',
|
||||||
|
models: [
|
||||||
|
{ name: 'GPT-5-mini', provider: 'OpenAI', input: '0.069', output: '0.552', discount: '-72.4%' },
|
||||||
|
{ name: 'GPT-4o-mini', provider: 'OpenAI', input: '0.023', output: '0.092', discount: '-84.7%' },
|
||||||
|
{ name: 'Gemini 3 Flash', provider: 'Google', input: '0.083', output: '0.662', discount: '-83.4%' },
|
||||||
|
{ name: 'Xiaomi Mimo v2', provider: 'Xiaomi', input: '0.097', output: '0.290', discount: '-3%' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
</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); }
|
||||||
|
|
||||||
|
/* ===== Model Pricing Section ===== */
|
||||||
|
.model-pricing-section {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 32px 60px;
|
||||||
|
}
|
||||||
|
.mp-group {
|
||||||
|
margin-top: 32px;
|
||||||
|
}
|
||||||
|
.mp-group:first-of-type {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.mp-group-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-1);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-left: 4px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.mp-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.mp-card {
|
||||||
|
padding: 18px 20px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: rgba(15, 23, 42, 0.4);
|
||||||
|
transition: all .2s;
|
||||||
|
}
|
||||||
|
.mp-card:hover {
|
||||||
|
border-color: rgba(34, 211, 238, 0.3);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 20px -10px rgba(34, 211, 238, 0.15);
|
||||||
|
}
|
||||||
|
.mp-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.mp-provider {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(148, 163, 184, 0.12);
|
||||||
|
color: var(--text-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.mp-provider[data-provider="OpenAI"] { background: rgba(16, 163, 127, 0.12); color: #10a37f; border-color: rgba(16, 163, 127, 0.3); }
|
||||||
|
.mp-provider[data-provider="Anthropic"] { background: rgba(217, 119, 87, 0.12); color: #d97757; border-color: rgba(217, 119, 87, 0.3); }
|
||||||
|
.mp-provider[data-provider="Google"] { background: rgba(66, 133, 244, 0.12); color: #4285f4; border-color: rgba(66, 133, 244, 0.3); }
|
||||||
|
.mp-provider[data-provider="xAI"] { background: rgba(168, 85, 247, 0.12); color: #a855f7; border-color: rgba(168, 85, 247, 0.3); }
|
||||||
|
.mp-provider[data-provider="DeepSeek"] { background: rgba(96, 165, 250, 0.12); color: #60a5fa; border-color: rgba(96, 165, 250, 0.3); }
|
||||||
|
.mp-provider[data-provider="MiniMax"] { background: rgba(251, 191, 36, 0.12); color: var(--amber); border-color: rgba(251, 191, 36, 0.3); }
|
||||||
|
.mp-provider[data-provider="Xiaomi"] { background: rgba(248, 113, 113, 0.12); color: #f87171; border-color: rgba(248, 113, 113, 0.3); }
|
||||||
|
.mp-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-0);
|
||||||
|
margin: 0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.mp-prices {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.mp-price-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.mp-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
.mp-value {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.mp-amount {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-0);
|
||||||
|
}
|
||||||
|
.mp-discount {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--green, #34d399);
|
||||||
|
background: rgba(52, 211, 153, 0.1);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.mp-unit {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
margin-top: 10px;
|
||||||
|
text-align: right;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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); }
|
||||||
|
.mp-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.mp-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -46,6 +46,20 @@ export default {
|
|||||||
800: '#1e293b',
|
800: '#1e293b',
|
||||||
900: '#0f172a',
|
900: '#0f172a',
|
||||||
950: '#020617'
|
950: '#020617'
|
||||||
|
},
|
||||||
|
// PURO AI 设计系统色板(给新页面 Landing/Auth/Docs 用,不影响 admin)
|
||||||
|
puro: {
|
||||||
|
cyan: '#22d3ee',
|
||||||
|
'cyan-2': '#67e8f9',
|
||||||
|
purple: '#a855f7',
|
||||||
|
amber: '#fbbf24',
|
||||||
|
green: '#34d399',
|
||||||
|
red: '#f87171',
|
||||||
|
// 平台品牌点色
|
||||||
|
claude: '#d97757',
|
||||||
|
gpt: '#10a37f',
|
||||||
|
gemini: '#4285f4',
|
||||||
|
codex: '#f0a030'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
|
|||||||
191
tools/sync-upstream.sh
Executable file
191
tools/sync-upstream.sh
Executable file
@@ -0,0 +1,191 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# tools/sync-upstream.sh
|
||||||
|
#
|
||||||
|
# 同步上游 (Wei-Shaw/sub2api) 最新代码到我们 Gitea fork 的工作流脚本。
|
||||||
|
#
|
||||||
|
# 设计目标:
|
||||||
|
# 1. 在 Gitea 上维护一个纯净的上游镜像分支(默认 ora_main),永远 = upstream/main HEAD
|
||||||
|
# 2. 永远不直接合并到 main —— 创建临时分支让人工 review/preview 后手动合并
|
||||||
|
# 3. 合并前打印冲突预检,让人提前知道改动范围
|
||||||
|
#
|
||||||
|
# 使用方式:
|
||||||
|
# ./tools/sync-upstream.sh # 仅刷新 gitea/ora_main 镜像
|
||||||
|
# ./tools/sync-upstream.sh --try-merge # 刷新 + 创建临时分支并启动合并(停在 --no-commit)
|
||||||
|
#
|
||||||
|
# --try-merge 模式的完整步骤:
|
||||||
|
# 1. fetch upstream + force-with-lease 推到 gitea/ora_main
|
||||||
|
# 2. 预检:列出预计会冲突的文件
|
||||||
|
# 3. 从最新的 main 切出临时分支 merge/upstream-YYYY-MM-DD-HHMM
|
||||||
|
# 4. 在临时分支启动 git merge --no-commit --no-ff upstream/main(停在合并状态)
|
||||||
|
# 5. 打印 next-steps 引导后续 typecheck/build/preview/commit/main 合并
|
||||||
|
#
|
||||||
|
# 自定义环境变量(可覆盖默认):
|
||||||
|
# UPSTREAM_REMOTE 远程名(默认 upstream,应指向 GitHub Wei-Shaw/sub2api)
|
||||||
|
# GITEA_REMOTE 远程名(默认 gitea,自建)
|
||||||
|
# MIRROR_BRANCH 镜像分支名(默认 ora_main)
|
||||||
|
#
|
||||||
|
# 安全注意:
|
||||||
|
# - 强制推送只发生在镜像分支上(force-with-lease,比 force 安全),不会动 main
|
||||||
|
# - 主分支 main 完全由人工接手最后一步 git merge --ff-only + git push
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
UPSTREAM_REMOTE="${UPSTREAM_REMOTE:-upstream}"
|
||||||
|
GITEA_REMOTE="${GITEA_REMOTE:-gitea}"
|
||||||
|
MIRROR_BRANCH="${MIRROR_BRANCH:-ora_main}"
|
||||||
|
TEMP_BRANCH_PREFIX="merge/upstream-"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 前置检查
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
if ! git rev-parse --git-dir >/dev/null 2>&1; then
|
||||||
|
echo "Error: 当前目录不是 git 仓库" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git remote get-url "$UPSTREAM_REMOTE" >/dev/null 2>&1; then
|
||||||
|
echo "Error: remote '$UPSTREAM_REMOTE' 未配置" >&2
|
||||||
|
echo " 添加方式: git remote add $UPSTREAM_REMOTE https://github.com/Wei-Shaw/sub2api.git" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git remote get-url "$GITEA_REMOTE" >/dev/null 2>&1; then
|
||||||
|
echo "Error: remote '$GITEA_REMOTE' 未配置" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 1. 拉最新 upstream
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
echo "[1/3] Fetching $UPSTREAM_REMOTE/main..."
|
||||||
|
git fetch "$UPSTREAM_REMOTE" main --tags
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 2. 刷新 Gitea 镜像分支
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
UPSTREAM_HEAD=$(git rev-parse --short "$UPSTREAM_REMOTE/main")
|
||||||
|
echo "[2/3] Refreshing $GITEA_REMOTE/$MIRROR_BRANCH to $UPSTREAM_REMOTE/main ($UPSTREAM_HEAD)..."
|
||||||
|
# force-with-lease:远端如果在我们 fetch 之后被别人改过会拒绝推送(比 --force 安全)
|
||||||
|
git push --force-with-lease "$GITEA_REMOTE" \
|
||||||
|
"refs/remotes/$UPSTREAM_REMOTE/main:refs/heads/$MIRROR_BRANCH"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 3. 状态报告
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
BASE=$(git merge-base main "$UPSTREAM_REMOTE/main")
|
||||||
|
NEW_COMMITS=$(git rev-list --count "$BASE..$UPSTREAM_REMOTE/main")
|
||||||
|
|
||||||
|
echo "[3/3] Status:"
|
||||||
|
echo " upstream/main HEAD : $UPSTREAM_HEAD"
|
||||||
|
echo " $GITEA_REMOTE/$MIRROR_BRANCH : refreshed"
|
||||||
|
echo " 距上次合并到 main 的新 commit : $NEW_COMMITS"
|
||||||
|
|
||||||
|
# 没有 --try-merge 就到此为止
|
||||||
|
if [[ "${1:-}" != "--try-merge" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "完成。要尝试合并到临时分支:./tools/sync-upstream.sh --try-merge"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# --try-merge 模式
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
if [[ "$NEW_COMMITS" -eq 0 ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "上游无新 commit,跳过合并。"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 预检:列冲突文件
|
||||||
|
echo ""
|
||||||
|
echo "[Pre-flight] 检测预计会冲突的文件..."
|
||||||
|
CONFLICT_LIST=""
|
||||||
|
|
||||||
|
# 优先用 merge-tree --name-only(git >= 2.38)
|
||||||
|
if git merge-tree --name-only --no-messages main "$UPSTREAM_REMOTE/main" >/tmp/.sync-conflicts 2>/dev/null && [[ -s /tmp/.sync-conflicts ]]; then
|
||||||
|
CONFLICT_LIST=$(cat /tmp/.sync-conflicts)
|
||||||
|
else
|
||||||
|
# Fallback:求两边都改过的文件作为「潜在冲突」候选(不一定真冲突,但是个高准确率的提示)
|
||||||
|
CONFLICT_LIST=$(comm -12 \
|
||||||
|
<(git diff --name-only "$BASE" "$UPSTREAM_REMOTE/main" | sort) \
|
||||||
|
<(git diff --name-only "$BASE" main | sort))
|
||||||
|
fi
|
||||||
|
rm -f /tmp/.sync-conflicts
|
||||||
|
|
||||||
|
if [[ -n "$CONFLICT_LIST" ]]; then
|
||||||
|
echo " 以下文件预计需要手动解冲突 (或 auto-merge 后核对):"
|
||||||
|
echo "$CONFLICT_LIST" | sed 's/^/ /'
|
||||||
|
else
|
||||||
|
echo " 无冲突预测,auto-merge 应该干净通过。"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 工作树检查
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "Error: 工作树非干净,请先 commit 或 stash" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 同步本地 main 到远端
|
||||||
|
echo ""
|
||||||
|
echo "[Branch] 同步本地 main 到 $GITEA_REMOTE/main..."
|
||||||
|
git fetch "$GITEA_REMOTE" main >/dev/null 2>&1
|
||||||
|
git checkout main >/dev/null
|
||||||
|
if ! git merge --ff-only "$GITEA_REMOTE/main" >/dev/null 2>&1; then
|
||||||
|
echo " Warning: 本地 main 与 $GITEA_REMOTE/main 不同步(非 fast-forward)。继续基于本地 main。" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 创建临时分支
|
||||||
|
TIMESTAMP=$(date +%Y-%m-%d-%H%M)
|
||||||
|
TEMP_BRANCH="${TEMP_BRANCH_PREFIX}${TIMESTAMP}"
|
||||||
|
|
||||||
|
echo "[Branch] 从 main 创建临时分支 $TEMP_BRANCH..."
|
||||||
|
git checkout -b "$TEMP_BRANCH"
|
||||||
|
|
||||||
|
# 启动合并(不 commit,让人工解冲突)
|
||||||
|
echo "[Merge] 启动 git merge --no-commit --no-ff $UPSTREAM_REMOTE/main..."
|
||||||
|
# || true 因为冲突时 git merge 退出码非 0
|
||||||
|
git merge --no-commit --no-ff "$UPSTREAM_REMOTE/main" || true
|
||||||
|
|
||||||
|
# Next-steps 引导
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
================================================================
|
||||||
|
合并已在分支 $TEMP_BRANCH 上启动(停在 --no-commit)
|
||||||
|
================================================================
|
||||||
|
|
||||||
|
接下来人工操作:
|
||||||
|
|
||||||
|
1. 解冲突(如果有)
|
||||||
|
git status # 看 Unmerged paths
|
||||||
|
# 编辑冲突文件,git add <file>
|
||||||
|
# 全部解完后:
|
||||||
|
git commit -m "Merge upstream/main ($UPSTREAM_HEAD) into $TEMP_BRANCH"
|
||||||
|
|
||||||
|
2. 验证:
|
||||||
|
cd frontend && pnpm run typecheck && pnpm run build
|
||||||
|
cd backend && go build ./cmd/server
|
||||||
|
|
||||||
|
3. 浏览器实测:
|
||||||
|
cd frontend && pnpm run preview
|
||||||
|
# 重点回归我们改过的页面:/ /pricing /docs /login /register
|
||||||
|
|
||||||
|
4. 测试通过后合并到 main:
|
||||||
|
git checkout main
|
||||||
|
git merge --ff-only $TEMP_BRANCH
|
||||||
|
git push $GITEA_REMOTE main
|
||||||
|
|
||||||
|
5. 清理临时分支:
|
||||||
|
git branch -d $TEMP_BRANCH
|
||||||
|
|
||||||
|
放弃合并:
|
||||||
|
git merge --abort
|
||||||
|
git checkout main
|
||||||
|
git branch -D $TEMP_BRANCH
|
||||||
|
|
||||||
|
EOF
|
||||||
Reference in New Issue
Block a user