diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index cfbb85d2..83781a6a 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "log" + mathrand "math/rand" "net/http" "strings" "sync/atomic" @@ -405,6 +406,14 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, // 重试循环 var resp *http.Response for attempt := 1; attempt <= antigravityMaxRetries; attempt++ { + // 检查 context 是否已取消(客户端断开连接) + select { + case <-ctx.Done(): + log.Printf("%s status=context_canceled error=%v", prefix, ctx.Err()) + return nil, ctx.Err() + default: + } + upstreamReq, err := antigravity.NewAPIRequest(ctx, action, accessToken, geminiBody) if err != nil { return nil, err @@ -414,7 +423,10 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, if err != nil { if attempt < antigravityMaxRetries { log.Printf("%s status=request_failed retry=%d/%d error=%v", prefix, attempt, antigravityMaxRetries, err) - sleepAntigravityBackoff(attempt) + if !sleepAntigravityBackoffWithContext(ctx, attempt) { + log.Printf("%s status=context_canceled_during_backoff", prefix) + return nil, ctx.Err() + } continue } log.Printf("%s status=request_failed retries_exhausted error=%v", prefix, err) @@ -427,7 +439,10 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, if attempt < antigravityMaxRetries { log.Printf("%s status=%d retry=%d/%d", prefix, resp.StatusCode, attempt, antigravityMaxRetries) - sleepAntigravityBackoff(attempt) + if !sleepAntigravityBackoffWithContext(ctx, attempt) { + log.Printf("%s status=context_canceled_during_backoff", prefix) + return nil, ctx.Err() + } continue } // 所有重试都失败,标记限流状态 @@ -901,6 +916,14 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co // 重试循环 var resp *http.Response for attempt := 1; attempt <= antigravityMaxRetries; attempt++ { + // 检查 context 是否已取消(客户端断开连接) + select { + case <-ctx.Done(): + log.Printf("%s status=context_canceled error=%v", prefix, ctx.Err()) + return nil, ctx.Err() + default: + } + upstreamReq, err := antigravity.NewAPIRequest(ctx, upstreamAction, accessToken, wrappedBody) if err != nil { return nil, err @@ -910,7 +933,10 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co if err != nil { if attempt < antigravityMaxRetries { log.Printf("%s status=request_failed retry=%d/%d error=%v", prefix, attempt, antigravityMaxRetries, err) - sleepAntigravityBackoff(attempt) + if !sleepAntigravityBackoffWithContext(ctx, attempt) { + log.Printf("%s status=context_canceled_during_backoff", prefix) + return nil, ctx.Err() + } continue } log.Printf("%s status=request_failed retries_exhausted error=%v", prefix, err) @@ -923,7 +949,10 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co if attempt < antigravityMaxRetries { log.Printf("%s status=%d retry=%d/%d", prefix, resp.StatusCode, attempt, antigravityMaxRetries) - sleepAntigravityBackoff(attempt) + if !sleepAntigravityBackoffWithContext(ctx, attempt) { + log.Printf("%s status=context_canceled_during_backoff", prefix) + return nil, ctx.Err() + } continue } // 所有重试都失败,标记限流状态 @@ -1058,8 +1087,28 @@ func (s *AntigravityGatewayService) shouldFailoverUpstreamError(statusCode int) } } -func sleepAntigravityBackoff(attempt int) { - sleepGeminiBackoff(attempt) // 复用 Gemini 的退避逻辑 +// sleepAntigravityBackoffWithContext 带 context 取消检查的退避等待 +// 返回 true 表示正常完成等待,false 表示 context 已取消 +func sleepAntigravityBackoffWithContext(ctx context.Context, attempt int) bool { + delay := geminiRetryBaseDelay * time.Duration(1< geminiRetryMaxDelay { + delay = geminiRetryMaxDelay + } + + // +/- 20% jitter + r := mathrand.New(mathrand.NewSource(time.Now().UnixNano())) + jitter := time.Duration(float64(delay) * 0.2 * (r.Float64()*2 - 1)) + sleepFor := delay + jitter + if sleepFor < 0 { + sleepFor = 0 + } + + select { + case <-ctx.Done(): + return false + case <-time.After(sleepFor): + return true + } } func (s *AntigravityGatewayService) handleUpstreamError(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte) { diff --git a/frontend/src/components/account/AccountStatsModal.vue b/frontend/src/components/account/AccountStatsModal.vue index 93f38a83..92016699 100644 --- a/frontend/src/components/account/AccountStatsModal.vue +++ b/frontend/src/components/account/AccountStatsModal.vue @@ -15,14 +15,7 @@
- - - +
{{ account.name }}
@@ -97,19 +90,7 @@ t('admin.accounts.stats.totalRequests') }}
- - - +

@@ -129,19 +110,12 @@ t('admin.accounts.stats.avgDailyCost') }}

- - - +

@@ -245,19 +219,12 @@

- - - +
{{ t('admin.accounts.stats.highestCostDay') @@ -295,19 +262,12 @@
- - - +
{{ t('admin.accounts.stats.highestRequestDay') @@ -348,19 +308,7 @@
- - - +
{{ t('admin.accounts.stats.accumulatedTokens') @@ -390,19 +338,7 @@
- - - +
{{ t('admin.accounts.stats.performance') @@ -432,19 +368,12 @@
- - - +
{{ t('admin.accounts.stats.recentActivity') @@ -504,14 +433,7 @@ v-else-if="!loading" class="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400" > - - - +

{{ t('admin.accounts.stats.noData') }}

@@ -547,6 +469,7 @@ import { Line } from 'vue-chartjs' import BaseDialog from '@/components/common/BaseDialog.vue' import LoadingSpinner from '@/components/common/LoadingSpinner.vue' import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue' +import Icon from '@/components/icons/Icon.vue' import { adminAPI } from '@/api/admin' import type { Account, AccountUsageStatsResponse } from '@/types' diff --git a/frontend/src/components/account/AccountStatusIndicator.vue b/frontend/src/components/account/AccountStatusIndicator.vue index 281bf832..7dae33bb 100644 --- a/frontend/src/components/account/AccountStatusIndicator.vue +++ b/frontend/src/components/account/AccountStatusIndicator.vue @@ -48,13 +48,7 @@ - - - + 429 @@ -73,13 +67,7 @@ - - - + 529 @@ -100,6 +88,7 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import type { Account } from '@/types' import { formatTime } from '@/utils/format' +import Icon from '@/components/icons/Icon.vue' const { t } = useI18n() @@ -179,4 +168,4 @@ const handleTempUnschedClick = () => { emit('show-temp-unsched', props.account) } - \ No newline at end of file + diff --git a/frontend/src/components/account/AccountTestModal.vue b/frontend/src/components/account/AccountTestModal.vue index 619a2ba3..42f3c1b9 100644 --- a/frontend/src/components/account/AccountTestModal.vue +++ b/frontend/src/components/account/AccountTestModal.vue @@ -15,14 +15,7 @@
- - - +
{{ account.name }}
@@ -70,14 +63,7 @@ >
- - - + {{ t('admin.accounts.readyToTest') }}
@@ -128,14 +114,7 @@ v-else-if="status === 'error'" class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-red-400" > - - - + {{ errorMessage }}
@@ -147,14 +126,7 @@ class="absolute right-2 top-2 rounded-lg bg-gray-800/80 p-1.5 text-gray-400 opacity-0 transition-all hover:bg-gray-700 hover:text-white group-hover:opacity-100" :title="t('admin.accounts.copyOutput')" > - - - +
@@ -162,26 +134,12 @@
- - - + {{ t('admin.accounts.testModel') }}
- - - + {{ t('admin.accounts.testPrompt') }}
@@ -278,6 +236,7 @@ import { ref, watch, nextTick } from 'vue' import { useI18n } from 'vue-i18n' import BaseDialog from '@/components/common/BaseDialog.vue' import Select from '@/components/common/Select.vue' +import Icon from '@/components/icons/Icon.vue' import { useClipboard } from '@/composables/useClipboard' import { adminAPI } from '@/api/admin' import type { Account, ClaudeModel } from '@/types' diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index 60b3d364..6dba60fe 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -318,19 +318,7 @@

- - - + {{ t('admin.accounts.customErrorCodesWarning') }}

@@ -391,14 +379,7 @@ class="hover:text-red-900 dark:hover:text-red-300" @click="removeErrorCode(code)" > - - - + @@ -642,6 +623,7 @@ import BaseDialog from '@/components/common/BaseDialog.vue' import Select from '@/components/common/Select.vue' import ProxySelector from '@/components/common/ProxySelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue' +import Icon from '@/components/icons/Icon.vue' interface Props { show: boolean diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 88b2815b..0091873c 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -81,19 +81,7 @@ : 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200' ]" > - - - + Anthropic
@@ -196,19 +172,7 @@ : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' ]" > - - - +
{{ @@ -238,19 +202,7 @@ : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' ]" > - - - +
{{ @@ -286,19 +238,7 @@ : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' ]" > - - - +
OAuth @@ -324,19 +264,7 @@ : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' ]" > - - - +
API Key @@ -380,19 +308,7 @@ : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' ]" > - - - +
@@ -487,9 +403,7 @@ : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' ]" > - - - +
@@ -532,9 +446,7 @@ : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' ]" > - - - +
@@ -710,19 +622,7 @@ class="flex items-center gap-3 rounded-lg border-2 border-purple-500 bg-purple-50 p-3 dark:bg-purple-900/20" >
- - - +
OAuth @@ -1012,19 +912,7 @@

- - - + {{ t('admin.accounts.customErrorCodesWarning') }}

@@ -1083,14 +971,7 @@ @click="removeErrorCode(code)" class="hover:text-red-900 dark:hover:text-red-300" > - - - + @@ -1158,23 +1039,11 @@
-

- - - - {{ t('admin.accounts.tempUnschedulable.notice') }} -

-
+

+ + {{ t('admin.accounts.tempUnschedulable.notice') }} +

+
@@ -1734,6 +1594,7 @@ import { useGeminiOAuth } from '@/composables/useGeminiOAuth' import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth' import type { Proxy, Group, AccountPlatform, AccountType } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' +import Icon from '@/components/icons/Icon.vue' import ProxySelector from '@/components/common/ProxySelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index dae316fa..c1ad5ddb 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -265,19 +265,7 @@

- - - + {{ t('admin.accounts.customErrorCodesWarning') }}

@@ -336,14 +324,7 @@ @click="removeErrorCode(code)" class="hover:text-red-900 dark:hover:text-red-300" > - - - + @@ -412,19 +393,7 @@

- - - + {{ t('admin.accounts.tempUnschedulable.notice') }}

@@ -458,9 +427,7 @@ @click="moveTempUnschedRule(index, -1)" class="rounded p-1 text-gray-400 transition-colors hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:text-gray-200" > - - - +
@@ -702,6 +662,7 @@ import { adminAPI } from '@/api/admin' import type { Account, Proxy, Group } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' import Select from '@/components/common/Select.vue' +import Icon from '@/components/icons/Icon.vue' import ProxySelector from '@/components/common/ProxySelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' diff --git a/frontend/src/components/account/ModelWhitelistSelector.vue b/frontend/src/components/account/ModelWhitelistSelector.vue index b029d376..c8c1b852 100644 --- a/frontend/src/components/account/ModelWhitelistSelector.vue +++ b/frontend/src/components/account/ModelWhitelistSelector.vue @@ -21,9 +21,7 @@ @click.stop="removeModel(model)" class="shrink-0 rounded-full hover:bg-gray-200 dark:hover:bg-dark-500" > - - - +
@@ -126,6 +124,7 @@ import { ref, computed } from 'vue' import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import ModelIcon from '@/components/common/ModelIcon.vue' +import Icon from '@/components/icons/Icon.vue' import { allModels, getModelsByPlatform } from '@/composables/useModelWhitelist' const { t } = useI18n() diff --git a/frontend/src/components/account/OAuthAuthorizationFlow.vue b/frontend/src/components/account/OAuthAuthorizationFlow.vue index 7ce30b46..194237fa 100644 --- a/frontend/src/components/account/OAuthAuthorizationFlow.vue +++ b/frontend/src/components/account/OAuthAuthorizationFlow.vue @@ -2,21 +2,9 @@
-
+
- - - +

{{ oauthTitle }}

@@ -66,19 +54,7 @@
@@ -427,19 +358,7 @@ >

- - - + {{ oauthAuthCodeHint }}

@@ -471,19 +378,12 @@ class="mt-3 rounded-lg border-2 border-amber-400 bg-amber-50 p-3 dark:border-amber-600 dark:bg-amber-900/30" >
- - - +

{{ $t('admin.accounts.oauth.gemini.stateWarningTitle') }}

{{ $t('admin.accounts.oauth.gemini.stateWarningDesc') }}

@@ -514,6 +414,7 @@ import { ref, computed, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useClipboard } from '@/composables/useClipboard' +import Icon from '@/components/icons/Icon.vue' import type { AddMethod, AuthInputMethod } from '@/composables/useAccountOAuth' interface Props { diff --git a/frontend/src/components/account/ReAuthAccountModal.vue b/frontend/src/components/account/ReAuthAccountModal.vue index 26320451..43d1198f 100644 --- a/frontend/src/components/account/ReAuthAccountModal.vue +++ b/frontend/src/components/account/ReAuthAccountModal.vue @@ -23,19 +23,7 @@ : 'from-orange-500 to-orange-600' ]" > - - - +
{{ @@ -135,19 +123,7 @@ : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' ]" > - - - +
@@ -179,19 +155,7 @@ : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' ]" > - - - +
@@ -295,6 +259,7 @@ import { useGeminiOAuth } from '@/composables/useGeminiOAuth' import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth' import type { Account } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' +import Icon from '@/components/icons/Icon.vue' import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue' // Type for exposed OAuthAuthorizationFlow component diff --git a/frontend/src/components/admin/account/AccountActionMenu.vue b/frontend/src/components/admin/account/AccountActionMenu.vue index 9fa7d718..980fd352 100644 --- a/frontend/src/components/admin/account/AccountActionMenu.vue +++ b/frontend/src/components/admin/account/AccountActionMenu.vue @@ -3,12 +3,33 @@
@@ -16,6 +37,14 @@ \ No newline at end of file +import { Icon } from '@/components/icons' +import type { Account } from '@/types' + +const props = defineProps<{ show: boolean; account: Account | null; position: { top: number; left: number } | null }>() +defineEmits(['close', 'test', 'stats', 'reauth', 'refresh-token', 'reset-status', 'clear-rate-limit']) +const { t } = useI18n() +const isRateLimited = computed(() => props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date()) +const isOverloaded = computed(() => props.account?.overload_until && new Date(props.account.overload_until) > new Date()) + diff --git a/frontend/src/components/admin/account/AccountStatsModal.vue b/frontend/src/components/admin/account/AccountStatsModal.vue index 93f38a83..138f5811 100644 --- a/frontend/src/components/admin/account/AccountStatsModal.vue +++ b/frontend/src/components/admin/account/AccountStatsModal.vue @@ -15,14 +15,7 @@
- - - +
{{ account.name }}
@@ -60,19 +53,7 @@ t('admin.accounts.stats.totalCost') }}
- - - +

@@ -97,19 +78,7 @@ t('admin.accounts.stats.totalRequests') }}

- - - +

@@ -129,19 +98,11 @@ t('admin.accounts.stats.avgDailyCost') }}

- - - +

@@ -195,19 +156,7 @@

- - - +
{{ t('admin.accounts.stats.todayOverview') @@ -245,19 +194,7 @@
- - - +
{{ t('admin.accounts.stats.highestCostDay') @@ -295,19 +232,11 @@
- - - +
{{ t('admin.accounts.stats.highestRequestDay') @@ -348,19 +277,7 @@
- - - +
{{ t('admin.accounts.stats.accumulatedTokens') @@ -390,19 +307,7 @@
- - - +
{{ t('admin.accounts.stats.performance') @@ -432,19 +337,11 @@
- - - +
{{ t('admin.accounts.stats.recentActivity') @@ -504,14 +401,7 @@ v-else-if="!loading" class="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400" > - - - +

{{ t('admin.accounts.stats.noData') }}

@@ -547,6 +437,7 @@ import { Line } from 'vue-chartjs' import BaseDialog from '@/components/common/BaseDialog.vue' import LoadingSpinner from '@/components/common/LoadingSpinner.vue' import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue' +import Icon from '@/components/icons/Icon.vue' import { adminAPI } from '@/api/admin' import type { Account, AccountUsageStatsResponse } from '@/types' diff --git a/frontend/src/components/admin/account/AccountTableActions.vue b/frontend/src/components/admin/account/AccountTableActions.vue index 035c9f83..96fceaa0 100644 --- a/frontend/src/components/admin/account/AccountTableActions.vue +++ b/frontend/src/components/admin/account/AccountTableActions.vue @@ -1,11 +1,19 @@ diff --git a/frontend/src/components/admin/account/AccountTableFilters.vue b/frontend/src/components/admin/account/AccountTableFilters.vue index 3721acc6..42043b33 100644 --- a/frontend/src/components/admin/account/AccountTableFilters.vue +++ b/frontend/src/components/admin/account/AccountTableFilters.vue @@ -1,23 +1,22 @@ diff --git a/frontend/src/components/admin/account/AccountTestModal.vue b/frontend/src/components/admin/account/AccountTestModal.vue index 619a2ba3..2cb1c5a5 100644 --- a/frontend/src/components/admin/account/AccountTestModal.vue +++ b/frontend/src/components/admin/account/AccountTestModal.vue @@ -15,14 +15,7 @@
- - - +
{{ account.name }}
@@ -70,32 +63,11 @@ >
- - - + {{ t('admin.accounts.readyToTest') }}
- - - - + {{ t('admin.accounts.connectingToApi') }}
@@ -114,28 +86,14 @@ v-if="status === 'success'" class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-green-400" > - - - + {{ t('admin.accounts.testCompleted') }}
- - - + {{ errorMessage }}
@@ -147,14 +105,7 @@ class="absolute right-2 top-2 rounded-lg bg-gray-800/80 p-1.5 text-gray-400 opacity-0 transition-all hover:bg-gray-700 hover:text-white group-hover:opacity-100" :title="t('admin.accounts.copyOutput')" > - - - +
@@ -162,26 +113,12 @@
- - - + {{ t('admin.accounts.testModel') }}
- - - + {{ t('admin.accounts.testPrompt') }}
@@ -210,54 +147,15 @@ : 'bg-primary-500 text-white hover:bg-primary-600' ]" > - - - - - - - - - - - + name="refresh" + size="sm" + class="animate-spin" + :stroke-width="2" + /> + + {{ status === 'connecting' @@ -278,6 +176,7 @@ import { ref, watch, nextTick } from 'vue' import { useI18n } from 'vue-i18n' import BaseDialog from '@/components/common/BaseDialog.vue' import Select from '@/components/common/Select.vue' +import { Icon } from '@/components/icons' import { useClipboard } from '@/composables/useClipboard' import { adminAPI } from '@/api/admin' import type { Account, ClaudeModel } from '@/types' diff --git a/frontend/src/components/admin/account/ReAuthAccountModal.vue b/frontend/src/components/admin/account/ReAuthAccountModal.vue index 9bfa9530..d9838a2e 100644 --- a/frontend/src/components/admin/account/ReAuthAccountModal.vue +++ b/frontend/src/components/admin/account/ReAuthAccountModal.vue @@ -23,19 +23,7 @@ : 'from-orange-500 to-orange-600' ]" > - - - +
{{ @@ -107,9 +95,7 @@ : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' ]" > - - - +
Google One @@ -135,19 +121,7 @@ : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' ]" > - - - +
@@ -179,19 +153,7 @@ : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' ]" > - - - +
@@ -295,6 +257,7 @@ import { useGeminiOAuth } from '@/composables/useGeminiOAuth' import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth' import type { Account } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' +import Icon from '@/components/icons/Icon.vue' import OAuthAuthorizationFlow from '@/components/account/OAuthAuthorizationFlow.vue' // Type for exposed OAuthAuthorizationFlow component diff --git a/frontend/src/components/admin/usage/UsageStatsCards.vue b/frontend/src/components/admin/usage/UsageStatsCards.vue index c214fc50..2af25e36 100644 --- a/frontend/src/components/admin/usage/UsageStatsCards.vue +++ b/frontend/src/components/admin/usage/UsageStatsCards.vue @@ -1,7 +1,9 @@ \ No newline at end of file +import { useI18n } from 'vue-i18n' +import type { AdminUsageStatsResponse } from '@/api/admin/usage' +import Icon from '@/components/icons/Icon.vue' + +defineProps<{ stats: AdminUsageStatsResponse | null }>() + +const { t } = useI18n() + +const formatDuration = (ms: number) => + ms < 1000 ? `${ms.toFixed(0)}ms` : `${(ms / 1000).toFixed(2)}s` + +const formatTokens = (value: number) => { + if (value >= 1e9) return (value / 1e9).toFixed(2) + 'B' + if (value >= 1e6) return (value / 1e6).toFixed(2) + 'M' + if (value >= 1e3) return (value / 1e3).toFixed(2) + 'K' + return value.toLocaleString() +} + diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index 91e71e42..87f7aeb7 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -1,22 +1,154 @@ \ No newline at end of file + +const formatCacheTokens = (tokens: number): string => { + if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M` + if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K` + return tokens.toString() +} + +const formatDuration = (ms: number | null | undefined): string => { + if (ms == null) return '-' + if (ms < 1000) return `${ms}ms` + return `${(ms / 1000).toFixed(2)}s` +} + +const copyRequestId = async (requestId: string) => { + try { + await navigator.clipboard.writeText(requestId) + copiedRequestId.value = requestId + appStore.showSuccess(t('admin.usage.requestIdCopied')) + setTimeout(() => { copiedRequestId.value = null }, 2000) + } catch { + appStore.showError(t('common.copyFailed')) + } +} + diff --git a/frontend/src/components/admin/user/UserBalanceModal.vue b/frontend/src/components/admin/user/UserBalanceModal.vue index 19e9ccab..31e242f2 100644 --- a/frontend/src/components/admin/user/UserBalanceModal.vue +++ b/frontend/src/components/admin/user/UserBalanceModal.vue @@ -37,10 +37,21 @@ watch(() => props.show, (v) => { if(v) { form.amount = 0; form.notes = '' } }) const calculateNewBalance = () => (props.user ? (props.operation === 'add' ? props.user.balance + form.amount : props.user.balance - form.amount) : 0) const handleBalanceSubmit = async () => { - if (!props.user) return; submitting.value = true + if (!props.user) return + if (!form.amount || form.amount <= 0) { + appStore.showError(t('admin.users.amountRequired')) + return + } + if (props.operation === 'subtract' && form.amount > props.user.balance) { + appStore.showError(t('admin.users.insufficientBalance')) + return + } + submitting.value = true try { await adminAPI.users.updateBalance(props.user.id, form.amount, props.operation, form.notes) appStore.showSuccess(t('common.success')); emit('success'); emit('close') - } catch {} finally { submitting.value = false } + } catch (e: any) { + appStore.showError(e.response?.data?.detail || t('common.error')) + } finally { submitting.value = false } } \ No newline at end of file diff --git a/frontend/src/components/admin/user/UserCreateModal.vue b/frontend/src/components/admin/user/UserCreateModal.vue index 2f28bf52..f2ab1e02 100644 --- a/frontend/src/components/admin/user/UserCreateModal.vue +++ b/frontend/src/components/admin/user/UserCreateModal.vue @@ -17,7 +17,7 @@
@@ -52,6 +52,7 @@ import { reactive, watch } from 'vue' import { useI18n } from 'vue-i18n'; import { adminAPI } from '@/api/admin' import { useForm } from '@/composables/useForm' import BaseDialog from '@/components/common/BaseDialog.vue' +import Icon from '@/components/icons/Icon.vue' const props = defineProps<{ show: boolean }>() const emit = defineEmits(['close', 'success']); const { t } = useI18n() diff --git a/frontend/src/components/admin/user/UserEditModal.vue b/frontend/src/components/admin/user/UserEditModal.vue index 3f6fd206..2c4b117a 100644 --- a/frontend/src/components/admin/user/UserEditModal.vue +++ b/frontend/src/components/admin/user/UserEditModal.vue @@ -21,7 +21,7 @@
@@ -59,6 +59,7 @@ import { adminAPI } from '@/api/admin' import type { User, UserAttributeValuesMap } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' import UserAttributeForm from '@/components/user/UserAttributeForm.vue' +import Icon from '@/components/icons/Icon.vue' const props = defineProps<{ show: boolean, user: User | null }>() const emit = defineEmits(['close', 'success']) @@ -86,6 +87,14 @@ const copyPassword = async () => { } const handleUpdateUser = async () => { if (!props.user) return + if (!form.email.trim()) { + appStore.showError(t('admin.users.emailRequired')) + return + } + if (form.concurrency < 1) { + appStore.showError(t('admin.users.concurrencyMin')) + return + } submitting.value = true try { const data: any = { email: form.email, username: form.username, notes: form.notes, concurrency: form.concurrency } @@ -98,4 +107,4 @@ const handleUpdateUser = async () => { appStore.showError(e.response?.data?.detail || t('admin.users.failedToUpdate')) } finally { submitting.value = false } } - \ No newline at end of file + diff --git a/frontend/src/components/common/BaseDialog.vue b/frontend/src/components/common/BaseDialog.vue index fab48fe0..3d38b568 100644 --- a/frontend/src/components/common/BaseDialog.vue +++ b/frontend/src/components/common/BaseDialog.vue @@ -21,15 +21,7 @@ class="-mr-2 rounded-xl p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-dark-500 dark:hover:bg-dark-700 dark:hover:text-dark-300" aria-label="Close modal" > - - - +
@@ -50,6 +42,7 @@ diff --git a/frontend/src/components/icons/index.ts b/frontend/src/components/icons/index.ts new file mode 100644 index 00000000..ea5ccfd4 --- /dev/null +++ b/frontend/src/components/icons/index.ts @@ -0,0 +1 @@ +export { default as Icon } from './Icon.vue' diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index 3d687b5a..16c39bf8 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -81,9 +81,7 @@ >

- - - + {{ file.hint }}

@@ -117,9 +115,7 @@
- - - +

{{ platformNote }}

@@ -144,6 +140,7 @@ import { ref, computed, h, watch, type Component } from 'vue' import { useI18n } from 'vue-i18n' import BaseDialog from '@/components/common/BaseDialog.vue' +import Icon from '@/components/icons/Icon.vue' import { useClipboard } from '@/composables/useClipboard' import type { GroupPlatform } from '@/types' diff --git a/frontend/src/components/layout/AppHeader.vue b/frontend/src/components/layout/AppHeader.vue index e3985619..fd8742c3 100644 --- a/frontend/src/components/layout/AppHeader.vue +++ b/frontend/src/components/layout/AppHeader.vue @@ -8,19 +8,7 @@ class="btn-ghost btn-icon lg:hidden" aria-label="Toggle Menu" > - - - +
- +
@@ -171,15 +163,11 @@ @click="removeOption(index)" class="rounded-lg p-1.5 text-gray-500 hover:bg-red-50 hover:text-red-600" > - - - +
@@ -256,6 +244,7 @@ import { adminAPI } from '@/api/admin' import type { UserAttributeDefinition, UserAttributeType, UserAttributeOption } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue' +import Icon from '@/components/icons/Icon.vue' import Select from '@/components/common/Select.vue' const { t } = useI18n() @@ -344,6 +333,18 @@ const removeOption = (index: number) => { } const handleSave = async () => { + if (!form.key.trim()) { + appStore.showError(t('admin.users.attributes.keyRequired')) + return + } + if (!form.name.trim()) { + appStore.showError(t('admin.users.attributes.nameRequired')) + return + } + if ((form.type === 'select' || form.type === 'multi_select') && form.options.length === 0) { + appStore.showError(t('admin.users.attributes.optionsRequired')) + return + } saving.value = true try { const data = { diff --git a/frontend/src/components/user/dashboard/UserDashboardQuickActions.vue b/frontend/src/components/user/dashboard/UserDashboardQuickActions.vue index 83180025..9d884aed 100644 --- a/frontend/src/components/user/dashboard/UserDashboardQuickActions.vue +++ b/frontend/src/components/user/dashboard/UserDashboardQuickActions.vue @@ -6,47 +6,47 @@
@@ -55,6 +55,7 @@ \ No newline at end of file + diff --git a/frontend/src/components/user/dashboard/UserDashboardRecentUsage.vue b/frontend/src/components/user/dashboard/UserDashboardRecentUsage.vue index 56f361bb..a0605c76 100644 --- a/frontend/src/components/user/dashboard/UserDashboardRecentUsage.vue +++ b/frontend/src/components/user/dashboard/UserDashboardRecentUsage.vue @@ -15,9 +15,7 @@
- - - +

{{ log.model }}

@@ -35,9 +33,7 @@ {{ t('dashboard.viewAllUsage') }} - - - +
@@ -48,6 +44,7 @@ import { useI18n } from 'vue-i18n' import LoadingSpinner from '@/components/common/LoadingSpinner.vue' import EmptyState from '@/components/common/EmptyState.vue' +import Icon from '@/components/icons/Icon.vue' import { formatDateTime } from '@/utils/format' import type { UsageLog } from '@/types' diff --git a/frontend/src/components/user/dashboard/UserDashboardStats.vue b/frontend/src/components/user/dashboard/UserDashboardStats.vue index 6cf7e07f..d375ba88 100644 --- a/frontend/src/components/user/dashboard/UserDashboardStats.vue +++ b/frontend/src/components/user/dashboard/UserDashboardStats.vue @@ -21,9 +21,7 @@
- - - +

{{ t('dashboard.apiKeys') }}

@@ -37,9 +35,7 @@
- - - +

{{ t('dashboard.todayRequests') }}

@@ -53,9 +49,7 @@
- - - +

{{ t('dashboard.todayCost') }}

@@ -79,9 +73,7 @@
- - - +

{{ t('dashboard.todayTokens') }}

@@ -95,9 +87,7 @@
- - - +

{{ t('dashboard.totalTokens') }}

@@ -111,9 +101,7 @@
- - - +

{{ t('dashboard.performance') }}

@@ -133,9 +121,7 @@
- - - +

{{ t('dashboard.avgResponse') }}

@@ -149,6 +135,7 @@ \ No newline at end of file diff --git a/frontend/src/views/admin/UsersView.vue b/frontend/src/views/admin/UsersView.vue index a16e05fa..d4bf555c 100644 --- a/frontend/src/views/admin/UsersView.vue +++ b/frontend/src/views/admin/UsersView.vue @@ -8,19 +8,11 @@
- - - + - - - +
@@ -136,9 +116,7 @@ @click="showFilterDropdown = !showFilterDropdown" class="btn btn-secondary" > - - - + {{ t('admin.users.filterSettings') }} @@ -154,16 +132,13 @@ class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700" > {{ filter.name }} - - - + name="check" + size="sm" + class="text-primary-500" + :stroke-width="2" + />
{{ attr.name }} - - - + name="check" + size="sm" + class="text-primary-500" + :stroke-width="2" + />
@@ -214,44 +186,24 @@ class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700" > {{ col.label }} - - - + name="check" + size="sm" + class="text-primary-500" + :stroke-width="2" + />
@@ -333,19 +285,7 @@ v-else class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-2 py-1 text-xs text-gray-400 dark:bg-dark-700/50 dark:text-dark-500" > - - - + {{ t('admin.users.noSubscription') }} @@ -400,19 +340,7 @@ @click="handleEdit(row)" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400" > - - - + {{ t('common.edit') }} @@ -427,34 +355,8 @@ : 'hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400' ]" > - - - - - - + + {{ row.status === 'active' ? t('admin.users.disable') : t('admin.users.enable') }} @@ -465,19 +367,7 @@ class="action-menu-trigger flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-dark-700 dark:hover:text-white" :class="{ 'bg-gray-100 text-gray-900 dark:bg-dark-700 dark:text-white': activeMenuId === row.id }" > - - - + {{ t('common.more') }}
@@ -522,9 +412,7 @@ @click="handleViewApiKeys(user); closeActionMenu()" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700" > - - - + {{ t('admin.users.apiKeys') }} @@ -533,9 +421,7 @@ @click="handleAllowedGroups(user); closeActionMenu()" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700" > - - - + {{ t('admin.users.groups') }} @@ -546,9 +432,7 @@ @click="handleDeposit(user); closeActionMenu()" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700" > - - - + {{ t('admin.users.deposit') }} @@ -571,9 +455,7 @@ @click="handleDelete(user); closeActionMenu()" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20" > - - - + {{ t('common.delete') }} @@ -597,6 +479,7 @@ import { ref, reactive, computed, onMounted, onUnmounted, type ComponentPublicIn import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import { formatDateTime } from '@/utils/format' +import Icon from '@/components/icons/Icon.vue' const { t } = useI18n() import { adminAPI } from '@/api/admin' diff --git a/frontend/src/views/auth/EmailVerifyView.vue b/frontend/src/views/auth/EmailVerifyView.vue index 50644887..abb43cab 100644 --- a/frontend/src/views/auth/EmailVerifyView.vue +++ b/frontend/src/views/auth/EmailVerifyView.vue @@ -19,19 +19,7 @@ >
- - - +

{{ t('auth.sessionExpired') }}

@@ -73,19 +61,7 @@ >
- - - +

Verification code sent! Please check your inbox. @@ -115,19 +91,7 @@ >

- - - +

{{ errorMessage }} @@ -158,20 +122,7 @@ 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" > - - - + {{ isLoading ? 'Verifying...' : 'Verify & Create Account' }} @@ -210,19 +161,7 @@ @click="handleBack" class="flex items-center gap-2 text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-gray-300" > - - - + Back to registration @@ -234,6 +173,7 @@ import { ref, onMounted, onUnmounted } from 'vue' import { useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { AuthLayout } from '@/components/layout' +import Icon from '@/components/icons/Icon.vue' import TurnstileWidget from '@/components/TurnstileWidget.vue' import { useAuthStore, useAppStore } from '@/stores' import { getPublicSettings, sendVerifyCode } from '@/api/auth' diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue index 477530b4..903db100 100644 --- a/frontend/src/views/auth/LoginView.vue +++ b/frontend/src/views/auth/LoginView.vue @@ -20,19 +20,7 @@

- - - +
- - - +
- - - - - - - + +

@@ -151,19 +96,7 @@ >

- - - +

{{ errorMessage }} @@ -198,20 +131,7 @@ 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" > - - - + {{ isLoading ? t('auth.signingIn') : t('auth.signIn') }} @@ -237,6 +157,7 @@ import { ref, reactive, onMounted } from 'vue' import { useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { AuthLayout } from '@/components/layout' +import Icon from '@/components/icons/Icon.vue' import TurnstileWidget from '@/components/TurnstileWidget.vue' import { useAuthStore, useAppStore } from '@/stores' import { getPublicSettings } from '@/api/auth' diff --git a/frontend/src/views/auth/RegisterView.vue b/frontend/src/views/auth/RegisterView.vue index 65865a11..9f3555d4 100644 --- a/frontend/src/views/auth/RegisterView.vue +++ b/frontend/src/views/auth/RegisterView.vue @@ -18,19 +18,7 @@ >

- - - +

{{ t('auth.registrationDisabled') }} @@ -47,19 +35,7 @@

- - - +
- - - +
- - - - - - - + +

@@ -181,19 +114,7 @@ >

- - - +

{{ errorMessage }} @@ -228,20 +149,7 @@ 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" > - - - + {{ isLoading ? t('auth.processing') @@ -273,6 +181,7 @@ import { ref, reactive, onMounted } from 'vue' import { useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { AuthLayout } from '@/components/layout' +import Icon from '@/components/icons/Icon.vue' import TurnstileWidget from '@/components/TurnstileWidget.vue' import { useAuthStore, useAppStore } from '@/stores' import { getPublicSettings } from '@/api/auth' diff --git a/frontend/src/views/setup/SetupWizardView.vue b/frontend/src/views/setup/SetupWizardView.vue index bc100533..2be837f5 100644 --- a/frontend/src/views/setup/SetupWizardView.vue +++ b/frontend/src/views/setup/SetupWizardView.vue @@ -8,24 +8,7 @@

- - - - +

{{ t('setup.title') }}

{{ t('setup.description') }}

@@ -46,16 +29,12 @@ : 'bg-gray-200 text-gray-500 dark:bg-dark-700 dark:text-dark-400' ]" > - - - + name="check" + size="md" + :stroke-width="2" + /> {{ index + 1 }}
- - - + {{ testingDb ? t('setup.status.testing') @@ -280,16 +250,13 @@ 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" > - - - + name="check" + size="md" + class="mr-2 text-green-500" + :stroke-width="2" + /> {{ testingRedis ? t('setup.status.testing') @@ -395,19 +362,7 @@ class="mt-6 rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20" >
- - - +

{{ errorMessage }}

@@ -438,20 +393,7 @@ 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" > - - - +

{{ t('setup.status.completed') }} @@ -474,19 +416,7 @@ @click="currentStep--" class="btn btn-secondary" > - - - + {{ t('common.back') }}

@@ -498,15 +428,7 @@ class="btn btn-primary" > {{ t('common.next') }} - - - +
@@ -55,30 +35,13 @@ " :title="copiedKeyId === row.id ? t('keys.copied') : t('keys.copyToClipboard')" > - - - - - - + name="check" + size="sm" + :stroke-width="2" + /> +
@@ -156,19 +119,7 @@ @click="openUseKeyModal(row)" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400" > - - - + {{ t('keys.useKey') }} @@ -176,19 +127,7 @@ @click="importToCcswitch(row)" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400" > - - - + {{ t('keys.importToCcSwitch') }} @@ -201,34 +140,8 @@ : 'text-gray-500 hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400' ]" > - - - - - - + + {{ row.status === 'active' ? t('keys.disable') : t('keys.enable') }} @@ -236,19 +149,7 @@ @click="editKey(row)" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400" > - - - + {{ t('common.edit') }} @@ -256,19 +157,7 @@ @click="confirmDelete(row)" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400" > - - - + {{ t('common.delete') }}
@@ -465,30 +354,34 @@

{{ t('keys.ccsClientSelect.description') }} -

-
- - -
-
+

+
+ + +
+