Commit 429f38d0 authored by shaw's avatar shaw
Browse files

Merge PR #37: Add Gemini OAuth and Messages Compat Support

parents 2d89f366 2714be99
......@@ -55,7 +55,10 @@ export function useOpenAIOAuth() {
payload.redirect_uri = redirectUri
}
const response = await adminAPI.accounts.generateAuthUrl('/admin/openai/generate-auth-url', payload)
const response = await adminAPI.accounts.generateAuthUrl(
'/admin/openai/generate-auth-url',
payload
)
authUrl.value = response.auth_url
sessionId.value = response.session_id
return true
......
......@@ -26,8 +26,8 @@ export const i18n = createI18n({
fallbackLocale: 'en',
messages: {
en,
zh,
},
zh
}
})
export function setLocale(locale: string) {
......@@ -44,7 +44,7 @@ export function getLocale(): string {
export const availableLocales = [
{ code: 'en', name: 'English', flag: '🇺🇸' },
{ code: 'zh', name: '中文', flag: '🇨🇳' },
{ code: 'zh', name: '中文', flag: '🇨🇳' }
]
export default i18n
......@@ -13,7 +13,7 @@ export default {
tags: {
subscriptionToApi: 'Subscription to API',
stickySession: 'Sticky Session',
realtimeBilling: 'Real-time Billing',
realtimeBilling: 'Real-time Billing'
},
features: {
unifiedGateway: 'Unified API Gateway',
......@@ -24,17 +24,17 @@ export default {
'Manage multiple upstream accounts with smart load balancing. Support OAuth and API Key authentication.',
balanceQuota: 'Balance & Quota',
balanceQuotaDesc:
'Token-based billing with precise usage tracking. Manage quotas and recharge with redeem codes.',
'Token-based billing with precise usage tracking. Manage quotas and recharge with redeem codes.'
},
providers: {
title: 'Supported Providers',
description: 'Unified API interface for AI services',
supported: 'Supported',
soon: 'Soon',
soon: 'Soon'
},
footer: {
allRightsReserved: 'All rights reserved.',
},
allRightsReserved: 'All rights reserved.'
}
},
// Common
......@@ -84,7 +84,7 @@ export default {
searchPlaceholder: 'Search...',
noOptionsFound: 'No options found',
saving: 'Saving...',
refresh: 'Refresh',
refresh: 'Refresh'
},
// Navigation
......@@ -108,7 +108,7 @@ export default {
expand: 'Expand',
logout: 'Logout',
github: 'GitHub',
mySubscriptions: 'My Subscriptions',
mySubscriptions: 'My Subscriptions'
},
// Auth
......@@ -142,7 +142,7 @@ export default {
accountCreatedSuccess: 'Account created successfully! Welcome to {siteName}.',
turnstileExpired: 'Verification expired, please try again',
turnstileFailed: 'Verification failed, please try again',
completeVerification: 'Please complete the verification',
completeVerification: 'Please complete the verification'
},
// Dashboard
......@@ -185,12 +185,12 @@ export default {
viewUsage: 'View Usage',
checkDetailedLogs: 'Check detailed usage logs',
redeemCode: 'Redeem Code',
addBalanceWithCode: 'Add balance with a code',
addBalanceWithCode: 'Add balance with a code'
},
// Groups (shared)
groups: {
subscription: 'Sub',
subscription: 'Sub'
},
// API Keys
......@@ -257,7 +257,7 @@ export default {
customKeyHint: 'Only letters, numbers, underscores and hyphens allowed. Minimum 16 characters.',
customKeyTooShort: 'Custom key must be at least 16 characters',
customKeyInvalidChars: 'Custom key can only contain letters, numbers, underscores, and hyphens',
customKeyRequired: 'Please enter a custom key',
customKeyRequired: 'Please enter a custom key'
},
// Usage
......@@ -298,7 +298,7 @@ export default {
exportSuccess: 'Usage data exported successfully',
billingType: 'Billing',
balance: 'Balance',
subscription: 'Subscription',
subscription: 'Subscription'
},
// Redeem
......@@ -338,7 +338,7 @@ export default {
subscriptionDays: '{days} days',
days: ' days',
codeRedeemSuccess: 'Code redeemed successfully!',
failedToRedeem: 'Failed to redeem code. Please check the code and try again.',
failedToRedeem: 'Failed to redeem code. Please check the code and try again.'
},
// Profile
......@@ -369,12 +369,12 @@ export default {
passwordsNotMatch: 'New passwords do not match',
passwordTooShort: 'Password must be at least 8 characters long',
passwordChangeSuccess: 'Password changed successfully',
passwordChangeFailed: 'Failed to change password',
passwordChangeFailed: 'Failed to change password'
},
// Empty States
empty: {
noData: 'No data found',
noData: 'No data found'
},
// Pagination
......@@ -388,7 +388,7 @@ export default {
previous: 'Previous',
next: 'Next',
perPage: 'Per page',
goToPage: 'Go to page {page}',
goToPage: 'Go to page {page}'
},
// Errors
......@@ -400,7 +400,7 @@ export default {
serverError: 'Server error',
networkError: 'Network error',
timeout: 'Request timeout',
tryAgain: 'Please try again',
tryAgain: 'Please try again'
},
// Dates
......@@ -418,7 +418,7 @@ export default {
startDate: 'Start Date',
endDate: 'End Date',
apply: 'Apply',
selectDateRange: 'Select date range',
selectDateRange: 'Select date range'
},
// Admin
......@@ -455,7 +455,7 @@ export default {
actual: 'Actual',
standard: 'Standard',
noDataAvailable: 'No data available',
failedToLoad: 'Failed to load dashboard statistics',
failedToLoad: 'Failed to load dashboard statistics'
},
// Users
......@@ -500,7 +500,7 @@ export default {
concurrency: 'Concurrency',
status: 'Status',
created: 'Created',
actions: 'Actions',
actions: 'Actions'
},
today: 'Today',
total: 'Total',
......@@ -558,7 +558,7 @@ export default {
failedToDeposit: 'Failed to deposit',
failedToWithdraw: 'Failed to withdraw',
useDepositWithdrawButtons: 'Please use deposit/withdraw buttons to adjust balance',
insufficientBalance: 'Insufficient balance, balance cannot be negative after withdrawal',
insufficientBalance: 'Insufficient balance, balance cannot be negative after withdrawal'
},
// Groups
......@@ -582,7 +582,7 @@ export default {
accounts: 'Accounts',
status: 'Status',
actions: 'Actions',
billingType: 'Billing Type',
billingType: 'Billing Type'
},
accountsCount: '{count} accounts',
form: {
......@@ -590,7 +590,7 @@ export default {
description: 'Description',
platform: 'Platform',
rateMultiplier: 'Rate Multiplier',
status: 'Status',
status: 'Status'
},
enterGroupName: 'Enter group name',
optionalDescription: 'Optional description',
......@@ -629,8 +629,8 @@ export default {
monthlyLimit: 'Monthly Limit (USD)',
defaultValidityDays: 'Default Validity (Days)',
validityHint: 'Number of days the subscription is valid when assigned to a user',
noLimit: 'No limit',
},
noLimit: 'No limit'
}
},
// Subscriptions
......@@ -656,7 +656,7 @@ export default {
status: {
active: 'Active',
expired: 'Expired',
revoked: 'Revoked',
revoked: 'Revoked'
},
columns: {
user: 'User',
......@@ -664,13 +664,13 @@ export default {
usage: 'Usage',
expires: 'Expires',
status: 'Status',
actions: 'Actions',
actions: 'Actions'
},
form: {
user: 'User',
group: 'Subscription Group',
validityDays: 'Validity (Days)',
extendDays: 'Extend by (Days)',
extendDays: 'Extend by (Days)'
},
selectUser: 'Select a user',
selectGroup: 'Select a subscription group',
......@@ -693,7 +693,7 @@ export default {
failedToExtend: 'Failed to extend subscription',
failedToRevoke: 'Failed to revoke subscription',
revokeConfirm:
"Are you sure you want to revoke the subscription for '{user}'? This action cannot be undone.",
"Are you sure you want to revoke the subscription for '{user}'? This action cannot be undone."
},
// Accounts
......@@ -740,6 +740,7 @@ export default {
anthropic: 'Anthropic',
claude: 'Claude',
openai: 'OpenAI',
gemini: 'Gemini'
},
columns: {
name: 'Name',
......@@ -754,7 +755,7 @@ export default {
usageWindows: 'Usage Windows',
priority: 'Priority',
lastUsed: 'Last Used',
actions: 'Actions',
actions: 'Actions'
},
clearRateLimit: 'Clear Rate Limit',
testConnection: 'Test Connection',
......@@ -770,7 +771,7 @@ export default {
selectCurrentPage: 'Select this page',
clear: 'Clear selection',
edit: 'Bulk Edit',
delete: 'Bulk Delete',
delete: 'Bulk Delete'
},
bulkEdit: {
title: 'Bulk Edit Accounts',
......@@ -784,7 +785,7 @@ export default {
partialSuccess: 'Partially updated: {success} succeeded, {failed} failed',
failed: 'Bulk update failed',
noSelection: 'Please select accounts to edit',
noFieldsSelected: 'Select at least one field to update',
noFieldsSelected: 'Select at least one field to update'
},
bulkDeleteTitle: 'Bulk Delete Accounts',
bulkDeleteConfirm: 'Delete the selected {count} account(s)? This action cannot be undone.',
......@@ -919,13 +920,69 @@ export default {
authCodePlaceholder:
'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value',
authCodeHint:
'You can copy the entire URL or just the code parameter value, the system will auto-detect',
'You can copy the entire URL or just the code parameter value, the system will auto-detect'
},
// Gemini specific
gemini: {
title: 'Gemini Account Authorization',
followSteps: 'Follow these steps to authorize your Gemini account:',
step1GenerateUrl: 'Generate the authorization URL',
generateAuthUrl: 'Generate Auth URL',
projectIdLabel: 'Project ID (optional)',
projectIdPlaceholder: 'e.g. my-gcp-project or cloud-ai-companion-xxxxx',
projectIdHint:
'Leave empty to auto-detect after code exchange. If auto-detection fails, fill it in and re-generate the auth URL to try again.',
howToGetProjectId: 'How to get',
step2OpenUrl: 'Open the URL in your browser and complete authorization',
openUrlDesc:
'Open the authorization URL in a new tab, log in to your Google account and authorize.',
step3EnterCode: 'Enter Authorization URL or Code',
authCodeDesc:
'After authorization, copy the callback URL (recommended) or just the <code>code</code> and paste it below.',
authCode: 'Callback URL or Code',
authCodePlaceholder:
'Option 1 (recommended): Paste the callback URL\nOption 2: Paste only the code value',
authCodeHint: 'The system will auto-extract code/state from the URL.',
redirectUri: 'Redirect URI',
redirectUriHint:
'This must be configured in your Google OAuth client and must match exactly.',
confirmRedirectUri:
'I have configured this Redirect URI in the Google OAuth client (must match exactly)',
invalidRedirectUri: 'Redirect URI must be a valid http(s) URL',
redirectUriNotConfirmed: 'Please confirm the Redirect URI is configured correctly',
missingRedirectUri: 'Missing redirect URI',
failedToGenerateUrl: 'Failed to generate Gemini auth URL',
missingExchangeParams: 'Missing auth code, session ID, or state',
failedToExchangeCode: 'Failed to exchange Gemini auth code',
modelPassthrough: 'Gemini Model Passthrough',
modelPassthroughDesc:
'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.',
stateWarningTitle: 'Note',
stateWarningDesc: 'Recommended: paste the full callback URL (includes code & state).',
oauthTypeLabel: 'OAuth Type',
needsProjectId: 'For GCP Developers',
needsProjectIdDesc: 'Requires GCP project',
noProjectIdNeeded: 'For Regular Users',
noProjectIdNeededDesc: 'Requires admin-configured OAuth client',
aiStudioNotConfiguredShort: 'Not configured',
aiStudioNotConfiguredTip:
'AI Studio OAuth is not configured: set GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET and add Redirect URI: http://localhost:1455/auth/callback (Consent screen scopes must include https://www.googleapis.com/auth/generative-language.retriever)',
aiStudioNotConfigured:
'AI Studio OAuth is not configured: set GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET and add Redirect URI: http://localhost:1455/auth/callback'
}
},
// Gemini specific (platform-wide)
gemini: {
modelPassthrough: 'Gemini Model Passthrough',
modelPassthroughDesc:
'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.',
apiKeyHint: 'Your Gemini API Key (starts with AIza)'
},
// Re-Auth Modal
reAuthorizeAccount: 'Re-Authorize Account',
claudeCodeAccount: 'Claude Code Account',
openaiAccount: 'OpenAI Account',
geminiAccount: 'Gemini Account',
inputMethod: 'Input Method',
reAuthorizedSuccess: 'Account re-authorized successfully',
// Test Modal
......@@ -946,7 +1003,7 @@ export default {
startingTestForAccount: 'Starting test for account: {name}',
testAccountTypeLabel: 'Account type: {type}',
selectTestModel: 'Select Test Model',
testModel: 'claude-sonnet-4-5-20250929',
testModel: 'Test model',
testPrompt: 'Prompt: "hi"',
// Stats Modal
viewStats: 'View Stats',
......@@ -979,8 +1036,8 @@ export default {
todayTokens: 'Today Tokens',
todayCost: 'Today Cost',
usageTrend: '30-Day Cost & Request Trend',
noData: 'No usage data available for this account',
},
noData: 'No usage data available for this account'
}
},
// Proxies
......@@ -998,7 +1055,7 @@ export default {
protocol: 'Protocol',
address: 'Address',
status: 'Status',
actions: 'Actions',
actions: 'Actions'
},
testConnection: 'Test Connection',
batchTest: 'Test All Proxies',
......@@ -1046,7 +1103,7 @@ export default {
failedToDelete: 'Failed to delete proxy',
failedToTest: 'Failed to test proxy',
deleteConfirm:
"Are you sure you want to delete '{name}'? Accounts using this proxy will have their proxy removed.",
"Are you sure you want to delete '{name}'? Accounts using this proxy will have their proxy removed."
},
// Redeem Codes
......@@ -1069,7 +1126,7 @@ export default {
status: 'Status',
usedBy: 'Used By',
usedAt: 'Used At',
actions: 'Actions',
actions: 'Actions'
},
userPrefix: 'User #{id}',
exportCsv: 'Export CSV',
......@@ -1106,7 +1163,7 @@ export default {
selectGroupPlaceholder: 'Choose a subscription group',
validityDays: 'Validity Days',
groupRequired: 'Please select a subscription group',
days: ' days',
days: ' days'
},
// Usage Records
......@@ -1117,7 +1174,7 @@ export default {
searchUserPlaceholder: 'Search user by email...',
selectedUser: 'Selected',
user: 'User',
failedToLoad: 'Failed to load usage records',
failedToLoad: 'Failed to load usage records'
},
// Settings
......@@ -1130,7 +1187,7 @@ export default {
enableRegistration: 'Enable Registration',
enableRegistrationHint: 'Allow new users to register',
emailVerification: 'Email Verification',
emailVerificationHint: 'Require email verification for new registrations',
emailVerificationHint: 'Require email verification for new registrations'
},
turnstile: {
title: 'Cloudflare Turnstile',
......@@ -1140,7 +1197,7 @@ export default {
siteKey: 'Site Key',
secretKey: 'Secret Key',
siteKeyHint: 'Get this from your Cloudflare Dashboard',
secretKeyHint: 'Server-side verification key (keep this secret)',
secretKeyHint: 'Server-side verification key (keep this secret)'
},
defaults: {
title: 'Default User Settings',
......@@ -1148,7 +1205,7 @@ export default {
defaultBalance: 'Default Balance',
defaultBalanceHint: 'Initial balance for new users',
defaultConcurrency: 'Default Concurrency',
defaultConcurrencyHint: 'Maximum concurrent requests for new users',
defaultConcurrencyHint: 'Maximum concurrent requests for new users'
},
site: {
title: 'Site Settings',
......@@ -1171,7 +1228,7 @@ export default {
logoHint: 'PNG, JPG, or SVG. Max 300KB. Recommended: 80x80px square image.',
logoSizeError: 'Image size exceeds 300KB limit ({size}KB)',
logoTypeError: 'Please select an image file',
logoReadError: 'Failed to read the image file',
logoReadError: 'Failed to read the image file'
},
smtp: {
title: 'SMTP Settings',
......@@ -1186,7 +1243,7 @@ export default {
fromEmail: 'From Email',
fromName: 'From Name',
useTls: 'Use TLS',
useTlsHint: 'Enable TLS encryption for SMTP connection',
useTlsHint: 'Enable TLS encryption for SMTP connection'
},
testEmail: {
title: 'Send Test Email',
......@@ -1194,7 +1251,7 @@ export default {
recipientEmail: 'Recipient Email',
sendTestEmail: 'Send Test Email',
sending: 'Sending...',
enterRecipientHint: 'Please enter a recipient email address',
enterRecipientHint: 'Please enter a recipient email address'
},
adminApiKey: {
title: 'Admin API Key',
......@@ -1217,7 +1274,7 @@ export default {
keyCopied: 'Key copied to clipboard',
keyWarning: 'This key will only be shown once. Please copy it now.',
securityWarning: 'Warning: This key provides full admin access. Keep it secure.',
usage: 'Usage: Add to request header - x-api-key: <your-admin-api-key>',
usage: 'Usage: Add to request header - x-api-key: <your-admin-api-key>'
},
saveSettings: 'Save Settings',
saving: 'Saving...',
......@@ -1227,8 +1284,8 @@ export default {
failedToLoad: 'Failed to load settings',
failedToSave: 'Failed to save settings',
failedToTestSmtp: 'SMTP connection test failed',
failedToSendTestEmail: 'Failed to send test email',
},
failedToSendTestEmail: 'Failed to send test email'
}
},
// Subscription Progress (Header component)
......@@ -1244,7 +1301,7 @@ export default {
expiresToday: 'Expires today',
expiresTomorrow: 'Expires tomorrow',
viewAll: 'View all subscriptions',
noSubscriptions: 'No active subscriptions',
noSubscriptions: 'No active subscriptions'
},
// Version Badge
......@@ -1268,7 +1325,7 @@ export default {
restartRequired: 'Please restart the service to apply the update',
restartNow: 'Restart Now',
restarting: 'Restarting...',
retry: 'Retry',
retry: 'Retry'
},
// User Subscriptions Page
......@@ -1281,7 +1338,7 @@ export default {
status: {
active: 'Active',
expired: 'Expired',
revoked: 'Revoked',
revoked: 'Revoked'
},
usage: 'Usage',
expires: 'Expires',
......@@ -1294,6 +1351,6 @@ export default {
expiresOn: 'Expires on {date}',
resetIn: 'Resets in {time}',
windowNotActive: 'Awaiting first use',
usageOf: '{used} of {limit}',
},
usageOf: '{used} of {limit}'
}
}
......@@ -13,7 +13,7 @@ export default {
tags: {
subscriptionToApi: '订阅转 API',
stickySession: '粘性会话',
realtimeBilling: '实时计费',
realtimeBilling: '实时计费'
},
features: {
unifiedGateway: '统一 API 网关',
......@@ -21,17 +21,17 @@ export default {
multiAccount: '多账号池',
multiAccountDesc: '智能负载均衡管理多个上游账号,支持 OAuth 和 API Key 认证。',
balanceQuota: '余额与配额',
balanceQuotaDesc: '基于 Token 的精确计费和用量追踪,支持配额管理和兑换码充值。',
balanceQuotaDesc: '基于 Token 的精确计费和用量追踪,支持配额管理和兑换码充值。'
},
providers: {
title: '支持的服务商',
description: 'AI 服务的统一 API 接口',
supported: '已支持',
soon: '即将推出',
soon: '即将推出'
},
footer: {
allRightsReserved: '保留所有权利。',
},
allRightsReserved: '保留所有权利。'
}
},
// Common
......@@ -81,7 +81,7 @@ export default {
searchPlaceholder: '搜索...',
noOptionsFound: '无匹配选项',
saving: '保存中...',
refresh: '刷新',
refresh: '刷新'
},
// Navigation
......@@ -105,7 +105,7 @@ export default {
expand: '展开',
logout: '退出登录',
github: 'GitHub',
mySubscriptions: '我的订阅',
mySubscriptions: '我的订阅'
},
// Auth
......@@ -139,7 +139,7 @@ export default {
accountCreatedSuccess: '账户创建成功!欢迎使用 {siteName}。',
turnstileExpired: '验证已过期,请重试',
turnstileFailed: '验证失败,请重试',
completeVerification: '请完成验证',
completeVerification: '请完成验证'
},
// Dashboard
......@@ -182,12 +182,12 @@ export default {
viewUsage: '查看使用记录',
checkDetailedLogs: '查看详细的使用日志',
redeemCode: '兑换码',
addBalanceWithCode: '使用兑换码充值',
addBalanceWithCode: '使用兑换码充值'
},
// Groups (shared)
groups: {
subscription: '订阅',
subscription: '订阅'
},
// API Keys
......@@ -253,7 +253,7 @@ export default {
customKeyHint: '仅允许字母、数字、下划线和连字符,最少16个字符。',
customKeyTooShort: '自定义密钥至少需要16个字符',
customKeyInvalidChars: '自定义密钥只能包含字母、数字、下划线和连字符',
customKeyRequired: '请输入自定义密钥',
customKeyRequired: '请输入自定义密钥'
},
// Usage
......@@ -294,7 +294,7 @@ export default {
exportSuccess: '使用数据导出成功',
billingType: '消费类型',
balance: '余额',
subscription: '订阅',
subscription: '订阅'
},
// Redeem
......@@ -334,7 +334,7 @@ export default {
subscriptionDays: '{days} 天',
days: '',
codeRedeemSuccess: '兑换成功!',
failedToRedeem: '兑换失败,请检查兑换码后重试。',
failedToRedeem: '兑换失败,请检查兑换码后重试。'
},
// Profile
......@@ -365,12 +365,12 @@ export default {
passwordsNotMatch: '两次输入的密码不一致',
passwordTooShort: '密码至少需要 8 个字符',
passwordChangeSuccess: '密码修改成功',
passwordChangeFailed: '密码修改失败',
passwordChangeFailed: '密码修改失败'
},
// Empty States
empty: {
noData: '暂无数据',
noData: '暂无数据'
},
// Pagination
......@@ -384,7 +384,7 @@ export default {
previous: '上一页',
next: '下一页',
perPage: '每页',
goToPage: '跳转到第 {page} 页',
goToPage: '跳转到第 {page} 页'
},
// Errors
......@@ -396,7 +396,7 @@ export default {
serverError: '服务器错误',
networkError: '网络错误',
timeout: '请求超时',
tryAgain: '请重试',
tryAgain: '请重试'
},
// Dates
......@@ -414,7 +414,7 @@ export default {
startDate: '开始日期',
endDate: '结束日期',
apply: '应用',
selectDateRange: '选择日期范围',
selectDateRange: '选择日期范围'
},
// Admin
......@@ -469,7 +469,7 @@ export default {
configureAiAccounts: '配置 AI 平台账号',
systemSettings: '系统设置',
configureSystem: '配置系统设置',
failedToLoad: '加载仪表盘数据失败',
failedToLoad: '加载仪表盘数据失败'
},
// Users Management
......@@ -520,7 +520,7 @@ export default {
concurrency: '并发数',
status: '状态',
created: '创建时间',
actions: '操作',
actions: '操作'
},
today: '今日',
total: '累计',
......@@ -550,11 +550,11 @@ export default {
deleteConfirm: "确定要删除用户 '{email}' 吗?此操作无法撤销。",
roles: {
admin: '管理员',
user: '用户',
user: '用户'
},
statuses: {
active: '正常',
banned: '禁用',
banned: '禁用'
},
form: {
emailLabel: '邮箱',
......@@ -573,7 +573,7 @@ export default {
balanceLabel: '余额',
concurrencyLabel: '并发数',
statusLabel: '状态',
selectStatus: '选择状态',
selectStatus: '选择状态'
},
adjustBalance: '调整余额',
adjustConcurrency: '调整并发数',
......@@ -617,7 +617,7 @@ export default {
failedToDeposit: '充值失败',
failedToWithdraw: '退款失败',
useDepositWithdrawButtons: '请使用充值/退款按钮调整余额',
insufficientBalance: '余额不足,退款后余额不能为负数',
insufficientBalance: '余额不足,退款后余额不能为负数'
},
// Groups Management
......@@ -641,7 +641,7 @@ export default {
accounts: '账号数',
status: '状态',
actions: '操作',
billingType: '计费类型',
billingType: '计费类型'
},
form: {
name: '名称',
......@@ -663,16 +663,16 @@ export default {
accountsPlaceholder: '选择账号(留空则不限制)',
priorityLabel: '优先级',
priorityHint: '数值越高优先级越高,用于账号调度',
statusLabel: '状态',
statusLabel: '状态'
},
exclusive: {
yes: '',
no: '',
no: ''
},
platforms: {
all: '全部平台',
claude: 'Claude',
openai: 'OpenAI',
openai: 'OpenAI'
},
saving: '保存中...',
noGroups: '暂无分组',
......@@ -718,8 +718,8 @@ export default {
monthlyLimit: '每月限额(USD)',
defaultValidityDays: '默认有效期(天)',
validityHint: '分配给用户时订阅的有效天数',
noLimit: '无限制',
},
noLimit: '无限制'
}
},
// Subscriptions Management
......@@ -745,7 +745,7 @@ export default {
status: {
active: '生效中',
expired: '已过期',
revoked: '已撤销',
revoked: '已撤销'
},
columns: {
user: '用户',
......@@ -753,13 +753,13 @@ export default {
usage: '用量',
expires: '到期时间',
status: '状态',
actions: '操作',
actions: '操作'
},
form: {
user: '用户',
group: '订阅分组',
validityDays: '有效期(天)',
extendDays: '延长天数',
extendDays: '延长天数'
},
selectUser: '选择用户',
selectGroup: '选择订阅分组',
......@@ -781,7 +781,7 @@ export default {
failedToAssign: '分配订阅失败',
failedToExtend: '延长订阅失败',
failedToRevoke: '撤销订阅失败',
revokeConfirm: "确定要撤销 '{user}' 的订阅吗?此操作无法撤销。",
revokeConfirm: "确定要撤销 '{user}' 的订阅吗?此操作无法撤销。"
},
// Accounts Management
......@@ -838,23 +838,24 @@ export default {
groups: '分组',
usageWindows: '用量窗口',
lastUsed: '最近使用',
actions: '操作',
actions: '操作'
},
platforms: {
claude: 'Claude',
openai: 'OpenAI',
anthropic: 'Anthropic',
gemini: 'Gemini'
},
types: {
oauth: 'OAuth',
api_key: 'API Key',
cookie: 'Cookie',
cookie: 'Cookie'
},
statuses: {
active: '正常',
inactive: '停用',
error: '错误',
cooldown: '冷却中',
cooldown: '冷却中'
},
form: {
nameLabel: '账号名称',
......@@ -869,7 +870,7 @@ export default {
priorityHint: '数值越高优先级越高',
weightLabel: '权重',
weightHint: '用于负载均衡的权重值',
statusLabel: '状态',
statusLabel: '状态'
},
filters: {
platform: '平台',
......@@ -877,7 +878,7 @@ export default {
type: '类型',
allTypes: '全部类型',
status: '状态',
allStatuses: '全部状态',
allStatuses: '全部状态'
},
saving: '保存中...',
refreshing: '刷新中...',
......@@ -892,7 +893,7 @@ export default {
selectCurrentPage: '本页全选',
clear: '清除选择',
edit: '批量编辑账号',
delete: '批量删除',
delete: '批量删除'
},
bulkEdit: {
title: '批量编辑账号',
......@@ -905,7 +906,7 @@ export default {
partialSuccess: '部分更新成功:成功 {success} 个,失败 {failed} 个',
failed: '批量更新失败',
noSelection: '请选择要编辑的账号',
noFieldsSelected: '请至少选择一个要更新的字段',
noFieldsSelected: '请至少选择一个要更新的字段'
},
bulkDeleteTitle: '批量删除账号',
bulkDeleteConfirm: '确定要删除选中的 {count} 个账号吗?此操作无法撤销。',
......@@ -1034,13 +1035,59 @@ export default {
authCode: '授权链接或 Code',
authCodePlaceholder:
'方式1:复制完整的链接\n(http://localhost:xxx/auth/callback?code=...)\n方式2:仅复制 code 参数的值',
authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别',
authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别'
},
// Gemini specific
gemini: {
title: 'Gemini 账户授权',
followSteps: '请按照以下步骤完成 Gemini 账户的授权:',
step1GenerateUrl: '生成授权链接',
generateAuthUrl: '生成授权链接',
projectIdLabel: 'Project ID(可选)',
projectIdPlaceholder: '例如:my-gcp-project 或 cloud-ai-companion-xxxxx',
projectIdHint: '留空则在兑换授权码后自动探测;若自动探测失败,可填写后重新生成授权链接再授权。',
howToGetProjectId: '如何获取',
step2OpenUrl: '在浏览器中打开链接并完成授权',
openUrlDesc: '请在新标签页中打开授权链接,登录您的 Google 账户并授权。',
step3EnterCode: '输入回调链接或 Code',
authCodeDesc: '授权完成后,复制浏览器跳转后的回调链接(推荐)或仅复制 <code>code</code>,粘贴到下方即可。',
authCode: '回调链接或 Code',
authCodePlaceholder: '方式1(推荐):粘贴回调链接\n方式2:仅粘贴 code 参数的值',
authCodeHint: '系统会自动从链接中解析 code/state。',
redirectUri: 'Redirect URI',
redirectUriHint: '需要在 Google OAuth Client 中配置,且必须与此处完全一致。',
confirmRedirectUri: '我已在 Google OAuth Client 中配置了该 Redirect URI(必须完全一致)',
invalidRedirectUri: 'Redirect URI 必须是合法的 http(s) URL',
redirectUriNotConfirmed: '请确认 Redirect URI 已在 Google OAuth Client 中正确配置',
missingRedirectUri: '缺少 Redirect URI',
failedToGenerateUrl: '生成 Gemini 授权链接失败',
missingExchangeParams: '缺少 code / session_id / state',
failedToExchangeCode: 'Gemini 授权码兑换失败',
modelPassthrough: 'Gemini 直接转发模型',
modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。',
stateWarningTitle: '提示',
stateWarningDesc: '建议粘贴完整回调链接(包含 code 和 state)。',
oauthTypeLabel: 'OAuth 类型',
needsProjectId: '适合 GCP 开发者',
needsProjectIdDesc: '需 GCP 项目',
noProjectIdNeeded: '适合普通用户',
noProjectIdNeededDesc: '需管理员配置 OAuth Client',
aiStudioNotConfiguredShort: '未配置',
aiStudioNotConfiguredTip: 'AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback(Consent Screen scopes 需包含 https://www.googleapis.com/auth/generative-language.retriever)',
aiStudioNotConfigured: 'AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback'
}
},
// Gemini specific (platform-wide)
gemini: {
modelPassthrough: 'Gemini 直接转发模型',
modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。',
apiKeyHint: 'Your Gemini API Key(以 AIza 开头)'
},
// Re-Auth Modal
reAuthorizeAccount: '重新授权账号',
claudeCodeAccount: 'Claude Code 账号',
openaiAccount: 'OpenAI 账号',
geminiAccount: 'Gemini 账号',
inputMethod: '输入方式',
reAuthorizedSuccess: '账号重新授权成功',
// Test Modal
......@@ -1058,9 +1105,9 @@ export default {
copyOutput: '复制输出',
startingTestForAccount: '开始测试账号:{name}',
testAccountTypeLabel: '账号类型:{type}',
selectTestModel: '选择测试模型',
testModel: 'claude-sonnet-4-5-20250929',
testPrompt: '提示词:"hi"',
selectTestModel: '选择测试模型',
testModel: '测试模型',
testPrompt: '提示词:"hi"',
// Stats Modal
viewStats: '查看统计',
usageStatistics: '使用统计',
......@@ -1092,8 +1139,8 @@ export default {
todayTokens: '今日 Token',
todayCost: '今日费用',
usageTrend: '30天费用与请求趋势',
noData: '该账号暂无使用数据',
},
noData: '该账号暂无使用数据'
}
},
// Proxies Management
......@@ -1112,17 +1159,17 @@ export default {
priority: '优先级',
status: '状态',
lastCheck: '最近检测',
actions: '操作',
actions: '操作'
},
protocols: {
http: 'HTTP',
https: 'HTTPS',
socks5: 'SOCKS5',
socks5: 'SOCKS5'
},
statuses: {
active: '正常',
inactive: '停用',
error: '错误',
error: '错误'
},
form: {
nameLabel: '名称',
......@@ -1138,13 +1185,13 @@ export default {
passwordLabel: '密码(可选)',
passwordPlaceholder: '请输入密码',
priorityLabel: '优先级',
statusLabel: '状态',
statusLabel: '状态'
},
filters: {
protocol: '协议',
allProtocols: '全部协议',
status: '状态',
allStatuses: '全部状态',
allStatuses: '全部状态'
},
// Additional keys used in ProxiesView
allProtocols: '全部协议',
......@@ -1203,7 +1250,7 @@ export default {
failedToCreate: '创建代理失败',
failedToUpdate: '更新代理失败',
failedToTest: '测试代理失败',
deleteConfirm: "确定要删除代理 '{name}' 吗?使用此代理的账号将被移除代理设置。",
deleteConfirm: "确定要删除代理 '{name}' 吗?使用此代理的账号将被移除代理设置。"
},
// Redeem Codes Management
......@@ -1219,12 +1266,12 @@ export default {
usedBy: '使用者',
usedAt: '使用时间',
createdAt: '创建时间',
actions: '操作',
actions: '操作'
},
types: {
balance: '余额',
concurrency: '并发数',
subscription: '订阅',
subscription: '订阅'
},
// 用于选择器和筛选器的直接键
balance: '余额',
......@@ -1267,7 +1314,7 @@ export default {
unused: '未使用',
used: '已使用',
expired: '已过期',
disabled: '已禁用',
disabled: '已禁用'
},
form: {
typeLabel: '类型',
......@@ -1281,14 +1328,14 @@ export default {
countHint: '要生成的兑换码数量',
prefixLabel: '前缀(可选)',
prefixPlaceholder: '例如:GIFT',
expiresLabel: '过期时间(可选)',
expiresLabel: '过期时间(可选)'
},
filters: {
type: '类型',
allTypes: '全部类型',
status: '状态',
allStatuses: '全部状态',
search: '搜索兑换码',
search: '搜索兑换码'
},
generating: '生成中...',
copyCode: '复制',
......@@ -1306,7 +1353,7 @@ export default {
failedToLoad: '加载兑换码列表失败',
failedToGenerate: '生成兑换码失败',
failedToUpdate: '更新兑换码失败',
failedToDelete: '删除兑换码失败',
failedToDelete: '删除兑换码失败'
},
// Usage Records
......@@ -1317,7 +1364,7 @@ export default {
searchUserPlaceholder: '按邮箱搜索用户...',
selectedUser: '已选择',
user: '用户',
failedToLoad: '加载使用记录失败',
failedToLoad: '加载使用记录失败'
},
// Settings
......@@ -1330,7 +1377,7 @@ export default {
enableRegistration: '开放注册',
enableRegistrationHint: '允许新用户注册',
emailVerification: '邮箱验证',
emailVerificationHint: '新用户注册时需要验证邮箱',
emailVerificationHint: '新用户注册时需要验证邮箱'
},
turnstile: {
title: 'Cloudflare Turnstile',
......@@ -1340,7 +1387,7 @@ export default {
siteKey: '站点密钥',
secretKey: '私密密钥',
siteKeyHint: '从 Cloudflare Dashboard 获取',
secretKeyHint: '服务端验证密钥(请保密)',
secretKeyHint: '服务端验证密钥(请保密)'
},
defaults: {
title: '用户默认设置',
......@@ -1348,7 +1395,7 @@ export default {
defaultBalance: '默认余额',
defaultBalanceHint: '新用户的初始余额',
defaultConcurrency: '默认并发数',
defaultConcurrencyHint: '新用户的最大并发请求数',
defaultConcurrencyHint: '新用户的最大并发请求数'
},
site: {
title: '站点设置',
......@@ -1370,7 +1417,7 @@ export default {
logoHint: 'PNG、JPG 或 SVG 格式,最大 300KB。建议:80x80px 正方形图片。',
logoSizeError: '图片大小超过 300KB 限制({size}KB)',
logoTypeError: '请选择图片文件',
logoReadError: '读取图片文件失败',
logoReadError: '读取图片文件失败'
},
smtp: {
title: 'SMTP 设置',
......@@ -1385,7 +1432,7 @@ export default {
fromEmail: '发件人邮箱',
fromName: '发件人名称',
useTls: '使用 TLS',
useTlsHint: '为 SMTP 连接启用 TLS 加密',
useTlsHint: '为 SMTP 连接启用 TLS 加密'
},
testEmail: {
title: '发送测试邮件',
......@@ -1393,7 +1440,7 @@ export default {
recipientEmail: '收件人邮箱',
sendTestEmail: '发送测试邮件',
sending: '发送中...',
enterRecipientHint: '请输入收件人邮箱地址',
enterRecipientHint: '请输入收件人邮箱地址'
},
adminApiKey: {
title: '管理员 API Key',
......@@ -1415,7 +1462,7 @@ export default {
keyCopied: '密钥已复制到剪贴板',
keyWarning: '此密钥仅显示一次,请立即复制保存。',
securityWarning: '警告:此密钥拥有完整的管理员权限,请妥善保管。',
usage: '使用方法:在请求头中添加 x-api-key: <your-admin-api-key>',
usage: '使用方法:在请求头中添加 x-api-key: <your-admin-api-key>'
},
saveSettings: '保存设置',
saving: '保存中...',
......@@ -1425,8 +1472,8 @@ export default {
failedToLoad: '加载设置失败',
failedToSave: '保存设置失败',
failedToTestSmtp: 'SMTP 连接测试失败',
failedToSendTestEmail: '发送测试邮件失败',
},
failedToSendTestEmail: '发送测试邮件失败'
}
},
// Subscription Progress (Header component)
......@@ -1442,7 +1489,7 @@ export default {
expiresToday: '今天到期',
expiresTomorrow: '明天到期',
viewAll: '查看全部订阅',
noSubscriptions: '暂无有效订阅',
noSubscriptions: '暂无有效订阅'
},
// Version Badge
......@@ -1466,7 +1513,7 @@ export default {
restartRequired: '请重启服务以应用更新',
restartNow: '立即重启',
restarting: '正在重启...',
retry: '重试',
retry: '重试'
},
// User Subscriptions Page
......@@ -1478,7 +1525,7 @@ export default {
status: {
active: '有效',
expired: '已过期',
revoked: '已撤销',
revoked: '已撤销'
},
usage: '用量',
expires: '到期时间',
......@@ -1491,6 +1538,6 @@ export default {
expiresOn: '{date} 到期',
resetIn: '{time} 后重置',
windowNotActive: '等待首次使用',
usageOf: '已用 {used} / {limit}',
},
usageOf: '已用 {used} / {limit}'
}
}
......@@ -13,38 +13,38 @@ This directory contains the Vue Router configuration for the Sub2API frontend ap
### Public Routes (No Authentication Required)
| Path | Component | Description |
|------|-----------|-------------|
| `/login` | LoginView | User login page |
| 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 |
| 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 |
| 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 |
|------|-----------|-------------|
| Path | Component | Description |
| ----------------- | ------------ | -------------- |
| `/:pathMatch(.*)` | NotFoundView | 404 error page |
## Navigation Guards
......@@ -92,15 +92,16 @@ 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
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
}
```
......@@ -113,6 +114,7 @@ component: () => import('@/views/user/DashboardView.vue')
```
Benefits:
- Reduced initial bundle size
- Faster initial page load
- Components loaded on-demand
......@@ -123,7 +125,7 @@ Benefits:
The router integrates with the Pinia auth store (`@/stores/auth`):
```typescript
const authStore = useAuthStore();
const authStore = useAuthStore()
// Check authentication status
authStore.isAuthenticated
......@@ -137,21 +139,21 @@ authStore.isAdmin
### Programmatic Navigation
```typescript
import { useRouter } from 'vue-router';
import { useRouter } from 'vue-router'
const router = useRouter();
const router = useRouter()
// Navigate to a route
router.push('/dashboard');
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');
router.push('/admin/users')
```
### Route Links
......@@ -165,24 +167,22 @@ router.push('/admin/users');
<router-link :to="{ name: 'Keys' }">API Keys</router-link>
<!-- With query parameters -->
<router-link :to="{ path: '/usage', query: { page: 1 } }">
Usage
</router-link>
<router-link :to="{ path: '/usage', query: { page: 1 } }"> Usage </router-link>
</template>
```
### Checking Current Route
```typescript
import { useRoute } from 'vue-router';
import { useRoute } from 'vue-router'
const route = useRoute();
const route = useRoute()
// Check if on admin page
const isAdminPage = route.path.startsWith('/admin');
const isAdminPage = route.path.startsWith('/admin')
// Get route meta
const requiresAdmin = route.meta.requiresAdmin;
const requiresAdmin = route.meta.requiresAdmin
```
## Scroll Behavior
......@@ -199,8 +199,8 @@ The router includes error handling for navigation failures:
```typescript
router.onError((error) => {
console.error('Router error:', error);
});
console.error('Router error:', error)
})
```
## Testing Routes
......@@ -229,7 +229,7 @@ Enable Vue Router debug mode:
```typescript
// In browser console
window.__VUE_ROUTER__ = router;
window.__VUE_ROUTER__ = router
// Check current route
router.currentRoute.value
......@@ -238,14 +238,17 @@ 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
......
......@@ -3,8 +3,8 @@
* Defines all application routes with lazy loading and navigation guards
*/
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
/**
* Route definitions with lazy loading
......@@ -17,8 +17,8 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/setup/SetupWizardView.vue'),
meta: {
requiresAuth: false,
title: 'Setup',
},
title: 'Setup'
}
},
// ==================== Public Routes ====================
......@@ -28,8 +28,8 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/HomeView.vue'),
meta: {
requiresAuth: false,
title: 'Home',
},
title: 'Home'
}
},
{
path: '/login',
......@@ -37,8 +37,8 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/auth/LoginView.vue'),
meta: {
requiresAuth: false,
title: 'Login',
},
title: 'Login'
}
},
{
path: '/register',
......@@ -46,8 +46,8 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/auth/RegisterView.vue'),
meta: {
requiresAuth: false,
title: 'Register',
},
title: 'Register'
}
},
{
path: '/email-verify',
......@@ -55,14 +55,23 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/auth/EmailVerifyView.vue'),
meta: {
requiresAuth: false,
title: 'Verify Email',
},
title: 'Verify Email'
}
},
{
path: '/auth/callback',
name: 'OAuthCallback',
component: () => import('@/views/auth/OAuthCallbackView.vue'),
meta: {
requiresAuth: false,
title: 'OAuth Callback'
}
},
// ==================== User Routes ====================
{
path: '/',
redirect: '/home',
redirect: '/home'
},
{
path: '/dashboard',
......@@ -73,8 +82,8 @@ const routes: RouteRecordRaw[] = [
requiresAdmin: false,
title: 'Dashboard',
titleKey: 'dashboard.title',
descriptionKey: 'dashboard.welcomeMessage',
},
descriptionKey: 'dashboard.welcomeMessage'
}
},
{
path: '/keys',
......@@ -85,8 +94,8 @@ const routes: RouteRecordRaw[] = [
requiresAdmin: false,
title: 'API Keys',
titleKey: 'keys.title',
descriptionKey: 'keys.description',
},
descriptionKey: 'keys.description'
}
},
{
path: '/usage',
......@@ -97,8 +106,8 @@ const routes: RouteRecordRaw[] = [
requiresAdmin: false,
title: 'Usage Records',
titleKey: 'usage.title',
descriptionKey: 'usage.description',
},
descriptionKey: 'usage.description'
}
},
{
path: '/redeem',
......@@ -109,8 +118,8 @@ const routes: RouteRecordRaw[] = [
requiresAdmin: false,
title: 'Redeem Code',
titleKey: 'redeem.title',
descriptionKey: 'redeem.description',
},
descriptionKey: 'redeem.description'
}
},
{
path: '/profile',
......@@ -121,8 +130,8 @@ const routes: RouteRecordRaw[] = [
requiresAdmin: false,
title: 'Profile',
titleKey: 'profile.title',
descriptionKey: 'profile.description',
},
descriptionKey: 'profile.description'
}
},
{
path: '/subscriptions',
......@@ -133,14 +142,14 @@ const routes: RouteRecordRaw[] = [
requiresAdmin: false,
title: 'My Subscriptions',
titleKey: 'userSubscriptions.title',
descriptionKey: 'userSubscriptions.description',
},
descriptionKey: 'userSubscriptions.description'
}
},
// ==================== Admin Routes ====================
{
path: '/admin',
redirect: '/admin/dashboard',
redirect: '/admin/dashboard'
},
{
path: '/admin/dashboard',
......@@ -151,8 +160,8 @@ const routes: RouteRecordRaw[] = [
requiresAdmin: true,
title: 'Admin Dashboard',
titleKey: 'admin.dashboard.title',
descriptionKey: 'admin.dashboard.description',
},
descriptionKey: 'admin.dashboard.description'
}
},
{
path: '/admin/users',
......@@ -163,8 +172,8 @@ const routes: RouteRecordRaw[] = [
requiresAdmin: true,
title: 'User Management',
titleKey: 'admin.users.title',
descriptionKey: 'admin.users.description',
},
descriptionKey: 'admin.users.description'
}
},
{
path: '/admin/groups',
......@@ -175,8 +184,8 @@ const routes: RouteRecordRaw[] = [
requiresAdmin: true,
title: 'Group Management',
titleKey: 'admin.groups.title',
descriptionKey: 'admin.groups.description',
},
descriptionKey: 'admin.groups.description'
}
},
{
path: '/admin/subscriptions',
......@@ -187,8 +196,8 @@ const routes: RouteRecordRaw[] = [
requiresAdmin: true,
title: 'Subscription Management',
titleKey: 'admin.subscriptions.title',
descriptionKey: 'admin.subscriptions.description',
},
descriptionKey: 'admin.subscriptions.description'
}
},
{
path: '/admin/accounts',
......@@ -199,8 +208,8 @@ const routes: RouteRecordRaw[] = [
requiresAdmin: true,
title: 'Account Management',
titleKey: 'admin.accounts.title',
descriptionKey: 'admin.accounts.description',
},
descriptionKey: 'admin.accounts.description'
}
},
{
path: '/admin/proxies',
......@@ -211,8 +220,8 @@ const routes: RouteRecordRaw[] = [
requiresAdmin: true,
title: 'Proxy Management',
titleKey: 'admin.proxies.title',
descriptionKey: 'admin.proxies.description',
},
descriptionKey: 'admin.proxies.description'
}
},
{
path: '/admin/redeem',
......@@ -223,8 +232,8 @@ const routes: RouteRecordRaw[] = [
requiresAdmin: true,
title: 'Redeem Code Management',
titleKey: 'admin.redeem.title',
descriptionKey: 'admin.redeem.description',
},
descriptionKey: 'admin.redeem.description'
}
},
{
path: '/admin/settings',
......@@ -235,8 +244,8 @@ const routes: RouteRecordRaw[] = [
requiresAdmin: true,
title: 'System Settings',
titleKey: 'admin.settings.title',
descriptionKey: 'admin.settings.description',
},
descriptionKey: 'admin.settings.description'
}
},
{
path: '/admin/usage',
......@@ -247,8 +256,8 @@ const routes: RouteRecordRaw[] = [
requiresAdmin: true,
title: 'Usage Records',
titleKey: 'admin.usage.title',
descriptionKey: 'admin.usage.description',
},
descriptionKey: 'admin.usage.description'
}
},
// ==================== 404 Not Found ====================
......@@ -257,10 +266,10 @@ const routes: RouteRecordRaw[] = [
name: 'NotFound',
component: () => import('@/views/NotFoundView.vue'),
meta: {
title: '404 Not Found',
},
},
];
title: '404 Not Found'
}
}
]
/**
* Create router instance
......@@ -271,48 +280,48 @@ const router = createRouter({
scrollBehavior(_to, _from, savedPosition) {
// Scroll to saved position when using browser back/forward
if (savedPosition) {
return savedPosition;
return savedPosition
}
// Scroll to top for new routes
return { top: 0 };
},
});
return { top: 0 }
}
})
/**
* Navigation guard: Authentication check
*/
let authInitialized = false;
let authInitialized = false
router.beforeEach((to, _from, next) => {
const authStore = useAuthStore();
const authStore = useAuthStore()
// Restore auth state from localStorage on first navigation (page refresh)
if (!authInitialized) {
authStore.checkAuth();
authInitialized = true;
authStore.checkAuth()
authInitialized = true
}
// Set page title
if (to.meta.title) {
document.title = `${to.meta.title} - Sub2API`;
document.title = `${to.meta.title} - Sub2API`
} else {
document.title = 'Sub2API';
document.title = 'Sub2API'
}
// Check if route requires authentication
const requiresAuth = to.meta.requiresAuth !== false; // Default to true
const requiresAdmin = to.meta.requiresAdmin === true;
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 appropriate dashboard
if (authStore.isAuthenticated && (to.path === '/login' || to.path === '/register')) {
// Admin users go to admin dashboard, regular users go to user dashboard
next(authStore.isAdmin ? '/admin/dashboard' : '/dashboard');
return;
next(authStore.isAdmin ? '/admin/dashboard' : '/dashboard')
return
}
next();
return;
next()
return
}
// Route requires authentication
......@@ -320,27 +329,27 @@ router.beforeEach((to, _from, next) => {
// Not authenticated, redirect to login
next({
path: '/login',
query: { redirect: to.fullPath }, // Save intended destination
});
return;
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;
next('/dashboard')
return
}
// All checks passed, allow navigation
next();
});
next()
})
/**
* Navigation guard: Error handling
*/
router.onError((error) => {
console.error('Router error:', error);
});
console.error('Router error:', error)
})
export default router;
export default router
......@@ -3,7 +3,7 @@
* Extends the RouteMeta interface with custom properties
*/
import 'vue-router';
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
......@@ -11,36 +11,36 @@ declare module 'vue-router' {
* Whether this route requires authentication
* @default true
*/
requiresAuth?: boolean;
requiresAuth?: boolean
/**
* Whether this route requires admin role
* @default false
*/
requiresAdmin?: boolean;
requiresAdmin?: boolean
/**
* Page title for this route
*/
title?: string;
title?: string
/**
* Optional breadcrumb items for navigation
*/
breadcrumbs?: Array<{
label: string;
to?: string;
}>;
label: string
to?: string
}>
/**
* Icon name for this route (for sidebar navigation)
*/
icon?: string;
icon?: string
/**
* Whether to hide this route from navigation menu
* @default false
*/
hideInMenu?: boolean;
hideInMenu?: boolean
}
}
......@@ -9,13 +9,16 @@ This directory contains all Pinia stores for the Sub2API frontend application.
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
......@@ -27,14 +30,17 @@ Manages user authentication state, login/logout, and token persistence.
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
......@@ -54,106 +60,104 @@ Manages global UI state including sidebar, loading indicators, and toast notific
### Auth Store
```typescript
import { useAuthStore } from '@/stores';
import { useAuthStore } from '@/stores'
// In component setup
const authStore = useAuthStore();
const authStore = useAuthStore()
// Initialize on app startup
authStore.checkAuth();
authStore.checkAuth()
// Login
try {
await authStore.login({ username: 'user', password: 'pass' });
console.log('Logged in:', authStore.user);
await authStore.login({ username: 'user', password: 'pass' })
console.log('Logged in:', authStore.user)
} catch (error) {
console.error('Login failed:', error);
console.error('Login failed:', error)
}
// Check authentication
if (authStore.isAuthenticated) {
console.log('User is logged in:', authStore.user?.username);
console.log('User is logged in:', authStore.user?.username)
}
// Logout
authStore.logout();
authStore.logout()
```
### App Store
```typescript
import { useAppStore } from '@/stores';
import { useAppStore } from '@/stores'
// In component setup
const appStore = useAppStore();
const appStore = useAppStore()
// Sidebar control
appStore.toggleSidebar();
appStore.setSidebarCollapsed(true);
appStore.toggleSidebar()
appStore.setSidebarCollapsed(true)
// Loading state
appStore.setLoading(true);
appStore.setLoading(true)
// ... do work
appStore.setLoading(false);
appStore.setLoading(false)
// Or use helper
await appStore.withLoading(async () => {
const data = await fetchData();
return data;
});
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!');
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
const toastId = appStore.showToast('info', 'Custom message', undefined) // No auto-dismiss
// Later...
appStore.hideToast(toastId);
appStore.hideToast(toastId)
```
### Combined Usage in Vue Component
```vue
<script setup lang="ts">
import { useAuthStore, useAppStore } from '@/stores';
import { onMounted } from 'vue';
import { useAuthStore, useAppStore } from '@/stores'
import { onMounted } from 'vue'
const authStore = useAuthStore();
const appStore = useAppStore();
const authStore = useAuthStore()
const appStore = useAppStore()
onMounted(() => {
// Check for existing session
authStore.checkAuth();
});
authStore.checkAuth()
})
async function handleLogin(username: string, password: string) {
try {
await appStore.withLoading(async () => {
await authStore.login({ username, password });
});
appStore.showSuccess('Welcome back!');
await authStore.login({ username, password })
})
appStore.showSuccess('Welcome back!')
} catch (error) {
appStore.showError('Login failed. Please check your credentials.');
appStore.showError('Login failed. Please check your credentials.')
}
}
async function handleLogout() {
authStore.logout();
appStore.showInfo('You have been logged out.');
authStore.logout()
appStore.showInfo('You have been logged out.')
}
</script>
<template>
<div>
<button @click="appStore.toggleSidebar">
Toggle Sidebar
</button>
<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>
......@@ -170,7 +174,6 @@ async function handleLogout() {
- **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
......@@ -178,7 +181,7 @@ async function handleLogout() {
All stores are fully typed with TypeScript. Import types from `@/types`:
```typescript
import type { User, Toast, ToastType } from '@/types';
import type { User, Toast, ToastType } from '@/types'
```
## Testing
......@@ -187,8 +190,8 @@ Stores can be reset to initial state:
```typescript
// Auth store
authStore.logout(); // Clears all auth state
authStore.logout() // Clears all auth state
// App store
appStore.reset(); // Resets to defaults
appStore.reset() // Resets to defaults
```
......@@ -3,47 +3,51 @@
* Manages global UI state including sidebar, loading indicators, and toast notifications
*/
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { Toast, ToastType, PublicSettings } from '@/types';
import { checkUpdates as checkUpdatesAPI, type VersionInfo, type ReleaseInfo } from '@/api/admin/system';
import { getPublicSettings as fetchPublicSettingsAPI } from '@/api/auth';
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Toast, ToastType, PublicSettings } from '@/types'
import {
checkUpdates as checkUpdatesAPI,
type VersionInfo,
type ReleaseInfo
} from '@/api/admin/system'
import { getPublicSettings as fetchPublicSettingsAPI } from '@/api/auth'
export const useAppStore = defineStore('app', () => {
// ==================== State ====================
const sidebarCollapsed = ref<boolean>(false);
const mobileOpen = ref<boolean>(false);
const loading = ref<boolean>(false);
const toasts = ref<Toast[]>([]);
const sidebarCollapsed = ref<boolean>(false)
const mobileOpen = ref<boolean>(false)
const loading = ref<boolean>(false)
const toasts = ref<Toast[]>([])
// Public settings cache state
const publicSettingsLoaded = ref<boolean>(false);
const publicSettingsLoading = ref<boolean>(false);
const siteName = ref<string>('Sub2API');
const siteLogo = ref<string>('');
const siteVersion = ref<string>('');
const contactInfo = ref<string>('');
const apiBaseUrl = ref<string>('');
const docUrl = ref<string>('');
const publicSettingsLoaded = ref<boolean>(false)
const publicSettingsLoading = ref<boolean>(false)
const siteName = ref<string>('Sub2API')
const siteLogo = ref<string>('')
const siteVersion = ref<string>('')
const contactInfo = ref<string>('')
const apiBaseUrl = ref<string>('')
const docUrl = ref<string>('')
// Version cache state
const versionLoaded = ref<boolean>(false);
const versionLoading = ref<boolean>(false);
const currentVersion = ref<string>('');
const latestVersion = ref<string>('');
const hasUpdate = ref<boolean>(false);
const buildType = ref<string>('source');
const releaseInfo = ref<ReleaseInfo | null>(null);
const versionLoaded = ref<boolean>(false)
const versionLoading = ref<boolean>(false)
const currentVersion = ref<string>('')
const latestVersion = ref<string>('')
const hasUpdate = ref<boolean>(false)
const buildType = ref<string>('source')
const releaseInfo = ref<ReleaseInfo | null>(null)
// Auto-incrementing ID for toasts
let toastIdCounter = 0;
let toastIdCounter = 0
// ==================== Computed ====================
const hasActiveToasts = computed(() => toasts.value.length > 0);
const loadingCount = ref<number>(0);
const hasActiveToasts = computed(() => toasts.value.length > 0)
const loadingCount = ref<number>(0)
// ==================== Actions ====================
......@@ -51,7 +55,7 @@ export const useAppStore = defineStore('app', () => {
* Toggle sidebar collapsed state
*/
function toggleSidebar(): void {
sidebarCollapsed.value = !sidebarCollapsed.value;
sidebarCollapsed.value = !sidebarCollapsed.value
}
/**
......@@ -59,14 +63,14 @@ export const useAppStore = defineStore('app', () => {
* @param collapsed - Whether sidebar should be collapsed
*/
function setSidebarCollapsed(collapsed: boolean): void {
sidebarCollapsed.value = collapsed;
sidebarCollapsed.value = collapsed
}
/**
* Toggle mobile sidebar open state
*/
function toggleMobileSidebar(): void {
mobileOpen.value = !mobileOpen.value;
mobileOpen.value = !mobileOpen.value
}
/**
......@@ -74,7 +78,7 @@ export const useAppStore = defineStore('app', () => {
* @param open - Whether mobile sidebar should be open
*/
function setMobileOpen(open: boolean): void {
mobileOpen.value = open;
mobileOpen.value = open
}
/**
......@@ -83,11 +87,11 @@ export const useAppStore = defineStore('app', () => {
*/
function setLoading(isLoading: boolean): void {
if (isLoading) {
loadingCount.value++;
loadingCount.value++
} else {
loadingCount.value = Math.max(0, loadingCount.value - 1);
loadingCount.value = Math.max(0, loadingCount.value - 1)
}
loading.value = loadingCount.value > 0;
loading.value = loadingCount.value > 0
}
/**
......@@ -97,30 +101,26 @@ export const useAppStore = defineStore('app', () => {
* @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}`;
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,
};
startTime: duration !== undefined ? Date.now() : undefined
}
toasts.value.push(toast);
toasts.value.push(toast)
// Auto-dismiss if duration is specified
if (duration !== undefined) {
setTimeout(() => {
hideToast(id);
}, duration);
hideToast(id)
}, duration)
}
return id;
return id
}
/**
......@@ -129,7 +129,7 @@ export const useAppStore = defineStore('app', () => {
* @param duration - Auto-dismiss duration in ms (default: 3000)
*/
function showSuccess(message: string, duration: number = 3000): string {
return showToast('success', message, duration);
return showToast('success', message, duration)
}
/**
......@@ -138,7 +138,7 @@ export const useAppStore = defineStore('app', () => {
* @param duration - Auto-dismiss duration in ms (default: 5000)
*/
function showError(message: string, duration: number = 5000): string {
return showToast('error', message, duration);
return showToast('error', message, duration)
}
/**
......@@ -147,7 +147,7 @@ export const useAppStore = defineStore('app', () => {
* @param duration - Auto-dismiss duration in ms (default: 3000)
*/
function showInfo(message: string, duration: number = 3000): string {
return showToast('info', message, duration);
return showToast('info', message, duration)
}
/**
......@@ -156,7 +156,7 @@ export const useAppStore = defineStore('app', () => {
* @param duration - Auto-dismiss duration in ms (default: 4000)
*/
function showWarning(message: string, duration: number = 4000): string {
return showToast('warning', message, duration);
return showToast('warning', message, duration)
}
/**
......@@ -164,9 +164,9 @@ export const useAppStore = defineStore('app', () => {
* @param id - Toast ID to hide
*/
function hideToast(id: string): void {
const index = toasts.value.findIndex((t) => t.id === id);
const index = toasts.value.findIndex((t) => t.id === id)
if (index !== -1) {
toasts.value.splice(index, 1);
toasts.value.splice(index, 1)
}
}
......@@ -174,7 +174,7 @@ export const useAppStore = defineStore('app', () => {
* Clear all toasts
*/
function clearAllToasts(): void {
toasts.value = [];
toasts.value = []
}
/**
......@@ -184,11 +184,11 @@ export const useAppStore = defineStore('app', () => {
* @returns Promise resolving to operation result
*/
async function withLoading<T>(operation: () => Promise<T>): Promise<T> {
setLoading(true);
setLoading(true)
try {
return await operation();
return await operation()
} finally {
setLoading(false);
setLoading(false)
}
}
......@@ -203,18 +203,15 @@ export const useAppStore = defineStore('app', () => {
operation: () => Promise<T>,
errorMessage?: string
): Promise<T | null> {
setLoading(true);
setLoading(true)
try {
return await operation();
return await operation()
} catch (error) {
const message =
errorMessage ||
(error as { message?: string }).message ||
'An error occurred';
showError(message);
return null;
const message = errorMessage || (error as { message?: string }).message || 'An error occurred'
showError(message)
return null
} finally {
setLoading(false);
setLoading(false)
}
}
......@@ -223,10 +220,10 @@ export const useAppStore = defineStore('app', () => {
* Useful for cleanup or testing
*/
function reset(): void {
sidebarCollapsed.value = false;
loading.value = false;
loadingCount.value = 0;
toasts.value = [];
sidebarCollapsed.value = false
loading.value = false
loadingCount.value = 0
toasts.value = []
}
// ==================== Version Management ====================
......@@ -244,30 +241,30 @@ export const useAppStore = defineStore('app', () => {
has_update: hasUpdate.value,
build_type: buildType.value,
release_info: releaseInfo.value || undefined,
cached: true,
};
cached: true
}
}
// Prevent duplicate requests
if (versionLoading.value) {
return null;
return null
}
versionLoading.value = true;
versionLoading.value = true
try {
const data = await checkUpdatesAPI(force);
currentVersion.value = data.current_version;
latestVersion.value = data.latest_version;
hasUpdate.value = data.has_update;
buildType.value = data.build_type || 'source';
releaseInfo.value = data.release_info || null;
versionLoaded.value = true;
return data;
const data = await checkUpdatesAPI(force)
currentVersion.value = data.current_version
latestVersion.value = data.latest_version
hasUpdate.value = data.has_update
buildType.value = data.build_type || 'source'
releaseInfo.value = data.release_info || null
versionLoaded.value = true
return data
} catch (error) {
console.error('Failed to fetch version:', error);
return null;
console.error('Failed to fetch version:', error)
return null
} finally {
versionLoading.value = false;
versionLoading.value = false
}
}
......@@ -275,8 +272,8 @@ export const useAppStore = defineStore('app', () => {
* Clear version cache (e.g., after update)
*/
function clearVersionCache(): void {
versionLoaded.value = false;
hasUpdate.value = false;
versionLoaded.value = false
hasUpdate.value = false
}
// ==================== Public Settings Management ====================
......@@ -299,31 +296,31 @@ export const useAppStore = defineStore('app', () => {
api_base_url: apiBaseUrl.value,
contact_info: contactInfo.value,
doc_url: docUrl.value,
version: siteVersion.value,
};
version: siteVersion.value
}
}
// Prevent duplicate requests
if (publicSettingsLoading.value) {
return null;
return null
}
publicSettingsLoading.value = true;
publicSettingsLoading.value = true
try {
const data = await fetchPublicSettingsAPI();
siteName.value = data.site_name || 'Sub2API';
siteLogo.value = data.site_logo || '';
siteVersion.value = data.version || '';
contactInfo.value = data.contact_info || '';
apiBaseUrl.value = data.api_base_url || '';
docUrl.value = data.doc_url || '';
publicSettingsLoaded.value = true;
return data;
const data = await fetchPublicSettingsAPI()
siteName.value = data.site_name || 'Sub2API'
siteLogo.value = data.site_logo || ''
siteVersion.value = data.version || ''
contactInfo.value = data.contact_info || ''
apiBaseUrl.value = data.api_base_url || ''
docUrl.value = data.doc_url || ''
publicSettingsLoaded.value = true
return data
} catch (error) {
console.error('Failed to fetch public settings:', error);
return null;
console.error('Failed to fetch public settings:', error)
return null
} finally {
publicSettingsLoading.value = false;
publicSettingsLoading.value = false
}
}
......@@ -331,7 +328,7 @@ export const useAppStore = defineStore('app', () => {
* Clear public settings cache
*/
function clearPublicSettingsCache(): void {
publicSettingsLoaded.value = false;
publicSettingsLoaded.value = false
}
// ==================== Return Store API ====================
......@@ -387,6 +384,6 @@ export const useAppStore = defineStore('app', () => {
// Public settings actions
fetchPublicSettings,
clearPublicSettingsCache,
};
});
clearPublicSettingsCache
}
})
......@@ -3,31 +3,31 @@
* 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';
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
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;
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;
});
return !!token.value && !!user.value
})
const isAdmin = computed(() => {
return user.value?.role === 'admin';
});
return user.value?.role === 'admin'
})
// ==================== Actions ====================
......@@ -37,24 +37,24 @@ export const useAuthStore = defineStore('auth', () => {
* 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);
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);
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);
});
console.error('Failed to refresh user on init:', error)
})
// Start auto-refresh interval
startAutoRefresh();
startAutoRefresh()
} catch (error) {
console.error('Failed to parse saved user data:', error);
clearAuth();
console.error('Failed to parse saved user data:', error)
clearAuth()
}
}
}
......@@ -65,15 +65,15 @@ export const useAuthStore = defineStore('auth', () => {
*/
function startAutoRefresh(): void {
// Clear existing interval if any
stopAutoRefresh();
stopAutoRefresh()
refreshIntervalId = setInterval(() => {
if (token.value) {
refreshUser().catch((error) => {
console.error('Auto-refresh user failed:', error);
});
console.error('Auto-refresh user failed:', error)
})
}
}, AUTO_REFRESH_INTERVAL);
}, AUTO_REFRESH_INTERVAL)
}
/**
......@@ -81,8 +81,8 @@ export const useAuthStore = defineStore('auth', () => {
*/
function stopAutoRefresh(): void {
if (refreshIntervalId) {
clearInterval(refreshIntervalId);
refreshIntervalId = null;
clearInterval(refreshIntervalId)
refreshIntervalId = null
}
}
......@@ -94,24 +94,24 @@ export const useAuthStore = defineStore('auth', () => {
*/
async function login(credentials: LoginRequest): Promise<User> {
try {
const response = await authAPI.login(credentials);
const response = await authAPI.login(credentials)
// Store token and user
token.value = response.access_token;
user.value = response.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));
localStorage.setItem(AUTH_TOKEN_KEY, response.access_token)
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(response.user))
// Start auto-refresh interval
startAutoRefresh();
startAutoRefresh()
return response.user;
return response.user
} catch (error) {
// Clear any partial state on error
clearAuth();
throw error;
clearAuth()
throw error
}
}
......@@ -123,24 +123,24 @@ export const useAuthStore = defineStore('auth', () => {
*/
async function register(userData: RegisterRequest): Promise<User> {
try {
const response = await authAPI.register(userData);
const response = await authAPI.register(userData)
// Store token and user
token.value = response.access_token;
user.value = response.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));
localStorage.setItem(AUTH_TOKEN_KEY, response.access_token)
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(response.user))
// Start auto-refresh interval
startAutoRefresh();
startAutoRefresh()
return response.user;
return response.user
} catch (error) {
// Clear any partial state on error
clearAuth();
throw error;
clearAuth()
throw error
}
}
......@@ -150,10 +150,10 @@ export const useAuthStore = defineStore('auth', () => {
*/
function logout(): void {
// Call API logout (client-side cleanup)
authAPI.logout();
authAPI.logout()
// Clear state
clearAuth();
clearAuth()
}
/**
......@@ -164,23 +164,23 @@ export const useAuthStore = defineStore('auth', () => {
*/
async function refreshUser(): Promise<User> {
if (!token.value) {
throw new Error('Not authenticated');
throw new Error('Not authenticated')
}
try {
const updatedUser = await authAPI.getCurrentUser();
user.value = updatedUser;
const updatedUser = await authAPI.getCurrentUser()
user.value = updatedUser
// Update localStorage
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(updatedUser));
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(updatedUser))
return updatedUser;
return updatedUser
} catch (error) {
// If refresh fails with 401, clear auth state
if ((error as { status?: number }).status === 401) {
clearAuth();
clearAuth()
}
throw error;
throw error
}
}
......@@ -190,12 +190,12 @@ export const useAuthStore = defineStore('auth', () => {
*/
function clearAuth(): void {
// Stop auto-refresh
stopAutoRefresh();
stopAutoRefresh()
token.value = null;
user.value = null;
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(AUTH_USER_KEY);
token.value = null
user.value = null
localStorage.removeItem(AUTH_TOKEN_KEY)
localStorage.removeItem(AUTH_USER_KEY)
}
// ==================== Return Store API ====================
......@@ -214,6 +214,6 @@ export const useAuthStore = defineStore('auth', () => {
register,
logout,
checkAuth,
refreshUser,
};
});
refreshUser
}
})
......@@ -3,9 +3,9 @@
* Central export point for all application stores
*/
export { useAuthStore } from './auth';
export { useAppStore } from './app';
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';
export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types'
export type { Toast, ToastType, AppState } from '@/types'
......@@ -10,7 +10,7 @@
}
html {
@apply antialiased scroll-smooth;
@apply scroll-smooth antialiased;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
}
......@@ -21,7 +21,7 @@
/* 自定义滚动条 */
::-webkit-scrollbar {
@apply w-2 h-2;
@apply h-2 w-2;
}
::-webkit-scrollbar-track {
......@@ -29,7 +29,7 @@
}
::-webkit-scrollbar-thumb {
@apply bg-gray-300 dark:bg-dark-600 rounded-full;
@apply rounded-full bg-gray-300 dark:bg-dark-600;
}
::-webkit-scrollbar-thumb:hover {
......@@ -46,10 +46,10 @@
/* ============ 按钮样式 ============ */
.btn {
@apply inline-flex items-center justify-center gap-2;
@apply px-4 py-2.5 rounded-xl font-medium text-sm;
@apply rounded-xl px-4 py-2.5 text-sm font-medium;
@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 focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:ring-offset-2;
@apply disabled:transform-none disabled:cursor-not-allowed disabled:opacity-50;
@apply active:scale-[0.98];
}
......@@ -80,53 +80,53 @@
}
.btn-sm {
@apply px-3 py-1.5 text-xs rounded-lg;
@apply rounded-lg px-3 py-1.5 text-xs;
}
.btn-lg {
@apply px-6 py-3 text-base rounded-2xl;
@apply rounded-2xl px-6 py-3 text-base;
}
.btn-icon {
@apply p-2.5 rounded-xl;
@apply rounded-xl p-2.5;
}
/* ============ 输入框样式 ============ */
.input {
@apply w-full px-4 py-2.5 rounded-xl text-sm;
@apply w-full rounded-xl px-4 py-2.5 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;
@apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
@apply disabled:cursor-not-allowed disabled:bg-gray-100 dark:disabled:bg-dark-900;
}
.input-error {
@apply border-red-500 focus:ring-red-500/30 focus:border-red-500;
@apply border-red-500 focus:border-red-500 focus:ring-red-500/30;
}
.input-label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5;
@apply mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300;
}
.input-hint {
@apply text-xs text-gray-500 dark:text-dark-400 mt-1;
@apply mt-1 text-xs text-gray-500 dark:text-dark-400;
}
.input-error-text {
@apply text-xs text-red-500 mt-1;
@apply mt-1 text-xs text-red-500;
}
/* Hide number input spinner buttons for cleaner UI */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
input[type='number'] {
-moz-appearance: textfield;
}
......@@ -140,7 +140,7 @@
}
.card-hover {
@apply hover:shadow-card-hover hover:-translate-y-0.5;
@apply hover:-translate-y-0.5 hover:shadow-card-hover;
@apply hover:border-gray-200 dark:hover:border-dark-600;
}
......@@ -158,7 +158,7 @@
}
.stat-icon {
@apply w-12 h-12 rounded-xl;
@apply h-12 w-12 rounded-xl;
@apply flex items-center justify-center;
@apply text-xl;
}
......@@ -188,7 +188,7 @@
}
.stat-trend {
@apply text-xs font-medium flex items-center gap-1 mt-1;
@apply mt-1 flex items-center gap-1 text-xs font-medium;
}
.stat-trend-up {
......@@ -233,7 +233,7 @@
/* ============ 徽章样式 ============ */
.badge {
@apply inline-flex items-center gap-1;
@apply px-2.5 py-0.5 rounded-full text-xs font-medium;
@apply rounded-full px-2.5 py-0.5 text-xs font-medium;
}
.badge-primary {
......@@ -264,7 +264,7 @@
@apply border border-gray-200 dark:border-dark-700;
@apply shadow-lg;
@apply py-1;
@apply animate-scale-in origin-top-right;
@apply origin-top-right animate-scale-in;
}
.dropdown-item {
......@@ -290,7 +290,7 @@
}
.modal-header {
@apply px-6 py-4 border-b border-gray-100 dark:border-dark-700;
@apply border-b border-gray-100 px-6 py-4 dark:border-dark-700;
@apply flex items-center justify-between;
}
......@@ -303,13 +303,13 @@
}
.modal-footer {
@apply px-6 py-4 border-t border-gray-100 dark:border-dark-700;
@apply border-t border-gray-100 px-6 py-4 dark:border-dark-700;
@apply flex items-center justify-end gap-3;
}
/* ============ Toast 通知 ============ */
.toast {
@apply fixed top-4 right-4 z-[100];
@apply fixed right-4 top-4 z-[100];
@apply min-w-[320px] max-w-md;
@apply bg-white dark:bg-dark-800;
@apply rounded-xl shadow-lg;
......@@ -350,11 +350,11 @@
}
.sidebar-nav {
@apply flex-1 overflow-y-auto py-4 px-3;
@apply flex-1 overflow-y-auto px-3 py-4;
}
.sidebar-link {
@apply flex items-center gap-3 px-3 py-2.5 rounded-xl;
@apply flex items-center gap-3 rounded-xl px-3 py-2.5;
@apply text-sm font-medium;
@apply text-gray-600 dark:text-dark-300;
@apply transition-all duration-200;
......@@ -373,7 +373,7 @@
}
.sidebar-section-title {
@apply px-3 mb-2;
@apply mb-2 px-3;
@apply text-xs font-semibold uppercase tracking-wider;
@apply text-gray-400 dark:text-dark-500;
}
......@@ -388,51 +388,51 @@
}
.page-description {
@apply text-sm text-gray-500 dark:text-dark-400 mt-1;
@apply mt-1 text-sm text-gray-500 dark:text-dark-400;
}
/* ============ 空状态 ============ */
.empty-state {
@apply flex flex-col items-center justify-center py-12 px-4;
@apply flex flex-col items-center justify-center px-4 py-12;
@apply text-center;
}
.empty-state-icon {
@apply w-16 h-16 mb-4;
@apply mb-4 h-16 w-16;
@apply text-gray-300 dark:text-dark-600;
}
.empty-state-title {
@apply text-lg font-medium text-gray-900 dark:text-white mb-1;
@apply mb-1 text-lg font-medium text-gray-900 dark:text-white;
}
.empty-state-description {
@apply text-sm text-gray-500 dark:text-dark-400 max-w-sm;
@apply max-w-sm text-sm text-gray-500 dark:text-dark-400;
}
/* ============ 加载状态 ============ */
.spinner {
@apply w-5 h-5 border-2 border-current border-t-transparent rounded-full;
@apply h-5 w-5 rounded-full border-2 border-current border-t-transparent;
@apply animate-spin;
}
.skeleton {
@apply bg-gray-200 dark:bg-dark-700 rounded animate-pulse;
@apply animate-pulse rounded bg-gray-200 dark:bg-dark-700;
}
/* ============ 分隔线 ============ */
.divider {
@apply h-px bg-gray-200 dark:bg-dark-700 my-4;
@apply my-4 h-px bg-gray-200 dark:bg-dark-700;
}
/* ============ 标签页 ============ */
.tabs {
@apply flex gap-1 p-1;
@apply bg-gray-100 dark:bg-dark-800 rounded-xl;
@apply rounded-xl bg-gray-100 dark:bg-dark-800;
}
.tab {
@apply px-4 py-2 rounded-lg text-sm font-medium;
@apply rounded-lg px-4 py-2 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;
......@@ -446,7 +446,7 @@
/* ============ 进度条 ============ */
.progress {
@apply h-2 bg-gray-200 dark:bg-dark-700 rounded-full overflow-hidden;
@apply h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-dark-700;
}
.progress-bar {
......@@ -456,7 +456,7 @@
/* ============ 开关 ============ */
.switch {
@apply relative w-11 h-6 rounded-full cursor-pointer;
@apply relative h-6 w-11 cursor-pointer rounded-full;
@apply bg-gray-300 dark:bg-dark-600;
@apply transition-colors duration-200;
}
......@@ -466,7 +466,7 @@
}
.switch-thumb {
@apply absolute top-0.5 left-0.5 w-5 h-5 rounded-full;
@apply absolute left-0.5 top-0.5 h-5 w-5 rounded-full;
@apply bg-white shadow-sm;
@apply transition-transform duration-200;
}
......@@ -479,14 +479,14 @@
.code {
@apply font-mono text-sm;
@apply bg-gray-100 dark:bg-dark-800;
@apply px-1.5 py-0.5 rounded;
@apply rounded px-1.5 py-0.5;
@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;
@apply overflow-x-auto rounded-xl p-4;
}
}
......@@ -498,7 +498,7 @@
/* 玻璃效果 */
.glass {
@apply bg-white/80 dark:bg-dark-800/80 backdrop-blur-xl;
@apply bg-white/80 backdrop-blur-xl dark:bg-dark-800/80;
}
/* 隐藏滚动条 */
......
......@@ -5,726 +5,726 @@
// ==================== User & Auth Types ====================
export interface User {
id: number;
username: string;
wechat: string;
notes: 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)
subscriptions?: UserSubscription[]; // User's active subscriptions
created_at: string;
updated_at: string;
id: number
username: string
wechat: string
notes: 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)
subscriptions?: UserSubscription[] // User's active subscriptions
created_at: string
updated_at: string
}
export interface LoginRequest {
email: string;
password: string;
turnstile_token?: string;
email: string
password: string
turnstile_token?: string
}
export interface RegisterRequest {
email: string;
password: string;
verify_code?: string;
turnstile_token?: string;
email: string
password: string
verify_code?: string
turnstile_token?: string
}
export interface SendVerifyCodeRequest {
email: string;
turnstile_token?: string;
email: string
turnstile_token?: string
}
export interface SendVerifyCodeResponse {
message: string;
countdown: number;
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;
doc_url: string;
version: string;
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
doc_url: string
version: string
}
export interface AuthResponse {
access_token: string;
token_type: string;
user: User;
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;
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;
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;
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;
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';
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;
};
name_pattern?: string
types?: ProxyNode['type'][]
min_latency?: number
max_latency?: number
available_only?: boolean
}
sort?: {
by: 'name' | 'latency' | 'type';
order: 'asc' | 'desc';
};
by: 'name' | 'latency' | 'type'
order: 'asc' | 'desc'
}
}
export interface ConversionResult {
url: string; // URL to download the converted subscription
expires_at: string;
node_count: number;
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;
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;
total_subscriptions: number
total_nodes: number
active_subscriptions: number
total_conversions: number
last_conversion: string | null
}
// ==================== API Response Types ====================
export interface ApiResponse<T = unknown> {
code: number;
message: string;
data: T;
code: number
message: string
data: T
}
export interface ApiError {
detail: string;
code?: string;
field?: string;
detail: string
code?: string
field?: string
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
page_size: number;
pages: number;
items: T[]
total: number
page: number
page_size: number
pages: number
}
// ==================== UI State Types ====================
export type ToastType = 'success' | 'error' | 'info' | 'warning';
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
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[];
sidebarCollapsed: boolean
loading: boolean
toasts: Toast[]
}
// ==================== Validation Types ====================
export interface ValidationError {
field: string;
message: string;
field: string
message: string
}
// ==================== Table/List Types ====================
export interface SortConfig {
key: string;
order: 'asc' | 'desc';
key: string
order: 'asc' | 'desc'
}
export interface FilterConfig {
[key: string]: string | number | boolean | null | undefined;
[key: string]: string | number | boolean | null | undefined
}
export interface PaginationConfig {
page: number;
page_size: number;
page: number
page_size: number
}
// ==================== API Key & Group Types ====================
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini';
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini'
export type SubscriptionType = 'standard' | 'subscription';
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;
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;
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
name: string
group_id?: number | null
custom_key?: string // 可选的自定义API Key
}
export interface UpdateApiKeyRequest {
name?: string;
group_id?: number | null;
status?: 'active' | 'inactive';
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;
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';
name?: string
description?: string | null
platform?: GroupPlatform
rate_multiplier?: number
is_exclusive?: boolean
status?: 'active' | 'inactive'
}
// ==================== Account & Proxy Types ====================
export type AccountPlatform = 'anthropic' | 'openai';
export type AccountType = 'oauth' | 'setup-token' | 'apikey';
export type OAuthAddMethod = 'oauth' | 'setup-token';
export type ProxyProtocol = 'http' | 'https' | 'socks5';
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini'
export type AccountType = 'oauth' | 'setup-token' | 'apikey'
export type OAuthAddMethod = 'oauth' | 'setup-token'
export type ProxyProtocol = 'http' | 'https' | 'socks5'
// Claude Model type (returned by /v1/models and account models API)
export interface ClaudeModel {
id: string;
type: string;
display_name: string;
created_at: string;
id: string
type: string
display_name: string
created_at: string
}
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;
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>;
extra?: CodexUsageSnapshot & Record<string, unknown>; // Extra fields including Codex usage
proxy_id: number | null;
concurrency: number;
current_concurrency?: number; // Real-time concurrency count from Redis
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
groups?: Group[]; // Preloaded group objects
id: number
name: string
platform: AccountPlatform
type: AccountType
credentials?: Record<string, unknown>
extra?: CodexUsageSnapshot & Record<string, unknown> // Extra fields including Codex usage
proxy_id: number | null
concurrency: number
current_concurrency?: number // Real-time concurrency count from Redis
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
groups?: Group[] // Preloaded group objects
// Rate limit & scheduling fields
schedulable: boolean;
rate_limited_at: string | null;
rate_limit_reset_at: string | null;
overload_until: string | null;
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;
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;
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; // 窗口期统计(从窗口开始到当前的使用量)
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;
updated_at: string | null
five_hour: UsageProgress | null
seven_day: UsageProgress | null
seven_day_sonnet: UsageProgress | null
}
// OpenAI Codex usage snapshot (from response headers)
export interface CodexUsageSnapshot {
// Legacy fields (kept for backwards compatibility)
// NOTE: The naming is ambiguous - actual window type is determined by window_minutes value
codex_primary_used_percent?: number; // Usage percentage (check window_minutes for actual window type)
codex_primary_reset_after_seconds?: number; // Seconds until reset
codex_primary_window_minutes?: number; // Window in minutes
codex_secondary_used_percent?: number; // Usage percentage (check window_minutes for actual window type)
codex_secondary_reset_after_seconds?: number; // Seconds until reset
codex_secondary_window_minutes?: number; // Window in minutes
codex_primary_over_secondary_percent?: number; // Overflow ratio
codex_primary_used_percent?: number // Usage percentage (check window_minutes for actual window type)
codex_primary_reset_after_seconds?: number // Seconds until reset
codex_primary_window_minutes?: number // Window in minutes
codex_secondary_used_percent?: number // Usage percentage (check window_minutes for actual window type)
codex_secondary_reset_after_seconds?: number // Seconds until reset
codex_secondary_window_minutes?: number // Window in minutes
codex_primary_over_secondary_percent?: number // Overflow ratio
// Canonical fields (normalized by backend, use these preferentially)
codex_5h_used_percent?: number; // 5-hour window usage percentage
codex_5h_reset_after_seconds?: number; // Seconds until 5h window reset
codex_5h_window_minutes?: number; // 5h window in minutes (should be ~300)
codex_7d_used_percent?: number; // 7-day window usage percentage
codex_7d_reset_after_seconds?: number; // Seconds until 7d window reset
codex_7d_window_minutes?: number; // 7d window in minutes (should be ~10080)
codex_5h_used_percent?: number // 5-hour window usage percentage
codex_5h_reset_after_seconds?: number // Seconds until 5h window reset
codex_5h_window_minutes?: number // 5h window in minutes (should be ~300)
codex_7d_used_percent?: number // 7-day window usage percentage
codex_7d_reset_after_seconds?: number // Seconds until 7d window reset
codex_7d_window_minutes?: number // 7d window in minutes (should be ~10080)
codex_usage_updated_at?: string; // Last update timestamp
codex_usage_updated_at?: string // Last update timestamp
}
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[];
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;
type?: AccountType;
credentials?: Record<string, unknown>;
extra?: Record<string, string>;
proxy_id?: number | null;
concurrency?: number;
priority?: number;
status?: 'active' | 'inactive';
group_ids?: number[];
name?: string
type?: AccountType
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;
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';
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';
export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription'
// 消费类型: 0=钱包余额, 1=订阅套餐
export type BillingType = 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;
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; // 关联的分组
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; // 订阅类型专用
count: number
type: RedeemCodeType
value: number
group_id?: number | null // 订阅类型专用
validity_days?: number // 订阅类型专用
}
export interface RedeemCodeRequest {
code: string;
code: string
}
// ==================== Dashboard & Statistics ====================
export interface DashboardStats {
// 用户统计
total_users: number;
today_new_users: number; // 今日新增用户数
active_users: number; // 今日有请求的用户数
total_users: number
today_new_users: number // 今日新增用户数
active_users: number // 今日有请求的用户数
// API Key 统计
total_api_keys: number;
active_api_keys: number; // 状态为 active 的 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; // 过载账户数
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; // 累计实际扣除
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; // 今日实际扣除
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; // 系统运行时间(秒)
average_duration_ms: number // 平均响应时间
uptime: number // 系统运行时间(秒)
// 性能指标
rpm: number; // 近5分钟平均每分钟请求数
tpm: number; // 近5分钟平均每分钟Token数
rpm: number // 近5分钟平均每分钟请求数
tpm: number // 近5分钟平均每分钟Token数
}
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>;
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; // 实际扣除
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; // 实际扣除
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;
email: string;
requests: number;
tokens: number;
cost: number; // 标准计费
actual_cost: number; // 实际扣除
date: string
user_id: number
email: 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;
date: string
api_key_id: number
key_name: string
requests: number
tokens: number
}
// ==================== Admin User Management ====================
export interface UpdateUserRequest {
email?: string;
password?: string;
username?: string;
wechat?: string;
notes?: string;
role?: 'admin' | 'user';
balance?: number;
concurrency?: number;
status?: 'active' | 'disabled';
allowed_groups?: number[] | null;
email?: string
password?: string
username?: string
wechat?: string
notes?: string
role?: 'admin' | 'user'
balance?: number
concurrency?: number
status?: 'active' | 'disabled'
allowed_groups?: number[] | null
}
export interface ChangePasswordRequest {
old_password: string;
new_password: string;
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;
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;
subscription_id: number
daily: {
used: number;
limit: number | null;
percentage: number;
reset_in_seconds: number | null;
} | null;
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;
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;
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;
user_id: number
group_id: number
validity_days?: number
}
export interface BulkAssignSubscriptionRequest {
user_ids: number[];
group_id: number;
validity_days?: number;
user_ids: number[]
group_id: number
validity_days?: number
}
export interface ExtendSubscriptionRequest {
days: number;
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;
page?: number
page_size?: number
api_key_id?: number
user_id?: number
start_date?: string
end_date?: string
}
// ==================== Account Usage Statistics ====================
export interface AccountUsageHistory {
date: string;
label: string;
requests: number;
tokens: number;
cost: number;
actual_cost: number;
date: string
label: string
requests: number
tokens: number
cost: number
actual_cost: number
}
export interface AccountUsageSummary {
days: number;
actual_days_used: number;
total_cost: number;
total_standard_cost: number;
total_requests: number;
total_tokens: number;
avg_daily_cost: number;
avg_daily_requests: number;
avg_daily_tokens: number;
avg_duration_ms: number;
days: number
actual_days_used: number
total_cost: number
total_standard_cost: number
total_requests: number
total_tokens: number
avg_daily_cost: number
avg_daily_requests: number
avg_daily_tokens: number
avg_duration_ms: number
today: {
date: string;
cost: number;
requests: number;
tokens: number;
} | null;
date: string
cost: number
requests: number
tokens: number
} | null
highest_cost_day: {
date: string;
label: string;
cost: number;
requests: number;
} | null;
date: string
label: string
cost: number
requests: number
} | null
highest_request_day: {
date: string;
label: string;
requests: number;
cost: number;
} | null;
date: string
label: string
requests: number
cost: number
} | null
}
export interface AccountUsageStatsResponse {
history: AccountUsageHistory[];
summary: AccountUsageSummary;
models: ModelStat[];
history: AccountUsageHistory[]
summary: AccountUsageSummary
models: ModelStat[]
}
......@@ -90,7 +90,10 @@ export function formatBytes(bytes: number, decimals: number = 2): string {
* @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 {
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)
......
<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">
<div
class="relative min-h-screen 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 class="pointer-events-none absolute inset-0 overflow-hidden">
<div
class="absolute -right-40 -top-40 h-96 w-96 rounded-full bg-primary-400/20 blur-3xl"
></div>
<div
class="absolute -bottom-40 -left-40 h-96 w-96 rounded-full bg-primary-500/15 blur-3xl"
></div>
<div
class="absolute left-1/3 top-1/4 h-72 w-72 rounded-full bg-primary-300/10 blur-3xl"
></div>
<div
class="absolute bottom-1/4 right-1/4 h-64 w-64 rounded-full bg-primary-400/10 blur-3xl"
></div>
<div
class="absolute inset-0 bg-[linear-gradient(rgba(20,184,166,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(20,184,166,0.03)_1px,transparent_1px)] bg-[size:64px_64px]"
></div>
</div>
<!-- Header -->
<header class="relative z-20 px-6 py-4">
<nav class="max-w-6xl mx-auto flex items-center justify-between">
<nav class="mx-auto flex max-w-6xl 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 class="h-10 w-10 overflow-hidden rounded-xl shadow-md">
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
</div>
</div>
......@@ -30,25 +42,57 @@
:href="docUrl"
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"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-dark-400 dark:hover:bg-dark-800 dark:hover:text-white"
:title="t('home.viewDocs')"
>
<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="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
<svg
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
/>
</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"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-dark-400 dark:hover:bg-dark-800 dark:hover:text-white"
:title="isDark ? t('home.switchToLight') : t('home.switchToDark')"
>
<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
v-if="isDark"
class="h-5 w-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
v-else
class="h-5 w-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>
......@@ -56,20 +100,32 @@
<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"
class="inline-flex items-center gap-1.5 rounded-full bg-gray-900 py-1 pl-1 pr-2.5 transition-colors hover:bg-gray-800 dark:bg-gray-800 dark:hover:bg-gray-700"
>
<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">
<span
class="flex h-5 w-5 items-center justify-center rounded-full bg-gradient-to-br from-primary-400 to-primary-600 text-[10px] font-semibold text-white"
>
{{ userInitial }}
</span>
<span class="text-xs font-medium text-white">{{ t('home.dashboard') }}</span>
<svg class="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
class="h-3 w-3 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4.5 19.5l15-15m0 0H8.25m11.25 0v11.25"
/>
</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"
class="inline-flex items-center rounded-full bg-gray-900 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-gray-800 dark:bg-gray-800 dark:hover:bg-gray-700"
>
{{ t('home.login') }}
</router-link>
......@@ -79,15 +135,17 @@
<!-- Main Content -->
<main class="relative z-10 px-6 py-16">
<div class="max-w-6xl mx-auto">
<div class="mx-auto max-w-6xl">
<!-- Hero Section - Left/Right Layout -->
<div class="flex flex-col lg:flex-row items-center justify-between gap-12 lg:gap-16 mb-12">
<div class="mb-12 flex flex-col items-center justify-between gap-12 lg:flex-row lg:gap-16">
<!-- 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">
<h1
class="mb-4 text-4xl font-bold text-gray-900 dark:text-white md:text-5xl lg:text-6xl"
>
{{ siteName }}
</h1>
<p class="text-lg md:text-xl text-gray-600 dark:text-dark-300 mb-8">
<p class="mb-8 text-lg text-gray-600 dark:text-dark-300 md:text-xl">
{{ siteSubtitle }}
</p>
......@@ -98,15 +156,25 @@
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
class="ml-2 h-5 w-5"
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="flex flex-1 justify-center lg:justify-end">
<div class="terminal-container">
<div class="terminal-window">
<!-- Window header -->
......@@ -144,117 +212,239 @@
</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" />
<div class="mb-12 flex flex-wrap items-center justify-center gap-4 md:gap-6">
<div
class="inline-flex items-center gap-2.5 rounded-full border border-gray-200/50 bg-white/80 px-5 py-2.5 shadow-sm backdrop-blur-sm dark:border-dark-700/50 dark:bg-dark-800/80"
>
<svg
class="h-4 w-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>
<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" />
<div
class="inline-flex items-center gap-2.5 rounded-full border border-gray-200/50 bg-white/80 px-5 py-2.5 shadow-sm backdrop-blur-sm dark:border-dark-700/50 dark:bg-dark-800/80"
>
<svg
class="h-4 w-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>
<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" />
<div
class="inline-flex items-center gap-2.5 rounded-full border border-gray-200/50 bg-white/80 px-5 py-2.5 shadow-sm backdrop-blur-sm dark:border-dark-700/50 dark:bg-dark-800/80"
>
<svg
class="h-4 w-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>
<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">
<div class="mb-12 grid gap-6 md:grid-cols-3">
<!-- 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" />
<div
class="group rounded-2xl border border-gray-200/50 bg-white/60 p-6 backdrop-blur-sm transition-all duration-300 hover:shadow-xl hover:shadow-primary-500/10 dark:border-dark-700/50 dark:bg-dark-800/60"
>
<div
class="mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 shadow-lg shadow-blue-500/30 transition-transform group-hover:scale-110"
>
<svg
class="h-6 w-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="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">
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
{{ t('home.features.unifiedGateway') }}
</h3>
<p class="text-sm leading-relaxed text-gray-600 dark:text-dark-400">
{{ t('home.features.unifiedGatewayDesc') }}
</p>
</div>
<!-- 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" />
<div
class="group rounded-2xl border border-gray-200/50 bg-white/60 p-6 backdrop-blur-sm transition-all duration-300 hover:shadow-xl hover:shadow-primary-500/10 dark:border-dark-700/50 dark:bg-dark-800/60"
>
<div
class="mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary-500 to-primary-600 shadow-lg shadow-primary-500/30 transition-transform group-hover:scale-110"
>
<svg
class="h-6 w-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
/>
</svg>
</div>
<h3 class="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">
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
{{ t('home.features.multiAccount') }}
</h3>
<p class="text-sm leading-relaxed text-gray-600 dark:text-dark-400">
{{ t('home.features.multiAccountDesc') }}
</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" />
<div
class="group rounded-2xl border border-gray-200/50 bg-white/60 p-6 backdrop-blur-sm transition-all duration-300 hover:shadow-xl hover:shadow-primary-500/10 dark:border-dark-700/50 dark:bg-dark-800/60"
>
<div
class="mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500 to-purple-600 shadow-lg shadow-purple-500/30 transition-transform group-hover:scale-110"
>
<svg
class="h-6 w-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="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">
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
{{ t('home.features.balanceQuota') }}
</h3>
<p class="text-sm leading-relaxed text-gray-600 dark:text-dark-400">
{{ t('home.features.balanceQuotaDesc') }}
</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">
<div class="mb-8 text-center">
<h2 class="mb-3 text-2xl font-bold text-gray-900 dark:text-white">
{{ t('home.providers.title') }}
</h2>
<p class="text-sm text-gray-600 dark:text-dark-400">
{{ t('home.providers.description') }}
</p>
</div>
<div class="flex flex-wrap items-center justify-center gap-4 mb-16">
<div class="mb-16 flex flex-wrap items-center justify-center gap-4">
<!-- 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
class="flex items-center gap-2 rounded-xl border border-primary-200 bg-white/60 px-5 py-3 ring-1 ring-primary-500/20 backdrop-blur-sm dark:border-primary-800 dark:bg-dark-800/60"
>
<div
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-orange-400 to-orange-500"
>
<span class="text-xs font-bold text-white">C</span>
</div>
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">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>
<span
class="rounded bg-primary-100 px-1.5 py-0.5 text-[10px] font-medium text-primary-600 dark:bg-primary-900/30 dark:text-primary-400"
>{{ t('home.providers.supported') }}</span
>
</div>
<!-- GPT - Supported -->
<div class="flex items-center gap-2 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-green-500 to-green-600 flex items-center justify-center">
<span class="text-white text-xs font-bold">G</span>
<div
class="flex items-center gap-2 rounded-xl border border-primary-200 bg-white/60 px-5 py-3 ring-1 ring-primary-500/20 backdrop-blur-sm dark:border-primary-800 dark:bg-dark-800/60"
>
<div
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-green-500 to-green-600"
>
<span class="text-xs font-bold text-white">G</span>
</div>
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">GPT</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>
<span
class="rounded bg-primary-100 px-1.5 py-0.5 text-[10px] font-medium text-primary-600 dark:bg-primary-900/30 dark:text-primary-400"
>{{ t('home.providers.supported') }}</span
>
</div>
<!-- Gemini - 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
class="flex items-center gap-2 rounded-xl border border-gray-200/50 bg-white/40 px-5 py-3 opacity-60 backdrop-blur-sm dark:border-dark-700/50 dark:bg-dark-800/40"
>
<div
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-blue-600"
>
<span class="text-xs font-bold text-white">G</span>
</div>
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">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>
<span
class="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-dark-700 dark:text-dark-400"
>{{ t('home.providers.soon') }}</span
>
</div>
<!-- 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
class="flex items-center gap-2 rounded-xl border border-gray-200/50 bg-white/40 px-5 py-3 opacity-60 backdrop-blur-sm dark:border-dark-700/50 dark:bg-dark-800/40"
>
<div
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-gray-500 to-gray-600"
>
<span class="text-xs font-bold text-white">+</span>
</div>
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">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>
<span
class="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-dark-700 dark:text-dark-400"
>{{ t('home.providers.soon') }}</span
>
</div>
</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 flex flex-col sm:flex-row items-center justify-center gap-4 text-center sm:text-left">
<footer class="relative z-10 border-t border-gray-200/50 px-6 py-8 dark:border-dark-800/50">
<div
class="mx-auto flex max-w-6xl flex-col items-center justify-center gap-4 text-center sm:flex-row sm:text-left"
>
<p class="text-sm text-gray-500 dark:text-dark-400">
&copy; {{ currentYear }} {{ siteName }}. {{ t('home.footer.allRightsReserved') }}
</p>
......@@ -264,7 +454,7 @@
:href="docUrl"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-gray-500 hover:text-gray-700 dark:text-dark-400 dark:hover:text-white transition-colors"
class="text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-white"
>
{{ t('home.docs') }}
</a>
......@@ -272,7 +462,7 @@
:href="githubUrl"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-gray-500 hover:text-gray-700 dark:text-dark-400 dark:hover:text-white transition-colors"
class="text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-white"
>
GitHub
</a>
......@@ -283,71 +473,74 @@
</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';
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 { t } = useI18n()
const authStore = useAuthStore();
const authStore = useAuthStore()
// Site settings
const siteName = ref('Sub2API');
const siteLogo = ref('');
const siteSubtitle = ref('AI API Gateway Platform');
const docUrl = ref('');
const siteName = ref('Sub2API')
const siteLogo = ref('')
const siteSubtitle = ref('AI API Gateway Platform')
const docUrl = ref('')
// Theme
const isDark = ref(document.documentElement.classList.contains('dark'));
const isDark = ref(document.documentElement.classList.contains('dark'))
// GitHub URL
const githubUrl = 'https://github.com/Wei-Shaw/sub2api';
const githubUrl = 'https://github.com/Wei-Shaw/sub2api'
// Auth state
const isAuthenticated = computed(() => authStore.isAuthenticated);
const isAuthenticated = computed(() => authStore.isAuthenticated)
const userInitial = computed(() => {
const user = authStore.user;
if (!user || !user.email) return '';
return user.email.charAt(0).toUpperCase();
});
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());
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');
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');
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();
initTheme()
// Check auth state
authStore.checkAuth();
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';
docUrl.value = settings.doc_url || '';
const settings = await getPublicSettings()
siteName.value = settings.site_name || 'Sub2API'
siteLogo.value = settings.site_logo || ''
siteSubtitle.value = settings.site_subtitle || 'AI API Gateway Platform'
docUrl.value = settings.doc_url || ''
} catch (error) {
console.error('Failed to load public settings:', error);
console.error('Failed to load public settings:', error)
}
});
})
</script>
<style scoped>
......@@ -395,9 +588,15 @@ onMounted(async () => {
border-radius: 50%;
}
.btn-close { background: #ef4444; }
.btn-minimize { background: #eab308; }
.btn-maximize { background: #22c55e; }
.btn-close {
background: #ef4444;
}
.btn-minimize {
background: #eab308;
}
.btn-maximize {
background: #22c55e;
}
.terminal-title {
flex: 1;
......@@ -425,21 +624,47 @@ onMounted(async () => {
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; }
.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); }
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-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);
......@@ -447,7 +672,9 @@ onMounted(async () => {
border-radius: 4px;
font-weight: 600;
}
.code-response { color: #fbbf24; }
.code-response {
color: #fbbf24;
}
/* Blinking Cursor */
.cursor {
......@@ -459,8 +686,14 @@ onMounted(async () => {
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
/* Dark mode adjustments */
......
<template>
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-dark-950 px-4 relative overflow-hidden">
<div
class="relative flex min-h-screen items-center justify-center overflow-hidden bg-gray-50 px-4 dark:bg-dark-950"
>
<!-- 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 class="pointer-events-none absolute inset-0 overflow-hidden">
<div
class="absolute -right-40 -top-40 h-80 w-80 rounded-full bg-primary-400/10 blur-3xl"
></div>
<div
class="absolute -bottom-40 -left-40 h-80 w-80 rounded-full bg-primary-500/10 blur-3xl"
></div>
</div>
<div class="max-w-md w-full text-center relative z-10">
<div class="relative z-10 w-full max-w-md text-center">
<!-- 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>
<span class="text-[12rem] font-bold leading-none text-gray-100 dark:text-dark-800"
>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" />
<div
class="flex h-24 w-24 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-500 to-primary-600 shadow-lg shadow-primary-500/30"
>
<svg
class="h-12 w-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>
......@@ -23,31 +43,43 @@
<!-- Text Content -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-3">
Page Not Found
</h1>
<h1 class="mb-3 text-2xl font-bold text-gray-900 dark:text-white">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" />
<div class="flex flex-col justify-center gap-3 sm:flex-row">
<button @click="goBack" class="btn btn-secondary">
<svg
class="mr-2 h-5 w-5"
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" />
<router-link to="/dashboard" class="btn btn-primary">
<svg
class="mr-2 h-5 w-5"
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>
......@@ -56,7 +88,10 @@
<!-- 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">
<a
href="#"
class="text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
Contact support
</a>
</p>
......@@ -65,11 +100,11 @@
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router';
import { useRouter } from 'vue-router'
const router = useRouter();
const router = useRouter()
function goBack(): void {
router.back();
router.back()
}
</script>
......@@ -10,26 +10,42 @@
:title="t('common.refresh')"
>
<svg
:class="['w-5 h-5', loading ? 'animate-spin' : '']"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"
:class="['h-5 w-5', loading ? 'animate-spin' : '']"
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" />
<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="showCrsSyncModal = true"
class="btn btn-secondary"
title="从 CRS 同步"
>
<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="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" />
<button @click="showCrsSyncModal = true" class="btn btn-secondary" title="从 CRS 同步">
<svg
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
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>
</button>
<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">
<button @click="showCreateModal = true" class="btn btn-primary">
<svg
class="mr-2 h-5 w-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.accounts.createAccount') }}
......@@ -38,9 +54,19 @@
<!-- 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" />
<div class="relative max-w-md flex-1">
<svg
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 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"
......@@ -76,7 +102,10 @@
</div>
<!-- Bulk Actions Bar -->
<div v-if="selectedAccountIds.length > 0" class="card bg-primary-50 dark:bg-primary-900/20 border-primary-200 dark:border-primary-800 px-4 py-3">
<div
v-if="selectedAccountIds.length > 0"
class="card border-primary-200 bg-primary-50 px-4 py-3 dark:border-primary-800 dark:bg-primary-900/20"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-primary-900 dark:text-primary-100">
......@@ -97,22 +126,35 @@
</button>
</div>
<div class="flex items-center gap-2">
<button
@click="handleBulkDelete"
class="btn btn-danger btn-sm"
>
<svg class="w-4 h-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" 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" />
<button @click="handleBulkDelete" class="btn btn-danger btn-sm">
<svg
class="mr-1.5 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
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>
{{ t('admin.accounts.bulkActions.delete') }}
</button>
<button
@click="showBulkEditModal = true"
class="btn btn-primary btn-sm"
>
<svg class="w-4 h-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" 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" />
<button @click="showBulkEditModal = true" class="btn btn-primary btn-sm">
<svg
class="mr-1.5 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
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>
{{ t('admin.accounts.bulkActions.edit') }}
</button>
......@@ -144,7 +186,7 @@
<div class="flex items-center gap-1.5">
<span
:class="[
'inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium',
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium',
(row.current_concurrency || 0) >= row.concurrency
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
: (row.current_concurrency || 0) > 0
......@@ -152,8 +194,18 @@
: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-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="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" />
<svg
class="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<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"
/>
</svg>
<span class="font-mono">{{ row.current_concurrency || 0 }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
......@@ -170,13 +222,17 @@
<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="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 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-dark-800"
: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'
: 'bg-gray-200 hover:bg-gray-300 dark:bg-dark-600 dark:hover:bg-dark-500'
]"
:title="row.schedulable ? t('admin.accounts.schedulableEnabled') : t('admin.accounts.schedulableDisabled')"
: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"
......@@ -224,82 +280,160 @@
<button
v-if="row.status === 'error'"
@click="handleResetStatus(row)"
class="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-red-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
:title="t('admin.accounts.resetStatus')"
>
<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="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3" />
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"
/>
</svg>
</button>
<!-- 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"
class="rounded-lg p-2 text-amber-500 transition-colors hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-900/20 dark:hover:text-amber-400"
: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
class="h-4 w-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"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
: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
class="h-4 w-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>
<!-- View Stats button -->
<button
@click="handleViewStats(row)"
class="p-2 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/20 text-gray-500 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-indigo-50 hover:text-indigo-600 dark:hover:bg-indigo-900/20 dark:hover:text-indigo-400"
:title="t('admin.accounts.viewStats')"
>
<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 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
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
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>
</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"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
: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
class="h-4 w-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"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400"
: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
class="h-4 w-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"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
: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
class="h-4 w-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"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
: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
class="h-4 w-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>
......@@ -354,18 +488,10 @@
/>
<!-- Test Account Modal -->
<AccountTestModal
:show="showTestModal"
:account="testingAccount"
@close="closeTestModal"
/>
<AccountTestModal :show="showTestModal" :account="testingAccount" @close="closeTestModal" />
<!-- Account Stats Modal -->
<AccountStatsModal
:show="showStatsModal"
:account="statsAccount"
@close="closeStatsModal"
/>
<AccountStatsModal :show="showStatsModal" :account="statsAccount" @close="closeStatsModal" />
<!-- Delete Confirmation Dialog -->
<ConfirmDialog
......@@ -420,7 +546,14 @@ 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, BulkEditAccountModal, ReAuthAccountModal, AccountStatsModal, SyncFromCrsModal } from '@/components/account'
import {
CreateAccountModal,
EditAccountModal,
BulkEditAccountModal,
ReAuthAccountModal,
AccountStatsModal,
SyncFromCrsModal
} from '@/components/account'
import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue'
import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
......@@ -452,7 +585,8 @@ const columns = computed<Column[]>(() => [
const platformOptions = computed(() => [
{ value: '', label: t('admin.accounts.allPlatforms') },
{ value: 'anthropic', label: t('admin.accounts.platforms.anthropic') },
{ value: 'openai', label: t('admin.accounts.platforms.openai') }
{ value: 'openai', label: t('admin.accounts.platforms.openai') },
{ value: 'gemini', label: t('admin.accounts.platforms.gemini') }
])
const typeOptions = computed(() => [
......@@ -478,7 +612,7 @@ const searchQuery = ref('')
const filters = reactive({
platform: '',
type: '',
status: '',
status: ''
})
const pagination = reactive({
page: 1,
......@@ -508,7 +642,7 @@ const bulkDeleting = ref(false)
// Bulk selection
const selectedAccountIds = ref<number[]>([])
const selectCurrentPageAccounts = () => {
const pageIds = accounts.value.map(account => account.id)
const pageIds = accounts.value.map((account) => account.id)
const merged = new Set([...selectedAccountIds.value, ...pageIds])
selectedAccountIds.value = Array.from(merged)
}
......@@ -528,16 +662,12 @@ const isOverloaded = (account: Account): boolean => {
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
}
)
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
......@@ -653,8 +783,8 @@ const confirmBulkDelete = async () => {
bulkDeleting.value = true
const ids = [...selectedAccountIds.value]
try {
const results = await Promise.allSettled(ids.map(id => adminAPI.accounts.delete(id)))
const success = results.filter(result => result.status === 'fulfilled').length
const results = await Promise.allSettled(ids.map((id) => adminAPI.accounts.delete(id)))
const success = results.filter((result) => result.status === 'fulfilled').length
const failed = results.length - success
if (failed === 0) {
......@@ -708,7 +838,7 @@ 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)
const index = accounts.value.findIndex((a) => a.id === account.id)
if (index !== -1) {
accounts.value[index] = updatedAccount
}
......@@ -718,7 +848,9 @@ const handleToggleSchedulable = async (account: Account) => {
: t('admin.accounts.schedulableDisabled')
)
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToToggleSchedulable'))
appStore.showError(
error.response?.data?.detail || t('admin.accounts.failedToToggleSchedulable')
)
console.error('Error toggling schedulable:', error)
} finally {
togglingSchedulable.value = null
......
......@@ -12,15 +12,31 @@
<!-- 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" />
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
<svg
class="h-5 w-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>
<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>
......@@ -28,17 +44,35 @@
<!-- 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" />
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30">
<svg
class="h-5 w-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 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>
<span class="text-green-600 dark:text-green-400"
>{{ stats.normal_accounts }} {{ t('common.active') }}</span
>
<span v-if="stats.error_accounts > 0" class="ml-1 text-red-500"
>{{ stats.error_accounts }} {{ t('common.error') }}</span
>
</p>
</div>
</div>
......@@ -47,15 +81,31 @@
<!-- 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" />
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30">
<svg
class="h-5 w-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>
<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>
......@@ -63,15 +113,31 @@
<!-- 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" />
<div class="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
<svg
class="h-5 w-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>
<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>
......@@ -82,17 +148,40 @@
<!-- 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" />
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30">
<svg
class="h-5 w-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 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>
<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>
......@@ -101,17 +190,40 @@
<!-- 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" />
<div class="rounded-lg bg-indigo-100 p-2 dark:bg-indigo-900/30">
<svg
class="h-5 w-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 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>
<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>
......@@ -120,19 +232,35 @@
<!-- Performance (RPM/TPM) -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-violet-100 dark:bg-violet-900/30">
<svg class="w-5 h-5 text-violet-600 dark:text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
<div class="rounded-lg bg-violet-100 p-2 dark:bg-violet-900/30">
<svg
class="h-5 w-5 text-violet-600 dark:text-violet-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
<div class="flex-1">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.performance') }}</p>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.dashboard.performance') }}
</p>
<div class="flex items-baseline gap-2">
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats.rpm) }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">
{{ formatTokens(stats.rpm) }}
</p>
<span class="text-xs text-gray-500 dark:text-gray-400">RPM</span>
</div>
<div class="flex items-baseline gap-2">
<p class="text-sm font-semibold text-violet-600 dark:text-violet-400">{{ formatTokens(stats.tpm) }}</p>
<p class="text-sm font-semibold text-violet-600 dark:text-violet-400">
{{ formatTokens(stats.tpm) }}
</p>
<span class="text-xs text-gray-500 dark:text-gray-400">TPM</span>
</div>
</div>
......@@ -142,15 +270,31 @@
<!-- 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" />
<div class="rounded-lg bg-rose-100 p-2 dark:bg-rose-900/30">
<svg
class="h-5 w-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>
<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>
......@@ -162,15 +306,19 @@
<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>
<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="ml-auto flex items-center gap-2">
<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"
......@@ -184,22 +332,21 @@
<!-- Charts Grid -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<ModelDistributionChart
:model-stats="modelStats"
:loading="chartsLoading"
/>
<TokenUsageTrend
:trend-data="trendData"
:loading="chartsLoading"
/>
<ModelDistributionChart :model-stats="modelStats" :loading="chartsLoading" />
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
</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>
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ 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">
<div
v-else
class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{ t('admin.dashboard.noDataAvailable') }}
</div>
</div>
......@@ -268,7 +415,7 @@ 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') },
{ value: 'hour', label: t('admin.dashboard.hour') }
])
// Dark mode detection
......@@ -279,7 +426,7 @@ const isDarkMode = computed(() => {
// Chart colors
const chartColors = computed(() => ({
text: isDarkMode.value ? '#e5e7eb' : '#374151',
grid: isDarkMode.value ? '#374151' : '#e5e7eb',
grid: isDarkMode.value ? '#374151' : '#e5e7eb'
}))
// Line chart options (for user trend chart)
......@@ -288,7 +435,7 @@ const lineOptions = computed(() => ({
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index' as const,
mode: 'index' as const
},
plugins: {
legend: {
......@@ -299,43 +446,43 @@ const lineOptions = computed(() => ({
pointStyle: 'circle',
padding: 15,
font: {
size: 11,
},
},
size: 11
}
}
},
tooltip: {
callbacks: {
label: (context: any) => {
return `${context.dataset.label}: ${formatTokens(context.raw)}`
},
},
},
}
}
}
},
scales: {
x: {
grid: {
color: chartColors.value.grid,
color: chartColors.value.grid
},
ticks: {
color: chartColors.value.text,
font: {
size: 10,
},
},
size: 10
}
}
},
y: {
grid: {
color: chartColors.value.grid,
color: chartColors.value.grid
},
ticks: {
color: chartColors.value.text,
font: {
size: 10,
size: 10
},
callback: (value: string | number) => formatTokens(Number(value)),
},
},
},
callback: (value: string | number) => formatTokens(Number(value))
}
}
}
}))
// User trend chart data
......@@ -354,7 +501,7 @@ const userTrendChartData = computed(() => {
const userGroups = new Map<string, { name: string; data: Map<string, number> }>()
const allDates = new Set<string>()
userTrend.value.forEach(point => {
userTrend.value.forEach((point) => {
allDates.add(point.date)
const key = getDisplayName(point.email, point.user_id)
if (!userGroups.has(key)) {
......@@ -364,20 +511,33 @@ const userTrendChartData = computed(() => {
})
const sortedDates = Array.from(allDates).sort()
const colors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16', '#06b6d4', '#a855f7']
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),
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,
tension: 0.3
}))
return {
labels: sortedDates,
datasets,
datasets
}
})
......@@ -417,7 +577,11 @@ const formatDuration = (ms: number): string => {
}
// Date range change handler
const onDateRangeChange = (range: { startDate: string; endDate: string; preset: string | null }) => {
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)
......@@ -464,13 +628,13 @@ const loadChartData = async () => {
const params = {
start_date: startDate.value,
end_date: endDate.value,
granularity: granularity.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 }),
adminAPI.dashboard.getUserUsageTrend({ ...params, limit: 12 })
])
trendData.value = trendResponse.trend || []
......@@ -493,7 +657,7 @@ onMounted(() => {
<style scoped>
/* Compact Select styling for dashboard */
:deep(.select-trigger) {
@apply px-3 py-1.5 text-sm rounded-lg;
@apply rounded-lg px-3 py-1.5 text-sm;
}
:deep(.select-dropdown) {
......
......@@ -10,17 +10,27 @@
:title="t('common.refresh')"
>
<svg
:class="['w-5 h-5', loading ? 'animate-spin' : '']"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"
:class="['h-5 w-5', loading ? 'animate-spin' : '']"
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" />
<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="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">
<button @click="showCreateModal = true" class="btn btn-primary">
<svg
class="mr-2 h-5 w-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.groups.createGroup') }}
......@@ -62,14 +72,16 @@
<template #cell-platform="{ value }">
<span
:class="[
'inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium',
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium',
value === 'anthropic'
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
: value === 'openai'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
]"
>
<PlatformIcon :platform="value" size="xs" />
{{ value === 'anthropic' ? 'Anthropic' : 'OpenAI' }}
{{ value === 'anthropic' ? 'Anthropic' : value === 'openai' ? 'OpenAI' : 'Gemini' }}
</span>
</template>
......@@ -78,24 +90,49 @@
<!-- Type Badge -->
<span
:class="[
'inline-block px-2 py-0.5 rounded-full text-xs font-medium',
'inline-block rounded-full px-2 py-0.5 text-xs font-medium',
row.subscription_type === 'subscription'
? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
]"
>
{{ row.subscription_type === 'subscription' ? t('admin.groups.subscription.subscription') : t('admin.groups.subscription.standard') }}
{{
row.subscription_type === 'subscription'
? t('admin.groups.subscription.subscription')
: t('admin.groups.subscription.standard')
}}
</span>
<!-- Subscription Limits - compact single line -->
<div v-if="row.subscription_type === 'subscription'" class="text-xs text-gray-500 dark:text-gray-400">
<template v-if="row.daily_limit_usd || row.weekly_limit_usd || row.monthly_limit_usd">
<span v-if="row.daily_limit_usd">${{ row.daily_limit_usd }}/{{ t('admin.groups.limitDay') }}</span>
<span v-if="row.daily_limit_usd && (row.weekly_limit_usd || row.monthly_limit_usd)" class="mx-1 text-gray-300 dark:text-gray-600">·</span>
<span v-if="row.weekly_limit_usd">${{ row.weekly_limit_usd }}/{{ t('admin.groups.limitWeek') }}</span>
<span v-if="row.weekly_limit_usd && row.monthly_limit_usd" class="mx-1 text-gray-300 dark:text-gray-600">·</span>
<span v-if="row.monthly_limit_usd">${{ row.monthly_limit_usd }}/{{ t('admin.groups.limitMonth') }}</span>
<div
v-if="row.subscription_type === 'subscription'"
class="text-xs text-gray-500 dark:text-gray-400"
>
<template
v-if="row.daily_limit_usd || row.weekly_limit_usd || row.monthly_limit_usd"
>
<span v-if="row.daily_limit_usd"
>${{ row.daily_limit_usd }}/{{ t('admin.groups.limitDay') }}</span
>
<span
v-if="row.daily_limit_usd && (row.weekly_limit_usd || row.monthly_limit_usd)"
class="mx-1 text-gray-300 dark:text-gray-600"
>·</span
>
<span v-if="row.weekly_limit_usd"
>${{ row.weekly_limit_usd }}/{{ t('admin.groups.limitWeek') }}</span
>
<span
v-if="row.weekly_limit_usd && row.monthly_limit_usd"
class="mx-1 text-gray-300 dark:text-gray-600"
>·</span
>
<span v-if="row.monthly_limit_usd"
>${{ row.monthly_limit_usd }}/{{ t('admin.groups.limitMonth') }}</span
>
</template>
<span v-else class="text-gray-400 dark:text-gray-500">{{ t('admin.groups.subscription.noLimit') }}</span>
<span v-else class="text-gray-400 dark:text-gray-500">{{
t('admin.groups.subscription.noLimit')
}}</span>
</div>
</div>
</template>
......@@ -105,29 +142,21 @@
</template>
<template #cell-is_exclusive="{ value }">
<span
:class="[
'badge',
value ? 'badge-primary' : 'badge-gray'
]"
>
<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">
<span
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium 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'
]"
>
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
{{ value }}
</span>
</template>
......@@ -136,22 +165,40 @@
<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"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
: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
class="h-4 w-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"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
: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
class="h-4 w-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>
......@@ -207,10 +254,7 @@
</div>
<div>
<label class="input-label">{{ t('admin.groups.form.platform') }}</label>
<Select
v-model="createForm.platform"
:options="platformOptions"
/>
<Select v-model="createForm.platform" :options="platformOptions" />
<p class="input-hint">{{ t('admin.groups.platformHint') }}</p>
</div>
<div v-if="createForm.subscription_type !== 'subscription'">
......@@ -236,7 +280,7 @@
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform shadow',
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
createForm.is_exclusive ? 'translate-x-6' : 'translate-x-1'
]"
/>
......@@ -247,20 +291,22 @@
</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="mt-4 border-t pt-4">
<h4 class="mb-4 text-sm font-medium text-gray-900 dark:text-white">
{{ 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"
/>
<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
v-if="createForm.subscription_type === 'subscription'"
class="space-y-4 border-l-2 border-primary-200 pl-4 dark:border-primary-800"
>
<div>
<label class="input-label">{{ t('admin.groups.subscription.dailyLimit') }}</label>
<input
......@@ -298,26 +344,29 @@
</div>
<div class="flex justify-end gap-3 pt-4">
<button
@click="closeCreateModal"
type="button"
class="btn btn-secondary"
>
<button @click="closeCreateModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }}
</button>
<button
type="submit"
:disabled="submitting"
class="btn btn-primary"
>
<button type="submit" :disabled="submitting" class="btn btn-primary">
<svg
v-if="submitting"
class="animate-spin -ml-1 mr-2 h-4 w-4"
class="-ml-1 mr-2 h-4 w-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>
<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>
......@@ -335,28 +384,15 @@
<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"
/>
<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>
<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"
:disabled="true"
/>
<Select v-model="editForm.platform" :options="platformOptions" :disabled="true" />
<p class="input-hint">{{ t('admin.groups.platformNotEditable') }}</p>
</div>
<div v-if="editForm.subscription_type !== 'subscription'">
......@@ -381,7 +417,7 @@
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform shadow',
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
editForm.is_exclusive ? 'translate-x-6' : 'translate-x-1'
]"
/>
......@@ -392,15 +428,14 @@
</div>
<div>
<label class="input-label">{{ t('admin.groups.form.status') }}</label>
<Select
v-model="editForm.status"
:options="editStatusOptions"
/>
<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="mt-4 border-t pt-4">
<h4 class="mb-4 text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.groups.subscription.title') }}
</h4>
<div class="mb-4">
<label class="input-label">{{ t('admin.groups.subscription.type') }}</label>
......@@ -413,7 +448,10 @@
</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
v-if="editForm.subscription_type === 'subscription'"
class="space-y-4 border-l-2 border-primary-200 pl-4 dark:border-primary-800"
>
<div>
<label class="input-label">{{ t('admin.groups.subscription.dailyLimit') }}</label>
<input
......@@ -451,26 +489,29 @@
</div>
<div class="flex justify-end gap-3 pt-4">
<button
@click="closeEditModal"
type="button"
class="btn btn-secondary"
>
<button @click="closeEditModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }}
</button>
<button
type="submit"
:disabled="submitting"
class="btn btn-primary"
>
<button type="submit" :disabled="submitting" class="btn btn-primary">
<svg
v-if="submitting"
class="animate-spin -ml-1 mr-2 h-4 w-4"
class="-ml-1 mr-2 h-4 w-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>
<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>
......@@ -537,13 +578,15 @@ const exclusiveOptions = computed(() => [
const platformOptions = computed(() => [
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'openai', label: 'OpenAI' }
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Gemini' }
])
const platformFilterOptions = computed(() => [
{ value: '', label: t('admin.groups.allPlatforms') },
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'openai', label: 'OpenAI' }
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Gemini' }
])
const editStatusOptions = computed(() => [
......@@ -616,15 +659,11 @@ const deleteConfirmMessage = computed(() => {
const loadGroups = async () => {
loading.value = true
try {
const response = await adminAPI.groups.list(
pagination.page,
pagination.page_size,
{
platform: (filters.platform as GroupPlatform) || undefined,
status: filters.status as any,
is_exclusive: filters.is_exclusive ? filters.is_exclusive === 'true' : undefined
}
)
const response = await adminAPI.groups.list(pagination.page, pagination.page_size, {
platform: (filters.platform as GroupPlatform) || 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
......@@ -727,12 +766,15 @@ const confirmDelete = async () => {
}
// 监听 subscription_type 变化,订阅模式时重置 rate_multiplier 为 1,is_exclusive 为 true
watch(() => createForm.subscription_type, (newVal) => {
if (newVal === 'subscription') {
createForm.rate_multiplier = 1.0
createForm.is_exclusive = true
watch(
() => createForm.subscription_type,
(newVal) => {
if (newVal === 'subscription') {
createForm.rate_multiplier = 1.0
createForm.is_exclusive = true
}
}
})
)
onMounted(() => {
loadGroups()
......
......@@ -10,17 +10,27 @@
:title="t('common.refresh')"
>
<svg
:class="['w-5 h-5', loading ? 'animate-spin' : '']"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"
:class="['h-5 w-5', loading ? 'animate-spin' : '']"
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" />
<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="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">
<button @click="showCreateModal = true" class="btn btn-primary">
<svg
class="mr-2 h-5 w-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.createProxy') }}
......@@ -29,9 +39,19 @@
<!-- 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" />
<div class="relative max-w-md flex-1">
<svg
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 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"
......@@ -69,10 +89,7 @@
<template #cell-protocol="{ value }">
<span
v-if="value"
:class="[
'badge',
value === 'socks5' ? 'badge-primary' : 'badge-gray'
]"
:class="['badge', value === 'socks5' ? 'badge-primary' : 'badge-gray']"
>
{{ value.toUpperCase() }}
</span>
......@@ -84,12 +101,7 @@
</template>
<template #cell-status="{ value }">
<span
:class="[
'badge',
value === 'active' ? 'badge-success' : 'badge-danger'
]"
>
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
{{ value }}
</span>
</template>
......@@ -99,35 +111,80 @@
<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"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400"
: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
v-if="testingProxyIds.has(row.id)"
class="h-4 w-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
v-else
class="h-4 w-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"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
: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
class="h-4 w-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"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
: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
class="h-4 w-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>
......@@ -162,18 +219,24 @@
@close="closeCreateModal"
>
<!-- Tab Switch -->
<div class="flex mb-6 border-b border-gray-200 dark:border-dark-600">
<div class="mb-6 flex 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',
'-mb-px border-b-2 px-4 py-2 text-sm font-medium 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">
<svg
class="mr-1.5 inline h-4 w-4"
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') }}
......@@ -182,14 +245,24 @@
type="button"
@click="createMode = 'batch'"
:class="[
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
'-mb-px border-b-2 px-4 py-2 text-sm font-medium 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
class="mr-1.5 inline h-4 w-4"
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>
......@@ -209,10 +282,7 @@
</div>
<div>
<label class="input-label">{{ t('admin.proxies.protocol') }}</label>
<Select
v-model="createForm.protocol"
:options="protocolSelectOptions"
/>
<Select v-model="createForm.protocol" :options="protocolSelectOptions" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
......@@ -258,26 +328,29 @@
</div>
<div class="flex justify-end gap-3 pt-4">
<button
@click="closeCreateModal"
type="button"
class="btn btn-secondary"
>
<button @click="closeCreateModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }}
</button>
<button
type="submit"
:disabled="submitting"
class="btn btn-primary"
>
<button type="submit" :disabled="submitting" class="btn btn-primary">
<svg
v-if="submitting"
class="animate-spin -ml-1 mr-2 h-4 w-4"
class="-ml-1 mr-2 h-4 w-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>
<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>
......@@ -301,27 +374,57 @@
</div>
<!-- Parse Result -->
<div v-if="batchParseResult.total > 0" class="rounded-lg p-4 bg-gray-50 dark:bg-dark-700">
<div v-if="batchParseResult.total > 0" class="rounded-lg bg-gray-50 p-4 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
class="h-4 w-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
class="h-4 w-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
class="h-4 w-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 }) }}
......@@ -331,11 +434,7 @@
</div>
<div class="flex justify-end gap-3 pt-4">
<button
@click="closeCreateModal"
type="button"
class="btn btn-secondary"
>
<button @click="closeCreateModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }}
</button>
<button
......@@ -346,14 +445,29 @@
>
<svg
v-if="submitting"
class="animate-spin -ml-1 mr-2 h-4 w-4"
class="-ml-1 mr-2 h-4 w-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>
<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 }) }}
{{
submitting
? t('admin.proxies.importing')
: t('admin.proxies.importProxies', { count: batchParseResult.valid })
}}
</button>
</div>
</div>
......@@ -369,29 +483,16 @@
<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"
/>
<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"
/>
<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"
/>
<input v-model="editForm.host" type="text" required class="input" />
</div>
<div>
<label class="input-label">{{ t('admin.proxies.port') }}</label>
......@@ -407,11 +508,7 @@
</div>
<div>
<label class="input-label">{{ t('admin.proxies.username') }}</label>
<input
v-model="editForm.username"
type="text"
class="input"
/>
<input v-model="editForm.username" type="text" class="input" />
</div>
<div>
<label class="input-label">{{ t('admin.proxies.password') }}</label>
......@@ -424,33 +521,33 @@
</div>
<div>
<label class="input-label">{{ t('admin.proxies.status') }}</label>
<Select
v-model="editForm.status"
:options="editStatusOptions"
/>
<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"
>
<button @click="closeEditModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }}
</button>
<button
type="submit"
:disabled="submitting"
class="btn btn-primary"
>
<button type="submit" :disabled="submitting" class="btn btn-primary">
<svg
v-if="submitting"
class="animate-spin -ml-1 mr-2 h-4 w-4"
class="-ml-1 mr-2 h-4 w-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>
<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>
......@@ -585,15 +682,11 @@ const editForm = reactive({
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
}
)
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
......@@ -637,7 +730,9 @@ const closeCreateModal = () => {
}
// Parse proxy URL: protocol://user:pass@host:port or protocol://host:port
const parseProxyUrl = (line: string): {
const parseProxyUrl = (
line: string
): {
protocol: ProxyProtocol
host: string
port: number
......@@ -668,7 +763,7 @@ const parseProxyUrl = (line: string): {
}
const parseBatchInput = () => {
const lines = batchInput.value.split('\n').filter(l => l.trim())
const lines = batchInput.value.split('\n').filter((l) => l.trim())
const seen = new Set<string>()
const proxies: typeof batchParseResult.proxies = []
let invalid = 0
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment