fix(frontend): sync with main and finalize i18n & component optimizations

This commit is contained in:
IanShaw027
2026-01-04 21:00:10 +08:00
186 changed files with 8252 additions and 3891 deletions

View File

@@ -12,7 +12,8 @@ import type {
AccountUsageInfo,
WindowStats,
ClaudeModel,
AccountUsageStatsResponse
AccountUsageStatsResponse,
TempUnschedulableStatus
} from '@/types'
/**
@@ -170,6 +171,30 @@ export async function clearRateLimit(id: number): Promise<{ message: string }> {
return data
}
/**
* Get temporary unschedulable status
* @param id - Account ID
* @returns Status with detail state if active
*/
export async function getTempUnschedulableStatus(id: number): Promise<TempUnschedulableStatus> {
const { data } = await apiClient.get<TempUnschedulableStatus>(
`/admin/accounts/${id}/temp-unschedulable`
)
return data
}
/**
* Reset temporary unschedulable status
* @param id - Account ID
* @returns Success confirmation
*/
export async function resetTempUnschedulable(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(
`/admin/accounts/${id}/temp-unschedulable`
)
return data
}
/**
* Generate OAuth authorization URL
* @param endpoint - API endpoint path
@@ -332,6 +357,8 @@ export const accountsAPI = {
getUsage,
getTodayStats,
clearRateLimit,
getTempUnschedulableStatus,
resetTempUnschedulable,
setSchedulable,
getAvailableModels,
generateAuthUrl,

View File

@@ -19,7 +19,8 @@ export interface GeminiOAuthCapabilities {
export interface GeminiAuthUrlRequest {
proxy_id?: number
project_id?: string
oauth_type?: 'code_assist' | 'ai_studio'
oauth_type?: 'code_assist' | 'google_one' | 'ai_studio'
tier_id?: string
}
export interface GeminiExchangeCodeRequest {
@@ -27,10 +28,23 @@ export interface GeminiExchangeCodeRequest {
state: string
code: string
proxy_id?: number
oauth_type?: 'code_assist' | 'ai_studio'
oauth_type?: 'code_assist' | 'google_one' | 'ai_studio'
tier_id?: string
}
export type GeminiTokenInfo = Record<string, unknown>
export type GeminiTokenInfo = {
access_token?: string
refresh_token?: string
token_type?: string
scope?: string
expires_in?: number
expires_at?: number
project_id?: string
oauth_type?: string
tier_id?: string
extra?: Record<string, unknown>
[key: string]: unknown
}
export async function generateAuthUrl(
payload: GeminiAuthUrlRequest

View File

@@ -1,28 +1,29 @@
<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>
<div v-if="shouldShowQuota">
<!-- First line: Platform + Tier Badge -->
<div class="mb-1 flex items-center gap-1">
<span :class="['badge text-xs px-2 py-0.5 rounded font-medium', tierBadgeClass]">
{{ tierLabel }}
</span>
</div>
<!-- 限流状态 -->
<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>
<!-- Usage status: unlimited flow or rate limit -->
<div class="text-xs text-gray-400 dark:text-gray-500">
<span v-if="!isRateLimited">
{{ t('admin.accounts.gemini.rateLimit.unlimited') }}
</span>
<span
v-else
:class="[
'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>
</div>
</template>
@@ -64,70 +65,67 @@ 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')
const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
if (tier === 'gcp_enterprise') return 'GCP Enterprise'
if (tier === 'gcp_standard') return 'GCP Standard'
// Backward compatibility
const upper = (creds?.tier_id || '').toString().trim().toUpperCase()
if (upper.includes('ULTRA') || upper.includes('ENTERPRISE')) return 'GCP Enterprise'
if (upper) return `GCP ${upper}`
return 'GCP'
}
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'
const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
if (tier === 'google_ai_ultra') return 'Google AI Ultra'
if (tier === 'google_ai_pro') return 'Google AI Pro'
if (tier === 'google_one_free') return 'Google One Free'
// Backward compatibility
const upper = (creds?.tier_id || '').toString().trim().toUpperCase()
if (upper === 'AI_PREMIUM') return 'Google AI Pro'
if (upper === 'GOOGLE_ONE_UNLIMITED') return 'Google AI Ultra'
if (upper) return `Google One ${upper}`
return 'Google One'
}
// AI Studio 或其他
return 'Gemini'
// API Key: 显示 AI Studio
const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
if (tier === 'aistudio_paid') return 'AI Studio Pay-as-you-go'
if (tier === 'aistudio_free') return 'AI Studio Free Tier'
return 'AI Studio'
})
// Tier Badge 样式
// 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'
)
const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
if (tier === 'gcp_enterprise') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
if (tier === 'gcp_standard') return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
// Backward compatibility
const upper = (creds?.tier_id || '').toString().trim().toUpperCase()
if (upper.includes('ULTRA') || upper.includes('ENTERPRISE')) return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
}
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'
const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
if (tier === 'google_ai_ultra') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
if (tier === 'google_ai_pro') return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
if (tier === 'google_one_free') return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
// Backward compatibility
const upper = (creds?.tier_id || '').toString().trim().toUpperCase()
if (upper === 'GOOGLE_ONE_UNLIMITED') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
if (upper === 'AI_PREMIUM') return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
}
// AI Studio 默认样式:蓝色
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
if (tier === 'aistudio_paid') return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
if (tier === 'aistudio_free') return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
})
// 是否限流

View File

@@ -1,7 +1,16 @@
<template>
<div class="flex items-center gap-2">
<!-- Main Status Badge -->
<span :class="['badge text-xs', statusClass]">
<button
v-if="isTempUnschedulable"
type="button"
:class="['badge text-xs', statusClass, 'cursor-pointer']"
:title="t('admin.accounts.status.viewTempUnschedDetails')"
@click="handleTempUnschedClick"
>
{{ statusText }}
</button>
<span v-else :class="['badge text-xs', statusClass]">
{{ statusText }}
</span>
@@ -52,7 +61,7 @@
<div
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
{{ t('admin.accounts.statuses.rateLimitedUntil', { time: formatTime(account.rate_limit_reset_at) }) }}
{{ t('admin.accounts.status.rateLimitedUntil', { time: formatTime(account.rate_limit_reset_at) }) }}
<div
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
></div>
@@ -77,20 +86,12 @@
<div
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
{{ t('admin.accounts.statuses.overloadedUntil', { time: formatTime(account.overload_until) }) }}
{{ t('admin.accounts.status.overloadedUntil', { time: formatTime(account.overload_until) }) }}
<div
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
></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>
@@ -106,6 +107,10 @@ const props = defineProps<{
account: Account
}>()
const emit = defineEmits<{
(e: 'show-temp-unsched', account: Account): void
}>()
// Computed: is rate limited (429)
const isRateLimited = computed(() => {
if (!props.account.rate_limit_reset_at) return false
@@ -118,6 +123,12 @@ const isOverloaded = computed(() => {
return new Date(props.account.overload_until) > new Date()
})
// Computed: is temp unschedulable
const isTempUnschedulable = computed(() => {
if (!props.account.temp_unschedulable_until) return false
return new Date(props.account.temp_unschedulable_until) > new Date()
})
// Computed: has error status
const hasError = computed(() => {
return props.account.status === 'error'
@@ -125,6 +136,12 @@ const hasError = computed(() => {
// Computed: status badge class
const statusClass = computed(() => {
if (hasError.value) {
return 'badge-danger'
}
if (isTempUnschedulable.value) {
return 'badge-warning'
}
if (!props.account.schedulable || isRateLimited.value || isOverloaded.value) {
return 'badge-gray'
}
@@ -142,32 +159,24 @@ const statusClass = computed(() => {
// Computed: status text
const statusText = computed(() => {
if (hasError.value) {
return t('admin.accounts.status.error')
}
if (isTempUnschedulable.value) {
return t('admin.accounts.status.tempUnschedulable')
}
if (!props.account.schedulable) {
return t('admin.accounts.statuses.paused')
return t('admin.accounts.status.paused')
}
if (isRateLimited.value || isOverloaded.value) {
return t('admin.accounts.statuses.limited')
return t('admin.accounts.status.limited')
}
return t(`admin.accounts.statuses.${props.account.status}`)
return t(`admin.accounts.status.${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 handleTempUnschedClick = () => {
if (!isTempUnschedulable.value) return
emit('show-temp-unsched', props.account)
}
const tierMap: Record<string, string> = {
'free': 'Free',
'payg': 'Pay-as-you-go',
'pay-as-you-go': 'Pay-as-you-go',
'enterprise': 'Enterprise',
'LEGACY': 'Legacy',
'PRO': 'Pro',
'ULTRA': 'Ultra'
}
return tierMap[tierId] || tierId
})
</script>
</script>

View File

@@ -186,17 +186,17 @@
<!-- 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">
<!-- Auth Type + Tier Badge (first line) -->
<div v-if="geminiAuthTypeLabel" 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 }}
{{ geminiAuthTypeLabel }}
</span>
<!-- 帮助图标 -->
<!-- Help icon -->
<span
class="group relative cursor-help"
>
@@ -220,7 +220,7 @@
<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">
<a :href="geminiQuotaPolicyDocsUrl" target="_blank" rel="noopener noreferrer" class="text-blue-400 hover:text-blue-300 underline">
{{ t('admin.accounts.gemini.quotaPolicy.columns.docs') }} →
</a>
</div>
@@ -229,6 +229,7 @@
</span>
</div>
<!-- Usage data or unlimited flow -->
<div class="space-y-1">
<div v-if="loading" class="space-y-1">
<div class="flex items-center gap-1">
@@ -240,29 +241,25 @@
<div v-else-if="error" class="text-xs text-red-500">
{{ error }}
</div>
<!-- Gemini: show daily usage bars when available -->
<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"
v-for="bar in geminiUsageBars"
:key="bar.key"
:label="bar.label"
:utilization="bar.utilization"
:resets-at="bar.resetsAt"
:window-stats="bar.windowStats"
:color="bar.color"
/>
<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>
<!-- AI Studio Client OAuth: show unlimited flow (no usage tracking) -->
<div v-else class="text-xs text-gray-400">
{{ t('admin.accounts.gemini.rateLimit.unlimited') }}
</div>
</div>
</template>
@@ -284,7 +281,7 @@
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageInfo, GeminiCredentials } from '@/types'
import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types'
import UsageProgressBar from './UsageProgressBar.vue'
import AccountQuotaInfo from './AccountQuotaInfo.vue'
@@ -299,16 +296,18 @@ const error = ref<string | null>(null)
const usageInfo = ref<AccountUsageInfo | null>(null)
// Show usage windows for OAuth and Setup Token accounts
const showUsageWindows = computed(
() => props.account.type === 'oauth' || props.account.type === 'setup-token'
)
const showUsageWindows = computed(() => {
// Gemini: we can always compute local usage windows from DB logs (simulated quotas).
if (props.account.platform === 'gemini') return true
return 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 true
}
if (props.account.platform === 'antigravity') {
return props.account.type === 'oauth'
@@ -318,8 +317,12 @@ const shouldFetchUsage = computed(() => {
const geminiUsageAvailable = computed(() => {
return (
!!usageInfo.value?.gemini_shared_daily ||
!!usageInfo.value?.gemini_pro_daily ||
!!usageInfo.value?.gemini_flash_daily
!!usageInfo.value?.gemini_flash_daily ||
!!usageInfo.value?.gemini_shared_minute ||
!!usageInfo.value?.gemini_pro_minute ||
!!usageInfo.value?.gemini_flash_minute
)
})
@@ -565,6 +568,12 @@ const geminiTier = computed(() => {
return creds?.tier_id || null
})
const geminiOAuthType = computed(() => {
if (props.account.platform !== 'gemini') return null
const creds = props.account.credentials as GeminiCredentials | undefined
return (creds?.oauth_type || '').trim() || null
})
// Gemini 是否为 Code Assist OAuth
const isGeminiCodeAssist = computed(() => {
if (props.account.platform !== 'gemini') return false
@@ -572,94 +581,208 @@ const isGeminiCodeAssist = computed(() => {
return creds?.oauth_type === 'code_assist' || (!creds?.oauth_type && !!creds?.project_id)
})
// Gemini 账户类型显示标签
const geminiTierLabel = computed(() => {
if (!geminiTier.value) return null
const geminiChannelShort = computed((): 'ai studio' | 'gcp' | 'google one' | 'client' | null => {
if (props.account.platform !== 'gemini') return null
const creds = props.account.credentials as GeminiCredentials | undefined
const isGoogleOne = creds?.oauth_type === 'google_one'
// API Key accounts are AI Studio.
if (props.account.type === 'apikey') return 'ai studio'
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')
}
if (geminiOAuthType.value === 'google_one') return 'google one'
if (isGeminiCodeAssist.value) return 'gcp'
if (geminiOAuthType.value === 'ai_studio') return 'client'
// 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
// Fallback (unknown legacy data): treat as AI Studio.
return 'ai studio'
})
// Gemini 账户类型徽章样式
const geminiUserLevel = computed((): string | null => {
if (props.account.platform !== 'gemini') return null
const tier = (geminiTier.value || '').toString().trim()
const tierLower = tier.toLowerCase()
const tierUpper = tier.toUpperCase()
// Google One: free / pro / ultra
if (geminiOAuthType.value === 'google_one') {
if (tierLower === 'google_one_free') return 'free'
if (tierLower === 'google_ai_pro') return 'pro'
if (tierLower === 'google_ai_ultra') return 'ultra'
// Backward compatibility (legacy tier markers)
if (tierUpper === 'AI_PREMIUM' || tierUpper === 'GOOGLE_ONE_STANDARD') return 'pro'
if (tierUpper === 'GOOGLE_ONE_UNLIMITED') return 'ultra'
if (tierUpper === 'FREE' || tierUpper === 'GOOGLE_ONE_BASIC' || tierUpper === 'GOOGLE_ONE_UNKNOWN' || tierUpper === '') return 'free'
return null
}
// GCP Code Assist: standard / enterprise
if (isGeminiCodeAssist.value) {
if (tierLower === 'gcp_enterprise') return 'enterprise'
if (tierLower === 'gcp_standard') return 'standard'
// Backward compatibility
if (tierUpper.includes('ULTRA') || tierUpper.includes('ENTERPRISE')) return 'enterprise'
return 'standard'
}
// AI Studio (API Key) and Client OAuth: free / paid
if (props.account.type === 'apikey' || geminiOAuthType.value === 'ai_studio') {
if (tierLower === 'aistudio_paid') return 'paid'
if (tierLower === 'aistudio_free') return 'free'
// Backward compatibility
if (tierUpper.includes('PAID') || tierUpper.includes('PAYG') || tierUpper.includes('PAY')) return 'paid'
if (tierUpper.includes('FREE')) return 'free'
if (props.account.type === 'apikey') return 'free'
return null
}
return null
})
// Gemini 认证类型(按要求:授权方式简称 + 用户等级)
const geminiAuthTypeLabel = computed(() => {
if (props.account.platform !== 'gemini') return null
if (!geminiChannelShort.value) return null
return geminiUserLevel.value ? `${geminiChannelShort.value} ${geminiUserLevel.value}` : geminiChannelShort.value
})
// Gemini 账户类型徽章样式(统一样式)
const geminiTierClass = computed(() => {
if (!geminiTier.value) return ''
// Use channel+level to choose a stable color without depending on raw tier_id variants.
const channel = geminiChannelShort.value
const level = geminiUserLevel.value
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'
if (channel === 'client' || channel === 'ai studio') {
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-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 ''
if (channel === 'google one') {
if (level === 'ultra') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
if (level === 'pro') return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
}
if (channel === 'gcp') {
if (level === 'enterprise') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
}
return ''
})
// Gemini 配额政策信息
const geminiQuotaPolicyChannel = computed(() => {
if (geminiOAuthType.value === 'google_one') {
return t('admin.accounts.gemini.quotaPolicy.rows.googleOne.channel')
}
if (isGeminiCodeAssist.value) {
return t('admin.accounts.gemini.quotaPolicy.rows.cli.channel')
return t('admin.accounts.gemini.quotaPolicy.rows.gcp.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')
const tierLower = (geminiTier.value || '').toString().trim().toLowerCase()
if (geminiOAuthType.value === 'google_one') {
if (tierLower === 'google_ai_ultra' || geminiUserLevel.value === 'ultra') {
return t('admin.accounts.gemini.quotaPolicy.rows.googleOne.limitsUltra')
}
return t('admin.accounts.gemini.quotaPolicy.rows.cli.limitsFree')
if (tierLower === 'google_ai_pro' || geminiUserLevel.value === 'pro') {
return t('admin.accounts.gemini.quotaPolicy.rows.googleOne.limitsPro')
}
return t('admin.accounts.gemini.quotaPolicy.rows.googleOne.limitsFree')
}
if (isGeminiCodeAssist.value) {
if (tierLower === 'gcp_enterprise' || geminiUserLevel.value === 'enterprise') {
return t('admin.accounts.gemini.quotaPolicy.rows.gcp.limitsEnterprise')
}
return t('admin.accounts.gemini.quotaPolicy.rows.gcp.limitsStandard')
}
// AI Studio (API Key / custom OAuth)
if (tierLower === 'aistudio_paid' || geminiUserLevel.value === 'paid') {
return t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.limitsPaid')
}
// 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'
if (geminiOAuthType.value === 'google_one' || isGeminiCodeAssist.value) {
return 'https://developers.google.com/gemini-code-assist/resources/quotas'
}
return 'https://ai.google.dev/pricing'
})
const geminiUsesSharedDaily = computed(() => {
if (props.account.platform !== 'gemini') return false
// Per requirement: Google One & GCP are shared RPD pools (no per-model breakdown).
return (
!!usageInfo.value?.gemini_shared_daily ||
!!usageInfo.value?.gemini_shared_minute ||
geminiOAuthType.value === 'google_one' ||
isGeminiCodeAssist.value
)
})
const geminiUsageBars = computed(() => {
if (props.account.platform !== 'gemini') return []
if (!usageInfo.value) return []
const bars: Array<{
key: string
label: string
utilization: number
resetsAt: string | null
windowStats?: WindowStats | null
color: 'indigo' | 'emerald'
}> = []
if (geminiUsesSharedDaily.value) {
const sharedDaily = usageInfo.value.gemini_shared_daily
if (sharedDaily) {
bars.push({
key: 'shared_daily',
label: '1d',
utilization: sharedDaily.utilization,
resetsAt: sharedDaily.resets_at,
windowStats: sharedDaily.window_stats,
color: 'indigo'
})
}
return bars
}
const pro = usageInfo.value.gemini_pro_daily
if (pro) {
bars.push({
key: 'pro_daily',
label: 'pro',
utilization: pro.utilization,
resetsAt: pro.resets_at,
windowStats: pro.window_stats,
color: 'indigo'
})
}
const flash = usageInfo.value.gemini_flash_daily
if (flash) {
bars.push({
key: 'flash_daily',
label: 'flash',
utilization: flash.utilization,
resetsAt: flash.resets_at,
windowStats: flash.window_stats,
color: 'emerald'
})
}
return bars
})
// 账户类型显示标签
const antigravityTierLabel = computed(() => {
switch (antigravityTier.value) {

View File

@@ -338,7 +338,19 @@
<!-- Account Type Selection (Gemini) -->
<div v-if="form.platform === 'gemini'">
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
<div class="flex items-center justify-between">
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
<button
type="button"
@click="showGeminiHelpDialog = true"
class="flex items-center gap-1 rounded px-2 py-1 text-xs text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/20"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
</svg>
{{ t('admin.accounts.gemini.helpButton') }}
</button>
</div>
<div class="mt-2 grid grid-cols-2 gap-3" data-tour="account-form-type">
<button
type="button"
@@ -439,15 +451,6 @@
>
{{ 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>
@@ -653,77 +656,39 @@
</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"
<!-- Tier selection (used as fallback when auto-detection is unavailable/fails) -->
<div class="mt-4">
<label class="input-label">{{ t('admin.accounts.gemini.tier.label') }}</label>
<div class="mt-2">
<select
v-if="geminiOAuthType === 'google_one'"
v-model="geminiTierGoogleOne"
class="input"
>
<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>
<option value="google_one_free">{{ t('admin.accounts.gemini.tier.googleOne.free') }}</option>
<option value="google_ai_pro">{{ t('admin.accounts.gemini.tier.googleOne.pro') }}</option>
<option value="google_ai_ultra">{{ t('admin.accounts.gemini.tier.googleOne.ultra') }}</option>
</select>
<select
v-else-if="geminiOAuthType === 'code_assist'"
v-model="geminiTierGcp"
class="input"
>
<option value="gcp_standard">{{ t('admin.accounts.gemini.tier.gcp.standard') }}</option>
<option value="gcp_enterprise">{{ t('admin.accounts.gemini.tier.gcp.enterprise') }}</option>
</select>
<select
v-else
v-model="geminiTierAIStudio"
class="input"
>
<option value="aistudio_free">{{ t('admin.accounts.gemini.tier.aiStudio.free') }}</option>
<option value="aistudio_paid">{{ t('admin.accounts.gemini.tier.aiStudio.paid') }}</option>
</select>
</div>
<p class="input-hint">{{ t('admin.accounts.gemini.tier.hint') }}</p>
</div>
</div>
@@ -820,6 +785,16 @@
<p class="input-hint">{{ apiKeyHint }}</p>
</div>
<!-- Gemini API Key tier selection -->
<div v-if="form.platform === 'gemini'">
<label class="input-label">{{ t('admin.accounts.gemini.tier.label') }}</label>
<select v-model="geminiTierAIStudio" class="input">
<option value="aistudio_free">{{ t('admin.accounts.gemini.tier.aiStudio.free') }}</option>
<option value="aistudio_paid">{{ t('admin.accounts.gemini.tier.aiStudio.paid') }}</option>
</select>
<p class="input-hint">{{ t('admin.accounts.gemini.tier.aiStudioHint') }}</p>
</div>
<!-- Model Restriction Section (不适用于 Gemini) -->
<div v-if="form.platform !== 'gemini'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
@@ -1065,7 +1040,7 @@
<!-- Manual input -->
<div class="flex items-center gap-2">
<input
v-model="customErrorCodeInput"
v-model.number="customErrorCodeInput"
type="number"
min="100"
max="599"
@@ -1143,13 +1118,39 @@
</div>
</div>
</div>
</div>
<!-- Gemini 配额与限流政策说明 -->
<div v-if="form.platform === 'gemini'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="rounded-lg bg-gray-50 p-4 dark:bg-gray-800/40">
<div class="flex items-start gap-3">
<!-- Temp Unschedulable Rules -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.tempUnschedulable.title') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.hint') }}
</p>
</div>
<button
type="button"
@click="tempUnschedEnabled = !tempUnschedEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
tempUnschedEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
tempUnschedEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="tempUnschedEnabled" class="space-y-3">
<div class="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="h-5 w-5 flex-shrink-0 text-gray-500 dark:text-gray-400"
class="mr-1 inline h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -1158,149 +1159,133 @@
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"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</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>
{{ t('admin.accounts.tempUnschedulable.notice') }}
</p>
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="preset in tempUnschedPresets"
:key="preset.label"
type="button"
@click="addTempUnschedRule(preset.rule)"
class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
>
+ {{ preset.label }}
</button>
</div>
<div v-if="tempUnschedRules.length > 0" class="space-y-3">
<div
v-for="(rule, index) in tempUnschedRules"
:key="index"
class="rounded-lg border border-gray-200 p-3 dark:border-dark-600"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.ruleIndex', { index: index + 1 }) }}
</span>
<div class="flex items-center gap-2">
<button
type="button"
:disabled="index === 0"
@click="moveTempUnschedRule(index, -1)"
class="rounded p-1 text-gray-400 transition-colors hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:text-gray-200"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</button>
<button
type="button"
:disabled="index === tempUnschedRules.length - 1"
@click="moveTempUnschedRule(index, 1)"
class="rounded p-1 text-gray-400 transition-colors hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:text-gray-200"
>
<svg class="h-4 w-4" 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>
</button>
<button
type="button"
@click="removeTempUnschedRule(index)"
class="rounded p-1 text-red-500 transition-colors hover:text-red-600"
>
<svg class="h-4 w-4" 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>
</div>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.errorCode') }}</label>
<input
v-model.number="rule.error_code"
type="number"
min="100"
max="599"
class="input"
:placeholder="t('admin.accounts.tempUnschedulable.errorCodePlaceholder')"
/>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.durationMinutes') }}</label>
<input
v-model.number="rule.duration_minutes"
type="number"
min="1"
class="input"
:placeholder="t('admin.accounts.tempUnschedulable.durationPlaceholder')"
/>
</div>
<div class="sm:col-span-2">
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.keywords') }}</label>
<input
v-model="rule.keywords"
type="text"
class="input"
:placeholder="t('admin.accounts.tempUnschedulable.keywordsPlaceholder')"
/>
<p class="input-hint">{{ t('admin.accounts.tempUnschedulable.keywordsHint') }}</p>
</div>
<div class="sm:col-span-2">
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.description') }}</label>
<input
v-model="rule.description"
type="text"
class="input"
:placeholder="t('admin.accounts.tempUnschedulable.descriptionPlaceholder')"
/>
</div>
</div>
</div>
</div>
<button
type="button"
@click="addTempUnschedRule()"
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-sm text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
>
<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="M12 4v16m8-8H4" />
</svg>
{{ t('admin.accounts.tempUnschedulable.addRule') }}
</button>
</div>
</div>
@@ -1503,6 +1488,214 @@
</div>
</template>
</BaseDialog>
<!-- Gemini Help Dialog -->
<BaseDialog
:show="showGeminiHelpDialog"
:title="t('admin.accounts.gemini.helpDialog.title')"
@close="showGeminiHelpDialog = false"
max-width="max-w-3xl"
>
<div class="space-y-6">
<!-- Setup Guide Section -->
<div>
<h3 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.accounts.gemini.setupGuide.title') }}
</h3>
<div class="space-y-4">
<div>
<p class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.gemini.setupGuide.checklistTitle') }}
</p>
<ul class="list-inside list-disc space-y-1 text-sm text-gray-600 dark:text-gray-400">
<li>{{ t('admin.accounts.gemini.setupGuide.checklistItems.usIp') }}</li>
<li>{{ t('admin.accounts.gemini.setupGuide.checklistItems.age') }}</li>
</ul>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.gemini.setupGuide.activationTitle') }}
</p>
<ul class="list-inside list-disc space-y-1 text-sm text-gray-600 dark:text-gray-400">
<li>{{ t('admin.accounts.gemini.setupGuide.activationItems.geminiWeb') }}</li>
<li>{{ t('admin.accounts.gemini.setupGuide.activationItems.gcpProject') }}</li>
</ul>
<div class="mt-2 flex flex-wrap gap-2">
<a
href="https://gemini.google.com/faq#location"
target="_blank"
rel="noreferrer"
class="text-sm text-blue-600 hover:underline dark:text-blue-400"
>
{{ t('admin.accounts.gemini.setupGuide.links.countryCheck') }}
</a>
<span class="text-gray-400">·</span>
<a
href="https://gemini.google.com"
target="_blank"
rel="noreferrer"
class="text-sm text-blue-600 hover:underline dark:text-blue-400"
>
{{ t('admin.accounts.gemini.setupGuide.links.geminiWebActivation') }}
</a>
<span class="text-gray-400">·</span>
<a
href="https://console.cloud.google.com"
target="_blank"
rel="noreferrer"
class="text-sm text-blue-600 hover:underline dark:text-blue-400"
>
{{ t('admin.accounts.gemini.setupGuide.links.gcpProject') }}
</a>
</div>
</div>
</div>
</div>
<!-- Quota Policy Section -->
<div class="border-t border-gray-200 pt-6 dark:border-dark-600">
<h3 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.accounts.gemini.quotaPolicy.title') }}
</h3>
<p class="mb-4 text-xs text-amber-600 dark:text-amber-400">
{{ t('admin.accounts.gemini.quotaPolicy.note') }}
</p>
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead class="bg-gray-50 dark:bg-dark-600">
<tr>
<th class="px-3 py-2 text-left font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.gemini.quotaPolicy.columns.channel') }}
</th>
<th class="px-3 py-2 text-left font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.gemini.quotaPolicy.columns.account') }}
</th>
<th class="px-3 py-2 text-left font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.gemini.quotaPolicy.columns.limits') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-dark-600">
<tr>
<td class="px-3 py-2 text-gray-900 dark:text-white">
{{ t('admin.accounts.gemini.quotaPolicy.rows.googleOne.channel') }}
</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-400">Free</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-400">
{{ t('admin.accounts.gemini.quotaPolicy.rows.googleOne.limitsFree') }}
</td>
</tr>
<tr>
<td class="px-3 py-2 text-gray-900 dark:text-white"></td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-400">Pro</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-400">
{{ t('admin.accounts.gemini.quotaPolicy.rows.googleOne.limitsPro') }}
</td>
</tr>
<tr>
<td class="px-3 py-2 text-gray-900 dark:text-white"></td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-400">Ultra</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-400">
{{ t('admin.accounts.gemini.quotaPolicy.rows.googleOne.limitsUltra') }}
</td>
</tr>
<tr>
<td class="px-3 py-2 text-gray-900 dark:text-white">
{{ t('admin.accounts.gemini.quotaPolicy.rows.gcp.channel') }}
</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-400">Standard</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-400">
{{ t('admin.accounts.gemini.quotaPolicy.rows.gcp.limitsStandard') }}
</td>
</tr>
<tr>
<td class="px-3 py-2 text-gray-900 dark:text-white"></td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-400">Enterprise</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-400">
{{ t('admin.accounts.gemini.quotaPolicy.rows.gcp.limitsEnterprise') }}
</td>
</tr>
<tr>
<td class="px-3 py-2 text-gray-900 dark:text-white">
{{ t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.channel') }}
</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-400">Free</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-400">
{{ t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.limitsFree') }}
</td>
</tr>
<tr>
<td class="px-3 py-2 text-gray-900 dark:text-white"></td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-400">Paid</td>
<td class="px-3 py-2 text-gray-600 dark:text-gray-400">
{{ t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.limitsPaid') }}
</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-4 flex flex-wrap gap-3">
<a
:href="geminiQuotaDocs.codeAssist"
target="_blank"
rel="noreferrer"
class="text-sm text-blue-600 hover:underline dark:text-blue-400"
>
{{ t('admin.accounts.gemini.quotaPolicy.docs.codeAssist') }}
</a>
<a
:href="geminiQuotaDocs.aiStudio"
target="_blank"
rel="noreferrer"
class="text-sm text-blue-600 hover:underline dark:text-blue-400"
>
{{ t('admin.accounts.gemini.quotaPolicy.docs.aiStudio') }}
</a>
<a
:href="geminiQuotaDocs.vertex"
target="_blank"
rel="noreferrer"
class="text-sm text-blue-600 hover:underline dark:text-blue-400"
>
{{ t('admin.accounts.gemini.quotaPolicy.docs.vertex') }}
</a>
</div>
</div>
<!-- API Key Links Section -->
<div class="border-t border-gray-200 pt-6 dark:border-dark-600">
<h3 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.accounts.gemini.helpDialog.apiKeySection') }}
</h3>
<div class="flex flex-wrap gap-3">
<a
:href="geminiHelpLinks.apiKey"
target="_blank"
rel="noreferrer"
class="text-sm text-blue-600 hover:underline dark:text-blue-400"
>
{{ t('admin.accounts.gemini.accountType.apiKeyLink') }}
</a>
<a
:href="geminiHelpLinks.aiStudioPricing"
target="_blank"
rel="noreferrer"
class="text-sm text-blue-600 hover:underline dark:text-blue-400"
>
{{ t('admin.accounts.gemini.accountType.quotaLink') }}
</a>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end">
<button @click="showGeminiHelpDialog = false" type="button" class="btn btn-primary">
{{ t('common.close') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
@@ -1619,6 +1812,13 @@ interface ModelMapping {
to: string
}
interface TempUnschedRuleForm {
error_code: number | null
keywords: string
duration_minutes: number | null
description: string
}
// State
const step = ref(1)
const submitting = ref(false)
@@ -1634,9 +1834,30 @@ 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 tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one')
const geminiAIStudioOAuthEnabled = ref(false)
const showAdvancedOAuth = ref(false)
const showGeminiHelpDialog = ref(false)
// Gemini tier selection (used as fallback when auto-detection is unavailable/fails)
const geminiTierGoogleOne = ref<'google_one_free' | 'google_ai_pro' | 'google_ai_ultra'>('google_one_free')
const geminiTierGcp = ref<'gcp_standard' | 'gcp_enterprise'>('gcp_standard')
const geminiTierAIStudio = ref<'aistudio_free' | 'aistudio_paid'>('aistudio_free')
const geminiSelectedTier = computed(() => {
if (form.platform !== 'gemini') return ''
if (accountCategory.value === 'apikey') return geminiTierAIStudio.value
switch (geminiOAuthType.value) {
case 'google_one':
return geminiTierGoogleOne.value
case 'code_assist':
return geminiTierGcp.value
default:
return geminiTierAIStudio.value
}
})
const geminiQuotaDocs = {
codeAssist: 'https://developers.google.com/gemini-code-assist/resources/quotas',
@@ -1654,6 +1875,35 @@ const geminiHelpLinks = {
// Computed: current preset mappings based on platform
const presetMappings = computed(() => getPresetMappingsByPlatform(form.platform))
const tempUnschedPresets = computed(() => [
{
label: t('admin.accounts.tempUnschedulable.presets.overloadLabel'),
rule: {
error_code: 529,
keywords: 'overloaded, too many',
duration_minutes: 60,
description: t('admin.accounts.tempUnschedulable.presets.overloadDesc')
}
},
{
label: t('admin.accounts.tempUnschedulable.presets.rateLimitLabel'),
rule: {
error_code: 429,
keywords: 'rate limit, too many requests',
duration_minutes: 10,
description: t('admin.accounts.tempUnschedulable.presets.rateLimitDesc')
}
},
{
label: t('admin.accounts.tempUnschedulable.presets.unavailableLabel'),
rule: {
error_code: 503,
keywords: 'unavailable, maintenance',
duration_minutes: 30,
description: t('admin.accounts.tempUnschedulable.presets.unavailableDesc')
}
}
])
const form = reactive({
name: '',
@@ -1828,6 +2078,89 @@ const removeErrorCode = (code: number) => {
}
}
const addTempUnschedRule = (preset?: TempUnschedRuleForm) => {
if (preset) {
tempUnschedRules.value.push({ ...preset })
return
}
tempUnschedRules.value.push({
error_code: null,
keywords: '',
duration_minutes: 30,
description: ''
})
}
const removeTempUnschedRule = (index: number) => {
tempUnschedRules.value.splice(index, 1)
}
const moveTempUnschedRule = (index: number, direction: number) => {
const target = index + direction
if (target < 0 || target >= tempUnschedRules.value.length) return
const rules = tempUnschedRules.value
const current = rules[index]
rules[index] = rules[target]
rules[target] = current
}
const buildTempUnschedRules = (rules: TempUnschedRuleForm[]) => {
const out: Array<{
error_code: number
keywords: string[]
duration_minutes: number
description: string
}> = []
for (const rule of rules) {
const errorCode = Number(rule.error_code)
const duration = Number(rule.duration_minutes)
const keywords = splitTempUnschedKeywords(rule.keywords)
if (!Number.isFinite(errorCode) || errorCode < 100 || errorCode > 599) {
continue
}
if (!Number.isFinite(duration) || duration <= 0) {
continue
}
if (keywords.length === 0) {
continue
}
out.push({
error_code: Math.trunc(errorCode),
keywords,
duration_minutes: Math.trunc(duration),
description: rule.description.trim()
})
}
return out
}
const applyTempUnschedConfig = (credentials: Record<string, unknown>) => {
if (!tempUnschedEnabled.value) {
delete credentials.temp_unschedulable_enabled
delete credentials.temp_unschedulable_rules
return true
}
const rules = buildTempUnschedRules(tempUnschedRules.value)
if (rules.length === 0) {
appStore.showError(t('admin.accounts.tempUnschedulable.rulesInvalid'))
return false
}
credentials.temp_unschedulable_enabled = true
credentials.temp_unschedulable_rules = rules
return true
}
const splitTempUnschedKeywords = (value: string) => {
return value
.split(/[,;]/)
.map((item) => item.trim())
.filter((item) => item.length > 0)
}
// Methods
const resetForm = () => {
step.value = 1
@@ -1850,7 +2183,12 @@ const resetForm = () => {
selectedErrorCodes.value = []
customErrorCodeInput.value = null
interceptWarmupRequests.value = false
tempUnschedEnabled.value = false
tempUnschedRules.value = []
geminiOAuthType.value = 'code_assist'
geminiTierGoogleOne.value = 'google_one_free'
geminiTierGcp.value = 'gcp_standard'
geminiTierAIStudio.value = 'aistudio_free'
oauth.resetState()
openaiOAuth.resetState()
geminiOAuth.resetState()
@@ -1892,6 +2230,9 @@ const handleSubmit = async () => {
base_url: apiKeyBaseUrl.value.trim() || defaultBaseUrl,
api_key: apiKeyValue.value.trim()
}
if (form.platform === 'gemini') {
credentials.tier_id = geminiTierAIStudio.value
}
// Add model mapping if configured
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
@@ -1910,6 +2251,10 @@ const handleSubmit = async () => {
credentials.intercept_warmup_requests = true
}
if (!applyTempUnschedConfig(credentials)) {
return
}
form.credentials = credentials
submitting.value = true
@@ -1941,7 +2286,12 @@ const handleGenerateUrl = async () => {
if (form.platform === 'openai') {
await openaiOAuth.generateAuthUrl(form.proxy_id)
} else if (form.platform === 'gemini') {
await geminiOAuth.generateAuthUrl(form.proxy_id, oauthFlowRef.value?.projectId, geminiOAuthType.value)
await geminiOAuth.generateAuthUrl(
form.proxy_id,
oauthFlowRef.value?.projectId,
geminiOAuthType.value,
geminiSelectedTier.value
)
} else if (form.platform === 'antigravity') {
await antigravityOAuth.generateAuthUrl(form.proxy_id)
} else {
@@ -1956,6 +2306,9 @@ const createAccountAndFinish = async (
credentials: Record<string, unknown>,
extra?: Record<string, unknown>
) => {
if (!applyTempUnschedConfig(credentials)) {
return
}
await adminAPI.accounts.create({
name: form.name,
platform,
@@ -2019,12 +2372,14 @@ const handleGeminiExchange = async (authCode: string) => {
sessionId: geminiOAuth.sessionId.value,
state: stateToUse,
proxyId: form.proxy_id,
oauthType: geminiOAuthType.value
oauthType: geminiOAuthType.value,
tierId: geminiSelectedTier.value
})
if (!tokenInfo) return
const credentials = geminiOAuth.buildCredentials(tokenInfo)
await createAccountAndFinish('gemini', 'oauth', credentials)
const extra = geminiOAuth.buildExtraInfo(tokenInfo)
await createAccountAndFinish('gemini', 'oauth', credentials, extra)
} catch (error: any) {
geminiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(geminiOAuth.error.value)
@@ -2131,6 +2486,14 @@ const handleCookieAuth = async (sessionKey: string) => {
return
}
const tempUnschedPayload = tempUnschedEnabled.value
? buildTempUnschedRules(tempUnschedRules.value)
: []
if (tempUnschedEnabled.value && tempUnschedPayload.length === 0) {
appStore.showError(t('admin.accounts.tempUnschedulable.rulesInvalid'))
return
}
const endpoint =
addMethod.value === 'oauth'
? '/admin/accounts/cookie-auth'
@@ -2152,10 +2515,14 @@ const handleCookieAuth = async (sessionKey: string) => {
const accountName = keys.length > 1 ? `${form.name} #${i + 1}` : form.name
// Merge interceptWarmupRequests into credentials
const credentials = {
const credentials: Record<string, unknown> = {
...tokenInfo,
...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {})
}
if (tempUnschedEnabled.value) {
credentials.temp_unschedulable_enabled = true
credentials.temp_unschedulable_rules = tempUnschedPayload
}
await adminAPI.accounts.create({
name: accountName,

View File

@@ -293,7 +293,7 @@
<!-- Manual input -->
<div class="flex items-center gap-2">
<input
v-model="customErrorCodeInput"
v-model.number="customErrorCodeInput"
type="number"
min="100"
max="599"
@@ -373,6 +373,175 @@
</div>
</div>
<!-- Temp Unschedulable Rules -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.tempUnschedulable.title') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.hint') }}
</p>
</div>
<button
type="button"
@click="tempUnschedEnabled = !tempUnschedEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
tempUnschedEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
tempUnschedEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="tempUnschedEnabled" class="space-y-3">
<div class="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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
{{ t('admin.accounts.tempUnschedulable.notice') }}
</p>
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="preset in tempUnschedPresets"
:key="preset.label"
type="button"
@click="addTempUnschedRule(preset.rule)"
class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
>
+ {{ preset.label }}
</button>
</div>
<div v-if="tempUnschedRules.length > 0" class="space-y-3">
<div
v-for="(rule, index) in tempUnschedRules"
:key="index"
class="rounded-lg border border-gray-200 p-3 dark:border-dark-600"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.ruleIndex', { index: index + 1 }) }}
</span>
<div class="flex items-center gap-2">
<button
type="button"
:disabled="index === 0"
@click="moveTempUnschedRule(index, -1)"
class="rounded p-1 text-gray-400 transition-colors hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:text-gray-200"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</button>
<button
type="button"
:disabled="index === tempUnschedRules.length - 1"
@click="moveTempUnschedRule(index, 1)"
class="rounded p-1 text-gray-400 transition-colors hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:text-gray-200"
>
<svg class="h-4 w-4" 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>
</button>
<button
type="button"
@click="removeTempUnschedRule(index)"
class="rounded p-1 text-red-500 transition-colors hover:text-red-600"
>
<svg class="h-4 w-4" 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>
</div>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.errorCode') }}</label>
<input
v-model.number="rule.error_code"
type="number"
min="100"
max="599"
class="input"
:placeholder="t('admin.accounts.tempUnschedulable.errorCodePlaceholder')"
/>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.durationMinutes') }}</label>
<input
v-model.number="rule.duration_minutes"
type="number"
min="1"
class="input"
:placeholder="t('admin.accounts.tempUnschedulable.durationPlaceholder')"
/>
</div>
<div class="sm:col-span-2">
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.keywords') }}</label>
<input
v-model="rule.keywords"
type="text"
class="input"
:placeholder="t('admin.accounts.tempUnschedulable.keywordsPlaceholder')"
/>
<p class="input-hint">{{ t('admin.accounts.tempUnschedulable.keywordsHint') }}</p>
</div>
<div class="sm:col-span-2">
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.description') }}</label>
<input
v-model="rule.description"
type="text"
class="input"
:placeholder="t('admin.accounts.tempUnschedulable.descriptionPlaceholder')"
/>
</div>
</div>
</div>
</div>
<button
type="button"
@click="addTempUnschedRule()"
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-sm text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
>
<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="M12 4v16m8-8H4" />
</svg>
{{ t('admin.accounts.tempUnschedulable.addRule') }}
</button>
</div>
</div>
<!-- Intercept Warmup Requests (Anthropic only) -->
<div
v-if="account?.platform === 'anthropic'"
@@ -563,6 +732,13 @@ interface ModelMapping {
to: string
}
interface TempUnschedRuleForm {
error_code: number | null
keywords: string
duration_minutes: number | null
description: string
}
// State
const submitting = ref(false)
const editBaseUrl = ref('https://api.anthropic.com')
@@ -575,9 +751,40 @@ 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 tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
// Computed: current preset mappings based on platform
const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic'))
const tempUnschedPresets = computed(() => [
{
label: t('admin.accounts.tempUnschedulable.presets.overloadLabel'),
rule: {
error_code: 529,
keywords: 'overloaded, too many',
duration_minutes: 60,
description: t('admin.accounts.tempUnschedulable.presets.overloadDesc')
}
},
{
label: t('admin.accounts.tempUnschedulable.presets.rateLimitLabel'),
rule: {
error_code: 429,
keywords: 'rate limit, too many requests',
duration_minutes: 10,
description: t('admin.accounts.tempUnschedulable.presets.rateLimitDesc')
}
},
{
label: t('admin.accounts.tempUnschedulable.presets.unavailableLabel'),
rule: {
error_code: 503,
keywords: 'unavailable, maintenance',
duration_minutes: 30,
description: t('admin.accounts.tempUnschedulable.presets.unavailableDesc')
}
}
])
// Computed: default base URL based on platform
const defaultBaseUrl = computed(() => {
@@ -620,6 +827,8 @@ watch(
const extra = newAccount.extra as Record<string, unknown> | undefined
mixedScheduling.value = extra?.mixed_scheduling === true
loadTempUnschedRules(credentials)
// Initialize API Key fields for apikey type
if (newAccount.type === 'apikey' && newAccount.credentials) {
const credentials = newAccount.credentials as Record<string, unknown>
@@ -736,6 +945,130 @@ const removeErrorCode = (code: number) => {
}
}
const addTempUnschedRule = (preset?: TempUnschedRuleForm) => {
if (preset) {
tempUnschedRules.value.push({ ...preset })
return
}
tempUnschedRules.value.push({
error_code: null,
keywords: '',
duration_minutes: 30,
description: ''
})
}
const removeTempUnschedRule = (index: number) => {
tempUnschedRules.value.splice(index, 1)
}
const moveTempUnschedRule = (index: number, direction: number) => {
const target = index + direction
if (target < 0 || target >= tempUnschedRules.value.length) return
const rules = tempUnschedRules.value
const current = rules[index]
rules[index] = rules[target]
rules[target] = current
}
const buildTempUnschedRules = (rules: TempUnschedRuleForm[]) => {
const out: Array<{
error_code: number
keywords: string[]
duration_minutes: number
description: string
}> = []
for (const rule of rules) {
const errorCode = Number(rule.error_code)
const duration = Number(rule.duration_minutes)
const keywords = splitTempUnschedKeywords(rule.keywords)
if (!Number.isFinite(errorCode) || errorCode < 100 || errorCode > 599) {
continue
}
if (!Number.isFinite(duration) || duration <= 0) {
continue
}
if (keywords.length === 0) {
continue
}
out.push({
error_code: Math.trunc(errorCode),
keywords,
duration_minutes: Math.trunc(duration),
description: rule.description.trim()
})
}
return out
}
const applyTempUnschedConfig = (credentials: Record<string, unknown>) => {
if (!tempUnschedEnabled.value) {
delete credentials.temp_unschedulable_enabled
delete credentials.temp_unschedulable_rules
return true
}
const rules = buildTempUnschedRules(tempUnschedRules.value)
if (rules.length === 0) {
appStore.showError(t('admin.accounts.tempUnschedulable.rulesInvalid'))
return false
}
credentials.temp_unschedulable_enabled = true
credentials.temp_unschedulable_rules = rules
return true
}
function loadTempUnschedRules(credentials?: Record<string, unknown>) {
tempUnschedEnabled.value = credentials?.temp_unschedulable_enabled === true
const rawRules = credentials?.temp_unschedulable_rules
if (!Array.isArray(rawRules)) {
tempUnschedRules.value = []
return
}
tempUnschedRules.value = rawRules.map((rule) => {
const entry = rule as Record<string, unknown>
return {
error_code: toPositiveNumber(entry.error_code),
keywords: formatTempUnschedKeywords(entry.keywords),
duration_minutes: toPositiveNumber(entry.duration_minutes),
description: typeof entry.description === 'string' ? entry.description : ''
}
})
}
function formatTempUnschedKeywords(value: unknown) {
if (Array.isArray(value)) {
return value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter((item) => item.length > 0)
.join(', ')
}
if (typeof value === 'string') {
return value
}
return ''
}
const splitTempUnschedKeywords = (value: string) => {
return value
.split(/[,;]/)
.map((item) => item.trim())
.filter((item) => item.length > 0)
}
function toPositiveNumber(value: unknown) {
const num = Number(value)
if (!Number.isFinite(num) || num <= 0) {
return null
}
return Math.trunc(num)
}
// Methods
const handleClose = () => {
emit('close')
@@ -788,6 +1121,11 @@ const handleSubmit = async () => {
newCredentials.intercept_warmup_requests = true
}
if (!applyTempUnschedConfig(newCredentials)) {
submitting.value = false
return
}
updatePayload.credentials = newCredentials
} else {
// For oauth/setup-token types, only update intercept_warmup_requests if changed
@@ -800,6 +1138,11 @@ const handleSubmit = async () => {
delete newCredentials.intercept_warmup_requests
}
if (!applyTempUnschedConfig(newCredentials)) {
submitting.value = false
return
}
updatePayload.credentials = newCredentials
}

View File

@@ -88,7 +88,35 @@
<!-- Gemini OAuth Type Selection -->
<fieldset v-if="isGemini" class="border-0 p-0">
<legend class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</legend>
<div class="mt-2 grid grid-cols-2 gap-3">
<div class="mt-2 grid grid-cols-3 gap-3">
<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">个人账号</span>
</div>
</button>
<button
type="button"
@click="handleSelectGeminiOAuthType('code_assist')"
@@ -305,7 +333,7 @@ const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
// State
const addMethod = ref<AddMethod>('oauth')
const geminiOAuthType = ref<'code_assist' | 'ai_studio'>('code_assist')
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_assist')
const geminiAIStudioOAuthEnabled = ref(false)
// Computed - check platform
@@ -367,7 +395,12 @@ watch(
}
if (isGemini.value) {
const creds = (props.account.credentials || {}) as Record<string, unknown>
geminiOAuthType.value = creds.oauth_type === 'ai_studio' ? 'ai_studio' : 'code_assist'
geminiOAuthType.value =
creds.oauth_type === 'google_one'
? 'google_one'
: creds.oauth_type === 'ai_studio'
? 'ai_studio'
: 'code_assist'
}
if (isGemini.value) {
geminiOAuth.getCapabilities().then((caps) => {
@@ -395,7 +428,7 @@ const resetState = () => {
oauthFlowRef.value?.reset()
}
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
@@ -413,8 +446,10 @@ const handleGenerateUrl = async () => {
if (isOpenAI.value) {
await openaiOAuth.generateAuthUrl(props.account.proxy_id)
} else if (isGemini.value) {
const creds = (props.account.credentials || {}) as Record<string, unknown>
const tierId = typeof creds.tier_id === 'string' ? creds.tier_id : undefined
const projectId = geminiOAuthType.value === 'code_assist' ? oauthFlowRef.value?.projectId : undefined
await geminiOAuth.generateAuthUrl(props.account.proxy_id, projectId, geminiOAuthType.value)
await geminiOAuth.generateAuthUrl(props.account.proxy_id, projectId, geminiOAuthType.value, tierId)
} else if (isAntigravity.value) {
await antigravityOAuth.generateAuthUrl(props.account.proxy_id)
} else {
@@ -475,7 +510,8 @@ const handleExchangeCode = async () => {
sessionId,
state: stateToUse,
proxyId: props.account.proxy_id,
oauthType: geminiOAuthType.value
oauthType: geminiOAuthType.value,
tierId: typeof (props.account.credentials as any)?.tier_id === 'string' ? ((props.account.credentials as any).tier_id as string) : undefined
})
if (!tokenInfo) return

View File

@@ -0,0 +1,249 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.accounts.tempUnschedulable.statusTitle')"
width="normal"
@close="handleClose"
>
<div class="space-y-4">
<div v-if="loading" class="flex items-center justify-center py-8">
<svg class="h-6 w-6 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
<div v-else-if="!isActive" class="rounded-lg border border-gray-200 p-4 text-sm text-gray-500 dark:border-dark-600 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.notActive') }}
</div>
<div v-else class="space-y-4">
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.accountName') }}
</p>
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ account?.name || '-' }}
</p>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.triggeredAt') }}
</p>
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ triggeredAtText }}
</p>
</div>
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.until') }}
</p>
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ untilText }}
</p>
</div>
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.remaining') }}
</p>
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ remainingText }}
</p>
</div>
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.errorCode') }}
</p>
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ state?.status_code || '-' }}
</p>
</div>
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.matchedKeyword') }}
</p>
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ state?.matched_keyword || '-' }}
</p>
</div>
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.ruleOrder') }}
</p>
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ ruleIndexDisplay }}
</p>
</div>
</div>
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.tempUnschedulable.errorMessage') }}
</p>
<div class="mt-2 rounded bg-gray-50 p-2 text-xs text-gray-700 dark:bg-dark-700 dark:text-gray-300">
{{ state?.error_message || '-' }}
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button type="button" class="btn btn-secondary" @click="handleClose">
{{ t('common.close') }}
</button>
<button
type="button"
class="btn btn-primary"
:disabled="!isActive || resetting"
@click="handleReset"
>
<svg
v-if="resetting"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ t('admin.accounts.tempUnschedulable.reset') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { Account, TempUnschedulableStatus } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import { formatDateTime } from '@/utils/format'
const props = defineProps<{
show: boolean
account: Account | null
}>()
const emit = defineEmits<{
close: []
reset: []
}>()
const { t } = useI18n()
const appStore = useAppStore()
const loading = ref(false)
const resetting = ref(false)
const status = ref<TempUnschedulableStatus | null>(null)
const state = computed(() => status.value?.state || null)
const isActive = computed(() => {
if (!status.value?.active || !state.value) return false
return state.value.until_unix * 1000 > Date.now()
})
const ruleIndexDisplay = computed(() => {
if (!state.value) return '-'
return state.value.rule_index + 1
})
const triggeredAtText = computed(() => {
if (!state.value?.triggered_at_unix) return '-'
return formatDateTime(new Date(state.value.triggered_at_unix * 1000))
})
const untilText = computed(() => {
if (!state.value?.until_unix) return '-'
return formatDateTime(new Date(state.value.until_unix * 1000))
})
const remainingText = computed(() => {
if (!state.value) return '-'
const remainingMs = state.value.until_unix * 1000 - Date.now()
if (remainingMs <= 0) {
return t('admin.accounts.tempUnschedulable.expired')
}
const minutes = Math.ceil(remainingMs / 60000)
if (minutes < 60) {
return t('admin.accounts.tempUnschedulable.remainingMinutes', { minutes })
}
const hours = Math.floor(minutes / 60)
const rest = minutes % 60
if (rest === 0) {
return t('admin.accounts.tempUnschedulable.remainingHours', { hours })
}
return t('admin.accounts.tempUnschedulable.remainingHoursMinutes', { hours, minutes: rest })
})
const loadStatus = async () => {
if (!props.account) return
loading.value = true
try {
status.value = await adminAPI.accounts.getTempUnschedulableStatus(props.account.id)
} catch (error: any) {
appStore.showError(error?.message || t('admin.accounts.tempUnschedulable.failedToLoad'))
status.value = null
} finally {
loading.value = false
}
}
const handleClose = () => {
emit('close')
}
const handleReset = async () => {
if (!props.account) return
resetting.value = true
try {
await adminAPI.accounts.resetTempUnschedulable(props.account.id)
appStore.showSuccess(t('admin.accounts.tempUnschedulable.resetSuccess'))
emit('reset')
handleClose()
} catch (error: any) {
appStore.showError(error?.message || t('admin.accounts.tempUnschedulable.resetFailed'))
} finally {
resetting.value = false
}
}
watch(
() => [props.show, props.account?.id],
([visible]) => {
if (visible && props.account) {
loadStatus()
return
}
status.value = null
}
)
</script>

View File

@@ -111,12 +111,12 @@ const displayPercent = computed(() => {
// Format reset time
const formatResetTime = computed(() => {
if (!props.resetsAt) return 'N/A'
if (!props.resetsAt) return t('common.notAvailable')
const date = new Date(props.resetsAt)
const now = new Date()
const diffMs = date.getTime() - now.getTime()
if (diffMs <= 0) return 'Now'
if (diffMs <= 0) return t('common.now')
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))

View File

@@ -9,4 +9,5 @@ export { default as UsageProgressBar } from './UsageProgressBar.vue'
export { default as AccountStatsModal } from './AccountStatsModal.vue'
export { default as AccountTestModal } from './AccountTestModal.vue'
export { default as AccountTodayStatsCell } from './AccountTodayStatsCell.vue'
export { default as TempUnschedStatusModal } from './TempUnschedStatusModal.vue'
export { default as SyncFromCrsModal } from './SyncFromCrsModal.vue'

View File

@@ -12,6 +12,8 @@ export interface GeminiTokenInfo {
expires_at?: number | string
project_id?: string
oauth_type?: string
tier_id?: string
extra?: Record<string, unknown>
[key: string]: unknown
}
@@ -36,7 +38,8 @@ export function useGeminiOAuth() {
const generateAuthUrl = async (
proxyId: number | null | undefined,
projectId?: string | null,
oauthType?: string
oauthType?: string,
tierId?: string
): Promise<boolean> => {
loading.value = true
authUrl.value = ''
@@ -50,6 +53,8 @@ export function useGeminiOAuth() {
const trimmedProjectID = projectId?.trim()
if (trimmedProjectID) payload.project_id = trimmedProjectID
if (oauthType) payload.oauth_type = oauthType
const trimmedTierID = tierId?.trim()
if (trimmedTierID) payload.tier_id = trimmedTierID
const response = await adminAPI.gemini.generateAuthUrl(payload as any)
authUrl.value = response.auth_url
@@ -71,6 +76,7 @@ export function useGeminiOAuth() {
state: string
proxyId?: number | null
oauthType?: string
tierId?: string
}): Promise<GeminiTokenInfo | null> => {
const code = params.code?.trim()
if (!code || !params.sessionId || !params.state) {
@@ -89,6 +95,8 @@ export function useGeminiOAuth() {
}
if (params.proxyId) payload.proxy_id = params.proxyId
if (params.oauthType) payload.oauth_type = params.oauthType
const trimmedTierID = params.tierId?.trim()
if (trimmedTierID) payload.tier_id = trimmedTierID
const tokenInfo = await adminAPI.gemini.exchangeCode(payload as any)
return tokenInfo as GeminiTokenInfo
@@ -122,10 +130,16 @@ export function useGeminiOAuth() {
expires_at: expiresAt,
scope: tokenInfo.scope,
project_id: tokenInfo.project_id,
oauth_type: tokenInfo.oauth_type
oauth_type: tokenInfo.oauth_type,
tier_id: tokenInfo.tier_id
}
}
const buildExtraInfo = (tokenInfo: GeminiTokenInfo): Record<string, unknown> | undefined => {
if (!tokenInfo.extra || typeof tokenInfo.extra !== 'object') return undefined
return tokenInfo.extra
}
const getCapabilities = async (): Promise<GeminiOAuthCapabilities | null> => {
try {
return await adminAPI.gemini.getCapabilities()
@@ -145,6 +159,7 @@ export function useGeminiOAuth() {
generateAuthUrl,
exchangeAuthCode,
buildCredentials,
buildExtraInfo,
getCapabilities
}
}

View File

@@ -150,6 +150,9 @@ export default {
noOptionsFound: 'No options found',
saving: 'Saving...',
refresh: 'Refresh',
notAvailable: 'N/A',
now: 'Now',
unknown: 'Unknown',
time: {
never: 'Never',
justNow: 'Just now',
@@ -812,11 +815,6 @@ export default {
gemini: 'Gemini',
antigravity: 'Antigravity'
},
statuses: {
active: 'Active',
inactive: 'Inactive',
error: 'Error'
},
deleteConfirm:
"Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.",
deleteConfirmSubscription:
@@ -957,6 +955,61 @@ export default {
codeAssist: 'Code Assist',
antigravityOauth: 'Antigravity OAuth'
},
status: {
active: 'Active',
inactive: 'Inactive',
error: 'Error',
cooldown: 'Cooldown',
paused: 'Paused',
limited: 'Limited',
tempUnschedulable: 'Temp Unschedulable',
rateLimitedUntil: 'Rate limited until {time}',
overloadedUntil: 'Overloaded until {time}',
viewTempUnschedDetails: 'View temp unschedulable details'
},
tempUnschedulable: {
title: 'Temp Unschedulable',
statusTitle: 'Temp Unschedulable Status',
hint: 'Disable accounts temporarily when error code and keyword both match.',
notice: 'Rules are evaluated in order and require both error code and keyword match.',
addRule: 'Add Rule',
ruleOrder: 'Rule Order',
ruleIndex: 'Rule #{index}',
errorCode: 'Error Code',
errorCodePlaceholder: 'e.g. 429',
durationMinutes: 'Duration (minutes)',
durationPlaceholder: 'e.g. 30',
keywords: 'Keywords',
keywordsPlaceholder: 'e.g. overloaded, too many requests',
keywordsHint: 'Separate keywords with commas; any keyword match will trigger.',
description: 'Description',
descriptionPlaceholder: 'Optional note for this rule',
rulesInvalid: 'Add at least one rule with error code, keywords, and duration.',
viewDetails: 'View temp unschedulable details',
accountName: 'Account',
triggeredAt: 'Triggered At',
until: 'Until',
remaining: 'Remaining',
matchedKeyword: 'Matched Keyword',
errorMessage: 'Error Details',
reset: 'Reset Status',
resetSuccess: 'Temp unschedulable status reset',
resetFailed: 'Failed to reset temp unschedulable status',
failedToLoad: 'Failed to load temp unschedulable status',
notActive: 'This account is not temporarily unschedulable.',
expired: 'Expired',
remainingMinutes: 'About {minutes} minutes',
remainingHours: 'About {hours} hours',
remainingHoursMinutes: 'About {hours} hours {minutes} minutes',
presets: {
overloadLabel: '529 Overloaded',
overloadDesc: 'Overloaded - pause 60 minutes',
rateLimitLabel: '429 Rate Limit',
rateLimitDesc: 'Rate limited - pause 10 minutes',
unavailableLabel: '503 Unavailable',
unavailableDesc: 'Unavailable - pause 30 minutes'
}
},
columns: {
name: 'Name',
platformType: 'Platform/Type',
@@ -981,16 +1034,6 @@ export default {
tokenRefreshed: 'Token refreshed successfully',
accountDeleted: 'Account deleted successfully',
rateLimitCleared: 'Rate limit cleared successfully',
statuses: {
active: 'Active',
inactive: 'Inactive',
error: 'Error',
cooldown: 'Cooldown',
paused: 'Paused',
limited: 'Limited',
rateLimitedUntil: 'Rate limited until {time}',
overloadedUntil: 'Overloaded until {time}'
},
bulkActions: {
selected: '{count} account(s) selected',
selectCurrentPage: 'Select this page',
@@ -1238,11 +1281,35 @@ export default {
},
// Gemini specific (platform-wide)
gemini: {
helpButton: 'Help',
helpDialog: {
title: 'Gemini Usage Guide',
apiKeySection: 'API Key Links'
},
modelPassthrough: 'Gemini Model Passthrough',
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)',
tier: {
label: 'Account Tier',
hint: 'Tip: The system will try to auto-detect the tier first; if auto-detection is unavailable or fails, your selected tier is used as a fallback (simulated quota).',
aiStudioHint:
'AI Studio quotas are per-model (Pro/Flash are limited independently). If billing is enabled, choose Pay-as-you-go.',
googleOne: {
free: 'Google One Free',
pro: 'Google One Pro',
ultra: 'Google One Ultra'
},
gcp: {
standard: 'GCP Standard',
enterprise: 'GCP Enterprise'
},
aiStudio: {
free: 'Google AI Free',
paid: 'Google AI Pay-as-you-go'
}
},
accountType: {
oauthTitle: 'OAuth (Gemini)',
oauthDesc: 'Authorize with your Google account and choose an OAuth type.',
@@ -1303,6 +1370,17 @@ export default {
},
simulatedNote: 'Simulated quota, for reference only',
rows: {
googleOne: {
channel: 'Google One OAuth (Individuals / Code Assist for Individuals)',
limitsFree: 'Shared pool: 1000 RPD / 60 RPM',
limitsPro: 'Shared pool: 1500 RPD / 120 RPM',
limitsUltra: 'Shared pool: 2000 RPD / 120 RPM'
},
gcp: {
channel: 'GCP Code Assist OAuth (Enterprise)',
limitsStandard: 'Shared pool: 1500 RPD / 120 RPM',
limitsEnterprise: 'Shared pool: 2000 RPD / 120 RPM'
},
cli: {
channel: 'Gemini CLI (Official Google Login / Code Assist)',
free: 'Free Google Account',
@@ -1320,7 +1398,7 @@ export default {
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)'
limitsPaid: 'RPD unlimited; RPM 1000 (Pro) / 2000 (Flash) (per model)'
},
customOAuth: {
channel: 'Custom OAuth Client (GCP)',
@@ -1333,6 +1411,7 @@ export default {
},
rateLimit: {
ok: 'Not rate limited',
unlimited: 'Unlimited',
limited: 'Rate limited {time}',
now: 'now'
}
@@ -1439,11 +1518,6 @@ export default {
socks5: 'SOCKS5',
socks5h: 'SOCKS5H (Remote DNS)'
},
statuses: {
active: 'Active',
inactive: 'Inactive',
error: 'Error'
},
columns: {
name: 'Name',
protocol: 'Protocol',
@@ -1561,7 +1635,13 @@ export default {
selectGroupPlaceholder: 'Choose a subscription group',
validityDays: 'Validity Days',
groupRequired: 'Please select a subscription group',
days: ' days'
days: ' days',
status: {
unused: 'Unused',
used: 'Used',
expired: 'Expired',
disabled: 'Disabled'
}
},
// Usage Records
@@ -1612,6 +1692,7 @@ export default {
siteKey: 'Site Key',
secretKey: 'Secret Key',
siteKeyHint: 'Get this from your Cloudflare Dashboard',
cloudflareDashboard: 'Cloudflare Dashboard',
secretKeyHint: 'Server-side verification key (keep this secret)'
},
defaults: {
@@ -1762,6 +1843,7 @@ export default {
noActiveSubscriptions: 'No Active Subscriptions',
noActiveSubscriptionsDesc:
"You don't have any active subscriptions. Contact administrator to get one.",
failedToLoad: 'Failed to load subscriptions',
status: {
active: 'Active',
expired: 'Expired',

View File

@@ -147,6 +147,9 @@ export default {
noOptionsFound: '无匹配选项',
saving: '保存中...',
refresh: '刷新',
notAvailable: '不可用',
now: '现在',
unknown: '未知',
time: {
never: '从未',
justNow: '刚刚',
@@ -868,11 +871,6 @@ export default {
gemini: 'Gemini',
antigravity: 'Antigravity'
},
statuses: {
active: '正常',
inactive: '停用',
error: '错误'
},
saving: '保存中...',
noGroups: '暂无分组',
noGroupsDescription: '创建分组以更好地管理 API 密钥和费率。',
@@ -1072,15 +1070,60 @@ export default {
api_key: 'API Key',
cookie: 'Cookie'
},
statuses: {
status: {
active: '正常',
inactive: '停用',
error: '错误',
cooldown: '冷却中',
paused: '暂停',
limited: '限流',
tempUnschedulable: '临时不可调度',
rateLimitedUntil: '限流中,重置时间:{time}',
overloadedUntil: '负载过重,重置时间:{time}'
overloadedUntil: '负载过重,重置时间:{time}',
viewTempUnschedDetails: '查看临时不可调度详情'
},
tempUnschedulable: {
title: '临时不可调度',
statusTitle: '临时不可调度状态',
hint: '当错误码与关键词同时匹配时,账号会在指定时间内被临时禁用。',
notice: '规则按顺序匹配,需同时满足错误码与关键词。',
addRule: '添加规则',
ruleOrder: '规则序号',
ruleIndex: '规则 #{index}',
errorCode: '错误码',
errorCodePlaceholder: '例如 429',
durationMinutes: '持续时间(分钟)',
durationPlaceholder: '例如 30',
keywords: '关键词',
keywordsPlaceholder: '例如 overloaded, too many requests',
keywordsHint: '多个关键词用逗号分隔,匹配时必须命中其中之一。',
description: '描述',
descriptionPlaceholder: '可选,便于记忆规则用途',
rulesInvalid: '请至少填写一条包含错误码、关键词和时长的规则。',
viewDetails: '查看临时不可调度详情',
accountName: '账号',
triggeredAt: '触发时间',
until: '解除时间',
remaining: '剩余时间',
matchedKeyword: '匹配关键词',
errorMessage: '错误详情',
reset: '重置状态',
resetSuccess: '临时不可调度已重置',
resetFailed: '重置临时不可调度失败',
failedToLoad: '加载临时不可调度状态失败',
notActive: '当前账号未处于临时不可调度状态。',
expired: '已到期',
remainingMinutes: '约 {minutes} 分钟',
remainingHours: '约 {hours} 小时',
remainingHoursMinutes: '约 {hours} 小时 {minutes} 分钟',
presets: {
overloadLabel: '529 过载',
overloadDesc: '服务过载 - 暂停 60 分钟',
rateLimitLabel: '429 限流',
rateLimitDesc: '触发限流 - 暂停 10 分钟',
unavailableLabel: '503 维护',
unavailableDesc: '服务不可用 - 暂停 30 分钟'
}
},
usageWindow: {
statsTitle: '5小时窗口用量统计',
@@ -1366,10 +1409,33 @@ export default {
},
// Gemini specific (platform-wide)
gemini: {
helpButton: '使用帮助',
helpDialog: {
title: 'Gemini 使用指南',
apiKeySection: 'API Key 相关链接'
},
modelPassthrough: 'Gemini 直接转发模型',
modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API不进行模型限制或映射。',
baseUrlHint: '留空使用官方 Gemini API',
apiKeyHint: '您的 Gemini API Key以 AIza 开头)',
tier: {
label: '账号等级',
hint: '提示:系统会优先尝试自动识别账号等级;若自动识别不可用或失败,则使用你选择的等级作为回退(本地模拟配额)。',
aiStudioHint: 'AI Studio 的配额是按模型分别限流Pro/Flash 独立)。若已绑卡(按量付费),请选 Pay-as-you-go。',
googleOne: {
free: 'Google One Free',
pro: 'Google One Pro',
ultra: 'Google One Ultra'
},
gcp: {
standard: 'GCP Standard',
enterprise: 'GCP Enterprise'
},
aiStudio: {
free: 'Google AI Free',
paid: 'Google AI Pay-as-you-go'
}
},
accountType: {
oauthTitle: 'OAuth 授权Gemini',
oauthDesc: '使用 Google 账号授权,并选择 OAuth 子类型。',
@@ -1429,6 +1495,17 @@ export default {
},
simulatedNote: '本地模拟配额,仅供参考',
rows: {
googleOne: {
channel: 'Google One OAuth个人版 / Code Assist for Individuals',
limitsFree: '共享池1000 RPD / 60 RPM不分模型',
limitsPro: '共享池1500 RPD / 120 RPM不分模型',
limitsUltra: '共享池2000 RPD / 120 RPM不分模型'
},
gcp: {
channel: 'GCP Code Assist OAuth企业版',
limitsStandard: '共享池1500 RPD / 120 RPM不分模型',
limitsEnterprise: '共享池2000 RPD / 120 RPM不分模型'
},
cli: {
channel: 'Gemini CLI官方 Google 登录 / Code Assist',
free: '免费 Google 账号',
@@ -1446,7 +1523,7 @@ export default {
free: '未绑卡(免费层)',
paid: '已绑卡(按量付费)',
limitsFree: 'RPD 50RPM 2Pro/ 15Flash',
limitsPaid: 'RPD 不限RPM 1000+(按模型配额)'
limitsPaid: 'RPD 不限RPM 1000Pro/ 2000Flash(按模型配额)'
},
customOAuth: {
channel: 'Custom OAuth ClientGCP',
@@ -1459,6 +1536,7 @@ export default {
},
rateLimit: {
ok: '未限流',
unlimited: '无限流',
limited: '限流 {time}',
now: '现在'
}
@@ -1549,12 +1627,7 @@ export default {
socks5: 'SOCKS5',
socks5h: 'SOCKS5H (服务端解析 DNS)'
},
statuses: {
active: '正常',
inactive: '停用',
error: '错误'
},
form: {
columns: {
nameLabel: '名称',
namePlaceholder: '请输入代理名称',
protocolLabel: '协议',
@@ -1693,7 +1766,7 @@ export default {
validityDays: '有效天数',
groupRequired: '请选择订阅分组',
days: '天',
statuses: {
status: {
unused: '未使用',
used: '已使用',
expired: '已过期',
@@ -1787,6 +1860,7 @@ export default {
siteKey: '站点密钥',
secretKey: '私密密钥',
siteKeyHint: '从 Cloudflare Dashboard 获取',
cloudflareDashboard: 'Cloudflare Dashboard',
secretKeyHint: '服务端验证密钥(请保密)'
},
defaults: {
@@ -1934,6 +2008,7 @@ export default {
description: '查看您的订阅计划和用量',
noActiveSubscriptions: '暂无有效订阅',
noActiveSubscriptionsDesc: '您没有任何有效订阅。请联系管理员获取订阅。',
failedToLoad: '加载订阅失败',
status: {
active: '有效',
expired: '已过期',

View File

@@ -322,14 +322,46 @@ export interface GeminiCredentials {
// OAuth authentication
access_token?: string
refresh_token?: string
oauth_type?: 'code_assist' | 'ai_studio' | string
tier_id?: 'LEGACY' | 'PRO' | 'ULTRA' | string
oauth_type?: 'code_assist' | 'google_one' | 'ai_studio' | string
tier_id?:
| 'google_one_free'
| 'google_ai_pro'
| 'google_ai_ultra'
| 'gcp_standard'
| 'gcp_enterprise'
| 'aistudio_free'
| 'aistudio_paid'
| 'LEGACY'
| 'PRO'
| 'ULTRA'
| string
project_id?: string
token_type?: string
scope?: string
expires_at?: string
}
export interface TempUnschedulableRule {
error_code: number
keywords: string[]
duration_minutes: number
description: string
}
export interface TempUnschedulableState {
until_unix: number
triggered_at_unix: number
status_code: number
matched_keyword: string
rule_index: number
error_message: string
}
export interface TempUnschedulableStatus {
active: boolean
state?: TempUnschedulableState
}
export interface Account {
id: number
name: string
@@ -355,6 +387,8 @@ export interface Account {
rate_limited_at: string | null
rate_limit_reset_at: string | null
overload_until: string | null
temp_unschedulable_until: string | null
temp_unschedulable_reason: string | null
// Session window fields (5-hour window)
session_window_start: string | null
@@ -374,6 +408,8 @@ export interface UsageProgress {
resets_at: string | null
remaining_seconds: number
window_stats?: WindowStats | null // 窗口期统计(从窗口开始到当前的使用量)
used_requests?: number
limit_requests?: number
}
// Antigravity 单个模型的配额信息
@@ -387,8 +423,12 @@ export interface AccountUsageInfo {
five_hour: UsageProgress | null
seven_day: UsageProgress | null
seven_day_sonnet: UsageProgress | null
gemini_shared_daily?: UsageProgress | null
gemini_pro_daily?: UsageProgress | null
gemini_flash_daily?: UsageProgress | null
gemini_shared_minute?: UsageProgress | null
gemini_pro_minute?: UsageProgress | null
gemini_flash_minute?: UsageProgress | null
antigravity_quota?: Record<string, AntigravityModelQuota> | null
}
@@ -425,6 +465,7 @@ export interface CreateAccountRequest {
concurrency?: number
priority?: number
group_ids?: number[]
confirm_mixed_channel_risk?: boolean
}
export interface UpdateAccountRequest {
@@ -437,6 +478,7 @@ export interface UpdateAccountRequest {
priority?: number
status?: 'active' | 'inactive'
group_ids?: number[]
confirm_mixed_channel_risk?: boolean
}
export interface CreateProxyRequest {

View File

@@ -216,7 +216,7 @@
</template>
<template #cell-status="{ row }">
<AccountStatusIndicator :account="row" />
<AccountStatusIndicator :account="row" @show-temp-unsched="handleShowTempUnsched" />
</template>
<template #cell-schedulable="{ row }">
@@ -400,6 +400,14 @@
<!-- Account Stats Modal -->
<AccountStatsModal :show="showStatsModal" :account="statsAccount" @close="closeStatsModal" />
<!-- Temp Unschedulable Status Modal -->
<TempUnschedStatusModal
:show="showTempUnschedModal"
:account="tempUnschedAccount"
@close="closeTempUnschedModal"
@reset="handleTempUnschedReset"
/>
<!-- Delete Confirmation Dialog -->
<ConfirmDialog
:show="showDeleteDialog"
@@ -512,6 +520,7 @@ import {
BulkEditAccountModal,
ReAuthAccountModal,
AccountStatsModal,
TempUnschedStatusModal,
SyncFromCrsModal
} from '@/components/account'
import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue'
@@ -572,9 +581,9 @@ const typeOptions = computed(() => [
const statusOptions = computed(() => [
{ value: '', label: t('admin.accounts.allStatus') },
{ value: 'active', label: t('common.active') },
{ value: 'inactive', label: t('common.inactive') },
{ value: 'error', label: t('common.error') }
{ value: 'active', label: t('admin.accounts.status.active') },
{ value: 'inactive', label: t('admin.accounts.status.inactive') },
{ value: 'error', label: t('admin.accounts.status.error') }
])
// State
@@ -604,6 +613,7 @@ const showDeleteDialog = ref(false)
const showBulkDeleteDialog = ref(false)
const showTestModal = ref(false)
const showStatsModal = ref(false)
const showTempUnschedModal = ref(false)
const showCrsSyncModal = ref(false)
const showBulkEditModal = ref(false)
const editingAccount = ref<Account | null>(null)
@@ -611,6 +621,7 @@ const reAuthAccount = ref<Account | null>(null)
const deletingAccount = ref<Account | null>(null)
const testingAccount = ref<Account | null>(null)
const statsAccount = ref<Account | null>(null)
const tempUnschedAccount = ref<Account | null>(null)
const togglingSchedulable = ref<number | null>(null)
const bulkDeleting = ref(false)
@@ -775,6 +786,21 @@ const closeReAuthModal = () => {
reAuthAccount.value = null
}
// Temp unschedulable modal
const handleShowTempUnsched = (account: Account) => {
tempUnschedAccount.value = account
showTempUnschedModal.value = true
}
const closeTempUnschedModal = () => {
showTempUnschedModal.value = false
tempUnschedAccount.value = null
}
const handleTempUnschedReset = () => {
loadAccounts()
}
// Token refresh
const handleRefreshToken = async (account: Account) => {
try {

View File

@@ -164,7 +164,7 @@
<template #cell-status="{ value }">
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
{{ t('admin.groups.statuses.' + value) }}
{{ t('admin.accounts.status.' + value) }}
</span>
</template>
@@ -683,8 +683,8 @@ const columns = computed<Column[]>(() => [
// Filter options
const statusOptions = computed(() => [
{ value: '', label: t('admin.groups.allStatus') },
{ value: 'active', label: t('common.active') },
{ value: 'inactive', label: t('common.inactive') }
{ value: 'active', label: t('admin.accounts.status.active') },
{ value: 'inactive', label: t('admin.accounts.status.inactive') }
])
const exclusiveOptions = computed(() => [
@@ -709,8 +709,8 @@ const platformFilterOptions = computed(() => [
])
const editStatusOptions = computed(() => [
{ value: 'active', label: t('common.active') },
{ value: 'inactive', label: t('common.inactive') }
{ value: 'active', label: t('admin.accounts.status.active') },
{ value: 'inactive', label: t('admin.accounts.status.inactive') }
])
const subscriptionTypeOptions = computed(() => [

View File

@@ -103,7 +103,7 @@
<template #cell-status="{ value }">
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
{{ t('admin.proxies.statuses.' + value) }}
{{ t('admin.accounts.status.' + value) }}
</span>
</template>
@@ -634,8 +634,8 @@ const protocolOptions = computed(() => [
const statusOptions = computed(() => [
{ value: '', label: t('admin.proxies.allStatus') },
{ value: 'active', label: t('common.active') },
{ value: 'inactive', label: t('common.inactive') }
{ value: 'active', label: t('admin.accounts.status.active') },
{ value: 'inactive', label: t('admin.accounts.status.inactive') }
])
// Form options
@@ -647,8 +647,8 @@ const protocolSelectOptions = computed(() => [
])
const editStatusOptions = computed(() => [
{ value: 'active', label: t('admin.proxies.statuses.active') },
{ value: 'inactive', label: t('admin.proxies.statuses.inactive') }
{ value: 'active', label: t('admin.accounts.status.active') },
{ value: 'inactive', label: t('admin.accounts.status.inactive') }
])
const proxies = ref<Proxy[]>([])

View File

@@ -140,7 +140,7 @@
: 'badge-danger'
]"
>
{{ t('admin.redeem.statuses.' + value) }}
{{ t('admin.redeem.status.' + value) }}
</span>
</template>

View File

@@ -240,7 +240,7 @@
href="https://dash.cloudflare.com/"
target="_blank"
class="text-primary-600 hover:text-primary-500"
>Cloudflare Dashboard</a
>{{ t('admin.settings.turnstile.cloudflareDashboard') }}</a
>
</p>
</div>

View File

@@ -295,12 +295,12 @@ function onTurnstileVerify(token: string): void {
function onTurnstileExpire(): void {
turnstileToken.value = ''
errors.turnstile = 'Verification expired, please try again'
errors.turnstile = t('auth.turnstileExpired')
}
function onTurnstileError(): void {
turnstileToken.value = ''
errors.turnstile = 'Verification failed, please try again'
errors.turnstile = t('auth.turnstileFailed')
}
// ==================== Validation ====================
@@ -315,25 +315,25 @@ function validateForm(): boolean {
// Email validation
if (!formData.email.trim()) {
errors.email = 'Email is required'
errors.email = t('auth.emailRequired')
isValid = false
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
errors.email = 'Please enter a valid email address'
errors.email = t('auth.invalidEmail')
isValid = false
}
// Password validation
if (!formData.password) {
errors.password = 'Password is required'
errors.password = t('auth.passwordRequired')
isValid = false
} else if (formData.password.length < 6) {
errors.password = 'Password must be at least 6 characters'
errors.password = t('auth.passwordMinLength')
isValid = false
}
// Turnstile validation
if (turnstileEnabled.value && !turnstileToken.value) {
errors.turnstile = 'Please complete the verification'
errors.turnstile = t('auth.completeVerification')
isValid = false
}
@@ -362,7 +362,7 @@ async function handleLogin(): Promise<void> {
})
// Show success toast
appStore.showSuccess('Login successful! Welcome back.')
appStore.showSuccess(t('auth.loginSuccess'))
// Redirect to dashboard or intended route
const redirectTo = (router.currentRoute.value.query.redirect as string) || '/dashboard'
@@ -382,7 +382,7 @@ async function handleLogin(): Promise<void> {
} else if (err.message) {
errorMessage.value = err.message
} else {
errorMessage.value = 'Login failed. Please check your credentials and try again.'
errorMessage.value = t('auth.loginFailed')
}
// Also show error toast

View File

@@ -340,12 +340,12 @@ function onTurnstileVerify(token: string): void {
function onTurnstileExpire(): void {
turnstileToken.value = ''
errors.turnstile = 'Verification expired, please try again'
errors.turnstile = t('auth.turnstileExpired')
}
function onTurnstileError(): void {
turnstileToken.value = ''
errors.turnstile = 'Verification failed, please try again'
errors.turnstile = t('auth.turnstileFailed')
}
// ==================== Validation ====================
@@ -365,25 +365,25 @@ function validateForm(): boolean {
// Email validation
if (!formData.email.trim()) {
errors.email = 'Email is required'
errors.email = t('auth.emailRequired')
isValid = false
} else if (!validateEmail(formData.email)) {
errors.email = 'Please enter a valid email address'
errors.email = t('auth.invalidEmail')
isValid = false
}
// Password validation
if (!formData.password) {
errors.password = 'Password is required'
errors.password = t('auth.passwordRequired')
isValid = false
} else if (formData.password.length < 6) {
errors.password = 'Password must be at least 6 characters'
errors.password = t('auth.passwordMinLength')
isValid = false
}
// Turnstile validation
if (turnstileEnabled.value && !turnstileToken.value) {
errors.turnstile = 'Please complete the verification'
errors.turnstile = t('auth.completeVerification')
isValid = false
}
@@ -429,7 +429,7 @@ async function handleRegister(): Promise<void> {
})
// Show success toast
appStore.showSuccess('Account created successfully! Welcome to ' + siteName.value + '.')
appStore.showSuccess(t('auth.accountCreatedSuccess', { siteName: siteName.value }))
// Redirect to dashboard
await router.push('/dashboard')
@@ -448,7 +448,7 @@ async function handleRegister(): Promise<void> {
} else if (err.message) {
errorMessage.value = err.message
} else {
errorMessage.value = 'Registration failed. Please try again.'
errorMessage.value = t('auth.registrationFailed')
}
// Also show error toast

View File

@@ -500,7 +500,7 @@ const getHistoryItemTitle = (item: RedeemHistoryItem) => {
} else if (item.type === 'subscription') {
return t('redeem.subscriptionAssigned')
}
return 'Unknown'
return t('common.unknown')
}
const formatHistoryValue = (item: RedeemHistoryItem) => {

View File

@@ -279,7 +279,7 @@ async function loadSubscriptions() {
subscriptions.value = await subscriptionsAPI.getMySubscriptions()
} catch (error) {
console.error('Failed to load subscriptions:', error)
appStore.showError('Failed to load subscriptions')
appStore.showError(t('userSubscriptions.failedToLoad'))
} finally {
loading.value = false
}