First commit
This commit is contained in:
60
frontend/src/App.vue
Normal file
60
frontend/src/App.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView, useRouter, useRoute } from 'vue-router'
|
||||
import { onMounted } from 'vue'
|
||||
import Toast from '@/components/common/Toast.vue'
|
||||
import { getPublicSettings } from '@/api/auth'
|
||||
import { getSetupStatus } from '@/api/setup'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
/**
|
||||
* Update favicon dynamically
|
||||
* @param logoUrl - URL of the logo to use as favicon
|
||||
*/
|
||||
function updateFavicon(logoUrl: string) {
|
||||
// Find existing favicon link or create new one
|
||||
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
|
||||
if (!link) {
|
||||
link = document.createElement('link')
|
||||
link.rel = 'icon'
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
link.type = logoUrl.endsWith('.svg') ? 'image/svg+xml' : 'image/x-icon'
|
||||
link.href = logoUrl
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Check if setup is needed
|
||||
try {
|
||||
const status = await getSetupStatus()
|
||||
if (status.needs_setup && route.path !== '/setup') {
|
||||
router.replace('/setup')
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// If setup endpoint fails, assume normal mode and continue
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await getPublicSettings()
|
||||
|
||||
// Update favicon if logo is set
|
||||
if (settings.site_logo) {
|
||||
updateFavicon(settings.site_logo)
|
||||
}
|
||||
|
||||
// Update page title if site name is set
|
||||
if (settings.site_name) {
|
||||
document.title = `${settings.site_name} - AI API Gateway`
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings for favicon:', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<Toast />
|
||||
</template>
|
||||
270
frontend/src/api/admin/accounts.ts
Normal file
270
frontend/src/api/admin/accounts.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Admin Accounts API endpoints
|
||||
* Handles AI platform account management for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import type {
|
||||
Account,
|
||||
CreateAccountRequest,
|
||||
UpdateAccountRequest,
|
||||
PaginatedResponse,
|
||||
AccountUsageInfo,
|
||||
WindowStats,
|
||||
} from '@/types';
|
||||
|
||||
/**
|
||||
* List all accounts with pagination
|
||||
* @param page - Page number (default: 1)
|
||||
* @param pageSize - Items per page (default: 20)
|
||||
* @param filters - Optional filters
|
||||
* @returns Paginated list of accounts
|
||||
*/
|
||||
export async function list(
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
filters?: {
|
||||
platform?: string;
|
||||
type?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
}
|
||||
): Promise<PaginatedResponse<Account>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<Account>>('/admin/accounts', {
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...filters,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account by ID
|
||||
* @param id - Account ID
|
||||
* @returns Account details
|
||||
*/
|
||||
export async function getById(id: number): Promise<Account> {
|
||||
const { data } = await apiClient.get<Account>(`/admin/accounts/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new account
|
||||
* @param accountData - Account data
|
||||
* @returns Created account
|
||||
*/
|
||||
export async function create(accountData: CreateAccountRequest): Promise<Account> {
|
||||
const { data } = await apiClient.post<Account>('/admin/accounts', accountData);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update account
|
||||
* @param id - Account ID
|
||||
* @param updates - Fields to update
|
||||
* @returns Updated account
|
||||
*/
|
||||
export async function update(id: number, updates: UpdateAccountRequest): Promise<Account> {
|
||||
const { data } = await apiClient.put<Account>(`/admin/accounts/${id}`, updates);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete account
|
||||
* @param id - Account ID
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function deleteAccount(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/accounts/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle account status
|
||||
* @param id - Account ID
|
||||
* @param status - New status
|
||||
* @returns Updated account
|
||||
*/
|
||||
export async function toggleStatus(
|
||||
id: number,
|
||||
status: 'active' | 'inactive'
|
||||
): Promise<Account> {
|
||||
return update(id, { status });
|
||||
}
|
||||
|
||||
/**
|
||||
* Test account connectivity
|
||||
* @param id - Account ID
|
||||
* @returns Test result
|
||||
*/
|
||||
export async function testAccount(id: number): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
latency_ms?: number;
|
||||
}> {
|
||||
const { data } = await apiClient.post<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
latency_ms?: number;
|
||||
}>(`/admin/accounts/${id}/test`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh account credentials
|
||||
* @param id - Account ID
|
||||
* @returns Updated account
|
||||
*/
|
||||
export async function refreshCredentials(id: number): Promise<Account> {
|
||||
const { data } = await apiClient.post<Account>(`/admin/accounts/${id}/refresh`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account usage statistics
|
||||
* @param id - Account ID
|
||||
* @param period - Time period
|
||||
* @returns Account usage statistics
|
||||
*/
|
||||
export async function getStats(
|
||||
id: number,
|
||||
period: string = 'month'
|
||||
): Promise<{
|
||||
total_requests: number;
|
||||
successful_requests: number;
|
||||
failed_requests: number;
|
||||
total_tokens: number;
|
||||
average_response_time: number;
|
||||
}> {
|
||||
const { data } = await apiClient.get<{
|
||||
total_requests: number;
|
||||
successful_requests: number;
|
||||
failed_requests: number;
|
||||
total_tokens: number;
|
||||
average_response_time: number;
|
||||
}>(`/admin/accounts/${id}/stats`, {
|
||||
params: { period },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear account error
|
||||
* @param id - Account ID
|
||||
* @returns Updated account
|
||||
*/
|
||||
export async function clearError(id: number): Promise<Account> {
|
||||
const { data } = await apiClient.post<Account>(`/admin/accounts/${id}/clear-error`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account usage information (5h/7d window)
|
||||
* @param id - Account ID
|
||||
* @returns Account usage info
|
||||
*/
|
||||
export async function getUsage(id: number): Promise<AccountUsageInfo> {
|
||||
const { data } = await apiClient.get<AccountUsageInfo>(`/admin/accounts/${id}/usage`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear account rate limit status
|
||||
* @param id - Account ID
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function clearRateLimit(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.post<{ message: string }>(`/admin/accounts/${id}/clear-rate-limit`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OAuth authorization URL
|
||||
* @param endpoint - API endpoint path
|
||||
* @param config - Proxy configuration
|
||||
* @returns Auth URL and session ID
|
||||
*/
|
||||
export async function generateAuthUrl(
|
||||
endpoint: string,
|
||||
config: { proxy_id?: number }
|
||||
): Promise<{ auth_url: string; session_id: string }> {
|
||||
const { data } = await apiClient.post<{ auth_url: string; session_id: string }>(endpoint, config);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens
|
||||
* @param endpoint - API endpoint path
|
||||
* @param exchangeData - Session ID, code, and optional proxy config
|
||||
* @returns Token information
|
||||
*/
|
||||
export async function exchangeCode(
|
||||
endpoint: string,
|
||||
exchangeData: { session_id: string; code: string; proxy_id?: number }
|
||||
): Promise<Record<string, unknown>> {
|
||||
const { data } = await apiClient.post<Record<string, unknown>>(endpoint, exchangeData);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch create accounts
|
||||
* @param accounts - Array of account data
|
||||
* @returns Results of batch creation
|
||||
*/
|
||||
export async function batchCreate(accounts: CreateAccountRequest[]): Promise<{
|
||||
success: number;
|
||||
failed: number;
|
||||
results: Array<{ success: boolean; account?: Account; error?: string }>;
|
||||
}> {
|
||||
const { data } = await apiClient.post<{
|
||||
success: number;
|
||||
failed: number;
|
||||
results: Array<{ success: boolean; account?: Account; error?: string }>;
|
||||
}>('/admin/accounts/batch', { accounts });
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account today statistics
|
||||
* @param id - Account ID
|
||||
* @returns Today's stats (requests, tokens, cost)
|
||||
*/
|
||||
export async function getTodayStats(id: number): Promise<WindowStats> {
|
||||
const { data } = await apiClient.get<WindowStats>(`/admin/accounts/${id}/today-stats`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set account schedulable status
|
||||
* @param id - Account ID
|
||||
* @param schedulable - Whether the account should participate in scheduling
|
||||
* @returns Updated account
|
||||
*/
|
||||
export async function setSchedulable(id: number, schedulable: boolean): Promise<Account> {
|
||||
const { data } = await apiClient.post<Account>(`/admin/accounts/${id}/schedulable`, { schedulable });
|
||||
return data;
|
||||
}
|
||||
|
||||
export const accountsAPI = {
|
||||
list,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
delete: deleteAccount,
|
||||
toggleStatus,
|
||||
testAccount,
|
||||
refreshCredentials,
|
||||
getStats,
|
||||
clearError,
|
||||
getUsage,
|
||||
getTodayStats,
|
||||
clearRateLimit,
|
||||
setSchedulable,
|
||||
generateAuthUrl,
|
||||
exchangeCode,
|
||||
batchCreate,
|
||||
};
|
||||
|
||||
export default accountsAPI;
|
||||
173
frontend/src/api/admin/dashboard.ts
Normal file
173
frontend/src/api/admin/dashboard.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Admin Dashboard API endpoints
|
||||
* Provides system-wide statistics and metrics
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import type { DashboardStats, TrendDataPoint, ModelStat, ApiKeyUsageTrendPoint, UserUsageTrendPoint } from '@/types';
|
||||
|
||||
/**
|
||||
* Get dashboard statistics
|
||||
* @returns Dashboard statistics including users, keys, accounts, and token usage
|
||||
*/
|
||||
export async function getStats(): Promise<DashboardStats> {
|
||||
const { data } = await apiClient.get<DashboardStats>('/admin/dashboard/stats');
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get real-time metrics
|
||||
* @returns Real-time system metrics
|
||||
*/
|
||||
export async function getRealtimeMetrics(): Promise<{
|
||||
active_requests: number;
|
||||
requests_per_minute: number;
|
||||
average_response_time: number;
|
||||
error_rate: number;
|
||||
}> {
|
||||
const { data } = await apiClient.get<{
|
||||
active_requests: number;
|
||||
requests_per_minute: number;
|
||||
average_response_time: number;
|
||||
error_rate: number;
|
||||
}>('/admin/dashboard/realtime');
|
||||
return data;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage trend data
|
||||
* @param params - Query parameters for filtering
|
||||
* @returns Usage trend data
|
||||
*/
|
||||
export async function getUsageTrend(params?: TrendParams): Promise<TrendResponse> {
|
||||
const { data } = await apiClient.get<TrendResponse>('/admin/dashboard/trend', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface ModelStatsResponse {
|
||||
models: ModelStat[];
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model usage statistics
|
||||
* @param params - Query parameters for filtering
|
||||
* @returns Model usage statistics
|
||||
*/
|
||||
export async function getModelStats(params?: { start_date?: string; end_date?: string }): Promise<ModelStatsResponse> {
|
||||
const { data } = await apiClient.get<ModelStatsResponse>('/admin/dashboard/models', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface ApiKeyTrendParams extends TrendParams {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ApiKeyTrendResponse {
|
||||
trend: ApiKeyUsageTrendPoint[];
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
granularity: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key usage trend data
|
||||
* @param params - Query parameters for filtering
|
||||
* @returns API key usage trend data
|
||||
*/
|
||||
export async function getApiKeyUsageTrend(params?: ApiKeyTrendParams): Promise<ApiKeyTrendResponse> {
|
||||
const { data } = await apiClient.get<ApiKeyTrendResponse>('/admin/dashboard/api-keys-trend', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface UserTrendParams extends TrendParams {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface UserTrendResponse {
|
||||
trend: UserUsageTrendPoint[];
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
granularity: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user usage trend data
|
||||
* @param params - Query parameters for filtering
|
||||
* @returns User usage trend data
|
||||
*/
|
||||
export async function getUserUsageTrend(params?: UserTrendParams): Promise<UserTrendResponse> {
|
||||
const { data } = await apiClient.get<UserTrendResponse>('/admin/dashboard/users-trend', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface BatchUserUsageStats {
|
||||
user_id: number;
|
||||
today_actual_cost: number;
|
||||
total_actual_cost: number;
|
||||
}
|
||||
|
||||
export interface BatchUsersUsageResponse {
|
||||
stats: Record<string, BatchUserUsageStats>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get batch usage stats for multiple users
|
||||
* @param userIds - Array of user IDs
|
||||
* @returns Usage stats map keyed by user ID
|
||||
*/
|
||||
export async function getBatchUsersUsage(userIds: number[]): Promise<BatchUsersUsageResponse> {
|
||||
const { data } = await apiClient.post<BatchUsersUsageResponse>('/admin/dashboard/users-usage', {
|
||||
user_ids: userIds,
|
||||
});
|
||||
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 multiple API keys
|
||||
* @param apiKeyIds - Array of API key IDs
|
||||
* @returns Usage stats map keyed by API key ID
|
||||
*/
|
||||
export async function getBatchApiKeysUsage(apiKeyIds: number[]): Promise<BatchApiKeysUsageResponse> {
|
||||
const { data } = await apiClient.post<BatchApiKeysUsageResponse>('/admin/dashboard/api-keys-usage', {
|
||||
api_key_ids: apiKeyIds,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export const dashboardAPI = {
|
||||
getStats,
|
||||
getRealtimeMetrics,
|
||||
getUsageTrend,
|
||||
getModelStats,
|
||||
getApiKeyUsageTrend,
|
||||
getUserUsageTrend,
|
||||
getBatchUsersUsage,
|
||||
getBatchApiKeysUsage,
|
||||
};
|
||||
|
||||
export default dashboardAPI;
|
||||
170
frontend/src/api/admin/groups.ts
Normal file
170
frontend/src/api/admin/groups.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Admin Groups API endpoints
|
||||
* Handles API key group management for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import type {
|
||||
Group,
|
||||
GroupPlatform,
|
||||
CreateGroupRequest,
|
||||
UpdateGroupRequest,
|
||||
PaginatedResponse,
|
||||
} from '@/types';
|
||||
|
||||
/**
|
||||
* List all groups with pagination
|
||||
* @param page - Page number (default: 1)
|
||||
* @param pageSize - Items per page (default: 20)
|
||||
* @param filters - Optional filters (platform, status, is_exclusive)
|
||||
* @returns Paginated list of groups
|
||||
*/
|
||||
export async function list(
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
filters?: {
|
||||
platform?: GroupPlatform;
|
||||
status?: 'active' | 'inactive';
|
||||
is_exclusive?: boolean;
|
||||
}
|
||||
): Promise<PaginatedResponse<Group>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<Group>>('/admin/groups', {
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...filters,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active groups (without pagination)
|
||||
* @param platform - Optional platform filter
|
||||
* @returns List of all active groups
|
||||
*/
|
||||
export async function getAll(platform?: GroupPlatform): Promise<Group[]> {
|
||||
const { data } = await apiClient.get<Group[]>('/admin/groups/all', {
|
||||
params: platform ? { platform } : undefined
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active groups by platform
|
||||
* @param platform - Platform to filter by
|
||||
* @returns List of groups for the specified platform
|
||||
*/
|
||||
export async function getByPlatform(platform: GroupPlatform): Promise<Group[]> {
|
||||
return getAll(platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get group by ID
|
||||
* @param id - Group ID
|
||||
* @returns Group details
|
||||
*/
|
||||
export async function getById(id: number): Promise<Group> {
|
||||
const { data } = await apiClient.get<Group>(`/admin/groups/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new group
|
||||
* @param groupData - Group data
|
||||
* @returns Created group
|
||||
*/
|
||||
export async function create(groupData: CreateGroupRequest): Promise<Group> {
|
||||
const { data } = await apiClient.post<Group>('/admin/groups', groupData);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update group
|
||||
* @param id - Group ID
|
||||
* @param updates - Fields to update
|
||||
* @returns Updated group
|
||||
*/
|
||||
export async function update(id: number, updates: UpdateGroupRequest): Promise<Group> {
|
||||
const { data } = await apiClient.put<Group>(`/admin/groups/${id}`, updates);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete group
|
||||
* @param id - Group ID
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function deleteGroup(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/groups/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle group status
|
||||
* @param id - Group ID
|
||||
* @param status - New status
|
||||
* @returns Updated group
|
||||
*/
|
||||
export async function toggleStatus(
|
||||
id: number,
|
||||
status: 'active' | 'inactive'
|
||||
): Promise<Group> {
|
||||
return update(id, { status });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get group statistics
|
||||
* @param id - Group ID
|
||||
* @returns Group usage statistics
|
||||
*/
|
||||
export async function getStats(id: number): Promise<{
|
||||
total_api_keys: number;
|
||||
active_api_keys: number;
|
||||
total_requests: number;
|
||||
total_cost: number;
|
||||
}> {
|
||||
const { data } = await apiClient.get<{
|
||||
total_api_keys: number;
|
||||
active_api_keys: number;
|
||||
total_requests: number;
|
||||
total_cost: number;
|
||||
}>(`/admin/groups/${id}/stats`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API keys in a group
|
||||
* @param id - Group ID
|
||||
* @param page - Page number
|
||||
* @param pageSize - Items per page
|
||||
* @returns Paginated list of API keys in the group
|
||||
*/
|
||||
export async function getGroupApiKeys(
|
||||
id: number,
|
||||
page: number = 1,
|
||||
pageSize: number = 20
|
||||
): Promise<PaginatedResponse<any>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<any>>(
|
||||
`/admin/groups/${id}/api-keys`,
|
||||
{
|
||||
params: { page, page_size: pageSize },
|
||||
}
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export const groupsAPI = {
|
||||
list,
|
||||
getAll,
|
||||
getByPlatform,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
delete: deleteGroup,
|
||||
toggleStatus,
|
||||
getStats,
|
||||
getGroupApiKeys,
|
||||
};
|
||||
|
||||
export default groupsAPI;
|
||||
35
frontend/src/api/admin/index.ts
Normal file
35
frontend/src/api/admin/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Admin API barrel export
|
||||
* Centralized exports for all admin API modules
|
||||
*/
|
||||
|
||||
import dashboardAPI from './dashboard';
|
||||
import usersAPI from './users';
|
||||
import groupsAPI from './groups';
|
||||
import accountsAPI from './accounts';
|
||||
import proxiesAPI from './proxies';
|
||||
import redeemAPI from './redeem';
|
||||
import settingsAPI from './settings';
|
||||
import systemAPI from './system';
|
||||
import subscriptionsAPI from './subscriptions';
|
||||
import usageAPI from './usage';
|
||||
|
||||
/**
|
||||
* Unified admin API object for convenient access
|
||||
*/
|
||||
export const adminAPI = {
|
||||
dashboard: dashboardAPI,
|
||||
users: usersAPI,
|
||||
groups: groupsAPI,
|
||||
accounts: accountsAPI,
|
||||
proxies: proxiesAPI,
|
||||
redeem: redeemAPI,
|
||||
settings: settingsAPI,
|
||||
system: systemAPI,
|
||||
subscriptions: subscriptionsAPI,
|
||||
usage: usageAPI,
|
||||
};
|
||||
|
||||
export { dashboardAPI, usersAPI, groupsAPI, accountsAPI, proxiesAPI, redeemAPI, settingsAPI, systemAPI, subscriptionsAPI, usageAPI };
|
||||
|
||||
export default adminAPI;
|
||||
211
frontend/src/api/admin/proxies.ts
Normal file
211
frontend/src/api/admin/proxies.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Admin Proxies API endpoints
|
||||
* Handles proxy server management for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import type {
|
||||
Proxy,
|
||||
CreateProxyRequest,
|
||||
UpdateProxyRequest,
|
||||
PaginatedResponse,
|
||||
} from '@/types';
|
||||
|
||||
/**
|
||||
* List all proxies with pagination
|
||||
* @param page - Page number (default: 1)
|
||||
* @param pageSize - Items per page (default: 20)
|
||||
* @param filters - Optional filters
|
||||
* @returns Paginated list of proxies
|
||||
*/
|
||||
export async function list(
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
filters?: {
|
||||
protocol?: string;
|
||||
status?: 'active' | 'inactive';
|
||||
search?: string;
|
||||
}
|
||||
): Promise<PaginatedResponse<Proxy>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<Proxy>>('/admin/proxies', {
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...filters,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active proxies (without pagination)
|
||||
* @returns List of all active proxies
|
||||
*/
|
||||
export async function getAll(): Promise<Proxy[]> {
|
||||
const { data } = await apiClient.get<Proxy[]>('/admin/proxies/all');
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active proxies with account count (sorted by creation time desc)
|
||||
* @returns List of all active proxies with account count
|
||||
*/
|
||||
export async function getAllWithCount(): Promise<Proxy[]> {
|
||||
const { data } = await apiClient.get<Proxy[]>('/admin/proxies/all', {
|
||||
params: { with_count: 'true' },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get proxy by ID
|
||||
* @param id - Proxy ID
|
||||
* @returns Proxy details
|
||||
*/
|
||||
export async function getById(id: number): Promise<Proxy> {
|
||||
const { data } = await apiClient.get<Proxy>(`/admin/proxies/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new proxy
|
||||
* @param proxyData - Proxy data
|
||||
* @returns Created proxy
|
||||
*/
|
||||
export async function create(proxyData: CreateProxyRequest): Promise<Proxy> {
|
||||
const { data } = await apiClient.post<Proxy>('/admin/proxies', proxyData);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update proxy
|
||||
* @param id - Proxy ID
|
||||
* @param updates - Fields to update
|
||||
* @returns Updated proxy
|
||||
*/
|
||||
export async function update(id: number, updates: UpdateProxyRequest): Promise<Proxy> {
|
||||
const { data } = await apiClient.put<Proxy>(`/admin/proxies/${id}`, updates);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete proxy
|
||||
* @param id - Proxy ID
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function deleteProxy(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/proxies/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle proxy status
|
||||
* @param id - Proxy ID
|
||||
* @param status - New status
|
||||
* @returns Updated proxy
|
||||
*/
|
||||
export async function toggleStatus(
|
||||
id: number,
|
||||
status: 'active' | 'inactive'
|
||||
): Promise<Proxy> {
|
||||
return update(id, { status });
|
||||
}
|
||||
|
||||
/**
|
||||
* Test proxy connectivity
|
||||
* @param id - Proxy ID
|
||||
* @returns Test result with IP info
|
||||
*/
|
||||
export async function testProxy(id: number): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
latency_ms?: number;
|
||||
ip_address?: string;
|
||||
city?: string;
|
||||
region?: string;
|
||||
country?: string;
|
||||
}> {
|
||||
const { data } = await apiClient.post<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
latency_ms?: number;
|
||||
ip_address?: string;
|
||||
city?: string;
|
||||
region?: string;
|
||||
country?: string;
|
||||
}>(`/admin/proxies/${id}/test`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get proxy usage statistics
|
||||
* @param id - Proxy ID
|
||||
* @returns Proxy usage statistics
|
||||
*/
|
||||
export async function getStats(id: number): Promise<{
|
||||
total_accounts: number;
|
||||
active_accounts: number;
|
||||
total_requests: number;
|
||||
success_rate: number;
|
||||
average_latency: number;
|
||||
}> {
|
||||
const { data } = await apiClient.get<{
|
||||
total_accounts: number;
|
||||
active_accounts: number;
|
||||
total_requests: number;
|
||||
success_rate: number;
|
||||
average_latency: number;
|
||||
}>(`/admin/proxies/${id}/stats`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accounts using a proxy
|
||||
* @param id - Proxy ID
|
||||
* @returns List of accounts using the proxy
|
||||
*/
|
||||
export async function getProxyAccounts(id: number): Promise<PaginatedResponse<any>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<any>>(
|
||||
`/admin/proxies/${id}/accounts`
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch create proxies
|
||||
* @param proxies - Array of proxy data to create
|
||||
* @returns Creation result with count of created and skipped
|
||||
*/
|
||||
export async function batchCreate(proxies: Array<{
|
||||
protocol: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}>): Promise<{
|
||||
created: number;
|
||||
skipped: number;
|
||||
}> {
|
||||
const { data } = await apiClient.post<{
|
||||
created: number;
|
||||
skipped: number;
|
||||
}>('/admin/proxies/batch', { proxies });
|
||||
return data;
|
||||
}
|
||||
|
||||
export const proxiesAPI = {
|
||||
list,
|
||||
getAll,
|
||||
getAllWithCount,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
delete: deleteProxy,
|
||||
toggleStatus,
|
||||
testProxy,
|
||||
getStats,
|
||||
getProxyAccounts,
|
||||
batchCreate,
|
||||
};
|
||||
|
||||
export default proxiesAPI;
|
||||
170
frontend/src/api/admin/redeem.ts
Normal file
170
frontend/src/api/admin/redeem.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Admin Redeem Codes API endpoints
|
||||
* Handles redeem code generation and management for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import type {
|
||||
RedeemCode,
|
||||
GenerateRedeemCodesRequest,
|
||||
RedeemCodeType,
|
||||
PaginatedResponse,
|
||||
} from '@/types';
|
||||
|
||||
/**
|
||||
* List all redeem codes with pagination
|
||||
* @param page - Page number (default: 1)
|
||||
* @param pageSize - Items per page (default: 20)
|
||||
* @param filters - Optional filters
|
||||
* @returns Paginated list of redeem codes
|
||||
*/
|
||||
export async function list(
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
filters?: {
|
||||
type?: RedeemCodeType;
|
||||
status?: 'active' | 'used' | 'expired';
|
||||
search?: string;
|
||||
}
|
||||
): Promise<PaginatedResponse<RedeemCode>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<RedeemCode>>('/admin/redeem-codes', {
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...filters,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get redeem code by ID
|
||||
* @param id - Redeem code ID
|
||||
* @returns Redeem code details
|
||||
*/
|
||||
export async function getById(id: number): Promise<RedeemCode> {
|
||||
const { data } = await apiClient.get<RedeemCode>(`/admin/redeem-codes/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate new redeem codes
|
||||
* @param count - Number of codes to generate
|
||||
* @param type - Type of redeem code
|
||||
* @param value - Value of the code
|
||||
* @param groupId - Group ID (required for subscription type)
|
||||
* @param validityDays - Validity days (for subscription type)
|
||||
* @returns Array of generated redeem codes
|
||||
*/
|
||||
export async function generate(
|
||||
count: number,
|
||||
type: RedeemCodeType,
|
||||
value: number,
|
||||
groupId?: number | null,
|
||||
validityDays?: number
|
||||
): Promise<RedeemCode[]> {
|
||||
const payload: GenerateRedeemCodesRequest = {
|
||||
count,
|
||||
type,
|
||||
value,
|
||||
};
|
||||
|
||||
// 订阅类型专用字段
|
||||
if (type === 'subscription') {
|
||||
payload.group_id = groupId;
|
||||
if (validityDays && validityDays > 0) {
|
||||
payload.validity_days = validityDays;
|
||||
}
|
||||
}
|
||||
|
||||
const { data } = await apiClient.post<RedeemCode[]>('/admin/redeem-codes/generate', payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete redeem code
|
||||
* @param id - Redeem code ID
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function deleteCode(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/redeem-codes/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch delete redeem codes
|
||||
* @param ids - Array of redeem code IDs
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function batchDelete(ids: number[]): Promise<{
|
||||
deleted: number;
|
||||
message: string;
|
||||
}> {
|
||||
const { data } = await apiClient.post<{
|
||||
deleted: number;
|
||||
message: string;
|
||||
}>('/admin/redeem-codes/batch-delete', { ids });
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expire redeem code
|
||||
* @param id - Redeem code ID
|
||||
* @returns Updated redeem code
|
||||
*/
|
||||
export async function expire(id: number): Promise<RedeemCode> {
|
||||
const { data } = await apiClient.post<RedeemCode>(`/admin/redeem-codes/${id}/expire`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get redeem code statistics
|
||||
* @returns Statistics about redeem codes
|
||||
*/
|
||||
export async function getStats(): Promise<{
|
||||
total_codes: number;
|
||||
active_codes: number;
|
||||
used_codes: number;
|
||||
expired_codes: number;
|
||||
total_value_distributed: number;
|
||||
by_type: Record<RedeemCodeType, number>;
|
||||
}> {
|
||||
const { data } = await apiClient.get<{
|
||||
total_codes: number;
|
||||
active_codes: number;
|
||||
used_codes: number;
|
||||
expired_codes: number;
|
||||
total_value_distributed: number;
|
||||
by_type: Record<RedeemCodeType, number>;
|
||||
}>('/admin/redeem-codes/stats');
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export redeem codes to CSV
|
||||
* @param filters - Optional filters
|
||||
* @returns CSV data as blob
|
||||
*/
|
||||
export async function exportCodes(filters?: {
|
||||
type?: RedeemCodeType;
|
||||
status?: 'active' | 'used' | 'expired';
|
||||
}): Promise<Blob> {
|
||||
const response = await apiClient.get('/admin/redeem-codes/export', {
|
||||
params: filters,
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const redeemAPI = {
|
||||
list,
|
||||
getById,
|
||||
generate,
|
||||
delete: deleteCode,
|
||||
batchDelete,
|
||||
expire,
|
||||
getStats,
|
||||
exportCodes,
|
||||
};
|
||||
|
||||
export default redeemAPI;
|
||||
109
frontend/src/api/admin/settings.ts
Normal file
109
frontend/src/api/admin/settings.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Admin Settings API endpoints
|
||||
* Handles system settings management for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
|
||||
/**
|
||||
* System settings interface
|
||||
*/
|
||||
export interface SystemSettings {
|
||||
// Registration settings
|
||||
registration_enabled: boolean;
|
||||
email_verify_enabled: boolean;
|
||||
// Default settings
|
||||
default_balance: number;
|
||||
default_concurrency: number;
|
||||
// OEM settings
|
||||
site_name: string;
|
||||
site_logo: string;
|
||||
site_subtitle: string;
|
||||
api_base_url: string;
|
||||
contact_info: string;
|
||||
// SMTP settings
|
||||
smtp_host: string;
|
||||
smtp_port: number;
|
||||
smtp_username: string;
|
||||
smtp_password: string;
|
||||
smtp_from_email: string;
|
||||
smtp_from_name: string;
|
||||
smtp_use_tls: boolean;
|
||||
// Cloudflare Turnstile settings
|
||||
turnstile_enabled: boolean;
|
||||
turnstile_site_key: string;
|
||||
turnstile_secret_key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all system settings
|
||||
* @returns System settings
|
||||
*/
|
||||
export async function getSettings(): Promise<SystemSettings> {
|
||||
const { data } = await apiClient.get<SystemSettings>('/admin/settings');
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update system settings
|
||||
* @param settings - Partial settings to update
|
||||
* @returns Updated settings
|
||||
*/
|
||||
export async function updateSettings(settings: Partial<SystemSettings>): Promise<SystemSettings> {
|
||||
const { data } = await apiClient.put<SystemSettings>('/admin/settings', settings);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test SMTP connection request
|
||||
*/
|
||||
export interface TestSmtpRequest {
|
||||
smtp_host: string;
|
||||
smtp_port: number;
|
||||
smtp_username: string;
|
||||
smtp_password: string;
|
||||
smtp_use_tls: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test SMTP connection with provided config
|
||||
* @param config - SMTP configuration to test
|
||||
* @returns Test result message
|
||||
*/
|
||||
export async function testSmtpConnection(config: TestSmtpRequest): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.post<{ message: string }>('/admin/settings/test-smtp', config);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send test email request
|
||||
*/
|
||||
export interface SendTestEmailRequest {
|
||||
email: string;
|
||||
smtp_host: string;
|
||||
smtp_port: number;
|
||||
smtp_username: string;
|
||||
smtp_password: string;
|
||||
smtp_from_email: string;
|
||||
smtp_from_name: string;
|
||||
smtp_use_tls: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send test email with provided SMTP config
|
||||
* @param request - Email address and SMTP config
|
||||
* @returns Test result message
|
||||
*/
|
||||
export async function sendTestEmail(request: SendTestEmailRequest): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.post<{ message: string }>('/admin/settings/send-test-email', request);
|
||||
return data;
|
||||
}
|
||||
|
||||
export const settingsAPI = {
|
||||
getSettings,
|
||||
updateSettings,
|
||||
testSmtpConnection,
|
||||
sendTestEmail,
|
||||
};
|
||||
|
||||
export default settingsAPI;
|
||||
157
frontend/src/api/admin/subscriptions.ts
Normal file
157
frontend/src/api/admin/subscriptions.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Admin Subscriptions API endpoints
|
||||
* Handles user subscription management for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import type {
|
||||
UserSubscription,
|
||||
SubscriptionProgress,
|
||||
AssignSubscriptionRequest,
|
||||
BulkAssignSubscriptionRequest,
|
||||
ExtendSubscriptionRequest,
|
||||
PaginatedResponse,
|
||||
} from '@/types';
|
||||
|
||||
/**
|
||||
* List all subscriptions with pagination
|
||||
* @param page - Page number (default: 1)
|
||||
* @param pageSize - Items per page (default: 20)
|
||||
* @param filters - Optional filters (status, user_id, group_id)
|
||||
* @returns Paginated list of subscriptions
|
||||
*/
|
||||
export async function list(
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
filters?: {
|
||||
status?: 'active' | 'expired' | 'revoked';
|
||||
user_id?: number;
|
||||
group_id?: number;
|
||||
}
|
||||
): Promise<PaginatedResponse<UserSubscription>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<UserSubscription>>('/admin/subscriptions', {
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...filters,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription by ID
|
||||
* @param id - Subscription ID
|
||||
* @returns Subscription details
|
||||
*/
|
||||
export async function getById(id: number): Promise<UserSubscription> {
|
||||
const { data } = await apiClient.get<UserSubscription>(`/admin/subscriptions/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription progress
|
||||
* @param id - Subscription ID
|
||||
* @returns Subscription progress with usage stats
|
||||
*/
|
||||
export async function getProgress(id: number): Promise<SubscriptionProgress> {
|
||||
const { data } = await apiClient.get<SubscriptionProgress>(`/admin/subscriptions/${id}/progress`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign subscription to user
|
||||
* @param request - Assignment request
|
||||
* @returns Created subscription
|
||||
*/
|
||||
export async function assign(request: AssignSubscriptionRequest): Promise<UserSubscription> {
|
||||
const { data } = await apiClient.post<UserSubscription>('/admin/subscriptions/assign', request);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk assign subscriptions to multiple users
|
||||
* @param request - Bulk assignment request
|
||||
* @returns Created subscriptions
|
||||
*/
|
||||
export async function bulkAssign(request: BulkAssignSubscriptionRequest): Promise<UserSubscription[]> {
|
||||
const { data } = await apiClient.post<UserSubscription[]>('/admin/subscriptions/bulk-assign', request);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend subscription validity
|
||||
* @param id - Subscription ID
|
||||
* @param request - Extension request with days
|
||||
* @returns Updated subscription
|
||||
*/
|
||||
export async function extend(id: number, request: ExtendSubscriptionRequest): Promise<UserSubscription> {
|
||||
const { data } = await apiClient.post<UserSubscription>(`/admin/subscriptions/${id}/extend`, request);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke subscription
|
||||
* @param id - Subscription ID
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function revoke(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/subscriptions/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* List subscriptions by group
|
||||
* @param groupId - Group ID
|
||||
* @param page - Page number
|
||||
* @param pageSize - Items per page
|
||||
* @returns Paginated list of subscriptions in the group
|
||||
*/
|
||||
export async function listByGroup(
|
||||
groupId: number,
|
||||
page: number = 1,
|
||||
pageSize: number = 20
|
||||
): Promise<PaginatedResponse<UserSubscription>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<UserSubscription>>(
|
||||
`/admin/groups/${groupId}/subscriptions`,
|
||||
{
|
||||
params: { page, page_size: pageSize },
|
||||
}
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* List subscriptions by user
|
||||
* @param userId - User ID
|
||||
* @param page - Page number
|
||||
* @param pageSize - Items per page
|
||||
* @returns Paginated list of user's subscriptions
|
||||
*/
|
||||
export async function listByUser(
|
||||
userId: number,
|
||||
page: number = 1,
|
||||
pageSize: number = 20
|
||||
): Promise<PaginatedResponse<UserSubscription>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<UserSubscription>>(
|
||||
`/admin/users/${userId}/subscriptions`,
|
||||
{
|
||||
params: { page, page_size: pageSize },
|
||||
}
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export const subscriptionsAPI = {
|
||||
list,
|
||||
getById,
|
||||
getProgress,
|
||||
assign,
|
||||
bulkAssign,
|
||||
extend,
|
||||
revoke,
|
||||
listByGroup,
|
||||
listByUser,
|
||||
};
|
||||
|
||||
export default subscriptionsAPI;
|
||||
48
frontend/src/api/admin/system.ts
Normal file
48
frontend/src/api/admin/system.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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;
|
||||
112
frontend/src/api/admin/usage.ts
Normal file
112
frontend/src/api/admin/usage.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 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;
|
||||
168
frontend/src/api/admin/users.ts
Normal file
168
frontend/src/api/admin/users.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 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;
|
||||
120
frontend/src/api/auth.ts
Normal file
120
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 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;
|
||||
89
frontend/src/api/client.ts
Normal file
89
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 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;
|
||||
25
frontend/src/api/groups.ts
Normal file
25
frontend/src/api/groups.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 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;
|
||||
23
frontend/src/api/index.ts
Normal file
23
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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';
|
||||
100
frontend/src/api/keys.ts
Normal file
100
frontend/src/api/keys.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 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;
|
||||
65
frontend/src/api/redeem.ts
Normal file
65
frontend/src/api/redeem.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 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;
|
||||
87
frontend/src/api/setup.ts
Normal file
87
frontend/src/api/setup.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
72
frontend/src/api/subscriptions.ts
Normal file
72
frontend/src/api/subscriptions.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
253
frontend/src/api/usage.ts
Normal file
253
frontend/src/api/usage.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* 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;
|
||||
41
frontend/src/api/user.ts
Normal file
41
frontend/src/api/user.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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;
|
||||
176
frontend/src/components/TurnstileWidget.vue
Normal file
176
frontend/src/components/TurnstileWidget.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<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>
|
||||
120
frontend/src/components/account/AccountStatusIndicator.vue
Normal file
120
frontend/src/components/account/AccountStatusIndicator.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<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>
|
||||
342
frontend/src/components/account/AccountTestModal.vue
Normal file
342
frontend/src/components/account/AccountTestModal.vue
Normal file
@@ -0,0 +1,342 @@
|
||||
<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>
|
||||
82
frontend/src/components/account/AccountTodayStatsCell.vue
Normal file
82
frontend/src/components/account/AccountTodayStatsCell.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<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>
|
||||
113
frontend/src/components/account/AccountUsageCell.vue
Normal file
113
frontend/src/components/account/AccountUsageCell.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<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>
|
||||
929
frontend/src/components/account/CreateAccountModal.vue
Normal file
929
frontend/src/components/account/CreateAccountModal.vue
Normal file
@@ -0,0 +1,929 @@
|
||||
<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>
|
||||
646
frontend/src/components/account/EditAccountModal.vue
Normal file
646
frontend/src/components/account/EditAccountModal.vue
Normal file
@@ -0,0 +1,646 @@
|
||||
<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>
|
||||
364
frontend/src/components/account/OAuthAuthorizationFlow.vue
Normal file
364
frontend/src/components/account/OAuthAuthorizationFlow.vue
Normal file
@@ -0,0 +1,364 @@
|
||||
<template>
|
||||
<div class="rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-700 dark:bg-blue-900/30 p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500">
|
||||
<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="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">{{ t('admin.accounts.oauth.title') }}</h4>
|
||||
|
||||
<!-- Auth Method Selection -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-2 block text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||
{{ methodLabel }}
|
||||
</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
v-model="inputMethod"
|
||||
type="radio"
|
||||
value="manual"
|
||||
class="text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="text-sm text-blue-900 dark:text-blue-200">{{ t('admin.accounts.oauth.manualAuth') }}</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
v-model="inputMethod"
|
||||
type="radio"
|
||||
value="cookie"
|
||||
class="text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="text-sm text-blue-900 dark:text-blue-200">{{ t('admin.accounts.oauth.cookieAutoAuth') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cookie Auto-Auth Form -->
|
||||
<div v-if="inputMethod === 'cookie'" class="space-y-4">
|
||||
<div class="rounded-lg border border-blue-300 dark:border-blue-600 bg-white/80 dark:bg-gray-800/80 p-4">
|
||||
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
|
||||
{{ t('admin.accounts.oauth.cookieAutoAuthDesc') }}
|
||||
</p>
|
||||
|
||||
<!-- sessionKey Input -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
<svg class="w-4 h-4 text-blue-500" 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>
|
||||
{{ t('admin.accounts.oauth.sessionKey') }}
|
||||
<span
|
||||
v-if="parsedKeyCount > 1 && allowMultiple"
|
||||
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
|
||||
>
|
||||
{{ t('admin.accounts.oauth.keysCount', { count: parsedKeyCount }) }}
|
||||
</span>
|
||||
<button
|
||||
v-if="showHelp"
|
||||
type="button"
|
||||
class="text-blue-500 hover:text-blue-600"
|
||||
@click="showHelpDialog = !showHelpDialog"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="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>
|
||||
</button>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="sessionKeyInput"
|
||||
rows="3"
|
||||
class="input w-full font-mono text-sm resize-y"
|
||||
:placeholder="allowMultiple ? t('admin.accounts.oauth.sessionKeyPlaceholder') : t('admin.accounts.oauth.sessionKeyPlaceholderSingle')"
|
||||
></textarea>
|
||||
<p
|
||||
v-if="parsedKeyCount > 1 && allowMultiple"
|
||||
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
{{ t('admin.accounts.oauth.batchCreateAccounts', { count: parsedKeyCount }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Help Section -->
|
||||
<div
|
||||
v-if="showHelpDialog && showHelp"
|
||||
class="mb-4 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/30 p-3"
|
||||
>
|
||||
<h5 class="mb-2 font-semibold text-amber-800 dark:text-amber-200">
|
||||
{{ t('admin.accounts.oauth.howToGetSessionKey') }}
|
||||
</h5>
|
||||
<ol class="list-inside list-decimal space-y-1 text-xs text-amber-700 dark:text-amber-300">
|
||||
<li v-html="t('admin.accounts.oauth.step1')"></li>
|
||||
<li v-html="t('admin.accounts.oauth.step2')"></li>
|
||||
<li v-html="t('admin.accounts.oauth.step3')"></li>
|
||||
<li v-html="t('admin.accounts.oauth.step4')"></li>
|
||||
<li v-html="t('admin.accounts.oauth.step5')"></li>
|
||||
<li v-html="t('admin.accounts.oauth.step6')"></li>
|
||||
</ol>
|
||||
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400" v-html="t('admin.accounts.oauth.sessionKeyFormat')"></p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
v-if="error"
|
||||
class="mb-4 rounded-lg border border-red-200 bg-red-50 dark:border-red-700 dark:bg-red-900/30 p-3"
|
||||
>
|
||||
<p class="text-sm text-red-600 dark:text-red-400 whitespace-pre-line">
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Auth Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="loading || !sessionKeyInput.trim()"
|
||||
@click="handleCookieAuth"
|
||||
>
|
||||
<svg v-if="loading" class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4 mr-2" 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.09z" />
|
||||
</svg>
|
||||
{{ loading ? t('admin.accounts.oauth.authorizing') : t('admin.accounts.oauth.startAutoAuth') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual Authorization Flow -->
|
||||
<div v-else class="space-y-4">
|
||||
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
|
||||
{{ t('admin.accounts.oauth.followSteps') }}
|
||||
</p>
|
||||
|
||||
<!-- Step 1: Generate Auth URL -->
|
||||
<div class="rounded-lg border border-blue-300 dark:border-blue-600 bg-white/80 dark:bg-gray-800/80 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white">
|
||||
1
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||
{{ t('admin.accounts.oauth.step1GenerateUrl') }}
|
||||
</p>
|
||||
<button
|
||||
v-if="!authUrl"
|
||||
type="button"
|
||||
:disabled="loading"
|
||||
class="btn btn-primary text-sm"
|
||||
@click="handleGenerateUrl"
|
||||
>
|
||||
<svg v-if="loading" class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
|
||||
</svg>
|
||||
{{ loading ? t('admin.accounts.oauth.generating') : t('admin.accounts.oauth.generateAuthUrl') }}
|
||||
</button>
|
||||
<div v-else class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
:value="authUrl"
|
||||
readonly
|
||||
type="text"
|
||||
class="input flex-1 bg-gray-50 dark:bg-gray-700 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary p-2"
|
||||
title="Copy URL"
|
||||
@click="handleCopyUrl"
|
||||
>
|
||||
<svg v-if="!copied" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
@click="handleRegenerate"
|
||||
>
|
||||
<svg class="w-3 h-3 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</svg>
|
||||
{{ t('admin.accounts.oauth.regenerate') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Open URL and authorize -->
|
||||
<div class="rounded-lg border border-blue-300 dark:border-blue-600 bg-white/80 dark:bg-gray-800/80 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white">
|
||||
2
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||
{{ t('admin.accounts.oauth.step2OpenUrl') }}
|
||||
</p>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300">
|
||||
{{ t('admin.accounts.oauth.openUrlDesc') }}
|
||||
</p>
|
||||
<div v-if="showProxyWarning" class="mt-2 rounded border border-yellow-300 dark:border-yellow-700 bg-yellow-50 dark:bg-yellow-900/30 p-3">
|
||||
<p class="text-xs text-yellow-800 dark:text-yellow-300" v-html="t('admin.accounts.oauth.proxyWarning')">
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Enter authorization code -->
|
||||
<div class="rounded-lg border border-blue-300 dark:border-blue-600 bg-white/80 dark:bg-gray-800/80 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white">
|
||||
3
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||
{{ t('admin.accounts.oauth.step3EnterCode') }}
|
||||
</p>
|
||||
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300" v-html="t('admin.accounts.oauth.authCodeDesc')">
|
||||
</p>
|
||||
<div>
|
||||
<label class="input-label">
|
||||
<svg class="w-4 h-4 inline mr-1 text-blue-500" 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>
|
||||
{{ t('admin.accounts.oauth.authCode') }}
|
||||
</label>
|
||||
<textarea
|
||||
v-model="authCodeInput"
|
||||
rows="3"
|
||||
class="input w-full font-mono text-sm resize-none"
|
||||
:placeholder="t('admin.accounts.oauth.authCodePlaceholder')"
|
||||
></textarea>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-3 h-3 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||
</svg>
|
||||
{{ t('admin.accounts.oauth.authCodeHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
v-if="error"
|
||||
class="mt-3 rounded-lg border border-red-200 bg-red-50 dark:border-red-700 dark:bg-red-900/30 p-3"
|
||||
>
|
||||
<p class="text-sm text-red-600 dark:text-red-400 whitespace-pre-line">
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import type { AddMethod, AuthInputMethod } from '@/composables/useAccountOAuth'
|
||||
|
||||
interface Props {
|
||||
addMethod: AddMethod
|
||||
authUrl?: string
|
||||
sessionId?: string
|
||||
loading?: boolean
|
||||
error?: string
|
||||
showHelp?: boolean
|
||||
showProxyWarning?: boolean
|
||||
allowMultiple?: boolean
|
||||
methodLabel?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
authUrl: '',
|
||||
sessionId: '',
|
||||
loading: false,
|
||||
error: '',
|
||||
showHelp: true,
|
||||
showProxyWarning: true,
|
||||
allowMultiple: false,
|
||||
methodLabel: 'Authorization Method'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'generate-url': []
|
||||
'exchange-code': [code: string]
|
||||
'cookie-auth': [sessionKey: string]
|
||||
'update:inputMethod': [method: AuthInputMethod]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Local state
|
||||
const inputMethod = ref<AuthInputMethod>('manual')
|
||||
const authCodeInput = ref('')
|
||||
const sessionKeyInput = ref('')
|
||||
const showHelpDialog = ref(false)
|
||||
|
||||
// Clipboard
|
||||
const { copied, copyToClipboard } = useClipboard()
|
||||
|
||||
// Computed
|
||||
const parsedKeyCount = computed(() => {
|
||||
return sessionKeyInput.value.split('\n').map(k => k.trim()).filter(k => k).length
|
||||
})
|
||||
|
||||
// Watchers
|
||||
watch(inputMethod, (newVal) => {
|
||||
emit('update:inputMethod', newVal)
|
||||
})
|
||||
|
||||
// Methods
|
||||
const handleGenerateUrl = () => {
|
||||
emit('generate-url')
|
||||
}
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
if (props.authUrl) {
|
||||
copyToClipboard(props.authUrl, 'URL copied to clipboard')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegenerate = () => {
|
||||
authCodeInput.value = ''
|
||||
emit('generate-url')
|
||||
}
|
||||
|
||||
const handleCookieAuth = () => {
|
||||
if (sessionKeyInput.value.trim()) {
|
||||
emit('cookie-auth', sessionKeyInput.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Expose methods and state
|
||||
defineExpose({
|
||||
authCode: authCodeInput,
|
||||
sessionKey: sessionKeyInput,
|
||||
inputMethod,
|
||||
reset: () => {
|
||||
authCodeInput.value = ''
|
||||
sessionKeyInput.value = ''
|
||||
inputMethod.value = 'manual'
|
||||
showHelpDialog.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
240
frontend/src/components/account/ReAuthAccountModal.vue
Normal file
240
frontend/src/components/account/ReAuthAccountModal.vue
Normal file
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<Modal
|
||||
:show="show"
|
||||
:title="t('admin.accounts.reAuthorizeAccount')"
|
||||
size="lg"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div v-if="account" class="space-y-5">
|
||||
<!-- Account Info -->
|
||||
<div class="rounded-lg border border-gray-200 dark:border-dark-600 bg-gray-50 dark:bg-dark-700 p-4">
|
||||
<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.09z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block font-semibold text-gray-900 dark:text-white">{{ account.name }}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.accounts.claudeCodeAccount') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Method Selection -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.oauth.authMethod') }}</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>
|
||||
|
||||
<!-- OAuth Authorization Section -->
|
||||
<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="false"
|
||||
:show-proxy-warning="false"
|
||||
:allow-multiple="false"
|
||||
:method-label="t('admin.accounts.inputMethod')"
|
||||
@generate-url="handleGenerateUrl"
|
||||
@cookie-auth="handleCookieAuth"
|
||||
/>
|
||||
|
||||
<div class="flex justify-between gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
@click="handleClose"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</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, 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 { Account } from '@/types'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
account: Account | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
reauthorized: []
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// OAuth composable
|
||||
const oauth = useAccountOAuth()
|
||||
|
||||
// Refs
|
||||
const oauthFlowRef = ref<InstanceType<typeof OAuthAuthorizationFlow> | null>(null)
|
||||
|
||||
// State
|
||||
const addMethod = ref<AddMethod>('oauth')
|
||||
|
||||
// Computed
|
||||
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 && props.account) {
|
||||
// Initialize addMethod based on current account type
|
||||
if (props.account.type === 'oauth' || props.account.type === 'setup-token') {
|
||||
addMethod.value = props.account.type as AddMethod
|
||||
}
|
||||
} else {
|
||||
resetState()
|
||||
}
|
||||
})
|
||||
|
||||
// Methods
|
||||
const resetState = () => {
|
||||
addMethod.value = 'oauth'
|
||||
oauth.resetState()
|
||||
oauthFlowRef.value?.reset()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleGenerateUrl = async () => {
|
||||
if (!props.account) return
|
||||
await oauth.generateAuthUrl(addMethod.value, props.account.proxy_id)
|
||||
}
|
||||
|
||||
const handleExchangeCode = async () => {
|
||||
if (!props.account) return
|
||||
|
||||
const authCode = oauthFlowRef.value?.authCode?.value || ''
|
||||
if (!authCode.trim() || !oauth.sessionId.value) return
|
||||
|
||||
oauth.loading.value = true
|
||||
oauth.error.value = ''
|
||||
|
||||
try {
|
||||
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.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)
|
||||
|
||||
// Update account with new credentials and type
|
||||
await adminAPI.accounts.update(props.account.id, {
|
||||
type: addMethod.value, // Update type based on selected method
|
||||
credentials: tokenInfo,
|
||||
extra
|
||||
})
|
||||
|
||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||
emit('reauthorized')
|
||||
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) => {
|
||||
if (!props.account) return
|
||||
|
||||
oauth.loading.value = true
|
||||
oauth.error.value = ''
|
||||
|
||||
try {
|
||||
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
|
||||
const endpoint = addMethod.value === 'oauth'
|
||||
? '/admin/accounts/cookie-auth'
|
||||
: '/admin/accounts/setup-token-cookie-auth'
|
||||
|
||||
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
|
||||
session_id: '',
|
||||
code: sessionKey.trim(),
|
||||
...proxyConfig
|
||||
})
|
||||
|
||||
const extra = oauth.buildExtraInfo(tokenInfo)
|
||||
|
||||
// Update account with new credentials and type
|
||||
await adminAPI.accounts.update(props.account.id, {
|
||||
type: addMethod.value, // Update type based on selected method
|
||||
credentials: tokenInfo,
|
||||
extra
|
||||
})
|
||||
|
||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||
emit('reauthorized')
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
oauth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.cookieAuthFailed')
|
||||
} finally {
|
||||
oauth.loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
200
frontend/src/components/account/SetupTokenTimeWindow.vue
Normal file
200
frontend/src/components/account/SetupTokenTimeWindow.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div class="space-y-1">
|
||||
<!-- 5h Time Window Progress -->
|
||||
<div v-if="hasWindowInfo" class="flex items-center gap-1">
|
||||
<!-- Label badge -->
|
||||
<span class="text-[10px] font-medium px-1 rounded w-[32px] text-center shrink-0 bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">
|
||||
5h
|
||||
</span>
|
||||
|
||||
<!-- Progress bar container -->
|
||||
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden shrink-0">
|
||||
<div
|
||||
:class="['h-full transition-all duration-300', barColorClass]"
|
||||
:style="{ width: progressWidth }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Percentage -->
|
||||
<span :class="['text-[10px] font-medium w-[32px] text-right shrink-0', textColorClass]">
|
||||
{{ displayPercent }}
|
||||
</span>
|
||||
|
||||
<!-- Reset time -->
|
||||
<span class="text-[10px] text-gray-400 shrink-0">
|
||||
{{ formatResetTime }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- No recent activity (had activity but window expired > 1 hour) -->
|
||||
<div v-else-if="hasExpiredWindow" class="flex items-center gap-1">
|
||||
<span class="text-[10px] font-medium px-1 rounded w-[32px] text-center shrink-0 bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">
|
||||
5h
|
||||
</span>
|
||||
<span class="text-[10px] text-gray-400 italic">
|
||||
No recent activity
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- No window info yet (never had activity) -->
|
||||
<div v-else class="flex items-center gap-1">
|
||||
<span class="text-[10px] font-medium px-1 rounded w-[32px] text-center shrink-0 bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">
|
||||
5h
|
||||
</span>
|
||||
<span class="text-[10px] text-gray-400 italic">
|
||||
No activity yet
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Hint -->
|
||||
<div class="text-[10px] text-gray-400 italic">
|
||||
Setup Token (time-based)
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import type { Account } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
account: Account
|
||||
}>()
|
||||
|
||||
// Update timer
|
||||
const currentTime = ref(new Date())
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
// Update every second for more accurate countdown
|
||||
timer = setInterval(() => {
|
||||
currentTime.value = new Date()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
})
|
||||
|
||||
// Check if we have window information but it's been expired for more than 1 hour
|
||||
const hasExpiredWindow = computed(() => {
|
||||
if (!props.account.session_window_start || !props.account.session_window_end) {
|
||||
return false
|
||||
}
|
||||
|
||||
const end = new Date(props.account.session_window_end).getTime()
|
||||
const now = currentTime.value.getTime()
|
||||
const expiredMs = now - end
|
||||
|
||||
// Window exists and expired more than 1 hour ago
|
||||
return expiredMs > 1000 * 60 * 60
|
||||
})
|
||||
|
||||
// Check if we have valid window information (not expired for more than 1 hour)
|
||||
const hasWindowInfo = computed(() => {
|
||||
if (!props.account.session_window_start || !props.account.session_window_end) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If window is expired more than 1 hour, don't show progress bar
|
||||
if (hasExpiredWindow.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// Calculate time-based progress (0-100)
|
||||
const timeProgress = computed(() => {
|
||||
if (!props.account.session_window_start || !props.account.session_window_end) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const start = new Date(props.account.session_window_start).getTime()
|
||||
const end = new Date(props.account.session_window_end).getTime()
|
||||
const now = currentTime.value.getTime()
|
||||
|
||||
// Window hasn't started yet
|
||||
if (now < start) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Window has ended
|
||||
if (now >= end) {
|
||||
return 100
|
||||
}
|
||||
|
||||
// Calculate progress within window
|
||||
const total = end - start
|
||||
const elapsed = now - start
|
||||
return Math.round((elapsed / total) * 100)
|
||||
})
|
||||
|
||||
// Progress bar width
|
||||
const progressWidth = computed(() => {
|
||||
return `${Math.min(timeProgress.value, 100)}%`
|
||||
})
|
||||
|
||||
// Display percentage
|
||||
const displayPercent = computed(() => {
|
||||
return `${timeProgress.value}%`
|
||||
})
|
||||
|
||||
// Progress bar color based on progress
|
||||
const barColorClass = computed(() => {
|
||||
if (timeProgress.value >= 100) {
|
||||
return 'bg-red-500'
|
||||
} else if (timeProgress.value >= 80) {
|
||||
return 'bg-amber-500'
|
||||
} else {
|
||||
return 'bg-green-500'
|
||||
}
|
||||
})
|
||||
|
||||
// Text color based on progress
|
||||
const textColorClass = computed(() => {
|
||||
if (timeProgress.value >= 100) {
|
||||
return 'text-red-600 dark:text-red-400'
|
||||
} else if (timeProgress.value >= 80) {
|
||||
return 'text-amber-600 dark:text-amber-400'
|
||||
} else {
|
||||
return 'text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
})
|
||||
|
||||
// Format reset time (time remaining until window end)
|
||||
const formatResetTime = computed(() => {
|
||||
if (!props.account.session_window_end) {
|
||||
return 'N/A'
|
||||
}
|
||||
|
||||
const end = new Date(props.account.session_window_end)
|
||||
const now = currentTime.value
|
||||
const diffMs = end.getTime() - now.getTime()
|
||||
|
||||
if (diffMs <= 0) {
|
||||
// 窗口已过期,计算过期了多久
|
||||
const expiredMs = Math.abs(diffMs)
|
||||
const expiredHours = Math.floor(expiredMs / (1000 * 60 * 60))
|
||||
|
||||
if (expiredHours >= 1) {
|
||||
return 'No recent activity'
|
||||
}
|
||||
return 'Window expired'
|
||||
}
|
||||
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
|
||||
const diffSecs = Math.floor((diffMs % (1000 * 60)) / 1000)
|
||||
|
||||
if (diffHours > 0) {
|
||||
return `${diffHours}h ${diffMins}m`
|
||||
} else if (diffMins > 0) {
|
||||
return `${diffMins}m ${diffSecs}s`
|
||||
} else {
|
||||
return `${diffSecs}s`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
129
frontend/src/components/account/UsageProgressBar.vue
Normal file
129
frontend/src/components/account/UsageProgressBar.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Label badge (fixed width for alignment) -->
|
||||
<span
|
||||
:class="[
|
||||
'text-[10px] font-medium px-1 rounded w-[32px] text-center shrink-0',
|
||||
labelClass
|
||||
]"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
|
||||
<!-- Progress bar container -->
|
||||
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden shrink-0">
|
||||
<div
|
||||
:class="['h-full transition-all duration-300', barClass]"
|
||||
:style="{ width: barWidth }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Percentage -->
|
||||
<span :class="['text-[10px] font-medium w-[32px] text-right shrink-0', textClass]">
|
||||
{{ displayPercent }}
|
||||
</span>
|
||||
|
||||
<!-- Reset time -->
|
||||
<span v-if="resetsAt" class="text-[10px] text-gray-400 shrink-0">
|
||||
{{ formatResetTime }}
|
||||
</span>
|
||||
|
||||
<!-- Window stats (only for 5h window) -->
|
||||
<span v-if="windowStats" class="text-[10px] text-gray-400 shrink-0 ml-1">
|
||||
({{ formatStats }})
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { WindowStats } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
utilization: number // Percentage (0-100+)
|
||||
resetsAt?: string | null
|
||||
color: 'indigo' | 'emerald' | 'purple'
|
||||
windowStats?: WindowStats | null
|
||||
}>()
|
||||
|
||||
// Label background colors
|
||||
const labelClass = computed(() => {
|
||||
const colors = {
|
||||
indigo: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300',
|
||||
emerald: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
purple: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300'
|
||||
}
|
||||
return colors[props.color]
|
||||
})
|
||||
|
||||
// Progress bar color based on utilization
|
||||
const barClass = computed(() => {
|
||||
if (props.utilization >= 100) {
|
||||
return 'bg-red-500'
|
||||
} else if (props.utilization >= 80) {
|
||||
return 'bg-amber-500'
|
||||
} else {
|
||||
return 'bg-green-500'
|
||||
}
|
||||
})
|
||||
|
||||
// Text color based on utilization
|
||||
const textClass = computed(() => {
|
||||
if (props.utilization >= 100) {
|
||||
return 'text-red-600 dark:text-red-400'
|
||||
} else if (props.utilization >= 80) {
|
||||
return 'text-amber-600 dark:text-amber-400'
|
||||
} else {
|
||||
return 'text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
})
|
||||
|
||||
// Bar width (capped at 100%)
|
||||
const barWidth = computed(() => {
|
||||
return `${Math.min(props.utilization, 100)}%`
|
||||
})
|
||||
|
||||
// Display percentage (cap at 999% for readability)
|
||||
const displayPercent = computed(() => {
|
||||
const percent = Math.round(props.utilization)
|
||||
return percent > 999 ? '>999%' : `${percent}%`
|
||||
})
|
||||
|
||||
// Format reset time
|
||||
const formatResetTime = computed(() => {
|
||||
if (!props.resetsAt) return 'N/A'
|
||||
const date = new Date(props.resetsAt)
|
||||
const now = new Date()
|
||||
const diffMs = date.getTime() - now.getTime()
|
||||
|
||||
if (diffMs <= 0) return 'Now'
|
||||
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
|
||||
|
||||
if (diffHours >= 24) {
|
||||
const days = Math.floor(diffHours / 24)
|
||||
return `${days}d ${diffHours % 24}h`
|
||||
} else if (diffHours > 0) {
|
||||
return `${diffHours}h ${diffMins}m`
|
||||
} else {
|
||||
return `${diffMins}m`
|
||||
}
|
||||
})
|
||||
|
||||
// Format window stats
|
||||
const formatStats = computed(() => {
|
||||
if (!props.windowStats) return ''
|
||||
const { requests, tokens, cost } = props.windowStats
|
||||
|
||||
// Format tokens (e.g., 1234567 -> 1.2M)
|
||||
const formatTokens = (t: number): string => {
|
||||
if (t >= 1000000) return `${(t / 1000000).toFixed(1)}M`
|
||||
if (t >= 1000) return `${(t / 1000).toFixed(1)}K`
|
||||
return t.toString()
|
||||
}
|
||||
|
||||
return `${requests}req ${formatTokens(tokens)}tok $${cost.toFixed(2)}`
|
||||
})
|
||||
</script>
|
||||
7
frontend/src/components/account/index.ts
Normal file
7
frontend/src/components/account/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { default as CreateAccountModal } from './CreateAccountModal.vue'
|
||||
export { default as EditAccountModal } from './EditAccountModal.vue'
|
||||
export { default as ReAuthAccountModal } from './ReAuthAccountModal.vue'
|
||||
export { default as OAuthAuthorizationFlow } from './OAuthAuthorizationFlow.vue'
|
||||
export { default as AccountStatusIndicator } from './AccountStatusIndicator.vue'
|
||||
export { default as AccountUsageCell } from './AccountUsageCell.vue'
|
||||
export { default as UsageProgressBar } from './UsageProgressBar.vue'
|
||||
65
frontend/src/components/common/ConfirmDialog.vue
Normal file
65
frontend/src/components/common/ConfirmDialog.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<Modal :show="show" :title="title" size="sm" @close="handleCancel">
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ message }}</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button
|
||||
@click="handleCancel"
|
||||
type="button"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-md hover:bg-gray-50 dark:hover:bg-dark-600 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-dark-800 focus:ring-primary-500"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
@click="handleConfirm"
|
||||
type="button"
|
||||
:class="[
|
||||
'px-4 py-2 text-sm font-medium text-white rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-dark-800',
|
||||
danger
|
||||
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
|
||||
: 'bg-primary-600 hover:bg-primary-700 focus:ring-primary-500'
|
||||
]"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Modal from './Modal.vue'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'confirm'): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
confirmText: 'Confirm',
|
||||
cancelText: 'Cancel',
|
||||
danger: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const handleConfirm = () => {
|
||||
emit('confirm')
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
134
frontend/src/components/common/DataTable.vue
Normal file
134
frontend/src/components/common/DataTable.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="bg-gray-50 dark:bg-dark-800">
|
||||
<tr>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-dark-400 uppercase tracking-wider"
|
||||
:class="{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable }"
|
||||
@click="column.sortable && handleSort(column.key)"
|
||||
>
|
||||
<div class="flex items-center space-x-1">
|
||||
<span>{{ column.label }}</span>
|
||||
<span v-if="column.sortable" class="text-gray-400 dark:text-dark-500">
|
||||
<svg
|
||||
v-if="sortKey === column.key"
|
||||
class="w-4 h-4"
|
||||
:class="{ 'transform rotate-180': sortOrder === 'desc' }"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-dark-900 divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<!-- Loading skeleton -->
|
||||
<tr v-if="loading" v-for="i in 5" :key="i">
|
||||
<td v-for="column in columns" :key="column.key" class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="animate-pulse">
|
||||
<div class="h-4 bg-gray-200 dark:bg-dark-700 rounded w-3/4"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Empty state -->
|
||||
<tr v-else-if="!data || data.length === 0">
|
||||
<td :colspan="columns.length" class="px-6 py-12 text-center text-gray-500 dark:text-dark-400">
|
||||
<slot name="empty">
|
||||
<div class="flex flex-col items-center">
|
||||
<svg class="w-12 h-12 text-gray-400 dark:text-dark-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-lg font-medium text-gray-900 dark:text-gray-100">{{ t('empty.noData') }}</p>
|
||||
</div>
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Data rows -->
|
||||
<tr v-else v-for="(row, index) in sortedData" :key="index" class="hover:bg-gray-50 dark:hover:bg-dark-800">
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]">
|
||||
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
export interface Column {
|
||||
key: string
|
||||
label: string
|
||||
sortable?: boolean
|
||||
formatter?: (value: any, row: any) => string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
columns: Column[]
|
||||
data: any[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false
|
||||
})
|
||||
|
||||
const sortKey = ref<string>('')
|
||||
const sortOrder = ref<'asc' | 'desc'>('asc')
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
if (sortKey.value === key) {
|
||||
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
sortKey.value = key
|
||||
sortOrder.value = 'asc'
|
||||
}
|
||||
}
|
||||
|
||||
const sortedData = computed(() => {
|
||||
if (!sortKey.value || !props.data) return props.data
|
||||
|
||||
return [...props.data].sort((a, b) => {
|
||||
const aVal = a[sortKey.value]
|
||||
const bVal = b[sortKey.value]
|
||||
|
||||
if (aVal === bVal) return 0
|
||||
|
||||
const comparison = aVal > bVal ? 1 : -1
|
||||
return sortOrder.value === 'asc' ? comparison : -comparison
|
||||
})
|
||||
})
|
||||
</script>
|
||||
415
frontend/src/components/common/DateRangePicker.vue
Normal file
415
frontend/src/components/common/DateRangePicker.vue
Normal file
@@ -0,0 +1,415 @@
|
||||
<template>
|
||||
<div class="relative" ref="containerRef">
|
||||
<button
|
||||
type="button"
|
||||
@click="toggle"
|
||||
:class="[
|
||||
'date-picker-trigger',
|
||||
isOpen && 'date-picker-trigger-open'
|
||||
]"
|
||||
>
|
||||
<span class="date-picker-icon">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="date-picker-value">
|
||||
{{ displayValue }}
|
||||
</span>
|
||||
<span class="date-picker-chevron">
|
||||
<svg
|
||||
:class="['w-4 h-4 transition-transform duration-200', isOpen && 'rotate-180']"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Transition name="date-picker-dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="date-picker-dropdown"
|
||||
>
|
||||
<!-- Quick presets -->
|
||||
<div class="date-picker-presets">
|
||||
<button
|
||||
v-for="preset in presets"
|
||||
:key="preset.value"
|
||||
@click="selectPreset(preset)"
|
||||
:class="[
|
||||
'date-picker-preset',
|
||||
isPresetActive(preset) && 'date-picker-preset-active'
|
||||
]"
|
||||
>
|
||||
{{ t(preset.labelKey) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="date-picker-divider"></div>
|
||||
|
||||
<!-- Custom date range inputs -->
|
||||
<div class="date-picker-custom">
|
||||
<div class="date-picker-field">
|
||||
<label class="date-picker-label">{{ t('dates.startDate') }}</label>
|
||||
<input
|
||||
type="date"
|
||||
v-model="localStartDate"
|
||||
:max="localEndDate || today"
|
||||
class="date-picker-input"
|
||||
@change="onDateChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="date-picker-separator">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="date-picker-field">
|
||||
<label class="date-picker-label">{{ t('dates.endDate') }}</label>
|
||||
<input
|
||||
type="date"
|
||||
v-model="localEndDate"
|
||||
:min="localStartDate"
|
||||
:max="today"
|
||||
class="date-picker-input"
|
||||
@change="onDateChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Apply button -->
|
||||
<div class="date-picker-actions">
|
||||
<button
|
||||
@click="apply"
|
||||
class="date-picker-apply"
|
||||
>
|
||||
{{ t('dates.apply') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface DatePreset {
|
||||
labelKey: string
|
||||
value: string
|
||||
getRange: () => { start: string; end: string }
|
||||
}
|
||||
|
||||
interface Props {
|
||||
startDate: string
|
||||
endDate: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:startDate', value: string): void
|
||||
(e: 'update:endDate', value: string): void
|
||||
(e: 'change', range: { startDate: string; endDate: string; preset: string | null }): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const localStartDate = ref(props.startDate)
|
||||
const localEndDate = ref(props.endDate)
|
||||
const activePreset = ref<string | null>('7days')
|
||||
|
||||
const today = computed(() => new Date().toISOString().split('T')[0])
|
||||
|
||||
const presets: DatePreset[] = [
|
||||
{
|
||||
labelKey: 'dates.today',
|
||||
value: 'today',
|
||||
getRange: () => {
|
||||
const t = today.value
|
||||
return { start: t, end: t }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.yesterday',
|
||||
value: 'yesterday',
|
||||
getRange: () => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 1)
|
||||
const yesterday = d.toISOString().split('T')[0]
|
||||
return { start: yesterday, end: yesterday }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.last7Days',
|
||||
value: '7days',
|
||||
getRange: () => {
|
||||
const end = today.value
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 6)
|
||||
const start = d.toISOString().split('T')[0]
|
||||
return { start, end }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.last14Days',
|
||||
value: '14days',
|
||||
getRange: () => {
|
||||
const end = today.value
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 13)
|
||||
const start = d.toISOString().split('T')[0]
|
||||
return { start, end }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.last30Days',
|
||||
value: '30days',
|
||||
getRange: () => {
|
||||
const end = today.value
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 29)
|
||||
const start = d.toISOString().split('T')[0]
|
||||
return { start, end }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.thisMonth',
|
||||
value: 'thisMonth',
|
||||
getRange: () => {
|
||||
const now = new Date()
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0]
|
||||
return { start, end: today.value }
|
||||
}
|
||||
},
|
||||
{
|
||||
labelKey: 'dates.lastMonth',
|
||||
value: 'lastMonth',
|
||||
getRange: () => {
|
||||
const now = new Date()
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString().split('T')[0]
|
||||
const end = new Date(now.getFullYear(), now.getMonth(), 0).toISOString().split('T')[0]
|
||||
return { start, end }
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (activePreset.value) {
|
||||
const preset = presets.find(p => p.value === activePreset.value)
|
||||
if (preset) return t(preset.labelKey)
|
||||
}
|
||||
|
||||
if (localStartDate.value && localEndDate.value) {
|
||||
if (localStartDate.value === localEndDate.value) {
|
||||
return formatDate(localStartDate.value)
|
||||
}
|
||||
return `${formatDate(localStartDate.value)} - ${formatDate(localEndDate.value)}`
|
||||
}
|
||||
|
||||
return t('dates.selectDateRange')
|
||||
})
|
||||
|
||||
const formatDate = (dateStr: string): string => {
|
||||
const date = new Date(dateStr + 'T00:00:00')
|
||||
const dateLocale = locale.value === 'zh' ? 'zh-CN' : 'en-US'
|
||||
return date.toLocaleDateString(dateLocale, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
const isPresetActive = (preset: DatePreset): boolean => {
|
||||
return activePreset.value === preset.value
|
||||
}
|
||||
|
||||
const selectPreset = (preset: DatePreset) => {
|
||||
const range = preset.getRange()
|
||||
localStartDate.value = range.start
|
||||
localEndDate.value = range.end
|
||||
activePreset.value = preset.value
|
||||
}
|
||||
|
||||
const onDateChange = () => {
|
||||
// Check if current dates match any preset
|
||||
activePreset.value = null
|
||||
for (const preset of presets) {
|
||||
const range = preset.getRange()
|
||||
if (range.start === localStartDate.value && range.end === localEndDate.value) {
|
||||
activePreset.value = preset.value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = () => {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
const apply = () => {
|
||||
emit('update:startDate', localStartDate.value)
|
||||
emit('update:endDate', localEndDate.value)
|
||||
emit('change', {
|
||||
startDate: localStartDate.value,
|
||||
endDate: localEndDate.value,
|
||||
preset: activePreset.value
|
||||
})
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen.value) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Sync local state with props
|
||||
watch(() => props.startDate, (val) => {
|
||||
localStartDate.value = val
|
||||
onDateChange()
|
||||
})
|
||||
|
||||
watch(() => props.endDate, (val) => {
|
||||
localEndDate.value = val
|
||||
onDateChange()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
// Initialize active preset detection
|
||||
onDateChange()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.date-picker-trigger {
|
||||
@apply flex items-center gap-2;
|
||||
@apply px-3 py-2 rounded-lg text-sm;
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply border border-gray-200 dark:border-dark-600;
|
||||
@apply text-gray-700 dark:text-gray-300;
|
||||
@apply transition-all duration-200;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-500;
|
||||
@apply hover:border-gray-300 dark:hover:border-dark-500;
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.date-picker-trigger-open {
|
||||
@apply ring-2 ring-primary-500/30 border-primary-500;
|
||||
}
|
||||
|
||||
.date-picker-icon {
|
||||
@apply text-gray-400 dark:text-dark-400;
|
||||
}
|
||||
|
||||
.date-picker-value {
|
||||
@apply font-medium;
|
||||
}
|
||||
|
||||
.date-picker-chevron {
|
||||
@apply text-gray-400 dark:text-dark-400;
|
||||
}
|
||||
|
||||
.date-picker-dropdown {
|
||||
@apply absolute z-[100] mt-2 left-0;
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply rounded-xl;
|
||||
@apply border border-gray-200 dark:border-dark-700;
|
||||
@apply shadow-lg shadow-black/10 dark:shadow-black/30;
|
||||
@apply overflow-hidden;
|
||||
@apply min-w-[320px];
|
||||
}
|
||||
|
||||
.date-picker-presets {
|
||||
@apply grid grid-cols-2 gap-1 p-2;
|
||||
}
|
||||
|
||||
.date-picker-preset {
|
||||
@apply px-3 py-1.5 text-xs font-medium rounded-md;
|
||||
@apply text-gray-600 dark:text-gray-400;
|
||||
@apply hover:bg-gray-100 dark:hover:bg-dark-700;
|
||||
@apply transition-colors duration-150;
|
||||
}
|
||||
|
||||
.date-picker-preset-active {
|
||||
@apply bg-primary-100 dark:bg-primary-900/30;
|
||||
@apply text-primary-700 dark:text-primary-300;
|
||||
}
|
||||
|
||||
.date-picker-divider {
|
||||
@apply border-t border-gray-100 dark:border-dark-700;
|
||||
}
|
||||
|
||||
.date-picker-custom {
|
||||
@apply flex items-end gap-2 p-3;
|
||||
}
|
||||
|
||||
.date-picker-field {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
.date-picker-label {
|
||||
@apply block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1;
|
||||
}
|
||||
|
||||
.date-picker-input {
|
||||
@apply w-full px-2 py-1.5 text-sm rounded-md;
|
||||
@apply bg-gray-50 dark:bg-dark-700;
|
||||
@apply border border-gray-200 dark:border-dark-600;
|
||||
@apply text-gray-900 dark:text-gray-100;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-500;
|
||||
}
|
||||
|
||||
.date-picker-input::-webkit-calendar-picker-indicator {
|
||||
@apply cursor-pointer opacity-60 hover:opacity-100;
|
||||
filter: invert(0.5);
|
||||
}
|
||||
|
||||
.dark .date-picker-input::-webkit-calendar-picker-indicator {
|
||||
filter: invert(0.7);
|
||||
}
|
||||
|
||||
.date-picker-separator {
|
||||
@apply flex items-center justify-center pb-1;
|
||||
}
|
||||
|
||||
.date-picker-actions {
|
||||
@apply flex justify-end p-2 pt-0;
|
||||
}
|
||||
|
||||
.date-picker-apply {
|
||||
@apply px-4 py-1.5 text-sm font-medium rounded-lg;
|
||||
@apply bg-primary-600 text-white;
|
||||
@apply hover:bg-primary-700;
|
||||
@apply transition-colors duration-150;
|
||||
}
|
||||
|
||||
/* Dropdown animation */
|
||||
.date-picker-dropdown-enter-active,
|
||||
.date-picker-dropdown-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.date-picker-dropdown-enter-from,
|
||||
.date-picker-dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
91
frontend/src/components/common/EmptyState.vue
Normal file
91
frontend/src/components/common/EmptyState.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<!-- Icon -->
|
||||
<div class="w-20 h-20 mb-5 rounded-2xl bg-gray-100 dark:bg-dark-800 flex items-center justify-center">
|
||||
<slot name="icon">
|
||||
<component
|
||||
v-if="icon"
|
||||
:is="icon"
|
||||
class="empty-state-icon w-10 h-10"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<svg
|
||||
v-else
|
||||
class="empty-state-icon w-10 h-10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
/>
|
||||
</svg>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h3 class="empty-state-title">
|
||||
{{ title }}
|
||||
</h3>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="empty-state-description">
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<!-- Action -->
|
||||
<div v-if="actionText || $slots.action" class="mt-6">
|
||||
<slot name="action">
|
||||
<component
|
||||
:is="actionTo ? 'RouterLink' : 'button'"
|
||||
v-if="actionText"
|
||||
:to="actionTo"
|
||||
@click="!actionTo && $emit('action')"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg
|
||||
v-if="actionIcon"
|
||||
class="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
{{ actionText }}
|
||||
</component>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
interface Props {
|
||||
icon?: Component | string
|
||||
title?: string
|
||||
description?: string
|
||||
actionText?: string
|
||||
actionTo?: string | object
|
||||
actionIcon?: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: 'No data found',
|
||||
description: '',
|
||||
actionIcon: true
|
||||
})
|
||||
|
||||
defineEmits(['action'])
|
||||
</script>
|
||||
50
frontend/src/components/common/GroupBadge.vue
Normal file
50
frontend/src/components/common/GroupBadge.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-xs font-medium transition-colors',
|
||||
isSubscription
|
||||
? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
|
||||
: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
]"
|
||||
>
|
||||
<!-- Subscription type icon (calendar) -->
|
||||
<svg v-if="isSubscription" class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
|
||||
</svg>
|
||||
<!-- Standard type icon (wallet) -->
|
||||
<svg v-else class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3" />
|
||||
</svg>
|
||||
<span class="truncate">{{ name }}</span>
|
||||
<span
|
||||
v-if="showRate && rateMultiplier !== undefined"
|
||||
:class="[
|
||||
'px-1 py-0.5 rounded text-[10px] font-semibold',
|
||||
isSubscription
|
||||
? 'bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300'
|
||||
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
|
||||
]"
|
||||
>
|
||||
{{ rateMultiplier }}x
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { SubscriptionType } from '@/types'
|
||||
|
||||
interface Props {
|
||||
name: string
|
||||
subscriptionType?: SubscriptionType
|
||||
rateMultiplier?: number
|
||||
showRate?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
subscriptionType: 'standard',
|
||||
showRate: true
|
||||
})
|
||||
|
||||
const isSubscription = computed(() => props.subscriptionType === 'subscription')
|
||||
</script>
|
||||
61
frontend/src/components/common/GroupSelector.vue
Normal file
61
frontend/src/components/common/GroupSelector.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div>
|
||||
<label class="input-label">
|
||||
Groups
|
||||
<span class="text-gray-400 font-normal">({{ modelValue.length }} selected)</span>
|
||||
</label>
|
||||
<div
|
||||
class="grid grid-cols-2 gap-1 max-h-32 overflow-y-auto p-2 border border-gray-200 dark:border-dark-600 rounded-lg bg-gray-50 dark:bg-dark-800"
|
||||
>
|
||||
<label
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-white dark:hover:bg-dark-700 cursor-pointer transition-colors"
|
||||
:title="`${group.rate_multiplier}x rate · ${group.account_count || 0} accounts`"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="group.id"
|
||||
:checked="modelValue.includes(group.id)"
|
||||
@change="handleChange(group.id, ($event.target as HTMLInputElement).checked)"
|
||||
class="w-3.5 h-3.5 text-primary-500 border-gray-300 dark:border-dark-500 rounded focus:ring-primary-500 shrink-0"
|
||||
/>
|
||||
<GroupBadge
|
||||
:name="group.name"
|
||||
:subscription-type="group.subscription_type"
|
||||
:rate-multiplier="group.rate_multiplier"
|
||||
class="flex-1 min-w-0"
|
||||
/>
|
||||
<span class="text-xs text-gray-400 shrink-0">{{ group.account_count || 0 }}</span>
|
||||
</label>
|
||||
<div
|
||||
v-if="groups.length === 0"
|
||||
class="col-span-2 text-center text-sm text-gray-500 dark:text-gray-400 py-2"
|
||||
>
|
||||
No groups available
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import GroupBadge from './GroupBadge.vue'
|
||||
import type { Group } from '@/types'
|
||||
|
||||
interface Props {
|
||||
modelValue: number[]
|
||||
groups: Group[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number[]]
|
||||
}>()
|
||||
|
||||
const handleChange = (groupId: number, checked: boolean) => {
|
||||
const newValue = checked
|
||||
? [...props.modelValue, groupId]
|
||||
: props.modelValue.filter(id => id !== groupId)
|
||||
emit('update:modelValue', newValue)
|
||||
}
|
||||
</script>
|
||||
65
frontend/src/components/common/LoadingSpinner.vue
Normal file
65
frontend/src/components/common/LoadingSpinner.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div
|
||||
:class="['spinner', sizeClasses, colorClass]"
|
||||
role="status"
|
||||
:aria-label="t('common.loading')"
|
||||
>
|
||||
<span class="sr-only">{{ t('common.loading') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
type SpinnerSize = 'sm' | 'md' | 'lg' | 'xl'
|
||||
type SpinnerColor = 'primary' | 'secondary' | 'white' | 'gray'
|
||||
|
||||
interface Props {
|
||||
size?: SpinnerSize
|
||||
color?: SpinnerColor
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'md',
|
||||
color: 'primary'
|
||||
})
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
const sizes: Record<SpinnerSize, string> = {
|
||||
sm: 'w-4 h-4 border-2',
|
||||
md: 'w-8 h-8 border-2',
|
||||
lg: 'w-12 h-12 border-[3px]',
|
||||
xl: 'w-16 h-16 border-4'
|
||||
}
|
||||
return sizes[props.size]
|
||||
})
|
||||
|
||||
const colorClass = computed(() => {
|
||||
const colors: Record<SpinnerColor, string> = {
|
||||
primary: 'text-primary-500',
|
||||
secondary: 'text-gray-500 dark:text-dark-400',
|
||||
white: 'text-white',
|
||||
gray: 'text-gray-400 dark:text-dark-500'
|
||||
}
|
||||
return colors[props.color]
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.spinner {
|
||||
@apply inline-block rounded-full border-solid border-current border-r-transparent;
|
||||
animation: spin 0.75s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
100
frontend/src/components/common/LocaleSwitcher.vue
Normal file
100
frontend/src/components/common/LocaleSwitcher.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="relative" ref="dropdownRef">
|
||||
<button
|
||||
@click="toggleDropdown"
|
||||
class="flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
|
||||
:title="currentLocale?.name"
|
||||
>
|
||||
<span class="text-base">{{ currentLocale?.flag }}</span>
|
||||
<span class="hidden sm:inline">{{ currentLocale?.code.toUpperCase() }}</span>
|
||||
<svg
|
||||
class="w-3.5 h-3.5 text-gray-400 transition-transform duration-200"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<transition name="dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute right-0 mt-1 w-32 rounded-lg bg-white dark:bg-dark-800 shadow-lg border border-gray-200 dark:border-dark-700 overflow-hidden z-50"
|
||||
>
|
||||
<button
|
||||
v-for="locale in availableLocales"
|
||||
:key="locale.code"
|
||||
@click="selectLocale(locale.code)"
|
||||
class="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
|
||||
:class="{ 'bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400': locale.code === currentLocaleCode }"
|
||||
>
|
||||
<span class="text-base">{{ locale.flag }}</span>
|
||||
<span>{{ locale.name }}</span>
|
||||
<svg
|
||||
v-if="locale.code === currentLocaleCode"
|
||||
class="w-4 h-4 ml-auto text-primary-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { setLocale, availableLocales } from '@/i18n'
|
||||
|
||||
const { locale } = useI18n()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const currentLocaleCode = computed(() => locale.value)
|
||||
const currentLocale = computed(() => availableLocales.find(l => l.code === locale.value))
|
||||
|
||||
function toggleDropdown() {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
function selectLocale(code: string) {
|
||||
setLocale(code)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-4px);
|
||||
}
|
||||
</style>
|
||||
122
frontend/src/components/common/Modal.vue
Normal file
122
frontend/src/components/common/Modal.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show"
|
||||
class="modal-overlay"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@click.self="handleClose"
|
||||
>
|
||||
<!-- Modal panel -->
|
||||
<div
|
||||
:class="['modal-content', sizeClasses]"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="modal-header">
|
||||
<h3
|
||||
id="modal-title"
|
||||
class="modal-title"
|
||||
>
|
||||
{{ title }}
|
||||
</h3>
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="p-2 -mr-2 rounded-xl text-gray-400 dark:text-dark-500 hover:text-gray-600 dark:hover:text-dark-300 hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="modal-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="modal-footer"
|
||||
>
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
title: string
|
||||
size?: ModalSize
|
||||
closeOnEscape?: boolean
|
||||
closeOnClickOutside?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'md',
|
||||
closeOnEscape: true,
|
||||
closeOnClickOutside: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
const sizes: Record<ModalSize, string> = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
full: 'max-w-4xl'
|
||||
}
|
||||
return sizes[props.size]
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
if (props.closeOnClickOutside) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (props.show && props.closeOnEscape && event.key === 'Escape') {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent body scroll when modal is open
|
||||
watch(
|
||||
() => props.show,
|
||||
(isOpen) => {
|
||||
console.log('[Modal] show changed to:', isOpen)
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
214
frontend/src/components/common/Pagination.vue
Normal file
214
frontend/src/components/common/Pagination.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-white dark:bg-dark-800 border-t border-gray-200 dark:border-dark-700 sm:px-6">
|
||||
<div class="flex items-center justify-between flex-1 sm:hidden">
|
||||
<!-- Mobile pagination -->
|
||||
<button
|
||||
@click="goToPage(page - 1)"
|
||||
:disabled="page === 1"
|
||||
class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-md hover:bg-gray-50 dark:hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ t('pagination.previous') }}
|
||||
</button>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('pagination.pageOf', { page, total: totalPages }) }}
|
||||
</span>
|
||||
<button
|
||||
@click="goToPage(page + 1)"
|
||||
:disabled="page === totalPages"
|
||||
class="relative inline-flex items-center px-4 py-2 ml-3 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-md hover:bg-gray-50 dark:hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ t('pagination.next') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
||||
<!-- Desktop pagination info -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('pagination.showing') }}
|
||||
<span class="font-medium">{{ fromItem }}</span>
|
||||
{{ t('pagination.to') }}
|
||||
<span class="font-medium">{{ toItem }}</span>
|
||||
{{ t('pagination.of') }}
|
||||
<span class="font-medium">{{ total }}</span>
|
||||
{{ t('pagination.results') }}
|
||||
</p>
|
||||
|
||||
<!-- Page size selector -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('pagination.perPage') }}:</span>
|
||||
<div class="w-20 page-size-select">
|
||||
<Select
|
||||
:model-value="pageSize"
|
||||
:options="pageSizeSelectOptions"
|
||||
@update:model-value="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop pagination buttons -->
|
||||
<nav
|
||||
class="relative z-0 inline-flex -space-x-px rounded-md shadow-sm"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<!-- Previous button -->
|
||||
<button
|
||||
@click="goToPage(page - 1)"
|
||||
:disabled="page === 1"
|
||||
class="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-l-md hover:bg-gray-50 dark:hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:aria-label="t('pagination.previous')"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Page numbers -->
|
||||
<button
|
||||
v-for="pageNum in visiblePages"
|
||||
:key="pageNum"
|
||||
@click="typeof pageNum === 'number' && goToPage(pageNum)"
|
||||
:disabled="typeof pageNum !== 'number'"
|
||||
:class="[
|
||||
'relative inline-flex items-center px-4 py-2 text-sm font-medium border',
|
||||
pageNum === page
|
||||
? 'z-10 bg-primary-50 dark:bg-primary-900/30 border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'bg-white dark:bg-dark-700 border-gray-300 dark:border-dark-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-dark-600',
|
||||
typeof pageNum !== 'number' && 'cursor-default'
|
||||
]"
|
||||
:aria-label="typeof pageNum === 'number' ? t('pagination.goToPage', { page: pageNum }) : undefined"
|
||||
:aria-current="pageNum === page ? 'page' : undefined"
|
||||
>
|
||||
{{ pageNum }}
|
||||
</button>
|
||||
|
||||
<!-- Next button -->
|
||||
<button
|
||||
@click="goToPage(page + 1)"
|
||||
:disabled="page === totalPages"
|
||||
class="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-r-md hover:bg-gray-50 dark:hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:aria-label="t('pagination.next')"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Select from './Select.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Props {
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
pageSizeOptions?: number[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:page', page: number): void
|
||||
(e: 'update:pageSize', pageSize: number): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
pageSizeOptions: () => [10, 20, 50, 100]
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const totalPages = computed(() => Math.ceil(props.total / props.pageSize))
|
||||
|
||||
const fromItem = computed(() => {
|
||||
if (props.total === 0) return 0
|
||||
return (props.page - 1) * props.pageSize + 1
|
||||
})
|
||||
|
||||
const toItem = computed(() => {
|
||||
const to = props.page * props.pageSize
|
||||
return to > props.total ? props.total : to
|
||||
})
|
||||
|
||||
const pageSizeSelectOptions = computed(() => {
|
||||
return props.pageSizeOptions.map(size => ({
|
||||
value: size,
|
||||
label: String(size)
|
||||
}))
|
||||
})
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const pages: (number | string)[] = []
|
||||
const maxVisible = 7
|
||||
const total = totalPages.value
|
||||
|
||||
if (total <= maxVisible) {
|
||||
// Show all pages if total is small
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
} else {
|
||||
// Always show first page
|
||||
pages.push(1)
|
||||
|
||||
const start = Math.max(2, props.page - 2)
|
||||
const end = Math.min(total - 1, props.page + 2)
|
||||
|
||||
// Add ellipsis before if needed
|
||||
if (start > 2) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
// Add middle pages
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
// Add ellipsis after if needed
|
||||
if (end < total - 1) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
// Always show last page
|
||||
pages.push(total)
|
||||
}
|
||||
|
||||
return pages
|
||||
})
|
||||
|
||||
const goToPage = (newPage: number) => {
|
||||
if (newPage >= 1 && newPage <= totalPages.value && newPage !== props.page) {
|
||||
emit('update:page', newPage)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (value: string | number | null) => {
|
||||
if (value === null) return
|
||||
const newPageSize = typeof value === 'string' ? parseInt(value) : value
|
||||
emit('update:pageSize', newPageSize)
|
||||
// Reset to first page when page size changes
|
||||
if (props.page !== 1) {
|
||||
emit('update:page', 1)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-size-select :deep(.select-trigger) {
|
||||
@apply py-1.5 px-3 text-sm;
|
||||
}
|
||||
</style>
|
||||
426
frontend/src/components/common/ProxySelector.vue
Normal file
426
frontend/src/components/common/ProxySelector.vue
Normal file
@@ -0,0 +1,426 @@
|
||||
<template>
|
||||
<div class="relative" ref="containerRef">
|
||||
<button
|
||||
type="button"
|
||||
@click="toggle"
|
||||
:disabled="disabled"
|
||||
:class="[
|
||||
'select-trigger',
|
||||
isOpen && 'select-trigger-open',
|
||||
disabled && 'select-trigger-disabled'
|
||||
]"
|
||||
>
|
||||
<span class="select-value">
|
||||
{{ selectedLabel }}
|
||||
</span>
|
||||
<span class="select-icon">
|
||||
<svg
|
||||
:class="['w-5 h-5 transition-transform duration-200', isOpen && 'rotate-180']"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Transition name="select-dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="select-dropdown"
|
||||
>
|
||||
<!-- Search and Batch Test Header -->
|
||||
<div class="select-header">
|
||||
<div class="select-search">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('admin.proxies.searchProxies')"
|
||||
class="select-search-input"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-if="proxies.length > 0"
|
||||
type="button"
|
||||
@click.stop="handleBatchTest"
|
||||
:disabled="batchTesting"
|
||||
class="batch-test-btn"
|
||||
:title="t('admin.proxies.batchTest')"
|
||||
>
|
||||
<svg v-if="batchTesting" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Options list -->
|
||||
<div class="select-options">
|
||||
<!-- No Proxy option -->
|
||||
<div
|
||||
@click="selectOption(null)"
|
||||
:class="[
|
||||
'select-option',
|
||||
modelValue === null && 'select-option-selected'
|
||||
]"
|
||||
>
|
||||
<span class="select-option-label">{{ t('admin.accounts.noProxy') }}</span>
|
||||
<svg
|
||||
v-if="modelValue === null"
|
||||
class="w-4 h-4 text-primary-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Proxy options -->
|
||||
<div
|
||||
v-for="proxy in filteredProxies"
|
||||
:key="proxy.id"
|
||||
@click="selectOption(proxy.id)"
|
||||
:class="[
|
||||
'select-option',
|
||||
modelValue === proxy.id && 'select-option-selected'
|
||||
]"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate font-medium">{{ proxy.name }}</span>
|
||||
<!-- Account count badge -->
|
||||
<span
|
||||
v-if="proxy.account_count !== undefined"
|
||||
class="flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 dark:bg-dark-600 text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
{{ proxy.account_count }}
|
||||
</span>
|
||||
<!-- Test result badges -->
|
||||
<template v-if="testResults[proxy.id]">
|
||||
<span
|
||||
v-if="testResults[proxy.id].success"
|
||||
class="flex-shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400"
|
||||
>
|
||||
<span v-if="testResults[proxy.id].country">{{ testResults[proxy.id].country }}</span>
|
||||
<span v-if="testResults[proxy.id].latency_ms">{{ testResults[proxy.id].latency_ms }}ms</span>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400"
|
||||
>
|
||||
{{ t('admin.proxies.testFailed') }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{{ proxy.protocol }}://{{ proxy.host }}:{{ proxy.port }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Individual test button -->
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="handleTestProxy(proxy)"
|
||||
:disabled="testingProxyIds.has(proxy.id)"
|
||||
class="test-btn"
|
||||
:title="t('admin.proxies.testConnection')"
|
||||
>
|
||||
<svg v-if="testingProxyIds.has(proxy.id)" class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<svg v-else class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<svg
|
||||
v-if="modelValue === proxy.id"
|
||||
class="w-4 h-4 text-primary-500 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="filteredProxies.length === 0 && searchQuery" class="select-empty">
|
||||
{{ t('common.noOptionsFound') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Proxy } from '@/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface ProxyTestResult {
|
||||
success: boolean
|
||||
message: string
|
||||
latency_ms?: number
|
||||
ip_address?: string
|
||||
city?: string
|
||||
region?: string
|
||||
country?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: number | null
|
||||
proxies: Proxy[]
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number | null]
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// Test state
|
||||
const testResults = reactive<Record<number, ProxyTestResult>>({})
|
||||
const testingProxyIds = reactive(new Set<number>())
|
||||
const batchTesting = ref(false)
|
||||
|
||||
const selectedProxy = computed(() => {
|
||||
if (props.modelValue === null) return null
|
||||
return props.proxies.find(p => p.id === props.modelValue) || null
|
||||
})
|
||||
|
||||
const selectedLabel = computed(() => {
|
||||
if (!selectedProxy.value) {
|
||||
return t('admin.accounts.noProxy')
|
||||
}
|
||||
const proxy = selectedProxy.value
|
||||
return `${proxy.name} (${proxy.protocol}://${proxy.host}:${proxy.port})`
|
||||
})
|
||||
|
||||
const filteredProxies = computed(() => {
|
||||
if (!searchQuery.value) {
|
||||
return props.proxies
|
||||
}
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return props.proxies.filter(proxy => {
|
||||
const name = proxy.name.toLowerCase()
|
||||
const host = proxy.host.toLowerCase()
|
||||
return name.includes(query) || host.includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
const toggle = () => {
|
||||
if (props.disabled) return
|
||||
isOpen.value = !isOpen.value
|
||||
if (isOpen.value) {
|
||||
nextTick(() => {
|
||||
searchInputRef.value?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const selectOption = (value: number | null) => {
|
||||
emit('update:modelValue', value)
|
||||
isOpen.value = false
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
const handleTestProxy = async (proxy: Proxy) => {
|
||||
if (testingProxyIds.has(proxy.id)) return
|
||||
|
||||
testingProxyIds.add(proxy.id)
|
||||
try {
|
||||
const result = await adminAPI.proxies.testProxy(proxy.id)
|
||||
testResults[proxy.id] = result
|
||||
} catch (error: any) {
|
||||
testResults[proxy.id] = {
|
||||
success: false,
|
||||
message: error.response?.data?.detail || 'Test failed'
|
||||
}
|
||||
} finally {
|
||||
testingProxyIds.delete(proxy.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBatchTest = async () => {
|
||||
if (batchTesting.value || props.proxies.length === 0) return
|
||||
|
||||
batchTesting.value = true
|
||||
|
||||
// Test all proxies in parallel
|
||||
const testPromises = props.proxies.map(async (proxy) => {
|
||||
testingProxyIds.add(proxy.id)
|
||||
try {
|
||||
const result = await adminAPI.proxies.testProxy(proxy.id)
|
||||
testResults[proxy.id] = result
|
||||
} catch (error: any) {
|
||||
testResults[proxy.id] = {
|
||||
success: false,
|
||||
message: error.response?.data?.detail || 'Test failed'
|
||||
}
|
||||
} finally {
|
||||
testingProxyIds.delete(proxy.id)
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(testPromises)
|
||||
batchTesting.value = false
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
|
||||
isOpen.value = false
|
||||
searchQuery.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen.value) {
|
||||
isOpen.value = false
|
||||
searchQuery.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.select-trigger {
|
||||
@apply w-full flex items-center justify-between gap-2;
|
||||
@apply px-4 py-2.5 rounded-xl text-sm;
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply border border-gray-200 dark:border-dark-600;
|
||||
@apply text-gray-900 dark:text-gray-100;
|
||||
@apply transition-all duration-200;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-500;
|
||||
@apply hover:border-gray-300 dark:hover:border-dark-500;
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.select-trigger-open {
|
||||
@apply ring-2 ring-primary-500/30 border-primary-500;
|
||||
}
|
||||
|
||||
.select-trigger-disabled {
|
||||
@apply bg-gray-100 dark:bg-dark-900 cursor-not-allowed opacity-60;
|
||||
}
|
||||
|
||||
.select-value {
|
||||
@apply flex-1 text-left truncate;
|
||||
}
|
||||
|
||||
.select-icon {
|
||||
@apply flex-shrink-0 text-gray-400 dark:text-dark-400;
|
||||
}
|
||||
|
||||
.select-dropdown {
|
||||
@apply absolute z-[100] w-full mt-2;
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply rounded-xl;
|
||||
@apply border border-gray-200 dark:border-dark-700;
|
||||
@apply shadow-lg shadow-black/10 dark:shadow-black/30;
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
.select-header {
|
||||
@apply flex items-center gap-2 px-3 py-2;
|
||||
@apply border-b border-gray-100 dark:border-dark-700;
|
||||
}
|
||||
|
||||
.select-search {
|
||||
@apply flex-1 flex items-center gap-2;
|
||||
}
|
||||
|
||||
.select-search-input {
|
||||
@apply flex-1 bg-transparent text-sm;
|
||||
@apply text-gray-900 dark:text-gray-100;
|
||||
@apply placeholder:text-gray-400 dark:placeholder:text-dark-400;
|
||||
@apply focus:outline-none;
|
||||
}
|
||||
|
||||
.batch-test-btn {
|
||||
@apply flex-shrink-0 p-1.5 rounded-lg;
|
||||
@apply text-gray-500 hover:text-emerald-600 dark:hover:text-emerald-400;
|
||||
@apply hover:bg-emerald-50 dark:hover:bg-emerald-900/20;
|
||||
@apply transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.select-options {
|
||||
@apply max-h-60 overflow-y-auto py-1;
|
||||
}
|
||||
|
||||
.select-option {
|
||||
@apply flex items-center justify-between gap-2;
|
||||
@apply px-4 py-2.5 text-sm;
|
||||
@apply text-gray-700 dark:text-gray-300;
|
||||
@apply cursor-pointer transition-colors duration-150;
|
||||
@apply hover:bg-gray-50 dark:hover:bg-dark-700;
|
||||
}
|
||||
|
||||
.select-option-selected {
|
||||
@apply bg-primary-50 dark:bg-primary-900/20;
|
||||
@apply text-primary-700 dark:text-primary-300;
|
||||
}
|
||||
|
||||
.select-option-label {
|
||||
@apply truncate;
|
||||
}
|
||||
|
||||
.select-empty {
|
||||
@apply px-4 py-8 text-center text-sm;
|
||||
@apply text-gray-500 dark:text-dark-400;
|
||||
}
|
||||
|
||||
.test-btn {
|
||||
@apply flex-shrink-0 p-1 rounded;
|
||||
@apply text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400;
|
||||
@apply hover:bg-emerald-50 dark:hover:bg-emerald-900/20;
|
||||
@apply transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
/* Dropdown animation */
|
||||
.select-dropdown-enter-active,
|
||||
.select-dropdown-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.select-dropdown-enter-from,
|
||||
.select-dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
243
frontend/src/components/common/README.md
Normal file
243
frontend/src/components/common/README.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# Common Components
|
||||
|
||||
This directory contains reusable Vue 3 components built with Composition API, TypeScript, and TailwindCSS.
|
||||
|
||||
## Components
|
||||
|
||||
### DataTable.vue
|
||||
A generic data table component with sorting, loading states, and custom cell rendering.
|
||||
|
||||
**Props:**
|
||||
- `columns: Column[]` - Array of column definitions with key, label, sortable, and formatter
|
||||
- `data: any[]` - Array of data objects to display
|
||||
- `loading?: boolean` - Show loading skeleton
|
||||
|
||||
**Slots:**
|
||||
- `empty` - Custom empty state content
|
||||
- `cell-{key}` - Custom cell renderer for specific column (receives `row` and `value`)
|
||||
|
||||
**Usage:**
|
||||
```vue
|
||||
<DataTable
|
||||
:columns="[
|
||||
{ key: 'name', label: 'Name', sortable: true },
|
||||
{ key: 'email', label: 'Email' },
|
||||
{ key: 'status', label: 'Status', formatter: (val) => val.toUpperCase() }
|
||||
]"
|
||||
:data="users"
|
||||
:loading="isLoading"
|
||||
>
|
||||
<template #cell-actions="{ row }">
|
||||
<button @click="editUser(row)">Edit</button>
|
||||
</template>
|
||||
</DataTable>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Pagination.vue
|
||||
Pagination component with page numbers, navigation, and page size selector.
|
||||
|
||||
**Props:**
|
||||
- `total: number` - Total number of items
|
||||
- `page: number` - Current page (1-indexed)
|
||||
- `pageSize: number` - Items per page
|
||||
- `pageSizeOptions?: number[]` - Available page size options (default: [10, 20, 50, 100])
|
||||
|
||||
**Events:**
|
||||
- `update:page` - Emitted when page changes
|
||||
- `update:pageSize` - Emitted when page size changes
|
||||
|
||||
**Usage:**
|
||||
```vue
|
||||
<Pagination
|
||||
:total="totalUsers"
|
||||
:page="currentPage"
|
||||
:pageSize="pageSize"
|
||||
@update:page="currentPage = $event"
|
||||
@update:pageSize="pageSize = $event"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Modal.vue
|
||||
Modal dialog with customizable size and close behavior.
|
||||
|
||||
**Props:**
|
||||
- `show: boolean` - Control modal visibility
|
||||
- `title: string` - Modal title
|
||||
- `size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'` - Modal size (default: 'md')
|
||||
- `closeOnEscape?: boolean` - Close on Escape key (default: true)
|
||||
- `closeOnClickOutside?: boolean` - Close on backdrop click (default: true)
|
||||
|
||||
**Events:**
|
||||
- `close` - Emitted when modal should close
|
||||
|
||||
**Slots:**
|
||||
- `default` - Modal body content
|
||||
- `footer` - Modal footer content
|
||||
|
||||
**Usage:**
|
||||
```vue
|
||||
<Modal :show="showModal" title="Edit User" size="lg" @close="showModal = false">
|
||||
<form @submit.prevent="saveUser">
|
||||
<!-- Form content -->
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button @click="showModal = false">Cancel</button>
|
||||
<button @click="saveUser">Save</button>
|
||||
</template>
|
||||
</Modal>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ConfirmDialog.vue
|
||||
Confirmation dialog built on top of Modal component.
|
||||
|
||||
**Props:**
|
||||
- `show: boolean` - Control dialog visibility
|
||||
- `title: string` - Dialog title
|
||||
- `message: string` - Confirmation message
|
||||
- `confirmText?: string` - Confirm button text (default: 'Confirm')
|
||||
- `cancelText?: string` - Cancel button text (default: 'Cancel')
|
||||
- `danger?: boolean` - Use danger/red styling (default: false)
|
||||
|
||||
**Events:**
|
||||
- `confirm` - Emitted when user confirms
|
||||
- `cancel` - Emitted when user cancels
|
||||
|
||||
**Usage:**
|
||||
```vue
|
||||
<ConfirmDialog
|
||||
:show="showDeleteConfirm"
|
||||
title="Delete User"
|
||||
message="Are you sure you want to delete this user? This action cannot be undone."
|
||||
confirm-text="Delete"
|
||||
cancel-text="Cancel"
|
||||
danger
|
||||
@confirm="deleteUser"
|
||||
@cancel="showDeleteConfirm = false"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### StatCard.vue
|
||||
Statistics card component for displaying metrics with optional change indicators.
|
||||
|
||||
**Props:**
|
||||
- `title: string` - Card title
|
||||
- `value: number | string` - Main value to display
|
||||
- `icon?: Component` - Icon component
|
||||
- `change?: number` - Percentage change value
|
||||
- `changeType?: 'up' | 'down' | 'neutral'` - Change direction (default: 'neutral')
|
||||
- `formatValue?: (value) => string` - Custom value formatter
|
||||
|
||||
**Usage:**
|
||||
```vue
|
||||
<StatCard
|
||||
title="Total Users"
|
||||
:value="1234"
|
||||
:icon="UserIcon"
|
||||
:change="12.5"
|
||||
change-type="up"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Toast.vue
|
||||
Toast notification component that automatically displays toasts from the app store.
|
||||
|
||||
**Usage:**
|
||||
```vue
|
||||
<!-- Add once in App.vue or layout -->
|
||||
<Toast />
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Trigger toasts from anywhere using the app store
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
appStore.addToast({
|
||||
type: 'success',
|
||||
title: 'Success!',
|
||||
message: 'User created successfully',
|
||||
duration: 3000
|
||||
})
|
||||
|
||||
appStore.addToast({
|
||||
type: 'error',
|
||||
message: 'Failed to delete user'
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### LoadingSpinner.vue
|
||||
Simple animated loading spinner.
|
||||
|
||||
**Props:**
|
||||
- `size?: 'sm' | 'md' | 'lg' | 'xl'` - Spinner size (default: 'md')
|
||||
- `color?: 'primary' | 'secondary' | 'white' | 'gray'` - Spinner color (default: 'primary')
|
||||
|
||||
**Usage:**
|
||||
```vue
|
||||
<LoadingSpinner size="lg" color="primary" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### EmptyState.vue
|
||||
Empty state placeholder with icon, message, and optional action button.
|
||||
|
||||
**Props:**
|
||||
- `icon?: Component` - Icon component
|
||||
- `title: string` - Empty state title
|
||||
- `description: string` - Empty state description
|
||||
- `actionText?: string` - Action button text
|
||||
- `actionTo?: string | object` - Router link destination
|
||||
- `actionIcon?: boolean` - Show plus icon in button (default: true)
|
||||
|
||||
**Slots:**
|
||||
- `icon` - Custom icon content
|
||||
- `action` - Custom action button/link
|
||||
|
||||
**Usage:**
|
||||
```vue
|
||||
<EmptyState
|
||||
title="No users found"
|
||||
description="Get started by creating your first user account."
|
||||
action-text="Add User"
|
||||
:action-to="{ name: 'users-create' }"
|
||||
/>
|
||||
```
|
||||
|
||||
## Import
|
||||
|
||||
You can import components individually:
|
||||
|
||||
```typescript
|
||||
import { DataTable, Pagination, Modal } from '@/components/common'
|
||||
```
|
||||
|
||||
Or import specific components:
|
||||
|
||||
```typescript
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
All components include:
|
||||
- **TypeScript support** with proper type definitions
|
||||
- **Accessibility** with ARIA attributes and keyboard navigation
|
||||
- **Responsive design** with mobile-friendly layouts
|
||||
- **TailwindCSS styling** for consistent design
|
||||
- **Vue 3 Composition API** with `<script setup>`
|
||||
- **Slot support** for customization
|
||||
319
frontend/src/components/common/Select.vue
Normal file
319
frontend/src/components/common/Select.vue
Normal file
@@ -0,0 +1,319 @@
|
||||
<template>
|
||||
<div class="relative" ref="containerRef">
|
||||
<button
|
||||
type="button"
|
||||
@click="toggle"
|
||||
:disabled="disabled"
|
||||
:class="[
|
||||
'select-trigger',
|
||||
isOpen && 'select-trigger-open',
|
||||
error && 'select-trigger-error',
|
||||
disabled && 'select-trigger-disabled'
|
||||
]"
|
||||
>
|
||||
<span class="select-value">
|
||||
<slot name="selected" :option="selectedOption">
|
||||
{{ selectedLabel }}
|
||||
</slot>
|
||||
</span>
|
||||
<span class="select-icon">
|
||||
<svg
|
||||
:class="['w-5 h-5 transition-transform duration-200', isOpen && 'rotate-180']"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Transition name="select-dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="select-dropdown"
|
||||
>
|
||||
<!-- Search input -->
|
||||
<div v-if="searchable" class="select-search">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="searchPlaceholderText"
|
||||
class="select-search-input"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Options list -->
|
||||
<div class="select-options">
|
||||
<div
|
||||
v-for="option in filteredOptions"
|
||||
:key="getOptionValue(option)"
|
||||
@click="selectOption(option)"
|
||||
:class="[
|
||||
'select-option',
|
||||
isSelected(option) && 'select-option-selected'
|
||||
]"
|
||||
>
|
||||
<slot name="option" :option="option" :selected="isSelected(option)">
|
||||
<span class="select-option-label">{{ getOptionLabel(option) }}</span>
|
||||
<svg
|
||||
v-if="isSelected(option)"
|
||||
class="w-4 h-4 text-primary-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="filteredOptions.length === 0" class="select-empty">
|
||||
{{ emptyTextDisplay }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
export interface SelectOption {
|
||||
value: string | number | null
|
||||
label: string
|
||||
disabled?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: string | number | null | undefined
|
||||
options: SelectOption[] | Array<Record<string, unknown>>
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
error?: boolean
|
||||
searchable?: boolean
|
||||
searchPlaceholder?: string
|
||||
emptyText?: string
|
||||
valueKey?: string
|
||||
labelKey?: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string | number | null): void
|
||||
(e: 'change', value: string | number | null, option: SelectOption | null): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
error: false,
|
||||
searchable: false,
|
||||
valueKey: 'value',
|
||||
labelKey: 'label'
|
||||
})
|
||||
|
||||
// Use computed for i18n default values
|
||||
const placeholderText = computed(() => props.placeholder ?? t('common.selectOption'))
|
||||
const searchPlaceholderText = computed(() => props.searchPlaceholder ?? t('common.searchPlaceholder'))
|
||||
const emptyTextDisplay = computed(() => props.emptyText ?? t('common.noOptionsFound'))
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const getOptionValue = (option: SelectOption | Record<string, unknown>): string | number | null => {
|
||||
if (typeof option === 'object' && option !== null) {
|
||||
return option[props.valueKey] as string | number | null
|
||||
}
|
||||
return option as string | number | null
|
||||
}
|
||||
|
||||
const getOptionLabel = (option: SelectOption | Record<string, unknown>): string => {
|
||||
if (typeof option === 'object' && option !== null) {
|
||||
return String(option[props.labelKey] ?? '')
|
||||
}
|
||||
return String(option ?? '')
|
||||
}
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
return props.options.find(opt => getOptionValue(opt) === props.modelValue) || null
|
||||
})
|
||||
|
||||
const selectedLabel = computed(() => {
|
||||
if (selectedOption.value) {
|
||||
return getOptionLabel(selectedOption.value)
|
||||
}
|
||||
return placeholderText.value
|
||||
})
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
if (!props.searchable || !searchQuery.value) {
|
||||
return props.options
|
||||
}
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return props.options.filter(opt => {
|
||||
const label = getOptionLabel(opt).toLowerCase()
|
||||
return label.includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
const isSelected = (option: SelectOption | Record<string, unknown>): boolean => {
|
||||
return getOptionValue(option) === props.modelValue
|
||||
}
|
||||
|
||||
const toggle = () => {
|
||||
if (props.disabled) return
|
||||
isOpen.value = !isOpen.value
|
||||
if (isOpen.value && props.searchable) {
|
||||
nextTick(() => {
|
||||
searchInputRef.value?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const selectOption = (option: SelectOption | Record<string, unknown>) => {
|
||||
const value = getOptionValue(option)
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value, option as SelectOption)
|
||||
isOpen.value = false
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
|
||||
isOpen.value = false
|
||||
searchQuery.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen.value) {
|
||||
isOpen.value = false
|
||||
searchQuery.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (!open) {
|
||||
searchQuery.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.select-trigger {
|
||||
@apply w-full flex items-center justify-between gap-2;
|
||||
@apply px-4 py-2.5 rounded-xl text-sm;
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply border border-gray-200 dark:border-dark-600;
|
||||
@apply text-gray-900 dark:text-gray-100;
|
||||
@apply transition-all duration-200;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-500;
|
||||
@apply hover:border-gray-300 dark:hover:border-dark-500;
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.select-trigger-open {
|
||||
@apply ring-2 ring-primary-500/30 border-primary-500;
|
||||
}
|
||||
|
||||
.select-trigger-error {
|
||||
@apply border-red-500 focus:ring-red-500/30 focus:border-red-500;
|
||||
}
|
||||
|
||||
.select-trigger-disabled {
|
||||
@apply bg-gray-100 dark:bg-dark-900 cursor-not-allowed opacity-60;
|
||||
}
|
||||
|
||||
.select-value {
|
||||
@apply flex-1 text-left truncate;
|
||||
}
|
||||
|
||||
.select-icon {
|
||||
@apply flex-shrink-0 text-gray-400 dark:text-dark-400;
|
||||
}
|
||||
|
||||
.select-dropdown {
|
||||
@apply absolute z-[100] w-full mt-2;
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply rounded-xl;
|
||||
@apply border border-gray-200 dark:border-dark-700;
|
||||
@apply shadow-lg shadow-black/10 dark:shadow-black/30;
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
.select-search {
|
||||
@apply flex items-center gap-2 px-3 py-2;
|
||||
@apply border-b border-gray-100 dark:border-dark-700;
|
||||
}
|
||||
|
||||
.select-search-input {
|
||||
@apply flex-1 bg-transparent text-sm;
|
||||
@apply text-gray-900 dark:text-gray-100;
|
||||
@apply placeholder:text-gray-400 dark:placeholder:text-dark-400;
|
||||
@apply focus:outline-none;
|
||||
}
|
||||
|
||||
.select-options {
|
||||
@apply max-h-60 overflow-y-auto py-1;
|
||||
}
|
||||
|
||||
.select-option {
|
||||
@apply flex items-center justify-between gap-2;
|
||||
@apply px-4 py-2.5 text-sm;
|
||||
@apply text-gray-700 dark:text-gray-300;
|
||||
@apply cursor-pointer transition-colors duration-150;
|
||||
@apply hover:bg-gray-50 dark:hover:bg-dark-700;
|
||||
}
|
||||
|
||||
.select-option-selected {
|
||||
@apply bg-primary-50 dark:bg-primary-900/20;
|
||||
@apply text-primary-700 dark:text-primary-300;
|
||||
}
|
||||
|
||||
.select-option-label {
|
||||
@apply truncate;
|
||||
}
|
||||
|
||||
.select-empty {
|
||||
@apply px-4 py-8 text-center text-sm;
|
||||
@apply text-gray-500 dark:text-dark-400;
|
||||
}
|
||||
|
||||
/* Dropdown animation */
|
||||
.select-dropdown-enter-active,
|
||||
.select-dropdown-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.select-dropdown-enter-from,
|
||||
.select-dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
94
frontend/src/components/common/StatCard.vue
Normal file
94
frontend/src/components/common/StatCard.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="stat-card">
|
||||
<div :class="['stat-icon', iconClass]">
|
||||
<component
|
||||
v-if="icon"
|
||||
:is="icon"
|
||||
class="w-6 h-6"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="stat-label truncate">{{ title }}</p>
|
||||
<div class="flex items-baseline gap-2 mt-1">
|
||||
<p class="stat-value">{{ formattedValue }}</p>
|
||||
<span
|
||||
v-if="change !== undefined"
|
||||
:class="['stat-trend', trendClass]"
|
||||
>
|
||||
<svg
|
||||
v-if="changeType !== 'neutral'"
|
||||
:class="['w-3 h-3', changeType === 'down' && 'rotate-180']"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{{ formattedChange }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
type ChangeType = 'up' | 'down' | 'neutral'
|
||||
type IconVariant = 'primary' | 'success' | 'warning' | 'danger'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
value: number | string
|
||||
icon?: Component
|
||||
iconVariant?: IconVariant
|
||||
change?: number
|
||||
changeType?: ChangeType
|
||||
formatValue?: (value: number | string) => string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
changeType: 'neutral',
|
||||
iconVariant: 'primary'
|
||||
})
|
||||
|
||||
const formattedValue = computed(() => {
|
||||
if (props.formatValue) {
|
||||
return props.formatValue(props.value)
|
||||
}
|
||||
if (typeof props.value === 'number') {
|
||||
return props.value.toLocaleString()
|
||||
}
|
||||
return props.value
|
||||
})
|
||||
|
||||
const formattedChange = computed(() => {
|
||||
if (props.change === undefined) return ''
|
||||
const absChange = Math.abs(props.change)
|
||||
return `${absChange}%`
|
||||
})
|
||||
|
||||
const iconClass = computed(() => {
|
||||
const classes: Record<IconVariant, string> = {
|
||||
primary: 'stat-icon-primary',
|
||||
success: 'stat-icon-success',
|
||||
warning: 'stat-icon-warning',
|
||||
danger: 'stat-icon-danger'
|
||||
}
|
||||
return classes[props.iconVariant]
|
||||
})
|
||||
|
||||
const trendClass = computed(() => {
|
||||
const classes: Record<ChangeType, string> = {
|
||||
up: 'stat-trend-up',
|
||||
down: 'stat-trend-down',
|
||||
neutral: 'text-gray-500 dark:text-dark-400'
|
||||
}
|
||||
return classes[props.changeType]
|
||||
})
|
||||
</script>
|
||||
267
frontend/src/components/common/SubscriptionProgressMini.vue
Normal file
267
frontend/src/components/common/SubscriptionProgressMini.vue
Normal file
@@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<div v-if="hasActiveSubscriptions" class="relative" ref="containerRef">
|
||||
<!-- Mini Progress Display -->
|
||||
<button
|
||||
@click="toggleTooltip"
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-purple-50 dark:bg-purple-900/20 hover:bg-purple-100 dark:hover:bg-purple-900/30 transition-colors cursor-pointer"
|
||||
:title="t('subscriptionProgress.viewDetails')"
|
||||
>
|
||||
<svg class="w-4 h-4 text-purple-600 dark:text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
|
||||
</svg>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<!-- Combined progress indicator -->
|
||||
<div class="flex items-center gap-0.5">
|
||||
<div
|
||||
v-for="(sub, index) in displaySubscriptions.slice(0, 3)"
|
||||
:key="index"
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="getProgressDotClass(sub)"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-purple-700 dark:text-purple-300">
|
||||
{{ activeSubscriptions.length }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Hover/Click Tooltip -->
|
||||
<transition name="dropdown">
|
||||
<div
|
||||
v-if="tooltipOpen"
|
||||
class="absolute right-0 mt-2 w-80 bg-white dark:bg-dark-800 rounded-xl shadow-xl border border-gray-200 dark:border-dark-700 z-50"
|
||||
>
|
||||
<div class="p-3 border-b border-gray-100 dark:border-dark-700">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('subscriptionProgress.title') }}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400 mt-0.5">
|
||||
{{ t('subscriptionProgress.activeCount', { count: activeSubscriptions.length }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
<div
|
||||
v-for="subscription in displaySubscriptions"
|
||||
:key="subscription.id"
|
||||
class="p-3 border-b border-gray-50 dark:border-dark-700/50 last:border-b-0"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ subscription.group?.name || `Group #${subscription.group_id}` }}
|
||||
</span>
|
||||
<span
|
||||
v-if="subscription.expires_at"
|
||||
class="text-xs"
|
||||
:class="getDaysRemainingClass(subscription.expires_at)"
|
||||
>
|
||||
{{ formatDaysRemaining(subscription.expires_at) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress bars -->
|
||||
<div class="space-y-1.5">
|
||||
<div v-if="subscription.group?.daily_limit_usd" class="flex items-center gap-2">
|
||||
<span class="text-[10px] text-gray-500 w-8">{{ t('subscriptionProgress.daily') }}</span>
|
||||
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all"
|
||||
:class="getProgressBarClass(subscription.daily_usage_usd, subscription.group?.daily_limit_usd)"
|
||||
:style="{ width: getProgressWidth(subscription.daily_usage_usd, subscription.group?.daily_limit_usd) }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-500 w-16 text-right">
|
||||
{{ formatUsage(subscription.daily_usage_usd, subscription.group?.daily_limit_usd) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="subscription.group?.weekly_limit_usd" class="flex items-center gap-2">
|
||||
<span class="text-[10px] text-gray-500 w-8">{{ t('subscriptionProgress.weekly') }}</span>
|
||||
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all"
|
||||
:class="getProgressBarClass(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd)"
|
||||
:style="{ width: getProgressWidth(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd) }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-500 w-16 text-right">
|
||||
{{ formatUsage(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="subscription.group?.monthly_limit_usd" class="flex items-center gap-2">
|
||||
<span class="text-[10px] text-gray-500 w-8">{{ t('subscriptionProgress.monthly') }}</span>
|
||||
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all"
|
||||
:class="getProgressBarClass(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd)"
|
||||
:style="{ width: getProgressWidth(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd) }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-500 w-16 text-right">
|
||||
{{ formatUsage(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-2 border-t border-gray-100 dark:border-dark-700">
|
||||
<router-link
|
||||
to="/subscriptions"
|
||||
@click="closeTooltip"
|
||||
class="block w-full text-center text-xs text-primary-600 dark:text-primary-400 hover:underline py-1"
|
||||
>
|
||||
{{ t('subscriptionProgress.viewAll') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import subscriptionsAPI from '@/api/subscriptions';
|
||||
import type { UserSubscription } from '@/types';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const tooltipOpen = ref(false);
|
||||
const activeSubscriptions = ref<UserSubscription[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const hasActiveSubscriptions = computed(() => activeSubscriptions.value.length > 0);
|
||||
|
||||
const displaySubscriptions = computed(() => {
|
||||
// Sort by most usage (highest percentage first)
|
||||
return [...activeSubscriptions.value].sort((a, b) => {
|
||||
const aMax = getMaxUsagePercentage(a);
|
||||
const bMax = getMaxUsagePercentage(b);
|
||||
return bMax - aMax;
|
||||
});
|
||||
});
|
||||
|
||||
function getMaxUsagePercentage(sub: UserSubscription): number {
|
||||
const percentages: number[] = [];
|
||||
if (sub.group?.daily_limit_usd) {
|
||||
percentages.push((sub.daily_usage_usd || 0) / sub.group.daily_limit_usd * 100);
|
||||
}
|
||||
if (sub.group?.weekly_limit_usd) {
|
||||
percentages.push((sub.weekly_usage_usd || 0) / sub.group.weekly_limit_usd * 100);
|
||||
}
|
||||
if (sub.group?.monthly_limit_usd) {
|
||||
percentages.push((sub.monthly_usage_usd || 0) / sub.group.monthly_limit_usd * 100);
|
||||
}
|
||||
return percentages.length > 0 ? Math.max(...percentages) : 0;
|
||||
}
|
||||
|
||||
function getProgressDotClass(sub: UserSubscription): string {
|
||||
const maxPercentage = getMaxUsagePercentage(sub);
|
||||
if (maxPercentage >= 90) return 'bg-red-500';
|
||||
if (maxPercentage >= 70) return 'bg-orange-500';
|
||||
return 'bg-green-500';
|
||||
}
|
||||
|
||||
function getProgressBarClass(used: number | undefined, limit: number | null | undefined): string {
|
||||
if (!limit || limit === 0) return 'bg-gray-400';
|
||||
const percentage = ((used || 0) / limit) * 100;
|
||||
if (percentage >= 90) return 'bg-red-500';
|
||||
if (percentage >= 70) return 'bg-orange-500';
|
||||
return 'bg-green-500';
|
||||
}
|
||||
|
||||
function getProgressWidth(used: number | undefined, limit: number | null | undefined): string {
|
||||
if (!limit || limit === 0) return '0%';
|
||||
const percentage = Math.min(((used || 0) / limit) * 100, 100);
|
||||
return `${percentage}%`;
|
||||
}
|
||||
|
||||
function formatUsage(used: number | undefined, limit: number | null | undefined): string {
|
||||
const usedValue = (used || 0).toFixed(2);
|
||||
const limitValue = limit?.toFixed(2) || '∞';
|
||||
return `$${usedValue}/$${limitValue}`;
|
||||
}
|
||||
|
||||
function formatDaysRemaining(expiresAt: string): string {
|
||||
const now = new Date();
|
||||
const expires = new Date(expiresAt);
|
||||
const diff = expires.getTime() - now.getTime();
|
||||
if (diff < 0) return t('subscriptionProgress.expired');
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
if (days === 0) return t('subscriptionProgress.expirestoday');
|
||||
if (days === 1) return t('subscriptionProgress.expiresTomorrow');
|
||||
return t('subscriptionProgress.daysRemaining', { days });
|
||||
}
|
||||
|
||||
function getDaysRemainingClass(expiresAt: string): string {
|
||||
const now = new Date();
|
||||
const expires = new Date(expiresAt);
|
||||
const diff = expires.getTime() - now.getTime();
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
if (days <= 3) return 'text-red-600 dark:text-red-400';
|
||||
if (days <= 7) return 'text-orange-600 dark:text-orange-400';
|
||||
return 'text-gray-500 dark:text-dark-400';
|
||||
}
|
||||
|
||||
function toggleTooltip() {
|
||||
tooltipOpen.value = !tooltipOpen.value;
|
||||
}
|
||||
|
||||
function closeTooltip() {
|
||||
tooltipOpen.value = false;
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
|
||||
closeTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubscriptions() {
|
||||
try {
|
||||
loading.value = true;
|
||||
activeSubscriptions.value = await subscriptionsAPI.getActiveSubscriptions();
|
||||
} catch (error) {
|
||||
console.error('Failed to load subscriptions:', error);
|
||||
activeSubscriptions.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
loadSubscriptions();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
|
||||
// Refresh subscriptions periodically (every 5 minutes)
|
||||
let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
||||
onMounted(() => {
|
||||
refreshInterval = setInterval(loadSubscriptions, 5 * 60 * 1000);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-4px);
|
||||
}
|
||||
</style>
|
||||
224
frontend/src/components/common/Toast.vue
Normal file
224
frontend/src/components/common/Toast.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
class="fixed top-4 right-4 z-[9999] space-y-3 pointer-events-none"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<TransitionGroup
|
||||
enter-active-class="transition ease-out duration-300"
|
||||
enter-from-class="opacity-0 translate-x-full"
|
||||
enter-to-class="opacity-100 translate-x-0"
|
||||
leave-active-class="transition ease-in duration-200"
|
||||
leave-from-class="opacity-100 translate-x-0"
|
||||
leave-to-class="opacity-0 translate-x-full"
|
||||
>
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
:class="[
|
||||
'pointer-events-auto min-w-[320px] max-w-md overflow-hidden rounded-lg shadow-lg',
|
||||
'bg-white dark:bg-dark-800',
|
||||
'border-l-4',
|
||||
getBorderColor(toast.type)
|
||||
]"
|
||||
>
|
||||
<div class="p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Icon -->
|
||||
<div class="flex-shrink-0 mt-0.5">
|
||||
<component
|
||||
:is="getIcon(toast.type)"
|
||||
:class="['w-5 h-5', getIconColor(toast.type)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
v-if="toast.title"
|
||||
class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>
|
||||
{{ toast.title }}
|
||||
</p>
|
||||
<p
|
||||
:class="[
|
||||
'text-sm leading-relaxed',
|
||||
toast.title ? 'mt-1 text-gray-600 dark:text-gray-300' : 'text-gray-900 dark:text-white'
|
||||
]"
|
||||
>
|
||||
{{ toast.message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Close button -->
|
||||
<button
|
||||
@click="removeToast(toast.id)"
|
||||
class="flex-shrink-0 p-1 -m-1 text-gray-400 dark:text-gray-500 transition-colors rounded hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-dark-700"
|
||||
aria-label="Close notification"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div
|
||||
v-if="toast.duration"
|
||||
class="h-1 bg-gray-100 dark:bg-dark-700"
|
||||
>
|
||||
<div
|
||||
:class="['h-full transition-all', getProgressBarColor(toast.type)]"
|
||||
:style="{ width: `${getProgress(toast)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, h } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const toasts = computed(() => appStore.toasts)
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
const icons = {
|
||||
success: () =>
|
||||
h(
|
||||
'svg',
|
||||
{
|
||||
fill: 'currentColor',
|
||||
viewBox: '0 0 20 20'
|
||||
},
|
||||
[
|
||||
h('path', {
|
||||
'fill-rule': 'evenodd',
|
||||
d: 'M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z',
|
||||
'clip-rule': 'evenodd'
|
||||
})
|
||||
]
|
||||
),
|
||||
error: () =>
|
||||
h(
|
||||
'svg',
|
||||
{
|
||||
fill: 'currentColor',
|
||||
viewBox: '0 0 20 20'
|
||||
},
|
||||
[
|
||||
h('path', {
|
||||
'fill-rule': 'evenodd',
|
||||
d: 'M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z',
|
||||
'clip-rule': 'evenodd'
|
||||
})
|
||||
]
|
||||
),
|
||||
warning: () =>
|
||||
h(
|
||||
'svg',
|
||||
{
|
||||
fill: 'currentColor',
|
||||
viewBox: '0 0 20 20'
|
||||
},
|
||||
[
|
||||
h('path', {
|
||||
'fill-rule': 'evenodd',
|
||||
d: 'M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z',
|
||||
'clip-rule': 'evenodd'
|
||||
})
|
||||
]
|
||||
),
|
||||
info: () =>
|
||||
h(
|
||||
'svg',
|
||||
{
|
||||
fill: 'currentColor',
|
||||
viewBox: '0 0 20 20'
|
||||
},
|
||||
[
|
||||
h('path', {
|
||||
'fill-rule': 'evenodd',
|
||||
d: 'M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z',
|
||||
'clip-rule': 'evenodd'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
return icons[type as keyof typeof icons] || icons.info
|
||||
}
|
||||
|
||||
const getIconColor = (type: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
success: 'text-green-500',
|
||||
error: 'text-red-500',
|
||||
warning: 'text-yellow-500',
|
||||
info: 'text-blue-500'
|
||||
}
|
||||
return colors[type] || colors.info
|
||||
}
|
||||
|
||||
const getBorderColor = (type: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
success: 'border-green-500',
|
||||
error: 'border-red-500',
|
||||
warning: 'border-yellow-500',
|
||||
info: 'border-blue-500'
|
||||
}
|
||||
return colors[type] || colors.info
|
||||
}
|
||||
|
||||
const getProgressBarColor = (type: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
success: 'bg-green-500',
|
||||
error: 'bg-red-500',
|
||||
warning: 'bg-yellow-500',
|
||||
info: 'bg-blue-500'
|
||||
}
|
||||
return colors[type] || colors.info
|
||||
}
|
||||
|
||||
const getProgress = (toast: any): number => {
|
||||
if (!toast.duration || !toast.startTime) return 100
|
||||
const elapsed = Date.now() - toast.startTime
|
||||
const progress = Math.max(0, 100 - (elapsed / toast.duration) * 100)
|
||||
return progress
|
||||
}
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
appStore.hideToast(id)
|
||||
}
|
||||
|
||||
let intervalId: number | undefined
|
||||
|
||||
onMounted(() => {
|
||||
// Check for expired toasts every 100ms
|
||||
intervalId = window.setInterval(() => {
|
||||
const now = Date.now()
|
||||
toasts.value.forEach((toast) => {
|
||||
if (toast.duration && toast.startTime) {
|
||||
if (now - toast.startTime >= toast.duration) {
|
||||
removeToast(toast.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, 100)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId !== undefined) {
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
35
frontend/src/components/common/Toggle.vue
Normal file
35
frontend/src/components/common/Toggle.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
@click="toggle"
|
||||
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 dark:focus:ring-offset-dark-800"
|
||||
:class="[
|
||||
modelValue
|
||||
? 'bg-primary-600'
|
||||
: 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
>
|
||||
<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"
|
||||
:class="[
|
||||
modelValue ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
}>();
|
||||
|
||||
function toggle() {
|
||||
emit('update:modelValue', !props.modelValue);
|
||||
}
|
||||
</script>
|
||||
250
frontend/src/components/common/VersionBadge.vue
Normal file
250
frontend/src/components/common/VersionBadge.vue
Normal file
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- Admin: Full version badge with dropdown -->
|
||||
<template v-if="isAdmin">
|
||||
<button
|
||||
@click="toggleDropdown"
|
||||
class="flex items-center gap-1.5 px-2 py-1 text-xs rounded-lg transition-colors"
|
||||
:class="[
|
||||
hasUpdate
|
||||
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 hover:bg-amber-200 dark:hover:bg-amber-900/50'
|
||||
: 'bg-gray-100 dark:bg-dark-800 text-gray-600 dark:text-dark-400 hover:bg-gray-200 dark:hover:bg-dark-700'
|
||||
]"
|
||||
:title="hasUpdate ? 'New version available' : 'Up to date'"
|
||||
>
|
||||
<span class="font-medium">v{{ currentVersion }}</span>
|
||||
<!-- Update indicator -->
|
||||
<span v-if="hasUpdate" class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-amber-500"></span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown -->
|
||||
<transition name="dropdown">
|
||||
<div
|
||||
v-if="dropdownOpen"
|
||||
ref="dropdownRef"
|
||||
class="absolute left-0 mt-2 w-64 bg-white dark:bg-dark-800 rounded-xl shadow-lg border border-gray-200 dark:border-dark-700 z-50 overflow-hidden"
|
||||
>
|
||||
<!-- Header with refresh button -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-dark-700">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-dark-300">{{ t('version.currentVersion') }}</span>
|
||||
<button
|
||||
@click="refreshVersion(true)"
|
||||
class="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-dark-200 hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
|
||||
:disabled="loading"
|
||||
:title="t('version.refresh')"
|
||||
>
|
||||
<svg class="w-4 h-4" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-6">
|
||||
<svg class="animate-spin h-6 w-6 text-primary-500" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<template v-else>
|
||||
<!-- Version display - centered and prominent -->
|
||||
<div class="text-center mb-4">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
<span class="text-2xl font-bold text-gray-900 dark:text-white">v{{ currentVersion }}</span>
|
||||
<!-- Show check mark when up to date -->
|
||||
<span v-if="!hasUpdate" class="flex items-center justify-center w-5 h-5 rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<svg class="w-3 h-3 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400 mt-1">
|
||||
{{ hasUpdate ? t('version.latestVersion') + ': v' + latestVersion : t('version.upToDate') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Update available for source build - show git pull hint -->
|
||||
<div v-if="hasUpdate && !isReleaseBuild" class="space-y-2">
|
||||
<a
|
||||
v-if="releaseInfo?.html_url && releaseInfo.html_url !== '#'"
|
||||
:href="releaseInfo.html_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-3 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50 hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors group"
|
||||
>
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-amber-700 dark:text-amber-300">{{ t('version.updateAvailable') }}</p>
|
||||
<p class="text-xs text-amber-600/70 dark:text-amber-400/70">v{{ latestVersion }}</p>
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-amber-500 dark:text-amber-400 group-hover:translate-x-0.5 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
<!-- Source build hint -->
|
||||
<div class="flex items-center gap-2 p-2 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50">
|
||||
<svg class="w-3.5 h-3.5 text-blue-500 dark:text-blue-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p class="text-xs text-blue-600 dark:text-blue-400">{{ t('version.sourceModeHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Update available for release build - show download link -->
|
||||
<a
|
||||
v-else-if="hasUpdate && isReleaseBuild && releaseInfo?.html_url && releaseInfo.html_url !== '#'"
|
||||
:href="releaseInfo.html_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-3 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50 hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors group"
|
||||
>
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-amber-700 dark:text-amber-300">{{ t('version.updateAvailable') }}</p>
|
||||
<p class="text-xs text-amber-600/70 dark:text-amber-400/70">v{{ latestVersion }}</p>
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-amber-500 dark:text-amber-400 group-hover:translate-x-0.5 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- GitHub link when up to date -->
|
||||
<a
|
||||
v-else-if="releaseInfo?.html_url && releaseInfo.html_url !== '#'"
|
||||
:href="releaseInfo.html_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center justify-center gap-2 py-2 text-sm text-gray-500 dark:text-dark-400 hover:text-gray-700 dark:hover:text-dark-200 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z" />
|
||||
</svg>
|
||||
{{ t('version.viewRelease') }}
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<!-- Non-admin: Simple static version text -->
|
||||
<span
|
||||
v-else-if="version"
|
||||
class="text-xs text-gray-500 dark:text-dark-400"
|
||||
>
|
||||
v{{ version }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '@/stores';
|
||||
import { checkUpdates, type VersionInfo, type ReleaseInfo } from '@/api/admin/system';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
version?: string;
|
||||
}>();
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const isAdmin = computed(() => authStore.isAdmin);
|
||||
|
||||
const loading = ref(false);
|
||||
const dropdownOpen = ref(false);
|
||||
const dropdownRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const currentVersion = ref('0.1.0');
|
||||
const latestVersion = ref('0.1.0');
|
||||
const hasUpdate = ref(false);
|
||||
const releaseInfo = ref<ReleaseInfo | null>(null);
|
||||
const buildType = ref('source'); // "source" or "release"
|
||||
|
||||
// Only show update check for release builds (binary/docker deployment)
|
||||
const isReleaseBuild = computed(() => buildType.value === 'release');
|
||||
|
||||
function toggleDropdown() {
|
||||
dropdownOpen.value = !dropdownOpen.value;
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
dropdownOpen.value = false;
|
||||
}
|
||||
|
||||
async function refreshVersion(force = true) {
|
||||
if (!isAdmin.value) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const data: VersionInfo = await checkUpdates(force);
|
||||
currentVersion.value = data.current_version;
|
||||
latestVersion.value = data.latest_version;
|
||||
buildType.value = data.build_type || 'source';
|
||||
// Show update indicator for all build types
|
||||
hasUpdate.value = data.has_update;
|
||||
releaseInfo.value = data.release_info || null;
|
||||
} catch (error) {
|
||||
console.error('Failed to check updates:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as Node;
|
||||
const button = (event.target as Element).closest('button');
|
||||
if (dropdownRef.value && !dropdownRef.value.contains(target) && !button?.contains(target)) {
|
||||
closeDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isAdmin.value) {
|
||||
refreshVersion(false);
|
||||
} else if (props.version) {
|
||||
currentVersion.value = props.version;
|
||||
}
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-4px);
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
13
frontend/src/components/common/index.ts
Normal file
13
frontend/src/components/common/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// Export all common components
|
||||
export { default as DataTable } from './DataTable.vue'
|
||||
export { default as Pagination } from './Pagination.vue'
|
||||
export { default as Modal } from './Modal.vue'
|
||||
export { default as ConfirmDialog } from './ConfirmDialog.vue'
|
||||
export { default as StatCard } from './StatCard.vue'
|
||||
export { default as Toast } from './Toast.vue'
|
||||
export { default as LoadingSpinner } from './LoadingSpinner.vue'
|
||||
export { default as EmptyState } from './EmptyState.vue'
|
||||
export { default as LocaleSwitcher } from './LocaleSwitcher.vue'
|
||||
|
||||
// Export types
|
||||
export type { Column } from './DataTable.vue'
|
||||
200
frontend/src/components/keys/UseKeyModal.vue
Normal file
200
frontend/src/components/keys/UseKeyModal.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<Modal
|
||||
:show="show"
|
||||
:title="t('keys.useKeyModal.title')"
|
||||
size="lg"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- Description -->
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ t('keys.useKeyModal.description') }}
|
||||
</p>
|
||||
|
||||
<!-- OS Tabs -->
|
||||
<div class="border-b border-gray-200 dark:border-dark-700">
|
||||
<nav class="-mb-px flex space-x-4" aria-label="Tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
@click="activeTab = tab.id"
|
||||
:class="[
|
||||
'whitespace-nowrap py-2.5 px-1 border-b-2 font-medium text-sm transition-colors',
|
||||
activeTab === tab.id
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
]"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<component :is="tab.icon" class="w-4 h-4" />
|
||||
{{ tab.label }}
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Code Block -->
|
||||
<div class="relative">
|
||||
<div class="bg-gray-900 dark:bg-dark-900 rounded-xl overflow-hidden">
|
||||
<!-- Code Header -->
|
||||
<div class="flex items-center justify-between px-4 py-2 bg-gray-800 dark:bg-dark-800 border-b border-gray-700 dark:border-dark-700">
|
||||
<span class="text-xs text-gray-400 font-mono">{{ activeTabConfig?.filename }}</span>
|
||||
<button
|
||||
@click="copyConfig"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg transition-colors"
|
||||
:class="copied
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-gray-700 hover:bg-gray-600 text-gray-300 hover:text-white'"
|
||||
>
|
||||
<svg v-if="copied" class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<svg v-else class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
||||
</svg>
|
||||
{{ copied ? t('keys.useKeyModal.copied') : t('keys.useKeyModal.copy') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Code Content -->
|
||||
<pre class="p-4 text-sm font-mono text-gray-100 overflow-x-auto"><code v-html="highlightedCode"></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Note -->
|
||||
<div class="flex items-start gap-3 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800">
|
||||
<svg class="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||
</svg>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300">
|
||||
{{ t('keys.useKeyModal.note') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
apiKey: string
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const copied = ref(false)
|
||||
const activeTab = ref<'unix' | 'cmd' | 'powershell'>('unix')
|
||||
|
||||
// Icon components
|
||||
const AppleIcon = {
|
||||
render() {
|
||||
return h('svg', {
|
||||
fill: 'currentColor',
|
||||
viewBox: '0 0 24 24',
|
||||
class: 'w-4 h-4'
|
||||
}, [
|
||||
h('path', { d: 'M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z' })
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
const WindowsIcon = {
|
||||
render() {
|
||||
return h('svg', {
|
||||
fill: 'currentColor',
|
||||
viewBox: '0 0 24 24',
|
||||
class: 'w-4 h-4'
|
||||
}, [
|
||||
h('path', { d: 'M3 12V6.75l6-1.32v6.48L3 12zm17-9v8.75l-10 .15V5.21L20 3zM3 13l6 .09v6.81l-6-1.15V13zm7 .25l10 .15V21l-10-1.91v-5.84z' })
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'unix' as const, label: 'macOS / Linux', icon: AppleIcon, filename: 'Terminal' },
|
||||
{ id: 'cmd' as const, label: 'Windows CMD', icon: WindowsIcon, filename: 'Command Prompt' },
|
||||
{ id: 'powershell' as const, label: 'PowerShell', icon: WindowsIcon, filename: 'PowerShell' }
|
||||
]
|
||||
|
||||
const activeTabConfig = computed(() => tabs.find(tab => tab.id === activeTab.value))
|
||||
|
||||
const configCode = computed(() => {
|
||||
const baseUrl = props.baseUrl || window.location.origin
|
||||
const apiKey = props.apiKey
|
||||
|
||||
switch (activeTab.value) {
|
||||
case 'unix':
|
||||
return `export ANTHROPIC_BASE_URL="${baseUrl}"
|
||||
export ANTHROPIC_AUTH_TOKEN="${apiKey}"`
|
||||
case 'cmd':
|
||||
return `set ANTHROPIC_BASE_URL=${baseUrl}
|
||||
set ANTHROPIC_AUTH_TOKEN=${apiKey}`
|
||||
case 'powershell':
|
||||
return `$env:ANTHROPIC_BASE_URL="${baseUrl}"
|
||||
$env:ANTHROPIC_AUTH_TOKEN="${apiKey}"`
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const highlightedCode = computed(() => {
|
||||
const baseUrl = props.baseUrl || window.location.origin
|
||||
const apiKey = props.apiKey
|
||||
|
||||
// Build highlighted code directly to avoid regex replacement conflicts
|
||||
const keyword = (text: string) => `<span class="text-purple-400">${text}</span>`
|
||||
const variable = (text: string) => `<span class="text-cyan-400">${text}</span>`
|
||||
const string = (text: string) => `<span class="text-green-400">${text}</span>`
|
||||
const operator = (text: string) => `<span class="text-yellow-400">${text}</span>`
|
||||
|
||||
switch (activeTab.value) {
|
||||
case 'unix':
|
||||
return `${keyword('export')} ${variable('ANTHROPIC_BASE_URL')}${operator('=')}${string(`"${baseUrl}"`)}
|
||||
${keyword('export')} ${variable('ANTHROPIC_AUTH_TOKEN')}${operator('=')}${string(`"${apiKey}"`)}`
|
||||
case 'cmd':
|
||||
return `${keyword('set')} ${variable('ANTHROPIC_BASE_URL')}${operator('=')}${baseUrl}
|
||||
${keyword('set')} ${variable('ANTHROPIC_AUTH_TOKEN')}${operator('=')}${apiKey}`
|
||||
case 'powershell':
|
||||
return `${keyword('$env:')}${variable('ANTHROPIC_BASE_URL')}${operator('=')}${string(`"${baseUrl}"`)}
|
||||
${keyword('$env:')}${variable('ANTHROPIC_AUTH_TOKEN')}${operator('=')}${string(`"${apiKey}"`)}`
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const copyConfig = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(configCode.value)
|
||||
copied.value = true
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
appStore.showError(t('common.copyFailed'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
259
frontend/src/components/layout/AppHeader.vue
Normal file
259
frontend/src/components/layout/AppHeader.vue
Normal file
@@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<header class="sticky top-0 z-30 glass border-b border-gray-200/50 dark:border-dark-700/50">
|
||||
<div class="flex items-center justify-between h-16 px-4 md:px-6">
|
||||
<!-- Left: Mobile Menu Toggle + Page Title -->
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
@click="toggleMobileSidebar"
|
||||
class="lg:hidden btn-ghost btn-icon"
|
||||
aria-label="Toggle Menu"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="hidden lg:block">
|
||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ pageTitle }}
|
||||
</h1>
|
||||
<p v-if="pageDescription" class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ pageDescription }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Language + Subscriptions + Balance + User Dropdown -->
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Language Switcher -->
|
||||
<LocaleSwitcher />
|
||||
|
||||
<!-- Subscription Progress (for users with active subscriptions) -->
|
||||
<SubscriptionProgressMini v-if="user" />
|
||||
|
||||
<!-- Balance Display -->
|
||||
<div v-if="user" class="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-xl bg-primary-50 dark:bg-primary-900/20">
|
||||
<svg class="w-4 h-4 text-primary-600 dark:text-primary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
|
||||
</svg>
|
||||
<span class="text-sm font-semibold text-primary-700 dark:text-primary-300">
|
||||
${{ user.balance?.toFixed(2) || '0.00' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- User Dropdown -->
|
||||
<div v-if="user" class="relative" ref="dropdownRef">
|
||||
<button
|
||||
@click="toggleDropdown"
|
||||
class="flex items-center gap-2 p-1.5 rounded-xl hover:bg-gray-100 dark:hover:bg-dark-800 transition-colors"
|
||||
aria-label="User Menu"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-xl bg-gradient-to-br from-primary-500 to-primary-600 text-white flex items-center justify-center text-sm font-medium shadow-sm">
|
||||
{{ userInitials }}
|
||||
</div>
|
||||
<div class="hidden md:block text-left">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ displayName }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-dark-400 capitalize">
|
||||
{{ user.role }}
|
||||
</div>
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-gray-400 hidden md:block" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown Menu -->
|
||||
<transition name="dropdown">
|
||||
<div
|
||||
v-if="dropdownOpen"
|
||||
class="dropdown right-0 mt-2 w-56"
|
||||
>
|
||||
<!-- User Info -->
|
||||
<div class="px-4 py-3 border-b border-gray-100 dark:border-dark-700">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">{{ displayName }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-dark-400">{{ user.email }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Balance (mobile only) -->
|
||||
<div class="sm:hidden px-4 py-2 border-b border-gray-100 dark:border-dark-700">
|
||||
<div class="text-xs text-gray-500 dark:text-dark-400">{{ t('common.balance') }}</div>
|
||||
<div class="text-sm font-semibold text-primary-600 dark:text-primary-400">
|
||||
${{ user.balance?.toFixed(2) || '0.00' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-1">
|
||||
<router-link
|
||||
to="/profile"
|
||||
@click="closeDropdown"
|
||||
class="dropdown-item"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</svg>
|
||||
{{ t('nav.profile') }}
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/keys"
|
||||
@click="closeDropdown"
|
||||
class="dropdown-item"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="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>
|
||||
{{ t('nav.apiKeys') }}
|
||||
</router-link>
|
||||
|
||||
<a
|
||||
href="https://github.com/fangyuan99/sub2api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click="closeDropdown"
|
||||
class="dropdown-item"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z" />
|
||||
</svg>
|
||||
{{ t('nav.github') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Contact Support (only show if configured) -->
|
||||
<div v-if="contactInfo" class="border-t border-gray-100 dark:border-dark-700 px-4 py-2.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
|
||||
</svg>
|
||||
<span>{{ t('common.contactSupport') }}:</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ contactInfo }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-100 dark:border-dark-700 py-1">
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="dropdown-item w-full text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||
</svg>
|
||||
{{ t('nav.logout') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAppStore, useAuthStore } from '@/stores';
|
||||
import { authAPI } from '@/api';
|
||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue';
|
||||
import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMini.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const appStore = useAppStore();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const user = computed(() => authStore.user);
|
||||
const dropdownOpen = ref(false);
|
||||
const dropdownRef = ref<HTMLElement | null>(null);
|
||||
const contactInfo = ref('');
|
||||
|
||||
const userInitials = computed(() => {
|
||||
if (!user.value) return '';
|
||||
// Prefer username, fallback to email
|
||||
if (user.value.username) {
|
||||
return user.value.username.substring(0, 2).toUpperCase();
|
||||
}
|
||||
if (user.value.email) {
|
||||
// Get the part before @ and take first 2 chars
|
||||
const localPart = user.value.email.split('@')[0];
|
||||
return localPart.substring(0, 2).toUpperCase();
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const displayName = computed(() => {
|
||||
if (!user.value) return '';
|
||||
return user.value.username || user.value.email?.split('@')[0] || '';
|
||||
});
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
const titleKey = route.meta.titleKey as string;
|
||||
if (titleKey) {
|
||||
return t(titleKey);
|
||||
}
|
||||
return (route.meta.title as string) || '';
|
||||
});
|
||||
|
||||
const pageDescription = computed(() => {
|
||||
const descKey = route.meta.descriptionKey as string;
|
||||
if (descKey) {
|
||||
return t(descKey);
|
||||
}
|
||||
return (route.meta.description as string) || '';
|
||||
});
|
||||
|
||||
function toggleMobileSidebar() {
|
||||
appStore.toggleSidebar();
|
||||
}
|
||||
|
||||
function toggleDropdown() {
|
||||
dropdownOpen.value = !dropdownOpen.value;
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
dropdownOpen.value = false;
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
closeDropdown();
|
||||
authStore.logout();
|
||||
await router.push('/login');
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
|
||||
closeDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
try {
|
||||
const settings = await authAPI.getPublicSettings();
|
||||
contactInfo.value = settings.contact_info || '';
|
||||
} catch (error) {
|
||||
console.error('Failed to load contact info:', error);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-4px);
|
||||
}
|
||||
</style>
|
||||
36
frontend/src/components/layout/AppLayout.vue
Normal file
36
frontend/src/components/layout/AppLayout.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-dark-950">
|
||||
<!-- Background Decoration -->
|
||||
<div class="fixed inset-0 bg-mesh-gradient pointer-events-none"></div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<AppSidebar />
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div
|
||||
class="relative min-h-screen transition-all duration-300"
|
||||
:class="[
|
||||
sidebarCollapsed ? 'lg:ml-[72px]' : 'lg:ml-64',
|
||||
]"
|
||||
>
|
||||
<!-- Header -->
|
||||
<AppHeader />
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="p-4 md:p-6 lg:p-8">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useAppStore } from '@/stores';
|
||||
import AppSidebar from './AppSidebar.vue';
|
||||
import AppHeader from './AppHeader.vue';
|
||||
|
||||
const appStore = useAppStore();
|
||||
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed);
|
||||
</script>
|
||||
|
||||
331
frontend/src/components/layout/AppSidebar.vue
Normal file
331
frontend/src/components/layout/AppSidebar.vue
Normal file
@@ -0,0 +1,331 @@
|
||||
<template>
|
||||
<aside
|
||||
class="sidebar"
|
||||
:class="[
|
||||
sidebarCollapsed ? 'w-[72px]' : 'w-64',
|
||||
{ '-translate-x-full lg:translate-x-0': !mobileOpen }
|
||||
]"
|
||||
>
|
||||
<!-- Logo/Brand -->
|
||||
<div class="sidebar-header">
|
||||
<!-- Custom Logo or Default Logo -->
|
||||
<div class="w-9 h-9 rounded-xl overflow-hidden flex items-center justify-center shadow-glow">
|
||||
<img :src="siteLogo || '/logo.png'" alt="Logo" class="w-full h-full object-contain" />
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div v-if="!sidebarCollapsed" class="flex flex-col">
|
||||
<span class="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{{ siteName }}
|
||||
</span>
|
||||
<!-- Version Badge -->
|
||||
<VersionBadge :version="siteVersion" />
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="sidebar-nav scrollbar-hide">
|
||||
<!-- Admin View: Admin menu first, then personal menu -->
|
||||
<template v-if="isAdmin">
|
||||
<!-- Admin Section -->
|
||||
<div class="sidebar-section">
|
||||
<router-link
|
||||
v-for="item in adminNavItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="sidebar-link mb-1"
|
||||
:class="{ 'sidebar-link-active': isActive(item.path) }"
|
||||
:title="sidebarCollapsed ? item.label : undefined"
|
||||
>
|
||||
<component :is="item.icon" class="w-5 h-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
|
||||
</transition>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Personal Section for Admin -->
|
||||
<div class="sidebar-section">
|
||||
<div v-if="!sidebarCollapsed" class="sidebar-section-title">
|
||||
{{ t('nav.myAccount') }}
|
||||
</div>
|
||||
<div v-else class="h-px bg-gray-200 dark:bg-dark-700 mx-3 my-3"></div>
|
||||
|
||||
<router-link
|
||||
v-for="item in personalNavItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="sidebar-link mb-1"
|
||||
:class="{ 'sidebar-link-active': isActive(item.path) }"
|
||||
:title="sidebarCollapsed ? item.label : undefined"
|
||||
>
|
||||
<component :is="item.icon" class="w-5 h-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
|
||||
</transition>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Regular User View -->
|
||||
<template v-else>
|
||||
<div class="sidebar-section">
|
||||
<router-link
|
||||
v-for="item in userNavItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="sidebar-link mb-1"
|
||||
:class="{ 'sidebar-link-active': isActive(item.path) }"
|
||||
:title="sidebarCollapsed ? item.label : undefined"
|
||||
>
|
||||
<component :is="item.icon" class="w-5 h-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
|
||||
</transition>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- Bottom Section -->
|
||||
<div class="mt-auto border-t border-gray-100 dark:border-dark-800 p-3">
|
||||
<!-- Theme Toggle -->
|
||||
<button
|
||||
@click="toggleTheme"
|
||||
class="sidebar-link w-full mb-2"
|
||||
:title="sidebarCollapsed ? (isDark ? t('nav.lightMode') : t('nav.darkMode')) : undefined"
|
||||
>
|
||||
<SunIcon v-if="isDark" class="w-5 h-5 flex-shrink-0 text-amber-500" />
|
||||
<MoonIcon v-else class="w-5 h-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
<span v-if="!sidebarCollapsed">{{ isDark ? t('nav.lightMode') : t('nav.darkMode') }}</span>
|
||||
</transition>
|
||||
</button>
|
||||
|
||||
<!-- Collapse Button -->
|
||||
<button
|
||||
@click="toggleSidebar"
|
||||
class="sidebar-link w-full"
|
||||
:title="sidebarCollapsed ? t('nav.expand') : t('nav.collapse')"
|
||||
>
|
||||
<ChevronDoubleLeftIcon v-if="!sidebarCollapsed" class="w-5 h-5 flex-shrink-0" />
|
||||
<ChevronDoubleRightIcon v-else class="w-5 h-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
<span v-if="!sidebarCollapsed">{{ t('nav.collapse') }}</span>
|
||||
</transition>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile Overlay -->
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="mobileOpen"
|
||||
class="fixed inset-0 bg-black/50 z-30 lg:hidden"
|
||||
@click="closeMobile"
|
||||
></div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, ref, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAppStore, useAuthStore } from '@/stores';
|
||||
import { getPublicSettings } from '@/api/auth';
|
||||
import VersionBadge from '@/components/common/VersionBadge.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed);
|
||||
const isAdmin = computed(() => authStore.isAdmin);
|
||||
const isDark = ref(document.documentElement.classList.contains('dark'));
|
||||
const mobileOpen = ref(false);
|
||||
|
||||
// Site settings
|
||||
const siteName = ref('Sub2API');
|
||||
const siteLogo = ref('');
|
||||
const siteVersion = ref('');
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const settings = await getPublicSettings();
|
||||
siteName.value = settings.site_name || 'Sub2API';
|
||||
siteLogo.value = settings.site_logo || '';
|
||||
siteVersion.value = settings.version || '';
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// SVG Icon Components
|
||||
const DashboardIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z' })
|
||||
])
|
||||
};
|
||||
|
||||
const KeyIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: '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' })
|
||||
])
|
||||
};
|
||||
|
||||
const ChartIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z' })
|
||||
])
|
||||
};
|
||||
|
||||
const GiftIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z' })
|
||||
])
|
||||
};
|
||||
|
||||
const UserIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z' })
|
||||
])
|
||||
};
|
||||
|
||||
const UsersIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z' })
|
||||
])
|
||||
};
|
||||
|
||||
const FolderIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z' })
|
||||
])
|
||||
};
|
||||
|
||||
const CreditCardIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z' })
|
||||
])
|
||||
};
|
||||
|
||||
const GlobeIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418' })
|
||||
])
|
||||
};
|
||||
|
||||
const ServerIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z' })
|
||||
])
|
||||
};
|
||||
|
||||
const TicketIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z' })
|
||||
])
|
||||
};
|
||||
|
||||
const CogIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z' }),
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z' })
|
||||
])
|
||||
};
|
||||
|
||||
const SunIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z' })
|
||||
])
|
||||
};
|
||||
|
||||
const MoonIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z' })
|
||||
])
|
||||
};
|
||||
|
||||
const ChevronDoubleLeftIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'm18.75 4.5-7.5 7.5 7.5 7.5m-6-15L5.25 12l7.5 7.5' })
|
||||
])
|
||||
};
|
||||
|
||||
const ChevronDoubleRightIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'm5.25 4.5 7.5 7.5-7.5 7.5m6-15 7.5 7.5-7.5 7.5' })
|
||||
])
|
||||
};
|
||||
|
||||
// User navigation items (for regular users)
|
||||
const userNavItems = computed(() => [
|
||||
{ path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
|
||||
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
|
||||
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon },
|
||||
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon },
|
||||
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon },
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
|
||||
]);
|
||||
|
||||
// Personal navigation items (for admin's "My Account" section, without Dashboard)
|
||||
const personalNavItems = computed(() => [
|
||||
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
|
||||
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon },
|
||||
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon },
|
||||
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon },
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
|
||||
]);
|
||||
|
||||
// Admin navigation items
|
||||
const adminNavItems = computed(() => [
|
||||
{ path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
|
||||
{ path: '/admin/users', label: t('nav.users'), icon: UsersIcon },
|
||||
{ path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon },
|
||||
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon },
|
||||
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
|
||||
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
|
||||
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon },
|
||||
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon },
|
||||
{ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon },
|
||||
]);
|
||||
|
||||
function toggleSidebar() {
|
||||
appStore.toggleSidebar();
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
isDark.value = !isDark.value;
|
||||
document.documentElement.classList.toggle('dark', isDark.value);
|
||||
localStorage.setItem('theme', isDark.value ? 'dark' : 'light');
|
||||
}
|
||||
|
||||
function closeMobile() {
|
||||
mobileOpen.value = false;
|
||||
}
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
return route.path === path || route.path.startsWith(path + '/');
|
||||
}
|
||||
|
||||
// Initialize theme
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
isDark.value = true;
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
77
frontend/src/components/layout/AuthLayout.vue
Normal file
77
frontend/src/components/layout/AuthLayout.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center p-4 relative overflow-hidden">
|
||||
<!-- Background -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-gray-50 via-primary-50/30 to-gray-100 dark:from-dark-950 dark:via-dark-900 dark:to-dark-950"></div>
|
||||
|
||||
<!-- Decorative Elements -->
|
||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<!-- Gradient Orbs -->
|
||||
<div class="absolute -top-40 -right-40 w-80 h-80 bg-primary-400/20 rounded-full blur-3xl"></div>
|
||||
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-primary-500/15 rounded-full blur-3xl"></div>
|
||||
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-primary-300/10 rounded-full blur-3xl"></div>
|
||||
|
||||
<!-- Grid Pattern -->
|
||||
<div class="absolute inset-0 bg-[linear-gradient(rgba(20,184,166,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(20,184,166,0.03)_1px,transparent_1px)] bg-[size:64px_64px]"></div>
|
||||
</div>
|
||||
|
||||
<!-- Content Container -->
|
||||
<div class="relative w-full max-w-md z-10">
|
||||
<!-- Logo/Brand -->
|
||||
<div class="text-center mb-8">
|
||||
<!-- Custom Logo or Default Logo -->
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl overflow-hidden shadow-lg shadow-primary-500/30 mb-4">
|
||||
<img :src="siteLogo || '/logo.png'" alt="Logo" class="w-full h-full object-contain" />
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-gradient mb-2">
|
||||
{{ siteName }}
|
||||
</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ siteSubtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card Container -->
|
||||
<div class="card-glass rounded-2xl p-8 shadow-glass">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Footer Links -->
|
||||
<div class="text-center mt-6 text-sm">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
|
||||
<!-- Copyright -->
|
||||
<div class="text-center mt-8 text-xs text-gray-400 dark:text-dark-500">
|
||||
© {{ currentYear }} {{ siteName }}. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { getPublicSettings } from '@/api/auth';
|
||||
|
||||
const siteName = ref('Sub2API');
|
||||
const siteLogo = ref('');
|
||||
const siteSubtitle = ref('Subscription to API Conversion Platform');
|
||||
|
||||
const currentYear = computed(() => new Date().getFullYear());
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const settings = await getPublicSettings();
|
||||
siteName.value = settings.site_name || 'Sub2API';
|
||||
siteLogo.value = settings.site_logo || '';
|
||||
siteSubtitle.value = settings.site_subtitle || 'Subscription to API Conversion Platform';
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-gradient {
|
||||
@apply bg-gradient-to-r from-primary-600 to-primary-500 bg-clip-text text-transparent;
|
||||
}
|
||||
</style>
|
||||
424
frontend/src/components/layout/EXAMPLES.md
Normal file
424
frontend/src/components/layout/EXAMPLES.md
Normal file
@@ -0,0 +1,424 @@
|
||||
# Layout Component Examples
|
||||
|
||||
## Example 1: Dashboard Page
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- Stats Cards -->
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<div class="text-sm text-gray-600">API Keys</div>
|
||||
<div class="text-2xl font-bold text-gray-900">5</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<div class="text-sm text-gray-600">Total Usage</div>
|
||||
<div class="text-2xl font-bold text-gray-900">1,234</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<div class="text-sm text-gray-600">Balance</div>
|
||||
<div class="text-2xl font-bold text-indigo-600">${{ balance }}</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<div class="text-sm text-gray-600">Status</div>
|
||||
<div class="text-2xl font-bold text-green-600">Active</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<h2 class="text-xl font-semibold mb-4">Recent Activity</h2>
|
||||
<p class="text-gray-600">No recent activity</p>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { AppLayout } from '@/components/layout';
|
||||
import { useAuthStore } from '@/stores';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const balance = computed(() => authStore.user?.balance.toFixed(2) || '0.00');
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example 2: Login Page
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6">Welcome Back</h2>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="form.username"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
placeholder="Enter your username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full bg-indigo-600 text-white py-2 px-4 rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{{ loading ? 'Logging in...' : 'Login' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<p class="text-gray-600">
|
||||
Don't have an account?
|
||||
<router-link to="/register" class="text-indigo-600 hover:underline font-medium">
|
||||
Sign up
|
||||
</router-link>
|
||||
</p>
|
||||
</template>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { AuthLayout } from '@/components/layout';
|
||||
import { useAuthStore, useAppStore } from '@/stores';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const appStore = useAppStore();
|
||||
|
||||
const form = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
async function handleSubmit() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await authStore.login(form.value);
|
||||
appStore.showSuccess('Login successful!');
|
||||
await router.push('/dashboard');
|
||||
} catch (error) {
|
||||
appStore.showError('Invalid username or password');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example 3: API Keys Page with Custom Header Title
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Custom page header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-3xl font-bold text-gray-900">API Keys</h1>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
Create New Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- API Keys List -->
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Name
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Key
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Status
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Created
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="key in apiKeys" :key="key.id">
|
||||
<td class="px-6 py-4 whitespace-nowrap">{{ key.name }}</td>
|
||||
<td class="px-6 py-4 font-mono text-sm">{{ key.key }}</td>
|
||||
<td class="px-6 py-4">
|
||||
<span
|
||||
class="px-2 py-1 text-xs rounded-full"
|
||||
:class="key.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'"
|
||||
>
|
||||
{{ key.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
{{ new Date(key.created_at).toLocaleDateString() }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<button class="text-red-600 hover:text-red-800 text-sm">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { AppLayout } from '@/components/layout';
|
||||
import type { ApiKey } from '@/types';
|
||||
|
||||
const showCreateModal = ref(false);
|
||||
const apiKeys = ref<ApiKey[]>([]);
|
||||
|
||||
// Fetch API keys on mount
|
||||
// fetchApiKeys();
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example 4: Admin Users Page
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-3xl font-bold text-gray-900">User Management</h1>
|
||||
<button
|
||||
@click="showCreateUser = true"
|
||||
class="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
Create User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
<div v-for="user in users" :key="user.id" class="flex items-center justify-between border-b pb-4">
|
||||
<div>
|
||||
<div class="font-medium text-gray-900">{{ user.username }}</div>
|
||||
<div class="text-sm text-gray-500">{{ user.email }}</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span
|
||||
class="px-2 py-1 text-xs rounded-full"
|
||||
:class="user.role === 'admin' ? 'bg-purple-100 text-purple-800' : 'bg-blue-100 text-blue-800'"
|
||||
>
|
||||
{{ user.role }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-700">
|
||||
${{ user.balance.toFixed(2) }}
|
||||
</span>
|
||||
<button class="text-indigo-600 hover:text-indigo-800 text-sm">
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { AppLayout } from '@/components/layout';
|
||||
import type { User } from '@/types';
|
||||
|
||||
const showCreateUser = ref(false);
|
||||
const users = ref<User[]>([]);
|
||||
|
||||
// Fetch users on mount
|
||||
// fetchUsers();
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example 5: Profile Page
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="max-w-2xl space-y-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Profile Settings</h1>
|
||||
|
||||
<!-- User Info Card -->
|
||||
<div class="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Account Information</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<div class="px-3 py-2 bg-gray-50 rounded-lg text-gray-900">
|
||||
{{ user?.username }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<div class="px-3 py-2 bg-gray-50 rounded-lg text-gray-900">
|
||||
{{ user?.email }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Role
|
||||
</label>
|
||||
<div class="px-3 py-2 bg-gray-50 rounded-lg">
|
||||
<span
|
||||
class="px-2 py-1 text-xs rounded-full"
|
||||
:class="user?.role === 'admin' ? 'bg-purple-100 text-purple-800' : 'bg-blue-100 text-blue-800'"
|
||||
>
|
||||
{{ user?.role }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Balance
|
||||
</label>
|
||||
<div class="px-3 py-2 bg-gray-50 rounded-lg text-indigo-600 font-semibold">
|
||||
${{ user?.balance.toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password Card -->
|
||||
<div class="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900">Change Password</h2>
|
||||
|
||||
<form @submit.prevent="handleChangePassword" class="space-y-4">
|
||||
<div>
|
||||
<label for="old-password" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Current Password
|
||||
</label>
|
||||
<input
|
||||
id="old-password"
|
||||
v-model="passwordForm.old_password"
|
||||
type="password"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="new-password" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
id="new-password"
|
||||
v-model="passwordForm.new_password"
|
||||
type="password"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
Update Password
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { AppLayout } from '@/components/layout';
|
||||
import { useAuthStore, useAppStore } from '@/stores';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const appStore = useAppStore();
|
||||
|
||||
const user = computed(() => authStore.user);
|
||||
|
||||
const passwordForm = ref({
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
});
|
||||
|
||||
async function handleChangePassword() {
|
||||
try {
|
||||
// await changePasswordAPI(passwordForm.value);
|
||||
appStore.showSuccess('Password updated successfully!');
|
||||
passwordForm.value = { old_password: '', new_password: '' };
|
||||
} catch (error) {
|
||||
appStore.showError('Failed to update password');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips for Using Layouts
|
||||
|
||||
1. **Page Titles**: Set route meta to automatically display page titles in the header
|
||||
2. **Loading States**: Use `appStore.setLoading(true/false)` for global loading indicators
|
||||
3. **Toast Notifications**: Use `appStore.showSuccess()`, `appStore.showError()`, etc.
|
||||
4. **Authentication**: All authenticated pages should use `AppLayout`
|
||||
5. **Auth Pages**: Login and Register pages should use `AuthLayout`
|
||||
6. **Sidebar State**: The sidebar state persists across navigation
|
||||
7. **Mobile First**: All examples are responsive by default using Tailwind's mobile-first approach
|
||||
480
frontend/src/components/layout/INTEGRATION.md
Normal file
480
frontend/src/components/layout/INTEGRATION.md
Normal file
@@ -0,0 +1,480 @@
|
||||
# Layout Components Integration Guide
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Import Layout Components
|
||||
|
||||
```typescript
|
||||
// In your view files
|
||||
import { AppLayout, AuthLayout } from '@/components/layout';
|
||||
```
|
||||
|
||||
### 2. Use in Routes
|
||||
|
||||
```typescript
|
||||
// src/router/index.ts
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
// Views
|
||||
import DashboardView from '@/views/DashboardView.vue';
|
||||
import LoginView from '@/views/auth/LoginView.vue';
|
||||
import RegisterView from '@/views/auth/RegisterView.vue';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
// Auth routes (no layout needed - views use AuthLayout internally)
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: LoginView,
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: RegisterView,
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
|
||||
// User routes (use AppLayout)
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: DashboardView,
|
||||
meta: { requiresAuth: true, title: 'Dashboard' },
|
||||
},
|
||||
{
|
||||
path: '/api-keys',
|
||||
name: 'ApiKeys',
|
||||
component: () => import('@/views/ApiKeysView.vue'),
|
||||
meta: { requiresAuth: true, title: 'API Keys' },
|
||||
},
|
||||
{
|
||||
path: '/usage',
|
||||
name: 'Usage',
|
||||
component: () => import('@/views/UsageView.vue'),
|
||||
meta: { requiresAuth: true, title: 'Usage Statistics' },
|
||||
},
|
||||
{
|
||||
path: '/redeem',
|
||||
name: 'Redeem',
|
||||
component: () => import('@/views/RedeemView.vue'),
|
||||
meta: { requiresAuth: true, title: 'Redeem Code' },
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'Profile',
|
||||
component: () => import('@/views/ProfileView.vue'),
|
||||
meta: { requiresAuth: true, title: 'Profile Settings' },
|
||||
},
|
||||
|
||||
// Admin routes (use AppLayout, admin only)
|
||||
{
|
||||
path: '/admin/dashboard',
|
||||
name: 'AdminDashboard',
|
||||
component: () => import('@/views/admin/DashboardView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Admin Dashboard' },
|
||||
},
|
||||
{
|
||||
path: '/admin/users',
|
||||
name: 'AdminUsers',
|
||||
component: () => import('@/views/admin/UsersView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'User Management' },
|
||||
},
|
||||
{
|
||||
path: '/admin/groups',
|
||||
name: 'AdminGroups',
|
||||
component: () => import('@/views/admin/GroupsView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Groups' },
|
||||
},
|
||||
{
|
||||
path: '/admin/accounts',
|
||||
name: 'AdminAccounts',
|
||||
component: () => import('@/views/admin/AccountsView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Accounts' },
|
||||
},
|
||||
{
|
||||
path: '/admin/proxies',
|
||||
name: 'AdminProxies',
|
||||
component: () => import('@/views/admin/ProxiesView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Proxies' },
|
||||
},
|
||||
{
|
||||
path: '/admin/redeem-codes',
|
||||
name: 'AdminRedeemCodes',
|
||||
component: () => import('@/views/admin/RedeemCodesView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Redeem Codes' },
|
||||
},
|
||||
|
||||
// Default redirect
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/dashboard',
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
// Navigation guards
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore();
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
// Redirect to login if not authenticated
|
||||
next('/login');
|
||||
} else if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
||||
// Redirect to dashboard if not admin
|
||||
next('/dashboard');
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
```
|
||||
|
||||
### 3. Initialize Stores in main.ts
|
||||
|
||||
```typescript
|
||||
// src/main.ts
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import './style.css';
|
||||
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
|
||||
// Initialize auth state on app startup
|
||||
import { useAuthStore } from '@/stores';
|
||||
const authStore = useAuthStore();
|
||||
authStore.checkAuth();
|
||||
|
||||
app.mount('#app');
|
||||
```
|
||||
|
||||
### 4. Update App.vue
|
||||
|
||||
```vue
|
||||
<!-- src/App.vue -->
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// App.vue just renders the router view
|
||||
// Layouts are handled by individual views
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## View Component Templates
|
||||
|
||||
### Authenticated Page Template
|
||||
|
||||
```vue
|
||||
<!-- src/views/DashboardView.vue -->
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||
|
||||
<!-- Your content here -->
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AppLayout } from '@/components/layout';
|
||||
|
||||
// Your component logic here
|
||||
</script>
|
||||
```
|
||||
|
||||
### Auth Page Template
|
||||
|
||||
```vue
|
||||
<!-- src/views/auth/LoginView.vue -->
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6">Login</h2>
|
||||
|
||||
<!-- Your login form here -->
|
||||
|
||||
<template #footer>
|
||||
<p class="text-gray-600">
|
||||
Don't have an account?
|
||||
<router-link to="/register" class="text-indigo-600 hover:underline">
|
||||
Sign up
|
||||
</router-link>
|
||||
</p>
|
||||
</template>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AuthLayout } from '@/components/layout';
|
||||
|
||||
// Your login logic here
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Customization
|
||||
|
||||
### Changing Colors
|
||||
|
||||
The components use Tailwind's indigo color scheme by default. To change:
|
||||
|
||||
```vue
|
||||
<!-- Change all instances of indigo-* to your preferred color -->
|
||||
<div class="bg-blue-600"> <!-- Instead of bg-indigo-600 -->
|
||||
<div class="text-blue-600"> <!-- Instead of text-indigo-600 -->
|
||||
```
|
||||
|
||||
### Adding Custom Icons
|
||||
|
||||
Replace HTML entity icons with your preferred icon library:
|
||||
|
||||
```vue
|
||||
<!-- Before (HTML entities) -->
|
||||
<span class="text-lg">📈</span>
|
||||
|
||||
<!-- After (Heroicons example) -->
|
||||
<ChartBarIcon class="w-5 h-5" />
|
||||
```
|
||||
|
||||
### Sidebar Customization
|
||||
|
||||
Modify navigation items in `AppSidebar.vue`:
|
||||
|
||||
```typescript
|
||||
// Add/remove/modify navigation items
|
||||
const userNavItems = [
|
||||
{ path: '/dashboard', label: 'Dashboard', icon: '📈' },
|
||||
{ path: '/new-page', label: 'New Page', icon: '📄' }, // Add new item
|
||||
// ...
|
||||
];
|
||||
```
|
||||
|
||||
### Header Customization
|
||||
|
||||
Modify user dropdown in `AppHeader.vue`:
|
||||
|
||||
```vue
|
||||
<!-- Add new dropdown items -->
|
||||
<router-link
|
||||
to="/settings"
|
||||
@click="closeDropdown"
|
||||
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<span class="mr-2">⚙</span>
|
||||
Settings
|
||||
</router-link>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mobile Responsive Behavior
|
||||
|
||||
### Sidebar
|
||||
- **Desktop (md+)**: Always visible, can be collapsed to icon-only view
|
||||
- **Mobile**: Hidden by default, shown via menu toggle in header
|
||||
|
||||
### Header
|
||||
- **Desktop**: Shows full user info and balance
|
||||
- **Mobile**: Shows compact view with hamburger menu
|
||||
|
||||
To improve mobile experience, you can add overlay and transitions:
|
||||
|
||||
```vue
|
||||
<!-- AppSidebar.vue enhancement for mobile -->
|
||||
<aside
|
||||
class="fixed left-0 top-0 h-screen transition-transform duration-300 z-40"
|
||||
:class="[
|
||||
sidebarCollapsed ? 'w-16' : 'w-64',
|
||||
// Hide on mobile when collapsed
|
||||
'md:translate-x-0',
|
||||
sidebarCollapsed ? '-translate-x-full md:translate-x-0' : 'translate-x-0'
|
||||
]"
|
||||
>
|
||||
<!-- ... -->
|
||||
</aside>
|
||||
|
||||
<!-- Add overlay for mobile -->
|
||||
<div
|
||||
v-if="!sidebarCollapsed"
|
||||
@click="toggleSidebar"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-30 md:hidden"
|
||||
></div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State Management Integration
|
||||
|
||||
### Auth Store Usage
|
||||
|
||||
```typescript
|
||||
import { useAuthStore } from '@/stores';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Check if user is authenticated
|
||||
if (authStore.isAuthenticated) {
|
||||
// User is logged in
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
if (authStore.isAdmin) {
|
||||
// User has admin role
|
||||
}
|
||||
|
||||
// Get current user
|
||||
const user = authStore.user;
|
||||
```
|
||||
|
||||
### App Store Usage
|
||||
|
||||
```typescript
|
||||
import { useAppStore } from '@/stores';
|
||||
|
||||
const appStore = useAppStore();
|
||||
|
||||
// Toggle sidebar
|
||||
appStore.toggleSidebar();
|
||||
|
||||
// Show notifications
|
||||
appStore.showSuccess('Operation completed!');
|
||||
appStore.showError('Something went wrong');
|
||||
appStore.showInfo('Did you know...');
|
||||
appStore.showWarning('Be careful!');
|
||||
|
||||
// Loading state
|
||||
appStore.setLoading(true);
|
||||
// ... perform operation
|
||||
appStore.setLoading(false);
|
||||
|
||||
// Or use helper
|
||||
await appStore.withLoading(async () => {
|
||||
// Your async operation
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Accessibility Features
|
||||
|
||||
All layout components include:
|
||||
|
||||
- **Semantic HTML**: Proper use of `<nav>`, `<header>`, `<main>`, `<aside>`
|
||||
- **ARIA labels**: Buttons have descriptive labels
|
||||
- **Keyboard navigation**: All interactive elements are keyboard accessible
|
||||
- **Focus management**: Proper focus states with Tailwind's `focus:` utilities
|
||||
- **Color contrast**: WCAG AA compliant color combinations
|
||||
|
||||
To enhance further:
|
||||
|
||||
```vue
|
||||
<!-- Add skip to main content link -->
|
||||
<a
|
||||
href="#main-content"
|
||||
class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 bg-white px-4 py-2 rounded"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
|
||||
<main id="main-content">
|
||||
<!-- Content -->
|
||||
</main>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Testing Layout Components
|
||||
|
||||
```typescript
|
||||
// AppHeader.test.ts
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import AppHeader from '@/components/layout/AppHeader.vue';
|
||||
|
||||
describe('AppHeader', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it('renders user info when authenticated', () => {
|
||||
const wrapper = mount(AppHeader);
|
||||
// Add assertions
|
||||
});
|
||||
|
||||
it('shows dropdown when clicked', async () => {
|
||||
const wrapper = mount(AppHeader);
|
||||
await wrapper.find('button').trigger('click');
|
||||
expect(wrapper.find('.dropdown').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Lazy Loading
|
||||
|
||||
Views using layouts are already lazy loaded in the router example above.
|
||||
|
||||
### Code Splitting
|
||||
|
||||
Layout components are automatically code-split when imported:
|
||||
|
||||
```typescript
|
||||
// This creates a separate chunk for layout components
|
||||
import { AppLayout } from '@/components/layout';
|
||||
```
|
||||
|
||||
### Reducing Re-renders
|
||||
|
||||
Layout components use `computed` refs to prevent unnecessary re-renders:
|
||||
|
||||
```typescript
|
||||
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed);
|
||||
// This only re-renders when sidebarCollapsed changes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Sidebar not showing
|
||||
- Check if `useAppStore` is properly initialized
|
||||
- Verify Tailwind classes are being processed
|
||||
- Check z-index conflicts with other components
|
||||
|
||||
### Routes not highlighting in sidebar
|
||||
- Ensure route paths match exactly
|
||||
- Check `isActive()` function logic
|
||||
- Verify `useRoute()` is working correctly
|
||||
|
||||
### User info not displaying
|
||||
- Ensure auth store is initialized with `checkAuth()`
|
||||
- Verify user is logged in
|
||||
- Check localStorage for auth data
|
||||
|
||||
### Mobile menu not working
|
||||
- Verify `toggleSidebar()` is called correctly
|
||||
- Check responsive breakpoints (md:)
|
||||
- Test on actual mobile device or browser dev tools
|
||||
212
frontend/src/components/layout/README.md
Normal file
212
frontend/src/components/layout/README.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# Layout Components
|
||||
|
||||
Vue 3 layout components for the Sub2API frontend, built with Composition API, TypeScript, and TailwindCSS.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. AppLayout.vue
|
||||
Main application layout with sidebar and header.
|
||||
|
||||
**Usage:**
|
||||
```vue
|
||||
<template>
|
||||
<AppLayout>
|
||||
<!-- Your page content here -->
|
||||
<h1>Dashboard</h1>
|
||||
<p>Welcome to your dashboard!</p>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AppLayout } from '@/components/layout';
|
||||
</script>
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Responsive sidebar (collapsible)
|
||||
- Fixed header at top
|
||||
- Main content area with slot
|
||||
- Automatically adjusts margin based on sidebar state
|
||||
|
||||
---
|
||||
|
||||
### 2. AppSidebar.vue
|
||||
Navigation sidebar with user and admin sections.
|
||||
|
||||
**Features:**
|
||||
- Logo/brand at top
|
||||
- User navigation links:
|
||||
- Dashboard
|
||||
- API Keys
|
||||
- Usage
|
||||
- Redeem
|
||||
- Profile
|
||||
- Admin navigation links (shown only if user is admin):
|
||||
- Admin Dashboard
|
||||
- Users
|
||||
- Groups
|
||||
- Accounts
|
||||
- Proxies
|
||||
- Redeem Codes
|
||||
- Collapsible sidebar with toggle button
|
||||
- Active route highlighting
|
||||
- Icons using HTML entities
|
||||
- Responsive (mobile-friendly)
|
||||
|
||||
**Used automatically by AppLayout** - no need to import separately.
|
||||
|
||||
---
|
||||
|
||||
### 3. AppHeader.vue
|
||||
Top header with user info and actions.
|
||||
|
||||
**Features:**
|
||||
- Mobile menu toggle button
|
||||
- Page title (from route meta or slot)
|
||||
- User balance display (desktop only)
|
||||
- User dropdown menu with:
|
||||
- Profile link
|
||||
- Logout button
|
||||
- User avatar with initials
|
||||
- Click-outside handling for dropdown
|
||||
- Responsive design
|
||||
|
||||
**Usage with custom title:**
|
||||
```vue
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #title>
|
||||
Custom Page Title
|
||||
</template>
|
||||
|
||||
<!-- Your content -->
|
||||
</AppLayout>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Used automatically by AppLayout** - no need to import separately.
|
||||
|
||||
---
|
||||
|
||||
### 4. AuthLayout.vue
|
||||
Simple centered layout for authentication pages (login/register).
|
||||
|
||||
**Usage:**
|
||||
```vue
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<!-- Login/Register form content -->
|
||||
<h2 class="text-2xl font-bold mb-6">Login</h2>
|
||||
|
||||
<form @submit.prevent="handleLogin">
|
||||
<!-- Form fields -->
|
||||
</form>
|
||||
|
||||
<!-- Optional footer slot -->
|
||||
<template #footer>
|
||||
<p>
|
||||
Don't have an account?
|
||||
<router-link to="/register" class="text-indigo-600 hover:underline">
|
||||
Sign up
|
||||
</router-link>
|
||||
</p>
|
||||
</template>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AuthLayout } from '@/components/layout';
|
||||
|
||||
function handleLogin() {
|
||||
// Login logic
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Centered card container
|
||||
- Gradient background
|
||||
- Logo/brand at top
|
||||
- Main content slot
|
||||
- Optional footer slot for links
|
||||
- Fully responsive
|
||||
|
||||
---
|
||||
|
||||
## Route Configuration
|
||||
|
||||
To set page titles in the header, add meta to your routes:
|
||||
|
||||
```typescript
|
||||
// router/index.ts
|
||||
const routes = [
|
||||
{
|
||||
path: '/dashboard',
|
||||
component: DashboardView,
|
||||
meta: { title: 'Dashboard' },
|
||||
},
|
||||
{
|
||||
path: '/api-keys',
|
||||
component: ApiKeysView,
|
||||
meta: { title: 'API Keys' },
|
||||
},
|
||||
// ...
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Store Dependencies
|
||||
|
||||
These components use the following Pinia stores:
|
||||
|
||||
- **useAuthStore**: For user authentication state, role checking, and logout
|
||||
- **useAppStore**: For sidebar state management and toast notifications
|
||||
|
||||
Make sure these stores are properly initialized in your app.
|
||||
|
||||
---
|
||||
|
||||
## Styling
|
||||
|
||||
All components use TailwindCSS utility classes. Make sure your `tailwind.config.js` includes the component paths:
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{vue,js,ts,jsx,tsx}',
|
||||
],
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Icons
|
||||
|
||||
Components use HTML entity icons for simplicity:
|
||||
- 📈 Chart (Dashboard)
|
||||
- 🔑 Key (API Keys)
|
||||
- 📊 Bar Chart (Usage)
|
||||
- 🎁 Gift (Redeem)
|
||||
- 👤 User (Profile)
|
||||
- 🔌 Admin
|
||||
- 👥 Users
|
||||
- 📁 Folder (Groups)
|
||||
- 🌐 Globe (Accounts)
|
||||
- 🔄 Network (Proxies)
|
||||
- 🏷 Ticket (Redeem Codes)
|
||||
|
||||
You can replace these with your preferred icon library (e.g., Heroicons, Font Awesome) if needed.
|
||||
|
||||
---
|
||||
|
||||
## Mobile Responsiveness
|
||||
|
||||
All components are fully responsive:
|
||||
- **AppSidebar**: Fixed positioning on desktop, hidden by default on mobile
|
||||
- **AppHeader**: Shows mobile menu toggle on small screens, hides balance display
|
||||
- **AuthLayout**: Adapts padding and card size for mobile devices
|
||||
|
||||
The sidebar uses Tailwind's responsive breakpoints (md:) to adjust behavior.
|
||||
9
frontend/src/components/layout/index.ts
Normal file
9
frontend/src/components/layout/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Layout Components
|
||||
* Export all layout components for easy importing
|
||||
*/
|
||||
|
||||
export { default as AppLayout } from './AppLayout.vue';
|
||||
export { default as AppSidebar } from './AppSidebar.vue';
|
||||
export { default as AppHeader } from './AppHeader.vue';
|
||||
export { default as AuthLayout } from './AuthLayout.vue';
|
||||
176
frontend/src/composables/useAccountOAuth.ts
Normal file
176
frontend/src/composables/useAccountOAuth.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { ref } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
|
||||
export type AddMethod = 'oauth' | 'setup-token'
|
||||
export type AuthInputMethod = 'manual' | 'cookie'
|
||||
|
||||
export interface OAuthState {
|
||||
authUrl: string
|
||||
authCode: string
|
||||
sessionId: string
|
||||
sessionKey: string
|
||||
loading: boolean
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface TokenInfo {
|
||||
org_uuid?: string
|
||||
account_uuid?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export function useAccountOAuth() {
|
||||
const appStore = useAppStore()
|
||||
|
||||
// State
|
||||
const authUrl = ref('')
|
||||
const authCode = ref('')
|
||||
const sessionId = ref('')
|
||||
const sessionKey = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// Reset state
|
||||
const resetState = () => {
|
||||
authUrl.value = ''
|
||||
authCode.value = ''
|
||||
sessionId.value = ''
|
||||
sessionKey.value = ''
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
// Generate auth URL
|
||||
const generateAuthUrl = async (
|
||||
addMethod: AddMethod,
|
||||
proxyId?: number | null
|
||||
): Promise<boolean> => {
|
||||
loading.value = true
|
||||
authUrl.value = ''
|
||||
sessionId.value = ''
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const proxyConfig = proxyId ? { proxy_id: proxyId } : {}
|
||||
const endpoint = addMethod === 'oauth'
|
||||
? '/admin/accounts/generate-auth-url'
|
||||
: '/admin/accounts/generate-setup-token-url'
|
||||
|
||||
const response = await adminAPI.accounts.generateAuthUrl(endpoint, proxyConfig)
|
||||
authUrl.value = response.auth_url
|
||||
sessionId.value = response.session_id
|
||||
return true
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to generate auth URL'
|
||||
appStore.showError(error.value)
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Exchange auth code for tokens
|
||||
const exchangeAuthCode = async (
|
||||
addMethod: AddMethod,
|
||||
proxyId?: number | null
|
||||
): Promise<TokenInfo | null> => {
|
||||
if (!authCode.value.trim() || !sessionId.value) {
|
||||
error.value = 'Missing auth code or session ID'
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const proxyConfig = proxyId ? { proxy_id: proxyId } : {}
|
||||
const endpoint = addMethod === 'oauth'
|
||||
? '/admin/accounts/exchange-code'
|
||||
: '/admin/accounts/exchange-setup-token-code'
|
||||
|
||||
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
|
||||
session_id: sessionId.value,
|
||||
code: authCode.value.trim(),
|
||||
...proxyConfig
|
||||
})
|
||||
|
||||
return tokenInfo as TokenInfo
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to exchange auth code'
|
||||
appStore.showError(error.value)
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Cookie-based authentication
|
||||
const cookieAuth = async (
|
||||
addMethod: AddMethod,
|
||||
sessionKeyValue: string,
|
||||
proxyId?: number | null
|
||||
): Promise<TokenInfo | null> => {
|
||||
if (!sessionKeyValue.trim()) {
|
||||
error.value = 'Please enter sessionKey'
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const proxyConfig = proxyId ? { proxy_id: proxyId } : {}
|
||||
const endpoint = addMethod === 'oauth'
|
||||
? '/admin/accounts/cookie-auth'
|
||||
: '/admin/accounts/setup-token-cookie-auth'
|
||||
|
||||
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
|
||||
session_id: '',
|
||||
code: sessionKeyValue.trim(),
|
||||
...proxyConfig
|
||||
})
|
||||
|
||||
return tokenInfo as TokenInfo
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Cookie authorization failed'
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Parse multiple session keys
|
||||
const parseSessionKeys = (input: string): string[] => {
|
||||
return input.split('\n').map(k => k.trim()).filter(k => k)
|
||||
}
|
||||
|
||||
// Build extra info from token response
|
||||
const buildExtraInfo = (tokenInfo: TokenInfo): Record<string, string> | undefined => {
|
||||
const extra: Record<string, string> = {}
|
||||
if (tokenInfo.org_uuid) {
|
||||
extra.org_uuid = tokenInfo.org_uuid
|
||||
}
|
||||
if (tokenInfo.account_uuid) {
|
||||
extra.account_uuid = tokenInfo.account_uuid
|
||||
}
|
||||
return Object.keys(extra).length > 0 ? extra : undefined
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
authUrl,
|
||||
authCode,
|
||||
sessionId,
|
||||
sessionKey,
|
||||
loading,
|
||||
error,
|
||||
// Methods
|
||||
resetState,
|
||||
generateAuthUrl,
|
||||
exchangeAuthCode,
|
||||
cookieAuth,
|
||||
parseSessionKeys,
|
||||
buildExtraInfo
|
||||
}
|
||||
}
|
||||
40
frontend/src/composables/useClipboard.ts
Normal file
40
frontend/src/composables/useClipboard.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ref } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
export function useClipboard() {
|
||||
const appStore = useAppStore()
|
||||
const copied = ref(false)
|
||||
|
||||
const copyToClipboard = async (text: string, successMessage = 'Copied to clipboard') => {
|
||||
if (!text) return false
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copied.value = true
|
||||
appStore.showSuccess(successMessage)
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
return true
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
const input = document.createElement('input')
|
||||
input.value = text
|
||||
document.body.appendChild(input)
|
||||
input.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(input)
|
||||
copied.value = true
|
||||
appStore.showSuccess(successMessage)
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
copied,
|
||||
copyToClipboard
|
||||
}
|
||||
}
|
||||
50
frontend/src/i18n/index.ts
Normal file
50
frontend/src/i18n/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import en from './locales/en'
|
||||
import zh from './locales/zh'
|
||||
|
||||
const LOCALE_KEY = 'sub2api_locale'
|
||||
|
||||
function getDefaultLocale(): string {
|
||||
// Check localStorage first
|
||||
const saved = localStorage.getItem(LOCALE_KEY)
|
||||
if (saved && ['en', 'zh'].includes(saved)) {
|
||||
return saved
|
||||
}
|
||||
|
||||
// Check browser language
|
||||
const browserLang = navigator.language.toLowerCase()
|
||||
if (browserLang.startsWith('zh')) {
|
||||
return 'zh'
|
||||
}
|
||||
|
||||
return 'en'
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: getDefaultLocale(),
|
||||
fallbackLocale: 'en',
|
||||
messages: {
|
||||
en,
|
||||
zh,
|
||||
},
|
||||
})
|
||||
|
||||
export function setLocale(locale: string) {
|
||||
if (['en', 'zh'].includes(locale)) {
|
||||
i18n.global.locale.value = locale as 'en' | 'zh'
|
||||
localStorage.setItem(LOCALE_KEY, locale)
|
||||
document.documentElement.setAttribute('lang', locale)
|
||||
}
|
||||
}
|
||||
|
||||
export function getLocale(): string {
|
||||
return i18n.global.locale.value
|
||||
}
|
||||
|
||||
export const availableLocales = [
|
||||
{ code: 'en', name: 'English', flag: '🇺🇸' },
|
||||
{ code: 'zh', name: '中文', flag: '🇨🇳' },
|
||||
]
|
||||
|
||||
export default i18n
|
||||
1054
frontend/src/i18n/locales/en.ts
Normal file
1054
frontend/src/i18n/locales/en.ts
Normal file
File diff suppressed because it is too large
Load Diff
1233
frontend/src/i18n/locales/zh.ts
Normal file
1233
frontend/src/i18n/locales/zh.ts
Normal file
File diff suppressed because it is too large
Load Diff
12
frontend/src/main.ts
Normal file
12
frontend/src/main.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import i18n from './i18n'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
app.mount('#app')
|
||||
273
frontend/src/router/README.md
Normal file
273
frontend/src/router/README.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# Vue Router Configuration
|
||||
|
||||
## Overview
|
||||
|
||||
This directory contains the Vue Router configuration for the Sub2API frontend application. The router implements a comprehensive navigation system with authentication guards, role-based access control, and lazy loading.
|
||||
|
||||
## Files
|
||||
|
||||
- **index.ts**: Main router configuration with route definitions and navigation guards
|
||||
- **meta.d.ts**: TypeScript type definitions for route meta fields
|
||||
|
||||
## Route Structure
|
||||
|
||||
### Public Routes (No Authentication Required)
|
||||
|
||||
| Path | Component | Description |
|
||||
|------|-----------|-------------|
|
||||
| `/login` | LoginView | User login page |
|
||||
| `/register` | RegisterView | User registration page |
|
||||
|
||||
### User Routes (Authentication Required)
|
||||
|
||||
| Path | Component | Description |
|
||||
|------|-----------|-------------|
|
||||
| `/` | - | Redirects to `/dashboard` |
|
||||
| `/dashboard` | DashboardView | User dashboard with stats |
|
||||
| `/keys` | KeysView | API key management |
|
||||
| `/usage` | UsageView | Usage records and statistics |
|
||||
| `/redeem` | RedeemView | Redeem code interface |
|
||||
| `/profile` | ProfileView | User profile settings |
|
||||
|
||||
### Admin Routes (Admin Role Required)
|
||||
|
||||
| Path | Component | Description |
|
||||
|------|-----------|-------------|
|
||||
| `/admin` | - | Redirects to `/admin/dashboard` |
|
||||
| `/admin/dashboard` | AdminDashboardView | Admin dashboard |
|
||||
| `/admin/users` | AdminUsersView | User management |
|
||||
| `/admin/groups` | AdminGroupsView | Group management |
|
||||
| `/admin/accounts` | AdminAccountsView | Account management |
|
||||
| `/admin/proxies` | AdminProxiesView | Proxy management |
|
||||
| `/admin/redeem` | AdminRedeemView | Redeem code management |
|
||||
|
||||
### Special Routes
|
||||
|
||||
| Path | Component | Description |
|
||||
|------|-----------|-------------|
|
||||
| `/:pathMatch(.*)` | NotFoundView | 404 error page |
|
||||
|
||||
## Navigation Guards
|
||||
|
||||
### Authentication Guard (beforeEach)
|
||||
|
||||
The router implements a comprehensive navigation guard that:
|
||||
|
||||
1. **Sets Page Title**: Updates document title based on route meta
|
||||
2. **Checks Authentication**:
|
||||
- Public routes (`requiresAuth: false`) are accessible without login
|
||||
- Protected routes require authentication
|
||||
- Redirects to `/login` if not authenticated
|
||||
3. **Prevents Double Login**:
|
||||
- Redirects authenticated users away from login/register pages
|
||||
4. **Role-Based Access Control**:
|
||||
- Admin routes (`requiresAdmin: true`) require admin role
|
||||
- Non-admin users are redirected to `/dashboard`
|
||||
5. **Preserves Intended Destination**:
|
||||
- Saves original URL in query parameter for post-login redirect
|
||||
|
||||
### Flow Diagram
|
||||
|
||||
```
|
||||
User navigates to route
|
||||
↓
|
||||
Set page title from meta
|
||||
↓
|
||||
Is route public? ──Yes──→ Already authenticated? ──Yes──→ Redirect to /dashboard
|
||||
↓ No ↓ No
|
||||
↓ Allow access
|
||||
↓
|
||||
Is user authenticated? ──No──→ Redirect to /login with redirect query
|
||||
↓ Yes
|
||||
↓
|
||||
Requires admin role? ──Yes──→ Is user admin? ──No──→ Redirect to /dashboard
|
||||
↓ No ↓ Yes
|
||||
↓ ↓
|
||||
Allow access ←────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Route Meta Fields
|
||||
|
||||
Each route can define the following meta fields:
|
||||
|
||||
```typescript
|
||||
interface RouteMeta {
|
||||
requiresAuth?: boolean; // Default: true (requires authentication)
|
||||
requiresAdmin?: boolean; // Default: false (admin access only)
|
||||
title?: string; // Page title
|
||||
breadcrumbs?: Array<{ // Breadcrumb navigation
|
||||
label: string;
|
||||
to?: string;
|
||||
}>;
|
||||
icon?: string; // Icon for navigation menu
|
||||
hideInMenu?: boolean; // Hide from navigation menu
|
||||
}
|
||||
```
|
||||
|
||||
## Lazy Loading
|
||||
|
||||
All route components use dynamic imports for code splitting:
|
||||
|
||||
```typescript
|
||||
component: () => import('@/views/user/DashboardView.vue')
|
||||
```
|
||||
|
||||
Benefits:
|
||||
- Reduced initial bundle size
|
||||
- Faster initial page load
|
||||
- Components loaded on-demand
|
||||
- Automatic code splitting by Vite
|
||||
|
||||
## Authentication Store Integration
|
||||
|
||||
The router integrates with the Pinia auth store (`@/stores/auth`):
|
||||
|
||||
```typescript
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Check authentication status
|
||||
authStore.isAuthenticated
|
||||
|
||||
// Check admin role
|
||||
authStore.isAdmin
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Programmatic Navigation
|
||||
|
||||
```typescript
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// Navigate to a route
|
||||
router.push('/dashboard');
|
||||
|
||||
// Navigate with query parameters
|
||||
router.push({
|
||||
path: '/usage',
|
||||
query: { filter: 'today' }
|
||||
});
|
||||
|
||||
// Navigate to admin route (will be blocked if not admin)
|
||||
router.push('/admin/users');
|
||||
```
|
||||
|
||||
### Route Links
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Simple link -->
|
||||
<router-link to="/dashboard">Dashboard</router-link>
|
||||
|
||||
<!-- Named route -->
|
||||
<router-link :to="{ name: 'Keys' }">API Keys</router-link>
|
||||
|
||||
<!-- With query parameters -->
|
||||
<router-link :to="{ path: '/usage', query: { page: 1 } }">
|
||||
Usage
|
||||
</router-link>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Checking Current Route
|
||||
|
||||
```typescript
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
// Check if on admin page
|
||||
const isAdminPage = route.path.startsWith('/admin');
|
||||
|
||||
// Get route meta
|
||||
const requiresAdmin = route.meta.requiresAdmin;
|
||||
```
|
||||
|
||||
## Scroll Behavior
|
||||
|
||||
The router implements automatic scroll management:
|
||||
|
||||
- **Browser Navigation**: Restores saved scroll position
|
||||
- **New Routes**: Scrolls to top of page
|
||||
- **Hash Links**: Scrolls to anchor (when implemented)
|
||||
|
||||
## Error Handling
|
||||
|
||||
The router includes error handling for navigation failures:
|
||||
|
||||
```typescript
|
||||
router.onError((error) => {
|
||||
console.error('Router error:', error);
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Routes
|
||||
|
||||
To test navigation guards and route access:
|
||||
|
||||
1. **Public Route Access**: Visit `/login` without authentication
|
||||
2. **Protected Route**: Try accessing `/dashboard` without login (should redirect)
|
||||
3. **Admin Access**: Login as regular user, try `/admin/users` (should redirect to dashboard)
|
||||
4. **Admin Success**: Login as admin, access `/admin/users` (should succeed)
|
||||
5. **404 Handling**: Visit non-existent route (should show 404 page)
|
||||
|
||||
## Development Tips
|
||||
|
||||
### Adding New Routes
|
||||
|
||||
1. Add route definition in `routes` array
|
||||
2. Create corresponding view component
|
||||
3. Set appropriate meta fields (`requiresAuth`, `requiresAdmin`)
|
||||
4. Use lazy loading with `() => import()`
|
||||
5. Update this README with route documentation
|
||||
|
||||
### Debugging Navigation
|
||||
|
||||
Enable Vue Router debug mode:
|
||||
|
||||
```typescript
|
||||
// In browser console
|
||||
window.__VUE_ROUTER__ = router;
|
||||
|
||||
// Check current route
|
||||
router.currentRoute.value
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Issue**: 404 on page refresh
|
||||
- **Cause**: Server not configured for SPA
|
||||
- **Solution**: Configure server to serve `index.html` for all routes
|
||||
|
||||
**Issue**: Navigation guard runs twice
|
||||
- **Cause**: Multiple `next()` calls
|
||||
- **Solution**: Ensure only one `next()` call per code path
|
||||
|
||||
**Issue**: User data not loaded
|
||||
- **Cause**: Auth store not initialized
|
||||
- **Solution**: Call `authStore.checkAuth()` in App.vue or main.ts
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Client-Side Only**: Navigation guards are client-side; server must also validate
|
||||
2. **Token Validation**: API should verify JWT token on every request
|
||||
3. **Role Checking**: Backend must verify admin role, not just frontend
|
||||
4. **XSS Protection**: Vue automatically escapes template content
|
||||
5. **CSRF Protection**: Use CSRF tokens for state-changing operations
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
1. **Lazy Loading**: All routes use dynamic imports
|
||||
2. **Code Splitting**: Vite automatically splits route chunks
|
||||
3. **Prefetching**: Consider adding route prefetch for common paths
|
||||
4. **Route Caching**: Vue Router caches component instances
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Add breadcrumb navigation system
|
||||
- [ ] Implement route-based permissions beyond admin/user
|
||||
- [ ] Add route transition animations
|
||||
- [ ] Implement route prefetching for anticipated navigation
|
||||
- [ ] Add navigation analytics tracking
|
||||
345
frontend/src/router/index.ts
Normal file
345
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Vue Router configuration for Sub2API frontend
|
||||
* Defines all application routes with lazy loading and navigation guards
|
||||
*/
|
||||
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
/**
|
||||
* Route definitions with lazy loading
|
||||
*/
|
||||
const routes: RouteRecordRaw[] = [
|
||||
// ==================== Setup Routes ====================
|
||||
{
|
||||
path: '/setup',
|
||||
name: 'Setup',
|
||||
component: () => import('@/views/setup/SetupWizardView.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: 'Setup',
|
||||
},
|
||||
},
|
||||
|
||||
// ==================== Public Routes ====================
|
||||
{
|
||||
path: '/home',
|
||||
name: 'Home',
|
||||
component: () => import('@/views/HomeView.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: 'Home',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/auth/LoginView.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: 'Login',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('@/views/auth/RegisterView.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: 'Register',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/email-verify',
|
||||
name: 'EmailVerify',
|
||||
component: () => import('@/views/auth/EmailVerifyView.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: 'Verify Email',
|
||||
},
|
||||
},
|
||||
|
||||
// ==================== User Routes ====================
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/home',
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/user/DashboardView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: false,
|
||||
title: 'Dashboard',
|
||||
titleKey: 'dashboard.title',
|
||||
descriptionKey: 'dashboard.welcomeMessage',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/keys',
|
||||
name: 'Keys',
|
||||
component: () => import('@/views/user/KeysView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: false,
|
||||
title: 'API Keys',
|
||||
titleKey: 'keys.title',
|
||||
descriptionKey: 'keys.description',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/usage',
|
||||
name: 'Usage',
|
||||
component: () => import('@/views/user/UsageView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: false,
|
||||
title: 'Usage Records',
|
||||
titleKey: 'usage.title',
|
||||
descriptionKey: 'usage.description',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/redeem',
|
||||
name: 'Redeem',
|
||||
component: () => import('@/views/user/RedeemView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: false,
|
||||
title: 'Redeem Code',
|
||||
titleKey: 'redeem.title',
|
||||
descriptionKey: 'redeem.description',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'Profile',
|
||||
component: () => import('@/views/user/ProfileView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: false,
|
||||
title: 'Profile',
|
||||
titleKey: 'profile.title',
|
||||
descriptionKey: 'profile.description',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/subscriptions',
|
||||
name: 'Subscriptions',
|
||||
component: () => import('@/views/user/SubscriptionsView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: false,
|
||||
title: 'My Subscriptions',
|
||||
titleKey: 'userSubscriptions.title',
|
||||
descriptionKey: 'userSubscriptions.description',
|
||||
},
|
||||
},
|
||||
|
||||
// ==================== Admin Routes ====================
|
||||
{
|
||||
path: '/admin',
|
||||
redirect: '/admin/dashboard',
|
||||
},
|
||||
{
|
||||
path: '/admin/dashboard',
|
||||
name: 'AdminDashboard',
|
||||
component: () => import('@/views/admin/DashboardView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'Admin Dashboard',
|
||||
titleKey: 'admin.dashboard.title',
|
||||
descriptionKey: 'admin.dashboard.description',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/admin/users',
|
||||
name: 'AdminUsers',
|
||||
component: () => import('@/views/admin/UsersView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'User Management',
|
||||
titleKey: 'admin.users.title',
|
||||
descriptionKey: 'admin.users.description',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/admin/groups',
|
||||
name: 'AdminGroups',
|
||||
component: () => import('@/views/admin/GroupsView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'Group Management',
|
||||
titleKey: 'admin.groups.title',
|
||||
descriptionKey: 'admin.groups.description',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/admin/subscriptions',
|
||||
name: 'AdminSubscriptions',
|
||||
component: () => import('@/views/admin/SubscriptionsView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'Subscription Management',
|
||||
titleKey: 'admin.subscriptions.title',
|
||||
descriptionKey: 'admin.subscriptions.description',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/admin/accounts',
|
||||
name: 'AdminAccounts',
|
||||
component: () => import('@/views/admin/AccountsView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'Account Management',
|
||||
titleKey: 'admin.accounts.title',
|
||||
descriptionKey: 'admin.accounts.description',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/admin/proxies',
|
||||
name: 'AdminProxies',
|
||||
component: () => import('@/views/admin/ProxiesView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'Proxy Management',
|
||||
titleKey: 'admin.proxies.title',
|
||||
descriptionKey: 'admin.proxies.description',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/admin/redeem',
|
||||
name: 'AdminRedeem',
|
||||
component: () => import('@/views/admin/RedeemView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'Redeem Code Management',
|
||||
titleKey: 'admin.redeem.title',
|
||||
descriptionKey: 'admin.redeem.description',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/admin/settings',
|
||||
name: 'AdminSettings',
|
||||
component: () => import('@/views/admin/SettingsView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'System Settings',
|
||||
titleKey: 'admin.settings.title',
|
||||
descriptionKey: 'admin.settings.description',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/admin/usage',
|
||||
name: 'AdminUsage',
|
||||
component: () => import('@/views/admin/UsageView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'Usage Records',
|
||||
titleKey: 'admin.usage.title',
|
||||
descriptionKey: 'admin.usage.description',
|
||||
},
|
||||
},
|
||||
|
||||
// ==================== 404 Not Found ====================
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/NotFoundView.vue'),
|
||||
meta: {
|
||||
title: '404 Not Found',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Create router instance
|
||||
*/
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
// Scroll to saved position when using browser back/forward
|
||||
if (savedPosition) {
|
||||
return savedPosition;
|
||||
}
|
||||
// Scroll to top for new routes
|
||||
return { top: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Navigation guard: Authentication check
|
||||
*/
|
||||
let authInitialized = false;
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Restore auth state from localStorage on first navigation (page refresh)
|
||||
if (!authInitialized) {
|
||||
authStore.checkAuth();
|
||||
authInitialized = true;
|
||||
}
|
||||
|
||||
// Set page title
|
||||
if (to.meta.title) {
|
||||
document.title = `${to.meta.title} - Sub2API`;
|
||||
} else {
|
||||
document.title = 'Sub2API';
|
||||
}
|
||||
|
||||
// Check if route requires authentication
|
||||
const requiresAuth = to.meta.requiresAuth !== false; // Default to true
|
||||
const requiresAdmin = to.meta.requiresAdmin === true;
|
||||
|
||||
// If route doesn't require auth, allow access
|
||||
if (!requiresAuth) {
|
||||
// If already authenticated and trying to access login/register, redirect to dashboard
|
||||
if (authStore.isAuthenticated && (to.path === '/login' || to.path === '/register')) {
|
||||
next('/dashboard');
|
||||
return;
|
||||
}
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Route requires authentication
|
||||
if (!authStore.isAuthenticated) {
|
||||
// Not authenticated, redirect to login
|
||||
next({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath }, // Save intended destination
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check admin requirement
|
||||
if (requiresAdmin && !authStore.isAdmin) {
|
||||
// User is authenticated but not admin, redirect to user dashboard
|
||||
next('/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
// All checks passed, allow navigation
|
||||
next();
|
||||
});
|
||||
|
||||
/**
|
||||
* Navigation guard: Error handling
|
||||
*/
|
||||
router.onError((error) => {
|
||||
console.error('Router error:', error);
|
||||
});
|
||||
|
||||
export default router;
|
||||
46
frontend/src/router/meta.d.ts
vendored
Normal file
46
frontend/src/router/meta.d.ts
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Type definitions for Vue Router meta fields
|
||||
* Extends the RouteMeta interface with custom properties
|
||||
*/
|
||||
|
||||
import 'vue-router';
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
/**
|
||||
* Whether this route requires authentication
|
||||
* @default true
|
||||
*/
|
||||
requiresAuth?: boolean;
|
||||
|
||||
/**
|
||||
* Whether this route requires admin role
|
||||
* @default false
|
||||
*/
|
||||
requiresAdmin?: boolean;
|
||||
|
||||
/**
|
||||
* Page title for this route
|
||||
*/
|
||||
title?: string;
|
||||
|
||||
/**
|
||||
* Optional breadcrumb items for navigation
|
||||
*/
|
||||
breadcrumbs?: Array<{
|
||||
label: string;
|
||||
to?: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Icon name for this route (for sidebar navigation)
|
||||
*/
|
||||
icon?: string;
|
||||
|
||||
/**
|
||||
* Whether to hide this route from navigation menu
|
||||
* @default false
|
||||
*/
|
||||
hideInMenu?: boolean;
|
||||
}
|
||||
}
|
||||
194
frontend/src/stores/README.md
Normal file
194
frontend/src/stores/README.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# Pinia Stores Documentation
|
||||
|
||||
This directory contains all Pinia stores for the Sub2API frontend application.
|
||||
|
||||
## Stores Overview
|
||||
|
||||
### 1. Auth Store (`auth.ts`)
|
||||
|
||||
Manages user authentication state, login/logout, and token persistence.
|
||||
|
||||
**State:**
|
||||
- `user: User | null` - Current authenticated user
|
||||
- `token: string | null` - JWT authentication token
|
||||
|
||||
**Computed:**
|
||||
- `isAuthenticated: boolean` - Whether user is currently authenticated
|
||||
|
||||
**Actions:**
|
||||
- `login(credentials)` - Authenticate user with username/password
|
||||
- `register(userData)` - Register new user account
|
||||
- `logout()` - Clear authentication and logout
|
||||
- `checkAuth()` - Restore session from localStorage
|
||||
- `refreshUser()` - Fetch latest user data from server
|
||||
|
||||
### 2. App Store (`app.ts`)
|
||||
|
||||
Manages global UI state including sidebar, loading indicators, and toast notifications.
|
||||
|
||||
**State:**
|
||||
- `sidebarCollapsed: boolean` - Sidebar collapsed state
|
||||
- `loading: boolean` - Global loading state
|
||||
- `toasts: Toast[]` - Active toast notifications
|
||||
|
||||
**Computed:**
|
||||
- `hasActiveToasts: boolean` - Whether any toasts are active
|
||||
|
||||
**Actions:**
|
||||
- `toggleSidebar()` - Toggle sidebar state
|
||||
- `setSidebarCollapsed(collapsed)` - Set sidebar state explicitly
|
||||
- `setLoading(isLoading)` - Set loading state
|
||||
- `showToast(type, message, duration?)` - Show toast notification
|
||||
- `showSuccess(message, duration?)` - Show success toast
|
||||
- `showError(message, duration?)` - Show error toast
|
||||
- `showInfo(message, duration?)` - Show info toast
|
||||
- `showWarning(message, duration?)` - Show warning toast
|
||||
- `hideToast(id)` - Hide specific toast
|
||||
- `clearAllToasts()` - Clear all toasts
|
||||
- `withLoading(operation)` - Execute async operation with loading state
|
||||
- `withLoadingAndError(operation, errorMessage?)` - Execute with loading and error handling
|
||||
- `reset()` - Reset store to defaults
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Auth Store
|
||||
|
||||
```typescript
|
||||
import { useAuthStore } from '@/stores';
|
||||
|
||||
// In component setup
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Initialize on app startup
|
||||
authStore.checkAuth();
|
||||
|
||||
// Login
|
||||
try {
|
||||
await authStore.login({ username: 'user', password: 'pass' });
|
||||
console.log('Logged in:', authStore.user);
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
}
|
||||
|
||||
// Check authentication
|
||||
if (authStore.isAuthenticated) {
|
||||
console.log('User is logged in:', authStore.user?.username);
|
||||
}
|
||||
|
||||
// Logout
|
||||
authStore.logout();
|
||||
```
|
||||
|
||||
### App Store
|
||||
|
||||
```typescript
|
||||
import { useAppStore } from '@/stores';
|
||||
|
||||
// In component setup
|
||||
const appStore = useAppStore();
|
||||
|
||||
// Sidebar control
|
||||
appStore.toggleSidebar();
|
||||
appStore.setSidebarCollapsed(true);
|
||||
|
||||
// Loading state
|
||||
appStore.setLoading(true);
|
||||
// ... do work
|
||||
appStore.setLoading(false);
|
||||
|
||||
// Or use helper
|
||||
await appStore.withLoading(async () => {
|
||||
const data = await fetchData();
|
||||
return data;
|
||||
});
|
||||
|
||||
// Toast notifications
|
||||
appStore.showSuccess('Operation completed!');
|
||||
appStore.showError('Something went wrong!', 5000);
|
||||
appStore.showInfo('FYI: This is informational');
|
||||
appStore.showWarning('Be careful!');
|
||||
|
||||
// Custom toast
|
||||
const toastId = appStore.showToast('info', 'Custom message', undefined); // No auto-dismiss
|
||||
// Later...
|
||||
appStore.hideToast(toastId);
|
||||
```
|
||||
|
||||
### Combined Usage in Vue Component
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore, useAppStore } from '@/stores';
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const appStore = useAppStore();
|
||||
|
||||
onMounted(() => {
|
||||
// Check for existing session
|
||||
authStore.checkAuth();
|
||||
});
|
||||
|
||||
async function handleLogin(username: string, password: string) {
|
||||
try {
|
||||
await appStore.withLoading(async () => {
|
||||
await authStore.login({ username, password });
|
||||
});
|
||||
appStore.showSuccess('Welcome back!');
|
||||
} catch (error) {
|
||||
appStore.showError('Login failed. Please check your credentials.');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
authStore.logout();
|
||||
appStore.showInfo('You have been logged out.');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<button @click="appStore.toggleSidebar">
|
||||
Toggle Sidebar
|
||||
</button>
|
||||
|
||||
<div v-if="appStore.loading">Loading...</div>
|
||||
|
||||
<div v-if="authStore.isAuthenticated">
|
||||
Welcome, {{ authStore.user?.username }}!
|
||||
<button @click="handleLogout">Logout</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<button @click="handleLogin('user', 'pass')">Login</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Persistence
|
||||
|
||||
- **Auth Store**: Token and user data are automatically persisted to `localStorage`
|
||||
- Keys: `auth_token`, `auth_user`
|
||||
- Restored on `checkAuth()` call
|
||||
|
||||
- **App Store**: No persistence (UI state resets on page reload)
|
||||
|
||||
## TypeScript Support
|
||||
|
||||
All stores are fully typed with TypeScript. Import types from `@/types`:
|
||||
|
||||
```typescript
|
||||
import type { User, Toast, ToastType } from '@/types';
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Stores can be reset to initial state:
|
||||
|
||||
```typescript
|
||||
// Auth store
|
||||
authStore.logout(); // Clears all auth state
|
||||
|
||||
// App store
|
||||
appStore.reset(); // Resets to defaults
|
||||
```
|
||||
221
frontend/src/stores/app.ts
Normal file
221
frontend/src/stores/app.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Application State Store
|
||||
* Manages global UI state including sidebar, loading indicators, and toast notifications
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import type { Toast, ToastType } from '@/types';
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
// ==================== State ====================
|
||||
|
||||
const sidebarCollapsed = ref<boolean>(false);
|
||||
const loading = ref<boolean>(false);
|
||||
const toasts = ref<Toast[]>([]);
|
||||
|
||||
// Auto-incrementing ID for toasts
|
||||
let toastIdCounter = 0;
|
||||
|
||||
// ==================== Computed ====================
|
||||
|
||||
const hasActiveToasts = computed(() => toasts.value.length > 0);
|
||||
|
||||
const loadingCount = ref<number>(0);
|
||||
|
||||
// ==================== Actions ====================
|
||||
|
||||
/**
|
||||
* Toggle sidebar collapsed state
|
||||
*/
|
||||
function toggleSidebar(): void {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set sidebar collapsed state explicitly
|
||||
* @param collapsed - Whether sidebar should be collapsed
|
||||
*/
|
||||
function setSidebarCollapsed(collapsed: boolean): void {
|
||||
sidebarCollapsed.value = collapsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set global loading state
|
||||
* @param isLoading - Whether app is in loading state
|
||||
*/
|
||||
function setLoading(isLoading: boolean): void {
|
||||
if (isLoading) {
|
||||
loadingCount.value++;
|
||||
} else {
|
||||
loadingCount.value = Math.max(0, loadingCount.value - 1);
|
||||
}
|
||||
loading.value = loadingCount.value > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a toast notification
|
||||
* @param type - Type of toast (success, error, info, warning)
|
||||
* @param message - Toast message content
|
||||
* @param duration - Auto-dismiss duration in ms (undefined = no auto-dismiss)
|
||||
* @returns Toast ID for manual dismissal
|
||||
*/
|
||||
function showToast(
|
||||
type: ToastType,
|
||||
message: string,
|
||||
duration?: number
|
||||
): string {
|
||||
const id = `toast-${++toastIdCounter}`;
|
||||
const toast: Toast = {
|
||||
id,
|
||||
type,
|
||||
message,
|
||||
duration,
|
||||
startTime: duration !== undefined ? Date.now() : undefined,
|
||||
};
|
||||
|
||||
toasts.value.push(toast);
|
||||
|
||||
// Auto-dismiss if duration is specified
|
||||
if (duration !== undefined) {
|
||||
setTimeout(() => {
|
||||
hideToast(id);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a success toast
|
||||
* @param message - Success message
|
||||
* @param duration - Auto-dismiss duration in ms (default: 3000)
|
||||
*/
|
||||
function showSuccess(message: string, duration: number = 3000): string {
|
||||
return showToast('success', message, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an error toast
|
||||
* @param message - Error message
|
||||
* @param duration - Auto-dismiss duration in ms (default: 5000)
|
||||
*/
|
||||
function showError(message: string, duration: number = 5000): string {
|
||||
return showToast('error', message, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an info toast
|
||||
* @param message - Info message
|
||||
* @param duration - Auto-dismiss duration in ms (default: 3000)
|
||||
*/
|
||||
function showInfo(message: string, duration: number = 3000): string {
|
||||
return showToast('info', message, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a warning toast
|
||||
* @param message - Warning message
|
||||
* @param duration - Auto-dismiss duration in ms (default: 4000)
|
||||
*/
|
||||
function showWarning(message: string, duration: number = 4000): string {
|
||||
return showToast('warning', message, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide a specific toast by ID
|
||||
* @param id - Toast ID to hide
|
||||
*/
|
||||
function hideToast(id: string): void {
|
||||
const index = toasts.value.findIndex((t) => t.id === id);
|
||||
if (index !== -1) {
|
||||
toasts.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all toasts
|
||||
*/
|
||||
function clearAllToasts(): void {
|
||||
toasts.value = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an async operation with loading state
|
||||
* Automatically manages loading indicator
|
||||
* @param operation - Async operation to execute
|
||||
* @returns Promise resolving to operation result
|
||||
*/
|
||||
async function withLoading<T>(operation: () => Promise<T>): Promise<T> {
|
||||
setLoading(true);
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an async operation with loading and error handling
|
||||
* Shows error toast on failure
|
||||
* @param operation - Async operation to execute
|
||||
* @param errorMessage - Custom error message (optional)
|
||||
* @returns Promise resolving to operation result or null on error
|
||||
*/
|
||||
async function withLoadingAndError<T>(
|
||||
operation: () => Promise<T>,
|
||||
errorMessage?: string
|
||||
): Promise<T | null> {
|
||||
setLoading(true);
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
const message =
|
||||
errorMessage ||
|
||||
(error as { message?: string }).message ||
|
||||
'An error occurred';
|
||||
showError(message);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset app state to defaults
|
||||
* Useful for cleanup or testing
|
||||
*/
|
||||
function reset(): void {
|
||||
sidebarCollapsed.value = false;
|
||||
loading.value = false;
|
||||
loadingCount.value = 0;
|
||||
toasts.value = [];
|
||||
}
|
||||
|
||||
// ==================== Return Store API ====================
|
||||
|
||||
return {
|
||||
// State
|
||||
sidebarCollapsed,
|
||||
loading,
|
||||
toasts,
|
||||
|
||||
// Computed
|
||||
hasActiveToasts,
|
||||
|
||||
// Actions
|
||||
toggleSidebar,
|
||||
setSidebarCollapsed,
|
||||
setLoading,
|
||||
showToast,
|
||||
showSuccess,
|
||||
showError,
|
||||
showInfo,
|
||||
showWarning,
|
||||
hideToast,
|
||||
clearAllToasts,
|
||||
withLoading,
|
||||
withLoadingAndError,
|
||||
reset,
|
||||
};
|
||||
});
|
||||
219
frontend/src/stores/auth.ts
Normal file
219
frontend/src/stores/auth.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Authentication Store
|
||||
* Manages user authentication state, login/logout, and token persistence
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { authAPI } from '@/api';
|
||||
import type { User, LoginRequest, RegisterRequest } from '@/types';
|
||||
|
||||
const AUTH_TOKEN_KEY = 'auth_token';
|
||||
const AUTH_USER_KEY = 'auth_user';
|
||||
const AUTO_REFRESH_INTERVAL = 60 * 1000; // 60 seconds
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// ==================== State ====================
|
||||
|
||||
const user = ref<User | null>(null);
|
||||
const token = ref<string | null>(null);
|
||||
let refreshIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// ==================== Computed ====================
|
||||
|
||||
const isAuthenticated = computed(() => {
|
||||
return !!token.value && !!user.value;
|
||||
});
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
return user.value?.role === 'admin';
|
||||
});
|
||||
|
||||
// ==================== Actions ====================
|
||||
|
||||
/**
|
||||
* Initialize auth state from localStorage
|
||||
* Call this on app startup to restore session
|
||||
* Also starts auto-refresh and immediately fetches latest user data
|
||||
*/
|
||||
function checkAuth(): void {
|
||||
const savedToken = localStorage.getItem(AUTH_TOKEN_KEY);
|
||||
const savedUser = localStorage.getItem(AUTH_USER_KEY);
|
||||
|
||||
if (savedToken && savedUser) {
|
||||
try {
|
||||
token.value = savedToken;
|
||||
user.value = JSON.parse(savedUser);
|
||||
|
||||
// Immediately refresh user data from backend (async, don't block)
|
||||
refreshUser().catch((error) => {
|
||||
console.error('Failed to refresh user on init:', error);
|
||||
});
|
||||
|
||||
// Start auto-refresh interval
|
||||
startAutoRefresh();
|
||||
} catch (error) {
|
||||
console.error('Failed to parse saved user data:', error);
|
||||
clearAuth();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto-refresh interval for user data
|
||||
* Refreshes user data every 60 seconds
|
||||
*/
|
||||
function startAutoRefresh(): void {
|
||||
// Clear existing interval if any
|
||||
stopAutoRefresh();
|
||||
|
||||
refreshIntervalId = setInterval(() => {
|
||||
if (token.value) {
|
||||
refreshUser().catch((error) => {
|
||||
console.error('Auto-refresh user failed:', error);
|
||||
});
|
||||
}
|
||||
}, AUTO_REFRESH_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop auto-refresh interval
|
||||
*/
|
||||
function stopAutoRefresh(): void {
|
||||
if (refreshIntervalId) {
|
||||
clearInterval(refreshIntervalId);
|
||||
refreshIntervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User login
|
||||
* @param credentials - Login credentials (username and password)
|
||||
* @returns Promise resolving to the authenticated user
|
||||
* @throws Error if login fails
|
||||
*/
|
||||
async function login(credentials: LoginRequest): Promise<User> {
|
||||
try {
|
||||
const response = await authAPI.login(credentials);
|
||||
|
||||
// Store token and user
|
||||
token.value = response.access_token;
|
||||
user.value = response.user;
|
||||
|
||||
// Persist to localStorage
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, response.access_token);
|
||||
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(response.user));
|
||||
|
||||
// Start auto-refresh interval
|
||||
startAutoRefresh();
|
||||
|
||||
return response.user;
|
||||
} catch (error) {
|
||||
// Clear any partial state on error
|
||||
clearAuth();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User registration
|
||||
* @param userData - Registration data (username, email, password)
|
||||
* @returns Promise resolving to the newly registered and authenticated user
|
||||
* @throws Error if registration fails
|
||||
*/
|
||||
async function register(userData: RegisterRequest): Promise<User> {
|
||||
try {
|
||||
const response = await authAPI.register(userData);
|
||||
|
||||
// Store token and user
|
||||
token.value = response.access_token;
|
||||
user.value = response.user;
|
||||
|
||||
// Persist to localStorage
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, response.access_token);
|
||||
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(response.user));
|
||||
|
||||
// Start auto-refresh interval
|
||||
startAutoRefresh();
|
||||
|
||||
return response.user;
|
||||
} catch (error) {
|
||||
// Clear any partial state on error
|
||||
clearAuth();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User logout
|
||||
* Clears all authentication state and persisted data
|
||||
*/
|
||||
function logout(): void {
|
||||
// Call API logout (client-side cleanup)
|
||||
authAPI.logout();
|
||||
|
||||
// Clear state
|
||||
clearAuth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh current user data
|
||||
* Fetches latest user info from the server
|
||||
* @returns Promise resolving to the updated user
|
||||
* @throws Error if not authenticated or request fails
|
||||
*/
|
||||
async function refreshUser(): Promise<User> {
|
||||
if (!token.value) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedUser = await authAPI.getCurrentUser();
|
||||
user.value = updatedUser;
|
||||
|
||||
// Update localStorage
|
||||
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(updatedUser));
|
||||
|
||||
return updatedUser;
|
||||
} catch (error) {
|
||||
// If refresh fails with 401, clear auth state
|
||||
if ((error as { status?: number }).status === 401) {
|
||||
clearAuth();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all authentication state
|
||||
* Internal helper function
|
||||
*/
|
||||
function clearAuth(): void {
|
||||
// Stop auto-refresh
|
||||
stopAutoRefresh();
|
||||
|
||||
token.value = null;
|
||||
user.value = null;
|
||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
localStorage.removeItem(AUTH_USER_KEY);
|
||||
}
|
||||
|
||||
// ==================== Return Store API ====================
|
||||
|
||||
return {
|
||||
// State
|
||||
user,
|
||||
token,
|
||||
|
||||
// Computed
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
|
||||
// Actions
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
checkAuth,
|
||||
refreshUser,
|
||||
};
|
||||
});
|
||||
11
frontend/src/stores/index.ts
Normal file
11
frontend/src/stores/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Pinia Stores Export
|
||||
* Central export point for all application stores
|
||||
*/
|
||||
|
||||
export { useAuthStore } from './auth';
|
||||
export { useAppStore } from './app';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types';
|
||||
export type { Toast, ToastType, AppState } from '@/types';
|
||||
521
frontend/src/style.css
Normal file
521
frontend/src/style.css
Normal file
@@ -0,0 +1,521 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-gray-200 dark:border-dark-700;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply antialiased scroll-smooth;
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900 dark:bg-dark-950 dark:text-gray-100;
|
||||
@apply min-h-screen;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
@apply w-2 h-2;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 dark:bg-dark-600 rounded-full;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-400 dark:bg-dark-500;
|
||||
}
|
||||
|
||||
/* 选中文本样式 */
|
||||
::selection {
|
||||
@apply bg-primary-500/20 text-primary-900 dark:text-primary-100;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ============ 按钮样式 ============ */
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-2;
|
||||
@apply px-4 py-2.5 rounded-xl font-medium text-sm;
|
||||
@apply transition-all duration-200 ease-out;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500/50;
|
||||
@apply disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none;
|
||||
@apply active:scale-[0.98];
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-gradient-to-r from-primary-500 to-primary-600;
|
||||
@apply text-white shadow-md shadow-primary-500/25;
|
||||
@apply hover:from-primary-600 hover:to-primary-700 hover:shadow-lg hover:shadow-primary-500/30;
|
||||
@apply dark:shadow-primary-500/20;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply text-gray-700 dark:text-gray-200;
|
||||
@apply border border-gray-200 dark:border-dark-600;
|
||||
@apply shadow-sm hover:bg-gray-50 dark:hover:bg-dark-700;
|
||||
@apply hover:border-gray-300 dark:hover:border-dark-500;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply bg-transparent text-gray-600 dark:text-gray-300;
|
||||
@apply hover:bg-gray-100 dark:hover:bg-dark-800;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-gradient-to-r from-red-500 to-red-600;
|
||||
@apply text-white shadow-md shadow-red-500/25;
|
||||
@apply hover:from-red-600 hover:to-red-700 hover:shadow-lg hover:shadow-red-500/30;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@apply px-3 py-1.5 text-xs rounded-lg;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
@apply px-6 py-3 text-base rounded-2xl;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
@apply p-2.5 rounded-xl;
|
||||
}
|
||||
|
||||
/* ============ 输入框样式 ============ */
|
||||
.input {
|
||||
@apply w-full px-4 py-2.5 rounded-xl text-sm;
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply border border-gray-200 dark:border-dark-600;
|
||||
@apply text-gray-900 dark:text-gray-100;
|
||||
@apply placeholder:text-gray-400 dark:placeholder:text-dark-400;
|
||||
@apply transition-all duration-200;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-500;
|
||||
@apply disabled:bg-gray-100 dark:disabled:bg-dark-900 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
@apply border-red-500 focus:ring-red-500/30 focus:border-red-500;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
@apply block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5;
|
||||
}
|
||||
|
||||
.input-hint {
|
||||
@apply text-xs text-gray-500 dark:text-dark-400 mt-1;
|
||||
}
|
||||
|
||||
.input-error-text {
|
||||
@apply text-xs text-red-500 mt-1;
|
||||
}
|
||||
|
||||
/* Hide number input spinner buttons for cleaner UI */
|
||||
input[type="number"]::-webkit-inner-spin-button,
|
||||
input[type="number"]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
/* ============ 卡片样式 ============ */
|
||||
.card {
|
||||
@apply bg-white dark:bg-dark-800/50;
|
||||
@apply rounded-2xl;
|
||||
@apply border border-gray-100 dark:border-dark-700/50;
|
||||
@apply shadow-card;
|
||||
@apply transition-all duration-300;
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply hover:shadow-card-hover hover:-translate-y-0.5;
|
||||
@apply hover:border-gray-200 dark:hover:border-dark-600;
|
||||
}
|
||||
|
||||
.card-glass {
|
||||
@apply bg-white/70 dark:bg-dark-800/70;
|
||||
@apply backdrop-blur-xl;
|
||||
@apply border border-white/20 dark:border-dark-700/50;
|
||||
@apply shadow-glass;
|
||||
}
|
||||
|
||||
/* ============ 统计卡片 ============ */
|
||||
.stat-card {
|
||||
@apply card p-5;
|
||||
@apply flex items-start gap-4;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
@apply w-12 h-12 rounded-xl;
|
||||
@apply flex items-center justify-center;
|
||||
@apply text-xl;
|
||||
}
|
||||
|
||||
.stat-icon-primary {
|
||||
@apply bg-primary-100 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400;
|
||||
}
|
||||
|
||||
.stat-icon-success {
|
||||
@apply bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400;
|
||||
}
|
||||
|
||||
.stat-icon-warning {
|
||||
@apply bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400;
|
||||
}
|
||||
|
||||
.stat-icon-danger {
|
||||
@apply bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
@apply text-2xl font-bold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
@apply text-sm text-gray-500 dark:text-dark-400;
|
||||
}
|
||||
|
||||
.stat-trend {
|
||||
@apply text-xs font-medium flex items-center gap-1 mt-1;
|
||||
}
|
||||
|
||||
.stat-trend-up {
|
||||
@apply text-emerald-600 dark:text-emerald-400;
|
||||
}
|
||||
|
||||
.stat-trend-down {
|
||||
@apply text-red-600 dark:text-red-400;
|
||||
}
|
||||
|
||||
/* ============ 表格样式 ============ */
|
||||
.table-container {
|
||||
@apply overflow-x-auto rounded-xl border border-gray-200 dark:border-dark-700;
|
||||
}
|
||||
|
||||
.table {
|
||||
@apply w-full text-sm;
|
||||
}
|
||||
|
||||
.table th {
|
||||
@apply px-4 py-3 text-left font-medium;
|
||||
@apply text-gray-600 dark:text-dark-300;
|
||||
@apply bg-gray-50 dark:bg-dark-800/50;
|
||||
@apply border-b border-gray-200 dark:border-dark-700;
|
||||
}
|
||||
|
||||
.table td {
|
||||
@apply px-4 py-3;
|
||||
@apply text-gray-700 dark:text-gray-300;
|
||||
@apply border-b border-gray-100 dark:border-dark-800;
|
||||
}
|
||||
|
||||
.table tr:last-child td {
|
||||
@apply border-b-0;
|
||||
}
|
||||
|
||||
.table tbody tr {
|
||||
@apply transition-colors duration-150;
|
||||
@apply hover:bg-gray-50 dark:hover:bg-dark-800/30;
|
||||
}
|
||||
|
||||
/* ============ 徽章样式 ============ */
|
||||
.badge {
|
||||
@apply inline-flex items-center gap-1;
|
||||
@apply px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
@apply bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
@apply bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
@apply bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400;
|
||||
}
|
||||
|
||||
.badge-gray {
|
||||
@apply bg-gray-100 text-gray-700 dark:bg-dark-700 dark:text-dark-300;
|
||||
}
|
||||
|
||||
/* ============ 下拉菜单 ============ */
|
||||
.dropdown {
|
||||
@apply absolute z-50;
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply rounded-xl;
|
||||
@apply border border-gray-200 dark:border-dark-700;
|
||||
@apply shadow-lg;
|
||||
@apply py-1;
|
||||
@apply animate-scale-in origin-top-right;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
@apply px-4 py-2 text-sm;
|
||||
@apply text-gray-700 dark:text-gray-300;
|
||||
@apply hover:bg-gray-100 dark:hover:bg-dark-700;
|
||||
@apply cursor-pointer transition-colors;
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
/* ============ 模态框 ============ */
|
||||
.modal-overlay {
|
||||
@apply fixed inset-0 z-50;
|
||||
@apply bg-black/50 backdrop-blur-sm;
|
||||
@apply flex items-center justify-center p-4;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply rounded-2xl shadow-2xl;
|
||||
@apply w-full;
|
||||
@apply max-h-[90vh] overflow-y-auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
@apply px-6 py-4 border-b border-gray-100 dark:border-dark-700;
|
||||
@apply flex items-center justify-between;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@apply text-lg font-semibold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
@apply px-6 py-4;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
@apply px-6 py-4 border-t border-gray-100 dark:border-dark-700;
|
||||
@apply flex items-center justify-end gap-3;
|
||||
}
|
||||
|
||||
/* ============ Toast 通知 ============ */
|
||||
.toast {
|
||||
@apply fixed top-4 right-4 z-[100];
|
||||
@apply min-w-[320px] max-w-md;
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply rounded-xl shadow-lg;
|
||||
@apply border-l-4;
|
||||
@apply p-4;
|
||||
@apply animate-slide-in-right;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
@apply border-l-emerald-500;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
@apply border-l-red-500;
|
||||
}
|
||||
|
||||
.toast-warning {
|
||||
@apply border-l-amber-500;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
@apply border-l-primary-500;
|
||||
}
|
||||
|
||||
/* ============ 侧边栏 ============ */
|
||||
.sidebar {
|
||||
@apply fixed inset-y-0 left-0 z-40;
|
||||
@apply w-64 bg-white dark:bg-dark-900;
|
||||
@apply border-r border-gray-200 dark:border-dark-800;
|
||||
@apply flex flex-col;
|
||||
@apply transition-transform duration-300;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
@apply h-16 px-6;
|
||||
@apply flex items-center gap-3;
|
||||
@apply border-b border-gray-100 dark:border-dark-800;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
@apply flex-1 overflow-y-auto py-4 px-3;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
@apply flex items-center gap-3 px-3 py-2.5 rounded-xl;
|
||||
@apply text-sm font-medium;
|
||||
@apply text-gray-600 dark:text-dark-300;
|
||||
@apply transition-all duration-200;
|
||||
@apply hover:bg-gray-100 dark:hover:bg-dark-800;
|
||||
@apply hover:text-gray-900 dark:hover:text-white;
|
||||
}
|
||||
|
||||
.sidebar-link-active {
|
||||
@apply bg-primary-50 dark:bg-primary-900/20;
|
||||
@apply text-primary-600 dark:text-primary-400;
|
||||
@apply hover:bg-primary-100 dark:hover:bg-primary-900/30;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
@apply mb-6;
|
||||
}
|
||||
|
||||
.sidebar-section-title {
|
||||
@apply px-3 mb-2;
|
||||
@apply text-xs font-semibold uppercase tracking-wider;
|
||||
@apply text-gray-400 dark:text-dark-500;
|
||||
}
|
||||
|
||||
/* ============ 页面头部 ============ */
|
||||
.page-header {
|
||||
@apply mb-6;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@apply text-2xl font-bold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
@apply text-sm text-gray-500 dark:text-dark-400 mt-1;
|
||||
}
|
||||
|
||||
/* ============ 空状态 ============ */
|
||||
.empty-state {
|
||||
@apply flex flex-col items-center justify-center py-12 px-4;
|
||||
@apply text-center;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
@apply w-16 h-16 mb-4;
|
||||
@apply text-gray-300 dark:text-dark-600;
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
@apply text-lg font-medium text-gray-900 dark:text-white mb-1;
|
||||
}
|
||||
|
||||
.empty-state-description {
|
||||
@apply text-sm text-gray-500 dark:text-dark-400 max-w-sm;
|
||||
}
|
||||
|
||||
/* ============ 加载状态 ============ */
|
||||
.spinner {
|
||||
@apply w-5 h-5 border-2 border-current border-t-transparent rounded-full;
|
||||
@apply animate-spin;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
@apply bg-gray-200 dark:bg-dark-700 rounded animate-pulse;
|
||||
}
|
||||
|
||||
/* ============ 分隔线 ============ */
|
||||
.divider {
|
||||
@apply h-px bg-gray-200 dark:bg-dark-700 my-4;
|
||||
}
|
||||
|
||||
/* ============ 标签页 ============ */
|
||||
.tabs {
|
||||
@apply flex gap-1 p-1;
|
||||
@apply bg-gray-100 dark:bg-dark-800 rounded-xl;
|
||||
}
|
||||
|
||||
.tab {
|
||||
@apply px-4 py-2 rounded-lg text-sm font-medium;
|
||||
@apply text-gray-600 dark:text-dark-400;
|
||||
@apply transition-all duration-200;
|
||||
@apply hover:text-gray-900 dark:hover:text-white;
|
||||
}
|
||||
|
||||
.tab-active {
|
||||
@apply bg-white dark:bg-dark-700;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
@apply shadow-sm;
|
||||
}
|
||||
|
||||
/* ============ 进度条 ============ */
|
||||
.progress {
|
||||
@apply h-2 bg-gray-200 dark:bg-dark-700 rounded-full overflow-hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
@apply h-full bg-gradient-to-r from-primary-500 to-primary-400;
|
||||
@apply transition-all duration-300;
|
||||
}
|
||||
|
||||
/* ============ 开关 ============ */
|
||||
.switch {
|
||||
@apply relative w-11 h-6 rounded-full cursor-pointer;
|
||||
@apply bg-gray-300 dark:bg-dark-600;
|
||||
@apply transition-colors duration-200;
|
||||
}
|
||||
|
||||
.switch-active {
|
||||
@apply bg-primary-500;
|
||||
}
|
||||
|
||||
.switch-thumb {
|
||||
@apply absolute top-0.5 left-0.5 w-5 h-5 rounded-full;
|
||||
@apply bg-white shadow-sm;
|
||||
@apply transition-transform duration-200;
|
||||
}
|
||||
|
||||
.switch-active .switch-thumb {
|
||||
@apply translate-x-5;
|
||||
}
|
||||
|
||||
/* ============ 代码块 ============ */
|
||||
.code {
|
||||
@apply font-mono text-sm;
|
||||
@apply bg-gray-100 dark:bg-dark-800;
|
||||
@apply px-1.5 py-0.5 rounded;
|
||||
@apply text-primary-600 dark:text-primary-400;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
@apply font-mono text-sm;
|
||||
@apply bg-gray-900 text-gray-100;
|
||||
@apply p-4 rounded-xl overflow-x-auto;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* 文字渐变 */
|
||||
.text-gradient {
|
||||
@apply bg-gradient-to-r from-primary-500 to-accent-500 bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
/* 玻璃效果 */
|
||||
.glass {
|
||||
@apply bg-white/80 dark:bg-dark-800/80 backdrop-blur-xl;
|
||||
}
|
||||
|
||||
/* 隐藏滚动条 */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 安全区域 */
|
||||
.safe-top {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.safe-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
}
|
||||
630
frontend/src/types/index.ts
Normal file
630
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,630 @@
|
||||
/**
|
||||
* Core Type Definitions for Sub2API Frontend
|
||||
*/
|
||||
|
||||
// ==================== User & Auth Types ====================
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: 'admin' | 'user'; // User role for authorization
|
||||
balance: number; // User balance for API usage
|
||||
concurrency: number; // Allowed concurrent requests
|
||||
status: 'active' | 'disabled'; // Account status
|
||||
allowed_groups: number[] | null; // Allowed group IDs (null = all non-exclusive groups)
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
turnstile_token?: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
verify_code?: string;
|
||||
turnstile_token?: string;
|
||||
}
|
||||
|
||||
export interface SendVerifyCodeRequest {
|
||||
email: string;
|
||||
turnstile_token?: string;
|
||||
}
|
||||
|
||||
export interface SendVerifyCodeResponse {
|
||||
message: string;
|
||||
countdown: number;
|
||||
}
|
||||
|
||||
export interface PublicSettings {
|
||||
registration_enabled: boolean;
|
||||
email_verify_enabled: boolean;
|
||||
turnstile_enabled: boolean;
|
||||
turnstile_site_key: string;
|
||||
site_name: string;
|
||||
site_logo: string;
|
||||
site_subtitle: string;
|
||||
api_base_url: string;
|
||||
contact_info: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
// ==================== Subscription Types ====================
|
||||
|
||||
export interface Subscription {
|
||||
id: number;
|
||||
user_id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
type: 'clash' | 'v2ray' | 'surge' | 'quantumult' | 'shadowrocket';
|
||||
update_interval: number; // in hours
|
||||
last_updated: string | null;
|
||||
node_count: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateSubscriptionRequest {
|
||||
name: string;
|
||||
url: string;
|
||||
type: Subscription['type'];
|
||||
update_interval?: number;
|
||||
}
|
||||
|
||||
export interface UpdateSubscriptionRequest {
|
||||
name?: string;
|
||||
url?: string;
|
||||
type?: Subscription['type'];
|
||||
update_interval?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
// ==================== Proxy Node Types ====================
|
||||
|
||||
export interface ProxyNode {
|
||||
id: number;
|
||||
subscription_id: number;
|
||||
name: string;
|
||||
type: 'ss' | 'ssr' | 'vmess' | 'vless' | 'trojan' | 'hysteria' | 'hysteria2';
|
||||
server: string;
|
||||
port: number;
|
||||
config: Record<string, unknown>; // JSON configuration specific to proxy type
|
||||
latency: number | null; // in milliseconds
|
||||
last_checked: string | null;
|
||||
is_available: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ==================== Conversion Types ====================
|
||||
|
||||
export interface ConversionRequest {
|
||||
subscription_ids: number[];
|
||||
target_type: 'clash' | 'v2ray' | 'surge' | 'quantumult' | 'shadowrocket';
|
||||
filter?: {
|
||||
name_pattern?: string;
|
||||
types?: ProxyNode['type'][];
|
||||
min_latency?: number;
|
||||
max_latency?: number;
|
||||
available_only?: boolean;
|
||||
};
|
||||
sort?: {
|
||||
by: 'name' | 'latency' | 'type';
|
||||
order: 'asc' | 'desc';
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConversionResult {
|
||||
url: string; // URL to download the converted subscription
|
||||
expires_at: string;
|
||||
node_count: number;
|
||||
}
|
||||
|
||||
// ==================== Statistics Types ====================
|
||||
|
||||
export interface SubscriptionStats {
|
||||
subscription_id: number;
|
||||
total_nodes: number;
|
||||
available_nodes: number;
|
||||
avg_latency: number | null;
|
||||
by_type: Record<ProxyNode['type'], number>;
|
||||
last_update: string;
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
total_subscriptions: number;
|
||||
total_nodes: number;
|
||||
active_subscriptions: number;
|
||||
total_conversions: number;
|
||||
last_conversion: string | null;
|
||||
}
|
||||
|
||||
// ==================== API Response Types ====================
|
||||
|
||||
export interface ApiError {
|
||||
detail: string;
|
||||
code?: string;
|
||||
field?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
// ==================== UI State Types ====================
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
title?: string;
|
||||
duration?: number; // in milliseconds, undefined means no auto-dismiss
|
||||
startTime?: number; // timestamp when toast was created, for progress bar
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
sidebarCollapsed: boolean;
|
||||
loading: boolean;
|
||||
toasts: Toast[];
|
||||
}
|
||||
|
||||
// ==================== Validation Types ====================
|
||||
|
||||
export interface ValidationError {
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ==================== Table/List Types ====================
|
||||
|
||||
export interface SortConfig {
|
||||
key: string;
|
||||
order: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface FilterConfig {
|
||||
[key: string]: string | number | boolean | null | undefined;
|
||||
}
|
||||
|
||||
export interface PaginationConfig {
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
// ==================== API Key & Group Types ====================
|
||||
|
||||
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini';
|
||||
|
||||
export type SubscriptionType = 'standard' | 'subscription';
|
||||
|
||||
export interface Group {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
platform: GroupPlatform;
|
||||
rate_multiplier: number;
|
||||
is_exclusive: boolean;
|
||||
status: 'active' | 'inactive';
|
||||
subscription_type: SubscriptionType;
|
||||
daily_limit_usd: number | null;
|
||||
weekly_limit_usd: number | null;
|
||||
monthly_limit_usd: number | null;
|
||||
account_count?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
id: number;
|
||||
user_id: number;
|
||||
key: string;
|
||||
name: string;
|
||||
group_id: number | null;
|
||||
status: 'active' | 'inactive';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
group?: Group;
|
||||
}
|
||||
|
||||
export interface CreateApiKeyRequest {
|
||||
name: string;
|
||||
group_id?: number | null;
|
||||
custom_key?: string; // 可选的自定义API Key
|
||||
}
|
||||
|
||||
export interface UpdateApiKeyRequest {
|
||||
name?: string;
|
||||
group_id?: number | null;
|
||||
status?: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
export interface CreateGroupRequest {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
platform?: GroupPlatform;
|
||||
rate_multiplier?: number;
|
||||
is_exclusive?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateGroupRequest {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
platform?: GroupPlatform;
|
||||
rate_multiplier?: number;
|
||||
is_exclusive?: boolean;
|
||||
status?: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
// ==================== Account & Proxy Types ====================
|
||||
|
||||
export type AccountPlatform = 'anthropic';
|
||||
export type AccountType = 'oauth' | 'setup-token' | 'apikey';
|
||||
export type OAuthAddMethod = 'oauth' | 'setup-token';
|
||||
export type ProxyProtocol = 'http' | 'https' | 'socks5';
|
||||
|
||||
export interface Proxy {
|
||||
id: number;
|
||||
name: string;
|
||||
protocol: ProxyProtocol;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string | null;
|
||||
password?: string | null;
|
||||
status: 'active' | 'inactive';
|
||||
account_count?: number; // Number of accounts using this proxy
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id: number;
|
||||
name: string;
|
||||
platform: AccountPlatform;
|
||||
type: AccountType;
|
||||
credentials?: Record<string, unknown>;
|
||||
proxy_id: number | null;
|
||||
concurrency: number;
|
||||
priority: number;
|
||||
status: 'active' | 'inactive' | 'error';
|
||||
error_message: string | null;
|
||||
last_used_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
proxy?: Proxy;
|
||||
group_ids?: number[]; // Groups this account belongs to
|
||||
|
||||
// Rate limit & scheduling fields
|
||||
schedulable: boolean;
|
||||
rate_limited_at: string | null;
|
||||
rate_limit_reset_at: string | null;
|
||||
overload_until: string | null;
|
||||
|
||||
// Session window fields (5-hour window)
|
||||
session_window_start: string | null;
|
||||
session_window_end: string | null;
|
||||
session_window_status: 'allowed' | 'allowed_warning' | 'rejected' | null;
|
||||
}
|
||||
|
||||
// Account Usage types
|
||||
export interface WindowStats {
|
||||
requests: number;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
export interface UsageProgress {
|
||||
utilization: number; // Percentage (0-100+, 100 = 100%)
|
||||
resets_at: string | null;
|
||||
remaining_seconds: number;
|
||||
window_stats?: WindowStats | null; // 窗口期统计(从窗口开始到当前的使用量)
|
||||
}
|
||||
|
||||
export interface AccountUsageInfo {
|
||||
updated_at: string | null;
|
||||
five_hour: UsageProgress | null;
|
||||
seven_day: UsageProgress | null;
|
||||
seven_day_sonnet: UsageProgress | null;
|
||||
}
|
||||
|
||||
export interface CreateAccountRequest {
|
||||
name: string;
|
||||
platform: AccountPlatform;
|
||||
type: AccountType;
|
||||
credentials: Record<string, unknown>;
|
||||
extra?: Record<string, string>;
|
||||
proxy_id?: number | null;
|
||||
concurrency?: number;
|
||||
priority?: number;
|
||||
group_ids?: number[];
|
||||
}
|
||||
|
||||
export interface UpdateAccountRequest {
|
||||
name?: string;
|
||||
credentials?: Record<string, unknown>;
|
||||
extra?: Record<string, string>;
|
||||
proxy_id?: number | null;
|
||||
concurrency?: number;
|
||||
priority?: number;
|
||||
status?: 'active' | 'inactive';
|
||||
group_ids?: number[];
|
||||
}
|
||||
|
||||
export interface CreateProxyRequest {
|
||||
name: string;
|
||||
protocol: ProxyProtocol;
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateProxyRequest {
|
||||
name?: string;
|
||||
protocol?: ProxyProtocol;
|
||||
host?: string;
|
||||
port?: number;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
status?: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
// ==================== Usage & Redeem Types ====================
|
||||
|
||||
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription';
|
||||
|
||||
// 消费类型: 0=钱包余额, 1=订阅套餐
|
||||
export type BillingType = 0 | 1;
|
||||
|
||||
export interface UsageLog {
|
||||
id: number;
|
||||
user_id: number;
|
||||
api_key_id: number;
|
||||
account_id: number | null;
|
||||
model: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_creation_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
total_cost: number;
|
||||
actual_cost: number;
|
||||
rate_multiplier: number;
|
||||
billing_type: BillingType;
|
||||
stream: boolean;
|
||||
duration_ms: number;
|
||||
first_token_ms: number | null;
|
||||
created_at: string;
|
||||
user?: User;
|
||||
api_key?: ApiKey;
|
||||
account?: Account;
|
||||
}
|
||||
|
||||
export interface RedeemCode {
|
||||
id: number;
|
||||
code: string;
|
||||
type: RedeemCodeType;
|
||||
value: number;
|
||||
status: 'active' | 'used' | 'expired' | 'unused';
|
||||
used_by: number | null;
|
||||
used_at: string | null;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
group_id?: number | null; // 订阅类型专用
|
||||
validity_days?: number; // 订阅类型专用
|
||||
user?: User;
|
||||
group?: Group; // 关联的分组
|
||||
}
|
||||
|
||||
export interface GenerateRedeemCodesRequest {
|
||||
count: number;
|
||||
type: RedeemCodeType;
|
||||
value: number;
|
||||
group_id?: number | null; // 订阅类型专用
|
||||
validity_days?: number; // 订阅类型专用
|
||||
}
|
||||
|
||||
export interface RedeemCodeRequest {
|
||||
code: string;
|
||||
}
|
||||
|
||||
// ==================== Dashboard & Statistics ====================
|
||||
|
||||
export interface DashboardStats {
|
||||
// 用户统计
|
||||
total_users: number;
|
||||
today_new_users: number; // 今日新增用户数
|
||||
active_users: number; // 今日有请求的用户数
|
||||
|
||||
// API Key 统计
|
||||
total_api_keys: number;
|
||||
active_api_keys: number; // 状态为 active 的 API Key 数
|
||||
|
||||
// 账户统计
|
||||
total_accounts: number;
|
||||
normal_accounts: number; // 正常账户数
|
||||
error_accounts: number; // 异常账户数
|
||||
ratelimit_accounts: number; // 限流账户数
|
||||
overload_accounts: number; // 过载账户数
|
||||
|
||||
// 累计 Token 使用统计
|
||||
total_requests: number;
|
||||
total_input_tokens: number;
|
||||
total_output_tokens: number;
|
||||
total_cache_creation_tokens: number;
|
||||
total_cache_read_tokens: number;
|
||||
total_tokens: number;
|
||||
total_cost: number; // 累计标准计费
|
||||
total_actual_cost: number; // 累计实际扣除
|
||||
|
||||
// 今日 Token 使用统计
|
||||
today_requests: number;
|
||||
today_input_tokens: number;
|
||||
today_output_tokens: number;
|
||||
today_cache_creation_tokens: number;
|
||||
today_cache_read_tokens: number;
|
||||
today_tokens: number;
|
||||
today_cost: number; // 今日标准计费
|
||||
today_actual_cost: number; // 今日实际扣除
|
||||
|
||||
// 系统运行统计
|
||||
average_duration_ms: number; // 平均响应时间
|
||||
uptime: number; // 系统运行时间(秒)
|
||||
}
|
||||
|
||||
export interface UsageStatsResponse {
|
||||
period?: string;
|
||||
total_requests: number;
|
||||
total_input_tokens: number;
|
||||
total_output_tokens: number;
|
||||
total_cache_tokens: number;
|
||||
total_tokens: number;
|
||||
total_cost: number; // 标准计费
|
||||
total_actual_cost: number; // 实际扣除
|
||||
average_duration_ms: number;
|
||||
models?: Record<string, number>;
|
||||
}
|
||||
|
||||
// ==================== Trend & Chart Types ====================
|
||||
|
||||
export interface TrendDataPoint {
|
||||
date: string;
|
||||
requests: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_tokens: number;
|
||||
total_tokens: number;
|
||||
cost: number; // 标准计费
|
||||
actual_cost: number; // 实际扣除
|
||||
}
|
||||
|
||||
export interface ModelStat {
|
||||
model: string;
|
||||
requests: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
cost: number; // 标准计费
|
||||
actual_cost: number; // 实际扣除
|
||||
}
|
||||
|
||||
export interface UserUsageTrendPoint {
|
||||
date: string;
|
||||
user_id: number;
|
||||
username: string;
|
||||
requests: number;
|
||||
tokens: number;
|
||||
cost: number; // 标准计费
|
||||
actual_cost: number; // 实际扣除
|
||||
}
|
||||
|
||||
export interface ApiKeyUsageTrendPoint {
|
||||
date: string;
|
||||
api_key_id: number;
|
||||
key_name: string;
|
||||
requests: number;
|
||||
tokens: number;
|
||||
}
|
||||
|
||||
// ==================== Admin User Management ====================
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
email?: string;
|
||||
password?: string;
|
||||
role?: 'admin' | 'user';
|
||||
balance?: number;
|
||||
concurrency?: number;
|
||||
status?: 'active' | 'disabled';
|
||||
allowed_groups?: number[] | null;
|
||||
}
|
||||
|
||||
export interface ChangePasswordRequest {
|
||||
old_password: string;
|
||||
new_password: string;
|
||||
}
|
||||
|
||||
// ==================== User Subscription Types ====================
|
||||
|
||||
export interface UserSubscription {
|
||||
id: number;
|
||||
user_id: number;
|
||||
group_id: number;
|
||||
status: 'active' | 'expired' | 'revoked';
|
||||
daily_usage_usd: number;
|
||||
weekly_usage_usd: number;
|
||||
monthly_usage_usd: number;
|
||||
daily_window_start: string | null;
|
||||
weekly_window_start: string | null;
|
||||
monthly_window_start: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
expires_at: string | null;
|
||||
user?: User;
|
||||
group?: Group;
|
||||
}
|
||||
|
||||
export interface SubscriptionProgress {
|
||||
subscription_id: number;
|
||||
daily: {
|
||||
used: number;
|
||||
limit: number | null;
|
||||
percentage: number;
|
||||
reset_in_seconds: number | null;
|
||||
} | null;
|
||||
weekly: {
|
||||
used: number;
|
||||
limit: number | null;
|
||||
percentage: number;
|
||||
reset_in_seconds: number | null;
|
||||
} | null;
|
||||
monthly: {
|
||||
used: number;
|
||||
limit: number | null;
|
||||
percentage: number;
|
||||
reset_in_seconds: number | null;
|
||||
} | null;
|
||||
expires_at: string | null;
|
||||
days_remaining: number | null;
|
||||
}
|
||||
|
||||
export interface AssignSubscriptionRequest {
|
||||
user_id: number;
|
||||
group_id: number;
|
||||
validity_days?: number;
|
||||
}
|
||||
|
||||
export interface BulkAssignSubscriptionRequest {
|
||||
user_ids: number[];
|
||||
group_id: number;
|
||||
validity_days?: number;
|
||||
}
|
||||
|
||||
export interface ExtendSubscriptionRequest {
|
||||
days: number;
|
||||
}
|
||||
|
||||
// ==================== Query Parameters ====================
|
||||
|
||||
export interface UsageQueryParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
api_key_id?: number;
|
||||
user_id?: number;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
113
frontend/src/utils/format.ts
Normal file
113
frontend/src/utils/format.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 格式化工具函数
|
||||
* 参考 CRS 项目的 format.js 实现
|
||||
*/
|
||||
|
||||
/**
|
||||
* 格式化相对时间
|
||||
* @param date 日期字符串或 Date 对象
|
||||
* @returns 相对时间字符串,如 "5m ago", "2h ago", "3d ago"
|
||||
*/
|
||||
export function formatRelativeTime(date: string | Date | null | undefined): string {
|
||||
if (!date) return 'Never'
|
||||
|
||||
const now = new Date()
|
||||
const past = new Date(date)
|
||||
const diffMs = now.getTime() - past.getTime()
|
||||
|
||||
// 处理未来时间或无效日期
|
||||
if (diffMs < 0 || isNaN(diffMs)) return 'Never'
|
||||
|
||||
const diffSecs = Math.floor(diffMs / 1000)
|
||||
const diffMins = Math.floor(diffSecs / 60)
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffDays > 0) return `${diffDays}d ago`
|
||||
if (diffHours > 0) return `${diffHours}h ago`
|
||||
if (diffMins > 0) return `${diffMins}m ago`
|
||||
return 'Just now'
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数字(支持 K/M/B 单位)
|
||||
* @param num 数字
|
||||
* @returns 格式化后的字符串,如 "1.2K", "3.5M"
|
||||
*/
|
||||
export function formatNumber(num: number | null | undefined): string {
|
||||
if (num === null || num === undefined) return '0'
|
||||
|
||||
const absNum = Math.abs(num)
|
||||
|
||||
if (absNum >= 1e9) {
|
||||
return (num / 1e9).toFixed(2) + 'B'
|
||||
} else if (absNum >= 1e6) {
|
||||
return (num / 1e6).toFixed(2) + 'M'
|
||||
} else if (absNum >= 1e3) {
|
||||
return (num / 1e3).toFixed(1) + 'K'
|
||||
}
|
||||
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化货币金额
|
||||
* @param amount 金额
|
||||
* @returns 格式化后的字符串,如 "$1.25" 或 "$0.000123"
|
||||
*/
|
||||
export function formatCurrency(amount: number | null | undefined): string {
|
||||
if (amount === null || amount === undefined) return '$0.00'
|
||||
|
||||
// 小于 0.01 时显示更多小数位
|
||||
if (amount > 0 && amount < 0.01) {
|
||||
return '$' + amount.toFixed(6)
|
||||
}
|
||||
|
||||
return '$' + amount.toFixed(2)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化字节大小
|
||||
* @param bytes 字节数
|
||||
* @param decimals 小数位数
|
||||
* @returns 格式化后的字符串,如 "1.5 MB"
|
||||
*/
|
||||
export function formatBytes(bytes: number, decimals: number = 2): string {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
|
||||
const k = 1024
|
||||
const dm = decimals < 0 ? 0 : decimals
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
* @param date 日期字符串或 Date 对象
|
||||
* @param format 格式字符串,支持 YYYY, MM, DD, HH, mm, ss
|
||||
* @returns 格式化后的日期字符串
|
||||
*/
|
||||
export function formatDate(date: string | Date | null | undefined, format: string = 'YYYY-MM-DD HH:mm:ss'): string {
|
||||
if (!date) return ''
|
||||
|
||||
const d = new Date(date)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(d.getSeconds()).padStart(2, '0')
|
||||
|
||||
return format
|
||||
.replace('YYYY', String(year))
|
||||
.replace('MM', month)
|
||||
.replace('DD', day)
|
||||
.replace('HH', hours)
|
||||
.replace('mm', minutes)
|
||||
.replace('ss', seconds)
|
||||
}
|
||||
452
frontend/src/views/HomeView.vue
Normal file
452
frontend/src/views/HomeView.vue
Normal file
@@ -0,0 +1,452 @@
|
||||
<template>
|
||||
<div class="min-h-screen relative overflow-hidden bg-gradient-to-br from-gray-50 via-primary-50/30 to-gray-100 dark:from-dark-950 dark:via-dark-900 dark:to-dark-950">
|
||||
<!-- Background Decorations -->
|
||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div class="absolute -top-40 -right-40 w-96 h-96 bg-primary-400/20 rounded-full blur-3xl"></div>
|
||||
<div class="absolute -bottom-40 -left-40 w-96 h-96 bg-primary-500/15 rounded-full blur-3xl"></div>
|
||||
<div class="absolute top-1/4 left-1/3 w-72 h-72 bg-primary-300/10 rounded-full blur-3xl"></div>
|
||||
<div class="absolute bottom-1/4 right-1/4 w-64 h-64 bg-primary-400/10 rounded-full blur-3xl"></div>
|
||||
<div class="absolute inset-0 bg-[linear-gradient(rgba(20,184,166,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(20,184,166,0.03)_1px,transparent_1px)] bg-[size:64px_64px]"></div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="relative z-20 px-6 py-4">
|
||||
<nav class="max-w-6xl mx-auto flex items-center justify-between">
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 rounded-xl overflow-hidden shadow-md">
|
||||
<img :src="siteLogo || '/logo.png'" alt="Logo" class="w-full h-full object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nav Actions -->
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Language Switcher -->
|
||||
<LocaleSwitcher />
|
||||
|
||||
<!-- GitHub Link -->
|
||||
<a
|
||||
:href="githubUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="p-2 rounded-lg text-gray-500 hover:text-gray-700 dark:text-dark-400 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-dark-800 transition-colors"
|
||||
:title="t('home.viewOnGithub')"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<button
|
||||
@click="toggleTheme"
|
||||
class="p-2 rounded-lg text-gray-500 hover:text-gray-700 dark:text-dark-400 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-dark-800 transition-colors"
|
||||
:title="isDark ? t('home.switchToLight') : t('home.switchToDark')"
|
||||
>
|
||||
<svg v-if="isDark" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Login / Dashboard Button -->
|
||||
<router-link
|
||||
v-if="isAuthenticated"
|
||||
to="/dashboard"
|
||||
class="inline-flex items-center gap-1.5 pl-1 pr-2.5 py-1 rounded-full bg-gray-900 dark:bg-gray-800 hover:bg-gray-800 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<span class="w-5 h-5 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 text-white flex items-center justify-center text-[10px] font-semibold">
|
||||
{{ userInitial }}
|
||||
</span>
|
||||
<span class="text-xs font-medium text-white">{{ t('home.dashboard') }}</span>
|
||||
<svg class="w-3 h-3 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 19.5l15-15m0 0H8.25m11.25 0v11.25" />
|
||||
</svg>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-else
|
||||
to="/login"
|
||||
class="inline-flex items-center px-3 py-1 rounded-full bg-gray-900 dark:bg-gray-800 hover:bg-gray-800 dark:hover:bg-gray-700 text-xs font-medium text-white transition-colors"
|
||||
>
|
||||
{{ t('home.login') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="relative z-10 px-6 py-16">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<!-- Hero Section - Left/Right Layout -->
|
||||
<div class="flex flex-col lg:flex-row items-center justify-between gap-12 lg:gap-16 mb-12">
|
||||
<!-- Left: Text Content -->
|
||||
<div class="flex-1 text-center lg:text-left">
|
||||
<h1 class="text-4xl md:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{{ siteName }}
|
||||
</h1>
|
||||
<p class="text-lg md:text-xl text-gray-600 dark:text-dark-300 mb-8">
|
||||
{{ siteSubtitle }}
|
||||
</p>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<div>
|
||||
<router-link
|
||||
:to="isAuthenticated ? '/dashboard' : '/login'"
|
||||
class="btn btn-primary px-8 py-3 text-base shadow-lg shadow-primary-500/30"
|
||||
>
|
||||
{{ isAuthenticated ? t('home.goToDashboard') : t('home.getStarted') }}
|
||||
<svg class="w-5 h-5 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Terminal Animation -->
|
||||
<div class="flex-1 flex justify-center lg:justify-end">
|
||||
<div class="terminal-container">
|
||||
<div class="terminal-window">
|
||||
<!-- Window header -->
|
||||
<div class="terminal-header">
|
||||
<div class="terminal-buttons">
|
||||
<span class="btn-close"></span>
|
||||
<span class="btn-minimize"></span>
|
||||
<span class="btn-maximize"></span>
|
||||
</div>
|
||||
<span class="terminal-title">terminal</span>
|
||||
</div>
|
||||
<!-- Terminal content -->
|
||||
<div class="terminal-body">
|
||||
<div class="code-line line-1">
|
||||
<span class="code-prompt">$</span>
|
||||
<span class="code-cmd">curl</span>
|
||||
<span class="code-flag">-X POST</span>
|
||||
<span class="code-url">/v1/messages</span>
|
||||
</div>
|
||||
<div class="code-line line-2">
|
||||
<span class="code-comment"># Routing to upstream...</span>
|
||||
</div>
|
||||
<div class="code-line line-3">
|
||||
<span class="code-success">200 OK</span>
|
||||
<span class="code-response">{ "content": "Hello!" }</span>
|
||||
</div>
|
||||
<div class="code-line line-4">
|
||||
<span class="code-prompt">$</span>
|
||||
<span class="cursor"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feature Tags - Centered -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-4 md:gap-6 mb-12">
|
||||
<div class="inline-flex items-center gap-2.5 px-5 py-2.5 rounded-full bg-white/80 dark:bg-dark-800/80 backdrop-blur-sm border border-gray-200/50 dark:border-dark-700/50 shadow-sm">
|
||||
<svg class="w-4 h-4 text-primary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">{{ t('home.tags.subscriptionToApi') }}</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-2.5 px-5 py-2.5 rounded-full bg-white/80 dark:bg-dark-800/80 backdrop-blur-sm border border-gray-200/50 dark:border-dark-700/50 shadow-sm">
|
||||
<svg class="w-4 h-4 text-primary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">{{ t('home.tags.stickySession') }}</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-2.5 px-5 py-2.5 rounded-full bg-white/80 dark:bg-dark-800/80 backdrop-blur-sm border border-gray-200/50 dark:border-dark-700/50 shadow-sm">
|
||||
<svg class="w-4 h-4 text-primary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">{{ t('home.tags.realtimeBilling') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features Grid -->
|
||||
<div class="grid md:grid-cols-3 gap-6 mb-12">
|
||||
<!-- Feature 1: Unified Gateway -->
|
||||
<div class="group p-6 rounded-2xl bg-white/60 dark:bg-dark-800/60 backdrop-blur-sm border border-gray-200/50 dark:border-dark-700/50 hover:shadow-xl hover:shadow-primary-500/10 transition-all duration-300">
|
||||
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center mb-4 shadow-lg shadow-blue-500/30 group-hover:scale-110 transition-transform">
|
||||
<svg class="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">{{ t('home.features.unifiedGateway') }}</h3>
|
||||
<p class="text-gray-600 dark:text-dark-400 text-sm leading-relaxed">
|
||||
{{ t('home.features.unifiedGatewayDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 2: Account Pool -->
|
||||
<div class="group p-6 rounded-2xl bg-white/60 dark:bg-dark-800/60 backdrop-blur-sm border border-gray-200/50 dark:border-dark-700/50 hover:shadow-xl hover:shadow-primary-500/10 transition-all duration-300">
|
||||
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center mb-4 shadow-lg shadow-primary-500/30 group-hover:scale-110 transition-transform">
|
||||
<svg class="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">{{ t('home.features.multiAccount') }}</h3>
|
||||
<p class="text-gray-600 dark:text-dark-400 text-sm leading-relaxed">
|
||||
{{ t('home.features.multiAccountDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 3: Billing & Quota -->
|
||||
<div class="group p-6 rounded-2xl bg-white/60 dark:bg-dark-800/60 backdrop-blur-sm border border-gray-200/50 dark:border-dark-700/50 hover:shadow-xl hover:shadow-primary-500/10 transition-all duration-300">
|
||||
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-purple-500 to-purple-600 flex items-center justify-center mb-4 shadow-lg shadow-purple-500/30 group-hover:scale-110 transition-transform">
|
||||
<svg class="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">{{ t('home.features.balanceQuota') }}</h3>
|
||||
<p class="text-gray-600 dark:text-dark-400 text-sm leading-relaxed">
|
||||
{{ t('home.features.balanceQuotaDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Supported Providers -->
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-3">{{ t('home.providers.title') }}</h2>
|
||||
<p class="text-gray-600 dark:text-dark-400 text-sm">
|
||||
{{ t('home.providers.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-4 mb-16">
|
||||
<!-- Claude - Supported -->
|
||||
<div class="flex items-center gap-2 px-5 py-3 rounded-xl bg-white/60 dark:bg-dark-800/60 backdrop-blur-sm border border-primary-200 dark:border-primary-800 ring-1 ring-primary-500/20">
|
||||
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-orange-400 to-orange-500 flex items-center justify-center">
|
||||
<span class="text-white text-xs font-bold">C</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">Claude</span>
|
||||
<span class="px-1.5 py-0.5 text-[10px] font-medium bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded">{{ t('home.providers.supported') }}</span>
|
||||
</div>
|
||||
<!-- GPT - Coming Soon -->
|
||||
<div class="flex items-center gap-2 px-5 py-3 rounded-xl bg-white/40 dark:bg-dark-800/40 backdrop-blur-sm border border-gray-200/50 dark:border-dark-700/50 opacity-60">
|
||||
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center">
|
||||
<span class="text-white text-xs font-bold">G</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">GPT</span>
|
||||
<span class="px-1.5 py-0.5 text-[10px] font-medium bg-gray-100 dark:bg-dark-700 text-gray-500 dark:text-dark-400 rounded">{{ t('home.providers.soon') }}</span>
|
||||
</div>
|
||||
<!-- Gemini - Coming Soon -->
|
||||
<div class="flex items-center gap-2 px-5 py-3 rounded-xl bg-white/40 dark:bg-dark-800/40 backdrop-blur-sm border border-gray-200/50 dark:border-dark-700/50 opacity-60">
|
||||
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center">
|
||||
<span class="text-white text-xs font-bold">G</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">Gemini</span>
|
||||
<span class="px-1.5 py-0.5 text-[10px] font-medium bg-gray-100 dark:bg-dark-700 text-gray-500 dark:text-dark-400 rounded">{{ t('home.providers.soon') }}</span>
|
||||
</div>
|
||||
<!-- More - Coming Soon -->
|
||||
<div class="flex items-center gap-2 px-5 py-3 rounded-xl bg-white/40 dark:bg-dark-800/40 backdrop-blur-sm border border-gray-200/50 dark:border-dark-700/50 opacity-60">
|
||||
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-gray-500 to-gray-600 flex items-center justify-center">
|
||||
<span class="text-white text-xs font-bold">+</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">More</span>
|
||||
<span class="px-1.5 py-0.5 text-[10px] font-medium bg-gray-100 dark:bg-dark-700 text-gray-500 dark:text-dark-400 rounded">{{ t('home.providers.soon') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="relative z-10 px-6 py-8 border-t border-gray-200/50 dark:border-dark-800/50">
|
||||
<div class="max-w-6xl mx-auto text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-dark-400">
|
||||
© {{ currentYear }} {{ siteName }}. {{ t('home.footer.allRightsReserved') }}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { getPublicSettings } from '@/api/auth';
|
||||
import { useAuthStore } from '@/stores';
|
||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Site settings
|
||||
const siteName = ref('Sub2API');
|
||||
const siteLogo = ref('');
|
||||
const siteSubtitle = ref('AI API Gateway Platform');
|
||||
|
||||
// Theme
|
||||
const isDark = ref(document.documentElement.classList.contains('dark'));
|
||||
|
||||
// GitHub URL
|
||||
const githubUrl = 'https://github.com/fangyuan99/sub2api';
|
||||
|
||||
// Auth state
|
||||
const isAuthenticated = computed(() => authStore.isAuthenticated);
|
||||
const userInitial = computed(() => {
|
||||
const user = authStore.user;
|
||||
if (!user || !user.email) return '';
|
||||
return user.email.charAt(0).toUpperCase();
|
||||
});
|
||||
|
||||
// Current year for footer
|
||||
const currentYear = computed(() => new Date().getFullYear());
|
||||
|
||||
// Toggle theme
|
||||
function toggleTheme() {
|
||||
isDark.value = !isDark.value;
|
||||
document.documentElement.classList.toggle('dark', isDark.value);
|
||||
localStorage.setItem('theme', isDark.value ? 'dark' : 'light');
|
||||
}
|
||||
|
||||
// Initialize theme
|
||||
function initTheme() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
isDark.value = true;
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
initTheme();
|
||||
|
||||
// Check auth state
|
||||
authStore.checkAuth();
|
||||
|
||||
try {
|
||||
const settings = await getPublicSettings();
|
||||
siteName.value = settings.site_name || 'Sub2API';
|
||||
siteLogo.value = settings.site_logo || '';
|
||||
siteSubtitle.value = settings.site_subtitle || 'AI API Gateway Platform';
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Terminal Container */
|
||||
.terminal-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Terminal Window */
|
||||
.terminal-window {
|
||||
width: 420px;
|
||||
background: linear-gradient(145deg, #1e293b 0%, #0f172a 100%);
|
||||
border-radius: 14px;
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
overflow: hidden;
|
||||
transform: perspective(1000px) rotateX(2deg) rotateY(-2deg);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.terminal-window:hover {
|
||||
transform: perspective(1000px) rotateX(0deg) rotateY(0deg) translateY(-4px);
|
||||
}
|
||||
|
||||
/* Terminal Header */
|
||||
.terminal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.terminal-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.terminal-buttons span {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.btn-close { background: #ef4444; }
|
||||
.btn-minimize { background: #eab308; }
|
||||
.btn-maximize { background: #22c55e; }
|
||||
|
||||
.terminal-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, monospace;
|
||||
color: #64748b;
|
||||
margin-right: 52px;
|
||||
}
|
||||
|
||||
/* Terminal Body */
|
||||
.terminal-body {
|
||||
padding: 20px 24px;
|
||||
font-family: ui-monospace, 'Fira Code', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.code-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
opacity: 0;
|
||||
animation: line-appear 0.5s ease forwards;
|
||||
}
|
||||
|
||||
.line-1 { animation-delay: 0.3s; }
|
||||
.line-2 { animation-delay: 1s; }
|
||||
.line-3 { animation-delay: 1.8s; }
|
||||
.line-4 { animation-delay: 2.5s; }
|
||||
|
||||
@keyframes line-appear {
|
||||
from { opacity: 0; transform: translateY(5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.code-prompt { color: #22c55e; font-weight: bold; }
|
||||
.code-cmd { color: #38bdf8; }
|
||||
.code-flag { color: #a78bfa; }
|
||||
.code-url { color: #14b8a6; }
|
||||
.code-comment { color: #64748b; font-style: italic; }
|
||||
.code-success {
|
||||
color: #22c55e;
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.code-response { color: #fbbf24; }
|
||||
|
||||
/* Blinking Cursor */
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 16px;
|
||||
background: #22c55e;
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
:deep(.dark) .terminal-window {
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.6),
|
||||
0 0 0 1px rgba(20, 184, 166, 0.2),
|
||||
0 0 40px rgba(20, 184, 166, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
</style>
|
||||
75
frontend/src/views/NotFoundView.vue
Normal file
75
frontend/src/views/NotFoundView.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-dark-950 px-4 relative overflow-hidden">
|
||||
<!-- Background Decoration -->
|
||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div class="absolute -top-40 -right-40 w-80 h-80 bg-primary-400/10 rounded-full blur-3xl"></div>
|
||||
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-primary-500/10 rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-md w-full text-center relative z-10">
|
||||
<!-- 404 Display -->
|
||||
<div class="mb-8">
|
||||
<div class="relative inline-block">
|
||||
<span class="text-[12rem] font-bold text-gray-100 dark:text-dark-800 leading-none">404</span>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="w-24 h-24 rounded-2xl bg-gradient-to-br from-primary-500 to-primary-600 shadow-lg shadow-primary-500/30 flex items-center justify-center">
|
||||
<svg class="w-12 h-12 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text Content -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
Page Not Found
|
||||
</h1>
|
||||
<p class="text-gray-500 dark:text-dark-400">
|
||||
The page you are looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<button
|
||||
@click="goBack"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
Go Back
|
||||
</button>
|
||||
<router-link
|
||||
to="/dashboard"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
||||
</svg>
|
||||
Go to Dashboard
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Help Link -->
|
||||
<p class="mt-8 text-sm text-gray-400 dark:text-dark-500">
|
||||
Need help?
|
||||
<a href="#" class="text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300 transition-colors">
|
||||
Contact support
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function goBack(): void {
|
||||
router.back();
|
||||
}
|
||||
</script>
|
||||
523
frontend/src/views/admin/AccountsView.vue
Normal file
523
frontend/src/views/admin/AccountsView.vue
Normal file
@@ -0,0 +1,523 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header Actions -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
{{ t('admin.accounts.createAccount') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="relative flex-1 max-w-md">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('admin.accounts.searchAccounts')"
|
||||
class="input pl-10"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Select
|
||||
v-model="filters.platform"
|
||||
:options="platformOptions"
|
||||
:placeholder="t('admin.accounts.allPlatforms')"
|
||||
class="w-40"
|
||||
@change="loadAccounts"
|
||||
/>
|
||||
<Select
|
||||
v-model="filters.type"
|
||||
:options="typeOptions"
|
||||
:placeholder="t('admin.accounts.allTypes')"
|
||||
class="w-40"
|
||||
@change="loadAccounts"
|
||||
/>
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="statusOptions"
|
||||
:placeholder="t('admin.accounts.allStatus')"
|
||||
class="w-36"
|
||||
@change="loadAccounts"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Accounts Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<DataTable :columns="columns" :data="accounts" :loading="loading">
|
||||
<template #cell-name="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-platform="{ value }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
:class="[
|
||||
'w-2 h-2 rounded-full',
|
||||
value === 'anthropic' ? 'bg-orange-500' : 'bg-gray-400'
|
||||
]"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 capitalize">{{ value === 'anthropic' ? 'Anthropic' : value }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-type="{ value }">
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
value === 'oauth' ? 'badge-primary' : value === 'setup-token' ? 'badge-info' : 'badge-purple'
|
||||
]"
|
||||
>
|
||||
{{ value === 'oauth' ? 'Oauth' : value === 'setup-token' ? t('admin.accounts.setupToken') : t('admin.accounts.apiKey') }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ row }">
|
||||
<AccountStatusIndicator :account="row" />
|
||||
</template>
|
||||
|
||||
<template #cell-schedulable="{ row }">
|
||||
<button
|
||||
@click="handleToggleSchedulable(row)"
|
||||
:disabled="togglingSchedulable === row.id"
|
||||
class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-dark-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="[
|
||||
row.schedulable
|
||||
? 'bg-primary-500 hover:bg-primary-600'
|
||||
: 'bg-gray-200 dark:bg-dark-600 hover:bg-gray-300 dark:hover:bg-dark-500'
|
||||
]"
|
||||
:title="row.schedulable ? t('admin.accounts.schedulableEnabled') : t('admin.accounts.schedulableDisabled')"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
|
||||
:class="[row.schedulable ? 'translate-x-4' : 'translate-x-0']"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #cell-today_stats="{ row }">
|
||||
<AccountTodayStatsCell :account="row" />
|
||||
</template>
|
||||
|
||||
<template #cell-usage="{ row }">
|
||||
<AccountUsageCell :account="row" />
|
||||
</template>
|
||||
|
||||
<template #cell-priority="{ value }">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-last_used_at="{ value }">
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ formatRelativeTime(value) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Clear Rate Limit button -->
|
||||
<button
|
||||
v-if="isRateLimited(row) || isOverloaded(row)"
|
||||
@click="handleClearRateLimit(row)"
|
||||
class="p-2 rounded-lg hover:bg-amber-50 dark:hover:bg-amber-900/20 text-amber-500 hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
|
||||
:title="t('admin.accounts.clearRateLimit')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Test Connection button -->
|
||||
<button
|
||||
@click="handleTest(row)"
|
||||
class="p-2 rounded-lg hover:bg-green-50 dark:hover:bg-green-900/20 text-gray-500 hover:text-green-600 dark:hover:text-green-400 transition-colors"
|
||||
:title="t('admin.accounts.testConnection')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="row.type === 'oauth' || row.type === 'setup-token'"
|
||||
@click="handleReAuth(row)"
|
||||
class="p-2 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/20 text-gray-500 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
:title="t('admin.accounts.reAuthorize')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="row.type === 'oauth' || row.type === 'setup-token'"
|
||||
@click="handleRefreshToken(row)"
|
||||
class="p-2 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900/20 text-gray-500 hover:text-purple-600 dark:hover:text-purple-400 transition-colors"
|
||||
:title="t('admin.accounts.refreshToken')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="handleEdit(row)"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-dark-700 text-gray-500 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
|
||||
:title="t('common.edit')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="handleDelete(row)"
|
||||
class="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-gray-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
:title="t('common.delete')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<EmptyState
|
||||
:title="t('admin.accounts.noAccountsYet')"
|
||||
:description="t('admin.accounts.createFirstAccount')"
|
||||
:action-text="t('admin.accounts.createAccount')"
|
||||
@action="showCreateModal = true"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Create Account Modal -->
|
||||
<CreateAccountModal
|
||||
:show="showCreateModal"
|
||||
:proxies="proxies"
|
||||
:groups="groups"
|
||||
@close="showCreateModal = false"
|
||||
@created="loadAccounts"
|
||||
/>
|
||||
|
||||
<!-- Edit Account Modal -->
|
||||
<EditAccountModal
|
||||
:show="showEditModal"
|
||||
:account="editingAccount"
|
||||
:proxies="proxies"
|
||||
:groups="groups"
|
||||
@close="closeEditModal"
|
||||
@updated="loadAccounts"
|
||||
/>
|
||||
|
||||
<!-- Re-Auth Modal -->
|
||||
<ReAuthAccountModal
|
||||
:show="showReAuthModal"
|
||||
:account="reAuthAccount"
|
||||
@close="closeReAuthModal"
|
||||
@reauthorized="loadAccounts"
|
||||
/>
|
||||
|
||||
<!-- Test Account Modal -->
|
||||
<AccountTestModal
|
||||
:show="showTestModal"
|
||||
:account="testingAccount"
|
||||
@close="closeTestModal"
|
||||
/>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
:show="showDeleteDialog"
|
||||
:title="t('admin.accounts.deleteAccount')"
|
||||
:message="t('admin.accounts.deleteConfirm', { name: deletingAccount?.name })"
|
||||
:confirm-text="t('common.delete')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
:danger="true"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="showDeleteDialog = false"
|
||||
/>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Account, Proxy, Group } from '@/types'
|
||||
import type { Column } from '@/components/common/DataTable.vue'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import { CreateAccountModal, EditAccountModal, ReAuthAccountModal } from '@/components/account'
|
||||
import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue'
|
||||
import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
|
||||
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
|
||||
import AccountTestModal from '@/components/account/AccountTestModal.vue'
|
||||
import { formatRelativeTime } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Table columns
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'name', label: t('admin.accounts.columns.name'), sortable: true },
|
||||
{ key: 'platform', label: t('admin.accounts.columns.platform'), sortable: true },
|
||||
{ key: 'type', label: t('admin.accounts.columns.type'), sortable: true },
|
||||
{ key: 'status', label: t('admin.accounts.columns.status'), sortable: true },
|
||||
{ key: 'schedulable', label: t('admin.accounts.columns.schedulable'), sortable: true },
|
||||
{ key: 'today_stats', label: t('admin.accounts.columns.todayStats'), sortable: false },
|
||||
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
|
||||
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
|
||||
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
|
||||
{ key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false }
|
||||
])
|
||||
|
||||
// Filter options
|
||||
const platformOptions = computed(() => [
|
||||
{ value: '', label: t('admin.accounts.allPlatforms') },
|
||||
{ value: 'anthropic', label: t('admin.accounts.platforms.anthropic') }
|
||||
])
|
||||
|
||||
const typeOptions = computed(() => [
|
||||
{ value: '', label: t('admin.accounts.allTypes') },
|
||||
{ value: 'oauth', label: t('admin.accounts.oauthType') },
|
||||
{ value: 'setup-token', label: t('admin.accounts.setupToken') },
|
||||
{ value: 'apikey', label: t('admin.accounts.apiKey') }
|
||||
])
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ value: '', label: t('admin.accounts.allStatus') },
|
||||
{ value: 'active', label: t('common.active') },
|
||||
{ value: 'inactive', label: t('common.inactive') },
|
||||
{ value: 'error', label: t('common.error') }
|
||||
])
|
||||
|
||||
// State
|
||||
const accounts = ref<Account[]>([])
|
||||
const proxies = ref<Proxy[]>([])
|
||||
const groups = ref<Group[]>([])
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const filters = reactive({
|
||||
platform: '',
|
||||
type: '',
|
||||
status: '',
|
||||
})
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
|
||||
// Modal states
|
||||
const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showReAuthModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const showTestModal = ref(false)
|
||||
const editingAccount = ref<Account | null>(null)
|
||||
const reAuthAccount = ref<Account | null>(null)
|
||||
const deletingAccount = ref<Account | null>(null)
|
||||
const testingAccount = ref<Account | null>(null)
|
||||
const togglingSchedulable = ref<number | null>(null)
|
||||
|
||||
// Rate limit / Overload helpers
|
||||
const isRateLimited = (account: Account): boolean => {
|
||||
if (!account.rate_limit_reset_at) return false
|
||||
return new Date(account.rate_limit_reset_at) > new Date()
|
||||
}
|
||||
|
||||
const isOverloaded = (account: Account): boolean => {
|
||||
if (!account.overload_until) return false
|
||||
return new Date(account.overload_until) > new Date()
|
||||
}
|
||||
|
||||
// Data loading
|
||||
const loadAccounts = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await adminAPI.accounts.list(
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
{
|
||||
platform: filters.platform || undefined,
|
||||
type: filters.type || undefined,
|
||||
status: filters.status || undefined,
|
||||
search: searchQuery.value || undefined
|
||||
}
|
||||
)
|
||||
accounts.value = response.items
|
||||
pagination.total = response.total
|
||||
pagination.pages = response.pages
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.accounts.failedToLoad'))
|
||||
console.error('Error loading accounts:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadProxies = async () => {
|
||||
try {
|
||||
proxies.value = await adminAPI.proxies.getAllWithCount()
|
||||
} catch (error) {
|
||||
console.error('Error loading proxies:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadGroups = async () => {
|
||||
try {
|
||||
groups.value = await adminAPI.groups.getByPlatform('anthropic')
|
||||
} catch (error) {
|
||||
console.error('Error loading groups:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Search handling
|
||||
let searchTimeout: ReturnType<typeof setTimeout>
|
||||
const handleSearch = () => {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
pagination.page = 1
|
||||
loadAccounts()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// Pagination
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.page = page
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
// Edit modal
|
||||
const handleEdit = (account: Account) => {
|
||||
editingAccount.value = account
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
const closeEditModal = () => {
|
||||
showEditModal.value = false
|
||||
editingAccount.value = null
|
||||
}
|
||||
|
||||
// Re-Auth modal
|
||||
const handleReAuth = (account: Account) => {
|
||||
reAuthAccount.value = account
|
||||
showReAuthModal.value = true
|
||||
}
|
||||
|
||||
const closeReAuthModal = () => {
|
||||
showReAuthModal.value = false
|
||||
reAuthAccount.value = null
|
||||
}
|
||||
|
||||
// Token refresh
|
||||
const handleRefreshToken = async (account: Account) => {
|
||||
try {
|
||||
await adminAPI.accounts.refreshCredentials(account.id)
|
||||
appStore.showSuccess(t('admin.accounts.tokenRefreshed'))
|
||||
loadAccounts()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToRefresh'))
|
||||
console.error('Error refreshing token:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete
|
||||
const handleDelete = (account: Account) => {
|
||||
deletingAccount.value = account
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingAccount.value) return
|
||||
|
||||
try {
|
||||
await adminAPI.accounts.delete(deletingAccount.value.id)
|
||||
appStore.showSuccess(t('admin.accounts.accountDeleted'))
|
||||
showDeleteDialog.value = false
|
||||
deletingAccount.value = null
|
||||
loadAccounts()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToDelete'))
|
||||
console.error('Error deleting account:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear rate limit
|
||||
const handleClearRateLimit = async (account: Account) => {
|
||||
try {
|
||||
await adminAPI.accounts.clearRateLimit(account.id)
|
||||
appStore.showSuccess(t('admin.accounts.rateLimitCleared'))
|
||||
loadAccounts()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToClearRateLimit'))
|
||||
console.error('Error clearing rate limit:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle schedulable
|
||||
const handleToggleSchedulable = async (account: Account) => {
|
||||
togglingSchedulable.value = account.id
|
||||
try {
|
||||
const updatedAccount = await adminAPI.accounts.setSchedulable(account.id, !account.schedulable)
|
||||
const index = accounts.value.findIndex(a => a.id === account.id)
|
||||
if (index !== -1) {
|
||||
accounts.value[index] = updatedAccount
|
||||
}
|
||||
appStore.showSuccess(
|
||||
updatedAccount.schedulable
|
||||
? t('admin.accounts.schedulableEnabled')
|
||||
: t('admin.accounts.schedulableDisabled')
|
||||
)
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToToggleSchedulable'))
|
||||
console.error('Error toggling schedulable:', error)
|
||||
} finally {
|
||||
togglingSchedulable.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Test modal
|
||||
const handleTest = (account: Account) => {
|
||||
testingAccount.value = account
|
||||
showTestModal.value = true
|
||||
}
|
||||
|
||||
const closeTestModal = () => {
|
||||
showTestModal.value = false
|
||||
testingAccount.value = null
|
||||
}
|
||||
|
||||
// Initialize
|
||||
onMounted(() => {
|
||||
loadAccounts()
|
||||
loadProxies()
|
||||
loadGroups()
|
||||
})
|
||||
</script>
|
||||
619
frontend/src/views/admin/DashboardView.vue
Normal file
619
frontend/src/views/admin/DashboardView.vue
Normal file
@@ -0,0 +1,619 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
|
||||
<template v-else-if="stats">
|
||||
<!-- Row 1: Core Stats -->
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<!-- Total API Keys -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/30">
|
||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.apiKeys') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ stats.total_api_keys }}</p>
|
||||
<p class="text-xs text-green-600 dark:text-green-400">{{ stats.active_api_keys }} {{ t('common.active') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Service Accounts -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-purple-100 dark:bg-purple-900/30">
|
||||
<svg class="w-5 h-5 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.accounts') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ stats.total_accounts }}</p>
|
||||
<p class="text-xs">
|
||||
<span class="text-green-600 dark:text-green-400">{{ stats.normal_accounts }} {{ t('common.active') }}</span>
|
||||
<span v-if="stats.error_accounts > 0" class="text-red-500 ml-1">{{ stats.error_accounts }} {{ t('common.error') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today Requests -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-green-100 dark:bg-green-900/30">
|
||||
<svg class="w-5 h-5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.todayRequests') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ stats.today_requests }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('common.total') }}: {{ formatNumber(stats.total_requests) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Users Today -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<svg class="w-5 h-5 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.users') }}</p>
|
||||
<p class="text-xl font-bold text-emerald-600 dark:text-emerald-400">+{{ stats.today_new_users }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('common.total') }}: {{ formatNumber(stats.total_users) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Token Stats -->
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<!-- Today Tokens -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-amber-100 dark:bg-amber-900/30">
|
||||
<svg class="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.todayTokens') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats.today_tokens) }}</p>
|
||||
<p class="text-xs">
|
||||
<span class="text-amber-600 dark:text-amber-400" :title="t('admin.dashboard.actual')">${{ formatCost(stats.today_actual_cost) }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500" :title="t('admin.dashboard.standard')"> / ${{ formatCost(stats.today_cost) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Tokens -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-indigo-100 dark:bg-indigo-900/30">
|
||||
<svg class="w-5 h-5 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.totalTokens') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats.total_tokens) }}</p>
|
||||
<p class="text-xs">
|
||||
<span class="text-indigo-600 dark:text-indigo-400" :title="t('admin.dashboard.actual')">${{ formatCost(stats.total_actual_cost) }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500" :title="t('admin.dashboard.standard')"> / ${{ formatCost(stats.total_cost) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cache Tokens -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-cyan-100 dark:bg-cyan-900/30">
|
||||
<svg class="w-5 h-5 text-cyan-600 dark:text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m.75 12l3 3m0 0l3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.cacheToday') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats.today_cache_read_tokens) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('common.create') }}: {{ formatTokens(stats.today_cache_creation_tokens) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Avg Response Time -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-rose-100 dark:bg-rose-900/30">
|
||||
<svg class="w-5 h-5 text-rose-600 dark:text-rose-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.avgResponse') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatDuration(stats.average_duration_ms) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ stats.active_users }} {{ t('admin.dashboard.activeUsers') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Section -->
|
||||
<div class="space-y-6">
|
||||
<!-- Date Range Filter -->
|
||||
<div class="card p-4">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.dashboard.timeRange') }}:</span>
|
||||
<DateRangePicker
|
||||
v-model:start-date="startDate"
|
||||
v-model:end-date="endDate"
|
||||
@change="onDateRangeChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.dashboard.granularity') }}:</span>
|
||||
<div class="w-28">
|
||||
<Select
|
||||
v-model="granularity"
|
||||
:options="granularityOptions"
|
||||
@change="loadChartData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Grid -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<!-- Model Distribution Chart -->
|
||||
<div class="card p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.dashboard.modelDistribution') }}</h3>
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="w-48 h-48">
|
||||
<Doughnut v-if="modelChartData" :data="modelChartData" :options="doughnutOptions" />
|
||||
<div v-else class="flex items-center justify-center h-full text-gray-500 dark:text-gray-400 text-sm">
|
||||
{{ t('admin.dashboard.noDataAvailable') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 max-h-48 overflow-y-auto">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="text-gray-500 dark:text-gray-400">
|
||||
<th class="text-left pb-2">{{ t('admin.dashboard.model') }}</th>
|
||||
<th class="text-right pb-2">{{ t('admin.dashboard.requests') }}</th>
|
||||
<th class="text-right pb-2">{{ t('admin.dashboard.tokens') }}</th>
|
||||
<th class="text-right pb-2">{{ t('admin.dashboard.actual') }}</th>
|
||||
<th class="text-right pb-2">{{ t('admin.dashboard.standard') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="model in modelStats" :key="model.model" class="border-t border-gray-100 dark:border-gray-700">
|
||||
<td class="py-1.5 text-gray-900 dark:text-white font-medium truncate max-w-[100px]" :title="model.model">{{ model.model }}</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatNumber(model.requests) }}</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatTokens(model.total_tokens) }}</td>
|
||||
<td class="py-1.5 text-right text-green-600 dark:text-green-400">${{ formatCost(model.actual_cost) }}</td>
|
||||
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">${{ formatCost(model.cost) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token Usage Trend Chart -->
|
||||
<div class="card p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.dashboard.tokenUsageTrend') }}</h3>
|
||||
<div class="h-48">
|
||||
<Line v-if="trendChartData" :data="trendChartData" :options="lineOptions" />
|
||||
<div v-else class="flex items-center justify-center h-full text-gray-500 dark:text-gray-400 text-sm">
|
||||
{{ t('admin.dashboard.noDataAvailable') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Usage Trend (Full Width) -->
|
||||
<div class="card p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.dashboard.recentUsage') }} (Top 12)</h3>
|
||||
<div class="h-64">
|
||||
<Line v-if="userTrendChartData" :data="userTrendChartData" :options="lineOptions" />
|
||||
<div v-else class="flex items-center justify-center h-full text-gray-500 dark:text-gray-400 text-sm">
|
||||
{{ t('admin.dashboard.noDataAvailable') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const { t } = useI18n()
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { DashboardStats, TrendDataPoint, ModelStat, UserUsageTrendPoint } from '@/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
} from 'chart.js'
|
||||
import { Line, Doughnut } from 'vue-chartjs'
|
||||
|
||||
// Register Chart.js components
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
)
|
||||
|
||||
const appStore = useAppStore()
|
||||
const stats = ref<DashboardStats | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// Chart data
|
||||
const trendData = ref<TrendDataPoint[]>([])
|
||||
const modelStats = ref<ModelStat[]>([])
|
||||
const userTrend = ref<UserUsageTrendPoint[]>([])
|
||||
|
||||
// Date range
|
||||
const granularity = ref<'day' | 'hour'>('day')
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
|
||||
// Granularity options for Select component
|
||||
const granularityOptions = computed(() => [
|
||||
{ value: 'day', label: t('admin.dashboard.day') },
|
||||
{ value: 'hour', label: t('admin.dashboard.hour') },
|
||||
])
|
||||
|
||||
// Dark mode detection
|
||||
const isDarkMode = computed(() => {
|
||||
return document.documentElement.classList.contains('dark')
|
||||
})
|
||||
|
||||
// Chart colors
|
||||
const chartColors = computed(() => ({
|
||||
text: isDarkMode.value ? '#e5e7eb' : '#374151',
|
||||
grid: isDarkMode.value ? '#374151' : '#e5e7eb',
|
||||
input: '#3b82f6',
|
||||
output: '#10b981',
|
||||
cache: '#f59e0b',
|
||||
total: '#8b5cf6',
|
||||
}))
|
||||
|
||||
// Doughnut chart options
|
||||
const doughnutOptions = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
const value = context.raw as number
|
||||
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
|
||||
const percentage = ((value / total) * 100).toFixed(1)
|
||||
return `${context.label}: ${formatTokens(value)} (${percentage}%)`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Line chart options
|
||||
const lineOptions = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index' as const,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
labels: {
|
||||
color: chartColors.value.text,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
padding: 15,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
return `${context.dataset.label}: ${formatTokens(context.raw)}`
|
||||
},
|
||||
footer: (tooltipItems: any) => {
|
||||
// Show both costs for the day if we have trend data
|
||||
const dataIndex = tooltipItems[0]?.dataIndex
|
||||
if (dataIndex !== undefined && trendData.value[dataIndex]) {
|
||||
const data = trendData.value[dataIndex]
|
||||
return `Actual: $${formatCost(data.actual_cost)} | Standard: $${formatCost(data.cost)}`
|
||||
}
|
||||
return ''
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: chartColors.value.grid,
|
||||
},
|
||||
ticks: {
|
||||
color: chartColors.value.text,
|
||||
font: {
|
||||
size: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: chartColors.value.grid,
|
||||
},
|
||||
ticks: {
|
||||
color: chartColors.value.text,
|
||||
font: {
|
||||
size: 10,
|
||||
},
|
||||
callback: (value: number) => formatTokens(value),
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Model chart data
|
||||
const modelChartData = computed(() => {
|
||||
if (!modelStats.value.length) return null
|
||||
|
||||
const colors = [
|
||||
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
|
||||
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16'
|
||||
]
|
||||
|
||||
return {
|
||||
labels: modelStats.value.map(m => m.model),
|
||||
datasets: [{
|
||||
data: modelStats.value.map(m => m.total_tokens),
|
||||
backgroundColor: colors.slice(0, modelStats.value.length),
|
||||
borderWidth: 0,
|
||||
}],
|
||||
}
|
||||
})
|
||||
|
||||
// Trend chart data
|
||||
const trendChartData = computed(() => {
|
||||
if (!trendData.value.length) return null
|
||||
|
||||
return {
|
||||
labels: trendData.value.map(d => d.date),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Input',
|
||||
data: trendData.value.map(d => d.input_tokens),
|
||||
borderColor: chartColors.value.input,
|
||||
backgroundColor: `${chartColors.value.input}20`,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
},
|
||||
{
|
||||
label: 'Output',
|
||||
data: trendData.value.map(d => d.output_tokens),
|
||||
borderColor: chartColors.value.output,
|
||||
backgroundColor: `${chartColors.value.output}20`,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
},
|
||||
{
|
||||
label: 'Cache',
|
||||
data: trendData.value.map(d => d.cache_tokens),
|
||||
borderColor: chartColors.value.cache,
|
||||
backgroundColor: `${chartColors.value.cache}20`,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
// User trend chart data
|
||||
const userTrendChartData = computed(() => {
|
||||
if (!userTrend.value.length) return null
|
||||
|
||||
// Group by user
|
||||
const userGroups = new Map<string, { name: string; data: Map<string, number> }>()
|
||||
const allDates = new Set<string>()
|
||||
|
||||
userTrend.value.forEach(point => {
|
||||
allDates.add(point.date)
|
||||
const key = point.username || `User #${point.user_id}`
|
||||
if (!userGroups.has(key)) {
|
||||
userGroups.set(key, { name: key, data: new Map() })
|
||||
}
|
||||
userGroups.get(key)!.data.set(point.date, point.tokens)
|
||||
})
|
||||
|
||||
const sortedDates = Array.from(allDates).sort()
|
||||
const colors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16', '#06b6d4', '#a855f7']
|
||||
|
||||
const datasets = Array.from(userGroups.values()).map((group, idx) => ({
|
||||
label: group.name,
|
||||
data: sortedDates.map(date => group.data.get(date) || 0),
|
||||
borderColor: colors[idx % colors.length],
|
||||
backgroundColor: `${colors[idx % colors.length]}20`,
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
}))
|
||||
|
||||
return {
|
||||
labels: sortedDates,
|
||||
datasets,
|
||||
}
|
||||
})
|
||||
|
||||
// Format helpers
|
||||
const formatTokens = (value: number): string => {
|
||||
if (value >= 1_000_000_000) {
|
||||
return `${(value / 1_000_000_000).toFixed(2)}B`
|
||||
} else if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(2)}M`
|
||||
} else if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(2)}K`
|
||||
}
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
const formatNumber = (value: number): string => {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
const formatCost = (value: number): string => {
|
||||
if (value >= 1000) {
|
||||
return (value / 1000).toFixed(2) + 'K'
|
||||
} else if (value >= 1) {
|
||||
return value.toFixed(2)
|
||||
} else if (value >= 0.01) {
|
||||
return value.toFixed(3)
|
||||
}
|
||||
return value.toFixed(4)
|
||||
}
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
if (ms >= 1000) {
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
return `${Math.round(ms)}ms`
|
||||
}
|
||||
|
||||
// Date range change handler
|
||||
const onDateRangeChange = (range: { startDate: string; endDate: string; preset: string | null }) => {
|
||||
// Auto-select granularity based on date range
|
||||
const start = new Date(range.startDate)
|
||||
const end = new Date(range.endDate)
|
||||
const daysDiff = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
// If range is 1 day, use hourly granularity
|
||||
if (daysDiff <= 1) {
|
||||
granularity.value = 'hour'
|
||||
} else {
|
||||
granularity.value = 'day'
|
||||
}
|
||||
|
||||
loadChartData()
|
||||
}
|
||||
|
||||
// Initialize default date range
|
||||
const initializeDateRange = () => {
|
||||
const now = new Date()
|
||||
const today = now.toISOString().split('T')[0]
|
||||
const weekAgo = new Date(now)
|
||||
weekAgo.setDate(weekAgo.getDate() - 6)
|
||||
|
||||
startDate.value = weekAgo.toISOString().split('T')[0]
|
||||
endDate.value = today
|
||||
granularity.value = 'day'
|
||||
}
|
||||
|
||||
// Load data
|
||||
const loadDashboardStats = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
stats.value = await adminAPI.dashboard.getStats()
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.dashboard.failedToLoad'))
|
||||
console.error('Error loading dashboard stats:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadChartData = async () => {
|
||||
try {
|
||||
const params = {
|
||||
start_date: startDate.value,
|
||||
end_date: endDate.value,
|
||||
granularity: granularity.value,
|
||||
}
|
||||
|
||||
const [trendResponse, modelResponse, userResponse] = await Promise.all([
|
||||
adminAPI.dashboard.getUsageTrend(params),
|
||||
adminAPI.dashboard.getModelStats({ start_date: startDate.value, end_date: endDate.value }),
|
||||
adminAPI.dashboard.getUserUsageTrend({ ...params, limit: 12 }),
|
||||
])
|
||||
|
||||
trendData.value = trendResponse.trend || []
|
||||
modelStats.value = modelResponse.models || []
|
||||
userTrend.value = userResponse.trend || []
|
||||
} catch (error) {
|
||||
console.error('Error loading chart data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDashboardStats()
|
||||
initializeDateRange()
|
||||
loadChartData()
|
||||
})
|
||||
|
||||
// Watch for dark mode changes
|
||||
watch(isDarkMode, () => {
|
||||
// Force chart re-render on theme change
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Compact Select styling for dashboard */
|
||||
:deep(.select-trigger) {
|
||||
@apply px-3 py-1.5 text-sm rounded-lg;
|
||||
}
|
||||
|
||||
:deep(.select-dropdown) {
|
||||
@apply rounded-lg;
|
||||
}
|
||||
|
||||
:deep(.select-option) {
|
||||
@apply px-3 py-2 text-sm;
|
||||
}
|
||||
</style>
|
||||
695
frontend/src/views/admin/GroupsView.vue
Normal file
695
frontend/src/views/admin/GroupsView.vue
Normal file
@@ -0,0 +1,695 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header Actions -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
{{ t('admin.groups.createGroup') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Select
|
||||
v-model="filters.platform"
|
||||
:options="platformFilterOptions"
|
||||
placeholder="All Platforms"
|
||||
class="w-44"
|
||||
@change="loadGroups"
|
||||
/>
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="statusOptions"
|
||||
placeholder="All Status"
|
||||
class="w-40"
|
||||
@change="loadGroups"
|
||||
/>
|
||||
<Select
|
||||
v-model="filters.is_exclusive"
|
||||
:options="exclusiveOptions"
|
||||
placeholder="All Groups"
|
||||
class="w-44"
|
||||
@change="loadGroups"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Groups Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<DataTable :columns="columns" :data="groups" :loading="loading">
|
||||
<template #cell-name="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-platform="{ value }">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
|
||||
</svg>
|
||||
{{ value.charAt(0).toUpperCase() + value.slice(1) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-rate_multiplier="{ value }">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ value }}x</span>
|
||||
</template>
|
||||
|
||||
<template #cell-is_exclusive="{ value }">
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
value ? 'badge-primary' : 'badge-gray'
|
||||
]"
|
||||
>
|
||||
{{ value ? t('admin.groups.exclusive') : t('admin.groups.public') }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-account_count="{ value }">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-dark-600 dark:text-gray-300">
|
||||
{{ t('admin.groups.accountsCount', { count: value || 0 }) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
value === 'active' ? 'badge-success' : 'badge-danger'
|
||||
]"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
@click="handleEdit(row)"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-dark-700 text-gray-500 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
|
||||
:title="t('common.edit')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="handleDelete(row)"
|
||||
class="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-gray-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
:title="t('common.delete')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<EmptyState
|
||||
:title="t('admin.groups.noGroupsYet')"
|
||||
:description="t('admin.groups.createFirstGroup')"
|
||||
:action-text="t('admin.groups.createGroup')"
|
||||
@action="showCreateModal = true"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Create Group Modal -->
|
||||
<Modal
|
||||
:show="showCreateModal"
|
||||
:title="t('admin.groups.createGroup')"
|
||||
size="lg"
|
||||
@close="closeCreateModal"
|
||||
>
|
||||
<form @submit.prevent="handleCreateGroup" class="space-y-5">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.name') }}</label>
|
||||
<input
|
||||
v-model="createForm.name"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.enterGroupName')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.description') }}</label>
|
||||
<textarea
|
||||
v-model="createForm.description"
|
||||
rows="3"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.optionalDescription')"
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.platform') }}</label>
|
||||
<Select
|
||||
v-model="createForm.platform"
|
||||
:options="platformOptions"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.groups.platformHint') }}</p>
|
||||
</div>
|
||||
<div v-if="createForm.subscription_type !== 'subscription'">
|
||||
<label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label>
|
||||
<input
|
||||
v-model.number="createForm.rate_multiplier"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.1"
|
||||
required
|
||||
class="input"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.groups.rateMultiplierHint') }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="createForm.is_exclusive = !createForm.is_exclusive"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
createForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform shadow',
|
||||
createForm.is_exclusive ? 'translate-x-6' : 'translate-x-1'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
<label class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.groups.exclusiveHint') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Subscription Configuration -->
|
||||
<div class="border-t pt-4 mt-4">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-4">{{ t('admin.groups.subscription.title') }}</h4>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="input-label">{{ t('admin.groups.subscription.type') }}</label>
|
||||
<Select
|
||||
v-model="createForm.subscription_type"
|
||||
:options="subscriptionTypeOptions"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.groups.subscription.typeHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Subscription limits (only show when subscription type is selected) -->
|
||||
<div v-if="createForm.subscription_type === 'subscription'" class="space-y-4 pl-4 border-l-2 border-primary-200 dark:border-primary-800">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.subscription.dailyLimit') }}</label>
|
||||
<input
|
||||
v-model.number="createForm.daily_limit_usd"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.subscription.noLimit')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.subscription.weeklyLimit') }}</label>
|
||||
<input
|
||||
v-model.number="createForm.weekly_limit_usd"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.subscription.noLimit')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.subscription.monthlyLimit') }}</label>
|
||||
<input
|
||||
v-model.number="createForm.monthly_limit_usd"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.subscription.noLimit')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
@click="closeCreateModal"
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg
|
||||
v-if="submitting"
|
||||
class="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ submitting ? t('admin.groups.creating') : t('common.create') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Edit Group Modal -->
|
||||
<Modal
|
||||
:show="showEditModal"
|
||||
:title="t('admin.groups.editGroup')"
|
||||
size="lg"
|
||||
@close="closeEditModal"
|
||||
>
|
||||
<form v-if="editingGroup" @submit.prevent="handleUpdateGroup" class="space-y-5">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.name') }}</label>
|
||||
<input
|
||||
v-model="editForm.name"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.description') }}</label>
|
||||
<textarea
|
||||
v-model="editForm.description"
|
||||
rows="3"
|
||||
class="input"
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.platform') }}</label>
|
||||
<Select
|
||||
v-model="editForm.platform"
|
||||
:options="platformOptions"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="editForm.subscription_type !== 'subscription'">
|
||||
<label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.rate_multiplier"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.1"
|
||||
required
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="editForm.is_exclusive = !editForm.is_exclusive"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
editForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform shadow',
|
||||
editForm.is_exclusive ? 'translate-x-6' : 'translate-x-1'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
<label class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.groups.exclusiveHint') }}
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.status') }}</label>
|
||||
<Select
|
||||
v-model="editForm.status"
|
||||
:options="editStatusOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Subscription Configuration -->
|
||||
<div class="border-t pt-4 mt-4">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-4">{{ t('admin.groups.subscription.title') }}</h4>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="input-label">{{ t('admin.groups.subscription.type') }}</label>
|
||||
<Select
|
||||
v-model="editForm.subscription_type"
|
||||
:options="subscriptionTypeOptions"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.groups.subscription.typeHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Subscription limits (only show when subscription type is selected) -->
|
||||
<div v-if="editForm.subscription_type === 'subscription'" class="space-y-4 pl-4 border-l-2 border-primary-200 dark:border-primary-800">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.subscription.dailyLimit') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.daily_limit_usd"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.subscription.noLimit')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.subscription.weeklyLimit') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.weekly_limit_usd"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.subscription.noLimit')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.subscription.monthlyLimit') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.monthly_limit_usd"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.subscription.noLimit')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
@click="closeEditModal"
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg
|
||||
v-if="submitting"
|
||||
class="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ submitting ? t('admin.groups.updating') : t('common.update') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
:show="showDeleteDialog"
|
||||
:title="t('admin.groups.deleteGroup')"
|
||||
:message="deleteConfirmMessage"
|
||||
:confirm-text="t('common.delete')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
:danger="true"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="showDeleteDialog = false"
|
||||
/>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Group, GroupPlatform, SubscriptionType } from '@/types'
|
||||
import type { Column } from '@/components/common/DataTable.vue'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'name', label: t('admin.groups.columns.name'), sortable: true },
|
||||
{ key: 'platform', label: t('admin.groups.columns.platform'), sortable: true },
|
||||
{ key: 'rate_multiplier', label: t('admin.groups.columns.rateMultiplier'), sortable: true },
|
||||
{ key: 'is_exclusive', label: t('admin.groups.columns.type'), sortable: true },
|
||||
{ key: 'account_count', label: t('admin.groups.columns.accounts'), sortable: true },
|
||||
{ key: 'status', label: t('admin.groups.columns.status'), sortable: true },
|
||||
{ key: 'actions', label: t('admin.groups.columns.actions'), sortable: false }
|
||||
])
|
||||
|
||||
// Filter options
|
||||
const statusOptions = computed(() => [
|
||||
{ value: '', label: t('admin.groups.allStatus') },
|
||||
{ value: 'active', label: t('common.active') },
|
||||
{ value: 'inactive', label: t('common.inactive') }
|
||||
])
|
||||
|
||||
const exclusiveOptions = computed(() => [
|
||||
{ value: '', label: t('admin.groups.allGroups') },
|
||||
{ value: 'true', label: t('admin.groups.exclusive') },
|
||||
{ value: 'false', label: t('admin.groups.nonExclusive') }
|
||||
])
|
||||
|
||||
const platformOptions = computed(() => [
|
||||
{ value: 'anthropic', label: 'Anthropic' }
|
||||
// Future: { value: 'openai', label: 'OpenAI' },
|
||||
// Future: { value: 'gemini', label: 'Gemini' }
|
||||
])
|
||||
|
||||
const platformFilterOptions = computed(() => [
|
||||
{ value: '', label: t('admin.groups.allPlatforms') },
|
||||
{ value: 'anthropic', label: 'Anthropic' }
|
||||
])
|
||||
|
||||
const editStatusOptions = computed(() => [
|
||||
{ value: 'active', label: t('common.active') },
|
||||
{ value: 'inactive', label: t('common.inactive') }
|
||||
])
|
||||
|
||||
const subscriptionTypeOptions = computed(() => [
|
||||
{ value: 'standard', label: t('admin.groups.subscription.standard') },
|
||||
{ value: 'subscription', label: t('admin.groups.subscription.subscription') }
|
||||
])
|
||||
|
||||
const groups = ref<Group[]>([])
|
||||
const loading = ref(false)
|
||||
const filters = reactive({
|
||||
platform: '',
|
||||
status: '',
|
||||
is_exclusive: ''
|
||||
})
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const submitting = ref(false)
|
||||
const editingGroup = ref<Group | null>(null)
|
||||
const deletingGroup = ref<Group | null>(null)
|
||||
|
||||
const createForm = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
platform: 'anthropic' as GroupPlatform,
|
||||
rate_multiplier: 1.0,
|
||||
is_exclusive: false,
|
||||
subscription_type: 'standard' as SubscriptionType,
|
||||
daily_limit_usd: null as number | null,
|
||||
weekly_limit_usd: null as number | null,
|
||||
monthly_limit_usd: null as number | null
|
||||
})
|
||||
|
||||
const editForm = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
platform: 'anthropic' as GroupPlatform,
|
||||
rate_multiplier: 1.0,
|
||||
is_exclusive: false,
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
subscription_type: 'standard' as SubscriptionType,
|
||||
daily_limit_usd: null as number | null,
|
||||
weekly_limit_usd: null as number | null,
|
||||
monthly_limit_usd: null as number | null
|
||||
})
|
||||
|
||||
// 根据分组类型返回不同的删除确认消息
|
||||
const deleteConfirmMessage = computed(() => {
|
||||
if (!deletingGroup.value) {
|
||||
return ''
|
||||
}
|
||||
if (deletingGroup.value.subscription_type === 'subscription') {
|
||||
return t('admin.groups.deleteConfirmSubscription', { name: deletingGroup.value.name })
|
||||
}
|
||||
return t('admin.groups.deleteConfirm', { name: deletingGroup.value.name })
|
||||
})
|
||||
|
||||
const loadGroups = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await adminAPI.groups.list(
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
{
|
||||
platform: filters.platform || undefined,
|
||||
status: filters.status as any,
|
||||
is_exclusive: filters.is_exclusive ? filters.is_exclusive === 'true' : undefined
|
||||
}
|
||||
)
|
||||
groups.value = response.items
|
||||
pagination.total = response.total
|
||||
pagination.pages = response.pages
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.groups.failedToLoad'))
|
||||
console.error('Error loading groups:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.page = page
|
||||
loadGroups()
|
||||
}
|
||||
|
||||
const closeCreateModal = () => {
|
||||
showCreateModal.value = false
|
||||
createForm.name = ''
|
||||
createForm.description = ''
|
||||
createForm.platform = 'anthropic'
|
||||
createForm.rate_multiplier = 1.0
|
||||
createForm.is_exclusive = false
|
||||
createForm.subscription_type = 'standard'
|
||||
createForm.daily_limit_usd = null
|
||||
createForm.weekly_limit_usd = null
|
||||
createForm.monthly_limit_usd = null
|
||||
}
|
||||
|
||||
const handleCreateGroup = async () => {
|
||||
submitting.value = true
|
||||
try {
|
||||
await adminAPI.groups.create(createForm)
|
||||
appStore.showSuccess(t('admin.groups.groupCreated'))
|
||||
closeCreateModal()
|
||||
loadGroups()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.groups.failedToCreate'))
|
||||
console.error('Error creating group:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (group: Group) => {
|
||||
editingGroup.value = group
|
||||
editForm.name = group.name
|
||||
editForm.description = group.description || ''
|
||||
editForm.platform = group.platform
|
||||
editForm.rate_multiplier = group.rate_multiplier
|
||||
editForm.is_exclusive = group.is_exclusive
|
||||
editForm.status = group.status
|
||||
editForm.subscription_type = group.subscription_type || 'standard'
|
||||
editForm.daily_limit_usd = group.daily_limit_usd
|
||||
editForm.weekly_limit_usd = group.weekly_limit_usd
|
||||
editForm.monthly_limit_usd = group.monthly_limit_usd
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
const closeEditModal = () => {
|
||||
showEditModal.value = false
|
||||
editingGroup.value = null
|
||||
}
|
||||
|
||||
const handleUpdateGroup = async () => {
|
||||
if (!editingGroup.value) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await adminAPI.groups.update(editingGroup.value.id, editForm)
|
||||
appStore.showSuccess(t('admin.groups.groupUpdated'))
|
||||
closeEditModal()
|
||||
loadGroups()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.groups.failedToUpdate'))
|
||||
console.error('Error updating group:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (group: Group) => {
|
||||
deletingGroup.value = group
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingGroup.value) return
|
||||
|
||||
try {
|
||||
await adminAPI.groups.delete(deletingGroup.value.id)
|
||||
appStore.showSuccess(t('admin.groups.groupDeleted'))
|
||||
showDeleteDialog.value = false
|
||||
deletingGroup.value = null
|
||||
loadGroups()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.groups.failedToDelete'))
|
||||
console.error('Error deleting group:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 subscription_type 变化,配额模式时重置 rate_multiplier 为 1
|
||||
watch(() => createForm.subscription_type, (newVal) => {
|
||||
if (newVal === 'subscription') {
|
||||
createForm.rate_multiplier = 1.0
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => editForm.subscription_type, (newVal) => {
|
||||
if (newVal === 'subscription') {
|
||||
editForm.rate_multiplier = 1.0
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadGroups()
|
||||
})
|
||||
</script>
|
||||
827
frontend/src/views/admin/ProxiesView.vue
Normal file
827
frontend/src/views/admin/ProxiesView.vue
Normal file
@@ -0,0 +1,827 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header Actions -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
{{ t('admin.proxies.createProxy') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="relative flex-1 max-w-md">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('admin.proxies.searchProxies')"
|
||||
class="input pl-10"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Select
|
||||
v-model="filters.protocol"
|
||||
:options="protocolOptions"
|
||||
:placeholder="t('admin.proxies.allProtocols')"
|
||||
class="w-40"
|
||||
@change="loadProxies"
|
||||
/>
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="statusOptions"
|
||||
:placeholder="t('admin.proxies.allStatus')"
|
||||
class="w-36"
|
||||
@change="loadProxies"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Proxies Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<DataTable :columns="columns" :data="proxies" :loading="loading">
|
||||
<template #cell-name="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-protocol="{ value }">
|
||||
<span
|
||||
v-if="value"
|
||||
:class="[
|
||||
'badge',
|
||||
value === 'socks5' ? 'badge-primary' : 'badge-gray'
|
||||
]"
|
||||
>
|
||||
{{ value.toUpperCase() }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-address="{ row }">
|
||||
<code class="code text-xs">{{ row.host }}:{{ row.port }}</code>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
value === 'active' ? 'badge-success' : 'badge-danger'
|
||||
]"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
@click="handleTestConnection(row)"
|
||||
:disabled="testingProxyIds.has(row.id)"
|
||||
class="p-2 rounded-lg hover:bg-emerald-50 dark:hover:bg-emerald-900/20 text-gray-500 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:title="t('admin.proxies.testConnection')"
|
||||
>
|
||||
<svg v-if="testingProxyIds.has(row.id)" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="handleEdit(row)"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-dark-700 text-gray-500 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
|
||||
:title="t('common.edit')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="handleDelete(row)"
|
||||
class="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-gray-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
:title="t('common.delete')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<EmptyState
|
||||
:title="t('admin.proxies.noProxiesYet')"
|
||||
:description="t('admin.proxies.createFirstProxy')"
|
||||
:action-text="t('admin.proxies.createProxy')"
|
||||
@action="showCreateModal = true"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Create Proxy Modal -->
|
||||
<Modal
|
||||
:show="showCreateModal"
|
||||
:title="t('admin.proxies.createProxy')"
|
||||
size="lg"
|
||||
@close="closeCreateModal"
|
||||
>
|
||||
<!-- Tab Switch -->
|
||||
<div class="flex mb-6 border-b border-gray-200 dark:border-dark-600">
|
||||
<button
|
||||
type="button"
|
||||
@click="createMode = 'standard'"
|
||||
:class="[
|
||||
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
createMode === 'standard'
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
]"
|
||||
>
|
||||
<svg class="w-4 h-4 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
{{ t('admin.proxies.standardAdd') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="createMode = 'batch'"
|
||||
:class="[
|
||||
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
createMode === 'batch'
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
]"
|
||||
>
|
||||
<svg class="w-4 h-4 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z" />
|
||||
</svg>
|
||||
{{ t('admin.proxies.batchAdd') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Standard Add Form -->
|
||||
<form v-if="createMode === 'standard'" @submit.prevent="handleCreateProxy" class="space-y-5">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.name') }}</label>
|
||||
<input
|
||||
v-model="createForm.name"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
:placeholder="t('admin.proxies.enterProxyName')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.protocol') }}</label>
|
||||
<Select
|
||||
v-model="createForm.protocol"
|
||||
:options="protocolSelectOptions"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.host') }}</label>
|
||||
<input
|
||||
v-model="createForm.host"
|
||||
type="text"
|
||||
required
|
||||
placeholder="proxy.example.com"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.port') }}</label>
|
||||
<input
|
||||
v-model.number="createForm.port"
|
||||
type="number"
|
||||
required
|
||||
min="1"
|
||||
max="65535"
|
||||
placeholder="8080"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.username') }}</label>
|
||||
<input
|
||||
v-model="createForm.username"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.proxies.optionalAuth')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.password') }}</label>
|
||||
<input
|
||||
v-model="createForm.password"
|
||||
type="password"
|
||||
class="input"
|
||||
:placeholder="t('admin.proxies.optionalAuth')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
@click="closeCreateModal"
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg
|
||||
v-if="submitting"
|
||||
class="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ submitting ? t('admin.proxies.creating') : t('common.create') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Batch Add Form -->
|
||||
<div v-else class="space-y-5">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.batchInput') }}</label>
|
||||
<textarea
|
||||
v-model="batchInput"
|
||||
rows="10"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.proxies.batchInputPlaceholder')"
|
||||
@input="parseBatchInput"
|
||||
></textarea>
|
||||
<p class="input-hint mt-2">
|
||||
{{ t('admin.proxies.batchInputHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Parse Result -->
|
||||
<div v-if="batchParseResult.total > 0" class="rounded-lg p-4 bg-gray-50 dark:bg-dark-700">
|
||||
<div class="flex items-center gap-4 text-sm">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4 text-primary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.proxies.parsedCount', { count: batchParseResult.valid }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="batchParseResult.invalid > 0" class="flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
<span class="text-amber-600 dark:text-amber-400">
|
||||
{{ t('admin.proxies.invalidCount', { count: batchParseResult.invalid }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="batchParseResult.duplicate > 0" class="flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" />
|
||||
</svg>
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.proxies.duplicateCount', { count: batchParseResult.duplicate }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
@click="closeCreateModal"
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
@click="handleBatchCreate"
|
||||
type="button"
|
||||
:disabled="submitting || batchParseResult.valid === 0"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg
|
||||
v-if="submitting"
|
||||
class="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ submitting ? t('admin.proxies.importing') : t('admin.proxies.importProxies', { count: batchParseResult.valid }) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Edit Proxy Modal -->
|
||||
<Modal
|
||||
:show="showEditModal"
|
||||
:title="t('admin.proxies.editProxy')"
|
||||
size="lg"
|
||||
@close="closeEditModal"
|
||||
>
|
||||
<form v-if="editingProxy" @submit.prevent="handleUpdateProxy" class="space-y-5">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.name') }}</label>
|
||||
<input
|
||||
v-model="editForm.name"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.protocol') }}</label>
|
||||
<Select
|
||||
v-model="editForm.protocol"
|
||||
:options="protocolSelectOptions"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.host') }}</label>
|
||||
<input
|
||||
v-model="editForm.host"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.port') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.port"
|
||||
type="number"
|
||||
required
|
||||
min="1"
|
||||
max="65535"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.username') }}</label>
|
||||
<input
|
||||
v-model="editForm.username"
|
||||
type="text"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.password') }}</label>
|
||||
<input
|
||||
v-model="editForm.password"
|
||||
type="password"
|
||||
:placeholder="t('admin.proxies.leaveEmptyToKeep')"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.status') }}</label>
|
||||
<Select
|
||||
v-model="editForm.status"
|
||||
:options="editStatusOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
@click="closeEditModal"
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg
|
||||
v-if="submitting"
|
||||
class="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ submitting ? t('admin.proxies.updating') : t('common.update') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
:show="showDeleteDialog"
|
||||
:title="t('admin.proxies.deleteProxy')"
|
||||
:message="t('admin.proxies.deleteConfirm', { name: deletingProxy?.name })"
|
||||
:confirm-text="t('common.delete')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
:danger="true"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="showDeleteDialog = false"
|
||||
/>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Proxy, ProxyProtocol } from '@/types'
|
||||
import type { Column } from '@/components/common/DataTable.vue'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'name', label: t('admin.proxies.columns.name'), sortable: true },
|
||||
{ key: 'protocol', label: t('admin.proxies.columns.protocol'), sortable: true },
|
||||
{ key: 'address', label: t('admin.proxies.columns.address'), sortable: false },
|
||||
{ key: 'status', label: t('admin.proxies.columns.status'), sortable: true },
|
||||
{ key: 'actions', label: t('admin.proxies.columns.actions'), sortable: false }
|
||||
])
|
||||
|
||||
// Filter options
|
||||
const protocolOptions = computed(() => [
|
||||
{ value: '', label: t('admin.proxies.allProtocols') },
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
{ value: 'https', label: 'HTTPS' },
|
||||
{ value: 'socks5', label: 'SOCKS5' }
|
||||
])
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ value: '', label: t('admin.proxies.allStatus') },
|
||||
{ value: 'active', label: t('common.active') },
|
||||
{ value: 'inactive', label: t('common.inactive') }
|
||||
])
|
||||
|
||||
// Form options
|
||||
const protocolSelectOptions = [
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
{ value: 'https', label: 'HTTPS' },
|
||||
{ value: 'socks5', label: 'SOCKS5' }
|
||||
]
|
||||
|
||||
const editStatusOptions = computed(() => [
|
||||
{ value: 'active', label: t('common.active') },
|
||||
{ value: 'inactive', label: t('common.inactive') }
|
||||
])
|
||||
|
||||
const proxies = ref<Proxy[]>([])
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const filters = reactive({
|
||||
protocol: '',
|
||||
status: ''
|
||||
})
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const submitting = ref(false)
|
||||
const testingProxyIds = ref<Set<number>>(new Set())
|
||||
const editingProxy = ref<Proxy | null>(null)
|
||||
const deletingProxy = ref<Proxy | null>(null)
|
||||
|
||||
// Batch import state
|
||||
const createMode = ref<'standard' | 'batch'>('standard')
|
||||
const batchInput = ref('')
|
||||
const batchParseResult = reactive({
|
||||
total: 0,
|
||||
valid: 0,
|
||||
invalid: 0,
|
||||
duplicate: 0,
|
||||
proxies: [] as Array<{
|
||||
protocol: ProxyProtocol
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
password: string
|
||||
}>
|
||||
})
|
||||
|
||||
const createForm = reactive({
|
||||
name: '',
|
||||
protocol: 'http' as ProxyProtocol,
|
||||
host: '',
|
||||
port: 8080,
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const editForm = reactive({
|
||||
name: '',
|
||||
protocol: 'http' as ProxyProtocol,
|
||||
host: '',
|
||||
port: 8080,
|
||||
username: '',
|
||||
password: '',
|
||||
status: 'active' as 'active' | 'inactive'
|
||||
})
|
||||
|
||||
const loadProxies = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await adminAPI.proxies.list(
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
{
|
||||
protocol: filters.protocol || undefined,
|
||||
status: filters.status as any,
|
||||
search: searchQuery.value || undefined
|
||||
}
|
||||
)
|
||||
proxies.value = response.items
|
||||
pagination.total = response.total
|
||||
pagination.pages = response.pages
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.proxies.failedToLoad'))
|
||||
console.error('Error loading proxies:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout>
|
||||
const handleSearch = () => {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
pagination.page = 1
|
||||
loadProxies()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.page = page
|
||||
loadProxies()
|
||||
}
|
||||
|
||||
const closeCreateModal = () => {
|
||||
showCreateModal.value = false
|
||||
createMode.value = 'standard'
|
||||
createForm.name = ''
|
||||
createForm.protocol = 'http'
|
||||
createForm.host = ''
|
||||
createForm.port = 8080
|
||||
createForm.username = ''
|
||||
createForm.password = ''
|
||||
batchInput.value = ''
|
||||
batchParseResult.total = 0
|
||||
batchParseResult.valid = 0
|
||||
batchParseResult.invalid = 0
|
||||
batchParseResult.duplicate = 0
|
||||
batchParseResult.proxies = []
|
||||
}
|
||||
|
||||
// Parse proxy URL: protocol://user:pass@host:port or protocol://host:port
|
||||
const parseProxyUrl = (line: string): {
|
||||
protocol: ProxyProtocol
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
password: string
|
||||
} | null => {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
// Regex to parse proxy URL
|
||||
const regex = /^(https?|socks5):\/\/(?:([^:@]+):([^@]+)@)?([^:]+):(\d+)$/i
|
||||
const match = trimmed.match(regex)
|
||||
|
||||
if (!match) return null
|
||||
|
||||
const [, protocol, username, password, host, port] = match
|
||||
const portNum = parseInt(port, 10)
|
||||
|
||||
if (portNum < 1 || portNum > 65535) return null
|
||||
|
||||
return {
|
||||
protocol: protocol.toLowerCase() as ProxyProtocol,
|
||||
host,
|
||||
port: portNum,
|
||||
username: username || '',
|
||||
password: password || ''
|
||||
}
|
||||
}
|
||||
|
||||
const parseBatchInput = () => {
|
||||
const lines = batchInput.value.split('\n').filter(l => l.trim())
|
||||
const seen = new Set<string>()
|
||||
const proxies: typeof batchParseResult.proxies = []
|
||||
let invalid = 0
|
||||
let duplicate = 0
|
||||
|
||||
for (const line of lines) {
|
||||
const parsed = parseProxyUrl(line)
|
||||
if (!parsed) {
|
||||
invalid++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for duplicates (same host:port:username:password)
|
||||
const key = `${parsed.host}:${parsed.port}:${parsed.username}:${parsed.password}`
|
||||
if (seen.has(key)) {
|
||||
duplicate++
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
proxies.push(parsed)
|
||||
}
|
||||
|
||||
batchParseResult.total = lines.length
|
||||
batchParseResult.valid = proxies.length
|
||||
batchParseResult.invalid = invalid
|
||||
batchParseResult.duplicate = duplicate
|
||||
batchParseResult.proxies = proxies
|
||||
}
|
||||
|
||||
const handleBatchCreate = async () => {
|
||||
if (batchParseResult.valid === 0) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const result = await adminAPI.proxies.batchCreate(batchParseResult.proxies)
|
||||
const created = result.created || 0
|
||||
const skipped = result.skipped || 0
|
||||
|
||||
if (created > 0) {
|
||||
appStore.showSuccess(t('admin.proxies.batchImportSuccess', { created, skipped }))
|
||||
} else {
|
||||
appStore.showInfo(t('admin.proxies.batchImportAllSkipped', { skipped }))
|
||||
}
|
||||
|
||||
closeCreateModal()
|
||||
loadProxies()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.proxies.failedToImport'))
|
||||
console.error('Error batch creating proxies:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateProxy = async () => {
|
||||
submitting.value = true
|
||||
try {
|
||||
await adminAPI.proxies.create({
|
||||
...createForm,
|
||||
username: createForm.username || null,
|
||||
password: createForm.password || null
|
||||
})
|
||||
appStore.showSuccess(t('admin.proxies.proxyCreated'))
|
||||
closeCreateModal()
|
||||
loadProxies()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.proxies.failedToCreate'))
|
||||
console.error('Error creating proxy:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (proxy: Proxy) => {
|
||||
editingProxy.value = proxy
|
||||
editForm.name = proxy.name
|
||||
editForm.protocol = proxy.protocol
|
||||
editForm.host = proxy.host
|
||||
editForm.port = proxy.port
|
||||
editForm.username = proxy.username || ''
|
||||
editForm.password = ''
|
||||
editForm.status = proxy.status
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
const closeEditModal = () => {
|
||||
showEditModal.value = false
|
||||
editingProxy.value = null
|
||||
}
|
||||
|
||||
const handleUpdateProxy = async () => {
|
||||
if (!editingProxy.value) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const updateData: any = {
|
||||
name: editForm.name,
|
||||
protocol: editForm.protocol,
|
||||
host: editForm.host,
|
||||
port: editForm.port,
|
||||
username: editForm.username || null,
|
||||
status: editForm.status
|
||||
}
|
||||
|
||||
// Only include password if it was changed
|
||||
if (editForm.password) {
|
||||
updateData.password = editForm.password
|
||||
}
|
||||
|
||||
await adminAPI.proxies.update(editingProxy.value.id, updateData)
|
||||
appStore.showSuccess(t('admin.proxies.proxyUpdated'))
|
||||
closeEditModal()
|
||||
loadProxies()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.proxies.failedToUpdate'))
|
||||
console.error('Error updating proxy:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestConnection = async (proxy: Proxy) => {
|
||||
// Create new Set to trigger reactivity
|
||||
testingProxyIds.value = new Set([...testingProxyIds.value, proxy.id])
|
||||
try {
|
||||
const result = await adminAPI.proxies.testProxy(proxy.id)
|
||||
if (result.success) {
|
||||
const message = result.latency_ms
|
||||
? t('admin.proxies.proxyWorkingWithLatency', { latency: result.latency_ms })
|
||||
: t('admin.proxies.proxyWorking')
|
||||
appStore.showSuccess(message)
|
||||
} else {
|
||||
appStore.showError(result.message || t('admin.proxies.proxyTestFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.proxies.failedToTest'))
|
||||
console.error('Error testing proxy:', error)
|
||||
} finally {
|
||||
// Create new Set without this proxy id to trigger reactivity
|
||||
const newSet = new Set(testingProxyIds.value)
|
||||
newSet.delete(proxy.id)
|
||||
testingProxyIds.value = newSet
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (proxy: Proxy) => {
|
||||
deletingProxy.value = proxy
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingProxy.value) return
|
||||
|
||||
try {
|
||||
await adminAPI.proxies.delete(deletingProxy.value.id)
|
||||
appStore.showSuccess(t('admin.proxies.proxyDeleted'))
|
||||
showDeleteDialog.value = false
|
||||
deletingProxy.value = null
|
||||
loadProxies()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.proxies.failedToDelete'))
|
||||
console.error('Error deleting proxy:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProxies()
|
||||
})
|
||||
</script>
|
||||
645
frontend/src/views/admin/RedeemView.vue
Normal file
645
frontend/src/views/admin/RedeemView.vue
Normal file
@@ -0,0 +1,645 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header Actions -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
@click="showGenerateDialog = true"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{{ t('admin.redeem.generateCodes') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters and Actions -->
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex-1 max-w-md">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('admin.redeem.searchCodes')"
|
||||
class="input"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Select
|
||||
v-model="filters.type"
|
||||
:options="filterTypeOptions"
|
||||
class="w-36"
|
||||
@change="loadCodes"
|
||||
/>
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="filterStatusOptions"
|
||||
class="w-36"
|
||||
@change="loadCodes"
|
||||
/>
|
||||
<button
|
||||
@click="handleExportCodes"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
{{ t('admin.redeem.exportCsv') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Redeem Codes Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<DataTable :columns="columns" :data="codes" :loading="loading">
|
||||
<template #cell-code="{ value }">
|
||||
<div class="flex items-center space-x-2">
|
||||
<code class="text-sm font-mono text-gray-900 dark:text-gray-100">{{ value }}</code>
|
||||
<button
|
||||
@click="copyToClipboard(value)"
|
||||
:class="[
|
||||
'flex items-center transition-colors',
|
||||
copiedCode === value ? 'text-green-500' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
]"
|
||||
:title="copiedCode === value ? t('admin.redeem.copied') : t('keys.copyToClipboard')"
|
||||
>
|
||||
<svg v-if="copiedCode !== value" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-type="{ value }">
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
value === 'balance' ? 'badge-success' :
|
||||
value === 'subscription' ? 'badge-warning' : 'badge-primary'
|
||||
]"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-value="{ value, row }">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
<template v-if="row.type === 'balance'">${{ value.toFixed(2) }}</template>
|
||||
<template v-else-if="row.type === 'subscription'">
|
||||
{{ row.validity_days || 30 }}{{ t('admin.redeem.days') }}
|
||||
<span v-if="row.group" class="text-gray-500 dark:text-gray-400 text-xs ml-1">({{ row.group.name }})</span>
|
||||
</template>
|
||||
<template v-else>{{ value }}</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
value === 'unused' ? 'badge-success' :
|
||||
value === 'used' ? 'badge-gray' :
|
||||
'badge-danger'
|
||||
]"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-used_by="{ value }">
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ value ? t('admin.redeem.userPrefix', { id: value }) : '-' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-used_at="{ value }">
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">{{ value ? formatDate(value) : '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
v-if="row.status === 'unused'"
|
||||
@click="handleDelete(row)"
|
||||
class="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-gray-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
:title="t('common.delete')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
|
||||
<!-- Batch Actions -->
|
||||
<div v-if="filters.status === 'unused'" class="flex justify-end">
|
||||
<button
|
||||
@click="showDeleteUnusedDialog = true"
|
||||
class="btn btn-danger"
|
||||
>
|
||||
{{ t('admin.redeem.deleteAllUnused') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
:show="showDeleteDialog"
|
||||
:title="t('admin.redeem.deleteCode')"
|
||||
:message="t('admin.redeem.deleteCodeConfirm')"
|
||||
:confirm-text="t('common.delete')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
danger
|
||||
@confirm="confirmDelete"
|
||||
@cancel="showDeleteDialog = false"
|
||||
/>
|
||||
|
||||
<!-- Delete Unused Codes Dialog -->
|
||||
<ConfirmDialog
|
||||
:show="showDeleteUnusedDialog"
|
||||
:title="t('admin.redeem.deleteAllUnused')"
|
||||
:message="t('admin.redeem.deleteAllUnusedConfirm')"
|
||||
:confirm-text="t('admin.redeem.deleteAll')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
danger
|
||||
@confirm="confirmDeleteUnused"
|
||||
@cancel="showDeleteUnusedDialog = false"
|
||||
/>
|
||||
|
||||
<!-- Generate Codes Dialog -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showGenerateDialog"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50"
|
||||
@click="showGenerateDialog = false"
|
||||
></div>
|
||||
<div class="relative z-10 w-full max-w-md bg-white dark:bg-dark-800 rounded-xl shadow-xl p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.redeem.generateCodesTitle') }}</h2>
|
||||
<form @submit.prevent="handleGenerateCodes" class="space-y-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.redeem.codeType') }}</label>
|
||||
<Select
|
||||
v-model="generateForm.type"
|
||||
:options="typeOptions"
|
||||
/>
|
||||
</div>
|
||||
<!-- 余额/并发类型:显示数值输入 -->
|
||||
<div v-if="generateForm.type !== 'subscription'">
|
||||
<label class="input-label">
|
||||
{{ generateForm.type === 'balance' ? t('admin.redeem.amount') : t('admin.redeem.columns.value') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="generateForm.value"
|
||||
type="number"
|
||||
:step="generateForm.type === 'balance' ? '0.01' : '1'"
|
||||
:min="generateForm.type === 'balance' ? '0.01' : '1'"
|
||||
required
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<!-- 订阅类型:显示分组选择和有效天数 -->
|
||||
<template v-if="generateForm.type === 'subscription'">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.redeem.selectGroup') }}</label>
|
||||
<Select
|
||||
v-model="generateForm.group_id"
|
||||
:options="subscriptionGroupOptions"
|
||||
:placeholder="t('admin.redeem.selectGroupPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.redeem.validityDays') }}</label>
|
||||
<input
|
||||
v-model.number="generateForm.validity_days"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
required
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.redeem.count') }}</label>
|
||||
<input
|
||||
v-model.number="generateForm.count"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
required
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="showGenerateDialog = false"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="generating"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{{ generating ? t('admin.redeem.generating') : t('admin.redeem.generate') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Generated Codes Result Dialog -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showResultDialog"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50"
|
||||
@click="closeResultDialog"
|
||||
></div>
|
||||
<div class="relative z-10 w-full max-w-lg bg-white dark:bg-dark-800 rounded-xl shadow-xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-dark-600">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.redeem.generatedSuccessfully') }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.redeem.codesCreated', { count: generatedCodes.length }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="closeResultDialog"
|
||||
class="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:hover:text-gray-300 dark:hover:bg-dark-700 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="p-5">
|
||||
<div class="relative">
|
||||
<textarea
|
||||
readonly
|
||||
:value="generatedCodesText"
|
||||
:style="{ height: textareaHeight }"
|
||||
class="w-full p-3 font-mono text-sm bg-gray-50 dark:bg-dark-700 border border-gray-200 dark:border-dark-600 rounded-lg resize-none focus:outline-none text-gray-800 dark:text-gray-200"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end gap-2 px-5 py-4 border-t border-gray-200 dark:border-dark-600 bg-gray-50 dark:bg-dark-700/50 rounded-b-xl">
|
||||
<button
|
||||
@click="copyGeneratedCodes"
|
||||
:class="[
|
||||
'btn flex items-center gap-2 transition-all',
|
||||
copiedAll ? 'btn-success' : 'btn-secondary'
|
||||
]"
|
||||
>
|
||||
<svg v-if="!copiedAll" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{{ copiedAll ? t('admin.redeem.copied') : t('admin.redeem.copyAll') }}
|
||||
</button>
|
||||
<button
|
||||
@click="downloadGeneratedCodes"
|
||||
class="btn btn-primary flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
{{ t('admin.redeem.download') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { RedeemCode, RedeemCodeType, Group } from '@/types'
|
||||
import type { Column } from '@/components/common/DataTable.vue'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const showGenerateDialog = ref(false)
|
||||
const showResultDialog = ref(false)
|
||||
const generatedCodes = ref<RedeemCode[]>([])
|
||||
const subscriptionGroups = ref<Group[]>([])
|
||||
|
||||
// 订阅类型分组选项
|
||||
const subscriptionGroupOptions = computed(() => {
|
||||
return subscriptionGroups.value
|
||||
.filter(g => g.subscription_type === 'subscription')
|
||||
.map(g => ({
|
||||
value: g.id,
|
||||
label: g.name
|
||||
}))
|
||||
})
|
||||
|
||||
const generatedCodesText = computed(() => {
|
||||
return generatedCodes.value.map(code => code.code).join('\n')
|
||||
})
|
||||
|
||||
const textareaHeight = computed(() => {
|
||||
const lineCount = generatedCodes.value.length
|
||||
const lineHeight = 24 // approximate line height in px
|
||||
const padding = 24 // top + bottom padding
|
||||
const minHeight = 60
|
||||
const maxHeight = 240
|
||||
const calculatedHeight = Math.min(Math.max(lineCount * lineHeight + padding, minHeight), maxHeight)
|
||||
return `${calculatedHeight}px`
|
||||
})
|
||||
|
||||
const copiedAll = ref(false)
|
||||
|
||||
const closeResultDialog = () => {
|
||||
showResultDialog.value = false
|
||||
generatedCodes.value = []
|
||||
copiedAll.value = false
|
||||
}
|
||||
|
||||
const copyGeneratedCodes = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(generatedCodesText.value)
|
||||
copiedAll.value = true
|
||||
setTimeout(() => {
|
||||
copiedAll.value = false
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.redeem.failedToCopy'))
|
||||
}
|
||||
}
|
||||
|
||||
const downloadGeneratedCodes = () => {
|
||||
const blob = new Blob([generatedCodesText.value], { type: 'text/plain' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `redeem-codes-${new Date().toISOString().split('T')[0]}.txt`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'code', label: t('admin.redeem.columns.code') },
|
||||
{ key: 'type', label: t('admin.redeem.columns.type'), sortable: true },
|
||||
{ key: 'value', label: t('admin.redeem.columns.value'), sortable: true },
|
||||
{ key: 'status', label: t('admin.redeem.columns.status'), sortable: true },
|
||||
{ key: 'used_by', label: t('admin.redeem.columns.usedBy') },
|
||||
{ key: 'used_at', label: t('admin.redeem.columns.usedAt'), sortable: true },
|
||||
{ key: 'actions', label: t('admin.redeem.columns.actions') }
|
||||
])
|
||||
|
||||
const typeOptions = computed(() => [
|
||||
{ value: 'balance', label: t('admin.redeem.balance') },
|
||||
{ value: 'concurrency', label: t('admin.redeem.concurrency') },
|
||||
{ value: 'subscription', label: t('admin.redeem.subscription') }
|
||||
])
|
||||
|
||||
const filterTypeOptions = computed(() => [
|
||||
{ value: '', label: t('admin.redeem.allTypes') },
|
||||
{ value: 'balance', label: t('admin.redeem.balance') },
|
||||
{ value: 'concurrency', label: t('admin.redeem.concurrency') },
|
||||
{ value: 'subscription', label: t('admin.redeem.subscription') }
|
||||
])
|
||||
|
||||
const filterStatusOptions = computed(() => [
|
||||
{ value: '', label: t('admin.redeem.allStatus') },
|
||||
{ value: 'unused', label: t('admin.redeem.unused') },
|
||||
{ value: 'used', label: t('admin.redeem.used') }
|
||||
])
|
||||
|
||||
const codes = ref<RedeemCode[]>([])
|
||||
const loading = ref(false)
|
||||
const generating = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const filters = reactive({
|
||||
type: '',
|
||||
status: ''
|
||||
})
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
|
||||
const showDeleteDialog = ref(false)
|
||||
const showDeleteUnusedDialog = ref(false)
|
||||
const deletingCode = ref<RedeemCode | null>(null)
|
||||
const copiedCode = ref<string | null>(null)
|
||||
|
||||
const generateForm = reactive({
|
||||
type: 'balance' as RedeemCodeType,
|
||||
value: 10,
|
||||
count: 1,
|
||||
group_id: null as number | null,
|
||||
validity_days: 30
|
||||
})
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
}
|
||||
|
||||
const loadCodes = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await adminAPI.redeem.list(
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
{
|
||||
type: filters.type as RedeemCodeType,
|
||||
status: filters.status as any,
|
||||
search: searchQuery.value || undefined
|
||||
}
|
||||
)
|
||||
codes.value = response.items
|
||||
pagination.total = response.total
|
||||
pagination.pages = response.pages
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.redeem.failedToLoad'))
|
||||
console.error('Error loading redeem codes:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout>
|
||||
const handleSearch = () => {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
pagination.page = 1
|
||||
loadCodes()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.page = page
|
||||
loadCodes()
|
||||
}
|
||||
|
||||
const handleGenerateCodes = async () => {
|
||||
// 订阅类型必须选择分组
|
||||
if (generateForm.type === 'subscription' && !generateForm.group_id) {
|
||||
appStore.showError(t('admin.redeem.groupRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
generating.value = true
|
||||
try {
|
||||
const result = await adminAPI.redeem.generate(
|
||||
generateForm.count,
|
||||
generateForm.type,
|
||||
generateForm.value,
|
||||
generateForm.type === 'subscription' ? generateForm.group_id : undefined,
|
||||
generateForm.type === 'subscription' ? generateForm.validity_days : undefined
|
||||
)
|
||||
showGenerateDialog.value = false
|
||||
generatedCodes.value = result
|
||||
showResultDialog.value = true
|
||||
// 重置表单
|
||||
generateForm.group_id = null
|
||||
generateForm.validity_days = 30
|
||||
loadCodes()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.redeem.failedToGenerate'))
|
||||
console.error('Error generating codes:', error)
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copiedCode.value = text
|
||||
setTimeout(() => {
|
||||
copiedCode.value = null
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.redeem.failedToCopy'))
|
||||
console.error('Error copying to clipboard:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportCodes = async () => {
|
||||
try {
|
||||
const blob = await adminAPI.redeem.exportCodes({
|
||||
type: filters.type as RedeemCodeType,
|
||||
status: filters.status as any
|
||||
})
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `redeem-codes-${new Date().toISOString().split('T')[0]}.csv`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
appStore.showSuccess(t('admin.redeem.codesExported'))
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.redeem.failedToExport'))
|
||||
console.error('Error exporting codes:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (code: RedeemCode) => {
|
||||
deletingCode.value = code
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingCode.value) return
|
||||
|
||||
try {
|
||||
await adminAPI.redeem.delete(deletingCode.value.id)
|
||||
appStore.showSuccess(t('admin.redeem.codeDeleted'))
|
||||
showDeleteDialog.value = false
|
||||
deletingCode.value = null
|
||||
loadCodes()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.redeem.failedToDelete'))
|
||||
console.error('Error deleting code:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDeleteUnused = async () => {
|
||||
try {
|
||||
// Get all unused codes and delete them
|
||||
const unusedCodesResponse = await adminAPI.redeem.list(1, 1000, { status: 'unused' })
|
||||
const unusedCodeIds = unusedCodesResponse.items.map(code => code.id)
|
||||
|
||||
if (unusedCodeIds.length === 0) {
|
||||
appStore.showInfo(t('admin.redeem.noUnusedCodes'))
|
||||
showDeleteUnusedDialog.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const result = await adminAPI.redeem.batchDelete(unusedCodeIds)
|
||||
appStore.showSuccess(t('admin.redeem.codesDeleted', { count: result.deleted }))
|
||||
showDeleteUnusedDialog.value = false
|
||||
loadCodes()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.redeem.failedToDeleteUnused'))
|
||||
console.error('Error deleting unused codes:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载订阅类型分组
|
||||
const loadSubscriptionGroups = async () => {
|
||||
try {
|
||||
const groups = await adminAPI.groups.getAll()
|
||||
subscriptionGroups.value = groups
|
||||
} catch (error) {
|
||||
console.error('Error loading subscription groups:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCodes()
|
||||
loadSubscriptionGroups()
|
||||
})
|
||||
</script>
|
||||
559
frontend/src/views/admin/SettingsView.vue
Normal file
559
frontend/src/views/admin/SettingsView.vue
Normal file
@@ -0,0 +1,559 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Form -->
|
||||
<form v-else @submit.prevent="saveSettings" class="space-y-6">
|
||||
<!-- Registration Settings -->
|
||||
<div class="card">
|
||||
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.registration.title') }}</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.settings.registration.description') }}</p>
|
||||
</div>
|
||||
<div class="p-6 space-y-5">
|
||||
<!-- Enable Registration -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{ t('admin.settings.registration.enableRegistration') }}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.registration.enableRegistrationHint') }}</p>
|
||||
</div>
|
||||
<Toggle v-model="form.registration_enabled" />
|
||||
</div>
|
||||
|
||||
<!-- Email Verification -->
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-100 dark:border-dark-700">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{ t('admin.settings.registration.emailVerification') }}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.registration.emailVerificationHint') }}</p>
|
||||
</div>
|
||||
<Toggle v-model="form.email_verify_enabled" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cloudflare Turnstile Settings -->
|
||||
<div class="card">
|
||||
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.turnstile.title') }}</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.settings.turnstile.description') }}</p>
|
||||
</div>
|
||||
<div class="p-6 space-y-5">
|
||||
<!-- Enable Turnstile -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{ t('admin.settings.turnstile.enableTurnstile') }}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.turnstile.enableTurnstileHint') }}</p>
|
||||
</div>
|
||||
<Toggle v-model="form.turnstile_enabled" />
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Keys - Only show when enabled -->
|
||||
<div v-if="form.turnstile_enabled" class="pt-4 border-t border-gray-100 dark:border-dark-700">
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.turnstile.siteKey') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.turnstile_site_key"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
placeholder="0x4AAAAAAA..."
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.turnstile.siteKeyHint') }}
|
||||
<a href="https://dash.cloudflare.com/turnstile" target="_blank" class="text-primary-600 hover:text-primary-500">Cloudflare Dashboard</a>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.turnstile.secretKey') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.turnstile_secret_key"
|
||||
type="password"
|
||||
class="input font-mono text-sm"
|
||||
placeholder="0x4AAAAAAA..."
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.turnstile.secretKeyHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Default Settings -->
|
||||
<div class="card">
|
||||
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.defaults.title') }}</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.settings.defaults.description') }}</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.defaults.defaultBalance') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.default_balance"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.defaults.defaultBalanceHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.defaults.defaultConcurrency') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.default_concurrency"
|
||||
type="number"
|
||||
min="1"
|
||||
class="input"
|
||||
placeholder="1"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.defaults.defaultConcurrencyHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Site Settings -->
|
||||
<div class="card">
|
||||
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.site.title') }}</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.settings.site.description') }}</p>
|
||||
</div>
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.site.siteName') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.site_name"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="Sub2API"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.site.siteNameHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.site.siteSubtitle') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.site_subtitle"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="Subscription to API Conversion Platform"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.site.siteSubtitleHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Base URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.site.apiBaseUrl') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.api_base_url"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
placeholder="https://api.example.com"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.site.apiBaseUrlHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Contact Info -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.site.contactInfo') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.contact_info"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.settings.site.contactInfoPlaceholder')"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.site.contactInfoHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Site Logo Upload -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.site.siteLogo') }}
|
||||
</label>
|
||||
<div class="flex items-start gap-6">
|
||||
<!-- Logo Preview -->
|
||||
<div class="flex-shrink-0">
|
||||
<div
|
||||
class="w-20 h-20 rounded-xl border-2 border-dashed border-gray-300 dark:border-dark-600 flex items-center justify-center overflow-hidden bg-gray-50 dark:bg-dark-800"
|
||||
:class="{ 'border-solid': form.site_logo }"
|
||||
>
|
||||
<img
|
||||
v-if="form.site_logo"
|
||||
:src="form.site_logo"
|
||||
alt="Site Logo"
|
||||
class="w-full h-full object-contain"
|
||||
/>
|
||||
<svg v-else class="w-8 h-8 text-gray-400 dark:text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Upload Controls -->
|
||||
<div class="flex-1 space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="btn btn-secondary btn-sm cursor-pointer">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handleLogoUpload"
|
||||
/>
|
||||
<svg class="w-4 h-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
{{ t('admin.settings.site.uploadImage') }}
|
||||
</label>
|
||||
<button
|
||||
v-if="form.site_logo"
|
||||
type="button"
|
||||
@click="form.site_logo = ''"
|
||||
class="btn btn-secondary btn-sm text-red-600 hover:text-red-700 dark:text-red-400"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
{{ t('admin.settings.site.remove') }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.site.logoHint') }}
|
||||
</p>
|
||||
<p v-if="logoError" class="text-xs text-red-500">{{ logoError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SMTP Settings - Only show when email verification is enabled -->
|
||||
<div v-if="form.email_verify_enabled" class="card">
|
||||
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.smtp.title') }}</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.settings.smtp.description') }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="testSmtpConnection"
|
||||
:disabled="testingSmtp"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
<svg v-if="testingSmtp" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ testingSmtp ? t('admin.settings.smtp.testing') : t('admin.settings.smtp.testConnection') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.smtp.host') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.smtp_host"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="smtp.gmail.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.smtp.port') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.smtp_port"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
class="input"
|
||||
placeholder="587"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.smtp.username') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.smtp_username"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="your-email@gmail.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.smtp.password') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.smtp_password"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="********"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.smtp.passwordHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.smtp.fromEmail') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.smtp_from_email"
|
||||
type="email"
|
||||
class="input"
|
||||
placeholder="noreply@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.smtp.fromName') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.smtp_from_name"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="Sub2API"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Use TLS Toggle -->
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-100 dark:border-dark-700">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{ t('admin.settings.smtp.useTls') }}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.smtp.useTlsHint') }}</p>
|
||||
</div>
|
||||
<Toggle v-model="form.smtp_use_tls" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send Test Email - Only show when email verification is enabled -->
|
||||
<div v-if="form.email_verify_enabled" class="card">
|
||||
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.testEmail.title') }}</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.settings.testEmail.description') }}</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="flex items-end gap-4">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.testEmail.recipientEmail') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="testEmailAddress"
|
||||
type="email"
|
||||
class="input"
|
||||
placeholder="test@example.com"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="sendTestEmail"
|
||||
:disabled="sendingTestEmail || !testEmailAddress"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
<svg v-if="sendingTestEmail" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ sendingTestEmail ? t('admin.settings.testEmail.sending') : t('admin.settings.testEmail.sendTestEmail') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="saving"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg v-if="saving" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ saving ? t('admin.settings.saving') : t('admin.settings.saveSettings') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { adminAPI } from '@/api';
|
||||
import type { SystemSettings } from '@/api/admin/settings';
|
||||
import AppLayout from '@/components/layout/AppLayout.vue';
|
||||
import Toggle from '@/components/common/Toggle.vue';
|
||||
import { useAppStore } from '@/stores';
|
||||
|
||||
const { t } = useI18n();
|
||||
const appStore = useAppStore();
|
||||
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const testingSmtp = ref(false);
|
||||
const sendingTestEmail = ref(false);
|
||||
const testEmailAddress = ref('');
|
||||
const logoError = ref('');
|
||||
|
||||
const form = reactive<SystemSettings>({
|
||||
registration_enabled: true,
|
||||
email_verify_enabled: false,
|
||||
default_balance: 0,
|
||||
default_concurrency: 1,
|
||||
site_name: 'Sub2API',
|
||||
site_logo: '',
|
||||
site_subtitle: 'Subscription to API Conversion Platform',
|
||||
api_base_url: '',
|
||||
contact_info: '',
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
smtp_username: '',
|
||||
smtp_password: '',
|
||||
smtp_from_email: '',
|
||||
smtp_from_name: '',
|
||||
smtp_use_tls: true,
|
||||
// Cloudflare Turnstile
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: '',
|
||||
turnstile_secret_key: '',
|
||||
});
|
||||
|
||||
function handleLogoUpload(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
logoError.value = '';
|
||||
|
||||
if (!file) return;
|
||||
|
||||
// Check file size (300KB = 307200 bytes)
|
||||
const maxSize = 300 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
logoError.value = t('admin.settings.site.logoSizeError', { size: (file.size / 1024).toFixed(1) });
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
logoError.value = t('admin.settings.site.logoTypeError');
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to base64
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
form.site_logo = e.target?.result as string;
|
||||
};
|
||||
reader.onerror = () => {
|
||||
logoError.value = t('admin.settings.site.logoReadError');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Reset input
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const settings = await adminAPI.settings.getSettings();
|
||||
Object.assign(form, settings);
|
||||
} catch (error: any) {
|
||||
appStore.showError(t('admin.settings.failedToLoad') + ': ' + (error.message || t('common.unknownError')));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
saving.value = true;
|
||||
try {
|
||||
await adminAPI.settings.updateSettings(form);
|
||||
appStore.showSuccess(t('admin.settings.settingsSaved'));
|
||||
} catch (error: any) {
|
||||
appStore.showError(t('admin.settings.failedToSave') + ': ' + (error.message || t('common.unknownError')));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testSmtpConnection() {
|
||||
testingSmtp.value = true;
|
||||
try {
|
||||
const result = await adminAPI.settings.testSmtpConnection({
|
||||
smtp_host: form.smtp_host,
|
||||
smtp_port: form.smtp_port,
|
||||
smtp_username: form.smtp_username,
|
||||
smtp_password: form.smtp_password,
|
||||
smtp_use_tls: form.smtp_use_tls,
|
||||
});
|
||||
// API returns { message: "..." } on success, errors are thrown as exceptions
|
||||
appStore.showSuccess(result.message || t('admin.settings.smtpConnectionSuccess'));
|
||||
} catch (error: any) {
|
||||
appStore.showError(t('admin.settings.failedToTestSmtp') + ': ' + (error.message || t('common.unknownError')));
|
||||
} finally {
|
||||
testingSmtp.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTestEmail() {
|
||||
if (!testEmailAddress.value) {
|
||||
appStore.showError(t('admin.settings.testEmail.enterRecipientHint'));
|
||||
return;
|
||||
}
|
||||
|
||||
sendingTestEmail.value = true;
|
||||
try {
|
||||
const result = await adminAPI.settings.sendTestEmail({
|
||||
email: testEmailAddress.value,
|
||||
smtp_host: form.smtp_host,
|
||||
smtp_port: form.smtp_port,
|
||||
smtp_username: form.smtp_username,
|
||||
smtp_password: form.smtp_password,
|
||||
smtp_from_email: form.smtp_from_email,
|
||||
smtp_from_name: form.smtp_from_name,
|
||||
smtp_use_tls: form.smtp_use_tls,
|
||||
});
|
||||
// API returns { message: "..." } on success, errors are thrown as exceptions
|
||||
appStore.showSuccess(result.message || t('admin.settings.testEmailSent'));
|
||||
} catch (error: any) {
|
||||
appStore.showError(t('admin.settings.failedToSendTestEmail') + ': ' + (error.message || t('common.unknownError')));
|
||||
} finally {
|
||||
sendingTestEmail.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings();
|
||||
});
|
||||
</script>
|
||||
548
frontend/src/views/admin/SubscriptionsView.vue
Normal file
548
frontend/src/views/admin/SubscriptionsView.vue
Normal file
@@ -0,0 +1,548 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header Actions -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
@click="showAssignModal = true"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
{{ t('admin.subscriptions.assignSubscription') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="statusOptions"
|
||||
:placeholder="t('admin.subscriptions.allStatus')"
|
||||
class="w-40"
|
||||
@change="loadSubscriptions"
|
||||
/>
|
||||
<Select
|
||||
v-model="filters.group_id"
|
||||
:options="groupOptions"
|
||||
:placeholder="t('admin.subscriptions.allGroups')"
|
||||
class="w-48"
|
||||
@change="loadSubscriptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Subscriptions Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<DataTable :columns="columns" :data="subscriptions" :loading="loading">
|
||||
<template #cell-user="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30">
|
||||
<span class="text-sm font-medium text-primary-700 dark:text-primary-300">
|
||||
{{ row.user?.email?.charAt(0).toUpperCase() || '?' }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ row.user?.email || `User #${row.user_id}` }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-group="{ row }">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400">
|
||||
{{ row.group?.name || `Group #${row.group_id}` }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-usage="{ row }">
|
||||
<div class="space-y-1 min-w-[200px]">
|
||||
<div v-if="row.group?.daily_limit_usd" class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500 w-12">{{ t('admin.subscriptions.daily') }}</span>
|
||||
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-2">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all"
|
||||
:class="getProgressClass(row.daily_usage_usd, row.group?.daily_limit_usd)"
|
||||
:style="{ width: getProgressWidth(row.daily_usage_usd, row.group?.daily_limit_usd) }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 w-20 text-right">
|
||||
${{ row.daily_usage_usd?.toFixed(2) || '0.00' }} / ${{ row.group?.daily_limit_usd?.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="row.group?.weekly_limit_usd" class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500 w-12">{{ t('admin.subscriptions.weekly') }}</span>
|
||||
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-2">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all"
|
||||
:class="getProgressClass(row.weekly_usage_usd, row.group?.weekly_limit_usd)"
|
||||
:style="{ width: getProgressWidth(row.weekly_usage_usd, row.group?.weekly_limit_usd) }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 w-20 text-right">
|
||||
${{ row.weekly_usage_usd?.toFixed(2) || '0.00' }} / ${{ row.group?.weekly_limit_usd?.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="row.group?.monthly_limit_usd" class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500 w-12">{{ t('admin.subscriptions.monthly') }}</span>
|
||||
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-2">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all"
|
||||
:class="getProgressClass(row.monthly_usage_usd, row.group?.monthly_limit_usd)"
|
||||
:style="{ width: getProgressWidth(row.monthly_usage_usd, row.group?.monthly_limit_usd) }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 w-20 text-right">
|
||||
${{ row.monthly_usage_usd?.toFixed(2) || '0.00' }} / ${{ row.group?.monthly_limit_usd?.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!row.group?.daily_limit_usd && !row.group?.weekly_limit_usd && !row.group?.monthly_limit_usd" class="text-xs text-gray-500">
|
||||
{{ t('admin.subscriptions.noLimits') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-expires_at="{ value }">
|
||||
<div v-if="value">
|
||||
<span class="text-sm" :class="isExpiringSoon(value) ? 'text-orange-600 dark:text-orange-400' : 'text-gray-700 dark:text-gray-300'">
|
||||
{{ formatDate(value) }}
|
||||
</span>
|
||||
<div v-if="getDaysRemaining(value) !== null" class="text-xs text-gray-500">
|
||||
{{ getDaysRemaining(value) }} {{ t('admin.subscriptions.daysRemaining') }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-sm text-gray-500">{{ t('admin.subscriptions.noExpiration') }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
value === 'active' ? 'badge-success' : value === 'expired' ? 'badge-warning' : 'badge-danger'
|
||||
]"
|
||||
>
|
||||
{{ t(`admin.subscriptions.status.${value}`) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-if="row.status === 'active'"
|
||||
@click="handleExtend(row)"
|
||||
class="p-2 rounded-lg hover:bg-green-50 dark:hover:bg-green-900/20 text-gray-500 hover:text-green-600 dark:hover:text-green-400 transition-colors"
|
||||
:title="t('admin.subscriptions.extend')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="row.status === 'active'"
|
||||
@click="handleRevoke(row)"
|
||||
class="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-gray-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
:title="t('admin.subscriptions.revoke')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<EmptyState
|
||||
:title="t('admin.subscriptions.noSubscriptionsYet')"
|
||||
:description="t('admin.subscriptions.assignFirstSubscription')"
|
||||
:action-text="t('admin.subscriptions.assignSubscription')"
|
||||
@action="showAssignModal = true"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Assign Subscription Modal -->
|
||||
<Modal
|
||||
:show="showAssignModal"
|
||||
:title="t('admin.subscriptions.assignSubscription')"
|
||||
size="lg"
|
||||
@close="closeAssignModal"
|
||||
>
|
||||
<form @submit.prevent="handleAssignSubscription" class="space-y-5">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.subscriptions.form.user') }}</label>
|
||||
<Select
|
||||
v-model="assignForm.user_id"
|
||||
:options="userOptions"
|
||||
:placeholder="t('admin.subscriptions.selectUser')"
|
||||
searchable
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.subscriptions.form.group') }}</label>
|
||||
<Select
|
||||
v-model="assignForm.group_id"
|
||||
:options="subscriptionGroupOptions"
|
||||
:placeholder="t('admin.subscriptions.selectGroup')"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.subscriptions.groupHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.subscriptions.form.validityDays') }}</label>
|
||||
<input
|
||||
v-model.number="assignForm.validity_days"
|
||||
type="number"
|
||||
min="1"
|
||||
class="input"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.subscriptions.validityHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
@click="closeAssignModal"
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg
|
||||
v-if="submitting"
|
||||
class="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ submitting ? t('admin.subscriptions.assigning') : t('admin.subscriptions.assign') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Extend Subscription Modal -->
|
||||
<Modal
|
||||
:show="showExtendModal"
|
||||
:title="t('admin.subscriptions.extendSubscription')"
|
||||
size="md"
|
||||
@close="closeExtendModal"
|
||||
>
|
||||
<form v-if="extendingSubscription" @submit.prevent="handleExtendSubscription" class="space-y-5">
|
||||
<div class="p-4 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.subscriptions.extendingFor') }}
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ extendingSubscription.user?.email }}</span>
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{{ t('admin.subscriptions.currentExpiration') }}:
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{{ extendingSubscription.expires_at ? formatDate(extendingSubscription.expires_at) : t('admin.subscriptions.noExpiration') }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.subscriptions.form.extendDays') }}</label>
|
||||
<input
|
||||
v-model.number="extendForm.days"
|
||||
type="number"
|
||||
min="1"
|
||||
required
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
@click="closeExtendModal"
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{{ submitting ? t('admin.subscriptions.extending') : t('admin.subscriptions.extend') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Revoke Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
:show="showRevokeDialog"
|
||||
:title="t('admin.subscriptions.revokeSubscription')"
|
||||
:message="t('admin.subscriptions.revokeConfirm', { user: revokingSubscription?.user?.email })"
|
||||
:confirm-text="t('admin.subscriptions.revoke')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
:danger="true"
|
||||
@confirm="confirmRevoke"
|
||||
@cancel="showRevokeDialog = false"
|
||||
/>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { UserSubscription, Group, User } from '@/types'
|
||||
import type { Column } from '@/components/common/DataTable.vue'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'user', label: t('admin.subscriptions.columns.user'), sortable: true },
|
||||
{ key: 'group', label: t('admin.subscriptions.columns.group'), sortable: true },
|
||||
{ key: 'usage', label: t('admin.subscriptions.columns.usage'), sortable: false },
|
||||
{ key: 'expires_at', label: t('admin.subscriptions.columns.expires'), sortable: true },
|
||||
{ key: 'status', label: t('admin.subscriptions.columns.status'), sortable: true },
|
||||
{ key: 'actions', label: t('admin.subscriptions.columns.actions'), sortable: false }
|
||||
])
|
||||
|
||||
// Filter options
|
||||
const statusOptions = computed(() => [
|
||||
{ value: '', label: t('admin.subscriptions.allStatus') },
|
||||
{ value: 'active', label: t('admin.subscriptions.status.active') },
|
||||
{ value: 'expired', label: t('admin.subscriptions.status.expired') },
|
||||
{ value: 'revoked', label: t('admin.subscriptions.status.revoked') }
|
||||
])
|
||||
|
||||
const subscriptions = ref<UserSubscription[]>([])
|
||||
const groups = ref<Group[]>([])
|
||||
const users = ref<User[]>([])
|
||||
const loading = ref(false)
|
||||
const filters = reactive({
|
||||
status: '',
|
||||
group_id: ''
|
||||
})
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
|
||||
const showAssignModal = ref(false)
|
||||
const showExtendModal = ref(false)
|
||||
const showRevokeDialog = ref(false)
|
||||
const submitting = ref(false)
|
||||
const extendingSubscription = ref<UserSubscription | null>(null)
|
||||
const revokingSubscription = ref<UserSubscription | null>(null)
|
||||
|
||||
const assignForm = reactive({
|
||||
user_id: null as number | null,
|
||||
group_id: null as number | null,
|
||||
validity_days: 30
|
||||
})
|
||||
|
||||
const extendForm = reactive({
|
||||
days: 30
|
||||
})
|
||||
|
||||
// Group options for filter (all groups)
|
||||
const groupOptions = computed(() => [
|
||||
{ value: '', label: t('admin.subscriptions.allGroups') },
|
||||
...groups.value.map(g => ({ value: g.id.toString(), label: g.name }))
|
||||
])
|
||||
|
||||
// Group options for assign (only subscription type groups)
|
||||
const subscriptionGroupOptions = computed(() =>
|
||||
groups.value
|
||||
.filter(g => g.subscription_type === 'subscription' && g.status === 'active')
|
||||
.map(g => ({ value: g.id, label: g.name }))
|
||||
)
|
||||
|
||||
// User options for assign
|
||||
const userOptions = computed(() =>
|
||||
users.value.map(u => ({ value: u.id, label: u.email }))
|
||||
)
|
||||
|
||||
const loadSubscriptions = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await adminAPI.subscriptions.list(
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
{
|
||||
status: filters.status as any || undefined,
|
||||
group_id: filters.group_id ? parseInt(filters.group_id) : undefined
|
||||
}
|
||||
)
|
||||
subscriptions.value = response.items
|
||||
pagination.total = response.total
|
||||
pagination.pages = response.pages
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.subscriptions.failedToLoad'))
|
||||
console.error('Error loading subscriptions:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadGroups = async () => {
|
||||
try {
|
||||
groups.value = await adminAPI.groups.getAll()
|
||||
} catch (error) {
|
||||
console.error('Error loading groups:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const response = await adminAPI.users.list(1, 1000)
|
||||
users.value = response.items
|
||||
} catch (error) {
|
||||
console.error('Error loading users:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.page = page
|
||||
loadSubscriptions()
|
||||
}
|
||||
|
||||
const closeAssignModal = () => {
|
||||
showAssignModal.value = false
|
||||
assignForm.user_id = null
|
||||
assignForm.group_id = null
|
||||
assignForm.validity_days = 30
|
||||
}
|
||||
|
||||
const handleAssignSubscription = async () => {
|
||||
if (!assignForm.user_id || !assignForm.group_id) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await adminAPI.subscriptions.assign({
|
||||
user_id: assignForm.user_id,
|
||||
group_id: assignForm.group_id,
|
||||
validity_days: assignForm.validity_days
|
||||
})
|
||||
appStore.showSuccess(t('admin.subscriptions.subscriptionAssigned'))
|
||||
closeAssignModal()
|
||||
loadSubscriptions()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.subscriptions.failedToAssign'))
|
||||
console.error('Error assigning subscription:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleExtend = (subscription: UserSubscription) => {
|
||||
extendingSubscription.value = subscription
|
||||
extendForm.days = 30
|
||||
showExtendModal.value = true
|
||||
}
|
||||
|
||||
const closeExtendModal = () => {
|
||||
showExtendModal.value = false
|
||||
extendingSubscription.value = null
|
||||
}
|
||||
|
||||
const handleExtendSubscription = async () => {
|
||||
if (!extendingSubscription.value) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await adminAPI.subscriptions.extend(extendingSubscription.value.id, {
|
||||
days: extendForm.days
|
||||
})
|
||||
appStore.showSuccess(t('admin.subscriptions.subscriptionExtended'))
|
||||
closeExtendModal()
|
||||
loadSubscriptions()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.subscriptions.failedToExtend'))
|
||||
console.error('Error extending subscription:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevoke = (subscription: UserSubscription) => {
|
||||
revokingSubscription.value = subscription
|
||||
showRevokeDialog.value = true
|
||||
}
|
||||
|
||||
const confirmRevoke = async () => {
|
||||
if (!revokingSubscription.value) return
|
||||
|
||||
try {
|
||||
await adminAPI.subscriptions.revoke(revokingSubscription.value.id)
|
||||
appStore.showSuccess(t('admin.subscriptions.subscriptionRevoked'))
|
||||
showRevokeDialog.value = false
|
||||
revokingSubscription.value = null
|
||||
loadSubscriptions()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.subscriptions.failedToRevoke'))
|
||||
console.error('Error revoking subscription:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const getDaysRemaining = (expiresAt: string): number | null => {
|
||||
const now = new Date()
|
||||
const expires = new Date(expiresAt)
|
||||
const diff = expires.getTime() - now.getTime()
|
||||
if (diff < 0) return null
|
||||
return Math.ceil(diff / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
const isExpiringSoon = (expiresAt: string): boolean => {
|
||||
const days = getDaysRemaining(expiresAt)
|
||||
return days !== null && days <= 7
|
||||
}
|
||||
|
||||
const getProgressWidth = (used: number, limit: number | null): string => {
|
||||
if (!limit || limit === 0) return '0%'
|
||||
const percentage = Math.min((used / limit) * 100, 100)
|
||||
return `${percentage}%`
|
||||
}
|
||||
|
||||
const getProgressClass = (used: number, limit: number | null): string => {
|
||||
if (!limit || limit === 0) return 'bg-gray-400'
|
||||
const percentage = (used / limit) * 100
|
||||
if (percentage >= 90) return 'bg-red-500'
|
||||
if (percentage >= 70) return 'bg-orange-500'
|
||||
return 'bg-green-500'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSubscriptions()
|
||||
loadGroups()
|
||||
loadUsers()
|
||||
})
|
||||
</script>
|
||||
593
frontend/src/views/admin/UsageView.vue
Normal file
593
frontend/src/views/admin/UsageView.vue
Normal file
@@ -0,0 +1,593 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Summary Stats Cards -->
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<!-- Total Requests -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/30">
|
||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('usage.totalRequests') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ usageStats?.total_requests?.toLocaleString() || '0' }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.inSelectedRange') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Tokens -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-amber-100 dark:bg-amber-900/30">
|
||||
<svg class="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('usage.totalTokens') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(usageStats?.total_tokens || 0) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.in') }}: {{ formatTokens(usageStats?.total_input_tokens || 0) }} / {{ t('usage.out') }}: {{ formatTokens(usageStats?.total_output_tokens || 0) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Cost -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-green-100 dark:bg-green-900/30">
|
||||
<svg class="w-5 h-5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('usage.totalCost') }}</p>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<p class="text-xl font-bold text-green-600 dark:text-green-400">${{ (usageStats?.total_actual_cost || 0).toFixed(4) }}</p>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500 line-through">${{ (usageStats?.total_cost || 0).toFixed(4) }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.actualCost') }} / {{ t('usage.standardCost') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Average Duration -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-purple-100 dark:bg-purple-900/30">
|
||||
<svg class="w-5 h-5 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('usage.avgDuration') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatDuration(usageStats?.average_duration_ms || 0) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.perRequest') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card">
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<!-- User Search -->
|
||||
<div class="min-w-[200px]">
|
||||
<label class="input-label">{{ t('admin.usage.userFilter') }}</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="userSearchKeyword"
|
||||
type="text"
|
||||
class="input pr-8"
|
||||
:placeholder="t('admin.usage.searchUserPlaceholder')"
|
||||
@input="debounceSearchUsers"
|
||||
@focus="showUserDropdown = true"
|
||||
/>
|
||||
<button
|
||||
v-if="selectedUser"
|
||||
@click="clearUserFilter"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- User Dropdown -->
|
||||
<div
|
||||
v-if="showUserDropdown && (userSearchResults.length > 0 || userSearchKeyword)"
|
||||
class="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-auto"
|
||||
>
|
||||
<div v-if="userSearchLoading" class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div v-else-if="userSearchResults.length === 0 && userSearchKeyword" class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('common.noOptionsFound') }}
|
||||
</div>
|
||||
<button
|
||||
v-for="user in userSearchResults"
|
||||
:key="user.id"
|
||||
@click="selectUser(user)"
|
||||
class="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ user.email }}</span>
|
||||
<span class="text-gray-500 dark:text-gray-400 ml-2">#{{ user.id }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Key Filter -->
|
||||
<div class="min-w-[180px]">
|
||||
<label class="input-label">{{ t('usage.apiKeyFilter') }}</label>
|
||||
<Select
|
||||
v-model="filters.api_key_id"
|
||||
:options="apiKeyOptions"
|
||||
:placeholder="t('usage.allApiKeys')"
|
||||
:disabled="!selectedUser && apiKeys.length === 0"
|
||||
@change="applyFilters"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Date Range Filter -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('usage.timeRange') }}</label>
|
||||
<DateRangePicker
|
||||
v-model:start-date="startDate"
|
||||
v-model:end-date="endDate"
|
||||
@change="onDateRangeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3 ml-auto">
|
||||
<button
|
||||
@click="resetFilters"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
{{ t('common.reset') }}
|
||||
</button>
|
||||
<button
|
||||
@click="exportToCSV"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{{ t('usage.exportCsv') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="usageLogs"
|
||||
:loading="loading"
|
||||
>
|
||||
<template #cell-user="{ row }">
|
||||
<div class="text-sm">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ row.user?.email || '-' }}</span>
|
||||
<span class="text-gray-500 dark:text-gray-400 ml-1">#{{ row.user_id }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-api_key="{ row }">
|
||||
<span class="text-sm text-gray-900 dark:text-white">{{ row.api_key?.name || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-model="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-stream="{ row }">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
:class="row.stream
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'"
|
||||
>
|
||||
{{ row.stream ? t('usage.stream') : t('usage.sync') }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-tokens="{ row }">
|
||||
<div class="text-sm">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('usage.in') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ row.input_tokens.toLocaleString() }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('usage.out') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ row.output_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="row.cache_read_tokens > 0" class="flex items-center gap-1 text-blue-600 dark:text-blue-400">
|
||||
<span>{{ t('dashboard.cache') }}</span>
|
||||
<span class="font-medium">{{ row.cache_read_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-cost="{ row }">
|
||||
<div class="text-sm flex items-center gap-1.5">
|
||||
<span class="font-medium text-green-600 dark:text-green-400">
|
||||
${{ row.actual_cost.toFixed(6) }}
|
||||
</span>
|
||||
<!-- Cost Detail Tooltip -->
|
||||
<div class="relative group">
|
||||
<div class="flex items-center justify-center w-4 h-4 rounded-full bg-gray-100 dark:bg-gray-700 cursor-help transition-colors group-hover:bg-blue-100 dark:group-hover:bg-blue-900/50">
|
||||
<svg class="w-3 h-3 text-gray-400 dark:text-gray-500 group-hover:text-blue-500 dark:group-hover:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Tooltip Content (right side) -->
|
||||
<div class="absolute z-[100] invisible group-hover:visible opacity-0 group-hover:opacity-100 transition-all duration-200 left-full top-1/2 -translate-y-1/2 ml-2">
|
||||
<div class="bg-gray-900 dark:bg-gray-800 text-white text-xs rounded-lg py-2.5 px-3 shadow-xl whitespace-nowrap border border-gray-700 dark:border-gray-600">
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-gray-400">{{ t('usage.rate') }}</span>
|
||||
<span class="font-semibold text-blue-400">{{ (row.rate_multiplier || 1).toFixed(2) }}x</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-gray-400">{{ t('usage.original') }}</span>
|
||||
<span class="font-medium text-white">${{ row.total_cost.toFixed(6) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-6 pt-1.5 border-t border-gray-700">
|
||||
<span class="text-gray-400">{{ t('usage.billed') }}</span>
|
||||
<span class="font-semibold text-green-400">${{ row.actual_cost.toFixed(6) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tooltip Arrow (left side) -->
|
||||
<div class="absolute right-full top-1/2 -translate-y-1/2 w-0 h-0 border-t-[6px] border-t-transparent border-b-[6px] border-b-transparent border-r-[6px] border-r-gray-900 dark:border-r-gray-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-billing_type="{ row }">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
:class="row.billing_type === 1
|
||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
||||
: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'"
|
||||
>
|
||||
{{ row.billing_type === 1 ? t('usage.subscription') : t('usage.balance') }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-first_token="{ row }">
|
||||
<span v-if="row.first_token_ms != null" class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ formatDuration(row.first_token_ms) }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-duration="{ row }">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDuration(row.duration_ms) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-created_at="{ value }">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDateTime(value) }}</span>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<EmptyState :message="t('usage.noRecords')" />
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||||
import type { UsageLog } from '@/types'
|
||||
import type { Column } from '@/components/common/DataTable.vue'
|
||||
import type { SimpleUser, SimpleApiKey, AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Usage stats from API
|
||||
const usageStats = ref<AdminUsageStatsResponse | null>(null)
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'user', label: t('admin.usage.user'), sortable: false },
|
||||
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
|
||||
{ key: 'model', label: t('usage.model'), sortable: true },
|
||||
{ key: 'stream', label: t('usage.type'), sortable: false },
|
||||
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
|
||||
{ key: 'cost', label: t('usage.cost'), sortable: false },
|
||||
{ key: 'billing_type', label: t('usage.billingType'), sortable: false },
|
||||
{ key: 'duration', label: t('usage.duration'), sortable: false },
|
||||
{ key: 'created_at', label: t('usage.time'), sortable: true }
|
||||
])
|
||||
|
||||
const usageLogs = ref<UsageLog[]>([])
|
||||
const apiKeys = ref<SimpleApiKey[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// User search state
|
||||
const userSearchKeyword = ref('')
|
||||
const userSearchResults = ref<SimpleUser[]>([])
|
||||
const userSearchLoading = ref(false)
|
||||
const showUserDropdown = ref(false)
|
||||
const selectedUser = ref<SimpleUser | null>(null)
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// API Key options computed from selected user's keys
|
||||
const apiKeyOptions = computed(() => {
|
||||
return [
|
||||
{ value: null, label: t('usage.allApiKeys') },
|
||||
...apiKeys.value.map(key => ({
|
||||
value: key.id,
|
||||
label: key.name
|
||||
}))
|
||||
]
|
||||
})
|
||||
|
||||
// Date range state
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
|
||||
const filters = ref<AdminUsageQueryParams>({
|
||||
user_id: undefined,
|
||||
api_key_id: undefined,
|
||||
start_date: undefined,
|
||||
end_date: undefined
|
||||
})
|
||||
|
||||
// Initialize default date range (last 7 days)
|
||||
const initializeDateRange = () => {
|
||||
const now = new Date()
|
||||
const today = now.toISOString().split('T')[0]
|
||||
const weekAgo = new Date(now)
|
||||
weekAgo.setDate(weekAgo.getDate() - 6)
|
||||
|
||||
startDate.value = weekAgo.toISOString().split('T')[0]
|
||||
endDate.value = today
|
||||
filters.value.start_date = startDate.value
|
||||
filters.value.end_date = endDate.value
|
||||
}
|
||||
|
||||
// User search with debounce
|
||||
const debounceSearchUsers = () => {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
searchTimeout = setTimeout(searchUsers, 300)
|
||||
}
|
||||
|
||||
const searchUsers = async () => {
|
||||
const keyword = userSearchKeyword.value.trim()
|
||||
if (!keyword) {
|
||||
userSearchResults.value = []
|
||||
return
|
||||
}
|
||||
|
||||
userSearchLoading.value = true
|
||||
try {
|
||||
userSearchResults.value = await adminAPI.usage.searchUsers(keyword)
|
||||
} catch (error) {
|
||||
console.error('Failed to search users:', error)
|
||||
userSearchResults.value = []
|
||||
} finally {
|
||||
userSearchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const selectUser = async (user: SimpleUser) => {
|
||||
selectedUser.value = user
|
||||
userSearchKeyword.value = user.email
|
||||
showUserDropdown.value = false
|
||||
filters.value.user_id = user.id
|
||||
filters.value.api_key_id = undefined
|
||||
|
||||
// Load API keys for selected user
|
||||
await loadApiKeysForUser(user.id)
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
const clearUserFilter = () => {
|
||||
selectedUser.value = null
|
||||
userSearchKeyword.value = ''
|
||||
userSearchResults.value = []
|
||||
filters.value.user_id = undefined
|
||||
filters.value.api_key_id = undefined
|
||||
apiKeys.value = []
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
const loadApiKeysForUser = async (userId: number) => {
|
||||
try {
|
||||
apiKeys.value = await adminAPI.usage.searchApiKeys(userId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load API keys:', error)
|
||||
apiKeys.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// Handle date range change from DateRangePicker
|
||||
const onDateRangeChange = (range: { startDate: string; endDate: string; preset: string | null }) => {
|
||||
filters.value.start_date = range.startDate
|
||||
filters.value.end_date = range.endDate
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
if (ms < 1000) return `${ms.toFixed(0)}ms`
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
const formatTokens = (value: number): string => {
|
||||
if (value >= 1_000_000_000) {
|
||||
return `${(value / 1_000_000_000).toFixed(2)}B`
|
||||
} else if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(2)}M`
|
||||
} else if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(2)}K`
|
||||
}
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
const formatDateTime = (dateString: string): string => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const loadUsageLogs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: AdminUsageQueryParams = {
|
||||
page: pagination.value.page,
|
||||
page_size: pagination.value.page_size,
|
||||
...filters.value
|
||||
}
|
||||
|
||||
const response = await adminAPI.usage.list(params)
|
||||
usageLogs.value = response.items
|
||||
pagination.value.total = response.total
|
||||
pagination.value.pages = response.pages
|
||||
} catch (error) {
|
||||
appStore.showError(t('usage.failedToLoad'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadUsageStats = async () => {
|
||||
try {
|
||||
const stats = await adminAPI.usage.getStats({
|
||||
user_id: filters.value.user_id,
|
||||
api_key_id: filters.value.api_key_id ? Number(filters.value.api_key_id) : undefined,
|
||||
start_date: filters.value.start_date || startDate.value,
|
||||
end_date: filters.value.end_date || endDate.value
|
||||
})
|
||||
usageStats.value = stats
|
||||
} catch (error) {
|
||||
console.error('Failed to load usage stats:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const applyFilters = () => {
|
||||
pagination.value.page = 1
|
||||
loadUsageLogs()
|
||||
loadUsageStats()
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
selectedUser.value = null
|
||||
userSearchKeyword.value = ''
|
||||
userSearchResults.value = []
|
||||
apiKeys.value = []
|
||||
filters.value = {
|
||||
user_id: undefined,
|
||||
api_key_id: undefined,
|
||||
start_date: undefined,
|
||||
end_date: undefined
|
||||
}
|
||||
// Reset date range to default (last 7 days)
|
||||
initializeDateRange()
|
||||
pagination.value.page = 1
|
||||
loadUsageLogs()
|
||||
loadUsageStats()
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.value.page = page
|
||||
loadUsageLogs()
|
||||
}
|
||||
|
||||
const exportToCSV = () => {
|
||||
if (usageLogs.value.length === 0) {
|
||||
appStore.showWarning(t('usage.noDataToExport'))
|
||||
return
|
||||
}
|
||||
|
||||
const headers = ['User', 'API Key', 'Model', 'Type', 'Input Tokens', 'Output Tokens', 'Cache Tokens', 'Total Cost', 'Billing Type', 'Duration (ms)', 'Time']
|
||||
const rows = usageLogs.value.map(log => [
|
||||
log.user?.email || '',
|
||||
log.api_key?.name || '',
|
||||
log.model,
|
||||
log.stream ? 'Stream' : 'Sync',
|
||||
log.input_tokens,
|
||||
log.output_tokens,
|
||||
log.cache_read_tokens,
|
||||
log.total_cost.toFixed(6),
|
||||
log.billing_type === 1 ? 'Subscription' : 'Balance',
|
||||
log.duration_ms,
|
||||
log.created_at
|
||||
])
|
||||
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map(row => row.join(','))
|
||||
].join('\n')
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `admin_usage_${new Date().toISOString().split('T')[0]}.csv`
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
appStore.showSuccess(t('usage.exportSuccess'))
|
||||
}
|
||||
|
||||
// Click outside to close dropdown
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('.relative')) {
|
||||
showUserDropdown.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initializeDateRange()
|
||||
loadUsageLogs()
|
||||
loadUsageStats()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
1002
frontend/src/views/admin/UsersView.vue
Normal file
1002
frontend/src/views/admin/UsersView.vue
Normal file
File diff suppressed because it is too large
Load Diff
423
frontend/src/views/auth/EmailVerifyView.vue
Normal file
423
frontend/src/views/auth/EmailVerifyView.vue
Normal file
@@ -0,0 +1,423 @@
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Title -->
|
||||
<div class="text-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Verify Your Email
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
We'll send a verification code to <span class="font-medium text-gray-700 dark:text-gray-300">{{ email }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- No Data Warning -->
|
||||
<div v-if="!hasRegisterData" class="p-4 rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-sm text-amber-700 dark:text-amber-400">
|
||||
<p class="font-medium">Session expired</p>
|
||||
<p class="mt-1">Please go back to the registration page and start again.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Verification Form -->
|
||||
<form v-else @submit.prevent="handleVerify" class="space-y-5">
|
||||
<!-- Verification Code Input -->
|
||||
<div>
|
||||
<label for="code" class="input-label text-center">
|
||||
Verification Code
|
||||
</label>
|
||||
<input
|
||||
id="code"
|
||||
v-model="verifyCode"
|
||||
type="text"
|
||||
required
|
||||
autocomplete="one-time-code"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
:disabled="isLoading"
|
||||
class="input text-center text-xl tracking-[0.5em] font-mono py-3"
|
||||
:class="{ 'input-error': errors.code }"
|
||||
placeholder="000000"
|
||||
/>
|
||||
<p v-if="errors.code" class="input-error-text text-center">
|
||||
{{ errors.code }}
|
||||
</p>
|
||||
<p v-else class="input-hint text-center">
|
||||
Enter the 6-digit code sent to your email
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Code Status -->
|
||||
<div v-if="codeSent" class="p-4 rounded-xl bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800/50">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm text-green-700 dark:text-green-400">
|
||||
Verification code sent! Please check your inbox.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Widget for Resend -->
|
||||
<div v-if="turnstileEnabled && turnstileSiteKey && showResendTurnstile">
|
||||
<TurnstileWidget
|
||||
ref="turnstileRef"
|
||||
:site-key="turnstileSiteKey"
|
||||
@verify="onTurnstileVerify"
|
||||
@expire="onTurnstileExpire"
|
||||
@error="onTurnstileError"
|
||||
/>
|
||||
<p v-if="errors.turnstile" class="input-error-text text-center mt-2">
|
||||
{{ errors.turnstile }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="p-4 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/50"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm text-red-700 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isLoading || !verifyCode"
|
||||
class="btn btn-primary w-full"
|
||||
>
|
||||
<svg
|
||||
v-if="isLoading"
|
||||
class="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ isLoading ? 'Verifying...' : 'Verify & Create Account' }}
|
||||
</button>
|
||||
|
||||
<!-- Resend Code -->
|
||||
<div class="text-center">
|
||||
<button
|
||||
v-if="countdown > 0"
|
||||
type="button"
|
||||
disabled
|
||||
class="text-sm text-gray-400 dark:text-dark-500 cursor-not-allowed"
|
||||
>
|
||||
Resend code in {{ countdown }}s
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
@click="handleResendCode"
|
||||
:disabled="isSendingCode || (turnstileEnabled && showResendTurnstile && !resendTurnstileToken)"
|
||||
class="text-sm text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="isSendingCode">Sending...</span>
|
||||
<span v-else-if="turnstileEnabled && !showResendTurnstile">Click to resend code</span>
|
||||
<span v-else>Resend verification code</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<template #footer>
|
||||
<button
|
||||
@click="handleBack"
|
||||
class="text-gray-500 dark:text-dark-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
Back to registration
|
||||
</button>
|
||||
</template>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { AuthLayout } from '@/components/layout';
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue';
|
||||
import { useAuthStore, useAppStore } from '@/stores';
|
||||
import { getPublicSettings, sendVerifyCode } from '@/api/auth';
|
||||
|
||||
// ==================== Router & Stores ====================
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const appStore = useAppStore();
|
||||
|
||||
// ==================== State ====================
|
||||
|
||||
const isLoading = ref<boolean>(false);
|
||||
const isSendingCode = ref<boolean>(false);
|
||||
const errorMessage = ref<string>('');
|
||||
const codeSent = ref<boolean>(false);
|
||||
const verifyCode = ref<string>('');
|
||||
const countdown = ref<number>(0);
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Registration data from sessionStorage
|
||||
const email = ref<string>('');
|
||||
const password = ref<string>('');
|
||||
const initialTurnstileToken = ref<string>('');
|
||||
const hasRegisterData = ref<boolean>(false);
|
||||
|
||||
// Public settings
|
||||
const turnstileEnabled = ref<boolean>(false);
|
||||
const turnstileSiteKey = ref<string>('');
|
||||
const siteName = ref<string>('Sub2API');
|
||||
|
||||
// Turnstile for resend
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null);
|
||||
const resendTurnstileToken = ref<string>('');
|
||||
const showResendTurnstile = ref<boolean>(false);
|
||||
|
||||
const errors = ref({
|
||||
code: '',
|
||||
turnstile: '',
|
||||
});
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
onMounted(async () => {
|
||||
// Load registration data from sessionStorage
|
||||
const registerDataStr = sessionStorage.getItem('register_data');
|
||||
if (registerDataStr) {
|
||||
try {
|
||||
const registerData = JSON.parse(registerDataStr);
|
||||
email.value = registerData.email || '';
|
||||
password.value = registerData.password || '';
|
||||
initialTurnstileToken.value = registerData.turnstile_token || '';
|
||||
hasRegisterData.value = !!(email.value && password.value);
|
||||
} catch {
|
||||
hasRegisterData.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Load public settings
|
||||
try {
|
||||
const settings = await getPublicSettings();
|
||||
turnstileEnabled.value = settings.turnstile_enabled;
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || '';
|
||||
siteName.value = settings.site_name || 'Sub2API';
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error);
|
||||
}
|
||||
|
||||
// Auto-send verification code if we have valid data
|
||||
if (hasRegisterData.value) {
|
||||
await sendCode();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer);
|
||||
countdownTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== Countdown ====================
|
||||
|
||||
function startCountdown(seconds: number): void {
|
||||
countdown.value = seconds;
|
||||
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer);
|
||||
}
|
||||
|
||||
countdownTimer = setInterval(() => {
|
||||
if (countdown.value > 0) {
|
||||
countdown.value--;
|
||||
} else {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer);
|
||||
countdownTimer = null;
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// ==================== Turnstile Handlers ====================
|
||||
|
||||
function onTurnstileVerify(token: string): void {
|
||||
resendTurnstileToken.value = token;
|
||||
errors.value.turnstile = '';
|
||||
}
|
||||
|
||||
function onTurnstileExpire(): void {
|
||||
resendTurnstileToken.value = '';
|
||||
errors.value.turnstile = 'Verification expired, please try again';
|
||||
}
|
||||
|
||||
function onTurnstileError(): void {
|
||||
resendTurnstileToken.value = '';
|
||||
errors.value.turnstile = 'Verification failed, please try again';
|
||||
}
|
||||
|
||||
// ==================== Send Code ====================
|
||||
|
||||
async function sendCode(): Promise<void> {
|
||||
isSendingCode.value = true;
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
const response = await sendVerifyCode({
|
||||
email: email.value,
|
||||
// 优先使用重发时新获取的 token(因为初始 token 可能已被使用)
|
||||
turnstile_token: resendTurnstileToken.value || initialTurnstileToken.value || undefined,
|
||||
});
|
||||
|
||||
codeSent.value = true;
|
||||
startCountdown(response.countdown);
|
||||
|
||||
// Reset turnstile state(token 已使用,清除以避免重复使用)
|
||||
initialTurnstileToken.value = '';
|
||||
showResendTurnstile.value = false;
|
||||
resendTurnstileToken.value = '';
|
||||
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string; response?: { data?: { detail?: string } } };
|
||||
|
||||
if (err.response?.data?.detail) {
|
||||
errorMessage.value = err.response.data.detail;
|
||||
} else if (err.message) {
|
||||
errorMessage.value = err.message;
|
||||
} else {
|
||||
errorMessage.value = 'Failed to send verification code. Please try again.';
|
||||
}
|
||||
|
||||
appStore.showError(errorMessage.value);
|
||||
} finally {
|
||||
isSendingCode.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Handlers ====================
|
||||
|
||||
async function handleResendCode(): Promise<void> {
|
||||
// If turnstile is enabled and we haven't shown it yet, show it
|
||||
if (turnstileEnabled.value && !showResendTurnstile.value) {
|
||||
showResendTurnstile.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// If turnstile is enabled but no token yet, wait
|
||||
if (turnstileEnabled.value && !resendTurnstileToken.value) {
|
||||
errors.value.turnstile = 'Please complete the verification';
|
||||
return;
|
||||
}
|
||||
|
||||
await sendCode();
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
errors.value.code = '';
|
||||
|
||||
if (!verifyCode.value.trim()) {
|
||||
errors.value.code = 'Verification code is required';
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!/^\d{6}$/.test(verifyCode.value.trim())) {
|
||||
errors.value.code = 'Please enter a valid 6-digit code';
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleVerify(): Promise<void> {
|
||||
errorMessage.value = '';
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
// Register with verification code
|
||||
await authStore.register({
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
verify_code: verifyCode.value.trim(),
|
||||
turnstile_token: initialTurnstileToken.value || undefined,
|
||||
});
|
||||
|
||||
// Clear session data
|
||||
sessionStorage.removeItem('register_data');
|
||||
|
||||
// Show success toast
|
||||
appStore.showSuccess('Account created successfully! Welcome to ' + siteName.value + '.');
|
||||
|
||||
// Redirect to dashboard
|
||||
await router.push('/dashboard');
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string; response?: { data?: { detail?: string } } };
|
||||
|
||||
if (err.response?.data?.detail) {
|
||||
errorMessage.value = err.response.data.detail;
|
||||
} else if (err.message) {
|
||||
errorMessage.value = err.message;
|
||||
} else {
|
||||
errorMessage.value = 'Verification failed. Please try again.';
|
||||
}
|
||||
|
||||
appStore.showError(errorMessage.value);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBack(): void {
|
||||
// Clear session data
|
||||
sessionStorage.removeItem('register_data');
|
||||
|
||||
// Go back to registration
|
||||
router.push('/register');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
328
frontend/src/views/auth/LoginView.vue
Normal file
328
frontend/src/views/auth/LoginView.vue
Normal file
@@ -0,0 +1,328 @@
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Title -->
|
||||
<div class="text-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ t('auth.welcomeBack') }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.signInToAccount') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form @submit.prevent="handleLogin" class="space-y-5">
|
||||
<!-- Email Input -->
|
||||
<div>
|
||||
<label for="email" class="input-label">
|
||||
{{ t('auth.emailLabel') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-400 dark:text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
v-model="formData.email"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
:disabled="isLoading"
|
||||
class="input pl-11"
|
||||
:class="{ 'input-error': errors.email }"
|
||||
:placeholder="t('auth.emailPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="errors.email" class="input-error-text">
|
||||
{{ errors.email }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Password Input -->
|
||||
<div>
|
||||
<label for="password" class="input-label">
|
||||
{{ t('auth.passwordLabel') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-400 dark:text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
v-model="formData.password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
:disabled="isLoading"
|
||||
class="input pl-11 pr-11"
|
||||
:class="{ 'input-error': errors.password }"
|
||||
:placeholder="t('auth.passwordPlaceholder')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute inset-y-0 right-0 pr-3.5 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-dark-300 transition-colors"
|
||||
>
|
||||
<svg v-if="showPassword" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="errors.password" class="input-error-text">
|
||||
{{ errors.password }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Widget -->
|
||||
<div v-if="turnstileEnabled && turnstileSiteKey">
|
||||
<TurnstileWidget
|
||||
ref="turnstileRef"
|
||||
:site-key="turnstileSiteKey"
|
||||
@verify="onTurnstileVerify"
|
||||
@expire="onTurnstileExpire"
|
||||
@error="onTurnstileError"
|
||||
/>
|
||||
<p v-if="errors.turnstile" class="input-error-text text-center mt-2">
|
||||
{{ errors.turnstile }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="p-4 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/50"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm text-red-700 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isLoading || (turnstileEnabled && !turnstileToken)"
|
||||
class="btn btn-primary w-full"
|
||||
>
|
||||
<svg
|
||||
v-if="isLoading"
|
||||
class="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
|
||||
</svg>
|
||||
{{ isLoading ? t('auth.signingIn') : t('auth.signIn') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<template #footer>
|
||||
<p class="text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.dontHaveAccount') }}
|
||||
<router-link
|
||||
to="/register"
|
||||
class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300 transition-colors"
|
||||
>
|
||||
{{ t('auth.signUp') }}
|
||||
</router-link>
|
||||
</p>
|
||||
</template>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { AuthLayout } from '@/components/layout';
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue';
|
||||
import { useAuthStore, useAppStore } from '@/stores';
|
||||
import { getPublicSettings } from '@/api/auth';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// ==================== Router & Stores ====================
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const appStore = useAppStore();
|
||||
|
||||
// ==================== State ====================
|
||||
|
||||
const isLoading = ref<boolean>(false);
|
||||
const errorMessage = ref<string>('');
|
||||
const showPassword = ref<boolean>(false);
|
||||
|
||||
// Public settings
|
||||
const turnstileEnabled = ref<boolean>(false);
|
||||
const turnstileSiteKey = ref<string>('');
|
||||
|
||||
// Turnstile
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null);
|
||||
const turnstileToken = ref<string>('');
|
||||
|
||||
const formData = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
const errors = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
turnstile: '',
|
||||
});
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const settings = await getPublicSettings();
|
||||
turnstileEnabled.value = settings.turnstile_enabled;
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || '';
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== Turnstile Handlers ====================
|
||||
|
||||
function onTurnstileVerify(token: string): void {
|
||||
turnstileToken.value = token;
|
||||
errors.turnstile = '';
|
||||
}
|
||||
|
||||
function onTurnstileExpire(): void {
|
||||
turnstileToken.value = '';
|
||||
errors.turnstile = 'Verification expired, please try again';
|
||||
}
|
||||
|
||||
function onTurnstileError(): void {
|
||||
turnstileToken.value = '';
|
||||
errors.turnstile = 'Verification failed, please try again';
|
||||
}
|
||||
|
||||
// ==================== Validation ====================
|
||||
|
||||
function validateForm(): boolean {
|
||||
// Reset errors
|
||||
errors.email = '';
|
||||
errors.password = '';
|
||||
errors.turnstile = '';
|
||||
|
||||
let isValid = true;
|
||||
|
||||
// Email validation
|
||||
if (!formData.email.trim()) {
|
||||
errors.email = 'Email is required';
|
||||
isValid = false;
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
errors.email = 'Please enter a valid email address';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Password validation
|
||||
if (!formData.password) {
|
||||
errors.password = 'Password is required';
|
||||
isValid = false;
|
||||
} else if (formData.password.length < 6) {
|
||||
errors.password = 'Password must be at least 6 characters';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Turnstile validation
|
||||
if (turnstileEnabled.value && !turnstileToken.value) {
|
||||
errors.turnstile = 'Please complete the verification';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// ==================== Form Handlers ====================
|
||||
|
||||
async function handleLogin(): Promise<void> {
|
||||
// Clear previous error
|
||||
errorMessage.value = '';
|
||||
|
||||
// Validate form
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
// Call auth store login
|
||||
await authStore.login({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
|
||||
});
|
||||
|
||||
// Show success toast
|
||||
appStore.showSuccess('Login successful! Welcome back.');
|
||||
|
||||
// Redirect to dashboard or intended route
|
||||
const redirectTo = router.currentRoute.value.query.redirect as string || '/dashboard';
|
||||
await router.push(redirectTo);
|
||||
} catch (error: unknown) {
|
||||
// Reset Turnstile on error
|
||||
if (turnstileRef.value) {
|
||||
turnstileRef.value.reset();
|
||||
turnstileToken.value = '';
|
||||
}
|
||||
|
||||
// Handle login error
|
||||
const err = error as { message?: string; response?: { data?: { detail?: string } } };
|
||||
|
||||
if (err.response?.data?.detail) {
|
||||
errorMessage.value = err.response.data.detail;
|
||||
} else if (err.message) {
|
||||
errorMessage.value = err.message;
|
||||
} else {
|
||||
errorMessage.value = 'Login failed. Please check your credentials and try again.';
|
||||
}
|
||||
|
||||
// Also show error toast
|
||||
appStore.showError(errorMessage.value);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
338
frontend/src/views/auth/README.md
Normal file
338
frontend/src/views/auth/README.md
Normal file
@@ -0,0 +1,338 @@
|
||||
# Authentication Views
|
||||
|
||||
This directory contains Vue 3 authentication views for the Sub2API frontend application.
|
||||
|
||||
## Components
|
||||
|
||||
### LoginView.vue
|
||||
|
||||
Login page for existing users to authenticate.
|
||||
|
||||
**Features:**
|
||||
- Username and password inputs with validation
|
||||
- Remember me checkbox for persistent sessions
|
||||
- Form validation with real-time error display
|
||||
- Loading state during authentication
|
||||
- Error message display for failed login attempts
|
||||
- Redirect to dashboard on successful login
|
||||
- Link to registration page for new users
|
||||
|
||||
**Usage:**
|
||||
```vue
|
||||
<template>
|
||||
<LoginView />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LoginView } from '@/views/auth';
|
||||
</script>
|
||||
```
|
||||
|
||||
**Route:**
|
||||
- Path: `/login`
|
||||
- Name: `Login`
|
||||
- Meta: `{ requiresAuth: false }`
|
||||
|
||||
**Validation Rules:**
|
||||
- Username: Required, minimum 3 characters
|
||||
- Password: Required, minimum 6 characters
|
||||
|
||||
**Behavior:**
|
||||
- Calls `authStore.login()` with credentials
|
||||
- Shows success toast on successful login
|
||||
- Shows error toast and inline error message on failure
|
||||
- Redirects to `/dashboard` or intended route from query parameter
|
||||
- Redirects authenticated users away from login page
|
||||
|
||||
### RegisterView.vue
|
||||
|
||||
Registration page for new users to create accounts.
|
||||
|
||||
**Features:**
|
||||
- Username, email, password, and confirm password inputs
|
||||
- Comprehensive form validation
|
||||
- Password strength requirements (8+ characters, letters + numbers)
|
||||
- Email format validation with regex
|
||||
- Password match validation
|
||||
- Loading state during registration
|
||||
- Error message display for failed registration
|
||||
- Redirect to dashboard on success
|
||||
- Link to login page for existing users
|
||||
|
||||
**Usage:**
|
||||
```vue
|
||||
<template>
|
||||
<RegisterView />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RegisterView } from '@/views/auth';
|
||||
</script>
|
||||
```
|
||||
|
||||
**Route:**
|
||||
- Path: `/register`
|
||||
- Name: `Register`
|
||||
- Meta: `{ requiresAuth: false }`
|
||||
|
||||
**Validation Rules:**
|
||||
- Username:
|
||||
- Required
|
||||
- 3-50 characters
|
||||
- Only letters, numbers, underscores, and hyphens
|
||||
- Email:
|
||||
- Required
|
||||
- Valid email format (RFC 5322 regex)
|
||||
- Password:
|
||||
- Required
|
||||
- Minimum 8 characters
|
||||
- Must contain at least one letter and one number
|
||||
- Confirm Password:
|
||||
- Required
|
||||
- Must match password
|
||||
|
||||
**Behavior:**
|
||||
- Calls `authStore.register()` with user data
|
||||
- Shows success toast on successful registration
|
||||
- Shows error toast and inline error message on failure
|
||||
- Redirects to `/dashboard` after successful registration
|
||||
- Redirects authenticated users away from register page
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component Structure
|
||||
|
||||
Both views follow a consistent structure:
|
||||
|
||||
```
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Title -->
|
||||
<!-- Form -->
|
||||
<!-- Error Message -->
|
||||
<!-- Submit Button -->
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<!-- Footer Links -->
|
||||
</template>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Imports
|
||||
// State
|
||||
// Validation
|
||||
// Form Handlers
|
||||
</script>
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
Both views use:
|
||||
- `useAuthStore()` - For authentication actions (login, register)
|
||||
- `useAppStore()` - For toast notifications and UI feedback
|
||||
- `useRouter()` - For navigation and redirects
|
||||
|
||||
### Validation Strategy
|
||||
|
||||
**Client-side Validation:**
|
||||
- Real-time validation on form submission
|
||||
- Field-level error messages
|
||||
- Comprehensive validation rules
|
||||
- TypeScript type safety
|
||||
|
||||
**Server-side Validation:**
|
||||
- Backend API validates all inputs
|
||||
- Error responses handled gracefully
|
||||
- User-friendly error messages displayed
|
||||
|
||||
### Styling
|
||||
|
||||
**Design System:**
|
||||
- TailwindCSS utility classes
|
||||
- Consistent color scheme (indigo primary)
|
||||
- Responsive design
|
||||
- Accessible form controls
|
||||
- Loading states with spinner animations
|
||||
|
||||
**Visual Feedback:**
|
||||
- Red border on invalid fields
|
||||
- Error messages below inputs
|
||||
- Global error banner for API errors
|
||||
- Success toasts on completion
|
||||
- Loading spinner on submit button
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Components
|
||||
- `AuthLayout` - Layout wrapper for auth pages from `@/components/layout`
|
||||
|
||||
### Stores
|
||||
- `authStore` - Authentication state management from `@/stores/auth`
|
||||
- `appStore` - Application state and toasts from `@/stores/app`
|
||||
|
||||
### Libraries
|
||||
- Vue 3 Composition API
|
||||
- Vue Router for navigation
|
||||
- Pinia for state management
|
||||
- TypeScript for type safety
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Login Flow
|
||||
|
||||
```typescript
|
||||
// User enters credentials
|
||||
formData.username = 'john_doe';
|
||||
formData.password = 'SecurePass123';
|
||||
|
||||
// Submit form
|
||||
await handleLogin();
|
||||
|
||||
// On success:
|
||||
// - authStore.login() called
|
||||
// - Token and user stored
|
||||
// - Success toast shown
|
||||
// - Redirected to /dashboard
|
||||
|
||||
// On error:
|
||||
// - Error message displayed
|
||||
// - Error toast shown
|
||||
// - Form remains editable
|
||||
```
|
||||
|
||||
### Basic Registration Flow
|
||||
|
||||
```typescript
|
||||
// User enters registration data
|
||||
formData.username = 'jane_smith';
|
||||
formData.email = 'jane@example.com';
|
||||
formData.password = 'SecurePass123';
|
||||
formData.confirmPassword = 'SecurePass123';
|
||||
|
||||
// Submit form
|
||||
await handleRegister();
|
||||
|
||||
// On success:
|
||||
// - authStore.register() called
|
||||
// - Token and user stored
|
||||
// - Success toast shown
|
||||
// - Redirected to /dashboard
|
||||
|
||||
// On error:
|
||||
// - Error message displayed
|
||||
// - Error toast shown
|
||||
// - Form remains editable
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Client-side Errors
|
||||
|
||||
```typescript
|
||||
// Validation errors
|
||||
errors.username = 'Username must be at least 3 characters';
|
||||
errors.email = 'Please enter a valid email address';
|
||||
errors.password = 'Password must be at least 8 characters with letters and numbers';
|
||||
errors.confirmPassword = 'Passwords do not match';
|
||||
```
|
||||
|
||||
### Server-side Errors
|
||||
|
||||
```typescript
|
||||
// API error responses
|
||||
{
|
||||
response: {
|
||||
data: {
|
||||
detail: 'Username already exists'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Displayed as:
|
||||
errorMessage.value = 'Username already exists';
|
||||
appStore.showError('Username already exists');
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Semantic HTML elements (`<label>`, `<input>`, `<button>`)
|
||||
- Proper `for` attributes on labels
|
||||
- ARIA attributes for loading states
|
||||
- Keyboard navigation support
|
||||
- Focus management
|
||||
- Error announcements
|
||||
- Sufficient color contrast
|
||||
|
||||
## Testing Considerations
|
||||
|
||||
### Unit Tests
|
||||
- Form validation logic
|
||||
- Error handling
|
||||
- State management
|
||||
- Router navigation
|
||||
|
||||
### Integration Tests
|
||||
- Full login flow
|
||||
- Full registration flow
|
||||
- Error scenarios
|
||||
- Redirect behavior
|
||||
|
||||
### E2E Tests
|
||||
- Complete user journeys
|
||||
- Form interactions
|
||||
- API integration
|
||||
- Success/error states
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
- OAuth/SSO integration (Google, GitHub)
|
||||
- Two-factor authentication (2FA)
|
||||
- Password strength meter
|
||||
- Email verification flow
|
||||
- Forgot password functionality
|
||||
- Social login buttons
|
||||
- CAPTCHA integration
|
||||
- Session timeout warnings
|
||||
- Password visibility toggle
|
||||
- Autofill support enhancement
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Passwords are never logged or displayed
|
||||
- HTTPS required in production
|
||||
- JWT tokens stored securely in localStorage
|
||||
- CORS protection on API
|
||||
- XSS protection with Vue's automatic escaping
|
||||
- CSRF protection with token-based auth
|
||||
- Rate limiting on backend API
|
||||
- Input sanitization
|
||||
- Secure password requirements
|
||||
|
||||
## Performance
|
||||
|
||||
- Lazy-loaded routes
|
||||
- Minimal bundle size
|
||||
- Fast initial render
|
||||
- Optimized re-renders with reactive refs
|
||||
- No unnecessary watchers
|
||||
- Efficient form validation
|
||||
|
||||
## Browser Support
|
||||
|
||||
- Modern browsers (Chrome, Firefox, Safari, Edge)
|
||||
- ES2015+ required
|
||||
- Flexbox and CSS Grid
|
||||
- Tailwind CSS utilities
|
||||
- Vue 3 runtime
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Auth Store Documentation](/src/stores/README.md#auth-store)
|
||||
- [AuthLayout Component](/src/components/layout/README.md#authlayout)
|
||||
- [Router Configuration](/src/router/index.ts)
|
||||
- [API Documentation](/src/api/README.md#authentication)
|
||||
- [Type Definitions](/src/types/index.ts)
|
||||
372
frontend/src/views/auth/RegisterView.vue
Normal file
372
frontend/src/views/auth/RegisterView.vue
Normal file
@@ -0,0 +1,372 @@
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Title -->
|
||||
<div class="text-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ t('auth.createAccount') }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.signUpToStart', { siteName }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Registration Disabled Message -->
|
||||
<div v-if="!registrationEnabled && settingsLoaded" class="p-4 rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm text-amber-700 dark:text-amber-400">
|
||||
{{ t('auth.registrationDisabled') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Registration Form -->
|
||||
<form v-else @submit.prevent="handleRegister" class="space-y-5">
|
||||
<!-- Email Input -->
|
||||
<div>
|
||||
<label for="email" class="input-label">
|
||||
{{ t('auth.emailLabel') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-400 dark:text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
v-model="formData.email"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
:disabled="isLoading"
|
||||
class="input pl-11"
|
||||
:class="{ 'input-error': errors.email }"
|
||||
:placeholder="t('auth.emailPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="errors.email" class="input-error-text">
|
||||
{{ errors.email }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Password Input -->
|
||||
<div>
|
||||
<label for="password" class="input-label">
|
||||
{{ t('auth.passwordLabel') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-400 dark:text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
v-model="formData.password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
:disabled="isLoading"
|
||||
class="input pl-11 pr-11"
|
||||
:class="{ 'input-error': errors.password }"
|
||||
:placeholder="t('auth.createPasswordPlaceholder')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute inset-y-0 right-0 pr-3.5 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-dark-300 transition-colors"
|
||||
>
|
||||
<svg v-if="showPassword" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="errors.password" class="input-error-text">
|
||||
{{ errors.password }}
|
||||
</p>
|
||||
<p v-else class="input-hint">
|
||||
{{ t('auth.passwordHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Widget -->
|
||||
<div v-if="turnstileEnabled && turnstileSiteKey">
|
||||
<TurnstileWidget
|
||||
ref="turnstileRef"
|
||||
:site-key="turnstileSiteKey"
|
||||
@verify="onTurnstileVerify"
|
||||
@expire="onTurnstileExpire"
|
||||
@error="onTurnstileError"
|
||||
/>
|
||||
<p v-if="errors.turnstile" class="input-error-text text-center mt-2">
|
||||
{{ errors.turnstile }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="p-4 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/50"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm text-red-700 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isLoading || (turnstileEnabled && !turnstileToken)"
|
||||
class="btn btn-primary w-full"
|
||||
>
|
||||
<svg
|
||||
v-if="isLoading"
|
||||
class="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zM4 19.235v-.11a6.375 6.375 0 0112.75 0v.109A12.318 12.318 0 0110.374 21c-2.331 0-4.512-.645-6.374-1.766z" />
|
||||
</svg>
|
||||
{{ isLoading ? t('auth.processing') : (emailVerifyEnabled ? t('auth.continue') : t('auth.createAccount')) }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<template #footer>
|
||||
<p class="text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.alreadyHaveAccount') }}
|
||||
<router-link
|
||||
to="/login"
|
||||
class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300 transition-colors"
|
||||
>
|
||||
{{ t('auth.signIn') }}
|
||||
</router-link>
|
||||
</p>
|
||||
</template>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { AuthLayout } from '@/components/layout';
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue';
|
||||
import { useAuthStore, useAppStore } from '@/stores';
|
||||
import { getPublicSettings } from '@/api/auth';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// ==================== Router & Stores ====================
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const appStore = useAppStore();
|
||||
|
||||
// ==================== State ====================
|
||||
|
||||
const isLoading = ref<boolean>(false);
|
||||
const settingsLoaded = ref<boolean>(false);
|
||||
const errorMessage = ref<string>('');
|
||||
const showPassword = ref<boolean>(false);
|
||||
|
||||
// Public settings
|
||||
const registrationEnabled = ref<boolean>(true);
|
||||
const emailVerifyEnabled = ref<boolean>(false);
|
||||
const turnstileEnabled = ref<boolean>(false);
|
||||
const turnstileSiteKey = ref<string>('');
|
||||
const siteName = ref<string>('Sub2API');
|
||||
|
||||
// Turnstile
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null);
|
||||
const turnstileToken = ref<string>('');
|
||||
|
||||
const formData = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
const errors = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
turnstile: '',
|
||||
});
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const settings = await getPublicSettings();
|
||||
registrationEnabled.value = settings.registration_enabled;
|
||||
emailVerifyEnabled.value = settings.email_verify_enabled;
|
||||
turnstileEnabled.value = settings.turnstile_enabled;
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || '';
|
||||
siteName.value = settings.site_name || 'Sub2API';
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error);
|
||||
} finally {
|
||||
settingsLoaded.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== Turnstile Handlers ====================
|
||||
|
||||
function onTurnstileVerify(token: string): void {
|
||||
turnstileToken.value = token;
|
||||
errors.turnstile = '';
|
||||
}
|
||||
|
||||
function onTurnstileExpire(): void {
|
||||
turnstileToken.value = '';
|
||||
errors.turnstile = 'Verification expired, please try again';
|
||||
}
|
||||
|
||||
function onTurnstileError(): void {
|
||||
turnstileToken.value = '';
|
||||
errors.turnstile = 'Verification failed, please try again';
|
||||
}
|
||||
|
||||
// ==================== Validation ====================
|
||||
|
||||
function validateEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
// Reset errors
|
||||
errors.email = '';
|
||||
errors.password = '';
|
||||
errors.turnstile = '';
|
||||
|
||||
let isValid = true;
|
||||
|
||||
// Email validation
|
||||
if (!formData.email.trim()) {
|
||||
errors.email = 'Email is required';
|
||||
isValid = false;
|
||||
} else if (!validateEmail(formData.email)) {
|
||||
errors.email = 'Please enter a valid email address';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Password validation
|
||||
if (!formData.password) {
|
||||
errors.password = 'Password is required';
|
||||
isValid = false;
|
||||
} else if (formData.password.length < 6) {
|
||||
errors.password = 'Password must be at least 6 characters';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Turnstile validation
|
||||
if (turnstileEnabled.value && !turnstileToken.value) {
|
||||
errors.turnstile = 'Please complete the verification';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// ==================== Form Handlers ====================
|
||||
|
||||
async function handleRegister(): Promise<void> {
|
||||
// Clear previous error
|
||||
errorMessage.value = '';
|
||||
|
||||
// Validate form
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
// If email verification is enabled, redirect to verification page
|
||||
if (emailVerifyEnabled.value) {
|
||||
// Store registration data in sessionStorage
|
||||
sessionStorage.setItem('register_data', JSON.stringify({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
turnstile_token: turnstileToken.value,
|
||||
}));
|
||||
|
||||
// Navigate to email verification page
|
||||
await router.push('/email-verify');
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, directly register
|
||||
await authStore.register({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
|
||||
});
|
||||
|
||||
// Show success toast
|
||||
appStore.showSuccess('Account created successfully! Welcome to ' + siteName.value + '.');
|
||||
|
||||
// Redirect to dashboard
|
||||
await router.push('/dashboard');
|
||||
} catch (error: unknown) {
|
||||
// Reset Turnstile on error
|
||||
if (turnstileRef.value) {
|
||||
turnstileRef.value.reset();
|
||||
turnstileToken.value = '';
|
||||
}
|
||||
|
||||
// Handle registration error
|
||||
const err = error as { message?: string; response?: { data?: { detail?: string } } };
|
||||
|
||||
if (err.response?.data?.detail) {
|
||||
errorMessage.value = err.response.data.detail;
|
||||
} else if (err.message) {
|
||||
errorMessage.value = err.message;
|
||||
} else {
|
||||
errorMessage.value = 'Registration failed. Please try again.';
|
||||
}
|
||||
|
||||
// Also show error toast
|
||||
appStore.showError(errorMessage.value);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
609
frontend/src/views/auth/USAGE_EXAMPLES.md
Normal file
609
frontend/src/views/auth/USAGE_EXAMPLES.md
Normal file
@@ -0,0 +1,609 @@
|
||||
# Authentication Views Usage Examples
|
||||
|
||||
This document provides practical examples of how to use the authentication views in the Sub2API frontend.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Login Flow
|
||||
|
||||
**Scenario:** User wants to log into their existing account
|
||||
|
||||
```typescript
|
||||
// Route: /login
|
||||
// Component: LoginView.vue
|
||||
|
||||
// User interactions:
|
||||
// 1. Navigate to /login
|
||||
// 2. Enter username: "john_doe"
|
||||
// 3. Enter password: "MySecurePass123"
|
||||
// 4. Optionally check "Remember me"
|
||||
// 5. Click "Sign In"
|
||||
|
||||
// What happens:
|
||||
// - Form validation runs (client-side)
|
||||
// - If valid, authStore.login() is called
|
||||
// - API request to POST /api/auth/login
|
||||
// - On success:
|
||||
// - Token stored in localStorage
|
||||
// - User data stored in state
|
||||
// - Success toast: "Login successful! Welcome back."
|
||||
// - Redirect to /dashboard (or intended route)
|
||||
// - On error:
|
||||
// - Error message displayed inline
|
||||
// - Error toast shown
|
||||
// - User can retry
|
||||
```
|
||||
|
||||
### 2. Registration Flow
|
||||
|
||||
**Scenario:** New user wants to create an account
|
||||
|
||||
```typescript
|
||||
// Route: /register
|
||||
// Component: RegisterView.vue
|
||||
|
||||
// User interactions:
|
||||
// 1. Navigate to /register
|
||||
// 2. Enter username: "jane_smith"
|
||||
// 3. Enter email: "jane@example.com"
|
||||
// 4. Enter password: "SecurePass123"
|
||||
// 5. Enter confirm password: "SecurePass123"
|
||||
// 6. Click "Create Account"
|
||||
|
||||
// What happens:
|
||||
// - Form validation runs (client-side)
|
||||
// - Username: 3-50 chars, alphanumeric + _ -
|
||||
// - Email: Valid format
|
||||
// - Password: 8+ chars, letters + numbers
|
||||
// - Passwords match
|
||||
// - If valid, authStore.register() is called
|
||||
// - API request to POST /api/auth/register
|
||||
// - On success:
|
||||
// - Token stored in localStorage
|
||||
// - User data stored in state
|
||||
// - Success toast: "Account created successfully! Welcome to Sub2API."
|
||||
// - Redirect to /dashboard
|
||||
// - On error:
|
||||
// - Error message displayed inline
|
||||
// - Error toast shown
|
||||
// - User can retry
|
||||
```
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Importing the Views
|
||||
|
||||
```typescript
|
||||
// Method 1: Direct import
|
||||
import LoginView from '@/views/auth/LoginView.vue';
|
||||
import RegisterView from '@/views/auth/RegisterView.vue';
|
||||
|
||||
// Method 2: Named exports from index
|
||||
import { LoginView, RegisterView } from '@/views/auth';
|
||||
|
||||
// Method 3: Lazy loading (recommended for routes)
|
||||
const LoginView = () => import('@/views/auth/LoginView.vue');
|
||||
const RegisterView = () => import('@/views/auth/RegisterView.vue');
|
||||
```
|
||||
|
||||
### Using in Router
|
||||
|
||||
```typescript
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/auth/LoginView.vue'),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('@/views/auth/RegisterView.vue'),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
export default router;
|
||||
```
|
||||
|
||||
### Navigation to Auth Views
|
||||
|
||||
```typescript
|
||||
// From template
|
||||
<router-link to="/login">Login</router-link>
|
||||
<router-link to="/register">Sign Up</router-link>
|
||||
|
||||
// From script
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// Navigate to login
|
||||
router.push('/login');
|
||||
|
||||
// Navigate to register
|
||||
router.push('/register');
|
||||
|
||||
// Navigate with redirect query
|
||||
router.push({
|
||||
path: '/login',
|
||||
query: { redirect: '/dashboard' }
|
||||
});
|
||||
```
|
||||
|
||||
### Programmatic Auth Flow
|
||||
|
||||
```typescript
|
||||
import { useAuthStore } from '@/stores';
|
||||
import { useAppStore } from '@/stores';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const appStore = useAppStore();
|
||||
const router = useRouter();
|
||||
|
||||
// Login
|
||||
async function login() {
|
||||
try {
|
||||
await authStore.login({
|
||||
username: 'john_doe',
|
||||
password: 'MySecurePass123'
|
||||
});
|
||||
|
||||
appStore.showSuccess('Login successful!');
|
||||
router.push('/dashboard');
|
||||
} catch (error) {
|
||||
appStore.showError('Login failed. Please check your credentials.');
|
||||
}
|
||||
}
|
||||
|
||||
// Register
|
||||
async function register() {
|
||||
try {
|
||||
await authStore.register({
|
||||
username: 'jane_smith',
|
||||
email: 'jane@example.com',
|
||||
password: 'SecurePass123'
|
||||
});
|
||||
|
||||
appStore.showSuccess('Account created successfully!');
|
||||
router.push('/dashboard');
|
||||
} catch (error) {
|
||||
appStore.showError('Registration failed. Please try again.');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Validation Examples
|
||||
|
||||
### Login Validation
|
||||
|
||||
```typescript
|
||||
// Valid inputs
|
||||
✅ Username: "john_doe" (3+ chars)
|
||||
✅ Password: "SecurePass123" (6+ chars)
|
||||
|
||||
// Invalid inputs
|
||||
❌ Username: "jo" → Error: "Username must be at least 3 characters"
|
||||
❌ Password: "12345" → Error: "Password must be at least 6 characters"
|
||||
❌ Username: "" → Error: "Username is required"
|
||||
❌ Password: "" → Error: "Password is required"
|
||||
```
|
||||
|
||||
### Registration Validation
|
||||
|
||||
```typescript
|
||||
// Valid inputs
|
||||
✅ Username: "jane_smith" (3-50 chars, alphanumeric + _ -)
|
||||
✅ Email: "jane@example.com" (valid format)
|
||||
✅ Password: "SecurePass123" (8+ chars, letters + numbers)
|
||||
✅ Confirm: "SecurePass123" (matches password)
|
||||
|
||||
// Invalid inputs
|
||||
❌ Username: "ja" → Error: "Username must be at least 3 characters"
|
||||
❌ Username: "jane@smith" → Error: "Username can only contain letters, numbers, underscores, and hyphens"
|
||||
❌ Email: "invalid-email" → Error: "Please enter a valid email address"
|
||||
❌ Password: "short" → Error: "Password must be at least 8 characters with letters and numbers"
|
||||
❌ Password: "12345678" → Error: "Password must be at least 8 characters with letters and numbers" (no letters)
|
||||
❌ Password: "password" → Error: "Password must be at least 8 characters with letters and numbers" (no numbers)
|
||||
❌ Confirm: "DifferentPass" → Error: "Passwords do not match"
|
||||
```
|
||||
|
||||
## Error Handling Examples
|
||||
|
||||
### Backend Errors
|
||||
|
||||
```typescript
|
||||
// Example 1: Username already exists
|
||||
{
|
||||
response: {
|
||||
data: {
|
||||
detail: "Username 'john_doe' is already taken"
|
||||
}
|
||||
}
|
||||
}
|
||||
// Displayed: "Username 'john_doe' is already taken"
|
||||
|
||||
// Example 2: Invalid credentials
|
||||
{
|
||||
response: {
|
||||
data: {
|
||||
detail: "Invalid username or password"
|
||||
}
|
||||
}
|
||||
}
|
||||
// Displayed: "Invalid username or password"
|
||||
|
||||
// Example 3: Network error
|
||||
{
|
||||
message: "Network Error"
|
||||
}
|
||||
// Displayed: "Network Error" + Error toast
|
||||
|
||||
// Example 4: Unknown error
|
||||
{}
|
||||
// Displayed: "Login failed. Please check your credentials and try again." (default)
|
||||
```
|
||||
|
||||
### Client-side Validation Errors
|
||||
|
||||
```typescript
|
||||
// Multiple validation errors displayed simultaneously
|
||||
errors = {
|
||||
username: "Username must be at least 3 characters",
|
||||
email: "Please enter a valid email address",
|
||||
password: "Password must be at least 8 characters with letters and numbers",
|
||||
confirmPassword: "Passwords do not match"
|
||||
}
|
||||
|
||||
// Each error appears below its respective input field with red styling
|
||||
```
|
||||
|
||||
## Testing Examples
|
||||
|
||||
### Unit Test: Login View
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createPinia } from 'pinia';
|
||||
import LoginView from '@/views/auth/LoginView.vue';
|
||||
|
||||
describe('LoginView', () => {
|
||||
it('validates required fields', async () => {
|
||||
const wrapper = mount(LoginView, {
|
||||
global: {
|
||||
plugins: [createPinia()],
|
||||
},
|
||||
});
|
||||
|
||||
// Submit empty form
|
||||
await wrapper.find('form').trigger('submit');
|
||||
|
||||
// Check for validation errors
|
||||
expect(wrapper.text()).toContain('Username is required');
|
||||
expect(wrapper.text()).toContain('Password is required');
|
||||
});
|
||||
|
||||
it('calls authStore.login on valid submission', async () => {
|
||||
const wrapper = mount(LoginView, {
|
||||
global: {
|
||||
plugins: [createPinia()],
|
||||
},
|
||||
});
|
||||
|
||||
// Fill in form
|
||||
await wrapper.find('#username').setValue('john_doe');
|
||||
await wrapper.find('#password').setValue('SecurePass123');
|
||||
|
||||
// Submit form
|
||||
await wrapper.find('form').trigger('submit');
|
||||
|
||||
// Verify authStore.login was called
|
||||
// (mock implementation needed)
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### E2E Test: Registration Flow
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('user can register successfully', async ({ page }) => {
|
||||
// Navigate to register page
|
||||
await page.goto('/register');
|
||||
|
||||
// Fill in registration form
|
||||
await page.fill('#username', 'new_user');
|
||||
await page.fill('#email', 'new_user@example.com');
|
||||
await page.fill('#password', 'SecurePass123');
|
||||
await page.fill('#confirmPassword', 'SecurePass123');
|
||||
|
||||
// Submit form
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Wait for redirect to dashboard
|
||||
await page.waitForURL('/dashboard');
|
||||
|
||||
// Verify success toast appears
|
||||
await expect(page.locator('.toast-success')).toBeVisible();
|
||||
await expect(page.locator('.toast-success')).toContainText('Account created successfully');
|
||||
});
|
||||
|
||||
test('shows validation errors for invalid inputs', async ({ page }) => {
|
||||
await page.goto('/register');
|
||||
|
||||
// Enter mismatched passwords
|
||||
await page.fill('#password', 'SecurePass123');
|
||||
await page.fill('#confirmPassword', 'DifferentPass');
|
||||
|
||||
// Submit form
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Verify error message
|
||||
await expect(page.locator('text=Passwords do not match')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Integration with Navigation Guards
|
||||
|
||||
### Router Guard Example
|
||||
|
||||
```typescript
|
||||
import { useAuthStore } from '@/stores';
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Redirect authenticated users away from auth pages
|
||||
if (authStore.isAuthenticated && (to.path === '/login' || to.path === '/register')) {
|
||||
next('/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect unauthenticated users to login
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
next({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
```
|
||||
|
||||
## Customization Examples
|
||||
|
||||
### Custom Success Redirect
|
||||
|
||||
```typescript
|
||||
// In LoginView.vue
|
||||
async function handleLogin(): Promise<void> {
|
||||
try {
|
||||
await authStore.login({
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
});
|
||||
|
||||
appStore.showSuccess('Login successful!');
|
||||
|
||||
// Custom redirect logic
|
||||
const isAdmin = authStore.isAdmin;
|
||||
const redirectTo = isAdmin ? '/admin/dashboard' : '/dashboard';
|
||||
|
||||
await router.push(redirectTo);
|
||||
} catch (error) {
|
||||
// Error handling...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Validation Rules
|
||||
|
||||
```typescript
|
||||
// Custom password strength validation
|
||||
function validatePasswordStrength(password: string): boolean {
|
||||
const hasMinLength = password.length >= 12;
|
||||
const hasUpperCase = /[A-Z]/.test(password);
|
||||
const hasLowerCase = /[a-z]/.test(password);
|
||||
const hasNumber = /[0-9]/.test(password);
|
||||
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
|
||||
|
||||
return hasMinLength && hasUpperCase && hasLowerCase && hasNumber && hasSpecialChar;
|
||||
}
|
||||
|
||||
// Use in validation
|
||||
if (!validatePasswordStrength(formData.password)) {
|
||||
errors.password = 'Password must be at least 12 characters with uppercase, lowercase, numbers, and special characters';
|
||||
isValid = false;
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Error Handling
|
||||
|
||||
```typescript
|
||||
// In RegisterView.vue
|
||||
async function handleRegister(): Promise<void> {
|
||||
try {
|
||||
await authStore.register({
|
||||
username: formData.username,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
});
|
||||
|
||||
appStore.showSuccess('Account created successfully!');
|
||||
await router.push('/dashboard');
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { status?: number; data?: { detail?: string } } };
|
||||
|
||||
// Custom error handling based on status code
|
||||
if (err.response?.status === 409) {
|
||||
errorMessage.value = 'This username or email is already registered. Please use a different one.';
|
||||
} else if (err.response?.status === 422) {
|
||||
errorMessage.value = 'Invalid input. Please check your information and try again.';
|
||||
} else if (err.response?.status === 500) {
|
||||
errorMessage.value = 'Server error. Please try again later.';
|
||||
} else {
|
||||
errorMessage.value = err.response?.data?.detail || 'Registration failed. Please try again.';
|
||||
}
|
||||
|
||||
appStore.showError(errorMessage.value);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Accessibility Examples
|
||||
|
||||
### Keyboard Navigation
|
||||
|
||||
```typescript
|
||||
// Tab order:
|
||||
// 1. Username input
|
||||
// 2. Password input
|
||||
// 3. Remember me checkbox (login) / Confirm password (register)
|
||||
// 4. Submit button
|
||||
// 5. Footer link (register/login)
|
||||
|
||||
// Enter key submits form
|
||||
// Escape key can be used to clear focus
|
||||
```
|
||||
|
||||
### Screen Reader Support
|
||||
|
||||
```html
|
||||
<!-- Proper labels for screen readers -->
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
aria-label="Username"
|
||||
aria-required="true"
|
||||
aria-invalid="false"
|
||||
aria-describedby="username-error"
|
||||
/>
|
||||
<p id="username-error" role="alert" class="text-sm text-red-600">
|
||||
<!-- Error message here -->
|
||||
</p>
|
||||
|
||||
<!-- Loading state announced -->
|
||||
<button type="submit" aria-busy="true" aria-label="Signing in...">
|
||||
<span class="sr-only">Signing in...</span>
|
||||
<!-- Visual content -->
|
||||
</button>
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Lazy Loading
|
||||
|
||||
```typescript
|
||||
// Router configuration with lazy loading
|
||||
{
|
||||
path: '/login',
|
||||
component: () => import('@/views/auth/LoginView.vue'), // ✅ Lazy loaded
|
||||
}
|
||||
|
||||
// Direct import (not recommended for routes)
|
||||
import LoginView from '@/views/auth/LoginView.vue'; // ❌ Eager loaded
|
||||
```
|
||||
|
||||
### Optimization Tips
|
||||
|
||||
1. Use `v-once` for static content
|
||||
2. Debounce expensive validation operations
|
||||
3. Minimize reactive dependencies
|
||||
4. Use `shallowRef` for complex objects when possible
|
||||
5. Avoid unnecessary watchers
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. Never log passwords or tokens
|
||||
2. Use HTTPS in production
|
||||
3. Implement rate limiting on backend
|
||||
4. Validate all inputs server-side
|
||||
5. Use secure password hashing (bcrypt, argon2)
|
||||
6. Implement CSRF protection
|
||||
7. Set secure cookie flags
|
||||
8. Use Content Security Policy headers
|
||||
9. Sanitize all user inputs
|
||||
10. Implement account lockout after failed attempts
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### Issue: Token not persisting after refresh
|
||||
|
||||
```typescript
|
||||
// Solution: Initialize auth state on app mount
|
||||
// In main.ts or App.vue
|
||||
import { useAuthStore } from '@/stores';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
authStore.checkAuth(); // Restore auth from localStorage
|
||||
```
|
||||
|
||||
### Issue: Redirect loop after login
|
||||
|
||||
```typescript
|
||||
// Solution: Check router guard logic
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// ✅ Correct: Check specific routes
|
||||
if (authStore.isAuthenticated && (to.path === '/login' || to.path === '/register')) {
|
||||
next('/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
// ❌ Wrong: Blanket redirect
|
||||
// if (authStore.isAuthenticated) {
|
||||
// next('/dashboard'); // This causes loops!
|
||||
// }
|
||||
|
||||
next();
|
||||
});
|
||||
```
|
||||
|
||||
### Issue: Form not clearing after successful submission
|
||||
|
||||
```typescript
|
||||
// Solution: Reset form data
|
||||
async function handleLogin(): Promise<void> {
|
||||
try {
|
||||
await authStore.login({...});
|
||||
|
||||
// Reset form
|
||||
formData.username = '';
|
||||
formData.password = '';
|
||||
formData.remember = false;
|
||||
|
||||
// Clear errors
|
||||
errors.username = '';
|
||||
errors.password = '';
|
||||
|
||||
await router.push('/dashboard');
|
||||
} catch (error) {
|
||||
// Error handling...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Vue 3 Documentation](https://vuejs.org/)
|
||||
- [Vue Router Documentation](https://router.vuejs.org/)
|
||||
- [Pinia Documentation](https://pinia.vuejs.org/)
|
||||
- [TailwindCSS Documentation](https://tailwindcss.com/)
|
||||
- [TypeScript Handbook](https://www.typescriptlang.org/docs/)
|
||||
591
frontend/src/views/auth/VISUAL_GUIDE.md
Normal file
591
frontend/src/views/auth/VISUAL_GUIDE.md
Normal file
@@ -0,0 +1,591 @@
|
||||
# Authentication Views Visual Guide
|
||||
|
||||
This document describes the visual design and layout of the authentication views.
|
||||
|
||||
## Layout Structure
|
||||
|
||||
Both LoginView and RegisterView use the AuthLayout component, which provides:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ Sub2API Logo │ │
|
||||
│ │ "Subscription to API Conversion" │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [Form Content - White Card] │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [Footer Links] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────┘
|
||||
|
||||
Background: Gradient (Indigo → White → Purple)
|
||||
Card: White with rounded corners and shadow
|
||||
Max Width: 28rem (448px)
|
||||
Centered: Both horizontally and vertically
|
||||
```
|
||||
|
||||
## LoginView Visual Design
|
||||
|
||||
### Default State
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 🔷 Sub2API │
|
||||
│ Subscription to API Conversion Platform │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ Welcome Back │ │
|
||||
│ │ Sign in to your account to continue│ │
|
||||
│ │ │ │
|
||||
│ │ Username │ │
|
||||
│ │ ┌────────────────────────────────┐ │ │
|
||||
│ │ │ Enter your username │ │ │
|
||||
│ │ └────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ Password │ │
|
||||
│ │ ┌────────────────────────────────┐ │ │
|
||||
│ │ │ •••••••••••••• │ │ │
|
||||
│ │ └────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ☐ Remember me │ │
|
||||
│ │ │ │
|
||||
│ │ ┌────────────────────────────────┐ │ │
|
||||
│ │ │ Sign In │ │ │
|
||||
│ │ └────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Don't have an account? Sign up │
|
||||
│ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Loading State
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ Username │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ john_doe │ │ │
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ Password │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ •••••••••••• │ │ │
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ☑ Remember me │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ ⟳ Signing in... │ │ ← Spinner
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ │ (Button disabled) │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Error State
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ Username │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ jo │ │ ← Red border
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ │ ⚠ Username must be at least 3 │ ← Red text
|
||||
│ │ characters │ │
|
||||
│ │ │ │
|
||||
│ │ Password │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ │ │ ← Red border
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ │ ⚠ Password is required │ ← Red text
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ ⚠ Invalid username or │ │ ← Error banner
|
||||
│ │ │ password. Please try │ │
|
||||
│ │ │ again. │ │
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ Sign In │ │ │
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## RegisterView Visual Design
|
||||
|
||||
### Default State
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 🔷 Sub2API │
|
||||
│ Subscription to API Conversion Platform │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ Create Account │ │
|
||||
│ │ Sign up to start using Sub2API │ │
|
||||
│ │ │ │
|
||||
│ │ Username │ │
|
||||
│ │ ┌────────────────────────────────┐ │ │
|
||||
│ │ │ Choose a username │ │ │
|
||||
│ │ └────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ Email │ │
|
||||
│ │ ┌────────────────────────────────┐ │ │
|
||||
│ │ │ your.email@example.com │ │ │
|
||||
│ │ └────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ Password │ │
|
||||
│ │ ┌────────────────────────────────┐ │ │
|
||||
│ │ │ Create a strong password │ │ │
|
||||
│ │ └────────────────────────────────┘ │ │
|
||||
│ │ At least 8 characters with letters │ │
|
||||
│ │ and numbers │ │
|
||||
│ │ │ │
|
||||
│ │ Confirm Password │ │
|
||||
│ │ ┌────────────────────────────────┐ │ │
|
||||
│ │ │ Confirm your password │ │ │
|
||||
│ │ └────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌────────────────────────────────┐ │ │
|
||||
│ │ │ Create Account │ │ │
|
||||
│ │ └────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ By signing up, you agree to our │ │
|
||||
│ │ Terms of Service and Privacy Policy│ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Already have an account? Sign in │
|
||||
│ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Validation Errors
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ Username │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ jane@smith │ │ ← Red border
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ │ ⚠ Username can only contain │ ← Red text
|
||||
│ │ letters, numbers, _, and - │ │
|
||||
│ │ │ │
|
||||
│ │ Email │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ invalid-email │ │ ← Red border
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ │ ⚠ Please enter a valid email │ ← Red text
|
||||
│ │ address │ │
|
||||
│ │ │ │
|
||||
│ │ Password │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ short │ │ ← Red border
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ │ ⚠ Password must be at least 8 │ ← Red text
|
||||
│ │ characters with letters │ │
|
||||
│ │ and numbers │ │
|
||||
│ │ │ │
|
||||
│ │ Confirm Password │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ different │ │ ← Red border
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ │ ⚠ Passwords do not match │ ← Red text
|
||||
│ │ │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Loading State
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ Username: jane_smith │ │
|
||||
│ │ Email: jane@example.com │ │
|
||||
│ │ Password: •••••••••••• │ │
|
||||
│ │ Confirm: •••••••••••• │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ ⟳ Creating account... │ │ ← Spinner
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ │ (All inputs disabled) │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Color Palette
|
||||
|
||||
### Primary Colors
|
||||
- **Indigo-600**: `#4F46E5` - Primary buttons, links, brand color
|
||||
- **Indigo-700**: `#4338CA` - Button hover state
|
||||
- **Indigo-500**: `#6366F1` - Focus ring
|
||||
|
||||
### Neutral Colors
|
||||
- **Gray-900**: `#111827` - Headings
|
||||
- **Gray-700**: `#374151` - Labels
|
||||
- **Gray-600**: `#4B5563` - Body text
|
||||
- **Gray-500**: `#6B7280` - Helper text
|
||||
- **Gray-300**: `#D1D5DB` - Borders
|
||||
- **Gray-100**: `#F3F4F6` - Disabled backgrounds
|
||||
- **White**: `#FFFFFF` - Card backgrounds
|
||||
|
||||
### Error Colors
|
||||
- **Red-600**: `#DC2626` - Error text
|
||||
- **Red-500**: `#EF4444` - Error border, focus ring
|
||||
- **Red-50**: `#FEF2F2` - Error banner background
|
||||
- **Red-200**: `#FECACA` - Error banner border
|
||||
|
||||
### Success Colors
|
||||
- **Green-600**: `#16A34A` - Success text
|
||||
- **Green-50**: `#F0FDF4` - Success banner background
|
||||
|
||||
### Background Gradient
|
||||
- **From**: Indigo-100 (`#E0E7FF`)
|
||||
- **Via**: White (`#FFFFFF`)
|
||||
- **To**: Purple-100 (`#F3E8FF`)
|
||||
|
||||
## Typography
|
||||
|
||||
### Font Family
|
||||
- **Default**: System font stack (`ui-sans-serif, system-ui, -apple-system, ...`)
|
||||
|
||||
### Font Sizes
|
||||
- **Headings (h2)**: `1.5rem` (24px), `font-bold`
|
||||
- **Body**: `0.875rem` (14px), `font-normal`
|
||||
- **Labels**: `0.875rem` (14px), `font-medium`
|
||||
- **Helper text**: `0.75rem` (12px), `font-normal`
|
||||
- **Error text**: `0.875rem` (14px), `font-normal`
|
||||
|
||||
### Line Heights
|
||||
- **Headings**: `1.5`
|
||||
- **Body**: `1.5`
|
||||
- **Helper text**: `1.25`
|
||||
|
||||
## Spacing
|
||||
|
||||
### Card Spacing
|
||||
- **Padding**: `2rem` (32px) all sides
|
||||
- **Gap between sections**: `1.5rem` (24px)
|
||||
- **Gap between fields**: `1rem` (16px)
|
||||
|
||||
### Input Spacing
|
||||
- **Padding**: `0.5rem 1rem` (8px 16px)
|
||||
- **Label margin-bottom**: `0.25rem` (4px)
|
||||
- **Error text margin-top**: `0.25rem` (4px)
|
||||
|
||||
### Button Spacing
|
||||
- **Padding**: `0.5rem 1rem` (8px 16px)
|
||||
- **Margin-top**: `1rem` (16px)
|
||||
|
||||
## Interactive States
|
||||
|
||||
### Input States
|
||||
|
||||
**Default:**
|
||||
```css
|
||||
border: 1px solid #D1D5DB (gray-300)
|
||||
focus: 2px ring #6366F1 (indigo-500)
|
||||
```
|
||||
|
||||
**Error:**
|
||||
```css
|
||||
border: 1px solid #EF4444 (red-500)
|
||||
focus: 2px ring #EF4444 (red-500)
|
||||
```
|
||||
|
||||
**Disabled:**
|
||||
```css
|
||||
background: #F3F4F6 (gray-100)
|
||||
cursor: not-allowed
|
||||
opacity: 0.6
|
||||
```
|
||||
|
||||
### Button States
|
||||
|
||||
**Default:**
|
||||
```css
|
||||
background: #4F46E5 (indigo-600)
|
||||
text: #FFFFFF (white)
|
||||
shadow: shadow-sm
|
||||
```
|
||||
|
||||
**Hover:**
|
||||
```css
|
||||
background: #4338CA (indigo-700)
|
||||
transition: colors 150ms
|
||||
```
|
||||
|
||||
**Focus:**
|
||||
```css
|
||||
outline: none
|
||||
ring: 2px offset-2 #6366F1 (indigo-500)
|
||||
```
|
||||
|
||||
**Disabled:**
|
||||
```css
|
||||
opacity: 0.5
|
||||
cursor: not-allowed
|
||||
```
|
||||
|
||||
**Loading:**
|
||||
```css
|
||||
opacity: 0.5
|
||||
cursor: not-allowed
|
||||
+ spinning icon
|
||||
```
|
||||
|
||||
### Link States
|
||||
|
||||
**Default:**
|
||||
```css
|
||||
color: #4F46E5 (indigo-600)
|
||||
font-weight: 500 (medium)
|
||||
```
|
||||
|
||||
**Hover:**
|
||||
```css
|
||||
color: #6366F1 (indigo-500)
|
||||
transition: colors 150ms
|
||||
```
|
||||
|
||||
## Responsive Design
|
||||
|
||||
### Breakpoints
|
||||
|
||||
**Mobile (< 640px):**
|
||||
```
|
||||
- Full width container
|
||||
- Padding: 1rem (16px)
|
||||
- Smaller text sizes
|
||||
```
|
||||
|
||||
**Tablet (640px - 768px):**
|
||||
```
|
||||
- Max width: 28rem (448px)
|
||||
- Centered layout
|
||||
- Standard spacing
|
||||
```
|
||||
|
||||
**Desktop (> 768px):**
|
||||
```
|
||||
- Max width: 28rem (448px)
|
||||
- Centered layout
|
||||
- Standard spacing
|
||||
```
|
||||
|
||||
### Mobile Optimizations
|
||||
|
||||
1. Touch-friendly tap targets (44px minimum)
|
||||
2. Proper keyboard handling on mobile
|
||||
3. Prevent zoom on input focus
|
||||
4. Responsive font sizes
|
||||
5. Full-width inputs
|
||||
6. Adequate spacing for thumbs
|
||||
|
||||
## Animations
|
||||
|
||||
### Transitions
|
||||
- Color changes: `150ms ease-in-out`
|
||||
- Opacity changes: `150ms ease-in-out`
|
||||
- Transform: `150ms ease-in-out`
|
||||
|
||||
### Loading Spinner
|
||||
```css
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
animation: spin 1s linear infinite;
|
||||
```
|
||||
|
||||
### Toast Animations
|
||||
- Enter: Slide in from right + fade in
|
||||
- Exit: Slide out to right + fade out
|
||||
- Duration: 300ms
|
||||
|
||||
## Accessibility Features
|
||||
|
||||
### Visual Indicators
|
||||
- Clear focus states (2px ring)
|
||||
- Error states (red border + red text)
|
||||
- Loading states (spinner + text)
|
||||
- Success states (green toast)
|
||||
|
||||
### Color Contrast
|
||||
- Text on white: > 7:1 (AAA)
|
||||
- Labels on white: > 4.5:1 (AA)
|
||||
- Buttons: > 4.5:1 (AA)
|
||||
- Error text: > 4.5:1 (AA)
|
||||
|
||||
### Interactive Elements
|
||||
- Minimum size: 44x44px (mobile)
|
||||
- Clear hover states
|
||||
- Distinct disabled states
|
||||
- Keyboard accessible
|
||||
|
||||
### Screen Reader Support
|
||||
- Proper labels on all inputs
|
||||
- ARIA attributes where needed
|
||||
- Error announcements
|
||||
- Loading state announcements
|
||||
|
||||
## Icons
|
||||
|
||||
### Loading Spinner
|
||||
```svg
|
||||
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
|
||||
</svg>
|
||||
```
|
||||
|
||||
### Error Icon
|
||||
```svg
|
||||
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
```
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
### Supported Browsers
|
||||
- Chrome/Edge: Latest 2 versions
|
||||
- Firefox: Latest 2 versions
|
||||
- Safari: Latest 2 versions
|
||||
- Mobile Safari: iOS 14+
|
||||
- Chrome Mobile: Latest 2 versions
|
||||
|
||||
### CSS Features Used
|
||||
- Flexbox (full support)
|
||||
- CSS Grid (full support)
|
||||
- CSS Transitions (full support)
|
||||
- CSS Custom Properties (full support)
|
||||
- Gradient backgrounds (full support)
|
||||
|
||||
### JavaScript Features Used
|
||||
- ES2015+ syntax
|
||||
- Async/await
|
||||
- Optional chaining
|
||||
- Nullish coalescing
|
||||
- Modules
|
||||
|
||||
## Print Styles
|
||||
|
||||
(Not applicable for authentication pages - users shouldn't print login forms)
|
||||
|
||||
## Dark Mode Considerations
|
||||
|
||||
**Future Enhancement:**
|
||||
- Dark mode toggle in user preferences
|
||||
- System preference detection
|
||||
- Persistent dark mode setting
|
||||
- Adjusted color palette for dark backgrounds
|
||||
|
||||
```css
|
||||
/* Example dark mode colors (not implemented yet) */
|
||||
dark:bg-gray-900
|
||||
dark:text-white
|
||||
dark:border-gray-700
|
||||
```
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
### Target Metrics
|
||||
- First Contentful Paint (FCP): < 1s
|
||||
- Largest Contentful Paint (LCP): < 2.5s
|
||||
- Time to Interactive (TTI): < 3s
|
||||
- Cumulative Layout Shift (CLS): < 0.1
|
||||
- First Input Delay (FID): < 100ms
|
||||
|
||||
### Optimization Strategies
|
||||
- Lazy load non-critical resources
|
||||
- Minimize initial bundle size
|
||||
- Use efficient animations (transform, opacity)
|
||||
- Optimize images (logo, icons)
|
||||
- Preconnect to API domain
|
||||
- Cache static assets
|
||||
|
||||
## Component Size
|
||||
|
||||
### Bundle Impact
|
||||
- LoginView.vue: ~4 KB (minified)
|
||||
- RegisterView.vue: ~6 KB (minified)
|
||||
- AuthLayout.vue: ~1 KB (minified)
|
||||
- Total: ~11 KB (excluding dependencies)
|
||||
|
||||
### Dependencies
|
||||
- Vue 3: ~40 KB (runtime)
|
||||
- Vue Router: ~15 KB
|
||||
- Pinia: ~10 KB
|
||||
- Total framework overhead: ~65 KB (gzipped)
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Visual Regression Tests
|
||||
- [ ] Default state (login)
|
||||
- [ ] Default state (register)
|
||||
- [ ] Loading state
|
||||
- [ ] Error state (validation)
|
||||
- [ ] Error state (API)
|
||||
- [ ] Success state
|
||||
- [ ] Mobile view
|
||||
- [ ] Tablet view
|
||||
- [ ] Desktop view
|
||||
- [ ] Focus states
|
||||
- [ ] Hover states
|
||||
|
||||
### Cross-browser Tests
|
||||
- [ ] Chrome (Windows, Mac, Linux)
|
||||
- [ ] Firefox (Windows, Mac, Linux)
|
||||
- [ ] Safari (Mac, iOS)
|
||||
- [ ] Edge (Windows)
|
||||
- [ ] Chrome Mobile (Android)
|
||||
- [ ] Safari Mobile (iOS)
|
||||
|
||||
### Accessibility Tests
|
||||
- [ ] Keyboard navigation
|
||||
- [ ] Screen reader (NVDA)
|
||||
- [ ] Screen reader (JAWS)
|
||||
- [ ] Screen reader (VoiceOver)
|
||||
- [ ] Color contrast
|
||||
- [ ] Focus indicators
|
||||
- [ ] Error announcements
|
||||
|
||||
## Design Assets
|
||||
|
||||
### Figma/Sketch Files
|
||||
(Not applicable - designed directly in code with Tailwind)
|
||||
|
||||
### Design Tokens
|
||||
- Defined in Tailwind config
|
||||
- Consistent with design system
|
||||
- Reusable across all components
|
||||
|
||||
### Iconography
|
||||
- SVG icons inline
|
||||
- Heroicons (outline and solid)
|
||||
- Consistent stroke width
|
||||
- Accessible with proper ARIA labels
|
||||
|
||||
---
|
||||
|
||||
**Note:** This visual guide is for reference and documentation purposes. The actual implementation is in the Vue components using TailwindCSS classes.
|
||||
7
frontend/src/views/auth/index.ts
Normal file
7
frontend/src/views/auth/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Authentication Views
|
||||
* Export all authentication-related views
|
||||
*/
|
||||
|
||||
export { default as LoginView } from './LoginView.vue';
|
||||
export { default as RegisterView } from './RegisterView.vue';
|
||||
400
frontend/src/views/setup/SetupWizardView.vue
Normal file
400
frontend/src/views/setup/SetupWizardView.vue
Normal file
@@ -0,0 +1,400 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-dark-900 dark:to-dark-800 flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-2xl">
|
||||
<!-- Logo & Title -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-primary-500 to-primary-600 shadow-lg mb-4">
|
||||
<svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Sub2API Setup</h1>
|
||||
<p class="mt-2 text-gray-500 dark:text-dark-400">Configure your Sub2API instance</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress Steps -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-center">
|
||||
<template v-for="(step, index) in steps" :key="step.id">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
:class="[
|
||||
'w-10 h-10 rounded-full flex items-center justify-center font-semibold text-sm transition-all',
|
||||
currentStep > index
|
||||
? 'bg-primary-500 text-white'
|
||||
: currentStep === index
|
||||
? 'bg-primary-500 text-white ring-4 ring-primary-100 dark:ring-primary-900'
|
||||
: 'bg-gray-200 dark:bg-dark-700 text-gray-500 dark:text-dark-400'
|
||||
]"
|
||||
>
|
||||
<svg v-if="currentStep > index" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
<span v-else>{{ index + 1 }}</span>
|
||||
</div>
|
||||
<span class="ml-2 text-sm font-medium" :class="currentStep >= index ? 'text-gray-900 dark:text-white' : 'text-gray-400 dark:text-dark-500'">
|
||||
{{ step.title }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="index < steps.length - 1" class="w-12 h-0.5 mx-3" :class="currentStep > index ? 'bg-primary-500' : 'bg-gray-200 dark:bg-dark-700'"></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step Content -->
|
||||
<div class="bg-white dark:bg-dark-800 rounded-2xl shadow-xl p-8">
|
||||
<!-- Step 1: Database -->
|
||||
<div v-if="currentStep === 0" class="space-y-6">
|
||||
<div class="text-center mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Database Configuration</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-dark-400 mt-1">Connect to your PostgreSQL database</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">Host</label>
|
||||
<input v-model="formData.database.host" type="text" class="input" placeholder="localhost" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">Port</label>
|
||||
<input v-model.number="formData.database.port" type="number" class="input" placeholder="5432" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">Username</label>
|
||||
<input v-model="formData.database.user" type="text" class="input" placeholder="postgres" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">Password</label>
|
||||
<input v-model="formData.database.password" type="password" class="input" placeholder="Password" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">Database Name</label>
|
||||
<input v-model="formData.database.dbname" type="text" class="input" placeholder="sub2api" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">SSL Mode</label>
|
||||
<select v-model="formData.database.sslmode" class="input">
|
||||
<option value="disable">Disable</option>
|
||||
<option value="require">Require</option>
|
||||
<option value="verify-ca">Verify CA</option>
|
||||
<option value="verify-full">Verify Full</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="testDatabaseConnection"
|
||||
:disabled="testingDb"
|
||||
class="btn btn-secondary w-full"
|
||||
>
|
||||
<svg v-if="testingDb" class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<svg v-else-if="dbConnected" class="w-5 h-5 mr-2 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
{{ testingDb ? 'Testing...' : dbConnected ? 'Connection Successful' : 'Test Connection' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Redis -->
|
||||
<div v-if="currentStep === 1" class="space-y-6">
|
||||
<div class="text-center mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Redis Configuration</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-dark-400 mt-1">Connect to your Redis server</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">Host</label>
|
||||
<input v-model="formData.redis.host" type="text" class="input" placeholder="localhost" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">Port</label>
|
||||
<input v-model.number="formData.redis.port" type="number" class="input" placeholder="6379" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">Password (optional)</label>
|
||||
<input v-model="formData.redis.password" type="password" class="input" placeholder="Password" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">Database</label>
|
||||
<input v-model.number="formData.redis.db" type="number" class="input" placeholder="0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="testRedisConnection"
|
||||
:disabled="testingRedis"
|
||||
class="btn btn-secondary w-full"
|
||||
>
|
||||
<svg v-if="testingRedis" class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<svg v-else-if="redisConnected" class="w-5 h-5 mr-2 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
{{ testingRedis ? 'Testing...' : redisConnected ? 'Connection Successful' : 'Test Connection' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Admin -->
|
||||
<div v-if="currentStep === 2" class="space-y-6">
|
||||
<div class="text-center mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Admin Account</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-dark-400 mt-1">Create your administrator account</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">Email</label>
|
||||
<input v-model="formData.admin.email" type="email" class="input" placeholder="admin@example.com" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">Password</label>
|
||||
<input v-model="formData.admin.password" type="password" class="input" placeholder="Min 6 characters" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">Confirm Password</label>
|
||||
<input v-model="confirmPassword" type="password" class="input" placeholder="Confirm password" />
|
||||
<p v-if="confirmPassword && formData.admin.password !== confirmPassword" class="input-error-text">
|
||||
Passwords do not match
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Complete -->
|
||||
<div v-if="currentStep === 3" class="space-y-6">
|
||||
<div class="text-center mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Ready to Install</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-dark-400 mt-1">Review your configuration and complete setup</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="p-4 bg-gray-50 dark:bg-dark-700 rounded-xl">
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-dark-400 mb-2">Database</h3>
|
||||
<p class="text-gray-900 dark:text-white">{{ formData.database.user }}@{{ formData.database.host }}:{{ formData.database.port }}/{{ formData.database.dbname }}</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-gray-50 dark:bg-dark-700 rounded-xl">
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-dark-400 mb-2">Redis</h3>
|
||||
<p class="text-gray-900 dark:text-white">{{ formData.redis.host }}:{{ formData.redis.port }}</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-gray-50 dark:bg-dark-700 rounded-xl">
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-dark-400 mb-2">Admin Email</h3>
|
||||
<p class="text-gray-900 dark:text-white">{{ formData.admin.email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="errorMessage" class="mt-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/50 rounded-xl">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-red-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
<p class="text-sm text-red-700 dark:text-red-400">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div v-if="installSuccess" class="mt-6 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800/50 rounded-xl">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-green-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-green-700 dark:text-green-400">Installation completed!</p>
|
||||
<p class="text-sm text-green-600 dark:text-green-500 mt-1">Please restart the service to apply changes.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Buttons -->
|
||||
<div class="mt-8 flex justify-between">
|
||||
<button
|
||||
v-if="currentStep > 0 && !installSuccess"
|
||||
@click="currentStep--"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
Previous
|
||||
</button>
|
||||
<div v-else></div>
|
||||
|
||||
<button
|
||||
v-if="currentStep < 3"
|
||||
@click="nextStep"
|
||||
:disabled="!canProceed"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
Next
|
||||
<svg class="w-4 h-4 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-else-if="!installSuccess"
|
||||
@click="performInstall"
|
||||
:disabled="installing"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg v-if="installing" class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ installing ? 'Installing...' : 'Complete Installation' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue';
|
||||
import { testDatabase, testRedis, install, type InstallRequest } from '@/api/setup';
|
||||
|
||||
const steps = [
|
||||
{ id: 'database', title: 'Database' },
|
||||
{ id: 'redis', title: 'Redis' },
|
||||
{ id: 'admin', title: 'Admin' },
|
||||
{ id: 'complete', title: 'Complete' },
|
||||
];
|
||||
|
||||
const currentStep = ref(0);
|
||||
const errorMessage = ref('');
|
||||
const installSuccess = ref(false);
|
||||
|
||||
// Connection test states
|
||||
const testingDb = ref(false);
|
||||
const testingRedis = ref(false);
|
||||
const dbConnected = ref(false);
|
||||
const redisConnected = ref(false);
|
||||
const installing = ref(false);
|
||||
const confirmPassword = ref('');
|
||||
|
||||
// Get current server port from browser location (set by install.sh)
|
||||
const getCurrentPort = (): number => {
|
||||
const port = window.location.port;
|
||||
if (port) {
|
||||
return parseInt(port, 10);
|
||||
}
|
||||
// Default port based on protocol
|
||||
return window.location.protocol === 'https:' ? 443 : 80;
|
||||
};
|
||||
|
||||
const formData = reactive<InstallRequest>({
|
||||
database: {
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
password: '',
|
||||
dbname: 'sub2api',
|
||||
sslmode: 'disable',
|
||||
},
|
||||
redis: {
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
password: '',
|
||||
db: 0,
|
||||
},
|
||||
admin: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: getCurrentPort(), // Use current port from browser
|
||||
mode: 'release',
|
||||
},
|
||||
});
|
||||
|
||||
const canProceed = computed(() => {
|
||||
switch (currentStep.value) {
|
||||
case 0:
|
||||
return dbConnected.value;
|
||||
case 1:
|
||||
return redisConnected.value;
|
||||
case 2:
|
||||
return (
|
||||
formData.admin.email &&
|
||||
formData.admin.password.length >= 6 &&
|
||||
formData.admin.password === confirmPassword.value
|
||||
);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
async function testDatabaseConnection() {
|
||||
testingDb.value = true;
|
||||
errorMessage.value = '';
|
||||
dbConnected.value = false;
|
||||
|
||||
try {
|
||||
await testDatabase(formData.database);
|
||||
dbConnected.value = true;
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string } }; message?: string };
|
||||
errorMessage.value = err.response?.data?.detail || err.message || 'Connection failed';
|
||||
} finally {
|
||||
testingDb.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testRedisConnection() {
|
||||
testingRedis.value = true;
|
||||
errorMessage.value = '';
|
||||
redisConnected.value = false;
|
||||
|
||||
try {
|
||||
await testRedis(formData.redis);
|
||||
redisConnected.value = true;
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string } }; message?: string };
|
||||
errorMessage.value = err.response?.data?.detail || err.message || 'Connection failed';
|
||||
} finally {
|
||||
testingRedis.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
if (canProceed.value) {
|
||||
errorMessage.value = '';
|
||||
currentStep.value++;
|
||||
}
|
||||
}
|
||||
|
||||
async function performInstall() {
|
||||
installing.value = true;
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
await install(formData);
|
||||
installSuccess.value = true;
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string } }; message?: string };
|
||||
errorMessage.value = err.response?.data?.detail || err.message || 'Installation failed';
|
||||
} finally {
|
||||
installing.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
738
frontend/src/views/user/DashboardView.vue
Normal file
738
frontend/src/views/user/DashboardView.vue
Normal file
@@ -0,0 +1,738 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
|
||||
<template v-else-if="stats">
|
||||
<!-- Row 1: Core Stats -->
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<!-- Balance -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<svg class="w-5 h-5 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.balance') }}</p>
|
||||
<p class="text-xl font-bold text-emerald-600 dark:text-emerald-400">${{ formatBalance(user?.balance || 0) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('common.available') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Keys -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/30">
|
||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.apiKeys') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ stats.total_api_keys }}</p>
|
||||
<p class="text-xs text-green-600 dark:text-green-400">{{ stats.active_api_keys }} {{ t('common.active') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today Requests -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-green-100 dark:bg-green-900/30">
|
||||
<svg class="w-5 h-5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.todayRequests') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ stats.today_requests }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('common.total') }}: {{ formatNumber(stats.total_requests) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today Cost -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-purple-100 dark:bg-purple-900/30">
|
||||
<svg class="w-5 h-5 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.todayCost') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
<span class="text-purple-600 dark:text-purple-400" :title="t('dashboard.actual')">${{ formatCost(stats.today_actual_cost) }}</span>
|
||||
<span class="text-sm font-normal text-gray-400 dark:text-gray-500" :title="t('dashboard.standard')"> / ${{ formatCost(stats.today_cost) }}</span>
|
||||
</p>
|
||||
<p class="text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('common.total') }}: </span>
|
||||
<span class="text-purple-600 dark:text-purple-400" :title="t('dashboard.actual')">${{ formatCost(stats.total_actual_cost) }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500" :title="t('dashboard.standard')"> / ${{ formatCost(stats.total_cost) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Token Stats -->
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<!-- Today Tokens -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-amber-100 dark:bg-amber-900/30">
|
||||
<svg class="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.todayTokens') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats.today_tokens) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('dashboard.input') }}: {{ formatTokens(stats.today_input_tokens) }} / {{ t('dashboard.output') }}: {{ formatTokens(stats.today_output_tokens) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Tokens -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-indigo-100 dark:bg-indigo-900/30">
|
||||
<svg class="w-5 h-5 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.totalTokens') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats.total_tokens) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('dashboard.input') }}: {{ formatTokens(stats.total_input_tokens) }} / {{ t('dashboard.output') }}: {{ formatTokens(stats.total_output_tokens) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cache Tokens -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-cyan-100 dark:bg-cyan-900/30">
|
||||
<svg class="w-5 h-5 text-cyan-600 dark:text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m.75 12l3 3m0 0l3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.cacheToday') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats.today_cache_read_tokens) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('common.create') }}: {{ formatTokens(stats.today_cache_creation_tokens) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Avg Response Time -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-rose-100 dark:bg-rose-900/30">
|
||||
<svg class="w-5 h-5 text-rose-600 dark:text-rose-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.avgResponse') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatDuration(stats.average_duration_ms) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('dashboard.averageTime') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Section -->
|
||||
<div class="space-y-6">
|
||||
<!-- Date Range Filter -->
|
||||
<div class="card p-4">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('dashboard.timeRange') }}:</span>
|
||||
<DateRangePicker
|
||||
v-model:start-date="startDate"
|
||||
v-model:end-date="endDate"
|
||||
@change="onDateRangeChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('dashboard.granularity') }}:</span>
|
||||
<div class="w-28">
|
||||
<Select
|
||||
v-model="granularity"
|
||||
:options="granularityOptions"
|
||||
@change="loadChartData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Grid -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<!-- Model Distribution Chart -->
|
||||
<div class="card p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('dashboard.modelDistribution') }}</h3>
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="w-48 h-48">
|
||||
<Doughnut v-if="modelChartData" :data="modelChartData" :options="doughnutOptions" />
|
||||
<div v-else class="flex items-center justify-center h-full text-gray-500 dark:text-gray-400 text-sm">
|
||||
{{ t('dashboard.noDataAvailable') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 max-h-48 overflow-y-auto">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="text-gray-500 dark:text-gray-400">
|
||||
<th class="text-left pb-2">{{ t('dashboard.model') }}</th>
|
||||
<th class="text-right pb-2">{{ t('dashboard.requests') }}</th>
|
||||
<th class="text-right pb-2">{{ t('dashboard.tokens') }}</th>
|
||||
<th class="text-right pb-2">{{ t('dashboard.actual') }}</th>
|
||||
<th class="text-right pb-2">{{ t('dashboard.standard') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="model in modelStats" :key="model.model" class="border-t border-gray-100 dark:border-gray-700">
|
||||
<td class="py-1.5 text-gray-900 dark:text-white font-medium truncate max-w-[100px]" :title="model.model">{{ model.model }}</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatNumber(model.requests) }}</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatTokens(model.total_tokens) }}</td>
|
||||
<td class="py-1.5 text-right text-green-600 dark:text-green-400">${{ formatCost(model.actual_cost) }}</td>
|
||||
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">${{ formatCost(model.cost) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token Usage Trend Chart -->
|
||||
<div class="card p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('dashboard.tokenUsageTrend') }}</h3>
|
||||
<div class="h-48">
|
||||
<Line v-if="trendChartData" :data="trendChartData" :options="lineOptions" />
|
||||
<div v-else class="flex items-center justify-center h-full text-gray-500 dark:text-gray-400 text-sm">
|
||||
{{ t('dashboard.noDataAvailable') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<!-- Recent Usage - Takes 2 columns -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="card">
|
||||
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('dashboard.recentUsage') }}</h2>
|
||||
<span class="badge badge-gray">{{ t('dashboard.last7Days') }}</span>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div v-if="loadingUsage" class="flex items-center justify-center py-12">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
<div v-else-if="recentUsage.length === 0" class="py-8">
|
||||
<EmptyState
|
||||
:title="t('dashboard.noUsageRecords')"
|
||||
:description="t('dashboard.startUsingApi')"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="log in recentUsage"
|
||||
:key="log.id"
|
||||
class="flex items-center justify-between p-4 rounded-xl bg-gray-50 dark:bg-dark-800/50 hover:bg-gray-100 dark:hover:bg-dark-800 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-10 h-10 rounded-xl bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-primary-600 dark:text-primary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ log.model }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ formatDate(log.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm font-semibold">
|
||||
<span class="text-green-600 dark:text-green-400" title="实际扣除">${{ formatCost(log.actual_cost) }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500 font-normal" title="标准计费"> / ${{ formatCost(log.total_cost) }}</span>
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ (log.input_tokens + log.output_tokens).toLocaleString() }} tokens
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<router-link
|
||||
to="/usage"
|
||||
class="flex items-center justify-center gap-2 py-3 text-sm font-medium text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 transition-colors"
|
||||
>
|
||||
{{ t('dashboard.viewAllUsage') }}
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions - Takes 1 column -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="card">
|
||||
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('dashboard.quickActions') }}</h2>
|
||||
</div>
|
||||
<div class="p-4 space-y-3">
|
||||
<button
|
||||
@click="navigateTo('/keys')"
|
||||
class="w-full flex items-center gap-4 p-4 rounded-xl bg-gray-50 dark:bg-dark-800/50 hover:bg-gray-100 dark:hover:bg-dark-800 transition-all duration-200 group text-left"
|
||||
>
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-xl bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center group-hover:scale-105 transition-transform">
|
||||
<svg class="w-6 h-6 text-primary-600 dark:text-primary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('dashboard.createApiKey') }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">{{ t('dashboard.generateNewKey') }}</p>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-gray-400 dark:text-dark-500 group-hover:text-primary-500 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="navigateTo('/usage')"
|
||||
class="w-full flex items-center gap-4 p-4 rounded-xl bg-gray-50 dark:bg-dark-800/50 hover:bg-gray-100 dark:hover:bg-dark-800 transition-all duration-200 group text-left"
|
||||
>
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-xl bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center group-hover:scale-105 transition-transform">
|
||||
<svg class="w-6 h-6 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('dashboard.viewUsage') }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">{{ t('dashboard.checkDetailedLogs') }}</p>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-gray-400 dark:text-dark-500 group-hover:text-emerald-500 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="navigateTo('/redeem')"
|
||||
class="w-full flex items-center gap-4 p-4 rounded-xl bg-gray-50 dark:bg-dark-800/50 hover:bg-gray-100 dark:hover:bg-dark-800 transition-all duration-200 group text-left"
|
||||
>
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-xl bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center group-hover:scale-105 transition-transform">
|
||||
<svg class="w-6 h-6 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('dashboard.redeemCode') }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">{{ t('dashboard.addBalanceWithCode') }}</p>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-gray-400 dark:text-dark-500 group-hover:text-amber-500 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
import { usageAPI, type UserDashboardStats } from '@/api/usage'
|
||||
import type { UsageLog, TrendDataPoint, ModelStat } from '@/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
} from 'chart.js'
|
||||
import { Line, Doughnut } from 'vue-chartjs'
|
||||
|
||||
// Register Chart.js components
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
)
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const user = computed(() => authStore.user)
|
||||
const stats = ref<UserDashboardStats | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadingUsage = ref(false)
|
||||
|
||||
// Chart data
|
||||
const trendData = ref<TrendDataPoint[]>([])
|
||||
const modelStats = ref<ModelStat[]>([])
|
||||
|
||||
// Recent usage
|
||||
const recentUsage = ref<UsageLog[]>([])
|
||||
|
||||
// Date range
|
||||
const granularity = ref<'day' | 'hour'>('day')
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
|
||||
// Granularity options for Select component
|
||||
const granularityOptions = computed(() => [
|
||||
{ value: 'day', label: t('dashboard.day') },
|
||||
{ value: 'hour', label: t('dashboard.hour') },
|
||||
])
|
||||
|
||||
// Dark mode detection
|
||||
const isDarkMode = computed(() => {
|
||||
return document.documentElement.classList.contains('dark')
|
||||
})
|
||||
|
||||
// Chart colors
|
||||
const chartColors = computed(() => ({
|
||||
text: isDarkMode.value ? '#e5e7eb' : '#374151',
|
||||
grid: isDarkMode.value ? '#374151' : '#e5e7eb',
|
||||
input: '#3b82f6',
|
||||
output: '#10b981',
|
||||
cache: '#f59e0b',
|
||||
}))
|
||||
|
||||
// Doughnut chart options
|
||||
const doughnutOptions = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
const value = context.raw as number
|
||||
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
|
||||
const percentage = ((value / total) * 100).toFixed(1)
|
||||
return `${context.label}: ${formatTokens(value)} (${percentage}%)`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Line chart options
|
||||
const lineOptions = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index' as const,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
labels: {
|
||||
color: chartColors.value.text,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
padding: 15,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
return `${context.dataset.label}: ${formatTokens(context.raw)}`
|
||||
},
|
||||
footer: (tooltipItems: any) => {
|
||||
const dataIndex = tooltipItems[0]?.dataIndex
|
||||
if (dataIndex !== undefined && trendData.value[dataIndex]) {
|
||||
const data = trendData.value[dataIndex]
|
||||
return `Actual: $${formatCost(data.actual_cost)} | Standard: $${formatCost(data.cost)}`
|
||||
}
|
||||
return ''
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: chartColors.value.grid,
|
||||
},
|
||||
ticks: {
|
||||
color: chartColors.value.text,
|
||||
font: {
|
||||
size: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: chartColors.value.grid,
|
||||
},
|
||||
ticks: {
|
||||
color: chartColors.value.text,
|
||||
font: {
|
||||
size: 10,
|
||||
},
|
||||
callback: (value: number) => formatTokens(value),
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Model chart data
|
||||
const modelChartData = computed(() => {
|
||||
if (!modelStats.value.length) return null
|
||||
|
||||
const colors = [
|
||||
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
|
||||
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16'
|
||||
]
|
||||
|
||||
return {
|
||||
labels: modelStats.value.map(m => m.model),
|
||||
datasets: [{
|
||||
data: modelStats.value.map(m => m.total_tokens),
|
||||
backgroundColor: colors.slice(0, modelStats.value.length),
|
||||
borderWidth: 0,
|
||||
}],
|
||||
}
|
||||
})
|
||||
|
||||
// Trend chart data
|
||||
const trendChartData = computed(() => {
|
||||
if (!trendData.value.length) return null
|
||||
|
||||
return {
|
||||
labels: trendData.value.map(d => d.date),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Input',
|
||||
data: trendData.value.map(d => d.input_tokens),
|
||||
borderColor: chartColors.value.input,
|
||||
backgroundColor: `${chartColors.value.input}20`,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
},
|
||||
{
|
||||
label: 'Output',
|
||||
data: trendData.value.map(d => d.output_tokens),
|
||||
borderColor: chartColors.value.output,
|
||||
backgroundColor: `${chartColors.value.output}20`,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
},
|
||||
{
|
||||
label: 'Cache',
|
||||
data: trendData.value.map(d => d.cache_tokens),
|
||||
borderColor: chartColors.value.cache,
|
||||
backgroundColor: `${chartColors.value.cache}20`,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
// Format helpers
|
||||
const formatTokens = (value: number): string => {
|
||||
if (value >= 1_000_000_000) {
|
||||
return `${(value / 1_000_000_000).toFixed(2)}B`
|
||||
} else if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(2)}M`
|
||||
} else if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(2)}K`
|
||||
}
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
const formatNumber = (value: number): string => {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
const formatBalance = (balance: number): string => {
|
||||
return balance.toFixed(2)
|
||||
}
|
||||
|
||||
const formatCost = (value: number): string => {
|
||||
if (value >= 1000) {
|
||||
return (value / 1000).toFixed(2) + 'K'
|
||||
} else if (value >= 1) {
|
||||
return value.toFixed(2)
|
||||
} else if (value >= 0.01) {
|
||||
return value.toFixed(3)
|
||||
}
|
||||
return value.toFixed(4)
|
||||
}
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
if (ms >= 1000) {
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
return `${Math.round(ms)}ms`
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const navigateTo = (path: string) => {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
// Date range change handler
|
||||
const onDateRangeChange = (range: { startDate: string; endDate: string; preset: string | null }) => {
|
||||
const start = new Date(range.startDate)
|
||||
const end = new Date(range.endDate)
|
||||
const daysDiff = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (daysDiff <= 1) {
|
||||
granularity.value = 'hour'
|
||||
} else {
|
||||
granularity.value = 'day'
|
||||
}
|
||||
|
||||
loadChartData()
|
||||
}
|
||||
|
||||
// Initialize default date range
|
||||
const initializeDateRange = () => {
|
||||
const now = new Date()
|
||||
const today = now.toISOString().split('T')[0]
|
||||
const weekAgo = new Date(now)
|
||||
weekAgo.setDate(weekAgo.getDate() - 6)
|
||||
|
||||
startDate.value = weekAgo.toISOString().split('T')[0]
|
||||
endDate.value = today
|
||||
granularity.value = 'day'
|
||||
}
|
||||
|
||||
// Load data
|
||||
const loadDashboardStats = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.refreshUser()
|
||||
stats.value = await usageAPI.getDashboardStats()
|
||||
} catch (error) {
|
||||
console.error('Error loading dashboard stats:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadChartData = async () => {
|
||||
try {
|
||||
const params = {
|
||||
start_date: startDate.value,
|
||||
end_date: endDate.value,
|
||||
granularity: granularity.value,
|
||||
}
|
||||
|
||||
const [trendResponse, modelResponse] = await Promise.all([
|
||||
usageAPI.getDashboardTrend(params),
|
||||
usageAPI.getDashboardModels({ start_date: startDate.value, end_date: endDate.value }),
|
||||
])
|
||||
|
||||
trendData.value = trendResponse.trend
|
||||
modelStats.value = modelResponse.models
|
||||
} catch (error) {
|
||||
console.error('Error loading chart data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadRecentUsage = async () => {
|
||||
loadingUsage.value = true
|
||||
try {
|
||||
const endDate = new Date().toISOString()
|
||||
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString()
|
||||
const usageResponse = await usageAPI.getByDateRange(startDate, endDate)
|
||||
recentUsage.value = usageResponse.items.slice(0, 5)
|
||||
} catch (error) {
|
||||
console.error('Failed to load recent usage:', error)
|
||||
} finally {
|
||||
loadingUsage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDashboardStats()
|
||||
initializeDateRange()
|
||||
loadChartData()
|
||||
loadRecentUsage()
|
||||
})
|
||||
|
||||
// Watch for dark mode changes
|
||||
watch(isDarkMode, () => {
|
||||
// Force chart re-render on theme change
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Compact Select styling for dashboard */
|
||||
:deep(.select-trigger) {
|
||||
@apply px-3 py-1.5 text-sm rounded-lg;
|
||||
}
|
||||
|
||||
:deep(.select-dropdown) {
|
||||
@apply rounded-lg;
|
||||
}
|
||||
|
||||
:deep(.select-option) {
|
||||
@apply px-3 py-2 text-sm;
|
||||
}
|
||||
</style>
|
||||
733
frontend/src/views/user/KeysView.vue
Normal file
733
frontend/src/views/user/KeysView.vue
Normal file
@@ -0,0 +1,733 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header Actions -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
{{ t('keys.createKey') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- API Keys Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="apiKeys"
|
||||
:loading="loading"
|
||||
>
|
||||
<template #cell-key="{ value, row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="code text-xs">
|
||||
{{ maskKey(value) }}
|
||||
</code>
|
||||
<button
|
||||
@click="copyToClipboard(value, row.id)"
|
||||
class="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
|
||||
:class="copiedKeyId === row.id ? 'text-green-500' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'"
|
||||
:title="copiedKeyId === row.id ? t('keys.copied') : t('keys.copyToClipboard')"
|
||||
>
|
||||
<svg v-if="copiedKeyId === row.id" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-name="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-group="{ row }">
|
||||
<div class="relative group/dropdown">
|
||||
<button
|
||||
:ref="(el) => setGroupButtonRef(row.id, el)"
|
||||
@click="openGroupSelector(row)"
|
||||
class="flex items-center gap-2 px-2 py-1 -mx-2 -my-1 rounded-lg hover:bg-gray-100 dark:hover:bg-dark-700 transition-all duration-200 cursor-pointer"
|
||||
:title="t('keys.clickToChangeGroup')"
|
||||
>
|
||||
<GroupBadge
|
||||
v-if="row.group"
|
||||
:name="row.group.name"
|
||||
:subscription-type="row.group.subscription_type"
|
||||
:rate-multiplier="row.group.rate_multiplier"
|
||||
/>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-dark-500">{{ t('keys.noGroup') }}</span>
|
||||
<svg class="w-3.5 h-3.5 text-gray-400 opacity-0 group-hover/dropdown:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-usage="{ row }">
|
||||
<div class="text-sm">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('keys.today') }}:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
${{ (usageStats[row.id]?.today_actual_cost ?? 0).toFixed(4) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 mt-0.5">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('keys.total') }}:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
${{ (usageStats[row.id]?.total_actual_cost ?? 0).toFixed(4) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
value === 'active'
|
||||
? 'badge-success'
|
||||
: 'badge-gray'
|
||||
]"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-created_at="{ value }">
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDate(value) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Use Key Button -->
|
||||
<button
|
||||
@click="openUseKeyModal(row)"
|
||||
class="p-2 rounded-lg hover:bg-green-50 dark:hover:bg-green-900/20 text-gray-500 hover:text-green-600 dark:hover:text-green-400 transition-colors"
|
||||
:title="t('keys.useKey')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Import to CC Switch Button -->
|
||||
<button
|
||||
@click="importToCcswitch(row.key)"
|
||||
class="p-2 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/20 text-gray-500 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
:title="t('keys.importToCcSwitch')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Toggle Status Button -->
|
||||
<button
|
||||
@click="toggleKeyStatus(row)"
|
||||
:class="[
|
||||
'p-2 rounded-lg transition-colors',
|
||||
row.status === 'active'
|
||||
? 'hover:bg-yellow-50 dark:hover:bg-yellow-900/20 text-gray-500 hover:text-yellow-600 dark:hover:text-yellow-400'
|
||||
: 'hover:bg-green-50 dark:hover:bg-green-900/20 text-gray-500 hover:text-green-600 dark:hover:text-green-400'
|
||||
]"
|
||||
:title="row.status === 'active' ? t('keys.disable') : t('keys.enable')"
|
||||
>
|
||||
<svg v-if="row.status === 'active'" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Edit Button -->
|
||||
<button
|
||||
@click="editKey(row)"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-dark-700 text-gray-500 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Delete Button -->
|
||||
<button
|
||||
@click="confirmDelete(row)"
|
||||
class="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-gray-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<EmptyState
|
||||
:title="t('keys.noKeysYet')"
|
||||
:description="t('keys.createFirstKey')"
|
||||
:action-text="t('keys.createKey')"
|
||||
@action="showCreateModal = true"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<Modal
|
||||
:show="showCreateModal || showEditModal"
|
||||
:title="showEditModal ? t('keys.editKey') : t('keys.createKey')"
|
||||
@close="closeModals"
|
||||
>
|
||||
<form @submit.prevent="handleSubmit" class="space-y-5">
|
||||
<div>
|
||||
<label class="input-label">{{ t('keys.nameLabel') }}</label>
|
||||
<input
|
||||
v-model="formData.name"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
:placeholder="t('keys.namePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('keys.groupLabel') }}</label>
|
||||
<Select
|
||||
v-model="formData.group_id"
|
||||
:options="groupOptions"
|
||||
:placeholder="t('keys.selectGroup')"
|
||||
>
|
||||
<template #selected="{ option }">
|
||||
<GroupBadge
|
||||
v-if="option"
|
||||
:name="option.label"
|
||||
:subscription-type="option.subscriptionType"
|
||||
:rate-multiplier="option.rate"
|
||||
/>
|
||||
<span v-else class="text-gray-400">{{ t('keys.selectGroup') }}</span>
|
||||
</template>
|
||||
<template #option="{ option }">
|
||||
<GroupBadge
|
||||
:name="option.label"
|
||||
:subscription-type="option.subscriptionType"
|
||||
:rate-multiplier="option.rate"
|
||||
/>
|
||||
</template>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Custom Key Section (only for create) -->
|
||||
<div v-if="!showEditModal" class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="input-label mb-0">{{ t('keys.customKeyLabel') }}</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="formData.use_custom_key = !formData.use_custom_key"
|
||||
:class="[
|
||||
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
|
||||
formData.use_custom_key ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
formData.use_custom_key ? 'translate-x-4' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="formData.use_custom_key">
|
||||
<input
|
||||
v-model="formData.custom_key"
|
||||
type="text"
|
||||
class="input font-mono"
|
||||
:placeholder="t('keys.customKeyPlaceholder')"
|
||||
:class="{ 'border-red-500 dark:border-red-500': customKeyError }"
|
||||
/>
|
||||
<p v-if="customKeyError" class="mt-1 text-sm text-red-500">{{ customKeyError }}</p>
|
||||
<p v-else class="input-hint">{{ t('keys.customKeyHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showEditModal">
|
||||
<label class="input-label">{{ t('keys.statusLabel') }}</label>
|
||||
<Select
|
||||
v-model="formData.status"
|
||||
:options="statusOptions"
|
||||
:placeholder="t('keys.selectStatus')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
@click="closeModals"
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg
|
||||
v-if="submitting"
|
||||
class="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ submitting ? t('keys.saving') : (showEditModal ? t('common.update') : t('common.create')) }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
:show="showDeleteDialog"
|
||||
:title="t('keys.deleteKey')"
|
||||
:message="t('keys.deleteConfirmMessage', { name: selectedKey?.name })"
|
||||
:confirm-text="t('common.delete')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
:danger="true"
|
||||
@confirm="handleDelete"
|
||||
@cancel="showDeleteDialog = false"
|
||||
/>
|
||||
|
||||
<!-- Use Key Modal -->
|
||||
<UseKeyModal
|
||||
:show="showUseKeyModal"
|
||||
:api-key="selectedKey?.key || ''"
|
||||
:base-url="publicSettings?.api_base_url || ''"
|
||||
@close="closeUseKeyModal"
|
||||
/>
|
||||
|
||||
<!-- Group Selector Dropdown (Teleported to body to avoid overflow clipping) -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="groupSelectorKeyId !== null && dropdownPosition"
|
||||
ref="dropdownRef"
|
||||
class="fixed z-[9999] w-64 rounded-xl bg-white dark:bg-dark-800 shadow-lg ring-1 ring-black/5 dark:ring-white/10 overflow-hidden animate-in fade-in slide-in-from-top-2 duration-200"
|
||||
:style="{ top: dropdownPosition.top + 'px', left: dropdownPosition.left + 'px' }"
|
||||
>
|
||||
<div class="p-1.5 max-h-64 overflow-y-auto">
|
||||
<button
|
||||
v-for="option in groupOptions"
|
||||
:key="option.value ?? 'null'"
|
||||
@click="changeGroup(selectedKeyForGroup!, option.value)"
|
||||
:class="[
|
||||
'w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors',
|
||||
(selectedKeyForGroup?.group_id === option.value || (!selectedKeyForGroup?.group_id && option.value === null))
|
||||
? 'bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-dark-700'
|
||||
]"
|
||||
>
|
||||
<GroupBadge
|
||||
:name="option.label"
|
||||
:subscription-type="option.subscriptionType"
|
||||
:rate-multiplier="option.rate"
|
||||
/>
|
||||
<svg
|
||||
v-if="selectedKeyForGroup?.group_id === option.value || (!selectedKeyForGroup?.group_id && option.value === null)"
|
||||
class="w-4 h-4 text-primary-600 dark:text-primary-400 shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const { t } = useI18n()
|
||||
import { keysAPI, authAPI, usageAPI, userGroupsAPI } from '@/api'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import UseKeyModal from '@/components/keys/UseKeyModal.vue'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
import type { ApiKey, Group, PublicSettings, SubscriptionType } from '@/types'
|
||||
import type { Column } from '@/components/common/DataTable.vue'
|
||||
import type { BatchApiKeyUsageStats } from '@/api/usage'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'name', label: t('common.name'), sortable: true },
|
||||
{ key: 'key', label: t('keys.apiKey'), sortable: false },
|
||||
{ key: 'group', label: t('keys.group'), sortable: false },
|
||||
{ key: 'usage', label: t('keys.usage'), sortable: false },
|
||||
{ key: 'status', label: t('common.status'), sortable: true },
|
||||
{ key: 'created_at', label: t('keys.created'), sortable: true },
|
||||
{ key: 'actions', label: t('common.actions'), sortable: false }
|
||||
])
|
||||
|
||||
const apiKeys = ref<ApiKey[]>([])
|
||||
const groups = ref<Group[]>([])
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const usageStats = ref<Record<string, BatchApiKeyUsageStats>>({})
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const showUseKeyModal = ref(false)
|
||||
const selectedKey = ref<ApiKey | null>(null)
|
||||
const copiedKeyId = ref<number | null>(null)
|
||||
const groupSelectorKeyId = ref<number | null>(null)
|
||||
const publicSettings = ref<PublicSettings | null>(null)
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
const dropdownPosition = ref<{ top: number; left: number } | null>(null)
|
||||
const groupButtonRefs = ref<Map<number, HTMLElement>>(new Map())
|
||||
|
||||
// Get the currently selected key for group change
|
||||
const selectedKeyForGroup = computed(() => {
|
||||
if (groupSelectorKeyId.value === null) return null
|
||||
return apiKeys.value.find(k => k.id === groupSelectorKeyId.value) || null
|
||||
})
|
||||
|
||||
const setGroupButtonRef = (keyId: number, el: HTMLElement | null) => {
|
||||
if (el) {
|
||||
groupButtonRefs.value.set(keyId, el)
|
||||
} else {
|
||||
groupButtonRefs.value.delete(keyId)
|
||||
}
|
||||
}
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
group_id: null as number | null,
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
use_custom_key: false,
|
||||
custom_key: ''
|
||||
})
|
||||
|
||||
// 自定义Key验证
|
||||
const customKeyError = computed(() => {
|
||||
if (!formData.value.use_custom_key || !formData.value.custom_key) {
|
||||
return ''
|
||||
}
|
||||
const key = formData.value.custom_key
|
||||
if (key.length < 16) {
|
||||
return t('keys.customKeyTooShort')
|
||||
}
|
||||
// 检查字符:只允许字母、数字、下划线、连字符
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(key)) {
|
||||
return t('keys.customKeyInvalidChars')
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ value: 'active', label: t('common.active') },
|
||||
{ value: 'inactive', label: t('common.inactive') }
|
||||
])
|
||||
|
||||
// Convert groups to Select options format with rate multiplier and subscription type
|
||||
const groupOptions = computed(() =>
|
||||
groups.value.map(group => ({
|
||||
value: group.id,
|
||||
label: group.name,
|
||||
rate: group.rate_multiplier,
|
||||
subscriptionType: group.subscription_type
|
||||
}))
|
||||
)
|
||||
|
||||
const maskKey = (key: string): string => {
|
||||
if (key.length <= 12) return key
|
||||
return `${key.slice(0, 8)}...${key.slice(-4)}`
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string, keyId: number) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copiedKeyId.value = keyId
|
||||
setTimeout(() => {
|
||||
copiedKeyId.value = null
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
appStore.showError(t('common.copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const loadApiKeys = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await keysAPI.list(pagination.value.page, pagination.value.page_size)
|
||||
apiKeys.value = response.items
|
||||
pagination.value.total = response.total
|
||||
pagination.value.pages = response.pages
|
||||
|
||||
// Load usage stats for all API keys in the list
|
||||
if (response.items.length > 0) {
|
||||
const keyIds = response.items.map(k => k.id)
|
||||
try {
|
||||
const usageResponse = await usageAPI.getDashboardApiKeysUsage(keyIds)
|
||||
usageStats.value = usageResponse.stats
|
||||
} catch (e) {
|
||||
console.error('Failed to load usage stats:', e)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
appStore.showError(t('keys.failedToLoad'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadGroups = async () => {
|
||||
try {
|
||||
groups.value = await userGroupsAPI.getAvailable()
|
||||
} catch (error) {
|
||||
console.error('Failed to load groups:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadPublicSettings = async () => {
|
||||
try {
|
||||
publicSettings.value = await authAPI.getPublicSettings()
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const openUseKeyModal = (key: ApiKey) => {
|
||||
selectedKey.value = key
|
||||
showUseKeyModal.value = true
|
||||
}
|
||||
|
||||
const closeUseKeyModal = () => {
|
||||
showUseKeyModal.value = false
|
||||
selectedKey.value = null
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.value.page = page
|
||||
loadApiKeys()
|
||||
}
|
||||
|
||||
const editKey = (key: ApiKey) => {
|
||||
selectedKey.value = key
|
||||
formData.value = {
|
||||
name: key.name,
|
||||
group_id: key.group_id,
|
||||
status: key.status
|
||||
}
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
const toggleKeyStatus = async (key: ApiKey) => {
|
||||
const newStatus = key.status === 'active' ? 'inactive' : 'active'
|
||||
try {
|
||||
await keysAPI.toggleStatus(key.id, newStatus)
|
||||
appStore.showSuccess(newStatus === 'active' ? t('keys.keyEnabledSuccess') : t('keys.keyDisabledSuccess'))
|
||||
loadApiKeys()
|
||||
} catch (error) {
|
||||
appStore.showError(t('keys.failedToUpdateStatus'))
|
||||
}
|
||||
}
|
||||
|
||||
const openGroupSelector = (key: ApiKey) => {
|
||||
if (groupSelectorKeyId.value === key.id) {
|
||||
groupSelectorKeyId.value = null
|
||||
dropdownPosition.value = null
|
||||
} else {
|
||||
const buttonEl = groupButtonRefs.value.get(key.id)
|
||||
if (buttonEl) {
|
||||
const rect = buttonEl.getBoundingClientRect()
|
||||
dropdownPosition.value = {
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left
|
||||
}
|
||||
}
|
||||
groupSelectorKeyId.value = key.id
|
||||
}
|
||||
}
|
||||
|
||||
const changeGroup = async (key: ApiKey, newGroupId: number | null) => {
|
||||
groupSelectorKeyId.value = null
|
||||
dropdownPosition.value = null
|
||||
if (key.group_id === newGroupId) return
|
||||
|
||||
try {
|
||||
await keysAPI.update(key.id, { group_id: newGroupId })
|
||||
appStore.showSuccess(t('keys.groupChangedSuccess'))
|
||||
loadApiKeys()
|
||||
} catch (error) {
|
||||
appStore.showError(t('keys.failedToChangeGroup'))
|
||||
}
|
||||
}
|
||||
|
||||
const closeGroupSelector = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
// Check if click is inside the dropdown or the trigger button
|
||||
if (!target.closest('.group\\/dropdown') && !dropdownRef.value?.contains(target)) {
|
||||
groupSelectorKeyId.value = null
|
||||
dropdownPosition.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = (key: ApiKey) => {
|
||||
selectedKey.value = key
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Validate group_id is required
|
||||
if (formData.value.group_id === null) {
|
||||
appStore.showError(t('keys.groupRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate custom key if enabled
|
||||
if (!showEditModal.value && formData.value.use_custom_key) {
|
||||
if (!formData.value.custom_key) {
|
||||
appStore.showError(t('keys.customKeyRequired'))
|
||||
return
|
||||
}
|
||||
if (customKeyError.value) {
|
||||
appStore.showError(customKeyError.value)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (showEditModal.value && selectedKey.value) {
|
||||
await keysAPI.update(selectedKey.value.id, formData.value)
|
||||
appStore.showSuccess(t('keys.keyUpdatedSuccess'))
|
||||
} else {
|
||||
const customKey = formData.value.use_custom_key ? formData.value.custom_key : undefined
|
||||
await keysAPI.create(formData.value.name, formData.value.group_id, customKey)
|
||||
appStore.showSuccess(t('keys.keyCreatedSuccess'))
|
||||
}
|
||||
closeModals()
|
||||
loadApiKeys()
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.response?.data?.detail || t('keys.failedToSave')
|
||||
appStore.showError(errorMsg)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedKey.value) return
|
||||
|
||||
try {
|
||||
await keysAPI.delete(selectedKey.value.id)
|
||||
appStore.showSuccess(t('keys.keyDeletedSuccess'))
|
||||
showDeleteDialog.value = false
|
||||
loadApiKeys()
|
||||
} catch (error) {
|
||||
appStore.showError(t('keys.failedToDelete'))
|
||||
}
|
||||
}
|
||||
|
||||
const closeModals = () => {
|
||||
showCreateModal.value = false
|
||||
showEditModal.value = false
|
||||
selectedKey.value = null
|
||||
formData.value = {
|
||||
name: '',
|
||||
group_id: null,
|
||||
status: 'active',
|
||||
use_custom_key: false,
|
||||
custom_key: ''
|
||||
}
|
||||
}
|
||||
|
||||
const importToCcswitch = (apiKey: string) => {
|
||||
const baseUrl = publicSettings.value?.api_base_url || window.location.origin
|
||||
const usageScript = `({
|
||||
request: {
|
||||
url: "{{baseUrl}}/v1/usage",
|
||||
method: "GET",
|
||||
headers: { "Authorization": "Bearer {{apiKey}}" }
|
||||
},
|
||||
extractor: function(response) {
|
||||
return {
|
||||
isValid: response.is_active || true,
|
||||
remaining: response.balance,
|
||||
unit: "USD"
|
||||
};
|
||||
}
|
||||
})`
|
||||
const params = new URLSearchParams({
|
||||
resource: 'provider',
|
||||
app: 'claude',
|
||||
name: 'sub2api',
|
||||
homepage: baseUrl,
|
||||
endpoint: baseUrl,
|
||||
apiKey: apiKey,
|
||||
configFormat: 'json',
|
||||
usageEnabled: 'true',
|
||||
usageScript: btoa(usageScript),
|
||||
usageAutoInterval: '30'
|
||||
})
|
||||
const deeplink = `ccswitch://v1/import?${params.toString()}`
|
||||
window.open(deeplink, '_self')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadApiKeys()
|
||||
loadGroups()
|
||||
loadPublicSettings()
|
||||
document.addEventListener('click', closeGroupSelector)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeGroupSelector)
|
||||
})
|
||||
</script>
|
||||
253
frontend/src/views/user/ProfileView.vue
Normal file
253
frontend/src/views/user/ProfileView.vue
Normal file
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<!-- Account Stats Summary -->
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-3">
|
||||
<StatCard
|
||||
:title="t('profile.accountBalance')"
|
||||
:value="formatCurrency(user?.balance || 0)"
|
||||
:icon="WalletIcon"
|
||||
icon-variant="success"
|
||||
/>
|
||||
<StatCard
|
||||
:title="t('profile.concurrencyLimit')"
|
||||
:value="user?.concurrency || 0"
|
||||
:icon="BoltIcon"
|
||||
icon-variant="warning"
|
||||
/>
|
||||
<StatCard
|
||||
:title="t('profile.memberSince')"
|
||||
:value="formatMemberSince(user?.created_at || '')"
|
||||
:icon="CalendarIcon"
|
||||
icon-variant="primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- User Information -->
|
||||
<div class="card overflow-hidden">
|
||||
<div class="px-6 py-5 bg-gradient-to-r from-primary-500/10 to-primary-600/5 dark:from-primary-500/20 dark:to-primary-600/10 border-b border-gray-100 dark:border-dark-700">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Avatar -->
|
||||
<div class="w-16 h-16 rounded-2xl bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center text-white text-2xl font-bold shadow-lg shadow-primary-500/20">
|
||||
{{ user?.email?.charAt(0).toUpperCase() || 'U' }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white truncate">{{ user?.email }}</h2>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
user?.role === 'admin' ? 'badge-primary' : 'badge-gray'
|
||||
]"
|
||||
>
|
||||
{{ user?.role === 'admin' ? t('profile.administrator') : t('profile.user') }}
|
||||
</span>
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
user?.status === 'active' ? 'badge-success' : 'badge-danger'
|
||||
]"
|
||||
>
|
||||
{{ user?.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<svg class="w-4 h-4 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
<span class="truncate">{{ user?.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Support Section -->
|
||||
<div v-if="contactInfo" class="card border-primary-200 dark:border-primary-800/40 bg-gradient-to-r from-primary-50 to-primary-100/50 dark:from-primary-900/20 dark:to-primary-800/10">
|
||||
<div class="px-6 py-5">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-xl bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-primary-600 dark:text-primary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-sm font-semibold text-primary-800 dark:text-primary-200">{{ t('common.contactSupport') }}</h3>
|
||||
<p class="mt-1 text-sm font-medium text-primary-600 dark:text-primary-300">{{ contactInfo }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password Section -->
|
||||
<div class="card">
|
||||
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700">
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-white">{{ t('profile.changePassword') }}</h2>
|
||||
</div>
|
||||
<div class="px-6 py-6">
|
||||
<form @submit.prevent="handleChangePassword" class="space-y-4">
|
||||
<div>
|
||||
<label for="old_password" class="input-label">
|
||||
{{ t('profile.currentPassword') }}
|
||||
</label>
|
||||
<input
|
||||
id="old_password"
|
||||
v-model="passwordForm.old_password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="new_password" class="input-label">
|
||||
{{ t('profile.newPassword') }}
|
||||
</label>
|
||||
<input
|
||||
id="new_password"
|
||||
v-model="passwordForm.new_password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="input"
|
||||
/>
|
||||
<p class="input-hint">
|
||||
{{ t('profile.passwordHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirm_password" class="input-label">
|
||||
{{ t('profile.confirmNewPassword') }}
|
||||
</label>
|
||||
<input
|
||||
id="confirm_password"
|
||||
v-model="passwordForm.confirm_password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="changingPassword"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{{ changingPassword ? t('profile.changingPassword') : t('profile.changePasswordButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const { t } = useI18n()
|
||||
import { userAPI, authAPI } from '@/api'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import StatCard from '@/components/common/StatCard.vue'
|
||||
|
||||
// SVG Icon Components
|
||||
const WalletIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3' })
|
||||
])
|
||||
}
|
||||
|
||||
const BoltIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'm3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z' })
|
||||
])
|
||||
}
|
||||
|
||||
const CalendarIcon = {
|
||||
render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5' })
|
||||
])
|
||||
}
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const user = computed(() => authStore.user)
|
||||
|
||||
const passwordForm = ref({
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
})
|
||||
|
||||
const changingPassword = ref(false)
|
||||
const contactInfo = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const settings = await authAPI.getPublicSettings()
|
||||
contactInfo.value = settings.contact_info || ''
|
||||
} catch (error) {
|
||||
console.error('Failed to load contact info:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const formatCurrency = (value: number): string => {
|
||||
return `$${value.toFixed(2)}`
|
||||
}
|
||||
|
||||
const formatMemberSince = (dateString: string): string => {
|
||||
if (!dateString) return 'N/A'
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short'
|
||||
})
|
||||
}
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
// Validate password match
|
||||
if (passwordForm.value.new_password !== passwordForm.value.confirm_password) {
|
||||
appStore.showError(t('profile.passwordsNotMatch'))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (passwordForm.value.new_password.length < 8) {
|
||||
appStore.showError(t('profile.passwordTooShort'))
|
||||
return
|
||||
}
|
||||
|
||||
changingPassword.value = true
|
||||
try {
|
||||
await userAPI.changePassword(
|
||||
passwordForm.value.old_password,
|
||||
passwordForm.value.new_password
|
||||
)
|
||||
|
||||
// Clear form
|
||||
passwordForm.value = {
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
}
|
||||
|
||||
appStore.showSuccess(t('profile.passwordChangeSuccess'))
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('profile.passwordChangeFailed'))
|
||||
} finally {
|
||||
changingPassword.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user