fix(frontend): sync with main and finalize i18n & component optimizations
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
// 是否限流
|
||||
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
249
frontend/src/components/account/TempUnschedStatusModal.vue
Normal file
249
frontend/src/components/account/TempUnschedStatusModal.vue
Normal 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>
|
||||
@@ -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))
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 50;RPM 2(Pro)/ 15(Flash)',
|
||||
limitsPaid: 'RPD 不限;RPM 1000+(按模型配额)'
|
||||
limitsPaid: 'RPD 不限;RPM 1000(Pro)/ 2000(Flash)(按模型配额)'
|
||||
},
|
||||
customOAuth: {
|
||||
channel: 'Custom OAuth Client(GCP)',
|
||||
@@ -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: '已过期',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(() => [
|
||||
|
||||
@@ -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[]>([])
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
: 'badge-danger'
|
||||
]"
|
||||
>
|
||||
{{ t('admin.redeem.statuses.' + value) }}
|
||||
{{ t('admin.redeem.status.' + value) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user