Merge remote-tracking branch 'upstream/main'
# Conflicts: # frontend/src/components/account/CreateAccountModal.vue
This commit is contained in:
154
frontend/src/components/account/AccountQuotaInfo.vue
Normal file
154
frontend/src/components/account/AccountQuotaInfo.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<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 @@
|
||||
></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>
|
||||
</template>
|
||||
|
||||
@@ -140,4 +148,23 @@ const statusText = computed(() => {
|
||||
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>
|
||||
|
||||
@@ -169,6 +169,88 @@
|
||||
<div v-else class="text-xs text-gray-400">-</div>
|
||||
</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 -->
|
||||
<template v-else>
|
||||
<div class="text-xs text-gray-400">-</div>
|
||||
@@ -176,15 +258,20 @@
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Account, AccountUsageInfo } from '@/types'
|
||||
import type { Account, AccountUsageInfo, GeminiCredentials } from '@/types'
|
||||
import UsageProgressBar from './UsageProgressBar.vue'
|
||||
import AccountQuotaInfo from './AccountQuotaInfo.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
account: Account
|
||||
@@ -201,6 +288,23 @@ const showUsageWindows = computed(
|
||||
() => 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
|
||||
const hasCodexUsage = computed(() => {
|
||||
const extra = props.account.extra
|
||||
@@ -447,6 +551,71 @@ const antigravityTier = computed(() => {
|
||||
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(() => {
|
||||
switch (antigravityTier.value) {
|
||||
@@ -488,10 +657,7 @@ const hasIneligibleTiers = computed(() => {
|
||||
})
|
||||
|
||||
const loadUsage = async () => {
|
||||
// Fetch usage for Anthropic OAuth and Setup Token accounts
|
||||
// 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
|
||||
if (!shouldFetchUsage.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
@@ -373,8 +373,12 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.googleOauth') }}</span>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ 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>
|
||||
</button>
|
||||
|
||||
@@ -411,12 +415,42 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">API Key</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">AI Studio API Key</span>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ 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>
|
||||
</button>
|
||||
</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) -->
|
||||
<div v-if="accountCategory === 'oauth-based'" class="mt-4">
|
||||
<label class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</label>
|
||||
@@ -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" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.types.codeAssist') }}</span>
|
||||
<span class="block text-xs font-medium text-blue-600 dark:text-blue-400">{{ t('admin.accounts.oauth.gemini.needsProjectId') }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.oauth.gemini.needsProjectIdDesc') }}</span>
|
||||
<div class="min-w-0">
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.accounts.gemini.oauthType.builtInTitle') }}
|
||||
</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>
|
||||
</button>
|
||||
|
||||
@@ -486,13 +551,27 @@
|
||||
</svg>
|
||||
</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-xs font-medium text-purple-600 dark:text-purple-400">{{
|
||||
t('admin.accounts.oauth.gemini.noProjectIdNeeded')
|
||||
}}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.oauth.gemini.noProjectIdNeededDesc')
|
||||
}}</span>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.accounts.gemini.oauthType.customTitle') }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.gemini.oauthType.customDesc') }}
|
||||
</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>
|
||||
<span
|
||||
v-if="!geminiAIStudioOAuthEnabled"
|
||||
@@ -511,6 +590,79 @@
|
||||
</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>
|
||||
|
||||
<!-- Account Type Selection (Antigravity - OAuth only) -->
|
||||
@@ -929,6 +1081,165 @@
|
||||
</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>
|
||||
|
||||
<!-- Intercept Warmup Requests (Anthropic only) -->
|
||||
@@ -1264,6 +1575,20 @@ const mixedScheduling = ref(false) // For antigravity accounts: enable mixed sch
|
||||
const geminiOAuthType = ref<'code_assist' | 'ai_studio'>('code_assist')
|
||||
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
|
||||
const presetMappings = computed(() => getPresetMappingsByPlatform(form.platform))
|
||||
|
||||
|
||||
@@ -121,16 +121,13 @@
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{
|
||||
t('admin.accounts.types.codeAssist')
|
||||
}}</span>
|
||||
<span class="block text-xs font-medium text-blue-600 dark:text-blue-400">{{
|
||||
t('admin.accounts.oauth.gemini.needsProjectId')
|
||||
}}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.oauth.gemini.needsProjectIdDesc')
|
||||
}}</span>
|
||||
<div class="min-w-0">
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.accounts.gemini.oauthType.builtInTitle') }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.gemini.oauthType.builtInDesc') }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -168,14 +165,13 @@
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">AI Studio</span>
|
||||
<span class="block text-xs font-medium text-purple-600 dark:text-purple-400">{{
|
||||
t('admin.accounts.oauth.gemini.noProjectIdNeeded')
|
||||
}}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.oauth.gemini.noProjectIdNeededDesc')
|
||||
}}</span>
|
||||
<div class="min-w-0">
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.accounts.gemini.oauthType.customTitle') }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.gemini.oauthType.customDesc') }}
|
||||
</span>
|
||||
<div v-if="!geminiAIStudioOAuthEnabled" class="group relative mt-1 inline-block">
|
||||
<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"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div
|
||||
v-if="windowStats"
|
||||
class="mb-0.5 flex items-center justify-between"
|
||||
:title="t('admin.accounts.usageWindow.statsTitle')"
|
||||
:title="statsTitle || t('admin.accounts.usageWindow.statsTitle')"
|
||||
>
|
||||
<div
|
||||
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<{
|
||||
resetsAt?: string | null
|
||||
color: 'indigo' | 'emerald' | 'purple' | 'amber'
|
||||
windowStats?: WindowStats | null
|
||||
statsTitle?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -69,94 +69,108 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress bars -->
|
||||
<!-- Progress bars or Unlimited badge -->
|
||||
<div class="space-y-1.5">
|
||||
<div v-if="subscription.group?.daily_limit_usd" class="flex items-center gap-2">
|
||||
<span class="w-8 flex-shrink-0 text-[10px] text-gray-500">{{
|
||||
t('subscriptionProgress.daily')
|
||||
}}</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.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)
|
||||
}}
|
||||
<!-- Unlimited subscription badge -->
|
||||
<div
|
||||
v-if="isUnlimited(subscription)"
|
||||
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"
|
||||
>
|
||||
<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('subscriptionProgress.unlimited') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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">{{
|
||||
t('subscriptionProgress.weekly')
|
||||
}}</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.weekly_usage_usd,
|
||||
subscription.group?.weekly_limit_usd
|
||||
)
|
||||
"
|
||||
:style="{
|
||||
width: getProgressWidth(
|
||||
subscription.weekly_usage_usd,
|
||||
subscription.group?.weekly_limit_usd
|
||||
)
|
||||
}"
|
||||
></div>
|
||||
<!-- Progress bars for limited subscriptions -->
|
||||
<template v-else>
|
||||
<div v-if="subscription.group?.daily_limit_usd" class="flex items-center gap-2">
|
||||
<span class="w-8 flex-shrink-0 text-[10px] text-gray-500">{{
|
||||
t('subscriptionProgress.daily')
|
||||
}}</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.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>
|
||||
</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 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">{{
|
||||
t('subscriptionProgress.weekly')
|
||||
}}</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.weekly_usage_usd,
|
||||
subscription.group?.weekly_limit_usd
|
||||
)
|
||||
"
|
||||
:style="{
|
||||
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>
|
||||
<span class="w-24 flex-shrink-0 text-right text-[10px] text-gray-500">
|
||||
{{
|
||||
formatUsage(
|
||||
subscription.monthly_usage_usd,
|
||||
subscription.group?.monthly_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.group?.monthly_limit_usd
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,7 +229,19 @@ function getMaxUsagePercentage(sub: UserSubscription): number {
|
||||
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 {
|
||||
// Unlimited subscriptions get a special color
|
||||
if (isUnlimited(sub)) {
|
||||
return 'bg-emerald-500'
|
||||
}
|
||||
const maxPercentage = getMaxUsagePercentage(sub)
|
||||
if (maxPercentage >= 90) return 'bg-red-500'
|
||||
if (maxPercentage >= 70) return 'bg-orange-500'
|
||||
|
||||
@@ -749,6 +749,7 @@ export default {
|
||||
weekly: 'Weekly',
|
||||
monthly: 'Monthly',
|
||||
noLimits: 'No limits configured',
|
||||
unlimited: 'Unlimited',
|
||||
resetNow: 'Resetting soon',
|
||||
windowNotActive: 'Window not active',
|
||||
resetInMinutes: 'Resets in {minutes}m',
|
||||
@@ -1090,10 +1091,10 @@ export default {
|
||||
stateWarningTitle: 'Note',
|
||||
stateWarningDesc: 'Recommended: paste the full callback URL (includes code & state).',
|
||||
oauthTypeLabel: 'OAuth Type',
|
||||
needsProjectId: 'For GCP Developers',
|
||||
needsProjectIdDesc: 'Requires GCP project',
|
||||
noProjectIdNeeded: 'For Regular Users',
|
||||
noProjectIdNeededDesc: 'Requires admin-configured OAuth client',
|
||||
needsProjectId: 'Built-in OAuth (Code Assist)',
|
||||
needsProjectIdDesc: 'Requires GCP project and Project ID',
|
||||
noProjectIdNeeded: 'Custom OAuth (AI Studio)',
|
||||
noProjectIdNeededDesc: 'Requires admin-configured OAuth client',
|
||||
aiStudioNotConfiguredShort: 'Not configured',
|
||||
aiStudioNotConfiguredTip:
|
||||
'AI Studio OAuth is not configured: set GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET and add Redirect URI: http://localhost:1455/auth/callback (Consent screen scopes must include https://www.googleapis.com/auth/generative-language.retriever)',
|
||||
@@ -1128,7 +1129,100 @@ export default {
|
||||
modelPassthroughDesc:
|
||||
'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.',
|
||||
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
|
||||
reAuthorizeAccount: 'Re-Authorize Account',
|
||||
@@ -1194,6 +1288,9 @@ export default {
|
||||
},
|
||||
usageWindow: {
|
||||
statsTitle: '5-Hour Window Usage Statistics',
|
||||
statsTitleDaily: 'Daily Usage Statistics',
|
||||
geminiProDaily: 'Pro',
|
||||
geminiFlashDaily: 'Flash',
|
||||
gemini3Pro: 'G3P',
|
||||
gemini3Flash: 'G3F',
|
||||
gemini3Image: 'G3I',
|
||||
@@ -1501,7 +1598,8 @@ export default {
|
||||
expiresToday: 'Expires today',
|
||||
expiresTomorrow: 'Expires tomorrow',
|
||||
viewAll: 'View all subscriptions',
|
||||
noSubscriptions: 'No active subscriptions'
|
||||
noSubscriptions: 'No active subscriptions',
|
||||
unlimited: 'Unlimited'
|
||||
},
|
||||
|
||||
// Version Badge
|
||||
@@ -1544,6 +1642,7 @@ export default {
|
||||
expires: 'Expires',
|
||||
noExpiration: 'No expiration',
|
||||
unlimited: 'Unlimited',
|
||||
unlimitedDesc: 'No usage limits on this subscription',
|
||||
daily: 'Daily',
|
||||
weekly: 'Weekly',
|
||||
monthly: 'Monthly',
|
||||
|
||||
@@ -840,6 +840,7 @@ export default {
|
||||
weekly: '每周',
|
||||
monthly: '每月',
|
||||
noLimits: '未配置限额',
|
||||
unlimited: '无限制',
|
||||
resetNow: '即将重置',
|
||||
windowNotActive: '窗口未激活',
|
||||
resetInMinutes: '{minutes} 分钟后重置',
|
||||
@@ -984,6 +985,9 @@ export default {
|
||||
},
|
||||
usageWindow: {
|
||||
statsTitle: '5小时窗口用量统计',
|
||||
statsTitleDaily: '每日用量统计',
|
||||
geminiProDaily: 'Pro',
|
||||
geminiFlashDaily: 'Flash',
|
||||
gemini3Pro: 'G3P',
|
||||
gemini3Flash: 'G3F',
|
||||
gemini3Image: 'G3I',
|
||||
@@ -1225,10 +1229,10 @@ export default {
|
||||
stateWarningTitle: '提示',
|
||||
stateWarningDesc: '建议粘贴完整回调链接(包含 code 和 state)。',
|
||||
oauthTypeLabel: 'OAuth 类型',
|
||||
needsProjectId: '适合 GCP 开发者',
|
||||
needsProjectIdDesc: '需 GCP 项目',
|
||||
noProjectIdNeeded: '适合普通用户',
|
||||
noProjectIdNeededDesc: '需管理员配置 OAuth Client',
|
||||
needsProjectId: '内置授权(Code Assist)',
|
||||
needsProjectIdDesc: '需要 GCP 项目与 Project ID',
|
||||
noProjectIdNeeded: '自定义授权(AI Studio)',
|
||||
noProjectIdNeededDesc: '需管理员配置 OAuth Client',
|
||||
aiStudioNotConfiguredShort: '未配置',
|
||||
aiStudioNotConfiguredTip: 'AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback(Consent Screen scopes 需包含 https://www.googleapis.com/auth/generative-language.retriever)',
|
||||
aiStudioNotConfigured: 'AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback'
|
||||
@@ -1260,7 +1264,99 @@ export default {
|
||||
modelPassthrough: 'Gemini 直接转发模型',
|
||||
modelPassthroughDesc: '所有模型请求将直接转发至 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
|
||||
reAuthorizeAccount: '重新授权账号',
|
||||
@@ -1698,7 +1794,8 @@ export default {
|
||||
expiresToday: '今天到期',
|
||||
expiresTomorrow: '明天到期',
|
||||
viewAll: '查看全部订阅',
|
||||
noSubscriptions: '暂无有效订阅'
|
||||
noSubscriptions: '暂无有效订阅',
|
||||
unlimited: '无限制'
|
||||
},
|
||||
|
||||
// Version Badge
|
||||
@@ -1740,6 +1837,7 @@ export default {
|
||||
expires: '到期时间',
|
||||
noExpiration: '无到期时间',
|
||||
unlimited: '无限制',
|
||||
unlimitedDesc: '该订阅无用量限制',
|
||||
daily: '每日',
|
||||
weekly: '每周',
|
||||
monthly: '每月',
|
||||
|
||||
@@ -315,6 +315,22 @@ export interface Proxy {
|
||||
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 {
|
||||
id: number
|
||||
name: string
|
||||
@@ -366,6 +382,8 @@ export interface AccountUsageInfo {
|
||||
five_hour: UsageProgress | null
|
||||
seven_day: UsageProgress | null
|
||||
seven_day_sonnet: UsageProgress | null
|
||||
gemini_pro_daily?: UsageProgress | null
|
||||
gemini_flash_daily?: UsageProgress | null
|
||||
}
|
||||
|
||||
// OpenAI Codex usage snapshot (from response headers)
|
||||
|
||||
@@ -202,16 +202,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Limits -->
|
||||
<!-- No Limits - Unlimited badge -->
|
||||
<div
|
||||
v-if="
|
||||
!row.group?.daily_limit_usd &&
|
||||
!row.group?.weekly_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>
|
||||
</template>
|
||||
|
||||
@@ -230,18 +230,26 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- No limits configured -->
|
||||
<!-- No limits configured - Unlimited badge -->
|
||||
<div
|
||||
v-if="
|
||||
!subscription.group?.daily_limit_usd &&
|
||||
!subscription.group?.weekly_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">{{
|
||||
t('userSubscriptions.unlimited')
|
||||
}}</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-4xl text-emerald-600 dark:text-emerald-400">∞</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>
|
||||
|
||||
Reference in New Issue
Block a user