Unverified Commit c4615a12 authored by Wesley Liddick's avatar Wesley Liddick Committed by GitHub
Browse files

Merge pull request #509 from touwaeriol/pr/antigravity-full

feat(antigravity): comprehensive enhancements - model mapping, rate limiting, scheduling & ops
parents 5d4327eb fa28dcbf
...@@ -337,6 +337,22 @@ export interface OpsConcurrencyStatsResponse { ...@@ -337,6 +337,22 @@ export interface OpsConcurrencyStatsResponse {
timestamp?: string timestamp?: string
} }
export interface UserConcurrencyInfo {
user_id: number
user_email: string
username: string
current_in_use: number
max_capacity: number
load_percentage: number
waiting_in_queue: number
}
export interface OpsUserConcurrencyStatsResponse {
enabled: boolean
user: Record<string, UserConcurrencyInfo>
timestamp?: string
}
export async function getConcurrencyStats(platform?: string, groupId?: number | null): Promise<OpsConcurrencyStatsResponse> { export async function getConcurrencyStats(platform?: string, groupId?: number | null): Promise<OpsConcurrencyStatsResponse> {
const params: Record<string, any> = {} const params: Record<string, any> = {}
if (platform) { if (platform) {
...@@ -350,6 +366,11 @@ export async function getConcurrencyStats(platform?: string, groupId?: number | ...@@ -350,6 +366,11 @@ export async function getConcurrencyStats(platform?: string, groupId?: number |
return data return data
} }
export async function getUserConcurrencyStats(): Promise<OpsUserConcurrencyStatsResponse> {
const { data } = await apiClient.get<OpsUserConcurrencyStatsResponse>('/admin/ops/user-concurrency')
return data
}
export interface PlatformAvailability { export interface PlatformAvailability {
platform: string platform: string
total_accounts: number total_accounts: number
...@@ -1171,6 +1192,7 @@ export const opsAPI = { ...@@ -1171,6 +1192,7 @@ export const opsAPI = {
getErrorTrend, getErrorTrend,
getErrorDistribution, getErrorDistribution,
getConcurrencyStats, getConcurrencyStats,
getUserConcurrencyStats,
getAccountAvailabilityStats, getAccountAvailabilityStats,
getRealtimeTrafficSummary, getRealtimeTrafficSummary,
subscribeQPS, subscribeQPS,
......
...@@ -56,6 +56,7 @@ ...@@ -56,6 +56,7 @@
></div> ></div>
</div> </div>
</div> </div>
<!-- Rate Limit Indicator (429) --> <!-- Rate Limit Indicator (429) -->
<div v-if="isRateLimited" class="group relative"> <div v-if="isRateLimited" class="group relative">
<span <span
...@@ -89,6 +90,26 @@ ...@@ -89,6 +90,26 @@
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.status.scopeRateLimitedUntil', { scope: formatScopeName(item.scope), time: formatTime(item.reset_at) }) }} {{ t('admin.accounts.status.scopeRateLimitedUntil', { scope: formatScopeName(item.scope), time: formatTime(item.reset_at) }) }}
<div
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700" ></div>
</div>
</div>
</template>
<!-- Model Rate Limit Indicators (Antigravity OAuth Smart Retry) -->
<template v-if="activeModelRateLimits.length > 0">
<div v-for="item in activeModelRateLimits" :key="item.model" class="group relative">
<span
class="inline-flex items-center gap-1 rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
>
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
{{ formatScopeName(item.model) }}
</span>
<!-- Tooltip -->
<div
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
{{ t('admin.accounts.status.modelRateLimitedUntil', { model: formatScopeName(item.model), time: formatTime(item.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>
...@@ -149,11 +170,28 @@ const activeScopeRateLimits = computed(() => { ...@@ -149,11 +170,28 @@ const activeScopeRateLimits = computed(() => {
.map(([scope, info]) => ({ scope, reset_at: info.reset_at })) .map(([scope, info]) => ({ scope, reset_at: info.reset_at }))
}) })
// Computed: active model rate limits (Antigravity OAuth Smart Retry)
const activeModelRateLimits = computed(() => {
const modelLimits = (props.account.extra as Record<string, unknown> | undefined)?.model_rate_limits as
| Record<string, { rate_limited_at: string; rate_limit_reset_at: string }>
| undefined
if (!modelLimits) return []
const now = new Date()
return Object.entries(modelLimits)
.filter(([, info]) => new Date(info.rate_limit_reset_at) > now)
.map(([model, info]) => ({ model, reset_at: info.rate_limit_reset_at }))
})
const formatScopeName = (scope: string): string => { const formatScopeName = (scope: string): string => {
const names: Record<string, string> = { const names: Record<string, string> = {
claude: 'Claude', claude: 'Claude',
claude_sonnet: 'Claude Sonnet',
claude_opus: 'Claude Opus',
claude_haiku: 'Claude Haiku',
gemini_text: 'Gemini', gemini_text: 'Gemini',
gemini_image: 'Image' gemini_image: 'Image',
gemini_flash: 'Gemini Flash',
gemini_pro: 'Gemini Pro'
} }
return names[scope] || scope return names[scope] || scope
} }
......
...@@ -925,9 +925,23 @@ const buildUpdatePayload = (): Record<string, unknown> | null => { ...@@ -925,9 +925,23 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
if (enableModelRestriction.value) { if (enableModelRestriction.value) {
const modelMapping = buildModelMappingObject() const modelMapping = buildModelMappingObject()
if (modelMapping) {
credentials.model_mapping = modelMapping // 统一使用 model_mapping 字段
credentialsChanged = true if (modelRestrictionMode.value === 'whitelist') {
if (allowedModels.value.length > 0) {
// 白名单模式:将模型转换为 model_mapping 格式(key=value)
const mapping: Record<string, string> = {}
for (const m of allowedModels.value) {
mapping[m] = m
}
credentials.model_mapping = mapping
credentialsChanged = true
}
} else {
if (modelMapping) {
credentials.model_mapping = modelMapping
credentialsChanged = true
}
} }
} }
......
...@@ -698,6 +698,97 @@ ...@@ -698,6 +698,97 @@
</div> </div>
</div> </div>
<!-- Antigravity model restriction (applies to OAuth + Upstream) -->
<!-- Antigravity 只支持模型映射模式,不支持白名单模式 -->
<div v-if="form.platform === 'antigravity'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
<!-- Mapping Mode Only (no toggle for Antigravity) -->
<div>
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
<p class="text-xs text-purple-700 dark:text-purple-400">
{{ t('admin.accounts.mapRequestModels') }}
</p>
</div>
<div v-if="antigravityModelMappings.length > 0" class="mb-3 space-y-2">
<div
v-for="(mapping, index) in antigravityModelMappings"
:key="index"
class="space-y-1"
>
<div class="flex items-center gap-2">
<input
v-model="mapping.from"
type="text"
:class="[
'input flex-1',
!isValidWildcardPattern(mapping.from) ? 'border-red-500 dark:border-red-500' : ''
]"
:placeholder="t('admin.accounts.requestModel')"
/>
<svg class="h-4 w-4 flex-shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
<input
v-model="mapping.to"
type="text"
:class="[
'input flex-1',
mapping.to.includes('*') ? 'border-red-500 dark:border-red-500' : ''
]"
:placeholder="t('admin.accounts.actualModel')"
/>
<button
type="button"
@click="removeAntigravityModelMapping(index)"
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
>
<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 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
<!-- 校验错误提示 -->
<p v-if="!isValidWildcardPattern(mapping.from)" class="text-xs text-red-500">
{{ t('admin.accounts.wildcardOnlyAtEnd') }}
</p>
<p v-if="mapping.to.includes('*')" class="text-xs text-red-500">
{{ t('admin.accounts.targetNoWildcard') }}
</p>
</div>
</div>
<button
type="button"
@click="addAntigravityModelMapping"
class="mb-3 w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 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.addMapping') }}
</button>
<div class="flex flex-wrap gap-2">
<button
v-for="preset in antigravityPresetMappings"
:key="preset.label"
type="button"
@click="addAntigravityPresetMapping(preset.from, preset.to)"
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
>
+ {{ preset.label }}
</button>
</div>
</div>
</div>
<!-- Add Method (only for Anthropic OAuth-based type) --> <!-- Add Method (only for Anthropic OAuth-based type) -->
<div v-if="form.platform === 'anthropic' && isOAuthFlow"> <div v-if="form.platform === 'anthropic' && isOAuthFlow">
<label class="input-label">{{ t('admin.accounts.addMethod') }}</label> <label class="input-label">{{ t('admin.accounts.addMethod') }}</label>
...@@ -1883,7 +1974,15 @@ ...@@ -1883,7 +1974,15 @@
import { ref, reactive, computed, watch } from 'vue' import { ref, reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { claudeModels, getPresetMappingsByPlatform, getModelsByPlatform, commonErrorCodes, buildModelMappingObject } from '@/composables/useModelWhitelist' import {
claudeModels,
getPresetMappingsByPlatform,
getModelsByPlatform,
commonErrorCodes,
buildModelMappingObject,
fetchAntigravityDefaultMappings,
isValidWildcardPattern
} from '@/composables/useModelWhitelist'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import { import {
...@@ -2022,6 +2121,10 @@ const mixedScheduling = ref(false) // For antigravity accounts: enable mixed sch ...@@ -2022,6 +2121,10 @@ const mixedScheduling = ref(false) // For antigravity accounts: enable mixed sch
const antigravityAccountType = ref<'oauth' | 'upstream'>('oauth') // For antigravity: oauth or upstream const antigravityAccountType = ref<'oauth' | 'upstream'>('oauth') // For antigravity: oauth or upstream
const upstreamBaseUrl = ref('') // For upstream type: base URL const upstreamBaseUrl = ref('') // For upstream type: base URL
const upstreamApiKey = ref('') // For upstream type: API key const upstreamApiKey = ref('') // For upstream type: API key
const antigravityModelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
const antigravityWhitelistModels = ref<string[]>([])
const antigravityModelMappings = ref<ModelMapping[]>([])
const antigravityPresetMappings = computed(() => getPresetMappingsByPlatform('antigravity'))
const tempUnschedEnabled = ref(false) const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([]) 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')
...@@ -2164,6 +2267,18 @@ watch( ...@@ -2164,6 +2267,18 @@ watch(
if (newVal) { if (newVal) {
// Modal opened - fill related models // Modal opened - fill related models
allowedModels.value = [...getModelsByPlatform(form.platform)] allowedModels.value = [...getModelsByPlatform(form.platform)]
// Antigravity: 默认使用映射模式并填充默认映射
if (form.platform === 'antigravity') {
antigravityModelRestrictionMode.value = 'mapping'
fetchAntigravityDefaultMappings().then(mappings => {
antigravityModelMappings.value = [...mappings]
})
antigravityWhitelistModels.value = []
} else {
antigravityWhitelistModels.value = []
antigravityModelMappings.value = []
antigravityModelRestrictionMode.value = 'mapping'
}
} else { } else {
resetForm() resetForm()
} }
...@@ -2202,14 +2317,23 @@ watch( ...@@ -2202,14 +2317,23 @@ watch(
// Clear model-related settings // Clear model-related settings
allowedModels.value = [] allowedModels.value = []
modelMappings.value = [] modelMappings.value = []
// Reset Anthropic-specific settings when switching to other platforms // Antigravity: 默认使用映射模式并填充默认映射
if (newPlatform !== 'anthropic') {
interceptWarmupRequests.value = false
}
// Antigravity: reset to OAuth by default, but allow upstream selection
if (newPlatform === 'antigravity') { if (newPlatform === 'antigravity') {
antigravityModelRestrictionMode.value = 'mapping'
fetchAntigravityDefaultMappings().then(mappings => {
antigravityModelMappings.value = [...mappings]
})
antigravityWhitelistModels.value = []
accountCategory.value = 'oauth-based' accountCategory.value = 'oauth-based'
antigravityAccountType.value = 'oauth' antigravityAccountType.value = 'oauth'
} else {
antigravityWhitelistModels.value = []
antigravityModelMappings.value = []
antigravityModelRestrictionMode.value = 'mapping'
}
// Reset Anthropic-specific settings when switching to other platforms
if (newPlatform !== 'anthropic') {
interceptWarmupRequests.value = false
} }
// Reset OAuth states // Reset OAuth states
oauth.resetState() oauth.resetState()
...@@ -2254,6 +2378,15 @@ watch( ...@@ -2254,6 +2378,15 @@ watch(
} }
) )
watch(
[antigravityModelRestrictionMode, () => form.platform],
([, platform]) => {
if (platform !== 'antigravity') return
// Antigravity 默认不做限制:白名单留空表示允许所有(包含未来新增模型)。
// 如果需要快速填充常用模型,可在组件内点“填充相关模型”。
}
)
// Model mapping helpers // Model mapping helpers
const addModelMapping = () => { const addModelMapping = () => {
modelMappings.value.push({ from: '', to: '' }) modelMappings.value.push({ from: '', to: '' })
...@@ -2271,6 +2404,22 @@ const addPresetMapping = (from: string, to: string) => { ...@@ -2271,6 +2404,22 @@ const addPresetMapping = (from: string, to: string) => {
modelMappings.value.push({ from, to }) modelMappings.value.push({ from, to })
} }
const addAntigravityModelMapping = () => {
antigravityModelMappings.value.push({ from: '', to: '' })
}
const removeAntigravityModelMapping = (index: number) => {
antigravityModelMappings.value.splice(index, 1)
}
const addAntigravityPresetMapping = (from: string, to: string) => {
if (antigravityModelMappings.value.some((m) => m.from === from)) {
appStore.showInfo(t('admin.accounts.mappingExists', { model: from }))
return
}
antigravityModelMappings.value.push({ from, to })
}
// Error code toggle helper // Error code toggle helper
const toggleErrorCode = (code: number) => { const toggleErrorCode = (code: number) => {
const index = selectedErrorCodes.value.indexOf(code) const index = selectedErrorCodes.value.indexOf(code)
...@@ -2428,6 +2577,12 @@ const resetForm = () => { ...@@ -2428,6 +2577,12 @@ const resetForm = () => {
modelMappings.value = [] modelMappings.value = []
modelRestrictionMode.value = 'whitelist' modelRestrictionMode.value = 'whitelist'
allowedModels.value = [...claudeModels] // Default fill related models allowedModels.value = [...claudeModels] // Default fill related models
antigravityModelRestrictionMode.value = 'mapping'
antigravityWhitelistModels.value = []
fetchAntigravityDefaultMappings().then(mappings => {
antigravityModelMappings.value = [...mappings]
})
customErrorCodesEnabled.value = false customErrorCodesEnabled.value = false
selectedErrorCodes.value = [] selectedErrorCodes.value = []
customErrorCodeInput.value = null customErrorCodeInput.value = null
...@@ -2541,12 +2696,24 @@ const handleSubmit = async () => { ...@@ -2541,12 +2696,24 @@ const handleSubmit = async () => {
return return
} }
// Build upstream credentials (and optional model restriction)
const credentials: Record<string, unknown> = {
base_url: upstreamBaseUrl.value.trim(),
api_key: upstreamApiKey.value.trim()
}
// Antigravity 只使用映射模式
const antigravityModelMapping = buildModelMappingObject(
'mapping',
[],
antigravityModelMappings.value
)
if (antigravityModelMapping) {
credentials.model_mapping = antigravityModelMapping
}
submitting.value = true submitting.value = true
try { try {
const credentials: Record<string, unknown> = {
base_url: upstreamBaseUrl.value.trim(),
api_key: upstreamApiKey.value.trim()
}
await createAccountAndFinish(form.platform, 'upstream', credentials) await createAccountAndFinish(form.platform, 'upstream', credentials)
} catch (error: any) { } catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToCreate')) appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToCreate'))
...@@ -2752,11 +2919,20 @@ const handleAntigravityExchange = async (authCode: string) => { ...@@ -2752,11 +2919,20 @@ const handleAntigravityExchange = async (authCode: string) => {
state: stateToUse, state: stateToUse,
proxyId: form.proxy_id proxyId: form.proxy_id
}) })
if (!tokenInfo) return if (!tokenInfo) return
const credentials = antigravityOAuth.buildCredentials(tokenInfo) const credentials = antigravityOAuth.buildCredentials(tokenInfo)
const extra = mixedScheduling.value ? { mixed_scheduling: true } : undefined // Antigravity 只使用映射模式
await createAccountAndFinish('antigravity', 'oauth', credentials, extra) const antigravityModelMapping = buildModelMappingObject(
'mapping',
[],
antigravityModelMappings.value
)
if (antigravityModelMapping) {
credentials.model_mapping = antigravityModelMapping
}
const extra = mixedScheduling.value ? { mixed_scheduling: true } : undefined
await createAccountAndFinish('antigravity', 'oauth', credentials, extra)
} catch (error: any) { } catch (error: any) {
antigravityOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed') antigravityOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(antigravityOAuth.error.value) appStore.showError(antigravityOAuth.error.value)
......
...@@ -364,6 +364,96 @@ ...@@ -364,6 +364,96 @@
</div> </div>
</div> </div>
<!-- Antigravity model restriction (applies to all antigravity types) -->
<!-- Antigravity 只支持模型映射模式不支持白名单模式 -->
<div v-if="account.platform === 'antigravity'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
<!-- Mapping Mode Only (no toggle for Antigravity) -->
<div>
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
<p class="text-xs text-purple-700 dark:text-purple-400">{{ t('admin.accounts.mapRequestModels') }}</p>
</div>
<div v-if="antigravityModelMappings.length > 0" class="mb-3 space-y-2">
<div
v-for="(mapping, index) in antigravityModelMappings"
:key="index"
class="space-y-1"
>
<div class="flex items-center gap-2">
<input
v-model="mapping.from"
type="text"
:class="[
'input flex-1',
!isValidWildcardPattern(mapping.from) ? 'border-red-500 dark:border-red-500' : '',
mapping.to.includes('*') ? '' : ''
]"
:placeholder="t('admin.accounts.requestModel')"
/>
<svg class="h-4 w-4 flex-shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
<input
v-model="mapping.to"
type="text"
:class="[
'input flex-1',
mapping.to.includes('*') ? 'border-red-500 dark:border-red-500' : ''
]"
:placeholder="t('admin.accounts.actualModel')"
/>
<button
type="button"
@click="removeAntigravityModelMapping(index)"
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
>
<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 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
<!-- 校验错误提示 -->
<p v-if="!isValidWildcardPattern(mapping.from)" class="text-xs text-red-500">
{{ t('admin.accounts.wildcardOnlyAtEnd') }}
</p>
<p v-if="mapping.to.includes('*')" class="text-xs text-red-500">
{{ t('admin.accounts.targetNoWildcard') }}
</p>
</div>
</div>
<button
type="button"
@click="addAntigravityModelMapping"
class="mb-3 w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 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.addMapping') }}
</button>
<div class="flex flex-wrap gap-2">
<button
v-for="preset in antigravityPresetMappings"
:key="preset.label"
type="button"
@click="addAntigravityPresetMapping(preset.from, preset.to)"
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
>
+ {{ preset.label }}
</button>
</div>
</div>
</div>
<!-- Temp Unschedulable Rules --> <!-- Temp Unschedulable Rules -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
...@@ -907,7 +997,8 @@ import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/forma ...@@ -907,7 +997,8 @@ import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/forma
import { import {
getPresetMappingsByPlatform, getPresetMappingsByPlatform,
commonErrorCodes, commonErrorCodes,
buildModelMappingObject buildModelMappingObject,
isValidWildcardPattern
} from '@/composables/useModelWhitelist' } from '@/composables/useModelWhitelist'
interface Props { interface Props {
...@@ -935,6 +1026,8 @@ const baseUrlHint = computed(() => { ...@@ -935,6 +1026,8 @@ const baseUrlHint = computed(() => {
return t('admin.accounts.baseUrlHint') return t('admin.accounts.baseUrlHint')
}) })
const antigravityPresetMappings = computed(() => getPresetMappingsByPlatform('antigravity'))
// Model mapping type // Model mapping type
interface ModelMapping { interface ModelMapping {
from: string from: string
...@@ -961,6 +1054,9 @@ const customErrorCodeInput = ref<number | null>(null) ...@@ -961,6 +1054,9 @@ const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false) const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(false) const autoPauseOnExpired = ref(false)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
const antigravityModelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
const antigravityWhitelistModels = ref<string[]>([])
const antigravityModelMappings = ref<ModelMapping[]>([])
const tempUnschedEnabled = ref(false) const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([]) const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
...@@ -1066,6 +1162,38 @@ watch( ...@@ -1066,6 +1162,38 @@ 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
// Load antigravity model mapping (Antigravity 只支持映射模式)
if (newAccount.platform === 'antigravity') {
const credentials = newAccount.credentials as Record<string, unknown> | undefined
// Antigravity 始终使用映射模式
antigravityModelRestrictionMode.value = 'mapping'
antigravityWhitelistModels.value = []
// 从 model_mapping 读取映射配置
const rawAgMapping = credentials?.model_mapping as Record<string, string> | undefined
if (rawAgMapping && typeof rawAgMapping === 'object') {
const entries = Object.entries(rawAgMapping)
// 无论是白名单样式(key===value)还是真正的映射,都统一转换为映射列表
antigravityModelMappings.value = entries.map(([from, to]) => ({ from, to }))
} else {
// 兼容旧数据:从 model_whitelist 读取,转换为映射格式
const rawWhitelist = credentials?.model_whitelist
if (Array.isArray(rawWhitelist) && rawWhitelist.length > 0) {
antigravityModelMappings.value = rawWhitelist
.map((v) => String(v).trim())
.filter((v) => v.length > 0)
.map((m) => ({ from: m, to: m }))
} else {
antigravityModelMappings.value = []
}
}
} else {
antigravityModelRestrictionMode.value = 'mapping'
antigravityWhitelistModels.value = []
antigravityModelMappings.value = []
}
// Load quota control settings (Anthropic OAuth/SetupToken only) // Load quota control settings (Anthropic OAuth/SetupToken only)
loadQuotaControlSettings(newAccount) loadQuotaControlSettings(newAccount)
...@@ -1154,6 +1282,23 @@ const addPresetMapping = (from: string, to: string) => { ...@@ -1154,6 +1282,23 @@ const addPresetMapping = (from: string, to: string) => {
modelMappings.value.push({ from, to }) modelMappings.value.push({ from, to })
} }
const addAntigravityModelMapping = () => {
antigravityModelMappings.value.push({ from: '', to: '' })
}
const removeAntigravityModelMapping = (index: number) => {
antigravityModelMappings.value.splice(index, 1)
}
const addAntigravityPresetMapping = (from: string, to: string) => {
const exists = antigravityModelMappings.value.some((m) => m.from === from)
if (exists) {
appStore.showInfo(t('admin.accounts.mappingExists', { model: from }))
return
}
antigravityModelMappings.value.push({ from, to })
}
// Error code toggle helper // Error code toggle helper
const toggleErrorCode = (code: number) => { const toggleErrorCode = (code: number) => {
const index = selectedErrorCodes.value.indexOf(code) const index = selectedErrorCodes.value.indexOf(code)
...@@ -1458,6 +1603,30 @@ const handleSubmit = async () => { ...@@ -1458,6 +1603,30 @@ const handleSubmit = async () => {
updatePayload.credentials = newCredentials updatePayload.credentials = newCredentials
} }
// Antigravity: persist model mapping to credentials (applies to all antigravity types)
// Antigravity 只支持映射模式
if (props.account.platform === 'antigravity') {
const currentCredentials = (updatePayload.credentials as Record<string, unknown>) ||
((props.account.credentials as Record<string, unknown>) || {})
const newCredentials: Record<string, unknown> = { ...currentCredentials }
// 移除旧字段
delete newCredentials.model_whitelist
delete newCredentials.model_mapping
// 只使用映射模式
const antigravityModelMapping = buildModelMappingObject(
'mapping',
[],
antigravityModelMappings.value
)
if (antigravityModelMapping) {
newCredentials.model_mapping = antigravityModelMapping
}
updatePayload.credentials = newCredentials
}
// For antigravity accounts, handle mixed_scheduling in extra // For antigravity accounts, handle mixed_scheduling in extra
if (props.account.platform === 'antigravity') { if (props.account.platform === 'antigravity') {
const currentExtra = (props.account.extra as Record<string, unknown>) || {} const currentExtra = (props.account.extra as Record<string, unknown>) || {}
......
...@@ -24,8 +24,6 @@ const openaiModels = [ ...@@ -24,8 +24,6 @@ const openaiModels = [
// GPT-5.2 系列 // GPT-5.2 系列
'gpt-5.2', 'gpt-5.2-2025-12-11', 'gpt-5.2-chat-latest', 'gpt-5.2', 'gpt-5.2-2025-12-11', 'gpt-5.2-chat-latest',
'gpt-5.2-codex', 'gpt-5.2-pro', 'gpt-5.2-pro-2025-12-11', 'gpt-5.2-codex', 'gpt-5.2-pro', 'gpt-5.2-pro-2025-12-11',
// GPT-5.3 系列
'gpt-5.3-codex', 'gpt-5.3',
'chatgpt-4o-latest', 'chatgpt-4o-latest',
'gpt-4o-audio-preview', 'gpt-4o-realtime-preview' 'gpt-4o-audio-preview', 'gpt-4o-realtime-preview'
] ]
...@@ -55,6 +53,29 @@ const geminiModels = [ ...@@ -55,6 +53,29 @@ const geminiModels = [
'gemini-3-pro-preview' 'gemini-3-pro-preview'
] ]
// Antigravity 官方支持的模型(精确匹配)
// 基于官方 API 返回的模型列表,只支持 Claude 4.5+ 和 Gemini 2.5+
const antigravityModels = [
// Claude 4.5+ 系列
'claude-opus-4-6',
'claude-opus-4-5-thinking',
'claude-sonnet-4-5',
'claude-sonnet-4-5-thinking',
// Gemini 2.5 系列
'gemini-2.5-flash',
'gemini-2.5-flash-lite',
'gemini-2.5-flash-thinking',
'gemini-2.5-pro',
// Gemini 3 系列
'gemini-3-flash',
'gemini-3-pro-high',
'gemini-3-pro-low',
'gemini-3-pro-image',
// 其他
'gpt-oss-120b-medium',
'tab_flash_lite_preview'
]
// 智谱 GLM // 智谱 GLM
const zhipuModels = [ const zhipuModels = [
'glm-4', 'glm-4v', 'glm-4-plus', 'glm-4-0520', 'glm-4', 'glm-4v', 'glm-4-plus', 'glm-4-0520',
...@@ -237,6 +258,41 @@ const geminiPresetMappings = [ ...@@ -237,6 +258,41 @@ const geminiPresetMappings = [
{ label: '2.5 Pro', from: 'gemini-2.5-pro', to: 'gemini-2.5-pro', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' } { label: '2.5 Pro', from: 'gemini-2.5-pro', to: 'gemini-2.5-pro', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }
] ]
// Antigravity 预设映射(支持通配符)
const antigravityPresetMappings = [
// Claude 通配符映射
{ label: 'Claude→Sonnet', from: 'claude-*', to: 'claude-sonnet-4-5', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
{ label: 'Sonnet→Sonnet', from: 'claude-sonnet-*', to: 'claude-sonnet-4-5', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: 'Opus→Opus', from: 'claude-opus-*', to: 'claude-opus-4-5-thinking', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
{ label: 'Haiku→Sonnet', from: 'claude-haiku-*', to: 'claude-sonnet-4-5', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
// Gemini 通配符映射
{ label: 'Gemini 3→Flash', from: 'gemini-3*', to: 'gemini-3-flash', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' },
{ label: 'Gemini 2.5→Flash', from: 'gemini-2.5*', to: 'gemini-2.5-flash', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
// 精确映射
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5', to: 'claude-sonnet-4-5', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
{ label: 'Opus 4.5', from: 'claude-opus-4-5-thinking', to: 'claude-opus-4-5-thinking', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' }
]
// Antigravity 默认映射(从后端 API 获取,与 constants.go 保持一致)
// 使用 fetchAntigravityDefaultMappings() 异步获取
import { getAntigravityDefaultModelMapping } from '@/api/admin/accounts'
let _antigravityDefaultMappingsCache: { from: string; to: string }[] | null = null
export async function fetchAntigravityDefaultMappings(): Promise<{ from: string; to: string }[]> {
if (_antigravityDefaultMappingsCache !== null) {
return _antigravityDefaultMappingsCache
}
try {
const mapping = await getAntigravityDefaultModelMapping()
_antigravityDefaultMappingsCache = Object.entries(mapping).map(([from, to]) => ({ from, to }))
} catch (e) {
console.warn('[fetchAntigravityDefaultMappings] API failed, using empty fallback', e)
_antigravityDefaultMappingsCache = []
}
return _antigravityDefaultMappingsCache
}
// ===================== // =====================
// 常用错误码 // 常用错误码
// ===================== // =====================
...@@ -262,6 +318,7 @@ export function getModelsByPlatform(platform: string): string[] { ...@@ -262,6 +318,7 @@ export function getModelsByPlatform(platform: string): string[] {
case 'anthropic': case 'anthropic':
case 'claude': return claudeModels case 'claude': return claudeModels
case 'gemini': return geminiModels case 'gemini': return geminiModels
case 'antigravity': return antigravityModels
case 'zhipu': return zhipuModels case 'zhipu': return zhipuModels
case 'qwen': return qwenModels case 'qwen': return qwenModels
case 'deepseek': return deepseekModels case 'deepseek': return deepseekModels
...@@ -285,6 +342,7 @@ export function getModelsByPlatform(platform: string): string[] { ...@@ -285,6 +342,7 @@ export function getModelsByPlatform(platform: string): string[] {
export function getPresetMappingsByPlatform(platform: string) { export function getPresetMappingsByPlatform(platform: string) {
if (platform === 'openai') return openaiPresetMappings if (platform === 'openai') return openaiPresetMappings
if (platform === 'gemini') return geminiPresetMappings if (platform === 'gemini') return geminiPresetMappings
if (platform === 'antigravity') return antigravityPresetMappings
return anthropicPresetMappings return anthropicPresetMappings
} }
...@@ -292,6 +350,15 @@ export function getPresetMappingsByPlatform(platform: string) { ...@@ -292,6 +350,15 @@ export function getPresetMappingsByPlatform(platform: string) {
// 构建模型映射对象(用于 API) // 构建模型映射对象(用于 API)
// ===================== // =====================
// isValidWildcardPattern 校验通配符格式:* 只能放在末尾
// 导出供表单组件使用实时校验
export function isValidWildcardPattern(pattern: string): boolean {
const starIndex = pattern.indexOf('*')
if (starIndex === -1) return true // 无通配符,有效
// * 必须在末尾,且只能有一个
return starIndex === pattern.length - 1 && pattern.lastIndexOf('*') === starIndex
}
export function buildModelMappingObject( export function buildModelMappingObject(
mode: 'whitelist' | 'mapping', mode: 'whitelist' | 'mapping',
allowedModels: string[], allowedModels: string[],
...@@ -301,13 +368,29 @@ export function buildModelMappingObject( ...@@ -301,13 +368,29 @@ export function buildModelMappingObject(
if (mode === 'whitelist') { if (mode === 'whitelist') {
for (const model of allowedModels) { for (const model of allowedModels) {
mapping[model] = model // whitelist 模式的本意是"精确模型列表",如果用户输入了通配符(如 claude-*),
// 写入 model_mapping 会导致 GetMappedModel() 把真实模型映射成 "claude-*",从而转发失败。
// 因此这里跳过包含通配符的条目。
if (!model.includes('*')) {
mapping[model] = model
}
} }
} else { } else {
for (const m of modelMappings) { for (const m of modelMappings) {
const from = m.from.trim() const from = m.from.trim()
const to = m.to.trim() const to = m.to.trim()
if (from && to) mapping[from] = to if (!from || !to) continue
// 校验通配符格式:* 只能放在末尾
if (!isValidWildcardPattern(from)) {
console.warn(`[buildModelMappingObject] 无效的通配符格式,跳过: ${from}`)
continue
}
// to 不允许包含通配符
if (to.includes('*')) {
console.warn(`[buildModelMappingObject] 目标模型不能包含通配符,跳过: ${from} -> ${to}`)
continue
}
mapping[from] = to
} }
} }
......
...@@ -10,25 +10,88 @@ export default { ...@@ -10,25 +10,88 @@ export default {
login: 'Login', login: 'Login',
getStarted: 'Get Started', getStarted: 'Get Started',
goToDashboard: 'Go to Dashboard', goToDashboard: 'Go to Dashboard',
// User-focused value proposition
heroSubtitle: 'One Key, All AI Models',
heroDescription: 'No need to manage multiple subscriptions. Access Claude, GPT, Gemini and more with a single API key',
tags: { tags: {
subscriptionToApi: 'Subscription to API', subscriptionToApi: 'Subscription to API',
stickySession: 'Sticky Session', stickySession: 'Session Persistence',
realtimeBilling: 'Real-time Billing' realtimeBilling: 'Pay As You Go'
},
// Pain points section
painPoints: {
title: 'Sound Familiar?',
items: {
expensive: {
title: 'High Subscription Costs',
desc: 'Paying for multiple AI subscriptions that add up every month'
},
complex: {
title: 'Account Chaos',
desc: 'Managing scattered accounts and API keys across different platforms'
},
unstable: {
title: 'Service Interruptions',
desc: 'Single accounts hitting rate limits and disrupting your workflow'
},
noControl: {
title: 'No Usage Control',
desc: "Can't track where your money goes or limit team member usage"
}
}
},
// Solutions section
solutions: {
title: 'We Solve These Problems',
subtitle: 'Three simple steps to stress-free AI access'
}, },
features: { features: {
unifiedGateway: 'Unified API Gateway', unifiedGateway: 'One-Click Access',
unifiedGatewayDesc: unifiedGatewayDesc: 'Get a single API key to call all connected AI models. No separate applications needed.',
'Convert Claude subscriptions to API endpoints. Access AI capabilities through standard /v1/messages interface.', multiAccount: 'Always Reliable',
multiAccount: 'Multi-Account Pool', multiAccountDesc: 'Smart routing across multiple upstream accounts with automatic failover. Say goodbye to errors.',
multiAccountDesc: balanceQuota: 'Pay What You Use',
'Manage multiple upstream accounts with smart load balancing. Support OAuth and API Key authentication.', balanceQuotaDesc: 'Usage-based billing with quota limits. Full visibility into team consumption.'
balanceQuota: 'Balance & Quota', },
balanceQuotaDesc: // Comparison section
'Token-based billing with precise usage tracking. Manage quotas and recharge with redeem codes.' comparison: {
title: 'Why Choose Us?',
headers: {
feature: 'Comparison',
official: 'Official Subscriptions',
us: 'Our Platform'
},
items: {
pricing: {
feature: 'Pricing',
official: 'Fixed monthly fee, pay even if unused',
us: 'Pay only for what you use'
},
models: {
feature: 'Model Selection',
official: 'Single provider only',
us: 'Switch between models freely'
},
management: {
feature: 'Account Management',
official: 'Manage each service separately',
us: 'Unified key, one dashboard'
},
stability: {
feature: 'Stability',
official: 'Single account rate limits',
us: 'Multi-account pool, auto-failover'
},
control: {
feature: 'Usage Control',
official: 'Not available',
us: 'Quotas & detailed analytics'
}
}
}, },
providers: { providers: {
title: 'Supported Providers', title: 'Supported AI Models',
description: 'Unified API interface for AI services', description: 'One API, Multiple Choices',
supported: 'Supported', supported: 'Supported',
soon: 'Soon', soon: 'Soon',
claude: 'Claude', claude: 'Claude',
...@@ -36,6 +99,12 @@ export default { ...@@ -36,6 +99,12 @@ export default {
antigravity: 'Antigravity', antigravity: 'Antigravity',
more: 'More' more: 'More'
}, },
// CTA section
cta: {
title: 'Ready to Get Started?',
description: 'Sign up now and get free trial credits to experience seamless AI access',
button: 'Sign Up Free'
},
footer: { footer: {
allRightsReserved: 'All rights reserved.' allRightsReserved: 'All rights reserved.'
} }
...@@ -1288,6 +1357,7 @@ export default { ...@@ -1288,6 +1357,7 @@ export default {
tempUnschedulable: 'Temp Unschedulable', tempUnschedulable: 'Temp Unschedulable',
rateLimitedUntil: 'Rate limited until {time}', rateLimitedUntil: 'Rate limited until {time}',
scopeRateLimitedUntil: '{scope} rate limited until {time}', scopeRateLimitedUntil: '{scope} rate limited until {time}',
modelRateLimitedUntil: '{model} rate limited until {time}',
overloadedUntil: 'Overloaded until {time}', overloadedUntil: 'Overloaded until {time}',
viewTempUnschedDetails: 'View temp unschedulable details' viewTempUnschedDetails: 'View temp unschedulable details'
}, },
...@@ -1447,6 +1517,8 @@ export default { ...@@ -1447,6 +1517,8 @@ export default {
actualModel: 'Actual model', actualModel: 'Actual model',
addMapping: 'Add Mapping', addMapping: 'Add Mapping',
mappingExists: 'Mapping for {model} already exists', mappingExists: 'Mapping for {model} already exists',
wildcardOnlyAtEnd: 'Wildcard * can only be at the end',
targetNoWildcard: 'Target model cannot contain wildcard *',
searchModels: 'Search models...', searchModels: 'Search models...',
noMatchingModels: 'No matching models', noMatchingModels: 'No matching models',
fillRelatedModels: 'Fill related models', fillRelatedModels: 'Fill related models',
...@@ -1913,8 +1985,6 @@ export default { ...@@ -1913,8 +1985,6 @@ export default {
editProxy: 'Edit Proxy', editProxy: 'Edit Proxy',
deleteProxy: 'Delete Proxy', deleteProxy: 'Delete Proxy',
dataImport: 'Import', dataImport: 'Import',
dataImportShort: 'Import',
dataExportShort: 'Export',
dataExportSelected: 'Export Selected', dataExportSelected: 'Export Selected',
dataImportTitle: 'Import Proxies', dataImportTitle: 'Import Proxies',
dataImportHint: 'Upload the exported proxy JSON file to import proxies in bulk.', dataImportHint: 'Upload the exported proxy JSON file to import proxies in bulk.',
...@@ -2970,6 +3040,10 @@ export default { ...@@ -2970,6 +3040,10 @@ export default {
byPlatform: 'By Platform', byPlatform: 'By Platform',
byGroup: 'By Group', byGroup: 'By Group',
byAccount: 'By Account', byAccount: 'By Account',
byUser: 'By User',
showByUserTooltip: 'Switch to user view to see concurrency usage per user',
switchToUser: 'Switch to user view',
switchToPlatform: 'Switch to platform view',
totalRows: '{count} rows', totalRows: '{count} rows',
disabledHint: 'Realtime monitoring is disabled in settings.', disabledHint: 'Realtime monitoring is disabled in settings.',
empty: 'No data', empty: 'No data',
......
...@@ -8,24 +8,90 @@ export default { ...@@ -8,24 +8,90 @@ export default {
switchToDark: '切换到深色模式', switchToDark: '切换到深色模式',
dashboard: '控制台', dashboard: '控制台',
login: '登录', login: '登录',
getStarted: '开始使用', getStarted: '立即开始',
goToDashboard: '进入控制台', goToDashboard: '进入控制台',
// 新增:面向用户的价值主张
heroSubtitle: '一个密钥,畅用多个 AI 模型',
heroDescription: '无需管理多个订阅账号,一站式接入 Claude、GPT、Gemini 等主流 AI 服务',
tags: { tags: {
subscriptionToApi: '订阅转 API', subscriptionToApi: '订阅转 API',
stickySession: '粘性会话', stickySession: '会话保持',
realtimeBilling: '实时计费' realtimeBilling: '按量计费'
},
// 用户痛点区块
painPoints: {
title: '你是否也遇到这些问题?',
items: {
expensive: {
title: '订阅费用高',
desc: '每个 AI 服务都要单独订阅,每月支出越来越多'
},
complex: {
title: '多账号难管理',
desc: '不同平台的账号、密钥分散各处,管理起来很麻烦'
},
unstable: {
title: '服务不稳定',
desc: '单一账号容易触发限制,影响正常使用'
},
noControl: {
title: '用量无法控制',
desc: '不知道钱花在哪了,也无法限制团队成员的使用'
}
}
},
// 解决方案区块
solutions: {
title: '我们帮你解决',
subtitle: '简单三步,开始省心使用 AI'
}, },
features: { features: {
unifiedGateway: '统一 API 网关', unifiedGateway: '一键接入',
unifiedGatewayDesc: '将 Claude 订阅转换为 API 接口,通过标准 /v1/messages 接口访问 AI 能力。', unifiedGatewayDesc: '获取一个 API 密钥,即可调用所有已接入的 AI 模型,无需分别申请。',
multiAccount: '多账号池', multiAccount: '稳定可靠',
multiAccountDesc: '智能负载均衡管理多个上游账号,支持 OAuth 和 API Key 认证。', multiAccountDesc: '智能调度多个上游账号,自动切换和负载均衡,告别频繁报错。',
balanceQuota: '余额与配额', balanceQuota: '用多少付多少',
balanceQuotaDesc: '基于 Token 的精确计费和用量追踪,支持配额管理和兑换码充值。' balanceQuotaDesc: '按实际使用量计费,支持设置配额上限,团队用量一目了然。'
},
// 优势对比
comparison: {
title: '为什么选择我们?',
headers: {
feature: '对比项',
official: '官方订阅',
us: '本平台'
},
items: {
pricing: {
feature: '付费方式',
official: '固定月费,用不完也付',
us: '按量付费,用多少付多少'
},
models: {
feature: '模型选择',
official: '单一服务商',
us: '多模型随意切换'
},
management: {
feature: '账号管理',
official: '每个服务单独管理',
us: '统一密钥,一站管理'
},
stability: {
feature: '服务稳定性',
official: '单账号易触发限制',
us: '多账号池,自动切换'
},
control: {
feature: '用量控制',
official: '无法限制',
us: '可设配额、查明细'
}
}
}, },
providers: { providers: {
title: '支持的服务商', title: '支持的 AI 模型',
description: 'AI 服务的统一 API 接口', description: '一个 API,多种选择',
supported: '已支持', supported: '已支持',
soon: '即将推出', soon: '即将推出',
claude: 'Claude', claude: 'Claude',
...@@ -33,6 +99,12 @@ export default { ...@@ -33,6 +99,12 @@ export default {
antigravity: 'Antigravity', antigravity: 'Antigravity',
more: '更多' more: '更多'
}, },
// CTA 区块
cta: {
title: '准备好开始了吗?',
description: '注册即可获得免费试用额度,体验一站式 AI 服务',
button: '免费注册'
},
footer: { footer: {
allRightsReserved: '保留所有权利。' allRightsReserved: '保留所有权利。'
} }
...@@ -1421,6 +1493,7 @@ export default { ...@@ -1421,6 +1493,7 @@ export default {
tempUnschedulable: '临时不可调度', tempUnschedulable: '临时不可调度',
rateLimitedUntil: '限流中,重置时间:{time}', rateLimitedUntil: '限流中,重置时间:{time}',
scopeRateLimitedUntil: '{scope} 限流中,重置时间:{time}', scopeRateLimitedUntil: '{scope} 限流中,重置时间:{time}',
modelRateLimitedUntil: '{model} 限流至 {time}',
overloadedUntil: '负载过重,重置时间:{time}', overloadedUntil: '负载过重,重置时间:{time}',
viewTempUnschedDetails: '查看临时不可调度详情' viewTempUnschedDetails: '查看临时不可调度详情'
}, },
...@@ -1592,6 +1665,8 @@ export default { ...@@ -1592,6 +1665,8 @@ export default {
actualModel: '实际模型', actualModel: '实际模型',
addMapping: '添加映射', addMapping: '添加映射',
mappingExists: '模型 {model} 的映射已存在', mappingExists: '模型 {model} 的映射已存在',
wildcardOnlyAtEnd: '通配符 * 只能放在末尾',
targetNoWildcard: '目标模型不能包含通配符 *',
searchModels: '搜索模型...', searchModels: '搜索模型...',
noMatchingModels: '没有匹配的模型', noMatchingModels: '没有匹配的模型',
fillRelatedModels: '填入相关模型', fillRelatedModels: '填入相关模型',
...@@ -2022,8 +2097,6 @@ export default { ...@@ -2022,8 +2097,6 @@ export default {
deleteConfirmMessage: "确定要删除代理 '{name}' 吗?", deleteConfirmMessage: "确定要删除代理 '{name}' 吗?",
testProxy: '测试代理', testProxy: '测试代理',
dataImport: '导入', dataImport: '导入',
dataImportShort: '导入',
dataExportShort: '导出',
dataExportSelected: '导出选中', dataExportSelected: '导出选中',
dataImportTitle: '导入代理', dataImportTitle: '导入代理',
dataImportHint: '上传代理导出的 JSON 文件以批量导入代理。', dataImportHint: '上传代理导出的 JSON 文件以批量导入代理。',
...@@ -3140,6 +3213,10 @@ export default { ...@@ -3140,6 +3213,10 @@ export default {
byPlatform: '按平台', byPlatform: '按平台',
byGroup: '按分组', byGroup: '按分组',
byAccount: '按账号', byAccount: '按账号',
byUser: '按用户',
showByUserTooltip: '切换用户视图,显示每个用户的并发使用情况',
switchToUser: '切换到用户视图',
switchToPlatform: '切换回平台视图',
totalRows: '共 {count} 项', totalRows: '共 {count} 项',
disabledHint: '已在设置中关闭实时监控。', disabledHint: '已在设置中关闭实时监控。',
empty: '暂无数据', empty: '暂无数据',
......
...@@ -561,7 +561,10 @@ export interface Account { ...@@ -561,7 +561,10 @@ export interface Account {
platform: AccountPlatform platform: AccountPlatform
type: AccountType type: AccountType
credentials?: Record<string, unknown> credentials?: Record<string, unknown>
extra?: CodexUsageSnapshot & Record<string, unknown> // Extra fields including Codex usage // Extra fields including Codex usage and model-level rate limits (Antigravity smart retry)
extra?: (CodexUsageSnapshot & {
model_rate_limits?: Record<string, { rate_limited_at: string; rate_limit_reset_at: string }>
} & Record<string, unknown>)
proxy_id: number | null proxy_id: number | null
concurrency: number concurrency: number
current_concurrency?: number // Real-time concurrency count from Redis current_concurrency?: number // Real-time concurrency count from Redis
......
...@@ -2,10 +2,50 @@ ...@@ -2,10 +2,50 @@
<AppLayout> <AppLayout>
<TablePageLayout> <TablePageLayout>
<template #filters> <template #filters>
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <div class="space-y-3">
<!-- Search + Filters --> <!-- Row 1: Actions -->
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-3">
<div class="relative min-w-0 flex-1 lg:w-48 lg:flex-none"> <button
@click="loadProxies"
:disabled="loading"
class="btn btn-secondary"
:title="t('common.refresh')"
>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
<button
@click="handleBatchTest"
:disabled="batchTesting || loading"
class="btn btn-secondary"
:title="t('admin.proxies.testConnection')"
>
<Icon name="play" size="md" class="mr-2" />
{{ t('admin.proxies.testConnection') }}
</button>
<button
@click="openBatchDelete"
:disabled="selectedCount === 0"
class="btn btn-danger"
:title="t('admin.proxies.batchDeleteAction')"
>
<Icon name="trash" size="md" class="mr-2" />
{{ t('admin.proxies.batchDeleteAction') }}
</button>
<button @click="showImportData = true" class="btn btn-secondary">
{{ t('admin.proxies.dataImport') }}
</button>
<button @click="showExportDataDialog = true" class="btn btn-secondary">
{{ selectedCount > 0 ? t('admin.proxies.dataExportSelected') : t('admin.proxies.dataExport') }}
</button>
<button @click="showCreateModal = true" class="btn btn-primary">
<Icon name="plus" size="md" class="mr-2" />
{{ t('admin.proxies.createProxy') }}
</button>
</div>
<!-- Row 2: Search + Filters -->
<div class="flex flex-wrap items-center gap-3">
<div class="relative w-full sm:w-64">
<Icon <Icon
name="search" name="search"
size="md" size="md"
...@@ -20,7 +60,7 @@ ...@@ -20,7 +60,7 @@
/> />
</div> </div>
<div class="w-28 lg:w-36"> <div class="w-full sm:w-40">
<Select <Select
v-model="filters.protocol" v-model="filters.protocol"
:options="protocolOptions" :options="protocolOptions"
...@@ -28,7 +68,7 @@ ...@@ -28,7 +68,7 @@
@change="loadProxies" @change="loadProxies"
/> />
</div> </div>
<div class="w-24 lg:w-32"> <div class="w-full sm:w-36">
<Select <Select
v-model="filters.status" v-model="filters.status"
:options="statusOptions" :options="statusOptions"
...@@ -37,48 +77,6 @@ ...@@ -37,48 +77,6 @@
/> />
</div> </div>
</div> </div>
<!-- Actions -->
<div class="flex flex-wrap items-center gap-2">
<button
@click="loadProxies"
:disabled="loading"
class="btn btn-secondary btn-sm lg:btn-md"
:title="t('common.refresh')"
>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
<button
@click="handleBatchTest"
:disabled="batchTesting || loading"
class="btn btn-secondary btn-sm lg:btn-md"
:title="t('admin.proxies.testConnection')"
>
<Icon name="play" size="md" class="lg:mr-2" />
<span class="hidden lg:inline">{{ t('admin.proxies.testConnection') }}</span>
</button>
<button
@click="openBatchDelete"
:disabled="selectedCount === 0"
class="btn btn-danger btn-sm lg:btn-md"
:title="t('admin.proxies.batchDeleteAction')"
>
<Icon name="trash" size="md" class="lg:mr-2" />
<span class="hidden lg:inline">{{ t('admin.proxies.batchDeleteAction') }}</span>
</button>
<button @click="showImportData = true" class="btn btn-secondary btn-sm lg:btn-md">
<span class="lg:hidden">{{ t('admin.proxies.dataImportShort') }}</span>
<span class="hidden lg:inline">{{ t('admin.proxies.dataImport') }}</span>
</button>
<button @click="showExportDataDialog = true" class="btn btn-secondary btn-sm lg:btn-md">
<span class="lg:hidden">{{ t('admin.proxies.dataExportShort') }}</span>
<span class="hidden lg:inline">{{ selectedCount > 0 ? t('admin.proxies.dataExportSelected') : t('admin.proxies.dataExport') }}</span>
</button>
<button @click="showCreateModal = true" class="btn btn-primary btn-sm lg:btn-md">
<Icon name="plus" size="md" class="lg:mr-2" />
<span class="hidden lg:inline">{{ t('admin.proxies.createProxy') }}</span>
</button>
</div>
</div> </div>
</template> </template>
......
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { opsAPI, type OpsAccountAvailabilityStatsResponse, type OpsConcurrencyStatsResponse } from '@/api/admin/ops' import { opsAPI, type OpsAccountAvailabilityStatsResponse, type OpsConcurrencyStatsResponse, type OpsUserConcurrencyStatsResponse } from '@/api/admin/ops'
interface Props { interface Props {
platformFilter?: string platformFilter?: string
...@@ -20,6 +20,10 @@ const loading = ref(false) ...@@ -20,6 +20,10 @@ const loading = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const concurrency = ref<OpsConcurrencyStatsResponse | null>(null) const concurrency = ref<OpsConcurrencyStatsResponse | null>(null)
const availability = ref<OpsAccountAvailabilityStatsResponse | null>(null) const availability = ref<OpsAccountAvailabilityStatsResponse | null>(null)
const userConcurrency = ref<OpsUserConcurrencyStatsResponse | null>(null)
// 用户视图开关
const showByUser = ref(false)
const realtimeEnabled = computed(() => { const realtimeEnabled = computed(() => {
return (concurrency.value?.enabled ?? true) && (availability.value?.enabled ?? true) return (concurrency.value?.enabled ?? true) && (availability.value?.enabled ?? true)
...@@ -30,7 +34,10 @@ function safeNumber(n: unknown): number { ...@@ -30,7 +34,10 @@ function safeNumber(n: unknown): number {
} }
// 计算显示维度 // 计算显示维度
const displayDimension = computed<'platform' | 'group' | 'account'>(() => { const displayDimension = computed<'platform' | 'group' | 'account' | 'user'>(() => {
if (showByUser.value) {
return 'user'
}
if (typeof props.groupIdFilter === 'number' && props.groupIdFilter > 0) { if (typeof props.groupIdFilter === 'number' && props.groupIdFilter > 0) {
return 'account' return 'account'
} }
...@@ -81,6 +88,18 @@ interface AccountRow { ...@@ -81,6 +88,18 @@ interface AccountRow {
error_message?: string error_message?: string
} }
// 用户行数据
interface UserRow {
key: string
user_id: number
user_email: string
username: string
current_in_use: number
max_capacity: number
waiting_in_queue: number
load_percentage: number
}
// 平台维度汇总 // 平台维度汇总
const platformRows = computed((): SummaryRow[] => { const platformRows = computed((): SummaryRow[] => {
const concStats = concurrency.value?.platform || {} const concStats = concurrency.value?.platform || {}
...@@ -205,14 +224,37 @@ const accountRows = computed((): AccountRow[] => { ...@@ -205,14 +224,37 @@ const accountRows = computed((): AccountRow[] => {
}) })
}) })
// 用户维度详细
const userRows = computed((): UserRow[] => {
const userStats = userConcurrency.value?.user || {}
return Object.keys(userStats)
.map(uid => {
const u = userStats[uid] || {}
return {
key: uid,
user_id: safeNumber(u.user_id),
user_email: u.user_email || `User ${uid}`,
username: u.username || '',
current_in_use: safeNumber(u.current_in_use),
max_capacity: safeNumber(u.max_capacity),
waiting_in_queue: safeNumber(u.waiting_in_queue),
load_percentage: safeNumber(u.load_percentage)
}
})
.sort((a, b) => b.current_in_use - a.current_in_use || b.load_percentage - a.load_percentage)
})
// 根据维度选择数据 // 根据维度选择数据
const displayRows = computed(() => { const displayRows = computed(() => {
if (displayDimension.value === 'user') return userRows.value
if (displayDimension.value === 'account') return accountRows.value if (displayDimension.value === 'account') return accountRows.value
if (displayDimension.value === 'group') return groupRows.value if (displayDimension.value === 'group') return groupRows.value
return platformRows.value return platformRows.value
}) })
const displayTitle = computed(() => { const displayTitle = computed(() => {
if (displayDimension.value === 'user') return t('admin.ops.concurrency.byUser')
if (displayDimension.value === 'account') return t('admin.ops.concurrency.byAccount') if (displayDimension.value === 'account') return t('admin.ops.concurrency.byAccount')
if (displayDimension.value === 'group') return t('admin.ops.concurrency.byGroup') if (displayDimension.value === 'group') return t('admin.ops.concurrency.byGroup')
return t('admin.ops.concurrency.byPlatform') return t('admin.ops.concurrency.byPlatform')
...@@ -222,12 +264,19 @@ async function loadData() { ...@@ -222,12 +264,19 @@ async function loadData() {
loading.value = true loading.value = true
errorMessage.value = '' errorMessage.value = ''
try { try {
const [concData, availData] = await Promise.all([ if (showByUser.value) {
opsAPI.getConcurrencyStats(props.platformFilter, props.groupIdFilter), // 用户视图模式只加载用户并发数据
opsAPI.getAccountAvailabilityStats(props.platformFilter, props.groupIdFilter) const userData = await opsAPI.getUserConcurrencyStats()
]) userConcurrency.value = userData
concurrency.value = concData } else {
availability.value = availData // 常规模式加载账号/平台/分组数据
const [concData, availData] = await Promise.all([
opsAPI.getConcurrencyStats(props.platformFilter, props.groupIdFilter),
opsAPI.getAccountAvailabilityStats(props.platformFilter, props.groupIdFilter)
])
concurrency.value = concData
availability.value = availData
}
} catch (err: any) { } catch (err: any) {
console.error('[OpsConcurrencyCard] Failed to load data', err) console.error('[OpsConcurrencyCard] Failed to load data', err)
errorMessage.value = err?.response?.data?.detail || t('admin.ops.concurrency.loadFailed') errorMessage.value = err?.response?.data?.detail || t('admin.ops.concurrency.loadFailed')
...@@ -245,6 +294,14 @@ watch( ...@@ -245,6 +294,14 @@ watch(
} }
) )
// 切换用户视图时重新加载数据
watch(
() => showByUser.value,
() => {
loadData()
}
)
function getLoadBarClass(loadPct: number): string { function getLoadBarClass(loadPct: number): string {
if (loadPct >= 90) return 'bg-red-500 dark:bg-red-600' if (loadPct >= 90) return 'bg-red-500 dark:bg-red-600'
if (loadPct >= 70) return 'bg-orange-500 dark:bg-orange-600' if (loadPct >= 70) return 'bg-orange-500 dark:bg-orange-600'
...@@ -302,16 +359,32 @@ watch( ...@@ -302,16 +359,32 @@ watch(
</svg> </svg>
{{ t('admin.ops.concurrency.title') }} {{ t('admin.ops.concurrency.title') }}
</h3> </h3>
<button <div class="flex items-center gap-2">
class="flex items-center gap-1 rounded-lg bg-gray-100 px-2 py-1 text-[11px] font-semibold text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600" <!-- 用户视图切换按钮 -->
:disabled="loading" <button
:title="t('common.refresh')" class="flex items-center justify-center rounded-lg px-2 py-1 transition-colors"
@click="loadData" :class="showByUser
> ? 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
<svg class="h-3 w-3" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor"> : 'bg-gray-100 text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600 dark:hover:text-gray-300'"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> :title="showByUser ? t('admin.ops.concurrency.switchToPlatform') : t('admin.ops.concurrency.switchToUser')"
</svg> @click="showByUser = !showByUser"
</button> >
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</button>
<!-- 刷新按钮 -->
<button
class="flex items-center gap-1 rounded-lg bg-gray-100 px-2 py-1 text-[11px] font-semibold text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
:disabled="loading"
:title="t('common.refresh')"
@click="loadData"
>
<svg class="h-3 w-3" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</div> </div>
<!-- 错误提示 --> <!-- 错误提示 -->
...@@ -344,8 +417,41 @@ watch( ...@@ -344,8 +417,41 @@ watch(
{{ t('admin.ops.concurrency.empty') }} {{ t('admin.ops.concurrency.empty') }}
</div> </div>
<!-- 用户视图 -->
<div v-else-if="displayDimension === 'user'" class="custom-scrollbar max-h-[360px] flex-1 space-y-2 overflow-y-auto p-3">
<div v-for="row in (displayRows as UserRow[])" :key="row.key" class="rounded-lg bg-gray-50 p-2.5 dark:bg-dark-900">
<!-- 用户信息和并发 -->
<div class="mb-1.5 flex items-center justify-between gap-2">
<div class="flex min-w-0 flex-1 items-center gap-1.5">
<span class="truncate text-[11px] font-bold text-gray-900 dark:text-white" :title="row.username || row.user_email">
{{ row.username || row.user_email }}
</span>
<span v-if="row.username" class="shrink-0 truncate text-[10px] text-gray-400 dark:text-gray-500" :title="row.user_email">
{{ row.user_email }}
</span>
</div>
<div class="flex shrink-0 items-center gap-2 text-[10px]">
<span class="font-mono font-bold text-gray-900 dark:text-white"> {{ row.current_in_use }}/{{ row.max_capacity }} </span>
<span :class="['font-bold', getLoadTextClass(row.load_percentage)]"> {{ Math.round(row.load_percentage) }}% </span>
</div>
</div>
<!-- 进度条 -->
<div class="h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-700">
<div class="h-full rounded-full transition-all duration-300" :class="getLoadBarClass(row.load_percentage)" :style="getLoadBarStyle(row.load_percentage)"></div>
</div>
<!-- 等待队列 -->
<div v-if="row.waiting_in_queue > 0" class="mt-1.5 flex justify-end">
<span class="rounded-full bg-purple-100 px-1.5 py-0.5 text-[10px] font-semibold text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
{{ t('admin.ops.concurrency.queued', { count: row.waiting_in_queue }) }}
</span>
</div>
</div>
</div>
<!-- 汇总视图平台/分组 --> <!-- 汇总视图平台/分组 -->
<div v-else-if="displayDimension !== 'account'" class="custom-scrollbar max-h-[360px] flex-1 space-y-2 overflow-y-auto p-3"> <div v-else-if="displayDimension === 'platform' || displayDimension === 'group'" class="custom-scrollbar max-h-[360px] flex-1 space-y-2 overflow-y-auto p-3">
<div v-for="row in (displayRows as SummaryRow[])" :key="row.key" class="rounded-lg bg-gray-50 p-3 dark:bg-dark-900"> <div v-for="row in (displayRows as SummaryRow[])" :key="row.key" class="rounded-lg bg-gray-50 p-3 dark:bg-dark-900">
<!-- 标题行 --> <!-- 标题行 -->
<div class="mb-2 flex items-center justify-between gap-2"> <div class="mb-2 flex items-center justify-between gap-2">
......
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