"frontend/src/i18n/vscode:/vscode.git/clone" did not exist on "706af2920f1ec571d3a7a519afe02c0a9cf7dee0"
Commit c86d445c authored by IanShaw027's avatar IanShaw027
Browse files

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

parents 6c036d7b e78c8646
# =============================================================================
# Docker Compose Override for Local Development
# =============================================================================
# This file automatically extends docker-compose-test.yml
# Usage: docker-compose -f docker-compose-test.yml up -d
# =============================================================================
services:
# ===========================================================================
# PostgreSQL - 暴露端口用于本地开发
# ===========================================================================
postgres:
ports:
- "127.0.0.1:5432:5432"
# ===========================================================================
# Redis - 暴露端口用于本地开发
# ===========================================================================
redis:
ports:
- "127.0.0.1:6379:6379"
# =============================================================================
# Docker Compose Override Configuration Example
# =============================================================================
# This file provides examples for customizing the Docker Compose setup.
# Copy this file to docker-compose.override.yml and modify as needed.
#
# Usage:
# cp docker-compose.override.yml.example docker-compose.override.yml
# # Edit docker-compose.override.yml with your settings
# docker-compose up -d
#
# IMPORTANT: docker-compose.override.yml is gitignored and will not be committed.
# =============================================================================
# =============================================================================
# Scenario 1: Use External Database and Redis (Recommended for Production)
# =============================================================================
# Use this when you have PostgreSQL and Redis running on the host machine
# or on separate servers.
#
# Prerequisites:
# - PostgreSQL running on host (accessible via host.docker.internal)
# - Redis running on host (accessible via host.docker.internal)
# - Update DATABASE_PORT and REDIS_PORT in .env file if using non-standard ports
#
# Security Notes:
# - Ensure PostgreSQL pg_hba.conf allows connections from Docker network
# - Use strong passwords for database and Redis
# - Consider using SSL/TLS for database connections in production
# =============================================================================
services:
sub2api:
# Remove dependencies on containerized postgres/redis
depends_on: []
# Enable access to host machine services
extra_hosts:
- "host.docker.internal:host-gateway"
# Override database and Redis connection settings
environment:
# PostgreSQL Configuration
DATABASE_HOST: host.docker.internal
DATABASE_PORT: "5678" # Change to your PostgreSQL port
# DATABASE_USER: postgres # Uncomment to override
# DATABASE_PASSWORD: your_password # Uncomment to override
# DATABASE_DBNAME: sub2api # Uncomment to override
# Redis Configuration
REDIS_HOST: host.docker.internal
REDIS_PORT: "6379" # Change to your Redis port
# REDIS_PASSWORD: your_redis_password # Uncomment if Redis requires auth
# REDIS_DB: 0 # Uncomment to override
# Disable containerized PostgreSQL
postgres:
deploy:
replicas: 0
scale: 0
# Disable containerized Redis
redis:
deploy:
replicas: 0
scale: 0
# =============================================================================
# Scenario 2: Development with Local Services (Alternative)
# =============================================================================
# Uncomment this section if you want to use the containerized postgres/redis
# but expose their ports for local development tools.
#
# Usage: Comment out Scenario 1 above and uncomment this section.
# =============================================================================
# services:
# sub2api:
# # Keep default dependencies
# pass
#
# postgres:
# ports:
# - "127.0.0.1:5432:5432" # Expose PostgreSQL on localhost
#
# redis:
# ports:
# - "127.0.0.1:6379:6379" # Expose Redis on localhost
# =============================================================================
# Scenario 3: Custom Network Configuration
# =============================================================================
# Uncomment if you need to connect to an existing Docker network
# =============================================================================
# networks:
# default:
# external: true
# name: your-existing-network
# =============================================================================
# Scenario 4: Resource Limits (Production)
# =============================================================================
# Uncomment to set resource limits for the sub2api container
# =============================================================================
# services:
# sub2api:
# deploy:
# resources:
# limits:
# cpus: '2.0'
# memory: 2G
# reservations:
# cpus: '1.0'
# memory: 1G
# =============================================================================
# Scenario 5: Custom Volumes
# =============================================================================
# Uncomment to mount additional volumes (e.g., for logs, backups)
# =============================================================================
# services:
# sub2api:
# volumes:
# - ./logs:/app/logs
# - ./backups:/app/backups
# =============================================================================
# Additional Notes
# =============================================================================
# - This file overrides settings in docker-compose.yml
# - Environment variables in .env file take precedence
# - For more information, see: https://docs.docker.com/compose/extends/
# - Check the main README.md for detailed configuration instructions
# =============================================================================
...@@ -12,7 +12,8 @@ import type { ...@@ -12,7 +12,8 @@ import type {
AccountUsageInfo, AccountUsageInfo,
WindowStats, WindowStats,
ClaudeModel, ClaudeModel,
AccountUsageStatsResponse AccountUsageStatsResponse,
TempUnschedulableStatus
} from '@/types' } from '@/types'
/** /**
...@@ -170,6 +171,30 @@ export async function clearRateLimit(id: number): Promise<{ message: string }> { ...@@ -170,6 +171,30 @@ export async function clearRateLimit(id: number): Promise<{ message: string }> {
return data 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 * Generate OAuth authorization URL
* @param endpoint - API endpoint path * @param endpoint - API endpoint path
...@@ -332,6 +357,8 @@ export const accountsAPI = { ...@@ -332,6 +357,8 @@ export const accountsAPI = {
getUsage, getUsage,
getTodayStats, getTodayStats,
clearRateLimit, clearRateLimit,
getTempUnschedulableStatus,
resetTempUnschedulable,
setSchedulable, setSchedulable,
getAvailableModels, getAvailableModels,
generateAuthUrl, generateAuthUrl,
......
...@@ -19,7 +19,8 @@ export interface GeminiOAuthCapabilities { ...@@ -19,7 +19,8 @@ export interface GeminiOAuthCapabilities {
export interface GeminiAuthUrlRequest { export interface GeminiAuthUrlRequest {
proxy_id?: number proxy_id?: number
project_id?: string project_id?: string
oauth_type?: 'code_assist' | 'ai_studio' oauth_type?: 'code_assist' | 'google_one' | 'ai_studio'
tier_id?: string
} }
export interface GeminiExchangeCodeRequest { export interface GeminiExchangeCodeRequest {
...@@ -27,10 +28,23 @@ export interface GeminiExchangeCodeRequest { ...@@ -27,10 +28,23 @@ export interface GeminiExchangeCodeRequest {
state: string state: string
code: string code: string
proxy_id?: number 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( export async function generateAuthUrl(
payload: GeminiAuthUrlRequest payload: GeminiAuthUrlRequest
......
<template> <template>
<div v-if="shouldShowQuota" class="flex items-center gap-2"> <div v-if="shouldShowQuota">
<!-- Tier Badge --> <!-- First line: Platform + Tier Badge -->
<span :class="['badge text-xs px-2 py-0.5 rounded font-medium', tierBadgeClass]"> <div class="mb-1 flex items-center gap-1">
{{ tierLabel }} <span :class="['badge text-xs px-2 py-0.5 rounded font-medium', tierBadgeClass]">
</span> {{ tierLabel }}
</span>
<!-- 限流状态 --> </div>
<span
v-if="!isRateLimited" <!-- Usage status: unlimited flow or rate limit -->
class="text-xs text-gray-400 dark:text-gray-500" <div class="text-xs text-gray-400 dark:text-gray-500">
> <span v-if="!isRateLimited">
{{ t('admin.accounts.gemini.rateLimit.ok') }} {{ t('admin.accounts.gemini.rateLimit.unlimited') }}
</span> </span>
<span <span
v-else v-else
:class="[ :class="[
'text-xs font-medium', 'font-medium',
isUrgent isUrgent
? 'text-red-600 dark:text-red-400 animate-pulse' ? 'text-red-600 dark:text-red-400 animate-pulse'
: 'text-amber-600 dark:text-amber-400' : 'text-amber-600 dark:text-amber-400'
]" ]"
> >
{{ t('admin.accounts.gemini.rateLimit.limited', { time: resetCountdown }) }} {{ t('admin.accounts.gemini.rateLimit.limited', { time: resetCountdown }) }}
</span> </span>
</div>
</div> </div>
</template> </template>
...@@ -64,70 +65,67 @@ const tierLabel = computed(() => { ...@@ -64,70 +65,67 @@ const tierLabel = computed(() => {
const creds = props.account.credentials as GeminiCredentials | undefined const creds = props.account.credentials as GeminiCredentials | undefined
if (isCodeAssist.value) { if (isCodeAssist.value) {
// GCP Code Assist: 显示 GCP tier const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
const tierMap: Record<string, string> = { if (tier === 'gcp_enterprise') return 'GCP Enterprise'
LEGACY: 'Free', if (tier === 'gcp_standard') return 'GCP Standard'
PRO: 'Pro', // Backward compatibility
ULTRA: 'Ultra', const upper = (creds?.tier_id || '').toString().trim().toUpperCase()
'standard-tier': 'Standard', if (upper.includes('ULTRA') || upper.includes('ENTERPRISE')) return 'GCP Enterprise'
'pro-tier': 'Pro', if (upper) return `GCP ${upper}`
'ultra-tier': 'Ultra' return 'GCP'
}
return tierMap[creds?.tier_id || ''] || (creds?.tier_id ? 'GCP' : 'Unknown')
} }
if (isGoogleOne.value) { if (isGoogleOne.value) {
// Google One: tier 映射 const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
const tierMap: Record<string, string> = { if (tier === 'google_ai_ultra') return 'Google AI Ultra'
AI_PREMIUM: 'AI Premium', if (tier === 'google_ai_pro') return 'Google AI Pro'
GOOGLE_ONE_STANDARD: 'Standard', if (tier === 'google_one_free') return 'Google One Free'
GOOGLE_ONE_BASIC: 'Basic', // Backward compatibility
FREE: 'Free', const upper = (creds?.tier_id || '').toString().trim().toUpperCase()
GOOGLE_ONE_UNKNOWN: 'Personal', if (upper === 'AI_PREMIUM') return 'Google AI Pro'
GOOGLE_ONE_UNLIMITED: 'Unlimited' if (upper === 'GOOGLE_ONE_UNLIMITED') return 'Google AI Ultra'
} if (upper) return `Google One ${upper}`
return tierMap[creds?.tier_id || ''] || 'Personal' return 'Google One'
} }
// AI Studio 或其他 // API Key: 显示 AI Studio
return 'Gemini' 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 tierBadgeClass = computed(() => {
const creds = props.account.credentials as GeminiCredentials | undefined const creds = props.account.credentials as GeminiCredentials | undefined
if (isCodeAssist.value) { if (isCodeAssist.value) {
// GCP Code Assist 样式 const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
const tierColorMap: Record<string, string> = { if (tier === 'gcp_enterprise') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
LEGACY: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400', if (tier === 'gcp_standard') return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
PRO: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', // Backward compatibility
ULTRA: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', const upper = (creds?.tier_id || '').toString().trim().toUpperCase()
'standard-tier': 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', if (upper.includes('ULTRA') || upper.includes('ENTERPRISE')) return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
'pro-tier': 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
'ultra-tier': 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
}
return (
tierColorMap[creds?.tier_id || ''] ||
'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
)
} }
if (isGoogleOne.value) { if (isGoogleOne.value) {
// Google One tier 样式 const tier = (creds?.tier_id || '').toString().trim().toLowerCase()
const tierColorMap: Record<string, string> = { if (tier === 'google_ai_ultra') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
AI_PREMIUM: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400', if (tier === 'google_ai_pro') return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
GOOGLE_ONE_STANDARD: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', if (tier === 'google_one_free') return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
GOOGLE_ONE_BASIC: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', // Backward compatibility
FREE: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400', const upper = (creds?.tier_id || '').toString().trim().toUpperCase()
GOOGLE_ONE_UNKNOWN: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400', if (upper === 'GOOGLE_ONE_UNLIMITED') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
GOOGLE_ONE_UNLIMITED: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' 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'
return tierColorMap[creds?.tier_id || ''] || 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
} }
// AI Studio 默认样式:蓝色 // 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'
}) })
// 是否限流 // 是否限流
......
<template> <template>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<!-- Main Status Badge --> <!-- 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 }} {{ statusText }}
</span> </span>
...@@ -52,7 +61,7 @@ ...@@ -52,7 +61,7 @@
<div <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" 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 <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" 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>
...@@ -77,20 +86,12 @@ ...@@ -77,20 +86,12 @@
<div <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" 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 <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" 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> </div>
</div> </div>
<!-- Tier Indicator -->
<span
v-if="tierDisplay"
class="inline-flex items-center rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>
{{ tierDisplay }}
</span>
</div> </div>
</template> </template>
...@@ -106,6 +107,10 @@ const props = defineProps<{ ...@@ -106,6 +107,10 @@ const props = defineProps<{
account: Account account: Account
}>() }>()
const emit = defineEmits<{
(e: 'show-temp-unsched', account: Account): void
}>()
// Computed: is rate limited (429) // Computed: is rate limited (429)
const isRateLimited = computed(() => { const isRateLimited = computed(() => {
if (!props.account.rate_limit_reset_at) return false if (!props.account.rate_limit_reset_at) return false
...@@ -118,6 +123,12 @@ const isOverloaded = computed(() => { ...@@ -118,6 +123,12 @@ const isOverloaded = computed(() => {
return new Date(props.account.overload_until) > new Date() 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 // Computed: has error status
const hasError = computed(() => { const hasError = computed(() => {
return props.account.status === 'error' return props.account.status === 'error'
...@@ -125,6 +136,12 @@ const hasError = computed(() => { ...@@ -125,6 +136,12 @@ const hasError = computed(() => {
// Computed: status badge class // Computed: status badge class
const statusClass = computed(() => { const statusClass = computed(() => {
if (hasError.value) {
return 'badge-danger'
}
if (isTempUnschedulable.value) {
return 'badge-warning'
}
if (!props.account.schedulable || isRateLimited.value || isOverloaded.value) { if (!props.account.schedulable || isRateLimited.value || isOverloaded.value) {
return 'badge-gray' return 'badge-gray'
} }
...@@ -142,32 +159,24 @@ const statusClass = computed(() => { ...@@ -142,32 +159,24 @@ const statusClass = computed(() => {
// Computed: status text // Computed: status text
const statusText = computed(() => { 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) { if (!props.account.schedulable) {
return t('admin.accounts.statuses.paused') return t('admin.accounts.status.paused')
} }
if (isRateLimited.value || isOverloaded.value) { 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 handleTempUnschedClick = () => {
const tierDisplay = computed(() => { if (!isTempUnschedulable.value) return
const credentials = props.account.credentials as Record<string, any> | undefined emit('show-temp-unsched', props.account)
const tierId = credentials?.tier_id }
if (!tierId || tierId === 'unknown') return null
const tierMap: Record<string, string> = {
'free': 'Free',
'payg': 'Pay-as-you-go',
'pay-as-you-go': 'Pay-as-you-go',
'enterprise': 'Enterprise',
'LEGACY': 'Legacy',
'PRO': 'Pro',
'ULTRA': 'Ultra'
}
return tierMap[tierId] || tierId
})
</script> </script>
\ No newline at end of file
...@@ -186,17 +186,17 @@ ...@@ -186,17 +186,17 @@
<!-- Gemini platform: show quota + local usage window --> <!-- Gemini platform: show quota + local usage window -->
<template v-else-if="account.platform === 'gemini'"> <template v-else-if="account.platform === 'gemini'">
<!-- 账户类型徽章 --> <!-- Auth Type + Tier Badge (first line) -->
<div v-if="geminiTierLabel" class="mb-1 flex items-center gap-1"> <div v-if="geminiAuthTypeLabel" class="mb-1 flex items-center gap-1">
<span <span
:class="[ :class="[
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium', 'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
geminiTierClass geminiTierClass
]" ]"
> >
{{ geminiTierLabel }} {{ geminiAuthTypeLabel }}
</span> </span>
<!-- 帮助图标 --> <!-- Help icon -->
<span <span
class="group relative cursor-help" class="group relative cursor-help"
> >
...@@ -220,7 +220,7 @@ ...@@ -220,7 +220,7 @@
<div><strong>{{ geminiQuotaPolicyChannel }}:</strong></div> <div><strong>{{ geminiQuotaPolicyChannel }}:</strong></div>
<div class="pl-2">{{ geminiQuotaPolicyLimits }}</div> <div class="pl-2">{{ geminiQuotaPolicyLimits }}</div>
<div class="mt-2"> <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') }} {{ t('admin.accounts.gemini.quotaPolicy.columns.docs') }}
</a> </a>
</div> </div>
...@@ -229,6 +229,7 @@ ...@@ -229,6 +229,7 @@
</span> </span>
</div> </div>
<!-- Usage data or unlimited flow -->
<div class="space-y-1"> <div class="space-y-1">
<div v-if="loading" class="space-y-1"> <div v-if="loading" class="space-y-1">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
...@@ -240,29 +241,25 @@ ...@@ -240,29 +241,25 @@
<div v-else-if="error" class="text-xs text-red-500"> <div v-else-if="error" class="text-xs text-red-500">
{{ error }} {{ error }}
</div> </div>
<!-- Gemini: show daily usage bars when available -->
<div v-else-if="geminiUsageAvailable" class="space-y-1"> <div v-else-if="geminiUsageAvailable" class="space-y-1">
<UsageProgressBar <UsageProgressBar
v-if="usageInfo?.gemini_pro_daily" v-for="bar in geminiUsageBars"
:label="t('admin.accounts.usageWindow.geminiProDaily')" :key="bar.key"
:utilization="usageInfo.gemini_pro_daily.utilization" :label="bar.label"
:resets-at="usageInfo.gemini_pro_daily.resets_at" :utilization="bar.utilization"
:window-stats="usageInfo.gemini_pro_daily.window_stats" :resets-at="bar.resetsAt"
:stats-title="t('admin.accounts.usageWindow.statsTitleDaily')" :window-stats="bar.windowStats"
color="indigo" :color="bar.color"
/>
<UsageProgressBar
v-if="usageInfo?.gemini_flash_daily"
:label="t('admin.accounts.usageWindow.geminiFlashDaily')"
:utilization="usageInfo.gemini_flash_daily.utilization"
:resets-at="usageInfo.gemini_flash_daily.resets_at"
:window-stats="usageInfo.gemini_flash_daily.window_stats"
:stats-title="t('admin.accounts.usageWindow.statsTitleDaily')"
color="emerald"
/> />
<p class="mt-1 text-[9px] leading-tight text-gray-400 dark:text-gray-500 italic"> <p class="mt-1 text-[9px] leading-tight text-gray-400 dark:text-gray-500 italic">
* {{ t('admin.accounts.gemini.quotaPolicy.simulatedNote') || 'Simulated quota' }} * {{ t('admin.accounts.gemini.quotaPolicy.simulatedNote') || 'Simulated quota' }}
</p> </p>
</div> </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> </div>
</template> </template>
...@@ -284,7 +281,7 @@ ...@@ -284,7 +281,7 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageInfo, GeminiCredentials } from '@/types' import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types'
import UsageProgressBar from './UsageProgressBar.vue' import UsageProgressBar from './UsageProgressBar.vue'
import AccountQuotaInfo from './AccountQuotaInfo.vue' import AccountQuotaInfo from './AccountQuotaInfo.vue'
...@@ -299,16 +296,18 @@ const error = ref<string | null>(null) ...@@ -299,16 +296,18 @@ const error = ref<string | null>(null)
const usageInfo = ref<AccountUsageInfo | null>(null) const usageInfo = ref<AccountUsageInfo | null>(null)
// Show usage windows for OAuth and Setup Token accounts // Show usage windows for OAuth and Setup Token accounts
const showUsageWindows = computed( const showUsageWindows = computed(() => {
() => props.account.type === 'oauth' || props.account.type === 'setup-token' // 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(() => { const shouldFetchUsage = computed(() => {
if (props.account.platform === 'anthropic') { if (props.account.platform === 'anthropic') {
return props.account.type === 'oauth' || props.account.type === 'setup-token' return props.account.type === 'oauth' || props.account.type === 'setup-token'
} }
if (props.account.platform === 'gemini') { if (props.account.platform === 'gemini') {
return props.account.type === 'oauth' return true
} }
if (props.account.platform === 'antigravity') { if (props.account.platform === 'antigravity') {
return props.account.type === 'oauth' return props.account.type === 'oauth'
...@@ -318,8 +317,12 @@ const shouldFetchUsage = computed(() => { ...@@ -318,8 +317,12 @@ const shouldFetchUsage = computed(() => {
const geminiUsageAvailable = computed(() => { const geminiUsageAvailable = computed(() => {
return ( return (
!!usageInfo.value?.gemini_shared_daily ||
!!usageInfo.value?.gemini_pro_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(() => { ...@@ -565,6 +568,12 @@ const geminiTier = computed(() => {
return creds?.tier_id || null 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 // Gemini 是否为 Code Assist OAuth
const isGeminiCodeAssist = computed(() => { const isGeminiCodeAssist = computed(() => {
if (props.account.platform !== 'gemini') return false if (props.account.platform !== 'gemini') return false
...@@ -572,94 +581,208 @@ const isGeminiCodeAssist = computed(() => { ...@@ -572,94 +581,208 @@ const isGeminiCodeAssist = computed(() => {
return creds?.oauth_type === 'code_assist' || (!creds?.oauth_type && !!creds?.project_id) return creds?.oauth_type === 'code_assist' || (!creds?.oauth_type && !!creds?.project_id)
}) })
// Gemini 账户类型显示标签 const geminiChannelShort = computed((): 'ai studio' | 'gcp' | 'google one' | 'client' | null => {
const geminiTierLabel = computed(() => { if (props.account.platform !== 'gemini') return null
if (!geminiTier.value) return null
const creds = props.account.credentials as GeminiCredentials | undefined // API Key accounts are AI Studio.
const isGoogleOne = creds?.oauth_type === 'google_one' if (props.account.type === 'apikey') return 'ai studio'
if (isGoogleOne) { if (geminiOAuthType.value === 'google_one') return 'google one'
// Google One tier 标签 if (isGeminiCodeAssist.value) return 'gcp'
const tierMap: Record<string, string> = { if (geminiOAuthType.value === 'ai_studio') return 'client'
AI_PREMIUM: t('admin.accounts.tier.aiPremium'),
GOOGLE_ONE_STANDARD: t('admin.accounts.tier.standard'), // Fallback (unknown legacy data): treat as AI Studio.
GOOGLE_ONE_BASIC: t('admin.accounts.tier.basic'), return 'ai studio'
FREE: t('admin.accounts.tier.free'), })
GOOGLE_ONE_UNKNOWN: t('admin.accounts.tier.personal'),
GOOGLE_ONE_UNLIMITED: t('admin.accounts.tier.unlimited') const geminiUserLevel = computed((): string | null => {
} if (props.account.platform !== 'gemini') return null
return tierMap[geminiTier.value] || t('admin.accounts.tier.personal')
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
} }
// Code Assist tier 标签 // GCP Code Assist: standard / enterprise
const tierMap: Record<string, string> = { if (isGeminiCodeAssist.value) {
LEGACY: t('admin.accounts.tier.free'), if (tierLower === 'gcp_enterprise') return 'enterprise'
PRO: t('admin.accounts.tier.pro'), if (tierLower === 'gcp_standard') return 'standard'
ULTRA: t('admin.accounts.tier.ultra')
// Backward compatibility
if (tierUpper.includes('ULTRA') || tierUpper.includes('ENTERPRISE')) return 'enterprise'
return 'standard'
} }
return tierMap[geminiTier.value] || null
// 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 账户类型徽章样式 // 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(() => { 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 if (channel === 'client' || channel === 'ai studio') {
const isGoogleOne = creds?.oauth_type === 'google_one' return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
if (isGoogleOne) {
// Google One tier 颜色
const colorMap: Record<string, string> = {
AI_PREMIUM: 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300',
GOOGLE_ONE_STANDARD: 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300',
GOOGLE_ONE_BASIC: 'bg-green-100 text-green-600 dark:bg-green-900/40 dark:text-green-300',
FREE: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
GOOGLE_ONE_UNKNOWN: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
GOOGLE_ONE_UNLIMITED: 'bg-amber-100 text-amber-600 dark:bg-amber-900/40 dark:text-amber-300'
}
return colorMap[geminiTier.value] || 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
} }
// Code Assist tier 颜色 if (channel === 'google one') {
switch (geminiTier.value) { if (level === 'ultra') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
case 'LEGACY': 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' 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': if (channel === 'gcp') {
return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300' if (level === 'enterprise') return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
default: return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
return ''
} }
return ''
}) })
// Gemini 配额政策信息 // Gemini 配额政策信息
const geminiQuotaPolicyChannel = computed(() => { const geminiQuotaPolicyChannel = computed(() => {
if (geminiOAuthType.value === 'google_one') {
return t('admin.accounts.gemini.quotaPolicy.rows.googleOne.channel')
}
if (isGeminiCodeAssist.value) { 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') return t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.channel')
}) })
const geminiQuotaPolicyLimits = computed(() => { const geminiQuotaPolicyLimits = computed(() => {
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')
}
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 (isGeminiCodeAssist.value) {
if (geminiTier.value === 'PRO' || geminiTier.value === 'ULTRA') { if (tierLower === 'gcp_enterprise' || geminiUserLevel.value === 'enterprise') {
return t('admin.accounts.gemini.quotaPolicy.rows.cli.limitsPremium') return t('admin.accounts.gemini.quotaPolicy.rows.gcp.limitsEnterprise')
} }
return t('admin.accounts.gemini.quotaPolicy.rows.cli.limitsFree') 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') return t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.limitsFree')
}) })
const geminiQuotaPolicyDocsUrl = computed(() => { const geminiQuotaPolicyDocsUrl = computed(() => {
if (isGeminiCodeAssist.value) { if (geminiOAuthType.value === 'google_one' || isGeminiCodeAssist.value) {
return 'https://cloud.google.com/products/gemini/code-assist#pricing' return 'https://developers.google.com/gemini-code-assist/resources/quotas'
} }
return 'https://ai.google.dev/pricing' 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(() => { const antigravityTierLabel = computed(() => {
switch (antigravityTier.value) { switch (antigravityTier.value) {
......
...@@ -338,7 +338,19 @@ ...@@ -338,7 +338,19 @@
<!-- Account Type Selection (Gemini) --> <!-- Account Type Selection (Gemini) -->
<div v-if="form.platform === '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"> <div class="mt-2 grid grid-cols-2 gap-3" data-tour="account-form-type">
<button <button
type="button" type="button"
...@@ -439,15 +451,6 @@ ...@@ -439,15 +451,6 @@
> >
{{ t('admin.accounts.gemini.accountType.apiKeyLink') }} {{ t('admin.accounts.gemini.accountType.apiKeyLink') }}
</a> </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>
</div> </div>
...@@ -653,77 +656,39 @@ ...@@ -653,77 +656,39 @@
</div> </div>
</div> </div>
<div class="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-4 text-xs text-blue-900 dark:border-blue-800/40 dark:bg-blue-900/20 dark:text-blue-200"> <!-- Tier selection (used as fallback when auto-detection is unavailable/fails) -->
<div class="flex items-start gap-3"> <div class="mt-4">
<svg <label class="input-label">{{ t('admin.accounts.gemini.tier.label') }}</label>
class="h-5 w-5 flex-shrink-0 text-blue-600 dark:text-blue-400" <div class="mt-2">
fill="none" <select
viewBox="0 0 24 24" v-if="geminiOAuthType === 'google_one'"
stroke="currentColor" v-model="geminiTierGoogleOne"
class="input"
> >
<path <option value="google_one_free">{{ t('admin.accounts.gemini.tier.googleOne.free') }}</option>
stroke-linecap="round" <option value="google_ai_pro">{{ t('admin.accounts.gemini.tier.googleOne.pro') }}</option>
stroke-linejoin="round" <option value="google_ai_ultra">{{ t('admin.accounts.gemini.tier.googleOne.ultra') }}</option>
stroke-width="2" </select>
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/> <select
</svg> v-else-if="geminiOAuthType === 'code_assist'"
<div class="min-w-0"> v-model="geminiTierGcp"
<p class="text-sm font-medium text-blue-800 dark:text-blue-300"> class="input"
{{ t('admin.accounts.gemini.setupGuide.title') }} >
</p> <option value="gcp_standard">{{ t('admin.accounts.gemini.tier.gcp.standard') }}</option>
<div class="mt-2 space-y-2"> <option value="gcp_enterprise">{{ t('admin.accounts.gemini.tier.gcp.enterprise') }}</option>
<div> </select>
<p class="font-semibold text-blue-800 dark:text-blue-300">
{{ t('admin.accounts.gemini.setupGuide.checklistTitle') }} <select
</p> v-else
<ul class="mt-1 list-disc space-y-1 pl-4"> v-model="geminiTierAIStudio"
<li> class="input"
{{ t('admin.accounts.gemini.setupGuide.checklistItems.usIp') }} >
<a <option value="aistudio_free">{{ t('admin.accounts.gemini.tier.aiStudio.free') }}</option>
:href="geminiHelpLinks.countryCheck" <option value="aistudio_paid">{{ t('admin.accounts.gemini.tier.aiStudio.paid') }}</option>
class="ml-1 text-blue-600 hover:underline dark:text-blue-400" </select>
target="_blank"
rel="noreferrer"
>
{{ t('admin.accounts.gemini.setupGuide.links.countryCheck') }}
</a>
</li>
<li>{{ t('admin.accounts.gemini.setupGuide.checklistItems.age') }}</li>
</ul>
</div>
<div>
<p class="font-semibold text-blue-800 dark:text-blue-300">
{{ t('admin.accounts.gemini.setupGuide.activationTitle') }}
</p>
<ul class="mt-1 list-disc space-y-1 pl-4">
<li>
{{ t('admin.accounts.gemini.setupGuide.activationItems.geminiWeb') }}
<a
:href="geminiHelpLinks.geminiWebActivation"
class="ml-1 text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{{ t('admin.accounts.gemini.setupGuide.links.geminiWebActivation') }}
</a>
</li>
<li>
{{ t('admin.accounts.gemini.setupGuide.activationItems.gcpProject') }}
<a
:href="geminiHelpLinks.gcpProject"
class="ml-1 text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{{ t('admin.accounts.gemini.setupGuide.links.gcpProject') }}
</a>
</li>
</ul>
</div>
</div>
</div>
</div> </div>
<p class="input-hint">{{ t('admin.accounts.gemini.tier.hint') }}</p>
</div> </div>
</div> </div>
...@@ -820,6 +785,16 @@ ...@@ -820,6 +785,16 @@
<p class="input-hint">{{ apiKeyHint }}</p> <p class="input-hint">{{ apiKeyHint }}</p>
</div> </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) --> <!-- Model Restriction Section (不适用于 Gemini) -->
<div v-if="form.platform !== 'gemini'" class="border-t border-gray-200 pt-4 dark:border-dark-600"> <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> <label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
...@@ -1065,7 +1040,7 @@ ...@@ -1065,7 +1040,7 @@
<!-- Manual input --> <!-- Manual input -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input <input
v-model="customErrorCodeInput" v-model.number="customErrorCodeInput"
type="number" type="number"
min="100" min="100"
max="599" max="599"
...@@ -1143,13 +1118,39 @@ ...@@ -1143,13 +1118,39 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Gemini 配额与限流政策说明 --> <!-- Temp Unschedulable Rules -->
<div v-if="form.platform === 'gemini'" class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div 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="mb-3 flex items-center justify-between">
<div class="flex items-start gap-3"> <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 <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" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
...@@ -1158,149 +1159,133 @@ ...@@ -1158,149 +1159,133 @@
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" 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> </svg>
<div class="min-w-0"> {{ t('admin.accounts.tempUnschedulable.notice') }}
<p class="text-sm font-medium text-gray-800 dark:text-gray-200"> </p>
{{ t('admin.accounts.gemini.quotaPolicy.title') }} </div>
</p>
<p class="mt-1 text-xs text-gray-600 dark:text-gray-400"> <div class="flex flex-wrap gap-2">
{{ t('admin.accounts.gemini.quotaPolicy.note') }} <button
</p> v-for="preset in tempUnschedPresets"
<div class="mt-3 overflow-x-auto"> :key="preset.label"
<table class="min-w-full text-xs text-gray-700 dark:text-gray-300"> type="button"
<thead> @click="addTempUnschedRule(preset.rule)"
<tr class="border-b border-gray-200 dark:border-gray-700"> 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"
<th class="px-2 py-1.5 text-left font-semibold"> >
{{ t('admin.accounts.gemini.quotaPolicy.columns.channel') }} + {{ preset.label }}
</th> </button>
<th class="px-2 py-1.5 text-left font-semibold"> </div>
{{ t('admin.accounts.gemini.quotaPolicy.columns.account') }}
</th> <div v-if="tempUnschedRules.length > 0" class="space-y-3">
<th class="px-2 py-1.5 text-left font-semibold"> <div
{{ t('admin.accounts.gemini.quotaPolicy.columns.limits') }} v-for="(rule, index) in tempUnschedRules"
</th> :key="index"
<th class="px-2 py-1.5 text-left font-semibold"> class="rounded-lg border border-gray-200 p-3 dark:border-dark-600"
{{ t('admin.accounts.gemini.quotaPolicy.columns.docs') }} >
</th> <div class="mb-2 flex items-center justify-between">
</tr> <span class="text-xs font-medium text-gray-500 dark:text-gray-400">
</thead> {{ t('admin.accounts.tempUnschedulable.ruleIndex', { index: index + 1 }) }}
<tbody> </span>
<tr class="border-b border-gray-100 dark:border-gray-800"> <div class="flex items-center gap-2">
<td class="px-2 py-1.5 align-top" rowspan="2"> <button
{{ t('admin.accounts.gemini.quotaPolicy.rows.cli.channel') }} type="button"
</td> :disabled="index === 0"
<td class="px-2 py-1.5"> @click="moveTempUnschedRule(index, -1)"
{{ t('admin.accounts.gemini.quotaPolicy.rows.cli.free') }} 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"
</td> >
<td class="px-2 py-1.5"> <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{{ t('admin.accounts.gemini.quotaPolicy.rows.cli.limitsFree') }} <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</td> </svg>
<td class="px-2 py-1.5 align-top" rowspan="2"> </button>
<a <button
:href="geminiQuotaDocs.codeAssist" type="button"
class="text-blue-600 hover:underline dark:text-blue-400" :disabled="index === tempUnschedRules.length - 1"
target="_blank" @click="moveTempUnschedRule(index, 1)"
rel="noreferrer" 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"
> >
{{ t('admin.accounts.gemini.quotaPolicy.docs.codeAssist') }} <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
</a> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</td> </svg>
</tr> </button>
<tr class="border-b border-gray-100 dark:border-gray-800"> <button
<td class="px-2 py-1.5"> type="button"
{{ t('admin.accounts.gemini.quotaPolicy.rows.cli.premium') }} @click="removeTempUnschedRule(index)"
</td> class="rounded p-1 text-red-500 transition-colors hover:text-red-600"
<td class="px-2 py-1.5"> >
{{ t('admin.accounts.gemini.quotaPolicy.rows.cli.limitsPremium') }} <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
</td> <path
</tr> stroke-linecap="round"
<tr class="border-b border-gray-100 dark:border-gray-800"> stroke-linejoin="round"
<td class="px-2 py-1.5 align-top"> stroke-width="2"
{{ t('admin.accounts.gemini.quotaPolicy.rows.gcloud.channel') }} d="M6 18L18 6M6 6l12 12"
</td> />
<td class="px-2 py-1.5"> </svg>
{{ t('admin.accounts.gemini.quotaPolicy.rows.gcloud.account') }} </button>
</td> </div>
<td class="px-2 py-1.5"> </div>
{{ t('admin.accounts.gemini.quotaPolicy.rows.gcloud.limits') }}
</td> <div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<td class="px-2 py-1.5 align-top"> <div>
<a <label class="input-label">{{ t('admin.accounts.tempUnschedulable.errorCode') }}</label>
:href="geminiQuotaDocs.codeAssist" <input
class="text-blue-600 hover:underline dark:text-blue-400" v-model.number="rule.error_code"
target="_blank" type="number"
rel="noreferrer" min="100"
> max="599"
{{ t('admin.accounts.gemini.quotaPolicy.docs.codeAssist') }} class="input"
</a> :placeholder="t('admin.accounts.tempUnschedulable.errorCodePlaceholder')"
</td> />
</tr> </div>
<tr class="border-b border-gray-100 dark:border-gray-800"> <div>
<td class="px-2 py-1.5 align-top" rowspan="2"> <label class="input-label">{{ t('admin.accounts.tempUnschedulable.durationMinutes') }}</label>
{{ t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.channel') }} <input
</td> v-model.number="rule.duration_minutes"
<td class="px-2 py-1.5"> type="number"
{{ t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.free') }} min="1"
</td> class="input"
<td class="px-2 py-1.5"> :placeholder="t('admin.accounts.tempUnschedulable.durationPlaceholder')"
{{ t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.limitsFree') }} />
</td> </div>
<td class="px-2 py-1.5 align-top" rowspan="2"> <div class="sm:col-span-2">
<a <label class="input-label">{{ t('admin.accounts.tempUnschedulable.keywords') }}</label>
:href="geminiQuotaDocs.aiStudio" <input
class="text-blue-600 hover:underline dark:text-blue-400" v-model="rule.keywords"
target="_blank" type="text"
rel="noreferrer" class="input"
> :placeholder="t('admin.accounts.tempUnschedulable.keywordsPlaceholder')"
{{ t('admin.accounts.gemini.quotaPolicy.docs.aiStudio') }} />
</a> <p class="input-hint">{{ t('admin.accounts.tempUnschedulable.keywordsHint') }}</p>
</td> </div>
</tr> <div class="sm:col-span-2">
<tr class="border-b border-gray-100 dark:border-gray-800"> <label class="input-label">{{ t('admin.accounts.tempUnschedulable.description') }}</label>
<td class="px-2 py-1.5"> <input
{{ t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.paid') }} v-model="rule.description"
</td> type="text"
<td class="px-2 py-1.5"> class="input"
{{ t('admin.accounts.gemini.quotaPolicy.rows.aiStudio.limitsPaid') }} :placeholder="t('admin.accounts.tempUnschedulable.descriptionPlaceholder')"
</td> />
</tr>
<tr>
<td class="px-2 py-1.5 align-top" rowspan="2">
{{ t('admin.accounts.gemini.quotaPolicy.rows.customOAuth.channel') }}
</td>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.customOAuth.free') }}
</td>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.customOAuth.limitsFree') }}
</td>
<td class="px-2 py-1.5 align-top" rowspan="2">
<a
:href="geminiQuotaDocs.vertex"
class="text-blue-600 hover:underline dark:text-blue-400"
target="_blank"
rel="noreferrer"
>
{{ t('admin.accounts.gemini.quotaPolicy.docs.vertex') }}
</a>
</td>
</tr>
<tr>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.customOAuth.paid') }}
</td>
<td class="px-2 py-1.5">
{{ t('admin.accounts.gemini.quotaPolicy.rows.customOAuth.limitsPaid') }}
</td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
</div> </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>
</div> </div>
...@@ -1503,6 +1488,214 @@ ...@@ -1503,6 +1488,214 @@
</div> </div>
</template> </template>
</BaseDialog> </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> </template>
<script setup lang="ts"> <script setup lang="ts">
...@@ -1619,6 +1812,13 @@ interface ModelMapping { ...@@ -1619,6 +1812,13 @@ interface ModelMapping {
to: string to: string
} }
interface TempUnschedRuleForm {
error_code: number | null
keywords: string
duration_minutes: number | null
description: string
}
// State // State
const step = ref(1) const step = ref(1)
const submitting = ref(false) const submitting = ref(false)
...@@ -1634,9 +1834,30 @@ const selectedErrorCodes = ref<number[]>([]) ...@@ -1634,9 +1834,30 @@ const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null) const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false) const interceptWarmupRequests = ref(false)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling 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 geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one')
const geminiAIStudioOAuthEnabled = ref(false) const geminiAIStudioOAuthEnabled = ref(false)
const showAdvancedOAuth = 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 = { const geminiQuotaDocs = {
codeAssist: 'https://developers.google.com/gemini-code-assist/resources/quotas', codeAssist: 'https://developers.google.com/gemini-code-assist/resources/quotas',
...@@ -1654,6 +1875,35 @@ const geminiHelpLinks = { ...@@ -1654,6 +1875,35 @@ const geminiHelpLinks = {
// Computed: current preset mappings based on platform // Computed: current preset mappings based on platform
const presetMappings = computed(() => getPresetMappingsByPlatform(form.platform)) const presetMappings = computed(() => getPresetMappingsByPlatform(form.platform))
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({ const form = reactive({
name: '', name: '',
...@@ -1828,6 +2078,89 @@ const removeErrorCode = (code: number) => { ...@@ -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 // Methods
const resetForm = () => { const resetForm = () => {
step.value = 1 step.value = 1
...@@ -1850,7 +2183,12 @@ const resetForm = () => { ...@@ -1850,7 +2183,12 @@ const resetForm = () => {
selectedErrorCodes.value = [] selectedErrorCodes.value = []
customErrorCodeInput.value = null customErrorCodeInput.value = null
interceptWarmupRequests.value = false interceptWarmupRequests.value = false
tempUnschedEnabled.value = false
tempUnschedRules.value = []
geminiOAuthType.value = 'code_assist' geminiOAuthType.value = 'code_assist'
geminiTierGoogleOne.value = 'google_one_free'
geminiTierGcp.value = 'gcp_standard'
geminiTierAIStudio.value = 'aistudio_free'
oauth.resetState() oauth.resetState()
openaiOAuth.resetState() openaiOAuth.resetState()
geminiOAuth.resetState() geminiOAuth.resetState()
...@@ -1892,6 +2230,9 @@ const handleSubmit = async () => { ...@@ -1892,6 +2230,9 @@ const handleSubmit = async () => {
base_url: apiKeyBaseUrl.value.trim() || defaultBaseUrl, base_url: apiKeyBaseUrl.value.trim() || defaultBaseUrl,
api_key: apiKeyValue.value.trim() api_key: apiKeyValue.value.trim()
} }
if (form.platform === 'gemini') {
credentials.tier_id = geminiTierAIStudio.value
}
// Add model mapping if configured // Add model mapping if configured
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value) const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
...@@ -1910,6 +2251,10 @@ const handleSubmit = async () => { ...@@ -1910,6 +2251,10 @@ const handleSubmit = async () => {
credentials.intercept_warmup_requests = true credentials.intercept_warmup_requests = true
} }
if (!applyTempUnschedConfig(credentials)) {
return
}
form.credentials = credentials form.credentials = credentials
submitting.value = true submitting.value = true
...@@ -1941,7 +2286,12 @@ const handleGenerateUrl = async () => { ...@@ -1941,7 +2286,12 @@ const handleGenerateUrl = async () => {
if (form.platform === 'openai') { if (form.platform === 'openai') {
await openaiOAuth.generateAuthUrl(form.proxy_id) await openaiOAuth.generateAuthUrl(form.proxy_id)
} else if (form.platform === 'gemini') { } 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') { } else if (form.platform === 'antigravity') {
await antigravityOAuth.generateAuthUrl(form.proxy_id) await antigravityOAuth.generateAuthUrl(form.proxy_id)
} else { } else {
...@@ -1956,6 +2306,9 @@ const createAccountAndFinish = async ( ...@@ -1956,6 +2306,9 @@ const createAccountAndFinish = async (
credentials: Record<string, unknown>, credentials: Record<string, unknown>,
extra?: Record<string, unknown> extra?: Record<string, unknown>
) => { ) => {
if (!applyTempUnschedConfig(credentials)) {
return
}
await adminAPI.accounts.create({ await adminAPI.accounts.create({
name: form.name, name: form.name,
platform, platform,
...@@ -2019,12 +2372,14 @@ const handleGeminiExchange = async (authCode: string) => { ...@@ -2019,12 +2372,14 @@ const handleGeminiExchange = async (authCode: string) => {
sessionId: geminiOAuth.sessionId.value, sessionId: geminiOAuth.sessionId.value,
state: stateToUse, state: stateToUse,
proxyId: form.proxy_id, proxyId: form.proxy_id,
oauthType: geminiOAuthType.value oauthType: geminiOAuthType.value,
tierId: geminiSelectedTier.value
}) })
if (!tokenInfo) return if (!tokenInfo) return
const credentials = geminiOAuth.buildCredentials(tokenInfo) const credentials = geminiOAuth.buildCredentials(tokenInfo)
await createAccountAndFinish('gemini', 'oauth', credentials) const extra = geminiOAuth.buildExtraInfo(tokenInfo)
await createAccountAndFinish('gemini', 'oauth', credentials, extra)
} catch (error: any) { } catch (error: any) {
geminiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed') geminiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(geminiOAuth.error.value) appStore.showError(geminiOAuth.error.value)
...@@ -2131,6 +2486,14 @@ const handleCookieAuth = async (sessionKey: string) => { ...@@ -2131,6 +2486,14 @@ const handleCookieAuth = async (sessionKey: string) => {
return return
} }
const tempUnschedPayload = tempUnschedEnabled.value
? buildTempUnschedRules(tempUnschedRules.value)
: []
if (tempUnschedEnabled.value && tempUnschedPayload.length === 0) {
appStore.showError(t('admin.accounts.tempUnschedulable.rulesInvalid'))
return
}
const endpoint = const endpoint =
addMethod.value === 'oauth' addMethod.value === 'oauth'
? '/admin/accounts/cookie-auth' ? '/admin/accounts/cookie-auth'
...@@ -2152,10 +2515,14 @@ const handleCookieAuth = async (sessionKey: string) => { ...@@ -2152,10 +2515,14 @@ const handleCookieAuth = async (sessionKey: string) => {
const accountName = keys.length > 1 ? `${form.name} #${i + 1}` : form.name const accountName = keys.length > 1 ? `${form.name} #${i + 1}` : form.name
// Merge interceptWarmupRequests into credentials // Merge interceptWarmupRequests into credentials
const credentials = { const credentials: Record<string, unknown> = {
...tokenInfo, ...tokenInfo,
...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {}) ...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {})
} }
if (tempUnschedEnabled.value) {
credentials.temp_unschedulable_enabled = true
credentials.temp_unschedulable_rules = tempUnschedPayload
}
await adminAPI.accounts.create({ await adminAPI.accounts.create({
name: accountName, name: accountName,
......
...@@ -293,7 +293,7 @@ ...@@ -293,7 +293,7 @@
<!-- Manual input --> <!-- Manual input -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input <input
v-model="customErrorCodeInput" v-model.number="customErrorCodeInput"
type="number" type="number"
min="100" min="100"
max="599" max="599"
...@@ -373,6 +373,175 @@ ...@@ -373,6 +373,175 @@
</div> </div>
</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) --> <!-- Intercept Warmup Requests (Anthropic only) -->
<div <div
v-if="account?.platform === 'anthropic'" v-if="account?.platform === 'anthropic'"
...@@ -563,6 +732,13 @@ interface ModelMapping { ...@@ -563,6 +732,13 @@ interface ModelMapping {
to: string to: string
} }
interface TempUnschedRuleForm {
error_code: number | null
keywords: string
duration_minutes: number | null
description: string
}
// State // State
const submitting = ref(false) const submitting = ref(false)
const editBaseUrl = ref('https://api.anthropic.com') const editBaseUrl = ref('https://api.anthropic.com')
...@@ -575,9 +751,40 @@ const selectedErrorCodes = ref<number[]>([]) ...@@ -575,9 +751,40 @@ const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null) const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false) const interceptWarmupRequests = ref(false)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling 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 // Computed: current preset mappings based on platform
const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic')) 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 // Computed: default base URL based on platform
const defaultBaseUrl = computed(() => { const defaultBaseUrl = computed(() => {
...@@ -620,6 +827,8 @@ watch( ...@@ -620,6 +827,8 @@ watch(
const extra = newAccount.extra as Record<string, unknown> | undefined const extra = newAccount.extra as Record<string, unknown> | undefined
mixedScheduling.value = extra?.mixed_scheduling === true mixedScheduling.value = extra?.mixed_scheduling === true
loadTempUnschedRules(credentials)
// Initialize API Key fields for apikey type // Initialize API Key fields for apikey type
if (newAccount.type === 'apikey' && newAccount.credentials) { if (newAccount.type === 'apikey' && newAccount.credentials) {
const credentials = newAccount.credentials as Record<string, unknown> const credentials = newAccount.credentials as Record<string, unknown>
...@@ -736,6 +945,130 @@ const removeErrorCode = (code: number) => { ...@@ -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 // Methods
const handleClose = () => { const handleClose = () => {
emit('close') emit('close')
...@@ -788,6 +1121,11 @@ const handleSubmit = async () => { ...@@ -788,6 +1121,11 @@ const handleSubmit = async () => {
newCredentials.intercept_warmup_requests = true newCredentials.intercept_warmup_requests = true
} }
if (!applyTempUnschedConfig(newCredentials)) {
submitting.value = false
return
}
updatePayload.credentials = newCredentials updatePayload.credentials = newCredentials
} else { } else {
// For oauth/setup-token types, only update intercept_warmup_requests if changed // For oauth/setup-token types, only update intercept_warmup_requests if changed
...@@ -800,6 +1138,11 @@ const handleSubmit = async () => { ...@@ -800,6 +1138,11 @@ const handleSubmit = async () => {
delete newCredentials.intercept_warmup_requests delete newCredentials.intercept_warmup_requests
} }
if (!applyTempUnschedConfig(newCredentials)) {
submitting.value = false
return
}
updatePayload.credentials = newCredentials updatePayload.credentials = newCredentials
} }
......
...@@ -88,7 +88,35 @@ ...@@ -88,7 +88,35 @@
<!-- Gemini OAuth Type Selection --> <!-- Gemini OAuth Type Selection -->
<fieldset v-if="isGemini" class="border-0 p-0"> <fieldset v-if="isGemini" class="border-0 p-0">
<legend class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</legend> <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 <button
type="button" type="button"
@click="handleSelectGeminiOAuthType('code_assist')" @click="handleSelectGeminiOAuthType('code_assist')"
...@@ -305,7 +333,7 @@ const oauthFlowRef = ref<OAuthFlowExposed | null>(null) ...@@ -305,7 +333,7 @@ const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
// State // State
const addMethod = ref<AddMethod>('oauth') 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) const geminiAIStudioOAuthEnabled = ref(false)
// Computed - check platform // Computed - check platform
...@@ -367,7 +395,12 @@ watch( ...@@ -367,7 +395,12 @@ watch(
} }
if (isGemini.value) { if (isGemini.value) {
const creds = (props.account.credentials || {}) as Record<string, unknown> 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) { if (isGemini.value) {
geminiOAuth.getCapabilities().then((caps) => { geminiOAuth.getCapabilities().then((caps) => {
...@@ -395,7 +428,7 @@ const resetState = () => { ...@@ -395,7 +428,7 @@ const resetState = () => {
oauthFlowRef.value?.reset() 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) { if (oauthType === 'ai_studio' && !geminiAIStudioOAuthEnabled.value) {
appStore.showError(t('admin.accounts.oauth.gemini.aiStudioNotConfigured')) appStore.showError(t('admin.accounts.oauth.gemini.aiStudioNotConfigured'))
return return
...@@ -413,8 +446,10 @@ const handleGenerateUrl = async () => { ...@@ -413,8 +446,10 @@ const handleGenerateUrl = async () => {
if (isOpenAI.value) { if (isOpenAI.value) {
await openaiOAuth.generateAuthUrl(props.account.proxy_id) await openaiOAuth.generateAuthUrl(props.account.proxy_id)
} else if (isGemini.value) { } 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 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) { } else if (isAntigravity.value) {
await antigravityOAuth.generateAuthUrl(props.account.proxy_id) await antigravityOAuth.generateAuthUrl(props.account.proxy_id)
} else { } else {
...@@ -475,7 +510,8 @@ const handleExchangeCode = async () => { ...@@ -475,7 +510,8 @@ const handleExchangeCode = async () => {
sessionId, sessionId,
state: stateToUse, state: stateToUse,
proxyId: props.account.proxy_id, 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 if (!tokenInfo) return
......
<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(() => { ...@@ -111,12 +111,12 @@ const displayPercent = computed(() => {
// Format reset time // Format reset time
const formatResetTime = computed(() => { const formatResetTime = computed(() => {
if (!props.resetsAt) return 'N/A' if (!props.resetsAt) return t('common.notAvailable')
const date = new Date(props.resetsAt) const date = new Date(props.resetsAt)
const now = new Date() const now = new Date()
const diffMs = date.getTime() - now.getTime() 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 diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)) const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
......
...@@ -9,4 +9,5 @@ export { default as UsageProgressBar } from './UsageProgressBar.vue' ...@@ -9,4 +9,5 @@ export { default as UsageProgressBar } from './UsageProgressBar.vue'
export { default as AccountStatsModal } from './AccountStatsModal.vue' export { default as AccountStatsModal } from './AccountStatsModal.vue'
export { default as AccountTestModal } from './AccountTestModal.vue' export { default as AccountTestModal } from './AccountTestModal.vue'
export { default as AccountTodayStatsCell } from './AccountTodayStatsCell.vue' export { default as AccountTodayStatsCell } from './AccountTodayStatsCell.vue'
export { default as TempUnschedStatusModal } from './TempUnschedStatusModal.vue'
export { default as SyncFromCrsModal } from './SyncFromCrsModal.vue' export { default as SyncFromCrsModal } from './SyncFromCrsModal.vue'
...@@ -12,6 +12,8 @@ export interface GeminiTokenInfo { ...@@ -12,6 +12,8 @@ export interface GeminiTokenInfo {
expires_at?: number | string expires_at?: number | string
project_id?: string project_id?: string
oauth_type?: string oauth_type?: string
tier_id?: string
extra?: Record<string, unknown>
[key: string]: unknown [key: string]: unknown
} }
...@@ -36,7 +38,8 @@ export function useGeminiOAuth() { ...@@ -36,7 +38,8 @@ export function useGeminiOAuth() {
const generateAuthUrl = async ( const generateAuthUrl = async (
proxyId: number | null | undefined, proxyId: number | null | undefined,
projectId?: string | null, projectId?: string | null,
oauthType?: string oauthType?: string,
tierId?: string
): Promise<boolean> => { ): Promise<boolean> => {
loading.value = true loading.value = true
authUrl.value = '' authUrl.value = ''
...@@ -50,6 +53,8 @@ export function useGeminiOAuth() { ...@@ -50,6 +53,8 @@ export function useGeminiOAuth() {
const trimmedProjectID = projectId?.trim() const trimmedProjectID = projectId?.trim()
if (trimmedProjectID) payload.project_id = trimmedProjectID if (trimmedProjectID) payload.project_id = trimmedProjectID
if (oauthType) payload.oauth_type = oauthType 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) const response = await adminAPI.gemini.generateAuthUrl(payload as any)
authUrl.value = response.auth_url authUrl.value = response.auth_url
...@@ -71,6 +76,7 @@ export function useGeminiOAuth() { ...@@ -71,6 +76,7 @@ export function useGeminiOAuth() {
state: string state: string
proxyId?: number | null proxyId?: number | null
oauthType?: string oauthType?: string
tierId?: string
}): Promise<GeminiTokenInfo | null> => { }): Promise<GeminiTokenInfo | null> => {
const code = params.code?.trim() const code = params.code?.trim()
if (!code || !params.sessionId || !params.state) { if (!code || !params.sessionId || !params.state) {
...@@ -89,6 +95,8 @@ export function useGeminiOAuth() { ...@@ -89,6 +95,8 @@ export function useGeminiOAuth() {
} }
if (params.proxyId) payload.proxy_id = params.proxyId if (params.proxyId) payload.proxy_id = params.proxyId
if (params.oauthType) payload.oauth_type = params.oauthType 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) const tokenInfo = await adminAPI.gemini.exchangeCode(payload as any)
return tokenInfo as GeminiTokenInfo return tokenInfo as GeminiTokenInfo
...@@ -122,10 +130,16 @@ export function useGeminiOAuth() { ...@@ -122,10 +130,16 @@ export function useGeminiOAuth() {
expires_at: expiresAt, expires_at: expiresAt,
scope: tokenInfo.scope, scope: tokenInfo.scope,
project_id: tokenInfo.project_id, 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> => { const getCapabilities = async (): Promise<GeminiOAuthCapabilities | null> => {
try { try {
return await adminAPI.gemini.getCapabilities() return await adminAPI.gemini.getCapabilities()
...@@ -145,6 +159,7 @@ export function useGeminiOAuth() { ...@@ -145,6 +159,7 @@ export function useGeminiOAuth() {
generateAuthUrl, generateAuthUrl,
exchangeAuthCode, exchangeAuthCode,
buildCredentials, buildCredentials,
buildExtraInfo,
getCapabilities getCapabilities
} }
} }
...@@ -150,6 +150,9 @@ export default { ...@@ -150,6 +150,9 @@ export default {
noOptionsFound: 'No options found', noOptionsFound: 'No options found',
saving: 'Saving...', saving: 'Saving...',
refresh: 'Refresh', refresh: 'Refresh',
notAvailable: 'N/A',
now: 'Now',
unknown: 'Unknown',
time: { time: {
never: 'Never', never: 'Never',
justNow: 'Just now', justNow: 'Just now',
...@@ -812,11 +815,6 @@ export default { ...@@ -812,11 +815,6 @@ export default {
gemini: 'Gemini', gemini: 'Gemini',
antigravity: 'Antigravity' antigravity: 'Antigravity'
}, },
statuses: {
active: 'Active',
inactive: 'Inactive',
error: 'Error'
},
deleteConfirm: deleteConfirm:
"Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.", "Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.",
deleteConfirmSubscription: deleteConfirmSubscription:
...@@ -957,6 +955,61 @@ export default { ...@@ -957,6 +955,61 @@ export default {
codeAssist: 'Code Assist', codeAssist: 'Code Assist',
antigravityOauth: 'Antigravity OAuth' 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: { columns: {
name: 'Name', name: 'Name',
platformType: 'Platform/Type', platformType: 'Platform/Type',
...@@ -981,16 +1034,6 @@ export default { ...@@ -981,16 +1034,6 @@ export default {
tokenRefreshed: 'Token refreshed successfully', tokenRefreshed: 'Token refreshed successfully',
accountDeleted: 'Account deleted successfully', accountDeleted: 'Account deleted successfully',
rateLimitCleared: 'Rate limit cleared 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: { bulkActions: {
selected: '{count} account(s) selected', selected: '{count} account(s) selected',
selectCurrentPage: 'Select this page', selectCurrentPage: 'Select this page',
...@@ -1238,11 +1281,35 @@ export default { ...@@ -1238,11 +1281,35 @@ export default {
}, },
// Gemini specific (platform-wide) // Gemini specific (platform-wide)
gemini: { gemini: {
helpButton: 'Help',
helpDialog: {
title: 'Gemini Usage Guide',
apiKeySection: 'API Key Links'
},
modelPassthrough: 'Gemini Model Passthrough', modelPassthrough: 'Gemini Model Passthrough',
modelPassthroughDesc: modelPassthroughDesc:
'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.', 'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.',
baseUrlHint: 'Leave default for official Gemini API', baseUrlHint: 'Leave default for official Gemini API',
apiKeyHint: 'Your Gemini API Key (starts with AIza)', apiKeyHint: 'Your Gemini API Key (starts with AIza)',
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: { accountType: {
oauthTitle: 'OAuth (Gemini)', oauthTitle: 'OAuth (Gemini)',
oauthDesc: 'Authorize with your Google account and choose an OAuth type.', oauthDesc: 'Authorize with your Google account and choose an OAuth type.',
...@@ -1303,6 +1370,17 @@ export default { ...@@ -1303,6 +1370,17 @@ export default {
}, },
simulatedNote: 'Simulated quota, for reference only', simulatedNote: 'Simulated quota, for reference only',
rows: { 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: { cli: {
channel: 'Gemini CLI (Official Google Login / Code Assist)', channel: 'Gemini CLI (Official Google Login / Code Assist)',
free: 'Free Google Account', free: 'Free Google Account',
...@@ -1320,7 +1398,7 @@ export default { ...@@ -1320,7 +1398,7 @@ export default {
free: 'No billing (free tier)', free: 'No billing (free tier)',
paid: 'Billing enabled (pay-as-you-go)', paid: 'Billing enabled (pay-as-you-go)',
limitsFree: 'RPD 50; RPM 2 (Pro) / 15 (Flash)', 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: { customOAuth: {
channel: 'Custom OAuth Client (GCP)', channel: 'Custom OAuth Client (GCP)',
...@@ -1333,6 +1411,7 @@ export default { ...@@ -1333,6 +1411,7 @@ export default {
}, },
rateLimit: { rateLimit: {
ok: 'Not rate limited', ok: 'Not rate limited',
unlimited: 'Unlimited',
limited: 'Rate limited {time}', limited: 'Rate limited {time}',
now: 'now' now: 'now'
} }
...@@ -1439,11 +1518,6 @@ export default { ...@@ -1439,11 +1518,6 @@ export default {
socks5: 'SOCKS5', socks5: 'SOCKS5',
socks5h: 'SOCKS5H (Remote DNS)' socks5h: 'SOCKS5H (Remote DNS)'
}, },
statuses: {
active: 'Active',
inactive: 'Inactive',
error: 'Error'
},
columns: { columns: {
name: 'Name', name: 'Name',
protocol: 'Protocol', protocol: 'Protocol',
...@@ -1561,7 +1635,13 @@ export default { ...@@ -1561,7 +1635,13 @@ export default {
selectGroupPlaceholder: 'Choose a subscription group', selectGroupPlaceholder: 'Choose a subscription group',
validityDays: 'Validity Days', validityDays: 'Validity Days',
groupRequired: 'Please select a subscription group', groupRequired: 'Please select a subscription group',
days: ' days' days: ' days',
status: {
unused: 'Unused',
used: 'Used',
expired: 'Expired',
disabled: 'Disabled'
}
}, },
// Usage Records // Usage Records
...@@ -1612,6 +1692,7 @@ export default { ...@@ -1612,6 +1692,7 @@ export default {
siteKey: 'Site Key', siteKey: 'Site Key',
secretKey: 'Secret Key', secretKey: 'Secret Key',
siteKeyHint: 'Get this from your Cloudflare Dashboard', siteKeyHint: 'Get this from your Cloudflare Dashboard',
cloudflareDashboard: 'Cloudflare Dashboard',
secretKeyHint: 'Server-side verification key (keep this secret)' secretKeyHint: 'Server-side verification key (keep this secret)'
}, },
defaults: { defaults: {
...@@ -1762,6 +1843,7 @@ export default { ...@@ -1762,6 +1843,7 @@ export default {
noActiveSubscriptions: 'No Active Subscriptions', noActiveSubscriptions: 'No Active Subscriptions',
noActiveSubscriptionsDesc: noActiveSubscriptionsDesc:
"You don't have any active subscriptions. Contact administrator to get one.", "You don't have any active subscriptions. Contact administrator to get one.",
failedToLoad: 'Failed to load subscriptions',
status: { status: {
active: 'Active', active: 'Active',
expired: 'Expired', expired: 'Expired',
......
...@@ -147,6 +147,9 @@ export default { ...@@ -147,6 +147,9 @@ export default {
noOptionsFound: '无匹配选项', noOptionsFound: '无匹配选项',
saving: '保存中...', saving: '保存中...',
refresh: '刷新', refresh: '刷新',
notAvailable: '不可用',
now: '现在',
unknown: '未知',
time: { time: {
never: '从未', never: '从未',
justNow: '刚刚', justNow: '刚刚',
...@@ -868,11 +871,6 @@ export default { ...@@ -868,11 +871,6 @@ export default {
gemini: 'Gemini', gemini: 'Gemini',
antigravity: 'Antigravity' antigravity: 'Antigravity'
}, },
statuses: {
active: '正常',
inactive: '停用',
error: '错误'
},
saving: '保存中...', saving: '保存中...',
noGroups: '暂无分组', noGroups: '暂无分组',
noGroupsDescription: '创建分组以更好地管理 API 密钥和费率。', noGroupsDescription: '创建分组以更好地管理 API 密钥和费率。',
...@@ -1072,15 +1070,60 @@ export default { ...@@ -1072,15 +1070,60 @@ export default {
api_key: 'API Key', api_key: 'API Key',
cookie: 'Cookie' cookie: 'Cookie'
}, },
statuses: { status: {
active: '正常', active: '正常',
inactive: '停用', inactive: '停用',
error: '错误', error: '错误',
cooldown: '冷却中', cooldown: '冷却中',
paused: '暂停', paused: '暂停',
limited: '限流', limited: '限流',
tempUnschedulable: '临时不可调度',
rateLimitedUntil: '限流中,重置时间:{time}', 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: { usageWindow: {
statsTitle: '5小时窗口用量统计', statsTitle: '5小时窗口用量统计',
...@@ -1366,10 +1409,33 @@ export default { ...@@ -1366,10 +1409,33 @@ export default {
}, },
// Gemini specific (platform-wide) // Gemini specific (platform-wide)
gemini: { gemini: {
helpButton: '使用帮助',
helpDialog: {
title: 'Gemini 使用指南',
apiKeySection: 'API Key 相关链接'
},
modelPassthrough: 'Gemini 直接转发模型', modelPassthrough: 'Gemini 直接转发模型',
modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。', modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。',
baseUrlHint: '留空使用官方 Gemini API', baseUrlHint: '留空使用官方 Gemini API',
apiKeyHint: '您的 Gemini API Key(以 AIza 开头)', apiKeyHint: '您的 Gemini API Key(以 AIza 开头)',
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: { accountType: {
oauthTitle: 'OAuth 授权(Gemini)', oauthTitle: 'OAuth 授权(Gemini)',
oauthDesc: '使用 Google 账号授权,并选择 OAuth 子类型。', oauthDesc: '使用 Google 账号授权,并选择 OAuth 子类型。',
...@@ -1429,6 +1495,17 @@ export default { ...@@ -1429,6 +1495,17 @@ export default {
}, },
simulatedNote: '本地模拟配额,仅供参考', simulatedNote: '本地模拟配额,仅供参考',
rows: { 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: { cli: {
channel: 'Gemini CLI(官方 Google 登录 / Code Assist)', channel: 'Gemini CLI(官方 Google 登录 / Code Assist)',
free: '免费 Google 账号', free: '免费 Google 账号',
...@@ -1446,7 +1523,7 @@ export default { ...@@ -1446,7 +1523,7 @@ export default {
free: '未绑卡(免费层)', free: '未绑卡(免费层)',
paid: '已绑卡(按量付费)', paid: '已绑卡(按量付费)',
limitsFree: 'RPD 50;RPM 2(Pro)/ 15(Flash)', limitsFree: 'RPD 50;RPM 2(Pro)/ 15(Flash)',
limitsPaid: 'RPD 不限;RPM 1000+(按模型配额)' limitsPaid: 'RPD 不限;RPM 1000(Pro)/ 2000(Flash)(按模型配额)'
}, },
customOAuth: { customOAuth: {
channel: 'Custom OAuth Client(GCP)', channel: 'Custom OAuth Client(GCP)',
...@@ -1459,6 +1536,7 @@ export default { ...@@ -1459,6 +1536,7 @@ export default {
}, },
rateLimit: { rateLimit: {
ok: '未限流', ok: '未限流',
unlimited: '无限流',
limited: '限流 {time}', limited: '限流 {time}',
now: '现在' now: '现在'
} }
...@@ -1549,12 +1627,7 @@ export default { ...@@ -1549,12 +1627,7 @@ export default {
socks5: 'SOCKS5', socks5: 'SOCKS5',
socks5h: 'SOCKS5H (服务端解析 DNS)' socks5h: 'SOCKS5H (服务端解析 DNS)'
}, },
statuses: { columns: {
active: '正常',
inactive: '停用',
error: '错误'
},
form: {
nameLabel: '名称', nameLabel: '名称',
namePlaceholder: '请输入代理名称', namePlaceholder: '请输入代理名称',
protocolLabel: '协议', protocolLabel: '协议',
...@@ -1693,7 +1766,7 @@ export default { ...@@ -1693,7 +1766,7 @@ export default {
validityDays: '有效天数', validityDays: '有效天数',
groupRequired: '请选择订阅分组', groupRequired: '请选择订阅分组',
days: '', days: '',
statuses: { status: {
unused: '未使用', unused: '未使用',
used: '已使用', used: '已使用',
expired: '已过期', expired: '已过期',
...@@ -1787,6 +1860,7 @@ export default { ...@@ -1787,6 +1860,7 @@ export default {
siteKey: '站点密钥', siteKey: '站点密钥',
secretKey: '私密密钥', secretKey: '私密密钥',
siteKeyHint: '从 Cloudflare Dashboard 获取', siteKeyHint: '从 Cloudflare Dashboard 获取',
cloudflareDashboard: 'Cloudflare Dashboard',
secretKeyHint: '服务端验证密钥(请保密)' secretKeyHint: '服务端验证密钥(请保密)'
}, },
defaults: { defaults: {
...@@ -1934,6 +2008,7 @@ export default { ...@@ -1934,6 +2008,7 @@ export default {
description: '查看您的订阅计划和用量', description: '查看您的订阅计划和用量',
noActiveSubscriptions: '暂无有效订阅', noActiveSubscriptions: '暂无有效订阅',
noActiveSubscriptionsDesc: '您没有任何有效订阅。请联系管理员获取订阅。', noActiveSubscriptionsDesc: '您没有任何有效订阅。请联系管理员获取订阅。',
failedToLoad: '加载订阅失败',
status: { status: {
active: '有效', active: '有效',
expired: '已过期', expired: '已过期',
......
...@@ -322,14 +322,46 @@ export interface GeminiCredentials { ...@@ -322,14 +322,46 @@ export interface GeminiCredentials {
// OAuth authentication // OAuth authentication
access_token?: string access_token?: string
refresh_token?: string refresh_token?: string
oauth_type?: 'code_assist' | 'ai_studio' | string oauth_type?: 'code_assist' | 'google_one' | 'ai_studio' | string
tier_id?: 'LEGACY' | 'PRO' | 'ULTRA' | 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 project_id?: string
token_type?: string token_type?: string
scope?: string scope?: string
expires_at?: 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 { export interface Account {
id: number id: number
name: string name: string
...@@ -355,6 +387,8 @@ export interface Account { ...@@ -355,6 +387,8 @@ export interface Account {
rate_limited_at: string | null rate_limited_at: string | null
rate_limit_reset_at: string | null rate_limit_reset_at: string | null
overload_until: string | null overload_until: string | null
temp_unschedulable_until: string | null
temp_unschedulable_reason: string | null
// Session window fields (5-hour window) // Session window fields (5-hour window)
session_window_start: string | null session_window_start: string | null
...@@ -374,6 +408,8 @@ export interface UsageProgress { ...@@ -374,6 +408,8 @@ export interface UsageProgress {
resets_at: string | null resets_at: string | null
remaining_seconds: number remaining_seconds: number
window_stats?: WindowStats | null // 窗口期统计(从窗口开始到当前的使用量) window_stats?: WindowStats | null // 窗口期统计(从窗口开始到当前的使用量)
used_requests?: number
limit_requests?: number
} }
// Antigravity 单个模型的配额信息 // Antigravity 单个模型的配额信息
...@@ -387,8 +423,12 @@ export interface AccountUsageInfo { ...@@ -387,8 +423,12 @@ export interface AccountUsageInfo {
five_hour: UsageProgress | null five_hour: UsageProgress | null
seven_day: UsageProgress | null seven_day: UsageProgress | null
seven_day_sonnet: UsageProgress | null seven_day_sonnet: UsageProgress | null
gemini_shared_daily?: UsageProgress | null
gemini_pro_daily?: UsageProgress | null gemini_pro_daily?: UsageProgress | null
gemini_flash_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 antigravity_quota?: Record<string, AntigravityModelQuota> | null
} }
...@@ -425,6 +465,7 @@ export interface CreateAccountRequest { ...@@ -425,6 +465,7 @@ export interface CreateAccountRequest {
concurrency?: number concurrency?: number
priority?: number priority?: number
group_ids?: number[] group_ids?: number[]
confirm_mixed_channel_risk?: boolean
} }
export interface UpdateAccountRequest { export interface UpdateAccountRequest {
...@@ -437,6 +478,7 @@ export interface UpdateAccountRequest { ...@@ -437,6 +478,7 @@ export interface UpdateAccountRequest {
priority?: number priority?: number
status?: 'active' | 'inactive' status?: 'active' | 'inactive'
group_ids?: number[] group_ids?: number[]
confirm_mixed_channel_risk?: boolean
} }
export interface CreateProxyRequest { export interface CreateProxyRequest {
......
...@@ -216,7 +216,7 @@ ...@@ -216,7 +216,7 @@
</template> </template>
<template #cell-status="{ row }"> <template #cell-status="{ row }">
<AccountStatusIndicator :account="row" /> <AccountStatusIndicator :account="row" @show-temp-unsched="handleShowTempUnsched" />
</template> </template>
<template #cell-schedulable="{ row }"> <template #cell-schedulable="{ row }">
...@@ -400,6 +400,14 @@ ...@@ -400,6 +400,14 @@
<!-- Account Stats Modal --> <!-- Account Stats Modal -->
<AccountStatsModal :show="showStatsModal" :account="statsAccount" @close="closeStatsModal" /> <AccountStatsModal :show="showStatsModal" :account="statsAccount" @close="closeStatsModal" />
<!-- Temp Unschedulable Status Modal -->
<TempUnschedStatusModal
:show="showTempUnschedModal"
:account="tempUnschedAccount"
@close="closeTempUnschedModal"
@reset="handleTempUnschedReset"
/>
<!-- Delete Confirmation Dialog --> <!-- Delete Confirmation Dialog -->
<ConfirmDialog <ConfirmDialog
:show="showDeleteDialog" :show="showDeleteDialog"
...@@ -512,6 +520,7 @@ import { ...@@ -512,6 +520,7 @@ import {
BulkEditAccountModal, BulkEditAccountModal,
ReAuthAccountModal, ReAuthAccountModal,
AccountStatsModal, AccountStatsModal,
TempUnschedStatusModal,
SyncFromCrsModal SyncFromCrsModal
} from '@/components/account' } from '@/components/account'
import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue' import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue'
...@@ -572,9 +581,9 @@ const typeOptions = computed(() => [ ...@@ -572,9 +581,9 @@ const typeOptions = computed(() => [
const statusOptions = computed(() => [ const statusOptions = computed(() => [
{ value: '', label: t('admin.accounts.allStatus') }, { value: '', label: t('admin.accounts.allStatus') },
{ value: 'active', label: t('common.active') }, { value: 'active', label: t('admin.accounts.status.active') },
{ value: 'inactive', label: t('common.inactive') }, { value: 'inactive', label: t('admin.accounts.status.inactive') },
{ value: 'error', label: t('common.error') } { value: 'error', label: t('admin.accounts.status.error') }
]) ])
// State // State
...@@ -604,6 +613,7 @@ const showDeleteDialog = ref(false) ...@@ -604,6 +613,7 @@ const showDeleteDialog = ref(false)
const showBulkDeleteDialog = ref(false) const showBulkDeleteDialog = ref(false)
const showTestModal = ref(false) const showTestModal = ref(false)
const showStatsModal = ref(false) const showStatsModal = ref(false)
const showTempUnschedModal = ref(false)
const showCrsSyncModal = ref(false) const showCrsSyncModal = ref(false)
const showBulkEditModal = ref(false) const showBulkEditModal = ref(false)
const editingAccount = ref<Account | null>(null) const editingAccount = ref<Account | null>(null)
...@@ -611,6 +621,7 @@ const reAuthAccount = ref<Account | null>(null) ...@@ -611,6 +621,7 @@ const reAuthAccount = ref<Account | null>(null)
const deletingAccount = ref<Account | null>(null) const deletingAccount = ref<Account | null>(null)
const testingAccount = ref<Account | null>(null) const testingAccount = ref<Account | null>(null)
const statsAccount = ref<Account | null>(null) const statsAccount = ref<Account | null>(null)
const tempUnschedAccount = ref<Account | null>(null)
const togglingSchedulable = ref<number | null>(null) const togglingSchedulable = ref<number | null>(null)
const bulkDeleting = ref(false) const bulkDeleting = ref(false)
...@@ -775,6 +786,21 @@ const closeReAuthModal = () => { ...@@ -775,6 +786,21 @@ const closeReAuthModal = () => {
reAuthAccount.value = null 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 // Token refresh
const handleRefreshToken = async (account: Account) => { const handleRefreshToken = async (account: Account) => {
try { try {
......
...@@ -164,7 +164,7 @@ ...@@ -164,7 +164,7 @@
<template #cell-status="{ value }"> <template #cell-status="{ value }">
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']"> <span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
{{ t('admin.groups.statuses.' + value) }} {{ t('admin.accounts.status.' + value) }}
</span> </span>
</template> </template>
...@@ -683,8 +683,8 @@ const columns = computed<Column[]>(() => [ ...@@ -683,8 +683,8 @@ const columns = computed<Column[]>(() => [
// Filter options // Filter options
const statusOptions = computed(() => [ const statusOptions = computed(() => [
{ value: '', label: t('admin.groups.allStatus') }, { value: '', label: t('admin.groups.allStatus') },
{ value: 'active', label: t('common.active') }, { value: 'active', label: t('admin.accounts.status.active') },
{ value: 'inactive', label: t('common.inactive') } { value: 'inactive', label: t('admin.accounts.status.inactive') }
]) ])
const exclusiveOptions = computed(() => [ const exclusiveOptions = computed(() => [
...@@ -709,8 +709,8 @@ const platformFilterOptions = computed(() => [ ...@@ -709,8 +709,8 @@ const platformFilterOptions = computed(() => [
]) ])
const editStatusOptions = computed(() => [ const editStatusOptions = computed(() => [
{ value: 'active', label: t('common.active') }, { value: 'active', label: t('admin.accounts.status.active') },
{ value: 'inactive', label: t('common.inactive') } { value: 'inactive', label: t('admin.accounts.status.inactive') }
]) ])
const subscriptionTypeOptions = computed(() => [ const subscriptionTypeOptions = computed(() => [
......
...@@ -103,7 +103,7 @@ ...@@ -103,7 +103,7 @@
<template #cell-status="{ value }"> <template #cell-status="{ value }">
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']"> <span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
{{ t('admin.proxies.statuses.' + value) }} {{ t('admin.accounts.status.' + value) }}
</span> </span>
</template> </template>
...@@ -634,8 +634,8 @@ const protocolOptions = computed(() => [ ...@@ -634,8 +634,8 @@ const protocolOptions = computed(() => [
const statusOptions = computed(() => [ const statusOptions = computed(() => [
{ value: '', label: t('admin.proxies.allStatus') }, { value: '', label: t('admin.proxies.allStatus') },
{ value: 'active', label: t('common.active') }, { value: 'active', label: t('admin.accounts.status.active') },
{ value: 'inactive', label: t('common.inactive') } { value: 'inactive', label: t('admin.accounts.status.inactive') }
]) ])
// Form options // Form options
...@@ -647,8 +647,8 @@ const protocolSelectOptions = computed(() => [ ...@@ -647,8 +647,8 @@ const protocolSelectOptions = computed(() => [
]) ])
const editStatusOptions = computed(() => [ const editStatusOptions = computed(() => [
{ value: 'active', label: t('admin.proxies.statuses.active') }, { value: 'active', label: t('admin.accounts.status.active') },
{ value: 'inactive', label: t('admin.proxies.statuses.inactive') } { value: 'inactive', label: t('admin.accounts.status.inactive') }
]) ])
const proxies = ref<Proxy[]>([]) const proxies = ref<Proxy[]>([])
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment