merge: 合并 test 分支到 test-dev,解决冲突

解决的冲突文件:
- wire_gen.go: 合并 ConcurrencyService/CRSSyncService 参数和 userAttributeHandler
- gateway_handler.go: 合并 pkg/errors 和 antigravity 导入
- gateway_service.go: 合并 validateUpstreamBaseURL 和 GetAvailableModels
- config.example.yaml: 合并 billing/turnstile 配置和额外 gateway 选项

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
yangjianbo
2026-01-03 11:36:31 +08:00
176 changed files with 27680 additions and 1952 deletions

View File

@@ -0,0 +1,200 @@
<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)
})
// 是否为 Google One OAuth
const isGoogleOne = computed(() => {
const creds = props.account.credentials as GeminiCredentials | undefined
return creds?.oauth_type === 'google_one'
})
// 是否应该显示配额信息
const shouldShowQuota = computed(() => {
return props.account.platform === 'gemini'
})
// Tier 标签文本
const tierLabel = computed(() => {
const creds = props.account.credentials as GeminiCredentials | undefined
if (isCodeAssist.value) {
// GCP Code Assist: 显示 GCP tier
const tierMap: Record<string, string> = {
LEGACY: 'Free',
PRO: 'Pro',
ULTRA: 'Ultra',
'standard-tier': 'Standard',
'pro-tier': 'Pro',
'ultra-tier': 'Ultra'
}
return tierMap[creds?.tier_id || ''] || (creds?.tier_id ? 'GCP' : 'Unknown')
}
if (isGoogleOne.value) {
// Google One: tier 映射
const tierMap: Record<string, string> = {
AI_PREMIUM: 'AI Premium',
GOOGLE_ONE_STANDARD: 'Standard',
GOOGLE_ONE_BASIC: 'Basic',
FREE: 'Free',
GOOGLE_ONE_UNKNOWN: 'Personal',
GOOGLE_ONE_UNLIMITED: 'Unlimited'
}
return tierMap[creds?.tier_id || ''] || 'Personal'
}
// AI Studio 或其他
return 'Gemini'
})
// Tier Badge 样式
const tierBadgeClass = computed(() => {
const creds = props.account.credentials as GeminiCredentials | undefined
if (isCodeAssist.value) {
// GCP Code Assist 样式
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',
'standard-tier': 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
'pro-tier': 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
'ultra-tier': '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'
)
}
if (isGoogleOne.value) {
// Google One tier 样式
const tierColorMap: Record<string, string> = {
AI_PREMIUM: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
GOOGLE_ONE_STANDARD: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
GOOGLE_ONE_BASIC: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
FREE: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400',
GOOGLE_ONE_UNKNOWN: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400',
GOOGLE_ONE_UNLIMITED: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
}
return tierColorMap[creds?.tier_id || ''] || 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
}
// AI Studio 默认样式:蓝色
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-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>

View File

@@ -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>

View File

@@ -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,108 @@ 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 creds = props.account.credentials as GeminiCredentials | undefined
const isGoogleOne = creds?.oauth_type === 'google_one'
if (isGoogleOne) {
// Google One tier 标签
const tierMap: Record<string, string> = {
AI_PREMIUM: t('admin.accounts.tier.aiPremium'),
GOOGLE_ONE_STANDARD: t('admin.accounts.tier.standard'),
GOOGLE_ONE_BASIC: t('admin.accounts.tier.basic'),
FREE: t('admin.accounts.tier.free'),
GOOGLE_ONE_UNKNOWN: t('admin.accounts.tier.personal'),
GOOGLE_ONE_UNLIMITED: t('admin.accounts.tier.unlimited')
}
return tierMap[geminiTier.value] || t('admin.accounts.tier.personal')
}
// Code Assist tier 标签
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(() => {
if (!geminiTier.value) return ''
const creds = props.account.credentials as GeminiCredentials | undefined
const isGoogleOne = creds?.oauth_type === 'google_one'
if (isGoogleOne) {
// Google One tier 颜色
const colorMap: Record<string, string> = {
AI_PREMIUM: 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300',
GOOGLE_ONE_STANDARD: 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300',
GOOGLE_ONE_BASIC: 'bg-green-100 text-green-600 dark:bg-green-900/40 dark:text-green-300',
FREE: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
GOOGLE_ONE_UNKNOWN: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
GOOGLE_ONE_UNLIMITED: 'bg-amber-100 text-amber-600 dark:bg-amber-900/40 dark:text-amber-300'
}
return colorMap[geminiTier.value] || 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
}
// Code Assist tier 颜色
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 +694,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

View File

@@ -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,16 +415,92 @@
</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>
<div class="mt-2 grid grid-cols-2 gap-3">
<!-- Google One OAuth -->
<button
type="button"
@click="handleSelectGeminiOAuthType('google_one')"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
geminiOAuthType === 'google_one'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
]"
>
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'google_one'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
</div>
<div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">
Google One
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
个人账号享受 Google One 订阅配额
</span>
<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"
>
推荐个人用户
</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"
>
无需 GCP
</span>
</div>
</div>
</button>
<!-- GCP Code Assist OAuth -->
<button
type="button"
@click="handleSelectGeminiOAuthType('code_assist')"
@@ -443,70 +523,204 @@
<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">
GCP Code Assist
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
企业级需要 GCP 项目
</span>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
需要激活 GCP 项目并绑定信用卡
<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"
>
企业用户
</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"
>
高并发
</span>
</div>
</div>
</button>
</div>
<div class="group relative">
<button
type="button"
:disabled="!geminiAIStudioOAuthEnabled"
@click="handleSelectGeminiOAuthType('ai_studio')"
<!-- Advanced Options Toggle -->
<div class="mt-3">
<button
type="button"
@click="showAdvancedOAuth = !showAdvancedOAuth"
class="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
>
<svg
:class="['h-4 w-4 transition-transform', showAdvancedOAuth ? 'rotate-90' : '']"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
<span>{{ showAdvancedOAuth ? '隐藏' : '显示' }}高级选项自建 OAuth Client</span>
</button>
</div>
<!-- Custom OAuth Client (Advanced) -->
<div v-if="showAdvancedOAuth" class="mt-3 group relative">
<button
type="button"
:disabled="!geminiAIStudioOAuthEnabled"
@click="handleSelectGeminiOAuthType('ai_studio')"
:class="[
'flex w-full items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
!geminiAIStudioOAuthEnabled ? 'cursor-not-allowed opacity-60' : '',
geminiOAuthType === 'ai_studio'
? 'border-amber-500 bg-amber-50 dark:bg-amber-900/20'
: 'border-gray-200 hover:border-amber-300 dark:border-dark-600 dark:hover:border-amber-700'
]"
>
<div
:class="[
'flex w-full items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
!geminiAIStudioOAuthEnabled ? 'cursor-not-allowed opacity-60' : '',
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'ai_studio'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
? 'bg-amber-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'ai_studio'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</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>
</div>
<span
v-if="!geminiAIStudioOAuthEnabled"
class="ml-auto shrink-0 rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
>
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredShort') }}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</svg>
</div>
<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>
</button>
<div
<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-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.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"
class="pointer-events-none absolute right-0 top-full z-50 mt-2 w-80 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 opacity-0 shadow-lg transition-opacity group-hover:opacity-100 dark:border-amber-700 dark:bg-amber-900/40 dark:text-amber-200"
class="ml-auto shrink-0 rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
>
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredTip') }}
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredShort') }}
</span>
</button>
<div
v-if="!geminiAIStudioOAuthEnabled"
class="pointer-events-none absolute right-0 top-full z-50 mt-2 w-80 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 opacity-0 shadow-lg transition-opacity group-hover:opacity-100 dark:border-amber-700 dark:bg-amber-900/40 dark:text-amber-200"
>
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredTip') }}
</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>
@@ -666,47 +880,7 @@
<!-- Whitelist Mode -->
<div v-if="modelRestrictionMode === 'whitelist'">
<div class="mb-3 rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
<p class="text-xs text-blue-700 dark:text-blue-400">
<svg
class="mr-1 inline h-4 w-4"
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>
{{ t('admin.accounts.selectAllowedModels') }}
</p>
</div>
<!-- Model Checkbox List -->
<div class="mb-3 grid grid-cols-2 gap-2">
<label
v-for="model in commonModels"
:key="model.value"
class="flex cursor-pointer items-center rounded-lg border p-3 transition-all hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700"
:class="
allowedModels.includes(model.value)
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-gray-200'
"
>
<input
type="checkbox"
:value="model.value"
v-model="allowedModels"
class="mr-2 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ model.label }}</span>
</label>
</div>
<ModelWhitelistSelector v-model="allowedModels" :platform="form.platform" />
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
<span v-if="allowedModels.length === 0">{{
@@ -969,6 +1143,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) -->
@@ -1176,6 +1509,7 @@
import { ref, reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { claudeModels, getPresetMappingsByPlatform, getModelsByPlatform, commonErrorCodes, buildModelMappingObject } from '@/composables/useModelWhitelist'
import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin'
import {
@@ -1190,6 +1524,7 @@ import type { Proxy, Group, AccountPlatform, AccountType } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
// Type for exposed OAuthAuthorizationFlow component
@@ -1299,181 +1634,26 @@ const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
const geminiOAuthType = ref<'code_assist' | 'ai_studio'>('code_assist')
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one')
const geminiAIStudioOAuthEnabled = ref(false)
const showAdvancedOAuth = ref(false)
// Common models for whitelist - Anthropic
const anthropicModels = [
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' },
{ value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku' },
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' },
{ value: 'claude-3-opus-20240229', label: 'Claude 3 Opus' },
{ value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
{ value: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku' }
]
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'
}
// Common models for whitelist - OpenAI
const openaiModels = [
{ value: 'gpt-5.2-2025-12-11', label: 'GPT-5.2' },
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
{ value: 'gpt-5.1-2025-11-13', label: 'GPT-5.1' },
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' }
]
// Common models for whitelist - Gemini
const geminiModels = [
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
{ value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' },
{ value: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro' },
{ value: 'gemini-1.5-flash', label: 'Gemini 1.5 Flash' }
]
// Computed: current models based on platform
const commonModels = computed(() => {
if (form.platform === 'openai') return openaiModels
if (form.platform === 'gemini') return geminiModels
return anthropicModels
})
// Preset mappings for quick add - Anthropic
const anthropicPresetMappings = [
{
label: 'Sonnet 4',
from: 'claude-sonnet-4-20250514',
to: 'claude-sonnet-4-20250514',
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
},
{
label: 'Sonnet 4.5',
from: 'claude-sonnet-4-5-20250929',
to: 'claude-sonnet-4-5-20250929',
color:
'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400'
},
{
label: 'Opus 4.5',
from: 'claude-opus-4-5-20251101',
to: 'claude-opus-4-5-20251101',
color:
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
},
{
label: 'Haiku 3.5',
from: 'claude-3-5-haiku-20241022',
to: 'claude-3-5-haiku-20241022',
color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400'
},
{
label: 'Haiku 4.5',
from: 'claude-haiku-4-5-20251001',
to: 'claude-haiku-4-5-20251001',
color:
'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
},
{
label: 'Opus->Sonnet',
from: 'claude-opus-4-5-20251101',
to: 'claude-sonnet-4-5-20250929',
color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
}
]
// Preset mappings for quick add - OpenAI
const openaiPresetMappings = [
{
label: 'GPT-5.2',
from: 'gpt-5.2-2025-12-11',
to: 'gpt-5.2-2025-12-11',
color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400'
},
{
label: 'GPT-5.2 Codex',
from: 'gpt-5.2-codex',
to: 'gpt-5.2-codex',
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
},
{
label: 'GPT-5.1 Codex',
from: 'gpt-5.1-codex',
to: 'gpt-5.1-codex',
color:
'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400'
},
{
label: 'Codex Max',
from: 'gpt-5.1-codex-max',
to: 'gpt-5.1-codex-max',
color:
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
},
{
label: 'Codex Mini',
from: 'gpt-5.1-codex-mini',
to: 'gpt-5.1-codex-mini',
color:
'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
},
{
label: 'Max->Codex',
from: 'gpt-5.1-codex-max',
to: 'gpt-5.1-codex',
color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
}
]
// Preset mappings for quick add - Gemini
const geminiPresetMappings = [
{
label: 'Flash',
from: 'gemini-2.0-flash',
to: 'gemini-2.0-flash',
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
},
{
label: 'Flash Lite',
from: 'gemini-2.0-flash-lite',
to: 'gemini-2.0-flash-lite',
color:
'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400'
},
{
label: '1.5 Pro',
from: 'gemini-1.5-pro',
to: 'gemini-1.5-pro',
color:
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
},
{
label: '1.5 Flash',
from: 'gemini-1.5-flash',
to: 'gemini-1.5-flash',
color:
'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
}
]
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(() => {
if (form.platform === 'openai') return openaiPresetMappings
if (form.platform === 'gemini') return geminiPresetMappings
return anthropicPresetMappings
})
// Common HTTP error codes for quick selection
const commonErrorCodes = [
{ value: 401, label: 'Unauthorized' },
{ value: 403, label: 'Forbidden' },
{ value: 429, label: 'Rate Limit' },
{ value: 500, label: 'Server Error' },
{ value: 502, label: 'Bad Gateway' },
{ value: 503, label: 'Unavailable' },
{ value: 529, label: 'Overloaded' }
]
const presetMappings = computed(() => getPresetMappingsByPlatform(form.platform))
const form = reactive({
name: '',
@@ -1511,7 +1691,10 @@ const canExchangeCode = computed(() => {
watch(
() => props.show,
(newVal) => {
if (!newVal) {
if (newVal) {
// Modal opened - fill related models
allowedModels.value = [...getModelsByPlatform(form.platform)]
} else {
resetForm()
}
}
@@ -1577,7 +1760,7 @@ watch(
{ immediate: true }
)
const handleSelectGeminiOAuthType = (oauthType: 'code_assist' | 'ai_studio') => {
const handleSelectGeminiOAuthType = (oauthType: 'code_assist' | 'google_one' | 'ai_studio') => {
if (oauthType === 'ai_studio' && !geminiAIStudioOAuthEnabled.value) {
appStore.showError(t('admin.accounts.oauth.gemini.aiStudioNotConfigured'))
return
@@ -1585,6 +1768,16 @@ const handleSelectGeminiOAuthType = (oauthType: 'code_assist' | 'ai_studio') =>
geminiOAuthType.value = oauthType
}
// Auto-fill related models when switching to whitelist mode or changing platform
watch(
[modelRestrictionMode, () => form.platform],
([newMode]) => {
if (newMode === 'whitelist') {
allowedModels.value = [...getModelsByPlatform(form.platform)]
}
}
)
// Model mapping helpers
const addModelMapping = () => {
modelMappings.value.push({ from: '', to: '' })
@@ -1595,9 +1788,7 @@ const removeModelMapping = (index: number) => {
}
const addPresetMapping = (from: string, to: string) => {
// Check if mapping already exists
const exists = modelMappings.value.some((m) => m.from === from)
if (exists) {
if (modelMappings.value.some((m) => m.from === from)) {
appStore.showInfo(t('admin.accounts.mappingExists', { model: from }))
return
}
@@ -1637,28 +1828,6 @@ const removeErrorCode = (code: number) => {
}
}
const buildModelMappingObject = (): Record<string, string> | null => {
const mapping: Record<string, string> = {}
if (modelRestrictionMode.value === 'whitelist') {
// Whitelist mode: map model to itself
for (const model of allowedModels.value) {
mapping[model] = model
}
} else {
// Mapping mode: use custom mappings
for (const m of modelMappings.value) {
const from = m.from.trim()
const to = m.to.trim()
if (from && to) {
mapping[from] = to
}
}
}
return Object.keys(mapping).length > 0 ? mapping : null
}
// Methods
const resetForm = () => {
step.value = 1
@@ -1676,7 +1845,7 @@ const resetForm = () => {
apiKeyValue.value = ''
modelMappings.value = []
modelRestrictionMode.value = 'whitelist'
allowedModels.value = []
allowedModels.value = [...claudeModels] // Default fill related models
customErrorCodesEnabled.value = false
selectedErrorCodes.value = []
customErrorCodeInput.value = null
@@ -1725,7 +1894,7 @@ const handleSubmit = async () => {
}
// Add model mapping if configured
const modelMapping = buildModelMappingObject()
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
if (modelMapping) {
credentials.model_mapping = modelMapping
}

View File

@@ -111,47 +111,7 @@
<!-- Whitelist Mode -->
<div v-if="modelRestrictionMode === 'whitelist'">
<div class="mb-3 rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
<p class="text-xs text-blue-700 dark:text-blue-400">
<svg
class="mr-1 inline h-4 w-4"
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>
{{ t('admin.accounts.selectAllowedModels') }}
</p>
</div>
<!-- Model Checkbox List -->
<div class="mb-3 grid grid-cols-2 gap-2">
<label
v-for="model in commonModels"
:key="model.value"
class="flex cursor-pointer items-center rounded-lg border p-3 transition-all hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700"
:class="
allowedModels.includes(model.value)
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-gray-200'
"
>
<input
type="checkbox"
:value="model.value"
v-model="allowedModels"
class="mr-2 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ model.label }}</span>
</label>
</div>
<ModelWhitelistSelector v-model="allowedModels" :platform="account?.platform || 'anthropic'" />
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
<span v-if="allowedModels.length === 0">{{
@@ -565,6 +525,12 @@ import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import {
getPresetMappingsByPlatform,
commonErrorCodes,
buildModelMappingObject
} from '@/composables/useModelWhitelist'
interface Props {
show: boolean
@@ -610,167 +576,8 @@ const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
// Common models for whitelist - Anthropic
const anthropicModels = [
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' },
{ value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku' },
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' },
{ value: 'claude-3-opus-20240229', label: 'Claude 3 Opus' },
{ value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
{ value: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku' }
]
// Common models for whitelist - OpenAI
const openaiModels = [
{ value: 'gpt-5.2-2025-12-11', label: 'GPT-5.2' },
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
{ value: 'gpt-5.1-2025-11-13', label: 'GPT-5.1' },
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' }
]
// Common models for whitelist - Gemini
const geminiModels = [
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
{ value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' },
{ value: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro' },
{ value: 'gemini-1.5-flash', label: 'Gemini 1.5 Flash' }
]
// Computed: current models based on platform
const commonModels = computed(() => {
if (props.account?.platform === 'openai') return openaiModels
if (props.account?.platform === 'gemini') return geminiModels
return anthropicModels
})
// Preset mappings for quick add - Anthropic
const anthropicPresetMappings = [
{
label: 'Sonnet 4',
from: 'claude-sonnet-4-20250514',
to: 'claude-sonnet-4-20250514',
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
},
{
label: 'Sonnet 4.5',
from: 'claude-sonnet-4-5-20250929',
to: 'claude-sonnet-4-5-20250929',
color:
'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400'
},
{
label: 'Opus 4.5',
from: 'claude-opus-4-5-20251101',
to: 'claude-opus-4-5-20251101',
color:
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
},
{
label: 'Haiku 3.5',
from: 'claude-3-5-haiku-20241022',
to: 'claude-3-5-haiku-20241022',
color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400'
},
{
label: 'Haiku 4.5',
from: 'claude-haiku-4-5-20251001',
to: 'claude-haiku-4-5-20251001',
color:
'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
},
{
label: 'Opus->Sonnet',
from: 'claude-opus-4-5-20251101',
to: 'claude-sonnet-4-5-20250929',
color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
}
]
// Preset mappings for quick add - OpenAI
const openaiPresetMappings = [
{
label: 'GPT-5.2',
from: 'gpt-5.2-2025-12-11',
to: 'gpt-5.2-2025-12-11',
color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400'
},
{
label: 'GPT-5.2 Codex',
from: 'gpt-5.2-codex',
to: 'gpt-5.2-codex',
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
},
{
label: 'GPT-5.1 Codex',
from: 'gpt-5.1-codex',
to: 'gpt-5.1-codex',
color:
'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400'
},
{
label: 'Codex Max',
from: 'gpt-5.1-codex-max',
to: 'gpt-5.1-codex-max',
color:
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
},
{
label: 'Codex Mini',
from: 'gpt-5.1-codex-mini',
to: 'gpt-5.1-codex-mini',
color:
'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
},
{
label: 'Max->Codex',
from: 'gpt-5.1-codex-max',
to: 'gpt-5.1-codex',
color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
}
]
// Preset mappings for quick add - Gemini
const geminiPresetMappings = [
{
label: 'Flash',
from: 'gemini-2.0-flash',
to: 'gemini-2.0-flash',
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
},
{
label: 'Flash Lite',
from: 'gemini-2.0-flash-lite',
to: 'gemini-2.0-flash-lite',
color:
'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400'
},
{
label: '1.5 Pro',
from: 'gemini-1.5-pro',
to: 'gemini-1.5-pro',
color:
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
},
{
label: '1.5 Flash',
from: 'gemini-1.5-flash',
to: 'gemini-1.5-flash',
color:
'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
}
]
// Computed: current preset mappings based on platform
const presetMappings = computed(() => {
if (props.account?.platform === 'openai') return openaiPresetMappings
if (props.account?.platform === 'gemini') return geminiPresetMappings
return anthropicPresetMappings
})
const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic'))
// Computed: default base URL based on platform
const defaultBaseUrl = computed(() => {
@@ -779,17 +586,6 @@ const defaultBaseUrl = computed(() => {
return 'https://api.anthropic.com'
})
// Common HTTP error codes for quick selection
const commonErrorCodes = [
{ value: 401, label: 'Unauthorized' },
{ value: 403, label: 'Forbidden' },
{ value: 429, label: 'Rate Limit' },
{ value: 500, label: 'Server Error' },
{ value: 502, label: 'Bad Gateway' },
{ value: 503, label: 'Unavailable' },
{ value: 529, label: 'Overloaded' }
]
const form = reactive({
name: '',
proxy_id: null as number | null,
@@ -940,28 +736,6 @@ const removeErrorCode = (code: number) => {
}
}
const buildModelMappingObject = (): Record<string, string> | null => {
const mapping: Record<string, string> = {}
if (modelRestrictionMode.value === 'whitelist') {
// Whitelist mode: model maps to itself
for (const model of allowedModels.value) {
mapping[model] = model
}
} else {
// Mapping mode: use the mapping entries
for (const m of modelMappings.value) {
const from = m.from.trim()
const to = m.to.trim()
if (from && to) {
mapping[from] = to
}
}
}
return Object.keys(mapping).length > 0 ? mapping : null
}
// Methods
const handleClose = () => {
emit('close')
@@ -978,7 +752,7 @@ const handleSubmit = async () => {
if (props.account.type === 'apikey') {
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}
const newBaseUrl = editBaseUrl.value.trim() || defaultBaseUrl.value
const modelMapping = buildModelMappingObject()
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
// Always update credentials for apikey type to handle model mapping changes
const newCredentials: Record<string, unknown> = {

View File

@@ -0,0 +1,201 @@
<template>
<div>
<!-- Multi-select Dropdown -->
<div class="relative mb-3">
<div
@click="toggleDropdown"
class="cursor-pointer rounded-lg border border-gray-300 bg-white px-3 py-2 dark:border-dark-500 dark:bg-dark-700"
>
<div class="grid grid-cols-2 gap-1.5">
<span
v-for="model in modelValue"
:key="model"
class="inline-flex items-center justify-between gap-1 rounded bg-gray-100 px-2 py-1 text-xs text-gray-700 dark:bg-dark-600 dark:text-gray-300"
>
<span class="flex items-center gap-1 truncate">
<ModelIcon :model="model" size="14px" />
<span class="truncate">{{ model }}</span>
</span>
<button
type="button"
@click.stop="removeModel(model)"
class="shrink-0 rounded-full hover:bg-gray-200 dark:hover:bg-dark-500"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
</div>
<div class="mt-2 flex items-center justify-between border-t border-gray-200 pt-2 dark:border-dark-600">
<span class="text-xs text-gray-400">{{ t('admin.accounts.modelCount', { count: modelValue.length }) }}</span>
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
<!-- Dropdown List -->
<div
v-if="showDropdown"
class="absolute left-0 right-0 top-full z-50 mt-1 rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dark-600 dark:bg-dark-700"
>
<div class="sticky top-0 border-b border-gray-200 bg-white p-2 dark:border-dark-600 dark:bg-dark-700">
<input
v-model="searchQuery"
type="text"
class="input w-full text-sm"
:placeholder="t('admin.accounts.searchModels')"
@click.stop
/>
</div>
<div class="max-h-52 overflow-auto">
<button
v-for="model in filteredModels"
:key="model.value"
type="button"
@click="toggleModel(model.value)"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-600"
>
<span
:class="[
'flex h-4 w-4 shrink-0 items-center justify-center rounded border',
modelValue.includes(model.value)
? 'border-primary-500 bg-primary-500 text-white'
: 'border-gray-300 dark:border-dark-500'
]"
>
<svg v-if="modelValue.includes(model.value)" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</span>
<ModelIcon :model="model.value" size="18px" />
<span class="truncate text-gray-900 dark:text-white">{{ model.value }}</span>
</button>
<div v-if="filteredModels.length === 0" class="px-3 py-4 text-center text-sm text-gray-500">
{{ t('admin.accounts.noMatchingModels') }}
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="mb-4 flex flex-wrap gap-2">
<button
type="button"
@click="fillRelated"
class="rounded-lg border border-blue-200 px-3 py-1.5 text-sm text-blue-600 hover:bg-blue-50 dark:border-blue-800 dark:text-blue-400 dark:hover:bg-blue-900/30"
>
{{ t('admin.accounts.fillRelatedModels') }}
</button>
<button
type="button"
@click="clearAll"
class="rounded-lg border border-red-200 px-3 py-1.5 text-sm text-red-600 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/30"
>
{{ t('admin.accounts.clearAllModels') }}
</button>
</div>
<!-- Custom Model Input -->
<div class="mb-3">
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.accounts.customModelName') }}</label>
<div class="flex gap-2">
<input
v-model="customModel"
type="text"
class="input flex-1"
:placeholder="t('admin.accounts.enterCustomModelName')"
@keydown.enter.prevent="handleEnter"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
/>
<button
type="button"
@click="addCustom"
class="rounded-lg bg-primary-50 px-4 py-2 text-sm font-medium text-primary-600 hover:bg-primary-100 dark:bg-primary-900/30 dark:text-primary-400 dark:hover:bg-primary-900/50"
>
{{ t('admin.accounts.addModel') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import ModelIcon from '@/components/common/ModelIcon.vue'
import { allModels, getModelsByPlatform } from '@/composables/useModelWhitelist'
const { t } = useI18n()
const props = defineProps<{
modelValue: string[]
platform: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
const appStore = useAppStore()
const showDropdown = ref(false)
const searchQuery = ref('')
const customModel = ref('')
const isComposing = ref(false)
const filteredModels = computed(() => {
const query = searchQuery.value.toLowerCase().trim()
if (!query) return allModels
return allModels.filter(
m => m.value.toLowerCase().includes(query) || m.label.toLowerCase().includes(query)
)
})
const toggleDropdown = () => {
showDropdown.value = !showDropdown.value
if (!showDropdown.value) searchQuery.value = ''
}
const removeModel = (model: string) => {
emit('update:modelValue', props.modelValue.filter(m => m !== model))
}
const toggleModel = (model: string) => {
if (props.modelValue.includes(model)) {
removeModel(model)
} else {
emit('update:modelValue', [...props.modelValue, model])
}
}
const addCustom = () => {
const model = customModel.value.trim()
if (!model) return
if (props.modelValue.includes(model)) {
appStore.showInfo(t('admin.accounts.modelExists'))
return
}
emit('update:modelValue', [...props.modelValue, model])
customModel.value = ''
}
const handleEnter = () => {
if (!isComposing.value) addCustom()
}
const fillRelated = () => {
const models = getModelsByPlatform(props.platform)
const newModels = [...props.modelValue]
for (const model of models) {
if (!newModels.includes(model)) newModels.push(model)
}
emit('update:modelValue', newModels)
}
const clearAll = () => {
emit('update:modelValue', [])
}
</script>

View File

@@ -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"

View File

@@ -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()