Files
sub2api/docs/superpowers/plans/2026-04-20-portal-i18n-pricing.md
mini 623a7518b2 fix(docs): remove iShare mention (puro 独立运营定位)
- docs.sections.getKey.note 键从 zh/en 删除
- DocsView 对应 <p class="note"> 段删掉
- 全仓再次 grep 确认无其他 ishare/iShare 引用
2026-04-23 13:08:43 +08:00

47 KiB
Raw Permalink Blame History

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

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"

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-links to 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-cta to 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 (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 }:

  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:

<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 (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
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:

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 →

  • 个 keykey

  • 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 /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:
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:

<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):

{
  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:

<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, 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:

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 added
  • PuroLocaleSwitcher reuses setLocale() imports; does not duplicate i18n plumbing
  • No changes to admin pages, AppHeader, or existing LocaleSwitcher.vue (decoupled)