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

First commit

parent 569f4882
/**
* System API endpoints for admin operations
*/
import { apiClient } from '../client';
export interface ReleaseInfo {
name: string;
body: string;
published_at: string;
html_url: string;
}
export interface VersionInfo {
current_version: string;
latest_version: string;
has_update: boolean;
release_info?: ReleaseInfo;
cached: boolean;
warning?: string;
build_type: string; // "source" for manual builds, "release" for CI builds
}
/**
* Get current version
*/
export async function getVersion(): Promise<{ version: string }> {
const { data } = await apiClient.get<{ version: string }>('/admin/system/version');
return data;
}
/**
* Check for updates
* @param force - Force refresh from GitHub API
*/
export async function checkUpdates(force = false): Promise<VersionInfo> {
const { data } = await apiClient.get<VersionInfo>('/admin/system/check-updates', {
params: force ? { force: 'true' } : undefined,
});
return data;
}
export const systemAPI = {
getVersion,
checkUpdates,
};
export default systemAPI;
/**
* Admin Usage API endpoints
* Handles admin-level usage logs and statistics retrieval
*/
import { apiClient } from '../client';
import type {
UsageLog,
UsageQueryParams,
PaginatedResponse,
} from '@/types';
// ==================== Types ====================
export interface AdminUsageStatsResponse {
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;
}
export interface SimpleUser {
id: number;
email: string;
}
export interface SimpleApiKey {
id: number;
name: string;
user_id: number;
}
export interface AdminUsageQueryParams extends UsageQueryParams {
user_id?: number;
}
// ==================== API Functions ====================
/**
* List all usage logs with optional filters (admin only)
* @param params - Query parameters for filtering and pagination
* @returns Paginated list of usage logs
*/
export async function list(params: AdminUsageQueryParams): Promise<PaginatedResponse<UsageLog>> {
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/admin/usage', {
params,
});
return data;
}
/**
* Get usage statistics with optional filters (admin only)
* @param params - Query parameters (user_id, api_key_id, period/date range)
* @returns Usage statistics
*/
export async function getStats(params: {
user_id?: number;
api_key_id?: number;
period?: string;
start_date?: string;
end_date?: string;
}): Promise<AdminUsageStatsResponse> {
const { data } = await apiClient.get<AdminUsageStatsResponse>('/admin/usage/stats', {
params,
});
return data;
}
/**
* Search users by email keyword (admin only)
* @param keyword - Email keyword to search
* @returns List of matching users (max 30)
*/
export async function searchUsers(keyword: string): Promise<SimpleUser[]> {
const { data } = await apiClient.get<SimpleUser[]>('/admin/usage/search-users', {
params: { q: keyword },
});
return data;
}
/**
* Search API keys by user ID and/or keyword (admin only)
* @param userId - Optional user ID to filter by
* @param keyword - Optional keyword to search in key name
* @returns List of matching API keys (max 30)
*/
export async function searchApiKeys(userId?: number, keyword?: string): Promise<SimpleApiKey[]> {
const params: Record<string, unknown> = {};
if (userId !== undefined) {
params.user_id = userId;
}
if (keyword) {
params.q = keyword;
}
const { data } = await apiClient.get<SimpleApiKey[]>('/admin/usage/search-api-keys', {
params,
});
return data;
}
export const adminUsageAPI = {
list,
getStats,
searchUsers,
searchApiKeys,
};
export default adminUsageAPI;
/**
* Admin Users API endpoints
* Handles user management for administrators
*/
import { apiClient } from '../client';
import type { User, UpdateUserRequest, PaginatedResponse } from '@/types';
/**
* List all users with pagination
* @param page - Page number (default: 1)
* @param pageSize - Items per page (default: 20)
* @param filters - Optional filters (status, role, search)
* @returns Paginated list of users
*/
export async function list(
page: number = 1,
pageSize: number = 20,
filters?: {
status?: 'active' | 'disabled';
role?: 'admin' | 'user';
search?: string;
}
): Promise<PaginatedResponse<User>> {
const { data } = await apiClient.get<PaginatedResponse<User>>('/admin/users', {
params: {
page,
page_size: pageSize,
...filters,
},
});
return data;
}
/**
* Get user by ID
* @param id - User ID
* @returns User details
*/
export async function getById(id: number): Promise<User> {
const { data } = await apiClient.get<User>(`/admin/users/${id}`);
return data;
}
/**
* Create new user
* @param userData - User data (email, password, etc.)
* @returns Created user
*/
export async function create(userData: {
email: string;
password: string;
balance?: number;
concurrency?: number;
allowed_groups?: number[] | null;
}): Promise<User> {
const { data } = await apiClient.post<User>('/admin/users', userData);
return data;
}
/**
* Update user
* @param id - User ID
* @param updates - Fields to update
* @returns Updated user
*/
export async function update(id: number, updates: UpdateUserRequest): Promise<User> {
const { data } = await apiClient.put<User>(`/admin/users/${id}`, updates);
return data;
}
/**
* Delete user
* @param id - User ID
* @returns Success confirmation
*/
export async function deleteUser(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(`/admin/users/${id}`);
return data;
}
/**
* Update user balance
* @param id - User ID
* @param balance - New balance
* @param operation - Operation type ('set', 'add', 'subtract')
* @returns Updated user
*/
export async function updateBalance(
id: number,
balance: number,
operation: 'set' | 'add' | 'subtract' = 'set'
): Promise<User> {
const { data } = await apiClient.post<User>(`/admin/users/${id}/balance`, {
balance,
operation,
});
return data;
}
/**
* Update user concurrency
* @param id - User ID
* @param concurrency - New concurrency limit
* @returns Updated user
*/
export async function updateConcurrency(id: number, concurrency: number): Promise<User> {
return update(id, { concurrency });
}
/**
* Toggle user status
* @param id - User ID
* @param status - New status
* @returns Updated user
*/
export async function toggleStatus(id: number, status: 'active' | 'disabled'): Promise<User> {
return update(id, { status });
}
/**
* Get user's API keys
* @param id - User ID
* @returns List of user's API keys
*/
export async function getUserApiKeys(id: number): Promise<PaginatedResponse<any>> {
const { data } = await apiClient.get<PaginatedResponse<any>>(`/admin/users/${id}/api-keys`);
return data;
}
/**
* Get user's usage statistics
* @param id - User ID
* @param period - Time period
* @returns User usage statistics
*/
export async function getUserUsageStats(
id: number,
period: string = 'month'
): Promise<{
total_requests: number;
total_cost: number;
total_tokens: number;
}> {
const { data } = await apiClient.get<{
total_requests: number;
total_cost: number;
total_tokens: number;
}>(`/admin/users/${id}/usage`, {
params: { period },
});
return data;
}
export const usersAPI = {
list,
getById,
create,
update,
delete: deleteUser,
updateBalance,
updateConcurrency,
toggleStatus,
getUserApiKeys,
getUserUsageStats,
};
export default usersAPI;
/**
* Authentication API endpoints
* Handles user login, registration, and logout operations
*/
import { apiClient } from './client';
import type { LoginRequest, RegisterRequest, AuthResponse, User, SendVerifyCodeRequest, SendVerifyCodeResponse, PublicSettings } from '@/types';
/**
* Store authentication token in localStorage
*/
export function setAuthToken(token: string): void {
localStorage.setItem('auth_token', token);
}
/**
* Get authentication token from localStorage
*/
export function getAuthToken(): string | null {
return localStorage.getItem('auth_token');
}
/**
* Clear authentication token from localStorage
*/
export function clearAuthToken(): void {
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
}
/**
* User login
* @param credentials - Username and password
* @returns Authentication response with token and user data
*/
export async function login(credentials: LoginRequest): Promise<AuthResponse> {
const { data } = await apiClient.post<AuthResponse>('/auth/login', credentials);
// Store token and user data
setAuthToken(data.access_token);
localStorage.setItem('auth_user', JSON.stringify(data.user));
return data;
}
/**
* User registration
* @param userData - Registration data (username, email, password)
* @returns Authentication response with token and user data
*/
export async function register(userData: RegisterRequest): Promise<AuthResponse> {
const { data } = await apiClient.post<AuthResponse>('/auth/register', userData);
// Store token and user data
setAuthToken(data.access_token);
localStorage.setItem('auth_user', JSON.stringify(data.user));
return data;
}
/**
* Get current authenticated user
* @returns User profile data
*/
export async function getCurrentUser(): Promise<User> {
const { data } = await apiClient.get<User>('/auth/me');
return data;
}
/**
* User logout
* Clears authentication token and user data from localStorage
*/
export function logout(): void {
clearAuthToken();
// Optionally redirect to login page
// window.location.href = '/login';
}
/**
* Check if user is authenticated
* @returns True if user has valid token
*/
export function isAuthenticated(): boolean {
return getAuthToken() !== null;
}
/**
* Get public settings (no auth required)
* @returns Public settings including registration and Turnstile config
*/
export async function getPublicSettings(): Promise<PublicSettings> {
const { data } = await apiClient.get<PublicSettings>('/settings/public');
return data;
}
/**
* Send verification code to email
* @param request - Email and optional Turnstile token
* @returns Response with countdown seconds
*/
export async function sendVerifyCode(request: SendVerifyCodeRequest): Promise<SendVerifyCodeResponse> {
const { data } = await apiClient.post<SendVerifyCodeResponse>('/auth/send-verify-code', request);
return data;
}
export const authAPI = {
login,
register,
getCurrentUser,
logout,
isAuthenticated,
setAuthToken,
getAuthToken,
clearAuthToken,
getPublicSettings,
sendVerifyCode,
};
export default authAPI;
/**
* Axios HTTP Client Configuration
* Base client with interceptors for authentication and error handling
*/
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
import type { ApiResponse } from '@/types';
// ==================== Axios Instance Configuration ====================
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1';
export const apiClient: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
// ==================== Request Interceptor ====================
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// Attach token from localStorage
const token = localStorage.getItem('auth_token');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// ==================== Response Interceptor ====================
apiClient.interceptors.response.use(
(response) => {
// Unwrap standard API response format { code, message, data }
const apiResponse = response.data as ApiResponse<unknown>;
if (apiResponse && typeof apiResponse === 'object' && 'code' in apiResponse) {
if (apiResponse.code === 0) {
// Success - return the data portion
response.data = apiResponse.data;
} else {
// API error
return Promise.reject({
status: response.status,
code: apiResponse.code,
message: apiResponse.message || 'Unknown error',
});
}
}
return response;
},
(error: AxiosError<ApiResponse<unknown>>) => {
// Handle common errors
if (error.response) {
const { status, data } = error.response;
// 401: Unauthorized - clear token and redirect to login
if (status === 401) {
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
// Only redirect if not already on login page
if (!window.location.pathname.includes('/login')) {
window.location.href = '/login';
}
}
// Return structured error
return Promise.reject({
status,
code: data?.code,
message: data?.message || error.message,
});
}
// Network error
return Promise.reject({
status: 0,
message: 'Network error. Please check your connection.',
});
}
);
export default apiClient;
/**
* User Groups API endpoints (non-admin)
* Handles group-related operations for regular users
*/
import { apiClient } from './client';
import type { Group } from '@/types';
/**
* Get available groups that the current user can bind to API keys
* This returns groups based on user's permissions:
* - Standard groups: public (non-exclusive) or explicitly allowed
* - Subscription groups: user has active subscription
* @returns List of available groups
*/
export async function getAvailable(): Promise<Group[]> {
const { data } = await apiClient.get<Group[]>('/groups/available');
return data;
}
export const userGroupsAPI = {
getAvailable,
};
export default userGroupsAPI;
/**
* API Client for Sub2API Backend
* Central export point for all API modules
*/
// Re-export the HTTP client
export { apiClient } from './client';
// Auth API
export { authAPI } from './auth';
// User APIs
export { keysAPI } from './keys';
export { usageAPI } from './usage';
export { userAPI } from './user';
export { redeemAPI, type RedeemHistoryItem } from './redeem';
export { userGroupsAPI } from './groups';
// Admin APIs
export { adminAPI } from './admin';
// Default export
export { default } from './client';
/**
* API Keys management endpoints
* Handles CRUD operations for user API keys
*/
import { apiClient } from './client';
import type {
ApiKey,
CreateApiKeyRequest,
UpdateApiKeyRequest,
PaginatedResponse,
} from '@/types';
/**
* List all API keys for current user
* @param page - Page number (default: 1)
* @param pageSize - Items per page (default: 10)
* @returns Paginated list of API keys
*/
export async function list(page: number = 1, pageSize: number = 10): Promise<PaginatedResponse<ApiKey>> {
const { data } = await apiClient.get<PaginatedResponse<ApiKey>>('/keys', {
params: { page, page_size: pageSize },
});
return data;
}
/**
* Get API key by ID
* @param id - API key ID
* @returns API key details
*/
export async function getById(id: number): Promise<ApiKey> {
const { data } = await apiClient.get<ApiKey>(`/keys/${id}`);
return data;
}
/**
* Create new API key
* @param name - Key name
* @param groupId - Optional group ID
* @param customKey - Optional custom key value
* @returns Created API key
*/
export async function create(name: string, groupId?: number | null, customKey?: string): Promise<ApiKey> {
const payload: CreateApiKeyRequest = { name };
if (groupId !== undefined) {
payload.group_id = groupId;
}
if (customKey) {
payload.custom_key = customKey;
}
const { data } = await apiClient.post<ApiKey>('/keys', payload);
return data;
}
/**
* Update API key
* @param id - API key ID
* @param updates - Fields to update
* @returns Updated API key
*/
export async function update(id: number, updates: UpdateApiKeyRequest): Promise<ApiKey> {
const { data } = await apiClient.put<ApiKey>(`/keys/${id}`, updates);
return data;
}
/**
* Delete API key
* @param id - API key ID
* @returns Success confirmation
*/
export async function deleteKey(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(`/keys/${id}`);
return data;
}
/**
* Toggle API key status (active/inactive)
* @param id - API key ID
* @param status - New status
* @returns Updated API key
*/
export async function toggleStatus(
id: number,
status: 'active' | 'inactive'
): Promise<ApiKey> {
return update(id, { status });
}
export const keysAPI = {
list,
getById,
create,
update,
delete: deleteKey,
toggleStatus,
};
export default keysAPI;
/**
* Redeem code API endpoints
* Handles redeem code redemption for users
*/
import { apiClient } from './client';
import type { RedeemCodeRequest } from '@/types';
export interface RedeemHistoryItem {
id: number;
code: string;
type: string;
value: number;
status: string;
used_at: string;
created_at: string;
// 订阅类型专用字段
group_id?: number;
validity_days?: number;
group?: {
id: number;
name: string;
};
}
/**
* Redeem a code
* @param code - Redeem code string
* @returns Redemption result with updated balance or concurrency
*/
export async function redeem(code: string): Promise<{
message: string;
type: string;
value: number;
new_balance?: number;
new_concurrency?: number;
}> {
const payload: RedeemCodeRequest = { code };
const { data } = await apiClient.post<{
message: string;
type: string;
value: number;
new_balance?: number;
new_concurrency?: number;
}>('/redeem', payload);
return data;
}
/**
* Get user's redemption history
* @returns List of redeemed codes
*/
export async function getHistory(): Promise<RedeemHistoryItem[]> {
const { data } = await apiClient.get<RedeemHistoryItem[]>('/redeem/history');
return data;
}
export const redeemAPI = {
redeem,
getHistory,
};
export default redeemAPI;
/**
* Setup API endpoints
*/
import axios from 'axios';
// Create a separate client for setup endpoints (not under /api/v1)
const setupClient = axios.create({
baseURL: '',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
export interface SetupStatus {
needs_setup: boolean;
step: string;
}
export interface DatabaseConfig {
host: string;
port: number;
user: string;
password: string;
dbname: string;
sslmode: string;
}
export interface RedisConfig {
host: string;
port: number;
password: string;
db: number;
}
export interface AdminConfig {
email: string;
password: string;
}
export interface ServerConfig {
host: string;
port: number;
mode: string;
}
export interface InstallRequest {
database: DatabaseConfig;
redis: RedisConfig;
admin: AdminConfig;
server: ServerConfig;
}
export interface InstallResponse {
message: string;
restart: boolean;
}
/**
* Get setup status
*/
export async function getSetupStatus(): Promise<SetupStatus> {
const response = await setupClient.get('/setup/status');
return response.data.data;
}
/**
* Test database connection
*/
export async function testDatabase(config: DatabaseConfig): Promise<void> {
await setupClient.post('/setup/test-db', config);
}
/**
* Test Redis connection
*/
export async function testRedis(config: RedisConfig): Promise<void> {
await setupClient.post('/setup/test-redis', config);
}
/**
* Perform installation
*/
export async function install(config: InstallRequest): Promise<InstallResponse> {
const response = await setupClient.post('/setup/install', config);
return response.data.data;
}
/**
* User Subscription API
* API for regular users to view their own subscriptions and progress
*/
import { apiClient } from './client';
import type { UserSubscription, SubscriptionProgress } from '@/types';
/**
* Subscription summary for user dashboard
*/
export interface SubscriptionSummary {
active_count: number;
subscriptions: Array<{
id: number;
group_name: string;
status: string;
daily_progress: number | null;
weekly_progress: number | null;
monthly_progress: number | null;
expires_at: string | null;
days_remaining: number | null;
}>;
}
/**
* Get list of current user's subscriptions
*/
export async function getMySubscriptions(): Promise<UserSubscription[]> {
const response = await apiClient.get<UserSubscription[]>('/subscriptions');
return response.data;
}
/**
* Get current user's active subscriptions
*/
export async function getActiveSubscriptions(): Promise<UserSubscription[]> {
const response = await apiClient.get<UserSubscription[]>('/subscriptions/active');
return response.data;
}
/**
* Get progress for all user's active subscriptions
*/
export async function getSubscriptionsProgress(): Promise<SubscriptionProgress[]> {
const response = await apiClient.get<SubscriptionProgress[]>('/subscriptions/progress');
return response.data;
}
/**
* Get subscription summary for dashboard display
*/
export async function getSubscriptionSummary(): Promise<SubscriptionSummary> {
const response = await apiClient.get<SubscriptionSummary>('/subscriptions/summary');
return response.data;
}
/**
* Get progress for a specific subscription
*/
export async function getSubscriptionProgress(subscriptionId: number): Promise<SubscriptionProgress> {
const response = await apiClient.get<SubscriptionProgress>(`/subscriptions/${subscriptionId}/progress`);
return response.data;
}
export default {
getMySubscriptions,
getActiveSubscriptions,
getSubscriptionsProgress,
getSubscriptionSummary,
getSubscriptionProgress,
};
/**
* Usage tracking API endpoints
* Handles usage logs and statistics retrieval
*/
import { apiClient } from './client';
import type {
UsageLog,
UsageQueryParams,
UsageStatsResponse,
PaginatedResponse,
TrendDataPoint,
ModelStat,
} from '@/types';
// ==================== Dashboard Types ====================
export interface UserDashboardStats {
total_api_keys: number;
active_api_keys: number;
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; // 实际扣除
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;
}
export interface TrendParams {
start_date?: string;
end_date?: string;
granularity?: 'day' | 'hour';
}
export interface TrendResponse {
trend: TrendDataPoint[];
start_date: string;
end_date: string;
granularity: string;
}
export interface ModelStatsResponse {
models: ModelStat[];
start_date: string;
end_date: string;
}
/**
* List usage logs with optional filters
* @param page - Page number (default: 1)
* @param pageSize - Items per page (default: 20)
* @param apiKeyId - Filter by API key ID
* @returns Paginated list of usage logs
*/
export async function list(
page: number = 1,
pageSize: number = 20,
apiKeyId?: number
): Promise<PaginatedResponse<UsageLog>> {
const params: UsageQueryParams = {
page,
page_size: pageSize,
};
if (apiKeyId !== undefined) {
params.api_key_id = apiKeyId;
}
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/usage', {
params,
});
return data;
}
/**
* Get usage logs with advanced query parameters
* @param params - Query parameters for filtering and pagination
* @returns Paginated list of usage logs
*/
export async function query(params: UsageQueryParams): Promise<PaginatedResponse<UsageLog>> {
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/usage', {
params,
});
return data;
}
/**
* Get usage statistics for a specific period
* @param period - Time period ('today', 'week', 'month', 'year')
* @param apiKeyId - Optional API key ID filter
* @returns Usage statistics
*/
export async function getStats(
period: string = 'today',
apiKeyId?: number
): Promise<UsageStatsResponse> {
const params: Record<string, unknown> = { period };
if (apiKeyId !== undefined) {
params.api_key_id = apiKeyId;
}
const { data } = await apiClient.get<UsageStatsResponse>('/usage/stats', {
params,
});
return data;
}
/**
* Get usage statistics for a date range
* @param startDate - Start date (YYYY-MM-DD format)
* @param endDate - End date (YYYY-MM-DD format)
* @param apiKeyId - Optional API key ID filter
* @returns Usage statistics
*/
export async function getStatsByDateRange(
startDate: string,
endDate: string,
apiKeyId?: number
): Promise<UsageStatsResponse> {
const params: Record<string, unknown> = {
start_date: startDate,
end_date: endDate,
};
if (apiKeyId !== undefined) {
params.api_key_id = apiKeyId;
}
const { data } = await apiClient.get<UsageStatsResponse>('/usage/stats', {
params,
});
return data;
}
/**
* Get usage by date range
* @param startDate - Start date (ISO format)
* @param endDate - End date (ISO format)
* @param apiKeyId - Optional API key ID filter
* @returns Usage logs within date range
*/
export async function getByDateRange(
startDate: string,
endDate: string,
apiKeyId?: number
): Promise<PaginatedResponse<UsageLog>> {
const params: UsageQueryParams = {
start_date: startDate,
end_date: endDate,
page: 1,
page_size: 100,
};
if (apiKeyId !== undefined) {
params.api_key_id = apiKeyId;
}
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/usage', {
params,
});
return data;
}
/**
* Get detailed usage log by ID
* @param id - Usage log ID
* @returns Usage log details
*/
export async function getById(id: number): Promise<UsageLog> {
const { data } = await apiClient.get<UsageLog>(`/usage/${id}`);
return data;
}
// ==================== Dashboard API ====================
/**
* Get user dashboard statistics
* @returns Dashboard statistics for current user
*/
export async function getDashboardStats(): Promise<UserDashboardStats> {
const { data } = await apiClient.get<UserDashboardStats>('/usage/dashboard/stats');
return data;
}
/**
* Get user usage trend data
* @param params - Query parameters for filtering
* @returns Usage trend data for current user
*/
export async function getDashboardTrend(params?: TrendParams): Promise<TrendResponse> {
const { data } = await apiClient.get<TrendResponse>('/usage/dashboard/trend', { params });
return data;
}
/**
* Get user model usage statistics
* @param params - Query parameters for filtering
* @returns Model usage statistics for current user
*/
export async function getDashboardModels(params?: { start_date?: string; end_date?: string }): Promise<ModelStatsResponse> {
const { data } = await apiClient.get<ModelStatsResponse>('/usage/dashboard/models', { params });
return data;
}
export interface BatchApiKeyUsageStats {
api_key_id: number;
today_actual_cost: number;
total_actual_cost: number;
}
export interface BatchApiKeysUsageResponse {
stats: Record<string, BatchApiKeyUsageStats>;
}
/**
* Get batch usage stats for user's own API keys
* @param apiKeyIds - Array of API key IDs
* @returns Usage stats map keyed by API key ID
*/
export async function getDashboardApiKeysUsage(apiKeyIds: number[]): Promise<BatchApiKeysUsageResponse> {
const { data } = await apiClient.post<BatchApiKeysUsageResponse>('/usage/dashboard/api-keys-usage', {
api_key_ids: apiKeyIds,
});
return data;
}
export const usageAPI = {
list,
query,
getStats,
getStatsByDateRange,
getByDateRange,
getById,
// Dashboard
getDashboardStats,
getDashboardTrend,
getDashboardModels,
getDashboardApiKeysUsage,
};
export default usageAPI;
/**
* User API endpoints
* Handles user profile management and password changes
*/
import { apiClient } from './client';
import type { User, ChangePasswordRequest } from '@/types';
/**
* Get current user profile
* @returns User profile data
*/
export async function getProfile(): Promise<User> {
const { data } = await apiClient.get<User>('/users/me');
return data;
}
/**
* Change current user password
* @param passwords - Old and new password
* @returns Success message
*/
export async function changePassword(
oldPassword: string,
newPassword: string
): Promise<{ message: string }> {
const payload: ChangePasswordRequest = {
old_password: oldPassword,
new_password: newPassword,
};
const { data } = await apiClient.post<{ message: string }>('/users/me/password', payload);
return data;
}
export const userAPI = {
getProfile,
changePassword,
};
export default userAPI;
<template>
<div v-if="siteKey" class="turnstile-wrapper">
<div ref="containerRef" class="turnstile-container"></div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
interface TurnstileRenderOptions {
sitekey: string;
callback: (token: string) => void;
'expired-callback'?: () => void;
'error-callback'?: () => void;
theme?: 'light' | 'dark' | 'auto';
size?: 'normal' | 'compact' | 'flexible';
}
interface TurnstileAPI {
render: (container: HTMLElement, options: TurnstileRenderOptions) => string;
reset: (widgetId?: string) => void;
remove: (widgetId?: string) => void;
}
declare global {
interface Window {
turnstile?: TurnstileAPI;
onTurnstileLoad?: () => void;
}
}
const props = withDefaults(defineProps<{
siteKey: string;
theme?: 'light' | 'dark' | 'auto';
size?: 'normal' | 'compact' | 'flexible';
}>(), {
theme: 'auto',
size: 'flexible',
});
const emit = defineEmits<{
(e: 'verify', token: string): void;
(e: 'expire'): void;
(e: 'error'): void;
}>();
const containerRef = ref<HTMLElement | null>(null);
const widgetId = ref<string | null>(null);
const scriptLoaded = ref(false);
const loadScript = (): Promise<void> => {
return new Promise((resolve, reject) => {
if (window.turnstile) {
scriptLoaded.value = true;
resolve();
return;
}
// Check if script is already loading
const existingScript = document.querySelector('script[src*="turnstile"]');
if (existingScript) {
window.onTurnstileLoad = () => {
scriptLoaded.value = true;
resolve();
};
return;
}
const script = document.createElement('script');
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad';
script.async = true;
script.defer = true;
window.onTurnstileLoad = () => {
scriptLoaded.value = true;
resolve();
};
script.onerror = () => {
reject(new Error('Failed to load Turnstile script'));
};
document.head.appendChild(script);
});
};
const renderWidget = () => {
if (!window.turnstile || !containerRef.value || !props.siteKey) {
return;
}
// Remove existing widget if any
if (widgetId.value) {
try {
window.turnstile.remove(widgetId.value);
} catch {
// Ignore errors when removing
}
widgetId.value = null;
}
// Clear container
containerRef.value.innerHTML = '';
widgetId.value = window.turnstile.render(containerRef.value, {
sitekey: props.siteKey,
callback: (token: string) => {
emit('verify', token);
},
'expired-callback': () => {
emit('expire');
},
'error-callback': () => {
emit('error');
},
theme: props.theme,
size: props.size,
});
};
const reset = () => {
if (window.turnstile && widgetId.value) {
window.turnstile.reset(widgetId.value);
}
};
// Expose reset method to parent
defineExpose({ reset });
onMounted(async () => {
if (!props.siteKey) {
return;
}
try {
await loadScript();
renderWidget();
} catch (error) {
console.error('Failed to initialize Turnstile:', error);
emit('error');
}
});
onUnmounted(() => {
if (window.turnstile && widgetId.value) {
try {
window.turnstile.remove(widgetId.value);
} catch {
// Ignore errors when removing
}
}
});
// Re-render when siteKey changes
watch(() => props.siteKey, (newKey) => {
if (newKey && scriptLoaded.value) {
renderWidget();
}
});
</script>
<style scoped>
.turnstile-wrapper {
width: 100%;
}
.turnstile-container {
width: 100%;
min-height: 65px;
}
/* Make the Turnstile iframe fill the container width */
.turnstile-container :deep(iframe) {
width: 100% !important;
}
</style>
<template>
<div class="flex items-center gap-2">
<!-- Main Status Badge -->
<span
:class="[
'badge text-xs',
statusClass
]"
>
{{ statusText }}
</span>
<!-- Error Info Indicator -->
<div v-if="hasError && account.error_message" class="relative group/error">
<svg class="w-4 h-4 text-red-500 dark:text-red-400 cursor-help hover:text-red-600 dark:hover:text-red-300 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
</svg>
<!-- Tooltip - 向下显示 -->
<div class="absolute top-full left-0 mt-1.5 px-3 py-2 bg-gray-800 dark:bg-gray-900 text-white text-xs rounded-lg shadow-xl opacity-0 invisible group-hover/error:opacity-100 group-hover/error:visible transition-all duration-200 z-[100] min-w-[200px] max-w-[300px]">
<div class="text-gray-300 break-words whitespace-pre-wrap leading-relaxed">{{ account.error_message }}</div>
<!-- 上方小三角 -->
<div class="absolute bottom-full left-3 border-[6px] border-transparent border-b-gray-800 dark:border-b-gray-900"></div>
</div>
</div>
<!-- Rate Limit Indicator (429) -->
<div v-if="isRateLimited" class="relative group">
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
429
</span>
<!-- Tooltip -->
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50">
Rate limited until {{ formatTime(account.rate_limit_reset_at) }}
<div class="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"></div>
</div>
</div>
<!-- Overload Indicator (529) -->
<div v-if="isOverloaded" class="relative group">
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
529
</span>
<!-- Tooltip -->
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50">
Overloaded until {{ formatTime(account.overload_until) }}
<div class="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Account } from '@/types'
const props = defineProps<{
account: Account
}>()
// Computed: is rate limited (429)
const isRateLimited = computed(() => {
if (!props.account.rate_limit_reset_at) return false
return new Date(props.account.rate_limit_reset_at) > new Date()
})
// Computed: is overloaded (529)
const isOverloaded = computed(() => {
if (!props.account.overload_until) return false
return new Date(props.account.overload_until) > new Date()
})
// Computed: has error status
const hasError = computed(() => {
return props.account.status === 'error'
})
// Computed: status badge class
const statusClass = computed(() => {
if (!props.account.schedulable || isRateLimited.value || isOverloaded.value) {
return 'badge-gray'
}
switch (props.account.status) {
case 'active':
return 'badge-success'
case 'inactive':
return 'badge-gray'
case 'error':
return 'badge-danger'
default:
return 'badge-gray'
}
})
// Computed: status text
const statusText = computed(() => {
if (!props.account.schedulable) {
return 'Paused'
}
if (isRateLimited.value || isOverloaded.value) {
return 'Limited'
}
return props.account.status
})
// Format time helper
const formatTime = (dateStr: string | null | undefined) => {
if (!dateStr) return 'N/A'
const date = new Date(dateStr)
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
})
}
</script>
<template>
<Modal
:show="show"
:title="t('admin.accounts.testAccountConnection')"
size="md"
@close="handleClose"
>
<div class="space-y-4">
<!-- Account Info Card -->
<div v-if="account" class="flex items-center justify-between p-3 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-dark-700 dark:to-dark-600 rounded-xl border border-gray-200 dark:border-dark-500">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1.5">
<span class="px-1.5 py-0.5 bg-gray-200 dark:bg-dark-500 rounded text-[10px] font-medium uppercase">
{{ account.type }}
</span>
<span>{{ t('admin.accounts.account') }}</span>
</div>
</div>
</div>
<span
:class="[
'px-2.5 py-1 text-xs font-semibold rounded-full',
account.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
]"
>
{{ account.status }}
</span>
</div>
<!-- Terminal Output -->
<div class="relative group">
<div
ref="terminalRef"
class="bg-gray-900 dark:bg-black rounded-xl p-4 min-h-[120px] max-h-[240px] overflow-y-auto font-mono text-sm border border-gray-700 dark:border-gray-800"
>
<!-- Status Line -->
<div v-if="status === 'idle'" class="text-gray-500 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>{{ t('admin.accounts.readyToTest') }}</span>
</div>
<div v-else-if="status === 'connecting'" class="text-yellow-400 flex items-center gap-2">
<svg class="animate-spin w-4 h-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>
<span>{{ t('admin.accounts.connectingToApi') }}</span>
</div>
<!-- Output Lines -->
<div v-for="(line, index) in outputLines" :key="index" :class="line.class">
{{ line.text }}
</div>
<!-- Streaming Content -->
<div v-if="streamingContent" class="text-green-400">
{{ streamingContent }}<span class="animate-pulse">_</span>
</div>
<!-- Result Status -->
<div v-if="status === 'success'" class="text-green-400 mt-3 pt-3 border-t border-gray-700 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ t('admin.accounts.testCompleted') }}</span>
</div>
<div v-else-if="status === 'error'" class="text-red-400 mt-3 pt-3 border-t border-gray-700 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ errorMessage }}</span>
</div>
</div>
<!-- Copy Button -->
<button
v-if="outputLines.length > 0"
@click="copyOutput"
class="absolute top-2 right-2 p-1.5 text-gray-400 hover:text-white bg-gray-800/80 hover:bg-gray-700 rounded-lg transition-all opacity-0 group-hover:opacity-100"
:title="t('admin.accounts.copyOutput')"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
</button>
</div>
<!-- Test Info -->
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 px-1">
<div class="flex items-center gap-3">
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
{{ t('admin.accounts.testModel') }}
</span>
</div>
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
{{ t('admin.accounts.testPrompt') }}
</span>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button
@click="handleClose"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-dark-600 hover:bg-gray-200 dark:hover:bg-dark-500 rounded-lg transition-colors"
:disabled="status === 'connecting'"
>
{{ t('common.close') }}
</button>
<button
@click="startTest"
:disabled="status === 'connecting'"
:class="[
'px-4 py-2 text-sm font-medium rounded-lg transition-all flex items-center gap-2',
status === 'connecting'
? 'bg-primary-400 text-white cursor-not-allowed'
: status === 'success'
? 'bg-green-500 hover:bg-green-600 text-white'
: status === 'error'
? 'bg-orange-500 hover:bg-orange-600 text-white'
: 'bg-primary-500 hover:bg-primary-600 text-white'
]"
>
<svg v-if="status === 'connecting'" 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>
<svg v-else-if="status === 'idle'" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-else class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>
{{ status === 'connecting' ? t('admin.accounts.testing') : status === 'idle' ? t('admin.accounts.startTest') : t('admin.accounts.retry') }}
</span>
</button>
</div>
</template>
</Modal>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import Modal from '@/components/common/Modal.vue'
import type { Account } from '@/types'
const { t } = useI18n()
interface OutputLine {
text: string
class: string
}
const props = defineProps<{
show: boolean
account: Account | null
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
const terminalRef = ref<HTMLElement | null>(null)
const status = ref<'idle' | 'connecting' | 'success' | 'error'>('idle')
const outputLines = ref<OutputLine[]>([])
const streamingContent = ref('')
const errorMessage = ref('')
let eventSource: EventSource | null = null
// Reset state when modal opens
watch(() => props.show, (newVal) => {
if (newVal) {
resetState()
} else {
closeEventSource()
}
})
const resetState = () => {
status.value = 'idle'
outputLines.value = []
streamingContent.value = ''
errorMessage.value = ''
}
const handleClose = () => {
closeEventSource()
emit('close')
}
const closeEventSource = () => {
if (eventSource) {
eventSource.close()
eventSource = null
}
}
const addLine = (text: string, className: string = 'text-gray-300') => {
outputLines.value.push({ text, class: className })
scrollToBottom()
}
const scrollToBottom = async () => {
await nextTick()
if (terminalRef.value) {
terminalRef.value.scrollTop = terminalRef.value.scrollHeight
}
}
const startTest = async () => {
if (!props.account) return
resetState()
status.value = 'connecting'
addLine(t('admin.accounts.startingTestForAccount', { name: props.account.name }), 'text-blue-400')
addLine(t('admin.accounts.testAccountTypeLabel', { type: props.account.type }), 'text-gray-400')
addLine('', 'text-gray-300')
closeEventSource()
try {
// Create EventSource for SSE
const url = `/api/v1/admin/accounts/${props.account.id}/test`
// Use fetch with streaming for SSE since EventSource doesn't support POST
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
'Content-Type': 'application/json'
}
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('No response body')
}
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonStr = line.slice(6).trim()
if (jsonStr) {
try {
const event = JSON.parse(jsonStr)
handleEvent(event)
} catch (e) {
console.error('Failed to parse SSE event:', e)
}
}
}
}
}
} catch (error: any) {
status.value = 'error'
errorMessage.value = error.message || 'Unknown error'
addLine(`Error: ${errorMessage.value}`, 'text-red-400')
}
}
const handleEvent = (event: { type: string; text?: string; model?: string; success?: boolean; error?: string }) => {
switch (event.type) {
case 'test_start':
addLine(t('admin.accounts.connectedToApi'), 'text-green-400')
if (event.model) {
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
}
addLine(t('admin.accounts.sendingTestMessage'), 'text-gray-400')
addLine('', 'text-gray-300')
addLine(t('admin.accounts.response'), 'text-yellow-400')
break
case 'content':
if (event.text) {
streamingContent.value += event.text
scrollToBottom()
}
break
case 'test_complete':
// Move streaming content to output lines
if (streamingContent.value) {
addLine(streamingContent.value, 'text-green-300')
streamingContent.value = ''
}
if (event.success) {
status.value = 'success'
} else {
status.value = 'error'
errorMessage.value = event.error || 'Test failed'
}
break
case 'error':
status.value = 'error'
errorMessage.value = event.error || 'Unknown error'
if (streamingContent.value) {
addLine(streamingContent.value, 'text-green-300')
streamingContent.value = ''
}
break
}
}
const copyOutput = () => {
const text = outputLines.value.map(l => l.text).join('\n')
navigator.clipboard.writeText(text)
}
</script>
<template>
<div>
<!-- Loading state -->
<div v-if="loading" class="space-y-0.5">
<div class="h-3 w-12 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div class="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div class="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
</div>
<!-- Error state -->
<div v-else-if="error" class="text-xs text-red-500">
{{ error }}
</div>
<!-- Stats data -->
<div v-else-if="stats" class="space-y-0.5 text-xs">
<!-- Requests -->
<div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">Req:</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ formatNumber(stats.requests) }}</span>
</div>
<!-- Tokens -->
<div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">Tok:</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ formatTokens(stats.tokens) }}</span>
</div>
<!-- Cost -->
<div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">Cost:</span>
<span class="font-medium text-emerald-600 dark:text-emerald-400">{{ formatCurrency(stats.cost) }}</span>
</div>
</div>
<!-- No data -->
<div v-else class="text-xs text-gray-400">
-
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { adminAPI } from '@/api/admin'
import type { Account, WindowStats } from '@/types'
import { formatNumber, formatCurrency } from '@/utils/format'
const props = defineProps<{
account: Account
}>()
const loading = ref(false)
const error = ref<string | null>(null)
const stats = ref<WindowStats | null>(null)
// Format large token numbers (e.g., 1234567 -> 1.23M)
const formatTokens = (tokens: number): string => {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(2)}M`
} else if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}K`
}
return tokens.toString()
}
const loadStats = async () => {
loading.value = true
error.value = null
try {
stats.value = await adminAPI.accounts.getTodayStats(props.account.id)
} catch (e: any) {
error.value = 'Failed'
console.error('Failed to load today stats:', e)
} finally {
loading.value = false
}
}
onMounted(() => {
loadStats()
})
</script>
<template>
<div v-if="account.type === 'oauth' || account.type === 'setup-token'">
<!-- OAuth accounts: fetch real usage data -->
<template v-if="account.type === 'oauth'">
<!-- Loading state -->
<div v-if="loading" class="space-y-1.5">
<div class="flex items-center gap-1">
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
</div>
<div class="flex items-center gap-1">
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
</div>
<div class="flex items-center gap-1">
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
</div>
</div>
<!-- Error state -->
<div v-else-if="error" class="text-xs text-red-500">
{{ error }}
</div>
<!-- Usage data -->
<div v-else-if="usageInfo" class="space-y-1">
<!-- 5h Window -->
<UsageProgressBar
v-if="usageInfo.five_hour"
label="5h"
:utilization="usageInfo.five_hour.utilization"
:resets-at="usageInfo.five_hour.resets_at"
:window-stats="usageInfo.five_hour.window_stats"
color="indigo"
/>
<!-- 7d Window -->
<UsageProgressBar
v-if="usageInfo.seven_day"
label="7d"
:utilization="usageInfo.seven_day.utilization"
:resets-at="usageInfo.seven_day.resets_at"
color="emerald"
/>
<!-- 7d Sonnet Window -->
<UsageProgressBar
v-if="usageInfo.seven_day_sonnet"
label="7d S"
:utilization="usageInfo.seven_day_sonnet.utilization"
:resets-at="usageInfo.seven_day_sonnet.resets_at"
color="purple"
/>
</div>
<!-- No data yet -->
<div v-else class="text-xs text-gray-400">
-
</div>
</template>
<!-- Setup Token accounts: show time-based window progress -->
<template v-else-if="account.type === 'setup-token'">
<SetupTokenTimeWindow :account="account" />
</template>
</div>
<!-- Non-OAuth accounts -->
<div v-else class="text-xs text-gray-400">
-
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageInfo } from '@/types'
import UsageProgressBar from './UsageProgressBar.vue'
import SetupTokenTimeWindow from './SetupTokenTimeWindow.vue'
const props = defineProps<{
account: Account
}>()
const loading = ref(false)
const error = ref<string | null>(null)
const usageInfo = ref<AccountUsageInfo | null>(null)
const loadUsage = async () => {
// Only fetch usage for OAuth accounts (Setup Token uses local time-based calculation)
if (props.account.type !== 'oauth') return
loading.value = true
error.value = null
try {
usageInfo.value = await adminAPI.accounts.getUsage(props.account.id)
} catch (e: any) {
error.value = 'Failed'
console.error('Failed to load usage:', e)
} finally {
loading.value = false
}
}
onMounted(() => {
loadUsage()
})
</script>
<template>
<Modal
:show="show"
:title="t('admin.accounts.createAccount')"
size="lg"
@close="handleClose"
>
<!-- Step Indicator for OAuth accounts -->
<div v-if="isOAuthFlow" class="mb-6 flex items-center justify-center">
<div class="flex items-center space-x-4">
<div class="flex items-center">
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold',
step >= 1 ? 'bg-primary-500 text-white' : 'bg-gray-200 text-gray-500 dark:bg-dark-600'
]"
>
1
</div>
<span class="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.accounts.oauth.authMethod') }}</span>
</div>
<div class="h-0.5 w-8 bg-gray-300 dark:bg-dark-600" />
<div class="flex items-center">
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold',
step >= 2 ? 'bg-primary-500 text-white' : 'bg-gray-200 text-gray-500 dark:bg-dark-600'
]"
>
2
</div>
<span class="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.accounts.oauth.title') }}</span>
</div>
</div>
</div>
<!-- Step 1: Basic Info -->
<form v-if="step === 1" @submit.prevent="handleSubmit" class="space-y-5">
<div>
<label class="input-label">{{ t('admin.accounts.accountName') }}</label>
<input
v-model="form.name"
type="text"
required
class="input"
:placeholder="t('admin.accounts.enterAccountName')"
/>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
<div class="grid grid-cols-2 gap-3 mt-2">
<label
:class="[
'relative flex cursor-pointer rounded-lg border-2 p-4 transition-all',
accountCategory === 'oauth-based'
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-gray-200 dark:border-dark-600 hover:border-primary-300'
]"
>
<input
v-model="accountCategory"
type="radio"
value="oauth-based"
class="sr-only"
/>
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-orange-500 to-orange-600">
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z" />
</svg>
</div>
<div>
<span class="block text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.accounts.claudeCode') }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.oauthSetupToken') }}</span>
</div>
</div>
<div
v-if="accountCategory === 'oauth-based'"
class="absolute right-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-primary-500"
>
<svg class="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
</label>
<label
:class="[
'relative flex cursor-pointer rounded-lg border-2 p-4 transition-all',
accountCategory === 'apikey'
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-gray-200 dark:border-dark-600 hover:border-primary-300'
]"
>
<input
v-model="accountCategory"
type="radio"
value="apikey"
class="sr-only"
/>
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-purple-500 to-purple-600">
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
</div>
<div>
<span class="block text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.accounts.claudeConsole') }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.apiKey') }}</span>
</div>
</div>
<div
v-if="accountCategory === 'apikey'"
class="absolute right-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-primary-500"
>
<svg class="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
</label>
</div>
</div>
<!-- Add Method (only for OAuth-based type) -->
<div v-if="isOAuthFlow">
<label class="input-label">{{ t('admin.accounts.addMethod') }}</label>
<div class="flex gap-4 mt-2">
<label class="flex cursor-pointer items-center">
<input
v-model="addMethod"
type="radio"
value="oauth"
class="mr-2 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Oauth</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="addMethod"
type="radio"
value="setup-token"
class="mr-2 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.accounts.setupTokenLongLived') }}</span>
</label>
</div>
</div>
<!-- API Key input (only for apikey type) -->
<div v-if="form.type === 'apikey'" class="space-y-4">
<div>
<label class="input-label">{{ t('admin.accounts.baseUrl') }}</label>
<input
v-model="apiKeyBaseUrl"
type="text"
class="input"
placeholder="https://api.anthropic.com"
/>
<p class="input-hint">{{ t('admin.accounts.baseUrlHint') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.apiKeyRequired') }}</label>
<input
v-model="apiKeyValue"
type="password"
required
class="input font-mono"
:placeholder="t('admin.accounts.apiKeyPlaceholder')"
/>
<p class="input-hint">{{ t('admin.accounts.apiKeyHint') }}</p>
</div>
<!-- Model Restriction Section -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
<!-- Mode Toggle -->
<div class="flex gap-2 mb-4">
<button
type="button"
@click="modelRestrictionMode = 'whitelist'"
:class="[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'whitelist'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
<svg class="w-4 h-4 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ t('admin.accounts.modelWhitelist') }}
</button>
<button
type="button"
@click="modelRestrictionMode = 'mapping'"
:class="[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'mapping'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
<svg class="w-4 h-4 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
{{ t('admin.accounts.modelMapping') }}
</button>
</div>
<!-- Whitelist Mode -->
<div v-if="modelRestrictionMode === 'whitelist'">
<div class="mb-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-3">
<p class="text-xs text-blue-700 dark:text-blue-400">
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ t('admin.accounts.selectAllowedModels') }}
</p>
</div>
<!-- Model Checkbox List -->
<div class="grid grid-cols-2 gap-2 mb-3">
<label
v-for="model in commonModels"
:key="model.value"
class="flex cursor-pointer items-center rounded-lg border p-3 transition-all hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700"
:class="allowedModels.includes(model.value) ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-200'"
>
<input
type="checkbox"
:value="model.value"
v-model="allowedModels"
class="mr-2 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ model.label }}</span>
</label>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
<span v-if="allowedModels.length === 0">{{ t('admin.accounts.supportsAllModels') }}</span>
</p>
</div>
<!-- Mapping Mode -->
<div v-else>
<div class="mb-3 rounded-lg bg-purple-50 dark:bg-purple-900/20 p-3">
<p class="text-xs text-purple-700 dark:text-purple-400">
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ t('admin.accounts.mapRequestModels') }}
</p>
</div>
<!-- Model Mapping List -->
<div v-if="modelMappings.length > 0" class="space-y-2 mb-3">
<div
v-for="(mapping, index) in modelMappings"
:key="index"
class="flex items-center gap-2"
>
<input
v-model="mapping.from"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.requestModel')"
/>
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
<input
v-model="mapping.to"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.actualModel')"
/>
<button
type="button"
@click="removeModelMapping(index)"
class="p-2 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
<svg class="w-4 h-4" 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>
</button>
</div>
</div>
<button
type="button"
@click="addModelMapping"
class="w-full rounded-lg border-2 border-dashed border-gray-300 dark:border-dark-500 px-4 py-2 text-gray-600 dark:text-gray-400 transition-colors hover:border-gray-400 hover:text-gray-700 dark:hover:border-dark-400 dark:hover:text-gray-300 mb-3"
>
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{{ t('admin.accounts.addMapping') }}
</button>
<!-- Quick Add Buttons -->
<div class="flex flex-wrap gap-2">
<button
v-for="preset in presetMappings"
:key="preset.label"
type="button"
@click="addPresetMapping(preset.from, preset.to)"
:class="[
'rounded-lg px-3 py-1 text-xs transition-colors',
preset.color
]"
>
+ {{ preset.label }}
</button>
</div>
</div>
</div>
<!-- Custom Error Codes Section -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
<div class="flex items-center justify-between mb-3">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.customErrorCodes') }}</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.accounts.customErrorCodesHint') }}</p>
</div>
<button
type="button"
@click="customErrorCodesEnabled = !customErrorCodesEnabled"
:class="[
'relative inline-flex h-6 w-11 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',
customErrorCodesEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
customErrorCodesEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="customErrorCodesEnabled" class="space-y-3">
<div class="rounded-lg bg-amber-50 dark:bg-amber-900/20 p-3">
<p class="text-xs text-amber-700 dark:text-amber-400">
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ t('admin.accounts.customErrorCodesWarning') }}
</p>
</div>
<!-- Error Code Buttons -->
<div class="flex flex-wrap gap-2">
<button
v-for="code in commonErrorCodes"
:key="code.value"
type="button"
@click="toggleErrorCode(code.value)"
:class="[
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
selectedErrorCodes.includes(code.value)
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 ring-1 ring-red-500'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
{{ code.value }} {{ code.label }}
</button>
</div>
<!-- Manual input -->
<div class="flex items-center gap-2">
<input
v-model="customErrorCodeInput"
type="number"
min="100"
max="599"
class="input flex-1"
:placeholder="t('admin.accounts.enterErrorCode')"
@keyup.enter="addCustomErrorCode"
/>
<button
type="button"
@click="addCustomErrorCode"
class="btn btn-secondary px-3"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
<!-- Selected codes summary -->
<div class="flex flex-wrap gap-1.5">
<span
v-for="code in selectedErrorCodes.sort((a, b) => a - b)"
:key="code"
class="inline-flex items-center gap-1 rounded-full bg-red-100 dark:bg-red-900/30 px-2.5 py-0.5 text-sm font-medium text-red-700 dark:text-red-400"
>
{{ code }}
<button
type="button"
@click="removeErrorCode(code)"
class="hover:text-red-900 dark:hover:text-red-300"
>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
<span v-if="selectedErrorCodes.length === 0" class="text-xs text-gray-400">
{{ t('admin.accounts.noneSelectedUsesDefault') }}
</span>
</div>
</div>
</div>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.proxy') }}</label>
<ProxySelector
v-model="form.proxy_id"
:proxies="proxies"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('admin.accounts.concurrency') }}</label>
<input
v-model.number="form.concurrency"
type="number"
min="1"
class="input"
/>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.priority') }}</label>
<input
v-model.number="form.priority"
type="number"
min="1"
class="input"
/>
<p class="input-hint">{{ t('admin.accounts.priorityHint') }}</p>
</div>
</div>
<!-- Group Selection -->
<GroupSelector
v-model="form.group_ids"
:groups="groups"
/>
<div class="flex justify-end gap-3 pt-4">
<button
@click="handleClose"
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>
{{ isOAuthFlow ? t('common.next') : (submitting ? t('admin.accounts.creating') : t('common.create')) }}
</button>
</div>
</form>
<!-- Step 2: OAuth Authorization -->
<div v-else class="space-y-5">
<OAuthAuthorizationFlow
ref="oauthFlowRef"
:add-method="addMethod"
:auth-url="oauth.authUrl.value"
:session-id="oauth.sessionId.value"
:loading="oauth.loading.value"
:error="oauth.error.value"
:show-help="true"
:show-proxy-warning="!!form.proxy_id"
:allow-multiple="true"
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
/>
<div class="flex justify-between gap-3 pt-4">
<button
type="button"
class="btn btn-secondary"
@click="goBackToBasicInfo"
>
{{ t('common.back') }}
</button>
<button
v-if="oauthFlowRef?.inputMethod?.value === 'manual'"
type="button"
:disabled="!canExchangeCode"
class="btn btn-primary"
@click="handleExchangeCode"
>
<svg
v-if="oauth.loading.value"
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>
{{ oauth.loading.value ? t('admin.accounts.oauth.verifying') : t('admin.accounts.oauth.completeAuth') }}
</button>
</div>
</div>
</Modal>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import { useAccountOAuth, type AddMethod } from '@/composables/useAccountOAuth'
import type { Proxy, Group, AccountPlatform, AccountType } from '@/types'
import Modal from '@/components/common/Modal.vue'
import Select from '@/components/common/Select.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
const { t } = useI18n()
interface Props {
show: boolean
proxies: Proxy[]
groups: Group[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
created: []
}>()
const appStore = useAppStore()
// OAuth composable
const oauth = useAccountOAuth()
// Refs
const oauthFlowRef = ref<InstanceType<typeof OAuthAuthorizationFlow> | null>(null)
// Model mapping type
interface ModelMapping {
from: string
to: string
}
// State
const step = ref(1)
const submitting = ref(false)
const accountCategory = ref<'oauth-based' | 'apikey'>('oauth-based') // UI selection for account category
const addMethod = ref<AddMethod>('oauth') // For oauth-based: 'oauth' or 'setup-token'
const apiKeyBaseUrl = ref('https://api.anthropic.com')
const apiKeyValue = ref('')
const modelMappings = ref<ModelMapping[]>([])
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
const allowedModels = ref<string[]>([])
const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null)
// Common models for whitelist
const commonModels = [
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' },
{ value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku' },
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' },
{ value: 'claude-3-opus-20240229', label: 'Claude 3 Opus' },
{ value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
{ value: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku' }
]
// Preset mappings for quick add
const presetMappings = [
{ label: 'Sonnet 4', from: 'claude-sonnet-4-20250514', to: 'claude-sonnet-4-20250514', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5-20250929', to: 'claude-sonnet-4-5-20250929', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: 'Opus 4.5', from: 'claude-opus-4-5-20251101', to: 'claude-opus-4-5-20251101', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
{ label: 'Haiku 3.5', from: 'claude-3-5-haiku-20241022', to: 'claude-3-5-haiku-20241022', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
{ label: 'Haiku 4.5', from: 'claude-haiku-4-5-20251001', to: 'claude-haiku-4-5-20251001', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
{ label: 'Opus->Sonnet', from: 'claude-opus-4-5-20251101', to: 'claude-sonnet-4-5-20250929', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }
]
// Common HTTP error codes for quick selection
const commonErrorCodes = [
{ value: 401, label: 'Unauthorized' },
{ value: 403, label: 'Forbidden' },
{ value: 429, label: 'Rate Limit' },
{ value: 500, label: 'Server Error' },
{ value: 502, label: 'Bad Gateway' },
{ value: 503, label: 'Unavailable' },
{ value: 529, label: 'Overloaded' }
]
const form = reactive({
name: '',
platform: 'anthropic' as AccountPlatform,
type: 'oauth' as AccountType, // Will be 'oauth', 'setup-token', or 'apikey'
credentials: {} as Record<string, unknown>,
proxy_id: null as number | null,
concurrency: 10,
priority: 1,
group_ids: [] as number[]
})
// Helper to check if current type needs OAuth flow
const isOAuthFlow = computed(() => accountCategory.value === 'oauth-based')
const canExchangeCode = computed(() => {
const authCode = oauthFlowRef.value?.authCode?.value || ''
return authCode.trim() && oauth.sessionId.value && !oauth.loading.value
})
// Watchers
watch(() => props.show, (newVal) => {
if (!newVal) {
resetForm()
}
})
// Sync form.type based on accountCategory and addMethod
watch([accountCategory, addMethod], ([category, method]) => {
if (category === 'oauth-based') {
form.type = method as AccountType // 'oauth' or 'setup-token'
} else {
form.type = 'apikey'
}
}, { immediate: true })
// Model mapping helpers
const addModelMapping = () => {
modelMappings.value.push({ from: '', to: '' })
}
const removeModelMapping = (index: number) => {
modelMappings.value.splice(index, 1)
}
const addPresetMapping = (from: string, to: string) => {
// Check if mapping already exists
const exists = modelMappings.value.some(m => m.from === from)
if (exists) {
appStore.showInfo(t('admin.accounts.mappingExists', { model: from }))
return
}
modelMappings.value.push({ from, to })
}
// Error code toggle helper
const toggleErrorCode = (code: number) => {
const index = selectedErrorCodes.value.indexOf(code)
if (index === -1) {
selectedErrorCodes.value.push(code)
} else {
selectedErrorCodes.value.splice(index, 1)
}
}
// Add custom error code from input
const addCustomErrorCode = () => {
const code = customErrorCodeInput.value
if (code === null || code < 100 || code > 599) {
appStore.showError(t('admin.accounts.invalidErrorCode'))
return
}
if (selectedErrorCodes.value.includes(code)) {
appStore.showInfo(t('admin.accounts.errorCodeExists'))
return
}
selectedErrorCodes.value.push(code)
customErrorCodeInput.value = null
}
// Remove error code
const removeErrorCode = (code: number) => {
const index = selectedErrorCodes.value.indexOf(code)
if (index !== -1) {
selectedErrorCodes.value.splice(index, 1)
}
}
const buildModelMappingObject = (): Record<string, string> | null => {
const mapping: Record<string, string> = {}
if (modelRestrictionMode.value === 'whitelist') {
// Whitelist mode: map model to itself
for (const model of allowedModels.value) {
mapping[model] = model
}
} else {
// Mapping mode: use custom mappings
for (const m of modelMappings.value) {
const from = m.from.trim()
const to = m.to.trim()
if (from && to) {
mapping[from] = to
}
}
}
return Object.keys(mapping).length > 0 ? mapping : null
}
// Methods
const resetForm = () => {
step.value = 1
form.name = ''
form.platform = 'anthropic'
form.type = 'oauth'
form.credentials = {}
form.proxy_id = null
form.concurrency = 10
form.priority = 1
form.group_ids = []
accountCategory.value = 'oauth-based'
addMethod.value = 'oauth'
apiKeyBaseUrl.value = 'https://api.anthropic.com'
apiKeyValue.value = ''
modelMappings.value = []
modelRestrictionMode.value = 'whitelist'
allowedModels.value = []
customErrorCodesEnabled.value = false
selectedErrorCodes.value = []
customErrorCodeInput.value = null
oauth.resetState()
oauthFlowRef.value?.reset()
}
const handleClose = () => {
emit('close')
}
const handleSubmit = async () => {
// For OAuth-based type, handle OAuth flow (goes to step 2)
if (isOAuthFlow.value) {
if (!form.name.trim()) {
appStore.showError(t('admin.accounts.pleaseEnterAccountName'))
return
}
step.value = 2
return
}
// For apikey type, create directly
if (!apiKeyValue.value.trim()) {
appStore.showError(t('admin.accounts.pleaseEnterApiKey'))
return
}
// Build credentials with optional model mapping
const credentials: Record<string, unknown> = {
base_url: apiKeyBaseUrl.value.trim() || 'https://api.anthropic.com',
api_key: apiKeyValue.value.trim()
}
// Add model mapping if configured
const modelMapping = buildModelMappingObject()
if (modelMapping) {
credentials.model_mapping = modelMapping
}
// Add custom error codes if enabled
if (customErrorCodesEnabled.value) {
credentials.custom_error_codes_enabled = true
credentials.custom_error_codes = [...selectedErrorCodes.value]
}
form.credentials = credentials
submitting.value = true
try {
await adminAPI.accounts.create(form)
appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created')
handleClose()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToCreate'))
} finally {
submitting.value = false
}
}
const goBackToBasicInfo = () => {
step.value = 1
oauth.resetState()
oauthFlowRef.value?.reset()
}
const handleGenerateUrl = async () => {
await oauth.generateAuthUrl(addMethod.value, form.proxy_id)
}
const handleExchangeCode = async () => {
const authCode = oauthFlowRef.value?.authCode?.value || ''
if (!authCode.trim() || !oauth.sessionId.value) return
oauth.loading.value = true
oauth.error.value = ''
try {
const proxyConfig = form.proxy_id ? { proxy_id: form.proxy_id } : {}
const endpoint = addMethod.value === 'oauth'
? '/admin/accounts/exchange-code'
: '/admin/accounts/exchange-setup-token-code'
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
session_id: oauth.sessionId.value,
code: authCode.trim(),
...proxyConfig
})
const extra = oauth.buildExtraInfo(tokenInfo)
await adminAPI.accounts.create({
name: form.name,
platform: form.platform,
type: addMethod.value, // Use addMethod as type: 'oauth' or 'setup-token'
credentials: tokenInfo,
extra,
proxy_id: form.proxy_id,
concurrency: form.concurrency,
priority: form.priority
})
appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created')
handleClose()
} catch (error: any) {
oauth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(oauth.error.value)
} finally {
oauth.loading.value = false
}
}
const handleCookieAuth = async (sessionKey: string) => {
oauth.loading.value = true
oauth.error.value = ''
try {
const proxyConfig = form.proxy_id ? { proxy_id: form.proxy_id } : {}
const keys = oauth.parseSessionKeys(sessionKey)
if (keys.length === 0) {
oauth.error.value = t('admin.accounts.oauth.pleaseEnterSessionKey')
return
}
const endpoint = addMethod.value === 'oauth'
? '/admin/accounts/cookie-auth'
: '/admin/accounts/setup-token-cookie-auth'
let successCount = 0
let failedCount = 0
const errors: string[] = []
for (let i = 0; i < keys.length; i++) {
try {
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
session_id: '',
code: keys[i],
...proxyConfig
})
const extra = oauth.buildExtraInfo(tokenInfo)
const accountName = keys.length > 1 ? `${form.name} #${i + 1}` : form.name
await adminAPI.accounts.create({
name: accountName,
platform: form.platform,
type: addMethod.value, // Use addMethod as type: 'oauth' or 'setup-token'
credentials: tokenInfo,
extra,
proxy_id: form.proxy_id,
concurrency: form.concurrency,
priority: form.priority
})
successCount++
} catch (error: any) {
failedCount++
errors.push(t('admin.accounts.oauth.keyAuthFailed', { index: i + 1, error: error.response?.data?.detail || t('admin.accounts.oauth.authFailed') }))
}
}
if (successCount > 0) {
appStore.showSuccess(t('admin.accounts.oauth.successCreated', { count: successCount }))
if (failedCount === 0) {
emit('created')
handleClose()
} else {
emit('created')
}
}
if (failedCount > 0) {
oauth.error.value = errors.join('\n')
}
} catch (error: any) {
oauth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.cookieAuthFailed')
} finally {
oauth.loading.value = false
}
}
</script>
<template>
<Modal
:show="show"
:title="t('admin.accounts.editAccount')"
size="lg"
@close="handleClose"
>
<form v-if="account" @submit.prevent="handleSubmit" class="space-y-5">
<div>
<label class="input-label">{{ t('common.name') }}</label>
<input
v-model="form.name"
type="text"
required
class="input"
/>
</div>
<!-- API Key fields (only for apikey type) -->
<div v-if="account.type === 'apikey'" class="space-y-4">
<div>
<label class="input-label">{{ t('admin.accounts.baseUrl') }}</label>
<input
v-model="editBaseUrl"
type="text"
class="input"
placeholder="https://api.anthropic.com"
/>
<p class="input-hint">{{ t('admin.accounts.baseUrlHint') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.apiKey') }}</label>
<input
v-model="editApiKey"
type="password"
class="input font-mono"
:placeholder="t('admin.accounts.leaveEmptyToKeep')"
/>
<p class="input-hint">{{ t('admin.accounts.leaveEmptyToKeep') }}</p>
</div>
<!-- Model Restriction Section -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
<!-- Mode Toggle -->
<div class="flex gap-2 mb-4">
<button
type="button"
@click="modelRestrictionMode = 'whitelist'"
:class="[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'whitelist'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
<svg class="w-4 h-4 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ t('admin.accounts.modelWhitelist') }}
</button>
<button
type="button"
@click="modelRestrictionMode = 'mapping'"
:class="[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'mapping'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
<svg class="w-4 h-4 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
{{ t('admin.accounts.modelMapping') }}
</button>
</div>
<!-- Whitelist Mode -->
<div v-if="modelRestrictionMode === 'whitelist'">
<div class="mb-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-3">
<p class="text-xs text-blue-700 dark:text-blue-400">
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ t('admin.accounts.selectAllowedModels') }}
</p>
</div>
<!-- Model Checkbox List -->
<div class="grid grid-cols-2 gap-2 mb-3">
<label
v-for="model in commonModels"
:key="model.value"
class="flex cursor-pointer items-center rounded-lg border p-3 transition-all hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700"
:class="allowedModels.includes(model.value) ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-200'"
>
<input
type="checkbox"
:value="model.value"
v-model="allowedModels"
class="mr-2 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ model.label }}</span>
</label>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
<span v-if="allowedModels.length === 0">{{ t('admin.accounts.supportsAllModels') }}</span>
</p>
</div>
<!-- Mapping Mode -->
<div v-else>
<div class="mb-3 rounded-lg bg-purple-50 dark:bg-purple-900/20 p-3">
<p class="text-xs text-purple-700 dark:text-purple-400">
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ t('admin.accounts.mapRequestModels') }}
</p>
</div>
<!-- Model Mapping List -->
<div v-if="modelMappings.length > 0" class="space-y-2 mb-3">
<div
v-for="(mapping, index) in modelMappings"
:key="index"
class="flex items-center gap-2"
>
<input
v-model="mapping.from"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.requestModel')"
/>
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
<input
v-model="mapping.to"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.actualModel')"
/>
<button
type="button"
@click="removeModelMapping(index)"
class="p-2 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
<svg class="w-4 h-4" 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>
</button>
</div>
</div>
<button
type="button"
@click="addModelMapping"
class="w-full rounded-lg border-2 border-dashed border-gray-300 dark:border-dark-500 px-4 py-2 text-gray-600 dark:text-gray-400 transition-colors hover:border-gray-400 hover:text-gray-700 dark:hover:border-dark-400 dark:hover:text-gray-300 mb-3"
>
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{{ t('admin.accounts.addMapping') }}
</button>
<!-- Quick Add Buttons -->
<div class="flex flex-wrap gap-2">
<button
v-for="preset in presetMappings"
:key="preset.label"
type="button"
@click="addPresetMapping(preset.from, preset.to)"
:class="[
'rounded-lg px-3 py-1 text-xs transition-colors',
preset.color
]"
>
+ {{ preset.label }}
</button>
</div>
</div>
</div>
<!-- Custom Error Codes Section -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
<div class="flex items-center justify-between mb-3">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.customErrorCodes') }}</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.accounts.customErrorCodesHint') }}</p>
</div>
<button
type="button"
@click="customErrorCodesEnabled = !customErrorCodesEnabled"
:class="[
'relative inline-flex h-6 w-11 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',
customErrorCodesEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
customErrorCodesEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="customErrorCodesEnabled" class="space-y-3">
<div class="rounded-lg bg-amber-50 dark:bg-amber-900/20 p-3">
<p class="text-xs text-amber-700 dark:text-amber-400">
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{{ t('admin.accounts.customErrorCodesWarning') }}
</p>
</div>
<!-- Error Code Buttons -->
<div class="flex flex-wrap gap-2">
<button
v-for="code in commonErrorCodes"
:key="code.value"
type="button"
@click="toggleErrorCode(code.value)"
:class="[
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
selectedErrorCodes.includes(code.value)
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 ring-1 ring-red-500'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
{{ code.value }} {{ code.label }}
</button>
</div>
<!-- Manual input -->
<div class="flex items-center gap-2">
<input
v-model="customErrorCodeInput"
type="number"
min="100"
max="599"
class="input flex-1"
:placeholder="t('admin.accounts.enterErrorCode')"
@keyup.enter="addCustomErrorCode"
/>
<button
type="button"
@click="addCustomErrorCode"
class="btn btn-secondary px-3"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
<!-- Selected codes summary -->
<div class="flex flex-wrap gap-1.5">
<span
v-for="code in selectedErrorCodes.sort((a, b) => a - b)"
:key="code"
class="inline-flex items-center gap-1 rounded-full bg-red-100 dark:bg-red-900/30 px-2.5 py-0.5 text-sm font-medium text-red-700 dark:text-red-400"
>
{{ code }}
<button
type="button"
@click="removeErrorCode(code)"
class="hover:text-red-900 dark:hover:text-red-300"
>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
<span v-if="selectedErrorCodes.length === 0" class="text-xs text-gray-400">
{{ t('admin.accounts.noneSelectedUsesDefault') }}
</span>
</div>
</div>
</div>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.proxy') }}</label>
<ProxySelector
v-model="form.proxy_id"
:proxies="proxies"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('admin.accounts.concurrency') }}</label>
<input
v-model.number="form.concurrency"
type="number"
min="1"
class="input"
/>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.priority') }}</label>
<input
v-model.number="form.priority"
type="number"
min="1"
class="input"
/>
</div>
</div>
<div>
<label class="input-label">{{ t('common.status') }}</label>
<Select
v-model="form.status"
:options="statusOptions"
/>
</div>
<!-- Group Selection -->
<GroupSelector
v-model="form.group_ids"
:groups="groups"
/>
<div class="flex justify-end gap-3 pt-4">
<button
@click="handleClose"
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.accounts.updating') : t('common.update') }}
</button>
</div>
</form>
</Modal>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } 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 Modal from '@/components/common/Modal.vue'
import Select from '@/components/common/Select.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue'
interface Props {
show: boolean
account: Account | null
proxies: Proxy[]
groups: Group[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
updated: []
}>()
const { t } = useI18n()
const appStore = useAppStore()
// Model mapping type
interface ModelMapping {
from: string
to: string
}
// State
const submitting = ref(false)
const editBaseUrl = ref('https://api.anthropic.com')
const editApiKey = ref('')
const modelMappings = ref<ModelMapping[]>([])
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
const allowedModels = ref<string[]>([])
const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null)
// Common models for whitelist
const commonModels = [
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' },
{ value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku' },
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' },
{ value: 'claude-3-opus-20240229', label: 'Claude 3 Opus' },
{ value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
{ value: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku' }
]
// Preset mappings for quick add
const presetMappings = [
{ label: 'Sonnet 4', from: 'claude-sonnet-4-20250514', to: 'claude-sonnet-4-20250514', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5-20250929', to: 'claude-sonnet-4-5-20250929', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: 'Opus 4.5', from: 'claude-opus-4-5-20251101', to: 'claude-opus-4-5-20251101', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
{ label: 'Haiku 3.5', from: 'claude-3-5-haiku-20241022', to: 'claude-3-5-haiku-20241022', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
{ label: 'Haiku 4.5', from: 'claude-haiku-4-5-20251001', to: 'claude-haiku-4-5-20251001', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
{ label: 'Opus->Sonnet', from: 'claude-opus-4-5-20251101', to: 'claude-sonnet-4-5-20250929', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }
]
// Common HTTP error codes for quick selection
const commonErrorCodes = [
{ value: 401, label: 'Unauthorized' },
{ value: 403, label: 'Forbidden' },
{ value: 429, label: 'Rate Limit' },
{ value: 500, label: 'Server Error' },
{ value: 502, label: 'Bad Gateway' },
{ value: 503, label: 'Unavailable' },
{ value: 529, label: 'Overloaded' }
]
const form = reactive({
name: '',
proxy_id: null as number | null,
concurrency: 1,
priority: 1,
status: 'active' as 'active' | 'inactive',
group_ids: [] as number[]
})
const statusOptions = computed(() => [
{ value: 'active', label: t('common.active') },
{ value: 'inactive', label: t('common.inactive') }
])
// Watchers
watch(() => props.account, (newAccount) => {
if (newAccount) {
form.name = newAccount.name
form.proxy_id = newAccount.proxy_id
form.concurrency = newAccount.concurrency
form.priority = newAccount.priority
form.status = newAccount.status as 'active' | 'inactive'
form.group_ids = newAccount.group_ids || []
// Initialize API Key fields for apikey type
if (newAccount.type === 'apikey' && newAccount.credentials) {
const credentials = newAccount.credentials as Record<string, unknown>
editBaseUrl.value = credentials.base_url as string || 'https://api.anthropic.com'
// Load model mappings and detect mode
const existingMappings = credentials.model_mapping as Record<string, string> | undefined
if (existingMappings && typeof existingMappings === 'object') {
const entries = Object.entries(existingMappings)
// Detect if this is whitelist mode (all from === to) or mapping mode
const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to)
if (isWhitelistMode) {
// Whitelist mode: populate allowedModels
modelRestrictionMode.value = 'whitelist'
allowedModels.value = entries.map(([from]) => from)
modelMappings.value = []
} else {
// Mapping mode: populate modelMappings
modelRestrictionMode.value = 'mapping'
modelMappings.value = entries.map(([from, to]) => ({ from, to }))
allowedModels.value = []
}
} else {
// No mappings: default to whitelist mode with empty selection (allow all)
modelRestrictionMode.value = 'whitelist'
modelMappings.value = []
allowedModels.value = []
}
// Load custom error codes
customErrorCodesEnabled.value = credentials.custom_error_codes_enabled === true
const existingErrorCodes = credentials.custom_error_codes as number[] | undefined
if (existingErrorCodes && Array.isArray(existingErrorCodes)) {
selectedErrorCodes.value = [...existingErrorCodes]
} else {
selectedErrorCodes.value = []
}
} else {
editBaseUrl.value = 'https://api.anthropic.com'
modelRestrictionMode.value = 'whitelist'
modelMappings.value = []
allowedModels.value = []
customErrorCodesEnabled.value = false
selectedErrorCodes.value = []
}
editApiKey.value = ''
}
}, { immediate: true })
// Model mapping helpers
const addModelMapping = () => {
modelMappings.value.push({ from: '', to: '' })
}
const removeModelMapping = (index: number) => {
modelMappings.value.splice(index, 1)
}
const addPresetMapping = (from: string, to: string) => {
const exists = modelMappings.value.some(m => m.from === from)
if (exists) {
appStore.showInfo(t('admin.accounts.mappingExists', { model: from }))
return
}
modelMappings.value.push({ from, to })
}
// Error code toggle helper
const toggleErrorCode = (code: number) => {
const index = selectedErrorCodes.value.indexOf(code)
if (index === -1) {
selectedErrorCodes.value.push(code)
} else {
selectedErrorCodes.value.splice(index, 1)
}
}
// Add custom error code from input
const addCustomErrorCode = () => {
const code = customErrorCodeInput.value
if (code === null || code < 100 || code > 599) {
appStore.showError(t('admin.accounts.invalidErrorCode'))
return
}
if (selectedErrorCodes.value.includes(code)) {
appStore.showInfo(t('admin.accounts.errorCodeExists'))
return
}
selectedErrorCodes.value.push(code)
customErrorCodeInput.value = null
}
// Remove error code
const removeErrorCode = (code: number) => {
const index = selectedErrorCodes.value.indexOf(code)
if (index !== -1) {
selectedErrorCodes.value.splice(index, 1)
}
}
const buildModelMappingObject = (): Record<string, string> | null => {
const mapping: Record<string, string> = {}
if (modelRestrictionMode.value === 'whitelist') {
// Whitelist mode: model maps to itself
for (const model of allowedModels.value) {
mapping[model] = model
}
} else {
// Mapping mode: use the mapping entries
for (const m of modelMappings.value) {
const from = m.from.trim()
const to = m.to.trim()
if (from && to) {
mapping[from] = to
}
}
}
return Object.keys(mapping).length > 0 ? mapping : null
}
// Methods
const handleClose = () => {
emit('close')
}
const handleSubmit = async () => {
if (!props.account) return
submitting.value = true
try {
const updatePayload: Record<string, unknown> = { ...form }
// For apikey type, handle credentials update
if (props.account.type === 'apikey') {
const currentCredentials = props.account.credentials as Record<string, unknown> || {}
const newBaseUrl = editBaseUrl.value.trim() || 'https://api.anthropic.com'
const modelMapping = buildModelMappingObject()
// Always update credentials for apikey type to handle model mapping changes
const newCredentials: Record<string, unknown> = {
base_url: newBaseUrl
}
// Handle API key
if (editApiKey.value.trim()) {
// User provided a new API key
newCredentials.api_key = editApiKey.value.trim()
} else if (currentCredentials.api_key) {
// Preserve existing api_key
newCredentials.api_key = currentCredentials.api_key
} else {
appStore.showError(t('admin.accounts.apiKeyIsRequired'))
submitting.value = false
return
}
// Add model mapping if configured
if (modelMapping) {
newCredentials.model_mapping = modelMapping
}
// Add custom error codes if enabled
if (customErrorCodesEnabled.value) {
newCredentials.custom_error_codes_enabled = true
newCredentials.custom_error_codes = [...selectedErrorCodes.value]
}
updatePayload.credentials = newCredentials
}
await adminAPI.accounts.update(props.account.id, updatePayload)
appStore.showSuccess(t('admin.accounts.accountUpdated'))
emit('updated')
handleClose()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
} finally {
submitting.value = false
}
}
</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