merge: 合并 main 分支到 test,解决 config 和 modelWhitelist 冲突
- config.go: 保留 Sora 配置,合入 SubscriptionCache 配置 - useModelWhitelist.ts: 同时保留 soraModels 和 antigravityModels Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,7 @@
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rate Limit Indicator (429) -->
|
||||
<div v-if="isRateLimited" class="group relative">
|
||||
<span
|
||||
@@ -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"
|
||||
>
|
||||
{{ 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
|
||||
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>
|
||||
@@ -149,11 +170,28 @@ const activeScopeRateLimits = computed(() => {
|
||||
.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 names: Record<string, string> = {
|
||||
claude: 'Claude',
|
||||
claude_sonnet: 'Claude Sonnet',
|
||||
claude_opus: 'Claude Opus',
|
||||
claude_haiku: 'Claude Haiku',
|
||||
gemini_text: 'Gemini',
|
||||
gemini_image: 'Image'
|
||||
gemini_image: 'Image',
|
||||
gemini_flash: 'Gemini Flash',
|
||||
gemini_pro: 'Gemini Pro'
|
||||
}
|
||||
return names[scope] || scope
|
||||
}
|
||||
|
||||
@@ -925,9 +925,23 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
|
||||
|
||||
if (enableModelRestriction.value) {
|
||||
const modelMapping = buildModelMappingObject()
|
||||
if (modelMapping) {
|
||||
credentials.model_mapping = modelMapping
|
||||
credentialsChanged = true
|
||||
|
||||
// 统一使用 model_mapping 字段
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('admin.accounts.createAccount')"
|
||||
width="normal"
|
||||
width="wide"
|
||||
@close="handleClose"
|
||||
>
|
||||
<!-- Step Indicator for OAuth accounts -->
|
||||
@@ -698,6 +698,97 @@
|
||||
</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) -->
|
||||
<div v-if="form.platform === 'anthropic' && isOAuthFlow">
|
||||
<label class="input-label">{{ t('admin.accounts.addMethod') }}</label>
|
||||
@@ -1909,7 +2000,15 @@
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { claudeModels, getPresetMappingsByPlatform, getModelsByPlatform, commonErrorCodes, buildModelMappingObject } from '@/composables/useModelWhitelist'
|
||||
import {
|
||||
claudeModels,
|
||||
getPresetMappingsByPlatform,
|
||||
getModelsByPlatform,
|
||||
commonErrorCodes,
|
||||
buildModelMappingObject,
|
||||
fetchAntigravityDefaultMappings,
|
||||
isValidWildcardPattern
|
||||
} from '@/composables/useModelWhitelist'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import {
|
||||
@@ -2049,6 +2148,10 @@ const mixedScheduling = ref(false) // For antigravity accounts: enable mixed sch
|
||||
const antigravityAccountType = ref<'oauth' | 'upstream'>('oauth') // For antigravity: oauth or upstream
|
||||
const upstreamBaseUrl = ref('') // For upstream type: base URL
|
||||
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 tempUnschedRules = ref<TempUnschedRuleForm[]>([])
|
||||
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one')
|
||||
@@ -2191,6 +2294,18 @@ watch(
|
||||
if (newVal) {
|
||||
// Modal opened - fill related models
|
||||
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 {
|
||||
resetForm()
|
||||
}
|
||||
@@ -2229,15 +2344,24 @@ watch(
|
||||
// Clear model-related settings
|
||||
allowedModels.value = []
|
||||
modelMappings.value = []
|
||||
// Antigravity: 默认使用映射模式并填充默认映射
|
||||
if (newPlatform === 'antigravity') {
|
||||
antigravityModelRestrictionMode.value = 'mapping'
|
||||
fetchAntigravityDefaultMappings().then(mappings => {
|
||||
antigravityModelMappings.value = [...mappings]
|
||||
})
|
||||
antigravityWhitelistModels.value = []
|
||||
accountCategory.value = 'oauth-based'
|
||||
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
|
||||
}
|
||||
// Antigravity: reset to OAuth by default, but allow upstream selection
|
||||
if (newPlatform === 'antigravity') {
|
||||
accountCategory.value = 'oauth-based'
|
||||
antigravityAccountType.value = 'oauth'
|
||||
}
|
||||
// Reset OAuth states
|
||||
oauth.resetState()
|
||||
openaiOAuth.resetState()
|
||||
@@ -2281,6 +2405,15 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
[antigravityModelRestrictionMode, () => form.platform],
|
||||
([, platform]) => {
|
||||
if (platform !== 'antigravity') return
|
||||
// Antigravity 默认不做限制:白名单留空表示允许所有(包含未来新增模型)。
|
||||
// 如果需要快速填充常用模型,可在组件内点“填充相关模型”。
|
||||
}
|
||||
)
|
||||
|
||||
// Model mapping helpers
|
||||
const addModelMapping = () => {
|
||||
modelMappings.value.push({ from: '', to: '' })
|
||||
@@ -2298,6 +2431,22 @@ const addPresetMapping = (from: string, to: string) => {
|
||||
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
|
||||
const toggleErrorCode = (code: number) => {
|
||||
const index = selectedErrorCodes.value.indexOf(code)
|
||||
@@ -2455,6 +2604,12 @@ const resetForm = () => {
|
||||
modelMappings.value = []
|
||||
modelRestrictionMode.value = 'whitelist'
|
||||
allowedModels.value = [...claudeModels] // Default fill related models
|
||||
|
||||
antigravityModelRestrictionMode.value = 'mapping'
|
||||
antigravityWhitelistModels.value = []
|
||||
fetchAntigravityDefaultMappings().then(mappings => {
|
||||
antigravityModelMappings.value = [...mappings]
|
||||
})
|
||||
customErrorCodesEnabled.value = false
|
||||
selectedErrorCodes.value = []
|
||||
customErrorCodeInput.value = null
|
||||
@@ -2569,12 +2724,24 @@ const handleSubmit = async () => {
|
||||
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
|
||||
try {
|
||||
const credentials: Record<string, unknown> = {
|
||||
base_url: upstreamBaseUrl.value.trim(),
|
||||
api_key: upstreamApiKey.value.trim()
|
||||
}
|
||||
await createAccountAndFinish(form.platform, 'upstream', credentials)
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToCreate'))
|
||||
@@ -2845,11 +3012,20 @@ const handleAntigravityExchange = async (authCode: string) => {
|
||||
state: stateToUse,
|
||||
proxyId: form.proxy_id
|
||||
})
|
||||
if (!tokenInfo) return
|
||||
if (!tokenInfo) return
|
||||
|
||||
const credentials = antigravityOAuth.buildCredentials(tokenInfo)
|
||||
const extra = mixedScheduling.value ? { mixed_scheduling: true } : undefined
|
||||
await createAccountAndFinish('antigravity', 'oauth', credentials, extra)
|
||||
const credentials = antigravityOAuth.buildCredentials(tokenInfo)
|
||||
// Antigravity 只使用映射模式
|
||||
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) {
|
||||
antigravityOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(antigravityOAuth.error.value)
|
||||
|
||||
@@ -364,6 +364,96 @@
|
||||
</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 -->
|
||||
<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">
|
||||
@@ -907,7 +997,8 @@ import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/forma
|
||||
import {
|
||||
getPresetMappingsByPlatform,
|
||||
commonErrorCodes,
|
||||
buildModelMappingObject
|
||||
buildModelMappingObject,
|
||||
isValidWildcardPattern
|
||||
} from '@/composables/useModelWhitelist'
|
||||
|
||||
interface Props {
|
||||
@@ -935,6 +1026,8 @@ const baseUrlHint = computed(() => {
|
||||
return t('admin.accounts.baseUrlHint')
|
||||
})
|
||||
|
||||
const antigravityPresetMappings = computed(() => getPresetMappingsByPlatform('antigravity'))
|
||||
|
||||
// Model mapping type
|
||||
interface ModelMapping {
|
||||
from: string
|
||||
@@ -961,6 +1054,9 @@ const customErrorCodeInput = ref<number | null>(null)
|
||||
const interceptWarmupRequests = ref(false)
|
||||
const autoPauseOnExpired = ref(false)
|
||||
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 tempUnschedRules = ref<TempUnschedRuleForm[]>([])
|
||||
|
||||
@@ -1066,6 +1162,38 @@ watch(
|
||||
const extra = newAccount.extra as Record<string, unknown> | undefined
|
||||
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)
|
||||
loadQuotaControlSettings(newAccount)
|
||||
|
||||
@@ -1154,6 +1282,23 @@ const addPresetMapping = (from: string, to: string) => {
|
||||
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
|
||||
const toggleErrorCode = (code: number) => {
|
||||
const index = selectedErrorCodes.value.indexOf(code)
|
||||
@@ -1458,6 +1603,30 @@ const handleSubmit = async () => {
|
||||
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
|
||||
if (props.account.platform === 'antigravity') {
|
||||
const currentExtra = (props.account.extra as Record<string, unknown>) || {}
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
</button>
|
||||
<slot name="after"></slot>
|
||||
<button @click="$emit('sync')" class="btn btn-secondary">{{ t('admin.accounts.syncFromCrs') }}</button>
|
||||
<slot name="beforeCreate"></slot>
|
||||
<button @click="$emit('create')" class="btn btn-primary">{{ t('admin.accounts.createAccount') }}</button>
|
||||
<slot name="afterCreate"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
187
frontend/src/components/admin/account/ImportDataModal.vue
Normal file
187
frontend/src/components/admin/account/ImportDataModal.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('admin.accounts.dataImportTitle')"
|
||||
width="normal"
|
||||
close-on-click-outside
|
||||
@close="handleClose"
|
||||
>
|
||||
<form id="import-data-form" class="space-y-4" @submit.prevent="handleImport">
|
||||
<div class="text-sm text-gray-600 dark:text-dark-300">
|
||||
{{ t('admin.accounts.dataImportHint') }}
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
|
||||
>
|
||||
{{ t('admin.accounts.dataImportWarning') }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.dataImportFile') }}</label>
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm text-gray-700 dark:text-dark-200">
|
||||
{{ fileName || t('admin.accounts.dataImportSelectFile') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-dark-400">JSON (.json)</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary shrink-0" @click="openFilePicker">
|
||||
{{ t('common.chooseFile') }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept="application/json,.json"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="result"
|
||||
class="space-y-2 rounded-xl border border-gray-200 p-4 dark:border-dark-700"
|
||||
>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.accounts.dataImportResult') }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 dark:text-dark-300">
|
||||
{{ t('admin.accounts.dataImportResultSummary', result) }}
|
||||
</div>
|
||||
|
||||
<div v-if="errorItems.length" class="mt-2">
|
||||
<div class="text-sm font-medium text-red-600 dark:text-red-400">
|
||||
{{ t('admin.accounts.dataImportErrors') }}
|
||||
</div>
|
||||
<div
|
||||
class="mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 p-3 font-mono text-xs dark:bg-dark-800"
|
||||
>
|
||||
<div v-for="(item, idx) in errorItems" :key="idx" class="whitespace-pre-wrap">
|
||||
{{ item.kind }} {{ item.name || item.proxy_key || '-' }} — {{ item.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button class="btn btn-secondary" type="button" :disabled="importing" @click="handleClose">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="submit"
|
||||
form="import-data-form"
|
||||
:disabled="importing"
|
||||
>
|
||||
{{ importing ? t('admin.accounts.dataImporting') : t('admin.accounts.dataImportButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import type { AdminDataImportResult } from '@/types'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
(e: 'imported'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const importing = ref(false)
|
||||
const file = ref<File | null>(null)
|
||||
const result = ref<AdminDataImportResult | null>(null)
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const fileName = computed(() => file.value?.name || '')
|
||||
|
||||
const errorItems = computed(() => result.value?.errors || [])
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(open) => {
|
||||
if (open) {
|
||||
file.value = null
|
||||
result.value = null
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const openFilePicker = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
file.value = target.files?.[0] || null
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (importing.value) return
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file.value) {
|
||||
appStore.showError(t('admin.accounts.dataImportSelectFile'))
|
||||
return
|
||||
}
|
||||
|
||||
importing.value = true
|
||||
try {
|
||||
const text = await file.value.text()
|
||||
const dataPayload = JSON.parse(text)
|
||||
|
||||
const res = await adminAPI.accounts.importData({
|
||||
data: dataPayload,
|
||||
skip_default_group_bind: true
|
||||
})
|
||||
|
||||
result.value = res
|
||||
|
||||
const msgParams: Record<string, unknown> = {
|
||||
account_created: res.account_created,
|
||||
account_failed: res.account_failed,
|
||||
proxy_created: res.proxy_created,
|
||||
proxy_reused: res.proxy_reused,
|
||||
proxy_failed: res.proxy_failed,
|
||||
}
|
||||
if (res.account_failed > 0 || res.proxy_failed > 0) {
|
||||
appStore.showError(t('admin.accounts.dataImportCompletedWithErrors', msgParams))
|
||||
} else {
|
||||
appStore.showSuccess(t('admin.accounts.dataImportSuccess', msgParams))
|
||||
emit('imported')
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error instanceof SyntaxError) {
|
||||
appStore.showError(t('admin.accounts.dataImportParseFailed'))
|
||||
} else {
|
||||
appStore.showError(error?.message || t('admin.accounts.dataImportFailed'))
|
||||
}
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
183
frontend/src/components/admin/proxy/ImportDataModal.vue
Normal file
183
frontend/src/components/admin/proxy/ImportDataModal.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('admin.proxies.dataImportTitle')"
|
||||
width="normal"
|
||||
close-on-click-outside
|
||||
@close="handleClose"
|
||||
>
|
||||
<form id="import-proxy-data-form" class="space-y-4" @submit.prevent="handleImport">
|
||||
<div class="text-sm text-gray-600 dark:text-dark-300">
|
||||
{{ t('admin.proxies.dataImportHint') }}
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
|
||||
>
|
||||
{{ t('admin.proxies.dataImportWarning') }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.dataImportFile') }}</label>
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm text-gray-700 dark:text-dark-200">
|
||||
{{ fileName || t('admin.proxies.dataImportSelectFile') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-dark-400">JSON (.json)</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary shrink-0" @click="openFilePicker">
|
||||
{{ t('common.chooseFile') }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept="application/json,.json"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="result"
|
||||
class="space-y-2 rounded-xl border border-gray-200 p-4 dark:border-dark-700"
|
||||
>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.proxies.dataImportResult') }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 dark:text-dark-300">
|
||||
{{ t('admin.proxies.dataImportResultSummary', result) }}
|
||||
</div>
|
||||
|
||||
<div v-if="errorItems.length" class="mt-2">
|
||||
<div class="text-sm font-medium text-red-600 dark:text-red-400">
|
||||
{{ t('admin.proxies.dataImportErrors') }}
|
||||
</div>
|
||||
<div
|
||||
class="mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 p-3 font-mono text-xs dark:bg-dark-800"
|
||||
>
|
||||
<div v-for="(item, idx) in errorItems" :key="idx" class="whitespace-pre-wrap">
|
||||
{{ item.kind }} {{ item.name || item.proxy_key || '-' }} — {{ item.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button class="btn btn-secondary" type="button" :disabled="importing" @click="handleClose">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="submit"
|
||||
form="import-proxy-data-form"
|
||||
:disabled="importing"
|
||||
>
|
||||
{{ importing ? t('admin.proxies.dataImporting') : t('admin.proxies.dataImportButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import type { AdminDataImportResult } from '@/types'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
(e: 'imported'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const importing = ref(false)
|
||||
const file = ref<File | null>(null)
|
||||
const result = ref<AdminDataImportResult | null>(null)
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const fileName = computed(() => file.value?.name || '')
|
||||
|
||||
const errorItems = computed(() => result.value?.errors || [])
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(open) => {
|
||||
if (open) {
|
||||
file.value = null
|
||||
result.value = null
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const openFilePicker = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
file.value = target.files?.[0] || null
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (importing.value) return
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file.value) {
|
||||
appStore.showError(t('admin.proxies.dataImportSelectFile'))
|
||||
return
|
||||
}
|
||||
|
||||
importing.value = true
|
||||
try {
|
||||
const text = await file.value.text()
|
||||
const dataPayload = JSON.parse(text)
|
||||
|
||||
const res = await adminAPI.proxies.importData({ data: dataPayload })
|
||||
|
||||
result.value = res
|
||||
|
||||
const msgParams: Record<string, unknown> = {
|
||||
proxy_created: res.proxy_created,
|
||||
proxy_reused: res.proxy_reused,
|
||||
proxy_failed: res.proxy_failed
|
||||
}
|
||||
|
||||
if (res.proxy_failed > 0) {
|
||||
appStore.showError(t('admin.proxies.dataImportCompletedWithErrors', msgParams))
|
||||
} else {
|
||||
appStore.showSuccess(t('admin.proxies.dataImportSuccess', msgParams))
|
||||
emit('imported')
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error instanceof SyntaxError) {
|
||||
appStore.showError(t('admin.proxies.dataImportParseFailed'))
|
||||
} else {
|
||||
appStore.showError(error?.message || t('admin.proxies.dataImportFailed'))
|
||||
}
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -154,6 +154,9 @@
|
||||
|
||||
<!-- Right: actions -->
|
||||
<div v-if="showActions" class="flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto">
|
||||
<button type="button" @click="$emit('refresh')" class="btn btn-secondary">
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
<button type="button" @click="$emit('reset')" class="btn btn-secondary">
|
||||
{{ t('common.reset') }}
|
||||
</button>
|
||||
@@ -194,6 +197,7 @@ const emit = defineEmits([
|
||||
'update:startDate',
|
||||
'update:endDate',
|
||||
'change',
|
||||
'refresh',
|
||||
'reset',
|
||||
'export',
|
||||
'cleanup'
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<BaseDialog :show="show" :title="title" width="narrow" @close="handleCancel">
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ message }}</p>
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
|
||||
@@ -491,7 +491,7 @@ async function checkServiceAndReload() {
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
const response = await fetch('/api/health', {
|
||||
const response = await fetch('/health', {
|
||||
method: 'GET',
|
||||
cache: 'no-cache'
|
||||
})
|
||||
|
||||
@@ -493,7 +493,7 @@ function generateOpenAIFiles(baseUrl: string, apiKey: string): FileConfig[] {
|
||||
|
||||
// config.toml content
|
||||
const configContent = `model_provider = "sub2api"
|
||||
model = "gpt-5.2-codex"
|
||||
model = "gpt-5.3-codex"
|
||||
model_reasoning_effort = "high"
|
||||
network_access = "enabled"
|
||||
disable_response_storage = true
|
||||
|
||||
@@ -55,16 +55,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Token Usage Trend Chart -->
|
||||
<div class="card relative overflow-hidden p-4">
|
||||
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-dark-800/50">
|
||||
<LoadingSpinner size="md" />
|
||||
</div>
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">{{ t('dashboard.tokenUsageTrend') }}</h3>
|
||||
<div class="h-48">
|
||||
<Line v-if="trendData" :data="trendData" :options="lineOptions" />
|
||||
<div v-else class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400">{{ t('dashboard.noDataAvailable') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<TokenUsageTrend :trend-data="trend" :loading="loading" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -75,7 +66,8 @@ import { useI18n } from 'vue-i18n'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import { Line, Doughnut } from 'vue-chartjs'
|
||||
import { Doughnut } from 'vue-chartjs'
|
||||
import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
|
||||
import type { TrendDataPoint, ModelStat } from '@/types'
|
||||
import { formatCostFixed as formatCost, formatNumberLocaleString as formatNumber, formatTokensK as formatTokens } from '@/utils/format'
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, ArcElement, Title, Tooltip, Legend, Filler } from 'chart.js'
|
||||
@@ -93,28 +85,6 @@ const modelData = computed(() => !props.models?.length ? null : {
|
||||
}]
|
||||
})
|
||||
|
||||
const trendData = computed(() => !props.trend?.length ? null : {
|
||||
labels: props.trend.map((d: TrendDataPoint) => d.date),
|
||||
datasets: [
|
||||
{
|
||||
label: t('dashboard.input'),
|
||||
data: props.trend.map((d: TrendDataPoint) => d.input_tokens),
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
tension: 0.3,
|
||||
fill: true
|
||||
},
|
||||
{
|
||||
label: t('dashboard.output'),
|
||||
data: props.trend.map((d: TrendDataPoint) => d.output_tokens),
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
tension: 0.3,
|
||||
fill: true
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const doughnutOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -127,25 +97,4 @@ const doughnutOptions = {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lineOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: true, position: 'top' as const },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: any) => `${context.dataset.label}: ${formatTokens(context.parsed.y)} tokens`
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: (value: any) => formatTokens(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user