- docs.sections.getKey.note 键从 zh/en 删除 - DocsView 对应 <p class="note"> 段删掉 - 全仓再次 grep 确认无其他 ishare/iShare 引用
1263 lines
47 KiB
Markdown
1263 lines
47 KiB
Markdown
# 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-i18n` infrastructure (`setLocale()`, `availableLocales`, `sub2api_locale` localStorage key). Only add portal-specific namespaces: `landing.*`, `docs.*`, `pricing.*`, `auth.narrative.*`.
|
||
- The current admin `LocaleSwitcher.vue` uses Tailwind gray/dark palette that clashes with `.puro-page` dark-tech theme — build a new `PuroLocaleSwitcher.vue` styled with puro.css tokens (`--cyan`, `--bg-0`, `--border`, `--font-mono`), reusing the same `setLocale()` core.
|
||
- Pricing page is ported from `docs/design-drafts/v2/Pricing.html` as 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 `SOON` chip (list resolved during Task 9)
|
||
- Binding card → links to `/register`; "联系商务" → `mailto:contact@puro.im`
|
||
- Cost calculator: zip algorithm verbatim, header pill `// estimated · 以实际计费为准`
|
||
- Nav adds `定价 / Pricing` link on Landing + Docs
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
**New files:**
|
||
- `frontend/src/components/puro/PuroLocaleSwitcher.vue` — dark-tech switcher
|
||
- `frontend/src/views/pricing/PricingView.vue` — new route
|
||
- `frontend/src/components/puro/PricingCalculator.vue` — cost estimator subcomponent (isolated so PricingView stays focused)
|
||
|
||
**Modified files:**
|
||
- `frontend/src/i18n/locales/zh.ts` — add `landing.*`, `docs.*`, `pricing.*`, `auth.narrative.*` namespaces
|
||
- `frontend/src/i18n/locales/en.ts` — same namespaces, English values
|
||
- `frontend/src/views/landing/LandingView.vue` — template-only, replace Chinese with `t('landing.*')`, mount switcher
|
||
- `frontend/src/views/docs/DocsView.vue` — same treatment, mount switcher, add nav link `定价`
|
||
- `frontend/src/views/auth/LoginView.vue` — narrative slot uses `t('auth.narrative.*')`
|
||
- `frontend/src/views/auth/RegisterView.vue` — same
|
||
- `frontend/src/components/layout/AuthLayout.vue` — mount switcher in a top-right floating slot
|
||
- `frontend/src/router/index.ts` — add `/pricing` route
|
||
|
||
---
|
||
|
||
## Task 0: Worktree + branch setup
|
||
|
||
**Files:**
|
||
- (workspace level — no source files changed)
|
||
|
||
- [ ] **Step 1: Create worktree and branch**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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)**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```vue
|
||
<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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```ts
|
||
import PuroLocaleSwitcher from '@/components/puro/PuroLocaleSwitcher.vue'
|
||
```
|
||
|
||
- [ ] **Step 2: Update nav template**
|
||
|
||
Replace the existing `.nav-cta` block:
|
||
|
||
```vue
|
||
<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:
|
||
|
||
```vue
|
||
<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**
|
||
|
||
```bash
|
||
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>`**
|
||
|
||
```ts
|
||
import PuroLocaleSwitcher from '@/components/puro/PuroLocaleSwitcher.vue'
|
||
```
|
||
|
||
- [ ] **Step 2: Update `.nav-links` to add Pricing link**
|
||
|
||
Find the nav links block (currently contains `产品` / `文档`) and replace with:
|
||
|
||
```vue
|
||
<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-cta` to include switcher**
|
||
|
||
Find `.nav-cta` block and prepend `<PuroLocaleSwitcher />` before the first `router-link`:
|
||
|
||
```vue
|
||
<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**
|
||
|
||
```bash
|
||
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">`**
|
||
|
||
```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:
|
||
|
||
```vue
|
||
<div class="auth-locale-corner">
|
||
<PuroLocaleSwitcher />
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 4: Add scoped styles at end of `<style scoped>` block**
|
||
|
||
```css
|
||
.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**
|
||
|
||
```bash
|
||
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` (add `landing` namespace)
|
||
- Modify: `frontend/src/i18n/locales/en.ts` (add `landing` namespace)
|
||
|
||
**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 `landing` namespace to `zh.ts`**
|
||
|
||
Open `frontend/src/i18n/locales/zh.ts` and append before the closing `}`:
|
||
|
||
```ts
|
||
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.ts` with English translations**
|
||
|
||
Use the same key structure. Translation guidelines:
|
||
- Keep brand: `PURO AI` unchanged
|
||
- 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.vue` template with `$t(...)` calls**
|
||
|
||
Example transformation:
|
||
|
||
Before:
|
||
```vue
|
||
<a href="#features">产品</a>
|
||
<a href="/docs">文档</a>
|
||
```
|
||
|
||
After:
|
||
```vue
|
||
<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:
|
||
|
||
```vue
|
||
<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**
|
||
|
||
```bash
|
||
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` (add `docs` namespace)
|
||
- Modify: `frontend/src/i18n/locales/en.ts` (add `docs` namespace)
|
||
|
||
**Key naming convention:**
|
||
- `docs.nav.*` — nav link labels (reuse `landing.nav.*` where identical — but keep separate namespace for clarity and to allow divergence)
|
||
- `docs.hero.*` — page title + intro
|
||
- `docs.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 `docs` namespace to `zh.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.vue` with `$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**
|
||
|
||
```bash
|
||
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` (add `auth.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.narrative` keys to `zh.ts`**
|
||
|
||
Extend the existing `auth` namespace (don't replace). Example snippet:
|
||
|
||
```ts
|
||
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:
|
||
|
||
```vue
|
||
<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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```vue
|
||
<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**
|
||
|
||
```bash
|
||
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 `/pricing` route)
|
||
- Modify: `frontend/src/i18n/locales/zh.ts` (add `pricing.*`)
|
||
- Modify: `frontend/src/i18n/locales/en.ts` (add `pricing.*`)
|
||
|
||
**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 → `/register` router-link (no `/binding` page)
|
||
- Tier CTAs → `/register` router-link
|
||
- Final CTA `Docs` link → `/docs`
|
||
|
||
**SOON chip:** before writing the template, the subagent audits the backend for these features (grep at `backend/`):
|
||
1. API Key monthly budget / 402 Payment Required → look for `budget` / `Payment Required`
|
||
2. Zero-log mode → look for `zero_log` / `zeroLog`
|
||
3. Priority scheduling → look for `priority`
|
||
4. RPM limits (60/120/300) → look for rate limiter
|
||
5. 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 `pricing` namespace to `zh.ts`** with structure:
|
||
|
||
```ts
|
||
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 `pricing` namespace in `en.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:
|
||
|
||
```vue
|
||
<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:
|
||
1. Open `docs/design-drafts/v2/Pricing.html` in one tab
|
||
2. Open the target `PricingView.vue` being written
|
||
3. 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
|
||
4. Keep all SVG/HTML structure verbatim
|
||
5. Apply the SOON chip to any unimplemented feature per Step 1 audit results
|
||
6. Remove the $5 bonus line from final CTA
|
||
|
||
- [ ] **Step 5: Add `/pricing` route**
|
||
|
||
Modify `frontend/src/router/index.ts`. Add new route entry (public, no auth guard):
|
||
|
||
```ts
|
||
{
|
||
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 `定价 / Pricing` link to LandingView nav**
|
||
|
||
Modify `LandingView.vue` `.nav-links` block:
|
||
|
||
```vue
|
||
<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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
cd frontend
|
||
pnpm run typecheck
|
||
pnpm run build
|
||
```
|
||
|
||
Expected: both PASS.
|
||
|
||
- [ ] **Step 2: Scan for leftover hard-coded Chinese in portal views**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
cd frontend
|
||
pnpm run preview
|
||
```
|
||
|
||
Open in browser (http://localhost:4173):
|
||
- `/` — Landing: switcher in top-right, click `EN` → 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:
|
||
```bash
|
||
pgrep -f "vite.*preview"
|
||
```
|
||
Expected: no output. If any: `pkill -f "vite.*preview"`.
|
||
|
||
- [ ] **Step 5: Push branch and open PR**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
gh pr merge --squash --delete-branch
|
||
```
|
||
|
||
- [ ] **Step 7: Verify production deploy**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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 added
|
||
- `PuroLocaleSwitcher` reuses `setLocale()` imports; does not duplicate i18n plumbing
|
||
- No changes to admin pages, AppHeader, or existing `LocaleSwitcher.vue` (decoupled)
|