Commit 642842c2 authored by shaw's avatar shaw
Browse files

First commit

parent 569f4882
/**
* Vue Router configuration for Sub2API frontend
* Defines all application routes with lazy loading and navigation guards
*/
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
/**
* Route definitions with lazy loading
*/
const routes: RouteRecordRaw[] = [
// ==================== Setup Routes ====================
{
path: '/setup',
name: 'Setup',
component: () => import('@/views/setup/SetupWizardView.vue'),
meta: {
requiresAuth: false,
title: 'Setup',
},
},
// ==================== Public Routes ====================
{
path: '/home',
name: 'Home',
component: () => import('@/views/HomeView.vue'),
meta: {
requiresAuth: false,
title: 'Home',
},
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/auth/LoginView.vue'),
meta: {
requiresAuth: false,
title: 'Login',
},
},
{
path: '/register',
name: 'Register',
component: () => import('@/views/auth/RegisterView.vue'),
meta: {
requiresAuth: false,
title: 'Register',
},
},
{
path: '/email-verify',
name: 'EmailVerify',
component: () => import('@/views/auth/EmailVerifyView.vue'),
meta: {
requiresAuth: false,
title: 'Verify Email',
},
},
// ==================== User Routes ====================
{
path: '/',
redirect: '/home',
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/user/DashboardView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'Dashboard',
titleKey: 'dashboard.title',
descriptionKey: 'dashboard.welcomeMessage',
},
},
{
path: '/keys',
name: 'Keys',
component: () => import('@/views/user/KeysView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'API Keys',
titleKey: 'keys.title',
descriptionKey: 'keys.description',
},
},
{
path: '/usage',
name: 'Usage',
component: () => import('@/views/user/UsageView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'Usage Records',
titleKey: 'usage.title',
descriptionKey: 'usage.description',
},
},
{
path: '/redeem',
name: 'Redeem',
component: () => import('@/views/user/RedeemView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'Redeem Code',
titleKey: 'redeem.title',
descriptionKey: 'redeem.description',
},
},
{
path: '/profile',
name: 'Profile',
component: () => import('@/views/user/ProfileView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'Profile',
titleKey: 'profile.title',
descriptionKey: 'profile.description',
},
},
{
path: '/subscriptions',
name: 'Subscriptions',
component: () => import('@/views/user/SubscriptionsView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'My Subscriptions',
titleKey: 'userSubscriptions.title',
descriptionKey: 'userSubscriptions.description',
},
},
// ==================== Admin Routes ====================
{
path: '/admin',
redirect: '/admin/dashboard',
},
{
path: '/admin/dashboard',
name: 'AdminDashboard',
component: () => import('@/views/admin/DashboardView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Admin Dashboard',
titleKey: 'admin.dashboard.title',
descriptionKey: 'admin.dashboard.description',
},
},
{
path: '/admin/users',
name: 'AdminUsers',
component: () => import('@/views/admin/UsersView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'User Management',
titleKey: 'admin.users.title',
descriptionKey: 'admin.users.description',
},
},
{
path: '/admin/groups',
name: 'AdminGroups',
component: () => import('@/views/admin/GroupsView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Group Management',
titleKey: 'admin.groups.title',
descriptionKey: 'admin.groups.description',
},
},
{
path: '/admin/subscriptions',
name: 'AdminSubscriptions',
component: () => import('@/views/admin/SubscriptionsView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Subscription Management',
titleKey: 'admin.subscriptions.title',
descriptionKey: 'admin.subscriptions.description',
},
},
{
path: '/admin/accounts',
name: 'AdminAccounts',
component: () => import('@/views/admin/AccountsView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Account Management',
titleKey: 'admin.accounts.title',
descriptionKey: 'admin.accounts.description',
},
},
{
path: '/admin/proxies',
name: 'AdminProxies',
component: () => import('@/views/admin/ProxiesView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Proxy Management',
titleKey: 'admin.proxies.title',
descriptionKey: 'admin.proxies.description',
},
},
{
path: '/admin/redeem',
name: 'AdminRedeem',
component: () => import('@/views/admin/RedeemView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Redeem Code Management',
titleKey: 'admin.redeem.title',
descriptionKey: 'admin.redeem.description',
},
},
{
path: '/admin/settings',
name: 'AdminSettings',
component: () => import('@/views/admin/SettingsView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'System Settings',
titleKey: 'admin.settings.title',
descriptionKey: 'admin.settings.description',
},
},
{
path: '/admin/usage',
name: 'AdminUsage',
component: () => import('@/views/admin/UsageView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Usage Records',
titleKey: 'admin.usage.title',
descriptionKey: 'admin.usage.description',
},
},
// ==================== 404 Not Found ====================
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFoundView.vue'),
meta: {
title: '404 Not Found',
},
},
];
/**
* Create router instance
*/
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
scrollBehavior(to, from, savedPosition) {
// Scroll to saved position when using browser back/forward
if (savedPosition) {
return savedPosition;
}
// Scroll to top for new routes
return { top: 0 };
},
});
/**
* Navigation guard: Authentication check
*/
let authInitialized = false;
router.beforeEach((to, from, next) => {
const authStore = useAuthStore();
// Restore auth state from localStorage on first navigation (page refresh)
if (!authInitialized) {
authStore.checkAuth();
authInitialized = true;
}
// Set page title
if (to.meta.title) {
document.title = `${to.meta.title} - Sub2API`;
} else {
document.title = 'Sub2API';
}
// Check if route requires authentication
const requiresAuth = to.meta.requiresAuth !== false; // Default to true
const requiresAdmin = to.meta.requiresAdmin === true;
// If route doesn't require auth, allow access
if (!requiresAuth) {
// If already authenticated and trying to access login/register, redirect to dashboard
if (authStore.isAuthenticated && (to.path === '/login' || to.path === '/register')) {
next('/dashboard');
return;
}
next();
return;
}
// Route requires authentication
if (!authStore.isAuthenticated) {
// Not authenticated, redirect to login
next({
path: '/login',
query: { redirect: to.fullPath }, // Save intended destination
});
return;
}
// Check admin requirement
if (requiresAdmin && !authStore.isAdmin) {
// User is authenticated but not admin, redirect to user dashboard
next('/dashboard');
return;
}
// All checks passed, allow navigation
next();
});
/**
* Navigation guard: Error handling
*/
router.onError((error) => {
console.error('Router error:', error);
});
export default router;
/**
* Type definitions for Vue Router meta fields
* Extends the RouteMeta interface with custom properties
*/
import 'vue-router';
declare module 'vue-router' {
interface RouteMeta {
/**
* Whether this route requires authentication
* @default true
*/
requiresAuth?: boolean;
/**
* Whether this route requires admin role
* @default false
*/
requiresAdmin?: boolean;
/**
* Page title for this route
*/
title?: string;
/**
* Optional breadcrumb items for navigation
*/
breadcrumbs?: Array<{
label: string;
to?: string;
}>;
/**
* Icon name for this route (for sidebar navigation)
*/
icon?: string;
/**
* Whether to hide this route from navigation menu
* @default false
*/
hideInMenu?: boolean;
}
}
# Pinia Stores Documentation
This directory contains all Pinia stores for the Sub2API frontend application.
## Stores Overview
### 1. Auth Store (`auth.ts`)
Manages user authentication state, login/logout, and token persistence.
**State:**
- `user: User | null` - Current authenticated user
- `token: string | null` - JWT authentication token
**Computed:**
- `isAuthenticated: boolean` - Whether user is currently authenticated
**Actions:**
- `login(credentials)` - Authenticate user with username/password
- `register(userData)` - Register new user account
- `logout()` - Clear authentication and logout
- `checkAuth()` - Restore session from localStorage
- `refreshUser()` - Fetch latest user data from server
### 2. App Store (`app.ts`)
Manages global UI state including sidebar, loading indicators, and toast notifications.
**State:**
- `sidebarCollapsed: boolean` - Sidebar collapsed state
- `loading: boolean` - Global loading state
- `toasts: Toast[]` - Active toast notifications
**Computed:**
- `hasActiveToasts: boolean` - Whether any toasts are active
**Actions:**
- `toggleSidebar()` - Toggle sidebar state
- `setSidebarCollapsed(collapsed)` - Set sidebar state explicitly
- `setLoading(isLoading)` - Set loading state
- `showToast(type, message, duration?)` - Show toast notification
- `showSuccess(message, duration?)` - Show success toast
- `showError(message, duration?)` - Show error toast
- `showInfo(message, duration?)` - Show info toast
- `showWarning(message, duration?)` - Show warning toast
- `hideToast(id)` - Hide specific toast
- `clearAllToasts()` - Clear all toasts
- `withLoading(operation)` - Execute async operation with loading state
- `withLoadingAndError(operation, errorMessage?)` - Execute with loading and error handling
- `reset()` - Reset store to defaults
## Usage Examples
### Auth Store
```typescript
import { useAuthStore } from '@/stores';
// In component setup
const authStore = useAuthStore();
// Initialize on app startup
authStore.checkAuth();
// Login
try {
await authStore.login({ username: 'user', password: 'pass' });
console.log('Logged in:', authStore.user);
} catch (error) {
console.error('Login failed:', error);
}
// Check authentication
if (authStore.isAuthenticated) {
console.log('User is logged in:', authStore.user?.username);
}
// Logout
authStore.logout();
```
### App Store
```typescript
import { useAppStore } from '@/stores';
// In component setup
const appStore = useAppStore();
// Sidebar control
appStore.toggleSidebar();
appStore.setSidebarCollapsed(true);
// Loading state
appStore.setLoading(true);
// ... do work
appStore.setLoading(false);
// Or use helper
await appStore.withLoading(async () => {
const data = await fetchData();
return data;
});
// Toast notifications
appStore.showSuccess('Operation completed!');
appStore.showError('Something went wrong!', 5000);
appStore.showInfo('FYI: This is informational');
appStore.showWarning('Be careful!');
// Custom toast
const toastId = appStore.showToast('info', 'Custom message', undefined); // No auto-dismiss
// Later...
appStore.hideToast(toastId);
```
### Combined Usage in Vue Component
```vue
<script setup lang="ts">
import { useAuthStore, useAppStore } from '@/stores';
import { onMounted } from 'vue';
const authStore = useAuthStore();
const appStore = useAppStore();
onMounted(() => {
// Check for existing session
authStore.checkAuth();
});
async function handleLogin(username: string, password: string) {
try {
await appStore.withLoading(async () => {
await authStore.login({ username, password });
});
appStore.showSuccess('Welcome back!');
} catch (error) {
appStore.showError('Login failed. Please check your credentials.');
}
}
async function handleLogout() {
authStore.logout();
appStore.showInfo('You have been logged out.');
}
</script>
<template>
<div>
<button @click="appStore.toggleSidebar">
Toggle Sidebar
</button>
<div v-if="appStore.loading">Loading...</div>
<div v-if="authStore.isAuthenticated">
Welcome, {{ authStore.user?.username }}!
<button @click="handleLogout">Logout</button>
</div>
<div v-else>
<button @click="handleLogin('user', 'pass')">Login</button>
</div>
</div>
</template>
```
## Persistence
- **Auth Store**: Token and user data are automatically persisted to `localStorage`
- Keys: `auth_token`, `auth_user`
- Restored on `checkAuth()` call
- **App Store**: No persistence (UI state resets on page reload)
## TypeScript Support
All stores are fully typed with TypeScript. Import types from `@/types`:
```typescript
import type { User, Toast, ToastType } from '@/types';
```
## Testing
Stores can be reset to initial state:
```typescript
// Auth store
authStore.logout(); // Clears all auth state
// App store
appStore.reset(); // Resets to defaults
```
/**
* Application State Store
* Manages global UI state including sidebar, loading indicators, and toast notifications
*/
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { Toast, ToastType } from '@/types';
export const useAppStore = defineStore('app', () => {
// ==================== State ====================
const sidebarCollapsed = ref<boolean>(false);
const loading = ref<boolean>(false);
const toasts = ref<Toast[]>([]);
// Auto-incrementing ID for toasts
let toastIdCounter = 0;
// ==================== Computed ====================
const hasActiveToasts = computed(() => toasts.value.length > 0);
const loadingCount = ref<number>(0);
// ==================== Actions ====================
/**
* Toggle sidebar collapsed state
*/
function toggleSidebar(): void {
sidebarCollapsed.value = !sidebarCollapsed.value;
}
/**
* Set sidebar collapsed state explicitly
* @param collapsed - Whether sidebar should be collapsed
*/
function setSidebarCollapsed(collapsed: boolean): void {
sidebarCollapsed.value = collapsed;
}
/**
* Set global loading state
* @param isLoading - Whether app is in loading state
*/
function setLoading(isLoading: boolean): void {
if (isLoading) {
loadingCount.value++;
} else {
loadingCount.value = Math.max(0, loadingCount.value - 1);
}
loading.value = loadingCount.value > 0;
}
/**
* Show a toast notification
* @param type - Type of toast (success, error, info, warning)
* @param message - Toast message content
* @param duration - Auto-dismiss duration in ms (undefined = no auto-dismiss)
* @returns Toast ID for manual dismissal
*/
function showToast(
type: ToastType,
message: string,
duration?: number
): string {
const id = `toast-${++toastIdCounter}`;
const toast: Toast = {
id,
type,
message,
duration,
startTime: duration !== undefined ? Date.now() : undefined,
};
toasts.value.push(toast);
// Auto-dismiss if duration is specified
if (duration !== undefined) {
setTimeout(() => {
hideToast(id);
}, duration);
}
return id;
}
/**
* Show a success toast
* @param message - Success message
* @param duration - Auto-dismiss duration in ms (default: 3000)
*/
function showSuccess(message: string, duration: number = 3000): string {
return showToast('success', message, duration);
}
/**
* Show an error toast
* @param message - Error message
* @param duration - Auto-dismiss duration in ms (default: 5000)
*/
function showError(message: string, duration: number = 5000): string {
return showToast('error', message, duration);
}
/**
* Show an info toast
* @param message - Info message
* @param duration - Auto-dismiss duration in ms (default: 3000)
*/
function showInfo(message: string, duration: number = 3000): string {
return showToast('info', message, duration);
}
/**
* Show a warning toast
* @param message - Warning message
* @param duration - Auto-dismiss duration in ms (default: 4000)
*/
function showWarning(message: string, duration: number = 4000): string {
return showToast('warning', message, duration);
}
/**
* Hide a specific toast by ID
* @param id - Toast ID to hide
*/
function hideToast(id: string): void {
const index = toasts.value.findIndex((t) => t.id === id);
if (index !== -1) {
toasts.value.splice(index, 1);
}
}
/**
* Clear all toasts
*/
function clearAllToasts(): void {
toasts.value = [];
}
/**
* Execute an async operation with loading state
* Automatically manages loading indicator
* @param operation - Async operation to execute
* @returns Promise resolving to operation result
*/
async function withLoading<T>(operation: () => Promise<T>): Promise<T> {
setLoading(true);
try {
return await operation();
} finally {
setLoading(false);
}
}
/**
* Execute an async operation with loading and error handling
* Shows error toast on failure
* @param operation - Async operation to execute
* @param errorMessage - Custom error message (optional)
* @returns Promise resolving to operation result or null on error
*/
async function withLoadingAndError<T>(
operation: () => Promise<T>,
errorMessage?: string
): Promise<T | null> {
setLoading(true);
try {
return await operation();
} catch (error) {
const message =
errorMessage ||
(error as { message?: string }).message ||
'An error occurred';
showError(message);
return null;
} finally {
setLoading(false);
}
}
/**
* Reset app state to defaults
* Useful for cleanup or testing
*/
function reset(): void {
sidebarCollapsed.value = false;
loading.value = false;
loadingCount.value = 0;
toasts.value = [];
}
// ==================== Return Store API ====================
return {
// State
sidebarCollapsed,
loading,
toasts,
// Computed
hasActiveToasts,
// Actions
toggleSidebar,
setSidebarCollapsed,
setLoading,
showToast,
showSuccess,
showError,
showInfo,
showWarning,
hideToast,
clearAllToasts,
withLoading,
withLoadingAndError,
reset,
};
});
/**
* Authentication Store
* Manages user authentication state, login/logout, and token persistence
*/
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { authAPI } from '@/api';
import type { User, LoginRequest, RegisterRequest } from '@/types';
const AUTH_TOKEN_KEY = 'auth_token';
const AUTH_USER_KEY = 'auth_user';
const AUTO_REFRESH_INTERVAL = 60 * 1000; // 60 seconds
export const useAuthStore = defineStore('auth', () => {
// ==================== State ====================
const user = ref<User | null>(null);
const token = ref<string | null>(null);
let refreshIntervalId: ReturnType<typeof setInterval> | null = null;
// ==================== Computed ====================
const isAuthenticated = computed(() => {
return !!token.value && !!user.value;
});
const isAdmin = computed(() => {
return user.value?.role === 'admin';
});
// ==================== Actions ====================
/**
* Initialize auth state from localStorage
* Call this on app startup to restore session
* Also starts auto-refresh and immediately fetches latest user data
*/
function checkAuth(): void {
const savedToken = localStorage.getItem(AUTH_TOKEN_KEY);
const savedUser = localStorage.getItem(AUTH_USER_KEY);
if (savedToken && savedUser) {
try {
token.value = savedToken;
user.value = JSON.parse(savedUser);
// Immediately refresh user data from backend (async, don't block)
refreshUser().catch((error) => {
console.error('Failed to refresh user on init:', error);
});
// Start auto-refresh interval
startAutoRefresh();
} catch (error) {
console.error('Failed to parse saved user data:', error);
clearAuth();
}
}
}
/**
* Start auto-refresh interval for user data
* Refreshes user data every 60 seconds
*/
function startAutoRefresh(): void {
// Clear existing interval if any
stopAutoRefresh();
refreshIntervalId = setInterval(() => {
if (token.value) {
refreshUser().catch((error) => {
console.error('Auto-refresh user failed:', error);
});
}
}, AUTO_REFRESH_INTERVAL);
}
/**
* Stop auto-refresh interval
*/
function stopAutoRefresh(): void {
if (refreshIntervalId) {
clearInterval(refreshIntervalId);
refreshIntervalId = null;
}
}
/**
* User login
* @param credentials - Login credentials (username and password)
* @returns Promise resolving to the authenticated user
* @throws Error if login fails
*/
async function login(credentials: LoginRequest): Promise<User> {
try {
const response = await authAPI.login(credentials);
// Store token and user
token.value = response.access_token;
user.value = response.user;
// Persist to localStorage
localStorage.setItem(AUTH_TOKEN_KEY, response.access_token);
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(response.user));
// Start auto-refresh interval
startAutoRefresh();
return response.user;
} catch (error) {
// Clear any partial state on error
clearAuth();
throw error;
}
}
/**
* User registration
* @param userData - Registration data (username, email, password)
* @returns Promise resolving to the newly registered and authenticated user
* @throws Error if registration fails
*/
async function register(userData: RegisterRequest): Promise<User> {
try {
const response = await authAPI.register(userData);
// Store token and user
token.value = response.access_token;
user.value = response.user;
// Persist to localStorage
localStorage.setItem(AUTH_TOKEN_KEY, response.access_token);
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(response.user));
// Start auto-refresh interval
startAutoRefresh();
return response.user;
} catch (error) {
// Clear any partial state on error
clearAuth();
throw error;
}
}
/**
* User logout
* Clears all authentication state and persisted data
*/
function logout(): void {
// Call API logout (client-side cleanup)
authAPI.logout();
// Clear state
clearAuth();
}
/**
* Refresh current user data
* Fetches latest user info from the server
* @returns Promise resolving to the updated user
* @throws Error if not authenticated or request fails
*/
async function refreshUser(): Promise<User> {
if (!token.value) {
throw new Error('Not authenticated');
}
try {
const updatedUser = await authAPI.getCurrentUser();
user.value = updatedUser;
// Update localStorage
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(updatedUser));
return updatedUser;
} catch (error) {
// If refresh fails with 401, clear auth state
if ((error as { status?: number }).status === 401) {
clearAuth();
}
throw error;
}
}
/**
* Clear all authentication state
* Internal helper function
*/
function clearAuth(): void {
// Stop auto-refresh
stopAutoRefresh();
token.value = null;
user.value = null;
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(AUTH_USER_KEY);
}
// ==================== Return Store API ====================
return {
// State
user,
token,
// Computed
isAuthenticated,
isAdmin,
// Actions
login,
register,
logout,
checkAuth,
refreshUser,
};
});
/**
* Pinia Stores Export
* Central export point for all application stores
*/
export { useAuthStore } from './auth';
export { useAppStore } from './app';
// Re-export types for convenience
export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types';
export type { Toast, ToastType, AppState } from '@/types';
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
@apply border-gray-200 dark:border-dark-700;
}
html {
@apply antialiased scroll-smooth;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
}
body {
@apply bg-gray-50 text-gray-900 dark:bg-dark-950 dark:text-gray-100;
@apply min-h-screen;
}
/* 自定义滚动条 */
::-webkit-scrollbar {
@apply w-2 h-2;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-gray-300 dark:bg-dark-600 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400 dark:bg-dark-500;
}
/* 选中文本样式 */
::selection {
@apply bg-primary-500/20 text-primary-900 dark:text-primary-100;
}
}
@layer components {
/* ============ 按钮样式 ============ */
.btn {
@apply inline-flex items-center justify-center gap-2;
@apply px-4 py-2.5 rounded-xl font-medium text-sm;
@apply transition-all duration-200 ease-out;
@apply focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500/50;
@apply disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none;
@apply active:scale-[0.98];
}
.btn-primary {
@apply bg-gradient-to-r from-primary-500 to-primary-600;
@apply text-white shadow-md shadow-primary-500/25;
@apply hover:from-primary-600 hover:to-primary-700 hover:shadow-lg hover:shadow-primary-500/30;
@apply dark:shadow-primary-500/20;
}
.btn-secondary {
@apply bg-white dark:bg-dark-800;
@apply text-gray-700 dark:text-gray-200;
@apply border border-gray-200 dark:border-dark-600;
@apply shadow-sm hover:bg-gray-50 dark:hover:bg-dark-700;
@apply hover:border-gray-300 dark:hover:border-dark-500;
}
.btn-ghost {
@apply bg-transparent text-gray-600 dark:text-gray-300;
@apply hover:bg-gray-100 dark:hover:bg-dark-800;
}
.btn-danger {
@apply bg-gradient-to-r from-red-500 to-red-600;
@apply text-white shadow-md shadow-red-500/25;
@apply hover:from-red-600 hover:to-red-700 hover:shadow-lg hover:shadow-red-500/30;
}
.btn-sm {
@apply px-3 py-1.5 text-xs rounded-lg;
}
.btn-lg {
@apply px-6 py-3 text-base rounded-2xl;
}
.btn-icon {
@apply p-2.5 rounded-xl;
}
/* ============ 输入框样式 ============ */
.input {
@apply w-full px-4 py-2.5 rounded-xl text-sm;
@apply bg-white dark:bg-dark-800;
@apply border border-gray-200 dark:border-dark-600;
@apply text-gray-900 dark:text-gray-100;
@apply placeholder:text-gray-400 dark:placeholder:text-dark-400;
@apply transition-all duration-200;
@apply focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-500;
@apply disabled:bg-gray-100 dark:disabled:bg-dark-900 disabled:cursor-not-allowed;
}
.input-error {
@apply border-red-500 focus:ring-red-500/30 focus:border-red-500;
}
.input-label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5;
}
.input-hint {
@apply text-xs text-gray-500 dark:text-dark-400 mt-1;
}
.input-error-text {
@apply text-xs text-red-500 mt-1;
}
/* Hide number input spinner buttons for cleaner UI */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
}
/* ============ 卡片样式 ============ */
.card {
@apply bg-white dark:bg-dark-800/50;
@apply rounded-2xl;
@apply border border-gray-100 dark:border-dark-700/50;
@apply shadow-card;
@apply transition-all duration-300;
}
.card-hover {
@apply hover:shadow-card-hover hover:-translate-y-0.5;
@apply hover:border-gray-200 dark:hover:border-dark-600;
}
.card-glass {
@apply bg-white/70 dark:bg-dark-800/70;
@apply backdrop-blur-xl;
@apply border border-white/20 dark:border-dark-700/50;
@apply shadow-glass;
}
/* ============ 统计卡片 ============ */
.stat-card {
@apply card p-5;
@apply flex items-start gap-4;
}
.stat-icon {
@apply w-12 h-12 rounded-xl;
@apply flex items-center justify-center;
@apply text-xl;
}
.stat-icon-primary {
@apply bg-primary-100 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400;
}
.stat-icon-success {
@apply bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400;
}
.stat-icon-warning {
@apply bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400;
}
.stat-icon-danger {
@apply bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400;
}
.stat-value {
@apply text-2xl font-bold text-gray-900 dark:text-white;
}
.stat-label {
@apply text-sm text-gray-500 dark:text-dark-400;
}
.stat-trend {
@apply text-xs font-medium flex items-center gap-1 mt-1;
}
.stat-trend-up {
@apply text-emerald-600 dark:text-emerald-400;
}
.stat-trend-down {
@apply text-red-600 dark:text-red-400;
}
/* ============ 表格样式 ============ */
.table-container {
@apply overflow-x-auto rounded-xl border border-gray-200 dark:border-dark-700;
}
.table {
@apply w-full text-sm;
}
.table th {
@apply px-4 py-3 text-left font-medium;
@apply text-gray-600 dark:text-dark-300;
@apply bg-gray-50 dark:bg-dark-800/50;
@apply border-b border-gray-200 dark:border-dark-700;
}
.table td {
@apply px-4 py-3;
@apply text-gray-700 dark:text-gray-300;
@apply border-b border-gray-100 dark:border-dark-800;
}
.table tr:last-child td {
@apply border-b-0;
}
.table tbody tr {
@apply transition-colors duration-150;
@apply hover:bg-gray-50 dark:hover:bg-dark-800/30;
}
/* ============ 徽章样式 ============ */
.badge {
@apply inline-flex items-center gap-1;
@apply px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-primary {
@apply bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400;
}
.badge-success {
@apply bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400;
}
.badge-warning {
@apply bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400;
}
.badge-danger {
@apply bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400;
}
.badge-gray {
@apply bg-gray-100 text-gray-700 dark:bg-dark-700 dark:text-dark-300;
}
/* ============ 下拉菜单 ============ */
.dropdown {
@apply absolute z-50;
@apply bg-white dark:bg-dark-800;
@apply rounded-xl;
@apply border border-gray-200 dark:border-dark-700;
@apply shadow-lg;
@apply py-1;
@apply animate-scale-in origin-top-right;
}
.dropdown-item {
@apply px-4 py-2 text-sm;
@apply text-gray-700 dark:text-gray-300;
@apply hover:bg-gray-100 dark:hover:bg-dark-700;
@apply cursor-pointer transition-colors;
@apply flex items-center gap-2;
}
/* ============ 模态框 ============ */
.modal-overlay {
@apply fixed inset-0 z-50;
@apply bg-black/50 backdrop-blur-sm;
@apply flex items-center justify-center p-4;
}
.modal-content {
@apply bg-white dark:bg-dark-800;
@apply rounded-2xl shadow-2xl;
@apply w-full;
@apply max-h-[90vh] overflow-y-auto;
}
.modal-header {
@apply px-6 py-4 border-b border-gray-100 dark:border-dark-700;
@apply flex items-center justify-between;
}
.modal-title {
@apply text-lg font-semibold text-gray-900 dark:text-white;
}
.modal-body {
@apply px-6 py-4;
}
.modal-footer {
@apply px-6 py-4 border-t border-gray-100 dark:border-dark-700;
@apply flex items-center justify-end gap-3;
}
/* ============ Toast 通知 ============ */
.toast {
@apply fixed top-4 right-4 z-[100];
@apply min-w-[320px] max-w-md;
@apply bg-white dark:bg-dark-800;
@apply rounded-xl shadow-lg;
@apply border-l-4;
@apply p-4;
@apply animate-slide-in-right;
}
.toast-success {
@apply border-l-emerald-500;
}
.toast-error {
@apply border-l-red-500;
}
.toast-warning {
@apply border-l-amber-500;
}
.toast-info {
@apply border-l-primary-500;
}
/* ============ 侧边栏 ============ */
.sidebar {
@apply fixed inset-y-0 left-0 z-40;
@apply w-64 bg-white dark:bg-dark-900;
@apply border-r border-gray-200 dark:border-dark-800;
@apply flex flex-col;
@apply transition-transform duration-300;
}
.sidebar-header {
@apply h-16 px-6;
@apply flex items-center gap-3;
@apply border-b border-gray-100 dark:border-dark-800;
}
.sidebar-nav {
@apply flex-1 overflow-y-auto py-4 px-3;
}
.sidebar-link {
@apply flex items-center gap-3 px-3 py-2.5 rounded-xl;
@apply text-sm font-medium;
@apply text-gray-600 dark:text-dark-300;
@apply transition-all duration-200;
@apply hover:bg-gray-100 dark:hover:bg-dark-800;
@apply hover:text-gray-900 dark:hover:text-white;
}
.sidebar-link-active {
@apply bg-primary-50 dark:bg-primary-900/20;
@apply text-primary-600 dark:text-primary-400;
@apply hover:bg-primary-100 dark:hover:bg-primary-900/30;
}
.sidebar-section {
@apply mb-6;
}
.sidebar-section-title {
@apply px-3 mb-2;
@apply text-xs font-semibold uppercase tracking-wider;
@apply text-gray-400 dark:text-dark-500;
}
/* ============ 页面头部 ============ */
.page-header {
@apply mb-6;
}
.page-title {
@apply text-2xl font-bold text-gray-900 dark:text-white;
}
.page-description {
@apply text-sm text-gray-500 dark:text-dark-400 mt-1;
}
/* ============ 空状态 ============ */
.empty-state {
@apply flex flex-col items-center justify-center py-12 px-4;
@apply text-center;
}
.empty-state-icon {
@apply w-16 h-16 mb-4;
@apply text-gray-300 dark:text-dark-600;
}
.empty-state-title {
@apply text-lg font-medium text-gray-900 dark:text-white mb-1;
}
.empty-state-description {
@apply text-sm text-gray-500 dark:text-dark-400 max-w-sm;
}
/* ============ 加载状态 ============ */
.spinner {
@apply w-5 h-5 border-2 border-current border-t-transparent rounded-full;
@apply animate-spin;
}
.skeleton {
@apply bg-gray-200 dark:bg-dark-700 rounded animate-pulse;
}
/* ============ 分隔线 ============ */
.divider {
@apply h-px bg-gray-200 dark:bg-dark-700 my-4;
}
/* ============ 标签页 ============ */
.tabs {
@apply flex gap-1 p-1;
@apply bg-gray-100 dark:bg-dark-800 rounded-xl;
}
.tab {
@apply px-4 py-2 rounded-lg text-sm font-medium;
@apply text-gray-600 dark:text-dark-400;
@apply transition-all duration-200;
@apply hover:text-gray-900 dark:hover:text-white;
}
.tab-active {
@apply bg-white dark:bg-dark-700;
@apply text-gray-900 dark:text-white;
@apply shadow-sm;
}
/* ============ 进度条 ============ */
.progress {
@apply h-2 bg-gray-200 dark:bg-dark-700 rounded-full overflow-hidden;
}
.progress-bar {
@apply h-full bg-gradient-to-r from-primary-500 to-primary-400;
@apply transition-all duration-300;
}
/* ============ 开关 ============ */
.switch {
@apply relative w-11 h-6 rounded-full cursor-pointer;
@apply bg-gray-300 dark:bg-dark-600;
@apply transition-colors duration-200;
}
.switch-active {
@apply bg-primary-500;
}
.switch-thumb {
@apply absolute top-0.5 left-0.5 w-5 h-5 rounded-full;
@apply bg-white shadow-sm;
@apply transition-transform duration-200;
}
.switch-active .switch-thumb {
@apply translate-x-5;
}
/* ============ 代码块 ============ */
.code {
@apply font-mono text-sm;
@apply bg-gray-100 dark:bg-dark-800;
@apply px-1.5 py-0.5 rounded;
@apply text-primary-600 dark:text-primary-400;
}
.code-block {
@apply font-mono text-sm;
@apply bg-gray-900 text-gray-100;
@apply p-4 rounded-xl overflow-x-auto;
}
}
@layer utilities {
/* 文字渐变 */
.text-gradient {
@apply bg-gradient-to-r from-primary-500 to-accent-500 bg-clip-text text-transparent;
}
/* 玻璃效果 */
.glass {
@apply bg-white/80 dark:bg-dark-800/80 backdrop-blur-xl;
}
/* 隐藏滚动条 */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* 安全区域 */
.safe-top {
padding-top: env(safe-area-inset-top);
}
.safe-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
}
/**
* Core Type Definitions for Sub2API Frontend
*/
// ==================== User & Auth Types ====================
export interface User {
id: number;
username: string;
email: string;
role: 'admin' | 'user'; // User role for authorization
balance: number; // User balance for API usage
concurrency: number; // Allowed concurrent requests
status: 'active' | 'disabled'; // Account status
allowed_groups: number[] | null; // Allowed group IDs (null = all non-exclusive groups)
created_at: string;
updated_at: string;
}
export interface LoginRequest {
email: string;
password: string;
turnstile_token?: string;
}
export interface RegisterRequest {
email: string;
password: string;
verify_code?: string;
turnstile_token?: string;
}
export interface SendVerifyCodeRequest {
email: string;
turnstile_token?: string;
}
export interface SendVerifyCodeResponse {
message: string;
countdown: number;
}
export interface PublicSettings {
registration_enabled: boolean;
email_verify_enabled: boolean;
turnstile_enabled: boolean;
turnstile_site_key: string;
site_name: string;
site_logo: string;
site_subtitle: string;
api_base_url: string;
contact_info: string;
version: string;
}
export interface AuthResponse {
access_token: string;
token_type: string;
user: User;
}
// ==================== Subscription Types ====================
export interface Subscription {
id: number;
user_id: number;
name: string;
url: string;
type: 'clash' | 'v2ray' | 'surge' | 'quantumult' | 'shadowrocket';
update_interval: number; // in hours
last_updated: string | null;
node_count: number;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface CreateSubscriptionRequest {
name: string;
url: string;
type: Subscription['type'];
update_interval?: number;
}
export interface UpdateSubscriptionRequest {
name?: string;
url?: string;
type?: Subscription['type'];
update_interval?: number;
is_active?: boolean;
}
// ==================== Proxy Node Types ====================
export interface ProxyNode {
id: number;
subscription_id: number;
name: string;
type: 'ss' | 'ssr' | 'vmess' | 'vless' | 'trojan' | 'hysteria' | 'hysteria2';
server: string;
port: number;
config: Record<string, unknown>; // JSON configuration specific to proxy type
latency: number | null; // in milliseconds
last_checked: string | null;
is_available: boolean;
created_at: string;
updated_at: string;
}
// ==================== Conversion Types ====================
export interface ConversionRequest {
subscription_ids: number[];
target_type: 'clash' | 'v2ray' | 'surge' | 'quantumult' | 'shadowrocket';
filter?: {
name_pattern?: string;
types?: ProxyNode['type'][];
min_latency?: number;
max_latency?: number;
available_only?: boolean;
};
sort?: {
by: 'name' | 'latency' | 'type';
order: 'asc' | 'desc';
};
}
export interface ConversionResult {
url: string; // URL to download the converted subscription
expires_at: string;
node_count: number;
}
// ==================== Statistics Types ====================
export interface SubscriptionStats {
subscription_id: number;
total_nodes: number;
available_nodes: number;
avg_latency: number | null;
by_type: Record<ProxyNode['type'], number>;
last_update: string;
}
export interface UserStats {
total_subscriptions: number;
total_nodes: number;
active_subscriptions: number;
total_conversions: number;
last_conversion: string | null;
}
// ==================== API Response Types ====================
export interface ApiError {
detail: string;
code?: string;
field?: string;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
page_size: number;
pages: number;
}
// ==================== UI State Types ====================
export type ToastType = 'success' | 'error' | 'info' | 'warning';
export interface Toast {
id: string;
type: ToastType;
message: string;
title?: string;
duration?: number; // in milliseconds, undefined means no auto-dismiss
startTime?: number; // timestamp when toast was created, for progress bar
}
export interface AppState {
sidebarCollapsed: boolean;
loading: boolean;
toasts: Toast[];
}
// ==================== Validation Types ====================
export interface ValidationError {
field: string;
message: string;
}
// ==================== Table/List Types ====================
export interface SortConfig {
key: string;
order: 'asc' | 'desc';
}
export interface FilterConfig {
[key: string]: string | number | boolean | null | undefined;
}
export interface PaginationConfig {
page: number;
page_size: number;
}
// ==================== API Key & Group Types ====================
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini';
export type SubscriptionType = 'standard' | 'subscription';
export interface Group {
id: number;
name: string;
description: string | null;
platform: GroupPlatform;
rate_multiplier: number;
is_exclusive: boolean;
status: 'active' | 'inactive';
subscription_type: SubscriptionType;
daily_limit_usd: number | null;
weekly_limit_usd: number | null;
monthly_limit_usd: number | null;
account_count?: number;
created_at: string;
updated_at: string;
}
export interface ApiKey {
id: number;
user_id: number;
key: string;
name: string;
group_id: number | null;
status: 'active' | 'inactive';
created_at: string;
updated_at: string;
group?: Group;
}
export interface CreateApiKeyRequest {
name: string;
group_id?: number | null;
custom_key?: string; // 可选的自定义API Key
}
export interface UpdateApiKeyRequest {
name?: string;
group_id?: number | null;
status?: 'active' | 'inactive';
}
export interface CreateGroupRequest {
name: string;
description?: string | null;
platform?: GroupPlatform;
rate_multiplier?: number;
is_exclusive?: boolean;
}
export interface UpdateGroupRequest {
name?: string;
description?: string | null;
platform?: GroupPlatform;
rate_multiplier?: number;
is_exclusive?: boolean;
status?: 'active' | 'inactive';
}
// ==================== Account & Proxy Types ====================
export type AccountPlatform = 'anthropic';
export type AccountType = 'oauth' | 'setup-token' | 'apikey';
export type OAuthAddMethod = 'oauth' | 'setup-token';
export type ProxyProtocol = 'http' | 'https' | 'socks5';
export interface Proxy {
id: number;
name: string;
protocol: ProxyProtocol;
host: string;
port: number;
username: string | null;
password?: string | null;
status: 'active' | 'inactive';
account_count?: number; // Number of accounts using this proxy
created_at: string;
updated_at: string;
}
export interface Account {
id: number;
name: string;
platform: AccountPlatform;
type: AccountType;
credentials?: Record<string, unknown>;
proxy_id: number | null;
concurrency: number;
priority: number;
status: 'active' | 'inactive' | 'error';
error_message: string | null;
last_used_at: string | null;
created_at: string;
updated_at: string;
proxy?: Proxy;
group_ids?: number[]; // Groups this account belongs to
// Rate limit & scheduling fields
schedulable: boolean;
rate_limited_at: string | null;
rate_limit_reset_at: string | null;
overload_until: string | null;
// Session window fields (5-hour window)
session_window_start: string | null;
session_window_end: string | null;
session_window_status: 'allowed' | 'allowed_warning' | 'rejected' | null;
}
// Account Usage types
export interface WindowStats {
requests: number;
tokens: number;
cost: number;
}
export interface UsageProgress {
utilization: number; // Percentage (0-100+, 100 = 100%)
resets_at: string | null;
remaining_seconds: number;
window_stats?: WindowStats | null; // 窗口期统计(从窗口开始到当前的使用量)
}
export interface AccountUsageInfo {
updated_at: string | null;
five_hour: UsageProgress | null;
seven_day: UsageProgress | null;
seven_day_sonnet: UsageProgress | null;
}
export interface CreateAccountRequest {
name: string;
platform: AccountPlatform;
type: AccountType;
credentials: Record<string, unknown>;
extra?: Record<string, string>;
proxy_id?: number | null;
concurrency?: number;
priority?: number;
group_ids?: number[];
}
export interface UpdateAccountRequest {
name?: string;
credentials?: Record<string, unknown>;
extra?: Record<string, string>;
proxy_id?: number | null;
concurrency?: number;
priority?: number;
status?: 'active' | 'inactive';
group_ids?: number[];
}
export interface CreateProxyRequest {
name: string;
protocol: ProxyProtocol;
host: string;
port: number;
username?: string | null;
password?: string | null;
}
export interface UpdateProxyRequest {
name?: string;
protocol?: ProxyProtocol;
host?: string;
port?: number;
username?: string | null;
password?: string | null;
status?: 'active' | 'inactive';
}
// ==================== Usage & Redeem Types ====================
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription';
// 消费类型: 0=钱包余额, 1=订阅套餐
export type BillingType = 0 | 1;
export interface UsageLog {
id: number;
user_id: number;
api_key_id: number;
account_id: number | null;
model: string;
input_tokens: number;
output_tokens: number;
cache_creation_tokens: number;
cache_read_tokens: number;
total_cost: number;
actual_cost: number;
rate_multiplier: number;
billing_type: BillingType;
stream: boolean;
duration_ms: number;
first_token_ms: number | null;
created_at: string;
user?: User;
api_key?: ApiKey;
account?: Account;
}
export interface RedeemCode {
id: number;
code: string;
type: RedeemCodeType;
value: number;
status: 'active' | 'used' | 'expired' | 'unused';
used_by: number | null;
used_at: string | null;
created_at: string;
updated_at?: string;
group_id?: number | null; // 订阅类型专用
validity_days?: number; // 订阅类型专用
user?: User;
group?: Group; // 关联的分组
}
export interface GenerateRedeemCodesRequest {
count: number;
type: RedeemCodeType;
value: number;
group_id?: number | null; // 订阅类型专用
validity_days?: number; // 订阅类型专用
}
export interface RedeemCodeRequest {
code: string;
}
// ==================== Dashboard & Statistics ====================
export interface DashboardStats {
// 用户统计
total_users: number;
today_new_users: number; // 今日新增用户数
active_users: number; // 今日有请求的用户数
// API Key 统计
total_api_keys: number;
active_api_keys: number; // 状态为 active 的 API Key 数
// 账户统计
total_accounts: number;
normal_accounts: number; // 正常账户数
error_accounts: number; // 异常账户数
ratelimit_accounts: number; // 限流账户数
overload_accounts: number; // 过载账户数
// 累计 Token 使用统计
total_requests: number;
total_input_tokens: number;
total_output_tokens: number;
total_cache_creation_tokens: number;
total_cache_read_tokens: number;
total_tokens: number;
total_cost: number; // 累计标准计费
total_actual_cost: number; // 累计实际扣除
// 今日 Token 使用统计
today_requests: number;
today_input_tokens: number;
today_output_tokens: number;
today_cache_creation_tokens: number;
today_cache_read_tokens: number;
today_tokens: number;
today_cost: number; // 今日标准计费
today_actual_cost: number; // 今日实际扣除
// 系统运行统计
average_duration_ms: number; // 平均响应时间
uptime: number; // 系统运行时间(秒)
}
export interface UsageStatsResponse {
period?: string;
total_requests: number;
total_input_tokens: number;
total_output_tokens: number;
total_cache_tokens: number;
total_tokens: number;
total_cost: number; // 标准计费
total_actual_cost: number; // 实际扣除
average_duration_ms: number;
models?: Record<string, number>;
}
// ==================== Trend & Chart Types ====================
export interface TrendDataPoint {
date: string;
requests: number;
input_tokens: number;
output_tokens: number;
cache_tokens: number;
total_tokens: number;
cost: number; // 标准计费
actual_cost: number; // 实际扣除
}
export interface ModelStat {
model: string;
requests: number;
input_tokens: number;
output_tokens: number;
total_tokens: number;
cost: number; // 标准计费
actual_cost: number; // 实际扣除
}
export interface UserUsageTrendPoint {
date: string;
user_id: number;
username: string;
requests: number;
tokens: number;
cost: number; // 标准计费
actual_cost: number; // 实际扣除
}
export interface ApiKeyUsageTrendPoint {
date: string;
api_key_id: number;
key_name: string;
requests: number;
tokens: number;
}
// ==================== Admin User Management ====================
export interface UpdateUserRequest {
email?: string;
password?: string;
role?: 'admin' | 'user';
balance?: number;
concurrency?: number;
status?: 'active' | 'disabled';
allowed_groups?: number[] | null;
}
export interface ChangePasswordRequest {
old_password: string;
new_password: string;
}
// ==================== User Subscription Types ====================
export interface UserSubscription {
id: number;
user_id: number;
group_id: number;
status: 'active' | 'expired' | 'revoked';
daily_usage_usd: number;
weekly_usage_usd: number;
monthly_usage_usd: number;
daily_window_start: string | null;
weekly_window_start: string | null;
monthly_window_start: string | null;
created_at: string;
updated_at: string;
expires_at: string | null;
user?: User;
group?: Group;
}
export interface SubscriptionProgress {
subscription_id: number;
daily: {
used: number;
limit: number | null;
percentage: number;
reset_in_seconds: number | null;
} | null;
weekly: {
used: number;
limit: number | null;
percentage: number;
reset_in_seconds: number | null;
} | null;
monthly: {
used: number;
limit: number | null;
percentage: number;
reset_in_seconds: number | null;
} | null;
expires_at: string | null;
days_remaining: number | null;
}
export interface AssignSubscriptionRequest {
user_id: number;
group_id: number;
validity_days?: number;
}
export interface BulkAssignSubscriptionRequest {
user_ids: number[];
group_id: number;
validity_days?: number;
}
export interface ExtendSubscriptionRequest {
days: number;
}
// ==================== Query Parameters ====================
export interface UsageQueryParams {
page?: number;
page_size?: number;
api_key_id?: number;
user_id?: number;
start_date?: string;
end_date?: string;
}
/**
* 格式化工具函数
* 参考 CRS 项目的 format.js 实现
*/
/**
* 格式化相对时间
* @param date 日期字符串或 Date 对象
* @returns 相对时间字符串,如 "5m ago", "2h ago", "3d ago"
*/
export function formatRelativeTime(date: string | Date | null | undefined): string {
if (!date) return 'Never'
const now = new Date()
const past = new Date(date)
const diffMs = now.getTime() - past.getTime()
// 处理未来时间或无效日期
if (diffMs < 0 || isNaN(diffMs)) return 'Never'
const diffSecs = Math.floor(diffMs / 1000)
const diffMins = Math.floor(diffSecs / 60)
const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffDays > 0) return `${diffDays}d ago`
if (diffHours > 0) return `${diffHours}h ago`
if (diffMins > 0) return `${diffMins}m ago`
return 'Just now'
}
/**
* 格式化数字(支持 K/M/B 单位)
* @param num 数字
* @returns 格式化后的字符串,如 "1.2K", "3.5M"
*/
export function formatNumber(num: number | null | undefined): string {
if (num === null || num === undefined) return '0'
const absNum = Math.abs(num)
if (absNum >= 1e9) {
return (num / 1e9).toFixed(2) + 'B'
} else if (absNum >= 1e6) {
return (num / 1e6).toFixed(2) + 'M'
} else if (absNum >= 1e3) {
return (num / 1e3).toFixed(1) + 'K'
}
return num.toLocaleString()
}
/**
* 格式化货币金额
* @param amount 金额
* @returns 格式化后的字符串,如 "$1.25" 或 "$0.000123"
*/
export function formatCurrency(amount: number | null | undefined): string {
if (amount === null || amount === undefined) return '$0.00'
// 小于 0.01 时显示更多小数位
if (amount > 0 && amount < 0.01) {
return '$' + amount.toFixed(6)
}
return '$' + amount.toFixed(2)
}
/**
* 格式化字节大小
* @param bytes 字节数
* @param decimals 小数位数
* @returns 格式化后的字符串,如 "1.5 MB"
*/
export function formatBytes(bytes: number, decimals: number = 2): string {
if (bytes === 0) return '0 Bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
/**
* 格式化日期
* @param date 日期字符串或 Date 对象
* @param format 格式字符串,支持 YYYY, MM, DD, HH, mm, ss
* @returns 格式化后的日期字符串
*/
export function formatDate(date: string | Date | null | undefined, format: string = 'YYYY-MM-DD HH:mm:ss'): string {
if (!date) return ''
const d = new Date(date)
if (isNaN(d.getTime())) return ''
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
const seconds = String(d.getSeconds()).padStart(2, '0')
return format
.replace('YYYY', String(year))
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes)
.replace('ss', seconds)
}
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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.users.createUser') }}
</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.users.searchUsers')"
class="input pl-10"
@input="handleSearch"
/>
</div>
<div class="flex flex-wrap gap-3">
<Select
v-model="filters.role"
:options="roleOptions"
:placeholder="t('admin.users.allRoles')"
class="w-36"
@change="loadUsers"
/>
<Select
v-model="filters.status"
:options="statusOptions"
:placeholder="t('admin.users.allStatus')"
class="w-36"
@change="loadUsers"
/>
</div>
</div>
<!-- Users Table -->
<div class="card overflow-hidden">
<DataTable :columns="columns" :data="users" :loading="loading">
<template #cell-email="{ value }">
<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">
{{ value.charAt(0).toUpperCase() }}
</span>
</div>
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</div>
</template>
<template #cell-role="{ value }">
<span
:class="[
'badge',
value === 'admin' ? 'badge-purple' : 'badge-gray'
]"
>
{{ value }}
</span>
</template>
<template #cell-balance="{ value }">
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span>
</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('admin.users.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('admin.users.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-concurrency="{ value }">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ value }}</span>
</template>
<template #cell-status="{ value }">
<span
:class="[
'badge',
value === 'active' ? 'badge-success' : 'badge-danger'
]"
>
{{ 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">
<!-- Toggle Status (hidden for admin users) -->
<button
v-if="row.role !== 'admin'"
@click="handleToggleStatus(row)"
:class="[
'p-2 rounded-lg transition-colors',
row.status === 'active'
? 'hover:bg-orange-50 dark:hover:bg-orange-900/20 text-gray-500 hover:text-orange-600 dark:hover:text-orange-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('admin.users.disableUser') : t('admin.users.enableUser')"
>
<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>
<!-- Allowed Groups -->
<button
@click="handleAllowedGroups(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.users.setAllowedGroups')"
>
<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 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>
</button>
<!-- View API Keys -->
<button
@click="handleViewApiKeys(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.users.viewApiKeys')"
>
<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="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>
</button>
<!-- Edit -->
<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>
<!-- Delete (hidden for admin users) -->
<button
v-if="row.role !== 'admin'"
@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.users.noUsersYet')"
:description="t('admin.users.createFirstUser')"
:action-text="t('admin.users.createUser')"
@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 User Modal -->
<Modal
:show="showCreateModal"
:title="t('admin.users.createUser')"
size="lg"
@close="closeCreateModal"
>
<form @submit.prevent="handleCreateUser" class="space-y-5">
<div>
<label class="input-label">{{ t('admin.users.email') }}</label>
<input
v-model="createForm.email"
type="email"
required
class="input"
:placeholder="t('admin.users.enterEmail')"
/>
</div>
<div>
<label class="input-label">{{ t('admin.users.password') }}</label>
<div class="flex gap-2">
<div class="relative flex-1">
<input
v-model="createForm.password"
type="text"
required
class="input pr-10"
:placeholder="t('admin.users.enterPassword')"
/>
<!-- Copy Password Button -->
<button
v-if="createForm.password"
type="button"
@click="copyPassword"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
:class="passwordCopied ? 'text-green-500' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'"
:title="passwordCopied ? t('keys.copied') : t('admin.users.copyPassword')"
>
<svg v-if="passwordCopied" 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>
<!-- Generate Random Password Button -->
<button
type="button"
@click="generateRandomPassword"
class="btn btn-secondary px-3"
:title="t('admin.users.generatePassword')"
>
<svg class="w-5 h-5" 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>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('admin.users.columns.balance') }}</label>
<input
v-model.number="createForm.balance"
type="number"
step="any"
class="input"
/>
</div>
<div>
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
<input
v-model.number="createForm.concurrency"
type="number"
class="input"
/>
</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.users.creating') : t('common.create') }}
</button>
</div>
</form>
</Modal>
<!-- Edit User Modal -->
<Modal
:show="showEditModal"
:title="t('admin.users.editUser')"
size="lg"
@close="closeEditModal"
>
<form v-if="editingUser" @submit.prevent="handleUpdateUser" class="space-y-5">
<div>
<label class="input-label">{{ t('admin.users.email') }}</label>
<input
v-model="editForm.email"
type="email"
class="input"
/>
</div>
<div>
<label class="input-label">{{ t('admin.users.password') }}</label>
<p class="text-xs text-gray-500 dark:text-dark-400 mb-1">{{ t('admin.users.leaveEmptyToKeep') }}</p>
<div class="flex gap-2">
<div class="relative flex-1">
<input
v-model="editForm.password"
type="text"
class="input pr-10"
:placeholder="t('admin.users.enterNewPassword')"
/>
<!-- Copy Password Button -->
<button
v-if="editForm.password"
type="button"
@click="copyEditPassword"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
:class="editPasswordCopied ? 'text-green-500' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'"
:title="editPasswordCopied ? t('keys.copied') : t('admin.users.copyPassword')"
>
<svg v-if="editPasswordCopied" 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>
<!-- Generate Random Password Button -->
<button
type="button"
@click="generateEditPassword"
class="btn btn-secondary px-3"
:title="t('admin.users.generatePassword')"
>
<svg class="w-5 h-5" 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>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('admin.users.columns.balance') }}</label>
<input
v-model.number="editForm.balance"
type="number"
step="any"
class="input"
/>
</div>
<div>
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
<input
v-model.number="editForm.concurrency"
type="number"
class="input"
/>
</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.users.updating') : t('common.update') }}
</button>
</div>
</form>
</Modal>
<!-- View API Keys Modal -->
<Modal
:show="showApiKeysModal"
:title="t('admin.users.userApiKeys')"
size="xl"
@close="closeApiKeysModal"
>
<div v-if="viewingUser" class="space-y-4">
<!-- User Info Header -->
<div class="flex items-center gap-3 p-4 rounded-xl bg-gray-50 dark:bg-dark-700">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30">
<span class="text-lg font-medium text-primary-700 dark:text-primary-300">
{{ viewingUser.email.charAt(0).toUpperCase() }}
</span>
</div>
<div>
<p class="font-medium text-gray-900 dark:text-white">{{ viewingUser.email }}</p>
<p class="text-sm text-gray-500 dark:text-dark-400">{{ viewingUser.username }}</p>
</div>
</div>
<!-- API Keys List -->
<div v-if="loadingApiKeys" class="flex justify-center py-8">
<svg class="animate-spin h-8 w-8 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>
<div v-else-if="userApiKeys.length === 0" class="text-center py-8">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1">
<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>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">{{ t('admin.users.noApiKeys') }}</p>
</div>
<div v-else class="space-y-3 max-h-96 overflow-y-auto">
<div
v-for="key in userApiKeys"
:key="key.id"
class="p-4 rounded-xl border border-gray-200 dark:border-dark-600 bg-white dark:bg-dark-800"
>
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-gray-900 dark:text-white">{{ key.name }}</span>
<span
:class="[
'badge text-xs',
key.status === 'active' ? 'badge-success' : 'badge-danger'
]"
>
{{ key.status }}
</span>
</div>
<p class="text-sm font-mono text-gray-500 dark:text-dark-400 truncate">
{{ key.key.substring(0, 20) }}...{{ key.key.substring(key.key.length - 8) }}
</p>
</div>
</div>
<div class="mt-3 flex flex-wrap gap-4 text-xs text-gray-500 dark:text-dark-400">
<div class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" 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>
<span>{{ t('admin.users.group') }}: {{ key.group?.name || t('admin.users.none') }}</span>
</div>
<div class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<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" />
</svg>
<span>{{ t('admin.users.columns.created') }}: {{ formatDate(key.created_at) }}</span>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end">
<button
@click="closeApiKeysModal"
type="button"
class="btn btn-secondary"
>
{{ t('common.cancel') }}
</button>
</div>
</template>
</Modal>
<!-- Allowed Groups Modal -->
<Modal
:show="showAllowedGroupsModal"
:title="t('admin.users.setAllowedGroups')"
size="lg"
@close="closeAllowedGroupsModal"
>
<div v-if="allowedGroupsUser" class="space-y-4">
<!-- User Info Header -->
<div class="flex items-center gap-3 p-4 rounded-xl bg-gray-50 dark:bg-dark-700">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30">
<span class="text-lg font-medium text-primary-700 dark:text-primary-300">
{{ allowedGroupsUser.email.charAt(0).toUpperCase() }}
</span>
</div>
<div>
<p class="font-medium text-gray-900 dark:text-white">{{ allowedGroupsUser.email }}</p>
</div>
</div>
<!-- Loading State -->
<div v-if="loadingGroups" class="flex justify-center py-8">
<svg class="animate-spin h-8 w-8 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>
<!-- Groups Selection -->
<div v-else>
<p class="text-sm text-gray-600 dark:text-dark-400 mb-3">
{{ t('admin.users.allowedGroupsHint') }}
</p>
<!-- Empty State -->
<div v-if="standardGroups.length === 0" class="text-center py-6">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1">
<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>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">{{ t('admin.users.noStandardGroups') }}</p>
</div>
<!-- Groups List -->
<div v-else class="space-y-2 max-h-64 overflow-y-auto">
<label
v-for="group in standardGroups"
:key="group.id"
class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-dark-600 hover:bg-gray-50 dark:hover:bg-dark-700 cursor-pointer transition-colors"
:class="{ 'bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-700': selectedGroupIds.includes(group.id) }"
>
<input
type="checkbox"
:value="group.id"
v-model="selectedGroupIds"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<div class="flex-1 min-w-0">
<p class="font-medium text-gray-900 dark:text-white">{{ group.name }}</p>
<p v-if="group.description" class="text-sm text-gray-500 dark:text-dark-400 truncate">{{ group.description }}</p>
</div>
<div class="flex items-center gap-2">
<span class="badge badge-gray text-xs">{{ group.platform }}</span>
<span v-if="group.is_exclusive" class="badge badge-purple text-xs">{{ t('admin.groups.exclusive') }}</span>
</div>
</label>
</div>
<!-- Clear Selection -->
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-dark-600">
<label class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-dark-600 hover:bg-gray-50 dark:hover:bg-dark-700 cursor-pointer transition-colors"
:class="{ 'bg-green-50 dark:bg-green-900/20 border-green-300 dark:border-green-700': selectedGroupIds.length === 0 }"
>
<input
type="radio"
:checked="selectedGroupIds.length === 0"
@change="selectedGroupIds = []"
class="h-4 w-4 border-gray-300 text-green-600 focus:ring-green-500"
/>
<div class="flex-1">
<p class="font-medium text-gray-900 dark:text-white">{{ t('admin.users.allowAllGroups') }}</p>
<p class="text-sm text-gray-500 dark:text-dark-400">{{ t('admin.users.allowAllGroupsHint') }}</p>
</div>
</label>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button
@click="closeAllowedGroupsModal"
type="button"
class="btn btn-secondary"
>
{{ t('common.cancel') }}
</button>
<button
@click="handleSaveAllowedGroups"
:disabled="savingAllowedGroups"
class="btn btn-primary"
>
<svg
v-if="savingAllowedGroups"
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>
{{ savingAllowedGroups ? t('common.saving') : t('common.save') }}
</button>
</div>
</template>
</Modal>
<!-- Delete Confirmation Dialog -->
<ConfirmDialog
:show="showDeleteDialog"
:title="t('admin.users.deleteUser')"
:message="t('admin.users.deleteConfirm', { email: deletingUser?.email })"
: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'
const { t } = useI18n()
import { adminAPI } from '@/api/admin'
import type { User, ApiKey, Group } from '@/types'
import type { BatchUserUsageStats } from '@/api/admin/dashboard'
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 appStore = useAppStore()
const columns = computed<Column[]>(() => [
{ key: 'email', label: t('admin.users.columns.user'), sortable: true },
{ key: 'role', label: t('admin.users.columns.role'), sortable: true },
{ key: 'balance', label: t('admin.users.columns.balance'), sortable: true },
{ key: 'usage', label: t('admin.users.columns.usage'), sortable: false },
{ key: 'concurrency', label: t('admin.users.columns.concurrency'), sortable: true },
{ key: 'status', label: t('admin.users.columns.status'), sortable: true },
{ key: 'created_at', label: t('admin.users.columns.created'), sortable: true },
{ key: 'actions', label: t('admin.users.columns.actions'), sortable: false }
])
// Filter options
const roleOptions = computed(() => [
{ value: '', label: t('admin.users.allRoles') },
{ value: 'admin', label: t('admin.users.admin') },
{ value: 'user', label: t('admin.users.user') }
])
const statusOptions = computed(() => [
{ value: '', label: t('admin.users.allStatus') },
{ value: 'active', label: t('common.active') },
{ value: 'disabled', label: t('admin.users.disabled') }
])
const users = ref<User[]>([])
const loading = ref(false)
const searchQuery = ref('')
const filters = reactive({
role: '',
status: ''
})
const usageStats = ref<Record<string, BatchUserUsageStats>>({})
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 showApiKeysModal = ref(false)
const submitting = ref(false)
const editingUser = ref<User | null>(null)
const deletingUser = ref<User | null>(null)
const viewingUser = ref<User | null>(null)
const userApiKeys = ref<ApiKey[]>([])
const loadingApiKeys = ref(false)
const passwordCopied = ref(false)
// Allowed groups modal state
const showAllowedGroupsModal = ref(false)
const allowedGroupsUser = ref<User | null>(null)
const standardGroups = ref<Group[]>([])
const selectedGroupIds = ref<number[]>([])
const loadingGroups = ref(false)
const savingAllowedGroups = ref(false)
const createForm = reactive({
email: '',
password: '',
balance: 0,
concurrency: 1
})
const editForm = reactive({
email: '',
password: '',
balance: 0,
concurrency: 1
})
const editPasswordCopied = ref(false)
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
const generateRandomPasswordStr = () => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*'
let password = ''
for (let i = 0; i < 16; i++) {
password += chars.charAt(Math.floor(Math.random() * chars.length))
}
return password
}
const generateRandomPassword = () => {
createForm.password = generateRandomPasswordStr()
}
const generateEditPassword = () => {
editForm.password = generateRandomPasswordStr()
}
const copyPassword = async () => {
if (!createForm.password) return
try {
await navigator.clipboard.writeText(createForm.password)
passwordCopied.value = true
setTimeout(() => {
passwordCopied.value = false
}, 2000)
} catch (error) {
appStore.showError(t('common.copyFailed'))
}
}
const copyEditPassword = async () => {
if (!editForm.password) return
try {
await navigator.clipboard.writeText(editForm.password)
editPasswordCopied.value = true
setTimeout(() => {
editPasswordCopied.value = false
}, 2000)
} catch (error) {
appStore.showError(t('common.copyFailed'))
}
}
const loadUsers = async () => {
loading.value = true
try {
const response = await adminAPI.users.list(
pagination.page,
pagination.page_size,
{
role: filters.role as any,
status: filters.status as any,
search: searchQuery.value || undefined
}
)
users.value = response.items
pagination.total = response.total
pagination.pages = response.pages
// Load usage stats for all users in the list
if (response.items.length > 0) {
const userIds = response.items.map(u => u.id)
try {
const usageResponse = await adminAPI.dashboard.getBatchUsersUsage(userIds)
usageStats.value = usageResponse.stats
} catch (e) {
console.error('Failed to load usage stats:', e)
}
}
} catch (error) {
appStore.showError(t('admin.users.failedToLoad'))
console.error('Error loading users:', error)
} finally {
loading.value = false
}
}
let searchTimeout: ReturnType<typeof setTimeout>
const handleSearch = () => {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
pagination.page = 1
loadUsers()
}, 300)
}
const handlePageChange = (page: number) => {
pagination.page = page
loadUsers()
}
const closeCreateModal = () => {
showCreateModal.value = false
createForm.email = ''
createForm.password = ''
createForm.balance = 0
createForm.concurrency = 1
passwordCopied.value = false
}
const handleCreateUser = async () => {
submitting.value = true
try {
await adminAPI.users.create(createForm)
appStore.showSuccess(t('admin.users.userCreated'))
closeCreateModal()
loadUsers()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.users.failedToCreate'))
console.error('Error creating user:', error)
} finally {
submitting.value = false
}
}
const handleEdit = (user: User) => {
editingUser.value = user
editForm.email = user.email
editForm.password = ''
editForm.balance = user.balance
editForm.concurrency = user.concurrency
editPasswordCopied.value = false
showEditModal.value = true
}
const closeEditModal = () => {
showEditModal.value = false
editingUser.value = null
editForm.password = ''
editPasswordCopied.value = false
}
const handleUpdateUser = async () => {
if (!editingUser.value) return
submitting.value = true
try {
// Build update data - only include password if not empty
const updateData: Record<string, any> = {
email: editForm.email,
balance: editForm.balance,
concurrency: editForm.concurrency
}
if (editForm.password.trim()) {
updateData.password = editForm.password.trim()
}
await adminAPI.users.update(editingUser.value.id, updateData)
appStore.showSuccess(t('admin.users.userUpdated'))
closeEditModal()
loadUsers()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.users.failedToUpdate'))
console.error('Error updating user:', error)
} finally {
submitting.value = false
}
}
const handleToggleStatus = async (user: User) => {
const newStatus = user.status === 'active' ? 'disabled' : 'active'
try {
await adminAPI.users.toggleStatus(user.id, newStatus)
appStore.showSuccess(newStatus === 'active' ? t('admin.users.userEnabled') : t('admin.users.userDisabled'))
loadUsers()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.users.failedToToggle'))
console.error('Error toggling user status:', error)
}
}
const handleViewApiKeys = async (user: User) => {
viewingUser.value = user
showApiKeysModal.value = true
loadingApiKeys.value = true
userApiKeys.value = []
try {
const response = await adminAPI.users.getUserApiKeys(user.id)
userApiKeys.value = response.items || []
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.users.failedToLoadApiKeys'))
console.error('Error loading user API keys:', error)
} finally {
loadingApiKeys.value = false
}
}
const closeApiKeysModal = () => {
showApiKeysModal.value = false
viewingUser.value = null
userApiKeys.value = []
}
// Allowed Groups functions
const handleAllowedGroups = async (user: User) => {
allowedGroupsUser.value = user
showAllowedGroupsModal.value = true
loadingGroups.value = true
standardGroups.value = []
selectedGroupIds.value = user.allowed_groups ? [...user.allowed_groups] : []
try {
const allGroups = await adminAPI.groups.getAll()
// Only show standard type groups (subscription type groups are managed in /admin/subscriptions)
standardGroups.value = allGroups.filter(g => g.subscription_type === 'standard' && g.status === 'active')
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.users.failedToLoadGroups'))
console.error('Error loading groups:', error)
} finally {
loadingGroups.value = false
}
}
const closeAllowedGroupsModal = () => {
showAllowedGroupsModal.value = false
allowedGroupsUser.value = null
standardGroups.value = []
selectedGroupIds.value = []
}
const handleSaveAllowedGroups = async () => {
if (!allowedGroupsUser.value) return
savingAllowedGroups.value = true
try {
// null means allow all non-exclusive groups, empty array also means allow all
const allowedGroups = selectedGroupIds.value.length > 0 ? selectedGroupIds.value : null
await adminAPI.users.update(allowedGroupsUser.value.id, { allowed_groups: allowedGroups })
appStore.showSuccess(t('admin.users.allowedGroupsUpdated'))
closeAllowedGroupsModal()
loadUsers()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.users.failedToUpdateAllowedGroups'))
console.error('Error updating allowed groups:', error)
} finally {
savingAllowedGroups.value = false
}
}
const handleDelete = (user: User) => {
deletingUser.value = user
showDeleteDialog.value = true
}
const confirmDelete = async () => {
if (!deletingUser.value) return
try {
await adminAPI.users.delete(deletingUser.value.id)
appStore.showSuccess(t('admin.users.userDeleted'))
showDeleteDialog.value = false
deletingUser.value = null
loadUsers()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.users.failedToDelete'))
console.error('Error deleting user:', error)
}
}
onMounted(() => {
loadUsers()
})
</script>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment