Commit 8f0ea7a0 authored by InCerry's avatar InCerry
Browse files

Merge branch 'main' into fix/enc_coot

parents e4a4dfd0 a1dc0089
...@@ -323,35 +323,6 @@ ...@@ -323,35 +323,6 @@
</div> </div>
</button> </button>
<button
type="button"
@click="accountCategory = 'bedrock-apikey'"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountCategory === 'bedrock-apikey'
? 'border-amber-500 bg-amber-50 dark:bg-amber-900/20'
: 'border-gray-200 hover:border-amber-300 dark:border-dark-600 dark:hover:border-amber-700'
]"
>
<div
:class="[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
accountCategory === 'bedrock-apikey'
? 'bg-amber-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon name="key" size="sm" />
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{
t('admin.accounts.bedrockApiKeyLabel')
}}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.bedrockApiKeyDesc')
}}</span>
</div>
</button>
</div> </div>
</div> </div>
...@@ -956,7 +927,7 @@ ...@@ -956,7 +927,7 @@
</div> </div>
<!-- API Key input (only for apikey type, excluding Antigravity which has its own fields) --> <!-- API Key input (only for apikey type, excluding Antigravity which has its own fields) -->
<div v-if="form.type === 'apikey' && form.platform !== 'antigravity' && accountCategory !== 'bedrock-apikey'" class="space-y-4"> <div v-if="form.type === 'apikey' && form.platform !== 'antigravity'" class="space-y-4">
<div> <div>
<label class="input-label">{{ t('admin.accounts.baseUrl') }}</label> <label class="input-label">{{ t('admin.accounts.baseUrl') }}</label>
<input <input
...@@ -1341,34 +1312,75 @@ ...@@ -1341,34 +1312,75 @@
<!-- Bedrock credentials (only for Anthropic Bedrock type) --> <!-- Bedrock credentials (only for Anthropic Bedrock type) -->
<div v-if="form.platform === 'anthropic' && accountCategory === 'bedrock'" class="space-y-4"> <div v-if="form.platform === 'anthropic' && accountCategory === 'bedrock'" class="space-y-4">
<!-- Auth Mode Radio -->
<div> <div>
<label class="input-label">{{ t('admin.accounts.bedrockAccessKeyId') }}</label> <label class="input-label">{{ t('admin.accounts.bedrockAuthMode') }}</label>
<input <div class="mt-2 flex gap-4">
v-model="bedrockAccessKeyId" <label class="flex cursor-pointer items-center">
type="text" <input
required v-model="bedrockAuthMode"
class="input font-mono" type="radio"
placeholder="AKIA..." value="sigv4"
/> class="mr-2 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.accounts.bedrockAuthModeSigv4') }}</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="bedrockAuthMode"
type="radio"
value="apikey"
class="mr-2 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.accounts.bedrockAuthModeApikey') }}</span>
</label>
</div>
</div> </div>
<div>
<label class="input-label">{{ t('admin.accounts.bedrockSecretAccessKey') }}</label> <!-- SigV4 fields -->
<template v-if="bedrockAuthMode === 'sigv4'">
<div>
<label class="input-label">{{ t('admin.accounts.bedrockAccessKeyId') }}</label>
<input
v-model="bedrockAccessKeyId"
type="text"
required
class="input font-mono"
placeholder="AKIA..."
/>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.bedrockSecretAccessKey') }}</label>
<input
v-model="bedrockSecretAccessKey"
type="password"
required
class="input font-mono"
/>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.bedrockSessionToken') }}</label>
<input
v-model="bedrockSessionToken"
type="password"
class="input font-mono"
/>
<p class="input-hint">{{ t('admin.accounts.bedrockSessionTokenHint') }}</p>
</div>
</template>
<!-- API Key field -->
<div v-if="bedrockAuthMode === 'apikey'">
<label class="input-label">{{ t('admin.accounts.bedrockApiKeyInput') }}</label>
<input <input
v-model="bedrockSecretAccessKey" v-model="bedrockApiKeyValue"
type="password" type="password"
required required
class="input font-mono" class="input font-mono"
/> />
</div> </div>
<div>
<label class="input-label">{{ t('admin.accounts.bedrockSessionToken') }}</label> <!-- Shared: Region -->
<input
v-model="bedrockSessionToken"
type="password"
class="input font-mono"
/>
<p class="input-hint">{{ t('admin.accounts.bedrockSessionTokenHint') }}</p>
</div>
<div> <div>
<label class="input-label">{{ t('admin.accounts.bedrockRegion') }}</label> <label class="input-label">{{ t('admin.accounts.bedrockRegion') }}</label>
<select v-model="bedrockRegion" class="input"> <select v-model="bedrockRegion" class="input">
...@@ -1408,6 +1420,8 @@ ...@@ -1408,6 +1420,8 @@
</select> </select>
<p class="input-hint">{{ t('admin.accounts.bedrockRegionHint') }}</p> <p class="input-hint">{{ t('admin.accounts.bedrockRegionHint') }}</p>
</div> </div>
<!-- Shared: Force Global -->
<div> <div>
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
<input <input
...@@ -1488,142 +1502,62 @@ ...@@ -1488,142 +1502,62 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Bedrock API Key credentials (only for Anthropic Bedrock API Key type) --> <!-- Pool Mode Section for Bedrock -->
<div v-if="form.platform === 'anthropic' && accountCategory === 'bedrock-apikey'" class="space-y-4">
<div>
<label class="input-label">{{ t('admin.accounts.bedrockApiKeyInput') }}</label>
<input
v-model="bedrockApiKeyValue"
type="password"
required
class="input font-mono"
/>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.bedrockRegion') }}</label>
<select v-model="bedrockApiKeyRegion" class="input">
<optgroup label="US">
<option value="us-east-1">us-east-1 (N. Virginia)</option>
<option value="us-east-2">us-east-2 (Ohio)</option>
<option value="us-west-1">us-west-1 (N. California)</option>
<option value="us-west-2">us-west-2 (Oregon)</option>
<option value="us-gov-east-1">us-gov-east-1 (GovCloud US-East)</option>
<option value="us-gov-west-1">us-gov-west-1 (GovCloud US-West)</option>
</optgroup>
<optgroup label="Europe">
<option value="eu-west-1">eu-west-1 (Ireland)</option>
<option value="eu-west-2">eu-west-2 (London)</option>
<option value="eu-west-3">eu-west-3 (Paris)</option>
<option value="eu-central-1">eu-central-1 (Frankfurt)</option>
<option value="eu-central-2">eu-central-2 (Zurich)</option>
<option value="eu-south-1">eu-south-1 (Milan)</option>
<option value="eu-south-2">eu-south-2 (Spain)</option>
<option value="eu-north-1">eu-north-1 (Stockholm)</option>
</optgroup>
<optgroup label="Asia Pacific">
<option value="ap-northeast-1">ap-northeast-1 (Tokyo)</option>
<option value="ap-northeast-2">ap-northeast-2 (Seoul)</option>
<option value="ap-northeast-3">ap-northeast-3 (Osaka)</option>
<option value="ap-south-1">ap-south-1 (Mumbai)</option>
<option value="ap-south-2">ap-south-2 (Hyderabad)</option>
<option value="ap-southeast-1">ap-southeast-1 (Singapore)</option>
<option value="ap-southeast-2">ap-southeast-2 (Sydney)</option>
</optgroup>
<optgroup label="Canada">
<option value="ca-central-1">ca-central-1 (Canada)</option>
</optgroup>
<optgroup label="South America">
<option value="sa-east-1">sa-east-1 (São Paulo)</option>
</optgroup>
</select>
<p class="input-hint">{{ t('admin.accounts.bedrockRegionHint') }}</p>
</div>
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="bedrockApiKeyForceGlobal"
type="checkbox"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.accounts.bedrockForceGlobal') }}</span>
</label>
<p class="input-hint mt-1">{{ t('admin.accounts.bedrockForceGlobalHint') }}</p>
</div>
<!-- Model Restriction Section for Bedrock API Key -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label> <div class="mb-3 flex items-center justify-between">
<div>
<!-- Mode Toggle --> <label class="input-label mb-0">{{ t('admin.accounts.poolMode') }}</label>
<div class="mb-4 flex gap-2"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<button {{ t('admin.accounts.poolModeHint') }}
type="button" </p>
@click="modelRestrictionMode = 'whitelist'" </div>
:class="[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'whitelist'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
{{ t('admin.accounts.modelWhitelist') }}
</button>
<button <button
type="button" type="button"
@click="modelRestrictionMode = 'mapping'" @click="poolModeEnabled = !poolModeEnabled"
:class="[ :class="[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all', 'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
modelRestrictionMode === 'mapping' poolModeEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]" ]"
> >
{{ t('admin.accounts.modelMapping') }} <span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
poolModeEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button> </button>
</div> </div>
<div v-if="poolModeEnabled" class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
<!-- Whitelist Mode --> <p class="text-xs text-blue-700 dark:text-blue-400">
<div v-if="modelRestrictionMode === 'whitelist'"> <Icon name="exclamationCircle" size="sm" class="mr-1 inline" :stroke-width="2" />
<ModelWhitelistSelector v-model="allowedModels" platform="anthropic" /> {{ t('admin.accounts.poolModeInfo') }}
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
<span v-if="allowedModels.length === 0">{{ t('admin.accounts.supportsAllModels') }}</span>
</p> </p>
</div> </div>
<div v-if="poolModeEnabled" class="mt-3">
<!-- Mapping Mode --> <label class="input-label">{{ t('admin.accounts.poolModeRetryCount') }}</label>
<div v-else class="space-y-3"> <input
<div v-for="(mapping, index) in modelMappings" :key="index" class="flex items-center gap-2"> v-model.number="poolModeRetryCount"
<input v-model="mapping.from" type="text" class="input flex-1" :placeholder="t('admin.accounts.fromModel')" /> type="number"
<span class="text-gray-400"></span> min="0"
<input v-model="mapping.to" type="text" class="input flex-1" :placeholder="t('admin.accounts.toModel')" /> :max="MAX_POOL_MODE_RETRY_COUNT"
<button type="button" @click="modelMappings.splice(index, 1)" class="text-red-500 hover:text-red-700"> step="1"
<Icon name="trash" size="sm" /> class="input"
</button> />
</div> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<button type="button" @click="modelMappings.push({ from: '', to: '' })" class="btn btn-secondary text-sm"> {{
+ {{ t('admin.accounts.addMapping') }} t('admin.accounts.poolModeRetryCountHint', {
</button> default: DEFAULT_POOL_MODE_RETRY_COUNT,
<!-- Bedrock Preset Mappings --> max: MAX_POOL_MODE_RETRY_COUNT
<div class="flex flex-wrap gap-2"> })
<button }}
v-for="preset in bedrockPresets" </p>
:key="preset.from"
type="button"
@click="addPresetMapping(preset.from, preset.to)"
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
>
+ {{ preset.label }}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- API Key 账号配额限制 --> <!-- API Key / Bedrock 账号配额限制 -->
<div v-if="form.type === 'apikey'" class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"> <div v-if="form.type === 'apikey' || form.type === 'bedrock'" class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
<div class="mb-3"> <div class="mb-3">
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaLimit') }}</h3> <h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaLimit') }}</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
...@@ -1634,9 +1568,21 @@ ...@@ -1634,9 +1568,21 @@
:totalLimit="editQuotaLimit" :totalLimit="editQuotaLimit"
:dailyLimit="editQuotaDailyLimit" :dailyLimit="editQuotaDailyLimit"
:weeklyLimit="editQuotaWeeklyLimit" :weeklyLimit="editQuotaWeeklyLimit"
:dailyResetMode="editDailyResetMode"
:dailyResetHour="editDailyResetHour"
:weeklyResetMode="editWeeklyResetMode"
:weeklyResetDay="editWeeklyResetDay"
:weeklyResetHour="editWeeklyResetHour"
:resetTimezone="editResetTimezone"
@update:totalLimit="editQuotaLimit = $event" @update:totalLimit="editQuotaLimit = $event"
@update:dailyLimit="editQuotaDailyLimit = $event" @update:dailyLimit="editQuotaDailyLimit = $event"
@update:weeklyLimit="editQuotaWeeklyLimit = $event" @update:weeklyLimit="editQuotaWeeklyLimit = $event"
@update:dailyResetMode="editDailyResetMode = $event"
@update:dailyResetHour="editDailyResetHour = $event"
@update:weeklyResetMode="editWeeklyResetMode = $event"
@update:weeklyResetDay="editWeeklyResetDay = $event"
@update:weeklyResetHour="editWeeklyResetHour = $event"
@update:resetTimezone="editResetTimezone = $event"
/> />
</div> </div>
...@@ -3014,13 +2960,19 @@ interface TempUnschedRuleForm { ...@@ -3014,13 +2960,19 @@ interface TempUnschedRuleForm {
// State // State
const step = ref(1) const step = ref(1)
const submitting = ref(false) const submitting = ref(false)
const accountCategory = ref<'oauth-based' | 'apikey' | 'bedrock' | 'bedrock-apikey'>('oauth-based') // UI selection for account category const accountCategory = ref<'oauth-based' | 'apikey' | 'bedrock'>('oauth-based') // UI selection for account category
const addMethod = ref<AddMethod>('oauth') // For oauth-based: 'oauth' or 'setup-token' const addMethod = ref<AddMethod>('oauth') // For oauth-based: 'oauth' or 'setup-token'
const apiKeyBaseUrl = ref('https://api.anthropic.com') const apiKeyBaseUrl = ref('https://api.anthropic.com')
const apiKeyValue = ref('') const apiKeyValue = ref('')
const editQuotaLimit = ref<number | null>(null) const editQuotaLimit = ref<number | null>(null)
const editQuotaDailyLimit = ref<number | null>(null) const editQuotaDailyLimit = ref<number | null>(null)
const editQuotaWeeklyLimit = ref<number | null>(null) const editQuotaWeeklyLimit = ref<number | null>(null)
const editDailyResetMode = ref<'rolling' | 'fixed' | null>(null)
const editDailyResetHour = ref<number | null>(null)
const editWeeklyResetMode = ref<'rolling' | 'fixed' | null>(null)
const editWeeklyResetDay = ref<number | null>(null)
const editWeeklyResetHour = ref<number | null>(null)
const editResetTimezone = ref<string | null>(null)
const modelMappings = ref<ModelMapping[]>([]) const modelMappings = ref<ModelMapping[]>([])
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist') const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
const allowedModels = ref<string[]>([]) const allowedModels = ref<string[]>([])
...@@ -3050,16 +3002,13 @@ const antigravityPresetMappings = computed(() => getPresetMappingsByPlatform('an ...@@ -3050,16 +3002,13 @@ const antigravityPresetMappings = computed(() => getPresetMappingsByPlatform('an
const bedrockPresets = computed(() => getPresetMappingsByPlatform('bedrock')) const bedrockPresets = computed(() => getPresetMappingsByPlatform('bedrock'))
// Bedrock credentials // Bedrock credentials
const bedrockAuthMode = ref<'sigv4' | 'apikey'>('sigv4')
const bedrockAccessKeyId = ref('') const bedrockAccessKeyId = ref('')
const bedrockSecretAccessKey = ref('') const bedrockSecretAccessKey = ref('')
const bedrockSessionToken = ref('') const bedrockSessionToken = ref('')
const bedrockRegion = ref('us-east-1') const bedrockRegion = ref('us-east-1')
const bedrockForceGlobal = ref(false) const bedrockForceGlobal = ref(false)
// Bedrock API Key credentials
const bedrockApiKeyValue = ref('') const bedrockApiKeyValue = ref('')
const bedrockApiKeyRegion = ref('us-east-1')
const bedrockApiKeyForceGlobal = ref(false)
const tempUnschedEnabled = ref(false) const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([]) const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-model-mapping') const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-model-mapping')
...@@ -3343,7 +3292,8 @@ watch( ...@@ -3343,7 +3292,8 @@ watch(
bedrockSessionToken.value = '' bedrockSessionToken.value = ''
bedrockRegion.value = 'us-east-1' bedrockRegion.value = 'us-east-1'
bedrockForceGlobal.value = false bedrockForceGlobal.value = false
bedrockApiKeyForceGlobal.value = false bedrockAuthMode.value = 'sigv4'
bedrockApiKeyValue.value = ''
// Reset Anthropic/Antigravity-specific settings when switching to other platforms // Reset Anthropic/Antigravity-specific settings when switching to other platforms
if (newPlatform !== 'anthropic' && newPlatform !== 'antigravity') { if (newPlatform !== 'anthropic' && newPlatform !== 'antigravity') {
interceptWarmupRequests.value = false interceptWarmupRequests.value = false
...@@ -3719,6 +3669,12 @@ const resetForm = () => { ...@@ -3719,6 +3669,12 @@ const resetForm = () => {
editQuotaLimit.value = null editQuotaLimit.value = null
editQuotaDailyLimit.value = null editQuotaDailyLimit.value = null
editQuotaWeeklyLimit.value = null editQuotaWeeklyLimit.value = null
editDailyResetMode.value = null
editDailyResetHour.value = null
editWeeklyResetMode.value = null
editWeeklyResetDay.value = null
editWeeklyResetHour.value = null
editResetTimezone.value = null
modelMappings.value = [] modelMappings.value = []
modelRestrictionMode.value = 'whitelist' modelRestrictionMode.value = 'whitelist'
allowedModels.value = [...claudeModels] // Default fill related models allowedModels.value = [...claudeModels] // Default fill related models
...@@ -3919,27 +3875,34 @@ const handleSubmit = async () => { ...@@ -3919,27 +3875,34 @@ const handleSubmit = async () => {
appStore.showError(t('admin.accounts.pleaseEnterAccountName')) appStore.showError(t('admin.accounts.pleaseEnterAccountName'))
return return
} }
if (!bedrockAccessKeyId.value.trim()) {
appStore.showError(t('admin.accounts.bedrockAccessKeyIdRequired'))
return
}
if (!bedrockSecretAccessKey.value.trim()) {
appStore.showError(t('admin.accounts.bedrockSecretAccessKeyRequired'))
return
}
if (!bedrockRegion.value.trim()) {
appStore.showError(t('admin.accounts.bedrockRegionRequired'))
return
}
const credentials: Record<string, unknown> = { const credentials: Record<string, unknown> = {
aws_access_key_id: bedrockAccessKeyId.value.trim(), auth_mode: bedrockAuthMode.value,
aws_secret_access_key: bedrockSecretAccessKey.value.trim(), aws_region: bedrockRegion.value.trim() || 'us-east-1',
aws_region: bedrockRegion.value.trim(),
} }
if (bedrockSessionToken.value.trim()) {
credentials.aws_session_token = bedrockSessionToken.value.trim() if (bedrockAuthMode.value === 'sigv4') {
if (!bedrockAccessKeyId.value.trim()) {
appStore.showError(t('admin.accounts.bedrockAccessKeyIdRequired'))
return
}
if (!bedrockSecretAccessKey.value.trim()) {
appStore.showError(t('admin.accounts.bedrockSecretAccessKeyRequired'))
return
}
credentials.aws_access_key_id = bedrockAccessKeyId.value.trim()
credentials.aws_secret_access_key = bedrockSecretAccessKey.value.trim()
if (bedrockSessionToken.value.trim()) {
credentials.aws_session_token = bedrockSessionToken.value.trim()
}
} else {
if (!bedrockApiKeyValue.value.trim()) {
appStore.showError(t('admin.accounts.bedrockApiKeyRequired'))
return
}
credentials.api_key = bedrockApiKeyValue.value.trim()
} }
if (bedrockForceGlobal.value) { if (bedrockForceGlobal.value) {
credentials.aws_force_global = 'true' credentials.aws_force_global = 'true'
} }
...@@ -3952,42 +3915,15 @@ const handleSubmit = async () => { ...@@ -3952,42 +3915,15 @@ const handleSubmit = async () => {
credentials.model_mapping = modelMapping credentials.model_mapping = modelMapping
} }
applyInterceptWarmup(credentials, interceptWarmupRequests.value, 'create') // Pool mode
if (poolModeEnabled.value) {
await createAccountAndFinish('anthropic', 'bedrock' as AccountType, credentials) credentials.pool_mode = true
return credentials.pool_mode_retry_count = normalizePoolModeRetryCount(poolModeRetryCount.value)
}
// For Bedrock API Key type, create directly
if (form.platform === 'anthropic' && accountCategory.value === 'bedrock-apikey') {
if (!form.name.trim()) {
appStore.showError(t('admin.accounts.pleaseEnterAccountName'))
return
}
if (!bedrockApiKeyValue.value.trim()) {
appStore.showError(t('admin.accounts.bedrockApiKeyRequired'))
return
}
const credentials: Record<string, unknown> = {
api_key: bedrockApiKeyValue.value.trim(),
aws_region: bedrockApiKeyRegion.value.trim() || 'us-east-1',
}
if (bedrockApiKeyForceGlobal.value) {
credentials.aws_force_global = 'true'
}
// Model mapping
const modelMapping = buildModelMappingObject(
modelRestrictionMode.value, allowedModels.value, modelMappings.value
)
if (modelMapping) {
credentials.model_mapping = modelMapping
} }
applyInterceptWarmup(credentials, interceptWarmupRequests.value, 'create') applyInterceptWarmup(credentials, interceptWarmupRequests.value, 'create')
await createAccountAndFinish('anthropic', 'bedrock-apikey' as AccountType, credentials) await createAccountAndFinish('anthropic', 'bedrock' as AccountType, credentials)
return return
} }
...@@ -4233,9 +4169,9 @@ const createAccountAndFinish = async ( ...@@ -4233,9 +4169,9 @@ const createAccountAndFinish = async (
if (!applyTempUnschedConfig(credentials)) { if (!applyTempUnschedConfig(credentials)) {
return return
} }
// Inject quota limits for apikey accounts // Inject quota limits for apikey/bedrock accounts
let finalExtra = extra let finalExtra = extra
if (type === 'apikey') { if (type === 'apikey' || type === 'bedrock') {
const quotaExtra: Record<string, unknown> = { ...(extra || {}) } const quotaExtra: Record<string, unknown> = { ...(extra || {}) }
if (editQuotaLimit.value != null && editQuotaLimit.value > 0) { if (editQuotaLimit.value != null && editQuotaLimit.value > 0) {
quotaExtra.quota_limit = editQuotaLimit.value quotaExtra.quota_limit = editQuotaLimit.value
...@@ -4246,6 +4182,19 @@ const createAccountAndFinish = async ( ...@@ -4246,6 +4182,19 @@ const createAccountAndFinish = async (
if (editQuotaWeeklyLimit.value != null && editQuotaWeeklyLimit.value > 0) { if (editQuotaWeeklyLimit.value != null && editQuotaWeeklyLimit.value > 0) {
quotaExtra.quota_weekly_limit = editQuotaWeeklyLimit.value quotaExtra.quota_weekly_limit = editQuotaWeeklyLimit.value
} }
// Quota reset mode config
if (editDailyResetMode.value === 'fixed') {
quotaExtra.quota_daily_reset_mode = 'fixed'
quotaExtra.quota_daily_reset_hour = editDailyResetHour.value ?? 0
}
if (editWeeklyResetMode.value === 'fixed') {
quotaExtra.quota_weekly_reset_mode = 'fixed'
quotaExtra.quota_weekly_reset_day = editWeeklyResetDay.value ?? 1
quotaExtra.quota_weekly_reset_hour = editWeeklyResetHour.value ?? 0
}
if (editDailyResetMode.value === 'fixed' || editWeeklyResetMode.value === 'fixed') {
quotaExtra.quota_reset_timezone = editResetTimezone.value || 'UTC'
}
if (Object.keys(quotaExtra).length > 0) { if (Object.keys(quotaExtra).length > 0) {
finalExtra = quotaExtra finalExtra = quotaExtra
} }
......
...@@ -563,37 +563,54 @@ ...@@ -563,37 +563,54 @@
</div> </div>
</div> </div>
<!-- Bedrock fields (only for bedrock type) --> <!-- Bedrock fields (for bedrock type, both SigV4 and API Key modes) -->
<div v-if="account.type === 'bedrock'" class="space-y-4"> <div v-if="account.type === 'bedrock'" class="space-y-4">
<div> <!-- SigV4 fields -->
<label class="input-label">{{ t('admin.accounts.bedrockAccessKeyId') }}</label> <template v-if="!isBedrockAPIKeyMode">
<input <div>
v-model="editBedrockAccessKeyId" <label class="input-label">{{ t('admin.accounts.bedrockAccessKeyId') }}</label>
type="text" <input
class="input font-mono" v-model="editBedrockAccessKeyId"
placeholder="AKIA..." type="text"
/> class="input font-mono"
</div> placeholder="AKIA..."
<div> />
<label class="input-label">{{ t('admin.accounts.bedrockSecretAccessKey') }}</label> </div>
<input <div>
v-model="editBedrockSecretAccessKey" <label class="input-label">{{ t('admin.accounts.bedrockSecretAccessKey') }}</label>
type="password" <input
class="input font-mono" v-model="editBedrockSecretAccessKey"
:placeholder="t('admin.accounts.bedrockSecretKeyLeaveEmpty')" type="password"
/> class="input font-mono"
<p class="input-hint">{{ t('admin.accounts.bedrockSecretKeyLeaveEmpty') }}</p> :placeholder="t('admin.accounts.bedrockSecretKeyLeaveEmpty')"
</div> />
<div> <p class="input-hint">{{ t('admin.accounts.bedrockSecretKeyLeaveEmpty') }}</p>
<label class="input-label">{{ t('admin.accounts.bedrockSessionToken') }}</label> </div>
<div>
<label class="input-label">{{ t('admin.accounts.bedrockSessionToken') }}</label>
<input
v-model="editBedrockSessionToken"
type="password"
class="input font-mono"
:placeholder="t('admin.accounts.bedrockSecretKeyLeaveEmpty')"
/>
<p class="input-hint">{{ t('admin.accounts.bedrockSessionTokenHint') }}</p>
</div>
</template>
<!-- API Key field -->
<div v-if="isBedrockAPIKeyMode">
<label class="input-label">{{ t('admin.accounts.bedrockApiKeyInput') }}</label>
<input <input
v-model="editBedrockSessionToken" v-model="editBedrockApiKeyValue"
type="password" type="password"
class="input font-mono" class="input font-mono"
:placeholder="t('admin.accounts.bedrockSecretKeyLeaveEmpty')" :placeholder="t('admin.accounts.bedrockApiKeyLeaveEmpty')"
/> />
<p class="input-hint">{{ t('admin.accounts.bedrockSessionTokenHint') }}</p> <p class="input-hint">{{ t('admin.accounts.bedrockApiKeyLeaveEmpty') }}</p>
</div> </div>
<!-- Shared: Region -->
<div> <div>
<label class="input-label">{{ t('admin.accounts.bedrockRegion') }}</label> <label class="input-label">{{ t('admin.accounts.bedrockRegion') }}</label>
<input <input
...@@ -604,6 +621,8 @@ ...@@ -604,6 +621,8 @@
/> />
<p class="input-hint">{{ t('admin.accounts.bedrockRegionHint') }}</p> <p class="input-hint">{{ t('admin.accounts.bedrockRegionHint') }}</p>
</div> </div>
<!-- Shared: Force Global -->
<div> <div>
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
<input <input
...@@ -684,108 +703,56 @@ ...@@ -684,108 +703,56 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Bedrock API Key fields (only for bedrock-apikey type) --> <!-- Pool Mode Section for Bedrock -->
<div v-if="account.type === 'bedrock-apikey'" class="space-y-4">
<div>
<label class="input-label">{{ t('admin.accounts.bedrockApiKeyInput') }}</label>
<input
v-model="editBedrockApiKeyValue"
type="password"
class="input font-mono"
:placeholder="t('admin.accounts.bedrockApiKeyLeaveEmpty')"
/>
<p class="input-hint">{{ t('admin.accounts.bedrockApiKeyLeaveEmpty') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.bedrockRegion') }}</label>
<input
v-model="editBedrockApiKeyRegion"
type="text"
class="input"
placeholder="us-east-1"
/>
<p class="input-hint">{{ t('admin.accounts.bedrockRegionHint') }}</p>
</div>
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="editBedrockApiKeyForceGlobal"
type="checkbox"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.accounts.bedrockForceGlobal') }}</span>
</label>
<p class="input-hint mt-1">{{ t('admin.accounts.bedrockForceGlobalHint') }}</p>
</div>
<!-- Model Restriction for Bedrock API Key -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label> <div class="mb-3 flex items-center justify-between">
<div>
<!-- Mode Toggle --> <label class="input-label mb-0">{{ t('admin.accounts.poolMode') }}</label>
<div class="mb-4 flex gap-2"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<button {{ t('admin.accounts.poolModeHint') }}
type="button" </p>
@click="modelRestrictionMode = 'whitelist'" </div>
:class="[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'whitelist'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
{{ t('admin.accounts.modelWhitelist') }}
</button>
<button <button
type="button" type="button"
@click="modelRestrictionMode = 'mapping'" @click="poolModeEnabled = !poolModeEnabled"
:class="[ :class="[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all', 'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
modelRestrictionMode === 'mapping' poolModeEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]" ]"
> >
{{ t('admin.accounts.modelMapping') }} <span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
poolModeEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button> </button>
</div> </div>
<div v-if="poolModeEnabled" class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
<!-- Whitelist Mode --> <p class="text-xs text-blue-700 dark:text-blue-400">
<div v-if="modelRestrictionMode === 'whitelist'"> <Icon name="exclamationCircle" size="sm" class="mr-1 inline" :stroke-width="2" />
<ModelWhitelistSelector v-model="allowedModels" platform="anthropic" /> {{ t('admin.accounts.poolModeInfo') }}
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
<span v-if="allowedModels.length === 0">{{ t('admin.accounts.supportsAllModels') }}</span>
</p> </p>
</div> </div>
<div v-if="poolModeEnabled" class="mt-3">
<!-- Mapping Mode --> <label class="input-label">{{ t('admin.accounts.poolModeRetryCount') }}</label>
<div v-else class="space-y-3"> <input
<div v-for="(mapping, index) in modelMappings" :key="getModelMappingKey(mapping)" class="flex items-center gap-2"> v-model.number="poolModeRetryCount"
<input v-model="mapping.from" type="text" class="input flex-1" :placeholder="t('admin.accounts.fromModel')" /> type="number"
<span class="text-gray-400"></span> min="0"
<input v-model="mapping.to" type="text" class="input flex-1" :placeholder="t('admin.accounts.toModel')" /> :max="MAX_POOL_MODE_RETRY_COUNT"
<button type="button" @click="modelMappings.splice(index, 1)" class="text-red-500 hover:text-red-700"> step="1"
<Icon name="trash" size="sm" /> class="input"
</button> />
</div> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<button type="button" @click="modelMappings.push({ from: '', to: '' })" class="btn btn-secondary text-sm"> {{
+ {{ t('admin.accounts.addMapping') }} t('admin.accounts.poolModeRetryCountHint', {
</button> default: DEFAULT_POOL_MODE_RETRY_COUNT,
<!-- Bedrock Preset Mappings --> max: MAX_POOL_MODE_RETRY_COUNT
<div class="flex flex-wrap gap-2"> })
<button }}
v-for="preset in bedrockPresets" </p>
:key="preset.from"
type="button"
@click="modelMappings.push({ from: preset.from, to: preset.to })"
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
>
+ {{ preset.label }}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
...@@ -1182,8 +1149,8 @@ ...@@ -1182,8 +1149,8 @@
</div> </div>
</div> </div>
<!-- API Key 账号配额限制 --> <!-- API Key / Bedrock 账号配额限制 -->
<div v-if="account?.type === 'apikey'" class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"> <div v-if="account?.type === 'apikey' || account?.type === 'bedrock'" class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
<div class="mb-3"> <div class="mb-3">
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaLimit') }}</h3> <h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaLimit') }}</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
...@@ -1194,9 +1161,21 @@ ...@@ -1194,9 +1161,21 @@
:totalLimit="editQuotaLimit" :totalLimit="editQuotaLimit"
:dailyLimit="editQuotaDailyLimit" :dailyLimit="editQuotaDailyLimit"
:weeklyLimit="editQuotaWeeklyLimit" :weeklyLimit="editQuotaWeeklyLimit"
:dailyResetMode="editDailyResetMode"
:dailyResetHour="editDailyResetHour"
:weeklyResetMode="editWeeklyResetMode"
:weeklyResetDay="editWeeklyResetDay"
:weeklyResetHour="editWeeklyResetHour"
:resetTimezone="editResetTimezone"
@update:totalLimit="editQuotaLimit = $event" @update:totalLimit="editQuotaLimit = $event"
@update:dailyLimit="editQuotaDailyLimit = $event" @update:dailyLimit="editQuotaDailyLimit = $event"
@update:weeklyLimit="editQuotaWeeklyLimit = $event" @update:weeklyLimit="editQuotaWeeklyLimit = $event"
@update:dailyResetMode="editDailyResetMode = $event"
@update:dailyResetHour="editDailyResetHour = $event"
@update:weeklyResetMode="editWeeklyResetMode = $event"
@update:weeklyResetDay="editWeeklyResetDay = $event"
@update:weeklyResetHour="editWeeklyResetHour = $event"
@update:resetTimezone="editResetTimezone = $event"
/> />
</div> </div>
...@@ -1781,11 +1760,11 @@ const editBedrockSecretAccessKey = ref('') ...@@ -1781,11 +1760,11 @@ const editBedrockSecretAccessKey = ref('')
const editBedrockSessionToken = ref('') const editBedrockSessionToken = ref('')
const editBedrockRegion = ref('') const editBedrockRegion = ref('')
const editBedrockForceGlobal = ref(false) const editBedrockForceGlobal = ref(false)
// Bedrock API Key credentials
const editBedrockApiKeyValue = ref('') const editBedrockApiKeyValue = ref('')
const editBedrockApiKeyRegion = ref('') const isBedrockAPIKeyMode = computed(() =>
const editBedrockApiKeyForceGlobal = ref(false) props.account?.type === 'bedrock' &&
(props.account?.credentials as Record<string, unknown>)?.auth_mode === 'apikey'
)
const modelMappings = ref<ModelMapping[]>([]) const modelMappings = ref<ModelMapping[]>([])
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist') const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
const allowedModels = ref<string[]>([]) const allowedModels = ref<string[]>([])
...@@ -1847,6 +1826,12 @@ const anthropicPassthroughEnabled = ref(false) ...@@ -1847,6 +1826,12 @@ const anthropicPassthroughEnabled = ref(false)
const editQuotaLimit = ref<number | null>(null) const editQuotaLimit = ref<number | null>(null)
const editQuotaDailyLimit = ref<number | null>(null) const editQuotaDailyLimit = ref<number | null>(null)
const editQuotaWeeklyLimit = ref<number | null>(null) const editQuotaWeeklyLimit = ref<number | null>(null)
const editDailyResetMode = ref<'rolling' | 'fixed' | null>(null)
const editDailyResetHour = ref<number | null>(null)
const editWeeklyResetMode = ref<'rolling' | 'fixed' | null>(null)
const editWeeklyResetDay = ref<number | null>(null)
const editWeeklyResetHour = ref<number | null>(null)
const editResetTimezone = ref<string | null>(null)
const openAIWSModeOptions = computed(() => [ const openAIWSModeOptions = computed(() => [
{ value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') }, { value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') },
// TODO: ctx_pool 选项暂时隐藏,待测试完成后恢复 // TODO: ctx_pool 选项暂时隐藏,待测试完成后恢复
...@@ -2026,18 +2011,31 @@ watch( ...@@ -2026,18 +2011,31 @@ watch(
anthropicPassthroughEnabled.value = extra?.anthropic_passthrough === true anthropicPassthroughEnabled.value = extra?.anthropic_passthrough === true
} }
// Load quota limit for apikey accounts // Load quota limit for apikey/bedrock accounts (bedrock quota is also loaded in its own branch above)
if (newAccount.type === 'apikey') { if (newAccount.type === 'apikey' || newAccount.type === 'bedrock') {
const quotaVal = extra?.quota_limit as number | undefined const quotaVal = extra?.quota_limit as number | undefined
editQuotaLimit.value = (quotaVal && quotaVal > 0) ? quotaVal : null editQuotaLimit.value = (quotaVal && quotaVal > 0) ? quotaVal : null
const dailyVal = extra?.quota_daily_limit as number | undefined const dailyVal = extra?.quota_daily_limit as number | undefined
editQuotaDailyLimit.value = (dailyVal && dailyVal > 0) ? dailyVal : null editQuotaDailyLimit.value = (dailyVal && dailyVal > 0) ? dailyVal : null
const weeklyVal = extra?.quota_weekly_limit as number | undefined const weeklyVal = extra?.quota_weekly_limit as number | undefined
editQuotaWeeklyLimit.value = (weeklyVal && weeklyVal > 0) ? weeklyVal : null editQuotaWeeklyLimit.value = (weeklyVal && weeklyVal > 0) ? weeklyVal : null
// Load quota reset mode config
editDailyResetMode.value = (extra?.quota_daily_reset_mode as 'rolling' | 'fixed') || null
editDailyResetHour.value = (extra?.quota_daily_reset_hour as number) ?? null
editWeeklyResetMode.value = (extra?.quota_weekly_reset_mode as 'rolling' | 'fixed') || null
editWeeklyResetDay.value = (extra?.quota_weekly_reset_day as number) ?? null
editWeeklyResetHour.value = (extra?.quota_weekly_reset_hour as number) ?? null
editResetTimezone.value = (extra?.quota_reset_timezone as string) || null
} else { } else {
editQuotaLimit.value = null editQuotaLimit.value = null
editQuotaDailyLimit.value = null editQuotaDailyLimit.value = null
editQuotaWeeklyLimit.value = null editQuotaWeeklyLimit.value = null
editDailyResetMode.value = null
editDailyResetHour.value = null
editWeeklyResetMode.value = null
editWeeklyResetDay.value = null
editWeeklyResetHour.value = null
editResetTimezone.value = null
} }
// Load antigravity model mapping (Antigravity 只支持映射模式) // Load antigravity model mapping (Antigravity 只支持映射模式)
...@@ -2130,39 +2128,31 @@ watch( ...@@ -2130,39 +2128,31 @@ watch(
} }
} else if (newAccount.type === 'bedrock' && newAccount.credentials) { } else if (newAccount.type === 'bedrock' && newAccount.credentials) {
const bedrockCreds = newAccount.credentials as Record<string, unknown> const bedrockCreds = newAccount.credentials as Record<string, unknown>
editBedrockAccessKeyId.value = (bedrockCreds.aws_access_key_id as string) || '' const authMode = (bedrockCreds.auth_mode as string) || 'sigv4'
editBedrockRegion.value = (bedrockCreds.aws_region as string) || '' editBedrockRegion.value = (bedrockCreds.aws_region as string) || ''
editBedrockForceGlobal.value = (bedrockCreds.aws_force_global as string) === 'true' editBedrockForceGlobal.value = (bedrockCreds.aws_force_global as string) === 'true'
editBedrockSecretAccessKey.value = ''
editBedrockSessionToken.value = ''
// Load model mappings for bedrock if (authMode === 'apikey') {
const existingMappings = bedrockCreds.model_mapping as Record<string, string> | undefined editBedrockApiKeyValue.value = ''
if (existingMappings && typeof existingMappings === 'object') {
const entries = Object.entries(existingMappings)
const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to)
if (isWhitelistMode) {
modelRestrictionMode.value = 'whitelist'
allowedModels.value = entries.map(([from]) => from)
modelMappings.value = []
} else {
modelRestrictionMode.value = 'mapping'
modelMappings.value = entries.map(([from, to]) => ({ from, to }))
allowedModels.value = []
}
} else { } else {
modelRestrictionMode.value = 'whitelist' editBedrockAccessKeyId.value = (bedrockCreds.aws_access_key_id as string) || ''
modelMappings.value = [] editBedrockSecretAccessKey.value = ''
allowedModels.value = [] editBedrockSessionToken.value = ''
} }
} else if (newAccount.type === 'bedrock-apikey' && newAccount.credentials) {
const bedrockApiKeyCreds = newAccount.credentials as Record<string, unknown> // Load pool mode for bedrock
editBedrockApiKeyRegion.value = (bedrockApiKeyCreds.aws_region as string) || 'us-east-1' poolModeEnabled.value = bedrockCreds.pool_mode === true
editBedrockApiKeyForceGlobal.value = (bedrockApiKeyCreds.aws_force_global as string) === 'true' const retryCount = bedrockCreds.pool_mode_retry_count
editBedrockApiKeyValue.value = '' poolModeRetryCount.value = (typeof retryCount === 'number' && retryCount >= 0) ? retryCount : DEFAULT_POOL_MODE_RETRY_COUNT
// Load model mappings for bedrock-apikey // Load quota limits for bedrock
const existingMappings = bedrockApiKeyCreds.model_mapping as Record<string, string> | undefined const bedrockExtra = (newAccount.extra as Record<string, unknown>) || {}
editQuotaLimit.value = typeof bedrockExtra.quota_limit === 'number' ? bedrockExtra.quota_limit : null
editQuotaDailyLimit.value = typeof bedrockExtra.quota_daily_limit === 'number' ? bedrockExtra.quota_daily_limit : null
editQuotaWeeklyLimit.value = typeof bedrockExtra.quota_weekly_limit === 'number' ? bedrockExtra.quota_weekly_limit : null
// Load model mappings for bedrock
const existingMappings = bedrockCreds.model_mapping as Record<string, string> | undefined
if (existingMappings && typeof existingMappings === 'object') { if (existingMappings && typeof existingMappings === 'object') {
const entries = Object.entries(existingMappings) const entries = Object.entries(existingMappings)
const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to) const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to)
...@@ -2727,7 +2717,6 @@ const handleSubmit = async () => { ...@@ -2727,7 +2717,6 @@ const handleSubmit = async () => {
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {} const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}
const newCredentials: Record<string, unknown> = { ...currentCredentials } const newCredentials: Record<string, unknown> = { ...currentCredentials }
newCredentials.aws_access_key_id = editBedrockAccessKeyId.value.trim()
newCredentials.aws_region = editBedrockRegion.value.trim() newCredentials.aws_region = editBedrockRegion.value.trim()
if (editBedrockForceGlobal.value) { if (editBedrockForceGlobal.value) {
newCredentials.aws_force_global = 'true' newCredentials.aws_force_global = 'true'
...@@ -2735,42 +2724,29 @@ const handleSubmit = async () => { ...@@ -2735,42 +2724,29 @@ const handleSubmit = async () => {
delete newCredentials.aws_force_global delete newCredentials.aws_force_global
} }
// Only update secrets if user provided new values if (isBedrockAPIKeyMode.value) {
if (editBedrockSecretAccessKey.value.trim()) { // API Key mode: only update api_key if user provided new value
newCredentials.aws_secret_access_key = editBedrockSecretAccessKey.value.trim() if (editBedrockApiKeyValue.value.trim()) {
} newCredentials.api_key = editBedrockApiKeyValue.value.trim()
if (editBedrockSessionToken.value.trim()) { }
newCredentials.aws_session_token = editBedrockSessionToken.value.trim()
}
// Model mapping
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
if (modelMapping) {
newCredentials.model_mapping = modelMapping
} else { } else {
delete newCredentials.model_mapping // SigV4 mode
} newCredentials.aws_access_key_id = editBedrockAccessKeyId.value.trim()
if (editBedrockSecretAccessKey.value.trim()) {
applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit') newCredentials.aws_secret_access_key = editBedrockSecretAccessKey.value.trim()
if (!applyTempUnschedConfig(newCredentials)) { }
return if (editBedrockSessionToken.value.trim()) {
newCredentials.aws_session_token = editBedrockSessionToken.value.trim()
}
} }
updatePayload.credentials = newCredentials // Pool mode
} else if (props.account.type === 'bedrock-apikey') { if (poolModeEnabled.value) {
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {} newCredentials.pool_mode = true
const newCredentials: Record<string, unknown> = { ...currentCredentials } newCredentials.pool_mode_retry_count = normalizePoolModeRetryCount(poolModeRetryCount.value)
newCredentials.aws_region = editBedrockApiKeyRegion.value.trim() || 'us-east-1'
if (editBedrockApiKeyForceGlobal.value) {
newCredentials.aws_force_global = 'true'
} else { } else {
delete newCredentials.aws_force_global delete newCredentials.pool_mode
} delete newCredentials.pool_mode_retry_count
// Only update API key if user provided new value
if (editBedrockApiKeyValue.value.trim()) {
newCredentials.api_key = editBedrockApiKeyValue.value.trim()
} }
// Model mapping // Model mapping
...@@ -2980,8 +2956,8 @@ const handleSubmit = async () => { ...@@ -2980,8 +2956,8 @@ const handleSubmit = async () => {
updatePayload.extra = newExtra updatePayload.extra = newExtra
} }
// For apikey accounts, handle quota_limit in extra // For apikey/bedrock accounts, handle quota_limit in extra
if (props.account.type === 'apikey') { if (props.account.type === 'apikey' || props.account.type === 'bedrock') {
const currentExtra = (updatePayload.extra as Record<string, unknown>) || const currentExtra = (updatePayload.extra as Record<string, unknown>) ||
(props.account.extra as Record<string, unknown>) || {} (props.account.extra as Record<string, unknown>) || {}
const newExtra: Record<string, unknown> = { ...currentExtra } const newExtra: Record<string, unknown> = { ...currentExtra }
...@@ -3000,6 +2976,28 @@ const handleSubmit = async () => { ...@@ -3000,6 +2976,28 @@ const handleSubmit = async () => {
} else { } else {
delete newExtra.quota_weekly_limit delete newExtra.quota_weekly_limit
} }
// Quota reset mode config
if (editDailyResetMode.value === 'fixed') {
newExtra.quota_daily_reset_mode = 'fixed'
newExtra.quota_daily_reset_hour = editDailyResetHour.value ?? 0
} else {
delete newExtra.quota_daily_reset_mode
delete newExtra.quota_daily_reset_hour
}
if (editWeeklyResetMode.value === 'fixed') {
newExtra.quota_weekly_reset_mode = 'fixed'
newExtra.quota_weekly_reset_day = editWeeklyResetDay.value ?? 1
newExtra.quota_weekly_reset_hour = editWeeklyResetHour.value ?? 0
} else {
delete newExtra.quota_weekly_reset_mode
delete newExtra.quota_weekly_reset_day
delete newExtra.quota_weekly_reset_hour
}
if (editDailyResetMode.value === 'fixed' || editWeeklyResetMode.value === 'fixed') {
newExtra.quota_reset_timezone = editResetTimezone.value || 'UTC'
} else {
delete newExtra.quota_reset_timezone
}
updatePayload.extra = newExtra updatePayload.extra = newExtra
} }
......
...@@ -8,12 +8,24 @@ const props = defineProps<{ ...@@ -8,12 +8,24 @@ const props = defineProps<{
totalLimit: number | null totalLimit: number | null
dailyLimit: number | null dailyLimit: number | null
weeklyLimit: number | null weeklyLimit: number | null
dailyResetMode: 'rolling' | 'fixed' | null
dailyResetHour: number | null
weeklyResetMode: 'rolling' | 'fixed' | null
weeklyResetDay: number | null
weeklyResetHour: number | null
resetTimezone: string | null
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'update:totalLimit': [value: number | null] 'update:totalLimit': [value: number | null]
'update:dailyLimit': [value: number | null] 'update:dailyLimit': [value: number | null]
'update:weeklyLimit': [value: number | null] 'update:weeklyLimit': [value: number | null]
'update:dailyResetMode': [value: 'rolling' | 'fixed' | null]
'update:dailyResetHour': [value: number | null]
'update:weeklyResetMode': [value: 'rolling' | 'fixed' | null]
'update:weeklyResetDay': [value: number | null]
'update:weeklyResetHour': [value: number | null]
'update:resetTimezone': [value: string | null]
}>() }>()
const enabled = computed(() => const enabled = computed(() =>
...@@ -35,9 +47,56 @@ watch(localEnabled, (val) => { ...@@ -35,9 +47,56 @@ watch(localEnabled, (val) => {
emit('update:totalLimit', null) emit('update:totalLimit', null)
emit('update:dailyLimit', null) emit('update:dailyLimit', null)
emit('update:weeklyLimit', null) emit('update:weeklyLimit', null)
emit('update:dailyResetMode', null)
emit('update:dailyResetHour', null)
emit('update:weeklyResetMode', null)
emit('update:weeklyResetDay', null)
emit('update:weeklyResetHour', null)
emit('update:resetTimezone', null)
} }
}) })
// Whether any fixed mode is active (to show timezone selector)
const hasFixedMode = computed(() =>
props.dailyResetMode === 'fixed' || props.weeklyResetMode === 'fixed'
)
// Common timezone options
const timezoneOptions = [
'UTC',
'Asia/Shanghai',
'Asia/Tokyo',
'Asia/Seoul',
'Asia/Singapore',
'Asia/Kolkata',
'Asia/Dubai',
'Europe/London',
'Europe/Paris',
'Europe/Berlin',
'Europe/Moscow',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Sao_Paulo',
'Australia/Sydney',
'Pacific/Auckland',
]
// Hours for dropdown (0-23)
const hourOptions = Array.from({ length: 24 }, (_, i) => i)
// Day of week options
const dayOptions = [
{ value: 1, key: 'monday' },
{ value: 2, key: 'tuesday' },
{ value: 3, key: 'wednesday' },
{ value: 4, key: 'thursday' },
{ value: 5, key: 'friday' },
{ value: 6, key: 'saturday' },
{ value: 0, key: 'sunday' },
]
const onTotalInput = (e: Event) => { const onTotalInput = (e: Event) => {
const raw = (e.target as HTMLInputElement).valueAsNumber const raw = (e.target as HTMLInputElement).valueAsNumber
emit('update:totalLimit', Number.isNaN(raw) ? null : raw) emit('update:totalLimit', Number.isNaN(raw) ? null : raw)
...@@ -50,6 +109,25 @@ const onWeeklyInput = (e: Event) => { ...@@ -50,6 +109,25 @@ const onWeeklyInput = (e: Event) => {
const raw = (e.target as HTMLInputElement).valueAsNumber const raw = (e.target as HTMLInputElement).valueAsNumber
emit('update:weeklyLimit', Number.isNaN(raw) ? null : raw) emit('update:weeklyLimit', Number.isNaN(raw) ? null : raw)
} }
const onDailyModeChange = (e: Event) => {
const val = (e.target as HTMLSelectElement).value as 'rolling' | 'fixed'
emit('update:dailyResetMode', val)
if (val === 'fixed') {
if (props.dailyResetHour == null) emit('update:dailyResetHour', 0)
if (!props.resetTimezone) emit('update:resetTimezone', 'UTC')
}
}
const onWeeklyModeChange = (e: Event) => {
const val = (e.target as HTMLSelectElement).value as 'rolling' | 'fixed'
emit('update:weeklyResetMode', val)
if (val === 'fixed') {
if (props.weeklyResetDay == null) emit('update:weeklyResetDay', 1)
if (props.weeklyResetHour == null) emit('update:weeklyResetHour', 0)
if (!props.resetTimezone) emit('update:resetTimezone', 'UTC')
}
}
</script> </script>
<template> <template>
...@@ -94,7 +172,37 @@ const onWeeklyInput = (e: Event) => { ...@@ -94,7 +172,37 @@ const onWeeklyInput = (e: Event) => {
:placeholder="t('admin.accounts.quotaLimitPlaceholder')" :placeholder="t('admin.accounts.quotaLimitPlaceholder')"
/> />
</div> </div>
<p class="input-hint">{{ t('admin.accounts.quotaDailyLimitHint') }}</p> <!-- 日配额重置模式 -->
<div class="mt-2 flex items-center gap-2">
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaResetMode') }}</label>
<select
:value="dailyResetMode || 'rolling'"
@change="onDailyModeChange"
class="input py-1 text-xs"
>
<option value="rolling">{{ t('admin.accounts.quotaResetModeRolling') }}</option>
<option value="fixed">{{ t('admin.accounts.quotaResetModeFixed') }}</option>
</select>
</div>
<!-- 固定模式:小时选择 -->
<div v-if="dailyResetMode === 'fixed'" class="mt-2 flex items-center gap-2">
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaResetHour') }}</label>
<select
:value="dailyResetHour ?? 0"
@change="emit('update:dailyResetHour', Number(($event.target as HTMLSelectElement).value))"
class="input py-1 text-xs w-24"
>
<option v-for="h in hourOptions" :key="h" :value="h">{{ String(h).padStart(2, '0') }}:00</option>
</select>
</div>
<p class="input-hint">
<template v-if="dailyResetMode === 'fixed'">
{{ t('admin.accounts.quotaDailyLimitHintFixed', { hour: String(dailyResetHour ?? 0).padStart(2, '0'), timezone: resetTimezone || 'UTC' }) }}
</template>
<template v-else>
{{ t('admin.accounts.quotaDailyLimitHint') }}
</template>
</p>
</div> </div>
<!-- 周配额 --> <!-- 周配额 -->
...@@ -112,7 +220,57 @@ const onWeeklyInput = (e: Event) => { ...@@ -112,7 +220,57 @@ const onWeeklyInput = (e: Event) => {
:placeholder="t('admin.accounts.quotaLimitPlaceholder')" :placeholder="t('admin.accounts.quotaLimitPlaceholder')"
/> />
</div> </div>
<p class="input-hint">{{ t('admin.accounts.quotaWeeklyLimitHint') }}</p> <!-- 周配额重置模式 -->
<div class="mt-2 flex items-center gap-2">
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaResetMode') }}</label>
<select
:value="weeklyResetMode || 'rolling'"
@change="onWeeklyModeChange"
class="input py-1 text-xs"
>
<option value="rolling">{{ t('admin.accounts.quotaResetModeRolling') }}</option>
<option value="fixed">{{ t('admin.accounts.quotaResetModeFixed') }}</option>
</select>
</div>
<!-- 固定模式星期几 + 小时 -->
<div v-if="weeklyResetMode === 'fixed'" class="mt-2 flex items-center gap-2 flex-wrap">
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaWeeklyResetDay') }}</label>
<select
:value="weeklyResetDay ?? 1"
@change="emit('update:weeklyResetDay', Number(($event.target as HTMLSelectElement).value))"
class="input py-1 text-xs w-28"
>
<option v-for="d in dayOptions" :key="d.value" :value="d.value">{{ t('admin.accounts.dayOfWeek.' + d.key) }}</option>
</select>
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaResetHour') }}</label>
<select
:value="weeklyResetHour ?? 0"
@change="emit('update:weeklyResetHour', Number(($event.target as HTMLSelectElement).value))"
class="input py-1 text-xs w-24"
>
<option v-for="h in hourOptions" :key="h" :value="h">{{ String(h).padStart(2, '0') }}:00</option>
</select>
</div>
<p class="input-hint">
<template v-if="weeklyResetMode === 'fixed'">
{{ t('admin.accounts.quotaWeeklyLimitHintFixed', { day: t('admin.accounts.dayOfWeek.' + (dayOptions.find(d => d.value === (weeklyResetDay ?? 1))?.key || 'monday')), hour: String(weeklyResetHour ?? 0).padStart(2, '0'), timezone: resetTimezone || 'UTC' }) }}
</template>
<template v-else>
{{ t('admin.accounts.quotaWeeklyLimitHint') }}
</template>
</p>
</div>
<!-- 时区选择当任一维度使用固定模式时显示 -->
<div v-if="hasFixedMode">
<label class="input-label">{{ t('admin.accounts.quotaResetTimezone') }}</label>
<select
:value="resetTimezone || 'UTC'"
@change="emit('update:resetTimezone', ($event.target as HTMLSelectElement).value)"
class="input text-sm"
>
<option v-for="tz in timezoneOptions" :key="tz" :value="tz">{{ tz }}</option>
</select>
</div> </div>
<!-- 总配额 --> <!-- 总配额 -->
......
...@@ -76,7 +76,7 @@ const hasRecoverableState = computed(() => { ...@@ -76,7 +76,7 @@ const hasRecoverableState = computed(() => {
return props.account?.status === 'error' || Boolean(isRateLimited.value) || Boolean(isOverloaded.value) || Boolean(isTempUnschedulable.value) return props.account?.status === 'error' || Boolean(isRateLimited.value) || Boolean(isOverloaded.value) || Boolean(isTempUnschedulable.value)
}) })
const hasQuotaLimit = computed(() => { const hasQuotaLimit = computed(() => {
return props.account?.type === 'apikey' && ( return (props.account?.type === 'apikey' || props.account?.type === 'bedrock') && (
(props.account?.quota_limit ?? 0) > 0 || (props.account?.quota_limit ?? 0) > 0 ||
(props.account?.quota_daily_limit ?? 0) > 0 || (props.account?.quota_daily_limit ?? 0) > 0 ||
(props.account?.quota_weekly_limit ?? 0) > 0 (props.account?.quota_weekly_limit ?? 0) > 0
......
...@@ -83,7 +83,7 @@ const typeLabel = computed(() => { ...@@ -83,7 +83,7 @@ const typeLabel = computed(() => {
case 'apikey': case 'apikey':
return 'Key' return 'Key'
case 'bedrock': case 'bedrock':
return 'Bedrock' return 'AWS'
default: default:
return props.type return props.type
} }
......
...@@ -82,7 +82,7 @@ ...@@ -82,7 +82,7 @@
</template> </template>
<!-- Regular User View --> <!-- Regular User View -->
<template v-else> <template v-else-if="!appStore.backendModeEnabled">
<div class="sidebar-section"> <div class="sidebar-section">
<router-link <router-link
v-for="item in userNavItems" v-for="item in userNavItems"
......
...@@ -84,9 +84,7 @@ onUnmounted(() => { ...@@ -84,9 +84,7 @@ onUnmounted(() => {
} }
.table-scroll-container :deep(th) { .table-scroll-container :deep(th) {
/* 表头高度和文字加粗优化 */ @apply px-5 py-4 text-left text-sm font-medium text-gray-600 dark:text-dark-300 border-b border-gray-200 dark:border-dark-700;
@apply px-5 py-4 text-left text-sm font-bold text-gray-900 dark:text-white border-b border-gray-200 dark:border-dark-700;
@apply uppercase tracking-wider; /* 让表头更有设计感 */
} }
.table-scroll-container :deep(td) { .table-scroll-container :deep(td) {
......
...@@ -412,7 +412,7 @@ export function getPresetMappingsByPlatform(platform: string) { ...@@ -412,7 +412,7 @@ export function getPresetMappingsByPlatform(platform: string) {
if (platform === 'gemini') return geminiPresetMappings if (platform === 'gemini') return geminiPresetMappings
if (platform === 'sora') return soraPresetMappings if (platform === 'sora') return soraPresetMappings
if (platform === 'antigravity') return antigravityPresetMappings if (platform === 'antigravity') return antigravityPresetMappings
if (platform === 'bedrock' || platform === 'bedrock-apikey') return bedrockPresetMappings if (platform === 'bedrock') return bedrockPresetMappings
return anthropicPresetMappings return anthropicPresetMappings
} }
......
...@@ -1866,6 +1866,23 @@ export default { ...@@ -1866,6 +1866,23 @@ export default {
quotaWeeklyLimitHint: 'Automatically resets every 7 days from first usage.', quotaWeeklyLimitHint: 'Automatically resets every 7 days from first usage.',
quotaTotalLimit: 'Total Limit', quotaTotalLimit: 'Total Limit',
quotaTotalLimitHint: 'Cumulative spending limit. Does not auto-reset — use "Reset Quota" to clear.', quotaTotalLimitHint: 'Cumulative spending limit. Does not auto-reset — use "Reset Quota" to clear.',
quotaResetMode: 'Reset Mode',
quotaResetModeRolling: 'Rolling Window',
quotaResetModeFixed: 'Fixed Time',
quotaResetHour: 'Reset Hour',
quotaWeeklyResetDay: 'Reset Day',
quotaResetTimezone: 'Reset Timezone',
quotaDailyLimitHintFixed: 'Resets daily at {hour}:00 ({timezone}).',
quotaWeeklyLimitHintFixed: 'Resets every {day} at {hour}:00 ({timezone}).',
dayOfWeek: {
monday: 'Monday',
tuesday: 'Tuesday',
wednesday: 'Wednesday',
thursday: 'Thursday',
friday: 'Friday',
saturday: 'Saturday',
sunday: 'Sunday',
},
quotaLimitAmount: 'Total Limit', quotaLimitAmount: 'Total Limit',
quotaLimitAmountHint: 'Cumulative spending limit. Does not auto-reset.', quotaLimitAmountHint: 'Cumulative spending limit. Does not auto-reset.',
testConnection: 'Test Connection', testConnection: 'Test Connection',
...@@ -1934,7 +1951,7 @@ export default { ...@@ -1934,7 +1951,7 @@ export default {
claudeCode: 'Claude Code', claudeCode: 'Claude Code',
claudeConsole: 'Claude Console', claudeConsole: 'Claude Console',
bedrockLabel: 'AWS Bedrock', bedrockLabel: 'AWS Bedrock',
bedrockDesc: 'SigV4 Signing', bedrockDesc: 'SigV4 / API Key',
oauthSetupToken: 'OAuth / Setup Token', oauthSetupToken: 'OAuth / Setup Token',
addMethod: 'Add Method', addMethod: 'Add Method',
setupTokenLongLived: 'Setup Token (Long-lived)', setupTokenLongLived: 'Setup Token (Long-lived)',
...@@ -2136,6 +2153,9 @@ export default { ...@@ -2136,6 +2153,9 @@ export default {
bedrockRegionRequired: 'Please select AWS Region', bedrockRegionRequired: 'Please select AWS Region',
bedrockSessionTokenHint: 'Optional, for temporary credentials', bedrockSessionTokenHint: 'Optional, for temporary credentials',
bedrockSecretKeyLeaveEmpty: 'Leave empty to keep current key', bedrockSecretKeyLeaveEmpty: 'Leave empty to keep current key',
bedrockAuthMode: 'Authentication Mode',
bedrockAuthModeSigv4: 'SigV4 Signing',
bedrockAuthModeApikey: 'Bedrock API Key',
bedrockApiKeyLabel: 'Bedrock API Key', bedrockApiKeyLabel: 'Bedrock API Key',
bedrockApiKeyDesc: 'Bearer Token', bedrockApiKeyDesc: 'Bearer Token',
bedrockApiKeyInput: 'API Key', bedrockApiKeyInput: 'API Key',
...@@ -2555,7 +2575,16 @@ export default { ...@@ -2555,7 +2575,16 @@ export default {
unlimited: 'Unlimited' unlimited: 'Unlimited'
}, },
ineligibleWarning: ineligibleWarning:
'This account is not eligible for Antigravity, but API forwarding still works. Use at your own risk.' 'This account is not eligible for Antigravity, but API forwarding still works. Use at your own risk.',
forbidden: 'Forbidden',
forbiddenValidation: 'Verification Required',
forbiddenViolation: 'Violation Ban',
openVerification: 'Open Verification Link',
copyLink: 'Copy Link',
linkCopied: 'Link Copied',
needsReauth: 'Re-auth Required',
rateLimited: 'Rate Limited',
usageError: 'Fetch Error'
}, },
// Scheduled Tests // Scheduled Tests
...@@ -3709,6 +3738,11 @@ export default { ...@@ -3709,6 +3738,11 @@ export default {
refreshInterval15s: '15 seconds', refreshInterval15s: '15 seconds',
refreshInterval30s: '30 seconds', refreshInterval30s: '30 seconds',
refreshInterval60s: '60 seconds', refreshInterval60s: '60 seconds',
dashboardCards: 'Dashboard Cards',
displayAlertEvents: 'Display alert events',
displayAlertEventsHint: 'Show or hide the recent alert events card on the ops dashboard. Enabled by default.',
displayOpenAITokenStats: 'Display OpenAI token request stats',
displayOpenAITokenStatsHint: 'Show or hide the OpenAI token request stats card on the ops dashboard. Hidden by default.',
autoRefreshCountdown: 'Auto refresh: {seconds}s', autoRefreshCountdown: 'Auto refresh: {seconds}s',
validation: { validation: {
title: 'Please fix the following issues', title: 'Please fix the following issues',
...@@ -3888,6 +3922,9 @@ export default { ...@@ -3888,6 +3922,9 @@ export default {
site: { site: {
title: 'Site Settings', title: 'Site Settings',
description: 'Customize site branding', description: 'Customize site branding',
backendMode: 'Backend Mode',
backendModeDescription:
'Disables user registration, public site, and self-service features. Only admin can log in and manage the platform.',
siteName: 'Site Name', siteName: 'Site Name',
siteNamePlaceholder: 'Sub2API', siteNamePlaceholder: 'Sub2API',
siteNameHint: 'Displayed in emails and page titles', siteNameHint: 'Displayed in emails and page titles',
...@@ -4127,6 +4164,7 @@ export default { ...@@ -4127,6 +4164,7 @@ export default {
scopeAll: 'All accounts', scopeAll: 'All accounts',
scopeOAuth: 'OAuth only', scopeOAuth: 'OAuth only',
scopeAPIKey: 'API Key only', scopeAPIKey: 'API Key only',
scopeBedrock: 'Bedrock only',
errorMessage: 'Error message', errorMessage: 'Error message',
errorMessagePlaceholder: 'Custom error message when blocked', errorMessagePlaceholder: 'Custom error message when blocked',
errorMessageHint: 'Leave empty for default message', errorMessageHint: 'Leave empty for default message',
......
...@@ -1872,6 +1872,23 @@ export default { ...@@ -1872,6 +1872,23 @@ export default {
quotaWeeklyLimitHint: '从首次使用起每 7 天自动重置。', quotaWeeklyLimitHint: '从首次使用起每 7 天自动重置。',
quotaTotalLimit: '总限额', quotaTotalLimit: '总限额',
quotaTotalLimitHint: '累计消费上限,不会自动重置 — 使用「重置配额」手动清零。', quotaTotalLimitHint: '累计消费上限,不会自动重置 — 使用「重置配额」手动清零。',
quotaResetMode: '重置方式',
quotaResetModeRolling: '滚动窗口',
quotaResetModeFixed: '固定时间',
quotaResetHour: '重置时间',
quotaWeeklyResetDay: '重置日',
quotaResetTimezone: '重置时区',
quotaDailyLimitHintFixed: '每天 {hour}:00({timezone})重置。',
quotaWeeklyLimitHintFixed: '每{day} {hour}:00({timezone})重置。',
dayOfWeek: {
monday: '周一',
tuesday: '周二',
wednesday: '周三',
thursday: '周四',
friday: '周五',
saturday: '周六',
sunday: '周日',
},
quotaLimitAmount: '总限额', quotaLimitAmount: '总限额',
quotaLimitAmountHint: '累计消费上限,不会自动重置。', quotaLimitAmountHint: '累计消费上限,不会自动重置。',
testConnection: '测试连接', testConnection: '测试连接',
...@@ -1992,6 +2009,15 @@ export default { ...@@ -1992,6 +2009,15 @@ export default {
}, },
ineligibleWarning: ineligibleWarning:
'该账号无 Antigravity 使用权限,但仍能进行 API 转发。继续使用请自行承担风险。', '该账号无 Antigravity 使用权限,但仍能进行 API 转发。继续使用请自行承担风险。',
forbidden: '已封禁',
forbiddenValidation: '需要验证',
forbiddenViolation: '违规封禁',
openVerification: '打开验证链接',
copyLink: '复制链接',
linkCopied: '链接已复制',
needsReauth: '需要重新授权',
rateLimited: '限流中',
usageError: '获取失败',
form: { form: {
nameLabel: '账号名称', nameLabel: '账号名称',
namePlaceholder: '请输入账号名称', namePlaceholder: '请输入账号名称',
...@@ -2082,7 +2108,7 @@ export default { ...@@ -2082,7 +2108,7 @@ export default {
claudeCode: 'Claude Code', claudeCode: 'Claude Code',
claudeConsole: 'Claude Console', claudeConsole: 'Claude Console',
bedrockLabel: 'AWS Bedrock', bedrockLabel: 'AWS Bedrock',
bedrockDesc: 'SigV4 签名', bedrockDesc: 'SigV4 / API Key',
oauthSetupToken: 'OAuth / Setup Token', oauthSetupToken: 'OAuth / Setup Token',
addMethod: '添加方式', addMethod: '添加方式',
setupTokenLongLived: 'Setup Token(长期有效)', setupTokenLongLived: 'Setup Token(长期有效)',
...@@ -2277,6 +2303,9 @@ export default { ...@@ -2277,6 +2303,9 @@ export default {
bedrockRegionRequired: '请选择 AWS Region', bedrockRegionRequired: '请选择 AWS Region',
bedrockSessionTokenHint: '可选,用于临时凭证', bedrockSessionTokenHint: '可选,用于临时凭证',
bedrockSecretKeyLeaveEmpty: '留空以保持当前密钥', bedrockSecretKeyLeaveEmpty: '留空以保持当前密钥',
bedrockAuthMode: '认证方式',
bedrockAuthModeSigv4: 'SigV4 签名',
bedrockAuthModeApikey: 'Bedrock API Key',
bedrockApiKeyLabel: 'Bedrock API Key', bedrockApiKeyLabel: 'Bedrock API Key',
bedrockApiKeyDesc: 'Bearer Token 认证', bedrockApiKeyDesc: 'Bearer Token 认证',
bedrockApiKeyInput: 'API Key', bedrockApiKeyInput: 'API Key',
...@@ -3883,6 +3912,11 @@ export default { ...@@ -3883,6 +3912,11 @@ export default {
refreshInterval15s: '15 秒', refreshInterval15s: '15 秒',
refreshInterval30s: '30 秒', refreshInterval30s: '30 秒',
refreshInterval60s: '60 秒', refreshInterval60s: '60 秒',
dashboardCards: '仪表盘卡片',
displayAlertEvents: '展示告警事件',
displayAlertEventsHint: '控制运维监控仪表盘中告警事件卡片是否显示,默认开启。',
displayOpenAITokenStats: '展示 OpenAI Token 请求统计',
displayOpenAITokenStatsHint: '控制运维监控仪表盘中 OpenAI Token 请求统计卡片是否显示,默认关闭。',
autoRefreshCountdown: '自动刷新:{seconds}s', autoRefreshCountdown: '自动刷新:{seconds}s',
validation: { validation: {
title: '请先修正以下问题', title: '请先修正以下问题',
...@@ -4060,6 +4094,9 @@ export default { ...@@ -4060,6 +4094,9 @@ export default {
site: { site: {
title: '站点设置', title: '站点设置',
description: '自定义站点品牌', description: '自定义站点品牌',
backendMode: 'Backend 模式',
backendModeDescription:
'禁用用户注册、公开页面和自助服务功能。仅管理员可以登录和管理平台。',
siteName: '站点名称', siteName: '站点名称',
siteNameHint: '显示在邮件和页面标题中', siteNameHint: '显示在邮件和页面标题中',
siteNamePlaceholder: 'Sub2API', siteNamePlaceholder: 'Sub2API',
...@@ -4300,6 +4337,7 @@ export default { ...@@ -4300,6 +4337,7 @@ export default {
scopeAll: '全部账号', scopeAll: '全部账号',
scopeOAuth: '仅 OAuth 账号', scopeOAuth: '仅 OAuth 账号',
scopeAPIKey: '仅 API Key 账号', scopeAPIKey: '仅 API Key 账号',
scopeBedrock: '仅 Bedrock 账号',
errorMessage: '错误消息', errorMessage: '错误消息',
errorMessagePlaceholder: '拦截时返回的自定义错误消息', errorMessagePlaceholder: '拦截时返回的自定义错误消息',
errorMessageHint: '留空则使用默认错误消息', errorMessageHint: '留空则使用默认错误消息',
......
...@@ -51,6 +51,7 @@ interface MockAuthState { ...@@ -51,6 +51,7 @@ interface MockAuthState {
isAuthenticated: boolean isAuthenticated: boolean
isAdmin: boolean isAdmin: boolean
isSimpleMode: boolean isSimpleMode: boolean
backendModeEnabled: boolean
} }
/** /**
...@@ -70,8 +71,17 @@ function simulateGuard( ...@@ -70,8 +71,17 @@ function simulateGuard(
authState.isAuthenticated && authState.isAuthenticated &&
(toPath === '/login' || toPath === '/register') (toPath === '/login' || toPath === '/register')
) { ) {
if (authState.backendModeEnabled && !authState.isAdmin) {
return null
}
return authState.isAdmin ? '/admin/dashboard' : '/dashboard' return authState.isAdmin ? '/admin/dashboard' : '/dashboard'
} }
if (authState.backendModeEnabled && !authState.isAuthenticated) {
const allowed = ['/login', '/key-usage', '/setup']
if (!allowed.some((path) => toPath === path || toPath.startsWith(path))) {
return '/login'
}
}
return null // 允许通过 return null // 允许通过
} }
...@@ -99,6 +109,17 @@ function simulateGuard( ...@@ -99,6 +109,17 @@ function simulateGuard(
} }
} }
// Backend mode: admin gets full access, non-admin blocked
if (authState.backendModeEnabled) {
if (authState.isAuthenticated && authState.isAdmin) {
return null
}
const allowed = ['/login', '/key-usage', '/setup']
if (!allowed.some((path) => toPath === path || toPath.startsWith(path))) {
return '/login'
}
}
return null // 允许通过 return null // 允许通过
} }
...@@ -114,6 +135,7 @@ describe('路由守卫逻辑', () => { ...@@ -114,6 +135,7 @@ describe('路由守卫逻辑', () => {
isAuthenticated: false, isAuthenticated: false,
isAdmin: false, isAdmin: false,
isSimpleMode: false, isSimpleMode: false,
backendModeEnabled: false,
} }
it('访问需要认证的页面重定向到 /login', () => { it('访问需要认证的页面重定向到 /login', () => {
...@@ -144,6 +166,7 @@ describe('路由守卫逻辑', () => { ...@@ -144,6 +166,7 @@ describe('路由守卫逻辑', () => {
isAuthenticated: true, isAuthenticated: true,
isAdmin: false, isAdmin: false,
isSimpleMode: false, isSimpleMode: false,
backendModeEnabled: false,
} }
it('访问 /login 重定向到 /dashboard', () => { it('访问 /login 重定向到 /dashboard', () => {
...@@ -179,6 +202,7 @@ describe('路由守卫逻辑', () => { ...@@ -179,6 +202,7 @@ describe('路由守卫逻辑', () => {
isAuthenticated: true, isAuthenticated: true,
isAdmin: true, isAdmin: true,
isSimpleMode: false, isSimpleMode: false,
backendModeEnabled: false,
} }
it('访问 /login 重定向到 /admin/dashboard', () => { it('访问 /login 重定向到 /admin/dashboard', () => {
...@@ -205,6 +229,7 @@ describe('路由守卫逻辑', () => { ...@@ -205,6 +229,7 @@ describe('路由守卫逻辑', () => {
isAuthenticated: true, isAuthenticated: true,
isAdmin: false, isAdmin: false,
isSimpleMode: true, isSimpleMode: true,
backendModeEnabled: false,
} }
const redirect = simulateGuard('/subscriptions', {}, authState) const redirect = simulateGuard('/subscriptions', {}, authState)
expect(redirect).toBe('/dashboard') expect(redirect).toBe('/dashboard')
...@@ -215,6 +240,7 @@ describe('路由守卫逻辑', () => { ...@@ -215,6 +240,7 @@ describe('路由守卫逻辑', () => {
isAuthenticated: true, isAuthenticated: true,
isAdmin: false, isAdmin: false,
isSimpleMode: true, isSimpleMode: true,
backendModeEnabled: false,
} }
const redirect = simulateGuard('/redeem', {}, authState) const redirect = simulateGuard('/redeem', {}, authState)
expect(redirect).toBe('/dashboard') expect(redirect).toBe('/dashboard')
...@@ -225,6 +251,7 @@ describe('路由守卫逻辑', () => { ...@@ -225,6 +251,7 @@ describe('路由守卫逻辑', () => {
isAuthenticated: true, isAuthenticated: true,
isAdmin: true, isAdmin: true,
isSimpleMode: true, isSimpleMode: true,
backendModeEnabled: false,
} }
const redirect = simulateGuard('/admin/groups', { requiresAdmin: true }, authState) const redirect = simulateGuard('/admin/groups', { requiresAdmin: true }, authState)
expect(redirect).toBe('/admin/dashboard') expect(redirect).toBe('/admin/dashboard')
...@@ -235,6 +262,7 @@ describe('路由守卫逻辑', () => { ...@@ -235,6 +262,7 @@ describe('路由守卫逻辑', () => {
isAuthenticated: true, isAuthenticated: true,
isAdmin: true, isAdmin: true,
isSimpleMode: true, isSimpleMode: true,
backendModeEnabled: false,
} }
const redirect = simulateGuard( const redirect = simulateGuard(
'/admin/subscriptions', '/admin/subscriptions',
...@@ -249,6 +277,7 @@ describe('路由守卫逻辑', () => { ...@@ -249,6 +277,7 @@ describe('路由守卫逻辑', () => {
isAuthenticated: true, isAuthenticated: true,
isAdmin: false, isAdmin: false,
isSimpleMode: true, isSimpleMode: true,
backendModeEnabled: false,
} }
const redirect = simulateGuard('/dashboard', {}, authState) const redirect = simulateGuard('/dashboard', {}, authState)
expect(redirect).toBeNull() expect(redirect).toBeNull()
...@@ -259,9 +288,111 @@ describe('路由守卫逻辑', () => { ...@@ -259,9 +288,111 @@ describe('路由守卫逻辑', () => {
isAuthenticated: true, isAuthenticated: true,
isAdmin: false, isAdmin: false,
isSimpleMode: true, isSimpleMode: true,
backendModeEnabled: false,
} }
const redirect = simulateGuard('/keys', {}, authState) const redirect = simulateGuard('/keys', {}, authState)
expect(redirect).toBeNull() expect(redirect).toBeNull()
}) })
}) })
describe('Backend Mode', () => {
it('unauthenticated: /home redirects to /login', () => {
const authState: MockAuthState = {
isAuthenticated: false,
isAdmin: false,
isSimpleMode: false,
backendModeEnabled: true,
}
const redirect = simulateGuard('/home', { requiresAuth: false }, authState)
expect(redirect).toBe('/login')
})
it('unauthenticated: /login is allowed', () => {
const authState: MockAuthState = {
isAuthenticated: false,
isAdmin: false,
isSimpleMode: false,
backendModeEnabled: true,
}
const redirect = simulateGuard('/login', { requiresAuth: false }, authState)
expect(redirect).toBeNull()
})
it('unauthenticated: /key-usage is allowed', () => {
const authState: MockAuthState = {
isAuthenticated: false,
isAdmin: false,
isSimpleMode: false,
backendModeEnabled: true,
}
const redirect = simulateGuard('/key-usage', { requiresAuth: false }, authState)
expect(redirect).toBeNull()
})
it('unauthenticated: /setup is allowed', () => {
const authState: MockAuthState = {
isAuthenticated: false,
isAdmin: false,
isSimpleMode: false,
backendModeEnabled: true,
}
const redirect = simulateGuard('/setup', { requiresAuth: false }, authState)
expect(redirect).toBeNull()
})
it('admin: /admin/dashboard is allowed', () => {
const authState: MockAuthState = {
isAuthenticated: true,
isAdmin: true,
isSimpleMode: false,
backendModeEnabled: true,
}
const redirect = simulateGuard('/admin/dashboard', { requiresAdmin: true }, authState)
expect(redirect).toBeNull()
})
it('admin: /login redirects to /admin/dashboard', () => {
const authState: MockAuthState = {
isAuthenticated: true,
isAdmin: true,
isSimpleMode: false,
backendModeEnabled: true,
}
const redirect = simulateGuard('/login', { requiresAuth: false }, authState)
expect(redirect).toBe('/admin/dashboard')
})
it('non-admin authenticated: /dashboard redirects to /login', () => {
const authState: MockAuthState = {
isAuthenticated: true,
isAdmin: false,
isSimpleMode: false,
backendModeEnabled: true,
}
const redirect = simulateGuard('/dashboard', {}, authState)
expect(redirect).toBe('/login')
})
it('non-admin authenticated: /login is allowed (no redirect loop)', () => {
const authState: MockAuthState = {
isAuthenticated: true,
isAdmin: false,
isSimpleMode: false,
backendModeEnabled: true,
}
const redirect = simulateGuard('/login', { requiresAuth: false }, authState)
expect(redirect).toBeNull()
})
it('non-admin authenticated: /key-usage is allowed', () => {
const authState: MockAuthState = {
isAuthenticated: true,
isAdmin: false,
isSimpleMode: false,
backendModeEnabled: true,
}
const redirect = simulateGuard('/key-usage', { requiresAuth: false }, authState)
expect(redirect).toBeNull()
})
})
}) })
...@@ -423,6 +423,7 @@ let authInitialized = false ...@@ -423,6 +423,7 @@ let authInitialized = false
const navigationLoading = useNavigationLoadingState() const navigationLoading = useNavigationLoadingState()
// 延迟初始化预加载,传入 router 实例 // 延迟初始化预加载,传入 router 实例
let routePrefetch: ReturnType<typeof useRoutePrefetch> | null = null let routePrefetch: ReturnType<typeof useRoutePrefetch> | null = null
const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup']
router.beforeEach((to, _from, next) => { router.beforeEach((to, _from, next) => {
// 开始导航加载状态 // 开始导航加载状态
...@@ -463,10 +464,24 @@ router.beforeEach((to, _from, next) => { ...@@ -463,10 +464,24 @@ router.beforeEach((to, _from, next) => {
if (!requiresAuth) { if (!requiresAuth) {
// If already authenticated and trying to access login/register, redirect to appropriate dashboard // If already authenticated and trying to access login/register, redirect to appropriate dashboard
if (authStore.isAuthenticated && (to.path === '/login' || to.path === '/register')) { if (authStore.isAuthenticated && (to.path === '/login' || to.path === '/register')) {
// In backend mode, non-admin users should NOT be redirected away from login
// (they are blocked from all protected routes, so redirecting would cause a loop)
if (appStore.backendModeEnabled && !authStore.isAdmin) {
next()
return
}
// Admin users go to admin dashboard, regular users go to user dashboard // Admin users go to admin dashboard, regular users go to user dashboard
next(authStore.isAdmin ? '/admin/dashboard' : '/dashboard') next(authStore.isAdmin ? '/admin/dashboard' : '/dashboard')
return return
} }
// Backend mode: block public pages for unauthenticated users (except login, key-usage, setup)
if (appStore.backendModeEnabled && !authStore.isAuthenticated) {
const isAllowed = BACKEND_MODE_ALLOWED_PATHS.some((p) => to.path === p || to.path.startsWith(p))
if (!isAllowed) {
next('/login')
return
}
}
next() next()
return return
} }
...@@ -505,6 +520,19 @@ router.beforeEach((to, _from, next) => { ...@@ -505,6 +520,19 @@ router.beforeEach((to, _from, next) => {
} }
} }
// Backend mode: admin gets full access, non-admin blocked
if (appStore.backendModeEnabled) {
if (authStore.isAuthenticated && authStore.isAdmin) {
next()
return
}
const isAllowed = BACKEND_MODE_ALLOWED_PATHS.some((p) => to.path === p || to.path.startsWith(p))
if (!isAllowed) {
next('/login')
return
}
}
// All checks passed, allow navigation // All checks passed, allow navigation
next() next()
}) })
......
...@@ -47,6 +47,7 @@ export const useAppStore = defineStore('app', () => { ...@@ -47,6 +47,7 @@ export const useAppStore = defineStore('app', () => {
// ==================== Computed ==================== // ==================== Computed ====================
const hasActiveToasts = computed(() => toasts.value.length > 0) const hasActiveToasts = computed(() => toasts.value.length > 0)
const backendModeEnabled = computed(() => cachedPublicSettings.value?.backend_mode_enabled ?? false)
const loadingCount = ref<number>(0) const loadingCount = ref<number>(0)
...@@ -331,6 +332,7 @@ export const useAppStore = defineStore('app', () => { ...@@ -331,6 +332,7 @@ export const useAppStore = defineStore('app', () => {
custom_menu_items: [], custom_menu_items: [],
linuxdo_oauth_enabled: false, linuxdo_oauth_enabled: false,
sora_client_enabled: false, sora_client_enabled: false,
backend_mode_enabled: false,
version: siteVersion.value version: siteVersion.value
} }
} }
...@@ -404,6 +406,7 @@ export const useAppStore = defineStore('app', () => { ...@@ -404,6 +406,7 @@ export const useAppStore = defineStore('app', () => {
// Computed // Computed
hasActiveToasts, hasActiveToasts,
backendModeEnabled,
// Actions // Actions
toggleSidebar, toggleSidebar,
......
...@@ -106,6 +106,7 @@ export interface PublicSettings { ...@@ -106,6 +106,7 @@ export interface PublicSettings {
custom_menu_items: CustomMenuItem[] custom_menu_items: CustomMenuItem[]
linuxdo_oauth_enabled: boolean linuxdo_oauth_enabled: boolean
sora_client_enabled: boolean sora_client_enabled: boolean
backend_mode_enabled: boolean
version: string version: string
} }
...@@ -531,7 +532,7 @@ export interface UpdateGroupRequest { ...@@ -531,7 +532,7 @@ export interface UpdateGroupRequest {
// ==================== Account & Proxy Types ==================== // ==================== Account & Proxy Types ====================
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'sora' export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'sora'
export type AccountType = 'oauth' | 'setup-token' | 'apikey' | 'upstream' | 'bedrock' | 'bedrock-apikey' export type AccountType = 'oauth' | 'setup-token' | 'apikey' | 'upstream' | 'bedrock'
export type OAuthAddMethod = 'oauth' | 'setup-token' export type OAuthAddMethod = 'oauth' | 'setup-token'
export type ProxyProtocol = 'http' | 'https' | 'socks5' | 'socks5h' export type ProxyProtocol = 'http' | 'https' | 'socks5' | 'socks5h'
...@@ -727,6 +728,16 @@ export interface Account { ...@@ -727,6 +728,16 @@ export interface Account {
quota_weekly_limit?: number | null quota_weekly_limit?: number | null
quota_weekly_used?: number | null quota_weekly_used?: number | null
// 配额固定时间重置配置
quota_daily_reset_mode?: 'rolling' | 'fixed' | null
quota_daily_reset_hour?: number | null
quota_weekly_reset_mode?: 'rolling' | 'fixed' | null
quota_weekly_reset_day?: number | null
quota_weekly_reset_hour?: number | null
quota_reset_timezone?: string | null
quota_daily_reset_at?: string | null
quota_weekly_reset_at?: string | null
// 运行时状态(仅当启用对应限制时返回) // 运行时状态(仅当启用对应限制时返回)
current_window_cost?: number | null // 当前窗口费用 current_window_cost?: number | null // 当前窗口费用
active_sessions?: number | null // 当前活跃会话数 active_sessions?: number | null // 当前活跃会话数
...@@ -769,6 +780,21 @@ export interface AccountUsageInfo { ...@@ -769,6 +780,21 @@ export interface AccountUsageInfo {
gemini_pro_minute?: UsageProgress | null gemini_pro_minute?: UsageProgress | null
gemini_flash_minute?: UsageProgress | null gemini_flash_minute?: UsageProgress | null
antigravity_quota?: Record<string, AntigravityModelQuota> | null antigravity_quota?: Record<string, AntigravityModelQuota> | null
// Antigravity 403 forbidden 状态
is_forbidden?: boolean
forbidden_reason?: string
forbidden_type?: string // "validation" | "violation" | "forbidden"
validation_url?: string // 验证/申诉链接
// 状态标记(后端自动推导)
needs_verify?: boolean // 需要人工验证(forbidden_type=validation)
is_banned?: boolean // 账号被封(forbidden_type=violation)
needs_reauth?: boolean // token 失效需重新授权(401)
// 机器可读错误码:forbidden / unauthenticated / rate_limited / network_error
error_code?: string
error?: string // usage 获取失败时的错误信息
} }
// OpenAI Codex usage snapshot (from response headers) // OpenAI Codex usage snapshot (from response headers)
......
...@@ -171,7 +171,15 @@ ...@@ -171,7 +171,15 @@
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span> <span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
</template> </template>
<template #cell-platform_type="{ row }"> <template #cell-platform_type="{ row }">
<PlatformTypeBadge :platform="row.platform" :type="row.type" :plan-type="row.credentials?.plan_type" :privacy-mode="row.extra?.privacy_mode" /> <div class="flex flex-wrap items-center gap-1">
<PlatformTypeBadge :platform="row.platform" :type="row.type" :plan-type="row.credentials?.plan_type" :privacy-mode="row.extra?.privacy_mode" />
<span
v-if="getAntigravityTierLabel(row)"
:class="['inline-block rounded px-1.5 py-0.5 text-[10px] font-medium', getAntigravityTierClass(row)]"
>
{{ getAntigravityTierLabel(row) }}
</span>
</div>
</template> </template>
<template #cell-capacity="{ row }"> <template #cell-capacity="{ row }">
<AccountCapacityCell :account="row" /> <AccountCapacityCell :account="row" />
...@@ -794,6 +802,40 @@ const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn( ...@@ -794,6 +802,40 @@ const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn(
{ immediate: false } { immediate: false }
) )
// Antigravity 订阅等级辅助函数
function getAntigravityTierFromRow(row: any): string | null {
if (row.platform !== 'antigravity') return null
const extra = row.extra as Record<string, unknown> | undefined
if (!extra) return null
const lca = extra.load_code_assist as Record<string, unknown> | undefined
if (!lca) return null
const paid = lca.paidTier as Record<string, unknown> | undefined
if (paid && typeof paid.id === 'string') return paid.id
const current = lca.currentTier as Record<string, unknown> | undefined
if (current && typeof current.id === 'string') return current.id
return null
}
function getAntigravityTierLabel(row: any): string | null {
const tier = getAntigravityTierFromRow(row)
switch (tier) {
case 'free-tier': return t('admin.accounts.tier.free')
case 'g1-pro-tier': return t('admin.accounts.tier.pro')
case 'g1-ultra-tier': return t('admin.accounts.tier.ultra')
default: return null
}
}
function getAntigravityTierClass(row: any): string {
const tier = getAntigravityTierFromRow(row)
switch (tier) {
case 'free-tier': return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
case 'g1-pro-tier': return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
case 'g1-ultra-tier': return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
default: return ''
}
}
// All available columns // All available columns
const allColumns = computed(() => { const allColumns = computed(() => {
const c = [ const c = [
......
...@@ -1070,6 +1070,21 @@ ...@@ -1070,6 +1070,21 @@
</p> </p>
</div> </div>
<div class="space-y-6 p-6"> <div class="space-y-6 p-6">
<!-- Backend Mode -->
<div
class="flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20"
>
<div>
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.settings.site.backendMode') }}
</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.site.backendModeDescription') }}
</p>
</div>
<Toggle v-model="form.backend_mode_enabled" />
</div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2"> <div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div> <div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
...@@ -1745,7 +1760,7 @@ const betaPolicyForm = reactive({ ...@@ -1745,7 +1760,7 @@ const betaPolicyForm = reactive({
rules: [] as Array<{ rules: [] as Array<{
beta_token: string beta_token: string
action: 'pass' | 'filter' | 'block' action: 'pass' | 'filter' | 'block'
scope: 'all' | 'oauth' | 'apikey' scope: 'all' | 'oauth' | 'apikey' | 'bedrock'
error_message?: string error_message?: string
}> }>
}) })
...@@ -1785,6 +1800,7 @@ const form = reactive<SettingsForm>({ ...@@ -1785,6 +1800,7 @@ const form = reactive<SettingsForm>({
contact_info: '', contact_info: '',
doc_url: '', doc_url: '',
home_content: '', home_content: '',
backend_mode_enabled: false,
hide_ccs_import_button: false, hide_ccs_import_button: false,
purchase_subscription_enabled: false, purchase_subscription_enabled: false,
purchase_subscription_url: '', purchase_subscription_url: '',
...@@ -1962,6 +1978,7 @@ async function loadSettings() { ...@@ -1962,6 +1978,7 @@ async function loadSettings() {
try { try {
const settings = await adminAPI.settings.getSettings() const settings = await adminAPI.settings.getSettings()
Object.assign(form, settings) Object.assign(form, settings)
form.backend_mode_enabled = settings.backend_mode_enabled
form.default_subscriptions = Array.isArray(settings.default_subscriptions) form.default_subscriptions = Array.isArray(settings.default_subscriptions)
? settings.default_subscriptions ? settings.default_subscriptions
.filter((item) => item.group_id > 0 && item.validity_days > 0) .filter((item) => item.group_id > 0 && item.validity_days > 0)
...@@ -2060,6 +2077,7 @@ async function saveSettings() { ...@@ -2060,6 +2077,7 @@ async function saveSettings() {
contact_info: form.contact_info, contact_info: form.contact_info,
doc_url: form.doc_url, doc_url: form.doc_url,
home_content: form.home_content, home_content: form.home_content,
backend_mode_enabled: form.backend_mode_enabled,
hide_ccs_import_button: form.hide_ccs_import_button, hide_ccs_import_button: form.hide_ccs_import_button,
purchase_subscription_enabled: form.purchase_subscription_enabled, purchase_subscription_enabled: form.purchase_subscription_enabled,
purchase_subscription_url: form.purchase_subscription_url, purchase_subscription_url: form.purchase_subscription_url,
...@@ -2297,7 +2315,8 @@ const betaPolicyActionOptions = computed(() => [ ...@@ -2297,7 +2315,8 @@ const betaPolicyActionOptions = computed(() => [
const betaPolicyScopeOptions = computed(() => [ const betaPolicyScopeOptions = computed(() => [
{ value: 'all', label: t('admin.settings.betaPolicy.scopeAll') }, { value: 'all', label: t('admin.settings.betaPolicy.scopeAll') },
{ value: 'oauth', label: t('admin.settings.betaPolicy.scopeOAuth') }, { value: 'oauth', label: t('admin.settings.betaPolicy.scopeOAuth') },
{ value: 'apikey', label: t('admin.settings.betaPolicy.scopeAPIKey') } { value: 'apikey', label: t('admin.settings.betaPolicy.scopeAPIKey') },
{ value: 'bedrock', label: t('admin.settings.betaPolicy.scopeBedrock') }
]) ])
// Beta Policy 方法 // Beta Policy 方法
......
...@@ -85,7 +85,7 @@ ...@@ -85,7 +85,7 @@
</div> </div>
<!-- Row: OpenAI Token Stats --> <!-- Row: OpenAI Token Stats -->
<div v-if="opsEnabled && !(loading && !hasLoadedOnce)" class="grid grid-cols-1 gap-6"> <div v-if="opsEnabled && showOpenAITokenStats && !(loading && !hasLoadedOnce)" class="grid grid-cols-1 gap-6">
<OpsOpenAITokenStatsCard <OpsOpenAITokenStatsCard
:platform-filter="platform" :platform-filter="platform"
:group-id-filter="groupId" :group-id-filter="groupId"
...@@ -94,7 +94,7 @@ ...@@ -94,7 +94,7 @@
</div> </div>
<!-- Alert Events --> <!-- Alert Events -->
<OpsAlertEventsCard v-if="opsEnabled && !(loading && !hasLoadedOnce)" /> <OpsAlertEventsCard v-if="opsEnabled && showAlertEvents && !(loading && !hasLoadedOnce)" />
<!-- System Logs --> <!-- System Logs -->
<OpsSystemLogTable <OpsSystemLogTable
...@@ -381,6 +381,8 @@ const showSettingsDialog = ref(false) ...@@ -381,6 +381,8 @@ const showSettingsDialog = ref(false)
const showAlertRulesCard = ref(false) const showAlertRulesCard = ref(false)
// Auto refresh settings // Auto refresh settings
const showAlertEvents = ref(true)
const showOpenAITokenStats = ref(false)
const autoRefreshEnabled = ref(false) const autoRefreshEnabled = ref(false)
const autoRefreshIntervalMs = ref(30000) // default 30 seconds const autoRefreshIntervalMs = ref(30000) // default 30 seconds
const autoRefreshCountdown = ref(0) const autoRefreshCountdown = ref(0)
...@@ -408,15 +410,22 @@ const { pause: pauseCountdown, resume: resumeCountdown } = useIntervalFn( ...@@ -408,15 +410,22 @@ const { pause: pauseCountdown, resume: resumeCountdown } = useIntervalFn(
{ immediate: false } { immediate: false }
) )
// Load auto refresh settings from backend // Load ops dashboard presentation settings from backend.
async function loadAutoRefreshSettings() { async function loadDashboardAdvancedSettings() {
try { try {
const settings = await opsAPI.getAdvancedSettings() const settings = await opsAPI.getAdvancedSettings()
showAlertEvents.value = settings.display_alert_events
showOpenAITokenStats.value = settings.display_openai_token_stats
autoRefreshEnabled.value = settings.auto_refresh_enabled autoRefreshEnabled.value = settings.auto_refresh_enabled
autoRefreshIntervalMs.value = settings.auto_refresh_interval_seconds * 1000 autoRefreshIntervalMs.value = settings.auto_refresh_interval_seconds * 1000
autoRefreshCountdown.value = settings.auto_refresh_interval_seconds autoRefreshCountdown.value = settings.auto_refresh_interval_seconds
} catch (err) { } catch (err) {
console.error('[OpsDashboard] Failed to load auto refresh settings', err) console.error('[OpsDashboard] Failed to load dashboard advanced settings', err)
showAlertEvents.value = true
showOpenAITokenStats.value = false
autoRefreshEnabled.value = false
autoRefreshIntervalMs.value = 30000
autoRefreshCountdown.value = 0
} }
} }
...@@ -464,7 +473,8 @@ function onCustomTimeRangeChange(startTime: string, endTime: string) { ...@@ -464,7 +473,8 @@ function onCustomTimeRangeChange(startTime: string, endTime: string) {
customEndTime.value = endTime customEndTime.value = endTime
} }
function onSettingsSaved() { async function onSettingsSaved() {
await loadDashboardAdvancedSettings()
loadThresholds() loadThresholds()
fetchData() fetchData()
} }
...@@ -774,7 +784,7 @@ onMounted(async () => { ...@@ -774,7 +784,7 @@ onMounted(async () => {
loadThresholds() loadThresholds()
// Load auto refresh settings // Load auto refresh settings
await loadAutoRefreshSettings() await loadDashboardAdvancedSettings()
if (opsEnabled.value) { if (opsEnabled.value) {
await fetchData() await fetchData()
...@@ -816,7 +826,7 @@ watch(autoRefreshEnabled, (enabled) => { ...@@ -816,7 +826,7 @@ watch(autoRefreshEnabled, (enabled) => {
// Reload auto refresh settings after settings dialog is closed // Reload auto refresh settings after settings dialog is closed
watch(showSettingsDialog, async (show) => { watch(showSettingsDialog, async (show) => {
if (!show) { if (!show) {
await loadAutoRefreshSettings() await loadDashboardAdvancedSettings()
} }
}) })
</script> </script>
...@@ -208,35 +208,39 @@ function onNextPage() { ...@@ -208,35 +208,39 @@ function onNextPage() {
:description="t('admin.ops.openaiTokenStats.empty')" :description="t('admin.ops.openaiTokenStats.empty')"
/> />
<div v-else class="overflow-x-auto"> <div v-else class="space-y-3">
<table class="min-w-full text-left text-xs md:text-sm"> <div class="overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
<thead> <div class="max-h-[420px] overflow-auto">
<tr class="border-b border-gray-200 text-gray-500 dark:border-dark-700 dark:text-gray-400"> <table class="min-w-full text-left text-xs md:text-sm">
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.model') }}</th> <thead class="sticky top-0 z-10 bg-white dark:bg-dark-800">
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.requestCount') }}</th> <tr class="border-b border-gray-200 text-gray-500 dark:border-dark-700 dark:text-gray-400">
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.avgTokensPerSec') }}</th> <th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.model') }}</th>
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.avgFirstTokenMs') }}</th> <th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.requestCount') }}</th>
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.totalOutputTokens') }}</th> <th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.avgTokensPerSec') }}</th>
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.avgDurationMs') }}</th> <th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.avgFirstTokenMs') }}</th>
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.requestsWithFirstToken') }}</th> <th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.totalOutputTokens') }}</th>
</tr> <th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.avgDurationMs') }}</th>
</thead> <th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.requestsWithFirstToken') }}</th>
<tbody> </tr>
<tr </thead>
v-for="row in items" <tbody>
:key="row.model" <tr
class="border-b border-gray-100 text-gray-700 dark:border-dark-800 dark:text-gray-200" v-for="row in items"
> :key="row.model"
<td class="px-2 py-2 font-medium">{{ row.model }}</td> class="border-b border-gray-100 text-gray-700 last:border-b-0 dark:border-dark-800 dark:text-gray-200"
<td class="px-2 py-2">{{ formatInt(row.request_count) }}</td> >
<td class="px-2 py-2">{{ formatRate(row.avg_tokens_per_sec) }}</td> <td class="px-2 py-2 font-medium">{{ row.model }}</td>
<td class="px-2 py-2">{{ formatRate(row.avg_first_token_ms) }}</td> <td class="px-2 py-2">{{ formatInt(row.request_count) }}</td>
<td class="px-2 py-2">{{ formatInt(row.total_output_tokens) }}</td> <td class="px-2 py-2">{{ formatRate(row.avg_tokens_per_sec) }}</td>
<td class="px-2 py-2">{{ formatInt(row.avg_duration_ms) }}</td> <td class="px-2 py-2">{{ formatRate(row.avg_first_token_ms) }}</td>
<td class="px-2 py-2">{{ formatInt(row.requests_with_first_token) }}</td> <td class="px-2 py-2">{{ formatInt(row.total_output_tokens) }}</td>
</tr> <td class="px-2 py-2">{{ formatInt(row.avg_duration_ms) }}</td>
</tbody> <td class="px-2 py-2">{{ formatInt(row.requests_with_first_token) }}</td>
</table> </tr>
</tbody>
</table>
</div>
</div>
<div v-if="viewMode === 'topn'" class="mt-3 text-xs text-gray-500 dark:text-gray-400"> <div v-if="viewMode === 'topn'" class="mt-3 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.ops.openaiTokenStats.totalModels', { total }) }} {{ t('admin.ops.openaiTokenStats.totalModels', { total }) }}
</div> </div>
......
...@@ -543,6 +543,31 @@ async function saveAllSettings() { ...@@ -543,6 +543,31 @@ async function saveAllSettings() {
/> />
</div> </div>
</div> </div>
<!-- Dashboard Cards -->
<div class="space-y-3">
<h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.dashboardCards') }}</h5>
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.displayAlertEvents') }}</label>
<p class="mt-1 text-xs text-gray-500">
{{ t('admin.ops.settings.displayAlertEventsHint') }}
</p>
</div>
<Toggle v-model="advancedSettings.display_alert_events" />
</div>
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.displayOpenAITokenStats') }}</label>
<p class="mt-1 text-xs text-gray-500">
{{ t('admin.ops.settings.displayOpenAITokenStatsHint') }}
</p>
</div>
<Toggle v-model="advancedSettings.display_openai_token_stats" />
</div>
</div>
</div> </div>
</details> </details>
</div> </div>
......
...@@ -196,6 +196,23 @@ describe('OpsOpenAITokenStatsCard', () => { ...@@ -196,6 +196,23 @@ describe('OpsOpenAITokenStatsCard', () => {
expect(wrapper.find('.empty-state').exists()).toBe(true) expect(wrapper.find('.empty-state').exists()).toBe(true)
}) })
it('数据表使用固定高度滚动容器,避免纵向无限增长', async () => {
mockGetOpenAITokenStats.mockResolvedValue(sampleResponse)
const wrapper = mount(OpsOpenAITokenStatsCard, {
props: { refreshToken: 0 },
global: {
stubs: {
Select: SelectStub,
EmptyState: EmptyStateStub,
},
},
})
await flushPromises()
expect(wrapper.find('.max-h-\\[420px\\]').exists()).toBe(true)
})
it('接口异常时显示错误提示', async () => { it('接口异常时显示错误提示', async () => {
mockGetOpenAITokenStats.mockRejectedValue(new Error('加载失败')) mockGetOpenAITokenStats.mockRejectedValue(new Error('加载失败'))
......
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