refactor(portal): extract PortalLayout so Nav/Footer persist across routes
Some checks failed
CI / test (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled

将 Landing/Docs/Pricing 的 Nav + Footer 提取到共享 PortalLayout。
Router 改为嵌套结构,路由切换时 router-view 内容变化,Nav 本身
不重挂载,消除切页时的 UI 抖动(真·SPA 行为)。

- new: components/layout/PortalLayout.vue(Nav + router-view + Footer)
- router: /、/docs、/pricing 作为 PortalLayout 的子路由
- i18n: 新增 portal.nav.* 命名空间;删除重复的 docs.nav.* / pricing.nav.* / landing.nav.*
- router: scrollBehavior 支持 hash 锚点跳转(offset 80px 绕开 sticky nav)
- router-link 使用 active-class/exact-active-class prop 替代硬编码 class="active"
This commit is contained in:
mini
2026-04-23 12:52:07 +08:00
parent 779005e1cd
commit e7f3fe5b4d
7 changed files with 174 additions and 168 deletions

View File

@@ -0,0 +1,129 @@
<template>
<div class="puro-page">
<div class="bg-glow"></div>
<div class="grain"></div>
<nav class="nav">
<div class="container nav-inner">
<router-link to="/" class="brand">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 2L21 7V17L12 22L3 17V7L12 2Z" fill="rgba(34, 211, 238, 0.08)"/>
</svg>
<span>PURO AI</span>
</router-link>
<div class="nav-links">
<router-link to="/" active-class="active" exact-active-class="active">{{ $t('portal.nav.products') }}</router-link>
<router-link to="/pricing" active-class="active" exact-active-class="active">{{ $t('portal.nav.pricing') }}</router-link>
<router-link to="/docs" active-class="active" exact-active-class="active">{{ $t('portal.nav.docs') }}</router-link>
</div>
<div class="nav-cta">
<PuroLocaleSwitcher />
<router-link to="/login" class="btn btn-ghost">{{ $t('portal.nav.login') }}</router-link>
<router-link to="/register" class="btn btn-primary">{{ $t('portal.nav.signup') }}</router-link>
</div>
</div>
</nav>
<router-view />
<footer class="puro-footer">
<div class="container footer-grid">
<div class="footer-brand">
<div class="brand">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 2L21 7V17L12 22L3 17V7L12 2Z" fill="rgba(34, 211, 238, 0.08)"/>
</svg>
<span>PURO AI</span>
</div>
<p class="footer-tagline">{{ $t('landing.footer.tagline1') }}<br>{{ $t('landing.footer.tagline2') }}</p>
<p class="footer-meta">© 2026 puro.im · MIT License<br>fork of Wei-Shaw/sub2api</p>
<div class="footer-status"><span class="dot-green"></span>all systems operational</div>
</div>
<div class="footer-col">
<div class="footer-col-title">{{ $t('landing.footer.colProducts') }}</div>
<router-link to="/docs">{{ $t('landing.footer.linkDocs') }}</router-link>
<router-link to="/pricing">{{ $t('portal.nav.pricing') }}</router-link>
<a href="https://git.puro.im/purovps/sub2api/commits/branch/main" target="_blank" rel="noopener noreferrer">{{ $t('landing.footer.linkChangelog') }}</a>
</div>
<div class="footer-col">
<div class="footer-col-title">{{ $t('landing.footer.colAccount') }}</div>
<router-link to="/login">{{ $t('landing.footer.linkLogin') }}</router-link>
<router-link to="/register">{{ $t('landing.footer.linkRegister') }}</router-link>
<router-link to="/dashboard">Dashboard</router-link>
</div>
<div class="footer-col">
<div class="footer-col-title">{{ $t('landing.footer.colContact') }}</div>
<a href="mailto:admin@puro.im">admin@puro.im</a>
<a href="https://git.puro.im" target="_blank" rel="noopener noreferrer">git.puro.im</a>
<a href="https://git.puro.im/purovps/sub2api" target="_blank" rel="noopener noreferrer">GitHub </a>
</div>
</div>
</footer>
</div>
</template>
<script setup lang="ts">
import PuroLocaleSwitcher from '@/components/puro/PuroLocaleSwitcher.vue'
</script>
<style scoped>
.puro-page {
min-height: 100vh;
background: var(--bg-0);
color: var(--text-0);
font-family: var(--font-sans);
position: relative;
overflow-x: hidden;
}
.puro-footer {
border-top: 1px solid var(--border);
padding: 48px 0 32px;
background: rgba(2, 6, 23, 0.4);
position: relative;
z-index: 2;
}
.footer-grid {
display: grid;
grid-template-columns: 1.3fr 1fr 1fr 1fr;
gap: 48px;
}
@media (max-width: 720px) { .footer-grid { grid-template-columns: 1fr 1fr; } }
.footer-brand .brand { margin-bottom: 12px; }
.footer-tagline { color: var(--text-2); font-size: 13px; line-height: 1.6; margin-bottom: 8px; max-width: 280px; }
.footer-meta { color: var(--text-3); font-size: 12px; line-height: 1.7; margin-bottom: 12px; }
.footer-status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-2);
background: rgba(15, 23, 42, 0.6);
border: 1px solid var(--border);
border-radius: 4px;
}
.dot-green {
display: inline-block;
width: 6px; height: 6px;
border-radius: 50%;
background: var(--green, #34d399);
box-shadow: 0 0 6px rgba(52,211,153,0.6);
}
.footer-col-title {
color: var(--text-0);
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 12px;
}
.footer-col a {
display: block;
color: var(--text-2);
font-size: 13px;
padding: 4px 0;
}
.footer-col a:hover { color: var(--cyan); }
</style>

View File

@@ -5630,7 +5630,7 @@ export default {
}, },
}, },
landing: { portal: {
nav: { nav: {
products: 'Products', products: 'Products',
pricing: 'Pricing', pricing: 'Pricing',
@@ -5638,6 +5638,9 @@ export default {
login: 'Sign in', login: 'Sign in',
signup: 'Free trial →', signup: 'Free trial →',
}, },
},
landing: {
hero: { hero: {
badgeNew: 'NEW', badgeNew: 'NEW',
eyebrow: 'Unified access to multiple AI platforms · Zero code change', eyebrow: 'Unified access to multiple AI platforms · Zero code change',
@@ -5714,13 +5717,6 @@ export default {
}, },
pricing: { pricing: {
nav: {
products: 'Products',
pricing: 'Pricing',
docs: 'Docs',
login: 'Sign in',
signup: 'Free trial →',
},
hero: { hero: {
kicker: '// pricing · top up · pay as you go · never expires', kicker: '// pricing · top up · pay as you go · never expires',
previewPill: '// preview · final pricing TBD at launch', previewPill: '// preview · final pricing TBD at launch',
@@ -5917,13 +5913,6 @@ export default {
}, },
docs: { docs: {
nav: {
products: 'Products',
pricing: 'Pricing',
docs: 'Docs',
login: 'Sign in',
signup: 'Free trial →',
},
hero: { hero: {
title: 'Quickstart — PURO AI', title: 'Quickstart — PURO AI',
subtitle: 'Three steps: get a key → set base_url → send a request', subtitle: 'Three steps: get a key → set base_url → send a request',

View File

@@ -5823,7 +5823,7 @@ export default {
}, },
}, },
landing: { portal: {
nav: { nav: {
products: '产品', products: '产品',
pricing: '定价', pricing: '定价',
@@ -5831,6 +5831,9 @@ export default {
login: '登录', login: '登录',
signup: '免费试用 →', signup: '免费试用 →',
}, },
},
landing: {
hero: { hero: {
badgeNew: 'NEW', badgeNew: 'NEW',
eyebrow: '统一接入多个 AI 平台 · 零改动切换', eyebrow: '统一接入多个 AI 平台 · 零改动切换',
@@ -5907,13 +5910,6 @@ export default {
}, },
pricing: { pricing: {
nav: {
products: '产品',
pricing: '定价',
docs: '文档',
login: '登录',
signup: '免费试用 →',
},
hero: { hero: {
kicker: '// pricing · 充多少 · 用多少 · 永不过期', kicker: '// pricing · 充多少 · 用多少 · 永不过期',
previewPill: '// preview · 最终定价以开售为准', previewPill: '// preview · 最终定价以开售为准',
@@ -6110,13 +6106,6 @@ export default {
}, },
docs: { docs: {
nav: {
products: '产品',
pricing: '定价',
docs: '文档',
login: '登录',
signup: '免费试用 →',
},
hero: { hero: {
title: '快速接入 PURO AI', title: '快速接入 PURO AI',
subtitle: '三步走:拿 key → 配 base_url → 发请求', subtitle: '三步走:拿 key → 配 base_url → 发请求',

View File

@@ -120,8 +120,24 @@ const routes: RouteRecordRaw[] = [
title: 'Key Usage', title: 'Key Usage',
} }
}, },
// ==================== Portal Routes (shared PortalLayout) ====================
{ {
path: '/docs', path: '/',
component: () => import('@/components/layout/PortalLayout.vue'),
children: [
{
path: '',
name: 'Landing',
component: () => import('@/views/landing/LandingView.vue'),
meta: {
requiresAuth: false,
title: 'PURO AI — 你的 AI 订阅,已经付过钱了',
redirectIfAuth: '/dashboard'
}
},
{
path: 'docs',
name: 'Docs', name: 'Docs',
component: () => import('@/views/docs/DocsView.vue'), component: () => import('@/views/docs/DocsView.vue'),
meta: { meta: {
@@ -130,22 +146,12 @@ const routes: RouteRecordRaw[] = [
} }
}, },
{ {
path: '/pricing', path: 'pricing',
name: 'pricing', name: 'pricing',
component: () => import('@/views/pricing/PricingView.vue'), component: () => import('@/views/pricing/PricingView.vue'),
meta: { requiresAuth: false, title: 'Pricing · PURO AI' } meta: { requiresAuth: false, title: 'Pricing · PURO AI' }
}, },
]
// ==================== User Routes ====================
{
path: '/',
name: 'Landing',
component: () => import('@/views/landing/LandingView.vue'),
meta: {
requiresAuth: false,
title: 'PURO AI — 你的 AI 订阅,已经付过钱了',
redirectIfAuth: '/dashboard'
}
}, },
{ {
path: '/dashboard', path: '/dashboard',
@@ -521,11 +527,15 @@ const routes: RouteRecordRaw[] = [
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes, routes,
scrollBehavior(_to, _from, savedPosition) { scrollBehavior(to, _from, savedPosition) {
// Scroll to saved position when using browser back/forward // Scroll to saved position when using browser back/forward
if (savedPosition) { if (savedPosition) {
return savedPosition return savedPosition
} }
// Scroll to hash target (anchor link) — offset by sticky nav height
if (to.hash) {
return { el: to.hash, behavior: 'smooth', top: 80 }
}
// Scroll to top for new routes // Scroll to top for new routes
return { top: 0 } return { top: 0 }
} }

View File

@@ -1,28 +1,5 @@
<template> <template>
<div class="puro-page"> <div>
<div class="bg-glow soft"></div>
<nav class="nav">
<div class="container nav-inner">
<router-link to="/" class="brand">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 2L21 7V17L12 22L3 17V7L12 2Z" fill="rgba(34, 211, 238, 0.08)"/>
</svg>
<span>PURO AI</span>
</router-link>
<div class="nav-links">
<router-link to="/">{{ $t('docs.nav.products') }}</router-link>
<router-link to="/pricing">{{ $t('docs.nav.pricing') }}</router-link>
<router-link to="/docs" class="active">{{ $t('docs.nav.docs') }}</router-link>
</div>
<div class="nav-cta">
<PuroLocaleSwitcher />
<router-link to="/login" class="btn btn-ghost">{{ $t('docs.nav.login') }}</router-link>
<router-link to="/register" class="btn btn-primary">{{ $t('docs.nav.signup') }}</router-link>
</div>
</div>
</nav>
<section class="docs-hero container"> <section class="docs-hero container">
<h1>{{ $t('docs.hero.title') }}</h1> <h1>{{ $t('docs.hero.title') }}</h1>
<p class="subtitle">{{ $t('docs.hero.subtitle') }}</p> <p class="subtitle">{{ $t('docs.hero.subtitle') }}</p>
@@ -256,7 +233,6 @@ requires_openai_auth = <span class="kw">true</span></code></pre>
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import PuroLocaleSwitcher from '@/components/puro/PuroLocaleSwitcher.vue'
const { t } = useI18n() const { t } = useI18n()

View File

@@ -1,30 +1,5 @@
<template> <template>
<div class="puro-page"> <div>
<div class="bg-glow"></div>
<div class="grain"></div>
<!-- NAV -->
<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">
<a href="#features">{{ $t('landing.nav.products') }}</a>
<router-link to="/pricing">{{ $t('landing.nav.pricing') }}</router-link>
<router-link to="/docs">{{ $t('landing.nav.docs') }}</router-link>
</div>
<div class="nav-cta">
<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 --> <!-- HERO -->
<section class="hero container"> <section class="hero container">
<div class="hero-eyebrow"> <div class="hero-eyebrow">
@@ -304,45 +279,10 @@ requires_openai_auth = <span class="kw">true</span></code></pre>
</div> </div>
</section> </section>
<!-- Footer -->
<footer class="puro-footer">
<div class="container footer-grid">
<div class="footer-brand">
<div class="brand">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 2L21 7V17L12 22L3 17V7L12 2Z" fill="rgba(34, 211, 238, 0.08)"/>
</svg>
<span>PURO AI</span>
</div>
<p class="footer-tagline">{{ $t('landing.footer.tagline1') }}<br>{{ $t('landing.footer.tagline2') }}</p>
<p class="footer-meta">© 2026 puro.im · MIT License<br>fork of Wei-Shaw/sub2api</p>
<div class="footer-status"><span class="dot-green"></span>all systems operational</div>
</div>
<div class="footer-col">
<div class="footer-col-title">{{ $t('landing.footer.colProducts') }}</div>
<a href="/docs">{{ $t('landing.footer.linkDocs') }}</a>
<a href="#features">{{ $t('landing.footer.linkFeatures') }}</a>
<a href="https://git.puro.im/purovps/sub2api/commits/branch/main" target="_blank" rel="noopener noreferrer">{{ $t('landing.footer.linkChangelog') }}</a>
</div>
<div class="footer-col">
<div class="footer-col-title">{{ $t('landing.footer.colAccount') }}</div>
<router-link to="/login">{{ $t('landing.footer.linkLogin') }}</router-link>
<router-link to="/register">{{ $t('landing.footer.linkRegister') }}</router-link>
<a href="/dashboard">Dashboard</a>
</div>
<div class="footer-col">
<div class="footer-col-title">{{ $t('landing.footer.colContact') }}</div>
<a href="mailto:admin@puro.im">admin@puro.im</a>
<a href="https://git.puro.im" target="_blank" rel="noopener noreferrer">git.puro.im</a>
<a href="https://git.puro.im/purovps/sub2api" target="_blank" rel="noopener noreferrer">GitHub </a>
</div>
</div>
</footer>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import PuroLocaleSwitcher from '@/components/puro/PuroLocaleSwitcher.vue'
</script> </script>
<style scoped> <style scoped>

View File

@@ -1,31 +1,5 @@
<template> <template>
<div class="puro-page"> <div>
<div class="bg-glow"></div>
<div class="grain"></div>
<!-- NAV -->
<nav class="nav">
<div class="container nav-inner">
<router-link to="/" class="brand">
<svg class="hex" viewBox="0 0 24 24" fill="none">
<path d="M12 2L21 7V17L12 22L3 17V7L12 2Z" stroke="currentColor" stroke-width="1.8" fill="rgba(34, 211, 238, 0.08)"/>
<path d="M12 7L17 9.5V14.5L12 17L7 14.5V9.5L12 7Z" fill="currentColor"/>
</svg>
PURO AI
</router-link>
<div class="nav-links">
<router-link to="/">{{ $t('pricing.nav.products') }}</router-link>
<router-link to="/pricing" class="active">{{ $t('pricing.nav.pricing') }}</router-link>
<router-link to="/docs">{{ $t('pricing.nav.docs') }}</router-link>
</div>
<div class="nav-cta">
<PuroLocaleSwitcher />
<router-link to="/login" class="btn btn-ghost">{{ $t('pricing.nav.login') }}</router-link>
<router-link to="/register" class="btn btn-primary">{{ $t('pricing.nav.signup') }}</router-link>
</div>
</div>
</nav>
<!-- HERO --> <!-- HERO -->
<section class="hero"> <section class="hero">
<div class="section-kicker" style="margin-bottom:14px;">{{ $t('pricing.hero.kicker') }}</div> <div class="section-kicker" style="margin-bottom:14px;">{{ $t('pricing.hero.kicker') }}</div>
@@ -362,7 +336,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import PuroLocaleSwitcher from '@/components/puro/PuroLocaleSwitcher.vue'
import PricingCalculator from '@/components/puro/PricingCalculator.vue' import PricingCalculator from '@/components/puro/PricingCalculator.vue'
const customAmt = ref(50) const customAmt = ref(50)