diff --git a/backend/internal/handler/usage_handler.go b/backend/internal/handler/usage_handler.go index 15b30bbb..a0cf9f2c 100644 --- a/backend/internal/handler/usage_handler.go +++ b/backend/internal/handler/usage_handler.go @@ -371,24 +371,16 @@ func (h *UsageHandler) DashboardApiKeysUsage(c *gin.Context) { return } - // Verify ownership of all requested API keys - userApiKeys, _, err := h.apiKeyService.List(c.Request.Context(), subject.UserID, pagination.PaginationParams{Page: 1, PageSize: 1000}) - if err != nil { - response.ErrorFrom(c, err) + // Limit the number of API key IDs to prevent SQL parameter overflow + if len(req.ApiKeyIDs) > 100 { + response.BadRequest(c, "Too many API key IDs (maximum 100 allowed)") return } - userApiKeyIDs := make(map[int64]bool) - for _, key := range userApiKeys { - userApiKeyIDs[key.ID] = true - } - - // Filter to only include user's own API keys - validApiKeyIDs := make([]int64, 0) - for _, id := range req.ApiKeyIDs { - if userApiKeyIDs[id] { - validApiKeyIDs = append(validApiKeyIDs, id) - } + validApiKeyIDs, err := h.apiKeyService.VerifyOwnership(c.Request.Context(), subject.UserID, req.ApiKeyIDs) + if err != nil { + response.ErrorFrom(c, err) + return } if len(validApiKeyIDs) == 0 { diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 718bef33..a6001ecc 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -81,6 +81,22 @@ func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, param return outKeys, paginationResultFromTotal(total, params), nil } +func (r *apiKeyRepository) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) { + if len(apiKeyIDs) == 0 { + return []int64{}, nil + } + + ids := make([]int64, 0, len(apiKeyIDs)) + err := r.db.WithContext(ctx). + Model(&apiKeyModel{}). + Where("user_id = ? AND id IN ?", userID, apiKeyIDs). + Pluck("id", &ids).Error + if err != nil { + return nil, err + } + return ids, nil +} + func (r *apiKeyRepository) CountByUserID(ctx context.Context, userID int64) (int64, error) { var count int64 err := r.db.WithContext(ctx).Model(&apiKeyModel{}).Where("user_id = ?", userID).Count(&count).Error diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index dd912c5a..84c44a91 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -129,51 +129,67 @@ type DashboardStats = usagestats.DashboardStats func (r *usageLogRepository) GetDashboardStats(ctx context.Context) (*DashboardStats, error) { var stats DashboardStats today := timezone.Today() + now := time.Now() - // 总用户数 - r.db.WithContext(ctx).Model(&userModel{}).Count(&stats.TotalUsers) + // 合并用户统计查询 + var userStats struct { + TotalUsers int64 `gorm:"column:total_users"` + TodayNewUsers int64 `gorm:"column:today_new_users"` + ActiveUsers int64 `gorm:"column:active_users"` + } + if err := r.db.WithContext(ctx).Raw(` + SELECT + COUNT(*) as total_users, + COUNT(CASE WHEN created_at >= ? THEN 1 END) as today_new_users, + (SELECT COUNT(DISTINCT user_id) FROM usage_logs WHERE created_at >= ?) as active_users + FROM users + `, today, today).Scan(&userStats).Error; err != nil { + return nil, err + } + stats.TotalUsers = userStats.TotalUsers + stats.TodayNewUsers = userStats.TodayNewUsers + stats.ActiveUsers = userStats.ActiveUsers - // 今日新增用户数 - r.db.WithContext(ctx).Model(&userModel{}). - Where("created_at >= ?", today). - Count(&stats.TodayNewUsers) + // 合并API Key统计查询 + var apiKeyStats struct { + TotalApiKeys int64 `gorm:"column:total_api_keys"` + ActiveApiKeys int64 `gorm:"column:active_api_keys"` + } + if err := r.db.WithContext(ctx).Raw(` + SELECT + COUNT(*) as total_api_keys, + COUNT(CASE WHEN status = ? THEN 1 END) as active_api_keys + FROM api_keys + `, service.StatusActive).Scan(&apiKeyStats).Error; err != nil { + return nil, err + } + stats.TotalApiKeys = apiKeyStats.TotalApiKeys + stats.ActiveApiKeys = apiKeyStats.ActiveApiKeys - // 今日活跃用户数 (今日有请求的用户) - r.db.WithContext(ctx).Model(&usageLogModel{}). - Distinct("user_id"). - Where("created_at >= ?", today). - Count(&stats.ActiveUsers) - - // 总 API Key 数 - r.db.WithContext(ctx).Model(&apiKeyModel{}).Count(&stats.TotalApiKeys) - - // 活跃 API Key 数 - r.db.WithContext(ctx).Model(&apiKeyModel{}). - Where("status = ?", service.StatusActive). - Count(&stats.ActiveApiKeys) - - // 总账户数 - r.db.WithContext(ctx).Model(&accountModel{}).Count(&stats.TotalAccounts) - - // 正常账户数 (schedulable=true, status=active) - r.db.WithContext(ctx).Model(&accountModel{}). - Where("status = ? AND schedulable = ?", service.StatusActive, true). - Count(&stats.NormalAccounts) - - // 异常账户数 (status=error) - r.db.WithContext(ctx).Model(&accountModel{}). - Where("status = ?", service.StatusError). - Count(&stats.ErrorAccounts) - - // 限流账户数 - r.db.WithContext(ctx).Model(&accountModel{}). - Where("rate_limited_at IS NOT NULL AND rate_limit_reset_at > ?", time.Now()). - Count(&stats.RateLimitAccounts) - - // 过载账户数 - r.db.WithContext(ctx).Model(&accountModel{}). - Where("overload_until IS NOT NULL AND overload_until > ?", time.Now()). - Count(&stats.OverloadAccounts) + // 合并账户统计查询 + var accountStats struct { + TotalAccounts int64 `gorm:"column:total_accounts"` + NormalAccounts int64 `gorm:"column:normal_accounts"` + ErrorAccounts int64 `gorm:"column:error_accounts"` + RateLimitAccounts int64 `gorm:"column:ratelimit_accounts"` + OverloadAccounts int64 `gorm:"column:overload_accounts"` + } + if err := r.db.WithContext(ctx).Raw(` + SELECT + COUNT(*) as total_accounts, + COUNT(CASE WHEN status = ? AND schedulable = true THEN 1 END) as normal_accounts, + COUNT(CASE WHEN status = ? THEN 1 END) as error_accounts, + COUNT(CASE WHEN rate_limited_at IS NOT NULL AND rate_limit_reset_at > ? THEN 1 END) as ratelimit_accounts, + COUNT(CASE WHEN overload_until IS NOT NULL AND overload_until > ? THEN 1 END) as overload_accounts + FROM accounts + `, service.StatusActive, service.StatusError, now, now).Scan(&accountStats).Error; err != nil { + return nil, err + } + stats.TotalAccounts = accountStats.TotalAccounts + stats.NormalAccounts = accountStats.NormalAccounts + stats.ErrorAccounts = accountStats.ErrorAccounts + stats.RateLimitAccounts = accountStats.RateLimitAccounts + stats.OverloadAccounts = accountStats.OverloadAccounts // 累计 Token 统计 var totalStats struct { @@ -273,6 +289,88 @@ func (r *usageLogRepository) ListByUserAndTimeRange(ctx context.Context, userID return usageLogModelsToService(logs), nil, err } +// GetUserStatsAggregated returns aggregated usage statistics for a user using database-level aggregation +func (r *usageLogRepository) GetUserStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) { + var stats struct { + TotalRequests int64 `gorm:"column:total_requests"` + TotalInputTokens int64 `gorm:"column:total_input_tokens"` + TotalOutputTokens int64 `gorm:"column:total_output_tokens"` + TotalCacheTokens int64 `gorm:"column:total_cache_tokens"` + TotalCost float64 `gorm:"column:total_cost"` + TotalActualCost float64 `gorm:"column:total_actual_cost"` + AverageDurationMs float64 `gorm:"column:avg_duration_ms"` + } + + err := r.db.WithContext(ctx).Model(&usageLogModel{}). + Select(` + COUNT(*) as total_requests, + COALESCE(SUM(input_tokens), 0) as total_input_tokens, + COALESCE(SUM(output_tokens), 0) as total_output_tokens, + COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens, + COALESCE(SUM(total_cost), 0) as total_cost, + COALESCE(SUM(actual_cost), 0) as total_actual_cost, + COALESCE(AVG(COALESCE(duration_ms, 0)), 0) as avg_duration_ms + `). + Where("user_id = ? AND created_at >= ? AND created_at < ?", userID, startTime, endTime). + Scan(&stats).Error + + if err != nil { + return nil, err + } + + return &usagestats.UsageStats{ + TotalRequests: stats.TotalRequests, + TotalInputTokens: stats.TotalInputTokens, + TotalOutputTokens: stats.TotalOutputTokens, + TotalCacheTokens: stats.TotalCacheTokens, + TotalTokens: stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens, + TotalCost: stats.TotalCost, + TotalActualCost: stats.TotalActualCost, + AverageDurationMs: stats.AverageDurationMs, + }, nil +} + +// GetApiKeyStatsAggregated returns aggregated usage statistics for an API key using database-level aggregation +func (r *usageLogRepository) GetApiKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) { + var stats struct { + TotalRequests int64 `gorm:"column:total_requests"` + TotalInputTokens int64 `gorm:"column:total_input_tokens"` + TotalOutputTokens int64 `gorm:"column:total_output_tokens"` + TotalCacheTokens int64 `gorm:"column:total_cache_tokens"` + TotalCost float64 `gorm:"column:total_cost"` + TotalActualCost float64 `gorm:"column:total_actual_cost"` + AverageDurationMs float64 `gorm:"column:avg_duration_ms"` + } + + err := r.db.WithContext(ctx).Model(&usageLogModel{}). + Select(` + COUNT(*) as total_requests, + COALESCE(SUM(input_tokens), 0) as total_input_tokens, + COALESCE(SUM(output_tokens), 0) as total_output_tokens, + COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens, + COALESCE(SUM(total_cost), 0) as total_cost, + COALESCE(SUM(actual_cost), 0) as total_actual_cost, + COALESCE(AVG(COALESCE(duration_ms, 0)), 0) as avg_duration_ms + `). + Where("api_key_id = ? AND created_at >= ? AND created_at < ?", apiKeyID, startTime, endTime). + Scan(&stats).Error + + if err != nil { + return nil, err + } + + return &usagestats.UsageStats{ + TotalRequests: stats.TotalRequests, + TotalInputTokens: stats.TotalInputTokens, + TotalOutputTokens: stats.TotalOutputTokens, + TotalCacheTokens: stats.TotalCacheTokens, + TotalTokens: stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens, + TotalCost: stats.TotalCost, + TotalActualCost: stats.TotalActualCost, + AverageDurationMs: stats.AverageDurationMs, + }, nil +} + func (r *usageLogRepository) ListByApiKeyAndTimeRange(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) { var logs []usageLogModel err := r.db.WithContext(ctx). diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 72909b58..06eb2ebf 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -788,6 +788,25 @@ func (r *stubApiKeyRepo) ListByUserID(ctx context.Context, userID int64, params }, nil } +func (r *stubApiKeyRepo) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) { + if len(apiKeyIDs) == 0 { + return []int64{}, nil + } + seen := make(map[int64]struct{}, len(apiKeyIDs)) + out := make([]int64, 0, len(apiKeyIDs)) + for _, id := range apiKeyIDs { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + key, ok := r.byID[id] + if ok && key.UserID == userID { + out = append(out, id) + } + } + return out, nil +} + func (r *stubApiKeyRepo) CountByUserID(ctx context.Context, userID int64) (int64, error) { var count int64 for _, key := range r.byID { @@ -903,6 +922,55 @@ func (r *stubUsageLogRepo) GetUserUsageTrend(ctx context.Context, startTime, end return nil, errors.New("not implemented") } +func (r *stubUsageLogRepo) GetUserStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) { + logs := r.userLogs[userID] + if len(logs) == 0 { + return &usagestats.UsageStats{}, nil + } + + var totalRequests int64 + var totalInputTokens int64 + var totalOutputTokens int64 + var totalCacheTokens int64 + var totalCost float64 + var totalActualCost float64 + var totalDuration int64 + var durationCount int64 + + for _, log := range logs { + totalRequests++ + totalInputTokens += int64(log.InputTokens) + totalOutputTokens += int64(log.OutputTokens) + totalCacheTokens += int64(log.CacheCreationTokens + log.CacheReadTokens) + totalCost += log.TotalCost + totalActualCost += log.ActualCost + if log.DurationMs != nil { + totalDuration += int64(*log.DurationMs) + durationCount++ + } + } + + var avgDuration float64 + if durationCount > 0 { + avgDuration = float64(totalDuration) / float64(durationCount) + } + + return &usagestats.UsageStats{ + TotalRequests: totalRequests, + TotalInputTokens: totalInputTokens, + TotalOutputTokens: totalOutputTokens, + TotalCacheTokens: totalCacheTokens, + TotalTokens: totalInputTokens + totalOutputTokens + totalCacheTokens, + TotalCost: totalCost, + TotalActualCost: totalActualCost, + AverageDurationMs: avgDuration, + }, nil +} + +func (r *stubUsageLogRepo) GetApiKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) { + return nil, errors.New("not implemented") +} + func (r *stubUsageLogRepo) GetBatchUserUsageStats(ctx context.Context, userIDs []int64) (map[int64]*usagestats.BatchUserUsageStats, error) { return nil, errors.New("not implemented") } diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 642c8e09..575e72b1 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -48,6 +48,10 @@ type UsageLogRepository interface { // Account stats GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.AccountUsageStatsResponse, error) + + // Aggregated stats (optimized) + GetUserStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) + GetApiKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) } // usageCache 用于缓存usage数据 diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go index 4ab50fb5..e6234382 100644 --- a/backend/internal/service/api_key_service.go +++ b/backend/internal/service/api_key_service.go @@ -34,6 +34,7 @@ type ApiKeyRepository interface { Delete(ctx context.Context, id int64) error ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]ApiKey, *pagination.PaginationResult, error) + VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) CountByUserID(ctx context.Context, userID int64) (int64, error) ExistsByKey(ctx context.Context, key string) (bool, error) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]ApiKey, *pagination.PaginationResult, error) @@ -256,6 +257,18 @@ func (s *ApiKeyService) List(ctx context.Context, userID int64, params paginatio return keys, pagination, nil } +func (s *ApiKeyService) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) { + if len(apiKeyIDs) == 0 { + return []int64{}, nil + } + + validIDs, err := s.apiKeyRepo.VerifyOwnership(ctx, userID, apiKeyIDs) + if err != nil { + return nil, fmt.Errorf("verify api key ownership: %w", err) + } + return validIDs, nil +} + // GetByID 根据ID获取API Key func (s *ApiKeyService) GetByID(ctx context.Context, id int64) (*ApiKey, error) { apiKey, err := s.apiKeyRepo.GetByID(ctx, id) diff --git a/backend/internal/service/usage_service.go b/backend/internal/service/usage_service.go index 2ccad4ff..0df8a0de 100644 --- a/backend/internal/service/usage_service.go +++ b/backend/internal/service/usage_service.go @@ -148,22 +148,40 @@ func (s *UsageService) ListByAccount(ctx context.Context, accountID int64, param // GetStatsByUser 获取用户的使用统计 func (s *UsageService) GetStatsByUser(ctx context.Context, userID int64, startTime, endTime time.Time) (*UsageStats, error) { - logs, _, err := s.usageRepo.ListByUserAndTimeRange(ctx, userID, startTime, endTime) + stats, err := s.usageRepo.GetUserStatsAggregated(ctx, userID, startTime, endTime) if err != nil { - return nil, fmt.Errorf("list usage logs: %w", err) + return nil, fmt.Errorf("get user stats: %w", err) } - return s.calculateStats(logs), nil + return &UsageStats{ + TotalRequests: stats.TotalRequests, + TotalInputTokens: stats.TotalInputTokens, + TotalOutputTokens: stats.TotalOutputTokens, + TotalCacheTokens: stats.TotalCacheTokens, + TotalTokens: stats.TotalTokens, + TotalCost: stats.TotalCost, + TotalActualCost: stats.TotalActualCost, + AverageDurationMs: stats.AverageDurationMs, + }, nil } // GetStatsByApiKey 获取API Key的使用统计 func (s *UsageService) GetStatsByApiKey(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*UsageStats, error) { - logs, _, err := s.usageRepo.ListByApiKeyAndTimeRange(ctx, apiKeyID, startTime, endTime) + stats, err := s.usageRepo.GetApiKeyStatsAggregated(ctx, apiKeyID, startTime, endTime) if err != nil { - return nil, fmt.Errorf("list usage logs: %w", err) + return nil, fmt.Errorf("get api key stats: %w", err) } - return s.calculateStats(logs), nil + return &UsageStats{ + TotalRequests: stats.TotalRequests, + TotalInputTokens: stats.TotalInputTokens, + TotalOutputTokens: stats.TotalOutputTokens, + TotalCacheTokens: stats.TotalCacheTokens, + TotalTokens: stats.TotalTokens, + TotalCost: stats.TotalCost, + TotalActualCost: stats.TotalActualCost, + AverageDurationMs: stats.AverageDurationMs, + }, nil } // GetStatsByAccount 获取账号的使用统计 diff --git a/frontend/src/components/common/ConfirmDialog.vue b/frontend/src/components/common/ConfirmDialog.vue index 267e397c..8de77837 100644 --- a/frontend/src/components/common/ConfirmDialog.vue +++ b/frontend/src/components/common/ConfirmDialog.vue @@ -31,8 +31,12 @@ diff --git a/frontend/src/components/common/SubscriptionProgressMini.vue b/frontend/src/components/common/SubscriptionProgressMini.vue index aefbf6fe..180ffed7 100644 --- a/frontend/src/components/common/SubscriptionProgressMini.vue +++ b/frontend/src/components/common/SubscriptionProgressMini.vue @@ -246,7 +246,7 @@ function formatDaysRemaining(expiresAt: string): string { const diff = expires.getTime() - now.getTime() if (diff < 0) return t('subscriptionProgress.expired') const days = Math.ceil(diff / (1000 * 60 * 60 * 24)) - if (days === 0) return t('subscriptionProgress.expirestoday') + if (days === 0) return t('subscriptionProgress.expiresToday') if (days === 1) return t('subscriptionProgress.expiresTomorrow') return t('subscriptionProgress.daysRemaining', { days }) } diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 269f1a1a..3c3e2a93 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -52,6 +52,7 @@ export default { password: 'Password', databaseName: 'Database Name', sslMode: 'SSL Mode', + passwordPlaceholder: 'Password', ssl: { disable: 'Disable', require: 'Require', @@ -64,13 +65,17 @@ export default { host: 'Host', port: 'Port', password: 'Password (optional)', - database: 'Database' + database: 'Database', + passwordPlaceholder: 'Password' }, admin: { title: 'Admin Account', email: 'Email', password: 'Password', - confirmPassword: 'Confirm Password' + confirmPassword: 'Confirm Password', + passwordPlaceholder: 'Min 6 characters', + confirmPasswordPlaceholder: 'Confirm password', + passwordMismatch: 'Passwords do not match' }, ready: { title: 'Ready to Install', @@ -127,7 +132,14 @@ export default { searchPlaceholder: 'Search...', noOptionsFound: 'No options found', saving: 'Saving...', - refresh: 'Refresh' + refresh: 'Refresh', + time: { + never: 'Never', + justNow: 'Just now', + minutesAgo: '{n}m ago', + hoursAgo: '{n}h ago', + daysAgo: '{n}d ago' + } }, // Navigation @@ -263,7 +275,7 @@ export default { created: 'Created', copyToClipboard: 'Copy to clipboard', copied: 'Copied!', - importToCcSwitch: 'Import to CC Switch', + importToCcSwitch: 'Import to CCS', enable: 'Enable', disable: 'Disable', nameLabel: 'Name', @@ -517,6 +529,7 @@ export default { actual: 'Actual', standard: 'Standard', noDataAvailable: 'No data available', + recentUsage: 'Recent Usage', failedToLoad: 'Failed to load dashboard statistics' }, @@ -569,9 +582,13 @@ export default { noSubscription: 'No subscription', daysRemaining: '{days}d', expired: 'Expired', + disable: 'Disable', + enable: 'Enable', disableUser: 'Disable User', enableUser: 'Enable User', viewApiKeys: 'View API Keys', + groups: 'Groups', + apiKeys: 'API Keys', userApiKeys: 'User API Keys', noApiKeys: 'This user has no API keys', group: 'Group', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index baf851ce..50aa911b 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -49,6 +49,7 @@ export default { password: '密码', databaseName: '数据库名称', sslMode: 'SSL 模式', + passwordPlaceholder: '密码', ssl: { disable: '禁用', require: '要求', @@ -61,13 +62,17 @@ export default { host: '主机', port: '端口', password: '密码(可选)', - database: '数据库' + database: '数据库', + passwordPlaceholder: '密码' }, admin: { title: '管理员账户', email: '邮箱', password: '密码', - confirmPassword: '确认密码' + confirmPassword: '确认密码', + passwordPlaceholder: '至少 6 个字符', + confirmPasswordPlaceholder: '确认密码', + passwordMismatch: '密码不匹配' }, ready: { title: '准备安装', @@ -124,7 +129,14 @@ export default { searchPlaceholder: '搜索...', noOptionsFound: '无匹配选项', saving: '保存中...', - refresh: '刷新' + refresh: '刷新', + time: { + never: '从未', + justNow: '刚刚', + minutesAgo: '{n}分钟前', + hoursAgo: '{n}小时前', + daysAgo: '{n}天前' + } }, // Navigation @@ -260,7 +272,7 @@ export default { created: '创建时间', copyToClipboard: '复制到剪贴板', copied: '已复制!', - importToCcSwitch: '导入到 CC Switch', + importToCcSwitch: '导入到 CCS', enable: '启用', disable: '禁用', nameLabel: '名称', @@ -589,9 +601,13 @@ export default { noSubscription: '暂无订阅', daysRemaining: '{days}天', expired: '已过期', + disable: '禁用', + enable: '启用', disableUser: '禁用用户', enableUser: '启用用户', viewApiKeys: '查看 API 密钥', + groups: '分组', + apiKeys: 'API密钥', userApiKeys: '用户 API 密钥', noApiKeys: '此用户暂无 API 密钥', group: '分组', @@ -727,10 +743,13 @@ export default { priorityHint: '数值越高优先级越高,用于账号调度', statusLabel: '状态' }, - exclusive: { + exclusiveObj: { yes: '是', no: '否' }, + exclusive: '独占', + exclusiveHint: '启用后,此分组的用户将独占使用分配的账号', + rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍', platforms: { all: '全部平台', claude: 'Claude', @@ -876,6 +895,7 @@ export default { deleteConfirmMessage: "确定要删除账号 '{name}' 吗?", refreshCookie: '刷新 Cookie', testAccount: '测试账号', + searchAccounts: '搜索账号...', // Filter options allPlatforms: '全部平台', allTypes: '全部类型', @@ -903,6 +923,19 @@ export default { lastUsed: '最近使用', actions: '操作' }, + clearRateLimit: '清除速率限制', + testConnection: '测试连接', + reAuthorize: '重新授权', + refreshToken: '刷新令牌', + noAccountsYet: '暂无账号', + createFirstAccount: '添加 AI 平台账号以开始使用 API 网关。', + tokenRefreshed: 'Token 刷新成功', + accountDeleted: '账号删除成功', + rateLimitCleared: '速率限制已清除', + setupToken: 'Setup Token', + apiKey: 'API Key', + deleteConfirm: "确定要删除账号 '{name}' 吗?此操作无法撤销。", + failedToClearRateLimit: '清除速率限制失败', platforms: { claude: 'Claude', openai: 'OpenAI', diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts index bd545d42..aec7c863 100644 --- a/frontend/src/utils/format.ts +++ b/frontend/src/utils/format.ts @@ -3,30 +3,32 @@ * 参考 CRS 项目的 format.js 实现 */ +import { i18n } from '@/i18n' + /** * 格式化相对时间 * @param date 日期字符串或 Date 对象 * @returns 相对时间字符串,如 "5m ago", "2h ago", "3d ago" */ export function formatRelativeTime(date: string | Date | null | undefined): string { - if (!date) return 'Never' + if (!date) return i18n.global.t('common.time.never') const now = new Date() const past = new Date(date) const diffMs = now.getTime() - past.getTime() // 处理未来时间或无效日期 - if (diffMs < 0 || isNaN(diffMs)) return 'Never' + if (diffMs < 0 || isNaN(diffMs)) return i18n.global.t('common.time.never') const diffSecs = Math.floor(diffMs / 1000) const diffMins = Math.floor(diffSecs / 60) const diffHours = Math.floor(diffMins / 60) const diffDays = Math.floor(diffHours / 24) - if (diffDays > 0) return `${diffDays}d ago` - if (diffHours > 0) return `${diffHours}h ago` - if (diffMins > 0) return `${diffMins}m ago` - return 'Just now' + if (diffDays > 0) return i18n.global.t('common.time.daysAgo', { n: diffDays }) + if (diffHours > 0) return i18n.global.t('common.time.hoursAgo', { n: diffHours }) + if (diffMins > 0) return i18n.global.t('common.time.minutesAgo', { n: diffMins }) + return i18n.global.t('common.time.justNow') } /** diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 567ffb52..2c389d5c 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -280,8 +280,7 @@ @@ -323,8 +323,7 @@ diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 75ff9570..a0d34fa3 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -166,8 +166,7 @@
diff --git a/frontend/src/views/admin/ProxiesView.vue b/frontend/src/views/admin/ProxiesView.vue index 68a58d01..f5d39ef2 100644 --- a/frontend/src/views/admin/ProxiesView.vue +++ b/frontend/src/views/admin/ProxiesView.vue @@ -112,8 +112,7 @@ diff --git a/frontend/src/views/admin/RedeemView.vue b/frontend/src/views/admin/RedeemView.vue index 1e580036..99487ce9 100644 --- a/frontend/src/views/admin/RedeemView.vue +++ b/frontend/src/views/admin/RedeemView.vue @@ -161,8 +161,7 @@ - diff --git a/frontend/src/views/admin/SubscriptionsView.vue b/frontend/src/views/admin/SubscriptionsView.vue index 5f58e1a2..9496f0dc 100644 --- a/frontend/src/views/admin/SubscriptionsView.vue +++ b/frontend/src/views/admin/SubscriptionsView.vue @@ -257,8 +257,7 @@ diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue index 69de978c..bdd9f68f 100644 --- a/frontend/src/views/admin/UsageView.vue +++ b/frontend/src/views/admin/UsageView.vue @@ -455,7 +455,11 @@ ${{ row.actual_cost.toFixed(6) }} -
+
@@ -471,60 +475,6 @@ />
- -
@@ -587,6 +537,66 @@ /> + + + +
+
+
+ +
+
成本明细
+
+ {{ t('admin.usage.inputCost') }} + ${{ tooltipData.input_cost.toFixed(6) }} +
+
+ {{ t('admin.usage.outputCost') }} + ${{ tooltipData.output_cost.toFixed(6) }} +
+
+ {{ t('admin.usage.cacheCreationCost') }} + ${{ tooltipData.cache_creation_cost.toFixed(6) }} +
+
+ {{ t('admin.usage.cacheReadCost') }} + ${{ tooltipData.cache_read_cost.toFixed(6) }} +
+
+ +
+ {{ t('usage.rate') }} + {{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x +
+
+ {{ t('usage.original') }} + ${{ tooltipData?.total_cost.toFixed(6) }} +
+
+ {{ t('usage.billed') }} + ${{ tooltipData?.actual_cost.toFixed(6) }} +
+
+ + +
+
+