- docs.sections.getKey.note 键从 zh/en 删除 - DocsView 对应 <p class="note"> 段删掉 - 全仓再次 grep 确认无其他 ishare/iShare 引用
47 KiB
PURO Portal i18n + Pricing Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship bilingual (zh/en) support across 5 portal pages (Landing, Docs, Login-narrative, Register-narrative, new Pricing) by mounting a dark-tech PuroLocaleSwitcher in each top nav and extracting all hard-coded Chinese into i18n keys with English translations.
Architecture:
- Reuse the existing
vue-i18ninfrastructure (setLocale(),availableLocales,sub2api_localelocalStorage key). Only add portal-specific namespaces:landing.*,docs.*,pricing.*,auth.narrative.*. - The current admin
LocaleSwitcher.vueuses Tailwind gray/dark palette that clashes with.puro-pagedark-tech theme — build a newPuroLocaleSwitcher.vuestyled with puro.css tokens (--cyan,--bg-0,--border,--font-mono), reusing the samesetLocale()core. - Pricing page is ported from
docs/design-drafts/v2/Pricing.htmlas a fidelity Vue component, i18n-native from commit one (no "extract later" debt).
Tech Stack: Vue 3 + TS (<script setup>), vue-i18n v9 (composition API), Tailwind + scoped puro.css tokens, Vue Router 4.
Decision log (locked before plan):
- Pricing tiers: keep zip values (
$9.9 / $29.9 / $99) with a// preview · 最终定价以开售为准header pill - Features: port all from zip; mark any not-yet-implemented with a
SOONchip (list resolved during Task 9) - Binding card → links to
/register; "联系商务" →mailto:contact@puro.im - Cost calculator: zip algorithm verbatim, header pill
// estimated · 以实际计费为准 - Nav adds
定价 / Pricinglink on Landing + Docs
File Structure
New files:
frontend/src/components/puro/PuroLocaleSwitcher.vue— dark-tech switcherfrontend/src/views/pricing/PricingView.vue— new routefrontend/src/components/puro/PricingCalculator.vue— cost estimator subcomponent (isolated so PricingView stays focused)
Modified files:
frontend/src/i18n/locales/zh.ts— addlanding.*,docs.*,pricing.*,auth.narrative.*namespacesfrontend/src/i18n/locales/en.ts— same namespaces, English valuesfrontend/src/views/landing/LandingView.vue— template-only, replace Chinese witht('landing.*'), mount switcherfrontend/src/views/docs/DocsView.vue— same treatment, mount switcher, add nav link定价frontend/src/views/auth/LoginView.vue— narrative slot usest('auth.narrative.*')frontend/src/views/auth/RegisterView.vue— samefrontend/src/components/layout/AuthLayout.vue— mount switcher in a top-right floating slotfrontend/src/router/index.ts— add/pricingroute
Task 0: Worktree + branch setup
Files:
-
(workspace level — no source files changed)
-
Step 1: Create worktree and branch
cd /Users/mini/Work/dev/sub2api
git worktree add ../sub2api-portal-i18n -b feat/portal-i18n-pricing main
cd ../sub2api-portal-i18n
- Step 2: Verify clean state
git status
git log --oneline -3
Expected: working tree clean, HEAD = current main.
- Step 3: Install frontend deps (if worktree needs its own node_modules)
cd frontend
pnpm install
Expected: install completes, no peer conflicts.
Task 1: Build PuroLocaleSwitcher component
Files:
-
Create:
frontend/src/components/puro/PuroLocaleSwitcher.vue -
Step 1: Write the component
<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>
- Step 2: Verify component typechecks
Run: pnpm run typecheck (from frontend/)
Expected: PASS, no new errors.
- Step 3: Commit
git add frontend/src/components/puro/PuroLocaleSwitcher.vue
git commit -m "feat(i18n): add PuroLocaleSwitcher for portal pages"
Task 2: Mount switcher in LandingView nav
Files:
-
Modify:
frontend/src/views/landing/LandingView.vue(template nav block + script import; NO i18n text extraction in this task — that happens in Task 5) -
Step 1: Import the switcher
In <script setup lang="ts"> block, add after existing imports:
import PuroLocaleSwitcher from '@/components/puro/PuroLocaleSwitcher.vue'
- Step 2: Update nav template
Replace the existing .nav-cta block:
<div class="nav-cta">
<router-link to="/login" class="btn btn-ghost">登录</router-link>
<router-link to="/register" class="btn btn-primary">免费试用 →</router-link>
</div>
With:
<div class="nav-cta">
<PuroLocaleSwitcher />
<router-link to="/login" class="btn btn-ghost">登录</router-link>
<router-link to="/register" class="btn btn-primary">免费试用 →</router-link>
</div>
- Step 3: Verify build
Run: pnpm run typecheck && pnpm run build (from frontend/)
Expected: both PASS.
- Step 4: Commit
git add frontend/src/views/landing/LandingView.vue
git commit -m "feat(landing): mount PuroLocaleSwitcher in nav"
Task 3: Mount switcher in DocsView nav + add 定价 link
Files:
-
Modify:
frontend/src/views/docs/DocsView.vue -
Step 1: Import switcher in
<script setup>
import PuroLocaleSwitcher from '@/components/puro/PuroLocaleSwitcher.vue'
- Step 2: Update
.nav-linksto add Pricing link
Find the nav links block (currently contains 产品 / 文档) and replace with:
<div class="nav-links">
<router-link to="/">产品</router-link>
<router-link to="/pricing">定价</router-link>
<router-link to="/docs" class="active">文档</router-link>
</div>
- Step 3: Update
.nav-ctato include switcher
Find .nav-cta block and prepend <PuroLocaleSwitcher /> before the first router-link:
<div class="nav-cta">
<PuroLocaleSwitcher />
<router-link to="/login" class="btn btn-ghost">登录</router-link>
<router-link to="/register" class="btn btn-primary">注册</router-link>
</div>
- Step 4: Typecheck + build
Run: pnpm run typecheck && pnpm run build
Expected: PASS.
- Step 5: Commit
git add frontend/src/views/docs/DocsView.vue
git commit -m "feat(docs): mount switcher + add pricing nav link"
Task 4: Mount switcher in AuthLayout (for Login/Register)
Files:
- Modify:
frontend/src/components/layout/AuthLayout.vue
The split layout already has <slot name="narrative" /> on the left and the form on the right. Add a fixed position switcher in the top-right.
- Step 1: Read current AuthLayout.vue to find the split container
Run: grep -n "auth-shell-split" /Users/mini/Work/dev/sub2api/frontend/src/components/layout/AuthLayout.vue
Note the root element.
- Step 2: Import switcher in
<script setup lang="ts">
import PuroLocaleSwitcher from '@/components/puro/PuroLocaleSwitcher.vue'
- Step 3: Add switcher inside split layout
Inside <div class="auth-shell-split" v-if="hasNarrative"> (or equivalent block), add as the first child:
<div class="auth-locale-corner">
<PuroLocaleSwitcher />
</div>
- Step 4: Add scoped styles at end of
<style scoped>block
.auth-shell-split .auth-locale-corner {
position: absolute;
top: 24px;
right: 24px;
z-index: 20;
}
Ensure .auth-shell-split has position: relative; — if not already set, add it to the existing rule.
- Step 5: Typecheck + build
Run: pnpm run typecheck && pnpm run build
Expected: PASS.
- Step 6: Commit
git add frontend/src/components/layout/AuthLayout.vue
git commit -m "feat(auth): mount switcher in split layout top-right"
Task 5: Extract LandingView i18n keys + EN translation
Files:
- Modify:
frontend/src/views/landing/LandingView.vue(template only) - Modify:
frontend/src/i18n/locales/zh.ts(addlandingnamespace) - Modify:
frontend/src/i18n/locales/en.ts(addlandingnamespace)
Key naming convention:
-
landing.nav.*— nav links + CTAs -
landing.hero.*— hero eyebrow, title lines, subtitle fragments, CTAs, micro -
landing.models.*— section kicker, title, subtitle, card-level keys -
landing.features.*— section kicker, title, bullets (9 total) -
landing.codeDemo.*— section header + tab labels -
landing.dashboard.*— section header + sidebar labels + donut caption -
landing.footer.*— tagline + column headers + nav link labels -
Step 1: Add
landingnamespace tozh.ts
Open frontend/src/i18n/locales/zh.ts and append before the closing }:
landing: {
nav: {
products: '产品',
docs: '文档',
pricing: '定价',
login: '登录',
signup: '免费试用 →'
},
hero: {
badgeNew: 'NEW',
eyebrow: '统一接入多个 AI 平台 · 零改动切换',
title1: '你的 AI 订阅,',
title2: '已经付过钱了。',
sub1: 'Claude Pro · ChatGPT Plus · Codex · Gemini 订阅',
sub2: '聚合成统一 API,零改动接入 {openai} / {anthropic} SDK',
openai: 'OpenAI',
anthropic: 'Anthropic',
ctaLogin: '登录 →',
ctaContact: '联系咨询',
micro: '已验证可用 Codex CLI · Claude Code · curl · 服务器出口新加坡'
}
// ...continue exactly for models / features / codeDemo / dashboard / footer
// Extract EVERY Chinese string present in LandingView template.
}
Subagent instruction: Read the entire LandingView.vue template. For each Chinese string, add a key in the landing namespace using the conventions above. Use {variable} placeholders for inline <span> wrappers (e.g., pill-inline or text-puro-cyan spans). Do NOT leave any Chinese in the template except brand name PURO AI.
- Step 2: Mirror the namespace in
en.tswith English translations
Use the same key structure. Translation guidelines:
-
Keep brand:
PURO AIunchanged -
Match tone: concise, tech-product register
-
Hero title:
"Your AI subscriptions, / are already paid for." -
CTAs:
"Sign in →","Contact us" -
Keep technical terms (
OAuth,API,SDK,RPM,Claude Code,Codex) as English (they're already English in zh too) -
Step 3: Replace Chinese in
LandingView.vuetemplate with$t(...)calls
Example transformation:
Before:
<a href="#features">产品</a>
<a href="/docs">文档</a>
After:
<a href="#features">{{ $t('landing.nav.products') }}</a>
<router-link to="/docs">{{ $t('landing.nav.docs') }}</router-link>
For interpolated spans (e.g., 零改动接入 <span class="pill-inline">OpenAI</span> / <span class="pill-inline">Anthropic</span> SDK), use <i18n-t> component:
<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>
- Step 4: Verify no Chinese remains
Run: grep -P "[\x{4e00}-\x{9fa5}]" frontend/src/views/landing/LandingView.vue
Expected: no results (except possibly inline <!-- comment --> blocks, which are fine).
- Step 5: Typecheck + build
Run: pnpm run typecheck && pnpm run build
Expected: PASS.
- Step 6: Commit
git add frontend/src/views/landing/LandingView.vue frontend/src/i18n/locales/zh.ts frontend/src/i18n/locales/en.ts
git commit -m "feat(landing): extract i18n keys + add English translations"
Task 6: Extract DocsView i18n keys + EN translation
Files:
- Modify:
frontend/src/views/docs/DocsView.vue - Modify:
frontend/src/i18n/locales/zh.ts(adddocsnamespace) - Modify:
frontend/src/i18n/locales/en.ts(adddocsnamespace)
Key naming convention:
docs.nav.*— nav link labels (reuselanding.nav.*where identical — but keep separate namespace for clarity and to allow divergence)docs.hero.*— page title + introdocs.sections.{baseUrl,auth,models,codex,claudeCode,curl,errors,faq}.*— each doc section: heading, paragraphs, code-block filename tabs
Code block content: keep code strings (curl, JSON, bash) as-is in the template — they are not translated. Only surrounding prose is extracted.
-
Step 1: Read DocsView.vue in full to inventory all Chinese strings.
-
Step 2: Add
docsnamespace tozh.ts
Use the section-based structure. Every Chinese string gets a key. Table cells (provider names, status chips OK/BETA) where the content is already English or neutral can stay as raw strings.
- Step 3: Mirror in
en.ts
Translation register: technical-reference docs tone. "快速开始" → "Quickstart". "基础 URL" → "Base URL". "认证" → "Authentication". "模型列表" → "Available models". "错误码" → "Error codes". "常见问题" → "FAQ".
-
Step 4: Replace Chinese in
DocsView.vuewith$t(...)calls -
Step 5: Verify no leftover Chinese
Run: grep -P "[\x{4e00}-\x{9fa5}]" frontend/src/views/docs/DocsView.vue
Expected: no results.
- Step 6: Typecheck + build
Run: pnpm run typecheck && pnpm run build
Expected: PASS.
- Step 7: Commit
git add frontend/src/views/docs/DocsView.vue frontend/src/i18n/locales/zh.ts frontend/src/i18n/locales/en.ts
git commit -m "feat(docs): extract i18n keys + add English translations"
Task 7: Extract auth narrative i18n keys (Login + Register) + EN
Files:
- Modify:
frontend/src/views/auth/LoginView.vue(narrative slot only) - Modify:
frontend/src/views/auth/RegisterView.vue(narrative slot only) - Modify:
frontend/src/i18n/locales/zh.ts(addauth.narrative.*) - Modify:
frontend/src/i18n/locales/en.ts(same)
Key naming convention:
-
auth.narrative.common.*— shared: brand, kicker, badges that appear on both -
auth.narrative.login.*— login-specific:kicker = '// 你的订阅,已经付过钱了', headline fragments (N 个订阅 → 1 个 key), subtitle lines, demo panel labels, bottom status text -
auth.narrative.register.*— register-specific:kicker = '// 5 分钟开始用', 3-step panel labels -
Step 1: Inventory both narrative blocks
Run: grep -nP "[\x{4e00}-\x{9fa5}]" frontend/src/views/auth/LoginView.vue frontend/src/views/auth/RegisterView.vue
Record each Chinese occurrence.
- Step 2: Add
auth.narrativekeys tozh.ts
Extend the existing auth namespace (don't replace). Example snippet:
auth: {
// ... existing keys preserved ...
narrative: {
common: {
statusLive: 'live',
brand: 'PURO AI'
},
login: {
kicker: '// 你的订阅,已经付过钱了',
headlineN: 'N',
headlineOne: '1',
headlineSep: '个订阅 →',
headlineSuffix: '个 key',
subtitle1: 'Claude Pro 的订阅 · 通过统一 API 用起来',
subtitle2: 'ChatGPT Plus 的订阅 · 无需再买官方 API',
subtitle3: 'Codex / Gemini · 一个 key 全搞定',
demoTitle: 'POST /v1/chat/completions',
bottomProviders: 'Claude · ChatGPT · Codex · Gemini',
bottomStatus: '运行中'
},
register: {
kicker: '// 5 分钟开始用',
headlineN: 'N',
headlineOne: '1',
headlineSep: '个订阅 →',
headlineSuffix: '个 key',
step1Title: '注册账号',
step1Desc: '邮箱 + 密码 · 不用信用卡',
step2Title: '绑定订阅',
step2Desc: 'OAuth 连接 Claude Pro / ChatGPT Plus',
step3Title: '拿到 API Key',
step3Desc: '替换 base_url 即可使用',
bottomProviders: 'Claude · ChatGPT · Codex · Gemini',
bottomStatus: '运行中'
}
}
}
- Step 3: Mirror in
en.ts
Translation notes:
-
// 你的订阅,已经付过钱了→// you already paid for your subscriptions -
// 5 分钟开始用→// up and running in 5 minutes -
个订阅 →→subscriptions → -
个 key→key -
Step titles: "Create account" / "Connect subscription" / "Get your API key"
-
Step 4: Replace Chinese in LoginView narrative slot
Use $t('auth.narrative.login.kicker') etc. For the N 个订阅 → 1 个 key headline with styled N and 1 spans, decompose:
<h1 class="auth-headline">
<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') }}
</h1>
-
Step 5: Replace Chinese in RegisterView narrative slot using the same pattern.
-
Step 6: Verify no Chinese in narrative slots
Check only the <template #narrative> blocks of both files.
- Step 7: Typecheck + build
Run: pnpm run typecheck && pnpm run build
Expected: PASS.
- Step 8: Commit
git add frontend/src/views/auth/LoginView.vue frontend/src/views/auth/RegisterView.vue frontend/src/i18n/locales/zh.ts frontend/src/i18n/locales/en.ts
git commit -m "feat(auth): i18n-ify narrative panels for login + register"
Task 8: Build PricingCalculator subcomponent
Files:
- Create:
frontend/src/components/puro/PricingCalculator.vue
Isolates the 3-slider cost estimator so PricingView.vue stays focused.
- Step 1: Write component
<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>
/* Styles ported verbatim from Pricing.html .calc rules */
.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>
- Step 2: Typecheck
Run: pnpm run typecheck
Expected: PASS.
- Step 3: Commit
git add frontend/src/components/puro/PricingCalculator.vue
git commit -m "feat(pricing): add PricingCalculator subcomponent"
Task 9: Build PricingView (i18n-native) + add route
Files:
- Create:
frontend/src/views/pricing/PricingView.vue - Modify:
frontend/src/router/index.ts(add/pricingroute) - Modify:
frontend/src/i18n/locales/zh.ts(addpricing.*) - Modify:
frontend/src/i18n/locales/en.ts(addpricing.*)
Source: docs/design-drafts/v2/Pricing.html — port verbatim, extract Chinese strings to keys.
Decisions locked:
- Header subkicker: ZH
// preview · 最终定价以开售为准/ EN// preview · final pricing TBD at launch - Calculator header pill: ZH
// estimated · 以实际计费为准/ EN// estimated · for reference only - Enterprise card →
mailto:contact@puro.im - Binding card →
/registerrouter-link (no/bindingpage) - Tier CTAs →
/registerrouter-link - Final CTA
Docslink →/docs
SOON chip: before writing the template, the subagent audits the backend for these features (grep at backend/):
- API Key monthly budget / 402 Payment Required → look for
budget/Payment Required - Zero-log mode → look for
zero_log/zeroLog - Priority scheduling → look for
priority - RPM limits (60/120/300) → look for rate limiter
- Subscription failover → look for
failover/cooling
For each feature NOT found: wrap the <div class="feat"> with an extra chip <span class="soon-chip">{{ $t('pricing.soonChip') }}</span> (chip = small pill, amber/muted). Add chip CSS in same scoped style.
- Step 1: Audit backend for advertised features
Run: cd /Users/mini/Work/dev/sub2api && grep -ril -E "budget|zero_log|priority_schedul|failover|cooling" backend/ 2>/dev/null | head -20
Document which features are implemented; the rest get SOON chips.
- Step 2: Add
pricingnamespace tozh.tswith structure:
pricing: {
hero: {
kicker: '// pricing · 充多少 · 用多少 · 永不过期',
previewPill: '// preview · 最终定价以开售为准',
title1: '一次充值,',
titleAccent: '全平台',
title2: '通用',
sub: '同一份积分可以用在 Claude / ChatGPT / Gemini 任意池上。我们把你的订阅额度变成真正的 API 余额 —— 相比官方 API 便宜 {discount}。',
subDiscount: '至多 70%',
underline: '余额永不过期 · 支持支付宝 / 微信 / USDT · 无隐藏订阅费'
},
soonChip: 'SOON',
tiers: {
starter: {
flag: 'STARTER',
tierLabel: 'tier · 01',
headline: '先尝尝鲜,跑通接入',
credit: '充 $9.9 → 得 {credit} 积分 {bonus}',
creditAmount: '$12',
creditBonus: '+21%',
discountTag: '相当于官方 API · 0.5 折起',
cta: '充值 →',
features: {
allModels: '可用所有模型 / 所有池',
oneKey: '{count} 个 API Key',
oneKeyCount: '1',
rpm60: '60 RPM 速率限制',
log7: '基础日志(7 天保留)',
noBYOS: '自带订阅接入',
noTeam: '团队 / 多人协作'
}
},
pro: {
flag: '◆ 推荐',
tierLabel: 'tier · 02',
headline: '个人重度用户 · 最划算',
credit: '充 $29.9 → 得 {credit} 积分 {bonus}',
creditAmount: '$45',
creditBonus: '+50%',
discountTag: '相当于官方 API · 3-7 折',
cta: '立即充值 →',
features: {
allModels: '可用所有模型 / 所有池',
threeKeys: '{count} 个 API Key · 独立预算',
threeKeysCount: '3',
rpm120: '120 RPM 速率限制',
log30: '调用日志(30 天保留)',
byos: '自带订阅接入(无限个)',
failover: '多账号 failover 调度'
}
},
scale: {
flag: '⚡ 限时 +100%',
tierLabel: 'tier · 03',
headline: '小团队 / 长跑项目',
credit: '充 $99 → 得 {credit} 积分 {bonus}',
creditAmount: '$198',
creditBonus: '+100%',
discountTag: '相当于官方 API · 2-5 折',
cta: '充值 →',
features: {
proAll: '所有 Pro 能力',
tenKeys: '{count} 个 API Key · 独立预算',
tenKeysCount: '10',
rpm300: '300 RPM 速率限制',
log90: '调用日志(90 天保留)',
priority: '请求优先级加权调度',
community: 'Slack / Discord 群组支持'
}
},
custom: {
flag: 'CUSTOM',
tierLabel: 'tier · 04',
headline: '自定义金额 · 按需充值',
creditPrefix: '得约',
bonusPrefix: '+',
discountTag: '根据金额阶梯自动匹配折扣',
cta: '定制充值 →',
features: {
noExpire: '积分永不过期',
proAll: 'Pro 全部能力',
tier: '阶梯 +21% ~ +100%',
pay: '支付宝 / 微信 / USDT',
slider: '拖动滑块预览赠送'
}
}
},
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'
},
faq: {
kicker: '// frequently asked',
title: '你可能想问的',
noAnswer: '没找到答案?{contact} · 通常 2 小时内回复。',
contact: '发邮件给我们 ↗',
q1: 'PURO 和 API 中转站 / API 代理有什么不同?',
a1: '中转站只是把官方 API 请求转一手,价格取决于你预付多少 balance。PURO 的不同是 —— 我们让你 {bold}。你原本就在付的 $20/月,不再只能在官网聊天里用,而是通过统一 API 喂给 Cursor、Claude Code、任何 SDK。同时我们也提供按量充值的官方 API 备用池,两种模式可以混用。',
a1bold: '把已有的 Claude Pro / ChatGPT Plus 订阅变成 API',
q2: '用订阅跑 API 会不会被封号?',
a2: '我们会自动控制每个订阅的请求节奏,并在触发限流时把请求 failover 到池子里的其他订阅。实际上 PURO 的调用模式比你在官方客户端直接复制粘贴大段对话 {bold}。你绑定多个订阅时,单个账号的 RPM 会被压到足够安全的阈值内。另外所有凭证用 AES-256 加密存储,请求链路不经过第三方。',
a2bold: '更不容易触发风控',
q3: '积分会过期吗?可以退款吗?',
a3: '{bold}你可以攒着慢慢用 —— 包括几个月都不用。首次充值 7 天内未产生任何调用可全额退款,之后按剩余积分 85% 比例退。详见退款政策。',
a3bold: '积分永不过期。',
q4: '支持哪些支付方式?',
a4: '国内:支付宝 · 微信支付。国际:Stripe 信用卡 · USDT (TRC20 / ERC20) · PayPal。企业充值支持 Invoice 对公打款,人民币开票。',
q5: '一个 PURO 账号可以绑定多少个订阅?',
a5Starter: 'Starter 档:不支持绑定自带订阅',
a5Pro: 'Pro 档及以上:无限制,你可以把 10 个 ChatGPT Plus + 3 个 Claude Pro 一起绑上去,统一调度',
a5Enterprise: 'Enterprise:支持跨团队共享池,按组织维度隔离',
q6: '如果某个订阅触发限流了会怎样?',
a6: 'PURO 的调度器会把受限的订阅自动标记为 cooling 状态,暂时从池子里摘除。同一请求会立刻被 failover 到池内其他健康订阅上 —— 调用方通常 {bold}。你可以在 Dashboard 看到每个订阅的当前状态和剩余配额。',
a6bold: '感受不到中断',
q7: '计费精度?超量会怎么办?',
a7: '按实际 token 数 + 模型单价计费,精度到 4 位小数。每个 API Key 可设置独立月度预算,达到后 402 Payment Required,不会继续扣费。账户总余额不足时同样会返回 402,且 Dashboard 有 80% / 95% 两级提醒邮件。',
q8: '数据会被用于训练吗?',
a8: '{bold}所有请求仅用于路由转发,不入库、不留存内容(仅保留元数据如模型、token 数、延迟,用于计费和日志)。Pro 档及以上可选开启"零日志模式",我们连请求 ID 都不记录。',
a8bold: '不会。',
q9: '可以私有化部署吗?',
a9: 'Enterprise 档支持 Docker / K8s 私有化部署,控制面和数据面可以分开。授权按年订阅,包含升级和技术支持。',
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)。每当官方发布新模型,我们通常在 {bold}。完整模型列表见文档。',
a10bold: '24 小时内上线'
},
finalCta: {
kicker: '// ready to start',
title: '5 分钟,拿到你第一个 sk-puro-* key',
subtitle: '绑定你的第一个订阅即可开始。',
ctaPrimary: '免费注册 →',
ctaDocs: '查看文档'
}
}
Note: The zip's final CTA contains 注册送 $5 测试积分 — DROP this line per Stage 1 decision (no $5 bonus). The subtitle above is pruned.
- Step 3: Mirror
pricingnamespace inen.ts
Translation guidelines:
-
Hero title:
"Top up once," / "unlimited across" / "all platforms" -
Hero sub:
"The same credits work across Claude / ChatGPT / Gemini pools. We turn your subscription into real API balance — {discount} cheaper than the official API." -
Tier CTAs:
"Top up →"/"Buy Pro →"/"Top up →"/"Custom top-up →" -
Tier flags:
"STARTER"/"◆ RECOMMENDED"/"⚡ LIMITED +100%"/"CUSTOM" -
FAQ answers: professional/concise tone, keep technical terms English
-
Step 4: Write
PricingView.vue
Structure:
<template>
<div class="puro-page">
<div class="bg-glow"></div>
<div class="grain"></div>
<!-- NAV (reuse Landing's nav pattern + /pricing active) -->
<nav class="nav">
<div class="container nav-inner">
<router-link to="/" class="brand">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 2L21 7V17L12 22L3 17V7L12 2Z" fill="rgba(34, 211, 238, 0.08)"/>
</svg>
<span>PURO AI</span>
</router-link>
<div class="nav-links">
<router-link to="/">{{ $t('landing.nav.products') }}</router-link>
<router-link to="/pricing" class="active">{{ $t('landing.nav.pricing') }}</router-link>
<router-link to="/docs">{{ $t('landing.nav.docs') }}</router-link>
</div>
<div class="nav-cta">
<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>
<!-- HERO + preview pill -->
<section class="hero">
<div class="section-kicker">{{ $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>
<i18n-t keypath="pricing.hero.sub" tag="p" class="sub">
<template #discount><b class="text-cyan">{{ $t('pricing.hero.subDiscount') }}</b></template>
</i18n-t>
<div class="underline">
<span class="dot"></span>
{{ $t('pricing.hero.underline') }}
</div>
</section>
<!-- TIER GRID: 4 cards (Starter, Pro popular, Scale, Custom with slider) -->
<!-- Each .feat that requires SOON chip per Step-1 audit gets an extra chip -->
<!-- CUSTOM ROW: Enterprise + Binding cards -->
<!-- PRICING CALCULATOR -->
<section class="calc-section">
<div class="calc-preview-pill">{{ $t('pricing.calc.previewPill') }}</div>
<PricingCalculator />
</section>
<!-- WORKS EVERYWHERE TOOLS GRID (12 tools, port SVGs verbatim) -->
<!-- FAQ (10 items, use <details>) -->
<!-- FINAL CTA (drop $5 bonus line per Stage 1 decision) -->
</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'
// Custom tier slider
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>
/* Port all styles from docs/design-drafts/v2/Pricing.html <style> block verbatim.
Add: .preview-pill (amber chip, mono 10px), .soon-chip (same style), .calc-preview-pill. */
</style>
Subagent execution note: This task is large. Full template is ~400 lines of Vue. Subagent should:
- Open
docs/design-drafts/v2/Pricing.htmlin one tab - Open the target
PricingView.vuebeing written - Copy each section (hero → tier grid → custom row → calc → works → faq → final-cta), replacing raw Chinese with
$t(...)lookups per the schema in Step 2 - Keep all SVG/HTML structure verbatim
- Apply the SOON chip to any unimplemented feature per Step 1 audit results
- Remove the $5 bonus line from final CTA
- Step 5: Add
/pricingroute
Modify frontend/src/router/index.ts. Add new route entry (public, no auth guard):
{
path: '/pricing',
name: 'pricing',
component: () => import('@/views/pricing/PricingView.vue'),
meta: { requiresAuth: false, title: 'Pricing · PURO AI' }
}
Add this near the /docs route (public portal section).
- Step 6: Add
定价 / Pricinglink to LandingView nav
Modify LandingView.vue .nav-links block:
<div class="nav-links">
<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>
(Landing's Pricing link was added as part of Task 3's nav update for Docs; Landing gets it here.)
- Step 7: Typecheck + build
Run: pnpm run typecheck && pnpm run build
Expected: PASS.
- Step 8: Commit
git add frontend/src/views/pricing/ frontend/src/router/index.ts frontend/src/views/landing/LandingView.vue frontend/src/i18n/locales/zh.ts frontend/src/i18n/locales/en.ts
git commit -m "feat(pricing): add PricingView with bilingual i18n + nav link"
Task 10: Verification + PR + deploy
Files: none changed
- Step 1: Run full typecheck + build
cd frontend
pnpm run typecheck
pnpm run build
Expected: both PASS.
- Step 2: Scan for leftover hard-coded Chinese in portal views
cd /Users/mini/Work/dev/sub2api
grep -rnP "[\x{4e00}-\x{9fa5}]" frontend/src/views/landing/ frontend/src/views/docs/ frontend/src/views/pricing/ 2>/dev/null | grep -v "^.*://" | grep -vE "<!--.*-->"
Expected: empty output (only things that should remain are comments, which this grep filters).
- Step 3: Start preview and manually verify
cd frontend
pnpm run preview
Open in browser (http://localhost:4173):
/— Landing: switcher in top-right, clickEN→ all text flips to English, refresh → stays English/pricing— Pricing: 4 tiers render, calculator sliders work, FAQ accordions open, switcher works/docs— Docs: tables render, copy-code works, switcher works/login— narrative panel + form, switcher top-right/register— narrative panel + form, switcher top-right
For each page: toggle EN ↔ ZH at least twice, confirm no flashes of untranslated Chinese.
- Step 4: Stop preview
Ctrl-C the preview server. Verify no zombie processes:
pgrep -f "vite.*preview"
Expected: no output. If any: pkill -f "vite.*preview".
- Step 5: Push branch and open PR
git push -u origin feat/portal-i18n-pricing
gh pr create --title "feat: PURO portal i18n (zh/en) + Pricing page" --body "$(cat <<'EOF'
## Summary
- Puro-themed `PuroLocaleSwitcher` mounted in Landing/Docs/Login/Register top nav
- Full i18n extraction for LandingView / DocsView / Login & Register narrative panels (zh + en)
- New `/pricing` page ported from design zip, i18n-native, with preview pricing pill + SOON chips for unshipped features
- Nav adds 定价 / Pricing link on Landing + Docs
## Test plan
- [ ] Typecheck + build pass
- [ ] Toggle EN/ZH on /, /pricing, /docs, /login, /register — all text switches
- [ ] Refresh persists locale (localStorage `sub2api_locale`)
- [ ] Pricing calculator sliders update live; custom tier bonus updates
- [ ] Admin pages (`/dashboard`, `/admin/*`) unaffected — no CSS regressions
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
- Step 6: Merge when CI green
gh pr merge --squash --delete-branch
- Step 7: Verify production deploy
curl -sSf -o /dev/null -w "%{http_code}\n" https://ai.puro.im/
curl -sSf -o /dev/null -w "%{http_code}\n" https://ai.puro.im/pricing
curl -sSf -o /dev/null -w "%{http_code}\n" https://ai.puro.im/docs
Expected: 200 200 200.
- Step 8: Cleanup worktree
cd /Users/mini/Work/dev/sub2api
git worktree remove ../sub2api-portal-i18n
Appendix: Translation style guide (EN)
- Register: tech-product, concise, no marketing fluff
- Technical terms: keep English (
OAuth,SDK,API key,RPM,base_url,tokens) - Brand tone: PURO speaks to developers first, so answers in FAQ stay factual, not salesy
- Punctuation: English full stops / commas (not
,or。) - Numbers: keep format from zh (e.g.,
$29.9,$198)
Appendix: Key guarantees
- All 5 pages continue to render correctly in zh after extraction (regression check at Task 10 Step 3)
setLocale()remains the single source of truth — no custom storage addedPuroLocaleSwitcherreusessetLocale()imports; does not duplicate i18n plumbing- No changes to admin pages, AppHeader, or existing
LocaleSwitcher.vue(decoupled)