Commit 7331220e authored by Edric Li's avatar Edric Li
Browse files

Merge remote-tracking branch 'upstream/main'

# Conflicts:
#	frontend/src/components/account/CreateAccountModal.vue
parents fb86002e 4f13c8de
# =============================================================================
# Docker Compose Override for Local Development
# =============================================================================
# This file automatically extends docker-compose-test.yml
# Usage: docker-compose -f docker-compose-test.yml up -d
# =============================================================================
services:
# ===========================================================================
# PostgreSQL - 暴露端口用于本地开发
# ===========================================================================
postgres:
ports:
- "127.0.0.1:5432:5432"
# ===========================================================================
# Redis - 暴露端口用于本地开发
# ===========================================================================
redis:
ports:
- "127.0.0.1:6379:6379"
...@@ -19,6 +19,10 @@ services: ...@@ -19,6 +19,10 @@ services:
image: weishaw/sub2api:latest image: weishaw/sub2api:latest
container_name: sub2api container_name: sub2api
restart: unless-stopped restart: unless-stopped
ulimits:
nofile:
soft: 100000
hard: 100000
ports: ports:
- "${BIND_HOST:-0.0.0.0}:${SERVER_PORT:-8080}:8080" - "${BIND_HOST:-0.0.0.0}:${SERVER_PORT:-8080}:8080"
volumes: volumes:
...@@ -86,6 +90,7 @@ services: ...@@ -86,6 +90,7 @@ services:
- GEMINI_OAUTH_CLIENT_ID=${GEMINI_OAUTH_CLIENT_ID:-} - GEMINI_OAUTH_CLIENT_ID=${GEMINI_OAUTH_CLIENT_ID:-}
- GEMINI_OAUTH_CLIENT_SECRET=${GEMINI_OAUTH_CLIENT_SECRET:-} - GEMINI_OAUTH_CLIENT_SECRET=${GEMINI_OAUTH_CLIENT_SECRET:-}
- GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-} - GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
- GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
...@@ -107,6 +112,10 @@ services: ...@@ -107,6 +112,10 @@ services:
image: postgres:18-alpine image: postgres:18-alpine
container_name: sub2api-postgres container_name: sub2api-postgres
restart: unless-stopped restart: unless-stopped
ulimits:
nofile:
soft: 100000
hard: 100000
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
environment: environment:
...@@ -132,6 +141,10 @@ services: ...@@ -132,6 +141,10 @@ services:
image: redis:7-alpine image: redis:7-alpine
container_name: sub2api-redis container_name: sub2api-redis
restart: unless-stopped restart: unless-stopped
ulimits:
nofile:
soft: 100000
hard: 100000
volumes: volumes:
- redis_data:/data - redis_data:/data
command: > command: >
......
```mermaid
flowchart TD
%% Master dispatch
A[HTTP Request] --> B{Route}
B -->|v1 messages| GA0
B -->|openai v1 responses| OA0
B -->|v1beta models model action| GM0
B -->|v1 messages count tokens| GT0
B -->|v1beta models list or get| GL0
%% =========================
%% FLOW A: Claude Gateway
%% =========================
subgraph FLOW_A["v1 messages Claude Gateway"]
GA0[Auth middleware] --> GA1[Read body]
GA1 -->|empty| GA1E[400 invalid_request_error]
GA1 --> GA2[ParseGatewayRequest]
GA2 -->|parse error| GA2E[400 invalid_request_error]
GA2 --> GA3{model present}
GA3 -->|no| GA3E[400 invalid_request_error]
GA3 --> GA4[streamStarted false]
GA4 --> GA5[IncrementWaitCount user]
GA5 -->|queue full| GA5E[429 rate_limit_error]
GA5 --> GA6[AcquireUserSlotWithWait]
GA6 -->|timeout or fail| GA6E[429 rate_limit_error]
GA6 --> GA7[BillingEligibility check post wait]
GA7 -->|fail| GA7E[403 billing_error]
GA7 --> GA8[Generate sessionHash]
GA8 --> GA9[Resolve platform]
GA9 --> GA10{platform gemini}
GA10 -->|yes| GA10Y[sessionKey gemini hash]
GA10 -->|no| GA10N[sessionKey hash]
GA10Y --> GA11
GA10N --> GA11
GA11[SelectAccountWithLoadAwareness] -->|err and no failed| GA11E1[503 no available accounts]
GA11 -->|err and failed| GA11E2[map failover error]
GA11 --> GA12[Warmup intercept]
GA12 -->|yes| GA12Y[return mock and release if held]
GA12 -->|no| GA13[Acquire account slot or wait]
GA13 -->|wait queue full| GA13E1[429 rate_limit_error]
GA13 -->|wait timeout| GA13E2[429 concurrency limit]
GA13 --> GA14[BindStickySession if waited]
GA14 --> GA15{account platform antigravity}
GA15 -->|yes| GA15Y[ForwardGemini antigravity]
GA15 -->|no| GA15N[Forward Claude]
GA15Y --> GA16[Release account slot and dec account wait]
GA15N --> GA16
GA16 --> GA17{UpstreamFailoverError}
GA17 -->|yes| GA18[mark failedAccountIDs and map error if exceed]
GA18 -->|loop| GA11
GA17 -->|no| GA19[success async RecordUsage and return]
GA19 --> GA20[defer release user slot and dec wait count]
end
%% =========================
%% FLOW B: OpenAI
%% =========================
subgraph FLOW_B["openai v1 responses"]
OA0[Auth middleware] --> OA1[Read body]
OA1 -->|empty| OA1E[400 invalid_request_error]
OA1 --> OA2[json Unmarshal body]
OA2 -->|parse error| OA2E[400 invalid_request_error]
OA2 --> OA3{model present}
OA3 -->|no| OA3E[400 invalid_request_error]
OA3 --> OA4{User Agent Codex CLI}
OA4 -->|no| OA4N[set default instructions]
OA4 -->|yes| OA4Y[no change]
OA4N --> OA5
OA4Y --> OA5
OA5[streamStarted false] --> OA6[IncrementWaitCount user]
OA6 -->|queue full| OA6E[429 rate_limit_error]
OA6 --> OA7[AcquireUserSlotWithWait]
OA7 -->|timeout or fail| OA7E[429 rate_limit_error]
OA7 --> OA8[BillingEligibility check post wait]
OA8 -->|fail| OA8E[403 billing_error]
OA8 --> OA9[sessionHash sha256 session_id]
OA9 --> OA10[SelectAccountWithLoadAwareness]
OA10 -->|err and no failed| OA10E1[503 no available accounts]
OA10 -->|err and failed| OA10E2[map failover error]
OA10 --> OA11[Acquire account slot or wait]
OA11 -->|wait queue full| OA11E1[429 rate_limit_error]
OA11 -->|wait timeout| OA11E2[429 concurrency limit]
OA11 --> OA12[BindStickySession openai hash if waited]
OA12 --> OA13[Forward OpenAI upstream]
OA13 --> OA14[Release account slot and dec account wait]
OA14 --> OA15{UpstreamFailoverError}
OA15 -->|yes| OA16[mark failedAccountIDs and map error if exceed]
OA16 -->|loop| OA10
OA15 -->|no| OA17[success async RecordUsage and return]
OA17 --> OA18[defer release user slot and dec wait count]
end
%% =========================
%% FLOW C: Gemini Native
%% =========================
subgraph FLOW_C["v1beta models model action Gemini Native"]
GM0[Auth middleware] --> GM1[Validate platform]
GM1 -->|invalid| GM1E[400 googleError]
GM1 --> GM2[Parse path modelName action]
GM2 -->|invalid| GM2E[400 googleError]
GM2 --> GM3{action supported}
GM3 -->|no| GM3E[404 googleError]
GM3 --> GM4[Read body]
GM4 -->|empty| GM4E[400 googleError]
GM4 --> GM5[streamStarted false]
GM5 --> GM6[IncrementWaitCount user]
GM6 -->|queue full| GM6E[429 googleError]
GM6 --> GM7[AcquireUserSlotWithWait]
GM7 -->|timeout or fail| GM7E[429 googleError]
GM7 --> GM8[BillingEligibility check post wait]
GM8 -->|fail| GM8E[403 googleError]
GM8 --> GM9[Generate sessionHash]
GM9 --> GM10[sessionKey gemini hash]
GM10 --> GM11[SelectAccountWithLoadAwareness]
GM11 -->|err and no failed| GM11E1[503 googleError]
GM11 -->|err and failed| GM11E2[mapGeminiUpstreamError]
GM11 --> GM12[Acquire account slot or wait]
GM12 -->|wait queue full| GM12E1[429 googleError]
GM12 -->|wait timeout| GM12E2[429 googleError]
GM12 --> GM13[BindStickySession if waited]
GM13 --> GM14{account platform antigravity}
GM14 -->|yes| GM14Y[ForwardGemini antigravity]
GM14 -->|no| GM14N[ForwardNative]
GM14Y --> GM15[Release account slot and dec account wait]
GM14N --> GM15
GM15 --> GM16{UpstreamFailoverError}
GM16 -->|yes| GM17[mark failedAccountIDs and map error if exceed]
GM17 -->|loop| GM11
GM16 -->|no| GM18[success async RecordUsage and return]
GM18 --> GM19[defer release user slot and dec wait count]
end
%% =========================
%% FLOW D: CountTokens
%% =========================
subgraph FLOW_D["v1 messages count tokens"]
GT0[Auth middleware] --> GT1[Read body]
GT1 -->|empty| GT1E[400 invalid_request_error]
GT1 --> GT2[ParseGatewayRequest]
GT2 -->|parse error| GT2E[400 invalid_request_error]
GT2 --> GT3{model present}
GT3 -->|no| GT3E[400 invalid_request_error]
GT3 --> GT4[BillingEligibility check]
GT4 -->|fail| GT4E[403 billing_error]
GT4 --> GT5[ForwardCountTokens]
end
%% =========================
%% FLOW E: Gemini Models List Get
%% =========================
subgraph FLOW_E["v1beta models list or get"]
GL0[Auth middleware] --> GL1[Validate platform]
GL1 -->|invalid| GL1E[400 googleError]
GL1 --> GL2{force platform antigravity}
GL2 -->|yes| GL2Y[return static fallback models]
GL2 -->|no| GL3[SelectAccountForAIStudioEndpoints]
GL3 -->|no gemini and has antigravity| GL3Y[return fallback models]
GL3 -->|no accounts| GL3E[503 googleError]
GL3 --> GL4[ForwardAIStudioGET]
GL4 -->|error| GL4E[502 googleError]
GL4 --> GL5[Passthrough response or fallback]
end
%% =========================
%% SHARED: Account Selection
%% =========================
subgraph SELECT["SelectAccountWithLoadAwareness detail"]
S0[Start] --> S1{concurrencyService nil OR load batch disabled}
S1 -->|yes| S2[SelectAccountForModelWithExclusions legacy]
S2 --> S3[tryAcquireAccountSlot]
S3 -->|acquired| S3Y[SelectionResult Acquired true ReleaseFunc]
S3 -->|not acquired| S3N[WaitPlan FallbackTimeout MaxWaiting]
S1 -->|no| S4[Resolve platform]
S4 --> S5[List schedulable accounts]
S5 --> S6[Layer1 Sticky session]
S6 -->|hit and valid| S6A[tryAcquireAccountSlot]
S6A -->|acquired| S6AY[SelectionResult Acquired true]
S6A -->|not acquired and waitingCount < StickyMax| S6AN[WaitPlan StickyTimeout Max]
S6 --> S7[Layer2 Load aware]
S7 --> S7A[Load batch concurrency plus wait to loadRate]
S7A --> S7B[Sort priority load LRU OAuth prefer for Gemini]
S7B --> S7C[tryAcquireAccountSlot in order]
S7C -->|first success| S7CY[SelectionResult Acquired true]
S7C -->|none| S8[Layer3 Fallback wait]
S8 --> S8A[Sort priority LRU]
S8A --> S8B[WaitPlan FallbackTimeout Max]
end
%% =========================
%% SHARED: Wait Acquire
%% =========================
subgraph WAIT["AcquireXSlotWithWait detail"]
W0[Try AcquireXSlot immediately] -->|acquired| W1[return ReleaseFunc]
W0 -->|not acquired| W2[Wait loop with timeout]
W2 --> W3[Backoff 100ms x1.5 jitter max2s]
W2 --> W4[If streaming and ping format send SSE ping]
W2 --> W5[Retry AcquireXSlot on timer]
W5 -->|acquired| W1
W2 -->|timeout| W6[ConcurrencyError IsTimeout true]
end
%% =========================
%% SHARED: Account Wait Queue
%% =========================
subgraph AQ["Account Wait Queue Redis Lua"]
Q1[IncrementAccountWaitCount] --> Q2{current >= max}
Q2 -->|yes| Q2Y[return false]
Q2 -->|no| Q3[INCR and if first set TTL]
Q3 --> Q4[return true]
Q5[DecrementAccountWaitCount] --> Q6[if current > 0 then DECR]
end
%% =========================
%% SHARED: Background cleanup
%% =========================
subgraph CLEANUP["Slot Cleanup Worker"]
C0[StartSlotCleanupWorker interval] --> C1[List schedulable accounts]
C1 --> C2[CleanupExpiredAccountSlots per account]
C2 --> C3[Repeat every interval]
end
```
<template>
<div v-if="shouldShowQuota" class="flex items-center gap-2">
<!-- Tier Badge -->
<span :class="['badge text-xs px-2 py-0.5 rounded font-medium', tierBadgeClass]">
{{ tierLabel }}
</span>
<!-- 限流状态 -->
<span
v-if="!isRateLimited"
class="text-xs text-gray-400 dark:text-gray-500"
>
{{ t('admin.accounts.gemini.rateLimit.ok') }}
</span>
<span
v-else
:class="[
'text-xs font-medium',
isUrgent
? 'text-red-600 dark:text-red-400 animate-pulse'
: 'text-amber-600 dark:text-amber-400'
]"
>
{{ t('admin.accounts.gemini.rateLimit.limited', { time: resetCountdown }) }}
</span>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Account, GeminiCredentials } from '@/types'
const props = defineProps<{
account: Account
}>()
const { t } = useI18n()
const now = ref(new Date())
let timer: ReturnType<typeof setInterval> | null = null
// 是否为 Code Assist OAuth
// 判断逻辑与后端保持一致:project_id 存在即为 Code Assist
const isCodeAssist = computed(() => {
const creds = props.account.credentials as GeminiCredentials | undefined
// 显式为 code_assist,或 legacy 情况(oauth_type 为空但 project_id 存在)
return creds?.oauth_type === 'code_assist' || (!creds?.oauth_type && !!creds?.project_id)
})
// 是否应该显示配额信息
const shouldShowQuota = computed(() => {
return props.account.platform === 'gemini'
})
// Tier 标签文本
const tierLabel = computed(() => {
if (isCodeAssist.value) {
const creds = props.account.credentials as GeminiCredentials | undefined
const tierMap: Record<string, string> = {
LEGACY: 'Free',
PRO: 'Pro',
ULTRA: 'Ultra'
}
return tierMap[creds?.tier_id || ''] || 'Unknown'
}
return 'Gemini'
})
// Tier Badge 样式
const tierBadgeClass = computed(() => {
if (!isCodeAssist.value) {
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
}
const creds = props.account.credentials as GeminiCredentials | undefined
const tierColorMap: Record<string, string> = {
LEGACY: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400',
PRO: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
ULTRA: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
}
return (
tierColorMap[creds?.tier_id || ''] ||
'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
)
})
// 是否限流
const isRateLimited = computed(() => {
if (!props.account.rate_limit_reset_at) return false
const resetTime = Date.parse(props.account.rate_limit_reset_at)
// 防护:如果日期解析失败(NaN),则认为未限流
if (Number.isNaN(resetTime)) return false
return resetTime > now.value.getTime()
})
// 倒计时文本
const resetCountdown = computed(() => {
if (!props.account.rate_limit_reset_at) return ''
const resetTime = Date.parse(props.account.rate_limit_reset_at)
// 防护:如果日期解析失败,显示 "-"
if (Number.isNaN(resetTime)) return '-'
const diffMs = resetTime - now.value.getTime()
if (diffMs <= 0) return t('admin.accounts.gemini.rateLimit.now')
const diffSeconds = Math.floor(diffMs / 1000)
const diffMinutes = Math.floor(diffSeconds / 60)
const diffHours = Math.floor(diffMinutes / 60)
if (diffMinutes < 1) return `${diffSeconds}s`
if (diffHours < 1) {
const secs = diffSeconds % 60
return `${diffMinutes}m ${secs}s`
}
const mins = diffMinutes % 60
return `${diffHours}h ${mins}m`
})
// 是否紧急(< 1分钟)
const isUrgent = computed(() => {
if (!props.account.rate_limit_reset_at) return false
const resetTime = Date.parse(props.account.rate_limit_reset_at)
// 防护:如果日期解析失败,返回 false
if (Number.isNaN(resetTime)) return false
const diffMs = resetTime - now.value.getTime()
return diffMs > 0 && diffMs < 60000
})
// 监听限流状态,动态启动/停止定时器
watch(
() => isRateLimited.value,
(limited) => {
if (limited && !timer) {
// 进入限流状态,启动定时器
timer = setInterval(() => {
now.value = new Date()
}, 1000)
} else if (!limited && timer) {
// 解除限流,停止定时器
clearInterval(timer)
timer = null
}
},
{ immediate: true } // 立即执行,确保挂载时已限流的情况也能启动定时器
)
onUnmounted(() => {
if (timer !== null) {
clearInterval(timer)
timer = null
}
})
</script>
...@@ -83,6 +83,14 @@ ...@@ -83,6 +83,14 @@
></div> ></div>
</div> </div>
</div> </div>
<!-- Tier Indicator -->
<span
v-if="tierDisplay"
class="inline-flex items-center rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>
{{ tierDisplay }}
</span>
</div> </div>
</template> </template>
...@@ -140,4 +148,23 @@ const statusText = computed(() => { ...@@ -140,4 +148,23 @@ const statusText = computed(() => {
return props.account.status return props.account.status
}) })
// Computed: tier display
const tierDisplay = computed(() => {
const credentials = props.account.credentials as Record<string, any> | undefined
const tierId = credentials?.tier_id
if (!tierId || tierId === 'unknown') return null
const tierMap: Record<string, string> = {
'free': 'Free',
'payg': 'Pay-as-you-go',
'pay-as-you-go': 'Pay-as-you-go',
'enterprise': 'Enterprise',
'LEGACY': 'Legacy',
'PRO': 'Pro',
'ULTRA': 'Ultra'
}
return tierMap[tierId] || tierId
})
</script> </script>
...@@ -169,6 +169,88 @@ ...@@ -169,6 +169,88 @@
<div v-else class="text-xs text-gray-400">-</div> <div v-else class="text-xs text-gray-400">-</div>
</template> </template>
<!-- Gemini platform: show quota + local usage window -->
<template v-else-if="account.platform === 'gemini'">
<!-- 账户类型徽章 -->
<div v-if="geminiTierLabel" class="mb-1 flex items-center gap-1">
<span
:class="[
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
geminiTierClass
]"
>
{{ geminiTierLabel }}
</span>
<!-- 帮助图标 -->
<span
class="group relative cursor-help"
>
<svg
class="h-3.5 w-3.5 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z"
clip-rule="evenodd"
/>
</svg>
<span
class="pointer-events-none absolute left-0 top-full z-50 mt-1 w-80 whitespace-normal break-words rounded bg-gray-900 px-3 py-2 text-xs leading-relaxed text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
<div class="font-semibold mb-1">{{ t('admin.accounts.gemini.quotaPolicy.title') }}</div>
<div class="mb-2 text-gray-300">{{ t('admin.accounts.gemini.quotaPolicy.note') }}</div>
<div class="space-y-1">
<div><strong>{{ geminiQuotaPolicyChannel }}:</strong></div>
<div class="pl-2">{{ geminiQuotaPolicyLimits }}</div>
<div class="mt-2">
<a :href="geminiQuotaPolicyDocsUrl" target="_blank" class="text-blue-400 hover:text-blue-300 underline">
{{ t('admin.accounts.gemini.quotaPolicy.columns.docs') }}
</a>
</div>
</div>
</span>
</span>
</div>
<div class="space-y-1">
<div v-if="loading" class="space-y-1">
<div class="flex items-center gap-1">
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
</div>
<div v-else-if="error" class="text-xs text-red-500">
{{ error }}
</div>
<div v-else-if="geminiUsageAvailable" class="space-y-1">
<UsageProgressBar
v-if="usageInfo?.gemini_pro_daily"
:label="t('admin.accounts.usageWindow.geminiProDaily')"
:utilization="usageInfo.gemini_pro_daily.utilization"
:resets-at="usageInfo.gemini_pro_daily.resets_at"
:window-stats="usageInfo.gemini_pro_daily.window_stats"
:stats-title="t('admin.accounts.usageWindow.statsTitleDaily')"
color="indigo"
/>
<UsageProgressBar
v-if="usageInfo?.gemini_flash_daily"
:label="t('admin.accounts.usageWindow.geminiFlashDaily')"
:utilization="usageInfo.gemini_flash_daily.utilization"
:resets-at="usageInfo.gemini_flash_daily.resets_at"
:window-stats="usageInfo.gemini_flash_daily.window_stats"
:stats-title="t('admin.accounts.usageWindow.statsTitleDaily')"
color="emerald"
/>
<p class="mt-1 text-[9px] leading-tight text-gray-400 dark:text-gray-500 italic">
* {{ t('admin.accounts.gemini.quotaPolicy.simulatedNote') || 'Simulated quota' }}
</p>
</div>
</div>
</template>
<!-- Other accounts: no usage window --> <!-- Other accounts: no usage window -->
<template v-else> <template v-else>
<div class="text-xs text-gray-400">-</div> <div class="text-xs text-gray-400">-</div>
...@@ -176,15 +258,20 @@ ...@@ -176,15 +258,20 @@
</div> </div>
<!-- Non-OAuth/Setup-Token accounts --> <!-- Non-OAuth/Setup-Token accounts -->
<div v-else class="text-xs text-gray-400">-</div> <div v-else>
<!-- Gemini API Key accounts: show quota info -->
<AccountQuotaInfo v-if="account.platform === 'gemini'" :account="account" />
<div v-else class="text-xs text-gray-400">-</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageInfo } from '@/types' import type { Account, AccountUsageInfo, GeminiCredentials } from '@/types'
import UsageProgressBar from './UsageProgressBar.vue' import UsageProgressBar from './UsageProgressBar.vue'
import AccountQuotaInfo from './AccountQuotaInfo.vue'
const props = defineProps<{ const props = defineProps<{
account: Account account: Account
...@@ -201,6 +288,23 @@ const showUsageWindows = computed( ...@@ -201,6 +288,23 @@ const showUsageWindows = computed(
() => props.account.type === 'oauth' || props.account.type === 'setup-token' () => props.account.type === 'oauth' || props.account.type === 'setup-token'
) )
const shouldFetchUsage = computed(() => {
if (props.account.platform === 'anthropic') {
return props.account.type === 'oauth' || props.account.type === 'setup-token'
}
if (props.account.platform === 'gemini') {
return props.account.type === 'oauth'
}
return false
})
const geminiUsageAvailable = computed(() => {
return (
!!usageInfo.value?.gemini_pro_daily ||
!!usageInfo.value?.gemini_flash_daily
)
})
// OpenAI Codex usage computed properties // OpenAI Codex usage computed properties
const hasCodexUsage = computed(() => { const hasCodexUsage = computed(() => {
const extra = props.account.extra const extra = props.account.extra
...@@ -447,6 +551,71 @@ const antigravityTier = computed(() => { ...@@ -447,6 +551,71 @@ const antigravityTier = computed(() => {
return null return null
}) })
// Gemini 账户类型(从 credentials 中提取)
const geminiTier = computed(() => {
if (props.account.platform !== 'gemini') return null
const creds = props.account.credentials as GeminiCredentials | undefined
return creds?.tier_id || null
})
// Gemini 是否为 Code Assist OAuth
const isGeminiCodeAssist = computed(() => {
if (props.account.platform !== 'gemini') return false
const creds = props.account.credentials as GeminiCredentials | undefined
return creds?.oauth_type === 'code_assist' || (!creds?.oauth_type && !!creds?.project_id)
})
// Gemini 账户类型显示标签
const geminiTierLabel = computed(() => {
if (!geminiTier.value) return null
const tierMap: Record<string, string> = {
LEGACY: t('admin.accounts.tier.free'),
PRO: t('admin.accounts.tier.pro'),
ULTRA: t('admin.accounts.tier.ultra')
}
return tierMap[geminiTier.value] || null
})
// Gemini 账户类型徽章样式
const geminiTierClass = computed(() => {
switch (geminiTier.value) {
case 'LEGACY':
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
case 'PRO':
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
case 'ULTRA':
return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
default:
return ''
}
})
// Gemini 配额政策信息
const geminiQuotaPolicyChannel = computed(() => {
if (isGeminiCodeAssist.value) {
return t('admin.accounts.gemini.quotaPolicy.rows.cli.channel')
}
return t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.channel')
})
const geminiQuotaPolicyLimits = computed(() => {
if (isGeminiCodeAssist.value) {
if (geminiTier.value === 'PRO' || geminiTier.value === 'ULTRA') {
return t('admin.accounts.gemini.quotaPolicy.rows.cli.limitsPremium')
}
return t('admin.accounts.gemini.quotaPolicy.rows.cli.limitsFree')
}
// AI Studio - 默认显示免费层限制
return t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.limitsFree')
})
const geminiQuotaPolicyDocsUrl = computed(() => {
if (isGeminiCodeAssist.value) {
return 'https://cloud.google.com/products/gemini/code-assist#pricing'
}
return 'https://ai.google.dev/pricing'
})
// 账户类型显示标签 // 账户类型显示标签
const antigravityTierLabel = computed(() => { const antigravityTierLabel = computed(() => {
switch (antigravityTier.value) { switch (antigravityTier.value) {
...@@ -488,10 +657,7 @@ const hasIneligibleTiers = computed(() => { ...@@ -488,10 +657,7 @@ const hasIneligibleTiers = computed(() => {
}) })
const loadUsage = async () => { const loadUsage = async () => {
// Fetch usage for Anthropic OAuth and Setup Token accounts if (!shouldFetchUsage.value) return
// OpenAI usage comes from account.extra field (updated during forwarding)
if (props.account.platform !== 'anthropic') return
if (props.account.type !== 'oauth' && props.account.type !== 'setup-token') return
loading.value = true loading.value = true
error.value = null error.value = null
......
...@@ -373,8 +373,12 @@ ...@@ -373,8 +373,12 @@
</svg> </svg>
</div> </div>
<div> <div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span> <span class="block text-sm font-medium text-gray-900 dark:text-white">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.googleOauth') }}</span> {{ t('admin.accounts.gemini.accountType.oauthTitle') }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.accountType.oauthDesc') }}
</span>
</div> </div>
</button> </button>
...@@ -411,12 +415,42 @@ ...@@ -411,12 +415,42 @@
</svg> </svg>
</div> </div>
<div> <div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">API Key</span> <span class="block text-sm font-medium text-gray-900 dark:text-white">
<span class="text-xs text-gray-500 dark:text-gray-400">AI Studio API Key</span> {{ t('admin.accounts.gemini.accountType.apiKeyTitle') }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.accountType.apiKeyDesc') }}
</span>
</div> </div>
</button> </button>
</div> </div>
<div
v-if="accountCategory === 'apikey'"
class="mt-3 rounded-lg border border-purple-200 bg-purple-50 px-3 py-2 text-xs text-purple-800 dark:border-purple-800/40 dark:bg-purple-900/20 dark:text-purple-200"
>
<p>{{ t('admin.accounts.gemini.accountType.apiKeyNote') }}</p>
<div class="mt-2 flex flex-wrap gap-2">
<a
:href="geminiHelpLinks.apiKey"
class="font-medium text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{{ t('admin.accounts.gemini.accountType.apiKeyLink') }}
</a>
<span class="text-purple-400">·</span>
<a
:href="geminiHelpLinks.aiStudioPricing"
class="font-medium text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{{ t('admin.accounts.gemini.accountType.quotaLink') }}
</a>
</div>
</div>
<!-- OAuth Type Selection (only show when oauth-based is selected) --> <!-- OAuth Type Selection (only show when oauth-based is selected) -->
<div v-if="accountCategory === 'oauth-based'" class="mt-4"> <div v-if="accountCategory === 'oauth-based'" class="mt-4">
<label class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</label> <label class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</label>
...@@ -443,10 +477,41 @@ ...@@ -443,10 +477,41 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z" />
</svg> </svg>
</div> </div>
<div> <div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.types.codeAssist') }}</span> <span class="block text-sm font-medium text-gray-900 dark:text-white">
<span class="block text-xs font-medium text-blue-600 dark:text-blue-400">{{ t('admin.accounts.oauth.gemini.needsProjectId') }}</span> {{ t('admin.accounts.gemini.oauthType.builtInTitle') }}
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.oauth.gemini.needsProjectIdDesc') }}</span> </span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.builtInDesc') }}
</span>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.builtInRequirement') }}
<a
:href="geminiHelpLinks.gcpProject"
class="ml-1 text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{{ t('admin.accounts.gemini.oauthType.gcpProjectLink') }}
</a>
</div>
<div class="mt-2 flex flex-wrap gap-1">
<span
class="rounded bg-blue-100 px-2 py-0.5 text-[10px] font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"
>
{{ t('admin.accounts.gemini.oauthType.badges.recommended') }}
</span>
<span
class="rounded bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
>
{{ t('admin.accounts.gemini.oauthType.badges.highConcurrency') }}
</span>
<span
class="rounded bg-gray-100 px-2 py-0.5 text-[10px] font-semibold text-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
{{ t('admin.accounts.gemini.oauthType.badges.noAdmin') }}
</span>
</div>
</div> </div>
</button> </button>
...@@ -486,13 +551,27 @@ ...@@ -486,13 +551,27 @@
</svg> </svg>
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">AI Studio</span> <span class="block text-sm font-medium text-gray-900 dark:text-white">
<span class="block text-xs font-medium text-purple-600 dark:text-purple-400">{{ {{ t('admin.accounts.gemini.oauthType.customTitle') }}
t('admin.accounts.oauth.gemini.noProjectIdNeeded') </span>
}}</span> <span class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ {{ t('admin.accounts.gemini.oauthType.customDesc') }}
t('admin.accounts.oauth.gemini.noProjectIdNeededDesc') </span>
}}</span> <div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.customRequirement') }}
</div>
<div class="mt-2 flex flex-wrap gap-1">
<span
class="rounded bg-purple-100 px-2 py-0.5 text-[10px] font-semibold text-purple-700 dark:bg-purple-900/40 dark:text-purple-300"
>
{{ t('admin.accounts.gemini.oauthType.badges.orgManaged') }}
</span>
<span
class="rounded bg-amber-100 px-2 py-0.5 text-[10px] font-semibold text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"
>
{{ t('admin.accounts.gemini.oauthType.badges.adminRequired') }}
</span>
</div>
</div> </div>
<span <span
v-if="!geminiAIStudioOAuthEnabled" v-if="!geminiAIStudioOAuthEnabled"
...@@ -511,6 +590,79 @@ ...@@ -511,6 +590,79 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-4 text-xs text-blue-900 dark:border-blue-800/40 dark:bg-blue-900/20 dark:text-blue-200">
<div class="flex items-start gap-3">
<svg
class="h-5 w-5 flex-shrink-0 text-blue-600 dark:text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div class="min-w-0">
<p class="text-sm font-medium text-blue-800 dark:text-blue-300">
{{ t('admin.accounts.gemini.setupGuide.title') }}
</p>
<div class="mt-2 space-y-2">
<div>
<p class="font-semibold text-blue-800 dark:text-blue-300">
{{ t('admin.accounts.gemini.setupGuide.checklistTitle') }}
</p>
<ul class="mt-1 list-disc space-y-1 pl-4">
<li>
{{ t('admin.accounts.gemini.setupGuide.checklistItems.usIp') }}
<a
:href="geminiHelpLinks.countryCheck"
class="ml-1 text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{{ t('admin.accounts.gemini.setupGuide.links.countryCheck') }}
</a>
</li>
<li>{{ t('admin.accounts.gemini.setupGuide.checklistItems.age') }}</li>
</ul>
</div>
<div>
<p class="font-semibold text-blue-800 dark:text-blue-300">
{{ t('admin.accounts.gemini.setupGuide.activationTitle') }}
</p>
<ul class="mt-1 list-disc space-y-1 pl-4">
<li>
{{ t('admin.accounts.gemini.setupGuide.activationItems.geminiWeb') }}
<a
:href="geminiHelpLinks.geminiWebActivation"
class="ml-1 text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{{ t('admin.accounts.gemini.setupGuide.links.geminiWebActivation') }}
</a>
</li>
<li>
{{ t('admin.accounts.gemini.setupGuide.activationItems.gcpProject') }}
<a
:href="geminiHelpLinks.gcpProject"
class="ml-1 text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{{ t('admin.accounts.gemini.setupGuide.links.gcpProject') }}
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div> </div>
<!-- Account Type Selection (Antigravity - OAuth only) --> <!-- Account Type Selection (Antigravity - OAuth only) -->
...@@ -929,6 +1081,165 @@ ...@@ -929,6 +1081,165 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Gemini 配额与限流政策说明 -->
<div v-if="form.platform === 'gemini'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="rounded-lg bg-gray-50 p-4 dark:bg-gray-800/40">
<div class="flex items-start gap-3">
<svg
class="h-5 w-5 flex-shrink-0 text-gray-500 dark:text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
<div class="min-w-0">
<p class="text-sm font-medium text-gray-800 dark:text-gray-200">
{{ t('admin.accounts.gemini.quotaPolicy.title') }}
</p>
<p class="mt-1 text-xs text-gray-600 dark:text-gray-400">
{{ t('admin.accounts.gemini.quotaPolicy.note') }}
</p>
<div class="mt-3 overflow-x-auto">
<table class="min-w-full text-xs text-gray-700 dark:text-gray-300">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="px-2 py-1.5 text-left font-semibold">
{{ t('admin.accounts.gemini.quotaPolicy.columns.channel') }}
</th>
<th class="px-2 py-1.5 text-left font-semibold">
{{ t('admin.accounts.gemini.quotaPolicy.columns.account') }}
</th>
<th class="px-2 py-1.5 text-left font-semibold">
{{ t('admin.accounts.gemini.quotaPolicy.columns.limits') }}
</th>
<th class="px-2 py-1.5 text-left font-semibold">
{{ t('admin.accounts.gemini.quotaPolicy.columns.docs') }}
</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="px-2 py-1.5 align-top" rowspan="2">
{{ t('admin.accounts.gemini.quotaPolicy.rows.cli.channel') }}
</td>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.cli.free') }}
</td>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.cli.limitsFree') }}
</td>
<td class="px-2 py-1.5 align-top" rowspan="2">
<a
:href="geminiQuotaDocs.codeAssist"
class="text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{{ t('admin.accounts.gemini.quotaPolicy.docs.codeAssist') }}
</a>
</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.cli.premium') }}
</td>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.cli.limitsPremium') }}
</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="px-2 py-1.5 align-top">
{{ t('admin.accounts.gemini.quotaPolicy.rows.gcloud.channel') }}
</td>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.gcloud.account') }}
</td>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.gcloud.limits') }}
</td>
<td class="px-2 py-1.5 align-top">
<a
:href="geminiQuotaDocs.codeAssist"
class="text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{{ t('admin.accounts.gemini.quotaPolicy.docs.codeAssist') }}
</a>
</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="px-2 py-1.5 align-top" rowspan="2">
{{ t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.channel') }}
</td>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.free') }}
</td>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.limitsFree') }}
</td>
<td class="px-2 py-1.5 align-top" rowspan="2">
<a
:href="geminiQuotaDocs.aiStudio"
class="text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{{ t('admin.accounts.gemini.quotaPolicy.docs.aiStudio') }}
</a>
</td>
</tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.paid') }}
</td>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.limitsPaid') }}
</td>
</tr>
<tr>
<td class="px-2 py-1.5 align-top" rowspan="2">
{{ t('admin.accounts.gemini.quotaPolicy.rows.customOAuth.channel') }}
</td>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.customOAuth.free') }}
</td>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.customOAuth.limitsFree') }}
</td>
<td class="px-2 py-1.5 align-top" rowspan="2">
<a
:href="geminiQuotaDocs.vertex"
class="text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{{ t('admin.accounts.gemini.quotaPolicy.docs.vertex') }}
</a>
</td>
</tr>
<tr>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.customOAuth.paid') }}
</td>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.customOAuth.limitsPaid') }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div> </div>
<!-- Intercept Warmup Requests (Anthropic only) --> <!-- Intercept Warmup Requests (Anthropic only) -->
...@@ -1264,6 +1575,20 @@ const mixedScheduling = ref(false) // For antigravity accounts: enable mixed sch ...@@ -1264,6 +1575,20 @@ const mixedScheduling = ref(false) // For antigravity accounts: enable mixed sch
const geminiOAuthType = ref<'code_assist' | 'ai_studio'>('code_assist') const geminiOAuthType = ref<'code_assist' | 'ai_studio'>('code_assist')
const geminiAIStudioOAuthEnabled = ref(false) const geminiAIStudioOAuthEnabled = ref(false)
const geminiQuotaDocs = {
codeAssist: 'https://developers.google.com/gemini-code-assist/resources/quotas',
aiStudio: 'https://ai.google.dev/pricing',
vertex: 'https://cloud.google.com/vertex-ai/generative-ai/docs/quotas'
}
const geminiHelpLinks = {
apiKey: 'https://aistudio.google.com/app/apikey',
aiStudioPricing: 'https://ai.google.dev/pricing',
gcpProject: 'https://console.cloud.google.com/welcome/new',
geminiWebActivation: 'https://gemini.google.com/gems/create?hl=en-US',
countryCheck: 'https://policies.google.com/country-association-form'
}
// Computed: current preset mappings based on platform // Computed: current preset mappings based on platform
const presetMappings = computed(() => getPresetMappingsByPlatform(form.platform)) const presetMappings = computed(() => getPresetMappingsByPlatform(form.platform))
......
...@@ -121,16 +121,13 @@ ...@@ -121,16 +121,13 @@
/> />
</svg> </svg>
</div> </div>
<div> <div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ <span class="block text-sm font-medium text-gray-900 dark:text-white">
t('admin.accounts.types.codeAssist') {{ t('admin.accounts.gemini.oauthType.builtInTitle') }}
}}</span> </span>
<span class="block text-xs font-medium text-blue-600 dark:text-blue-400">{{ <span class="text-xs text-gray-500 dark:text-gray-400">
t('admin.accounts.oauth.gemini.needsProjectId') {{ t('admin.accounts.gemini.oauthType.builtInDesc') }}
}}</span> </span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.oauth.gemini.needsProjectIdDesc')
}}</span>
</div> </div>
</button> </button>
...@@ -168,14 +165,13 @@ ...@@ -168,14 +165,13 @@
/> />
</svg> </svg>
</div> </div>
<div> <div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">AI Studio</span> <span class="block text-sm font-medium text-gray-900 dark:text-white">
<span class="block text-xs font-medium text-purple-600 dark:text-purple-400">{{ {{ t('admin.accounts.gemini.oauthType.customTitle') }}
t('admin.accounts.oauth.gemini.noProjectIdNeeded') </span>
}}</span> <span class="text-xs text-gray-500 dark:text-gray-400">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ {{ t('admin.accounts.gemini.oauthType.customDesc') }}
t('admin.accounts.oauth.gemini.noProjectIdNeededDesc') </span>
}}</span>
<div v-if="!geminiAIStudioOAuthEnabled" class="group relative mt-1 inline-block"> <div v-if="!geminiAIStudioOAuthEnabled" class="group relative mt-1 inline-block">
<span <span
class="rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-300" class="rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
<div <div
v-if="windowStats" v-if="windowStats"
class="mb-0.5 flex items-center justify-between" class="mb-0.5 flex items-center justify-between"
:title="t('admin.accounts.usageWindow.statsTitle')" :title="statsTitle || t('admin.accounts.usageWindow.statsTitle')"
> >
<div <div
class="flex cursor-help items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400" class="flex cursor-help items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400"
...@@ -60,6 +60,7 @@ const props = defineProps<{ ...@@ -60,6 +60,7 @@ const props = defineProps<{
resetsAt?: string | null resetsAt?: string | null
color: 'indigo' | 'emerald' | 'purple' | 'amber' color: 'indigo' | 'emerald' | 'purple' | 'amber'
windowStats?: WindowStats | null windowStats?: WindowStats | null
statsTitle?: string
}>() }>()
const { t } = useI18n() const { t } = useI18n()
......
...@@ -69,94 +69,108 @@ ...@@ -69,94 +69,108 @@
</span> </span>
</div> </div>
<!-- Progress bars --> <!-- Progress bars or Unlimited badge -->
<div class="space-y-1.5"> <div class="space-y-1.5">
<div v-if="subscription.group?.daily_limit_usd" class="flex items-center gap-2"> <!-- Unlimited subscription badge -->
<span class="w-8 flex-shrink-0 text-[10px] text-gray-500">{{ <div
t('subscriptionProgress.daily') v-if="isUnlimited(subscription)"
}}</span> class="flex items-center gap-2 rounded-lg bg-gradient-to-r from-emerald-50 to-teal-50 px-2.5 py-1.5 dark:from-emerald-900/20 dark:to-teal-900/20"
<div class="h-1.5 min-w-0 flex-1 rounded-full bg-gray-200 dark:bg-dark-600"> >
<div <span class="text-lg text-emerald-600 dark:text-emerald-400"></span>
class="h-1.5 rounded-full transition-all" <span class="text-xs font-medium text-emerald-700 dark:text-emerald-300">
:class=" {{ t('subscriptionProgress.unlimited') }}
getProgressBarClass(
subscription.daily_usage_usd,
subscription.group?.daily_limit_usd
)
"
:style="{
width: getProgressWidth(
subscription.daily_usage_usd,
subscription.group?.daily_limit_usd
)
}"
></div>
</div>
<span class="w-24 flex-shrink-0 text-right text-[10px] text-gray-500">
{{
formatUsage(subscription.daily_usage_usd, subscription.group?.daily_limit_usd)
}}
</span> </span>
</div> </div>
<div v-if="subscription.group?.weekly_limit_usd" class="flex items-center gap-2"> <!-- Progress bars for limited subscriptions -->
<span class="w-8 flex-shrink-0 text-[10px] text-gray-500">{{ <template v-else>
t('subscriptionProgress.weekly') <div v-if="subscription.group?.daily_limit_usd" class="flex items-center gap-2">
}}</span> <span class="w-8 flex-shrink-0 text-[10px] text-gray-500">{{
<div class="h-1.5 min-w-0 flex-1 rounded-full bg-gray-200 dark:bg-dark-600"> t('subscriptionProgress.daily')
<div }}</span>
class="h-1.5 rounded-full transition-all" <div class="h-1.5 min-w-0 flex-1 rounded-full bg-gray-200 dark:bg-dark-600">
:class=" <div
getProgressBarClass( class="h-1.5 rounded-full transition-all"
subscription.weekly_usage_usd, :class="
subscription.group?.weekly_limit_usd getProgressBarClass(
) subscription.daily_usage_usd,
" subscription.group?.daily_limit_usd
:style="{ )
width: getProgressWidth( "
subscription.weekly_usage_usd, :style="{
subscription.group?.weekly_limit_usd width: getProgressWidth(
) subscription.daily_usage_usd,
}" subscription.group?.daily_limit_usd
></div> )
}"
></div>
</div>
<span class="w-24 flex-shrink-0 text-right text-[10px] text-gray-500">
{{
formatUsage(subscription.daily_usage_usd, subscription.group?.daily_limit_usd)
}}
</span>
</div> </div>
<span class="w-24 flex-shrink-0 text-right text-[10px] text-gray-500">
{{
formatUsage(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd)
}}
</span>
</div>
<div v-if="subscription.group?.monthly_limit_usd" class="flex items-center gap-2"> <div v-if="subscription.group?.weekly_limit_usd" class="flex items-center gap-2">
<span class="w-8 flex-shrink-0 text-[10px] text-gray-500">{{ <span class="w-8 flex-shrink-0 text-[10px] text-gray-500">{{
t('subscriptionProgress.monthly') t('subscriptionProgress.weekly')
}}</span> }}</span>
<div class="h-1.5 min-w-0 flex-1 rounded-full bg-gray-200 dark:bg-dark-600"> <div class="h-1.5 min-w-0 flex-1 rounded-full bg-gray-200 dark:bg-dark-600">
<div <div
class="h-1.5 rounded-full transition-all" class="h-1.5 rounded-full transition-all"
:class=" :class="
getProgressBarClass( getProgressBarClass(
subscription.monthly_usage_usd, subscription.weekly_usage_usd,
subscription.group?.monthly_limit_usd subscription.group?.weekly_limit_usd
) )
" "
:style="{ :style="{
width: getProgressWidth( width: getProgressWidth(
subscription.weekly_usage_usd,
subscription.group?.weekly_limit_usd
)
}"
></div>
</div>
<span class="w-24 flex-shrink-0 text-right text-[10px] text-gray-500">
{{
formatUsage(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd)
}}
</span>
</div>
<div v-if="subscription.group?.monthly_limit_usd" class="flex items-center gap-2">
<span class="w-8 flex-shrink-0 text-[10px] text-gray-500">{{
t('subscriptionProgress.monthly')
}}</span>
<div class="h-1.5 min-w-0 flex-1 rounded-full bg-gray-200 dark:bg-dark-600">
<div
class="h-1.5 rounded-full transition-all"
:class="
getProgressBarClass(
subscription.monthly_usage_usd,
subscription.group?.monthly_limit_usd
)
"
:style="{
width: getProgressWidth(
subscription.monthly_usage_usd,
subscription.group?.monthly_limit_usd
)
}"
></div>
</div>
<span class="w-24 flex-shrink-0 text-right text-[10px] text-gray-500">
{{
formatUsage(
subscription.monthly_usage_usd, subscription.monthly_usage_usd,
subscription.group?.monthly_limit_usd subscription.group?.monthly_limit_usd
) )
}" }}
></div> </span>
</div> </div>
<span class="w-24 flex-shrink-0 text-right text-[10px] text-gray-500"> </template>
{{
formatUsage(
subscription.monthly_usage_usd,
subscription.group?.monthly_limit_usd
)
}}
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
...@@ -215,7 +229,19 @@ function getMaxUsagePercentage(sub: UserSubscription): number { ...@@ -215,7 +229,19 @@ function getMaxUsagePercentage(sub: UserSubscription): number {
return percentages.length > 0 ? Math.max(...percentages) : 0 return percentages.length > 0 ? Math.max(...percentages) : 0
} }
function isUnlimited(sub: UserSubscription): boolean {
return (
!sub.group?.daily_limit_usd &&
!sub.group?.weekly_limit_usd &&
!sub.group?.monthly_limit_usd
)
}
function getProgressDotClass(sub: UserSubscription): string { function getProgressDotClass(sub: UserSubscription): string {
// Unlimited subscriptions get a special color
if (isUnlimited(sub)) {
return 'bg-emerald-500'
}
const maxPercentage = getMaxUsagePercentage(sub) const maxPercentage = getMaxUsagePercentage(sub)
if (maxPercentage >= 90) return 'bg-red-500' if (maxPercentage >= 90) return 'bg-red-500'
if (maxPercentage >= 70) return 'bg-orange-500' if (maxPercentage >= 70) return 'bg-orange-500'
......
...@@ -749,6 +749,7 @@ export default { ...@@ -749,6 +749,7 @@ export default {
weekly: 'Weekly', weekly: 'Weekly',
monthly: 'Monthly', monthly: 'Monthly',
noLimits: 'No limits configured', noLimits: 'No limits configured',
unlimited: 'Unlimited',
resetNow: 'Resetting soon', resetNow: 'Resetting soon',
windowNotActive: 'Window not active', windowNotActive: 'Window not active',
resetInMinutes: 'Resets in {minutes}m', resetInMinutes: 'Resets in {minutes}m',
...@@ -1090,10 +1091,10 @@ export default { ...@@ -1090,10 +1091,10 @@ export default {
stateWarningTitle: 'Note', stateWarningTitle: 'Note',
stateWarningDesc: 'Recommended: paste the full callback URL (includes code & state).', stateWarningDesc: 'Recommended: paste the full callback URL (includes code & state).',
oauthTypeLabel: 'OAuth Type', oauthTypeLabel: 'OAuth Type',
needsProjectId: 'For GCP Developers', needsProjectId: 'Built-in OAuth (Code Assist)',
needsProjectIdDesc: 'Requires GCP project', needsProjectIdDesc: 'Requires GCP project and Project ID',
noProjectIdNeeded: 'For Regular Users', noProjectIdNeeded: 'Custom OAuth (AI Studio)',
noProjectIdNeededDesc: 'Requires admin-configured OAuth client', noProjectIdNeededDesc: 'Requires admin-configured OAuth client',
aiStudioNotConfiguredShort: 'Not configured', aiStudioNotConfiguredShort: 'Not configured',
aiStudioNotConfiguredTip: 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)', '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)',
...@@ -1128,7 +1129,100 @@ export default { ...@@ -1128,7 +1129,100 @@ export default {
modelPassthroughDesc: modelPassthroughDesc:
'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.', 'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.',
baseUrlHint: 'Leave default for official Gemini API', baseUrlHint: 'Leave default for official Gemini API',
apiKeyHint: 'Your Gemini API Key (starts with AIza)' apiKeyHint: 'Your Gemini API Key (starts with AIza)',
accountType: {
oauthTitle: 'OAuth (Gemini)',
oauthDesc: 'Authorize with your Google account and choose an OAuth type.',
apiKeyTitle: 'API Key (AI Studio)',
apiKeyDesc: 'Fastest setup. Use an AIza API key.',
apiKeyNote:
'Best for light testing. Free tier has strict rate limits and data may be used for training.',
apiKeyLink: 'Get API Key',
quotaLink: 'Quota guide'
},
oauthType: {
builtInTitle: 'Built-in OAuth (Gemini CLI / Code Assist)',
builtInDesc: 'Uses Google built-in client ID. No admin configuration required.',
builtInRequirement: 'Requires a GCP project and Project ID.',
gcpProjectLink: 'Create project',
customTitle: 'Custom OAuth (AI Studio OAuth)',
customDesc: 'Uses admin-configured OAuth client for org management.',
customRequirement: 'Admin must configure Client ID and add you as a test user.',
badges: {
recommended: 'Recommended',
highConcurrency: 'High concurrency',
noAdmin: 'No admin setup',
orgManaged: 'Org managed',
adminRequired: 'Admin required'
}
},
setupGuide: {
title: 'Gemini Setup Checklist',
checklistTitle: 'Checklist',
checklistItems: {
usIp: 'Use a US IP and ensure your account country is set to US.',
age: 'Account must be 18+.'
},
activationTitle: 'One-click Activation',
activationItems: {
geminiWeb: 'Activate Gemini Web to avoid User not initialized.',
gcpProject: 'Activate a GCP project and get the Project ID for Code Assist.'
},
links: {
countryCheck: 'Check country association',
geminiWebActivation: 'Activate Gemini Web',
gcpProject: 'Open GCP Console'
}
},
quotaPolicy: {
title: 'Gemini Quota & Limit Policy (Reference)',
note: 'Note: Gemini does not provide an official quota inquiry API. The "Daily Quota" shown here is an estimate simulated by the system based on account tiers for scheduling reference only. Please refer to official Google errors for actual limits.',
columns: {
channel: 'Auth Channel',
account: 'Account Status',
limits: 'Limit Policy',
docs: 'Official Docs'
},
docs: {
codeAssist: 'Code Assist Quotas',
aiStudio: 'AI Studio Pricing',
vertex: 'Vertex AI Quotas'
},
simulatedNote: 'Simulated quota, for reference only',
rows: {
cli: {
channel: 'Gemini CLI (Official Google Login / Code Assist)',
free: 'Free Google Account',
premium: 'Google One AI Premium',
limitsFree: 'RPD ~1000; RPM ~60 (soft)',
limitsPremium: 'RPD ~1500+; RPM ~60+ (priority queue)'
},
gcloud: {
channel: 'GCP Code Assist (gcloud auth)',
account: 'No Code Assist subscription',
limits: 'RPD ~1000; RPM ~60 (preview)'
},
aiStudio: {
channel: 'AI Studio API Key / OAuth',
free: 'No billing (free tier)',
paid: 'Billing enabled (pay-as-you-go)',
limitsFree: 'RPD 50; RPM 2 (Pro) / 15 (Flash)',
limitsPaid: 'RPD unlimited; RPM 1000+ (per model quota)'
},
customOAuth: {
channel: 'Custom OAuth Client (GCP)',
free: 'Project not billed',
paid: 'Project billed',
limitsFree: 'RPD 50; RPM 2 (project quota)',
limitsPaid: 'RPD unlimited; RPM 1000+ (project quota)'
}
}
},
rateLimit: {
ok: 'Not rate limited',
limited: 'Rate limited {time}',
now: 'now'
}
}, },
// Re-Auth Modal // Re-Auth Modal
reAuthorizeAccount: 'Re-Authorize Account', reAuthorizeAccount: 'Re-Authorize Account',
...@@ -1194,6 +1288,9 @@ export default { ...@@ -1194,6 +1288,9 @@ export default {
}, },
usageWindow: { usageWindow: {
statsTitle: '5-Hour Window Usage Statistics', statsTitle: '5-Hour Window Usage Statistics',
statsTitleDaily: 'Daily Usage Statistics',
geminiProDaily: 'Pro',
geminiFlashDaily: 'Flash',
gemini3Pro: 'G3P', gemini3Pro: 'G3P',
gemini3Flash: 'G3F', gemini3Flash: 'G3F',
gemini3Image: 'G3I', gemini3Image: 'G3I',
...@@ -1501,7 +1598,8 @@ export default { ...@@ -1501,7 +1598,8 @@ export default {
expiresToday: 'Expires today', expiresToday: 'Expires today',
expiresTomorrow: 'Expires tomorrow', expiresTomorrow: 'Expires tomorrow',
viewAll: 'View all subscriptions', viewAll: 'View all subscriptions',
noSubscriptions: 'No active subscriptions' noSubscriptions: 'No active subscriptions',
unlimited: 'Unlimited'
}, },
// Version Badge // Version Badge
...@@ -1544,6 +1642,7 @@ export default { ...@@ -1544,6 +1642,7 @@ export default {
expires: 'Expires', expires: 'Expires',
noExpiration: 'No expiration', noExpiration: 'No expiration',
unlimited: 'Unlimited', unlimited: 'Unlimited',
unlimitedDesc: 'No usage limits on this subscription',
daily: 'Daily', daily: 'Daily',
weekly: 'Weekly', weekly: 'Weekly',
monthly: 'Monthly', monthly: 'Monthly',
......
...@@ -840,6 +840,7 @@ export default { ...@@ -840,6 +840,7 @@ export default {
weekly: '每周', weekly: '每周',
monthly: '每月', monthly: '每月',
noLimits: '未配置限额', noLimits: '未配置限额',
unlimited: '无限制',
resetNow: '即将重置', resetNow: '即将重置',
windowNotActive: '窗口未激活', windowNotActive: '窗口未激活',
resetInMinutes: '{minutes} 分钟后重置', resetInMinutes: '{minutes} 分钟后重置',
...@@ -984,6 +985,9 @@ export default { ...@@ -984,6 +985,9 @@ export default {
}, },
usageWindow: { usageWindow: {
statsTitle: '5小时窗口用量统计', statsTitle: '5小时窗口用量统计',
statsTitleDaily: '每日用量统计',
geminiProDaily: 'Pro',
geminiFlashDaily: 'Flash',
gemini3Pro: 'G3P', gemini3Pro: 'G3P',
gemini3Flash: 'G3F', gemini3Flash: 'G3F',
gemini3Image: 'G3I', gemini3Image: 'G3I',
...@@ -1225,10 +1229,10 @@ export default { ...@@ -1225,10 +1229,10 @@ export default {
stateWarningTitle: '提示', stateWarningTitle: '提示',
stateWarningDesc: '建议粘贴完整回调链接(包含 code 和 state)。', stateWarningDesc: '建议粘贴完整回调链接(包含 code 和 state)。',
oauthTypeLabel: 'OAuth 类型', oauthTypeLabel: 'OAuth 类型',
needsProjectId: '适合 GCP 开发者', needsProjectId: '内置授权(Code Assist)',
needsProjectIdDesc: '需 GCP 项目', needsProjectIdDesc: ' GCP 项目与 Project ID',
noProjectIdNeeded: '适合普通用户', noProjectIdNeeded: '自定义授权(AI Studio)',
noProjectIdNeededDesc: '需管理员配置 OAuth Client', noProjectIdNeededDesc: '需管理员配置 OAuth Client',
aiStudioNotConfiguredShort: '未配置', 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)', 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' aiStudioNotConfigured: 'AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback'
...@@ -1260,7 +1264,99 @@ export default { ...@@ -1260,7 +1264,99 @@ export default {
modelPassthrough: 'Gemini 直接转发模型', modelPassthrough: 'Gemini 直接转发模型',
modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。', modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。',
baseUrlHint: '留空使用官方 Gemini API', baseUrlHint: '留空使用官方 Gemini API',
apiKeyHint: '您的 Gemini API Key(以 AIza 开头)' apiKeyHint: '您的 Gemini API Key(以 AIza 开头)',
accountType: {
oauthTitle: 'OAuth 授权(Gemini)',
oauthDesc: '使用 Google 账号授权,并选择 OAuth 子类型。',
apiKeyTitle: 'API 密钥(AI Studio)',
apiKeyDesc: '最快接入方式,使用 AIza API Key。',
apiKeyNote: '适合轻量测试。免费层限流严格,数据可能用于训练。',
apiKeyLink: '获取 API Key',
quotaLink: '配额说明'
},
oauthType: {
builtInTitle: '内置授权(Gemini CLI / Code Assist)',
builtInDesc: '使用 Google 内置客户端 ID,无需管理员配置。',
builtInRequirement: '需要 GCP 项目并填写 Project ID。',
gcpProjectLink: '创建项目',
customTitle: '自定义授权(AI Studio OAuth)',
customDesc: '使用管理员预设的 OAuth 客户端,适合组织管理。',
customRequirement: '需管理员配置 Client ID 并加入测试用户白名单。',
badges: {
recommended: '推荐',
highConcurrency: '高并发',
noAdmin: '无需管理员配置',
orgManaged: '组织管理',
adminRequired: '需要管理员'
}
},
setupGuide: {
title: 'Gemini 使用准备',
checklistTitle: '准备工作',
checklistItems: {
usIp: '使用美国 IP,并确保账号归属地为美国。',
age: '账号需满 18 岁。'
},
activationTitle: '服务激活',
activationItems: {
geminiWeb: '激活 Gemini Web,避免 User not initialized。',
gcpProject: '激活 GCP 项目,获取 Code Assist 所需 Project ID。'
},
links: {
countryCheck: '检查归属地',
geminiWebActivation: '激活 Gemini Web',
gcpProject: '打开 GCP 控制台'
}
},
quotaPolicy: {
title: 'Gemini 配额与限流政策(参考)',
note: '注意:Gemini 官方未提供用量查询接口。此处显示的“每日配额”是由系统根据账号等级模拟计算的估算值,仅供调度参考,请以 Google 官方实际报错为准。',
columns: {
channel: '授权通道',
account: '账号状态',
limits: '限流政策',
docs: '官方文档'
},
docs: {
codeAssist: 'Code Assist 配额',
aiStudio: 'AI Studio 定价',
vertex: 'Vertex AI 配额'
},
simulatedNote: '本地模拟配额,仅供参考',
rows: {
cli: {
channel: 'Gemini CLI(官方 Google 登录 / Code Assist)',
free: '免费 Google 账号',
premium: 'Google One AI Premium',
limitsFree: 'RPD ~1000;RPM ~60(软限制)',
limitsPremium: 'RPD ~1500+;RPM ~60+(优先队列)'
},
gcloud: {
channel: 'GCP Code Assist(gcloud 登录)',
account: '未购买 Code Assist 订阅',
limits: 'RPD ~1000;RPM ~60(预览期)'
},
aiStudio: {
channel: 'AI Studio API Key / OAuth',
free: '未绑卡(免费层)',
paid: '已绑卡(按量付费)',
limitsFree: 'RPD 50;RPM 2(Pro)/ 15(Flash)',
limitsPaid: 'RPD 不限;RPM 1000+(按模型配额)'
},
customOAuth: {
channel: 'Custom OAuth Client(GCP)',
free: '项目未绑卡',
paid: '项目已绑卡',
limitsFree: 'RPD 50;RPM 2(项目配额)',
limitsPaid: 'RPD 不限;RPM 1000+(项目配额)'
}
}
},
rateLimit: {
ok: '未限流',
limited: '限流 {time}',
now: '现在'
}
}, },
// Re-Auth Modal // Re-Auth Modal
reAuthorizeAccount: '重新授权账号', reAuthorizeAccount: '重新授权账号',
...@@ -1698,7 +1794,8 @@ export default { ...@@ -1698,7 +1794,8 @@ export default {
expiresToday: '今天到期', expiresToday: '今天到期',
expiresTomorrow: '明天到期', expiresTomorrow: '明天到期',
viewAll: '查看全部订阅', viewAll: '查看全部订阅',
noSubscriptions: '暂无有效订阅' noSubscriptions: '暂无有效订阅',
unlimited: '无限制'
}, },
// Version Badge // Version Badge
...@@ -1740,6 +1837,7 @@ export default { ...@@ -1740,6 +1837,7 @@ export default {
expires: '到期时间', expires: '到期时间',
noExpiration: '无到期时间', noExpiration: '无到期时间',
unlimited: '无限制', unlimited: '无限制',
unlimitedDesc: '该订阅无用量限制',
daily: '每日', daily: '每日',
weekly: '每周', weekly: '每周',
monthly: '每月', monthly: '每月',
......
...@@ -315,6 +315,22 @@ export interface Proxy { ...@@ -315,6 +315,22 @@ export interface Proxy {
updated_at: string updated_at: string
} }
// Gemini credentials structure for OAuth and API Key authentication
export interface GeminiCredentials {
// API Key authentication
api_key?: string
// OAuth authentication
access_token?: string
refresh_token?: string
oauth_type?: 'code_assist' | 'ai_studio' | string
tier_id?: 'LEGACY' | 'PRO' | 'ULTRA' | string
project_id?: string
token_type?: string
scope?: string
expires_at?: string
}
export interface Account { export interface Account {
id: number id: number
name: string name: string
...@@ -366,6 +382,8 @@ export interface AccountUsageInfo { ...@@ -366,6 +382,8 @@ export interface AccountUsageInfo {
five_hour: UsageProgress | null five_hour: UsageProgress | null
seven_day: UsageProgress | null seven_day: UsageProgress | null
seven_day_sonnet: UsageProgress | null seven_day_sonnet: UsageProgress | null
gemini_pro_daily?: UsageProgress | null
gemini_flash_daily?: UsageProgress | null
} }
// OpenAI Codex usage snapshot (from response headers) // OpenAI Codex usage snapshot (from response headers)
......
...@@ -202,16 +202,19 @@ ...@@ -202,16 +202,19 @@
</div> </div>
</div> </div>
<!-- No Limits --> <!-- No Limits - Unlimited badge -->
<div <div
v-if=" v-if="
!row.group?.daily_limit_usd && !row.group?.daily_limit_usd &&
!row.group?.weekly_limit_usd && !row.group?.weekly_limit_usd &&
!row.group?.monthly_limit_usd !row.group?.monthly_limit_usd
" "
class="text-xs text-gray-500" class="flex items-center gap-2 rounded-lg bg-gradient-to-r from-emerald-50 to-teal-50 px-3 py-2 dark:from-emerald-900/20 dark:to-teal-900/20"
> >
{{ t('admin.subscriptions.noLimits') }} <span class="text-lg text-emerald-600 dark:text-emerald-400"></span>
<span class="text-xs font-medium text-emerald-700 dark:text-emerald-300">
{{ t('admin.subscriptions.unlimited') }}
</span>
</div> </div>
</div> </div>
</template> </template>
......
...@@ -230,18 +230,26 @@ ...@@ -230,18 +230,26 @@
</p> </p>
</div> </div>
<!-- No limits configured --> <!-- No limits configured - Unlimited badge -->
<div <div
v-if=" v-if="
!subscription.group?.daily_limit_usd && !subscription.group?.daily_limit_usd &&
!subscription.group?.weekly_limit_usd && !subscription.group?.weekly_limit_usd &&
!subscription.group?.monthly_limit_usd !subscription.group?.monthly_limit_usd
" "
class="py-4 text-center" class="flex items-center justify-center rounded-xl bg-gradient-to-r from-emerald-50 to-teal-50 py-6 dark:from-emerald-900/20 dark:to-teal-900/20"
> >
<span class="text-sm text-gray-500 dark:text-dark-400">{{ <div class="flex items-center gap-3">
t('userSubscriptions.unlimited') <span class="text-4xl text-emerald-600 dark:text-emerald-400"></span>
}}</span> <div>
<p class="text-sm font-medium text-emerald-700 dark:text-emerald-300">
{{ t('userSubscriptions.unlimited') }}
</p>
<p class="text-xs text-emerald-600/70 dark:text-emerald-400/70">
{{ t('userSubscriptions.unlimitedDesc') }}
</p>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
......
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