Commit 3909a33b authored by 陈曦's avatar 陈曦
Browse files

1.去除自动migration 2.前端home、login改变界面风格 3.调试pay

parent ef5c8e68
......@@ -11,8 +11,6 @@ import (
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/migrations"
"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
_ "github.com/lib/pq" // PostgreSQL 驱动,通过副作用导入注册驱动
......@@ -57,17 +55,19 @@ func InitEnt(cfg *config.Config) (*ent.Client, *sql.DB, error) {
// 确保数据库 schema 已准备就绪。
// SQL 迁移文件是 schema 的权威来源(source of truth)。
// 这种方式比 Ent 的自动迁移更可控,支持复杂的迁移场景。
migrationCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
if err := applyMigrationsFS(migrationCtx, drv.DB(), migrations.FS); err != nil {
_ = drv.Close() // 迁移失败时关闭驱动,避免资源泄露
return nil, nil, err
}
// migrationCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
// defer cancel()
// if err := applyMigrationsFS(migrationCtx, drv.DB(), migrations.FS); err != nil {
// _ = drv.Close() // 迁移失败时关闭驱动,避免资源泄露
// return nil, nil, err
// }
// 创建 Ent 客户端,绑定到已配置的数据库驱动。
client := ent.NewClient(ent.Driver(drv))
// 启动阶段:从配置或数据库中确保系统密钥可用。
migrationCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
if err := ensureBootstrapSecrets(migrationCtx, client, cfg); err != nil {
_ = client.Close()
return nil, nil, err
......
......@@ -14,7 +14,6 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/repository"
"github.com/Wei-Shaw/sub2api/internal/service"
_ "github.com/lib/pq"
......@@ -345,9 +344,10 @@ func initializeDatabase(cfg *SetupConfig) error {
}
}()
migrationCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
return repository.ApplyMigrations(migrationCtx, db)
// migrationCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
// defer cancel()
// return repository.ApplyMigrations(migrationCtx, db)
return nil
}
func createAdminUser(cfg *SetupConfig) (bool, string, error) {
......
<template>
<div class="relative flex min-h-screen items-center justify-center overflow-hidden p-4">
<!-- Background -->
<div
class="absolute inset-0 bg-gradient-to-br from-gray-50 via-primary-50/30 to-gray-100 dark:from-dark-950 dark:via-dark-900 dark:to-dark-950"
></div>
<div class="auth-root relative flex min-h-screen items-center justify-center overflow-hidden p-4">
<!-- Decorative Elements -->
<!-- Background -->
<div class="pointer-events-none absolute inset-0 overflow-hidden">
<!-- Gradient Orbs -->
<div
class="absolute -right-40 -top-40 h-80 w-80 rounded-full bg-primary-400/20 blur-3xl"
></div>
<div
class="absolute -bottom-40 -left-40 h-80 w-80 rounded-full bg-primary-500/15 blur-3xl"
></div>
<div
class="absolute left-1/2 top-1/2 h-96 w-96 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-300/10 blur-3xl"
></div>
<!-- Grid Pattern -->
<div
class="absolute inset-0 bg-[linear-gradient(rgba(20,184,166,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(20,184,166,0.03)_1px,transparent_1px)] bg-[size:64px_64px]"
></div>
<div class="auth-orb auth-orb-tr"></div>
<div class="auth-orb auth-orb-bl"></div>
<div class="auth-grid"></div>
<div class="auth-scan"></div>
</div>
<!-- Content Container -->
<!-- Content -->
<div class="relative z-10 w-full max-w-md">
<!-- Logo/Brand -->
<!-- Brand -->
<div class="mb-8 text-center">
<!-- Custom Logo or Default Logo -->
<template v-if="settingsLoaded">
<div
class="mb-4 inline-flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl shadow-lg shadow-primary-500/30"
>
<!-- Logo -->
<div class="auth-logo-wrap mb-5 inline-flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl">
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
</div>
<h1 class="text-gradient mb-2 text-3xl font-bold">
<!-- Site name -->
<h1 class="auth-site-name mb-2 text-3xl font-black tracking-tight">
{{ siteName }}
</h1>
<p class="text-sm text-gray-500 dark:text-dark-400">
{{ siteSubtitle }}
<!-- Subtitle -->
<p class="auth-subtitle font-mono text-xs uppercase tracking-widest">
// {{ siteSubtitle }}
</p>
</template>
</div>
<!-- Card Container -->
<div class="card-glass rounded-2xl p-8 shadow-glass">
<!-- Card -->
<div class="auth-card rounded-2xl p-8">
<slot />
</div>
<!-- Footer Links -->
<!-- Footer links -->
<div class="mt-6 text-center text-sm">
<slot name="footer" />
</div>
<!-- Copyright -->
<div class="mt-8 text-center text-xs text-gray-400 dark:text-dark-500">
<div class="mt-8 text-center font-mono text-xs text-slate-600">
&copy; {{ currentYear }} {{ siteName }}. All rights reserved.
</div>
</div>
......@@ -69,9 +57,9 @@ import { sanitizeUrl } from '@/utils/url'
const appStore = useAppStore()
const siteName = computed(() => appStore.siteName || 'Sub2API')
const siteName = computed(() => appStore.siteName || 'TrafficAPI')
const siteLogo = computed(() => sanitizeUrl(appStore.siteLogo || '', { allowRelative: true, allowDataUrl: true }))
const siteSubtitle = computed(() => appStore.cachedPublicSettings?.site_subtitle || 'Subscription to API Conversion Platform')
const siteSubtitle = computed(() => appStore.cachedPublicSettings?.site_subtitle || 'Intelligent AI Traffic Routing & Management')
const settingsLoaded = computed(() => appStore.publicSettingsLoaded)
const currentYear = computed(() => new Date().getFullYear())
......@@ -82,7 +70,108 @@ onMounted(() => {
</script>
<style scoped>
.text-gradient {
@apply bg-gradient-to-r from-primary-600 to-primary-500 bg-clip-text text-transparent;
/* ── Root ────────────────────────────────── */
.auth-root {
background: #182a40;
color: white;
}
/* ── Background layers ───────────────────── */
.auth-orb {
position: absolute;
border-radius: 50%;
filter: blur(90px);
pointer-events: none;
}
.auth-orb-tr {
top: -10%;
right: -10%;
width: 420px;
height: 420px;
background: radial-gradient(ellipse, rgba(20,184,166,0.18) 0%, transparent 70%);
}
.auth-orb-bl {
bottom: -10%;
left: -10%;
width: 380px;
height: 380px;
background: radial-gradient(ellipse, rgba(6,182,212,0.13) 0%, transparent 70%);
}
.auth-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(20,184,166,0.07) 1px, transparent 1px),
linear-gradient(90deg, rgba(20,184,166,0.07) 1px, transparent 1px);
background-size: 48px 48px;
}
.auth-scan {
position: absolute;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent 0%, rgba(20,184,166,0.45) 30%, rgba(20,184,166,0.7) 50%, rgba(20,184,166,0.45) 70%, transparent 100%);
animation: auth-scan 12s linear infinite;
box-shadow: 0 0 8px rgba(20,184,166,0.35);
}
@keyframes auth-scan {
0% { top: 0%; opacity: 0; }
4% { opacity: 1; }
96% { opacity: 0.4; }
100% { top: 100%; opacity: 0; }
}
/* ── Logo ────────────────────────────────── */
.auth-logo-wrap {
box-shadow:
0 0 0 1px rgba(20,184,166,0.3),
0 0 20px rgba(20,184,166,0.18),
0 8px 24px rgba(0,0,0,0.4);
}
/* ── Brand text ──────────────────────────── */
.auth-site-name {
background: linear-gradient(135deg, #ffffff 0%, #7dd3c8 45%, #14b8a6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.auth-subtitle {
color: rgba(20,184,166,0.5);
}
/* ── Card ────────────────────────────────── */
.auth-card {
background: rgba(26,46,72,0.72);
border: 1px solid rgba(255,255,255,0.09);
backdrop-filter: blur(18px);
box-shadow:
0 0 0 1px rgba(20,184,166,0.08),
0 24px 60px rgba(0,0,0,0.45),
0 0 50px rgba(20,184,166,0.05);
}
/* ── Slot overrides: make child inputs/links readable ── */
:deep(.input-label) {
color: #94a3b8;
}
:deep(.input) {
background: rgba(15,28,48,0.7);
border-color: rgba(255,255,255,0.1);
color: white;
}
:deep(.input:focus) {
border-color: rgba(20,184,166,0.5);
box-shadow: 0 0 0 3px rgba(20,184,166,0.12);
}
:deep(.input::placeholder) {
color: #475569;
}
:deep(h2) {
color: white !important;
}
:deep(.text-gray-500),
:deep(.dark\:text-dark-400) {
color: #64748b !important;
}
</style>
......@@ -10,89 +10,89 @@ export default {
login: 'Login',
getStarted: 'Get Started',
goToDashboard: 'Go to Dashboard',
// User-focused value proposition
heroSubtitle: 'One Key, All AI Models',
heroDescription: 'No need to manage multiple subscriptions. Access Claude, GPT, Gemini and more with a single API key',
// TrafficAPI value proposition
heroSubtitle: 'Route Smarter. Scale Faster.',
heroDescription: 'TrafficAPI is the intelligent AI API gateway that routes your requests across Claude, GPT, Gemini and more with automatic load balancing, zero downtime, and real-time traffic analytics.',
tags: {
subscriptionToApi: 'Subscription to API',
stickySession: 'Session Persistence',
realtimeBilling: 'Pay As You Go'
subscriptionToApi: 'Intelligent Routing',
stickySession: 'Auto Load Balancing',
realtimeBilling: 'Real-time Analytics'
},
// Pain points section
painPoints: {
title: 'Sound Familiar?',
items: {
expensive: {
title: 'High Subscription Costs',
desc: 'Paying for multiple AI subscriptions that add up every month'
title: 'Fragmented AI Costs',
desc: 'Paying for multiple AI subscriptions that add up every month with no unified control'
},
complex: {
title: 'Account Chaos',
desc: 'Managing scattered accounts and API keys across different platforms'
title: 'Multi-Provider Chaos',
desc: 'Juggling different SDKs, credentials, and endpoints across Claude, GPT, Gemini and more'
},
unstable: {
title: 'Service Interruptions',
desc: 'Single accounts hitting rate limits and disrupting your workflow'
title: 'Rate Limit Downtime',
desc: 'A single upstream account hitting limits can bring your entire application offline'
},
noControl: {
title: 'No Usage Control',
desc: "Can't track where your money goes or limit team member usage"
title: 'Zero Traffic Visibility',
desc: "No insight into request volume, latency, or spend across providers and team members"
}
}
},
// Solutions section
solutions: {
title: 'We Solve These Problems',
subtitle: 'Three simple steps to stress-free AI access'
title: 'TrafficAPI Solves This',
subtitle: 'One gateway to route, balance, and monitor all your AI traffic'
},
features: {
unifiedGateway: 'One-Click Access',
unifiedGatewayDesc: 'Get a single API key to call all connected AI models. No separate applications needed.',
multiAccount: 'Always Reliable',
multiAccountDesc: 'Smart routing across multiple upstream accounts with automatic failover. Say goodbye to errors.',
balanceQuota: 'Pay What You Use',
balanceQuotaDesc: 'Usage-based billing with quota limits. Full visibility into team consumption.'
unifiedGateway: 'Unified API Endpoint',
unifiedGatewayDesc: 'One endpoint, one key — access every connected AI provider. No SDK changes, no credential juggling, zero migration friction.',
multiAccount: 'Smart Load Balancing',
multiAccountDesc: 'Traffic is distributed intelligently across multiple upstream accounts. Automatic failover ensures zero downtime even when upstream limits are hit.',
balanceQuota: 'Traffic Analytics',
balanceQuotaDesc: 'Monitor request volume, response latency, and per-model costs in real time. Set spending quotas per user or team with granular control.'
},
// Comparison section
comparison: {
title: 'Why Choose Us?',
title: 'Why TrafficAPI?',
headers: {
feature: 'Comparison',
official: 'Official Subscriptions',
us: 'Our Platform'
feature: 'Feature',
official: 'Direct Provider',
us: 'TrafficAPI'
},
items: {
pricing: {
feature: 'Pricing',
official: 'Fixed monthly fee, pay even if unused',
us: 'Pay only for what you use'
official: 'Fixed monthly fee per provider',
us: 'Pay per request, unified billing'
},
models: {
feature: 'Model Selection',
feature: 'Model Access',
official: 'Single provider only',
us: 'Switch between models freely'
us: 'Any model, one endpoint'
},
management: {
feature: 'Account Management',
official: 'Manage each service separately',
us: 'Unified key, one dashboard'
official: 'Each service managed separately',
us: 'Unified dashboard, one API key'
},
stability: {
feature: 'Stability',
official: 'Single account rate limits',
feature: 'Reliability',
official: 'Single account, no failover',
us: 'Multi-account pool, auto-failover'
},
control: {
feature: 'Usage Control',
feature: 'Traffic Control',
official: 'Not available',
us: 'Quotas & detailed analytics'
us: 'Quotas, rate limits & analytics'
}
}
},
providers: {
title: 'Supported AI Models',
description: 'One API, Multiple Choices',
supported: 'Supported',
title: 'Supported AI Providers',
description: 'One Gateway, Every Model',
supported: 'Live',
soon: 'Soon',
claude: 'Claude',
gemini: 'Gemini',
......@@ -101,9 +101,9 @@ export default {
},
// CTA section
cta: {
title: 'Ready to Get Started?',
description: 'Sign up now and get free trial credits to experience seamless AI access',
button: 'Sign Up Free'
title: 'Start Routing Your AI Traffic',
description: 'Join teams who use TrafficAPI to unify their AI access, eliminate downtime, and gain full visibility into usage.',
button: 'Get Started Free'
},
footer: {
allRightsReserved: 'All rights reserved.'
......@@ -182,8 +182,8 @@ export default {
// Setup Wizard
setup: {
title: 'Sub2API Setup',
description: 'Configure your Sub2API instance',
title: 'TrafficAPI Setup',
description: 'Configure your TrafficAPI instance',
database: {
title: 'Database Configuration',
description: 'Connect to your PostgreSQL database',
......@@ -357,8 +357,8 @@ export default {
// Auth
auth: {
welcomeBack: 'Welcome Back',
signInToAccount: 'Sign in to your account to continue',
welcomeBack: 'Welcome Back to TrafficAPI',
signInToAccount: 'Sign in to manage your AI traffic, keys, and analytics',
signIn: 'Sign In',
signingIn: 'Signing in...',
createAccount: 'Create Account',
......@@ -1085,7 +1085,7 @@ export default {
step1: {
title: 'Create an R2 Bucket',
line1: 'Log in to the Cloudflare Dashboard (dash.cloudflare.com), select "R2 Object Storage" from the sidebar',
line2: 'Click "Create bucket", enter a name (e.g. sub2api-backups), choose a region',
line2: 'Click "Create bucket", enter a name (e.g. trafficapi-backups), choose a region',
line3: 'Click create to finish'
},
step2: {
......@@ -1934,7 +1934,7 @@ export default {
antigravityOauth: 'Antigravity OAuth',
antigravityApikey: 'Connect via Base URL + API Key',
soraApiKey: 'API Key / Upstream',
soraApiKeyHint: 'Connect to another Sub2API or compatible API',
soraApiKeyHint: 'Connect to another TrafficAPI or compatible API',
soraBaseUrlRequired: 'Sora API Key account requires a Base URL',
soraBaseUrlInvalidScheme: 'Base URL must start with http:// or https://',
upstream: 'Upstream',
......@@ -2235,7 +2235,7 @@ export default {
poolMode: 'Pool Mode',
poolModeHint: 'Enable when upstream is an account pool; errors won\'t mark local account status',
poolModeInfo:
'When enabled, upstream 429/403/401 errors will auto-retry without marking the account as rate-limited or errored. Suitable for upstream pointing to another sub2api instance.',
'When enabled, upstream 429/403/401 errors will auto-retry without marking the account as rate-limited or errored. Suitable for upstream pointing to another trafficapi instance.',
poolModeRetryCount: 'Same-Account Retries',
poolModeRetryCountHint:
'Only applies in pool mode. Use 0 to disable in-place retry. Default {default}, maximum {max}.',
......@@ -2743,7 +2743,7 @@ export default {
geminiImageTestMode: 'Mode: Gemini image generation test',
geminiImagePreview: 'Generated images:',
geminiImageReceived: 'Received test image #{count}',
soraUpstreamBaseUrlHint: 'Upstream Sora service URL (another Sub2API instance or compatible API)',
soraUpstreamBaseUrlHint: 'Upstream Sora service URL (another TrafficAPI instance or compatible API)',
soraTestHint: 'Sora test runs connectivity and capability checks (/backend/me, subscription, Sora2 invite and remaining quota).',
soraTestTarget: 'Target: Sora account capability',
soraTestMode: 'Mode: Connectivity + Capability checks',
......@@ -4123,7 +4123,7 @@ export default {
secretKeyConfiguredHint: 'Secret key configured. Leave empty to keep the current value.' },
linuxdo: {
title: 'LinuxDo Connect Login',
description: 'Configure LinuxDo Connect OAuth for Sub2API end-user login',
description: 'Configure LinuxDo Connect OAuth for TrafficAPI end-user login',
enable: 'Enable LinuxDo Login',
enableHint: 'Show LinuxDo login on the login/register pages',
clientId: 'Client ID',
......@@ -4190,7 +4190,7 @@ export default {
backendModeDescription:
'Disables user registration, public site, and self-service features. Only admin can log in and manage the platform.',
siteName: 'Site Name',
siteNamePlaceholder: 'Sub2API',
siteNamePlaceholder: 'TrafficAPI',
siteNameHint: 'Displayed in emails and page titles',
siteSubtitle: 'Site Subtitle',
siteSubtitlePlaceholder: 'Subscription to API Conversion Platform',
......@@ -4290,7 +4290,7 @@ export default {
fromEmail: 'From Email',
fromEmailPlaceholder: "noreply{'@'}example.com",
fromName: 'From Name',
fromNamePlaceholder: 'Sub2API',
fromNamePlaceholder: 'TrafficAPI',
useTls: 'Use TLS',
useTlsHint: 'Enable TLS encryption for SMTP connection'
},
......@@ -4721,14 +4721,14 @@ export default {
// Admin tour steps
admin: {
welcome: {
title: '👋 Welcome to Sub2API',
description: '<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">Sub2API is a powerful AI service gateway platform that helps you easily manage and distribute AI services.</p><p style="margin-bottom: 12px;"><b>🎯 Core Features:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>📦 <b>Group Management</b> - Create service tiers (VIP, Free Trial, etc.)</li><li>🔗 <b>Account Pool</b> - Connect multiple upstream AI service accounts</li><li>🔑 <b>Key Distribution</b> - Generate independent API Keys for users</li><li>💰 <b>Billing Control</b> - Flexible rate and quota management</li></ul><p style="color: #10b981; font-weight: 600;">Let\'s complete the initial setup in 3 minutes →</p></div>',
title: '👋 Welcome to TrafficAPI',
description: '<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">TrafficAPI is your intelligent AI traffic gateway — route, balance, and monitor requests across every major AI provider from a single control plane.</p><p style="margin-bottom: 12px;"><b>🎯 Core Capabilities:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>📦 <b>Traffic Groups</b> - Define service tiers with routing policies (VIP, Free Trial, etc.)</li><li>🔗 <b>Account Pool</b> - Connect multiple upstream accounts for load balancing & failover</li><li>🔑 <b>API Key Distribution</b> - Issue independent keys per user with quota controls</li><li>📊 <b>Traffic Analytics</b> - Real-time visibility into requests, latency, and costs</li></ul><p style="color: #10b981; font-weight: 600;">Let\'s complete the initial setup in 3 minutes →</p></div>',
nextBtn: 'Start Setup 🚀',
prevBtn: 'Skip'
},
groupManage: {
title: '📦 Step 1: Group Management',
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>What is a Group?</b></p><p style="margin-bottom: 12px;">Groups are the core concept of Sub2API, like a "service package":</p><ul style="margin-left: 20px; margin-bottom: 12px; font-size: 13px;"><li>🎯 Each group can contain multiple upstream accounts</li><li>💰 Each group has independent billing multiplier</li><li>👥 Can be set as public or exclusive</li></ul><p style="margin-top: 12px; padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Example:</b> You can create "VIP Premium" (high rate) and "Free Trial" (low rate) groups</p><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 Click "Group Management" on the left sidebar</p></div>'
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>What is a Group?</b></p><p style="margin-bottom: 12px;">Groups are the core concept of TrafficAPI, like a "service package":</p><ul style="margin-left: 20px; margin-bottom: 12px; font-size: 13px;"><li>🎯 Each group can contain multiple upstream accounts</li><li>💰 Each group has independent billing multiplier</li><li>👥 Can be set as public or exclusive</li></ul><p style="margin-top: 12px; padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Example:</b> You can create "VIP Premium" (high rate) and "Free Trial" (low rate) groups</p><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 Click "Group Management" on the left sidebar</p></div>'
},
createGroup: {
title: '➕ Create New Group',
......@@ -4821,8 +4821,8 @@ export default {
// User tour steps
user: {
welcome: {
title: '👋 Welcome to Sub2API',
description: '<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">Hello! Welcome to the Sub2API AI service platform.</p><p style="margin-bottom: 12px;"><b>🎯 Quick Start:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>🔑 Create API Key</li><li>📋 Copy key to your application</li><li>🚀 Start using AI services</li></ul><p style="color: #10b981; font-weight: 600;">Just 1 minute, let\'s get started →</p></div>',
title: '👋 Welcome to TrafficAPI',
description: '<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">Hello! Welcome to TrafficAPI — the intelligent gateway for routing your AI API traffic across Claude, GPT, Gemini and more.</p><p style="margin-bottom: 12px;"><b>🎯 Quick Start:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>🔑 Create your API Key</li><li>📋 Use it with any OpenAI-compatible client</li><li>📊 Monitor traffic & usage in real time</li></ul><p style="color: #10b981; font-weight: 600;">Just 1 minute, let\'s get started →</p></div>',
nextBtn: 'Start 🚀',
prevBtn: 'Skip'
},
......
......@@ -10,90 +10,90 @@ export default {
login: '登录',
getStarted: '立即开始',
goToDashboard: '进入控制台',
// 新增:面向用户的价值主张
heroSubtitle: '一个密钥,畅用多个 AI 模型',
heroDescription: '无需管理多个订阅账号,一站式接入 Claude、GPT、Gemini 等主流 AI 服务',
// TrafficAPI 价值主张
heroSubtitle: '智能路由,极速扩展。',
heroDescription: 'TrafficAPI 是面向企业的智能 AI API 网关,自动将请求路由至 Claude、GPT、Gemini 等多个服务商,具备负载均衡、零停机故障转移与实时流量分析能力。',
tags: {
subscriptionToApi: '订阅转 API',
stickySession: '会话保持',
realtimeBilling: '按量计费'
subscriptionToApi: '智能请求路由',
stickySession: '自动负载均衡',
realtimeBilling: '实时流量分析'
},
// 用户痛点区块
painPoints: {
title: '你是否也遇到这些问题?',
items: {
expensive: {
title: '订阅费用高',
desc: '个 AI 服务都要单独订阅,每月支出越来越多'
title: 'AI 成本分散难控',
desc: '个 AI 服务各自订阅、各自付费,没有统一管控,月度支出节节攀升'
},
complex: {
title: '账号难管理',
desc: '不同平台的账号、密钥分散各处,管理起来很麻烦'
title: '服务商集成混乱',
desc: '不同平台的 SDK、凭证和接口地址各不相同,集成和维护成本极高'
},
unstable: {
title: '服务不稳定',
desc: '单一账号容易触发限制,影响正常使用'
title: '限流导致服务中断',
desc: '单一上游账号触发限流,便可导致整个应用离线,严重影响业务可用性'
},
noControl: {
title: '用量无法控制',
desc: '不知道钱花在哪了,也无法限制团队成员的使用'
title: '流量完全不透明',
desc: '无从了解各服务商的请求量、延迟分布与实际费用,团队用量更是无法管控'
}
}
},
// 解决方案区块
solutions: {
title: '我们帮你解决',
subtitle: '简单三步,开始省心使用 AI'
title: 'TrafficAPI 为你解决',
subtitle: '统一网关,路由、均衡、监控 AI 流量全掌握'
},
features: {
unifiedGateway: '一键接入',
unifiedGatewayDesc: '获取一个 API 密钥,即可调用所有已接入的 AI 模型,无需分别申请',
multiAccount: '稳定可靠',
multiAccountDesc: '智能调度多个上游账号,自动切换和负载均衡,告别频繁报错',
balanceQuota: '用多少付多少',
balanceQuotaDesc: '按实际使用量计费,支持设置配额上限,团队用量一目了然'
unifiedGateway: '统一 API 接入',
unifiedGatewayDesc: '一个接入地址,一个 API Key,即可调用所有已接入的 AI 服务商。无需更改 SDK,无需管理多套凭证,迁移零成本',
multiAccount: '智能负载均衡',
multiAccountDesc: '流量自动分发至多个上游账号,智能调度与自动故障转移确保服务 7×24 小时在线,彻底告别限流宕机',
balanceQuota: '流量分析与管控',
balanceQuotaDesc: '实时监控请求量、响应延迟与各模型费用,按用户或团队设置消费配额,AI 流量全链路可视化、可管控'
},
// 优势对比
comparison: {
title: '为什么选择我们',
title: '为什么选择 TrafficAPI',
headers: {
feature: '对比',
official: '官方订阅',
us: '本平台'
feature: '对比维度',
official: '直连服务商',
us: 'TrafficAPI'
},
items: {
pricing: {
feature: '费方式',
official: '固定月费,用不完也付',
us: '量付费,用多少付多少'
feature: '费方式',
official: '各服务商固定月费',
us: '请求计费,统一结算'
},
models: {
feature: '模型选择',
feature: '模型覆盖',
official: '单一服务商',
us: '多模型随意切换'
us: '任意模型,统一入口'
},
management: {
feature: '账号管理',
official: '每个服务单独管理',
us: '统一密钥,一站管理'
official: '各服务分散管理',
us: '统一控制台,一个 Key'
},
stability: {
feature: '服务稳定',
official: '单账号易触发限制',
us: '多账号池,自动切换'
feature: '服务可靠',
official: '单账号,无故障转移',
us: '多账号池,自动故障转移'
},
control: {
feature: '用量控制',
official: '无法限制',
us: '可设配额、查明细'
feature: '流量管控',
official: '不支持',
us: '配额限制 + 详细分析'
}
}
},
providers: {
title: '已支持的 AI 模型',
description: '一个 API,多种选择',
supported: '支持',
soon: '即将推出',
title: '已支持的 AI 服务商',
description: '一个网关,接入所有模型',
supported: '上线',
soon: '即将支持',
claude: 'Claude',
gemini: 'Gemini',
antigravity: 'Antigravity',
......@@ -101,9 +101,9 @@ export default {
},
// CTA 区块
cta: {
title: '准备好开始了吗?',
description: '注册即可获得免费试用额度,体验一站式 AI 服务',
button: '免费注册'
title: '开始路由你的 AI 流量',
description: '加入正在使用 TrafficAPI 的团队,统一 AI 接入、消除停机风险、获得完整的流量可见性。',
button: '免费开始使用'
},
footer: {
allRightsReserved: '保留所有权利。'
......@@ -182,8 +182,8 @@ export default {
// Setup Wizard
setup: {
title: 'Sub2API 安装向导',
description: '配置您的 Sub2API 实例',
title: 'TrafficAPI 安装向导',
description: '配置您的 TrafficAPI 实例',
database: {
title: '数据库配置',
description: '连接到您的 PostgreSQL 数据库',
......@@ -357,8 +357,8 @@ export default {
// Auth
auth: {
welcomeBack: '欢迎回',
signInToAccount: '登录您的账户以继续',
welcomeBack: '欢迎回到 TrafficAPI',
signInToAccount: '登录以管理您的 AI 流量、密钥与数据分析',
signIn: '登录',
signingIn: '登录中...',
createAccount: '创建账户',
......@@ -1107,7 +1107,7 @@ export default {
step1: {
title: '创建 R2 存储桶',
line1: '登录 Cloudflare Dashboard (dash.cloudflare.com),左侧菜单选择「R2 对象存储」',
line2: '点击「创建存储桶」,输入名称(如 sub2api-backups),选择区域',
line2: '点击「创建存储桶」,输入名称(如 trafficapi-backups),选择区域',
line3: '点击创建完成'
},
step2: {
......@@ -2115,7 +2115,7 @@ export default {
antigravityOauth: 'Antigravity OAuth',
antigravityApikey: '通过 Base URL + API Key 连接',
soraApiKey: 'API Key / 上游透传',
soraApiKeyHint: '连接另一个 Sub2API 或兼容 API',
soraApiKeyHint: '连接另一个 TrafficAPI 或兼容 API',
soraBaseUrlRequired: 'Sora apikey 账号必须设置上游地址(Base URL)',
soraBaseUrlInvalidScheme: 'Base URL 必须以 http:// 或 https:// 开头',
upstream: '对接上游',
......@@ -2382,7 +2382,7 @@ export default {
poolMode: '池模式',
poolModeHint: '上游为账号池时启用,错误不标记本地账号状态',
poolModeInfo:
'启用后,上游 429/403/401 错误将自动重试而不标记账号限流或错误,适用于上游指向另一个 sub2api 实例的场景。',
'启用后,上游 429/403/401 错误将自动重试而不标记账号限流或错误,适用于上游指向另一个 trafficapi 实例的场景。',
poolModeRetryCount: '同账号重试次数',
poolModeRetryCountHint: '仅在池模式下生效。0 表示不原地重试;默认 {default},最大 {max}。',
customErrorCodes: '自定义错误码',
......@@ -2874,7 +2874,7 @@ export default {
geminiImageTestMode: '模式:Gemini 生图测试',
geminiImagePreview: '生成结果:',
geminiImageReceived: '已收到第 {count} 张测试图片',
soraUpstreamBaseUrlHint: '上游 Sora 服务地址(另一个 Sub2API 实例或兼容 API)',
soraUpstreamBaseUrlHint: '上游 Sora 服务地址(另一个 TrafficAPI 实例或兼容 API)',
soraTestHint: 'Sora 测试将执行连通性与能力检测(/backend/me、订阅信息、Sora2 邀请码与剩余额度)。',
soraTestTarget: '检测目标:Sora 账号能力',
soraTestMode: '模式:连通性 + 能力探测',
......@@ -4290,7 +4290,7 @@ export default {
},
linuxdo: {
title: 'LinuxDo Connect 登录',
description: '配置 LinuxDo Connect OAuth,用于 Sub2API 用户登录',
description: '配置 LinuxDo Connect OAuth,用于 TrafficAPI 用户登录',
enable: '启用 LinuxDo 登录',
enableHint: '在登录/注册页面显示 LinuxDo 登录入口',
clientId: 'Client ID',
......@@ -4354,7 +4354,7 @@ export default {
'禁用用户注册、公开页面和自助服务功能。仅管理员可以登录和管理平台。',
siteName: '站点名称',
siteNameHint: '显示在邮件和页面标题中',
siteNamePlaceholder: 'Sub2API',
siteNamePlaceholder: 'TrafficAPI',
siteSubtitle: '站点副标题',
siteSubtitleHint: '显示在登录和注册页面',
siteSubtitlePlaceholder: '订阅转 API 转换平台',
......@@ -4455,7 +4455,7 @@ export default {
fromEmail: '发件人邮箱',
fromEmailPlaceholder: "noreply{'@'}example.com",
fromName: '发件人名称',
fromNamePlaceholder: 'Sub2API',
fromNamePlaceholder: 'TrafficAPI',
useTls: '使用 TLS',
useTlsHint: '为 SMTP 连接启用 TLS 加密'
},
......@@ -4883,16 +4883,16 @@ export default {
// Admin tour steps
admin: {
welcome: {
title: '👋 欢迎使用 Sub2API',
title: '👋 欢迎使用 TrafficAPI',
description:
'<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">Sub2API 是一个强大的 AI 服务中转平台,让您轻松管理和分发 AI 服务。</p><p style="margin-bottom: 12px;"><b>🎯 核心能:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>📦 <b>分组管理</b> - 创建不同的服务套餐(VIP、免费试用等)</li><li>🔗 <b>账号池</b> - 连接多个上游 AI 服务商账号</li><li>🔑 <b>密钥分发</b> - 为用户生成独立 API Key</li><li>💰 <b>计费管理</b> - 灵活的费率和配额控制</li></ul><p style="color: #10b981; font-weight: 600;">接下来,我们将用 3 分钟带您完成首次配置 →</p></div>',
'<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">TrafficAPI 是智能 AI 流量网关,帮助您统一路由、负载均衡并实时监控多个 AI 服务商的请求流量。</p><p style="margin-bottom: 12px;"><b>🎯 核心能:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>📦 <b>流量分组</b> - 按路由策略创建服务套餐(VIP、免费试用等)</li><li>🔗 <b>账号池</b> - 连接多个上游账号,实现负载均衡与故障转移</li><li>🔑 <b>密钥分发</b> - 为用户生成独立 API Key,支持配额管控</li><li>📊 <b>流量分析</b> - 实时查看请求量、响应延迟与费用</li></ul><p style="color: #10b981; font-weight: 600;">接下来,我们将用 3 分钟带您完成首次配置 →</p></div>',
nextBtn: '开始配置 🚀',
prevBtn: '跳过'
},
groupManage: {
title: '📦 第一步:分组管理',
description:
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>什么是分组?</b></p><p style="margin-bottom: 12px;">分组是 Sub2API 的核心概念,它就像一个"服务套餐":</p><ul style="margin-left: 20px; margin-bottom: 12px; font-size: 13px;"><li>🎯 每个分组可以包含多个上游账号</li><li>💰 每个分组有独立的计费倍率</li><li>👥 可以设置为公开或专属分组</li></ul><p style="margin-top: 12px; padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 示例:</b>您可以创建"VIP专线"(高倍率)和"免费试用"(低倍率)两个分组</p><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"分组管理"开始</p></div>'
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>什么是分组?</b></p><p style="margin-bottom: 12px;">分组是 TrafficAPI 的核心流量管理单元,类似一个"服务套餐":</p><ul style="margin-left: 20px; margin-bottom: 12px; font-size: 13px;"><li>🎯 每个分组可以包含多个上游账号</li><li>💰 每个分组有独立的计费倍率</li><li>👥 可以设置为公开或专属分组</li></ul><p style="margin-top: 12px; padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 示例:</b>您可以创建"VIP专线"(高倍率)和"免费试用"(低倍率)两个分组</p><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"分组管理"开始</p></div>'
},
createGroup: {
title: '➕ 创建新分组',
......@@ -5004,9 +5004,9 @@ export default {
// User tour steps
user: {
welcome: {
title: '👋 欢迎使用 Sub2API',
title: '👋 欢迎使用 TrafficAPI',
description:
'<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">您好!欢迎来到 Sub2API AI 服务平台。</p><p style="margin-bottom: 12px;"><b>🎯 快速开始:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>🔑 创建 API 密钥</li><li>📋 复制密钥到您的应用</li><li>🚀 开始使用 AI 服务</li></ul><p style="color: #10b981; font-weight: 600;">只需 1 分钟,让我们开始吧 →</p></div>',
'<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">您好!欢迎来到 TrafficAPI —— 智能路由 Claude、GPT、Gemini 等多服务商 AI 请求的统一网关平台。</p><p style="margin-bottom: 12px;"><b>🎯 快速开始:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>🔑 创建您的 API 密钥</li><li>📋 在任意兼容 OpenAI 的客户端中使用</li><li>📊 实时查看流量与使用情况</li></ul><p style="color: #10b981; font-weight: 600;">只需 1 分钟,让我们开始吧 →</p></div>',
nextBtn: '开始 🚀',
prevBtn: '跳过'
},
......
......@@ -28,7 +28,7 @@ async function bootstrap() {
appStore.initFromInjectedConfig()
// Set document title immediately after config is loaded
if (appStore.siteName && appStore.siteName !== 'Sub2API') {
if (appStore.siteName && appStore.siteName !== 'TrafficAPI') {
document.title = `${appStore.siteName} - AI API Gateway`
}
......
......@@ -11,8 +11,8 @@ describe('resolveDocumentTitle', () => {
})
it('站点名为空时,回退默认站点名', () => {
expect(resolveDocumentTitle('Dashboard', '')).toBe('Dashboard - Sub2API')
expect(resolveDocumentTitle(undefined, ' ')).toBe('Sub2API')
expect(resolveDocumentTitle('Dashboard', '')).toBe('Dashboard - TrafficAPI')
expect(resolveDocumentTitle(undefined, ' ')).toBe('TrafficAPI')
})
it('站点名变更时仅影响后续路由标题计算', () => {
......
......@@ -435,7 +435,7 @@ router.beforeEach((to, _from, next) => {
const menuItem = publicItems.find((item) => item.id === id)
?? (authStore.isAdmin ? adminSettingsStore.customMenuItems.find((item) => item.id === id) : undefined)
if (menuItem?.label) {
const siteName = appStore.siteName || 'Sub2API'
const siteName = appStore.siteName || 'TrafficAPI'
document.title = `${menuItem.label} - ${siteName}`
} else {
document.title = resolveDocumentTitle(to.meta.title, appStore.siteName, to.meta.titleKey as string)
......
......@@ -5,7 +5,7 @@ import { i18n } from '@/i18n'
* 优先使用 titleKey 通过 i18n 翻译,fallback 到静态 routeTitle。
*/
export function resolveDocumentTitle(routeTitle: unknown, siteName?: string, titleKey?: string): string {
const normalizedSiteName = typeof siteName === 'string' && siteName.trim() ? siteName.trim() : 'Sub2API'
const normalizedSiteName = typeof siteName === 'string' && siteName.trim() ? siteName.trim() : 'TrafficAPI'
if (typeof titleKey === 'string' && titleKey.trim()) {
const translated = i18n.global.t(titleKey)
......
......@@ -24,7 +24,7 @@ export const useAppStore = defineStore('app', () => {
// Public settings cache state
const publicSettingsLoaded = ref<boolean>(false)
const publicSettingsLoading = ref<boolean>(false)
const siteName = ref<string>('Sub2API')
const siteName = ref<string>('TrafficAPI')
const siteLogo = ref<string>('')
const siteVersion = ref<string>('')
const contactInfo = ref<string>('')
......@@ -285,7 +285,7 @@ export const useAppStore = defineStore('app', () => {
*/
function applySettings(config: PublicSettings): void {
cachedPublicSettings.value = config
siteName.value = config.site_name || 'Sub2API'
siteName.value = config.site_name || 'TrafficAPI'
siteLogo.value = config.site_logo || ''
siteVersion.value = config.version || ''
contactInfo.value = config.contact_info || ''
......
<template>
<!-- Custom Home Content: Full Page Mode -->
<div v-if="homeContent" class="min-h-screen">
<!-- iframe mode -->
<iframe
v-if="isHomeContentUrl"
:src="homeContent.trim()"
......@@ -12,418 +11,339 @@
<div v-else v-html="homeContent"></div>
</div>
<!-- Default Home Page -->
<div
v-else
class="relative flex min-h-screen flex-col overflow-hidden bg-gradient-to-br from-gray-50 via-primary-50/30 to-gray-100 dark:from-dark-950 dark:via-dark-900 dark:to-dark-950"
>
<!-- Background Decorations -->
<div class="pointer-events-none absolute inset-0 overflow-hidden">
<div
class="absolute -right-40 -top-40 h-96 w-96 rounded-full bg-primary-400/20 blur-3xl"
></div>
<div
class="absolute -bottom-40 -left-40 h-96 w-96 rounded-full bg-primary-500/15 blur-3xl"
></div>
<div
class="absolute left-1/3 top-1/4 h-72 w-72 rounded-full bg-primary-300/10 blur-3xl"
></div>
<div
class="absolute bottom-1/4 right-1/4 h-64 w-64 rounded-full bg-primary-400/10 blur-3xl"
></div>
<div
class="absolute inset-0 bg-[linear-gradient(rgba(20,184,166,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(20,184,166,0.03)_1px,transparent_1px)] bg-[size:64px_64px]"
></div>
</div>
<!-- Default Home Page — Tech Redesign -->
<div v-else class="home-root relative flex min-h-screen flex-col overflow-hidden">
<!-- Header -->
<header class="relative z-20 px-6 py-4">
<!-- ══════════════════════════════════════════
BACKGROUND
══════════════════════════════════════════ -->
<div class="pointer-events-none absolute inset-0 overflow-hidden">
<!-- Glow orbs -->
<div class="orb orb-tr"></div>
<div class="orb orb-bl"></div>
<div class="orb orb-center"></div>
<!-- Grid -->
<div class="grid-overlay"></div>
<!-- Scan line -->
<div class="scan-line"></div>
</div>
<!-- ══════════════════════════════════════════
HEADER
══════════════════════════════════════════ -->
<header class="home-header relative z-20 px-6 py-4">
<nav class="mx-auto flex max-w-6xl items-center justify-between">
<!-- Logo -->
<div class="flex items-center">
<div class="h-10 w-10 overflow-hidden rounded-xl shadow-md">
<!-- Logo + Brand -->
<div class="flex items-center gap-3">
<div class="logo-ring h-9 w-9 overflow-hidden rounded-lg">
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
</div>
<span class="hidden font-mono text-sm font-semibold uppercase tracking-widest text-white/70 sm:block">
{{ siteName }}
</span>
</div>
<!-- Nav Actions -->
<div class="flex items-center gap-3">
<!-- Language Switcher -->
<!-- Actions -->
<div class="flex items-center gap-2">
<LocaleSwitcher />
<!-- Doc Link -->
<a
v-if="docUrl"
:href="docUrl"
target="_blank"
rel="noopener noreferrer"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-dark-400 dark:hover:bg-dark-800 dark:hover:text-white"
class="nav-btn"
:title="t('home.viewDocs')"
>
<Icon name="book" size="md" />
<Icon name="book" size="sm" class="mr-1" />
<span class="hidden sm:inline">{{ t('home.docs') }}</span>
</a>
<!-- Theme Toggle -->
<button
@click="toggleTheme"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-dark-400 dark:hover:bg-dark-800 dark:hover:text-white"
:title="isDark ? t('home.switchToLight') : t('home.switchToDark')"
>
<Icon v-if="isDark" name="sun" size="md" />
<Icon v-else name="moon" size="md" />
<button @click="toggleTheme" class="nav-btn icon-only" :title="isDark ? t('home.switchToLight') : t('home.switchToDark')">
<Icon v-if="isDark" name="sun" size="sm" />
<Icon v-else name="moon" size="sm" />
</button>
<!-- Login / Dashboard Button -->
<router-link
v-if="isAuthenticated"
:to="dashboardPath"
class="inline-flex items-center gap-1.5 rounded-full bg-gray-900 py-1 pl-1 pr-2.5 transition-colors hover:bg-gray-800 dark:bg-gray-800 dark:hover:bg-gray-700"
class="cta-nav-btn"
>
<span
class="flex h-5 w-5 items-center justify-center rounded-full bg-gradient-to-br from-primary-400 to-primary-600 text-[10px] font-semibold text-white"
>
{{ userInitial }}
</span>
<span class="text-xs font-medium text-white">{{ t('home.dashboard') }}</span>
<svg
class="h-3 w-3 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4.5 19.5l15-15m0 0H8.25m11.25 0v11.25"
/>
<span class="user-dot">{{ userInitial }}</span>
{{ t('home.dashboard') }}
<svg class="h-3 w-3 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 19.5l15-15m0 0H8.25m11.25 0v11.25" />
</svg>
</router-link>
<router-link
v-else
to="/login"
class="inline-flex items-center rounded-full bg-gray-900 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-gray-800 dark:bg-gray-800 dark:hover:bg-gray-700"
>
<router-link v-else to="/login" class="cta-nav-btn">
{{ t('home.login') }}
</router-link>
</div>
</nav>
</header>
<!-- Main Content -->
<main class="relative z-10 flex-1 px-6 py-16">
<div class="mx-auto max-w-6xl">
<!-- Hero Section - Left/Right Layout -->
<div class="mb-12 flex flex-col items-center justify-between gap-12 lg:flex-row lg:gap-16">
<!-- Left: Text Content -->
<!-- ══════════════════════════════════════════
MAIN
══════════════════════════════════════════ -->
<main class="relative z-10 flex-1 px-6">
<!-- ── HERO ────────────────────────────── -->
<section class="mx-auto max-w-6xl py-20 lg:py-28">
<div class="flex flex-col items-center gap-14 lg:flex-row lg:gap-20">
<!-- Text side -->
<div class="flex-1 text-center lg:text-left">
<h1
class="mb-4 text-4xl font-bold text-gray-900 dark:text-white md:text-5xl lg:text-6xl"
>
{{ siteName }}
<!-- Badge -->
<div class="mb-7 inline-flex items-center gap-2.5 rounded-full border border-teal-500/25 bg-teal-500/8 px-4 py-1.5 font-mono text-xs font-medium text-teal-400">
<span class="h-1.5 w-1.5 animate-pulse rounded-full bg-teal-400 shadow-[0_0_6px_#14b8a6]"></span>
AI API GATEWAY · ENTERPRISE GRADE
</div>
<!-- Headline -->
<h1 class="mb-5 font-black leading-[1.08] tracking-tight">
<span class="gradient-text block whitespace-nowrap text-[clamp(1.6rem,4.5vw,3.75rem)]">{{ t('home.heroSubtitle') }}</span>
</h1>
<p class="mb-8 text-lg text-gray-600 dark:text-dark-300 md:text-xl">
{{ siteSubtitle }}
<!-- Mono accent line -->
<p class="mb-5 font-mono text-[11px] uppercase tracking-[0.2em] text-teal-600/60">
// {{ siteName }} · intelligent traffic layer
</p>
<p class="mb-10 max-w-lg text-base leading-relaxed text-slate-400 lg:text-lg">
{{ t('home.heroDescription') }}
</p>
<!-- CTA Button -->
<div>
<!-- CTA buttons -->
<div class="flex flex-wrap items-center justify-center gap-4 lg:justify-start">
<router-link
:to="isAuthenticated ? dashboardPath : '/login'"
class="btn btn-primary px-8 py-3 text-base shadow-lg shadow-primary-500/30"
class="cta-primary inline-flex items-center gap-2 rounded-lg px-7 py-3 text-sm font-semibold text-white"
>
{{ isAuthenticated ? t('home.goToDashboard') : t('home.getStarted') }}
<Icon name="arrowRight" size="md" class="ml-2" :stroke-width="2" />
<Icon name="arrowRight" size="sm" :stroke-width="2.5" />
</router-link>
<a
v-if="docUrl"
:href="docUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 rounded-lg border border-white/10 px-7 py-3 text-sm font-medium text-slate-300 transition-all hover:border-teal-500/30 hover:text-teal-300"
>
<Icon name="book" size="sm" />
{{ t('home.viewDocs') }}
</a>
</div>
<!-- Stats -->
<div class="mt-12 flex flex-wrap items-center justify-center gap-8 lg:justify-start">
<div v-for="s in stats" :key="s.label" class="stat-item">
<span class="stat-value">{{ s.value }}</span>
<span class="stat-label">{{ s.label }}</span>
</div>
</div>
</div>
<!-- Right: Terminal Animation -->
<!-- Terminal side -->
<div class="flex flex-1 justify-center lg:justify-end">
<div class="terminal-container">
<div class="terminal-wrap">
<div class="terminal-glow"></div>
<div class="terminal-window">
<!-- Window header -->
<!-- Header bar -->
<div class="terminal-header">
<div class="terminal-buttons">
<span class="btn-close"></span>
<span class="btn-minimize"></span>
<span class="btn-maximize"></span>
</div>
<span class="terminal-title">terminal</span>
<div class="terminal-dots">
<span class="dot-red"></span>
<span class="dot-yellow"></span>
<span class="dot-green"></span>
</div>
<span class="terminal-title">trafficapi · gateway</span>
<span class="terminal-live">
<span class="h-1.5 w-1.5 animate-pulse rounded-full bg-teal-400 inline-block shadow-[0_0_5px_#14b8a6]"></span>
LIVE
</span>
</div>
<!-- Terminal content -->
<!-- Code body -->
<div class="terminal-body">
<div class="code-line line-1">
<span class="code-prompt">$</span>
<span class="code-cmd">curl</span>
<span class="code-flag">-X POST</span>
<span class="code-url">/v1/messages</span>
<div class="t-line line-1">
<span class="t-prompt"></span>
<span class="t-method">POST</span>
<span class="t-path">/v1/messages</span>
<span class="t-dim ml-auto">claude-3-5-sonnet</span>
</div>
<div class="code-line line-2">
<span class="code-comment"># Routing to upstream...</span>
<div class="t-line line-2">
<span class="t-comment">&nbsp;&nbsp;⠿ routing to pool · 3 accounts available</span>
</div>
<div class="code-line line-3">
<span class="code-success">200 OK</span>
<span class="code-response">{ "content": "Hello!" }</span>
<div class="t-line line-3">
<span class="t-ok">✓ 200</span>
<span class="t-ms">38ms</span>
<span class="t-dim">· account[1] selected</span>
</div>
<div class="code-line line-4">
<span class="code-prompt">$</span>
<span class="cursor"></span>
<div class="t-sep sep-1"></div>
<div class="t-line line-4">
<span class="t-prompt"></span>
<span class="t-method">POST</span>
<span class="t-path">/v1/chat/completions</span>
<span class="t-dim ml-auto">gpt-4o</span>
</div>
<div class="t-line line-5">
<span class="t-comment">&nbsp;&nbsp;⠿ load balancing · 2 upstream accounts</span>
</div>
<div class="t-line line-6">
<span class="t-ok">✓ 200</span>
<span class="t-ms">57ms</span>
<span class="t-dim">· account[3] selected</span>
</div>
<div class="t-sep sep-2"></div>
<div class="t-line line-7">
<span class="t-stat">📊 analytics</span>
<span class="t-dim">&nbsp;req: 1.4k/min · p99: 68ms · err: 0%</span>
</div>
<div class="t-line line-8">
<span class="t-prompt">_</span>
<span class="t-cursor"></span>
</div>
</div>
<!-- Feature Tags - Centered -->
<div class="mb-12 flex flex-wrap items-center justify-center gap-4 md:gap-6">
<div
class="inline-flex items-center gap-2.5 rounded-full border border-gray-200/50 bg-white/80 px-5 py-2.5 shadow-sm backdrop-blur-sm dark:border-dark-700/50 dark:bg-dark-800/80"
>
<Icon name="swap" size="sm" class="text-primary-500" />
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">{{
t('home.tags.subscriptionToApi')
}}</span>
</div>
<div
class="inline-flex items-center gap-2.5 rounded-full border border-gray-200/50 bg-white/80 px-5 py-2.5 shadow-sm backdrop-blur-sm dark:border-dark-700/50 dark:bg-dark-800/80"
>
<Icon name="shield" size="sm" class="text-primary-500" />
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">{{
t('home.tags.stickySession')
}}</span>
</div>
<div
class="inline-flex items-center gap-2.5 rounded-full border border-gray-200/50 bg-white/80 px-5 py-2.5 shadow-sm backdrop-blur-sm dark:border-dark-700/50 dark:bg-dark-800/80"
>
<Icon name="chart" size="sm" class="text-primary-500" />
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">{{
t('home.tags.realtimeBilling')
}}</span>
</div>
</div>
</section>
<!-- Features Grid -->
<div class="mb-12 grid gap-6 md:grid-cols-3">
<!-- Feature 1: Unified Gateway -->
<div
class="group rounded-2xl border border-gray-200/50 bg-white/60 p-6 backdrop-blur-sm transition-all duration-300 hover:shadow-xl hover:shadow-primary-500/10 dark:border-dark-700/50 dark:bg-dark-800/60"
>
<div
class="mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 shadow-lg shadow-blue-500/30 transition-transform group-hover:scale-110"
>
<Icon name="server" size="lg" class="text-white" />
<!-- ── TAGS BAR ─────────────────────────── -->
<section class="mx-auto max-w-6xl pb-14">
<div class="flex flex-wrap items-center justify-center gap-3">
<div v-for="tag in featureTags" :key="tag.text" class="tag-pill">
<span class="tag-pip"></span>
{{ tag.text }}
</div>
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
{{ t('home.features.unifiedGateway') }}
</h3>
<p class="text-sm leading-relaxed text-gray-600 dark:text-dark-400">
{{ t('home.features.unifiedGatewayDesc') }}
</p>
</div>
</section>
<!-- Feature 2: Account Pool -->
<div
class="group rounded-2xl border border-gray-200/50 bg-white/60 p-6 backdrop-blur-sm transition-all duration-300 hover:shadow-xl hover:shadow-primary-500/10 dark:border-dark-700/50 dark:bg-dark-800/60"
>
<div
class="mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary-500 to-primary-600 shadow-lg shadow-primary-500/30 transition-transform group-hover:scale-110"
>
<svg
class="h-6 w-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
/>
</svg>
</div>
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
{{ t('home.features.multiAccount') }}
</h3>
<p class="text-sm leading-relaxed text-gray-600 dark:text-dark-400">
{{ t('home.features.multiAccountDesc') }}
<!-- ── FEATURES ─────────────────────────── -->
<section class="mx-auto max-w-6xl pb-16">
<div class="mb-10 text-center">
<h2 class="mb-2 text-2xl font-bold text-white md:text-3xl">
<span class="gradient-text">{{ t('home.solutions.title') }}</span>
</h2>
<p class="font-mono text-xs uppercase tracking-widest text-slate-600">
// {{ t('home.solutions.subtitle') }}
</p>
</div>
<!-- Feature 3: Billing & Quota -->
<div class="grid gap-4 md:grid-cols-3">
<div
class="group rounded-2xl border border-gray-200/50 bg-white/60 p-6 backdrop-blur-sm transition-all duration-300 hover:shadow-xl hover:shadow-primary-500/10 dark:border-dark-700/50 dark:bg-dark-800/60"
v-for="feat in features"
:key="feat.key"
class="feat-card group"
>
<div class="feat-accent" :style="{ background: feat.color }"></div>
<div class="feat-corner-tr"></div>
<div class="feat-corner-bl"></div>
<div
class="mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500 to-purple-600 shadow-lg shadow-purple-500/30 transition-transform group-hover:scale-110"
class="feat-icon mb-5"
:style="{ background: feat.iconBg, boxShadow: `0 4px 20px ${feat.glow}` }"
>
<svg
class="h-6 w-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z"
/>
</svg>
<component :is="feat.iconComponent" />
</div>
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
{{ t('home.features.balanceQuota') }}
</h3>
<p class="text-sm leading-relaxed text-gray-600 dark:text-dark-400">
{{ t('home.features.balanceQuotaDesc') }}
<h3 class="mb-2 font-semibold text-white">{{ t(`home.features.${feat.key}`) }}</h3>
<p class="text-sm leading-relaxed text-slate-500 transition-colors group-hover:text-slate-400">
{{ t(`home.features.${feat.key}Desc`) }}
</p>
</div>
</div>
</section>
<!-- Supported Providers -->
<!-- ── PROVIDERS ────────────────────────── -->
<section class="mx-auto max-w-6xl pb-20">
<div class="mb-8 text-center">
<h2 class="mb-3 text-2xl font-bold text-gray-900 dark:text-white">
{{ t('home.providers.title') }}
</h2>
<p class="text-sm text-gray-600 dark:text-dark-400">
{{ t('home.providers.description') }}
<h2 class="mb-2 text-xl font-bold text-white">{{ t('home.providers.title') }}</h2>
<p class="font-mono text-xs uppercase tracking-[0.2em] text-slate-600">
// {{ t('home.providers.description') }}
</p>
</div>
<div class="mb-16 flex flex-wrap items-center justify-center gap-4">
<!-- Claude - Supported -->
<div
class="flex items-center gap-2 rounded-xl border border-primary-200 bg-white/60 px-5 py-3 ring-1 ring-primary-500/20 backdrop-blur-sm dark:border-primary-800 dark:bg-dark-800/60"
>
<div
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-orange-400 to-orange-500"
>
<span class="text-xs font-bold text-white">C</span>
</div>
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">{{ t('home.providers.claude') }}</span>
<span
class="rounded bg-primary-100 px-1.5 py-0.5 text-[10px] font-medium text-primary-600 dark:bg-primary-900/30 dark:text-primary-400"
>{{ t('home.providers.supported') }}</span
>
</div>
<!-- GPT - Supported -->
<div
class="flex items-center gap-2 rounded-xl border border-primary-200 bg-white/60 px-5 py-3 ring-1 ring-primary-500/20 backdrop-blur-sm dark:border-primary-800 dark:bg-dark-800/60"
>
<div class="flex flex-wrap items-center justify-center gap-3">
<div
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-green-500 to-green-600"
v-for="p in providerList"
:key="p.name"
class="provider-card"
:class="{ 'provider-active': p.live, 'provider-inactive': !p.live }"
>
<span class="text-xs font-bold text-white">G</span>
</div>
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">GPT</span>
<span
class="rounded bg-primary-100 px-1.5 py-0.5 text-[10px] font-medium text-primary-600 dark:bg-primary-900/30 dark:text-primary-400"
>{{ t('home.providers.supported') }}</span
>
</div>
<!-- Gemini - Supported -->
<div
class="flex items-center gap-2 rounded-xl border border-primary-200 bg-white/60 px-5 py-3 ring-1 ring-primary-500/20 backdrop-blur-sm dark:border-primary-800 dark:bg-dark-800/60"
>
<div
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-blue-600"
>
<span class="text-xs font-bold text-white">G</span>
</div>
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">{{ t('home.providers.gemini') }}</span>
<span
class="rounded bg-primary-100 px-1.5 py-0.5 text-[10px] font-medium text-primary-600 dark:bg-primary-900/30 dark:text-primary-400"
>{{ t('home.providers.supported') }}</span
>
</div>
<!-- Antigravity - Supported -->
<div
class="flex items-center gap-2 rounded-xl border border-primary-200 bg-white/60 px-5 py-3 ring-1 ring-primary-500/20 backdrop-blur-sm dark:border-primary-800 dark:bg-dark-800/60"
>
<div
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-rose-500 to-pink-600"
>
<span class="text-xs font-bold text-white">A</span>
</div>
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">{{ t('home.providers.antigravity') }}</span>
<span
class="rounded bg-primary-100 px-1.5 py-0.5 text-[10px] font-medium text-primary-600 dark:bg-primary-900/30 dark:text-primary-400"
>{{ t('home.providers.supported') }}</span
>
</div>
<!-- More - Coming Soon -->
<div
class="flex items-center gap-2 rounded-xl border border-gray-200/50 bg-white/40 px-5 py-3 opacity-60 backdrop-blur-sm dark:border-dark-700/50 dark:bg-dark-800/40"
>
<div
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-gray-500 to-gray-600"
>
<span class="text-xs font-bold text-white">+</span>
</div>
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">{{ t('home.providers.more') }}</span>
<span
class="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-dark-700 dark:text-dark-400"
>{{ t('home.providers.soon') }}</span
>
</div>
class="provider-status-dot"
:class="p.live ? 'dot-live' : 'dot-offline'"
></span>
<div class="provider-icon-badge" :style="{ background: p.gradient }">
<span class="text-[11px] font-bold text-white">{{ p.letter }}</span>
</div>
<span class="text-sm font-medium text-slate-300">{{ p.name }}</span>
<span class="provider-tag" :class="p.live ? 'tag-live' : 'tag-soon'">
{{ p.live ? t('home.providers.supported') : t('home.providers.soon') }}
</span>
</div>
</div>
</section>
</main>
<!-- Footer -->
<footer class="relative z-10 border-t border-gray-200/50 px-6 py-8 dark:border-dark-800/50">
<div
class="mx-auto flex max-w-6xl flex-col items-center justify-center gap-4 text-center sm:flex-row sm:text-left"
>
<p class="text-sm text-gray-500 dark:text-dark-400">
&copy; {{ currentYear }} {{ siteName }}. {{ t('home.footer.allRightsReserved') }}
<!-- ══════════════════════════════════════════
FOOTER
══════════════════════════════════════════ -->
<footer class="home-footer relative z-10 px-6 py-6">
<div class="mx-auto flex max-w-6xl flex-col items-center justify-between gap-3 text-center sm:flex-row sm:text-left">
<p class="font-mono text-xs text-slate-700">
&copy; {{ currentYear }} {{ siteName }} · {{ t('home.footer.allRightsReserved') }}
</p>
<div class="flex items-center gap-4">
<div class="flex items-center gap-5">
<a
v-if="docUrl"
:href="docUrl"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-white"
class="font-mono text-xs text-slate-700 transition-colors hover:text-teal-400"
>
{{ t('home.docs') }}
/docs
</a>
<a
:href="githubUrl"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-white"
class="font-mono text-xs text-slate-700 transition-colors hover:text-teal-400"
>
GitHub
/github
</a>
</div>
</div>
</footer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, defineComponent, h } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore, useAppStore } from '@/stores'
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
const authStore = useAuthStore()
const appStore = useAppStore()
// Site settings - directly from appStore (already initialized from injected config)
const siteName = computed(() => appStore.cachedPublicSettings?.site_name || appStore.siteName || 'Sub2API')
// Site settings
const siteName = computed(() => appStore.cachedPublicSettings?.site_name || appStore.siteName || 'TrafficAPI')
const siteLogo = computed(() => appStore.cachedPublicSettings?.site_logo || appStore.siteLogo || '')
const siteSubtitle = computed(() => appStore.cachedPublicSettings?.site_subtitle || 'AI API Gateway Platform')
const docUrl = computed(() => appStore.cachedPublicSettings?.doc_url || appStore.docUrl || '')
const homeContent = computed(() => appStore.cachedPublicSettings?.home_content || '')
// Check if homeContent is a URL (for iframe display)
const isHomeContentUrl = computed(() => {
const content = homeContent.value.trim()
return content.startsWith('http://') || content.startsWith('https://')
......@@ -432,36 +352,96 @@ const isHomeContentUrl = computed(() => {
// Theme
const isDark = ref(document.documentElement.classList.contains('dark'))
// GitHub URL
// GitHub
const githubUrl = 'https://github.com/Wei-Shaw/sub2api'
// Auth state
// Auth
const isAuthenticated = computed(() => authStore.isAuthenticated)
const isAdmin = computed(() => authStore.isAdmin)
const dashboardPath = computed(() => isAdmin.value ? '/admin/dashboard' : '/dashboard')
const userInitial = computed(() => {
const user = authStore.user
if (!user || !user.email) return ''
if (!user?.email) return ''
return user.email.charAt(0).toUpperCase()
})
// Current year for footer
const currentYear = computed(() => new Date().getFullYear())
// Toggle theme
// Stats
const stats = [
{ value: '3+', label: 'AI Providers' },
{ value: '99.9%', label: 'SLA Uptime' },
{ value: '1', label: 'API Key' },
]
// Feature tags
const featureTags = computed(() => [
{ text: t('home.tags.subscriptionToApi') },
{ text: t('home.tags.stickySession') },
{ text: t('home.tags.realtimeBilling') },
])
// SVG icon components (inline)
const IconServer = defineComponent({
render: () => h('svg', { class: 'h-5 w-5 text-white', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z' })
])
})
const IconRoute = defineComponent({
render: () => h('svg', { class: 'h-5 w-5 text-white', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5' })
])
})
const IconChart = defineComponent({
render: () => h('svg', { class: 'h-5 w-5 text-white', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z' })
])
})
// Features config — all icons use inline components, no string name needed
const features = [
{
key: 'unifiedGateway',
iconComponent: IconServer,
iconBg: 'linear-gradient(135deg, #3b82f6, #1d4ed8)',
glow: 'rgba(59,130,246,0.3)',
color: '#3b82f6',
},
{
key: 'multiAccount',
iconComponent: IconRoute,
iconBg: 'linear-gradient(135deg, #14b8a6, #0d9488)',
glow: 'rgba(20,184,166,0.3)',
color: '#14b8a6',
},
{
key: 'balanceQuota',
iconComponent: IconChart,
iconBg: 'linear-gradient(135deg, #a855f7, #7c3aed)',
glow: 'rgba(168,85,247,0.3)',
color: '#a855f7',
},
]
// Providers
const providerList = computed(() => [
{ name: t('home.providers.claude'), letter: 'C', gradient: 'linear-gradient(135deg,#f97316,#ea580c)', live: true },
{ name: 'GPT', letter: 'G', gradient: 'linear-gradient(135deg,#22c55e,#16a34a)', live: true },
{ name: t('home.providers.gemini'), letter: 'G', gradient: 'linear-gradient(135deg,#3b82f6,#2563eb)', live: true },
{ name: t('home.providers.antigravity'), letter: 'A', gradient: 'linear-gradient(135deg,#f43f5e,#db2777)', live: true },
{ name: t('home.providers.more'), letter: '+', gradient: 'linear-gradient(135deg,#475569,#334155)', live: false },
])
// Theme toggle
function toggleTheme() {
isDark.value = !isDark.value
document.documentElement.classList.toggle('dark', isDark.value)
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
}
// Initialize theme
function initTheme() {
const savedTheme = localStorage.getItem('theme')
if (
savedTheme === 'dark' ||
(!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
isDark.value = true
document.documentElement.classList.add('dark')
}
......@@ -469,11 +449,7 @@ function initTheme() {
onMounted(() => {
initTheme()
// Check auth state
authStore.checkAuth()
// Ensure public settings are loaded (will use cache if already loaded from injected config)
if (!appStore.publicSettingsLoaded) {
appStore.fetchPublicSettings()
}
......@@ -481,164 +457,497 @@ onMounted(() => {
</script>
<style scoped>
/* Terminal Container */
.terminal-container {
/* ══════════════════════════════════════════════
ROOT — always dark for this landing page
══════════════════════════════════════════════ */
.home-root {
background: #182a40;
color: white;
}
/* ══════════════════════════════════════════════
BACKGROUND LAYERS
══════════════════════════════════════════════ */
.orb {
position: absolute;
border-radius: 50%;
filter: blur(100px);
pointer-events: none;
}
.orb-tr {
top: -15%;
right: -15%;
width: 600px;
height: 600px;
background: radial-gradient(ellipse, rgba(20,184,166,0.18) 0%, transparent 70%);
}
.orb-bl {
bottom: -15%;
left: -15%;
width: 500px;
height: 500px;
background: radial-gradient(ellipse, rgba(6,182,212,0.13) 0%, transparent 70%);
}
.orb-center {
top: 30%;
left: 40%;
width: 400px;
height: 400px;
background: radial-gradient(ellipse, rgba(20,184,166,0.08) 0%, transparent 70%);
}
.grid-overlay {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(20,184,166,0.09) 1px, transparent 1px),
linear-gradient(90deg, rgba(20,184,166,0.09) 1px, transparent 1px);
background-size: 48px 48px;
}
.scan-line {
position: absolute;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent 0%, rgba(20,184,166,0.5) 30%, rgba(20,184,166,0.8) 50%, rgba(20,184,166,0.5) 70%, transparent 100%);
animation: scan 10s linear infinite;
box-shadow: 0 0 8px rgba(20,184,166,0.4);
}
@keyframes scan {
0% { top: 0%; opacity: 0; }
3% { opacity: 1; }
97% { opacity: 0.4; }
100% { top: 100%; opacity: 0; }
}
/* ══════════════════════════════════════════════
HEADER
══════════════════════════════════════════════ */
.home-header {
border-bottom: 1px solid rgba(20,184,166,0.12);
backdrop-filter: blur(12px);
background: rgba(24,42,64,0.8);
}
.logo-ring {
box-shadow: 0 0 0 1px rgba(20,184,166,0.25), 0 0 16px rgba(20,184,166,0.15);
}
.nav-btn {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
color: #94a3b8;
transition: all 0.2s;
}
.nav-btn:hover {
background: rgba(20,184,166,0.08);
color: #5eead4;
}
.nav-btn.icon-only {
padding: 6px 8px;
}
.cta-nav-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 7px;
border: 1px solid rgba(20,184,166,0.3);
background: rgba(20,184,166,0.08);
font-size: 12px;
font-weight: 500;
color: #5eead4;
transition: all 0.2s;
}
.cta-nav-btn:hover {
border-color: rgba(20,184,166,0.6);
background: rgba(20,184,166,0.15);
box-shadow: 0 0 14px rgba(20,184,166,0.2);
}
.user-dot {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: linear-gradient(135deg, #14b8a6, #0d9488);
font-size: 9px;
font-weight: 700;
color: white;
}
/* ══════════════════════════════════════════════
HERO
══════════════════════════════════════════════ */
.gradient-text {
background: linear-gradient(135deg, #ffffff 0%, #7dd3c8 40%, #14b8a6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.cta-primary {
background: linear-gradient(135deg, #0f766e, #14b8a6, #0d9488);
background-size: 200% 200%;
animation: gradient-shift 4s ease infinite;
box-shadow: 0 0 0 1px rgba(20,184,166,0.4), 0 4px 20px rgba(20,184,166,0.25);
transition: box-shadow 0.25s, transform 0.25s;
}
.cta-primary:hover {
box-shadow: 0 0 0 1px rgba(20,184,166,0.7), 0 6px 28px rgba(20,184,166,0.4);
transform: translateY(-2px);
}
@keyframes gradient-shift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
/* Stats */
.stat-item {
display: flex;
flex-direction: column;
gap: 2px;
position: relative;
display: inline-block;
}
.stat-item + .stat-item::before {
content: '';
position: absolute;
left: -16px;
top: 50%;
transform: translateY(-50%);
width: 1px;
height: 28px;
background: rgba(20,184,166,0.2);
}
.stat-value {
font-size: 1.6rem;
font-weight: 900;
font-family: ui-monospace, monospace;
color: white;
line-height: 1;
letter-spacing: -0.02em;
}
.stat-label {
font-size: 10px;
color: #475569;
text-transform: uppercase;
letter-spacing: 0.1em;
}
/* Terminal Window */
/* ══════════════════════════════════════════════
TERMINAL
══════════════════════════════════════════════ */
.terminal-wrap {
position: relative;
display: inline-block;
}
.terminal-glow {
position: absolute;
inset: -30px;
background: radial-gradient(ellipse at center, rgba(20,184,166,0.1) 0%, transparent 65%);
pointer-events: none;
z-index: 0;
}
.terminal-window {
width: 420px;
background: linear-gradient(145deg, #1e293b 0%, #0f172a 100%);
border-radius: 14px;
position: relative;
z-index: 1;
width: 460px;
max-width: 100%;
background: linear-gradient(160deg, #1a2e48 0%, #162540 100%);
border-radius: 13px;
border: 1px solid rgba(20,184,166,0.18);
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
0 0 0 1px rgba(255,255,255,0.025),
0 28px 64px rgba(0,0,0,0.7),
0 0 50px rgba(20,184,166,0.07);
overflow: hidden;
transform: perspective(1000px) rotateX(2deg) rotateY(-2deg);
transition: transform 0.3s ease;
transform: perspective(900px) rotateX(1.5deg) rotateY(-2.5deg);
transition: transform 0.4s ease, box-shadow 0.4s ease;
}
.terminal-window:hover {
transform: perspective(1000px) rotateX(0deg) rotateY(0deg) translateY(-4px);
transform: perspective(900px) rotateX(0deg) rotateY(0deg) translateY(-8px);
box-shadow:
0 0 0 1px rgba(20,184,166,0.28),
0 36px 80px rgba(0,0,0,0.75),
0 0 70px rgba(20,184,166,0.12);
}
/* Terminal Header */
.terminal-header {
display: flex;
align-items: center;
padding: 12px 16px;
background: rgba(30, 41, 59, 0.8);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
gap: 10px;
padding: 11px 16px;
background: rgba(22,37,60,0.9);
border-bottom: 1px solid rgba(20,184,166,0.12);
}
.terminal-buttons {
.terminal-dots {
display: flex;
gap: 8px;
gap: 6px;
}
.terminal-buttons span {
width: 12px;
height: 12px;
.terminal-dots span {
width: 11px;
height: 11px;
border-radius: 50%;
}
.btn-close {
background: #ef4444;
}
.btn-minimize {
background: #eab308;
}
.btn-maximize {
background: #22c55e;
}
.dot-red { background: #ef4444; }
.dot-yellow { background: #eab308; }
.dot-green { background: #22c55e; }
.terminal-title {
flex: 1;
text-align: center;
font-size: 12px;
font-size: 11px;
font-family: ui-monospace, monospace;
color: #64748b;
margin-right: 52px;
color: #334155;
}
.terminal-live {
display: flex;
align-items: center;
gap: 5px;
font-size: 10px;
font-family: ui-monospace, monospace;
color: #14b8a6;
letter-spacing: 0.06em;
}
/* Terminal Body */
.terminal-body {
padding: 20px 24px;
padding: 18px 22px 22px;
font-family: ui-monospace, 'Fira Code', monospace;
font-size: 14px;
line-height: 2;
font-size: 12.5px;
line-height: 1.85;
}
.code-line {
/* Terminal lines */
.t-line {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
gap: 7px;
opacity: 0;
animation: line-appear 0.5s ease forwards;
animation: t-appear 0.35s ease forwards;
}
.t-sep {
height: 1px;
background: rgba(20,184,166,0.07);
margin: 5px 0;
opacity: 0;
animation: t-appear 0.2s ease forwards;
}
.line-1 { animation-delay: 0.2s; }
.line-2 { animation-delay: 0.7s; }
.line-3 { animation-delay: 1.2s; }
.sep-1 { animation-delay: 1.75s; }
.line-4 { animation-delay: 2.0s; }
.line-5 { animation-delay: 2.5s; }
.line-6 { animation-delay: 3.0s; }
.sep-2 { animation-delay: 3.5s; }
.line-7 { animation-delay: 3.8s; }
.line-8 { animation-delay: 4.4s; }
@keyframes t-appear {
from { opacity: 0; transform: translateX(-5px); }
to { opacity: 1; transform: translateX(0); }
}
.t-prompt { color: #14b8a6; font-weight: 700; }
.t-method { color: #38bdf8; font-weight: 600; }
.t-path { color: #bfdbfe; }
.t-comment{ color: #1e3a4a; font-style: italic; }
.t-ok {
color: #4ade80;
background: rgba(74,222,128,0.07);
border: 1px solid rgba(74,222,128,0.15);
padding: 1px 6px;
border-radius: 3px;
font-weight: 700;
font-size: 11px;
}
.t-ms { color: #fcd34d; font-size: 11px; }
.t-dim { color: #1e3a4a; font-size: 11px; }
.t-stat { color: #a78bfa; }
.line-1 {
animation-delay: 0.3s;
/* Cursor */
.t-cursor {
display: inline-block;
width: 7px;
height: 13px;
background: #14b8a6;
box-shadow: 0 0 8px rgba(20,184,166,0.7);
animation: blink 1.1s step-end infinite;
border-radius: 1px;
}
@keyframes blink {
0%,44% { opacity: 1; }
50%,94% { opacity: 0; }
100% { opacity: 1; }
}
.line-2 {
animation-delay: 1s;
/* ══════════════════════════════════════════════
FEATURE TAGS
══════════════════════════════════════════════ */
.tag-pill {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid rgba(20,184,166,0.14);
background: rgba(20,184,166,0.04);
border-radius: 9999px;
padding: 7px 18px;
font-size: 12px;
font-weight: 500;
color: #64748b;
transition: all 0.2s;
cursor: default;
}
.line-3 {
animation-delay: 1.8s;
.tag-pill:hover {
border-color: rgba(20,184,166,0.3);
background: rgba(20,184,166,0.08);
color: #94a3b8;
}
.line-4 {
animation-delay: 2.5s;
.tag-pip {
width: 5px;
height: 5px;
border-radius: 50%;
background: #14b8a6;
box-shadow: 0 0 7px rgba(20,184,166,0.9);
flex-shrink: 0;
}
@keyframes line-appear {
from {
/* ══════════════════════════════════════════════
FEATURE CARDS
══════════════════════════════════════════════ */
.feat-card {
position: relative;
padding: 26px;
background: rgba(26,46,72,0.65);
border: 1px solid rgba(255,255,255,0.09);
border-radius: 13px;
backdrop-filter: blur(14px);
overflow: hidden;
transition: all 0.3s ease;
}
.feat-card:hover {
border-color: rgba(255,255,255,0.15);
background: rgba(26,46,72,0.88);
transform: translateY(-3px);
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
}
/* left accent bar */
.feat-accent {
position: absolute;
left: 0;
top: 0;
width: 3px;
height: 100%;
opacity: 0.55;
transition: opacity 0.3s, box-shadow 0.3s;
}
.feat-card:hover .feat-accent {
opacity: 1;
box-shadow: 0 0 12px currentColor;
}
/* corner brackets */
.feat-corner-tr,
.feat-corner-bl {
position: absolute;
width: 10px;
height: 10px;
opacity: 0;
transform: translateY(5px);
}
to {
transition: opacity 0.3s;
}
.feat-corner-tr {
top: 10px;
right: 10px;
border-top: 1px solid rgba(20,184,166,0.45);
border-right: 1px solid rgba(20,184,166,0.45);
}
.feat-corner-bl {
bottom: 10px;
right: 10px;
border-bottom: 1px solid rgba(20,184,166,0.45);
border-right: 1px solid rgba(20,184,166,0.45);
}
.feat-card:hover .feat-corner-tr,
.feat-card:hover .feat-corner-bl {
opacity: 1;
transform: translateY(0);
}
}
.feat-icon {
width: 44px;
height: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s, box-shadow 0.3s;
}
.feat-card:hover .feat-icon {
transform: scale(1.08);
}
.code-prompt {
color: #22c55e;
font-weight: bold;
/* ══════════════════════════════════════════════
PROVIDERS
══════════════════════════════════════════════ */
.provider-card {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.1);
background: rgba(26,46,72,0.55);
backdrop-filter: blur(8px);
transition: all 0.25s;
}
.code-cmd {
color: #38bdf8;
.provider-active:hover {
border-color: rgba(20,184,166,0.22);
background: rgba(20,184,166,0.05);
box-shadow: 0 4px 20px rgba(0,0,0,0.35);
}
.code-flag {
color: #a78bfa;
.provider-inactive {
opacity: 0.38;
}
.code-url {
color: #14b8a6;
.provider-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.code-comment {
color: #64748b;
font-style: italic;
.dot-live {
background: #34d399;
box-shadow: 0 0 7px rgba(52,211,153,0.8);
}
.code-success {
color: #22c55e;
background: rgba(34, 197, 94, 0.15);
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
.dot-offline {
background: #334155;
}
.code-response {
color: #fbbf24;
.provider-icon-badge {
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* Blinking Cursor */
.cursor {
display: inline-block;
width: 8px;
height: 16px;
background: #22c55e;
animation: blink 1s step-end infinite;
.provider-tag {
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 9999px;
letter-spacing: 0.05em;
text-transform: uppercase;
}
@keyframes blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
.tag-live {
background: rgba(20,184,166,0.1);
color: #2dd4bf;
border: 1px solid rgba(20,184,166,0.2);
}
.tag-soon {
background: rgba(71,85,105,0.2);
color: #475569;
border: 1px solid rgba(71,85,105,0.25);
}
/* Dark mode adjustments */
:deep(.dark) .terminal-window {
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(20, 184, 166, 0.2),
0 0 40px rgba(20, 184, 166, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
/* ══════════════════════════════════════════════
FOOTER
══════════════════════════════════════════════ */
.home-footer {
border-top: 1px solid rgba(20,184,166,0.1);
background: rgba(24,42,64,0.8);
}
</style>
......@@ -372,7 +372,7 @@ const appStore = useAppStore()
// ==================== Site Settings (same as HomeView) ====================
const siteName = computed(() => appStore.cachedPublicSettings?.site_name || appStore.siteName || 'Sub2API')
const siteName = computed(() => appStore.cachedPublicSettings?.site_name || appStore.siteName || 'TrafficAPI')
const siteLogo = computed(() => appStore.cachedPublicSettings?.site_logo || appStore.siteLogo || '')
const docUrl = computed(() => appStore.cachedPublicSettings?.doc_url || appStore.docUrl || '')
const githubUrl = 'https://github.com/Wei-Shaw/sub2api'
......
......@@ -2121,9 +2121,9 @@ const form = reactive<SettingsForm>({
default_balance: 0,
default_concurrency: 1,
default_subscriptions: [],
site_name: 'Sub2API',
site_name: 'TrafficAPI',
site_logo: '',
site_subtitle: 'Subscription to API Conversion Platform',
site_subtitle: 'Intelligent AI Traffic Routing & Management',
api_base_url: '',
contact_info: '',
doc_url: '',
......
......@@ -212,7 +212,7 @@ const hasRegisterData = ref<boolean>(false)
// Public settings
const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
const siteName = ref<string>('Sub2API')
const siteName = ref<string>('TrafficAPI')
const registrationEmailSuffixWhitelist = ref<string[]>([])
// Turnstile for resend
......@@ -249,7 +249,7 @@ onMounted(async () => {
const settings = await getPublicSettings()
turnstileEnabled.value = settings.turnstile_enabled
turnstileSiteKey.value = settings.turnstile_site_key || ''
siteName.value = settings.site_name || 'Sub2API'
siteName.value = settings.site_name || 'TrafficAPI'
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
settings.registration_email_suffix_whitelist || []
)
......
......@@ -322,7 +322,7 @@ const promoCodeEnabled = ref<boolean>(true)
const invitationCodeEnabled = ref<boolean>(false)
const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
const siteName = ref<string>('Sub2API')
const siteName = ref<string>('TrafficAPI')
const linuxdoOAuthEnabled = ref<boolean>(false)
const registrationEmailSuffixWhitelist = ref<string[]>([])
......@@ -374,7 +374,7 @@ onMounted(async () => {
invitationCodeEnabled.value = settings.invitation_code_enabled
turnstileEnabled.value = settings.turnstile_enabled
turnstileSiteKey.value = settings.turnstile_site_key || ''
siteName.value = settings.site_name || 'Sub2API'
siteName.value = settings.site_name || 'TrafficAPI'
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
settings.registration_email_suffix_whitelist || []
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment