First commit

This commit is contained in:
shaw
2025-12-18 13:50:39 +08:00
parent 569f4882e5
commit 642842c29e
218 changed files with 86902 additions and 0 deletions

View File

@@ -0,0 +1,452 @@
<template>
<div class="min-h-screen relative 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="absolute inset-0 overflow-hidden pointer-events-none">
<div class="absolute -top-40 -right-40 w-96 h-96 bg-primary-400/20 rounded-full blur-3xl"></div>
<div class="absolute -bottom-40 -left-40 w-96 h-96 bg-primary-500/15 rounded-full blur-3xl"></div>
<div class="absolute top-1/4 left-1/3 w-72 h-72 bg-primary-300/10 rounded-full blur-3xl"></div>
<div class="absolute bottom-1/4 right-1/4 w-64 h-64 bg-primary-400/10 rounded-full 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>
<!-- Header -->
<header class="relative z-20 px-6 py-4">
<nav class="max-w-6xl mx-auto flex items-center justify-between">
<!-- Logo -->
<div class="flex items-center">
<div class="w-10 h-10 rounded-xl overflow-hidden shadow-md">
<img :src="siteLogo || '/logo.png'" alt="Logo" class="w-full h-full object-contain" />
</div>
</div>
<!-- Nav Actions -->
<div class="flex items-center gap-3">
<!-- Language Switcher -->
<LocaleSwitcher />
<!-- GitHub Link -->
<a
:href="githubUrl"
target="_blank"
rel="noopener noreferrer"
class="p-2 rounded-lg text-gray-500 hover:text-gray-700 dark:text-dark-400 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-dark-800 transition-colors"
:title="t('home.viewOnGithub')"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z" />
</svg>
</a>
<!-- Theme Toggle -->
<button
@click="toggleTheme"
class="p-2 rounded-lg text-gray-500 hover:text-gray-700 dark:text-dark-400 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-dark-800 transition-colors"
:title="isDark ? t('home.switchToLight') : t('home.switchToDark')"
>
<svg v-if="isDark" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
<svg v-else class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
</button>
<!-- Login / Dashboard Button -->
<router-link
v-if="isAuthenticated"
to="/dashboard"
class="inline-flex items-center gap-1.5 pl-1 pr-2.5 py-1 rounded-full bg-gray-900 dark:bg-gray-800 hover:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
>
<span class="w-5 h-5 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 text-white flex items-center justify-center text-[10px] font-semibold">
{{ userInitial }}
</span>
<span class="text-xs font-medium text-white">{{ t('home.dashboard') }}</span>
<svg class="w-3 h-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" />
</svg>
</router-link>
<router-link
v-else
to="/login"
class="inline-flex items-center px-3 py-1 rounded-full bg-gray-900 dark:bg-gray-800 hover:bg-gray-800 dark:hover:bg-gray-700 text-xs font-medium text-white transition-colors"
>
{{ t('home.login') }}
</router-link>
</div>
</nav>
</header>
<!-- Main Content -->
<main class="relative z-10 px-6 py-16">
<div class="max-w-6xl mx-auto">
<!-- Hero Section - Left/Right Layout -->
<div class="flex flex-col lg:flex-row items-center justify-between gap-12 lg:gap-16 mb-12">
<!-- Left: Text Content -->
<div class="flex-1 text-center lg:text-left">
<h1 class="text-4xl md:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-white mb-4">
{{ siteName }}
</h1>
<p class="text-lg md:text-xl text-gray-600 dark:text-dark-300 mb-8">
{{ siteSubtitle }}
</p>
<!-- CTA Button -->
<div>
<router-link
:to="isAuthenticated ? '/dashboard' : '/login'"
class="btn btn-primary px-8 py-3 text-base shadow-lg shadow-primary-500/30"
>
{{ isAuthenticated ? t('home.goToDashboard') : t('home.getStarted') }}
<svg class="w-5 h-5 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</router-link>
</div>
</div>
<!-- Right: Terminal Animation -->
<div class="flex-1 flex justify-center lg:justify-end">
<div class="terminal-container">
<div class="terminal-window">
<!-- Window header -->
<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>
<!-- Terminal content -->
<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>
<div class="code-line line-2">
<span class="code-comment"># Routing to upstream...</span>
</div>
<div class="code-line line-3">
<span class="code-success">200 OK</span>
<span class="code-response">{ "content": "Hello!" }</span>
</div>
<div class="code-line line-4">
<span class="code-prompt">$</span>
<span class="cursor"></span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Feature Tags - Centered -->
<div class="flex flex-wrap items-center justify-center gap-4 md:gap-6 mb-12">
<div class="inline-flex items-center gap-2.5 px-5 py-2.5 rounded-full bg-white/80 dark:bg-dark-800/80 backdrop-blur-sm border border-gray-200/50 dark:border-dark-700/50 shadow-sm">
<svg class="w-4 h-4 text-primary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<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" />
</svg>
<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 px-5 py-2.5 rounded-full bg-white/80 dark:bg-dark-800/80 backdrop-blur-sm border border-gray-200/50 dark:border-dark-700/50 shadow-sm">
<svg class="w-4 h-4 text-primary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
<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 px-5 py-2.5 rounded-full bg-white/80 dark:bg-dark-800/80 backdrop-blur-sm border border-gray-200/50 dark:border-dark-700/50 shadow-sm">
<svg class="w-4 h-4 text-primary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<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" />
</svg>
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">{{ t('home.tags.realtimeBilling') }}</span>
</div>
</div>
<!-- Features Grid -->
<div class="grid md:grid-cols-3 gap-6 mb-12">
<!-- Feature 1: Unified Gateway -->
<div class="group p-6 rounded-2xl bg-white/60 dark:bg-dark-800/60 backdrop-blur-sm border border-gray-200/50 dark:border-dark-700/50 hover:shadow-xl hover:shadow-primary-500/10 transition-all duration-300">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center mb-4 shadow-lg shadow-blue-500/30 group-hover:scale-110 transition-transform">
<svg class="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<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" />
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">{{ t('home.features.unifiedGateway') }}</h3>
<p class="text-gray-600 dark:text-dark-400 text-sm leading-relaxed">
{{ t('home.features.unifiedGatewayDesc') }}
</p>
</div>
<!-- Feature 2: Account Pool -->
<div class="group p-6 rounded-2xl bg-white/60 dark:bg-dark-800/60 backdrop-blur-sm border border-gray-200/50 dark:border-dark-700/50 hover:shadow-xl hover:shadow-primary-500/10 transition-all duration-300">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center mb-4 shadow-lg shadow-primary-500/30 group-hover:scale-110 transition-transform">
<svg class="w-6 h-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="text-lg font-semibold text-gray-900 dark:text-white mb-2">{{ t('home.features.multiAccount') }}</h3>
<p class="text-gray-600 dark:text-dark-400 text-sm leading-relaxed">
{{ t('home.features.multiAccountDesc') }}
</p>
</div>
<!-- Feature 3: Billing & Quota -->
<div class="group p-6 rounded-2xl bg-white/60 dark:bg-dark-800/60 backdrop-blur-sm border border-gray-200/50 dark:border-dark-700/50 hover:shadow-xl hover:shadow-primary-500/10 transition-all duration-300">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-purple-500 to-purple-600 flex items-center justify-center mb-4 shadow-lg shadow-purple-500/30 group-hover:scale-110 transition-transform">
<svg class="w-6 h-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>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">{{ t('home.features.balanceQuota') }}</h3>
<p class="text-gray-600 dark:text-dark-400 text-sm leading-relaxed">
{{ t('home.features.balanceQuotaDesc') }}
</p>
</div>
</div>
<!-- Supported Providers -->
<div class="text-center mb-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-3">{{ t('home.providers.title') }}</h2>
<p class="text-gray-600 dark:text-dark-400 text-sm">
{{ t('home.providers.description') }}
</p>
</div>
<div class="flex flex-wrap items-center justify-center gap-4 mb-16">
<!-- Claude - Supported -->
<div class="flex items-center gap-2 px-5 py-3 rounded-xl bg-white/60 dark:bg-dark-800/60 backdrop-blur-sm border border-primary-200 dark:border-primary-800 ring-1 ring-primary-500/20">
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-orange-400 to-orange-500 flex items-center justify-center">
<span class="text-white text-xs font-bold">C</span>
</div>
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">Claude</span>
<span class="px-1.5 py-0.5 text-[10px] font-medium bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded">{{ t('home.providers.supported') }}</span>
</div>
<!-- GPT - Coming Soon -->
<div class="flex items-center gap-2 px-5 py-3 rounded-xl bg-white/40 dark:bg-dark-800/40 backdrop-blur-sm border border-gray-200/50 dark:border-dark-700/50 opacity-60">
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center">
<span class="text-white text-xs font-bold">G</span>
</div>
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">GPT</span>
<span class="px-1.5 py-0.5 text-[10px] font-medium bg-gray-100 dark:bg-dark-700 text-gray-500 dark:text-dark-400 rounded">{{ t('home.providers.soon') }}</span>
</div>
<!-- Gemini - Coming Soon -->
<div class="flex items-center gap-2 px-5 py-3 rounded-xl bg-white/40 dark:bg-dark-800/40 backdrop-blur-sm border border-gray-200/50 dark:border-dark-700/50 opacity-60">
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center">
<span class="text-white text-xs font-bold">G</span>
</div>
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">Gemini</span>
<span class="px-1.5 py-0.5 text-[10px] font-medium bg-gray-100 dark:bg-dark-700 text-gray-500 dark:text-dark-400 rounded">{{ t('home.providers.soon') }}</span>
</div>
<!-- More - Coming Soon -->
<div class="flex items-center gap-2 px-5 py-3 rounded-xl bg-white/40 dark:bg-dark-800/40 backdrop-blur-sm border border-gray-200/50 dark:border-dark-700/50 opacity-60">
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-gray-500 to-gray-600 flex items-center justify-center">
<span class="text-white text-xs font-bold">+</span>
</div>
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">More</span>
<span class="px-1.5 py-0.5 text-[10px] font-medium bg-gray-100 dark:bg-dark-700 text-gray-500 dark:text-dark-400 rounded">{{ t('home.providers.soon') }}</span>
</div>
</div>
</div>
</main>
<!-- Footer -->
<footer class="relative z-10 px-6 py-8 border-t border-gray-200/50 dark:border-dark-800/50">
<div class="max-w-6xl mx-auto text-center">
<p class="text-sm text-gray-500 dark:text-dark-400">
&copy; {{ currentYear }} {{ siteName }}. {{ t('home.footer.allRightsReserved') }}
</p>
</div>
</footer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { getPublicSettings } from '@/api/auth';
import { useAuthStore } from '@/stores';
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue';
const { t } = useI18n();
const authStore = useAuthStore();
// Site settings
const siteName = ref('Sub2API');
const siteLogo = ref('');
const siteSubtitle = ref('AI API Gateway Platform');
// Theme
const isDark = ref(document.documentElement.classList.contains('dark'));
// GitHub URL
const githubUrl = 'https://github.com/fangyuan99/sub2api';
// Auth state
const isAuthenticated = computed(() => authStore.isAuthenticated);
const userInitial = computed(() => {
const user = authStore.user;
if (!user || !user.email) return '';
return user.email.charAt(0).toUpperCase();
});
// Current year for footer
const currentYear = computed(() => new Date().getFullYear());
// Toggle theme
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)) {
isDark.value = true;
document.documentElement.classList.add('dark');
}
}
onMounted(async () => {
initTheme();
// Check auth state
authStore.checkAuth();
try {
const settings = await getPublicSettings();
siteName.value = settings.site_name || 'Sub2API';
siteLogo.value = settings.site_logo || '';
siteSubtitle.value = settings.site_subtitle || 'AI API Gateway Platform';
} catch (error) {
console.error('Failed to load public settings:', error);
}
});
</script>
<style scoped>
/* Terminal Container */
.terminal-container {
position: relative;
display: inline-block;
}
/* Terminal Window */
.terminal-window {
width: 420px;
background: linear-gradient(145deg, #1e293b 0%, #0f172a 100%);
border-radius: 14px;
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);
overflow: hidden;
transform: perspective(1000px) rotateX(2deg) rotateY(-2deg);
transition: transform 0.3s ease;
}
.terminal-window:hover {
transform: perspective(1000px) rotateX(0deg) rotateY(0deg) translateY(-4px);
}
/* 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);
}
.terminal-buttons {
display: flex;
gap: 8px;
}
.terminal-buttons span {
width: 12px;
height: 12px;
border-radius: 50%;
}
.btn-close { background: #ef4444; }
.btn-minimize { background: #eab308; }
.btn-maximize { background: #22c55e; }
.terminal-title {
flex: 1;
text-align: center;
font-size: 12px;
font-family: ui-monospace, monospace;
color: #64748b;
margin-right: 52px;
}
/* Terminal Body */
.terminal-body {
padding: 20px 24px;
font-family: ui-monospace, 'Fira Code', monospace;
font-size: 14px;
line-height: 2;
}
.code-line {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
opacity: 0;
animation: line-appear 0.5s ease forwards;
}
.line-1 { animation-delay: 0.3s; }
.line-2 { animation-delay: 1s; }
.line-3 { animation-delay: 1.8s; }
.line-4 { animation-delay: 2.5s; }
@keyframes line-appear {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
.code-prompt { color: #22c55e; font-weight: bold; }
.code-cmd { color: #38bdf8; }
.code-flag { color: #a78bfa; }
.code-url { color: #14b8a6; }
.code-comment { color: #64748b; font-style: italic; }
.code-success {
color: #22c55e;
background: rgba(34, 197, 94, 0.15);
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
}
.code-response { color: #fbbf24; }
/* Blinking Cursor */
.cursor {
display: inline-block;
width: 8px;
height: 16px;
background: #22c55e;
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
/* 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);
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-dark-950 px-4 relative overflow-hidden">
<!-- Background Decoration -->
<div class="absolute inset-0 overflow-hidden pointer-events-none">
<div class="absolute -top-40 -right-40 w-80 h-80 bg-primary-400/10 rounded-full blur-3xl"></div>
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-primary-500/10 rounded-full blur-3xl"></div>
</div>
<div class="max-w-md w-full text-center relative z-10">
<!-- 404 Display -->
<div class="mb-8">
<div class="relative inline-block">
<span class="text-[12rem] font-bold text-gray-100 dark:text-dark-800 leading-none">404</span>
<div class="absolute inset-0 flex items-center justify-center">
<div class="w-24 h-24 rounded-2xl bg-gradient-to-br from-primary-500 to-primary-600 shadow-lg shadow-primary-500/30 flex items-center justify-center">
<svg class="w-12 h-12 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div>
</div>
</div>
</div>
<!-- Text Content -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-3">
Page Not Found
</h1>
<p class="text-gray-500 dark:text-dark-400">
The page you are looking for doesn't exist or has been moved.
</p>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-3 justify-center">
<button
@click="goBack"
class="btn btn-secondary"
>
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
</svg>
Go Back
</button>
<router-link
to="/dashboard"
class="btn btn-primary"
>
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg>
Go to Dashboard
</router-link>
</div>
<!-- Help Link -->
<p class="mt-8 text-sm text-gray-400 dark:text-dark-500">
Need help?
<a href="#" class="text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300 transition-colors">
Contact support
</a>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router';
const router = useRouter();
function goBack(): void {
router.back();
}
</script>

View File

@@ -0,0 +1,523 @@
<template>
<AppLayout>
<div class="space-y-6">
<!-- Page Header Actions -->
<div class="flex justify-end">
<button
@click="showCreateModal = true"
class="btn btn-primary"
>
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{{ t('admin.accounts.createAccount') }}
</button>
</div>
<!-- Search and Filters -->
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="relative flex-1 max-w-md">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<input
v-model="searchQuery"
type="text"
:placeholder="t('admin.accounts.searchAccounts')"
class="input pl-10"
@input="handleSearch"
/>
</div>
<div class="flex flex-wrap gap-3">
<Select
v-model="filters.platform"
:options="platformOptions"
:placeholder="t('admin.accounts.allPlatforms')"
class="w-40"
@change="loadAccounts"
/>
<Select
v-model="filters.type"
:options="typeOptions"
:placeholder="t('admin.accounts.allTypes')"
class="w-40"
@change="loadAccounts"
/>
<Select
v-model="filters.status"
:options="statusOptions"
:placeholder="t('admin.accounts.allStatus')"
class="w-36"
@change="loadAccounts"
/>
</div>
</div>
<!-- Accounts Table -->
<div class="card overflow-hidden">
<DataTable :columns="columns" :data="accounts" :loading="loading">
<template #cell-name="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template>
<template #cell-platform="{ value }">
<div class="flex items-center gap-2">
<span
:class="[
'w-2 h-2 rounded-full',
value === 'anthropic' ? 'bg-orange-500' : 'bg-gray-400'
]"
/>
<span class="text-sm text-gray-700 dark:text-gray-300 capitalize">{{ value === 'anthropic' ? 'Anthropic' : value }}</span>
</div>
</template>
<template #cell-type="{ value }">
<span
:class="[
'badge',
value === 'oauth' ? 'badge-primary' : value === 'setup-token' ? 'badge-info' : 'badge-purple'
]"
>
{{ value === 'oauth' ? 'Oauth' : value === 'setup-token' ? t('admin.accounts.setupToken') : t('admin.accounts.apiKey') }}
</span>
</template>
<template #cell-status="{ row }">
<AccountStatusIndicator :account="row" />
</template>
<template #cell-schedulable="{ row }">
<button
@click="handleToggleSchedulable(row)"
:disabled="togglingSchedulable === row.id"
class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-dark-800 disabled:opacity-50 disabled:cursor-not-allowed"
:class="[
row.schedulable
? 'bg-primary-500 hover:bg-primary-600'
: 'bg-gray-200 dark:bg-dark-600 hover:bg-gray-300 dark:hover:bg-dark-500'
]"
:title="row.schedulable ? t('admin.accounts.schedulableEnabled') : t('admin.accounts.schedulableDisabled')"
>
<span
class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="[row.schedulable ? 'translate-x-4' : 'translate-x-0']"
/>
</button>
</template>
<template #cell-today_stats="{ row }">
<AccountTodayStatsCell :account="row" />
</template>
<template #cell-usage="{ row }">
<AccountUsageCell :account="row" />
</template>
<template #cell-priority="{ value }">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ value }}</span>
</template>
<template #cell-last_used_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">
{{ formatRelativeTime(value) }}
</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-1">
<!-- Clear Rate Limit button -->
<button
v-if="isRateLimited(row) || isOverloaded(row)"
@click="handleClearRateLimit(row)"
class="p-2 rounded-lg hover:bg-amber-50 dark:hover:bg-amber-900/20 text-amber-500 hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
:title="t('admin.accounts.clearRateLimit')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<!-- Test Connection button -->
<button
@click="handleTest(row)"
class="p-2 rounded-lg hover:bg-green-50 dark:hover:bg-green-900/20 text-gray-500 hover:text-green-600 dark:hover:text-green-400 transition-colors"
:title="t('admin.accounts.testConnection')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z" />
</svg>
</button>
<button
v-if="row.type === 'oauth' || row.type === 'setup-token'"
@click="handleReAuth(row)"
class="p-2 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/20 text-gray-500 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
:title="t('admin.accounts.reAuthorize')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
</svg>
</button>
<button
v-if="row.type === 'oauth' || row.type === 'setup-token'"
@click="handleRefreshToken(row)"
class="p-2 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900/20 text-gray-500 hover:text-purple-600 dark:hover:text-purple-400 transition-colors"
:title="t('admin.accounts.refreshToken')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
</button>
<button
@click="handleEdit(row)"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-dark-700 text-gray-500 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
:title="t('common.edit')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
</button>
<button
@click="handleDelete(row)"
class="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-gray-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
:title="t('common.delete')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
</div>
</template>
<template #empty>
<EmptyState
:title="t('admin.accounts.noAccountsYet')"
:description="t('admin.accounts.createFirstAccount')"
:action-text="t('admin.accounts.createAccount')"
@action="showCreateModal = true"
/>
</template>
</DataTable>
</div>
<!-- Pagination -->
<Pagination
v-if="pagination.total > 0"
:page="pagination.page"
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
/>
</div>
<!-- Create Account Modal -->
<CreateAccountModal
:show="showCreateModal"
:proxies="proxies"
:groups="groups"
@close="showCreateModal = false"
@created="loadAccounts"
/>
<!-- Edit Account Modal -->
<EditAccountModal
:show="showEditModal"
:account="editingAccount"
:proxies="proxies"
:groups="groups"
@close="closeEditModal"
@updated="loadAccounts"
/>
<!-- Re-Auth Modal -->
<ReAuthAccountModal
:show="showReAuthModal"
:account="reAuthAccount"
@close="closeReAuthModal"
@reauthorized="loadAccounts"
/>
<!-- Test Account Modal -->
<AccountTestModal
:show="showTestModal"
:account="testingAccount"
@close="closeTestModal"
/>
<!-- Delete Confirmation Dialog -->
<ConfirmDialog
:show="showDeleteDialog"
:title="t('admin.accounts.deleteAccount')"
:message="t('admin.accounts.deleteConfirm', { name: deletingAccount?.name })"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="confirmDelete"
@cancel="showDeleteDialog = false"
/>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { Account, Proxy, Group } from '@/types'
import type { Column } from '@/components/common/DataTable.vue'
import AppLayout from '@/components/layout/AppLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
import { CreateAccountModal, EditAccountModal, ReAuthAccountModal } from '@/components/account'
import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue'
import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
import AccountTestModal from '@/components/account/AccountTestModal.vue'
import { formatRelativeTime } from '@/utils/format'
const { t } = useI18n()
const appStore = useAppStore()
// Table columns
const columns = computed<Column[]>(() => [
{ key: 'name', label: t('admin.accounts.columns.name'), sortable: true },
{ key: 'platform', label: t('admin.accounts.columns.platform'), sortable: true },
{ key: 'type', label: t('admin.accounts.columns.type'), sortable: true },
{ key: 'status', label: t('admin.accounts.columns.status'), sortable: true },
{ key: 'schedulable', label: t('admin.accounts.columns.schedulable'), sortable: true },
{ key: 'today_stats', label: t('admin.accounts.columns.todayStats'), sortable: false },
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
{ key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false }
])
// Filter options
const platformOptions = computed(() => [
{ value: '', label: t('admin.accounts.allPlatforms') },
{ value: 'anthropic', label: t('admin.accounts.platforms.anthropic') }
])
const typeOptions = computed(() => [
{ value: '', label: t('admin.accounts.allTypes') },
{ value: 'oauth', label: t('admin.accounts.oauthType') },
{ value: 'setup-token', label: t('admin.accounts.setupToken') },
{ value: 'apikey', label: t('admin.accounts.apiKey') }
])
const statusOptions = computed(() => [
{ value: '', label: t('admin.accounts.allStatus') },
{ value: 'active', label: t('common.active') },
{ value: 'inactive', label: t('common.inactive') },
{ value: 'error', label: t('common.error') }
])
// State
const accounts = ref<Account[]>([])
const proxies = ref<Proxy[]>([])
const groups = ref<Group[]>([])
const loading = ref(false)
const searchQuery = ref('')
const filters = reactive({
platform: '',
type: '',
status: '',
})
const pagination = reactive({
page: 1,
page_size: 20,
total: 0,
pages: 0
})
// Modal states
const showCreateModal = ref(false)
const showEditModal = ref(false)
const showReAuthModal = ref(false)
const showDeleteDialog = ref(false)
const showTestModal = ref(false)
const editingAccount = ref<Account | null>(null)
const reAuthAccount = ref<Account | null>(null)
const deletingAccount = ref<Account | null>(null)
const testingAccount = ref<Account | null>(null)
const togglingSchedulable = ref<number | null>(null)
// Rate limit / Overload helpers
const isRateLimited = (account: Account): boolean => {
if (!account.rate_limit_reset_at) return false
return new Date(account.rate_limit_reset_at) > new Date()
}
const isOverloaded = (account: Account): boolean => {
if (!account.overload_until) return false
return new Date(account.overload_until) > new Date()
}
// Data loading
const loadAccounts = async () => {
loading.value = true
try {
const response = await adminAPI.accounts.list(
pagination.page,
pagination.page_size,
{
platform: filters.platform || undefined,
type: filters.type || undefined,
status: filters.status || undefined,
search: searchQuery.value || undefined
}
)
accounts.value = response.items
pagination.total = response.total
pagination.pages = response.pages
} catch (error) {
appStore.showError(t('admin.accounts.failedToLoad'))
console.error('Error loading accounts:', error)
} finally {
loading.value = false
}
}
const loadProxies = async () => {
try {
proxies.value = await adminAPI.proxies.getAllWithCount()
} catch (error) {
console.error('Error loading proxies:', error)
}
}
const loadGroups = async () => {
try {
groups.value = await adminAPI.groups.getByPlatform('anthropic')
} catch (error) {
console.error('Error loading groups:', error)
}
}
// Search handling
let searchTimeout: ReturnType<typeof setTimeout>
const handleSearch = () => {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
pagination.page = 1
loadAccounts()
}, 300)
}
// Pagination
const handlePageChange = (page: number) => {
pagination.page = page
loadAccounts()
}
// Edit modal
const handleEdit = (account: Account) => {
editingAccount.value = account
showEditModal.value = true
}
const closeEditModal = () => {
showEditModal.value = false
editingAccount.value = null
}
// Re-Auth modal
const handleReAuth = (account: Account) => {
reAuthAccount.value = account
showReAuthModal.value = true
}
const closeReAuthModal = () => {
showReAuthModal.value = false
reAuthAccount.value = null
}
// Token refresh
const handleRefreshToken = async (account: Account) => {
try {
await adminAPI.accounts.refreshCredentials(account.id)
appStore.showSuccess(t('admin.accounts.tokenRefreshed'))
loadAccounts()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToRefresh'))
console.error('Error refreshing token:', error)
}
}
// Delete
const handleDelete = (account: Account) => {
deletingAccount.value = account
showDeleteDialog.value = true
}
const confirmDelete = async () => {
if (!deletingAccount.value) return
try {
await adminAPI.accounts.delete(deletingAccount.value.id)
appStore.showSuccess(t('admin.accounts.accountDeleted'))
showDeleteDialog.value = false
deletingAccount.value = null
loadAccounts()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToDelete'))
console.error('Error deleting account:', error)
}
}
// Clear rate limit
const handleClearRateLimit = async (account: Account) => {
try {
await adminAPI.accounts.clearRateLimit(account.id)
appStore.showSuccess(t('admin.accounts.rateLimitCleared'))
loadAccounts()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToClearRateLimit'))
console.error('Error clearing rate limit:', error)
}
}
// Toggle schedulable
const handleToggleSchedulable = async (account: Account) => {
togglingSchedulable.value = account.id
try {
const updatedAccount = await adminAPI.accounts.setSchedulable(account.id, !account.schedulable)
const index = accounts.value.findIndex(a => a.id === account.id)
if (index !== -1) {
accounts.value[index] = updatedAccount
}
appStore.showSuccess(
updatedAccount.schedulable
? t('admin.accounts.schedulableEnabled')
: t('admin.accounts.schedulableDisabled')
)
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToToggleSchedulable'))
console.error('Error toggling schedulable:', error)
} finally {
togglingSchedulable.value = null
}
}
// Test modal
const handleTest = (account: Account) => {
testingAccount.value = account
showTestModal.value = true
}
const closeTestModal = () => {
showTestModal.value = false
testingAccount.value = null
}
// Initialize
onMounted(() => {
loadAccounts()
loadProxies()
loadGroups()
})
</script>

View File

@@ -0,0 +1,619 @@
<template>
<AppLayout>
<div class="space-y-6">
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-12">
<LoadingSpinner />
</div>
<template v-else-if="stats">
<!-- Row 1: Core Stats -->
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<!-- Total API Keys -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/30">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.apiKeys') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ stats.total_api_keys }}</p>
<p class="text-xs text-green-600 dark:text-green-400">{{ stats.active_api_keys }} {{ t('common.active') }}</p>
</div>
</div>
</div>
<!-- Service Accounts -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-purple-100 dark:bg-purple-900/30">
<svg class="w-5 h-5 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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" />
</svg>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.accounts') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ stats.total_accounts }}</p>
<p class="text-xs">
<span class="text-green-600 dark:text-green-400">{{ stats.normal_accounts }} {{ t('common.active') }}</span>
<span v-if="stats.error_accounts > 0" class="text-red-500 ml-1">{{ stats.error_accounts }} {{ t('common.error') }}</span>
</p>
</div>
</div>
</div>
<!-- Today Requests -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-green-100 dark:bg-green-900/30">
<svg class="w-5 h-5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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" />
</svg>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.todayRequests') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ stats.today_requests }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('common.total') }}: {{ formatNumber(stats.total_requests) }}</p>
</div>
</div>
</div>
<!-- New Users Today -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-emerald-100 dark:bg-emerald-900/30">
<svg class="w-5 h-5 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.users') }}</p>
<p class="text-xl font-bold text-emerald-600 dark:text-emerald-400">+{{ stats.today_new_users }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('common.total') }}: {{ formatNumber(stats.total_users) }}</p>
</div>
</div>
</div>
</div>
<!-- Row 2: Token Stats -->
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<!-- Today Tokens -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-amber-100 dark:bg-amber-900/30">
<svg class="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />
</svg>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.todayTokens') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats.today_tokens) }}</p>
<p class="text-xs">
<span class="text-amber-600 dark:text-amber-400" :title="t('admin.dashboard.actual')">${{ formatCost(stats.today_actual_cost) }}</span>
<span class="text-gray-400 dark:text-gray-500" :title="t('admin.dashboard.standard')"> / ${{ formatCost(stats.today_cost) }}</span>
</p>
</div>
</div>
</div>
<!-- Total Tokens -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-indigo-100 dark:bg-indigo-900/30">
<svg class="w-5 h-5 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
</svg>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.totalTokens') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats.total_tokens) }}</p>
<p class="text-xs">
<span class="text-indigo-600 dark:text-indigo-400" :title="t('admin.dashboard.actual')">${{ formatCost(stats.total_actual_cost) }}</span>
<span class="text-gray-400 dark:text-gray-500" :title="t('admin.dashboard.standard')"> / ${{ formatCost(stats.total_cost) }}</span>
</p>
</div>
</div>
</div>
<!-- Cache Tokens -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-cyan-100 dark:bg-cyan-900/30">
<svg class="w-5 h-5 text-cyan-600 dark:text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m.75 12l3 3m0 0l3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.cacheToday') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats.today_cache_read_tokens) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('common.create') }}: {{ formatTokens(stats.today_cache_creation_tokens) }}
</p>
</div>
</div>
</div>
<!-- Avg Response Time -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-rose-100 dark:bg-rose-900/30">
<svg class="w-5 h-5 text-rose-600 dark:text-rose-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.avgResponse') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatDuration(stats.average_duration_ms) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ stats.active_users }} {{ t('admin.dashboard.activeUsers') }}</p>
</div>
</div>
</div>
</div>
<!-- Charts Section -->
<div class="space-y-6">
<!-- Date Range Filter -->
<div class="card p-4">
<div class="flex flex-wrap items-center gap-4">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.dashboard.timeRange') }}:</span>
<DateRangePicker
v-model:start-date="startDate"
v-model:end-date="endDate"
@change="onDateRangeChange"
/>
</div>
<div class="flex items-center gap-2 ml-auto">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.dashboard.granularity') }}:</span>
<div class="w-28">
<Select
v-model="granularity"
:options="granularityOptions"
@change="loadChartData"
/>
</div>
</div>
</div>
</div>
<!-- Charts Grid -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- Model Distribution Chart -->
<div class="card p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.dashboard.modelDistribution') }}</h3>
<div class="flex items-center gap-6">
<div class="w-48 h-48">
<Doughnut v-if="modelChartData" :data="modelChartData" :options="doughnutOptions" />
<div v-else class="flex items-center justify-center h-full text-gray-500 dark:text-gray-400 text-sm">
{{ t('admin.dashboard.noDataAvailable') }}
</div>
</div>
<div class="flex-1 max-h-48 overflow-y-auto">
<table class="w-full text-xs">
<thead>
<tr class="text-gray-500 dark:text-gray-400">
<th class="text-left pb-2">{{ t('admin.dashboard.model') }}</th>
<th class="text-right pb-2">{{ t('admin.dashboard.requests') }}</th>
<th class="text-right pb-2">{{ t('admin.dashboard.tokens') }}</th>
<th class="text-right pb-2">{{ t('admin.dashboard.actual') }}</th>
<th class="text-right pb-2">{{ t('admin.dashboard.standard') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="model in modelStats" :key="model.model" class="border-t border-gray-100 dark:border-gray-700">
<td class="py-1.5 text-gray-900 dark:text-white font-medium truncate max-w-[100px]" :title="model.model">{{ model.model }}</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatNumber(model.requests) }}</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatTokens(model.total_tokens) }}</td>
<td class="py-1.5 text-right text-green-600 dark:text-green-400">${{ formatCost(model.actual_cost) }}</td>
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">${{ formatCost(model.cost) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Token Usage Trend Chart -->
<div class="card p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.dashboard.tokenUsageTrend') }}</h3>
<div class="h-48">
<Line v-if="trendChartData" :data="trendChartData" :options="lineOptions" />
<div v-else class="flex items-center justify-center h-full text-gray-500 dark:text-gray-400 text-sm">
{{ t('admin.dashboard.noDataAvailable') }}
</div>
</div>
</div>
</div>
<!-- User Usage Trend (Full Width) -->
<div class="card p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.dashboard.recentUsage') }} (Top 12)</h3>
<div class="h-64">
<Line v-if="userTrendChartData" :data="userTrendChartData" :options="lineOptions" />
<div v-else class="flex items-center justify-center h-full text-gray-500 dark:text-gray-400 text-sm">
{{ t('admin.dashboard.noDataAvailable') }}
</div>
</div>
</div>
</div>
</template>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
const { t } = useI18n()
import { adminAPI } from '@/api/admin'
import type { DashboardStats, TrendDataPoint, ModelStat, UserUsageTrendPoint } from '@/types'
import AppLayout from '@/components/layout/AppLayout.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import DateRangePicker from '@/components/common/DateRangePicker.vue'
import Select from '@/components/common/Select.vue'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js'
import { Line, Doughnut } from 'vue-chartjs'
// Register Chart.js components
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler
)
const appStore = useAppStore()
const stats = ref<DashboardStats | null>(null)
const loading = ref(false)
// Chart data
const trendData = ref<TrendDataPoint[]>([])
const modelStats = ref<ModelStat[]>([])
const userTrend = ref<UserUsageTrendPoint[]>([])
// Date range
const granularity = ref<'day' | 'hour'>('day')
const startDate = ref('')
const endDate = ref('')
// Granularity options for Select component
const granularityOptions = computed(() => [
{ value: 'day', label: t('admin.dashboard.day') },
{ value: 'hour', label: t('admin.dashboard.hour') },
])
// Dark mode detection
const isDarkMode = computed(() => {
return document.documentElement.classList.contains('dark')
})
// Chart colors
const chartColors = computed(() => ({
text: isDarkMode.value ? '#e5e7eb' : '#374151',
grid: isDarkMode.value ? '#374151' : '#e5e7eb',
input: '#3b82f6',
output: '#10b981',
cache: '#f59e0b',
total: '#8b5cf6',
}))
// Doughnut chart options
const doughnutOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
tooltip: {
callbacks: {
label: (context: any) => {
const value = context.raw as number
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
const percentage = ((value / total) * 100).toFixed(1)
return `${context.label}: ${formatTokens(value)} (${percentage}%)`
},
},
},
},
}))
// Line chart options
const lineOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index' as const,
},
plugins: {
legend: {
position: 'top' as const,
labels: {
color: chartColors.value.text,
usePointStyle: true,
pointStyle: 'circle',
padding: 15,
font: {
size: 11,
},
},
},
tooltip: {
callbacks: {
label: (context: any) => {
return `${context.dataset.label}: ${formatTokens(context.raw)}`
},
footer: (tooltipItems: any) => {
// Show both costs for the day if we have trend data
const dataIndex = tooltipItems[0]?.dataIndex
if (dataIndex !== undefined && trendData.value[dataIndex]) {
const data = trendData.value[dataIndex]
return `Actual: $${formatCost(data.actual_cost)} | Standard: $${formatCost(data.cost)}`
}
return ''
},
},
},
},
scales: {
x: {
grid: {
color: chartColors.value.grid,
},
ticks: {
color: chartColors.value.text,
font: {
size: 10,
},
},
},
y: {
grid: {
color: chartColors.value.grid,
},
ticks: {
color: chartColors.value.text,
font: {
size: 10,
},
callback: (value: number) => formatTokens(value),
},
},
},
}))
// Model chart data
const modelChartData = computed(() => {
if (!modelStats.value.length) return null
const colors = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16'
]
return {
labels: modelStats.value.map(m => m.model),
datasets: [{
data: modelStats.value.map(m => m.total_tokens),
backgroundColor: colors.slice(0, modelStats.value.length),
borderWidth: 0,
}],
}
})
// Trend chart data
const trendChartData = computed(() => {
if (!trendData.value.length) return null
return {
labels: trendData.value.map(d => d.date),
datasets: [
{
label: 'Input',
data: trendData.value.map(d => d.input_tokens),
borderColor: chartColors.value.input,
backgroundColor: `${chartColors.value.input}20`,
fill: true,
tension: 0.3,
},
{
label: 'Output',
data: trendData.value.map(d => d.output_tokens),
borderColor: chartColors.value.output,
backgroundColor: `${chartColors.value.output}20`,
fill: true,
tension: 0.3,
},
{
label: 'Cache',
data: trendData.value.map(d => d.cache_tokens),
borderColor: chartColors.value.cache,
backgroundColor: `${chartColors.value.cache}20`,
fill: true,
tension: 0.3,
},
],
}
})
// User trend chart data
const userTrendChartData = computed(() => {
if (!userTrend.value.length) return null
// Group by user
const userGroups = new Map<string, { name: string; data: Map<string, number> }>()
const allDates = new Set<string>()
userTrend.value.forEach(point => {
allDates.add(point.date)
const key = point.username || `User #${point.user_id}`
if (!userGroups.has(key)) {
userGroups.set(key, { name: key, data: new Map() })
}
userGroups.get(key)!.data.set(point.date, point.tokens)
})
const sortedDates = Array.from(allDates).sort()
const colors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16', '#06b6d4', '#a855f7']
const datasets = Array.from(userGroups.values()).map((group, idx) => ({
label: group.name,
data: sortedDates.map(date => group.data.get(date) || 0),
borderColor: colors[idx % colors.length],
backgroundColor: `${colors[idx % colors.length]}20`,
fill: false,
tension: 0.3,
}))
return {
labels: sortedDates,
datasets,
}
})
// Format helpers
const formatTokens = (value: number): string => {
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(2)}B`
} else if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(2)}M`
} else if (value >= 1_000) {
return `${(value / 1_000).toFixed(2)}K`
}
return value.toLocaleString()
}
const formatNumber = (value: number): string => {
return value.toLocaleString()
}
const formatCost = (value: number): string => {
if (value >= 1000) {
return (value / 1000).toFixed(2) + 'K'
} else if (value >= 1) {
return value.toFixed(2)
} else if (value >= 0.01) {
return value.toFixed(3)
}
return value.toFixed(4)
}
const formatDuration = (ms: number): string => {
if (ms >= 1000) {
return `${(ms / 1000).toFixed(2)}s`
}
return `${Math.round(ms)}ms`
}
// Date range change handler
const onDateRangeChange = (range: { startDate: string; endDate: string; preset: string | null }) => {
// Auto-select granularity based on date range
const start = new Date(range.startDate)
const end = new Date(range.endDate)
const daysDiff = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))
// If range is 1 day, use hourly granularity
if (daysDiff <= 1) {
granularity.value = 'hour'
} else {
granularity.value = 'day'
}
loadChartData()
}
// Initialize default date range
const initializeDateRange = () => {
const now = new Date()
const today = now.toISOString().split('T')[0]
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
startDate.value = weekAgo.toISOString().split('T')[0]
endDate.value = today
granularity.value = 'day'
}
// Load data
const loadDashboardStats = async () => {
loading.value = true
try {
stats.value = await adminAPI.dashboard.getStats()
} catch (error) {
appStore.showError(t('admin.dashboard.failedToLoad'))
console.error('Error loading dashboard stats:', error)
} finally {
loading.value = false
}
}
const loadChartData = async () => {
try {
const params = {
start_date: startDate.value,
end_date: endDate.value,
granularity: granularity.value,
}
const [trendResponse, modelResponse, userResponse] = await Promise.all([
adminAPI.dashboard.getUsageTrend(params),
adminAPI.dashboard.getModelStats({ start_date: startDate.value, end_date: endDate.value }),
adminAPI.dashboard.getUserUsageTrend({ ...params, limit: 12 }),
])
trendData.value = trendResponse.trend || []
modelStats.value = modelResponse.models || []
userTrend.value = userResponse.trend || []
} catch (error) {
console.error('Error loading chart data:', error)
}
}
onMounted(() => {
loadDashboardStats()
initializeDateRange()
loadChartData()
})
// Watch for dark mode changes
watch(isDarkMode, () => {
// Force chart re-render on theme change
})
</script>
<style scoped>
/* Compact Select styling for dashboard */
:deep(.select-trigger) {
@apply px-3 py-1.5 text-sm rounded-lg;
}
:deep(.select-dropdown) {
@apply rounded-lg;
}
:deep(.select-option) {
@apply px-3 py-2 text-sm;
}
</style>

View File

@@ -0,0 +1,695 @@
<template>
<AppLayout>
<div class="space-y-6">
<!-- Page Header Actions -->
<div class="flex justify-end">
<button
@click="showCreateModal = true"
class="btn btn-primary"
>
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{{ t('admin.groups.createGroup') }}
</button>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-3">
<Select
v-model="filters.platform"
:options="platformFilterOptions"
placeholder="All Platforms"
class="w-44"
@change="loadGroups"
/>
<Select
v-model="filters.status"
:options="statusOptions"
placeholder="All Status"
class="w-40"
@change="loadGroups"
/>
<Select
v-model="filters.is_exclusive"
:options="exclusiveOptions"
placeholder="All Groups"
class="w-44"
@change="loadGroups"
/>
</div>
<!-- Groups Table -->
<div class="card overflow-hidden">
<DataTable :columns="columns" :data="groups" :loading="loading">
<template #cell-name="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template>
<template #cell-platform="{ value }">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
</svg>
{{ value.charAt(0).toUpperCase() + value.slice(1) }}
</span>
</template>
<template #cell-rate_multiplier="{ value }">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ value }}x</span>
</template>
<template #cell-is_exclusive="{ value }">
<span
:class="[
'badge',
value ? 'badge-primary' : 'badge-gray'
]"
>
{{ value ? t('admin.groups.exclusive') : t('admin.groups.public') }}
</span>
</template>
<template #cell-account_count="{ value }">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-dark-600 dark:text-gray-300">
{{ t('admin.groups.accountsCount', { count: value || 0 }) }}
</span>
</template>
<template #cell-status="{ value }">
<span
:class="[
'badge',
value === 'active' ? 'badge-success' : 'badge-danger'
]"
>
{{ value }}
</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-1">
<button
@click="handleEdit(row)"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-dark-700 text-gray-500 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
:title="t('common.edit')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
</button>
<button
@click="handleDelete(row)"
class="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-gray-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
:title="t('common.delete')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
</div>
</template>
<template #empty>
<EmptyState
:title="t('admin.groups.noGroupsYet')"
:description="t('admin.groups.createFirstGroup')"
:action-text="t('admin.groups.createGroup')"
@action="showCreateModal = true"
/>
</template>
</DataTable>
</div>
<!-- Pagination -->
<Pagination
v-if="pagination.total > 0"
:page="pagination.page"
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
/>
</div>
<!-- Create Group Modal -->
<Modal
:show="showCreateModal"
:title="t('admin.groups.createGroup')"
size="lg"
@close="closeCreateModal"
>
<form @submit.prevent="handleCreateGroup" class="space-y-5">
<div>
<label class="input-label">{{ t('admin.groups.form.name') }}</label>
<input
v-model="createForm.name"
type="text"
required
class="input"
:placeholder="t('admin.groups.enterGroupName')"
/>
</div>
<div>
<label class="input-label">{{ t('admin.groups.form.description') }}</label>
<textarea
v-model="createForm.description"
rows="3"
class="input"
:placeholder="t('admin.groups.optionalDescription')"
></textarea>
</div>
<div>
<label class="input-label">{{ t('admin.groups.form.platform') }}</label>
<Select
v-model="createForm.platform"
:options="platformOptions"
/>
<p class="input-hint">{{ t('admin.groups.platformHint') }}</p>
</div>
<div v-if="createForm.subscription_type !== 'subscription'">
<label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label>
<input
v-model.number="createForm.rate_multiplier"
type="number"
step="0.1"
min="0.1"
required
class="input"
/>
<p class="input-hint">{{ t('admin.groups.rateMultiplierHint') }}</p>
</div>
<div class="flex items-center gap-3">
<button
type="button"
@click="createForm.is_exclusive = !createForm.is_exclusive"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
createForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform shadow',
createForm.is_exclusive ? 'translate-x-6' : 'translate-x-1'
]"
/>
</button>
<label class="text-sm text-gray-700 dark:text-gray-300">
{{ t('admin.groups.exclusiveHint') }}
</label>
</div>
<!-- Subscription Configuration -->
<div class="border-t pt-4 mt-4">
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-4">{{ t('admin.groups.subscription.title') }}</h4>
<div class="mb-4">
<label class="input-label">{{ t('admin.groups.subscription.type') }}</label>
<Select
v-model="createForm.subscription_type"
:options="subscriptionTypeOptions"
/>
<p class="input-hint">{{ t('admin.groups.subscription.typeHint') }}</p>
</div>
<!-- Subscription limits (only show when subscription type is selected) -->
<div v-if="createForm.subscription_type === 'subscription'" class="space-y-4 pl-4 border-l-2 border-primary-200 dark:border-primary-800">
<div>
<label class="input-label">{{ t('admin.groups.subscription.dailyLimit') }}</label>
<input
v-model.number="createForm.daily_limit_usd"
type="number"
step="0.01"
min="0"
class="input"
:placeholder="t('admin.groups.subscription.noLimit')"
/>
</div>
<div>
<label class="input-label">{{ t('admin.groups.subscription.weeklyLimit') }}</label>
<input
v-model.number="createForm.weekly_limit_usd"
type="number"
step="0.01"
min="0"
class="input"
:placeholder="t('admin.groups.subscription.noLimit')"
/>
</div>
<div>
<label class="input-label">{{ t('admin.groups.subscription.monthlyLimit') }}</label>
<input
v-model.number="createForm.monthly_limit_usd"
type="number"
step="0.01"
min="0"
class="input"
:placeholder="t('admin.groups.subscription.noLimit')"
/>
</div>
</div>
</div>
<div class="flex justify-end gap-3 pt-4">
<button
@click="closeCreateModal"
type="button"
class="btn btn-secondary"
>
{{ t('common.cancel') }}
</button>
<button
type="submit"
:disabled="submitting"
class="btn btn-primary"
>
<svg
v-if="submitting"
class="animate-spin -ml-1 mr-2 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ submitting ? t('admin.groups.creating') : t('common.create') }}
</button>
</div>
</form>
</Modal>
<!-- Edit Group Modal -->
<Modal
:show="showEditModal"
:title="t('admin.groups.editGroup')"
size="lg"
@close="closeEditModal"
>
<form v-if="editingGroup" @submit.prevent="handleUpdateGroup" class="space-y-5">
<div>
<label class="input-label">{{ t('admin.groups.form.name') }}</label>
<input
v-model="editForm.name"
type="text"
required
class="input"
/>
</div>
<div>
<label class="input-label">{{ t('admin.groups.form.description') }}</label>
<textarea
v-model="editForm.description"
rows="3"
class="input"
></textarea>
</div>
<div>
<label class="input-label">{{ t('admin.groups.form.platform') }}</label>
<Select
v-model="editForm.platform"
:options="platformOptions"
/>
</div>
<div v-if="editForm.subscription_type !== 'subscription'">
<label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label>
<input
v-model.number="editForm.rate_multiplier"
type="number"
step="0.1"
min="0.1"
required
class="input"
/>
</div>
<div class="flex items-center gap-3">
<button
type="button"
@click="editForm.is_exclusive = !editForm.is_exclusive"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
editForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform shadow',
editForm.is_exclusive ? 'translate-x-6' : 'translate-x-1'
]"
/>
</button>
<label class="text-sm text-gray-700 dark:text-gray-300">
{{ t('admin.groups.exclusiveHint') }}
</label>
</div>
<div>
<label class="input-label">{{ t('admin.groups.form.status') }}</label>
<Select
v-model="editForm.status"
:options="editStatusOptions"
/>
</div>
<!-- Subscription Configuration -->
<div class="border-t pt-4 mt-4">
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-4">{{ t('admin.groups.subscription.title') }}</h4>
<div class="mb-4">
<label class="input-label">{{ t('admin.groups.subscription.type') }}</label>
<Select
v-model="editForm.subscription_type"
:options="subscriptionTypeOptions"
/>
<p class="input-hint">{{ t('admin.groups.subscription.typeHint') }}</p>
</div>
<!-- Subscription limits (only show when subscription type is selected) -->
<div v-if="editForm.subscription_type === 'subscription'" class="space-y-4 pl-4 border-l-2 border-primary-200 dark:border-primary-800">
<div>
<label class="input-label">{{ t('admin.groups.subscription.dailyLimit') }}</label>
<input
v-model.number="editForm.daily_limit_usd"
type="number"
step="0.01"
min="0"
class="input"
:placeholder="t('admin.groups.subscription.noLimit')"
/>
</div>
<div>
<label class="input-label">{{ t('admin.groups.subscription.weeklyLimit') }}</label>
<input
v-model.number="editForm.weekly_limit_usd"
type="number"
step="0.01"
min="0"
class="input"
:placeholder="t('admin.groups.subscription.noLimit')"
/>
</div>
<div>
<label class="input-label">{{ t('admin.groups.subscription.monthlyLimit') }}</label>
<input
v-model.number="editForm.monthly_limit_usd"
type="number"
step="0.01"
min="0"
class="input"
:placeholder="t('admin.groups.subscription.noLimit')"
/>
</div>
</div>
</div>
<div class="flex justify-end gap-3 pt-4">
<button
@click="closeEditModal"
type="button"
class="btn btn-secondary"
>
{{ t('common.cancel') }}
</button>
<button
type="submit"
:disabled="submitting"
class="btn btn-primary"
>
<svg
v-if="submitting"
class="animate-spin -ml-1 mr-2 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ submitting ? t('admin.groups.updating') : t('common.update') }}
</button>
</div>
</form>
</Modal>
<!-- Delete Confirmation Dialog -->
<ConfirmDialog
:show="showDeleteDialog"
:title="t('admin.groups.deleteGroup')"
:message="deleteConfirmMessage"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="confirmDelete"
@cancel="showDeleteDialog = false"
/>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { Group, GroupPlatform, SubscriptionType } from '@/types'
import type { Column } from '@/components/common/DataTable.vue'
import AppLayout from '@/components/layout/AppLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue'
import Modal from '@/components/common/Modal.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
const { t } = useI18n()
const appStore = useAppStore()
const columns = computed<Column[]>(() => [
{ key: 'name', label: t('admin.groups.columns.name'), sortable: true },
{ key: 'platform', label: t('admin.groups.columns.platform'), sortable: true },
{ key: 'rate_multiplier', label: t('admin.groups.columns.rateMultiplier'), sortable: true },
{ key: 'is_exclusive', label: t('admin.groups.columns.type'), sortable: true },
{ key: 'account_count', label: t('admin.groups.columns.accounts'), sortable: true },
{ key: 'status', label: t('admin.groups.columns.status'), sortable: true },
{ key: 'actions', label: t('admin.groups.columns.actions'), sortable: false }
])
// Filter options
const statusOptions = computed(() => [
{ value: '', label: t('admin.groups.allStatus') },
{ value: 'active', label: t('common.active') },
{ value: 'inactive', label: t('common.inactive') }
])
const exclusiveOptions = computed(() => [
{ value: '', label: t('admin.groups.allGroups') },
{ value: 'true', label: t('admin.groups.exclusive') },
{ value: 'false', label: t('admin.groups.nonExclusive') }
])
const platformOptions = computed(() => [
{ value: 'anthropic', label: 'Anthropic' }
// Future: { value: 'openai', label: 'OpenAI' },
// Future: { value: 'gemini', label: 'Gemini' }
])
const platformFilterOptions = computed(() => [
{ value: '', label: t('admin.groups.allPlatforms') },
{ value: 'anthropic', label: 'Anthropic' }
])
const editStatusOptions = computed(() => [
{ value: 'active', label: t('common.active') },
{ value: 'inactive', label: t('common.inactive') }
])
const subscriptionTypeOptions = computed(() => [
{ value: 'standard', label: t('admin.groups.subscription.standard') },
{ value: 'subscription', label: t('admin.groups.subscription.subscription') }
])
const groups = ref<Group[]>([])
const loading = ref(false)
const filters = reactive({
platform: '',
status: '',
is_exclusive: ''
})
const pagination = reactive({
page: 1,
page_size: 20,
total: 0,
pages: 0
})
const showCreateModal = ref(false)
const showEditModal = ref(false)
const showDeleteDialog = ref(false)
const submitting = ref(false)
const editingGroup = ref<Group | null>(null)
const deletingGroup = ref<Group | null>(null)
const createForm = reactive({
name: '',
description: '',
platform: 'anthropic' as GroupPlatform,
rate_multiplier: 1.0,
is_exclusive: false,
subscription_type: 'standard' as SubscriptionType,
daily_limit_usd: null as number | null,
weekly_limit_usd: null as number | null,
monthly_limit_usd: null as number | null
})
const editForm = reactive({
name: '',
description: '',
platform: 'anthropic' as GroupPlatform,
rate_multiplier: 1.0,
is_exclusive: false,
status: 'active' as 'active' | 'inactive',
subscription_type: 'standard' as SubscriptionType,
daily_limit_usd: null as number | null,
weekly_limit_usd: null as number | null,
monthly_limit_usd: null as number | null
})
// 根据分组类型返回不同的删除确认消息
const deleteConfirmMessage = computed(() => {
if (!deletingGroup.value) {
return ''
}
if (deletingGroup.value.subscription_type === 'subscription') {
return t('admin.groups.deleteConfirmSubscription', { name: deletingGroup.value.name })
}
return t('admin.groups.deleteConfirm', { name: deletingGroup.value.name })
})
const loadGroups = async () => {
loading.value = true
try {
const response = await adminAPI.groups.list(
pagination.page,
pagination.page_size,
{
platform: filters.platform || undefined,
status: filters.status as any,
is_exclusive: filters.is_exclusive ? filters.is_exclusive === 'true' : undefined
}
)
groups.value = response.items
pagination.total = response.total
pagination.pages = response.pages
} catch (error) {
appStore.showError(t('admin.groups.failedToLoad'))
console.error('Error loading groups:', error)
} finally {
loading.value = false
}
}
const handlePageChange = (page: number) => {
pagination.page = page
loadGroups()
}
const closeCreateModal = () => {
showCreateModal.value = false
createForm.name = ''
createForm.description = ''
createForm.platform = 'anthropic'
createForm.rate_multiplier = 1.0
createForm.is_exclusive = false
createForm.subscription_type = 'standard'
createForm.daily_limit_usd = null
createForm.weekly_limit_usd = null
createForm.monthly_limit_usd = null
}
const handleCreateGroup = async () => {
submitting.value = true
try {
await adminAPI.groups.create(createForm)
appStore.showSuccess(t('admin.groups.groupCreated'))
closeCreateModal()
loadGroups()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.groups.failedToCreate'))
console.error('Error creating group:', error)
} finally {
submitting.value = false
}
}
const handleEdit = (group: Group) => {
editingGroup.value = group
editForm.name = group.name
editForm.description = group.description || ''
editForm.platform = group.platform
editForm.rate_multiplier = group.rate_multiplier
editForm.is_exclusive = group.is_exclusive
editForm.status = group.status
editForm.subscription_type = group.subscription_type || 'standard'
editForm.daily_limit_usd = group.daily_limit_usd
editForm.weekly_limit_usd = group.weekly_limit_usd
editForm.monthly_limit_usd = group.monthly_limit_usd
showEditModal.value = true
}
const closeEditModal = () => {
showEditModal.value = false
editingGroup.value = null
}
const handleUpdateGroup = async () => {
if (!editingGroup.value) return
submitting.value = true
try {
await adminAPI.groups.update(editingGroup.value.id, editForm)
appStore.showSuccess(t('admin.groups.groupUpdated'))
closeEditModal()
loadGroups()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.groups.failedToUpdate'))
console.error('Error updating group:', error)
} finally {
submitting.value = false
}
}
const handleDelete = (group: Group) => {
deletingGroup.value = group
showDeleteDialog.value = true
}
const confirmDelete = async () => {
if (!deletingGroup.value) return
try {
await adminAPI.groups.delete(deletingGroup.value.id)
appStore.showSuccess(t('admin.groups.groupDeleted'))
showDeleteDialog.value = false
deletingGroup.value = null
loadGroups()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.groups.failedToDelete'))
console.error('Error deleting group:', error)
}
}
// 监听 subscription_type 变化,配额模式时重置 rate_multiplier 为 1
watch(() => createForm.subscription_type, (newVal) => {
if (newVal === 'subscription') {
createForm.rate_multiplier = 1.0
}
})
watch(() => editForm.subscription_type, (newVal) => {
if (newVal === 'subscription') {
editForm.rate_multiplier = 1.0
}
})
onMounted(() => {
loadGroups()
})
</script>

View File

@@ -0,0 +1,827 @@
<template>
<AppLayout>
<div class="space-y-6">
<!-- Page Header Actions -->
<div class="flex justify-end">
<button
@click="showCreateModal = true"
class="btn btn-primary"
>
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{{ t('admin.proxies.createProxy') }}
</button>
</div>
<!-- Search and Filters -->
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="relative flex-1 max-w-md">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<input
v-model="searchQuery"
type="text"
:placeholder="t('admin.proxies.searchProxies')"
class="input pl-10"
@input="handleSearch"
/>
</div>
<div class="flex flex-wrap gap-3">
<Select
v-model="filters.protocol"
:options="protocolOptions"
:placeholder="t('admin.proxies.allProtocols')"
class="w-40"
@change="loadProxies"
/>
<Select
v-model="filters.status"
:options="statusOptions"
:placeholder="t('admin.proxies.allStatus')"
class="w-36"
@change="loadProxies"
/>
</div>
</div>
<!-- Proxies Table -->
<div class="card overflow-hidden">
<DataTable :columns="columns" :data="proxies" :loading="loading">
<template #cell-name="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template>
<template #cell-protocol="{ value }">
<span
v-if="value"
:class="[
'badge',
value === 'socks5' ? 'badge-primary' : 'badge-gray'
]"
>
{{ value.toUpperCase() }}
</span>
<span v-else class="text-sm text-gray-400">-</span>
</template>
<template #cell-address="{ row }">
<code class="code text-xs">{{ row.host }}:{{ row.port }}</code>
</template>
<template #cell-status="{ value }">
<span
:class="[
'badge',
value === 'active' ? 'badge-success' : 'badge-danger'
]"
>
{{ value }}
</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-1">
<button
@click="handleTestConnection(row)"
:disabled="testingProxyIds.has(row.id)"
class="p-2 rounded-lg hover:bg-emerald-50 dark:hover:bg-emerald-900/20 text-gray-500 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:title="t('admin.proxies.testConnection')"
>
<svg v-if="testingProxyIds.has(row.id)" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<button
@click="handleEdit(row)"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-dark-700 text-gray-500 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
:title="t('common.edit')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
</button>
<button
@click="handleDelete(row)"
class="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-gray-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
:title="t('common.delete')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
</div>
</template>
<template #empty>
<EmptyState
:title="t('admin.proxies.noProxiesYet')"
:description="t('admin.proxies.createFirstProxy')"
:action-text="t('admin.proxies.createProxy')"
@action="showCreateModal = true"
/>
</template>
</DataTable>
</div>
<!-- Pagination -->
<Pagination
v-if="pagination.total > 0"
:page="pagination.page"
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
/>
</div>
<!-- Create Proxy Modal -->
<Modal
:show="showCreateModal"
:title="t('admin.proxies.createProxy')"
size="lg"
@close="closeCreateModal"
>
<!-- Tab Switch -->
<div class="flex mb-6 border-b border-gray-200 dark:border-dark-600">
<button
type="button"
@click="createMode = 'standard'"
:class="[
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
createMode === 'standard'
? 'border-primary-500 text-primary-600 dark:text-primary-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
]"
>
<svg class="w-4 h-4 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{{ t('admin.proxies.standardAdd') }}
</button>
<button
type="button"
@click="createMode = 'batch'"
:class="[
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
createMode === 'batch'
? 'border-primary-500 text-primary-600 dark:text-primary-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
]"
>
<svg class="w-4 h-4 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z" />
</svg>
{{ t('admin.proxies.batchAdd') }}
</button>
</div>
<!-- Standard Add Form -->
<form v-if="createMode === 'standard'" @submit.prevent="handleCreateProxy" class="space-y-5">
<div>
<label class="input-label">{{ t('admin.proxies.name') }}</label>
<input
v-model="createForm.name"
type="text"
required
class="input"
:placeholder="t('admin.proxies.enterProxyName')"
/>
</div>
<div>
<label class="input-label">{{ t('admin.proxies.protocol') }}</label>
<Select
v-model="createForm.protocol"
:options="protocolSelectOptions"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('admin.proxies.host') }}</label>
<input
v-model="createForm.host"
type="text"
required
placeholder="proxy.example.com"
class="input"
/>
</div>
<div>
<label class="input-label">{{ t('admin.proxies.port') }}</label>
<input
v-model.number="createForm.port"
type="number"
required
min="1"
max="65535"
placeholder="8080"
class="input"
/>
</div>
</div>
<div>
<label class="input-label">{{ t('admin.proxies.username') }}</label>
<input
v-model="createForm.username"
type="text"
class="input"
:placeholder="t('admin.proxies.optionalAuth')"
/>
</div>
<div>
<label class="input-label">{{ t('admin.proxies.password') }}</label>
<input
v-model="createForm.password"
type="password"
class="input"
:placeholder="t('admin.proxies.optionalAuth')"
/>
</div>
<div class="flex justify-end gap-3 pt-4">
<button
@click="closeCreateModal"
type="button"
class="btn btn-secondary"
>
{{ t('common.cancel') }}
</button>
<button
type="submit"
:disabled="submitting"
class="btn btn-primary"
>
<svg
v-if="submitting"
class="animate-spin -ml-1 mr-2 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ submitting ? t('admin.proxies.creating') : t('common.create') }}
</button>
</div>
</form>
<!-- Batch Add Form -->
<div v-else class="space-y-5">
<div>
<label class="input-label">{{ t('admin.proxies.batchInput') }}</label>
<textarea
v-model="batchInput"
rows="10"
class="input font-mono text-sm"
:placeholder="t('admin.proxies.batchInputPlaceholder')"
@input="parseBatchInput"
></textarea>
<p class="input-hint mt-2">
{{ t('admin.proxies.batchInputHint') }}
</p>
</div>
<!-- Parse Result -->
<div v-if="batchParseResult.total > 0" class="rounded-lg p-4 bg-gray-50 dark:bg-dark-700">
<div class="flex items-center gap-4 text-sm">
<div class="flex items-center gap-1.5">
<svg class="w-4 h-4 text-primary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-gray-700 dark:text-gray-300">
{{ t('admin.proxies.parsedCount', { count: batchParseResult.valid }) }}
</span>
</div>
<div v-if="batchParseResult.invalid > 0" class="flex items-center gap-1.5">
<svg class="w-4 h-4 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
<span class="text-amber-600 dark:text-amber-400">
{{ t('admin.proxies.invalidCount', { count: batchParseResult.invalid }) }}
</span>
</div>
<div v-if="batchParseResult.duplicate > 0" class="flex items-center gap-1.5">
<svg class="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" />
</svg>
<span class="text-gray-500 dark:text-gray-400">
{{ t('admin.proxies.duplicateCount', { count: batchParseResult.duplicate }) }}
</span>
</div>
</div>
</div>
<div class="flex justify-end gap-3 pt-4">
<button
@click="closeCreateModal"
type="button"
class="btn btn-secondary"
>
{{ t('common.cancel') }}
</button>
<button
@click="handleBatchCreate"
type="button"
:disabled="submitting || batchParseResult.valid === 0"
class="btn btn-primary"
>
<svg
v-if="submitting"
class="animate-spin -ml-1 mr-2 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ submitting ? t('admin.proxies.importing') : t('admin.proxies.importProxies', { count: batchParseResult.valid }) }}
</button>
</div>
</div>
</Modal>
<!-- Edit Proxy Modal -->
<Modal
:show="showEditModal"
:title="t('admin.proxies.editProxy')"
size="lg"
@close="closeEditModal"
>
<form v-if="editingProxy" @submit.prevent="handleUpdateProxy" class="space-y-5">
<div>
<label class="input-label">{{ t('admin.proxies.name') }}</label>
<input
v-model="editForm.name"
type="text"
required
class="input"
/>
</div>
<div>
<label class="input-label">{{ t('admin.proxies.protocol') }}</label>
<Select
v-model="editForm.protocol"
:options="protocolSelectOptions"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('admin.proxies.host') }}</label>
<input
v-model="editForm.host"
type="text"
required
class="input"
/>
</div>
<div>
<label class="input-label">{{ t('admin.proxies.port') }}</label>
<input
v-model.number="editForm.port"
type="number"
required
min="1"
max="65535"
class="input"
/>
</div>
</div>
<div>
<label class="input-label">{{ t('admin.proxies.username') }}</label>
<input
v-model="editForm.username"
type="text"
class="input"
/>
</div>
<div>
<label class="input-label">{{ t('admin.proxies.password') }}</label>
<input
v-model="editForm.password"
type="password"
:placeholder="t('admin.proxies.leaveEmptyToKeep')"
class="input"
/>
</div>
<div>
<label class="input-label">{{ t('admin.proxies.status') }}</label>
<Select
v-model="editForm.status"
:options="editStatusOptions"
/>
</div>
<div class="flex justify-end gap-3 pt-4">
<button
@click="closeEditModal"
type="button"
class="btn btn-secondary"
>
{{ t('common.cancel') }}
</button>
<button
type="submit"
:disabled="submitting"
class="btn btn-primary"
>
<svg
v-if="submitting"
class="animate-spin -ml-1 mr-2 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ submitting ? t('admin.proxies.updating') : t('common.update') }}
</button>
</div>
</form>
</Modal>
<!-- Delete Confirmation Dialog -->
<ConfirmDialog
:show="showDeleteDialog"
:title="t('admin.proxies.deleteProxy')"
:message="t('admin.proxies.deleteConfirm', { name: deletingProxy?.name })"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="confirmDelete"
@cancel="showDeleteDialog = false"
/>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { Proxy, ProxyProtocol } from '@/types'
import type { Column } from '@/components/common/DataTable.vue'
import AppLayout from '@/components/layout/AppLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue'
import Modal from '@/components/common/Modal.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
const { t } = useI18n()
const appStore = useAppStore()
const columns = computed<Column[]>(() => [
{ key: 'name', label: t('admin.proxies.columns.name'), sortable: true },
{ key: 'protocol', label: t('admin.proxies.columns.protocol'), sortable: true },
{ key: 'address', label: t('admin.proxies.columns.address'), sortable: false },
{ key: 'status', label: t('admin.proxies.columns.status'), sortable: true },
{ key: 'actions', label: t('admin.proxies.columns.actions'), sortable: false }
])
// Filter options
const protocolOptions = computed(() => [
{ value: '', label: t('admin.proxies.allProtocols') },
{ value: 'http', label: 'HTTP' },
{ value: 'https', label: 'HTTPS' },
{ value: 'socks5', label: 'SOCKS5' }
])
const statusOptions = computed(() => [
{ value: '', label: t('admin.proxies.allStatus') },
{ value: 'active', label: t('common.active') },
{ value: 'inactive', label: t('common.inactive') }
])
// Form options
const protocolSelectOptions = [
{ value: 'http', label: 'HTTP' },
{ value: 'https', label: 'HTTPS' },
{ value: 'socks5', label: 'SOCKS5' }
]
const editStatusOptions = computed(() => [
{ value: 'active', label: t('common.active') },
{ value: 'inactive', label: t('common.inactive') }
])
const proxies = ref<Proxy[]>([])
const loading = ref(false)
const searchQuery = ref('')
const filters = reactive({
protocol: '',
status: ''
})
const pagination = reactive({
page: 1,
page_size: 20,
total: 0,
pages: 0
})
const showCreateModal = ref(false)
const showEditModal = ref(false)
const showDeleteDialog = ref(false)
const submitting = ref(false)
const testingProxyIds = ref<Set<number>>(new Set())
const editingProxy = ref<Proxy | null>(null)
const deletingProxy = ref<Proxy | null>(null)
// Batch import state
const createMode = ref<'standard' | 'batch'>('standard')
const batchInput = ref('')
const batchParseResult = reactive({
total: 0,
valid: 0,
invalid: 0,
duplicate: 0,
proxies: [] as Array<{
protocol: ProxyProtocol
host: string
port: number
username: string
password: string
}>
})
const createForm = reactive({
name: '',
protocol: 'http' as ProxyProtocol,
host: '',
port: 8080,
username: '',
password: ''
})
const editForm = reactive({
name: '',
protocol: 'http' as ProxyProtocol,
host: '',
port: 8080,
username: '',
password: '',
status: 'active' as 'active' | 'inactive'
})
const loadProxies = async () => {
loading.value = true
try {
const response = await adminAPI.proxies.list(
pagination.page,
pagination.page_size,
{
protocol: filters.protocol || undefined,
status: filters.status as any,
search: searchQuery.value || undefined
}
)
proxies.value = response.items
pagination.total = response.total
pagination.pages = response.pages
} catch (error) {
appStore.showError(t('admin.proxies.failedToLoad'))
console.error('Error loading proxies:', error)
} finally {
loading.value = false
}
}
let searchTimeout: ReturnType<typeof setTimeout>
const handleSearch = () => {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
pagination.page = 1
loadProxies()
}, 300)
}
const handlePageChange = (page: number) => {
pagination.page = page
loadProxies()
}
const closeCreateModal = () => {
showCreateModal.value = false
createMode.value = 'standard'
createForm.name = ''
createForm.protocol = 'http'
createForm.host = ''
createForm.port = 8080
createForm.username = ''
createForm.password = ''
batchInput.value = ''
batchParseResult.total = 0
batchParseResult.valid = 0
batchParseResult.invalid = 0
batchParseResult.duplicate = 0
batchParseResult.proxies = []
}
// Parse proxy URL: protocol://user:pass@host:port or protocol://host:port
const parseProxyUrl = (line: string): {
protocol: ProxyProtocol
host: string
port: number
username: string
password: string
} | null => {
const trimmed = line.trim()
if (!trimmed) return null
// Regex to parse proxy URL
const regex = /^(https?|socks5):\/\/(?:([^:@]+):([^@]+)@)?([^:]+):(\d+)$/i
const match = trimmed.match(regex)
if (!match) return null
const [, protocol, username, password, host, port] = match
const portNum = parseInt(port, 10)
if (portNum < 1 || portNum > 65535) return null
return {
protocol: protocol.toLowerCase() as ProxyProtocol,
host,
port: portNum,
username: username || '',
password: password || ''
}
}
const parseBatchInput = () => {
const lines = batchInput.value.split('\n').filter(l => l.trim())
const seen = new Set<string>()
const proxies: typeof batchParseResult.proxies = []
let invalid = 0
let duplicate = 0
for (const line of lines) {
const parsed = parseProxyUrl(line)
if (!parsed) {
invalid++
continue
}
// Check for duplicates (same host:port:username:password)
const key = `${parsed.host}:${parsed.port}:${parsed.username}:${parsed.password}`
if (seen.has(key)) {
duplicate++
continue
}
seen.add(key)
proxies.push(parsed)
}
batchParseResult.total = lines.length
batchParseResult.valid = proxies.length
batchParseResult.invalid = invalid
batchParseResult.duplicate = duplicate
batchParseResult.proxies = proxies
}
const handleBatchCreate = async () => {
if (batchParseResult.valid === 0) return
submitting.value = true
try {
const result = await adminAPI.proxies.batchCreate(batchParseResult.proxies)
const created = result.created || 0
const skipped = result.skipped || 0
if (created > 0) {
appStore.showSuccess(t('admin.proxies.batchImportSuccess', { created, skipped }))
} else {
appStore.showInfo(t('admin.proxies.batchImportAllSkipped', { skipped }))
}
closeCreateModal()
loadProxies()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.proxies.failedToImport'))
console.error('Error batch creating proxies:', error)
} finally {
submitting.value = false
}
}
const handleCreateProxy = async () => {
submitting.value = true
try {
await adminAPI.proxies.create({
...createForm,
username: createForm.username || null,
password: createForm.password || null
})
appStore.showSuccess(t('admin.proxies.proxyCreated'))
closeCreateModal()
loadProxies()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.proxies.failedToCreate'))
console.error('Error creating proxy:', error)
} finally {
submitting.value = false
}
}
const handleEdit = (proxy: Proxy) => {
editingProxy.value = proxy
editForm.name = proxy.name
editForm.protocol = proxy.protocol
editForm.host = proxy.host
editForm.port = proxy.port
editForm.username = proxy.username || ''
editForm.password = ''
editForm.status = proxy.status
showEditModal.value = true
}
const closeEditModal = () => {
showEditModal.value = false
editingProxy.value = null
}
const handleUpdateProxy = async () => {
if (!editingProxy.value) return
submitting.value = true
try {
const updateData: any = {
name: editForm.name,
protocol: editForm.protocol,
host: editForm.host,
port: editForm.port,
username: editForm.username || null,
status: editForm.status
}
// Only include password if it was changed
if (editForm.password) {
updateData.password = editForm.password
}
await adminAPI.proxies.update(editingProxy.value.id, updateData)
appStore.showSuccess(t('admin.proxies.proxyUpdated'))
closeEditModal()
loadProxies()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.proxies.failedToUpdate'))
console.error('Error updating proxy:', error)
} finally {
submitting.value = false
}
}
const handleTestConnection = async (proxy: Proxy) => {
// Create new Set to trigger reactivity
testingProxyIds.value = new Set([...testingProxyIds.value, proxy.id])
try {
const result = await adminAPI.proxies.testProxy(proxy.id)
if (result.success) {
const message = result.latency_ms
? t('admin.proxies.proxyWorkingWithLatency', { latency: result.latency_ms })
: t('admin.proxies.proxyWorking')
appStore.showSuccess(message)
} else {
appStore.showError(result.message || t('admin.proxies.proxyTestFailed'))
}
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.proxies.failedToTest'))
console.error('Error testing proxy:', error)
} finally {
// Create new Set without this proxy id to trigger reactivity
const newSet = new Set(testingProxyIds.value)
newSet.delete(proxy.id)
testingProxyIds.value = newSet
}
}
const handleDelete = (proxy: Proxy) => {
deletingProxy.value = proxy
showDeleteDialog.value = true
}
const confirmDelete = async () => {
if (!deletingProxy.value) return
try {
await adminAPI.proxies.delete(deletingProxy.value.id)
appStore.showSuccess(t('admin.proxies.proxyDeleted'))
showDeleteDialog.value = false
deletingProxy.value = null
loadProxies()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.proxies.failedToDelete'))
console.error('Error deleting proxy:', error)
}
}
onMounted(() => {
loadProxies()
})
</script>

View File

@@ -0,0 +1,645 @@
<template>
<AppLayout>
<div class="space-y-6">
<!-- Page Header Actions -->
<div class="flex justify-end">
<button
@click="showGenerateDialog = true"
class="btn btn-primary"
>
{{ t('admin.redeem.generateCodes') }}
</button>
</div>
<!-- Filters and Actions -->
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex-1 max-w-md">
<input
v-model="searchQuery"
type="text"
:placeholder="t('admin.redeem.searchCodes')"
class="input"
@input="handleSearch"
/>
</div>
<div class="flex gap-2">
<Select
v-model="filters.type"
:options="filterTypeOptions"
class="w-36"
@change="loadCodes"
/>
<Select
v-model="filters.status"
:options="filterStatusOptions"
class="w-36"
@change="loadCodes"
/>
<button
@click="handleExportCodes"
class="btn btn-secondary"
>
{{ t('admin.redeem.exportCsv') }}
</button>
</div>
</div>
<!-- Redeem Codes Table -->
<div class="card overflow-hidden">
<DataTable :columns="columns" :data="codes" :loading="loading">
<template #cell-code="{ value }">
<div class="flex items-center space-x-2">
<code class="text-sm font-mono text-gray-900 dark:text-gray-100">{{ value }}</code>
<button
@click="copyToClipboard(value)"
:class="[
'flex items-center transition-colors',
copiedCode === value ? 'text-green-500' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
]"
:title="copiedCode === value ? t('admin.redeem.copied') : t('keys.copyToClipboard')"
>
<svg v-if="copiedCode !== value" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</button>
</div>
</template>
<template #cell-type="{ value }">
<span
:class="[
'badge',
value === 'balance' ? 'badge-success' :
value === 'subscription' ? 'badge-warning' : 'badge-primary'
]"
>
{{ value }}
</span>
</template>
<template #cell-value="{ value, row }">
<span class="text-sm font-medium text-gray-900 dark:text-white">
<template v-if="row.type === 'balance'">${{ value.toFixed(2) }}</template>
<template v-else-if="row.type === 'subscription'">
{{ row.validity_days || 30 }}{{ t('admin.redeem.days') }}
<span v-if="row.group" class="text-gray-500 dark:text-gray-400 text-xs ml-1">({{ row.group.name }})</span>
</template>
<template v-else>{{ value }}</template>
</span>
</template>
<template #cell-status="{ value }">
<span
:class="[
'badge',
value === 'unused' ? 'badge-success' :
value === 'used' ? 'badge-gray' :
'badge-danger'
]"
>
{{ value }}
</span>
</template>
<template #cell-used_by="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">
{{ value ? t('admin.redeem.userPrefix', { id: value }) : '-' }}
</span>
</template>
<template #cell-used_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ value ? formatDate(value) : '-' }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center space-x-2">
<button
v-if="row.status === 'unused'"
@click="handleDelete(row)"
class="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-gray-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
:title="t('common.delete')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
</div>
</template>
</DataTable>
</div>
<!-- Pagination -->
<Pagination
v-if="pagination.total > 0"
:page="pagination.page"
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
/>
<!-- Batch Actions -->
<div v-if="filters.status === 'unused'" class="flex justify-end">
<button
@click="showDeleteUnusedDialog = true"
class="btn btn-danger"
>
{{ t('admin.redeem.deleteAllUnused') }}
</button>
</div>
</div>
<!-- Delete Confirmation Dialog -->
<ConfirmDialog
:show="showDeleteDialog"
:title="t('admin.redeem.deleteCode')"
:message="t('admin.redeem.deleteCodeConfirm')"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
danger
@confirm="confirmDelete"
@cancel="showDeleteDialog = false"
/>
<!-- Delete Unused Codes Dialog -->
<ConfirmDialog
:show="showDeleteUnusedDialog"
:title="t('admin.redeem.deleteAllUnused')"
:message="t('admin.redeem.deleteAllUnusedConfirm')"
:confirm-text="t('admin.redeem.deleteAll')"
:cancel-text="t('common.cancel')"
danger
@confirm="confirmDeleteUnused"
@cancel="showDeleteUnusedDialog = false"
/>
<!-- Generate Codes Dialog -->
<Teleport to="body">
<div
v-if="showGenerateDialog"
class="fixed inset-0 z-50 flex items-center justify-center"
>
<div
class="fixed inset-0 bg-black/50"
@click="showGenerateDialog = false"
></div>
<div class="relative z-10 w-full max-w-md bg-white dark:bg-dark-800 rounded-xl shadow-xl p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.redeem.generateCodesTitle') }}</h2>
<form @submit.prevent="handleGenerateCodes" class="space-y-4">
<div>
<label class="input-label">{{ t('admin.redeem.codeType') }}</label>
<Select
v-model="generateForm.type"
:options="typeOptions"
/>
</div>
<!-- 余额/并发类型显示数值输入 -->
<div v-if="generateForm.type !== 'subscription'">
<label class="input-label">
{{ generateForm.type === 'balance' ? t('admin.redeem.amount') : t('admin.redeem.columns.value') }}
</label>
<input
v-model.number="generateForm.value"
type="number"
:step="generateForm.type === 'balance' ? '0.01' : '1'"
:min="generateForm.type === 'balance' ? '0.01' : '1'"
required
class="input"
/>
</div>
<!-- 订阅类型显示分组选择和有效天数 -->
<template v-if="generateForm.type === 'subscription'">
<div>
<label class="input-label">{{ t('admin.redeem.selectGroup') }}</label>
<Select
v-model="generateForm.group_id"
:options="subscriptionGroupOptions"
:placeholder="t('admin.redeem.selectGroupPlaceholder')"
/>
</div>
<div>
<label class="input-label">{{ t('admin.redeem.validityDays') }}</label>
<input
v-model.number="generateForm.validity_days"
type="number"
min="1"
max="365"
required
class="input"
/>
</div>
</template>
<div>
<label class="input-label">{{ t('admin.redeem.count') }}</label>
<input
v-model.number="generateForm.count"
type="number"
min="1"
max="100"
required
class="input"
/>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
@click="showGenerateDialog = false"
class="btn btn-secondary"
>
{{ t('common.cancel') }}
</button>
<button
type="submit"
:disabled="generating"
class="btn btn-primary"
>
{{ generating ? t('admin.redeem.generating') : t('admin.redeem.generate') }}
</button>
</div>
</form>
</div>
</div>
</Teleport>
<!-- Generated Codes Result Dialog -->
<Teleport to="body">
<div
v-if="showResultDialog"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<div
class="fixed inset-0 bg-black/50"
@click="closeResultDialog"
></div>
<div class="relative z-10 w-full max-w-lg bg-white dark:bg-dark-800 rounded-xl shadow-xl">
<!-- Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-dark-600">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<svg class="w-5 h-5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<h2 class="text-base font-semibold text-gray-900 dark:text-white">
{{ t('admin.redeem.generatedSuccessfully') }}
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.redeem.codesCreated', { count: generatedCodes.length }) }}</p>
</div>
</div>
<button
@click="closeResultDialog"
class="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:hover:text-gray-300 dark:hover:bg-dark-700 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Content -->
<div class="p-5">
<div class="relative">
<textarea
readonly
:value="generatedCodesText"
:style="{ height: textareaHeight }"
class="w-full p-3 font-mono text-sm bg-gray-50 dark:bg-dark-700 border border-gray-200 dark:border-dark-600 rounded-lg resize-none focus:outline-none text-gray-800 dark:text-gray-200"
></textarea>
</div>
</div>
<!-- Footer -->
<div class="flex justify-end gap-2 px-5 py-4 border-t border-gray-200 dark:border-dark-600 bg-gray-50 dark:bg-dark-700/50 rounded-b-xl">
<button
@click="copyGeneratedCodes"
:class="[
'btn flex items-center gap-2 transition-all',
copiedAll ? 'btn-success' : 'btn-secondary'
]"
>
<svg v-if="!copiedAll" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
{{ copiedAll ? t('admin.redeem.copied') : t('admin.redeem.copyAll') }}
</button>
<button
@click="downloadGeneratedCodes"
class="btn btn-primary flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{{ t('admin.redeem.download') }}
</button>
</div>
</div>
</div>
</Teleport>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { RedeemCode, RedeemCodeType, Group } from '@/types'
import type { Column } from '@/components/common/DataTable.vue'
import AppLayout from '@/components/layout/AppLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue'
const { t } = useI18n()
const appStore = useAppStore()
const showGenerateDialog = ref(false)
const showResultDialog = ref(false)
const generatedCodes = ref<RedeemCode[]>([])
const subscriptionGroups = ref<Group[]>([])
// 订阅类型分组选项
const subscriptionGroupOptions = computed(() => {
return subscriptionGroups.value
.filter(g => g.subscription_type === 'subscription')
.map(g => ({
value: g.id,
label: g.name
}))
})
const generatedCodesText = computed(() => {
return generatedCodes.value.map(code => code.code).join('\n')
})
const textareaHeight = computed(() => {
const lineCount = generatedCodes.value.length
const lineHeight = 24 // approximate line height in px
const padding = 24 // top + bottom padding
const minHeight = 60
const maxHeight = 240
const calculatedHeight = Math.min(Math.max(lineCount * lineHeight + padding, minHeight), maxHeight)
return `${calculatedHeight}px`
})
const copiedAll = ref(false)
const closeResultDialog = () => {
showResultDialog.value = false
generatedCodes.value = []
copiedAll.value = false
}
const copyGeneratedCodes = async () => {
try {
await navigator.clipboard.writeText(generatedCodesText.value)
copiedAll.value = true
setTimeout(() => {
copiedAll.value = false
}, 2000)
} catch (error) {
appStore.showError(t('admin.redeem.failedToCopy'))
}
}
const downloadGeneratedCodes = () => {
const blob = new Blob([generatedCodesText.value], { type: 'text/plain' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `redeem-codes-${new Date().toISOString().split('T')[0]}.txt`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
}
const columns = computed<Column[]>(() => [
{ key: 'code', label: t('admin.redeem.columns.code') },
{ key: 'type', label: t('admin.redeem.columns.type'), sortable: true },
{ key: 'value', label: t('admin.redeem.columns.value'), sortable: true },
{ key: 'status', label: t('admin.redeem.columns.status'), sortable: true },
{ key: 'used_by', label: t('admin.redeem.columns.usedBy') },
{ key: 'used_at', label: t('admin.redeem.columns.usedAt'), sortable: true },
{ key: 'actions', label: t('admin.redeem.columns.actions') }
])
const typeOptions = computed(() => [
{ value: 'balance', label: t('admin.redeem.balance') },
{ value: 'concurrency', label: t('admin.redeem.concurrency') },
{ value: 'subscription', label: t('admin.redeem.subscription') }
])
const filterTypeOptions = computed(() => [
{ value: '', label: t('admin.redeem.allTypes') },
{ value: 'balance', label: t('admin.redeem.balance') },
{ value: 'concurrency', label: t('admin.redeem.concurrency') },
{ value: 'subscription', label: t('admin.redeem.subscription') }
])
const filterStatusOptions = computed(() => [
{ value: '', label: t('admin.redeem.allStatus') },
{ value: 'unused', label: t('admin.redeem.unused') },
{ value: 'used', label: t('admin.redeem.used') }
])
const codes = ref<RedeemCode[]>([])
const loading = ref(false)
const generating = ref(false)
const searchQuery = ref('')
const filters = reactive({
type: '',
status: ''
})
const pagination = reactive({
page: 1,
page_size: 20,
total: 0,
pages: 0
})
const showDeleteDialog = ref(false)
const showDeleteUnusedDialog = ref(false)
const deletingCode = ref<RedeemCode | null>(null)
const copiedCode = ref<string | null>(null)
const generateForm = reactive({
type: 'balance' as RedeemCodeType,
value: 10,
count: 1,
group_id: null as number | null,
validity_days: 30
})
const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleDateString()
}
const loadCodes = async () => {
loading.value = true
try {
const response = await adminAPI.redeem.list(
pagination.page,
pagination.page_size,
{
type: filters.type as RedeemCodeType,
status: filters.status as any,
search: searchQuery.value || undefined
}
)
codes.value = response.items
pagination.total = response.total
pagination.pages = response.pages
} catch (error) {
appStore.showError(t('admin.redeem.failedToLoad'))
console.error('Error loading redeem codes:', error)
} finally {
loading.value = false
}
}
let searchTimeout: ReturnType<typeof setTimeout>
const handleSearch = () => {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
pagination.page = 1
loadCodes()
}, 300)
}
const handlePageChange = (page: number) => {
pagination.page = page
loadCodes()
}
const handleGenerateCodes = async () => {
// 订阅类型必须选择分组
if (generateForm.type === 'subscription' && !generateForm.group_id) {
appStore.showError(t('admin.redeem.groupRequired'))
return
}
generating.value = true
try {
const result = await adminAPI.redeem.generate(
generateForm.count,
generateForm.type,
generateForm.value,
generateForm.type === 'subscription' ? generateForm.group_id : undefined,
generateForm.type === 'subscription' ? generateForm.validity_days : undefined
)
showGenerateDialog.value = false
generatedCodes.value = result
showResultDialog.value = true
// 重置表单
generateForm.group_id = null
generateForm.validity_days = 30
loadCodes()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.redeem.failedToGenerate'))
console.error('Error generating codes:', error)
} finally {
generating.value = false
}
}
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
copiedCode.value = text
setTimeout(() => {
copiedCode.value = null
}, 2000)
} catch (error) {
appStore.showError(t('admin.redeem.failedToCopy'))
console.error('Error copying to clipboard:', error)
}
}
const handleExportCodes = async () => {
try {
const blob = await adminAPI.redeem.exportCodes({
type: filters.type as RedeemCodeType,
status: filters.status as any
})
// Create download link
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `redeem-codes-${new Date().toISOString().split('T')[0]}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
appStore.showSuccess(t('admin.redeem.codesExported'))
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.redeem.failedToExport'))
console.error('Error exporting codes:', error)
}
}
const handleDelete = (code: RedeemCode) => {
deletingCode.value = code
showDeleteDialog.value = true
}
const confirmDelete = async () => {
if (!deletingCode.value) return
try {
await adminAPI.redeem.delete(deletingCode.value.id)
appStore.showSuccess(t('admin.redeem.codeDeleted'))
showDeleteDialog.value = false
deletingCode.value = null
loadCodes()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.redeem.failedToDelete'))
console.error('Error deleting code:', error)
}
}
const confirmDeleteUnused = async () => {
try {
// Get all unused codes and delete them
const unusedCodesResponse = await adminAPI.redeem.list(1, 1000, { status: 'unused' })
const unusedCodeIds = unusedCodesResponse.items.map(code => code.id)
if (unusedCodeIds.length === 0) {
appStore.showInfo(t('admin.redeem.noUnusedCodes'))
showDeleteUnusedDialog.value = false
return
}
const result = await adminAPI.redeem.batchDelete(unusedCodeIds)
appStore.showSuccess(t('admin.redeem.codesDeleted', { count: result.deleted }))
showDeleteUnusedDialog.value = false
loadCodes()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.redeem.failedToDeleteUnused'))
console.error('Error deleting unused codes:', error)
}
}
// 加载订阅类型分组
const loadSubscriptionGroups = async () => {
try {
const groups = await adminAPI.groups.getAll()
subscriptionGroups.value = groups
} catch (error) {
console.error('Error loading subscription groups:', error)
}
}
onMounted(() => {
loadCodes()
loadSubscriptionGroups()
})
</script>

View File

@@ -0,0 +1,559 @@
<template>
<AppLayout>
<div class="max-w-4xl mx-auto space-y-6">
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
<!-- Settings Form -->
<form v-else @submit.prevent="saveSettings" class="space-y-6">
<!-- Registration Settings -->
<div class="card">
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.registration.title') }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.settings.registration.description') }}</p>
</div>
<div class="p-6 space-y-5">
<!-- Enable Registration -->
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{ t('admin.settings.registration.enableRegistration') }}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.registration.enableRegistrationHint') }}</p>
</div>
<Toggle v-model="form.registration_enabled" />
</div>
<!-- Email Verification -->
<div class="flex items-center justify-between pt-4 border-t border-gray-100 dark:border-dark-700">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{ t('admin.settings.registration.emailVerification') }}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.registration.emailVerificationHint') }}</p>
</div>
<Toggle v-model="form.email_verify_enabled" />
</div>
</div>
</div>
<!-- Cloudflare Turnstile Settings -->
<div class="card">
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.turnstile.title') }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.settings.turnstile.description') }}</p>
</div>
<div class="p-6 space-y-5">
<!-- Enable Turnstile -->
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{ t('admin.settings.turnstile.enableTurnstile') }}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.turnstile.enableTurnstileHint') }}</p>
</div>
<Toggle v-model="form.turnstile_enabled" />
</div>
<!-- Turnstile Keys - Only show when enabled -->
<div v-if="form.turnstile_enabled" class="pt-4 border-t border-gray-100 dark:border-dark-700">
<div class="grid grid-cols-1 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t('admin.settings.turnstile.siteKey') }}
</label>
<input
v-model="form.turnstile_site_key"
type="text"
class="input font-mono text-sm"
placeholder="0x4AAAAAAA..."
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.turnstile.siteKeyHint') }}
<a href="https://dash.cloudflare.com/turnstile" target="_blank" class="text-primary-600 hover:text-primary-500">Cloudflare Dashboard</a>
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t('admin.settings.turnstile.secretKey') }}
</label>
<input
v-model="form.turnstile_secret_key"
type="password"
class="input font-mono text-sm"
placeholder="0x4AAAAAAA..."
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.turnstile.secretKeyHint') }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Default Settings -->
<div class="card">
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.defaults.title') }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.settings.defaults.description') }}</p>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t('admin.settings.defaults.defaultBalance') }}
</label>
<input
v-model.number="form.default_balance"
type="number"
step="0.01"
min="0"
class="input"
placeholder="0.00"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.defaults.defaultBalanceHint') }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t('admin.settings.defaults.defaultConcurrency') }}
</label>
<input
v-model.number="form.default_concurrency"
type="number"
min="1"
class="input"
placeholder="1"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.defaults.defaultConcurrencyHint') }}</p>
</div>
</div>
</div>
</div>
<!-- Site Settings -->
<div class="card">
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.site.title') }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.settings.site.description') }}</p>
</div>
<div class="p-6 space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t('admin.settings.site.siteName') }}
</label>
<input
v-model="form.site_name"
type="text"
class="input"
placeholder="Sub2API"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.site.siteNameHint') }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t('admin.settings.site.siteSubtitle') }}
</label>
<input
v-model="form.site_subtitle"
type="text"
class="input"
placeholder="Subscription to API Conversion Platform"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.site.siteSubtitleHint') }}</p>
</div>
</div>
<!-- API Base URL -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t('admin.settings.site.apiBaseUrl') }}
</label>
<input
v-model="form.api_base_url"
type="text"
class="input font-mono text-sm"
placeholder="https://api.example.com"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.site.apiBaseUrlHint') }}</p>
</div>
<!-- Contact Info -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t('admin.settings.site.contactInfo') }}
</label>
<input
v-model="form.contact_info"
type="text"
class="input"
:placeholder="t('admin.settings.site.contactInfoPlaceholder')"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.site.contactInfoHint') }}</p>
</div>
<!-- Site Logo Upload -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t('admin.settings.site.siteLogo') }}
</label>
<div class="flex items-start gap-6">
<!-- Logo Preview -->
<div class="flex-shrink-0">
<div
class="w-20 h-20 rounded-xl border-2 border-dashed border-gray-300 dark:border-dark-600 flex items-center justify-center overflow-hidden bg-gray-50 dark:bg-dark-800"
:class="{ 'border-solid': form.site_logo }"
>
<img
v-if="form.site_logo"
:src="form.site_logo"
alt="Site Logo"
class="w-full h-full object-contain"
/>
<svg v-else class="w-8 h-8 text-gray-400 dark:text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
</div>
<!-- Upload Controls -->
<div class="flex-1 space-y-3">
<div class="flex items-center gap-3">
<label class="btn btn-secondary btn-sm cursor-pointer">
<input
type="file"
accept="image/*"
class="hidden"
@change="handleLogoUpload"
/>
<svg class="w-4 h-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
{{ t('admin.settings.site.uploadImage') }}
</label>
<button
v-if="form.site_logo"
type="button"
@click="form.site_logo = ''"
class="btn btn-secondary btn-sm text-red-600 hover:text-red-700 dark:text-red-400"
>
<svg class="w-4 h-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{{ t('admin.settings.site.remove') }}
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.site.logoHint') }}
</p>
<p v-if="logoError" class="text-xs text-red-500">{{ logoError }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- SMTP Settings - Only show when email verification is enabled -->
<div v-if="form.email_verify_enabled" class="card">
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700 flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.smtp.title') }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.settings.smtp.description') }}</p>
</div>
<button
type="button"
@click="testSmtpConnection"
:disabled="testingSmtp"
class="btn btn-secondary btn-sm"
>
<svg v-if="testingSmtp" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ testingSmtp ? t('admin.settings.smtp.testing') : t('admin.settings.smtp.testConnection') }}
</button>
</div>
<div class="p-6 space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t('admin.settings.smtp.host') }}
</label>
<input
v-model="form.smtp_host"
type="text"
class="input"
placeholder="smtp.gmail.com"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t('admin.settings.smtp.port') }}
</label>
<input
v-model.number="form.smtp_port"
type="number"
min="1"
max="65535"
class="input"
placeholder="587"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t('admin.settings.smtp.username') }}
</label>
<input
v-model="form.smtp_username"
type="text"
class="input"
placeholder="your-email@gmail.com"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t('admin.settings.smtp.password') }}
</label>
<input
v-model="form.smtp_password"
type="password"
class="input"
placeholder="********"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.smtp.passwordHint') }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t('admin.settings.smtp.fromEmail') }}
</label>
<input
v-model="form.smtp_from_email"
type="email"
class="input"
placeholder="noreply@example.com"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t('admin.settings.smtp.fromName') }}
</label>
<input
v-model="form.smtp_from_name"
type="text"
class="input"
placeholder="Sub2API"
/>
</div>
</div>
<!-- Use TLS Toggle -->
<div class="flex items-center justify-between pt-4 border-t border-gray-100 dark:border-dark-700">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{ t('admin.settings.smtp.useTls') }}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.smtp.useTlsHint') }}</p>
</div>
<Toggle v-model="form.smtp_use_tls" />
</div>
</div>
</div>
<!-- Send Test Email - Only show when email verification is enabled -->
<div v-if="form.email_verify_enabled" class="card">
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.testEmail.title') }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.settings.testEmail.description') }}</p>
</div>
<div class="p-6">
<div class="flex items-end gap-4">
<div class="flex-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t('admin.settings.testEmail.recipientEmail') }}
</label>
<input
v-model="testEmailAddress"
type="email"
class="input"
placeholder="test@example.com"
/>
</div>
<button
type="button"
@click="sendTestEmail"
:disabled="sendingTestEmail || !testEmailAddress"
class="btn btn-secondary"
>
<svg v-if="sendingTestEmail" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ sendingTestEmail ? t('admin.settings.testEmail.sending') : t('admin.settings.testEmail.sendTestEmail') }}
</button>
</div>
</div>
</div>
<!-- Save Button -->
<div class="flex justify-end">
<button
type="submit"
:disabled="saving"
class="btn btn-primary"
>
<svg v-if="saving" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ saving ? t('admin.settings.saving') : t('admin.settings.saveSettings') }}
</button>
</div>
</form>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { adminAPI } from '@/api';
import type { SystemSettings } from '@/api/admin/settings';
import AppLayout from '@/components/layout/AppLayout.vue';
import Toggle from '@/components/common/Toggle.vue';
import { useAppStore } from '@/stores';
const { t } = useI18n();
const appStore = useAppStore();
const loading = ref(true);
const saving = ref(false);
const testingSmtp = ref(false);
const sendingTestEmail = ref(false);
const testEmailAddress = ref('');
const logoError = ref('');
const form = reactive<SystemSettings>({
registration_enabled: true,
email_verify_enabled: false,
default_balance: 0,
default_concurrency: 1,
site_name: 'Sub2API',
site_logo: '',
site_subtitle: 'Subscription to API Conversion Platform',
api_base_url: '',
contact_info: '',
smtp_host: '',
smtp_port: 587,
smtp_username: '',
smtp_password: '',
smtp_from_email: '',
smtp_from_name: '',
smtp_use_tls: true,
// Cloudflare Turnstile
turnstile_enabled: false,
turnstile_site_key: '',
turnstile_secret_key: '',
});
function handleLogoUpload(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
logoError.value = '';
if (!file) return;
// Check file size (300KB = 307200 bytes)
const maxSize = 300 * 1024;
if (file.size > maxSize) {
logoError.value = t('admin.settings.site.logoSizeError', { size: (file.size / 1024).toFixed(1) });
input.value = '';
return;
}
// Check file type
if (!file.type.startsWith('image/')) {
logoError.value = t('admin.settings.site.logoTypeError');
input.value = '';
return;
}
// Convert to base64
const reader = new FileReader();
reader.onload = (e) => {
form.site_logo = e.target?.result as string;
};
reader.onerror = () => {
logoError.value = t('admin.settings.site.logoReadError');
};
reader.readAsDataURL(file);
// Reset input
input.value = '';
}
async function loadSettings() {
loading.value = true;
try {
const settings = await adminAPI.settings.getSettings();
Object.assign(form, settings);
} catch (error: any) {
appStore.showError(t('admin.settings.failedToLoad') + ': ' + (error.message || t('common.unknownError')));
} finally {
loading.value = false;
}
}
async function saveSettings() {
saving.value = true;
try {
await adminAPI.settings.updateSettings(form);
appStore.showSuccess(t('admin.settings.settingsSaved'));
} catch (error: any) {
appStore.showError(t('admin.settings.failedToSave') + ': ' + (error.message || t('common.unknownError')));
} finally {
saving.value = false;
}
}
async function testSmtpConnection() {
testingSmtp.value = true;
try {
const result = await adminAPI.settings.testSmtpConnection({
smtp_host: form.smtp_host,
smtp_port: form.smtp_port,
smtp_username: form.smtp_username,
smtp_password: form.smtp_password,
smtp_use_tls: form.smtp_use_tls,
});
// API returns { message: "..." } on success, errors are thrown as exceptions
appStore.showSuccess(result.message || t('admin.settings.smtpConnectionSuccess'));
} catch (error: any) {
appStore.showError(t('admin.settings.failedToTestSmtp') + ': ' + (error.message || t('common.unknownError')));
} finally {
testingSmtp.value = false;
}
}
async function sendTestEmail() {
if (!testEmailAddress.value) {
appStore.showError(t('admin.settings.testEmail.enterRecipientHint'));
return;
}
sendingTestEmail.value = true;
try {
const result = await adminAPI.settings.sendTestEmail({
email: testEmailAddress.value,
smtp_host: form.smtp_host,
smtp_port: form.smtp_port,
smtp_username: form.smtp_username,
smtp_password: form.smtp_password,
smtp_from_email: form.smtp_from_email,
smtp_from_name: form.smtp_from_name,
smtp_use_tls: form.smtp_use_tls,
});
// API returns { message: "..." } on success, errors are thrown as exceptions
appStore.showSuccess(result.message || t('admin.settings.testEmailSent'));
} catch (error: any) {
appStore.showError(t('admin.settings.failedToSendTestEmail') + ': ' + (error.message || t('common.unknownError')));
} finally {
sendingTestEmail.value = false;
}
}
onMounted(() => {
loadSettings();
});
</script>

View File

@@ -0,0 +1,548 @@
<template>
<AppLayout>
<div class="space-y-6">
<!-- Page Header Actions -->
<div class="flex justify-end">
<button
@click="showAssignModal = true"
class="btn btn-primary"
>
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{{ t('admin.subscriptions.assignSubscription') }}
</button>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-3">
<Select
v-model="filters.status"
:options="statusOptions"
:placeholder="t('admin.subscriptions.allStatus')"
class="w-40"
@change="loadSubscriptions"
/>
<Select
v-model="filters.group_id"
:options="groupOptions"
:placeholder="t('admin.subscriptions.allGroups')"
class="w-48"
@change="loadSubscriptions"
/>
</div>
<!-- Subscriptions Table -->
<div class="card overflow-hidden">
<DataTable :columns="columns" :data="subscriptions" :loading="loading">
<template #cell-user="{ row }">
<div class="flex items-center gap-2">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30">
<span class="text-sm font-medium text-primary-700 dark:text-primary-300">
{{ row.user?.email?.charAt(0).toUpperCase() || '?' }}
</span>
</div>
<span class="font-medium text-gray-900 dark:text-white">{{ row.user?.email || `User #${row.user_id}` }}</span>
</div>
</template>
<template #cell-group="{ row }">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400">
{{ row.group?.name || `Group #${row.group_id}` }}
</span>
</template>
<template #cell-usage="{ row }">
<div class="space-y-1 min-w-[200px]">
<div v-if="row.group?.daily_limit_usd" class="flex items-center gap-2">
<span class="text-xs text-gray-500 w-12">{{ t('admin.subscriptions.daily') }}</span>
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-2">
<div
class="h-2 rounded-full transition-all"
:class="getProgressClass(row.daily_usage_usd, row.group?.daily_limit_usd)"
:style="{ width: getProgressWidth(row.daily_usage_usd, row.group?.daily_limit_usd) }"
></div>
</div>
<span class="text-xs text-gray-500 w-20 text-right">
${{ row.daily_usage_usd?.toFixed(2) || '0.00' }} / ${{ row.group?.daily_limit_usd?.toFixed(2) }}
</span>
</div>
<div v-if="row.group?.weekly_limit_usd" class="flex items-center gap-2">
<span class="text-xs text-gray-500 w-12">{{ t('admin.subscriptions.weekly') }}</span>
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-2">
<div
class="h-2 rounded-full transition-all"
:class="getProgressClass(row.weekly_usage_usd, row.group?.weekly_limit_usd)"
:style="{ width: getProgressWidth(row.weekly_usage_usd, row.group?.weekly_limit_usd) }"
></div>
</div>
<span class="text-xs text-gray-500 w-20 text-right">
${{ row.weekly_usage_usd?.toFixed(2) || '0.00' }} / ${{ row.group?.weekly_limit_usd?.toFixed(2) }}
</span>
</div>
<div v-if="row.group?.monthly_limit_usd" class="flex items-center gap-2">
<span class="text-xs text-gray-500 w-12">{{ t('admin.subscriptions.monthly') }}</span>
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-2">
<div
class="h-2 rounded-full transition-all"
:class="getProgressClass(row.monthly_usage_usd, row.group?.monthly_limit_usd)"
:style="{ width: getProgressWidth(row.monthly_usage_usd, row.group?.monthly_limit_usd) }"
></div>
</div>
<span class="text-xs text-gray-500 w-20 text-right">
${{ row.monthly_usage_usd?.toFixed(2) || '0.00' }} / ${{ row.group?.monthly_limit_usd?.toFixed(2) }}
</span>
</div>
<div v-if="!row.group?.daily_limit_usd && !row.group?.weekly_limit_usd && !row.group?.monthly_limit_usd" class="text-xs text-gray-500">
{{ t('admin.subscriptions.noLimits') }}
</div>
</div>
</template>
<template #cell-expires_at="{ value }">
<div v-if="value">
<span class="text-sm" :class="isExpiringSoon(value) ? 'text-orange-600 dark:text-orange-400' : 'text-gray-700 dark:text-gray-300'">
{{ formatDate(value) }}
</span>
<div v-if="getDaysRemaining(value) !== null" class="text-xs text-gray-500">
{{ getDaysRemaining(value) }} {{ t('admin.subscriptions.daysRemaining') }}
</div>
</div>
<span v-else class="text-sm text-gray-500">{{ t('admin.subscriptions.noExpiration') }}</span>
</template>
<template #cell-status="{ value }">
<span
:class="[
'badge',
value === 'active' ? 'badge-success' : value === 'expired' ? 'badge-warning' : 'badge-danger'
]"
>
{{ t(`admin.subscriptions.status.${value}`) }}
</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-1">
<button
v-if="row.status === 'active'"
@click="handleExtend(row)"
class="p-2 rounded-lg hover:bg-green-50 dark:hover:bg-green-900/20 text-gray-500 hover:text-green-600 dark:hover:text-green-400 transition-colors"
:title="t('admin.subscriptions.extend')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<button
v-if="row.status === 'active'"
@click="handleRevoke(row)"
class="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-gray-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
:title="t('admin.subscriptions.revoke')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
</button>
</div>
</template>
<template #empty>
<EmptyState
:title="t('admin.subscriptions.noSubscriptionsYet')"
:description="t('admin.subscriptions.assignFirstSubscription')"
:action-text="t('admin.subscriptions.assignSubscription')"
@action="showAssignModal = true"
/>
</template>
</DataTable>
</div>
<!-- Pagination -->
<Pagination
v-if="pagination.total > 0"
:page="pagination.page"
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
/>
</div>
<!-- Assign Subscription Modal -->
<Modal
:show="showAssignModal"
:title="t('admin.subscriptions.assignSubscription')"
size="lg"
@close="closeAssignModal"
>
<form @submit.prevent="handleAssignSubscription" class="space-y-5">
<div>
<label class="input-label">{{ t('admin.subscriptions.form.user') }}</label>
<Select
v-model="assignForm.user_id"
:options="userOptions"
:placeholder="t('admin.subscriptions.selectUser')"
searchable
/>
</div>
<div>
<label class="input-label">{{ t('admin.subscriptions.form.group') }}</label>
<Select
v-model="assignForm.group_id"
:options="subscriptionGroupOptions"
:placeholder="t('admin.subscriptions.selectGroup')"
/>
<p class="input-hint">{{ t('admin.subscriptions.groupHint') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.subscriptions.form.validityDays') }}</label>
<input
v-model.number="assignForm.validity_days"
type="number"
min="1"
class="input"
/>
<p class="input-hint">{{ t('admin.subscriptions.validityHint') }}</p>
</div>
<div class="flex justify-end gap-3 pt-4">
<button
@click="closeAssignModal"
type="button"
class="btn btn-secondary"
>
{{ t('common.cancel') }}
</button>
<button
type="submit"
:disabled="submitting"
class="btn btn-primary"
>
<svg
v-if="submitting"
class="animate-spin -ml-1 mr-2 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ submitting ? t('admin.subscriptions.assigning') : t('admin.subscriptions.assign') }}
</button>
</div>
</form>
</Modal>
<!-- Extend Subscription Modal -->
<Modal
:show="showExtendModal"
:title="t('admin.subscriptions.extendSubscription')"
size="md"
@close="closeExtendModal"
>
<form v-if="extendingSubscription" @submit.prevent="handleExtendSubscription" class="space-y-5">
<div class="p-4 bg-gray-50 dark:bg-dark-700 rounded-lg">
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ t('admin.subscriptions.extendingFor') }}
<span class="font-medium text-gray-900 dark:text-white">{{ extendingSubscription.user?.email }}</span>
</p>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{{ t('admin.subscriptions.currentExpiration') }}:
<span class="font-medium text-gray-900 dark:text-white">
{{ extendingSubscription.expires_at ? formatDate(extendingSubscription.expires_at) : t('admin.subscriptions.noExpiration') }}
</span>
</p>
</div>
<div>
<label class="input-label">{{ t('admin.subscriptions.form.extendDays') }}</label>
<input
v-model.number="extendForm.days"
type="number"
min="1"
required
class="input"
/>
</div>
<div class="flex justify-end gap-3 pt-4">
<button
@click="closeExtendModal"
type="button"
class="btn btn-secondary"
>
{{ t('common.cancel') }}
</button>
<button
type="submit"
:disabled="submitting"
class="btn btn-primary"
>
{{ submitting ? t('admin.subscriptions.extending') : t('admin.subscriptions.extend') }}
</button>
</div>
</form>
</Modal>
<!-- Revoke Confirmation Dialog -->
<ConfirmDialog
:show="showRevokeDialog"
:title="t('admin.subscriptions.revokeSubscription')"
:message="t('admin.subscriptions.revokeConfirm', { user: revokingSubscription?.user?.email })"
:confirm-text="t('admin.subscriptions.revoke')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="confirmRevoke"
@cancel="showRevokeDialog = false"
/>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { UserSubscription, Group, User } from '@/types'
import type { Column } from '@/components/common/DataTable.vue'
import AppLayout from '@/components/layout/AppLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue'
import Modal from '@/components/common/Modal.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
const { t } = useI18n()
const appStore = useAppStore()
const columns = computed<Column[]>(() => [
{ key: 'user', label: t('admin.subscriptions.columns.user'), sortable: true },
{ key: 'group', label: t('admin.subscriptions.columns.group'), sortable: true },
{ key: 'usage', label: t('admin.subscriptions.columns.usage'), sortable: false },
{ key: 'expires_at', label: t('admin.subscriptions.columns.expires'), sortable: true },
{ key: 'status', label: t('admin.subscriptions.columns.status'), sortable: true },
{ key: 'actions', label: t('admin.subscriptions.columns.actions'), sortable: false }
])
// Filter options
const statusOptions = computed(() => [
{ value: '', label: t('admin.subscriptions.allStatus') },
{ value: 'active', label: t('admin.subscriptions.status.active') },
{ value: 'expired', label: t('admin.subscriptions.status.expired') },
{ value: 'revoked', label: t('admin.subscriptions.status.revoked') }
])
const subscriptions = ref<UserSubscription[]>([])
const groups = ref<Group[]>([])
const users = ref<User[]>([])
const loading = ref(false)
const filters = reactive({
status: '',
group_id: ''
})
const pagination = reactive({
page: 1,
page_size: 20,
total: 0,
pages: 0
})
const showAssignModal = ref(false)
const showExtendModal = ref(false)
const showRevokeDialog = ref(false)
const submitting = ref(false)
const extendingSubscription = ref<UserSubscription | null>(null)
const revokingSubscription = ref<UserSubscription | null>(null)
const assignForm = reactive({
user_id: null as number | null,
group_id: null as number | null,
validity_days: 30
})
const extendForm = reactive({
days: 30
})
// Group options for filter (all groups)
const groupOptions = computed(() => [
{ value: '', label: t('admin.subscriptions.allGroups') },
...groups.value.map(g => ({ value: g.id.toString(), label: g.name }))
])
// Group options for assign (only subscription type groups)
const subscriptionGroupOptions = computed(() =>
groups.value
.filter(g => g.subscription_type === 'subscription' && g.status === 'active')
.map(g => ({ value: g.id, label: g.name }))
)
// User options for assign
const userOptions = computed(() =>
users.value.map(u => ({ value: u.id, label: u.email }))
)
const loadSubscriptions = async () => {
loading.value = true
try {
const response = await adminAPI.subscriptions.list(
pagination.page,
pagination.page_size,
{
status: filters.status as any || undefined,
group_id: filters.group_id ? parseInt(filters.group_id) : undefined
}
)
subscriptions.value = response.items
pagination.total = response.total
pagination.pages = response.pages
} catch (error) {
appStore.showError(t('admin.subscriptions.failedToLoad'))
console.error('Error loading subscriptions:', error)
} finally {
loading.value = false
}
}
const loadGroups = async () => {
try {
groups.value = await adminAPI.groups.getAll()
} catch (error) {
console.error('Error loading groups:', error)
}
}
const loadUsers = async () => {
try {
const response = await adminAPI.users.list(1, 1000)
users.value = response.items
} catch (error) {
console.error('Error loading users:', error)
}
}
const handlePageChange = (page: number) => {
pagination.page = page
loadSubscriptions()
}
const closeAssignModal = () => {
showAssignModal.value = false
assignForm.user_id = null
assignForm.group_id = null
assignForm.validity_days = 30
}
const handleAssignSubscription = async () => {
if (!assignForm.user_id || !assignForm.group_id) return
submitting.value = true
try {
await adminAPI.subscriptions.assign({
user_id: assignForm.user_id,
group_id: assignForm.group_id,
validity_days: assignForm.validity_days
})
appStore.showSuccess(t('admin.subscriptions.subscriptionAssigned'))
closeAssignModal()
loadSubscriptions()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.subscriptions.failedToAssign'))
console.error('Error assigning subscription:', error)
} finally {
submitting.value = false
}
}
const handleExtend = (subscription: UserSubscription) => {
extendingSubscription.value = subscription
extendForm.days = 30
showExtendModal.value = true
}
const closeExtendModal = () => {
showExtendModal.value = false
extendingSubscription.value = null
}
const handleExtendSubscription = async () => {
if (!extendingSubscription.value) return
submitting.value = true
try {
await adminAPI.subscriptions.extend(extendingSubscription.value.id, {
days: extendForm.days
})
appStore.showSuccess(t('admin.subscriptions.subscriptionExtended'))
closeExtendModal()
loadSubscriptions()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.subscriptions.failedToExtend'))
console.error('Error extending subscription:', error)
} finally {
submitting.value = false
}
}
const handleRevoke = (subscription: UserSubscription) => {
revokingSubscription.value = subscription
showRevokeDialog.value = true
}
const confirmRevoke = async () => {
if (!revokingSubscription.value) return
try {
await adminAPI.subscriptions.revoke(revokingSubscription.value.id)
appStore.showSuccess(t('admin.subscriptions.subscriptionRevoked'))
showRevokeDialog.value = false
revokingSubscription.value = null
loadSubscriptions()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.subscriptions.failedToRevoke'))
console.error('Error revoking subscription:', error)
}
}
// Helper functions
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
const getDaysRemaining = (expiresAt: string): number | null => {
const now = new Date()
const expires = new Date(expiresAt)
const diff = expires.getTime() - now.getTime()
if (diff < 0) return null
return Math.ceil(diff / (1000 * 60 * 60 * 24))
}
const isExpiringSoon = (expiresAt: string): boolean => {
const days = getDaysRemaining(expiresAt)
return days !== null && days <= 7
}
const getProgressWidth = (used: number, limit: number | null): string => {
if (!limit || limit === 0) return '0%'
const percentage = Math.min((used / limit) * 100, 100)
return `${percentage}%`
}
const getProgressClass = (used: number, limit: number | null): string => {
if (!limit || limit === 0) return 'bg-gray-400'
const percentage = (used / limit) * 100
if (percentage >= 90) return 'bg-red-500'
if (percentage >= 70) return 'bg-orange-500'
return 'bg-green-500'
}
onMounted(() => {
loadSubscriptions()
loadGroups()
loadUsers()
})
</script>

View File

@@ -0,0 +1,593 @@
<template>
<AppLayout>
<div class="space-y-6">
<!-- Summary Stats Cards -->
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<!-- Total Requests -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/30">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('usage.totalRequests') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ usageStats?.total_requests?.toLocaleString() || '0' }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.inSelectedRange') }}</p>
</div>
</div>
</div>
<!-- Total Tokens -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-amber-100 dark:bg-amber-900/30">
<svg class="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />
</svg>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('usage.totalTokens') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(usageStats?.total_tokens || 0) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.in') }}: {{ formatTokens(usageStats?.total_input_tokens || 0) }} / {{ t('usage.out') }}: {{ formatTokens(usageStats?.total_output_tokens || 0) }}</p>
</div>
</div>
</div>
<!-- Total Cost -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-green-100 dark:bg-green-900/30">
<svg class="w-5 h-5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('usage.totalCost') }}</p>
<div class="flex items-baseline gap-2">
<p class="text-xl font-bold text-green-600 dark:text-green-400">${{ (usageStats?.total_actual_cost || 0).toFixed(4) }}</p>
<span class="text-xs text-gray-400 dark:text-gray-500 line-through">${{ (usageStats?.total_cost || 0).toFixed(4) }}</span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.actualCost') }} / {{ t('usage.standardCost') }}</p>
</div>
</div>
</div>
<!-- Average Duration -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-purple-100 dark:bg-purple-900/30">
<svg class="w-5 h-5 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('usage.avgDuration') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatDuration(usageStats?.average_duration_ms || 0) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.perRequest') }}</p>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="card">
<div class="px-6 py-4">
<div class="flex flex-wrap items-end gap-4">
<!-- User Search -->
<div class="min-w-[200px]">
<label class="input-label">{{ t('admin.usage.userFilter') }}</label>
<div class="relative">
<input
v-model="userSearchKeyword"
type="text"
class="input pr-8"
:placeholder="t('admin.usage.searchUserPlaceholder')"
@input="debounceSearchUsers"
@focus="showUserDropdown = true"
/>
<button
v-if="selectedUser"
@click="clearUserFilter"
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- User Dropdown -->
<div
v-if="showUserDropdown && (userSearchResults.length > 0 || userSearchKeyword)"
class="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-auto"
>
<div v-if="userSearchLoading" class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{{ t('common.loading') }}
</div>
<div v-else-if="userSearchResults.length === 0 && userSearchKeyword" class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{{ t('common.noOptionsFound') }}
</div>
<button
v-for="user in userSearchResults"
:key="user.id"
@click="selectUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span class="font-medium text-gray-900 dark:text-white">{{ user.email }}</span>
<span class="text-gray-500 dark:text-gray-400 ml-2">#{{ user.id }}</span>
</button>
</div>
</div>
</div>
<!-- API Key Filter -->
<div class="min-w-[180px]">
<label class="input-label">{{ t('usage.apiKeyFilter') }}</label>
<Select
v-model="filters.api_key_id"
:options="apiKeyOptions"
:placeholder="t('usage.allApiKeys')"
:disabled="!selectedUser && apiKeys.length === 0"
@change="applyFilters"
/>
</div>
<!-- Date Range Filter -->
<div>
<label class="input-label">{{ t('usage.timeRange') }}</label>
<DateRangePicker
v-model:start-date="startDate"
v-model:end-date="endDate"
@change="onDateRangeChange"
/>
</div>
<!-- Actions -->
<div class="flex items-center gap-3 ml-auto">
<button
@click="resetFilters"
class="btn btn-secondary"
>
{{ t('common.reset') }}
</button>
<button
@click="exportToCSV"
class="btn btn-primary"
>
{{ t('usage.exportCsv') }}
</button>
</div>
</div>
</div>
</div>
<!-- Usage Table -->
<div class="card overflow-hidden">
<DataTable
:columns="columns"
:data="usageLogs"
:loading="loading"
>
<template #cell-user="{ row }">
<div class="text-sm">
<span class="font-medium text-gray-900 dark:text-white">{{ row.user?.email || '-' }}</span>
<span class="text-gray-500 dark:text-gray-400 ml-1">#{{ row.user_id }}</span>
</div>
</template>
<template #cell-api_key="{ row }">
<span class="text-sm text-gray-900 dark:text-white">{{ row.api_key?.name || '-' }}</span>
</template>
<template #cell-model="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template>
<template #cell-stream="{ row }">
<span
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
:class="row.stream
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'"
>
{{ row.stream ? t('usage.stream') : t('usage.sync') }}
</span>
</template>
<template #cell-tokens="{ row }">
<div class="text-sm">
<div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">{{ t('usage.in') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ row.input_tokens.toLocaleString() }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="text-gray-500 dark:text-gray-400">{{ t('usage.out') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ row.output_tokens.toLocaleString() }}</span>
</div>
<div v-if="row.cache_read_tokens > 0" class="flex items-center gap-1 text-blue-600 dark:text-blue-400">
<span>{{ t('dashboard.cache') }}</span>
<span class="font-medium">{{ row.cache_read_tokens.toLocaleString() }}</span>
</div>
</div>
</template>
<template #cell-cost="{ row }">
<div class="text-sm flex items-center gap-1.5">
<span class="font-medium text-green-600 dark:text-green-400">
${{ row.actual_cost.toFixed(6) }}
</span>
<!-- Cost Detail Tooltip -->
<div class="relative group">
<div class="flex items-center justify-center w-4 h-4 rounded-full bg-gray-100 dark:bg-gray-700 cursor-help transition-colors group-hover:bg-blue-100 dark:group-hover:bg-blue-900/50">
<svg class="w-3 h-3 text-gray-400 dark:text-gray-500 group-hover:text-blue-500 dark:group-hover:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
</div>
<!-- Tooltip Content (right side) -->
<div class="absolute z-[100] invisible group-hover:visible opacity-0 group-hover:opacity-100 transition-all duration-200 left-full top-1/2 -translate-y-1/2 ml-2">
<div class="bg-gray-900 dark:bg-gray-800 text-white text-xs rounded-lg py-2.5 px-3 shadow-xl whitespace-nowrap border border-gray-700 dark:border-gray-600">
<div class="space-y-1.5">
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.rate') }}</span>
<span class="font-semibold text-blue-400">{{ (row.rate_multiplier || 1).toFixed(2) }}x</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.original') }}</span>
<span class="font-medium text-white">${{ row.total_cost.toFixed(6) }}</span>
</div>
<div class="flex items-center justify-between gap-6 pt-1.5 border-t border-gray-700">
<span class="text-gray-400">{{ t('usage.billed') }}</span>
<span class="font-semibold text-green-400">${{ row.actual_cost.toFixed(6) }}</span>
</div>
</div>
<!-- Tooltip Arrow (left side) -->
<div class="absolute right-full top-1/2 -translate-y-1/2 w-0 h-0 border-t-[6px] border-t-transparent border-b-[6px] border-b-transparent border-r-[6px] border-r-gray-900 dark:border-r-gray-800"></div>
</div>
</div>
</div>
</div>
</template>
<template #cell-billing_type="{ row }">
<span
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
:class="row.billing_type === 1
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'"
>
{{ row.billing_type === 1 ? t('usage.subscription') : t('usage.balance') }}
</span>
</template>
<template #cell-first_token="{ row }">
<span v-if="row.first_token_ms != null" class="text-sm text-gray-600 dark:text-gray-400">
{{ formatDuration(row.first_token_ms) }}
</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #cell-duration="{ row }">
<span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDuration(row.duration_ms) }}</span>
</template>
<template #cell-created_at="{ value }">
<span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDateTime(value) }}</span>
</template>
<template #empty>
<EmptyState :message="t('usage.noRecords')" />
</template>
</DataTable>
</div>
<!-- Pagination -->
<Pagination
v-if="pagination.total > 0"
:page="pagination.page"
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
/>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import AppLayout from '@/components/layout/AppLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
import DateRangePicker from '@/components/common/DateRangePicker.vue'
import type { UsageLog } from '@/types'
import type { Column } from '@/components/common/DataTable.vue'
import type { SimpleUser, SimpleApiKey, AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
const { t } = useI18n()
const appStore = useAppStore()
// Usage stats from API
const usageStats = ref<AdminUsageStatsResponse | null>(null)
const columns = computed<Column[]>(() => [
{ key: 'user', label: t('admin.usage.user'), sortable: false },
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
{ key: 'model', label: t('usage.model'), sortable: true },
{ key: 'stream', label: t('usage.type'), sortable: false },
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
{ key: 'cost', label: t('usage.cost'), sortable: false },
{ key: 'billing_type', label: t('usage.billingType'), sortable: false },
{ key: 'duration', label: t('usage.duration'), sortable: false },
{ key: 'created_at', label: t('usage.time'), sortable: true }
])
const usageLogs = ref<UsageLog[]>([])
const apiKeys = ref<SimpleApiKey[]>([])
const loading = ref(false)
// User search state
const userSearchKeyword = ref('')
const userSearchResults = ref<SimpleUser[]>([])
const userSearchLoading = ref(false)
const showUserDropdown = ref(false)
const selectedUser = ref<SimpleUser | null>(null)
let searchTimeout: ReturnType<typeof setTimeout> | null = null
// API Key options computed from selected user's keys
const apiKeyOptions = computed(() => {
return [
{ value: null, label: t('usage.allApiKeys') },
...apiKeys.value.map(key => ({
value: key.id,
label: key.name
}))
]
})
// Date range state
const startDate = ref('')
const endDate = ref('')
const filters = ref<AdminUsageQueryParams>({
user_id: undefined,
api_key_id: undefined,
start_date: undefined,
end_date: undefined
})
// Initialize default date range (last 7 days)
const initializeDateRange = () => {
const now = new Date()
const today = now.toISOString().split('T')[0]
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
startDate.value = weekAgo.toISOString().split('T')[0]
endDate.value = today
filters.value.start_date = startDate.value
filters.value.end_date = endDate.value
}
// User search with debounce
const debounceSearchUsers = () => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(searchUsers, 300)
}
const searchUsers = async () => {
const keyword = userSearchKeyword.value.trim()
if (!keyword) {
userSearchResults.value = []
return
}
userSearchLoading.value = true
try {
userSearchResults.value = await adminAPI.usage.searchUsers(keyword)
} catch (error) {
console.error('Failed to search users:', error)
userSearchResults.value = []
} finally {
userSearchLoading.value = false
}
}
const selectUser = async (user: SimpleUser) => {
selectedUser.value = user
userSearchKeyword.value = user.email
showUserDropdown.value = false
filters.value.user_id = user.id
filters.value.api_key_id = undefined
// Load API keys for selected user
await loadApiKeysForUser(user.id)
applyFilters()
}
const clearUserFilter = () => {
selectedUser.value = null
userSearchKeyword.value = ''
userSearchResults.value = []
filters.value.user_id = undefined
filters.value.api_key_id = undefined
apiKeys.value = []
applyFilters()
}
const loadApiKeysForUser = async (userId: number) => {
try {
apiKeys.value = await adminAPI.usage.searchApiKeys(userId)
} catch (error) {
console.error('Failed to load API keys:', error)
apiKeys.value = []
}
}
// Handle date range change from DateRangePicker
const onDateRangeChange = (range: { startDate: string; endDate: string; preset: string | null }) => {
filters.value.start_date = range.startDate
filters.value.end_date = range.endDate
applyFilters()
}
const pagination = ref({
page: 1,
page_size: 20,
total: 0,
pages: 0
})
const formatDuration = (ms: number): string => {
if (ms < 1000) return `${ms.toFixed(0)}ms`
return `${(ms / 1000).toFixed(2)}s`
}
const formatTokens = (value: number): string => {
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(2)}B`
} else if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(2)}M`
} else if (value >= 1_000) {
return `${(value / 1_000).toFixed(2)}K`
}
return value.toLocaleString()
}
const formatDateTime = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const loadUsageLogs = async () => {
loading.value = true
try {
const params: AdminUsageQueryParams = {
page: pagination.value.page,
page_size: pagination.value.page_size,
...filters.value
}
const response = await adminAPI.usage.list(params)
usageLogs.value = response.items
pagination.value.total = response.total
pagination.value.pages = response.pages
} catch (error) {
appStore.showError(t('usage.failedToLoad'))
} finally {
loading.value = false
}
}
const loadUsageStats = async () => {
try {
const stats = await adminAPI.usage.getStats({
user_id: filters.value.user_id,
api_key_id: filters.value.api_key_id ? Number(filters.value.api_key_id) : undefined,
start_date: filters.value.start_date || startDate.value,
end_date: filters.value.end_date || endDate.value
})
usageStats.value = stats
} catch (error) {
console.error('Failed to load usage stats:', error)
}
}
const applyFilters = () => {
pagination.value.page = 1
loadUsageLogs()
loadUsageStats()
}
const resetFilters = () => {
selectedUser.value = null
userSearchKeyword.value = ''
userSearchResults.value = []
apiKeys.value = []
filters.value = {
user_id: undefined,
api_key_id: undefined,
start_date: undefined,
end_date: undefined
}
// Reset date range to default (last 7 days)
initializeDateRange()
pagination.value.page = 1
loadUsageLogs()
loadUsageStats()
}
const handlePageChange = (page: number) => {
pagination.value.page = page
loadUsageLogs()
}
const exportToCSV = () => {
if (usageLogs.value.length === 0) {
appStore.showWarning(t('usage.noDataToExport'))
return
}
const headers = ['User', 'API Key', 'Model', 'Type', 'Input Tokens', 'Output Tokens', 'Cache Tokens', 'Total Cost', 'Billing Type', 'Duration (ms)', 'Time']
const rows = usageLogs.value.map(log => [
log.user?.email || '',
log.api_key?.name || '',
log.model,
log.stream ? 'Stream' : 'Sync',
log.input_tokens,
log.output_tokens,
log.cache_read_tokens,
log.total_cost.toFixed(6),
log.billing_type === 1 ? 'Subscription' : 'Balance',
log.duration_ms,
log.created_at
])
const csvContent = [
headers.join(','),
...rows.map(row => row.join(','))
].join('\n')
const blob = new Blob([csvContent], { type: 'text/csv' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `admin_usage_${new Date().toISOString().split('T')[0]}.csv`
link.click()
window.URL.revokeObjectURL(url)
appStore.showSuccess(t('usage.exportSuccess'))
}
// Click outside to close dropdown
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (!target.closest('.relative')) {
showUserDropdown.value = false
}
}
onMounted(() => {
initializeDateRange()
loadUsageLogs()
loadUsageStats()
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
if (searchTimeout) {
clearTimeout(searchTimeout)
}
})
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,423 @@
<template>
<AuthLayout>
<div class="space-y-6">
<!-- Title -->
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
Verify Your Email
</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
We'll send a verification code to <span class="font-medium text-gray-700 dark:text-gray-300">{{ email }}</span>
</p>
</div>
<!-- No Data Warning -->
<div v-if="!hasRegisterData" class="p-4 rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50">
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<svg class="w-5 h-5 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<div class="text-sm text-amber-700 dark:text-amber-400">
<p class="font-medium">Session expired</p>
<p class="mt-1">Please go back to the registration page and start again.</p>
</div>
</div>
</div>
<!-- Verification Form -->
<form v-else @submit.prevent="handleVerify" class="space-y-5">
<!-- Verification Code Input -->
<div>
<label for="code" class="input-label text-center">
Verification Code
</label>
<input
id="code"
v-model="verifyCode"
type="text"
required
autocomplete="one-time-code"
inputmode="numeric"
maxlength="6"
:disabled="isLoading"
class="input text-center text-xl tracking-[0.5em] font-mono py-3"
:class="{ 'input-error': errors.code }"
placeholder="000000"
/>
<p v-if="errors.code" class="input-error-text text-center">
{{ errors.code }}
</p>
<p v-else class="input-hint text-center">
Enter the 6-digit code sent to your email
</p>
</div>
<!-- Code Status -->
<div v-if="codeSent" class="p-4 rounded-xl bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800/50">
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<svg class="w-5 h-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p class="text-sm text-green-700 dark:text-green-400">
Verification code sent! Please check your inbox.
</p>
</div>
</div>
<!-- Turnstile Widget for Resend -->
<div v-if="turnstileEnabled && turnstileSiteKey && showResendTurnstile">
<TurnstileWidget
ref="turnstileRef"
:site-key="turnstileSiteKey"
@verify="onTurnstileVerify"
@expire="onTurnstileExpire"
@error="onTurnstileError"
/>
<p v-if="errors.turnstile" class="input-error-text text-center mt-2">
{{ errors.turnstile }}
</p>
</div>
<!-- Error Message -->
<transition name="fade">
<div
v-if="errorMessage"
class="p-4 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/50"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<svg class="w-5 h-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<p class="text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
</div>
</div>
</transition>
<!-- Submit Button -->
<button
type="submit"
:disabled="isLoading || !verifyCode"
class="btn btn-primary w-full"
>
<svg
v-if="isLoading"
class="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<svg v-else class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ isLoading ? 'Verifying...' : 'Verify & Create Account' }}
</button>
<!-- Resend Code -->
<div class="text-center">
<button
v-if="countdown > 0"
type="button"
disabled
class="text-sm text-gray-400 dark:text-dark-500 cursor-not-allowed"
>
Resend code in {{ countdown }}s
</button>
<button
v-else
type="button"
@click="handleResendCode"
:disabled="isSendingCode || (turnstileEnabled && showResendTurnstile && !resendTurnstileToken)"
class="text-sm text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<span v-if="isSendingCode">Sending...</span>
<span v-else-if="turnstileEnabled && !showResendTurnstile">Click to resend code</span>
<span v-else>Resend verification code</span>
</button>
</div>
</form>
</div>
<!-- Footer -->
<template #footer>
<button
@click="handleBack"
class="text-gray-500 dark:text-dark-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
</svg>
Back to registration
</button>
</template>
</AuthLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { AuthLayout } from '@/components/layout';
import TurnstileWidget from '@/components/TurnstileWidget.vue';
import { useAuthStore, useAppStore } from '@/stores';
import { getPublicSettings, sendVerifyCode } from '@/api/auth';
// ==================== Router & Stores ====================
const router = useRouter();
const authStore = useAuthStore();
const appStore = useAppStore();
// ==================== State ====================
const isLoading = ref<boolean>(false);
const isSendingCode = ref<boolean>(false);
const errorMessage = ref<string>('');
const codeSent = ref<boolean>(false);
const verifyCode = ref<string>('');
const countdown = ref<number>(0);
let countdownTimer: ReturnType<typeof setInterval> | null = null;
// Registration data from sessionStorage
const email = ref<string>('');
const password = ref<string>('');
const initialTurnstileToken = ref<string>('');
const hasRegisterData = ref<boolean>(false);
// Public settings
const turnstileEnabled = ref<boolean>(false);
const turnstileSiteKey = ref<string>('');
const siteName = ref<string>('Sub2API');
// Turnstile for resend
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null);
const resendTurnstileToken = ref<string>('');
const showResendTurnstile = ref<boolean>(false);
const errors = ref({
code: '',
turnstile: '',
});
// ==================== Lifecycle ====================
onMounted(async () => {
// Load registration data from sessionStorage
const registerDataStr = sessionStorage.getItem('register_data');
if (registerDataStr) {
try {
const registerData = JSON.parse(registerDataStr);
email.value = registerData.email || '';
password.value = registerData.password || '';
initialTurnstileToken.value = registerData.turnstile_token || '';
hasRegisterData.value = !!(email.value && password.value);
} catch {
hasRegisterData.value = false;
}
}
// Load public settings
try {
const settings = await getPublicSettings();
turnstileEnabled.value = settings.turnstile_enabled;
turnstileSiteKey.value = settings.turnstile_site_key || '';
siteName.value = settings.site_name || 'Sub2API';
} catch (error) {
console.error('Failed to load public settings:', error);
}
// Auto-send verification code if we have valid data
if (hasRegisterData.value) {
await sendCode();
}
});
onUnmounted(() => {
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
});
// ==================== Countdown ====================
function startCountdown(seconds: number): void {
countdown.value = seconds;
if (countdownTimer) {
clearInterval(countdownTimer);
}
countdownTimer = setInterval(() => {
if (countdown.value > 0) {
countdown.value--;
} else {
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
}
}, 1000);
}
// ==================== Turnstile Handlers ====================
function onTurnstileVerify(token: string): void {
resendTurnstileToken.value = token;
errors.value.turnstile = '';
}
function onTurnstileExpire(): void {
resendTurnstileToken.value = '';
errors.value.turnstile = 'Verification expired, please try again';
}
function onTurnstileError(): void {
resendTurnstileToken.value = '';
errors.value.turnstile = 'Verification failed, please try again';
}
// ==================== Send Code ====================
async function sendCode(): Promise<void> {
isSendingCode.value = true;
errorMessage.value = '';
try {
const response = await sendVerifyCode({
email: email.value,
// 优先使用重发时新获取的 token因为初始 token 可能已被使用)
turnstile_token: resendTurnstileToken.value || initialTurnstileToken.value || undefined,
});
codeSent.value = true;
startCountdown(response.countdown);
// Reset turnstile statetoken 已使用,清除以避免重复使用)
initialTurnstileToken.value = '';
showResendTurnstile.value = false;
resendTurnstileToken.value = '';
} catch (error: unknown) {
const err = error as { message?: string; response?: { data?: { detail?: string } } };
if (err.response?.data?.detail) {
errorMessage.value = err.response.data.detail;
} else if (err.message) {
errorMessage.value = err.message;
} else {
errorMessage.value = 'Failed to send verification code. Please try again.';
}
appStore.showError(errorMessage.value);
} finally {
isSendingCode.value = false;
}
}
// ==================== Handlers ====================
async function handleResendCode(): Promise<void> {
// If turnstile is enabled and we haven't shown it yet, show it
if (turnstileEnabled.value && !showResendTurnstile.value) {
showResendTurnstile.value = true;
return;
}
// If turnstile is enabled but no token yet, wait
if (turnstileEnabled.value && !resendTurnstileToken.value) {
errors.value.turnstile = 'Please complete the verification';
return;
}
await sendCode();
}
function validateForm(): boolean {
errors.value.code = '';
if (!verifyCode.value.trim()) {
errors.value.code = 'Verification code is required';
return false;
}
if (!/^\d{6}$/.test(verifyCode.value.trim())) {
errors.value.code = 'Please enter a valid 6-digit code';
return false;
}
return true;
}
async function handleVerify(): Promise<void> {
errorMessage.value = '';
if (!validateForm()) {
return;
}
isLoading.value = true;
try {
// Register with verification code
await authStore.register({
email: email.value,
password: password.value,
verify_code: verifyCode.value.trim(),
turnstile_token: initialTurnstileToken.value || undefined,
});
// Clear session data
sessionStorage.removeItem('register_data');
// Show success toast
appStore.showSuccess('Account created successfully! Welcome to ' + siteName.value + '.');
// Redirect to dashboard
await router.push('/dashboard');
} catch (error: unknown) {
const err = error as { message?: string; response?: { data?: { detail?: string } } };
if (err.response?.data?.detail) {
errorMessage.value = err.response.data.detail;
} else if (err.message) {
errorMessage.value = err.message;
} else {
errorMessage.value = 'Verification failed. Please try again.';
}
appStore.showError(errorMessage.value);
} finally {
isLoading.value = false;
}
}
function handleBack(): void {
// Clear session data
sessionStorage.removeItem('register_data');
// Go back to registration
router.push('/register');
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@@ -0,0 +1,328 @@
<template>
<AuthLayout>
<div class="space-y-6">
<!-- Title -->
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ t('auth.welcomeBack') }}
</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
{{ t('auth.signInToAccount') }}
</p>
</div>
<!-- Login Form -->
<form @submit.prevent="handleLogin" class="space-y-5">
<!-- Email Input -->
<div>
<label for="email" class="input-label">
{{ t('auth.emailLabel') }}
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none">
<svg class="w-5 h-5 text-gray-400 dark:text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
</svg>
</div>
<input
id="email"
v-model="formData.email"
type="email"
required
autocomplete="email"
:disabled="isLoading"
class="input pl-11"
:class="{ 'input-error': errors.email }"
:placeholder="t('auth.emailPlaceholder')"
/>
</div>
<p v-if="errors.email" class="input-error-text">
{{ errors.email }}
</p>
</div>
<!-- Password Input -->
<div>
<label for="password" class="input-label">
{{ t('auth.passwordLabel') }}
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none">
<svg class="w-5 h-5 text-gray-400 dark:text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
</div>
<input
id="password"
v-model="formData.password"
:type="showPassword ? 'text' : 'password'"
required
autocomplete="current-password"
:disabled="isLoading"
class="input pl-11 pr-11"
:class="{ 'input-error': errors.password }"
:placeholder="t('auth.passwordPlaceholder')"
/>
<button
type="button"
@click="showPassword = !showPassword"
class="absolute inset-y-0 right-0 pr-3.5 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-dark-300 transition-colors"
>
<svg v-if="showPassword" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
<svg v-else class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
<p v-if="errors.password" class="input-error-text">
{{ errors.password }}
</p>
</div>
<!-- Turnstile Widget -->
<div v-if="turnstileEnabled && turnstileSiteKey">
<TurnstileWidget
ref="turnstileRef"
:site-key="turnstileSiteKey"
@verify="onTurnstileVerify"
@expire="onTurnstileExpire"
@error="onTurnstileError"
/>
<p v-if="errors.turnstile" class="input-error-text text-center mt-2">
{{ errors.turnstile }}
</p>
</div>
<!-- Error Message -->
<transition name="fade">
<div
v-if="errorMessage"
class="p-4 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/50"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<svg class="w-5 h-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<p class="text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
</div>
</div>
</transition>
<!-- Submit Button -->
<button
type="submit"
:disabled="isLoading || (turnstileEnabled && !turnstileToken)"
class="btn btn-primary w-full"
>
<svg
v-if="isLoading"
class="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<svg v-else class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
</svg>
{{ isLoading ? t('auth.signingIn') : t('auth.signIn') }}
</button>
</form>
</div>
<!-- Footer -->
<template #footer>
<p class="text-gray-500 dark:text-dark-400">
{{ t('auth.dontHaveAccount') }}
<router-link
to="/register"
class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300 transition-colors"
>
{{ t('auth.signUp') }}
</router-link>
</p>
</template>
</AuthLayout>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { AuthLayout } from '@/components/layout';
import TurnstileWidget from '@/components/TurnstileWidget.vue';
import { useAuthStore, useAppStore } from '@/stores';
import { getPublicSettings } from '@/api/auth';
const { t } = useI18n();
// ==================== Router & Stores ====================
const router = useRouter();
const authStore = useAuthStore();
const appStore = useAppStore();
// ==================== State ====================
const isLoading = ref<boolean>(false);
const errorMessage = ref<string>('');
const showPassword = ref<boolean>(false);
// Public settings
const turnstileEnabled = ref<boolean>(false);
const turnstileSiteKey = ref<string>('');
// Turnstile
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null);
const turnstileToken = ref<string>('');
const formData = reactive({
email: '',
password: '',
});
const errors = reactive({
email: '',
password: '',
turnstile: '',
});
// ==================== Lifecycle ====================
onMounted(async () => {
try {
const settings = await getPublicSettings();
turnstileEnabled.value = settings.turnstile_enabled;
turnstileSiteKey.value = settings.turnstile_site_key || '';
} catch (error) {
console.error('Failed to load public settings:', error);
}
});
// ==================== Turnstile Handlers ====================
function onTurnstileVerify(token: string): void {
turnstileToken.value = token;
errors.turnstile = '';
}
function onTurnstileExpire(): void {
turnstileToken.value = '';
errors.turnstile = 'Verification expired, please try again';
}
function onTurnstileError(): void {
turnstileToken.value = '';
errors.turnstile = 'Verification failed, please try again';
}
// ==================== Validation ====================
function validateForm(): boolean {
// Reset errors
errors.email = '';
errors.password = '';
errors.turnstile = '';
let isValid = true;
// Email validation
if (!formData.email.trim()) {
errors.email = 'Email is required';
isValid = false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
errors.email = 'Please enter a valid email address';
isValid = false;
}
// Password validation
if (!formData.password) {
errors.password = 'Password is required';
isValid = false;
} else if (formData.password.length < 6) {
errors.password = 'Password must be at least 6 characters';
isValid = false;
}
// Turnstile validation
if (turnstileEnabled.value && !turnstileToken.value) {
errors.turnstile = 'Please complete the verification';
isValid = false;
}
return isValid;
}
// ==================== Form Handlers ====================
async function handleLogin(): Promise<void> {
// Clear previous error
errorMessage.value = '';
// Validate form
if (!validateForm()) {
return;
}
isLoading.value = true;
try {
// Call auth store login
await authStore.login({
email: formData.email,
password: formData.password,
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
});
// Show success toast
appStore.showSuccess('Login successful! Welcome back.');
// Redirect to dashboard or intended route
const redirectTo = router.currentRoute.value.query.redirect as string || '/dashboard';
await router.push(redirectTo);
} catch (error: unknown) {
// Reset Turnstile on error
if (turnstileRef.value) {
turnstileRef.value.reset();
turnstileToken.value = '';
}
// Handle login error
const err = error as { message?: string; response?: { data?: { detail?: string } } };
if (err.response?.data?.detail) {
errorMessage.value = err.response.data.detail;
} else if (err.message) {
errorMessage.value = err.message;
} else {
errorMessage.value = 'Login failed. Please check your credentials and try again.';
}
// Also show error toast
appStore.showError(errorMessage.value);
} finally {
isLoading.value = false;
}
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@@ -0,0 +1,338 @@
# Authentication Views
This directory contains Vue 3 authentication views for the Sub2API frontend application.
## Components
### LoginView.vue
Login page for existing users to authenticate.
**Features:**
- Username and password inputs with validation
- Remember me checkbox for persistent sessions
- Form validation with real-time error display
- Loading state during authentication
- Error message display for failed login attempts
- Redirect to dashboard on successful login
- Link to registration page for new users
**Usage:**
```vue
<template>
<LoginView />
</template>
<script setup lang="ts">
import { LoginView } from '@/views/auth';
</script>
```
**Route:**
- Path: `/login`
- Name: `Login`
- Meta: `{ requiresAuth: false }`
**Validation Rules:**
- Username: Required, minimum 3 characters
- Password: Required, minimum 6 characters
**Behavior:**
- Calls `authStore.login()` with credentials
- Shows success toast on successful login
- Shows error toast and inline error message on failure
- Redirects to `/dashboard` or intended route from query parameter
- Redirects authenticated users away from login page
### RegisterView.vue
Registration page for new users to create accounts.
**Features:**
- Username, email, password, and confirm password inputs
- Comprehensive form validation
- Password strength requirements (8+ characters, letters + numbers)
- Email format validation with regex
- Password match validation
- Loading state during registration
- Error message display for failed registration
- Redirect to dashboard on success
- Link to login page for existing users
**Usage:**
```vue
<template>
<RegisterView />
</template>
<script setup lang="ts">
import { RegisterView } from '@/views/auth';
</script>
```
**Route:**
- Path: `/register`
- Name: `Register`
- Meta: `{ requiresAuth: false }`
**Validation Rules:**
- Username:
- Required
- 3-50 characters
- Only letters, numbers, underscores, and hyphens
- Email:
- Required
- Valid email format (RFC 5322 regex)
- Password:
- Required
- Minimum 8 characters
- Must contain at least one letter and one number
- Confirm Password:
- Required
- Must match password
**Behavior:**
- Calls `authStore.register()` with user data
- Shows success toast on successful registration
- Shows error toast and inline error message on failure
- Redirects to `/dashboard` after successful registration
- Redirects authenticated users away from register page
## Architecture
### Component Structure
Both views follow a consistent structure:
```
<template>
<AuthLayout>
<div class="space-y-6">
<!-- Title -->
<!-- Form -->
<!-- Error Message -->
<!-- Submit Button -->
</div>
<template #footer>
<!-- Footer Links -->
</template>
</AuthLayout>
</template>
<script setup lang="ts">
// Imports
// State
// Validation
// Form Handlers
</script>
```
### State Management
Both views use:
- `useAuthStore()` - For authentication actions (login, register)
- `useAppStore()` - For toast notifications and UI feedback
- `useRouter()` - For navigation and redirects
### Validation Strategy
**Client-side Validation:**
- Real-time validation on form submission
- Field-level error messages
- Comprehensive validation rules
- TypeScript type safety
**Server-side Validation:**
- Backend API validates all inputs
- Error responses handled gracefully
- User-friendly error messages displayed
### Styling
**Design System:**
- TailwindCSS utility classes
- Consistent color scheme (indigo primary)
- Responsive design
- Accessible form controls
- Loading states with spinner animations
**Visual Feedback:**
- Red border on invalid fields
- Error messages below inputs
- Global error banner for API errors
- Success toasts on completion
- Loading spinner on submit button
## Dependencies
### Components
- `AuthLayout` - Layout wrapper for auth pages from `@/components/layout`
### Stores
- `authStore` - Authentication state management from `@/stores/auth`
- `appStore` - Application state and toasts from `@/stores/app`
### Libraries
- Vue 3 Composition API
- Vue Router for navigation
- Pinia for state management
- TypeScript for type safety
## Usage Examples
### Basic Login Flow
```typescript
// User enters credentials
formData.username = 'john_doe';
formData.password = 'SecurePass123';
// Submit form
await handleLogin();
// On success:
// - authStore.login() called
// - Token and user stored
// - Success toast shown
// - Redirected to /dashboard
// On error:
// - Error message displayed
// - Error toast shown
// - Form remains editable
```
### Basic Registration Flow
```typescript
// User enters registration data
formData.username = 'jane_smith';
formData.email = 'jane@example.com';
formData.password = 'SecurePass123';
formData.confirmPassword = 'SecurePass123';
// Submit form
await handleRegister();
// On success:
// - authStore.register() called
// - Token and user stored
// - Success toast shown
// - Redirected to /dashboard
// On error:
// - Error message displayed
// - Error toast shown
// - Form remains editable
```
## Error Handling
### Client-side Errors
```typescript
// Validation errors
errors.username = 'Username must be at least 3 characters';
errors.email = 'Please enter a valid email address';
errors.password = 'Password must be at least 8 characters with letters and numbers';
errors.confirmPassword = 'Passwords do not match';
```
### Server-side Errors
```typescript
// API error responses
{
response: {
data: {
detail: 'Username already exists'
}
}
}
// Displayed as:
errorMessage.value = 'Username already exists';
appStore.showError('Username already exists');
```
## Accessibility
- Semantic HTML elements (`<label>`, `<input>`, `<button>`)
- Proper `for` attributes on labels
- ARIA attributes for loading states
- Keyboard navigation support
- Focus management
- Error announcements
- Sufficient color contrast
## Testing Considerations
### Unit Tests
- Form validation logic
- Error handling
- State management
- Router navigation
### Integration Tests
- Full login flow
- Full registration flow
- Error scenarios
- Redirect behavior
### E2E Tests
- Complete user journeys
- Form interactions
- API integration
- Success/error states
## Future Enhancements
Potential improvements:
- OAuth/SSO integration (Google, GitHub)
- Two-factor authentication (2FA)
- Password strength meter
- Email verification flow
- Forgot password functionality
- Social login buttons
- CAPTCHA integration
- Session timeout warnings
- Password visibility toggle
- Autofill support enhancement
## Security Considerations
- Passwords are never logged or displayed
- HTTPS required in production
- JWT tokens stored securely in localStorage
- CORS protection on API
- XSS protection with Vue's automatic escaping
- CSRF protection with token-based auth
- Rate limiting on backend API
- Input sanitization
- Secure password requirements
## Performance
- Lazy-loaded routes
- Minimal bundle size
- Fast initial render
- Optimized re-renders with reactive refs
- No unnecessary watchers
- Efficient form validation
## Browser Support
- Modern browsers (Chrome, Firefox, Safari, Edge)
- ES2015+ required
- Flexbox and CSS Grid
- Tailwind CSS utilities
- Vue 3 runtime
## Related Documentation
- [Auth Store Documentation](/src/stores/README.md#auth-store)
- [AuthLayout Component](/src/components/layout/README.md#authlayout)
- [Router Configuration](/src/router/index.ts)
- [API Documentation](/src/api/README.md#authentication)
- [Type Definitions](/src/types/index.ts)

View File

@@ -0,0 +1,372 @@
<template>
<AuthLayout>
<div class="space-y-6">
<!-- Title -->
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ t('auth.createAccount') }}
</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
{{ t('auth.signUpToStart', { siteName }) }}
</p>
</div>
<!-- Registration Disabled Message -->
<div v-if="!registrationEnabled && settingsLoaded" class="p-4 rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50">
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<svg class="w-5 h-5 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<p class="text-sm text-amber-700 dark:text-amber-400">
{{ t('auth.registrationDisabled') }}
</p>
</div>
</div>
<!-- Registration Form -->
<form v-else @submit.prevent="handleRegister" class="space-y-5">
<!-- Email Input -->
<div>
<label for="email" class="input-label">
{{ t('auth.emailLabel') }}
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none">
<svg class="w-5 h-5 text-gray-400 dark:text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
</svg>
</div>
<input
id="email"
v-model="formData.email"
type="email"
required
autocomplete="email"
:disabled="isLoading"
class="input pl-11"
:class="{ 'input-error': errors.email }"
:placeholder="t('auth.emailPlaceholder')"
/>
</div>
<p v-if="errors.email" class="input-error-text">
{{ errors.email }}
</p>
</div>
<!-- Password Input -->
<div>
<label for="password" class="input-label">
{{ t('auth.passwordLabel') }}
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none">
<svg class="w-5 h-5 text-gray-400 dark:text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
</div>
<input
id="password"
v-model="formData.password"
:type="showPassword ? 'text' : 'password'"
required
autocomplete="new-password"
:disabled="isLoading"
class="input pl-11 pr-11"
:class="{ 'input-error': errors.password }"
:placeholder="t('auth.createPasswordPlaceholder')"
/>
<button
type="button"
@click="showPassword = !showPassword"
class="absolute inset-y-0 right-0 pr-3.5 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-dark-300 transition-colors"
>
<svg v-if="showPassword" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
<svg v-else class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
<p v-if="errors.password" class="input-error-text">
{{ errors.password }}
</p>
<p v-else class="input-hint">
{{ t('auth.passwordHint') }}
</p>
</div>
<!-- Turnstile Widget -->
<div v-if="turnstileEnabled && turnstileSiteKey">
<TurnstileWidget
ref="turnstileRef"
:site-key="turnstileSiteKey"
@verify="onTurnstileVerify"
@expire="onTurnstileExpire"
@error="onTurnstileError"
/>
<p v-if="errors.turnstile" class="input-error-text text-center mt-2">
{{ errors.turnstile }}
</p>
</div>
<!-- Error Message -->
<transition name="fade">
<div
v-if="errorMessage"
class="p-4 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/50"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<svg class="w-5 h-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<p class="text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
</div>
</div>
</transition>
<!-- Submit Button -->
<button
type="submit"
:disabled="isLoading || (turnstileEnabled && !turnstileToken)"
class="btn btn-primary w-full"
>
<svg
v-if="isLoading"
class="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<svg v-else class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zM4 19.235v-.11a6.375 6.375 0 0112.75 0v.109A12.318 12.318 0 0110.374 21c-2.331 0-4.512-.645-6.374-1.766z" />
</svg>
{{ isLoading ? t('auth.processing') : (emailVerifyEnabled ? t('auth.continue') : t('auth.createAccount')) }}
</button>
</form>
</div>
<!-- Footer -->
<template #footer>
<p class="text-gray-500 dark:text-dark-400">
{{ t('auth.alreadyHaveAccount') }}
<router-link
to="/login"
class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300 transition-colors"
>
{{ t('auth.signIn') }}
</router-link>
</p>
</template>
</AuthLayout>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { AuthLayout } from '@/components/layout';
import TurnstileWidget from '@/components/TurnstileWidget.vue';
import { useAuthStore, useAppStore } from '@/stores';
import { getPublicSettings } from '@/api/auth';
const { t } = useI18n();
// ==================== Router & Stores ====================
const router = useRouter();
const authStore = useAuthStore();
const appStore = useAppStore();
// ==================== State ====================
const isLoading = ref<boolean>(false);
const settingsLoaded = ref<boolean>(false);
const errorMessage = ref<string>('');
const showPassword = ref<boolean>(false);
// Public settings
const registrationEnabled = ref<boolean>(true);
const emailVerifyEnabled = ref<boolean>(false);
const turnstileEnabled = ref<boolean>(false);
const turnstileSiteKey = ref<string>('');
const siteName = ref<string>('Sub2API');
// Turnstile
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null);
const turnstileToken = ref<string>('');
const formData = reactive({
email: '',
password: '',
});
const errors = reactive({
email: '',
password: '',
turnstile: '',
});
// ==================== Lifecycle ====================
onMounted(async () => {
try {
const settings = await getPublicSettings();
registrationEnabled.value = settings.registration_enabled;
emailVerifyEnabled.value = settings.email_verify_enabled;
turnstileEnabled.value = settings.turnstile_enabled;
turnstileSiteKey.value = settings.turnstile_site_key || '';
siteName.value = settings.site_name || 'Sub2API';
} catch (error) {
console.error('Failed to load public settings:', error);
} finally {
settingsLoaded.value = true;
}
});
// ==================== Turnstile Handlers ====================
function onTurnstileVerify(token: string): void {
turnstileToken.value = token;
errors.turnstile = '';
}
function onTurnstileExpire(): void {
turnstileToken.value = '';
errors.turnstile = 'Verification expired, please try again';
}
function onTurnstileError(): void {
turnstileToken.value = '';
errors.turnstile = 'Verification failed, please try again';
}
// ==================== Validation ====================
function validateEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
function validateForm(): boolean {
// Reset errors
errors.email = '';
errors.password = '';
errors.turnstile = '';
let isValid = true;
// Email validation
if (!formData.email.trim()) {
errors.email = 'Email is required';
isValid = false;
} else if (!validateEmail(formData.email)) {
errors.email = 'Please enter a valid email address';
isValid = false;
}
// Password validation
if (!formData.password) {
errors.password = 'Password is required';
isValid = false;
} else if (formData.password.length < 6) {
errors.password = 'Password must be at least 6 characters';
isValid = false;
}
// Turnstile validation
if (turnstileEnabled.value && !turnstileToken.value) {
errors.turnstile = 'Please complete the verification';
isValid = false;
}
return isValid;
}
// ==================== Form Handlers ====================
async function handleRegister(): Promise<void> {
// Clear previous error
errorMessage.value = '';
// Validate form
if (!validateForm()) {
return;
}
isLoading.value = true;
try {
// If email verification is enabled, redirect to verification page
if (emailVerifyEnabled.value) {
// Store registration data in sessionStorage
sessionStorage.setItem('register_data', JSON.stringify({
email: formData.email,
password: formData.password,
turnstile_token: turnstileToken.value,
}));
// Navigate to email verification page
await router.push('/email-verify');
return;
}
// Otherwise, directly register
await authStore.register({
email: formData.email,
password: formData.password,
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
});
// Show success toast
appStore.showSuccess('Account created successfully! Welcome to ' + siteName.value + '.');
// Redirect to dashboard
await router.push('/dashboard');
} catch (error: unknown) {
// Reset Turnstile on error
if (turnstileRef.value) {
turnstileRef.value.reset();
turnstileToken.value = '';
}
// Handle registration error
const err = error as { message?: string; response?: { data?: { detail?: string } } };
if (err.response?.data?.detail) {
errorMessage.value = err.response.data.detail;
} else if (err.message) {
errorMessage.value = err.message;
} else {
errorMessage.value = 'Registration failed. Please try again.';
}
// Also show error toast
appStore.showError(errorMessage.value);
} finally {
isLoading.value = false;
}
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@@ -0,0 +1,609 @@
# Authentication Views Usage Examples
This document provides practical examples of how to use the authentication views in the Sub2API frontend.
## Quick Start
### 1. Login Flow
**Scenario:** User wants to log into their existing account
```typescript
// Route: /login
// Component: LoginView.vue
// User interactions:
// 1. Navigate to /login
// 2. Enter username: "john_doe"
// 3. Enter password: "MySecurePass123"
// 4. Optionally check "Remember me"
// 5. Click "Sign In"
// What happens:
// - Form validation runs (client-side)
// - If valid, authStore.login() is called
// - API request to POST /api/auth/login
// - On success:
// - Token stored in localStorage
// - User data stored in state
// - Success toast: "Login successful! Welcome back."
// - Redirect to /dashboard (or intended route)
// - On error:
// - Error message displayed inline
// - Error toast shown
// - User can retry
```
### 2. Registration Flow
**Scenario:** New user wants to create an account
```typescript
// Route: /register
// Component: RegisterView.vue
// User interactions:
// 1. Navigate to /register
// 2. Enter username: "jane_smith"
// 3. Enter email: "jane@example.com"
// 4. Enter password: "SecurePass123"
// 5. Enter confirm password: "SecurePass123"
// 6. Click "Create Account"
// What happens:
// - Form validation runs (client-side)
// - Username: 3-50 chars, alphanumeric + _ -
// - Email: Valid format
// - Password: 8+ chars, letters + numbers
// - Passwords match
// - If valid, authStore.register() is called
// - API request to POST /api/auth/register
// - On success:
// - Token stored in localStorage
// - User data stored in state
// - Success toast: "Account created successfully! Welcome to Sub2API."
// - Redirect to /dashboard
// - On error:
// - Error message displayed inline
// - Error toast shown
// - User can retry
```
## Code Examples
### Importing the Views
```typescript
// Method 1: Direct import
import LoginView from '@/views/auth/LoginView.vue';
import RegisterView from '@/views/auth/RegisterView.vue';
// Method 2: Named exports from index
import { LoginView, RegisterView } from '@/views/auth';
// Method 3: Lazy loading (recommended for routes)
const LoginView = () => import('@/views/auth/LoginView.vue');
const RegisterView = () => import('@/views/auth/RegisterView.vue');
```
### Using in Router
```typescript
import { createRouter, createWebHistory } from 'vue-router';
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/auth/LoginView.vue'),
meta: { requiresAuth: false },
},
{
path: '/register',
name: 'Register',
component: () => import('@/views/auth/RegisterView.vue'),
meta: { requiresAuth: false },
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
export default router;
```
### Navigation to Auth Views
```typescript
// From template
<router-link to="/login">Login</router-link>
<router-link to="/register">Sign Up</router-link>
// From script
import { useRouter } from 'vue-router';
const router = useRouter();
// Navigate to login
router.push('/login');
// Navigate to register
router.push('/register');
// Navigate with redirect query
router.push({
path: '/login',
query: { redirect: '/dashboard' }
});
```
### Programmatic Auth Flow
```typescript
import { useAuthStore } from '@/stores';
import { useAppStore } from '@/stores';
import { useRouter } from 'vue-router';
const authStore = useAuthStore();
const appStore = useAppStore();
const router = useRouter();
// Login
async function login() {
try {
await authStore.login({
username: 'john_doe',
password: 'MySecurePass123'
});
appStore.showSuccess('Login successful!');
router.push('/dashboard');
} catch (error) {
appStore.showError('Login failed. Please check your credentials.');
}
}
// Register
async function register() {
try {
await authStore.register({
username: 'jane_smith',
email: 'jane@example.com',
password: 'SecurePass123'
});
appStore.showSuccess('Account created successfully!');
router.push('/dashboard');
} catch (error) {
appStore.showError('Registration failed. Please try again.');
}
}
```
## Validation Examples
### Login Validation
```typescript
// Valid inputs
Username: "john_doe" (3+ chars)
Password: "SecurePass123" (6+ chars)
// Invalid inputs
Username: "jo" Error: "Username must be at least 3 characters"
Password: "12345" Error: "Password must be at least 6 characters"
Username: "" Error: "Username is required"
Password: "" Error: "Password is required"
```
### Registration Validation
```typescript
// Valid inputs
Username: "jane_smith" (3-50 chars, alphanumeric + _ -)
Email: "jane@example.com" (valid format)
Password: "SecurePass123" (8+ chars, letters + numbers)
Confirm: "SecurePass123" (matches password)
// Invalid inputs
Username: "ja" Error: "Username must be at least 3 characters"
Username: "jane@smith" Error: "Username can only contain letters, numbers, underscores, and hyphens"
Email: "invalid-email" Error: "Please enter a valid email address"
Password: "short" Error: "Password must be at least 8 characters with letters and numbers"
Password: "12345678" Error: "Password must be at least 8 characters with letters and numbers" (no letters)
Password: "password" Error: "Password must be at least 8 characters with letters and numbers" (no numbers)
Confirm: "DifferentPass" Error: "Passwords do not match"
```
## Error Handling Examples
### Backend Errors
```typescript
// Example 1: Username already exists
{
response: {
data: {
detail: "Username 'john_doe' is already taken"
}
}
}
// Displayed: "Username 'john_doe' is already taken"
// Example 2: Invalid credentials
{
response: {
data: {
detail: "Invalid username or password"
}
}
}
// Displayed: "Invalid username or password"
// Example 3: Network error
{
message: "Network Error"
}
// Displayed: "Network Error" + Error toast
// Example 4: Unknown error
{}
// Displayed: "Login failed. Please check your credentials and try again." (default)
```
### Client-side Validation Errors
```typescript
// Multiple validation errors displayed simultaneously
errors = {
username: "Username must be at least 3 characters",
email: "Please enter a valid email address",
password: "Password must be at least 8 characters with letters and numbers",
confirmPassword: "Passwords do not match"
}
// Each error appears below its respective input field with red styling
```
## Testing Examples
### Unit Test: Login View
```typescript
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia } from 'pinia';
import LoginView from '@/views/auth/LoginView.vue';
describe('LoginView', () => {
it('validates required fields', async () => {
const wrapper = mount(LoginView, {
global: {
plugins: [createPinia()],
},
});
// Submit empty form
await wrapper.find('form').trigger('submit');
// Check for validation errors
expect(wrapper.text()).toContain('Username is required');
expect(wrapper.text()).toContain('Password is required');
});
it('calls authStore.login on valid submission', async () => {
const wrapper = mount(LoginView, {
global: {
plugins: [createPinia()],
},
});
// Fill in form
await wrapper.find('#username').setValue('john_doe');
await wrapper.find('#password').setValue('SecurePass123');
// Submit form
await wrapper.find('form').trigger('submit');
// Verify authStore.login was called
// (mock implementation needed)
});
});
```
### E2E Test: Registration Flow
```typescript
import { test, expect } from '@playwright/test';
test('user can register successfully', async ({ page }) => {
// Navigate to register page
await page.goto('/register');
// Fill in registration form
await page.fill('#username', 'new_user');
await page.fill('#email', 'new_user@example.com');
await page.fill('#password', 'SecurePass123');
await page.fill('#confirmPassword', 'SecurePass123');
// Submit form
await page.click('button[type="submit"]');
// Wait for redirect to dashboard
await page.waitForURL('/dashboard');
// Verify success toast appears
await expect(page.locator('.toast-success')).toBeVisible();
await expect(page.locator('.toast-success')).toContainText('Account created successfully');
});
test('shows validation errors for invalid inputs', async ({ page }) => {
await page.goto('/register');
// Enter mismatched passwords
await page.fill('#password', 'SecurePass123');
await page.fill('#confirmPassword', 'DifferentPass');
// Submit form
await page.click('button[type="submit"]');
// Verify error message
await expect(page.locator('text=Passwords do not match')).toBeVisible();
});
```
## Integration with Navigation Guards
### Router Guard Example
```typescript
import { useAuthStore } from '@/stores';
router.beforeEach((to, from, next) => {
const authStore = useAuthStore();
// Redirect authenticated users away from auth pages
if (authStore.isAuthenticated && (to.path === '/login' || to.path === '/register')) {
next('/dashboard');
return;
}
// Redirect unauthenticated users to login
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next({
path: '/login',
query: { redirect: to.fullPath }
});
return;
}
next();
});
```
## Customization Examples
### Custom Success Redirect
```typescript
// In LoginView.vue
async function handleLogin(): Promise<void> {
try {
await authStore.login({
username: formData.username,
password: formData.password,
});
appStore.showSuccess('Login successful!');
// Custom redirect logic
const isAdmin = authStore.isAdmin;
const redirectTo = isAdmin ? '/admin/dashboard' : '/dashboard';
await router.push(redirectTo);
} catch (error) {
// Error handling...
}
}
```
### Custom Validation Rules
```typescript
// Custom password strength validation
function validatePasswordStrength(password: string): boolean {
const hasMinLength = password.length >= 12;
const hasUpperCase = /[A-Z]/.test(password);
const hasLowerCase = /[a-z]/.test(password);
const hasNumber = /[0-9]/.test(password);
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
return hasMinLength && hasUpperCase && hasLowerCase && hasNumber && hasSpecialChar;
}
// Use in validation
if (!validatePasswordStrength(formData.password)) {
errors.password = 'Password must be at least 12 characters with uppercase, lowercase, numbers, and special characters';
isValid = false;
}
```
### Custom Error Handling
```typescript
// In RegisterView.vue
async function handleRegister(): Promise<void> {
try {
await authStore.register({
username: formData.username,
email: formData.email,
password: formData.password,
});
appStore.showSuccess('Account created successfully!');
await router.push('/dashboard');
} catch (error: unknown) {
const err = error as { response?: { status?: number; data?: { detail?: string } } };
// Custom error handling based on status code
if (err.response?.status === 409) {
errorMessage.value = 'This username or email is already registered. Please use a different one.';
} else if (err.response?.status === 422) {
errorMessage.value = 'Invalid input. Please check your information and try again.';
} else if (err.response?.status === 500) {
errorMessage.value = 'Server error. Please try again later.';
} else {
errorMessage.value = err.response?.data?.detail || 'Registration failed. Please try again.';
}
appStore.showError(errorMessage.value);
}
}
```
## Accessibility Examples
### Keyboard Navigation
```typescript
// Tab order:
// 1. Username input
// 2. Password input
// 3. Remember me checkbox (login) / Confirm password (register)
// 4. Submit button
// 5. Footer link (register/login)
// Enter key submits form
// Escape key can be used to clear focus
```
### Screen Reader Support
```html
<!-- Proper labels for screen readers -->
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<input
id="username"
type="text"
aria-label="Username"
aria-required="true"
aria-invalid="false"
aria-describedby="username-error"
/>
<p id="username-error" role="alert" class="text-sm text-red-600">
<!-- Error message here -->
</p>
<!-- Loading state announced -->
<button type="submit" aria-busy="true" aria-label="Signing in...">
<span class="sr-only">Signing in...</span>
<!-- Visual content -->
</button>
```
## Performance Considerations
### Lazy Loading
```typescript
// Router configuration with lazy loading
{
path: '/login',
component: () => import('@/views/auth/LoginView.vue'), // ✅ Lazy loaded
}
// Direct import (not recommended for routes)
import LoginView from '@/views/auth/LoginView.vue'; // ❌ Eager loaded
```
### Optimization Tips
1. Use `v-once` for static content
2. Debounce expensive validation operations
3. Minimize reactive dependencies
4. Use `shallowRef` for complex objects when possible
5. Avoid unnecessary watchers
## Security Best Practices
1. Never log passwords or tokens
2. Use HTTPS in production
3. Implement rate limiting on backend
4. Validate all inputs server-side
5. Use secure password hashing (bcrypt, argon2)
6. Implement CSRF protection
7. Set secure cookie flags
8. Use Content Security Policy headers
9. Sanitize all user inputs
10. Implement account lockout after failed attempts
## Common Issues and Solutions
### Issue: Token not persisting after refresh
```typescript
// Solution: Initialize auth state on app mount
// In main.ts or App.vue
import { useAuthStore } from '@/stores';
const authStore = useAuthStore();
authStore.checkAuth(); // Restore auth from localStorage
```
### Issue: Redirect loop after login
```typescript
// Solution: Check router guard logic
router.beforeEach((to, from, next) => {
const authStore = useAuthStore();
// ✅ Correct: Check specific routes
if (authStore.isAuthenticated && (to.path === '/login' || to.path === '/register')) {
next('/dashboard');
return;
}
// ❌ Wrong: Blanket redirect
// if (authStore.isAuthenticated) {
// next('/dashboard'); // This causes loops!
// }
next();
});
```
### Issue: Form not clearing after successful submission
```typescript
// Solution: Reset form data
async function handleLogin(): Promise<void> {
try {
await authStore.login({...});
// Reset form
formData.username = '';
formData.password = '';
formData.remember = false;
// Clear errors
errors.username = '';
errors.password = '';
await router.push('/dashboard');
} catch (error) {
// Error handling...
}
}
```
## Additional Resources
- [Vue 3 Documentation](https://vuejs.org/)
- [Vue Router Documentation](https://router.vuejs.org/)
- [Pinia Documentation](https://pinia.vuejs.org/)
- [TailwindCSS Documentation](https://tailwindcss.com/)
- [TypeScript Handbook](https://www.typescriptlang.org/docs/)

View File

@@ -0,0 +1,591 @@
# Authentication Views Visual Guide
This document describes the visual design and layout of the authentication views.
## Layout Structure
Both LoginView and RegisterView use the AuthLayout component, which provides:
```
┌─────────────────────────────────────────────┐
│ │
│ ┌─────────────────────────────────────┐ │
│ │ │ │
│ │ Sub2API Logo │ │
│ │ "Subscription to API Conversion" │ │
│ │ │ │
│ └─────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ │ │
│ │ [Form Content - White Card] │ │
│ │ │ │
│ └─────────────────────────────────────┘ │
│ │
│ [Footer Links] │
│ │
└─────────────────────────────────────────────┘
Background: Gradient (Indigo → White → Purple)
Card: White with rounded corners and shadow
Max Width: 28rem (448px)
Centered: Both horizontally and vertically
```
## LoginView Visual Design
### Default State
```
┌─────────────────────────────────────────────┐
│ │
│ 🔷 Sub2API │
│ Subscription to API Conversion Platform │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ │ │
│ │ Welcome Back │ │
│ │ Sign in to your account to continue│ │
│ │ │ │
│ │ Username │ │
│ │ ┌────────────────────────────────┐ │ │
│ │ │ Enter your username │ │ │
│ │ └────────────────────────────────┘ │ │
│ │ │ │
│ │ Password │ │
│ │ ┌────────────────────────────────┐ │ │
│ │ │ •••••••••••••• │ │ │
│ │ └────────────────────────────────┘ │ │
│ │ │ │
│ │ ☐ Remember me │ │
│ │ │ │
│ │ ┌────────────────────────────────┐ │ │
│ │ │ Sign In │ │ │
│ │ └────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────┘ │
│ │
│ Don't have an account? Sign up │
│ │
└─────────────────────────────────────────────┘
```
### Loading State
```
┌─────────────────────────────────────────────┐
│ ┌────────────────────────────────┐ │
│ │ Username │ │
│ │ ┌──────────────────────────┐ │ │
│ │ │ john_doe │ │ │
│ │ └──────────────────────────┘ │ │
│ │ │ │
│ │ Password │ │
│ │ ┌──────────────────────────┐ │ │
│ │ │ •••••••••••• │ │ │
│ │ └──────────────────────────┘ │ │
│ │ │ │
│ │ ☑ Remember me │ │
│ │ │ │
│ │ ┌──────────────────────────┐ │ │
│ │ │ ⟳ Signing in... │ │ ← Spinner
│ │ └──────────────────────────┘ │ │
│ │ (Button disabled) │ │
│ └────────────────────────────────┘ │
└─────────────────────────────────────────────┘
```
### Error State
```
┌─────────────────────────────────────────────┐
│ ┌────────────────────────────────┐ │
│ │ Username │ │
│ │ ┌──────────────────────────┐ │ │
│ │ │ jo │ │ ← Red border
│ │ └──────────────────────────┘ │ │
│ │ ⚠ Username must be at least 3 │ ← Red text
│ │ characters │ │
│ │ │ │
│ │ Password │ │
│ │ ┌──────────────────────────┐ │ │
│ │ │ │ │ ← Red border
│ │ └──────────────────────────┘ │ │
│ │ ⚠ Password is required │ ← Red text
│ │ │ │
│ │ ┌──────────────────────────┐ │ │
│ │ │ ⚠ Invalid username or │ │ ← Error banner
│ │ │ password. Please try │ │
│ │ │ again. │ │
│ │ └──────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────┐ │ │
│ │ │ Sign In │ │ │
│ │ └──────────────────────────┘ │ │
│ └────────────────────────────────┘ │
└─────────────────────────────────────────────┘
```
## RegisterView Visual Design
### Default State
```
┌─────────────────────────────────────────────┐
│ │
│ 🔷 Sub2API │
│ Subscription to API Conversion Platform │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ │ │
│ │ Create Account │ │
│ │ Sign up to start using Sub2API │ │
│ │ │ │
│ │ Username │ │
│ │ ┌────────────────────────────────┐ │ │
│ │ │ Choose a username │ │ │
│ │ └────────────────────────────────┘ │ │
│ │ │ │
│ │ Email │ │
│ │ ┌────────────────────────────────┐ │ │
│ │ │ your.email@example.com │ │ │
│ │ └────────────────────────────────┘ │ │
│ │ │ │
│ │ Password │ │
│ │ ┌────────────────────────────────┐ │ │
│ │ │ Create a strong password │ │ │
│ │ └────────────────────────────────┘ │ │
│ │ At least 8 characters with letters │ │
│ │ and numbers │ │
│ │ │ │
│ │ Confirm Password │ │
│ │ ┌────────────────────────────────┐ │ │
│ │ │ Confirm your password │ │ │
│ │ └────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────┐ │ │
│ │ │ Create Account │ │ │
│ │ └────────────────────────────────┘ │ │
│ │ │ │
│ │ By signing up, you agree to our │ │
│ │ Terms of Service and Privacy Policy│ │
│ │ │ │
│ └─────────────────────────────────────┘ │
│ │
│ Already have an account? Sign in │
│ │
└─────────────────────────────────────────────┘
```
### Validation Errors
```
┌─────────────────────────────────────────────┐
│ ┌────────────────────────────────┐ │
│ │ Username │ │
│ │ ┌──────────────────────────┐ │ │
│ │ │ jane@smith │ │ ← Red border
│ │ └──────────────────────────┘ │ │
│ │ ⚠ Username can only contain │ ← Red text
│ │ letters, numbers, _, and - │ │
│ │ │ │
│ │ Email │ │
│ │ ┌──────────────────────────┐ │ │
│ │ │ invalid-email │ │ ← Red border
│ │ └──────────────────────────┘ │ │
│ │ ⚠ Please enter a valid email │ ← Red text
│ │ address │ │
│ │ │ │
│ │ Password │ │
│ │ ┌──────────────────────────┐ │ │
│ │ │ short │ │ ← Red border
│ │ └──────────────────────────┘ │ │
│ │ ⚠ Password must be at least 8 │ ← Red text
│ │ characters with letters │ │
│ │ and numbers │ │
│ │ │ │
│ │ Confirm Password │ │
│ │ ┌──────────────────────────┐ │ │
│ │ │ different │ │ ← Red border
│ │ └──────────────────────────┘ │ │
│ │ ⚠ Passwords do not match │ ← Red text
│ │ │ │
│ └────────────────────────────────┘ │
└─────────────────────────────────────────────┘
```
### Loading State
```
┌─────────────────────────────────────────────┐
│ ┌────────────────────────────────┐ │
│ │ Username: jane_smith │ │
│ │ Email: jane@example.com │ │
│ │ Password: •••••••••••• │ │
│ │ Confirm: •••••••••••• │ │
│ │ │ │
│ │ ┌──────────────────────────┐ │ │
│ │ │ ⟳ Creating account... │ │ ← Spinner
│ │ └──────────────────────────┘ │ │
│ │ (All inputs disabled) │ │
│ └────────────────────────────────┘ │
└─────────────────────────────────────────────┘
```
## Color Palette
### Primary Colors
- **Indigo-600**: `#4F46E5` - Primary buttons, links, brand color
- **Indigo-700**: `#4338CA` - Button hover state
- **Indigo-500**: `#6366F1` - Focus ring
### Neutral Colors
- **Gray-900**: `#111827` - Headings
- **Gray-700**: `#374151` - Labels
- **Gray-600**: `#4B5563` - Body text
- **Gray-500**: `#6B7280` - Helper text
- **Gray-300**: `#D1D5DB` - Borders
- **Gray-100**: `#F3F4F6` - Disabled backgrounds
- **White**: `#FFFFFF` - Card backgrounds
### Error Colors
- **Red-600**: `#DC2626` - Error text
- **Red-500**: `#EF4444` - Error border, focus ring
- **Red-50**: `#FEF2F2` - Error banner background
- **Red-200**: `#FECACA` - Error banner border
### Success Colors
- **Green-600**: `#16A34A` - Success text
- **Green-50**: `#F0FDF4` - Success banner background
### Background Gradient
- **From**: Indigo-100 (`#E0E7FF`)
- **Via**: White (`#FFFFFF`)
- **To**: Purple-100 (`#F3E8FF`)
## Typography
### Font Family
- **Default**: System font stack (`ui-sans-serif, system-ui, -apple-system, ...`)
### Font Sizes
- **Headings (h2)**: `1.5rem` (24px), `font-bold`
- **Body**: `0.875rem` (14px), `font-normal`
- **Labels**: `0.875rem` (14px), `font-medium`
- **Helper text**: `0.75rem` (12px), `font-normal`
- **Error text**: `0.875rem` (14px), `font-normal`
### Line Heights
- **Headings**: `1.5`
- **Body**: `1.5`
- **Helper text**: `1.25`
## Spacing
### Card Spacing
- **Padding**: `2rem` (32px) all sides
- **Gap between sections**: `1.5rem` (24px)
- **Gap between fields**: `1rem` (16px)
### Input Spacing
- **Padding**: `0.5rem 1rem` (8px 16px)
- **Label margin-bottom**: `0.25rem` (4px)
- **Error text margin-top**: `0.25rem` (4px)
### Button Spacing
- **Padding**: `0.5rem 1rem` (8px 16px)
- **Margin-top**: `1rem` (16px)
## Interactive States
### Input States
**Default:**
```css
border: 1px solid #D1D5DB (gray-300)
focus: 2px ring #6366F1 (indigo-500)
```
**Error:**
```css
border: 1px solid #EF4444 (red-500)
focus: 2px ring #EF4444 (red-500)
```
**Disabled:**
```css
background: #F3F4F6 (gray-100)
cursor: not-allowed
opacity: 0.6
```
### Button States
**Default:**
```css
background: #4F46E5 (indigo-600)
text: #FFFFFF (white)
shadow: shadow-sm
```
**Hover:**
```css
background: #4338CA (indigo-700)
transition: colors 150ms
```
**Focus:**
```css
outline: none
ring: 2px offset-2 #6366F1 (indigo-500)
```
**Disabled:**
```css
opacity: 0.5
cursor: not-allowed
```
**Loading:**
```css
opacity: 0.5
cursor: not-allowed
+ spinning icon
```
### Link States
**Default:**
```css
color: #4F46E5 (indigo-600)
font-weight: 500 (medium)
```
**Hover:**
```css
color: #6366F1 (indigo-500)
transition: colors 150ms
```
## Responsive Design
### Breakpoints
**Mobile (< 640px):**
```
- Full width container
- Padding: 1rem (16px)
- Smaller text sizes
```
**Tablet (640px - 768px):**
```
- Max width: 28rem (448px)
- Centered layout
- Standard spacing
```
**Desktop (> 768px):**
```
- Max width: 28rem (448px)
- Centered layout
- Standard spacing
```
### Mobile Optimizations
1. Touch-friendly tap targets (44px minimum)
2. Proper keyboard handling on mobile
3. Prevent zoom on input focus
4. Responsive font sizes
5. Full-width inputs
6. Adequate spacing for thumbs
## Animations
### Transitions
- Color changes: `150ms ease-in-out`
- Opacity changes: `150ms ease-in-out`
- Transform: `150ms ease-in-out`
### Loading Spinner
```css
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
animation: spin 1s linear infinite;
```
### Toast Animations
- Enter: Slide in from right + fade in
- Exit: Slide out to right + fade out
- Duration: 300ms
## Accessibility Features
### Visual Indicators
- Clear focus states (2px ring)
- Error states (red border + red text)
- Loading states (spinner + text)
- Success states (green toast)
### Color Contrast
- Text on white: > 7:1 (AAA)
- Labels on white: > 4.5:1 (AA)
- Buttons: > 4.5:1 (AA)
- Error text: > 4.5:1 (AA)
### Interactive Elements
- Minimum size: 44x44px (mobile)
- Clear hover states
- Distinct disabled states
- Keyboard accessible
### Screen Reader Support
- Proper labels on all inputs
- ARIA attributes where needed
- Error announcements
- Loading state announcements
## Icons
### Loading Spinner
```svg
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
```
### Error Icon
```svg
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
```
## Browser Compatibility
### Supported Browsers
- Chrome/Edge: Latest 2 versions
- Firefox: Latest 2 versions
- Safari: Latest 2 versions
- Mobile Safari: iOS 14+
- Chrome Mobile: Latest 2 versions
### CSS Features Used
- Flexbox (full support)
- CSS Grid (full support)
- CSS Transitions (full support)
- CSS Custom Properties (full support)
- Gradient backgrounds (full support)
### JavaScript Features Used
- ES2015+ syntax
- Async/await
- Optional chaining
- Nullish coalescing
- Modules
## Print Styles
(Not applicable for authentication pages - users shouldn't print login forms)
## Dark Mode Considerations
**Future Enhancement:**
- Dark mode toggle in user preferences
- System preference detection
- Persistent dark mode setting
- Adjusted color palette for dark backgrounds
```css
/* Example dark mode colors (not implemented yet) */
dark:bg-gray-900
dark:text-white
dark:border-gray-700
```
## Performance Metrics
### Target Metrics
- First Contentful Paint (FCP): < 1s
- Largest Contentful Paint (LCP): < 2.5s
- Time to Interactive (TTI): < 3s
- Cumulative Layout Shift (CLS): < 0.1
- First Input Delay (FID): < 100ms
### Optimization Strategies
- Lazy load non-critical resources
- Minimize initial bundle size
- Use efficient animations (transform, opacity)
- Optimize images (logo, icons)
- Preconnect to API domain
- Cache static assets
## Component Size
### Bundle Impact
- LoginView.vue: ~4 KB (minified)
- RegisterView.vue: ~6 KB (minified)
- AuthLayout.vue: ~1 KB (minified)
- Total: ~11 KB (excluding dependencies)
### Dependencies
- Vue 3: ~40 KB (runtime)
- Vue Router: ~15 KB
- Pinia: ~10 KB
- Total framework overhead: ~65 KB (gzipped)
## Testing Checklist
### Visual Regression Tests
- [ ] Default state (login)
- [ ] Default state (register)
- [ ] Loading state
- [ ] Error state (validation)
- [ ] Error state (API)
- [ ] Success state
- [ ] Mobile view
- [ ] Tablet view
- [ ] Desktop view
- [ ] Focus states
- [ ] Hover states
### Cross-browser Tests
- [ ] Chrome (Windows, Mac, Linux)
- [ ] Firefox (Windows, Mac, Linux)
- [ ] Safari (Mac, iOS)
- [ ] Edge (Windows)
- [ ] Chrome Mobile (Android)
- [ ] Safari Mobile (iOS)
### Accessibility Tests
- [ ] Keyboard navigation
- [ ] Screen reader (NVDA)
- [ ] Screen reader (JAWS)
- [ ] Screen reader (VoiceOver)
- [ ] Color contrast
- [ ] Focus indicators
- [ ] Error announcements
## Design Assets
### Figma/Sketch Files
(Not applicable - designed directly in code with Tailwind)
### Design Tokens
- Defined in Tailwind config
- Consistent with design system
- Reusable across all components
### Iconography
- SVG icons inline
- Heroicons (outline and solid)
- Consistent stroke width
- Accessible with proper ARIA labels
---
**Note:** This visual guide is for reference and documentation purposes. The actual implementation is in the Vue components using TailwindCSS classes.

View File

@@ -0,0 +1,7 @@
/**
* Authentication Views
* Export all authentication-related views
*/
export { default as LoginView } from './LoginView.vue';
export { default as RegisterView } from './RegisterView.vue';

View File

@@ -0,0 +1,400 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-dark-900 dark:to-dark-800 flex items-center justify-center p-4">
<div class="w-full max-w-2xl">
<!-- Logo & Title -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-primary-500 to-primary-600 shadow-lg mb-4">
<svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Sub2API Setup</h1>
<p class="mt-2 text-gray-500 dark:text-dark-400">Configure your Sub2API instance</p>
</div>
<!-- Progress Steps -->
<div class="mb-8">
<div class="flex items-center justify-center">
<template v-for="(step, index) in steps" :key="step.id">
<div class="flex items-center">
<div
:class="[
'w-10 h-10 rounded-full flex items-center justify-center font-semibold text-sm transition-all',
currentStep > index
? 'bg-primary-500 text-white'
: currentStep === index
? 'bg-primary-500 text-white ring-4 ring-primary-100 dark:ring-primary-900'
: 'bg-gray-200 dark:bg-dark-700 text-gray-500 dark:text-dark-400'
]"
>
<svg v-if="currentStep > index" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
<span v-else>{{ index + 1 }}</span>
</div>
<span class="ml-2 text-sm font-medium" :class="currentStep >= index ? 'text-gray-900 dark:text-white' : 'text-gray-400 dark:text-dark-500'">
{{ step.title }}
</span>
</div>
<div v-if="index < steps.length - 1" class="w-12 h-0.5 mx-3" :class="currentStep > index ? 'bg-primary-500' : 'bg-gray-200 dark:bg-dark-700'"></div>
</template>
</div>
</div>
<!-- Step Content -->
<div class="bg-white dark:bg-dark-800 rounded-2xl shadow-xl p-8">
<!-- Step 1: Database -->
<div v-if="currentStep === 0" class="space-y-6">
<div class="text-center mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Database Configuration</h2>
<p class="text-sm text-gray-500 dark:text-dark-400 mt-1">Connect to your PostgreSQL database</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">Host</label>
<input v-model="formData.database.host" type="text" class="input" placeholder="localhost" />
</div>
<div>
<label class="input-label">Port</label>
<input v-model.number="formData.database.port" type="number" class="input" placeholder="5432" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">Username</label>
<input v-model="formData.database.user" type="text" class="input" placeholder="postgres" />
</div>
<div>
<label class="input-label">Password</label>
<input v-model="formData.database.password" type="password" class="input" placeholder="Password" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">Database Name</label>
<input v-model="formData.database.dbname" type="text" class="input" placeholder="sub2api" />
</div>
<div>
<label class="input-label">SSL Mode</label>
<select v-model="formData.database.sslmode" class="input">
<option value="disable">Disable</option>
<option value="require">Require</option>
<option value="verify-ca">Verify CA</option>
<option value="verify-full">Verify Full</option>
</select>
</div>
</div>
<button
@click="testDatabaseConnection"
:disabled="testingDb"
class="btn btn-secondary w-full"
>
<svg v-if="testingDb" class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<svg v-else-if="dbConnected" class="w-5 h-5 mr-2 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
{{ testingDb ? 'Testing...' : dbConnected ? 'Connection Successful' : 'Test Connection' }}
</button>
</div>
<!-- Step 2: Redis -->
<div v-if="currentStep === 1" class="space-y-6">
<div class="text-center mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Redis Configuration</h2>
<p class="text-sm text-gray-500 dark:text-dark-400 mt-1">Connect to your Redis server</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">Host</label>
<input v-model="formData.redis.host" type="text" class="input" placeholder="localhost" />
</div>
<div>
<label class="input-label">Port</label>
<input v-model.number="formData.redis.port" type="number" class="input" placeholder="6379" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">Password (optional)</label>
<input v-model="formData.redis.password" type="password" class="input" placeholder="Password" />
</div>
<div>
<label class="input-label">Database</label>
<input v-model.number="formData.redis.db" type="number" class="input" placeholder="0" />
</div>
</div>
<button
@click="testRedisConnection"
:disabled="testingRedis"
class="btn btn-secondary w-full"
>
<svg v-if="testingRedis" class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<svg v-else-if="redisConnected" class="w-5 h-5 mr-2 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
{{ testingRedis ? 'Testing...' : redisConnected ? 'Connection Successful' : 'Test Connection' }}
</button>
</div>
<!-- Step 3: Admin -->
<div v-if="currentStep === 2" class="space-y-6">
<div class="text-center mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Admin Account</h2>
<p class="text-sm text-gray-500 dark:text-dark-400 mt-1">Create your administrator account</p>
</div>
<div>
<label class="input-label">Email</label>
<input v-model="formData.admin.email" type="email" class="input" placeholder="admin@example.com" />
</div>
<div>
<label class="input-label">Password</label>
<input v-model="formData.admin.password" type="password" class="input" placeholder="Min 6 characters" />
</div>
<div>
<label class="input-label">Confirm Password</label>
<input v-model="confirmPassword" type="password" class="input" placeholder="Confirm password" />
<p v-if="confirmPassword && formData.admin.password !== confirmPassword" class="input-error-text">
Passwords do not match
</p>
</div>
</div>
<!-- Step 4: Complete -->
<div v-if="currentStep === 3" class="space-y-6">
<div class="text-center mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Ready to Install</h2>
<p class="text-sm text-gray-500 dark:text-dark-400 mt-1">Review your configuration and complete setup</p>
</div>
<div class="space-y-4">
<div class="p-4 bg-gray-50 dark:bg-dark-700 rounded-xl">
<h3 class="text-sm font-medium text-gray-500 dark:text-dark-400 mb-2">Database</h3>
<p class="text-gray-900 dark:text-white">{{ formData.database.user }}@{{ formData.database.host }}:{{ formData.database.port }}/{{ formData.database.dbname }}</p>
</div>
<div class="p-4 bg-gray-50 dark:bg-dark-700 rounded-xl">
<h3 class="text-sm font-medium text-gray-500 dark:text-dark-400 mb-2">Redis</h3>
<p class="text-gray-900 dark:text-white">{{ formData.redis.host }}:{{ formData.redis.port }}</p>
</div>
<div class="p-4 bg-gray-50 dark:bg-dark-700 rounded-xl">
<h3 class="text-sm font-medium text-gray-500 dark:text-dark-400 mb-2">Admin Email</h3>
<p class="text-gray-900 dark:text-white">{{ formData.admin.email }}</p>
</div>
</div>
</div>
<!-- Error Message -->
<div v-if="errorMessage" class="mt-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/50 rounded-xl">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-red-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
<p class="text-sm text-red-700 dark:text-red-400">{{ errorMessage }}</p>
</div>
</div>
<!-- Success Message -->
<div v-if="installSuccess" class="mt-6 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800/50 rounded-xl">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-green-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<p class="text-sm font-medium text-green-700 dark:text-green-400">Installation completed!</p>
<p class="text-sm text-green-600 dark:text-green-500 mt-1">Please restart the service to apply changes.</p>
</div>
</div>
</div>
<!-- Navigation Buttons -->
<div class="mt-8 flex justify-between">
<button
v-if="currentStep > 0 && !installSuccess"
@click="currentStep--"
class="btn btn-secondary"
>
<svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
Previous
</button>
<div v-else></div>
<button
v-if="currentStep < 3"
@click="nextStep"
:disabled="!canProceed"
class="btn btn-primary"
>
Next
<svg class="w-4 h-4 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
<button
v-else-if="!installSuccess"
@click="performInstall"
:disabled="installing"
class="btn btn-primary"
>
<svg v-if="installing" class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ installing ? 'Installing...' : 'Complete Installation' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue';
import { testDatabase, testRedis, install, type InstallRequest } from '@/api/setup';
const steps = [
{ id: 'database', title: 'Database' },
{ id: 'redis', title: 'Redis' },
{ id: 'admin', title: 'Admin' },
{ id: 'complete', title: 'Complete' },
];
const currentStep = ref(0);
const errorMessage = ref('');
const installSuccess = ref(false);
// Connection test states
const testingDb = ref(false);
const testingRedis = ref(false);
const dbConnected = ref(false);
const redisConnected = ref(false);
const installing = ref(false);
const confirmPassword = ref('');
// Get current server port from browser location (set by install.sh)
const getCurrentPort = (): number => {
const port = window.location.port;
if (port) {
return parseInt(port, 10);
}
// Default port based on protocol
return window.location.protocol === 'https:' ? 443 : 80;
};
const formData = reactive<InstallRequest>({
database: {
host: 'localhost',
port: 5432,
user: 'postgres',
password: '',
dbname: 'sub2api',
sslmode: 'disable',
},
redis: {
host: 'localhost',
port: 6379,
password: '',
db: 0,
},
admin: {
email: '',
password: '',
},
server: {
host: '0.0.0.0',
port: getCurrentPort(), // Use current port from browser
mode: 'release',
},
});
const canProceed = computed(() => {
switch (currentStep.value) {
case 0:
return dbConnected.value;
case 1:
return redisConnected.value;
case 2:
return (
formData.admin.email &&
formData.admin.password.length >= 6 &&
formData.admin.password === confirmPassword.value
);
default:
return true;
}
});
async function testDatabaseConnection() {
testingDb.value = true;
errorMessage.value = '';
dbConnected.value = false;
try {
await testDatabase(formData.database);
dbConnected.value = true;
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } }; message?: string };
errorMessage.value = err.response?.data?.detail || err.message || 'Connection failed';
} finally {
testingDb.value = false;
}
}
async function testRedisConnection() {
testingRedis.value = true;
errorMessage.value = '';
redisConnected.value = false;
try {
await testRedis(formData.redis);
redisConnected.value = true;
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } }; message?: string };
errorMessage.value = err.response?.data?.detail || err.message || 'Connection failed';
} finally {
testingRedis.value = false;
}
}
function nextStep() {
if (canProceed.value) {
errorMessage.value = '';
currentStep.value++;
}
}
async function performInstall() {
installing.value = true;
errorMessage.value = '';
try {
await install(formData);
installSuccess.value = true;
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } }; message?: string };
errorMessage.value = err.response?.data?.detail || err.message || 'Installation failed';
} finally {
installing.value = false;
}
}
</script>

View File

@@ -0,0 +1,738 @@
<template>
<AppLayout>
<div class="space-y-6">
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-12">
<LoadingSpinner />
</div>
<template v-else-if="stats">
<!-- Row 1: Core Stats -->
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<!-- Balance -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-emerald-100 dark:bg-emerald-900/30">
<svg class="w-5 h-5 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.balance') }}</p>
<p class="text-xl font-bold text-emerald-600 dark:text-emerald-400">${{ formatBalance(user?.balance || 0) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('common.available') }}</p>
</div>
</div>
</div>
<!-- API Keys -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/30">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.apiKeys') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ stats.total_api_keys }}</p>
<p class="text-xs text-green-600 dark:text-green-400">{{ stats.active_api_keys }} {{ t('common.active') }}</p>
</div>
</div>
</div>
<!-- Today Requests -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-green-100 dark:bg-green-900/30">
<svg class="w-5 h-5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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" />
</svg>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.todayRequests') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ stats.today_requests }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('common.total') }}: {{ formatNumber(stats.total_requests) }}</p>
</div>
</div>
</div>
<!-- Today Cost -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-purple-100 dark:bg-purple-900/30">
<svg class="w-5 h-5 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.todayCost') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">
<span class="text-purple-600 dark:text-purple-400" :title="t('dashboard.actual')">${{ formatCost(stats.today_actual_cost) }}</span>
<span class="text-sm font-normal text-gray-400 dark:text-gray-500" :title="t('dashboard.standard')"> / ${{ formatCost(stats.today_cost) }}</span>
</p>
<p class="text-xs">
<span class="text-gray-500 dark:text-gray-400">{{ t('common.total') }}: </span>
<span class="text-purple-600 dark:text-purple-400" :title="t('dashboard.actual')">${{ formatCost(stats.total_actual_cost) }}</span>
<span class="text-gray-400 dark:text-gray-500" :title="t('dashboard.standard')"> / ${{ formatCost(stats.total_cost) }}</span>
</p>
</div>
</div>
</div>
</div>
<!-- Row 2: Token Stats -->
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<!-- Today Tokens -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-amber-100 dark:bg-amber-900/30">
<svg class="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />
</svg>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.todayTokens') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats.today_tokens) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('dashboard.input') }}: {{ formatTokens(stats.today_input_tokens) }} / {{ t('dashboard.output') }}: {{ formatTokens(stats.today_output_tokens) }}
</p>
</div>
</div>
</div>
<!-- Total Tokens -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-indigo-100 dark:bg-indigo-900/30">
<svg class="w-5 h-5 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
</svg>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.totalTokens') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats.total_tokens) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('dashboard.input') }}: {{ formatTokens(stats.total_input_tokens) }} / {{ t('dashboard.output') }}: {{ formatTokens(stats.total_output_tokens) }}
</p>
</div>
</div>
</div>
<!-- Cache Tokens -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-cyan-100 dark:bg-cyan-900/30">
<svg class="w-5 h-5 text-cyan-600 dark:text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m.75 12l3 3m0 0l3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.cacheToday') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats.today_cache_read_tokens) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('common.create') }}: {{ formatTokens(stats.today_cache_creation_tokens) }}
</p>
</div>
</div>
</div>
<!-- Avg Response Time -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-rose-100 dark:bg-rose-900/30">
<svg class="w-5 h-5 text-rose-600 dark:text-rose-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.avgResponse') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatDuration(stats.average_duration_ms) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('dashboard.averageTime') }}</p>
</div>
</div>
</div>
</div>
<!-- Charts Section -->
<div class="space-y-6">
<!-- Date Range Filter -->
<div class="card p-4">
<div class="flex flex-wrap items-center gap-4">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('dashboard.timeRange') }}:</span>
<DateRangePicker
v-model:start-date="startDate"
v-model:end-date="endDate"
@change="onDateRangeChange"
/>
</div>
<div class="flex items-center gap-2 ml-auto">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('dashboard.granularity') }}:</span>
<div class="w-28">
<Select
v-model="granularity"
:options="granularityOptions"
@change="loadChartData"
/>
</div>
</div>
</div>
</div>
<!-- Charts Grid -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- Model Distribution Chart -->
<div class="card p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('dashboard.modelDistribution') }}</h3>
<div class="flex items-center gap-6">
<div class="w-48 h-48">
<Doughnut v-if="modelChartData" :data="modelChartData" :options="doughnutOptions" />
<div v-else class="flex items-center justify-center h-full text-gray-500 dark:text-gray-400 text-sm">
{{ t('dashboard.noDataAvailable') }}
</div>
</div>
<div class="flex-1 max-h-48 overflow-y-auto">
<table class="w-full text-xs">
<thead>
<tr class="text-gray-500 dark:text-gray-400">
<th class="text-left pb-2">{{ t('dashboard.model') }}</th>
<th class="text-right pb-2">{{ t('dashboard.requests') }}</th>
<th class="text-right pb-2">{{ t('dashboard.tokens') }}</th>
<th class="text-right pb-2">{{ t('dashboard.actual') }}</th>
<th class="text-right pb-2">{{ t('dashboard.standard') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="model in modelStats" :key="model.model" class="border-t border-gray-100 dark:border-gray-700">
<td class="py-1.5 text-gray-900 dark:text-white font-medium truncate max-w-[100px]" :title="model.model">{{ model.model }}</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatNumber(model.requests) }}</td>
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatTokens(model.total_tokens) }}</td>
<td class="py-1.5 text-right text-green-600 dark:text-green-400">${{ formatCost(model.actual_cost) }}</td>
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">${{ formatCost(model.cost) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Token Usage Trend Chart -->
<div class="card p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('dashboard.tokenUsageTrend') }}</h3>
<div class="h-48">
<Line v-if="trendChartData" :data="trendChartData" :options="lineOptions" />
<div v-else class="flex items-center justify-center h-full text-gray-500 dark:text-gray-400 text-sm">
{{ t('dashboard.noDataAvailable') }}
</div>
</div>
</div>
</div>
</div>
<!-- Main Content Grid -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Recent Usage - Takes 2 columns -->
<div class="lg:col-span-2">
<div class="card">
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('dashboard.recentUsage') }}</h2>
<span class="badge badge-gray">{{ t('dashboard.last7Days') }}</span>
</div>
<div class="p-6">
<div v-if="loadingUsage" class="flex items-center justify-center py-12">
<LoadingSpinner size="lg" />
</div>
<div v-else-if="recentUsage.length === 0" class="py-8">
<EmptyState
:title="t('dashboard.noUsageRecords')"
:description="t('dashboard.startUsingApi')"
/>
</div>
<div v-else class="space-y-3">
<div
v-for="log in recentUsage"
:key="log.id"
class="flex items-center justify-between p-4 rounded-xl bg-gray-50 dark:bg-dark-800/50 hover:bg-gray-100 dark:hover:bg-dark-800 transition-colors"
>
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-xl bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center">
<svg class="w-5 h-5 text-primary-600 dark:text-primary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5" />
</svg>
</div>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ log.model }}</p>
<p class="text-xs text-gray-500 dark:text-dark-400">
{{ formatDate(log.created_at) }}
</p>
</div>
</div>
<div class="text-right">
<p class="text-sm font-semibold">
<span class="text-green-600 dark:text-green-400" title="实际扣除">${{ formatCost(log.actual_cost) }}</span>
<span class="text-gray-400 dark:text-gray-500 font-normal" title="标准计费"> / ${{ formatCost(log.total_cost) }}</span>
</p>
<p class="text-xs text-gray-500 dark:text-dark-400">
{{ (log.input_tokens + log.output_tokens).toLocaleString() }} tokens
</p>
</div>
</div>
<router-link
to="/usage"
class="flex items-center justify-center gap-2 py-3 text-sm font-medium text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 transition-colors"
>
{{ t('dashboard.viewAllUsage') }}
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</router-link>
</div>
</div>
</div>
</div>
<!-- Quick Actions - Takes 1 column -->
<div class="lg:col-span-1">
<div class="card">
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('dashboard.quickActions') }}</h2>
</div>
<div class="p-4 space-y-3">
<button
@click="navigateTo('/keys')"
class="w-full flex items-center gap-4 p-4 rounded-xl bg-gray-50 dark:bg-dark-800/50 hover:bg-gray-100 dark:hover:bg-dark-800 transition-all duration-200 group text-left"
>
<div class="flex-shrink-0 w-12 h-12 rounded-xl bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center group-hover:scale-105 transition-transform">
<svg class="w-6 h-6 text-primary-600 dark:text-primary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('dashboard.createApiKey') }}</p>
<p class="text-xs text-gray-500 dark:text-dark-400">{{ t('dashboard.generateNewKey') }}</p>
</div>
<svg class="w-5 h-5 text-gray-400 dark:text-dark-500 group-hover:text-primary-500 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
<button
@click="navigateTo('/usage')"
class="w-full flex items-center gap-4 p-4 rounded-xl bg-gray-50 dark:bg-dark-800/50 hover:bg-gray-100 dark:hover:bg-dark-800 transition-all duration-200 group text-left"
>
<div class="flex-shrink-0 w-12 h-12 rounded-xl bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center group-hover:scale-105 transition-transform">
<svg class="w-6 h-6 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<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" />
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('dashboard.viewUsage') }}</p>
<p class="text-xs text-gray-500 dark:text-dark-400">{{ t('dashboard.checkDetailedLogs') }}</p>
</div>
<svg class="w-5 h-5 text-gray-400 dark:text-dark-500 group-hover:text-emerald-500 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
<button
@click="navigateTo('/redeem')"
class="w-full flex items-center gap-4 p-4 rounded-xl bg-gray-50 dark:bg-dark-800/50 hover:bg-gray-100 dark:hover:bg-dark-800 transition-all duration-200 group text-left"
>
<div class="flex-shrink-0 w-12 h-12 rounded-xl bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center group-hover:scale-105 transition-transform">
<svg class="w-6 h-6 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('dashboard.redeemCode') }}</p>
<p class="text-xs text-gray-500 dark:text-dark-400">{{ t('dashboard.addBalanceWithCode') }}</p>
</div>
<svg class="w-5 h-5 text-gray-400 dark:text-dark-500 group-hover:text-amber-500 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
</div>
</div>
</div>
</div>
</template>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
const { t } = useI18n()
import { usageAPI, type UserDashboardStats } from '@/api/usage'
import type { UsageLog, TrendDataPoint, ModelStat } from '@/types'
import AppLayout from '@/components/layout/AppLayout.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import DateRangePicker from '@/components/common/DateRangePicker.vue'
import Select from '@/components/common/Select.vue'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js'
import { Line, Doughnut } from 'vue-chartjs'
// Register Chart.js components
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler
)
const router = useRouter()
const authStore = useAuthStore()
const user = computed(() => authStore.user)
const stats = ref<UserDashboardStats | null>(null)
const loading = ref(false)
const loadingUsage = ref(false)
// Chart data
const trendData = ref<TrendDataPoint[]>([])
const modelStats = ref<ModelStat[]>([])
// Recent usage
const recentUsage = ref<UsageLog[]>([])
// Date range
const granularity = ref<'day' | 'hour'>('day')
const startDate = ref('')
const endDate = ref('')
// Granularity options for Select component
const granularityOptions = computed(() => [
{ value: 'day', label: t('dashboard.day') },
{ value: 'hour', label: t('dashboard.hour') },
])
// Dark mode detection
const isDarkMode = computed(() => {
return document.documentElement.classList.contains('dark')
})
// Chart colors
const chartColors = computed(() => ({
text: isDarkMode.value ? '#e5e7eb' : '#374151',
grid: isDarkMode.value ? '#374151' : '#e5e7eb',
input: '#3b82f6',
output: '#10b981',
cache: '#f59e0b',
}))
// Doughnut chart options
const doughnutOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
tooltip: {
callbacks: {
label: (context: any) => {
const value = context.raw as number
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
const percentage = ((value / total) * 100).toFixed(1)
return `${context.label}: ${formatTokens(value)} (${percentage}%)`
},
},
},
},
}))
// Line chart options
const lineOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index' as const,
},
plugins: {
legend: {
position: 'top' as const,
labels: {
color: chartColors.value.text,
usePointStyle: true,
pointStyle: 'circle',
padding: 15,
font: {
size: 11,
},
},
},
tooltip: {
callbacks: {
label: (context: any) => {
return `${context.dataset.label}: ${formatTokens(context.raw)}`
},
footer: (tooltipItems: any) => {
const dataIndex = tooltipItems[0]?.dataIndex
if (dataIndex !== undefined && trendData.value[dataIndex]) {
const data = trendData.value[dataIndex]
return `Actual: $${formatCost(data.actual_cost)} | Standard: $${formatCost(data.cost)}`
}
return ''
},
},
},
},
scales: {
x: {
grid: {
color: chartColors.value.grid,
},
ticks: {
color: chartColors.value.text,
font: {
size: 10,
},
},
},
y: {
grid: {
color: chartColors.value.grid,
},
ticks: {
color: chartColors.value.text,
font: {
size: 10,
},
callback: (value: number) => formatTokens(value),
},
},
},
}))
// Model chart data
const modelChartData = computed(() => {
if (!modelStats.value.length) return null
const colors = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16'
]
return {
labels: modelStats.value.map(m => m.model),
datasets: [{
data: modelStats.value.map(m => m.total_tokens),
backgroundColor: colors.slice(0, modelStats.value.length),
borderWidth: 0,
}],
}
})
// Trend chart data
const trendChartData = computed(() => {
if (!trendData.value.length) return null
return {
labels: trendData.value.map(d => d.date),
datasets: [
{
label: 'Input',
data: trendData.value.map(d => d.input_tokens),
borderColor: chartColors.value.input,
backgroundColor: `${chartColors.value.input}20`,
fill: true,
tension: 0.3,
},
{
label: 'Output',
data: trendData.value.map(d => d.output_tokens),
borderColor: chartColors.value.output,
backgroundColor: `${chartColors.value.output}20`,
fill: true,
tension: 0.3,
},
{
label: 'Cache',
data: trendData.value.map(d => d.cache_tokens),
borderColor: chartColors.value.cache,
backgroundColor: `${chartColors.value.cache}20`,
fill: true,
tension: 0.3,
},
],
}
})
// Format helpers
const formatTokens = (value: number): string => {
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(2)}B`
} else if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(2)}M`
} else if (value >= 1_000) {
return `${(value / 1_000).toFixed(2)}K`
}
return value.toLocaleString()
}
const formatNumber = (value: number): string => {
return value.toLocaleString()
}
const formatBalance = (balance: number): string => {
return balance.toFixed(2)
}
const formatCost = (value: number): string => {
if (value >= 1000) {
return (value / 1000).toFixed(2) + 'K'
} else if (value >= 1) {
return value.toFixed(2)
} else if (value >= 0.01) {
return value.toFixed(3)
}
return value.toFixed(4)
}
const formatDuration = (ms: number): string => {
if (ms >= 1000) {
return `${(ms / 1000).toFixed(2)}s`
}
return `${Math.round(ms)}ms`
}
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const navigateTo = (path: string) => {
router.push(path)
}
// Date range change handler
const onDateRangeChange = (range: { startDate: string; endDate: string; preset: string | null }) => {
const start = new Date(range.startDate)
const end = new Date(range.endDate)
const daysDiff = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))
if (daysDiff <= 1) {
granularity.value = 'hour'
} else {
granularity.value = 'day'
}
loadChartData()
}
// Initialize default date range
const initializeDateRange = () => {
const now = new Date()
const today = now.toISOString().split('T')[0]
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
startDate.value = weekAgo.toISOString().split('T')[0]
endDate.value = today
granularity.value = 'day'
}
// Load data
const loadDashboardStats = async () => {
loading.value = true
try {
await authStore.refreshUser()
stats.value = await usageAPI.getDashboardStats()
} catch (error) {
console.error('Error loading dashboard stats:', error)
} finally {
loading.value = false
}
}
const loadChartData = async () => {
try {
const params = {
start_date: startDate.value,
end_date: endDate.value,
granularity: granularity.value,
}
const [trendResponse, modelResponse] = await Promise.all([
usageAPI.getDashboardTrend(params),
usageAPI.getDashboardModels({ start_date: startDate.value, end_date: endDate.value }),
])
trendData.value = trendResponse.trend
modelStats.value = modelResponse.models
} catch (error) {
console.error('Error loading chart data:', error)
}
}
const loadRecentUsage = async () => {
loadingUsage.value = true
try {
const endDate = new Date().toISOString()
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString()
const usageResponse = await usageAPI.getByDateRange(startDate, endDate)
recentUsage.value = usageResponse.items.slice(0, 5)
} catch (error) {
console.error('Failed to load recent usage:', error)
} finally {
loadingUsage.value = false
}
}
onMounted(() => {
loadDashboardStats()
initializeDateRange()
loadChartData()
loadRecentUsage()
})
// Watch for dark mode changes
watch(isDarkMode, () => {
// Force chart re-render on theme change
})
</script>
<style scoped>
/* Compact Select styling for dashboard */
:deep(.select-trigger) {
@apply px-3 py-1.5 text-sm rounded-lg;
}
:deep(.select-dropdown) {
@apply rounded-lg;
}
:deep(.select-option) {
@apply px-3 py-2 text-sm;
}
</style>

View File

@@ -0,0 +1,733 @@
<template>
<AppLayout>
<div class="space-y-6">
<!-- Page Header Actions -->
<div class="flex justify-end">
<button
@click="showCreateModal = true"
class="btn btn-primary"
>
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{{ t('keys.createKey') }}
</button>
</div>
<!-- API Keys Table -->
<div class="card overflow-hidden">
<DataTable
:columns="columns"
:data="apiKeys"
:loading="loading"
>
<template #cell-key="{ value, row }">
<div class="flex items-center gap-2">
<code class="code text-xs">
{{ maskKey(value) }}
</code>
<button
@click="copyToClipboard(value, row.id)"
class="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
:class="copiedKeyId === row.id ? 'text-green-500' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'"
:title="copiedKeyId === row.id ? t('keys.copied') : t('keys.copyToClipboard')"
>
<svg v-if="copiedKeyId === row.id" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
</svg>
</button>
</div>
</template>
<template #cell-name="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template>
<template #cell-group="{ row }">
<div class="relative group/dropdown">
<button
:ref="(el) => setGroupButtonRef(row.id, el)"
@click="openGroupSelector(row)"
class="flex items-center gap-2 px-2 py-1 -mx-2 -my-1 rounded-lg hover:bg-gray-100 dark:hover:bg-dark-700 transition-all duration-200 cursor-pointer"
:title="t('keys.clickToChangeGroup')"
>
<GroupBadge
v-if="row.group"
:name="row.group.name"
:subscription-type="row.group.subscription_type"
:rate-multiplier="row.group.rate_multiplier"
/>
<span v-else class="text-sm text-gray-400 dark:text-dark-500">{{ t('keys.noGroup') }}</span>
<svg class="w-3.5 h-3.5 text-gray-400 opacity-0 group-hover/dropdown:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" />
</svg>
</button>
</div>
</template>
<template #cell-usage="{ row }">
<div class="text-sm">
<div class="flex items-center gap-1.5">
<span class="text-gray-500 dark:text-gray-400">{{ t('keys.today') }}:</span>
<span class="font-medium text-gray-900 dark:text-white">
${{ (usageStats[row.id]?.today_actual_cost ?? 0).toFixed(4) }}
</span>
</div>
<div class="flex items-center gap-1.5 mt-0.5">
<span class="text-gray-500 dark:text-gray-400">{{ t('keys.total') }}:</span>
<span class="font-medium text-gray-900 dark:text-white">
${{ (usageStats[row.id]?.total_actual_cost ?? 0).toFixed(4) }}
</span>
</div>
</div>
</template>
<template #cell-status="{ value }">
<span
:class="[
'badge',
value === 'active'
? 'badge-success'
: 'badge-gray'
]"
>
{{ value }}
</span>
</template>
<template #cell-created_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDate(value) }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-1">
<!-- Use Key Button -->
<button
@click="openUseKeyModal(row)"
class="p-2 rounded-lg hover:bg-green-50 dark:hover:bg-green-900/20 text-gray-500 hover:text-green-600 dark:hover:text-green-400 transition-colors"
:title="t('keys.useKey')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z" />
</svg>
</button>
<!-- Import to CC Switch Button -->
<button
@click="importToCcswitch(row.key)"
class="p-2 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/20 text-gray-500 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
:title="t('keys.importToCcSwitch')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
</button>
<!-- Toggle Status Button -->
<button
@click="toggleKeyStatus(row)"
:class="[
'p-2 rounded-lg transition-colors',
row.status === 'active'
? 'hover:bg-yellow-50 dark:hover:bg-yellow-900/20 text-gray-500 hover:text-yellow-600 dark:hover:text-yellow-400'
: 'hover:bg-green-50 dark:hover:bg-green-900/20 text-gray-500 hover:text-green-600 dark:hover:text-green-400'
]"
:title="row.status === 'active' ? t('keys.disable') : t('keys.enable')"
>
<svg v-if="row.status === 'active'" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<!-- Edit Button -->
<button
@click="editKey(row)"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-dark-700 text-gray-500 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
title="Edit"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
</button>
<!-- Delete Button -->
<button
@click="confirmDelete(row)"
class="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-gray-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
title="Delete"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
</div>
</template>
<template #empty>
<EmptyState
:title="t('keys.noKeysYet')"
:description="t('keys.createFirstKey')"
:action-text="t('keys.createKey')"
@action="showCreateModal = true"
/>
</template>
</DataTable>
</div>
<!-- Pagination -->
<Pagination
v-if="pagination.total > 0"
:page="pagination.page"
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
/>
</div>
<!-- Create/Edit Modal -->
<Modal
:show="showCreateModal || showEditModal"
:title="showEditModal ? t('keys.editKey') : t('keys.createKey')"
@close="closeModals"
>
<form @submit.prevent="handleSubmit" class="space-y-5">
<div>
<label class="input-label">{{ t('keys.nameLabel') }}</label>
<input
v-model="formData.name"
type="text"
required
class="input"
:placeholder="t('keys.namePlaceholder')"
/>
</div>
<div>
<label class="input-label">{{ t('keys.groupLabel') }}</label>
<Select
v-model="formData.group_id"
:options="groupOptions"
:placeholder="t('keys.selectGroup')"
>
<template #selected="{ option }">
<GroupBadge
v-if="option"
:name="option.label"
:subscription-type="option.subscriptionType"
:rate-multiplier="option.rate"
/>
<span v-else class="text-gray-400">{{ t('keys.selectGroup') }}</span>
</template>
<template #option="{ option }">
<GroupBadge
:name="option.label"
:subscription-type="option.subscriptionType"
:rate-multiplier="option.rate"
/>
</template>
</Select>
</div>
<!-- Custom Key Section (only for create) -->
<div v-if="!showEditModal" class="space-y-3">
<div class="flex items-center justify-between">
<label class="input-label mb-0">{{ t('keys.customKeyLabel') }}</label>
<button
type="button"
@click="formData.use_custom_key = !formData.use_custom_key"
:class="[
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
formData.use_custom_key ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
formData.use_custom_key ? 'translate-x-4' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="formData.use_custom_key">
<input
v-model="formData.custom_key"
type="text"
class="input font-mono"
:placeholder="t('keys.customKeyPlaceholder')"
:class="{ 'border-red-500 dark:border-red-500': customKeyError }"
/>
<p v-if="customKeyError" class="mt-1 text-sm text-red-500">{{ customKeyError }}</p>
<p v-else class="input-hint">{{ t('keys.customKeyHint') }}</p>
</div>
</div>
<div v-if="showEditModal">
<label class="input-label">{{ t('keys.statusLabel') }}</label>
<Select
v-model="formData.status"
:options="statusOptions"
:placeholder="t('keys.selectStatus')"
/>
</div>
<div class="flex justify-end gap-3 pt-4">
<button
@click="closeModals"
type="button"
class="btn btn-secondary"
>
{{ t('common.cancel') }}
</button>
<button
type="submit"
:disabled="submitting"
class="btn btn-primary"
>
<svg
v-if="submitting"
class="animate-spin -ml-1 mr-2 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ submitting ? t('keys.saving') : (showEditModal ? t('common.update') : t('common.create')) }}
</button>
</div>
</form>
</Modal>
<!-- Delete Confirmation Dialog -->
<ConfirmDialog
:show="showDeleteDialog"
:title="t('keys.deleteKey')"
:message="t('keys.deleteConfirmMessage', { name: selectedKey?.name })"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="handleDelete"
@cancel="showDeleteDialog = false"
/>
<!-- Use Key Modal -->
<UseKeyModal
:show="showUseKeyModal"
:api-key="selectedKey?.key || ''"
:base-url="publicSettings?.api_base_url || ''"
@close="closeUseKeyModal"
/>
<!-- Group Selector Dropdown (Teleported to body to avoid overflow clipping) -->
<Teleport to="body">
<div
v-if="groupSelectorKeyId !== null && dropdownPosition"
ref="dropdownRef"
class="fixed z-[9999] w-64 rounded-xl bg-white dark:bg-dark-800 shadow-lg ring-1 ring-black/5 dark:ring-white/10 overflow-hidden animate-in fade-in slide-in-from-top-2 duration-200"
:style="{ top: dropdownPosition.top + 'px', left: dropdownPosition.left + 'px' }"
>
<div class="p-1.5 max-h-64 overflow-y-auto">
<button
v-for="option in groupOptions"
:key="option.value ?? 'null'"
@click="changeGroup(selectedKeyForGroup!, option.value)"
:class="[
'w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors',
(selectedKeyForGroup?.group_id === option.value || (!selectedKeyForGroup?.group_id && option.value === null))
? 'bg-primary-50 dark:bg-primary-900/20'
: 'hover:bg-gray-100 dark:hover:bg-dark-700'
]"
>
<GroupBadge
:name="option.label"
:subscription-type="option.subscriptionType"
:rate-multiplier="option.rate"
/>
<svg
v-if="selectedKeyForGroup?.group_id === option.value || (!selectedKeyForGroup?.group_id && option.value === null)"
class="w-4 h-4 text-primary-600 dark:text-primary-400 shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</button>
</div>
</div>
</Teleport>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
const { t } = useI18n()
import { keysAPI, authAPI, usageAPI, userGroupsAPI } from '@/api'
import AppLayout from '@/components/layout/AppLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue'
import Modal from '@/components/common/Modal.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
import UseKeyModal from '@/components/keys/UseKeyModal.vue'
import GroupBadge from '@/components/common/GroupBadge.vue'
import type { ApiKey, Group, PublicSettings, SubscriptionType } from '@/types'
import type { Column } from '@/components/common/DataTable.vue'
import type { BatchApiKeyUsageStats } from '@/api/usage'
const appStore = useAppStore()
const columns = computed<Column[]>(() => [
{ key: 'name', label: t('common.name'), sortable: true },
{ key: 'key', label: t('keys.apiKey'), sortable: false },
{ key: 'group', label: t('keys.group'), sortable: false },
{ key: 'usage', label: t('keys.usage'), sortable: false },
{ key: 'status', label: t('common.status'), sortable: true },
{ key: 'created_at', label: t('keys.created'), sortable: true },
{ key: 'actions', label: t('common.actions'), sortable: false }
])
const apiKeys = ref<ApiKey[]>([])
const groups = ref<Group[]>([])
const loading = ref(false)
const submitting = ref(false)
const usageStats = ref<Record<string, BatchApiKeyUsageStats>>({})
const pagination = ref({
page: 1,
page_size: 10,
total: 0,
pages: 0
})
const showCreateModal = ref(false)
const showEditModal = ref(false)
const showDeleteDialog = ref(false)
const showUseKeyModal = ref(false)
const selectedKey = ref<ApiKey | null>(null)
const copiedKeyId = ref<number | null>(null)
const groupSelectorKeyId = ref<number | null>(null)
const publicSettings = ref<PublicSettings | null>(null)
const dropdownRef = ref<HTMLElement | null>(null)
const dropdownPosition = ref<{ top: number; left: number } | null>(null)
const groupButtonRefs = ref<Map<number, HTMLElement>>(new Map())
// Get the currently selected key for group change
const selectedKeyForGroup = computed(() => {
if (groupSelectorKeyId.value === null) return null
return apiKeys.value.find(k => k.id === groupSelectorKeyId.value) || null
})
const setGroupButtonRef = (keyId: number, el: HTMLElement | null) => {
if (el) {
groupButtonRefs.value.set(keyId, el)
} else {
groupButtonRefs.value.delete(keyId)
}
}
const formData = ref({
name: '',
group_id: null as number | null,
status: 'active' as 'active' | 'inactive',
use_custom_key: false,
custom_key: ''
})
// 自定义Key验证
const customKeyError = computed(() => {
if (!formData.value.use_custom_key || !formData.value.custom_key) {
return ''
}
const key = formData.value.custom_key
if (key.length < 16) {
return t('keys.customKeyTooShort')
}
// 检查字符:只允许字母、数字、下划线、连字符
if (!/^[a-zA-Z0-9_-]+$/.test(key)) {
return t('keys.customKeyInvalidChars')
}
return ''
})
const statusOptions = computed(() => [
{ value: 'active', label: t('common.active') },
{ value: 'inactive', label: t('common.inactive') }
])
// Convert groups to Select options format with rate multiplier and subscription type
const groupOptions = computed(() =>
groups.value.map(group => ({
value: group.id,
label: group.name,
rate: group.rate_multiplier,
subscriptionType: group.subscription_type
}))
)
const maskKey = (key: string): string => {
if (key.length <= 12) return key
return `${key.slice(0, 8)}...${key.slice(-4)}`
}
const copyToClipboard = async (text: string, keyId: number) => {
try {
await navigator.clipboard.writeText(text)
copiedKeyId.value = keyId
setTimeout(() => {
copiedKeyId.value = null
}, 2000)
} catch (error) {
appStore.showError(t('common.copyFailed'))
}
}
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
const loadApiKeys = async () => {
loading.value = true
try {
const response = await keysAPI.list(pagination.value.page, pagination.value.page_size)
apiKeys.value = response.items
pagination.value.total = response.total
pagination.value.pages = response.pages
// Load usage stats for all API keys in the list
if (response.items.length > 0) {
const keyIds = response.items.map(k => k.id)
try {
const usageResponse = await usageAPI.getDashboardApiKeysUsage(keyIds)
usageStats.value = usageResponse.stats
} catch (e) {
console.error('Failed to load usage stats:', e)
}
}
} catch (error) {
appStore.showError(t('keys.failedToLoad'))
} finally {
loading.value = false
}
}
const loadGroups = async () => {
try {
groups.value = await userGroupsAPI.getAvailable()
} catch (error) {
console.error('Failed to load groups:', error)
}
}
const loadPublicSettings = async () => {
try {
publicSettings.value = await authAPI.getPublicSettings()
} catch (error) {
console.error('Failed to load public settings:', error)
}
}
const openUseKeyModal = (key: ApiKey) => {
selectedKey.value = key
showUseKeyModal.value = true
}
const closeUseKeyModal = () => {
showUseKeyModal.value = false
selectedKey.value = null
}
const handlePageChange = (page: number) => {
pagination.value.page = page
loadApiKeys()
}
const editKey = (key: ApiKey) => {
selectedKey.value = key
formData.value = {
name: key.name,
group_id: key.group_id,
status: key.status
}
showEditModal.value = true
}
const toggleKeyStatus = async (key: ApiKey) => {
const newStatus = key.status === 'active' ? 'inactive' : 'active'
try {
await keysAPI.toggleStatus(key.id, newStatus)
appStore.showSuccess(newStatus === 'active' ? t('keys.keyEnabledSuccess') : t('keys.keyDisabledSuccess'))
loadApiKeys()
} catch (error) {
appStore.showError(t('keys.failedToUpdateStatus'))
}
}
const openGroupSelector = (key: ApiKey) => {
if (groupSelectorKeyId.value === key.id) {
groupSelectorKeyId.value = null
dropdownPosition.value = null
} else {
const buttonEl = groupButtonRefs.value.get(key.id)
if (buttonEl) {
const rect = buttonEl.getBoundingClientRect()
dropdownPosition.value = {
top: rect.bottom + 4,
left: rect.left
}
}
groupSelectorKeyId.value = key.id
}
}
const changeGroup = async (key: ApiKey, newGroupId: number | null) => {
groupSelectorKeyId.value = null
dropdownPosition.value = null
if (key.group_id === newGroupId) return
try {
await keysAPI.update(key.id, { group_id: newGroupId })
appStore.showSuccess(t('keys.groupChangedSuccess'))
loadApiKeys()
} catch (error) {
appStore.showError(t('keys.failedToChangeGroup'))
}
}
const closeGroupSelector = (event: MouseEvent) => {
const target = event.target as HTMLElement
// Check if click is inside the dropdown or the trigger button
if (!target.closest('.group\\/dropdown') && !dropdownRef.value?.contains(target)) {
groupSelectorKeyId.value = null
dropdownPosition.value = null
}
}
const confirmDelete = (key: ApiKey) => {
selectedKey.value = key
showDeleteDialog.value = true
}
const handleSubmit = async () => {
// Validate group_id is required
if (formData.value.group_id === null) {
appStore.showError(t('keys.groupRequired'))
return
}
// Validate custom key if enabled
if (!showEditModal.value && formData.value.use_custom_key) {
if (!formData.value.custom_key) {
appStore.showError(t('keys.customKeyRequired'))
return
}
if (customKeyError.value) {
appStore.showError(customKeyError.value)
return
}
}
submitting.value = true
try {
if (showEditModal.value && selectedKey.value) {
await keysAPI.update(selectedKey.value.id, formData.value)
appStore.showSuccess(t('keys.keyUpdatedSuccess'))
} else {
const customKey = formData.value.use_custom_key ? formData.value.custom_key : undefined
await keysAPI.create(formData.value.name, formData.value.group_id, customKey)
appStore.showSuccess(t('keys.keyCreatedSuccess'))
}
closeModals()
loadApiKeys()
} catch (error: any) {
const errorMsg = error.response?.data?.detail || t('keys.failedToSave')
appStore.showError(errorMsg)
} finally {
submitting.value = false
}
}
const handleDelete = async () => {
if (!selectedKey.value) return
try {
await keysAPI.delete(selectedKey.value.id)
appStore.showSuccess(t('keys.keyDeletedSuccess'))
showDeleteDialog.value = false
loadApiKeys()
} catch (error) {
appStore.showError(t('keys.failedToDelete'))
}
}
const closeModals = () => {
showCreateModal.value = false
showEditModal.value = false
selectedKey.value = null
formData.value = {
name: '',
group_id: null,
status: 'active',
use_custom_key: false,
custom_key: ''
}
}
const importToCcswitch = (apiKey: string) => {
const baseUrl = publicSettings.value?.api_base_url || window.location.origin
const usageScript = `({
request: {
url: "{{baseUrl}}/v1/usage",
method: "GET",
headers: { "Authorization": "Bearer {{apiKey}}" }
},
extractor: function(response) {
return {
isValid: response.is_active || true,
remaining: response.balance,
unit: "USD"
};
}
})`
const params = new URLSearchParams({
resource: 'provider',
app: 'claude',
name: 'sub2api',
homepage: baseUrl,
endpoint: baseUrl,
apiKey: apiKey,
configFormat: 'json',
usageEnabled: 'true',
usageScript: btoa(usageScript),
usageAutoInterval: '30'
})
const deeplink = `ccswitch://v1/import?${params.toString()}`
window.open(deeplink, '_self')
}
onMounted(() => {
loadApiKeys()
loadGroups()
loadPublicSettings()
document.addEventListener('click', closeGroupSelector)
})
onUnmounted(() => {
document.removeEventListener('click', closeGroupSelector)
})
</script>

View File

@@ -0,0 +1,253 @@
<template>
<AppLayout>
<div class="max-w-4xl mx-auto space-y-6">
<!-- Account Stats Summary -->
<div class="grid grid-cols-1 gap-6 sm:grid-cols-3">
<StatCard
:title="t('profile.accountBalance')"
:value="formatCurrency(user?.balance || 0)"
:icon="WalletIcon"
icon-variant="success"
/>
<StatCard
:title="t('profile.concurrencyLimit')"
:value="user?.concurrency || 0"
:icon="BoltIcon"
icon-variant="warning"
/>
<StatCard
:title="t('profile.memberSince')"
:value="formatMemberSince(user?.created_at || '')"
:icon="CalendarIcon"
icon-variant="primary"
/>
</div>
<!-- User Information -->
<div class="card overflow-hidden">
<div class="px-6 py-5 bg-gradient-to-r from-primary-500/10 to-primary-600/5 dark:from-primary-500/20 dark:to-primary-600/10 border-b border-gray-100 dark:border-dark-700">
<div class="flex items-center gap-4">
<!-- Avatar -->
<div class="w-16 h-16 rounded-2xl bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center text-white text-2xl font-bold shadow-lg shadow-primary-500/20">
{{ user?.email?.charAt(0).toUpperCase() || 'U' }}
</div>
<div class="flex-1 min-w-0">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white truncate">{{ user?.email }}</h2>
<div class="flex items-center gap-2 mt-1">
<span
:class="[
'badge',
user?.role === 'admin' ? 'badge-primary' : 'badge-gray'
]"
>
{{ user?.role === 'admin' ? t('profile.administrator') : t('profile.user') }}
</span>
<span
:class="[
'badge',
user?.status === 'active' ? 'badge-success' : 'badge-danger'
]"
>
{{ user?.status }}
</span>
</div>
</div>
</div>
</div>
<div class="px-6 py-4">
<div class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
<svg class="w-4 h-4 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
</svg>
<span class="truncate">{{ user?.email }}</span>
</div>
</div>
</div>
<!-- Contact Support Section -->
<div v-if="contactInfo" class="card border-primary-200 dark:border-primary-800/40 bg-gradient-to-r from-primary-50 to-primary-100/50 dark:from-primary-900/20 dark:to-primary-800/10">
<div class="px-6 py-5">
<div class="flex items-center gap-4">
<div class="flex-shrink-0 w-12 h-12 rounded-xl bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center">
<svg class="w-6 h-6 text-primary-600 dark:text-primary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-sm font-semibold text-primary-800 dark:text-primary-200">{{ t('common.contactSupport') }}</h3>
<p class="mt-1 text-sm font-medium text-primary-600 dark:text-primary-300">{{ contactInfo }}</p>
</div>
</div>
</div>
</div>
<!-- Change Password Section -->
<div class="card">
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700">
<h2 class="text-lg font-medium text-gray-900 dark:text-white">{{ t('profile.changePassword') }}</h2>
</div>
<div class="px-6 py-6">
<form @submit.prevent="handleChangePassword" class="space-y-4">
<div>
<label for="old_password" class="input-label">
{{ t('profile.currentPassword') }}
</label>
<input
id="old_password"
v-model="passwordForm.old_password"
type="password"
required
autocomplete="current-password"
class="input"
/>
</div>
<div>
<label for="new_password" class="input-label">
{{ t('profile.newPassword') }}
</label>
<input
id="new_password"
v-model="passwordForm.new_password"
type="password"
required
autocomplete="new-password"
class="input"
/>
<p class="input-hint">
{{ t('profile.passwordHint') }}
</p>
</div>
<div>
<label for="confirm_password" class="input-label">
{{ t('profile.confirmNewPassword') }}
</label>
<input
id="confirm_password"
v-model="passwordForm.confirm_password"
type="password"
required
autocomplete="new-password"
class="input"
/>
</div>
<div class="flex justify-end pt-4">
<button
type="submit"
:disabled="changingPassword"
class="btn btn-primary"
>
{{ changingPassword ? t('profile.changingPassword') : t('profile.changePasswordButton') }}
</button>
</div>
</form>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, computed, h, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
const { t } = useI18n()
import { userAPI, authAPI } from '@/api'
import AppLayout from '@/components/layout/AppLayout.vue'
import StatCard from '@/components/common/StatCard.vue'
// SVG Icon Components
const WalletIcon = {
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3' })
])
}
const BoltIcon = {
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'm3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z' })
])
}
const CalendarIcon = {
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5' })
])
}
const authStore = useAuthStore()
const appStore = useAppStore()
const user = computed(() => authStore.user)
const passwordForm = ref({
old_password: '',
new_password: '',
confirm_password: ''
})
const changingPassword = ref(false)
const contactInfo = ref('')
onMounted(async () => {
try {
const settings = await authAPI.getPublicSettings()
contactInfo.value = settings.contact_info || ''
} catch (error) {
console.error('Failed to load contact info:', error)
}
})
const formatCurrency = (value: number): string => {
return `$${value.toFixed(2)}`
}
const formatMemberSince = (dateString: string): string => {
if (!dateString) return 'N/A'
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short'
})
}
const handleChangePassword = async () => {
// Validate password match
if (passwordForm.value.new_password !== passwordForm.value.confirm_password) {
appStore.showError(t('profile.passwordsNotMatch'))
return
}
// Validate password length
if (passwordForm.value.new_password.length < 8) {
appStore.showError(t('profile.passwordTooShort'))
return
}
changingPassword.value = true
try {
await userAPI.changePassword(
passwordForm.value.old_password,
passwordForm.value.new_password
)
// Clear form
passwordForm.value = {
old_password: '',
new_password: '',
confirm_password: ''
}
appStore.showSuccess(t('profile.passwordChangeSuccess'))
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('profile.passwordChangeFailed'))
} finally {
changingPassword.value = false
}
}
</script>

View File

@@ -0,0 +1,453 @@
<template>
<AppLayout>
<div class="max-w-2xl mx-auto space-y-6">
<!-- Current Balance Card -->
<div class="card overflow-hidden">
<div class="bg-gradient-to-br from-primary-500 to-primary-600 px-6 py-8 text-center">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-white/20 backdrop-blur-sm mb-4">
<svg class="w-8 h-8 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>
</div>
<p class="text-sm font-medium text-primary-100">{{ t('redeem.currentBalance') }}</p>
<p class="mt-2 text-4xl font-bold text-white">
${{ user?.balance?.toFixed(2) || '0.00' }}
</p>
<p class="mt-2 text-sm text-primary-100">
{{ t('redeem.concurrency') }}: {{ user?.concurrency || 0 }} {{ t('redeem.requests') }}
</p>
</div>
</div>
<!-- Redeem Form -->
<div class="card">
<div class="p-6">
<form @submit.prevent="handleRedeem" class="space-y-5">
<div>
<label for="code" class="input-label">
{{ t('redeem.redeemCodeLabel') }}
</label>
<div class="relative mt-1">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<svg class="w-5 h-5 text-gray-400 dark:text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
</div>
<input
id="code"
v-model="redeemCode"
type="text"
required
:placeholder="t('redeem.redeemCodePlaceholder')"
:disabled="submitting"
class="input pl-12 text-lg py-3"
/>
</div>
<p class="input-hint">
{{ t('redeem.redeemCodeHint') }}
</p>
</div>
<button
type="submit"
:disabled="!redeemCode || submitting"
class="btn btn-primary w-full py-3"
>
<svg
v-if="submitting"
class="animate-spin -ml-1 mr-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<svg v-else class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ submitting ? t('redeem.redeeming') : t('redeem.redeemButton') }}
</button>
</form>
</div>
</div>
<!-- Success Message -->
<transition name="fade">
<div
v-if="redeemResult"
class="card border-emerald-200 dark:border-emerald-800/50 bg-emerald-50 dark:bg-emerald-900/20"
>
<div class="p-6">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-xl bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center">
<svg class="w-5 h-5 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="flex-1">
<h3 class="text-sm font-semibold text-emerald-800 dark:text-emerald-300">
{{ t('redeem.redeemSuccess') }}
</h3>
<div class="mt-2 text-sm text-emerald-700 dark:text-emerald-400">
<p>{{ redeemResult.message }}</p>
<div class="mt-3 space-y-1">
<p v-if="redeemResult.type === 'balance'" class="font-medium">
{{ t('redeem.added') }}: ${{ redeemResult.value.toFixed(2) }}
</p>
<p v-else-if="redeemResult.type === 'concurrency'" class="font-medium">
{{ t('redeem.added') }}: {{ redeemResult.value }} {{ t('redeem.concurrentRequests') }}
</p>
<p v-else-if="redeemResult.type === 'subscription'" class="font-medium">
{{ t('redeem.subscriptionAssigned') }}
<span v-if="redeemResult.group_name"> - {{ redeemResult.group_name }}</span>
<span v-if="redeemResult.validity_days"> ({{ t('redeem.subscriptionDays', { days: redeemResult.validity_days }) }})</span>
</p>
<p v-if="redeemResult.new_balance !== undefined">
{{ t('redeem.newBalance') }}: <span class="font-semibold">${{ redeemResult.new_balance.toFixed(2) }}</span>
</p>
<p v-if="redeemResult.new_concurrency !== undefined">
{{ t('redeem.newConcurrency') }}: <span class="font-semibold">{{ redeemResult.new_concurrency }} {{ t('redeem.requests') }}</span>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</transition>
<!-- Error Message -->
<transition name="fade">
<div
v-if="errorMessage"
class="card border-red-200 dark:border-red-800/50 bg-red-50 dark:bg-red-900/20"
>
<div class="p-6">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-xl bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<svg class="w-5 h-5 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<div class="flex-1">
<h3 class="text-sm font-semibold text-red-800 dark:text-red-300">
{{ t('redeem.redeemFailed') }}
</h3>
<p class="mt-2 text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
</div>
</div>
</div>
</div>
</transition>
<!-- Information Card -->
<div class="card border-primary-200 dark:border-primary-800/50 bg-primary-50 dark:bg-primary-900/20">
<div class="p-6">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-xl bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center">
<svg class="w-5 h-5 text-primary-600 dark:text-primary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
</div>
<div class="flex-1">
<h3 class="text-sm font-semibold text-primary-800 dark:text-primary-300">
{{ t('redeem.aboutCodes') }}
</h3>
<ul class="mt-2 text-sm text-primary-700 dark:text-primary-400 space-y-1 list-disc list-inside">
<li>{{ t('redeem.codeRule1') }}</li>
<li>{{ t('redeem.codeRule2') }}</li>
<li>
{{ t('redeem.codeRule3') }}
<span v-if="contactInfo" class="inline-flex items-center ml-1.5 px-2 py-0.5 rounded-md bg-primary-200/50 dark:bg-primary-800/40 text-primary-800 dark:text-primary-200 font-medium text-xs">
{{ contactInfo }}
</span>
</li>
<li>{{ t('redeem.codeRule4') }}</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="card">
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('redeem.recentActivity') }}</h2>
</div>
<div class="p-6">
<!-- Loading State -->
<div v-if="loadingHistory" class="flex items-center justify-center py-8">
<svg class="animate-spin h-6 w-6 text-primary-500" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<!-- History List -->
<div v-else-if="history.length > 0" class="space-y-3">
<div
v-for="item in history"
:key="item.id"
class="flex items-center justify-between p-4 rounded-xl bg-gray-50 dark:bg-dark-800"
>
<div class="flex items-center gap-4">
<div
:class="[
'w-10 h-10 rounded-xl flex items-center justify-center',
isBalanceType(item.type)
? (item.value >= 0 ? 'bg-emerald-100 dark:bg-emerald-900/30' : 'bg-red-100 dark:bg-red-900/30')
: isSubscriptionType(item.type)
? 'bg-purple-100 dark:bg-purple-900/30'
: (item.value >= 0 ? 'bg-blue-100 dark:bg-blue-900/30' : 'bg-orange-100 dark:bg-orange-900/30')
]"
>
<!-- 余额类型图标 -->
<svg
v-if="isBalanceType(item.type)"
:class="[
'w-5 h-5',
item.value >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'
]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<!-- 订阅类型图标 -->
<svg
v-else-if="isSubscriptionType(item.type)"
class="w-5 h-5 text-purple-600 dark:text-purple-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z" />
</svg>
<!-- 并发类型图标 -->
<svg
v-else
:class="[
'w-5 h-5',
item.value >= 0 ? 'text-blue-600 dark:text-blue-400' : 'text-orange-600 dark:text-orange-400'
]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" />
</svg>
</div>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ getHistoryItemTitle(item) }}
</p>
<p class="text-xs text-gray-500 dark:text-dark-400">
{{ formatDate(item.used_at) }}
</p>
</div>
</div>
<div class="text-right">
<p
:class="[
'text-sm font-semibold',
isBalanceType(item.type)
? (item.value >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400')
: isSubscriptionType(item.type)
? 'text-purple-600 dark:text-purple-400'
: (item.value >= 0 ? 'text-blue-600 dark:text-blue-400' : 'text-orange-600 dark:text-orange-400')
]"
>
{{ formatHistoryValue(item) }}
</p>
<p v-if="!isAdminAdjustment(item.type)" class="text-xs text-gray-400 dark:text-dark-500 font-mono">
{{ item.code.slice(0, 8) }}...
</p>
<p v-else class="text-xs text-gray-400 dark:text-dark-500">
{{ t('redeem.adminAdjustment') }}
</p>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="empty-state py-8">
<div class="w-16 h-16 mb-4 rounded-2xl bg-gray-100 dark:bg-dark-800 flex items-center justify-center">
<svg class="w-8 h-8 text-gray-400 dark:text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p class="text-sm text-gray-500 dark:text-dark-400">
{{ t('redeem.historyWillAppear') }}
</p>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
import { redeemAPI, authAPI, type RedeemHistoryItem } from '@/api'
import AppLayout from '@/components/layout/AppLayout.vue'
const { t } = useI18n()
const authStore = useAuthStore()
const appStore = useAppStore()
const user = computed(() => authStore.user)
const redeemCode = ref('')
const submitting = ref(false)
const redeemResult = ref<{
message: string
type: string
value: number
new_balance?: number
new_concurrency?: number
group_name?: string
validity_days?: number
} | null>(null)
const errorMessage = ref('')
// History data
const history = ref<RedeemHistoryItem[]>([])
const loadingHistory = ref(false)
const contactInfo = ref('')
const formatDate = (dateString: string) => {
if (!dateString) return '-'
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
// Helper functions for history display
const isBalanceType = (type: string) => {
return type === 'balance' || type === 'admin_balance'
}
const isConcurrencyType = (type: string) => {
return type === 'concurrency' || type === 'admin_concurrency'
}
const isSubscriptionType = (type: string) => {
return type === 'subscription'
}
const isAdminAdjustment = (type: string) => {
return type === 'admin_balance' || type === 'admin_concurrency'
}
const getHistoryItemTitle = (item: RedeemHistoryItem) => {
if (item.type === 'balance') {
return t('redeem.balanceAddedRedeem')
} else if (item.type === 'admin_balance') {
return item.value >= 0 ? t('redeem.balanceAddedAdmin') : t('redeem.balanceDeductedAdmin')
} else if (item.type === 'concurrency') {
return t('redeem.concurrencyAddedRedeem')
} else if (item.type === 'admin_concurrency') {
return item.value >= 0 ? t('redeem.concurrencyAddedAdmin') : t('redeem.concurrencyReducedAdmin')
} else if (item.type === 'subscription') {
return t('redeem.subscriptionAssigned')
}
return 'Unknown'
}
const formatHistoryValue = (item: RedeemHistoryItem) => {
if (isBalanceType(item.type)) {
const sign = item.value >= 0 ? '+' : ''
return `${sign}$${item.value.toFixed(2)}`
} else if (isSubscriptionType(item.type)) {
// 订阅类型显示有效天数和分组名称
const days = item.validity_days || Math.round(item.value)
const groupName = item.group?.name || ''
return groupName ? `${days}${t('redeem.days')} - ${groupName}` : `${days}${t('redeem.days')}`
} else {
const sign = item.value >= 0 ? '+' : ''
return `${sign}${item.value} ${t('redeem.requests')}`
}
}
const fetchHistory = async () => {
loadingHistory.value = true
try {
history.value = await redeemAPI.getHistory()
} catch (error) {
console.error('Failed to fetch history:', error)
} finally {
loadingHistory.value = false
}
}
const handleRedeem = async () => {
if (!redeemCode.value.trim()) {
return
}
submitting.value = true
errorMessage.value = ''
redeemResult.value = null
try {
const result = await redeemAPI.redeem(redeemCode.value.trim())
redeemResult.value = result
// Refresh user data to get updated balance/concurrency
await authStore.refreshUser()
// Clear the input
redeemCode.value = ''
// Refresh history
await fetchHistory()
// Show success toast
appStore.showSuccess(t('redeem.codeRedeemSuccess'))
} catch (error: any) {
errorMessage.value = error.response?.data?.detail || t('redeem.failedToRedeem')
appStore.showError(t('redeem.redeemFailed'))
} finally {
submitting.value = false
}
}
onMounted(async () => {
fetchHistory()
try {
const settings = await authAPI.getPublicSettings()
contactInfo.value = settings.contact_info || ''
} catch (error) {
console.error('Failed to load contact info:', error)
}
})
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@@ -0,0 +1,260 @@
<template>
<AppLayout>
<div class="space-y-6">
<!-- Loading State -->
<div v-if="loading" class="flex justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-2 border-primary-500 border-t-transparent"></div>
</div>
<!-- Empty State -->
<div v-else-if="subscriptions.length === 0" class="card p-12 text-center">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-100 dark:bg-dark-700 flex items-center justify-center">
<svg class="w-8 h-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
{{ t('userSubscriptions.noActiveSubscriptions') }}
</h3>
<p class="text-gray-500 dark:text-dark-400">
{{ t('userSubscriptions.noActiveSubscriptionsDesc') }}
</p>
</div>
<!-- Subscriptions Grid -->
<div v-else class="grid gap-6 lg:grid-cols-2">
<div
v-for="subscription in subscriptions"
:key="subscription.id"
class="card overflow-hidden"
>
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-gray-100 dark:border-dark-700">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
<svg class="w-5 h-5 text-purple-600 dark:text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
</svg>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ subscription.group?.name || `Group #${subscription.group_id}` }}
</h3>
<p class="text-xs text-gray-500 dark:text-dark-400">
{{ subscription.group?.description || '' }}
</p>
</div>
</div>
<span
:class="[
'badge',
subscription.status === 'active' ? 'badge-success' :
subscription.status === 'expired' ? 'badge-warning' : 'badge-danger'
]"
>
{{ t(`userSubscriptions.status.${subscription.status}`) }}
</span>
</div>
<!-- Usage Progress -->
<div class="p-4 space-y-4">
<!-- Expiration Info -->
<div v-if="subscription.expires_at" class="flex items-center justify-between text-sm">
<span class="text-gray-500 dark:text-dark-400">{{ t('userSubscriptions.expires') }}</span>
<span :class="getExpirationClass(subscription.expires_at)">
{{ formatExpirationDate(subscription.expires_at) }}
</span>
</div>
<div v-else class="flex items-center justify-between text-sm">
<span class="text-gray-500 dark:text-dark-400">{{ t('userSubscriptions.expires') }}</span>
<span class="text-gray-700 dark:text-gray-300">{{ t('userSubscriptions.noExpiration') }}</span>
</div>
<!-- Daily Usage -->
<div v-if="subscription.group?.daily_limit_usd" class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('userSubscriptions.daily') }}
</span>
<span class="text-sm text-gray-500 dark:text-dark-400">
${{ (subscription.daily_usage_usd || 0).toFixed(2) }} / ${{ subscription.group.daily_limit_usd.toFixed(2) }}
</span>
</div>
<div class="relative h-2 bg-gray-200 dark:bg-dark-600 rounded-full overflow-hidden">
<div
class="absolute inset-y-0 left-0 rounded-full transition-all duration-300"
:class="getProgressBarClass(subscription.daily_usage_usd, subscription.group.daily_limit_usd)"
:style="{ width: getProgressWidth(subscription.daily_usage_usd, subscription.group.daily_limit_usd) }"
></div>
</div>
<p v-if="subscription.daily_window_start" class="text-xs text-gray-500 dark:text-dark-400">
{{ t('userSubscriptions.resetIn', { time: formatResetTime(subscription.daily_window_start, 24) }) }}
</p>
</div>
<!-- Weekly Usage -->
<div v-if="subscription.group?.weekly_limit_usd" class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('userSubscriptions.weekly') }}
</span>
<span class="text-sm text-gray-500 dark:text-dark-400">
${{ (subscription.weekly_usage_usd || 0).toFixed(2) }} / ${{ subscription.group.weekly_limit_usd.toFixed(2) }}
</span>
</div>
<div class="relative h-2 bg-gray-200 dark:bg-dark-600 rounded-full overflow-hidden">
<div
class="absolute inset-y-0 left-0 rounded-full transition-all duration-300"
:class="getProgressBarClass(subscription.weekly_usage_usd, subscription.group.weekly_limit_usd)"
:style="{ width: getProgressWidth(subscription.weekly_usage_usd, subscription.group.weekly_limit_usd) }"
></div>
</div>
<p v-if="subscription.weekly_window_start" class="text-xs text-gray-500 dark:text-dark-400">
{{ t('userSubscriptions.resetIn', { time: formatResetTime(subscription.weekly_window_start, 168) }) }}
</p>
</div>
<!-- Monthly Usage -->
<div v-if="subscription.group?.monthly_limit_usd" class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('userSubscriptions.monthly') }}
</span>
<span class="text-sm text-gray-500 dark:text-dark-400">
${{ (subscription.monthly_usage_usd || 0).toFixed(2) }} / ${{ subscription.group.monthly_limit_usd.toFixed(2) }}
</span>
</div>
<div class="relative h-2 bg-gray-200 dark:bg-dark-600 rounded-full overflow-hidden">
<div
class="absolute inset-y-0 left-0 rounded-full transition-all duration-300"
:class="getProgressBarClass(subscription.monthly_usage_usd, subscription.group.monthly_limit_usd)"
:style="{ width: getProgressWidth(subscription.monthly_usage_usd, subscription.group.monthly_limit_usd) }"
></div>
</div>
<p v-if="subscription.monthly_window_start" class="text-xs text-gray-500 dark:text-dark-400">
{{ t('userSubscriptions.resetIn', { time: formatResetTime(subscription.monthly_window_start, 720) }) }}
</p>
</div>
<!-- No limits configured -->
<div
v-if="!subscription.group?.daily_limit_usd && !subscription.group?.weekly_limit_usd && !subscription.group?.monthly_limit_usd"
class="text-center py-4"
>
<span class="text-sm text-gray-500 dark:text-dark-400">{{ t('userSubscriptions.unlimited') }}</span>
</div>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAppStore } from '@/stores/app';
import subscriptionsAPI from '@/api/subscriptions';
import type { UserSubscription } from '@/types';
import AppLayout from '@/components/layout/AppLayout.vue';
const { t } = useI18n();
const appStore = useAppStore();
const subscriptions = ref<UserSubscription[]>([]);
const loading = ref(true);
async function loadSubscriptions() {
try {
loading.value = true;
subscriptions.value = await subscriptionsAPI.getMySubscriptions();
} catch (error) {
console.error('Failed to load subscriptions:', error);
appStore.showError('Failed to load subscriptions');
} finally {
loading.value = false;
}
}
function getProgressWidth(used: number | undefined, limit: number | null | undefined): string {
if (!limit || limit === 0) return '0%';
const percentage = Math.min(((used || 0) / limit) * 100, 100);
return `${percentage}%`;
}
function getProgressBarClass(used: number | undefined, limit: number | null | undefined): string {
if (!limit || limit === 0) return 'bg-gray-400';
const percentage = ((used || 0) / limit) * 100;
if (percentage >= 90) return 'bg-red-500';
if (percentage >= 70) return 'bg-orange-500';
return 'bg-green-500';
}
function formatExpirationDate(expiresAt: string): string {
const now = new Date();
const expires = new Date(expiresAt);
const diff = expires.getTime() - now.getTime();
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
if (days < 0) {
return t('userSubscriptions.status.expired');
}
const dateStr = expires.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
if (days === 0) {
return `${dateStr} (Today)`;
}
if (days === 1) {
return `${dateStr} (Tomorrow)`;
}
return t('userSubscriptions.daysRemaining', { days }) + ` (${dateStr})`;
}
function getExpirationClass(expiresAt: string): string {
const now = new Date();
const expires = new Date(expiresAt);
const diff = expires.getTime() - now.getTime();
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
if (days <= 0) return 'text-red-600 dark:text-red-400 font-medium';
if (days <= 3) return 'text-red-600 dark:text-red-400';
if (days <= 7) return 'text-orange-600 dark:text-orange-400';
return 'text-gray-700 dark:text-gray-300';
}
function formatResetTime(windowStart: string | null, windowHours: number): string {
if (!windowStart) return '--';
const start = new Date(windowStart);
const end = new Date(start.getTime() + windowHours * 60 * 60 * 1000);
const now = new Date();
const diff = end.getTime() - now.getTime();
if (diff <= 0) return 'Now';
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 24) {
const days = Math.floor(hours / 24);
const remainingHours = hours % 24;
return `${days}d ${remainingHours}h`;
}
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
onMounted(() => {
loadSubscriptions();
});
</script>

View File

@@ -0,0 +1,470 @@
<template>
<AppLayout>
<div class="space-y-6">
<!-- Summary Stats Cards -->
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<!-- Total Requests -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/30">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('usage.totalRequests') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ usageStats?.total_requests?.toLocaleString() || '0' }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.inSelectedRange') }}</p>
</div>
</div>
</div>
<!-- Total Tokens -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-amber-100 dark:bg-amber-900/30">
<svg class="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />
</svg>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('usage.totalTokens') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(usageStats?.total_tokens || 0) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.in') }}: {{ formatTokens(usageStats?.total_input_tokens || 0) }} / {{ t('usage.out') }}: {{ formatTokens(usageStats?.total_output_tokens || 0) }}</p>
</div>
</div>
</div>
<!-- Total Cost -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-green-100 dark:bg-green-900/30">
<svg class="w-5 h-5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('usage.totalCost') }}</p>
<div class="flex items-baseline gap-2">
<p class="text-xl font-bold text-green-600 dark:text-green-400">${{ (usageStats?.total_actual_cost || 0).toFixed(4) }}</p>
<span class="text-xs text-gray-400 dark:text-gray-500 line-through">${{ (usageStats?.total_cost || 0).toFixed(4) }}</span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.actualCost') }} / {{ t('usage.standardCost') }}</p>
</div>
</div>
</div>
<!-- Average Duration -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-purple-100 dark:bg-purple-900/30">
<svg class="w-5 h-5 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('usage.avgDuration') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatDuration(usageStats?.average_duration_ms || 0) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.perRequest') }}</p>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="card">
<div class="px-6 py-4">
<div class="flex flex-wrap items-end gap-4">
<!-- API Key Filter -->
<div class="min-w-[180px]">
<label class="input-label">{{ t('usage.apiKeyFilter') }}</label>
<Select
v-model="filters.api_key_id"
:options="apiKeyOptions"
:placeholder="t('usage.allApiKeys')"
@change="applyFilters"
/>
</div>
<!-- Date Range Filter -->
<div>
<label class="input-label">{{ t('usage.timeRange') }}</label>
<DateRangePicker
v-model:start-date="startDate"
v-model:end-date="endDate"
@change="onDateRangeChange"
/>
</div>
<!-- Actions -->
<div class="flex items-center gap-3 ml-auto">
<button
@click="resetFilters"
class="btn btn-secondary"
>
{{ t('common.reset') }}
</button>
<button
@click="exportToCSV"
class="btn btn-primary"
>
{{ t('usage.exportCsv') }}
</button>
</div>
</div>
</div>
</div>
<!-- Usage Table -->
<div class="card overflow-hidden">
<DataTable
:columns="columns"
:data="usageLogs"
:loading="loading"
>
<template #cell-model="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template>
<template #cell-stream="{ row }">
<span
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
:class="row.stream
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'"
>
{{ row.stream ? t('usage.stream') : t('usage.sync') }}
</span>
</template>
<template #cell-tokens="{ row }">
<div class="text-sm">
<div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">{{ t('usage.in') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ row.input_tokens.toLocaleString() }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="text-gray-500 dark:text-gray-400">{{ t('usage.out') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ row.output_tokens.toLocaleString() }}</span>
</div>
<div v-if="row.cache_read_tokens > 0" class="flex items-center gap-1 text-blue-600 dark:text-blue-400">
<span>{{ t('dashboard.cache') }}</span>
<span class="font-medium">{{ row.cache_read_tokens.toLocaleString() }}</span>
</div>
</div>
</template>
<template #cell-cost="{ row }">
<div class="text-sm flex items-center gap-1.5">
<span class="font-medium text-green-600 dark:text-green-400">
${{ row.actual_cost.toFixed(6) }}
</span>
<!-- Cost Detail Tooltip -->
<div class="relative group">
<div class="flex items-center justify-center w-4 h-4 rounded-full bg-gray-100 dark:bg-gray-700 cursor-help transition-colors group-hover:bg-blue-100 dark:group-hover:bg-blue-900/50">
<svg class="w-3 h-3 text-gray-400 dark:text-gray-500 group-hover:text-blue-500 dark:group-hover:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
</div>
<!-- Tooltip Content (right side) -->
<div class="absolute z-[100] invisible group-hover:visible opacity-0 group-hover:opacity-100 transition-all duration-200 left-full top-1/2 -translate-y-1/2 ml-2">
<div class="bg-gray-900 dark:bg-gray-800 text-white text-xs rounded-lg py-2.5 px-3 shadow-xl whitespace-nowrap border border-gray-700 dark:border-gray-600">
<div class="space-y-1.5">
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.rate') }}</span>
<span class="font-semibold text-blue-400">{{ (row.rate_multiplier || 1).toFixed(2) }}x</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.original') }}</span>
<span class="font-medium text-white">${{ row.total_cost.toFixed(6) }}</span>
</div>
<div class="flex items-center justify-between gap-6 pt-1.5 border-t border-gray-700">
<span class="text-gray-400">{{ t('usage.billed') }}</span>
<span class="font-semibold text-green-400">${{ row.actual_cost.toFixed(6) }}</span>
</div>
</div>
<!-- Tooltip Arrow (left side) -->
<div class="absolute right-full top-1/2 -translate-y-1/2 w-0 h-0 border-t-[6px] border-t-transparent border-b-[6px] border-b-transparent border-r-[6px] border-r-gray-900 dark:border-r-gray-800"></div>
</div>
</div>
</div>
</div>
</template>
<template #cell-billing_type="{ row }">
<span
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
:class="row.billing_type === 1
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'"
>
{{ row.billing_type === 1 ? t('usage.subscription') : t('usage.balance') }}
</span>
</template>
<template #cell-first_token="{ row }">
<span v-if="row.first_token_ms != null" class="text-sm text-gray-600 dark:text-gray-400">
{{ formatDuration(row.first_token_ms) }}
</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #cell-duration="{ row }">
<span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDuration(row.duration_ms) }}</span>
</template>
<template #cell-created_at="{ value }">
<span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDateTime(value) }}</span>
</template>
<template #empty>
<EmptyState :message="t('usage.noRecords')" />
</template>
</DataTable>
</div>
<!-- Pagination -->
<Pagination
v-if="pagination.total > 0"
:page="pagination.page"
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
/>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { usageAPI, keysAPI } from '@/api'
import AppLayout from '@/components/layout/AppLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue'
import DateRangePicker from '@/components/common/DateRangePicker.vue'
import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse } from '@/types'
import type { Column } from '@/components/common/DataTable.vue'
const { t } = useI18n()
const appStore = useAppStore()
// Usage stats from API
const usageStats = ref<UsageStatsResponse | null>(null)
const columns = computed<Column[]>(() => [
{ key: 'model', label: t('usage.model'), sortable: true },
{ key: 'stream', label: t('usage.type'), sortable: false },
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
{ key: 'cost', label: t('usage.cost'), sortable: false },
{ key: 'billing_type', label: t('usage.billingType'), sortable: false },
{ key: 'first_token', label: t('usage.firstToken'), sortable: false },
{ key: 'duration', label: t('usage.duration'), sortable: false },
{ key: 'created_at', label: t('usage.time'), sortable: true }
])
const usageLogs = ref<UsageLog[]>([])
const apiKeys = ref<ApiKey[]>([])
const loading = ref(false)
const apiKeyOptions = computed(() => {
return [
{ value: null, label: t('usage.allApiKeys') },
...apiKeys.value.map(key => ({
value: key.id,
label: key.name
}))
]
})
// Date range state
const startDate = ref('')
const endDate = ref('')
const filters = ref<UsageQueryParams>({
api_key_id: undefined,
start_date: undefined,
end_date: undefined
})
// Initialize default date range (last 7 days)
const initializeDateRange = () => {
const now = new Date()
const today = now.toISOString().split('T')[0]
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
startDate.value = weekAgo.toISOString().split('T')[0]
endDate.value = today
filters.value.start_date = startDate.value
filters.value.end_date = endDate.value
}
// Handle date range change from DateRangePicker
const onDateRangeChange = (range: { startDate: string; endDate: string; preset: string | null }) => {
filters.value.start_date = range.startDate
filters.value.end_date = range.endDate
applyFilters()
}
const pagination = ref({
page: 1,
page_size: 20,
total: 0,
pages: 0
})
const totalTokens = computed(() => {
return usageLogs.value.reduce((sum, log) =>
sum + log.input_tokens + log.output_tokens, 0
)
})
const totalCost = computed(() => {
return usageLogs.value.reduce((sum, log) => sum + log.total_cost, 0)
})
const avgDuration = computed(() => {
if (usageLogs.value.length === 0) return 0
return usageLogs.value.reduce((sum, log) => sum + log.duration_ms, 0) / usageLogs.value.length
})
const formatDuration = (ms: number): string => {
if (ms < 1000) return `${ms.toFixed(0)}ms`
return `${(ms / 1000).toFixed(2)}s`
}
const formatTokens = (value: number): string => {
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(2)}B`
} else if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(2)}M`
} else if (value >= 1_000) {
return `${(value / 1_000).toFixed(2)}K`
}
return value.toLocaleString()
}
const formatDateTime = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const loadUsageLogs = async () => {
loading.value = true
try {
const params: UsageQueryParams = {
page: pagination.value.page,
page_size: pagination.value.page_size,
...filters.value
}
const response = await usageAPI.query(params)
usageLogs.value = response.items
pagination.value.total = response.total
pagination.value.pages = response.pages
} catch (error) {
appStore.showError(t('usage.failedToLoad'))
} finally {
loading.value = false
}
}
const loadApiKeys = async () => {
try {
const response = await keysAPI.list(1, 100)
apiKeys.value = response.items
} catch (error) {
console.error('Failed to load API keys:', error)
}
}
const loadUsageStats = async () => {
try {
const apiKeyId = filters.value.api_key_id ? Number(filters.value.api_key_id) : undefined
const stats = await usageAPI.getStatsByDateRange(
filters.value.start_date || startDate.value,
filters.value.end_date || endDate.value,
apiKeyId
)
usageStats.value = stats
} catch (error) {
console.error('Failed to load usage stats:', error)
}
}
const applyFilters = () => {
pagination.value.page = 1
loadUsageLogs()
loadUsageStats()
}
const resetFilters = () => {
filters.value = {
api_key_id: undefined,
start_date: undefined,
end_date: undefined
}
// Reset date range to default (last 7 days)
initializeDateRange()
pagination.value.page = 1
loadUsageLogs()
loadUsageStats()
}
const handlePageChange = (page: number) => {
pagination.value.page = page
loadUsageLogs()
}
const exportToCSV = () => {
if (usageLogs.value.length === 0) {
appStore.showWarning(t('usage.noDataToExport'))
return
}
const headers = ['Model', 'Type', 'Input Tokens', 'Output Tokens', 'Cache Tokens', 'Total Cost', 'Billing Type', 'First Token (ms)', 'Duration (ms)', 'Time']
const rows = usageLogs.value.map(log => [
log.model,
log.stream ? 'Stream' : 'Sync',
log.input_tokens,
log.output_tokens,
log.cache_read_tokens,
log.total_cost.toFixed(6),
log.billing_type === 1 ? 'Subscription' : 'Balance',
log.first_token_ms ?? '',
log.duration_ms,
log.created_at
])
const csvContent = [
headers.join(','),
...rows.map(row => row.join(','))
].join('\n')
const blob = new Blob([csvContent], { type: 'text/csv' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `usage_${new Date().toISOString().split('T')[0]}.csv`
link.click()
window.URL.revokeObjectURL(url)
appStore.showSuccess(t('usage.exportSuccess'))
}
onMounted(() => {
initializeDateRange()
loadApiKeys()
loadUsageLogs()
loadUsageStats()
})
</script>