Compare commits

13 Commits

Author SHA1 Message Date
mini
b989c50317 feat(pricing): add PricingView + calculator with bilingual i18n
Some checks failed
CI / test (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / golangci-lint (pull_request) Has been cancelled
Security Scan / backend-security (pull_request) Has been cancelled
Security Scan / frontend-security (pull_request) Has been cancelled
Port Pricing.html verbatim to Vue: hero with preview pill, 4-tier grid,
custom-amount slider, PricingCalculator subcomponent, works-everywhere
grid, FAQ accordions, final CTA. Full zh/en pricing namespace (~200 keys
each). SOON chip on Scale priority feature; zero-log FAQ uses inline
parenthetical. Drop $5 bonus line; Enterprise → mailto, Binding/tiers →
/register, docs link → /register/docs.

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

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

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

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

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

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

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

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

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

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

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

Legacy (non-split) mode unaffected.

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

Legacy (non-split) mode unaffected.

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -129,6 +129,12 @@ const routes: RouteRecordRaw[] = [
title: 'PURO AI · 文档'
}
},
{
path: '/pricing',
name: 'pricing',
component: () => import('@/views/pricing/PricingView.vue'),
meta: { requiresAuth: false, title: 'Pricing · PURO AI' }
},
// ==================== User Routes ====================
{

View File

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

View File

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

View File

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

View File

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

View File

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